Skip to content

Commit 4753b0a

Browse files
enable getCloudflareContext to work in middlewares via a new enableEdgeDevGetCloudflareContext utility
1 parent e6078b5 commit 4753b0a

File tree

12 files changed

+242
-96
lines changed

12 files changed

+242
-96
lines changed

.changeset/chilly-dryers-begin.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
enable `getCloudflareContext` to work in middlewares via a new `enableEdgeDevGetCloudflareContext` utility
6+
7+
`getCloudflareContext` can't work, during development (with `next dev`), in middlewares since they run in
8+
the edge runtime (see: https://nextjs.org/docs/app/building-your-application/routing/middleware#runtime) which
9+
is incompatible with the `wrangler`'s APIs which run in node.js
10+
11+
To solve this a new utility called `enableEdgeDevGetCloudflareContext` has been introduced that allows the
12+
context to be available also in the edge runtime, the utility needs to be called inside the Next.js config
13+
file, for example:
14+
15+
```js
16+
// next.config.mjs
17+
18+
import { enableEdgeDevGetCloudflareContext } from "@opennextjs/cloudflare";
19+
20+
/** @type {import('next').NextConfig} */
21+
const nextConfig = {};
22+
23+
enableEdgeDevGetCloudflareContext();
24+
25+
export default nextConfig;
26+
```
27+
28+
a helpful error is also thrown in `getCloudflareContext` to prompt developers to use the utility
29+
30+
`getCloudflareContext` called in the nodejs runtime works as before without needing any setup
Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
import { headers } from "next/headers";
2+
13
export default function MiddlewarePage() {
2-
return <h1>Via middleware</h1>;
4+
const cloudflareContextHeader = headers().get("x-cloudflare-context");
5+
6+
return (
7+
<>
8+
<h1>Via middleware</h1>
9+
<p>
10+
The value of the <i>x-cloudflare-context</i> header is: <br />
11+
<span
12+
style={{
13+
display: "inline-block",
14+
margin: "1rem 2rem",
15+
color: "grey",
16+
fontSize: "1.2rem",
17+
}}
18+
data-testid="cloudflare-context-header"
19+
>
20+
{cloudflareContextHeader}
21+
</span>
22+
</p>
23+
</>
24+
);
325
}

examples/middleware/e2e/base.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,28 @@ import { test, expect } from "@playwright/test";
33
test("redirect", async ({ page }) => {
44
await page.goto("/");
55
await page.click('[href="/about"]');
6-
expect(page.waitForURL("**/redirected"));
6+
await page.waitForURL("**/redirected");
77
expect(await page.textContent("h1")).toContain("Redirected");
88
});
99

1010
test("rewrite", async ({ page }) => {
1111
await page.goto("/");
1212
await page.click('[href="/another"]');
13-
expect(page.waitForURL("**/another"));
13+
await page.waitForURL("**/another");
1414
expect(await page.textContent("h1")).toContain("Rewrite");
1515
});
1616

1717
test("no matching middleware", async ({ page }) => {
1818
await page.goto("/");
1919
await page.click('[href="/about2"]');
20-
expect(page.waitForURL("**/about2"));
20+
await page.waitForURL("**/about2");
2121
expect(await page.textContent("h1")).toContain("About 2");
2222
});
2323

2424
test("matching noop middleware", async ({ page }) => {
2525
await page.goto("/");
2626
await page.click('[href="/middleware"]');
27-
expect(page.waitForURL("**/middleware"));
27+
await page.waitForURL("**/middleware");
2828
expect(await page.textContent("h1")).toContain("Via middleware");
2929
});
3030

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
test("cloudflare context env object is populated", async ({ page }) => {
4+
await page.goto("/middleware");
5+
const a = page.getByTestId("cloudflare-context-header");
6+
expect(await a.textContent()).toEqual("keys of `cloudflareContext.env`: MY_VAR, MY_KV, ASSETS");
7+
});

examples/middleware/e2e/playwright.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,7 @@ export default defineConfig({
4949
command: "pnpm preview:worker",
5050
url: "http://localhost:8774",
5151
reuseExistingServer: !process.env.CI,
52+
// the app takes a bit to boot up for some reason, that's why we need a longer timeout here
53+
timeout: 2 * 60 * 1000,
5254
},
5355
});

