Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/compiled-binary-asset-loading.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/anonymize/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
},
Expand Down
119 changes: 119 additions & 0 deletions packages/anonymize/scripts/smoke-wasm-compiled.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/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 });
}
14 changes: 9 additions & 5 deletions packages/anonymize/src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,11 @@ const NATIVE_DIR = "native";
* anchor. `new URL(fileName, <anchor>)` resolves to `native/<fileName>` 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.<language>.stlanonpkg` (scoped variants). */
Expand Down Expand Up @@ -184,9 +185,12 @@ export default function stllAnonymizeWasmVite(
);
}
return {
// `assetUrl` resolves `new URL(fileName, <base>)`; pointing the base
// at the emitted anchor file keeps the last-path-segment replacement
// trick: `new URL(fileName, …/native/index.js)` -> `native/<fileName>`.
code: code.replace(
ASSET_URL_BASE,
`fileName, import.meta.ROLLUP_FILE_URL_${anchorRef}`,
`new URL(import.meta.ROLLUP_FILE_URL_${anchorRef})`,
),
map: null,
};
Expand Down
50 changes: 47 additions & 3 deletions packages/anonymize/src/wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<default>";
Expand Down Expand Up @@ -160,7 +161,10 @@ const loadWasmBinding = async (): Promise<NativeAnonymizeBinding> => {
};

type RuntimeGlobals = {
process?: { versions?: { node?: string } };
process?: {
env?: Record<string, string | undefined>;
versions?: { node?: string };
};
window?: unknown;
};

Expand All @@ -172,8 +176,48 @@ const isNodeRuntime = (): boolean => {
);
};

const assetUrl = (fileName: string): URL =>
new URL(`./${NATIVE_ASSET_DIR}/${fileName}`, import.meta.url);
/**
* `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 assetDirOverrideUrl = (): URL | undefined => {
const globals: RuntimeGlobals = globalThis;
const override = globals.process?.env?.[ASSET_DIR_ENV];
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)}`,
);
}
// 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 = (
options?: WasmBindingOptions,
Expand Down
Loading