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( + ( +
+
+ Vercel +
+
+ '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)); + } +}