I write "this rule catches algorithms: ["HS256", "none"]", I paste a snippet, I paste the finding it produces. You read it and think: fine, but does it fire on my code? And the article cannot answer. It can only ever show you someone else's code, findings I generated on my machine, screenshotted into prose. You have to install the thing to find out whether it was worth installing.
So I stopped writing about the rule and shipped the linter instead. On the JWT article there's now a button that says Try it live. Click it, paste your own jwt.verify call, and the actual published rule runs on it — in your browser, on your keystrokes, with nothing leaving the page.
ESLint ships a browser build. eslint/universal exports the Linter class with no Node dependencies in its public surface — you hand it source text and a flat config, it hands you messages back. No file system, no CLI, no plugin resolution.
| | bytes | |---|---| | raw bundle | 1,764,382 | | gzip | 463,039 | | brotli (what actually ships) | 370,746 |
362 KB over the wire, and production serves brotli — I checked the response headers rather than assuming. That is one mid-sized hero image. It is less than the JavaScript most marketing sites load to render a cookie banner.
That number is the whole argument. At 3 MB you write a blog post about the rule. At 362 KB you ship the rule.
And it is lazy: the bundle sits behind an explicit gate, so an article costs nothing until a reader asks for it. The button says what it will cost before it costs it.
The build step is where the interesting work is. A handful of aliases and a banner:
node-shims.ts is a no-op Proxy — the modules are imported but never exercised by rule logic, so they only need to exist:
The client seam is one lazy worker and an id-matched request map. The part worth copying is the failure path:
Nulling the singleton is the difference between a playground that self-heals and one that is dead until reload. Without it every later lint posts into a corpse, and the UI shows a clean result forever — the worst possible failure, because "no findings" and "the analyzer is dead" look identical to a reader.
That's why the surface renders "unknown, not clean" on failure, never an empty list.
Each of these cost real time, so here they are with the error text you'll actually see. Don't let your framework bundle it. My first attempt asked Next.js to build the worker. That means teaching both webpack (dev) and Turbopack (build) about node: schemes and shims — two fragile configs for one bundle. Worse, this repo lints itself with eslint-plugin-import-next, which pulls oxc-resolver, whose native/wasm bindings then ride into the graph and break any bundler pass. The fix was to stop negotiating: public/lint-worker.js is our own esbuild artifact, gitignored, built by predev/prebuild. A real worker has no process. My spike ran under Node, where one existed, so this only appeared in the browser:
Hence the banner stub. The spike passing is not the same as the thing working. Linting the artifact will OOM your editor. Once the 1.7 MB bundle landed in public/, ESLint tried to lint it:
That looks like a native crash. It's a JS heap exhaustion on a 4 GB default. Add public/ to globalIgnores. Ship the fragment on a redirect, not a path. Unrelated to bundling, but it cost me twice: my link router rebuilds article destinations as origin + pathname, which silently drops #playground. Readers landed at the top of a long article instead of on the thing they clicked for. Fragments survive a 302, so link to the redirect and let the browser carry the hash.
The node-security playground advertised three rules: detect-eval-with-expression, detect-child-process, and no-zip-slip. It shipped, it looked right, and only one of them could ever fire.
detect-child-process is provenance-gated by design — it resolves a command back to an attacker-reachable root like req or event rather than flagging any dynamic string, because that's the difference between a finding and noise. My sample's only input was a bare userInput parameter, which satisfied neither it nor no-zip-slip. Two of the three rules sat there silent, advertised to every reader.
It greps the sample for eval( or exec(. The sample contains both. The test passes just as happily when the rules say nothing — it never ran a linter. A test shaped like a grep can only ever check that text exists.
