All checks were successful
Check / build (pull_request) Successful in 9m24s
307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
// Generates `pkg-rn/`: a React Native / Hermes-compatible build of the
|
|
// wasm-bindgen bindings in which the WebAssembly module is replaced by its
|
|
// wasm2js (pure-JS) translation.
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { homedir } from 'node:os';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const reconcileJsDir = resolve(here, '..');
|
|
const repoRoot = resolve(reconcileJsDir, '..');
|
|
|
|
const releaseWasm = resolve(
|
|
repoRoot,
|
|
'target/wasm32-unknown-unknown/release/reconcile_text.wasm'
|
|
);
|
|
const outDir = resolve(reconcileJsDir, 'pkg-rn');
|
|
const bgWasm = resolve(outDir, 'reconcile_text_bg.wasm');
|
|
const bgWasmJs = resolve(outDir, 'reconcile_text_bg.wasm.js');
|
|
const loweredWasm = resolve(outDir, '_lowered.wasm');
|
|
const entryJs = resolve(outDir, 'reconcile_text.js');
|
|
|
|
const wasmOpt = resolve(reconcileJsDir, 'node_modules/.bin/wasm-opt');
|
|
const wasm2js = resolve(reconcileJsDir, 'node_modules/.bin/wasm2js');
|
|
|
|
function run(cmd, args) {
|
|
execFileSync(cmd, args, { stdio: 'inherit' });
|
|
}
|
|
|
|
// Locate the wasm-bindgen CLI. It MUST match the `wasm-bindgen` crate version pinned
|
|
// in Cargo.toml: a mismatched CLI emits bindings the runtime can't use. So we resolve
|
|
// the required version first and verify every candidate against it, failing loudly
|
|
// rather than silently falling back to whatever other version happens to be around.
|
|
function findWasmBindgen() {
|
|
const cargoToml = readFileSync(resolve(repoRoot, 'Cargo.toml'), 'utf8');
|
|
const wanted = cargoToml.match(
|
|
/wasm-bindgen\s*=\s*\{[^}]*version\s*=\s*"([^"]+)"/
|
|
)?.[1];
|
|
if (!wanted) {
|
|
throw new Error(
|
|
'[build-rn] Could not parse the pinned wasm-bindgen version from Cargo.toml, so ' +
|
|
'the required CLI version is unknown. Has the dependency declaration changed?'
|
|
);
|
|
}
|
|
|
|
// 1. On PATH: accept it only if its version matches the pin.
|
|
let onPath = null;
|
|
try {
|
|
onPath = execFileSync('which', ['wasm-bindgen'], { encoding: 'utf8' }).trim();
|
|
} catch {
|
|
/* not on PATH; try the wasm-pack cache next */
|
|
}
|
|
if (onPath) {
|
|
const version = execFileSync(onPath, ['--version'], { encoding: 'utf8' }).match(
|
|
/\d+\.\d+\.\d+/
|
|
)?.[0];
|
|
if (version !== wanted) {
|
|
throw new Error(
|
|
`[build-rn] wasm-bindgen on PATH (${onPath}) is ${version ?? 'an unknown version'}, ` +
|
|
`but Cargo.toml pins ${wanted}. Install the matching CLI ` +
|
|
`(\`cargo install wasm-bindgen-cli --version ${wanted}\`) or remove the mismatched one.`
|
|
);
|
|
}
|
|
return onPath;
|
|
}
|
|
|
|
const cacheRoots = [
|
|
resolve(homedir(), 'Library/Caches/.wasm-pack'),
|
|
resolve(homedir(), '.cache/.wasm-pack'),
|
|
];
|
|
for (const root of cacheRoots) {
|
|
if (!existsSync(root)) {
|
|
continue;
|
|
}
|
|
for (const entry of readdirSync(root)) {
|
|
const candidate = resolve(root, entry, 'wasm-bindgen');
|
|
if (!existsSync(candidate)) {
|
|
continue;
|
|
}
|
|
let version;
|
|
try {
|
|
version = execFileSync(candidate, ['--version'], { encoding: 'utf8' }).match(
|
|
/\d+\.\d+\.\d+/
|
|
)?.[0];
|
|
} catch {
|
|
continue; // not an invokable wasm-bindgen; ignore
|
|
}
|
|
if (version === wanted) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`[build-rn] No wasm-bindgen ${wanted} found on PATH or in the wasm-pack cache. ` +
|
|
'Run `wasm-pack build --target web --features wasm` first (it caches the matching ' +
|
|
`wasm-bindgen), or \`cargo install wasm-bindgen-cli --version ${wanted}\`.`
|
|
);
|
|
}
|
|
|
|
if (!existsSync(releaseWasm)) {
|
|
throw new Error(
|
|
`Missing ${releaseWasm}.\nRun \`wasm-pack build --target web --features wasm\` from the repo root first.`
|
|
);
|
|
}
|
|
|
|
console.log('[build-rn] generating bundler-target bindings with wasm-bindgen');
|
|
rmSync(outDir, { recursive: true, force: true });
|
|
const wasmBindgen = findWasmBindgen();
|
|
run(wasmBindgen, ['--target', 'bundler', '--out-dir', outDir, releaseWasm]);
|
|
|
|
// --- Patch wasm-bindgen's cached-memory getters for wasm2js -----------------
|
|
//
|
|
// wasm-bindgen caches typed-array / DataView views over `wasm.memory.buffer` and
|
|
// only re-creates them when it detects the heap grew. It detects a grow by looking
|
|
// for ArrayBuffer *detachment*: a real `WebAssembly.Memory.grow()` detaches the old
|
|
// buffer (its `byteLength` becomes 0 and `.detached` becomes true), and those are the
|
|
// only signals the generated getters check:
|
|
// - getUint8ArrayMemory0(): refreshes when `byteLength === 0` (detach only)
|
|
// - getDataViewMemory0(): refreshes when `.detached === true`, OR when the buffer
|
|
// identity changed but only `if (.detached === undefined)` — i.e. that identity
|
|
// fallback runs solely on engines lacking `ArrayBuffer.prototype.detached`.
|
|
//
|
|
// wasm2js grows differently: `__wasm_memory_grow` (in reconcile_text_bg.wasm.js)
|
|
// allocates a NEW ArrayBuffer, copies the old heap into it, and reassigns
|
|
// `memory.buffer` WITHOUT ever detaching the old buffer. So the old buffer keeps
|
|
// `byteLength > 0` and `.detached === false`, and on modern engines that DO expose
|
|
// `ArrayBuffer.prototype.detached` (Node 25+, current Hermes) the identity fallback is
|
|
// gated off. Net effect: after a grow the getters keep returning views over the stale
|
|
// pre-grow buffer, silently corrupting any operation large enough to grow the heap.
|
|
// Small inputs never grow, so this escapes naive testing.
|
|
//
|
|
// WHY WE PATCH INSTEAD OF CONFIGURING.
|
|
// This is not fixed or configurable upstream: wasm-bindgen has no wasm2js / asm.js /
|
|
// React Native / "no-WebAssembly" target (every target assumes real WebAssembly
|
|
// detach-on-grow semantics), there is no flag to force buffer-identity comparison, and
|
|
// the getter-generation logic (crates/cli-support/src/js/mod.rs `memview`) is
|
|
// byte-for-byte identical from the pinned 0.2.114 through the latest release and
|
|
// `main`. The non-detaching-grow case is not even a tracked upstream issue. Rewriting
|
|
// the generated glue is therefore the only available fix: the two replacements below
|
|
// make BOTH getters also refresh on a buffer-identity change
|
|
// (`buffer !== wasm.memory.buffer`), which is the one signal wasm2js does give.
|
|
//
|
|
// Each replacement is asserted independently. If a future wasm-bindgen reshapes one
|
|
// getter but not the other, we MUST fail the build rather than ship a half-patched
|
|
// module whose un-patched getter corrupts large inputs. The post-build self-test at
|
|
// the bottom of this file is the backstop that proves the result survives a real grow.
|
|
const bgJsPath = resolve(outDir, 'reconcile_text_bg.js');
|
|
let bgJs = readFileSync(bgJsPath, 'utf8');
|
|
|
|
// (1) Uint8Array getter: append an unconditional buffer-identity check to the
|
|
// `byteLength === 0` detach guard (upstream has no identity check here at all).
|
|
const byteLengthGuard = /(cached\w*Memory0)\.byteLength === 0/g;
|
|
const byteLengthHits = bgJs.match(byteLengthGuard)?.length ?? 0;
|
|
if (byteLengthHits === 0) {
|
|
throw new Error(
|
|
`[build-rn] Could not find the Uint8Array \`byteLength === 0\` growth guard in ` +
|
|
`${bgJsPath} to patch for wasm2js. The wasm-bindgen output shape changed; update ` +
|
|
'this patch (see crates/cli-support/src/js/mod.rs `memview`) — do NOT ship an ' +
|
|
'unpatched getter, it will corrupt large inputs under wasm2js.'
|
|
);
|
|
}
|
|
bgJs = bgJs.replace(
|
|
byteLengthGuard,
|
|
'$1.byteLength === 0 || $1.buffer !== wasm.memory.buffer'
|
|
);
|
|
|
|
// (2) DataView getter: drop the `detached === undefined &&` prefix so the existing
|
|
// buffer-identity check runs on every runtime, not only legacy ones.
|
|
const gatedGuard =
|
|
/(cached\w*Memory0)\.buffer\.detached === undefined && \1\.buffer !== wasm\.memory\.buffer/g;
|
|
const gatedHits = bgJs.match(gatedGuard)?.length ?? 0;
|
|
if (gatedHits === 0) {
|
|
throw new Error(
|
|
`[build-rn] Could not find the DataView \`detached === undefined\`-gated buffer-identity ` +
|
|
`check in ${bgJsPath} to un-gate for wasm2js. The wasm-bindgen output shape changed; ` +
|
|
'update this patch (see crates/cli-support/src/js/mod.rs `memview`) — do NOT ship an ' +
|
|
'unpatched getter, it will corrupt large inputs under wasm2js.'
|
|
);
|
|
}
|
|
bgJs = bgJs.replace(gatedGuard, '$1.buffer !== wasm.memory.buffer');
|
|
|
|
writeFileSync(bgJsPath, bgJs);
|
|
|
|
// Post-MVP features that wasm2js cannot translate must be lowered to MVP first.
|
|
// reference-types stays enabled: it only covers the funcref table here, which
|
|
// wasm2js handles via call_indirect.
|
|
const featureFlags = [
|
|
'--enable-bulk-memory',
|
|
'--enable-sign-ext',
|
|
'--enable-nontrapping-float-to-int',
|
|
'--enable-mutable-globals',
|
|
'--enable-reference-types',
|
|
];
|
|
|
|
console.log('[build-rn] optimising and lowering to MVP with wasm-opt');
|
|
run(wasmOpt, [
|
|
...featureFlags,
|
|
'-O3',
|
|
'--signext-lowering',
|
|
'--llvm-memory-copy-fill-lowering',
|
|
'--llvm-nontrapping-fptoint-lowering',
|
|
bgWasm,
|
|
'-o',
|
|
loweredWasm,
|
|
]);
|
|
|
|
console.log('[build-rn] translating wasm -> JS with wasm2js');
|
|
run(wasm2js, ['--enable-reference-types', loweredWasm, '-o', bgWasmJs]);
|
|
|
|
console.log('[build-rn] wiring the JS translation into reconcile_text.js');
|
|
const entry = readFileSync(entryJs, 'utf8');
|
|
const rewired = entry.replace(
|
|
/from\s+(['"])\.\/reconcile_text_bg\.wasm\1/,
|
|
'from $1./reconcile_text_bg.wasm.js$1'
|
|
);
|
|
if (rewired === entry) {
|
|
throw new Error(
|
|
`Could not find the \`./reconcile_text_bg.wasm\` import in ${entryJs}; ` +
|
|
'the wasm-bindgen bundler output layout may have changed.'
|
|
);
|
|
}
|
|
writeFileSync(entryJs, rewired);
|
|
|
|
// The binary and the intermediate are no longer referenced; remove them so no
|
|
// bundler attempts to instantiate WebAssembly from this directory.
|
|
rmSync(bgWasm, { force: true });
|
|
rmSync(loweredWasm, { force: true });
|
|
|
|
// Mark the directory as ESM (matching the web `pkg/`) so Node and Jest treat
|
|
// these `.js` files as modules. `sideEffects` stays true because importing the
|
|
// entry runs `__wbg_set_wasm(...)`, which must not be tree-shaken away.
|
|
writeFileSync(
|
|
resolve(outDir, 'package.json'),
|
|
JSON.stringify({ type: 'module', sideEffects: true }, null, 2) + '\n'
|
|
);
|
|
|
|
// Backstop: import the freshly generated module and prove it survives a heap grow.
|
|
// The patches above are matched by regex against wasm-bindgen output; a silently
|
|
// mis-applied patch (or a wasm-bindgen change we matched too loosely) would leave a
|
|
// getter reading the stale pre-grow buffer and corrupt large inputs only. Rather than
|
|
// trust the regexes, we force a grow here and assert a byte-exact round-trip, so a
|
|
// broken bundle fails the build instead of reaching a React Native consumer.
|
|
async function selfTest() {
|
|
// Importing the entry runs `__wbg_set_wasm(...)`, initialising the wasm2js module.
|
|
const api = await import(pathToFileURL(entryJs).href);
|
|
// Same module instance (Node caches by resolved path), so this `memory` is the heap
|
|
// the API operates on; its `.buffer` getter reflects the current (post-grow) buffer.
|
|
const { memory } = await import(pathToFileURL(bgWasmJs).href);
|
|
|
|
// ~100 KB of distinct tokens. The diff working set amplifies the input many-fold
|
|
// (a ~50 KB input already forces dozens of grows), so this reliably grows the heap
|
|
// well past wasm2js's ~1 MB initial allocation while staying fast. A tiny parent
|
|
// keeps the edit distance — and therefore the runtime — small.
|
|
const tokens = [];
|
|
for (let i = 0; i < 10000; i++) {
|
|
tokens.push(`token-${i}`);
|
|
}
|
|
const target = tokens.join(' ');
|
|
const parent = 'reconcile self-test';
|
|
|
|
const heapBefore = memory.buffer.byteLength;
|
|
|
|
// Stale post-grow reads surface either as an out-of-bounds throw or as silently
|
|
// wrong bytes, so handle both: a throw here is itself the failure signal.
|
|
let roundTripped;
|
|
try {
|
|
const changed = new api.TextWithCursors(target, []);
|
|
const compact = api
|
|
.diff(parent, changed, 'Word')
|
|
// This build's `undiff` rejects BigInt; normalise exactly as src/core.ts does.
|
|
.map((item) => (typeof item === 'bigint' ? Number(item) : item));
|
|
changed.free();
|
|
roundTripped = api.undiff(parent, compact, 'Word');
|
|
} catch (cause) {
|
|
throw new Error(
|
|
'[build-rn] self-test crashed during a large diff/undiff round-trip (after the heap ' +
|
|
'grew). This is the signature of unpatched wasm2js cached-memory getters reading the ' +
|
|
'stale pre-grow buffer. The growth patch is not taking effect. Refusing to ship this ' +
|
|
'React Native bundle.',
|
|
{ cause }
|
|
);
|
|
}
|
|
|
|
const heapAfter = memory.buffer.byteLength;
|
|
|
|
if (heapAfter <= heapBefore) {
|
|
throw new Error(
|
|
`[build-rn] self-test did not grow the wasm heap (stayed at ${heapBefore} bytes), ` +
|
|
'so it cannot validate the memory-growth patch. Enlarge the self-test input.'
|
|
);
|
|
}
|
|
if (roundTripped !== target) {
|
|
throw new Error(
|
|
'[build-rn] self-test FAILED: diff/undiff round-trip did not match after a heap grow. ' +
|
|
'The patched wasm2js cached-memory getters are returning stale/corrupt data — the ' +
|
|
'growth patch is not taking effect. Refusing to ship this React Native bundle.'
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log('[build-rn] self-testing the patched module (forces a heap grow)');
|
|
await selfTest();
|
|
|
|
console.log('[build-rn] done -> pkg-rn/');
|