Skip to content

Commit 4ec334a

Browse files
james-elicxvicb
authored andcommittedJan 27, 2025·
fix: patch @vercel/og usage to use the edge runtime version (#283)
1 parent 15ce996 commit 4ec334a

File tree

14 files changed

+334
-12
lines changed

14 files changed

+334
-12
lines changed
 

Diff for: ‎.changeset/quick-timers-fail.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
fix: @vercel/og failing due to using the node version.
6+
7+
Patches usage of the @vercel/og library to require the edge runtime version, and enables importing of the fallback font.

Diff for: ‎.github/workflows/checks.yml

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ on:
44
push:
55
branches: [main, experimental]
66
pull_request:
7-
branches: [main, experimental]
87

98
jobs:
109
checks:

Diff for: ‎.github/workflows/playwright.yml

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ on:
44
push:
55
branches: [main]
66
pull_request:
7-
branches: [main]
87

98
jobs:
109
test:

Diff for: ‎.github/workflows/prereleases.yml

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ on:
44
push:
55
branches: [main, experimental]
66
pull_request:
7-
branches: [main, experimental]
87

98
jobs:
109
release:

Diff for: ‎examples/api/app/og/route.tsx

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { ImageResponse } from "next/og";
2+
3+
export const dynamic = "force-dynamic";
4+
5+
export async function GET() {
6+
try {
7+
return new ImageResponse(
8+
(
9+
<div
10+
style={{
11+
backgroundColor: "black",
12+
backgroundSize: "150px 150px",
13+
height: "100%",
14+
width: "100%",
15+
display: "flex",
16+
textAlign: "center",
17+
alignItems: "center",
18+
justifyContent: "center",
19+
flexDirection: "column",
20+
flexWrap: "nowrap",
21+
}}
22+
>
23+
<div
24+
style={{
25+
display: "flex",
26+
alignItems: "center",
27+
justifyContent: "center",
28+
justifyItems: "center",
29+
}}
30+
>
31+
<img
32+
alt="Vercel"
33+
height={200}
34+
src="data:image/svg+xml,%3Csvg width='116' height='100' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M57.5 0L115 100H0L57.5 0z' /%3E%3C/svg%3E"
35+
style={{ margin: "0 30px" }}
36+
width={232}
37+
/>
38+
</div>
39+
<div
40+
style={{
41+
fontSize: 60,
42+
fontStyle: "normal",
43+
letterSpacing: "-0.025em",
44+
color: "white",
45+
marginTop: 30,
46+
padding: "0 120px",
47+
lineHeight: 1.4,
48+
whiteSpace: "pre-wrap",
49+
}}
50+
>
51+
'next/og'
52+
</div>
53+
</div>
54+
),
55+
{
56+
width: 1200,
57+
height: 630,
58+
}
59+
);
60+
} catch (e: any) {
61+
return new Response("Failed to generate the image", {
62+
status: 500,
63+
});
64+
}
65+
}

Diff for: ‎examples/api/e2e/base.spec.ts

+19
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
import { test, expect } from "@playwright/test";
2+
import type { BinaryLike } from "node:crypto";
3+
import { createHash } from "node:crypto";
4+
5+
const OG_MD5 = "2f7b724d62d8c7739076da211aa62e7b";
6+
7+
export function validateMd5(data: Buffer, expectedHash: string) {
8+
return (
9+
createHash("md5")
10+
.update(data as BinaryLike)
11+
.digest("hex") === expectedHash
12+
);
13+
}
214

315
test("the application's noop index page is visible and it allows navigating to the hello-world api route", async ({
416
page,
@@ -42,3 +54,10 @@ test("returns correct information about the request from a route handler", async
4254
const expectedURL = expect.stringMatching(/https?:\/\/localhost:(?!3000)\d+\/api\/request/);
4355
await expect(res.json()).resolves.toEqual({ nextUrl: expectedURL, url: expectedURL });
4456
});
57+
58+
test("generates an og image successfully", async ({ page }) => {
59+
const res = await page.request.get("/og");
60+
expect(res.status()).toEqual(200);
61+
expect(res.headers()["content-type"]).toEqual("image/png");
62+
expect(validateMd5(await res.body(), OG_MD5)).toEqual(true);
63+
});

Diff for: ‎packages/cloudflare/src/cli/build/bundle-server.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export async function bundleServer(buildOpts: BuildOptions): Promise<void> {
3333
console.log(`\x1b[35m⚙️ Bundling the OpenNext server...\n\x1b[0m`);
3434

3535
patches.patchWranglerDeps(buildOpts);
36-
patches.updateWebpackChunksFile(buildOpts);
36+
await patches.updateWebpackChunksFile(buildOpts);
37+
patches.patchVercelOgLibrary(buildOpts);
3738

3839
const outputPath = path.join(outputDir, "server-functions", "default");
3940
const packagePath = getPackagePath(buildOpts);
@@ -176,7 +177,7 @@ async function updateWorkerBundledCode(workerOutputFile: string, buildOpts: Buil
176177

177178
const bundle = parse(Lang.TypeScript, patchedCode).root();
178179

179-
const edits = patchOptionalDependencies(bundle);
180+
const { edits } = patchOptionalDependencies(bundle);
180181

181182
await writeFile(workerOutputFile, bundle.commitEdits(edits));
182183
}

Diff for: ‎packages/cloudflare/src/cli/build/patches/ast/optional-deps.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type SgNode } from "@ast-grep/napi";
22

3-
import { getRuleEdits } from "./util.js";
3+
import { applyRule } from "./util.js";
44

55
/**
66
* Handle optional dependencies.
@@ -31,5 +31,5 @@ fix: |-
3131
`;
3232

3333
export function patchOptionalDependencies(root: SgNode) {
34-
return getRuleEdits(optionalDepRule, root);
34+
return applyRule(optionalDepRule, root);
3535
}

Diff for: ‎packages/cloudflare/src/cli/build/patches/ast/util.ts

+18-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { readFileSync } from "node:fs";
2+
13
import { type Edit, Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi";
24
import yaml from "yaml";
35

@@ -8,7 +10,7 @@ import yaml from "yaml";
810
export type RuleConfig = NapiConfig & { fix?: string };
911

1012
/**
11-
* Returns the `Edit`s for an ast-grep rule in yaml format
13+
* Returns the `Edit`s and `Match`es for an ast-grep rule in yaml format
1214
*
1315
* The rule must have a `fix` to rewrite the matched node.
1416
*
@@ -17,9 +19,9 @@ export type RuleConfig = NapiConfig & { fix?: string };
1719
* @param rule The rule. Either a yaml string or an instance of `RuleConfig`
1820
* @param root The root node
1921
* @param once only apply once
20-
* @returns A list of edits.
22+
* @returns A list of edits and a list of matches.
2123
*/
22-
export function getRuleEdits(rule: string | RuleConfig, root: SgNode, { once = false } = {}) {
24+
export function applyRule(rule: string | RuleConfig, root: SgNode, { once = false } = {}) {
2325
const ruleConfig: RuleConfig = typeof rule === "string" ? yaml.parse(rule) : rule;
2426
if (ruleConfig.transform) {
2527
throw new Error("transform is not supported");
@@ -50,7 +52,18 @@ export function getRuleEdits(rule: string | RuleConfig, root: SgNode, { once = f
5052
);
5153
});
5254

53-
return edits;
55+
return { edits, matches };
56+
}
57+
58+
/**
59+
* Parse a file and obtain its root.
60+
*
61+
* @param path The file path
62+
* @param lang The language to parse. Defaults to TypeScript.
63+
* @returns The root for the file.
64+
*/
65+
export function parseFile(path: string, lang = Lang.TypeScript) {
66+
return parse(lang, readFileSync(path, { encoding: "utf-8" })).root();
5467
}
5568

5669
/**
@@ -71,6 +84,6 @@ export function patchCode(
7184
{ lang = Lang.TypeScript, once = false } = {}
7285
): string {
7386
const node = parse(lang, code).root();
74-
const edits = getRuleEdits(rule, node, { once });
87+
const { edits } = applyRule(rule, node, { once });
7588
return node.commitEdits(edits);
7689
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { patchCode } from "./util";
4+
import { vercelOgFallbackFontRule, vercelOgImportRule } from "./vercel-og";
5+
6+
describe("vercelOgImportRule", () => {
7+
it("should rewrite a node import to an edge import", () => {
8+
const code = `e.exports=import("next/dist/compiled/@vercel/og/index.node.js")`;
9+
expect(patchCode(code, vercelOgImportRule)).toMatchInlineSnapshot(
10+
`"e.exports=import("next/dist/compiled/@vercel/og/index.edge.js")"`
11+
);
12+
});
13+
});
14+
15+
describe("vercelOgFallbackFontRule", () => {
16+
it("should replace a fetch call for a font with an import", () => {
17+
const code = `var fallbackFont = fetch(new URL("./noto-sans-v27-latin-regular.ttf", import.meta.url)).then((res) => res.arrayBuffer());`;
18+
expect(patchCode(code, vercelOgFallbackFontRule)).toMatchInlineSnapshot(`
19+
"async function getFallbackFont() {
20+
// .bin is used so that a loader does not need to be configured for .ttf files
21+
return (await import("./noto-sans-v27-latin-regular.ttf.bin")).default;
22+
}
23+
24+
var fallbackFont = getFallbackFont();"
25+
`);
26+
});
27+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { SgNode } from "@ast-grep/napi";
2+
3+
import { applyRule } from "./util.js";
4+
5+
export const vercelOgImportRule = `
6+
rule:
7+
pattern: $NODE
8+
kind: string
9+
regex: "next/dist/compiled/@vercel/og/index\\\\.node\\\\.js"
10+
inside:
11+
kind: arguments
12+
inside:
13+
kind: call_expression
14+
stopBy: end
15+
has:
16+
field: function
17+
regex: "import"
18+
19+
fix: |-
20+
"next/dist/compiled/@vercel/og/index.edge.js"
21+
`;
22+
23+
/**
24+
* Patches Node.js imports for the library to be Edge imports.
25+
*
26+
* @param root Root node.
27+
* @returns Results of applying the rule.
28+
*/
29+
export function patchVercelOgImport(root: SgNode) {
30+
return applyRule(vercelOgImportRule, root);
31+
}
32+
33+
export const vercelOgFallbackFontRule = `
34+
rule:
35+
kind: variable_declaration
36+
all:
37+
- has:
38+
kind: variable_declarator
39+
has:
40+
kind: identifier
41+
regex: ^fallbackFont$
42+
- has:
43+
kind: call_expression
44+
pattern: fetch(new URL("$PATH", $$$REST))
45+
stopBy: end
46+
47+
fix: |-
48+
async function getFallbackFont() {
49+
// .bin is used so that a loader does not need to be configured for .ttf files
50+
return (await import("$PATH.bin")).default;
51+
}
52+
53+
var fallbackFont = getFallbackFont();
54+
`;
55+
56+
/**
57+
* Patches the default font fetching to use a .bin import.
58+
*
59+
* @param root Root node.
60+
* @returns Results of applying the rule.
61+
*/
62+
export function patchVercelOgFallbackFont(root: SgNode) {
63+
return applyRule(vercelOgFallbackFontRule, root);
64+
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./copy-package-cli-files.js";
22
export * from "./patch-cache.js";
33
export * from "./patch-require.js";
4+
export * from "./patch-vercel-og-library.js";
45
export * from "./update-webpack-chunks-file/index.js";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
3+
4+
import { BuildOptions } from "@opennextjs/aws/build/helper.js";
5+
import mockFs from "mock-fs";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
8+
import { patchVercelOgLibrary } from "./patch-vercel-og-library";
9+
10+
const nodeModulesVercelOgDir = "node_modules/.pnpm/next@14.2.11/node_modules/next/dist/compiled/@vercel/og";
11+
const nextServerOgNftPath = "examples/api/.next/server/app/og/route.js.nft.json";
12+
const openNextFunctionDir = "examples/api/.open-next/server-functions/default/examples/api";
13+
const openNextOgRoutePath = path.join(openNextFunctionDir, ".next/server/app/og/route.js");
14+
const openNextVercelOgDir = path.join(openNextFunctionDir, "node_modules/next/dist/compiled/@vercel/og");
15+
16+
const buildOpts = {
17+
appBuildOutputPath: "examples/api",
18+
monorepoRoot: "",
19+
outputDir: "examples/api/.open-next",
20+
} as BuildOptions;
21+
22+
describe("patchVercelOgLibrary", () => {
23+
beforeAll(() => {
24+
mockFs();
25+
26+
mkdirSync(nodeModulesVercelOgDir, { recursive: true });
27+
mkdirSync(path.dirname(nextServerOgNftPath), { recursive: true });
28+
mkdirSync(path.dirname(openNextOgRoutePath), { recursive: true });
29+
mkdirSync(openNextVercelOgDir, { recursive: true });
30+
31+
writeFileSync(
32+
nextServerOgNftPath,
33+
JSON.stringify({ version: 1, files: [`../../../../../../${nodeModulesVercelOgDir}/index.node.js`] })
34+
);
35+
writeFileSync(
36+
path.join(nodeModulesVercelOgDir, "index.edge.js"),
37+
`var fallbackFont = fetch(new URL("./noto-sans-v27-latin-regular.ttf", import.meta.url)).then((res) => res.arrayBuffer());`
38+
);
39+
writeFileSync(openNextOgRoutePath, `e.exports=import("next/dist/compiled/@vercel/og/index.node.js")`);
40+
writeFileSync(path.join(openNextVercelOgDir, "index.node.js"), "");
41+
writeFileSync(path.join(openNextVercelOgDir, "noto-sans-v27-latin-regular.ttf"), "");
42+
});
43+
44+
afterAll(() => mockFs.restore());
45+
46+
it("should patch the open-next files correctly", () => {
47+
patchVercelOgLibrary(buildOpts);
48+
49+
expect(readdirSync(openNextVercelOgDir)).toMatchInlineSnapshot(`
50+
[
51+
"index.edge.js",
52+
"index.node.js",
53+
"noto-sans-v27-latin-regular.ttf.bin",
54+
]
55+
`);
56+
57+
expect(readFileSync(path.join(openNextVercelOgDir, "index.edge.js"), { encoding: "utf-8" }))
58+
.toMatchInlineSnapshot(`
59+
"async function getFallbackFont() {
60+
// .bin is used so that a loader does not need to be configured for .ttf files
61+
return (await import("./noto-sans-v27-latin-regular.ttf.bin")).default;
62+
}
63+
64+
var fallbackFont = getFallbackFont();"
65+
`);
66+
67+
expect(readFileSync(openNextOgRoutePath, { encoding: "utf-8" })).toMatchInlineSnapshot(
68+
`"e.exports=import("next/dist/compiled/@vercel/og/index.edge.js")"`
69+
);
70+
});
71+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
3+
4+
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
5+
import { getPackagePath } from "@opennextjs/aws/build/helper.js";
6+
import { globSync } from "glob";
7+
8+
import { parseFile } from "../ast/util.js";
9+
import { patchVercelOgFallbackFont, patchVercelOgImport } from "../ast/vercel-og.js";
10+
11+
type TraceInfo = { version: number; files: string[] };
12+
13+
/**
14+
* Patches the usage of @vercel/og to be compatible with Cloudflare Workers.
15+
*
16+
* @param buildOpts Build options.
17+
*/
18+
export function patchVercelOgLibrary(buildOpts: BuildOptions) {
19+
const { appBuildOutputPath, outputDir } = buildOpts;
20+
21+
const packagePath = path.join(outputDir, "server-functions/default", getPackagePath(buildOpts));
22+
23+
for (const traceInfoPath of globSync(path.join(appBuildOutputPath, ".next/server/**/*.nft.json"))) {
24+
const traceInfo: TraceInfo = JSON.parse(readFileSync(traceInfoPath, { encoding: "utf8" }));
25+
const tracedNodePath = traceInfo.files.find((p) => p.endsWith("@vercel/og/index.node.js"));
26+
27+
if (!tracedNodePath) continue;
28+
29+
const outputDir = path.join(packagePath, "node_modules/next/dist/compiled/@vercel/og");
30+
const outputEdgePath = path.join(outputDir, "index.edge.js");
31+
32+
// Ensure the edge version is available in the OpenNext node_modules.
33+
if (!existsSync(outputEdgePath)) {
34+
const tracedEdgePath = path.join(
35+
path.dirname(traceInfoPath),
36+
tracedNodePath.replace("index.node.js", "index.edge.js")
37+
);
38+
39+
copyFileSync(tracedEdgePath, outputEdgePath);
40+
41+
// Change font fetches in the library to use imports.
42+
const node = parseFile(outputEdgePath);
43+
const { edits, matches } = patchVercelOgFallbackFont(node);
44+
writeFileSync(outputEdgePath, node.commitEdits(edits));
45+
46+
const fontFileName = matches[0]!.getMatch("PATH")!.text();
47+
renameSync(path.join(outputDir, fontFileName), path.join(outputDir, `${fontFileName}.bin`));
48+
}
49+
50+
// Change node imports for the library to edge imports.
51+
const routeFilePath = traceInfoPath.replace(appBuildOutputPath, packagePath).replace(".nft.json", "");
52+
53+
const node = parseFile(routeFilePath);
54+
const { edits } = patchVercelOgImport(node);
55+
writeFileSync(routeFilePath, node.commitEdits(edits));
56+
}
57+
}

0 commit comments

Comments
 (0)
Please sign in to comment.