From ddaa077a7301bde79c6921b8d553f7711c032e1f Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Fri, 17 Jan 2025 14:56:22 +0000 Subject: [PATCH] enable `getCloudflareContext` to work in middlewares via a new `enableEdgeDevGetCloudflareContext` utility --- .changeset/chilly-dryers-begin.md | 30 ++++ examples/middleware/app/middleware/page.tsx | 24 ++- examples/middleware/e2e/base.spec.ts | 8 +- .../middleware/e2e/cloudflare-context.spec.ts | 7 + examples/middleware/e2e/playwright.config.ts | 2 + examples/middleware/middleware.ts | 20 ++- examples/middleware/next.config.mjs | 4 + examples/middleware/wrangler.json | 6 +- .../cloudflare/src/api/cloudflare-context.ts | 149 ++++++++++++++++++ .../src/api/get-cloudflare-context.ts | 86 ---------- packages/cloudflare/src/api/index.ts | 2 +- packages/cloudflare/src/api/kvCache.ts | 2 +- 12 files changed, 243 insertions(+), 97 deletions(-) create mode 100644 .changeset/chilly-dryers-begin.md create mode 100644 examples/middleware/e2e/cloudflare-context.spec.ts create mode 100644 packages/cloudflare/src/api/cloudflare-context.ts delete mode 100644 packages/cloudflare/src/api/get-cloudflare-context.ts diff --git a/.changeset/chilly-dryers-begin.md b/.changeset/chilly-dryers-begin.md new file mode 100644 index 00000000..5d2df349 --- /dev/null +++ b/.changeset/chilly-dryers-begin.md @@ -0,0 +1,30 @@ +--- +"@opennextjs/cloudflare": patch +--- + +enable `getCloudflareContext` to work in middlewares via a new `enableEdgeDevGetCloudflareContext` utility + +`getCloudflareContext` can't work, during development (with `next dev`), in middlewares since they run in +the edge runtime (see: https://nextjs.org/docs/app/building-your-application/routing/middleware#runtime) which +is incompatible with the `wrangler`'s APIs which run in node.js + +To solve this a new utility called `enableEdgeDevGetCloudflareContext` has been introduced that allows the +context to be available also in the edge runtime, the utility needs to be called inside the Next.js config +file, for example: + +```js +// next.config.mjs + +import { enableEdgeDevGetCloudflareContext } from "@opennextjs/cloudflare"; + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +enableEdgeDevGetCloudflareContext(); + +export default nextConfig; +``` + +a helpful error is also thrown in `getCloudflareContext` to prompt developers to use the utility + +`getCloudflareContext` called in the nodejs runtime works as before without needing any setup diff --git a/examples/middleware/app/middleware/page.tsx b/examples/middleware/app/middleware/page.tsx index aa28fa5f..51739a0b 100644 --- a/examples/middleware/app/middleware/page.tsx +++ b/examples/middleware/app/middleware/page.tsx @@ -1,3 +1,25 @@ +import { headers } from "next/headers"; + export default function MiddlewarePage() { - return

Via middleware

; + const cloudflareContextHeader = headers().get("x-cloudflare-context"); + + return ( + <> +

Via middleware

+

+ The value of the x-cloudflare-context header is:
+ + {cloudflareContextHeader} + +

+ + ); } diff --git a/examples/middleware/e2e/base.spec.ts b/examples/middleware/e2e/base.spec.ts index 75ce9421..ec0f7565 100644 --- a/examples/middleware/e2e/base.spec.ts +++ b/examples/middleware/e2e/base.spec.ts @@ -3,28 +3,28 @@ import { test, expect } from "@playwright/test"; test("redirect", async ({ page }) => { await page.goto("/"); await page.click('[href="/about"]'); - expect(page.waitForURL("**/redirected")); + await page.waitForURL("**/redirected"); expect(await page.textContent("h1")).toContain("Redirected"); }); test("rewrite", async ({ page }) => { await page.goto("/"); await page.click('[href="/another"]'); - expect(page.waitForURL("**/another")); + await page.waitForURL("**/another"); expect(await page.textContent("h1")).toContain("Rewrite"); }); test("no matching middleware", async ({ page }) => { await page.goto("/"); await page.click('[href="/about2"]'); - expect(page.waitForURL("**/about2")); + await page.waitForURL("**/about2"); expect(await page.textContent("h1")).toContain("About 2"); }); test("matching noop middleware", async ({ page }) => { await page.goto("/"); await page.click('[href="/middleware"]'); - expect(page.waitForURL("**/middleware")); + await page.waitForURL("**/middleware"); expect(await page.textContent("h1")).toContain("Via middleware"); }); diff --git a/examples/middleware/e2e/cloudflare-context.spec.ts b/examples/middleware/e2e/cloudflare-context.spec.ts new file mode 100644 index 00000000..bd34724c --- /dev/null +++ b/examples/middleware/e2e/cloudflare-context.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from "@playwright/test"; + +test("cloudflare context env object is populated", async ({ page }) => { + await page.goto("/middleware"); + const a = page.getByTestId("cloudflare-context-header"); + expect(await a.textContent()).toEqual("keys of `cloudflareContext.env`: MY_VAR, MY_KV, ASSETS"); +}); diff --git a/examples/middleware/e2e/playwright.config.ts b/examples/middleware/e2e/playwright.config.ts index d6d8499c..8aa2d486 100644 --- a/examples/middleware/e2e/playwright.config.ts +++ b/examples/middleware/e2e/playwright.config.ts @@ -49,5 +49,7 @@ export default defineConfig({ command: "pnpm preview:worker", url: "http://localhost:8774", reuseExistingServer: !process.env.CI, + // the app takes a bit to boot up for some reason, that's why we need a longer timeout here + timeout: 2 * 60 * 1000, }, }); diff --git a/examples/middleware/middleware.ts b/examples/middleware/middleware.ts index 9145eb73..c432c77c 100644 --- a/examples/middleware/middleware.ts +++ b/examples/middleware/middleware.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse, NextFetchEvent } from "next/server"; import { clerkMiddleware } from "@clerk/nextjs/server"; -export function middleware(request: NextRequest, event: NextFetchEvent) { +import { getCloudflareContext } from "@opennextjs/cloudflare"; + +export async function middleware(request: NextRequest, event: NextFetchEvent) { console.log("middleware"); if (request.nextUrl.pathname === "/about") { return NextResponse.redirect(new URL("/redirected", request.url)); @@ -16,9 +18,21 @@ export function middleware(request: NextRequest, event: NextFetchEvent) { })(request, event); } - return NextResponse.next(); + const requestHeaders = new Headers(request.headers); + const cloudflareContext = await getCloudflareContext(); + + requestHeaders.set( + "x-cloudflare-context", + `keys of \`cloudflareContext.env\`: ${Object.keys(cloudflareContext.env).join(", ")}` + ); + + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); } export const config = { - matcher: ["/about/:path*", "/another/:path*", "/middleware/:path*", "/clerk"], + matcher: ["/about/:path*", "/another/:path*", "/middleware/:path*", "/clerk", "/cloudflare-context"], }; diff --git a/examples/middleware/next.config.mjs b/examples/middleware/next.config.mjs index 4678774e..12e2f394 100644 --- a/examples/middleware/next.config.mjs +++ b/examples/middleware/next.config.mjs @@ -1,4 +1,8 @@ +import { enableEdgeDevGetCloudflareContext } from "@opennextjs/cloudflare"; + /** @type {import('next').NextConfig} */ const nextConfig = {}; +enableEdgeDevGetCloudflareContext(); + export default nextConfig; diff --git a/examples/middleware/wrangler.json b/examples/middleware/wrangler.json index a0fb8497..1f761090 100644 --- a/examples/middleware/wrangler.json +++ b/examples/middleware/wrangler.json @@ -6,5 +6,9 @@ "assets": { "directory": ".open-next/assets", "binding": "ASSETS" - } + }, + "vars": { + "MY_VAR": "my-var" + }, + "kv_namespaces": [{ "binding": "MY_KV", "id": "" }] } diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts new file mode 100644 index 00000000..1e4d79b8 --- /dev/null +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -0,0 +1,149 @@ +declare global { + interface CloudflareEnv { + NEXT_CACHE_WORKERS_KV?: KVNamespace; + ASSETS?: Fetcher; + } +} + +export type CloudflareContext< + CfProperties extends Record = IncomingRequestCfProperties, + Context = ExecutionContext, +> = { + /** + * the worker's [bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/) + */ + env: CloudflareEnv; + /** + * the request's [cf properties](https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties) + */ + cf: CfProperties | undefined; + /** + * the current [execution context](https://developers.cloudflare.com/workers/runtime-apis/context) + */ + ctx: Context; +}; + +// Note: this symbol needs to be kept in sync with the one used in `src/cli/templates/worker.ts` +const cloudflareContextSymbol = Symbol.for("__cloudflare-context__"); + +/** + * Utility to get the current Cloudflare context + * + * @returns the cloudflare context + */ +export async function getCloudflareContext< + CfProperties extends Record = IncomingRequestCfProperties, + Context = ExecutionContext, +>(): Promise> { + const global = globalThis as unknown as { + [cloudflareContextSymbol]: CloudflareContext | undefined; + }; + + const cloudflareContext = global[cloudflareContextSymbol]; + + if (!cloudflareContext) { + // the cloudflare context is initialized by the worker and is always present in production/preview, + // so, it not being present means that the application is running under `next dev` + return getCloudflareContextInNextDev(); + } + + return cloudflareContext; +} + +const cloudflareContextInNextDevSymbol = Symbol.for("__next-dev/cloudflare-context__"); + +/** + * Gets a local proxy version of the cloudflare context (created using `getPlatformProxy`) when + * running in the standard next dev server (via `next dev`) + * + * @returns the local proxy version of the cloudflare context + */ +async function getCloudflareContextInNextDev< + CfProperties extends Record = IncomingRequestCfProperties, + Context = ExecutionContext, +>(): Promise> { + const global = globalThis as unknown as { + [cloudflareContextInNextDevSymbol]: CloudflareContext | undefined; + }; + + if (!global[cloudflareContextInNextDevSymbol]) { + try { + const cloudflareContext = await getCloudflareContextFromWrangler(); + global[cloudflareContextInNextDevSymbol] = cloudflareContext; + } catch (e: unknown) { + if (e instanceof Error && e.message.includes("A dynamic import callback was not specified")) { + const getCloudflareContextFunctionName = getCloudflareContext.name; + const enablingFunctionName = enableEdgeDevGetCloudflareContext.name; + throw new Error( + `\n\n\`${getCloudflareContextFunctionName}\` has been invoked during development inside the edge runtime ` + + "this is not enabled, to enable such use of the function you need to import and call the " + + `\`${enablingFunctionName}\` function inside your Next.js config file\n\n"` + + "Example: \n ```\n // next.config.mjs\n\n" + + ` import { ${enablingFunctionName} } from "@opennextjs/cloudflare";\n\n` + + ` ${enablingFunctionName}();\n\n` + + " /** @type {import('next').NextConfig} */\n" + + " const nextConfig = {};\n" + + " export default nextConfig;\n" + + " ```\n" + + "\n(note: currently middlewares in Next.js are always run using the edge runtime)\n\n" + ); + } else { + throw e; + } + } + } + + return global[cloudflareContextInNextDevSymbol]!; +} + +type RuntimeContext = Record & { + process?: { env?: Record }; + [cloudflareContextInNextDevSymbol]?: { + env: unknown; + ctx: unknown; + cf: unknown; + }; +}; + +/** + * Enables `getCloudflareContext` to work, during development (via `next dev`), in the edge runtime + * + * Note: currently middlewares always run in the edge runtime + * + * Note: this function should only be called inside the Next.js config file + */ +export async function enableEdgeDevGetCloudflareContext() { + const require = ( + await import(/* webpackIgnore: true */ `${"__module".replaceAll("_", "")}`) + ).default.createRequire(import.meta.url); + + // eslint-disable-next-line unicorn/prefer-node-protocol -- the `next dev` compiler doesn't accept the node prefix + const vmModule = require("vm"); + + const originalRunInContext = vmModule.runInContext.bind(vmModule); + + const context = await getCloudflareContextFromWrangler(); + + vmModule.runInContext = (...args: [string, RuntimeContext, ...unknown[]]) => { + const runtimeContext = args[1]; + runtimeContext[cloudflareContextInNextDevSymbol] ??= context; + return originalRunInContext(...args); + }; +} + +async function getCloudflareContextFromWrangler< + CfProperties extends Record = IncomingRequestCfProperties, + Context = ExecutionContext, +>(): Promise> { + // Note: we never want wrangler to be bundled in the Next.js app, that's why the import below looks like it does + const { getPlatformProxy } = await import(/* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}`); + const { env, cf, ctx } = await getPlatformProxy({ + // This allows the selection of a wrangler environment while running in next dev mode + environment: process.env.NEXT_DEV_WRANGLER_ENV, + }); + return { + env, + cf: cf as unknown as CfProperties, + ctx: ctx as Context, + }; +} diff --git a/packages/cloudflare/src/api/get-cloudflare-context.ts b/packages/cloudflare/src/api/get-cloudflare-context.ts deleted file mode 100644 index 1e627309..00000000 --- a/packages/cloudflare/src/api/get-cloudflare-context.ts +++ /dev/null @@ -1,86 +0,0 @@ -declare global { - interface CloudflareEnv { - NEXT_CACHE_WORKERS_KV?: KVNamespace; - ASSETS?: Fetcher; - } -} - -export type CloudflareContext< - CfProperties extends Record = IncomingRequestCfProperties, - Context = ExecutionContext, -> = { - /** - * the worker's [bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/) - */ - env: CloudflareEnv; - /** - * the request's [cf properties](https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties) - */ - cf: CfProperties | undefined; - /** - * the current [execution context](https://developers.cloudflare.com/workers/runtime-apis/context) - */ - ctx: Context; -}; - -// Note: this symbol needs to be kept in sync with the one used in `src/cli/templates/worker.ts` -const cloudflareContextSymbol = Symbol.for("__cloudflare-context__"); - -/** - * Utility to get the current Cloudflare context - * - * @returns the cloudflare context - */ -export async function getCloudflareContext< - CfProperties extends Record = IncomingRequestCfProperties, - Context = ExecutionContext, ->(): Promise> { - const global = globalThis as unknown as { - [cloudflareContextSymbol]: CloudflareContext | undefined; - }; - - const cloudflareContext = global[cloudflareContextSymbol]; - - if (!cloudflareContext) { - // the cloudflare context is initialized by the worker and is always present in production/preview, - // so, it not being present means that the application is running under `next dev` - return getCloudflareContextInNextDev(); - } - - return cloudflareContext; -} - -const cloudflareContextInNextDevSymbol = Symbol.for("__next-dev/cloudflare-context__"); - -/** - * Gets a local proxy version of the cloudflare context (created using `getPlatformProxy`) when - * running in the standard next dev server (via `next dev`) - * - * @returns the local proxy version of the cloudflare context - */ -async function getCloudflareContextInNextDev< - CfProperties extends Record = IncomingRequestCfProperties, - Context = ExecutionContext, ->(): Promise> { - const global = globalThis as unknown as { - [cloudflareContextInNextDevSymbol]: CloudflareContext | undefined; - }; - - if (!global[cloudflareContextInNextDevSymbol]) { - // Note: we never want wrangler to be bundled in the Next.js app, that's why the import below looks like it does - const { getPlatformProxy } = await import( - /* webpackIgnore: true */ `${"__wrangler".replaceAll("_", "")}` - ); - const { env, cf, ctx } = await getPlatformProxy({ - // This allows the selection of a wrangler environment while running in next dev mode - environment: process.env.NEXT_DEV_WRANGLER_ENV, - }); - global[cloudflareContextInNextDevSymbol] = { - env, - cf: cf as unknown as CfProperties, - ctx: ctx as Context, - }; - } - - return global[cloudflareContextInNextDevSymbol]!; -} diff --git a/packages/cloudflare/src/api/index.ts b/packages/cloudflare/src/api/index.ts index 5442c415..574ce7de 100644 --- a/packages/cloudflare/src/api/index.ts +++ b/packages/cloudflare/src/api/index.ts @@ -1 +1 @@ -export * from "./get-cloudflare-context.js"; +export * from "./cloudflare-context.js"; diff --git a/packages/cloudflare/src/api/kvCache.ts b/packages/cloudflare/src/api/kvCache.ts index 6e780a9b..5c2652cb 100644 --- a/packages/cloudflare/src/api/kvCache.ts +++ b/packages/cloudflare/src/api/kvCache.ts @@ -2,7 +2,7 @@ import type { KVNamespace } from "@cloudflare/workers-types"; import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides"; import { IgnorableError, RecoverableError } from "@opennextjs/aws/utils/error.js"; -import { getCloudflareContext } from "./get-cloudflare-context.js"; +import { getCloudflareContext } from "./cloudflare-context.js"; export const CACHE_ASSET_DIR = "cnd-cgi/_next_cache";