diff --git a/.changeset/quick-timers-fail.md b/.changeset/quick-timers-fail.md
new file mode 100644
index 00000000..be6af030
--- /dev/null
+++ b/.changeset/quick-timers-fail.md
@@ -0,0 +1,7 @@
+---
+"@opennextjs/cloudflare": patch
+---
+
+fix: @vercel/og failing due to using the node version.
+
+Patches usage of the @vercel/og library to require the edge runtime version, and enables importing of the fallback font.
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index dafd1144..408104e8 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -4,7 +4,6 @@ on:
push:
branches: [main, experimental]
pull_request:
- branches: [main, experimental]
jobs:
checks:
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 4fc920b3..d83daec8 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
- branches: [main]
jobs:
test:
diff --git a/.github/workflows/prereleases.yml b/.github/workflows/prereleases.yml
index a52603a2..a70ea2ea 100644
--- a/.github/workflows/prereleases.yml
+++ b/.github/workflows/prereleases.yml
@@ -4,7 +4,6 @@ on:
push:
branches: [main, experimental]
pull_request:
- branches: [main, experimental]
jobs:
release:
diff --git a/examples/api/app/og/route.tsx b/examples/api/app/og/route.tsx
new file mode 100644
index 00000000..3f48fb0f
--- /dev/null
+++ b/examples/api/app/og/route.tsx
@@ -0,0 +1,65 @@
+import { ImageResponse } from "next/og";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ try {
+ return new ImageResponse(
+ (
+
+
+

+
+
+ 'next/og'
+
+
+ ),
+ {
+ width: 1200,
+ height: 630,
+ }
+ );
+ } catch (e: any) {
+ return new Response("Failed to generate the image", {
+ status: 500,
+ });
+ }
+}
diff --git a/examples/api/e2e/base.spec.ts b/examples/api/e2e/base.spec.ts
index 60024131..9a5c3e9a 100644
--- a/examples/api/e2e/base.spec.ts
+++ b/examples/api/e2e/base.spec.ts
@@ -1,4 +1,16 @@
import { test, expect } from "@playwright/test";
+import type { BinaryLike } from "node:crypto";
+import { createHash } from "node:crypto";
+
+const OG_MD5 = "2f7b724d62d8c7739076da211aa62e7b";
+
+export function validateMd5(data: Buffer, expectedHash: string) {
+ return (
+ createHash("md5")
+ .update(data as BinaryLike)
+ .digest("hex") === expectedHash
+ );
+}
test("the application's noop index page is visible and it allows navigating to the hello-world api route", async ({
page,
@@ -42,3 +54,10 @@ test("returns correct information about the request from a route handler", async
const expectedURL = expect.stringMatching(/https?:\/\/localhost:(?!3000)\d+\/api\/request/);
await expect(res.json()).resolves.toEqual({ nextUrl: expectedURL, url: expectedURL });
});
+
+test("generates an og image successfully", async ({ page }) => {
+ const res = await page.request.get("/og");
+ expect(res.status()).toEqual(200);
+ expect(res.headers()["content-type"]).toEqual("image/png");
+ expect(validateMd5(await res.body(), OG_MD5)).toEqual(true);
+});
diff --git a/packages/cloudflare/src/cli/build/bundle-server.ts b/packages/cloudflare/src/cli/build/bundle-server.ts
index 14e103cf..784b4094 100644
--- a/packages/cloudflare/src/cli/build/bundle-server.ts
+++ b/packages/cloudflare/src/cli/build/bundle-server.ts
@@ -33,7 +33,8 @@ export async function bundleServer(buildOpts: BuildOptions): Promise {
console.log(`\x1b[35m⚙️ Bundling the OpenNext server...\n\x1b[0m`);
patches.patchWranglerDeps(buildOpts);
- patches.updateWebpackChunksFile(buildOpts);
+ await patches.updateWebpackChunksFile(buildOpts);
+ patches.patchVercelOgLibrary(buildOpts);
const outputPath = path.join(outputDir, "server-functions", "default");
const packagePath = getPackagePath(buildOpts);
@@ -176,7 +177,7 @@ async function updateWorkerBundledCode(workerOutputFile: string, buildOpts: Buil
const bundle = parse(Lang.TypeScript, patchedCode).root();
- const edits = patchOptionalDependencies(bundle);
+ const { edits } = patchOptionalDependencies(bundle);
await writeFile(workerOutputFile, bundle.commitEdits(edits));
}
diff --git a/packages/cloudflare/src/cli/build/patches/ast/optional-deps.ts b/packages/cloudflare/src/cli/build/patches/ast/optional-deps.ts
index 9a6f0b8b..9ca7be80 100644
--- a/packages/cloudflare/src/cli/build/patches/ast/optional-deps.ts
+++ b/packages/cloudflare/src/cli/build/patches/ast/optional-deps.ts
@@ -1,6 +1,6 @@
import { type SgNode } from "@ast-grep/napi";
-import { getRuleEdits } from "./util.js";
+import { applyRule } from "./util.js";
/**
* Handle optional dependencies.
@@ -31,5 +31,5 @@ fix: |-
`;
export function patchOptionalDependencies(root: SgNode) {
- return getRuleEdits(optionalDepRule, root);
+ return applyRule(optionalDepRule, root);
}
diff --git a/packages/cloudflare/src/cli/build/patches/ast/util.ts b/packages/cloudflare/src/cli/build/patches/ast/util.ts
index 30392770..404964f7 100644
--- a/packages/cloudflare/src/cli/build/patches/ast/util.ts
+++ b/packages/cloudflare/src/cli/build/patches/ast/util.ts
@@ -1,3 +1,5 @@
+import { readFileSync } from "node:fs";
+
import { type Edit, Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi";
import yaml from "yaml";
@@ -8,7 +10,7 @@ import yaml from "yaml";
export type RuleConfig = NapiConfig & { fix?: string };
/**
- * Returns the `Edit`s for an ast-grep rule in yaml format
+ * Returns the `Edit`s and `Match`es for an ast-grep rule in yaml format
*
* The rule must have a `fix` to rewrite the matched node.
*
@@ -17,9 +19,9 @@ export type RuleConfig = NapiConfig & { fix?: string };
* @param rule The rule. Either a yaml string or an instance of `RuleConfig`
* @param root The root node
* @param once only apply once
- * @returns A list of edits.
+ * @returns A list of edits and a list of matches.
*/
-export function getRuleEdits(rule: string | RuleConfig, root: SgNode, { once = false } = {}) {
+export function applyRule(rule: string | RuleConfig, root: SgNode, { once = false } = {}) {
const ruleConfig: RuleConfig = typeof rule === "string" ? yaml.parse(rule) : rule;
if (ruleConfig.transform) {
throw new Error("transform is not supported");
@@ -50,7 +52,18 @@ export function getRuleEdits(rule: string | RuleConfig, root: SgNode, { once = f
);
});
- return edits;
+ return { edits, matches };
+}
+
+/**
+ * Parse a file and obtain its root.
+ *
+ * @param path The file path
+ * @param lang The language to parse. Defaults to TypeScript.
+ * @returns The root for the file.
+ */
+export function parseFile(path: string, lang = Lang.TypeScript) {
+ return parse(lang, readFileSync(path, { encoding: "utf-8" })).root();
}
/**
@@ -71,6 +84,6 @@ export function patchCode(
{ lang = Lang.TypeScript, once = false } = {}
): string {
const node = parse(lang, code).root();
- const edits = getRuleEdits(rule, node, { once });
+ const { edits } = applyRule(rule, node, { once });
return node.commitEdits(edits);
}
diff --git a/packages/cloudflare/src/cli/build/patches/ast/vercel-og.spec.ts b/packages/cloudflare/src/cli/build/patches/ast/vercel-og.spec.ts
new file mode 100644
index 00000000..8c65fbbd
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/ast/vercel-og.spec.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import { patchCode } from "./util";
+import { vercelOgFallbackFontRule, vercelOgImportRule } from "./vercel-og";
+
+describe("vercelOgImportRule", () => {
+ it("should rewrite a node import to an edge import", () => {
+ const code = `e.exports=import("next/dist/compiled/@vercel/og/index.node.js")`;
+ expect(patchCode(code, vercelOgImportRule)).toMatchInlineSnapshot(
+ `"e.exports=import("next/dist/compiled/@vercel/og/index.edge.js")"`
+ );
+ });
+});
+
+describe("vercelOgFallbackFontRule", () => {
+ it("should replace a fetch call for a font with an import", () => {
+ const code = `var fallbackFont = fetch(new URL("./noto-sans-v27-latin-regular.ttf", import.meta.url)).then((res) => res.arrayBuffer());`;
+ expect(patchCode(code, vercelOgFallbackFontRule)).toMatchInlineSnapshot(`
+ "async function getFallbackFont() {
+ // .bin is used so that a loader does not need to be configured for .ttf files
+ return (await import("./noto-sans-v27-latin-regular.ttf.bin")).default;
+ }
+
+ var fallbackFont = getFallbackFont();"
+ `);
+ });
+});
diff --git a/packages/cloudflare/src/cli/build/patches/ast/vercel-og.ts b/packages/cloudflare/src/cli/build/patches/ast/vercel-og.ts
new file mode 100644
index 00000000..e2bbf20c
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/ast/vercel-og.ts
@@ -0,0 +1,64 @@
+import { SgNode } from "@ast-grep/napi";
+
+import { applyRule } from "./util.js";
+
+export const vercelOgImportRule = `
+rule:
+ pattern: $NODE
+ kind: string
+ regex: "next/dist/compiled/@vercel/og/index\\\\.node\\\\.js"
+inside:
+ kind: arguments
+ inside:
+ kind: call_expression
+ stopBy: end
+ has:
+ field: function
+ regex: "import"
+
+fix: |-
+ "next/dist/compiled/@vercel/og/index.edge.js"
+`;
+
+/**
+ * Patches Node.js imports for the library to be Edge imports.
+ *
+ * @param root Root node.
+ * @returns Results of applying the rule.
+ */
+export function patchVercelOgImport(root: SgNode) {
+ return applyRule(vercelOgImportRule, root);
+}
+
+export const vercelOgFallbackFontRule = `
+rule:
+ kind: variable_declaration
+ all:
+ - has:
+ kind: variable_declarator
+ has:
+ kind: identifier
+ regex: ^fallbackFont$
+ - has:
+ kind: call_expression
+ pattern: fetch(new URL("$PATH", $$$REST))
+ stopBy: end
+
+fix: |-
+ async function getFallbackFont() {
+ // .bin is used so that a loader does not need to be configured for .ttf files
+ return (await import("$PATH.bin")).default;
+ }
+
+ var fallbackFont = getFallbackFont();
+`;
+
+/**
+ * Patches the default font fetching to use a .bin import.
+ *
+ * @param root Root node.
+ * @returns Results of applying the rule.
+ */
+export function patchVercelOgFallbackFont(root: SgNode) {
+ return applyRule(vercelOgFallbackFontRule, root);
+}
diff --git a/packages/cloudflare/src/cli/build/patches/investigated/index.ts b/packages/cloudflare/src/cli/build/patches/investigated/index.ts
index 9d46a4d8..55f1684f 100644
--- a/packages/cloudflare/src/cli/build/patches/investigated/index.ts
+++ b/packages/cloudflare/src/cli/build/patches/investigated/index.ts
@@ -1,4 +1,5 @@
export * from "./copy-package-cli-files.js";
export * from "./patch-cache.js";
export * from "./patch-require.js";
+export * from "./patch-vercel-og-library.js";
export * from "./update-webpack-chunks-file/index.js";
diff --git a/packages/cloudflare/src/cli/build/patches/investigated/patch-vercel-og-library.spec.ts b/packages/cloudflare/src/cli/build/patches/investigated/patch-vercel-og-library.spec.ts
new file mode 100644
index 00000000..51d09584
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/investigated/patch-vercel-og-library.spec.ts
@@ -0,0 +1,71 @@
+import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+
+import { BuildOptions } from "@opennextjs/aws/build/helper.js";
+import mockFs from "mock-fs";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { patchVercelOgLibrary } from "./patch-vercel-og-library";
+
+const nodeModulesVercelOgDir = "node_modules/.pnpm/next@14.2.11/node_modules/next/dist/compiled/@vercel/og";
+const nextServerOgNftPath = "examples/api/.next/server/app/og/route.js.nft.json";
+const openNextFunctionDir = "examples/api/.open-next/server-functions/default/examples/api";
+const openNextOgRoutePath = path.join(openNextFunctionDir, ".next/server/app/og/route.js");
+const openNextVercelOgDir = path.join(openNextFunctionDir, "node_modules/next/dist/compiled/@vercel/og");
+
+const buildOpts = {
+ appBuildOutputPath: "examples/api",
+ monorepoRoot: "",
+ outputDir: "examples/api/.open-next",
+} as BuildOptions;
+
+describe("patchVercelOgLibrary", () => {
+ beforeAll(() => {
+ mockFs();
+
+ mkdirSync(nodeModulesVercelOgDir, { recursive: true });
+ mkdirSync(path.dirname(nextServerOgNftPath), { recursive: true });
+ mkdirSync(path.dirname(openNextOgRoutePath), { recursive: true });
+ mkdirSync(openNextVercelOgDir, { recursive: true });
+
+ writeFileSync(
+ nextServerOgNftPath,
+ JSON.stringify({ version: 1, files: [`../../../../../../${nodeModulesVercelOgDir}/index.node.js`] })
+ );
+ writeFileSync(
+ path.join(nodeModulesVercelOgDir, "index.edge.js"),
+ `var fallbackFont = fetch(new URL("./noto-sans-v27-latin-regular.ttf", import.meta.url)).then((res) => res.arrayBuffer());`
+ );
+ writeFileSync(openNextOgRoutePath, `e.exports=import("next/dist/compiled/@vercel/og/index.node.js")`);
+ writeFileSync(path.join(openNextVercelOgDir, "index.node.js"), "");
+ writeFileSync(path.join(openNextVercelOgDir, "noto-sans-v27-latin-regular.ttf"), "");
+ });
+
+ afterAll(() => mockFs.restore());
+
+ it("should patch the open-next files correctly", () => {
+ patchVercelOgLibrary(buildOpts);
+
+ expect(readdirSync(openNextVercelOgDir)).toMatchInlineSnapshot(`
+ [
+ "index.edge.js",
+ "index.node.js",
+ "noto-sans-v27-latin-regular.ttf.bin",
+ ]
+ `);
+
+ expect(readFileSync(path.join(openNextVercelOgDir, "index.edge.js"), { encoding: "utf-8" }))
+ .toMatchInlineSnapshot(`
+ "async function getFallbackFont() {
+ // .bin is used so that a loader does not need to be configured for .ttf files
+ return (await import("./noto-sans-v27-latin-regular.ttf.bin")).default;
+ }
+
+ var fallbackFont = getFallbackFont();"
+ `);
+
+ expect(readFileSync(openNextOgRoutePath, { encoding: "utf-8" })).toMatchInlineSnapshot(
+ `"e.exports=import("next/dist/compiled/@vercel/og/index.edge.js")"`
+ );
+ });
+});
diff --git a/packages/cloudflare/src/cli/build/patches/investigated/patch-vercel-og-library.ts b/packages/cloudflare/src/cli/build/patches/investigated/patch-vercel-og-library.ts
new file mode 100644
index 00000000..63fdbfc0
--- /dev/null
+++ b/packages/cloudflare/src/cli/build/patches/investigated/patch-vercel-og-library.ts
@@ -0,0 +1,57 @@
+import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import path from "node:path";
+
+import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
+import { getPackagePath } from "@opennextjs/aws/build/helper.js";
+import { globSync } from "glob";
+
+import { parseFile } from "../ast/util.js";
+import { patchVercelOgFallbackFont, patchVercelOgImport } from "../ast/vercel-og.js";
+
+type TraceInfo = { version: number; files: string[] };
+
+/**
+ * Patches the usage of @vercel/og to be compatible with Cloudflare Workers.
+ *
+ * @param buildOpts Build options.
+ */
+export function patchVercelOgLibrary(buildOpts: BuildOptions) {
+ const { appBuildOutputPath, outputDir } = buildOpts;
+
+ const packagePath = path.join(outputDir, "server-functions/default", getPackagePath(buildOpts));
+
+ for (const traceInfoPath of globSync(path.join(appBuildOutputPath, ".next/server/**/*.nft.json"))) {
+ const traceInfo: TraceInfo = JSON.parse(readFileSync(traceInfoPath, { encoding: "utf8" }));
+ const tracedNodePath = traceInfo.files.find((p) => p.endsWith("@vercel/og/index.node.js"));
+
+ if (!tracedNodePath) continue;
+
+ const outputDir = path.join(packagePath, "node_modules/next/dist/compiled/@vercel/og");
+ const outputEdgePath = path.join(outputDir, "index.edge.js");
+
+ // Ensure the edge version is available in the OpenNext node_modules.
+ if (!existsSync(outputEdgePath)) {
+ const tracedEdgePath = path.join(
+ path.dirname(traceInfoPath),
+ tracedNodePath.replace("index.node.js", "index.edge.js")
+ );
+
+ copyFileSync(tracedEdgePath, outputEdgePath);
+
+ // Change font fetches in the library to use imports.
+ const node = parseFile(outputEdgePath);
+ const { edits, matches } = patchVercelOgFallbackFont(node);
+ writeFileSync(outputEdgePath, node.commitEdits(edits));
+
+ const fontFileName = matches[0]!.getMatch("PATH")!.text();
+ renameSync(path.join(outputDir, fontFileName), path.join(outputDir, `${fontFileName}.bin`));
+ }
+
+ // Change node imports for the library to edge imports.
+ const routeFilePath = traceInfoPath.replace(appBuildOutputPath, packagePath).replace(".nft.json", "");
+
+ const node = parseFile(routeFilePath);
+ const { edits } = patchVercelOgImport(node);
+ writeFileSync(routeFilePath, node.commitEdits(edits));
+ }
+}