Skip to content

Commit 522076b

Browse files
authoredFeb 14, 2025··
add "async mode" to getCloudflareContext (#372)
1 parent 54bcbc3 commit 522076b

19 files changed

+582
-65
lines changed
 

‎.changeset/tough-tables-talk.md

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
add "async mode" to `getCloudflareContext`
6+
7+
Add an `async` option to `getCloudflareContext({async})` to run it in "async mode", the difference being that the returned value is a
8+
promise of the Cloudflare context instead of the context itself
9+
10+
The main of this is that it allows the function to also run during SSG (since the missing context can be created on demand).

‎examples/common/apps.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const apps = [
66
"playground",
77
"vercel-blog-starter",
88
"vercel-commerce",
9+
"ssg-app",
910
// e2e
1011
"app-pages-router",
1112
"app-router",

‎examples/ssg-app/.dev.vars

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MY_SECRET = "psst... this is a secret!"

‎examples/ssg-app/.gitignore

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
/.pnp
6+
.pnp.*
7+
.yarn/*
8+
!.yarn/patches
9+
!.yarn/plugins
10+
!.yarn/releases
11+
!.yarn/versions
12+
13+
# testing
14+
/coverage
15+
16+
# next.js
17+
/.next/
18+
/out/
19+
20+
# production
21+
/build
22+
23+
# misc
24+
.DS_Store
25+
*.pem
26+
27+
# debug
28+
npm-debug.log*
29+
yarn-debug.log*
30+
yarn-error.log*
31+
.pnpm-debug.log*
32+
33+
# env files (can opt-in for committing if needed)
34+
.env*
35+
36+
# vercel
37+
.vercel
38+
39+
# typescript
40+
*.tsbuildinfo
41+
next-env.d.ts
42+
43+
# playwright
44+
/test-results/
45+
/playwright-report/
46+
/blob-report/
47+
/playwright/.cache/

‎examples/ssg-app/app/favicon.ico

25.3 KB
Binary file not shown.

‎examples/ssg-app/app/globals.css

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
html,
2+
body {
3+
max-width: 100vw;
4+
overflow-x: hidden;
5+
height: 100vh;
6+
display: flex;
7+
flex-direction: column;
8+
}
9+
10+
footer {
11+
padding: 1rem;
12+
display: flex;
13+
justify-content: end;
14+
}

‎examples/ssg-app/app/layout.tsx

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { Metadata } from "next";
2+
import "./globals.css";
3+
4+
import { getCloudflareContext } from "@opennextjs/cloudflare";
5+
6+
export const metadata: Metadata = {
7+
title: "SSG App",
8+
description: "An app in which all the routes are SSG'd",
9+
};
10+
11+
export default async function RootLayout({
12+
children,
13+
}: Readonly<{
14+
children: React.ReactNode;
15+
}>) {
16+
const cloudflareContext = await getCloudflareContext({
17+
async: true,
18+
});
19+
20+
return (
21+
<html lang="en">
22+
<body>
23+
{children}
24+
<footer data-testid="app-version">{cloudflareContext.env.APP_VERSION}</footer>
25+
</body>
26+
</html>
27+
);
28+
}

‎examples/ssg-app/app/page.module.css

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.page {
2+
display: grid;
3+
grid-template-rows: 20px 1fr 20px;
4+
align-items: center;
5+
justify-items: center;
6+
flex: 1;
7+
border: 3px solid gray;
8+
margin: 1rem;
9+
margin-block-end: 0;
10+
}
11+
12+
.main {
13+
display: flex;
14+
flex-direction: column;
15+
gap: 32px;
16+
grid-row-start: 2;
17+
}

‎examples/ssg-app/app/page.tsx

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import styles from "./page.module.css";
2+
import { getCloudflareContext } from "@opennextjs/cloudflare";
3+
4+
export default async function Home() {
5+
const cloudflareContext = await getCloudflareContext({
6+
async: true,
7+
});
8+
9+
return (
10+
<div className={styles.page}>
11+
<main className={styles.main}>
12+
<h1>Hello from a Statically generated page</h1>
13+
<p data-testid="my-secret">{cloudflareContext.env.MY_SECRET}</p>
14+
</main>
15+
</div>
16+
);
17+
}

‎examples/ssg-app/e2e/base.spec.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
test("the index page should work", async ({ page }) => {
4+
await page.goto("/");
5+
await expect(page.getByText("Hello from a Statically generated page")).toBeVisible();
6+
});
7+
8+
test("the APP_VERSION var from the cloudflare context should be displayed", async ({ page }) => {
9+
await page.goto("/");
10+
await expect(page.getByTestId("app-version")).toHaveText("1.2.345");
11+
});
12+
13+
// Note: secrets from .dev.vars are also part of the SSG output, this is expected and nothing we can avoid
14+
test("the MY_SECRET secret from the cloudflare context should be displayed", async ({ page }) => {
15+
await page.goto("/");
16+
await expect(page.getByTestId("my-secret")).toHaveText("psst... this is a secret!");
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { configurePlaywright } from "../../common/config-e2e";
2+
3+
export default configurePlaywright("ssg-app", { isCI: !!process.env.CI, multipleBrowsers: false });

‎examples/ssg-app/next.config.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { NextConfig } from "next";
2+
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
3+
4+
initOpenNextCloudflareForDev();
5+
6+
const nextConfig: NextConfig = {
7+
/* config options here */
8+
};
9+
10+
export default nextConfig;

‎examples/ssg-app/open-next.config.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// default open-next.config.ts file created by @opennextjs/cloudflare
2+
3+
import cache from "@opennextjs/cloudflare/kv-cache";
4+
5+
const config = {
6+
default: {
7+
override: {
8+
wrapper: "cloudflare-node",
9+
converter: "edge",
10+
incrementalCache: async () => cache,
11+
tagCache: "dummy",
12+
queue: "dummy",
13+
},
14+
},
15+
16+
middleware: {
17+
external: true,
18+
override: {
19+
wrapper: "cloudflare-edge",
20+
converter: "edge",
21+
proxyExternalRequest: "fetch",
22+
},
23+
},
24+
};
25+
26+
export default config;

‎examples/ssg-app/package.json

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "ssg-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "next lint",
10+
"build:worker": "opennextjs-cloudflare",
11+
"preview": "pnpm build:worker && pnpm wrangler dev",
12+
"e2e": "playwright test -c e2e/playwright.config.ts"
13+
},
14+
"dependencies": {
15+
"react": "^19.0.0",
16+
"react-dom": "^19.0.0",
17+
"next": "15.1.7"
18+
},
19+
"devDependencies": {
20+
"@opennextjs/cloudflare": "workspace:*",
21+
"@playwright/test": "catalog:",
22+
"@types/node": "catalog:",
23+
"@types/react": "^19",
24+
"@types/react-dom": "^19",
25+
"typescript": "catalog:",
26+
"wrangler": "catalog:"
27+
}
28+
}

‎examples/ssg-app/tsconfig.json

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2017",
4+
"lib": ["dom", "dom.iterable", "esnext"],
5+
"allowJs": true,
6+
"skipLibCheck": true,
7+
"strict": true,
8+
"noEmit": true,
9+
"esModuleInterop": true,
10+
"module": "esnext",
11+
"moduleResolution": "bundler",
12+
"resolveJsonModule": true,
13+
"isolatedModules": true,
14+
"jsx": "preserve",
15+
"incremental": true,
16+
"plugins": [
17+
{
18+
"name": "next"
19+
}
20+
],
21+
"paths": {
22+
"@/*": ["./*"]
23+
}
24+
},
25+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26+
"exclude": ["node_modules"]
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
interface CloudflareEnv {
2+
APP_VERSION: "1.2.345";
3+
MY_SECRET: string;
4+
ASSETS: Fetcher;
5+
}

‎examples/ssg-app/wrangler.json

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "node_modules/wrangler/config-schema.json",
3+
"main": ".open-next/worker.js",
4+
"name": "ssg-app",
5+
"compatibility_date": "2025-02-04",
6+
"compatibility_flags": ["nodejs_compat"],
7+
"assets": {
8+
"directory": ".open-next/assets",
9+
"binding": "ASSETS"
10+
},
11+
"vars": {
12+
"APP_VERSION": "1.2.345"
13+
}
14+
}

‎packages/cloudflare/src/api/cloudflare-context.ts

+116-34
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ type InternalGlobalThis<
4545
__NEXT_DATA__: Record<string, unknown>;
4646
};
4747

48+
type GetCloudflareContextOptions = {
49+
/**
50+
* When `true`, `getCloudflareContext` returns a promise of the cloudflare context instead of the context,
51+
* this is needed to access the context from statically generated routes.
52+
*/
53+
async: boolean;
54+
};
55+
4856
/**
4957
* Utility to get the current Cloudflare context
5058
*
@@ -53,44 +61,100 @@ type InternalGlobalThis<
5361
export function getCloudflareContext<
5462
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
5563
Context = ExecutionContext,
56-
>(): CloudflareContext<CfProperties, Context> {
64+
>(options: { async: true }): Promise<CloudflareContext<CfProperties, Context>>;
65+
export function getCloudflareContext<
66+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
67+
Context = ExecutionContext,
68+
>(options?: { async: false }): CloudflareContext<CfProperties, Context>;
69+
export function getCloudflareContext<
70+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
71+
Context = ExecutionContext,
72+
>(
73+
options: GetCloudflareContextOptions = { async: false }
74+
): CloudflareContext<CfProperties, Context> | Promise<CloudflareContext<CfProperties, Context>> {
75+
return options.async ? getCloudflareContextAsync() : getCloudflareContextSync();
76+
}
77+
78+
/**
79+
* Get the cloudflare context from the current global scope
80+
*/
81+
function getCloudflareContextFromGlobalScope<
82+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
83+
Context = ExecutionContext,
84+
>(): CloudflareContext<CfProperties, Context> | undefined {
85+
const global = globalThis as InternalGlobalThis<CfProperties, Context>;
86+
return global[cloudflareContextSymbol];
87+
}
88+
89+
/**
90+
* Detects whether the current code is being evaluated in a statically generated route
91+
*/
92+
function inSSG<
93+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
94+
Context = ExecutionContext,
95+
>(): boolean {
5796
const global = globalThis as InternalGlobalThis<CfProperties, Context>;
97+
// Note: Next.js sets globalThis.__NEXT_DATA__.nextExport to true for SSG routes
98+
// source: https://github.com/vercel/next.js/blob/4e394608423/packages/next/src/export/worker.ts#L55-L57)
99+
return global.__NEXT_DATA__?.nextExport === true;
100+
}
58101

59-
const cloudflareContext = global[cloudflareContextSymbol];
60-
61-
if (!cloudflareContext) {
62-
// For SSG Next.js creates (jest) workers that run in parallel, those don't get the current global
63-
// state so they can't get access to the cloudflare context, unfortunately there isn't anything we
64-
// can do about this, so the only solution is to error asking the developer to opt-out of SSG
65-
// Next.js sets globalThis.__NEXT_DATA__.nextExport to true for the worker, so we can use that to detect
66-
// that the route is being SSG'd (source: https://github.com/vercel/next.js/blob/4e394608423/packages/next/src/export/worker.ts#L55-L57)
67-
if (global.__NEXT_DATA__?.nextExport === true) {
68-
throw new Error(
69-
`\n\nERROR: \`getCloudflareContext\` has been called in a static route` +
70-
` that is not allowed, please either avoid calling \`getCloudflareContext\`` +
71-
` in the route or make the route non static (for example by exporting the` +
72-
` \`dynamic\` route segment config set to \`'force-dynamic'\`.\n`
73-
);
74-
}
75-
76-
// the cloudflare context is initialized by the worker and is always present in production/preview
77-
// during local development (`next dev`) it might be missing only if the developers hasn't called
78-
// the `initOpenNextCloudflareForDev` function in their Next.js config file
102+
/**
103+
* Utility to get the current Cloudflare context in sync mode
104+
*/
105+
function getCloudflareContextSync<
106+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
107+
Context = ExecutionContext,
108+
>(): CloudflareContext<CfProperties, Context> {
109+
const cloudflareContext = getCloudflareContextFromGlobalScope<CfProperties, Context>();
110+
111+
if (cloudflareContext) {
112+
return cloudflareContext;
113+
}
114+
115+
// The sync mode of `getCloudflareContext`, relies on the context being set on the global state
116+
// by either the worker entrypoint (in prod) or by `initOpenNextCloudflareForDev` (in dev), neither
117+
// can work during SSG since for SSG Next.js creates (jest) workers that don't get access to the
118+
// normal global state so we throw with a helpful error message.
119+
if (inSSG()) {
79120
throw new Error(
80-
`\n\nERROR: \`getCloudflareContext\` has been called without having called` +
81-
` \`initOpenNextCloudflareForDev\` from the Next.js config file.\n` +
82-
`You should update your Next.js config file as shown below:\n\n` +
83-
" ```\n // next.config.mjs\n\n" +
84-
` import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";\n\n` +
85-
` initOpenNextCloudflareForDev();\n\n` +
86-
" const nextConfig = { ... };\n" +
87-
" export default nextConfig;\n" +
88-
" ```\n" +
89-
"\n"
121+
`\n\nERROR: \`getCloudflareContext\` has been called in a static route,` +
122+
` that is not allowed, this can be solved in different ways:\n\n` +
123+
` - call \`getCloudflareContext({async: true})\` to use the \`async\` mode\n` +
124+
` - avoid calling \`getCloudflareContext\` in the route\n` +
125+
` - make the route non static\n`
90126
);
91127
}
92128

93-
return cloudflareContext;
129+
throw new Error(initOpenNextCloudflareForDevErrorMsg);
130+
}
131+
132+
/**
133+
* Utility to get the current Cloudflare context in async mode
134+
*/
135+
async function getCloudflareContextAsync<
136+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
137+
Context = ExecutionContext,
138+
>(): Promise<CloudflareContext<CfProperties, Context>> {
139+
const cloudflareContext = getCloudflareContextFromGlobalScope<CfProperties, Context>();
140+
141+
if (cloudflareContext) {
142+
return cloudflareContext;
143+
}
144+
145+
// Note: Next.js sets process.env.NEXT_RUNTIME to 'nodejs' when the runtime in use is the node.js one
146+
// We want to detect when the runtime is the node.js one so that during development (`next dev`) we know wether
147+
// we are or not in a node.js process and that access to wrangler's node.js apis
148+
const inNodejsRuntime = process.env.NEXT_RUNTIME === "nodejs";
149+
150+
if (inNodejsRuntime || inSSG()) {
151+
// we're in a node.js process and also in "async mode" so we can use wrangler to asynchronously get the context
152+
const cloudflareContext = await getCloudflareContextFromWrangler<CfProperties, Context>();
153+
addCloudflareContextToNodejsGlobal(cloudflareContext);
154+
return cloudflareContext;
155+
}
156+
157+
throw new Error(initOpenNextCloudflareForDevErrorMsg);
94158
}
95159

96160
/**
@@ -127,12 +191,15 @@ function shouldContextInitializationRun(): boolean {
127191
}
128192

129193
/**
130-
* Adds the cloudflare context to the global scope in which the Next.js dev node.js process runs in, enabling
194+
* Adds the cloudflare context to the global scope of the current node.js process, enabling
131195
* future calls to `getCloudflareContext` to retrieve and return such context
132196
*
133197
* @param cloudflareContext the cloudflare context to add to the node.sj global scope
134198
*/
135-
function addCloudflareContextToNodejsGlobal(cloudflareContext: CloudflareContext<CfProperties, Context>) {
199+
function addCloudflareContextToNodejsGlobal<
200+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
201+
Context = ExecutionContext,
202+
>(cloudflareContext: CloudflareContext<CfProperties, Context>) {
136203
const global = globalThis as InternalGlobalThis<CfProperties, Context>;
137204
global[cloudflareContextSymbol] = cloudflareContext;
138205
}
@@ -192,3 +259,18 @@ async function getCloudflareContextFromWrangler<
192259
ctx: ctx as Context,
193260
};
194261
}
262+
263+
// In production the cloudflare context is initialized by the worker so it is always available.
264+
// During local development (`next dev`) it might be missing only if the developers hasn't called
265+
// the `initOpenNextCloudflareForDev` function in their Next.js config file
266+
const initOpenNextCloudflareForDevErrorMsg =
267+
`\n\nERROR: \`getCloudflareContext\` has been called without having called` +
268+
` \`initOpenNextCloudflareForDev\` from the Next.js config file.\n` +
269+
`You should update your Next.js config file as shown below:\n\n` +
270+
" ```\n // next.config.mjs\n\n" +
271+
` import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";\n\n` +
272+
` initOpenNextCloudflareForDev();\n\n` +
273+
" const nextConfig = { ... };\n" +
274+
" export default nextConfig;\n" +
275+
" ```\n" +
276+
"\n";

‎pnpm-lock.yaml

+201-31
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.