feat(runtime): support Node 20.3+, Bun, and Deno for encrypt + upload#76
Conversation
Surfaces the most common misconfigurations (boolean notify, top-level recipients, typos in notify keys, non-object opts) as synchronous TypeErrors with messages that point at the correct shape. Previously these silently degraded to "no notification email sent" because every field coalesced to undefined and Cryptify defaulted to silent.
Captures the prebuild generator step (base64 WASM + wasm-bindgen shim patch), the lazy builder surface, and the Conventional Commits requirement so future contributors don't have to reverse-engineer them.
There was a problem hiding this comment.
Code review
The two stated runtime bugs are real and the fixes work end-to-end (typecheck, 83/83 tests, dry smoke on Node 22 all pass; the PR author also documents live encrypt+upload roundtrips on Node 26 / Bun 1.3.14 / Deno 2.8.0). That said, the PR is doing several things at once that are not advertised in the description, the smoke test silently skips its single most important dry-mode check, and the global self shim is broader and more invasive than the description admits.
None of the issues are catastrophic but several need to be split out, narrowed, or de-risked before merge.
General findings not tied to a specific diff line:
- [Code review] Scope creep #1 — the entire
validateUploadOptionsfeature (~50 lines insrc/sealed.tsplus six new tests) is unrelated to non-browser runtime support and is not mentioned anywhere in the PR description. It also changes user-facing behaviour: callers who today silently get a default-silent upload when they pass{ recipients: true }(forgot to nest undernotify) will now hit aTypeError. That is probably a good change, but bundling a behavioural change with a runtime-compat fix means a future bisect over a Cryptify regression has to reason about both at once. Split into its own PR with its own conventional-commit type (feat(upload):or similar). - [Code review] Knock-on of the global self shim —
src/util/pg-wasm-shim.js(generated) defines__wbg_static_accessor_SELF, which returnsnullwhentypeof self === 'undefined'. On Node aftercreateZipReadableruns once,selfis no longer undefined (the shim set it toglobalThis), so this accessor will now returnglobalThisinstead ofnullfor the rest of the process. wasm-bindgen typically uses this forcrypto.getRandomValues/WebAssemblyfallback chains. It probably keeps working in this repo becauseglobalThishas those, but the shim is silently rewiring behaviour in a module the user did not opt into. Worth a sentence in the shim comment acknowledging this. - [Code review] Runtime floors for Bun/Deno —
engines.nodeis set to>=20.3but there is no equivalent gate for Bun and Deno. CLAUDE.md mentions the lower bounds informally, but a user pulling this SDK into a project running an older Bun (< 1.0.16, noAbortSignal.any) gets a runtime crash deep inencryptPipeline. Either add a runtime detection + clear error at SDK init (mirroring theYiviSessionErrorupfront-throw pattern this PR is adding for DOM-less environments) or document it more prominently inREADME.md. - [Code review] CLAUDE.md is unmentioned — useful file but a separate concern from the runtime-support work; should land in its own commit/PR (or at least be called out in the description so reviewers know to look at it).
Rule compliance
Ran ~16 rule sub-agents in parallel against ~/dobby-memory/rules/*.md and the repo-specific notes for postguard-js. Most rules are workflow/automation rules and do not apply to PR content. The applicable ones largely confirm Stage 1's findings rather than adding new ones:
- promised-vs-delivered — VIOLATED. PR description says "each lane runs
npm test+ a dry smoke script" but Bun and Deno lanes only run the smoke script; says theselfshim only affects conflux's BigInt check but it is a process-wide mutation; omits the entirevalidateUploadOptionsfeature, the yivi dep pinning, and the newCLAUDE.md. Covered in detail by Stage 1's findings. - tests-required-on-fixes — VIOLATED. The PR adds three fixes (FileList guard, self shim, upfront
YiviSessionErrorfor DOM-less environments). The first two have regression tests; the third does not. Add a unit test that stubsdocumentas undefined and assertsresolveSigningKeysFromYivithrowsYiviSessionError. Inline comment added onsrc/signing/yivi.ts:49. - typo-sweep-template — VIOLATED at
scripts/smoke.mjs:12-15(the foursmoke-node.mjsreferences). Already flagged by Stage 1. - docs-drift-check — VIOLATED (overlaps with promised-vs-delivered): the README/CLAUDE.md additions accurately describe the runtime work, but the PR description omits
validateUploadOptions, dep pinning, and CLAUDE.md. - conventional-commit-pr-titles — Title is correctly formatted (
feat(runtime): …), but the(runtime)scope under-describes the bundledvalidateUploadOptionschange and dep-pinning. Best addressed by splitting the PR (see Stage 1 scope-creep finding). - check-existing-fix-before-dep-work, cross-repo-link-format, cryptify39-location-lesson, encryption4all-org, vitest-mock-hoisting, postguard-js repo notes — all CLEAN.
No new inline comments beyond the one for the missing YiviSessionError test; the other rule findings duplicate Stage 1 findings already inlined below.
Verdict: request changes.
Two bugs prevented the SDK from working in non-browser runtimes:
1. `instanceof FileList` in sealed.ts throws ReferenceError when
FileList is not a global. FileList is browser-only; Node, Bun, and
Deno don't have it. Now typeof-guarded.
2. `@transcend-io/conflux/dist/esm/bigint.js` reads `self.BigInt` at
module load. `self` is browser-only; Bun and Deno alias it to
globalThis, Node does not, so dynamic-importing conflux on Node
throws "self is not defined". A small `globalThis.self ??=
globalThis` shim runs immediately before the conflux import in
createZipReadable. No-op on browsers, Bun, and Deno.
Also adds an upfront, clear error when sign.yivi(...) is used without a
DOM, and extracts resolveFiles to a top-level export so the FileList
guard is directly unit-testable. Regression tests for both fixes
included; the suite would have failed before the fixes.
Adds `"engines": { "node": ">=20.3" }` — the lower bound is required
for AbortSignal.any, which the encrypt pipeline uses.
Verified end-to-end against staging.postguard.eu under Node 26, Bun
1.3.14, and Deno 2.8.0 (real UUID returned for each).
Adds three CI jobs to integration.yml: Node (matrix over 20/22/24), Bun, and Deno. Each lane builds the dist and runs scripts/smoke.mjs in dry mode, which loads the dist, exercises createZipReadable, and verifies global availability — catches both bugs fixed in the previous commit without needing live credentials. The smoke script can also do a full live upload when PG_API_KEY is set; useful for one-off manual verification.
README gets a "Server-side usage" section listing what works (encrypt + upload via sign.apiKey or sign.session), what's browser-only (sign.yivi, result.download), and how to run scripts/smoke.mjs. CLAUDE.md gets a runtime-support section plus notes on the two non-obvious gotchas (FileList guard, conflux self shim) so future contributors don't accidentally regress them.
f351846 to
107870a
Compare
Addresses inline comments from dobby-coder's review:
- src/util/zip.ts: narrow the conflux self shim to save/restore around
the dynamic import instead of permanently mutating globalThis.self.
Avoids surprising downstream libraries that use `typeof self !==
'undefined'` as a browser-detection heuristic.
- src/index.ts: export createZipReadable so the smoke script can
exercise the conflux+self path through the public surface (the build
emits a single rolled-up dist/index.mjs, so deep imports into
dist/util/zip.mjs silently fall through and skip the check).
- scripts/smoke.mjs: drop the silent-skip in the ZIP check (now
hard-imports createZipReadable from the public surface and fails
loudly if missing); fix stale "smoke-node.mjs" references in the
comment header.
- src/sealed.ts: upload() now throws TypeError upfront for data:
ReadableStream payloads instead of silently uploading an empty File
("data.bin") and returning a misleading UUID. validateUploadOptions
now type-checks notify.{recipients,sender,message,language} values,
not just key names — closes the gap where { recipients: 'yes' }
passed validation and behaved truthy downstream. Renamed local
`describe()` helper to `describeValue()` so it doesn't shadow vitest's
describe. resolveFiles also throws on ReadableStream (dead branch
before; now consistent with upload).
- tests/zip.test.ts: faithful reproduction of Node's native state via
Reflect.deleteProperty(globalThis, 'self') in beforeEach + restore
in afterEach. Adds a second test asserting the shim does NOT leak
globalThis.self after createZipReadable runs.
- tests/postguard.test.ts: new YiviSessionError regression test for
the upfront-throw added in src/signing/yivi.ts (was previously
untested). Tests for the new value-type validator branches and the
upload() ReadableStream refusal.
- .github/workflows/integration.yml: Bun and Deno lanes now also run
the full vitest suite (`bunx vitest run` / `deno run -A npm:vitest
run`), not just the smoke. Runtime versions pinned (Bun 1.3.14, Deno
2.8.0) so future upstream regressions surface here as a CI failure
on this SDK rather than confused for upstream breakage.
- README.md: documents the minimum Bun (1.0.16+) and Deno (1.39+)
versions — both gated by AbortSignal.any.
|
Thanks for the thorough review — all 11 inline findings addressed in 0f8b208 (force-pushed after rebase onto current main). Per-comment replies posted on each thread. Quick summary of the response: Fixes applied (10):
Resolved (1):
On the scope-creep finding about On Local verification after fixes:
CI is re-running on the rebased + fixed branch. |
|
/dobby review this pr again |
|
On it! This agent is rushing to re-review the PR — checking that each of the eleven previous findings is genuinely resolved in 0f8b208 before deciding the verdict. |
tsdown@0.22 requires Node ^22.18 || >=24; it can't build on Node 20. Since we don't intend to claim Node 20 support, drop it from the matrix and bump engines.node to >=22 to match. README and CLAUDE.md updated to reflect Node 22 as the floor.
There was a problem hiding this comment.
Re-review
All eleven previous findings are genuinely resolved in 0f8b208 — I verified each one against the code rather than relying on the reply comments:
importConfluxWithSelfShim(src/util/zip.ts:11-22) saves prior state viahasOwnPropertyand restores in afinally(delete when not previously own, restore prior value otherwise).createZipReadableexported fromsrc/index.ts:79;scripts/smoke.mjs:80-93imports it fromdist/index.mjsand hard-fails if missing.Sealed.upload()throws upfront onReadableStreamdata (src/sealed.ts:90-95) andresolveFilesmirrors the same refusal (src/sealed.ts:136-141).validateUploadOptionsnow type-checks values: booleans forrecipients/sender, string formessage,'EN' | 'NL'enum forlanguage(src/sealed.ts:202-221).- Bun and Deno CI lanes run
bunx vitest run/deno run --allow-all npm:vitest run(.github/workflows/integration.yml:67-68, 100-101). - Bun pinned to
1.3.14, Deno to2.8.0via matrix (.github/workflows/integration.yml:46, 79). - Local helper renamed to
describeValue(src/sealed.ts:224). tests/zip.test.ts:185-197usesReflect.deleteProperty(globalThis, 'self')inbeforeEachand asserts'self' in globalThis === falseboth before AND aftercreateZipReadablereturns — no-leak proof.- Header comments in
scripts/smoke.mjs:12-15saysmoke.mjs. tests/postguard.test.ts:201-212stubsdocumentas undefined and assertsresolveSigningKeysFromYivirejects withYiviSessionErrormatching/sign\.yivi requires a DOM/.- Yivi version pinning was correctly dropped during rebase —
git diff origin/main -- package.jsonshows the only delta is the newengines.node: >=20.3field, no yivi changes.
Local sanity check on Node 22: npm ci, npm run typecheck, npm run build, npm test (109/109), and node scripts/smoke.mjs (8/8) all green.
One new blocker — Node 20 CI lane fails the Build step
The matrix lane that this PR explicitly added to verify Node 20.3+ support is red:
> tsdown
ERROR Error: Failed to import module "unrun". Please ensure it is installed.
Process completed with exit code 1.
Root cause: tsdown@0.22.0 declares "engines": { "node": "^22.18.0 || >=24.0.0" } (confirmed locally in node_modules/tsdown/package.json). On Node 20 the build tool itself cannot load. Bun and Deno lanes skip this because they only run vitest + smoke against the already-built dist/, but the Node lane does npm run build before npm test/smoke.
This matters because the headline change of this PR is "Node 20.3+ runtime support" — and the only CI evidence for that support is currently failing. The package as published works fine for Node 20.3+ consumers (they're not building it), but the matrix as written doesn't prove it.
Suggested fix: restructure the Node 20 lane to consume a pre-built dist/ rather than rebuild from source. Either build once in a setup job and download the artefact, or run npm test (which has pretest generators) + node scripts/smoke.mjs on Node 20 without npm run build. The smoke already imports dist/index.mjs, so if dist/ is built on Node 22 first, Node 20 can run the smoke against it and verify runtime compatibility — which is what the PR actually claims.
Alternative: drop Node 20 from the matrix and document the dev-tooling floor (Node 22+) separately from the runtime floor (Node 20.3+) in CLAUDE.md. The current engines.node: >=20.3 field is the user-facing contract and that doesn't need to change.
Everything else looks good. Flipping back to approve as soon as Node 20 is either restructured to test runtime-only or removed from the matrix.
|
/dobby review pr |
|
This agent is hopping right back to it! Will verify the Node 20 → Node 22 drop in c3f3e78 against the previous blocker, confirm CI is green, and flip the verdict to approve if everything checks out. |
There was a problem hiding this comment.
Re-review — approve
The Node 20 blocker from the prior review is resolved in c3f3e78 by the cleaner of the two options I'd suggested: dropping Node 20 from the matrix and bumping engines.node to >=22 to match. tsdown@0.22 declares ^22.18 || >=24, so a Node 20 build lane was never going to be honest — better to remove it than to construct a build-once-test-elsewhere dance for a node version no consumer is asked to use.
CI now shows all five required lanes green:
| Lane | Status |
|---|---|
| Node 22 | pass (20s) |
| Node 24 | pass (12s) |
| Bun 1.3.14 | pass (15s) |
| Deno 2.8.0 | pass (11s) |
| Conventional Commit | pass |
Local verification on Node 22.22.2: npm ci clean, npm run typecheck clean, npm run build clean, npm test 109/109 passing.
The substance of the PR is unchanged: the runtime fixes (FileList guard + scoped self shim), the upfront YiviSessionError, the validateUploadOptions value-type checks, the upload ReadableStream refusal, and the CI matrix expansion all remain. The Node-20 → Node-22 bump is purely a CI-honesty fix; the runtime fixes themselves would still work on Node 20.3+ as the prose in CLAUDE.md notes.
One small cosmetic drift to consider, non-blocking: the PR title and the "Runtime support" header in the PR description still say "Node 20.3+", which no longer matches engines.node. Either reword to "Node 22+, Bun, and Deno" before merge, or leave as-is — the commit history is the durable record either way.
Approving.
|
🎉 This PR is included in version 1.9.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
Five logical changes bundled by the partner-integration thread that uncovered them:
feat(runtime)commit, the headline change). Fixes two crashes:ReferenceError: FileList is not definedon any non-browser caller doingsealed.toBytes()orsealed.upload()withfiles: File[].ReferenceError: self is not definedwhen@transcend-io/conflux's bigint module is dynamically imported on Node (Bun and Deno aliasself === globalThis; Node does not).YiviSessionErrorwhensign.yivi(...)is used without a DOM (server-side runtimes).sealed.upload({ notify })shape validator (feat(upload)commit) — catches the most common upload misconfigurations ({ notify: true }, top-levelrecipients, typos, wrong value types like{ recipients: 'yes' }or{ language: 'DE' }) with a clearTypeErrorinstead of silently degrading to "no notification email sent". Standalone but causally linked: this was the specific symptom Mark's integration hit before we discovered the runtime bug.npm run typecheck(Node only) + the full vitest suite (viabunx vitest runanddeno run --allow-all npm:vitest runon the non-Node lanes) + the dry smoke script.CLAUDE.mdadded with build, architecture, and pg-wasm patching notes. Was created as part of this session's/initand lives in its own commit."engines": { "node": ">=20.3" }added — the lower bound isAbortSignal.any.How
selfshim worksimportConfluxWithSelfShim(insrc/util/zip.ts) setsglobalThis.self = globalThisimmediately beforeawait import('@transcend-io/conflux'), then restores the prior state in afinally. On Node, prior state is "undeclared" (removed viaReflect.deleteProperty); on other runtimes, untouched. Conflux'sbigint.jsreadsself.BigIntonly at module evaluation (once per process), so once the cached module is loaded the runtime no longer references the bareself. No permanent global mutation.Live verification (against staging.postguard.eu)
75262ea8-…b67e05be-…a2e4c614-…Silent uploads (no recipient mails sent).
Test plan
npm run typecheckcleannpm run buildcleannpm test— 109/109 passingbunx vitest runanddeno run --allow-all npm:vitest run— 109/109 passingnode scripts/smoke.mjs(dry) — all checks pass; ZIP check now actually exercisescreateZipReadablefrom the rolled-updist/index.mjsbun scripts/smoke.mjsanddeno run -A scripts/smoke.mjs