examples/middleware/middleware.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { NextRequest, NextResponse, NextFetchEvent } from "next/server";
22
import { clerkMiddleware } from "@clerk/nextjs/server";
33

4-
export function middleware(request: NextRequest, event: NextFetchEvent) {
4+
import { getCloudflareContext } from "@opennextjs/cloudflare";
5+
6+
export async function middleware(request: NextRequest, event: NextFetchEvent) {
57
console.log("middleware");
68
if (request.nextUrl.pathname === "/about") {
79
return NextResponse.redirect(new URL("/redirected", request.url));
@@ -16,7 +18,19 @@ export function middleware(request: NextRequest, event: NextFetchEvent) {
1618
})(request, event);
1719
}
1820

19-
return NextResponse.next();
21+
const requestHeaders = new Headers(request.headers);
22+
const cloudflareContext = await getCloudflareContext();
23+
24+
requestHeaders.set(
25+
"x-cloudflare-context",
26+
`keys of \`cloudflareContext.env\`: ${Object.keys(cloudflareContext.env).join(", ")}`
27+
);
28+
29+
return NextResponse.next({
30+
request: {
31+
headers: requestHeaders,
32+
},
33+
});
2034
}
2135

2236
export const config = {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import { enableEdgeDevGetCloudflareContext } from "@opennextjs/cloudflare";
2+
13
/** @type {import('next').NextConfig} */
24
const nextConfig = {};
35

6+
enableEdgeDevGetCloudflareContext();
7+
48
export default nextConfig;

examples/middleware/wrangler.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,9 @@
66
"assets": {
77
"directory": ".open-next/assets",
88
"binding": "ASSETS"
9-
}
9+
},
10+
"vars": {
11+
"MY_VAR": "my-var"
12+
},
13+
"kv_namespaces": [{ "binding": "MY_KV", "id": "<id>" }]
1014
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
declare global {
2+
interface CloudflareEnv {
3+
NEXT_CACHE_WORKERS_KV?: KVNamespace;
4+
ASSETS?: Fetcher;
5+
}
6+
}
7+
8+
export type CloudflareContext<
9+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
10+
Context = ExecutionContext,
11+
> = {
12+
/**
13+
* the worker's [bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/)
14+
*/
15+
env: CloudflareEnv;
16+
/**
17+
* the request's [cf properties](https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties)
18+
*/
19+
cf: CfProperties | undefined;
20+
/**
21+
* the current [execution context](https://developers.cloudflare.com/workers/runtime-apis/context)
22+
*/
23+
ctx: Context;
24+
};
25+
26+
// Note: this symbol needs to be kept in sync with the one used in `src/cli/templates/worker.ts`
27+
const cloudflareContextSymbol = Symbol.for("__cloudflare-context__");
28+
29+
/**
30+
* Utility to get the current Cloudflare context
31+
*
32+
* @returns the cloudflare context
33+
*/
34+
export async function getCloudflareContext<
35+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
36+
Context = ExecutionContext,
37+
>(): Promise<CloudflareContext<CfProperties, Context>> {
38+
const global = globalThis as unknown as {
39+
[cloudflareContextSymbol]: CloudflareContext<CfProperties, Context> | undefined;
40+
};
41+
42+
const cloudflareContext = global[cloudflareContextSymbol];
43+
44+
if (!cloudflareContext) {
45+
// the cloudflare context is initialized by the worker and is always present in production/preview,
46+
// so, it not being present means that the application is running under `next dev`
47+
return getCloudflareContextInNextDev();
48+
}
49+
50+
return cloudflareContext;
51+
}
52+
53+
const cloudflareContextInNextDevSymbol = Symbol.for("__next-dev/cloudflare-context__");
54+
55+
/**
56+
* Gets a local proxy version of the cloudflare context (created using `getPlatformProxy`) when
57+
* running in the standard next dev server (via `next dev`)
58+
*
59+
* @returns the local proxy version of the cloudflare context
60+
*/
61+
async function getCloudflareContextInNextDev<
62+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
63+
Context = ExecutionContext,
64+
>(): Promise<CloudflareContext<CfProperties, Context>> {
65+
const global = globalThis as unknown as {
66+
[cloudflareContextInNextDevSymbol]: CloudflareContext<CfProperties, Context> | undefined;
67+
};
68+
69+
if (!global[cloudflareContextInNextDevSymbol]) {
70+
try {
71+
const cloudflareContext = await getCloudflareContextFromWrangler<CfProperties, Context>();
72+
global[cloudflareContextInNextDevSymbol] = cloudflareContext;
73+
} catch (e: unknown) {
74+
if (e instanceof Error && e.message.includes("A dynamic import callback was not specified")) {
75+
const getCloudflareContextFunctionName = getCloudflareContext.name;
76+
const enablingFunctionName = enableEdgeDevGetCloudflareContext.name;
77+
throw new Error(
78+
`\n\n\`${getCloudflareContextFunctionName}\` has been invoked during development inside the edge runtime ` +
79+
"this is not enabled, to enable such use of the function you need to import and call the " +
80+
`\`${enablingFunctionName}\` function inside your Next.js config file\n\n"` +
81+
"Example: \n ```\n // next.config.mjs\n\n" +
82+
` import { ${enablingFunctionName} } from "@opennextjs/cloudflare";\n\n` +
83+
` ${enablingFunctionName}();\n\n` +
84+
" /** @type {import('next').NextConfig} */\n" +
85+
" const nextConfig = {};\n" +
86+
" export default nextConfig;\n" +
87+
" ```\n" +
88+
"\n(note: currently middlewares in Next.js are always run using the edge runtime)\n\n"
89+
);
90+
} else {
91+
throw e;
92+
}
93+
}
94+
}
95+
96+
return global[cloudflareContextInNextDevSymbol]!;
97+
}
98+
99+
type RuntimeContext = Record<string, unknown> & {
100+
process?: { env?: Record<string | symbol, unknown> };
101+
[cloudflareContextInNextDevSymbol]?: {
102+
env: unknown;
103+
ctx: unknown;
104+
cf: unknown;
105+
};
106+
};
107+
108+
/**
109+
* Enables `getCloudflareContext` to work, during development (via `next dev`), in the edge runtime
110+
*
111+
* Note: currently middlewares always run in the edge runtime
112+
*
113+
* Note: this function should only be called inside the Next.js config file
114+
*/
115+
export async function enableEdgeDevGetCloudflareContext() {
116+
const require = (
117+
await import(/* webpackIgnore: true */ `${"__module".replaceAll("_", "")}`)
118+
).default.createRequire(import.meta.url);
119+
120+
// eslint-disable-next-line unicorn/prefer-node-protocol -- the `next dev` compiler doesn't accept the node prefix
121+
const vmModule = require("vm");
122+
123+
const originalRunInContext = vmModule.runInContext.bind(vmModule);
124+
125+
const context = await getCloudflareContextFromWrangler();
126+
127+
vmModule.runInContext = (...args: [string, RuntimeContext, ...unknown[]]) => {
128+
const runtimeContext = args[1];
129+
runtimeContext[cloudflareContextInNextDevSymbol] ??= context;
130+
return originalRunInContext(...args);
131+
};
132+
}
133+
134+
async function getCloudflareContextFromWrangler<
135+
CfProperties extends Record<string, unknown> = IncomingRequestCfProperties,
136+
Context = ExecutionContext,
137+
>(): Promise<CloudflareContext<CfProperties, Context>> {
138+
// Note: we never want wrangler to be bundled in the Next.js app, that's why the import below looks like it does
139+
const { getPlatformProxy } = await import(/* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}`);
140+
const { env, cf, ctx } = await getPlatformProxy({
141+
// This allows the selection of a wrangler environment while running in next dev mode
142+
environment: process.env.NEXT_DEV_WRANGLER_ENV,
143+
});
144+
return {
145+
env,
146+
cf: cf as unknown as CfProperties,
147+
ctx: ctx as Context,
148+
};
149+
}

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

Lines changed: 0 additions & 86 deletions
This file was deleted.

0 commit comments

Comments
 (0)