Skip to content

fix: use dynamic WASM imports in og-font-patch for Node.js compat#644

Open
NathanDrake2406 wants to merge 3 commits intocloudflare:mainfrom
NathanDrake2406:fix/og-wasm-node-compat
Open

fix: use dynamic WASM imports in og-font-patch for Node.js compat#644
NathanDrake2406 wants to merge 3 commits intocloudflare:mainfrom
NathanDrake2406:fix/og-wasm-node-compat

Conversation

@NathanDrake2406
Copy link
Contributor

Summary

Fixes #642

vinext start crashes with ERR_MODULE_NOT_FOUND: Cannot find package 'a' when the app uses ImageResponse from next/og. The root cause: the vinext:og-font-patch plugin injects static import 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 ?module path (for workerd) and fall back to compiling from bytes (for Node.js). This produces a single build output that runs on both runtimes.

WASM module Size Strategy
Yoga ~70KB Dynamic import + inline base64 fallback
Resvg ~1.3MB Dynamic import + new URL() disk-read fallback

How it works

  • On workerd (Cloudflare Workers): import("./yoga.wasm?module") succeeds → returns a pre-compiled WebAssembly.Module → used directly
  • On Node.js (vinext start): The dynamic import fails → .catch() fires → yoga compiles from inline base64 bytes, resvg reads .wasm from disk via readFileSync + WebAssembly.compile

The key insight is that import() is lazy — failures are catchable at runtime, unlike static import which crashes at module parse time.

Test plan

  • 14 new unit tests in tests/og-font-patch.test.ts
  • Verify no static WASM imports in transform output (critical invariant)
  • Verify yoga dynamic import + base64 fallback
  • Verify resvg dynamic import + disk-read fallback
  • Verify yoga.wasm still written to disk for workerd builds
  • Existing OG inline, shims, and app-router tests pass (1035 tests)

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
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@644

commit: 3f96f21

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.
@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_wasm sync→Promise change is safe: initWasm does await module_or_path internally (line 20279 of index.edge.js), so it correctly handles both a WebAssembly.Module and a Promise<WebAssembly.Module>.
  • __vi_yoga_mod Promise timing is fine: The yoga WASM is consumed inside loadYoga2() (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 workerd import.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; });`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The bundler doesn't choke on the dynamic ?module imports during build
  2. resvg.wasm ends up in the output directory (the vinext:og-assets safety net plugin should handle this)
  3. vinext start actually renders an OG image without crashing

` if (mod) {`,
` WebAssembly.instantiate(mod, imports).then(function(inst) { callback(inst); });`,
` } else {`,
` var b = typeof Buffer !== "undefined" ? Buffer.from(__vi_yoga_b64, "base64")`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
` 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.

): { code: string; map: null } {
const plugin = createOgFontPatchPlugin();
const transform = unwrapHook(plugin.transform);
const result = transform.call(plugin, fakeEdgeEntry(yogaBase64), idPath);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


describe("yoga WASM transform", () => {
it("does NOT produce a static import of yoga.wasm?module", () => {
const { code } = transformEdgeEntry(path.join(fakeOgDistDir, "index.edge.js"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Reduce ~8 redundant vinext() plugin instantiations + transform calls
  2. 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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 22, 2026

Review posted successfully. Here's a summary of my findings:

Overall assessment: The approach is correct and well-reasoned. The core insight — using dynamic import() with .catch() fallback instead of static import — is the right way to produce a single build that works on both workerd and Node.js.

Key validations:

  • The resvg_wasm sync-to-Promise change is safe because initWasm() internally awaits its argument
  • The yoga WASM Promise timing works because the yoga module is consumed inside an async function called lazily, not at module evaluation
  • The new URL() placement inside .catch() correctly avoids the workerd import.meta.url = "worker" pitfall

Inline comments posted:

  1. Bundler interaction concern — The unit tests validate the transform strings but don't exercise the actual build+start pipeline. Worth confirming end-to-end that the bundler handles dynamic ?module imports and vinext start works.
  2. Unreachable atob branch — The typeof Buffer guard in the yoga fallback is technically unreachable since mod === null only on Node.js where Buffer always exists.
  3. readFileSync in async chain — Suggested using fs.promises.readFile instead of readFileSync inside the Promise chain for the resvg fallback (non-blocking, more idiomatic).
  4. Test this context — Noted that .call(plugin, ...) doesn't match Vite's actual Rollup plugin context, which is fine now but could break if this.warn() etc. are added later.
  5. Test deduplication — Multiple tests recreate the same transform output; could consolidate to a single beforeAll transform.

github run

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

vinext start fails with ERR_MODULE_NOT_FOUND for Yoga WASM (next/og)

2 participants