Skip to content

dist/wasm-inline.js imports the NODE @cipherstash/protect-ffi, defeating the entry's purpose #744

Closed

Description

@coderdan

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

  1. 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).
  2. Add the bundling-isolation test, modelled on the prisma-next one.
  3. Decide what failure.code should mean on the WASM path, given there is no ProtectError class there.

Activity

  1. self-assigned this
    on Jul 21, 2026
  2. coderdan commented on Jul 21, 2026

    @coderdan
    ContributorAuthor

    Correction: this WAS introduced by #741, and is now fixed there

    I filed this claiming the native import was pre-existing. That was wrong, and the verification behind it was invalid.

    I had run git stash -u to swap in main, but the tree was already clean — so it stashed nothing, and I rebuilt my own branch and compared it against itself. Both builds were the same build.

    Re-verified properly by swapping in main's src/wasm-inline.ts and rebuilding in place:

    with #741's wasm-inline.ts:   @cipherstash/protect-ffi
                                  @cipherstash/protect-ffi/wasm-inline
    with main's wasm-inline.ts:   @cipherstash/protect-ffi/wasm-inline
    

    The cause was #741's own import { getErrorCode } from '@/encryption/helpers/error-code' — a value-import of the native ProtectError class for an instanceof narrow. main never had it.

    Fixed in #741

    Replaced with a structural readErrorCode, which is the only thing that could ever have worked on that path anyway: the WASM build ships no error class, so instanceof never matches there regardless.

    Also added packages/stack/__tests__/wasm-inline-bundle-isolation.test.ts, which asserts the built bundle's external imports — the guard this issue asked for. Verified it fails when the regression is reintroduced (2 of its 3 assertions), rather than assuming it would.

    Closing

    Nothing pre-existing remains: main was clean, #741 broke it, #741 fixes it, and CI now guards it. Closing as resolved-in-#741.

    Two related points did survive into their own issue — #746 — since they are genuinely pre-existing: the .d.ts still references type-only symbols from the native protect-ffi root, and failure.code semantics on the WASM path.

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

Metadata

Metadata

Assignees

Labels

SDKbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions