Skip to content

🍕 Harden browser-compat stubs: string_decoder, url, node: import-safety#254

Open
jjpaulino wants to merge 2 commits into
masterfrom
jordan/harden-browser-stubs
Open

🍕 Harden browser-compat stubs: string_decoder, url, node: import-safety#254
jjpaulino wants to merge 2 commits into
masterfrom
jordan/harden-browser-stubs

Conversation

@jjpaulino

@jjpaulino jjpaulino commented Jul 2, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Hardens the Vite browser-compat stubs so more Node-builtin usage is import-safe (evaluates without throwing at module-eval time), and fixes a real edit-mode crash in the crypto path. Same failure family as the safe-buffer #253 crash: an incomplete stub shape — or an empty stub where a real one is needed — throws when a library subclasses/instantiates it, taking the kiln-edit bundle down before Kiln boots.

  • string_decoder crash fix (the shippable bug). It was an empty stub, but cipher-base (the base class behind crypto-browserify's create-hash / createHmac) does new StringDecoder(enc) inside .digest(enc). An empty stub throws StringDecoder is not a constructor the instant a hash/HMAC is stringified in the browser — e.g. sites subscription-stripe-plan computing an HMAC in its Kiln save hook. Now a minimal rich stub whose StringDecoder delegates to Buffer.toString(encoding).
  • node:-prefixed rich builtins routed to empty stubs. node:buffer, node:stream, etc. were listed in SIMPLE_STUBS, so resolveId short-circuited them to the empty stub before the rich branch — re-opening the exact subclass-at-eval crash the rich stubs prevent. resolveId already normalizes the node: prefix, so the explicit entries are removed and rich node: forms now route correctly.
  • Prefer real browser globals over shims, at zero bundle cost. Codifies a stub import-safety invariant + a shared preferGlobal() helper ("use globalThis.X, else a minimal function-shaped shim"). buffer/node-fetch already did this; applied it to a new url stub (globalThis.URL / URLSearchParams). No heavyweight polyfills added — bundle stays minimal.

Unit tests in browser-compat.test.js cover all of the above.

No linked issue — continuation of the Vite kiln-edit rollout (same effort as #252/#253).

Why are we doing this? Any context or related work?

#253 fixed the buffer stub after crypto-browserify (added downstream in nymag/sites) pulled safe-buffer into the kiln bundle and crashed it at module-eval. That was one instance of a broader pattern: each edit-mode code path that reaches a different Node builtin exposes the next incomplete stub. This PR hardens the next ones the crypto chain reaches (string_decoder, node: routing) and documents the invariant so new stubs are safe by construction.

Where should a reviewer start?

In review order (core fix first):

Manual testing steps?
  • npx jest lib/cmd/vite/plugins/browser-compat.test.js → green (buffer/url/string_decoder/node: routing).
  • Full suite: npm test → lint clean, jest green.
  • Repro of the crash it fixes: in a kiln bundle reaching crypto-browserify (create-hash/createHmac), calling .digest('hex') in the browser threw StringDecoder is not a constructor before this change.
Screenshots

N/A.

Additional Context

This PR does not by itself make browser crypto (createHmac) functional. create-hash/createHmac .digest() also needs a byte-capable Buffer (writeUInt32BE, toString(enc)); with no global Buffer on the page it throws writeUInt32BE is not a function. The minimal buffer stub can only defer to a real globalThis.Buffer (the invariant's rule 2), and neither claycli nor sites currently provides one. So any browser feature doing real crypto must either expose a real Buffer global (which preferGlobal then picks up automatically) or use Web Crypto (crypto.subtle). Tracked separately.

An earlier revision of this PR added a claycli-side bundle "boot-smoke" test. It was removed: it only guards claycli's own stubs against a fixed library set and cannot catch a sites change pulling in a new dependency — that guard belongs against a consuming site's real kiln bundle (sites CI), not here.

Prevent the class of edit-mode crash where a Node-builtin browser stub is
import-unsafe (throws at module evaluation) — the family the safe-buffer #253
crash belongs to — and add a real-bundle smoke test so these regress in CI
instead of in an editor's console.

#1 CI boot-smoke test (browser-compat.bundle.test.js)
- Runs the real kiln-edit plugin pipeline (browser-compat + node-resolve +
  commonjs) over the libraries that have broken before (safe-buffer,
  create-hash) and evaluates the emitted bundle in a Buffer-less VM. A
  stub-shape regression now fails CI.

#2 Prefer real browser globals over bespoke shims, at zero bundle cost
- Codify the stub import-safety invariant and add a shared preferGlobal()
  helper (single source of truth for "use globalThis.X, else minimal shim").
- buffer/node-fetch already did this; apply it to url (globalThis.URL /
  URLSearchParams). No heavyweight polyfills added.

Fixes surfaced by the smoke test
- string_decoder was an empty stub, but cipher-base (crypto-browserify ->
  create-hash/createHmac) does `new StringDecoder(enc)` inside .digest(enc):
  an empty stub throws "StringDecoder is not a constructor" the instant a
  hash/HMAC is stringified in the browser. Promoted to a minimal rich stub
  whose StringDecoder delegates to Buffer.toString(encoding).
- node:-prefixed rich builtins (node:buffer, node:stream, ...) were listed in
  SIMPLE_STUBS, forcing them to empty stubs and re-opening the subclass-at-eval
  crash. resolveId already normalizes the node: prefix, so the explicit entries
  are removed; rich node: forms now route to the rich stub.

Adds safe-buffer + create-hash as devDependencies (smoke-test fixtures only).

Co-authored-by: Cursor <cursoragent@cursor.com>
@coveralls

coveralls commented Jul 2, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 87.078% (-0.006%) from 87.084% — jordan/harden-browser-stubs into master

The bundle smoke test guards claycli's own stubs against a fixed set of known
libraries — it does not (and cannot) catch a sites change that pulls a new
Node-builtin-dependent dependency into the kiln bundle, which is how edit mode
actually broke. That guard belongs against a consuming site's real bundle
(sites CI), not here.

Removes browser-compat.bundle.test.js and its smoke-test-only devDependencies
(safe-buffer, create-hash). Keeps the stub hardening it surfaced: string_decoder
+ url rich stubs, the node: routing fix, the import-safety invariant, and
preferGlobal() — all covered by browser-compat.test.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jjpaulino jjpaulino changed the title 🍕 Harden browser-compat stubs + kiln-edit bundle smoke test 🍕 Harden browser-compat stubs: string_decoder, url, node: import-safety Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants