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
3 changes: 2 additions & 1 deletion packages/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"./package.json": "./package.json"
},
"files": [
"dist"
"dist",
"CHANGELOG.md"
],
"sideEffects": [
"**/*.wasm",
Expand Down
91 changes: 71 additions & 20 deletions packages/js/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,6 @@ if (!T) {
throw new Error(`Unknown TARGET "${TARGET}". Expected one of: ${Object.keys(TARGETS).join(", ")}`);
}

/**
* Vite inlines `new URL('x.wasm', import.meta.url)` as a data URL in lib mode
* and ignores assetsInlineLimit. Hoisting the literal defeats the static
* analysis, leaving a real runtime URL resolution against the sibling file.
*/
function preventWasmInlining() {
return {
name: "img2num:prevent-wasm-inlining",
enforce: "pre",
transform(code, id) {
if (!id.includes("build-wasm") || !T.copyWasm) return null;
if (!code.includes("import.meta.url")) return null;

const patched = code.replace(/new URL\((["'])(img2num\.wasm)\1,\s*import\.meta\.url\)/g, 'new URL(globalThis.__IMG2NUM_WASM_NAME__ ??= "$2", import.meta.url)');
return patched === code ? null : { code: patched, map: null };
},
};
}

/**
* Copies the emitted .wasm into dist/<target>/ after the bundle is written.
*/
Expand All @@ -103,6 +84,76 @@ function copyWasmPlugin() {
};
}

/**
* Ships a bundler-detectable wasm URL in the published output.
*
* Problem: Vite's lib mode inlines `new URL("x.wasm", import.meta.url)` as a
* data URL and ignores assetsInlineLimit. But downstream bundlers (Vite,
* webpack 5, Rollup, Parcel) rely on that exact literal form to detect,
* emit, and rewrite the wasm asset in *consumer* builds.
*
* Solution, in two phases:
* 1. transform: mangle the literal into a non-static expression so
* lib-mode asset analysis can't inline it.
* 2. generateBundle: after analysis is done, restore a bundler-detectable
* form in the emitted chunk. The restored form is a TERNARY, not the
* bare literal: the false branch is the exact literal (which consumer
* bundlers detect and rewrite), while the true branch preserves
* `globalThis.__IMG2NUM_WASM_NAME__` as a runtime override for exotic
* setups (e.g. CDN-hosted wasm). Do not "simplify" the ternary away --
* collapsing it to the literal silently kills the override.
*
* LOAD-BEARING CONSTRAINT: the mangled sentinel must be valid syntax at
* build.target (es2020). Do NOT use `??=` or other ES2021+ operators here --
* they get transpiled before generateBundle runs, the restore match fails
* silently, and the published chunk ships a non-static URL (the 0.4.0 bug).
* `||` is safe at es2020.
*/
function wasmUrlPlugin() {
const LITERAL = 'new URL("img2num.wasm", import.meta.url)';
const MANGLED = 'new URL(globalThis.__IMG2NUM_WASM_NAME__ || "img2num.wasm", import.meta.url)';
const WASM_NAME = "globalThis.__IMG2NUM_WASM_NAME__";
// Override wins when set; otherwise the literal branch is what consumer
// bundlers statically detect. Bundlers match the expression node, so the
// literal being inside a ternary branch does not defeat detection.
const RESTORED = `(${WASM_NAME} ? new URL(${WASM_NAME}, import.meta.url) : ${LITERAL})`;

// The pattern only exists in the ES6 web glue; node glue resolves via
// __dirname and standalone glue via document.currentScript. Restricting to
// the browser target makes that a structural guarantee instead of a
// coincidence of glue contents.
if (TARGET !== "browser") return { name: "img2num:wasm-url" };

return {
name: "img2num:wasm-url",
enforce: "pre",
transform(code, id) {
if (!id.includes("build-wasm")) return null;
if (!code.includes("import.meta.url")) return null;

const patched = code.replace(/new URL\((["'])(img2num\.wasm)\1,\s*import\.meta\.url\)/g, MANGLED);
return patched === code ? null : { code: patched, map: null };
},
generateBundle(_, bundle) {
// Tolerant match: quote style and whitespace may be normalized by the
// bundler even when the expression itself survives.
const mangledRe = /new URL\(\s*globalThis\.__IMG2NUM_WASM_NAME__\s*\|\|\s*(["'])img2num\.wasm\1\s*,\s*import\.meta\.url\s*\)/g;

for (const chunk of Object.values(bundle)) {
if (chunk.type !== "chunk") continue;
chunk.code = chunk.code.replace(mangledRe, RESTORED);
}

// Guard: the browser ES build must ship the bundler-detectable literal.
// Fails the build loudly instead of shipping a consumer-breaking chunk.
const hasLiteral = Object.values(bundle).some((c) => c.type === "chunk" && c.code.includes(LITERAL));
if (!hasLiteral) {
throw new Error("[img2num] wasm URL literal missing from browser bundle -- consumers' bundlers won't detect the asset.");
}
},
};
}

const FILE_NAMES = {
es: "img2num.js",
cjs: "img2num.cjs",
Expand All @@ -111,7 +162,7 @@ const FILE_NAMES = {
};

export default defineConfig({
plugins: [preventWasmInlining(), copyWasmPlugin()],
plugins: [wasmUrlPlugin(), copyWasmPlugin()],
Comment thread
coderabbitai[bot] marked this conversation as resolved.

build: {
outDir: T.outDir ?? `dist/${TARGET}`,
Expand Down