-
Notifications
You must be signed in to change notification settings - Fork 314
fix: use dynamic WASM imports in og-font-patch for Node.js compat #644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5669b0f
c44121b
3f96f21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -3797,15 +3797,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |||||||
| // | ||||||||
| // The edge build (dist/index.edge.js) uses: | ||||||||
| // - fetch(new URL("./noto-sans...", import.meta.url)) → inlined by og-inline-fetch-assets | ||||||||
| // - import resvg_wasm from "./resvg.wasm?module" → static Vite import, emitted by Rollup | ||||||||
| // - resvg.wasm via dynamic import (og-font-patch rewrites the static import) | ||||||||
| // | ||||||||
| // The node build (dist/index.node.js) uses: | ||||||||
| // - fs.readFileSync(fileURLToPath(new URL("./noto-sans...", import.meta.url))) → inlined | ||||||||
| // - fs.readFileSync(fileURLToPath(new URL("./resvg.wasm", import.meta.url))) → inlined | ||||||||
| // | ||||||||
| // Both builds' font + WASM assets are inlined as base64 by vinext:og-inline-fetch-assets, | ||||||||
| // so no file copy is strictly needed. This plugin is kept as a safety net for any edge-build | ||||||||
| // ?module WASM imports that Rollup/Vite might not emit correctly in the RSC environment. | ||||||||
| // The og-font-patch plugin's resvg fallback uses new URL("./resvg.wasm", import.meta.url) | ||||||||
| // which the bundler should emit as an asset. This plugin is kept as a safety net to ensure | ||||||||
| // the resvg.wasm file exists in the output directory for the Node.js disk-read fallback. | ||||||||
| { | ||||||||
| name: "vinext:og-assets", | ||||||||
| apply: "build", | ||||||||
|
|
@@ -4124,52 +4124,98 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { | |||||||
| }, | ||||||||
| }, | ||||||||
| { | ||||||||
| // @vercel/og patch for workerd (cloudflare-dev + cloudflare-workers) | ||||||||
| // @vercel/og WASM patch — universal (workerd + Node.js) | ||||||||
| // | ||||||||
| // @vercel/og/dist/index.edge.js has one remaining workerd issue after the | ||||||||
| // generic vinext:og-inline-fetch-assets plugin runs (which already handles | ||||||||
| // the font fetch pattern): | ||||||||
| // @vercel/og/dist/index.edge.js uses two WASM modules that need special handling: | ||||||||
| // | ||||||||
| // YOGA WASM: yoga-layout embeds its WASM as a base64 data URL and instantiates | ||||||||
| // it via WebAssembly.instantiate(bytes) at runtime. | ||||||||
| // workerd forbids dynamic WASM compilation from bytes — WASM must be loaded | ||||||||
| // through the module system as a pre-compiled WebAssembly.Module. | ||||||||
| // Fix: extract the yoga WASM bytes at Vite transform time (Node.js), write | ||||||||
| // yoga.wasm to @vercel/og/dist/, import it via `?module` so @cloudflare/vite-plugin | ||||||||
| // can serve it through the module system, and inject h2.instantiateWasm to | ||||||||
| // use the pre-compiled module instead of bytes. | ||||||||
| // 1. YOGA WASM: yoga-layout embeds its WASM as a base64 data URL and instantiates | ||||||||
| // it via WebAssembly.instantiate(bytes). workerd forbids this — WASM must be | ||||||||
| // loaded as a pre-compiled WebAssembly.Module via the module system. | ||||||||
| // | ||||||||
| // 2. RESVG WASM: imported as `import resvg_wasm from "./resvg.wasm?module"` which | ||||||||
| // only works on workerd. Node.js can't import WASM files as ESM modules. | ||||||||
| // | ||||||||
| // Fix: replace all static WASM imports with dynamic imports 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. | ||||||||
| name: "vinext:og-font-patch", | ||||||||
| enforce: "pre" as const, | ||||||||
| transform(code: string, id: string) { | ||||||||
| if (!id.includes("@vercel/og") || !id.includes("index.edge.js")) return null; | ||||||||
| let result = code; | ||||||||
|
|
||||||||
| // ── Extract yoga WASM and import via ?module ────────────────────────────────── | ||||||||
| // ── Yoga WASM: dynamic import + inline base64 fallback ────────────────────── | ||||||||
| // yoga-layout's emscripten bundle sets H to a data URL containing the yoga WASM, | ||||||||
| // then later calls WebAssembly.instantiate(bytes, imports), which workerd rejects. | ||||||||
| // Emscripten supports a custom h2.instantiateWasm(imports, callback) escape hatch | ||||||||
| // that we inject to use a pre-compiled WebAssembly.Module loaded via ?module. | ||||||||
| // Emscripten supports a custom h2.instantiateWasm(imports, callback) escape hatch. | ||||||||
| // | ||||||||
| // Strategy: try dynamic import("./yoga.wasm?module") for workerd (pre-compiled | ||||||||
| // module), fall back to compiling from inline base64 bytes for Node.js. | ||||||||
| // Yoga WASM is ~70KB so inlining the base64 (~95KB) is acceptable. | ||||||||
| const YOGA_DATA_URL_RE = /H = "data:application\/octet-stream;base64,([A-Za-z0-9+/]+=*)";/; | ||||||||
| const yogaMatch = YOGA_DATA_URL_RE.exec(result); | ||||||||
| if (yogaMatch) { | ||||||||
| const yogaBase64 = yogaMatch[1]; | ||||||||
| const distDir = path.dirname(id); | ||||||||
| const yogaWasmPath = path.join(distDir, "yoga.wasm"); | ||||||||
| // Write yoga.wasm to disk idempotently at transform time (Node.js side) | ||||||||
| // so the ?module dynamic import can resolve it on workerd builds. | ||||||||
| if (!fs.existsSync(yogaWasmPath)) { | ||||||||
| fs.writeFileSync(yogaWasmPath, Buffer.from(yogaBase64, "base64")); | ||||||||
| } | ||||||||
| // Disable the data-URL branch so emscripten doesn't try to instantiate from bytes | ||||||||
| result = result.replace(yogaMatch[0], `H = "";`); | ||||||||
| // Patch the loadYoga call site to inject instantiateWasm using the ?module import | ||||||||
| // Patch the loadYoga call site to inject instantiateWasm with universal handler. | ||||||||
| // WebAssembly.instantiate(Module, imports) → Instance (workerd path) | ||||||||
| // WebAssembly.instantiate(bytes, imports) → { module, instance } (Node.js path) | ||||||||
| const YOGA_CALL = `yoga_wasm_base64_esm_default()`; | ||||||||
| const YOGA_CALL_PATCHED = | ||||||||
| `yoga_wasm_base64_esm_default({ instantiateWasm: function(imports, callback) {` + | ||||||||
| ` WebAssembly.instantiate(yoga_wasm_module, imports).then(function(inst) { callback(inst); });` + | ||||||||
| ` return {}; } })`; | ||||||||
| const YOGA_CALL_PATCHED = [ | ||||||||
| `yoga_wasm_base64_esm_default({ instantiateWasm: function(imports, callback) {`, | ||||||||
| ` __vi_yoga_mod.then(function(mod) {`, | ||||||||
| ` if (mod) {`, | ||||||||
| ` WebAssembly.instantiate(mod, imports).then(function(inst) { callback(inst); });`, | ||||||||
| ` } else {`, | ||||||||
| ` var b = Buffer.from(__vi_yoga_b64, "base64");`, | ||||||||
| ` WebAssembly.instantiate(b, imports).then(function(r) { callback(r.instance); });`, | ||||||||
| ` }`, | ||||||||
| ` });`, | ||||||||
| ` return {};`, | ||||||||
| `} })`, | ||||||||
| ].join("\n"); | ||||||||
| result = result.replace(YOGA_CALL, YOGA_CALL_PATCHED); | ||||||||
| // Prepend the yoga wasm ?module import so @cloudflare/vite-plugin handles it | ||||||||
| result = `import yoga_wasm_module from "./yoga.wasm?module";\n` + result; | ||||||||
| // 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; });`, | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential bundler interaction: Vite/Rolldown will see On workerd builds this is fine because Have you verified that a
|
||||||||
| ].join("\n"); | ||||||||
| result = yogaPreamble + "\n" + result; | ||||||||
| } | ||||||||
|
|
||||||||
| // ── Resvg WASM: dynamic import + disk fallback ────────────────────────────── | ||||||||
| // The edge entry has `import resvg_wasm from "./resvg.wasm?module"` which is a | ||||||||
| // static ESM import that only works on workerd. Node.js fails because the WASM | ||||||||
| // binary's emscripten imports (module "a") can't be resolved as npm packages. | ||||||||
| // | ||||||||
| // Strategy: replace the static import with a dynamic import for workerd, falling | ||||||||
| // back to reading the .wasm file from disk + WebAssembly.compile for Node.js. | ||||||||
| // Resvg WASM is ~1.3MB so we read from disk instead of inlining base64. | ||||||||
| const RESVG_STATIC_IMPORT_RE = | ||||||||
| /import\s+resvg_wasm\s+from\s+["']\.\/resvg\.wasm\?module["']\s*;?/; | ||||||||
| const resvgMatch = RESVG_STATIC_IMPORT_RE.exec(result); | ||||||||
| if (resvgMatch) { | ||||||||
| // Note: new URL("./resvg.wasm", import.meta.url) MUST be inside the catch handler, | ||||||||
| // not at the top level. In workerd, import.meta.url is "worker" (not a valid URL | ||||||||
| // base), so new URL(..., "worker") throws TypeError at module load time. | ||||||||
| // The catch block only runs on Node.js where import.meta.url is a file:// URL. | ||||||||
| 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));`, | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using
Suggested change
Non-blocking and still uses the |
||||||||
| ` return mods[0].promises.readFile(p).then(function(buf) { return WebAssembly.compile(buf); });`, | ||||||||
| ` });`, | ||||||||
| `});`, | ||||||||
| ].join("\n"); | ||||||||
| result = result.replace(resvgMatch[0], resvgLoader); | ||||||||
| } | ||||||||
|
|
||||||||
| if (result === code) return null; | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| import { describe, it, expect, beforeAll, afterAll } from "vite-plus/test"; | ||
| import vinext from "../packages/vinext/src/index.js"; | ||
| import type { Plugin } from "vite-plus"; | ||
| import fs from "node:fs"; | ||
| import fsp from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| // ── Helpers ─────────────────────────────────────────────────── | ||
|
|
||
| function unwrapHook(hook: any): Function { | ||
| return typeof hook === "function" ? hook : hook?.handler; | ||
| } | ||
|
|
||
| function createOgFontPatchPlugin(): Plugin { | ||
| const plugins = vinext() as Plugin[]; | ||
| const plugin = plugins.find((p) => p.name === "vinext:og-font-patch"); | ||
| if (!plugin) throw new Error("vinext:og-font-patch plugin not found"); | ||
| return plugin; | ||
| } | ||
|
|
||
| // ── Fixture data ────────────────────────────────────────────── | ||
|
|
||
| const FAKE_YOGA_B64 = Buffer.from("fake-yoga-wasm-bytes").toString("base64"); | ||
|
|
||
| /** Minimal simulation of @vercel/og/dist/index.edge.js containing both WASM patterns */ | ||
| function fakeEdgeEntry(yogaBase64: string): string { | ||
| return [ | ||
| `import resvg_wasm from "./resvg.wasm?module";`, | ||
| ``, | ||
| `var h2 = {};`, | ||
| `H = "data:application/octet-stream;base64,${yogaBase64}";`, | ||
| ``, | ||
| `var yoga_wasm_base64_esm_default = loadYoga;`, | ||
| ``, | ||
| `async function loadYoga2() {`, | ||
| ` return wrapAssembly(await yoga_wasm_base64_esm_default());`, | ||
| `}`, | ||
| ``, | ||
| `var initializedResvg = initWasm(resvg_wasm);`, | ||
| ].join("\n"); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor test coverage gap: Non-blocking — the current tests cover the real-world shape of |
||
|
|
||
| // ── Test fixture setup ──────────────────────────────────────── | ||
|
|
||
| let tmpDir: string; | ||
| let fakeOgDistDir: string; | ||
|
|
||
| beforeAll(async () => { | ||
| tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "og-font-patch-test-")); | ||
| fakeOgDistDir = path.join(tmpDir, "node_modules/@vercel/og/dist"); | ||
| await fsp.mkdir(fakeOgDistDir, { recursive: true }); | ||
| await fsp.writeFile(path.join(fakeOgDistDir, "resvg.wasm"), Buffer.from("fake-resvg-wasm")); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await fsp.rm(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| // ── Tests ───────────────────────────────────────────────────── | ||
|
|
||
| describe("vinext:og-font-patch plugin", () => { | ||
| it("exists in the plugin array", () => { | ||
| const plugin = createOgFontPatchPlugin(); | ||
| expect(plugin.name).toBe("vinext:og-font-patch"); | ||
| expect(plugin.enforce).toBe("pre"); | ||
| }); | ||
|
|
||
| it("returns null for non-@vercel/og modules", () => { | ||
| const plugin = createOgFontPatchPlugin(); | ||
| const transform = unwrapHook(plugin.transform); | ||
| expect(transform.call(plugin, "const x = 1;", "/app/page.tsx")).toBeNull(); | ||
| }); | ||
|
|
||
| it("returns null for @vercel/og/dist/index.node.js", () => { | ||
| const plugin = createOgFontPatchPlugin(); | ||
| const transform = unwrapHook(plugin.transform); | ||
| const code = `const x = 1;`; | ||
| expect(transform.call(plugin, code, "/node_modules/@vercel/og/dist/index.node.js")).toBeNull(); | ||
| }); | ||
|
|
||
| // ── Transform output assertions ──────────────────────────── | ||
| // All tests below assert on the same transform output. Run it once. | ||
|
|
||
| describe("edge entry transform", () => { | ||
| let code: string; | ||
|
|
||
| beforeAll(() => { | ||
| const plugin = createOgFontPatchPlugin(); | ||
| const transform = unwrapHook(plugin.transform); | ||
| const result = transform.call( | ||
| plugin, | ||
| fakeEdgeEntry(FAKE_YOGA_B64), | ||
| path.join(fakeOgDistDir, "index.edge.js"), | ||
| ); | ||
| if (!result) throw new Error("Expected transform to produce output, got null"); | ||
| code = result.code; | ||
| }); | ||
|
|
||
| // ── Yoga WASM ──────────────────────────────────────────── | ||
|
|
||
| describe("yoga WASM", () => { | ||
| it("does NOT produce a static import of yoga.wasm?module", () => { | ||
| expect(code).not.toMatch(/^import\s+\w+\s+from\s+["'].*yoga\.wasm/m); | ||
| }); | ||
|
|
||
| it("uses dynamic import with catch fallback", () => { | ||
| expect(code).toContain('import("./yoga.wasm?module")'); | ||
| expect(code).toContain(".catch("); | ||
| }); | ||
|
|
||
| it("includes inline base64 bytes as Node.js fallback", () => { | ||
| expect(code).toContain(FAKE_YOGA_B64); | ||
| expect(code).toContain("WebAssembly.instantiate"); | ||
| }); | ||
|
|
||
| it("clears the emscripten data URL", () => { | ||
| expect(code).not.toContain("data:application/octet-stream;base64,"); | ||
| expect(code).toContain('H = "";'); | ||
| }); | ||
|
|
||
| it("patches instantiateWasm with dual-path handler (module vs bytes)", () => { | ||
| expect(code).toContain("instantiateWasm"); | ||
| // workerd path: instantiate from pre-compiled module → callback(inst) | ||
| expect(code).toMatch(/WebAssembly\.instantiate\(mod,\s*imports\)/); | ||
| // Node.js path: instantiate from bytes → callback(r.instance) | ||
| expect(code).toMatch(/WebAssembly\.instantiate\(b,\s*imports\)/); | ||
| }); | ||
|
|
||
| it("uses Buffer.from directly (no atob — fallback only runs on Node.js)", () => { | ||
| // The catch path (mod === null) only executes on Node.js where Buffer is | ||
| // always available. No need for an atob/Uint8Array browser fallback. | ||
| expect(code).toContain("Buffer.from(__vi_yoga_b64"); | ||
| expect(code).not.toContain("atob("); | ||
| }); | ||
| }); | ||
|
|
||
| // ── Resvg WASM ─────────────────────────────────────────── | ||
|
|
||
| describe("resvg WASM", () => { | ||
| it("does NOT produce a static import of resvg.wasm?module", () => { | ||
| expect(code).not.toMatch(/^import\s+\w+\s+from\s+["'].*resvg\.wasm/m); | ||
| }); | ||
|
|
||
| it("uses dynamic import with catch fallback", () => { | ||
| expect(code).toContain('import("./resvg.wasm?module")'); | ||
| expect(code).toMatch(/resvg.*\.catch\(/s); | ||
| }); | ||
|
|
||
| it("uses new URL() inside catch handler, not at top level (workerd compat)", () => { | ||
| // In workerd, import.meta.url is "worker" (not a valid URL base), so | ||
| // top-level new URL() would throw TypeError at module load time. | ||
| expect(code).toContain('new URL("./resvg.wasm"'); | ||
| expect(code).not.toMatch(/^var\s+\w+\s*=\s*new URL\("\.\/resvg\.wasm"/m); | ||
| }); | ||
|
|
||
| it("reads resvg.wasm asynchronously via fs.promises", () => { | ||
| expect(code).toContain("node:fs"); | ||
| expect(code).toContain("promises.readFile"); | ||
| expect(code).toContain("WebAssembly.compile"); | ||
| }); | ||
|
|
||
| it("preserves resvg_wasm variable name for downstream usage", () => { | ||
| expect(code).toContain("initWasm(resvg_wasm)"); | ||
| }); | ||
| }); | ||
|
|
||
| // ── Critical invariant ─────────────────────────────────── | ||
|
|
||
| it("output contains zero static WASM module imports", () => { | ||
| const staticWasmImports = code.match( | ||
| /^import\s+\w+\s+from\s+["'][^"']*\.wasm[^"']*["']\s*;?$/gm, | ||
| ); | ||
| expect(staticWasmImports).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| // ── Side effect: writes yoga.wasm to disk ────────────────── | ||
| // Separate describe because it needs its own directory to avoid | ||
| // conflicting with the shared transform above. | ||
|
|
||
| it("writes yoga.wasm to disk at transform time", () => { | ||
| const writeDistDir = path.join(tmpDir, "write-test/node_modules/@vercel/og/dist"); | ||
| fs.mkdirSync(writeDistDir, { recursive: true }); | ||
|
|
||
| const plugin = createOgFontPatchPlugin(); | ||
| const transform = unwrapHook(plugin.transform); | ||
| transform.call(plugin, fakeEdgeEntry(FAKE_YOGA_B64), path.join(writeDistDir, "index.edge.js")); | ||
|
|
||
| const yogaPath = path.join(writeDistDir, "yoga.wasm"); | ||
| expect(fs.existsSync(yogaPath)).toBe(true); | ||
| expect(fs.readFileSync(yogaPath)).toEqual(Buffer.from(FAKE_YOGA_B64, "base64")); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: the inner
WebAssembly.instantiate(...).then(...)calls on lines 4176 and 4179 don't have.catch()handlers. If instantiation fails (corrupt WASM, mismatched imports), the rejection propagates to__vi_yoga_mod.then()but isn't caught there either — it becomes an unhandled rejection.In practice this is fine: if WASM instantiation fails, the app is broken regardless, and the unhandled rejection will surface in logs. But if you want cleaner error reporting, you could add a
.catch()on__vi_yoga_mod.then(...)that logs a descriptive error:Non-blocking — take it or leave it.