fix: use dynamic WASM imports in og-font-patch for Node.js compat#644
fix: use dynamic WASM imports in og-font-patch for Node.js compat#644NathanDrake2406 wants to merge 3 commits intocloudflare:mainfrom
Conversation
Replace static `import X from "./X.wasm?module"` (workerd-only) with dynamic `import()` + catch fallback so the same build output runs on both Cloudflare Workers and Node.js (`vinext start`). - Yoga WASM (~70KB): dynamic import with inline base64 fallback - Resvg WASM (~1.3MB): dynamic import with disk-read fallback via new URL() + readFileSync + WebAssembly.compile Fixes cloudflare#642
commit: |
In workerd, import.meta.url is "worker" (not a valid URL base), so
new URL("./resvg.wasm", import.meta.url) at the top level throws
TypeError at module load time. Move it inside the .catch() handler
which only runs on Node.js where import.meta.url is a file:// URL.
|
/bigbonk review |
There was a problem hiding this comment.
Review: og-font-patch universal WASM loading
The core approach is sound — replacing static import ... from "./foo.wasm?module" (workerd-only) with dynamic import() + .catch() fallback produces a single artifact that works on both runtimes. The key invariants check out:
resvg_wasmsync→Promise change is safe:initWasmdoesawait module_or_pathinternally (line 20279 of index.edge.js), so it correctly handles both aWebAssembly.Moduleand aPromise<WebAssembly.Module>.__vi_yoga_modPromise timing is fine: The yoga WASM is consumed insideloadYoga2()(an async function called lazily), so the top-level dynamic import has time to settle before first use.new URL()inside.catch()is correct: Avoids the workerdimport.meta.url = "worker"pitfall documented in the comment.
The tests are well-structured and cover the critical invariant (zero static WASM imports in output). Two items worth addressing below.
| // Prepend dynamic import with base64 fallback (no static import — Node.js safe) | ||
| const yogaPreamble = [ | ||
| `var __vi_yoga_b64 = ${JSON.stringify(yogaBase64)};`, | ||
| `var __vi_yoga_mod = import("./yoga.wasm?module").then(function(m) { return m.default; }).catch(function() { return null; });`, |
There was a problem hiding this comment.
Potential bundler interaction: Vite/Rolldown will see import("./yoga.wasm?module") and import("./resvg.wasm?module") in the transformed code during production builds. Depending on the build environment, the bundler may try to resolve and bundle these dynamic imports (or emit warnings about unresolvable imports).
On workerd builds this is fine because @cloudflare/vite-plugin handles ?module. But on a pure Node.js build (if someone runs vite build without the cloudflare plugin), the bundler might warn or fail trying to resolve ./yoga.wasm?module.
Have you verified that a vinext build followed by vinext start works end-to-end? The unit tests validate the transform output strings but don't exercise the actual build pipeline. Worth confirming that:
- The bundler doesn't choke on the dynamic
?moduleimports during build resvg.wasmends up in the output directory (thevinext:og-assetssafety net plugin should handle this)vinext startactually renders an OG image without crashing
packages/vinext/src/index.ts
Outdated
| ` if (mod) {`, | ||
| ` WebAssembly.instantiate(mod, imports).then(function(inst) { callback(inst); });`, | ||
| ` } else {`, | ||
| ` var b = typeof Buffer !== "undefined" ? Buffer.from(__vi_yoga_b64, "base64")`, |
There was a problem hiding this comment.
Minor: the typeof Buffer !== "undefined" guard is a nice touch for browser/edge environments, but in the Node.js fallback path (mod is null), Buffer is always available. The guard is technically redundant here since mod === null means the ?module import failed, which only happens on Node.js. Not a blocker — just noting that the atob branch is unreachable in the current architecture (it would only matter if someone ran this in a browser, which wouldn't have the ?module import either).
| const resvgLoader = [ | ||
| `var resvg_wasm = import("./resvg.wasm?module").then(function(m) { return m.default; }).catch(function() {`, | ||
| ` return Promise.all([import("node:fs"), import("node:url")]).then(function(mods) {`, | ||
| ` var p = mods[1].fileURLToPath(new URL("./resvg.wasm", import.meta.url));`, |
There was a problem hiding this comment.
Using readFileSync inside a Promise chain is a bit unusual — it blocks the event loop during the synchronous read. Since this runs at module evaluation time (top-level) and resvg.wasm is ~1.3MB, this is a ~1-2ms blocking read that happens once at startup. Acceptable for a startup-time initialization, but fs.promises.readFile would be more idiomatic inside an async chain:
| ` var p = mods[1].fileURLToPath(new URL("./resvg.wasm", import.meta.url));`, | |
| var p = mods[1].fileURLToPath(new URL("./resvg.wasm", import.meta.url)); | |
| return mods[0].promises.readFile(p).then(function(buf) { return WebAssembly.compile(buf); }); |
Non-blocking and still uses the node:fs module that's already imported. Up to you — the sync version works fine for a one-time startup cost.
tests/og-font-patch.test.ts
Outdated
| ): { code: string; map: null } { | ||
| const plugin = createOgFontPatchPlugin(); | ||
| const transform = unwrapHook(plugin.transform); | ||
| const result = transform.call(plugin, fakeEdgeEntry(yogaBase64), idPath); |
There was a problem hiding this comment.
The transform hook is called with plugin as this context via .call(plugin, ...). In Vite, the this context for transform hooks is the Rollup plugin context, not the plugin object itself. This works here because the og-font-patch transform doesn't use this at all, but it's worth noting — if someone later adds this.warn() or this.emitFile() to the transform, these tests would break.
No change needed now, just a heads-up for future maintenance.
tests/og-font-patch.test.ts
Outdated
|
|
||
| describe("yoga WASM transform", () => { | ||
| it("does NOT produce a static import of yoga.wasm?module", () => { | ||
| const { code } = transformEdgeEntry(path.join(fakeOgDistDir, "index.edge.js")); |
There was a problem hiding this comment.
Several tests call transformEdgeEntry(path.join(fakeOgDistDir, "index.edge.js")) repeatedly with the same arguments. Consider running the transform once in a beforeAll or let at the describe level and reusing the result. This would:
- Reduce ~8 redundant vinext() plugin instantiations + transform calls
- Make it clearer that all assertions are about the same output
Not a big deal for 14 fast tests, but it would clean up the test structure.
|
Review posted successfully. Here's a summary of my findings: Overall assessment: The approach is correct and well-reasoned. The core insight — using dynamic Key validations:
Inline comments posted:
|
…edup tests - Remove unreachable atob/Uint8Array fallback in yoga Node.js path (mod === null only on Node.js where Buffer always exists) - Use fs.promises.readFile instead of readFileSync for resvg disk fallback (non-blocking, idiomatic in async chain) - Deduplicate test transforms with shared beforeAll - Remove unnecessary IIFE in test setup - Add assertions for Buffer.from and async readFile
Summary
Fixes #642
vinext startcrashes withERR_MODULE_NOT_FOUND: Cannot find package 'a'when the app usesImageResponsefromnext/og. The root cause: thevinext:og-font-patchplugin injects staticimport X from "./X.wasm?module"statements — a workerd-only convention that Node.js can't handle. Node tries to resolve the WASM binary's emscripten import section (module"a") as an npm package.Fix: Replace all static WASM imports with dynamic
import()calls that try the?modulepath (for workerd) and fall back to compiling from bytes (for Node.js). This produces a single build output that runs on both runtimes.new URL()disk-read fallbackHow it works
import("./yoga.wasm?module")succeeds → returns a pre-compiledWebAssembly.Module→ used directlyvinext start): The dynamic import fails →.catch()fires → yoga compiles from inline base64 bytes, resvg reads.wasmfrom disk viareadFileSync+WebAssembly.compileThe key insight is that
import()is lazy — failures are catchable at runtime, unlike staticimportwhich crashes at module parse time.Test plan
tests/og-font-patch.test.ts