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
98 changes: 72 additions & 26 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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); });`,
Copy link
Copy Markdown
Contributor

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:

Suggested change
` WebAssembly.instantiate(mod, imports).then(function(inst) { callback(inst); });`,
__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); });
}
}).catch(function(e) { console.error("[vinext] yoga WASM instantiation failed:", e); });

Non-blocking — take it or leave it.

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

].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));`,
Copy link
Copy Markdown
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.

` 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;
Expand Down
194 changes: 194 additions & 0 deletions tests/og-font-patch.test.ts
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");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor test coverage gap: fakeEdgeEntry always includes both the yoga data URL pattern and the resvg static import. There are no tests for partial matches (e.g., a future version of @vercel/og that changes one pattern but not the other). Consider adding a test that verifies the transform still works when only one pattern is present — e.g., passing code with only the yoga data URL but no import resvg_wasm, or vice versa. This would guard against regressions if the regex replacement for one pattern accidentally interferes with the other.

Non-blocking — the current tests cover the real-world shape of index.edge.js well.


// ── 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"));
});
});
Loading