Summary
The built WASM entry imports both protect-ffi entries:
$ grep -oE 'from "@cipherstash/protect-ffi[^"]*"' packages/stack/dist/wasm-inline.js | sort -u
from "@cipherstash/protect-ffi"
from "@cipherstash/protect-ffi/wasm-inline"
The bare specifier is the Node entry, which loads the native NAPI binding. @cipherstash/stack/wasm-inline exists precisely so that never happens — its docblock opens with "for Deno, Bun, Cloudflare Workers, Supabase Edge Functions, and any runtime where the native @cipherstash/protect-ffi NAPI bindings are unavailable."
Pre-existing
Confirmed by building main directly — not introduced by #741. Recording it because that PR touched the entry and the check was worth doing.
Why it may not be biting yet
Whether this breaks at runtime depends on the consumer's bundler and whether the import is tree-shaken or hoisted. The supabase-worker example and the Deno WASM E2E both pass today, so on those paths it evidently survives. That makes it more insidious rather than less: the entry currently relies on downstream tree-shaking to uphold a guarantee it states unconditionally, and a consumer whose bundler behaves differently gets a native-binding load on an edge runtime.
Likely source
Value (not type-only) imports reaching the Node entry transitively. One concrete example: packages/stack/src/encryption/helpers/error-code.ts does
import { ProtectError as FfiProtectError } from '@cipherstash/protect-ffi'
which is a runtime class reference, so any module importing getErrorCode drags the Node entry in. Worth auditing the whole import graph of src/wasm-inline.ts rather than assuming that is the only one.
Related consequence: getErrorCode narrows via instanceof against the Node ProtectError, and the WASM build exports no such class — so failure.code is likely always undefined on the WASM path even once the import is gone.
Nothing guards it
packages/prisma-next has test/bundling-isolation.test.ts asserting its entries stay separable; packages/stack has no equivalent for the WASM entry. A test asserting dist/wasm-inline.js contains no bare @cipherstash/protect-ffi import would have caught this at the point it was introduced, and would keep it caught.
Fix
- Audit
src/wasm-inline.ts's transitive imports for anything reaching the Node protect-ffi; make them type-only, or provide WASM-local equivalents (as WasmPlaintext already does for JsPlaintext).
- Add the bundling-isolation test, modelled on the prisma-next one.
- Decide what
failure.code should mean on the WASM path, given there is no ProtectError class there.
Summary
The built WASM entry imports both protect-ffi entries:
The bare specifier is the Node entry, which loads the native NAPI binding.
@cipherstash/stack/wasm-inlineexists precisely so that never happens — its docblock opens with "for Deno, Bun, Cloudflare Workers, Supabase Edge Functions, and any runtime where the native@cipherstash/protect-ffiNAPI bindings are unavailable."Pre-existing
Confirmed by building
maindirectly — not introduced by #741. Recording it because that PR touched the entry and the check was worth doing.Why it may not be biting yet
Whether this breaks at runtime depends on the consumer's bundler and whether the import is tree-shaken or hoisted. The
supabase-workerexample and the Deno WASM E2E both pass today, so on those paths it evidently survives. That makes it more insidious rather than less: the entry currently relies on downstream tree-shaking to uphold a guarantee it states unconditionally, and a consumer whose bundler behaves differently gets a native-binding load on an edge runtime.Likely source
Value (not type-only) imports reaching the Node entry transitively. One concrete example:
packages/stack/src/encryption/helpers/error-code.tsdoeswhich is a runtime class reference, so any module importing
getErrorCodedrags the Node entry in. Worth auditing the whole import graph ofsrc/wasm-inline.tsrather than assuming that is the only one.Related consequence:
getErrorCodenarrows viainstanceofagainst the NodeProtectError, and the WASM build exports no such class — sofailure.codeis likely alwaysundefinedon the WASM path even once the import is gone.Nothing guards it
packages/prisma-nexthastest/bundling-isolation.test.tsasserting its entries stay separable;packages/stackhas no equivalent for the WASM entry. A test assertingdist/wasm-inline.jscontains no bare@cipherstash/protect-ffiimport would have caught this at the point it was introduced, and would keep it caught.Fix
src/wasm-inline.ts's transitive imports for anything reaching the Node protect-ffi; make them type-only, or provide WASM-local equivalents (asWasmPlaintextalready does forJsPlaintext).failure.codeshould mean on the WASM path, given there is noProtectErrorclass there.