From 3ce95a8a740666053d368106bf6a511cd21bdcdb Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Mon, 10 Aug 2026 23:44:24 +0200 Subject: [PATCH 1/3] fix(wasm): load native assets from a real directory in compiled binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside a compiled single binary (bun build --compile), import.meta.url points into the binary's embedded filesystem; dynamic import() resolves against it exclusively, so the binding's relative asset URLs can never reach assets installed on disk and getBinding() fails even when dist/native/ is present next to the binary. STLL_ANONYMIZE_ASSET_DIR now overrides the asset base (absolute POSIX path or file: URL). Browsers never define process, so the override is inert there; the default path is unchanged. smoke-wasm-compiled.mjs guards both directions: a compiled consumer must fail without the override (proving the guard exercises the embedded filesystem class) and must redact end to end with it — glue, wasm, and prepared package all resolve through the override. --- .changeset/compiled-binary-asset-loading.md | 5 + .github/workflows/ci.yml | 2 + packages/anonymize/package.json | 1 + .../anonymize/scripts/smoke-wasm-compiled.mjs | 102 ++++++++++++++++++ packages/anonymize/src/wasm.ts | 31 +++++- 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 .changeset/compiled-binary-asset-loading.md create mode 100644 packages/anonymize/scripts/smoke-wasm-compiled.mjs diff --git a/.changeset/compiled-binary-asset-loading.md b/.changeset/compiled-binary-asset-loading.md new file mode 100644 index 00000000..07548c5e --- /dev/null +++ b/.changeset/compiled-binary-asset-loading.md @@ -0,0 +1,5 @@ +--- +"@stll/anonymize-wasm": patch +--- + +Support loading native assets from a real directory via `STLL_ANONYMIZE_ASSET_DIR`, so the wasm binding initializes inside compiled single binaries (`bun build --compile`), where `import.meta.url`-relative asset URLs resolve against the embedded filesystem and can never reach assets installed on disk. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0aa1946a..7b87c359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -378,6 +378,8 @@ jobs: run: bun run --cwd packages/anonymize smoke:dist - name: Smoke test wasm binding + package entry across runtimes run: bun run --cwd packages/anonymize smoke:wasm-runtimes + - name: Smoke test wasm entry inside a compiled single binary + run: bun run --cwd packages/anonymize smoke:wasm-compiled - name: Smoke test wasm browser path env: CHROME_BIN: /usr/bin/google-chrome-stable diff --git a/packages/anonymize/package.json b/packages/anonymize/package.json index 86c51c00..5978cdba 100644 --- a/packages/anonymize/package.json +++ b/packages/anonymize/package.json @@ -92,6 +92,7 @@ "smoke:wasm": "node scripts/smoke-wasm.mjs", "smoke:wasm-package": "node scripts/smoke-wasm-package.mjs", "smoke:wasm-runtimes": "node scripts/smoke-wasm-runtimes.mjs", + "smoke:wasm-compiled": "node scripts/smoke-wasm-compiled.mjs", "smoke:wasm-browser": "node scripts/smoke-wasm-browser.mjs", "format": "oxfmt ." }, diff --git a/packages/anonymize/scripts/smoke-wasm-compiled.mjs b/packages/anonymize/scripts/smoke-wasm-compiled.mjs new file mode 100644 index 00000000..430dd61b --- /dev/null +++ b/packages/anonymize/scripts/smoke-wasm-compiled.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Compiled single-binary loader guard for the wasm package entry. + * + * `bun build --compile` embeds the module graph in a virtual filesystem, and + * dynamic `import()` resolves against it exclusively — the binding's relative + * asset URLs (derived from `import.meta.url`) can never reach assets copied + * onto disk, so `getBinding()` fails inside compiled binaries even when + * `dist/native/` sits next to the binary. `STLL_ANONYMIZE_ASSET_DIR` + * redirects asset resolution to a real directory. + * + * This smoke compiles a minimal consumer of the built `wasm/dist/wasm.mjs` + * and asserts both directions: without the override the engine load fails + * (proving the smoke exercises the class), and with the override a default + * redaction resolves entities end to end (glue, wasm, prepared package). + * + * Prerequisites (same as smoke-wasm-package.mjs): + * - `bun run build:native-wasm` + * - `bun run build` + * - `bun run build:wasm-assets` + */ +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const packageRoot = dirname(here); +const entryPath = join(packageRoot, "wasm", "dist", "wasm.mjs"); +const nativeAssetsPath = join(packageRoot, "wasm", "dist", "native"); + +for (const required of [entryPath, join(nativeAssetsPath, "index.js")]) { + if (!existsSync(required)) { + throw new Error( + `missing ${required}; run \`bun run build\` and \`bun run build:wasm-assets\` first`, + ); + } +} + +const workDir = mkdtempSync(join(tmpdir(), "smoke-wasm-compiled-")); +try { + const consumerPath = join(workDir, "consumer.ts"); + writeFileSync( + consumerPath, + [ + `import { redactDefaultText } from ${JSON.stringify(entryPath)};`, + "", + 'const result = await redactDefaultText("Alice Novak called +420 777 123 456.");', + "if (result.resolvedEntities.length === 0) {", + ' throw new Error("compiled binary resolved no entities");', + "}", + 'console.log(JSON.stringify({ ok: true, entities: result.resolvedEntities.length }));', + ].join("\n"), + ); + + const binaryPath = join(workDir, "consumer"); + const compile = spawnSync( + "bun", + ["build", "--compile", "--target=bun", "--outfile", binaryPath, consumerPath], + { stdio: "inherit" }, + ); + if (compile.status !== 0) { + throw new Error(`bun build --compile exited with status ${compile.status}`); + } + + // Without the override the loader must fail: a pass here would mean the + // guard no longer exercises the embedded-filesystem class at all. + const withoutOverride = spawnSync(binaryPath, [], { + encoding: "utf8", + env: { ...process.env, STLL_ANONYMIZE_ASSET_DIR: "" }, + }); + if (withoutOverride.status === 0) { + throw new Error( + "compiled consumer unexpectedly loaded the engine without STLL_ANONYMIZE_ASSET_DIR; " + + "the embedded-filesystem constraint changed — re-evaluate this guard and the override", + ); + } + + const assetDir = join(workDir, "assets"); + cpSync(nativeAssetsPath, assetDir, { recursive: true }); + const withOverride = spawnSync(binaryPath, [], { + encoding: "utf8", + env: { ...process.env, STLL_ANONYMIZE_ASSET_DIR: assetDir }, + }); + if (withOverride.status !== 0) { + throw new Error( + `compiled consumer failed with STLL_ANONYMIZE_ASSET_DIR:\n${withOverride.stdout}\n${withOverride.stderr}`, + ); + } + const lastLine = withOverride.stdout.trim().split("\n").at(-1) ?? ""; + const parsed = JSON.parse(lastLine); + if (parsed.ok !== true || typeof parsed.entities !== "number") { + throw new Error(`unexpected consumer output: ${lastLine}`); + } + + console.log( + JSON.stringify({ event: "smoke-wasm-compiled", ok: true, entities: parsed.entities }), + ); +} finally { + rmSync(workDir, { force: true, recursive: true }); +} diff --git a/packages/anonymize/src/wasm.ts b/packages/anonymize/src/wasm.ts index 66813ee0..e8a3fc84 100644 --- a/packages/anonymize/src/wasm.ts +++ b/packages/anonymize/src/wasm.ts @@ -125,6 +125,7 @@ const GLUE_MODULE = "index.js"; const WASM_MODULE = "index_bg.wasm"; const NODE_FS_MODULE = "node:fs/promises"; const NATIVE_ASSET_DIR = "native"; +const ASSET_DIR_ENV = "STLL_ANONYMIZE_ASSET_DIR"; const DEFAULT_PACKAGE_FILE = "native-pipeline.stlanonpkg"; const LANGUAGE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const DEFAULT_PIPELINE_CACHE_KEY = ""; @@ -160,7 +161,10 @@ const loadWasmBinding = async (): Promise => { }; type RuntimeGlobals = { - process?: { versions?: { node?: string } }; + process?: { + env?: Record; + versions?: { node?: string }; + }; window?: unknown; }; @@ -172,8 +176,29 @@ const isNodeRuntime = (): boolean => { ); }; -const assetUrl = (fileName: string): URL => - new URL(`./${NATIVE_ASSET_DIR}/${fileName}`, import.meta.url); +/** + * Base URL the native assets (glue module, wasm, prepared packages) resolve + * against. Defaults to the `native/` directory next to this module. + * + * `STLL_ANONYMIZE_ASSET_DIR` overrides the base for single-binary + * deployments (e.g. `bun build --compile`): there `import.meta.url` points + * into the binary's embedded filesystem, which dynamic `import()` never + * escapes, so relative resolution cannot reach assets installed on disk. + * Point the override at a real directory holding the contents of + * `dist/native/`; it accepts an absolute POSIX path or a `file:` URL. + * Browsers never define `process`, so the override is inert there. + */ +const assetBaseUrl = (): URL => { + const globals: RuntimeGlobals = globalThis; + const override = globals.process?.env?.[ASSET_DIR_ENV]; + if (override !== undefined && override !== "") { + const base = override.startsWith("file:") ? override : `file://${override}`; + return new URL(base.endsWith("/") ? base : `${base}/`); + } + return new URL(`./${NATIVE_ASSET_DIR}/`, import.meta.url); +}; + +const assetUrl = (fileName: string): URL => new URL(fileName, assetBaseUrl()); const resolveBinding = ( options?: WasmBindingOptions, From f82a999d40c38a50cc87028d96c5a9f4e9b94c94 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Mon, 10 Aug 2026 23:51:58 +0200 Subject: [PATCH 2/3] chore: format the compiled-binary smoke --- .../anonymize/scripts/smoke-wasm-compiled.mjs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/anonymize/scripts/smoke-wasm-compiled.mjs b/packages/anonymize/scripts/smoke-wasm-compiled.mjs index 430dd61b..ee423d04 100644 --- a/packages/anonymize/scripts/smoke-wasm-compiled.mjs +++ b/packages/anonymize/scripts/smoke-wasm-compiled.mjs @@ -20,7 +20,13 @@ * - `bun run build:wasm-assets` */ import { spawnSync } from "node:child_process"; -import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -50,14 +56,21 @@ try { "if (result.resolvedEntities.length === 0) {", ' throw new Error("compiled binary resolved no entities");', "}", - 'console.log(JSON.stringify({ ok: true, entities: result.resolvedEntities.length }));', + "console.log(JSON.stringify({ ok: true, entities: result.resolvedEntities.length }));", ].join("\n"), ); const binaryPath = join(workDir, "consumer"); const compile = spawnSync( "bun", - ["build", "--compile", "--target=bun", "--outfile", binaryPath, consumerPath], + [ + "build", + "--compile", + "--target=bun", + "--outfile", + binaryPath, + consumerPath, + ], { stdio: "inherit" }, ); if (compile.status !== 0) { @@ -95,7 +108,11 @@ try { } console.log( - JSON.stringify({ event: "smoke-wasm-compiled", ok: true, entities: parsed.entities }), + JSON.stringify({ + event: "smoke-wasm-compiled", + ok: true, + entities: parsed.entities, + }), ); } finally { rmSync(workDir, { force: true, recursive: true }); From 285e6cb683278718fea519883ea2efde9cee8d1b Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Tue, 11 Aug 2026 00:05:10 +0200 Subject: [PATCH 3/3] fix(wasm): validated override URL and a stable Vite anchor - The override rejects relative paths, keeps file: URLs as-is, and encodes path segments (pathToFileURL semantics without importing node:url, since this module also ships to browsers), so #, ?, and % stay path data. - The default asset base stays one verbatim expression and the Vite plugin anchors on its exact emitted text, re-pointing it at the emitted anchor asset; new URL(fileName, ) keeps the last-segment replacement behavior. --- packages/anonymize/src/vite.ts | 14 ++++++---- packages/anonymize/src/wasm.ts | 49 +++++++++++++++++++++++----------- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/anonymize/src/vite.ts b/packages/anonymize/src/vite.ts index b797c545..bbfe922f 100644 --- a/packages/anonymize/src/vite.ts +++ b/packages/anonymize/src/vite.ts @@ -39,10 +39,11 @@ const NATIVE_DIR = "native"; * anchor. `new URL(fileName, )` resolves to `native/` because * URL resolution replaces the anchor's last path segment. */ const ANCHOR_FILE = "index.js"; -/** The exact `assetUrl` base expression emitted by the build (see wasm.ts). - * Kept in sync with the compiled `wasm.mjs`; the transform fails loudly if the - * dist shape drifts so this cannot silently ship broken asset paths. */ -const ASSET_URL_BASE = "`./${NATIVE_ASSET_DIR}/${fileName}`, import.meta.url"; +/** The exact default asset-base expression emitted by the build (see + * `assetBaseUrl` in wasm.ts). Kept in sync with the compiled `wasm.mjs`; the + * transform fails loudly if the dist shape drifts so this cannot silently + * ship broken asset paths. */ +const ASSET_URL_BASE = "new URL(`./${NATIVE_ASSET_DIR}/`, import.meta.url)"; /** Prepared packages are named `native-pipeline.stlanonpkg` (the full-dictionary * default) and `native-pipeline..stlanonpkg` (scoped variants). */ @@ -184,9 +185,12 @@ export default function stllAnonymizeWasmVite( ); } return { + // `assetUrl` resolves `new URL(fileName, )`; pointing the base + // at the emitted anchor file keeps the last-path-segment replacement + // trick: `new URL(fileName, …/native/index.js)` -> `native/`. code: code.replace( ASSET_URL_BASE, - `fileName, import.meta.ROLLUP_FILE_URL_${anchorRef}`, + `new URL(import.meta.ROLLUP_FILE_URL_${anchorRef})`, ), map: null, }; diff --git a/packages/anonymize/src/wasm.ts b/packages/anonymize/src/wasm.ts index e8a3fc84..3af2e833 100644 --- a/packages/anonymize/src/wasm.ts +++ b/packages/anonymize/src/wasm.ts @@ -177,27 +177,46 @@ const isNodeRuntime = (): boolean => { }; /** - * Base URL the native assets (glue module, wasm, prepared packages) resolve - * against. Defaults to the `native/` directory next to this module. - * - * `STLL_ANONYMIZE_ASSET_DIR` overrides the base for single-binary - * deployments (e.g. `bun build --compile`): there `import.meta.url` points - * into the binary's embedded filesystem, which dynamic `import()` never - * escapes, so relative resolution cannot reach assets installed on disk. - * Point the override at a real directory holding the contents of - * `dist/native/`; it accepts an absolute POSIX path or a `file:` URL. - * Browsers never define `process`, so the override is inert there. + * `STLL_ANONYMIZE_ASSET_DIR` overrides the native-asset base for + * single-binary deployments (e.g. `bun build --compile`): there + * `import.meta.url` points into the binary's embedded filesystem, which + * dynamic `import()` never escapes, so relative resolution cannot reach + * assets installed on disk. Point the override at a real directory holding + * the contents of `dist/native/`; it accepts an absolute POSIX path or a + * `file:` URL. Browsers never define `process`, so the override is inert + * there. */ -const assetBaseUrl = (): URL => { +const assetDirOverrideUrl = (): URL | undefined => { const globals: RuntimeGlobals = globalThis; const override = globals.process?.env?.[ASSET_DIR_ENV]; - if (override !== undefined && override !== "") { - const base = override.startsWith("file:") ? override : `file://${override}`; - return new URL(base.endsWith("/") ? base : `${base}/`); + if (override === undefined || override === "") { + return undefined; + } + if (override.startsWith("file:")) { + return new URL(override.endsWith("/") ? override : `${override}/`); + } + if (!override.startsWith("/")) { + throw new Error( + `${ASSET_DIR_ENV} must be an absolute path or a file: URL, got ${JSON.stringify(override)}`, + ); } - return new URL(`./${NATIVE_ASSET_DIR}/`, import.meta.url); + // pathToFileURL semantics without importing node:url (this module also + // ships to browsers): encode each segment so characters like `#`, `?`, + // and `%` stay path data instead of URL syntax. + const encoded = override.split("/").map(encodeURIComponent).join("/"); + return new URL(`file://${encoded.endsWith("/") ? encoded : `${encoded}/`}`); }; +/** + * Base URL the native assets (glue module, wasm, prepared packages) resolve + * against: the override when set, otherwise the `native/` directory next to + * this module. The default stays a single verbatim expression — the Vite + * plugin (vite.ts) anchors on its exact emitted text to re-point browser + * builds at emitted assets. + */ +const assetBaseUrl = (): URL => + assetDirOverrideUrl() ?? new URL(`./${NATIVE_ASSET_DIR}/`, import.meta.url); + const assetUrl = (fileName: string): URL => new URL(fileName, assetBaseUrl()); const resolveBinding = (