Skip to content

feat(runtime): support Node 20.3+, Bun, and Deno for encrypt + upload#76

Merged
rubenhensen merged 7 commits into
mainfrom
feat/non-browser-runtime-support
May 24, 2026
Merged

feat(runtime): support Node 20.3+, Bun, and Deno for encrypt + upload#76
rubenhensen merged 7 commits into
mainfrom
feat/non-browser-runtime-support

Conversation

@rubenhensen
Copy link
Copy Markdown
Contributor

@rubenhensen rubenhensen commented May 24, 2026

Summary

Five logical changes bundled by the partner-integration thread that uncovered them:

  1. Runtime support for Node 20.3+, Bun, Deno (feat(runtime) commit, the headline change). Fixes two crashes:
    • ReferenceError: FileList is not defined on any non-browser caller doing sealed.toBytes() or sealed.upload() with files: File[].
    • ReferenceError: self is not defined when @transcend-io/conflux's bigint module is dynamically imported on Node (Bun and Deno alias self === globalThis; Node does not).
  2. Upfront YiviSessionError when sign.yivi(...) is used without a DOM (server-side runtimes).
  3. sealed.upload({ notify }) shape validator (feat(upload) commit) — catches the most common upload misconfigurations ({ notify: true }, top-level recipients, typos, wrong value types like { recipients: 'yes' } or { language: 'DE' }) with a clear TypeError instead 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.
  4. CI matrix expansion: integration workflow now runs Node 20 / 22 / 24, Bun 1.3.14, Deno 2.8.0. Each lane runs npm run typecheck (Node only) + the full vitest suite (via bunx vitest run and deno run --allow-all npm:vitest run on the non-Node lanes) + the dry smoke script.
  5. CLAUDE.md added with build, architecture, and pg-wasm patching notes. Was created as part of this session's /init and lives in its own commit.

"engines": { "node": ">=20.3" } added — the lower bound is AbortSignal.any.

How self shim works

importConfluxWithSelfShim (in src/util/zip.ts) sets globalThis.self = globalThis immediately before await import('@transcend-io/conflux'), then restores the prior state in a finally. On Node, prior state is "undeclared" (removed via Reflect.deleteProperty); on other runtimes, untouched. Conflux's bigint.js reads self.BigInt only at module evaluation (once per process), so once the cached module is loaded the runtime no longer references the bare self. No permanent global mutation.

Live verification (against staging.postguard.eu)

Runtime vitest toBytes live upload UUID
Node 26.0.0 109/109 182ms / 1035 bytes 75262ea8-…
Bun 1.3.14 109/109 148ms / 1035 bytes b67e05be-…
Deno 2.8.0 109/109 271ms / 1035 bytes a2e4c614-…

Silent uploads (no recipient mails sent).

Test plan

  • npm run typecheck clean
  • npm run build clean
  • npm test — 109/109 passing
  • Same suite under bunx vitest run and deno run --allow-all npm:vitest run — 109/109 passing
  • node scripts/smoke.mjs (dry) — all checks pass; ZIP check now actually exercises createZipReadable from the rolled-up dist/index.mjs
  • Same under bun scripts/smoke.mjs and deno run -A scripts/smoke.mjs
  • Live upload to staging from Node, Bun, and Deno — three real UUIDs returned
  • CI matrix passes (see this PR's checks)

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.
Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 validateUploadOptions feature (~50 lines in src/sealed.ts plus 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 under notify) will now hit a TypeError. 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 shimsrc/util/pg-wasm-shim.js (generated) defines __wbg_static_accessor_SELF, which returns null when typeof self === 'undefined'. On Node after createZipReadable runs once, self is no longer undefined (the shim set it to globalThis), so this accessor will now return globalThis instead of null for the rest of the process. wasm-bindgen typically uses this for crypto.getRandomValues / WebAssembly fallback chains. It probably keeps working in this repo because globalThis has 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/Denoengines.node is set to >=20.3 but 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, no AbortSignal.any) gets a runtime crash deep in encryptPipeline. Either add a runtime detection + clear error at SDK init (mirroring the YiviSessionError upfront-throw pattern this PR is adding for DOM-less environments) or document it more prominently in README.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 the self shim only affects conflux's BigInt check but it is a process-wide mutation; omits the entire validateUploadOptions feature, the yivi dep pinning, and the new CLAUDE.md. Covered in detail by Stage 1's findings.
  • tests-required-on-fixes — VIOLATED. The PR adds three fixes (FileList guard, self shim, upfront YiviSessionError for DOM-less environments). The first two have regression tests; the third does not. Add a unit test that stubs document as undefined and asserts resolveSigningKeysFromYivi throws YiviSessionError. Inline comment added on src/signing/yivi.ts:49.
  • typo-sweep-template — VIOLATED at scripts/smoke.mjs:12-15 (the four smoke-node.mjs references). 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 bundled validateUploadOptions change 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.

Comment thread src/util/zip.ts Outdated
Comment thread scripts/smoke.mjs Outdated
Comment thread package.json Outdated
Comment thread src/sealed.ts Outdated
Comment thread .github/workflows/integration.yml
Comment thread tests/zip.test.ts Outdated
Comment thread scripts/smoke.mjs Outdated
Comment thread src/sealed.ts Outdated
Comment thread .github/workflows/integration.yml
Comment thread src/signing/yivi.ts
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.
@rubenhensen rubenhensen force-pushed the feat/non-browser-runtime-support branch from f351846 to 107870a Compare May 24, 2026 12:46
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.
@rubenhensen
Copy link
Copy Markdown
Contributor Author

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):

  • src/util/zip.tsself shim now save/restore around the dynamic import (no permanent globalThis mutation).
  • src/index.tscreateZipReadable exported so the smoke uses the public surface.
  • scripts/smoke.mjs — hard-fails if createZipReadable is missing instead of silent-skip; smoke-node.mjssmoke.mjs typos cleaned up.
  • src/sealed.tsupload() throws upfront on data: ReadableStream; validateUploadOptions now type-checks value types; local describe renamed to describeValue.
  • tests/zip.test.ts — faithful Node-state reproduction via Reflect.deleteProperty; added a no-global-leak assertion.
  • tests/postguard.test.ts — regression tests for value-type validator branches, ReadableStream refusal, and the YiviSessionError upfront throw.
  • .github/workflows/integration.yml — Bun and Deno lanes now run the full vitest suite (bunx vitest run / deno run --allow-all npm:vitest run); Bun pinned to 1.3.14, Deno to 2.8.0.
  • README.md — minimum Bun/Deno versions documented.

Resolved (1):

  • Yivi version pinning scope-creep: stale after rebase. Main's c6905cf already bumped yivi to ^1.0.0 / ^1.0.1, so my dedicated chore(deps) commit became empty during rebase and was dropped. Branch has no yivi delta vs. main.

On the scope-creep finding about validateUploadOptions: the validator and the runtime-support work are separate commits (3b9c2f2 feat(upload): … and 2adff7a feat(runtime): …) — a future bisect over Cryptify behaviour can land on either independently. Keeping them in one PR because they arose from the same partner-integration thread and were sequenced together in the same session. The validator was the specific symptom that surfaced the runtime bug, so they're causally linked.

On CLAUDE.md: the file was created in a separate 89b0722 docs: add CLAUDE.md commit during this same session. Added to the PR description for visibility — happy to extract to a separate PR if you'd prefer.

Local verification after fixes:

Runtime vitest live encrypt+upload to staging
Node 26.0.0 109/109 UUID 75262ea8-…
Bun 1.3.14 109/109 UUID b67e05be-…
Deno 2.8.0 109/109 UUID a2e4c614-…

CI is re-running on the rebased + fixed branch.

@rubenhensen
Copy link
Copy Markdown
Contributor Author

/dobby review this pr again

@dobby-coder
Copy link
Copy Markdown
Contributor

dobby-coder Bot commented May 24, 2026

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.
Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 via hasOwnProperty and restores in a finally (delete when not previously own, restore prior value otherwise).
  • createZipReadable exported from src/index.ts:79; scripts/smoke.mjs:80-93 imports it from dist/index.mjs and hard-fails if missing.
  • Sealed.upload() throws upfront on ReadableStream data (src/sealed.ts:90-95) and resolveFiles mirrors the same refusal (src/sealed.ts:136-141).
  • validateUploadOptions now type-checks values: booleans for recipients/sender, string for message, 'EN' | 'NL' enum for language (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 to 2.8.0 via matrix (.github/workflows/integration.yml:46, 79).
  • Local helper renamed to describeValue (src/sealed.ts:224).
  • tests/zip.test.ts:185-197 uses Reflect.deleteProperty(globalThis, 'self') in beforeEach and asserts 'self' in globalThis === false both before AND after createZipReadable returns — no-leak proof.
  • Header comments in scripts/smoke.mjs:12-15 say smoke.mjs.
  • tests/postguard.test.ts:201-212 stubs document as undefined and asserts resolveSigningKeysFromYivi rejects with YiviSessionError matching /sign\.yivi requires a DOM/.
  • Yivi version pinning was correctly dropped during rebase — git diff origin/main -- package.json shows the only delta is the new engines.node: >=20.3 field, 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.

@rubenhensen
Copy link
Copy Markdown
Contributor Author

/dobby review pr

@dobby-coder
Copy link
Copy Markdown
Contributor

dobby-coder Bot commented May 24, 2026

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.

Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rubenhensen rubenhensen merged commit b6d9726 into main May 24, 2026
5 checks passed
@rubenhensen rubenhensen deleted the feat/non-browser-runtime-support branch May 24, 2026 13:32
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 1.9.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant