From 8de2c0422e3d93848371cf07816ccb989070cd62 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz <dario@cloudflare.com> Date: Mon, 27 Jan 2025 10:30:02 +0000 Subject: [PATCH] introduce new `initOpenNextCloudflareForDev` utility and make `getCloudflareContext` synchronous (#265) Co-authored-by: Victor Berchet <victor@suumit.com> --- .changeset/chilly-dryers-begin.md | 28 +++ examples/api/app/api/hello/route.ts | 5 +- examples/api/e2e/playwright.config.ts | 4 +- examples/api/e2e/playwright.dev.config.ts | 3 +- examples/api/next.config.mjs | 4 + .../create-next-app/e2e/playwright.config.ts | 4 +- examples/create-next-app/next.config.mjs | 4 + examples/middleware/app/middleware/page.tsx | 24 ++- .../middleware/e2e/cloudflare-context.spec.ts | 9 + examples/middleware/e2e/playwright.config.ts | 4 +- .../middleware/e2e/playwright.dev.config.ts | 54 ++++++ examples/middleware/middleware.ts | 16 +- examples/middleware/next.config.mjs | 4 + examples/middleware/package.json | 3 +- examples/middleware/wrangler.json | 6 +- package.json | 1 + packages/cloudflare/README.md | 67 +------ .../cloudflare/src/api/cloudflare-context.ts | 164 ++++++++++++++++++ .../src/api/get-cloudflare-context.ts | 86 --------- packages/cloudflare/src/api/index.ts | 2 +- packages/cloudflare/src/api/kvCache.ts | 51 +++--- pnpm-lock.yaml | 22 +-- pnpm-workspace.yaml | 2 +- 23 files changed, 361 insertions(+), 206 deletions(-) create mode 100644 .changeset/chilly-dryers-begin.md create mode 100644 examples/middleware/e2e/cloudflare-context.spec.ts create mode 100644 examples/middleware/e2e/playwright.dev.config.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..40ce3f52 --- /dev/null +++ b/.changeset/chilly-dryers-begin.md @@ -0,0 +1,28 @@ +--- +"@opennextjs/cloudflare": minor +--- + +introduce new `initOpenNextCloudflareForDev` utility and make `getCloudflareContext` synchronous + +this change introduces a new `initOpenNextCloudflareForDev` function that must called in the [Next.js config file](https://nextjs.org/docs/app/api-reference/config/next-config-js) to integrate the Next.js dev server with the open-next Cloudflare adapter. + +Also makes `getCloudflareContext` synchronous. + +Additionally the `getCloudflareContext` can now work during local development (`next dev`) in the edge runtime (including middlewares). + +Moving forward we'll recommend that all applications include the use of the `initOpenNextCloudflareForDev` utility in their config file (there is no downside in doing so and it only effect local development). + +Example: + +```js +// next.config.mjs + +import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; + +initOpenNextCloudflareForDev(); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; +``` diff --git a/examples/api/app/api/hello/route.ts b/examples/api/app/api/hello/route.ts index c42676cc..79a62065 100644 --- a/examples/api/app/api/hello/route.ts +++ b/examples/api/app/api/hello/route.ts @@ -11,9 +11,8 @@ export async function GET() { return new Response("Hello World!"); } - // Retrieve the bindings defined in wrangler.toml - const { env } = await getCloudflareContext(); - return new Response(env.hello); + // Retrieve the bindings defined in wrangler.json + return new Response(getCloudflareContext().env.hello); } export async function POST(request: Request) { diff --git a/examples/api/e2e/playwright.config.ts b/examples/api/e2e/playwright.config.ts index 4bca883e..0f5f526d 100644 --- a/examples/api/e2e/playwright.config.ts +++ b/examples/api/e2e/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +import type nodeProcess from "node:process"; -declare var process: { env: Record<string, string> }; +declare const process: typeof nodeProcess; /** * See https://playwright.dev/docs/test-configuration. @@ -49,5 +50,6 @@ export default defineConfig({ command: "pnpm preview:worker", url: "http://localhost:8770", reuseExistingServer: !process.env.CI, + timeout: 70_000, }, }); diff --git a/examples/api/e2e/playwright.dev.config.ts b/examples/api/e2e/playwright.dev.config.ts index d3d61c03..b14e729e 100644 --- a/examples/api/e2e/playwright.dev.config.ts +++ b/examples/api/e2e/playwright.dev.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +import type nodeProcess from "node:process"; -declare var process: { env: Record<string, string> }; +declare const process: typeof nodeProcess; /** * See https://playwright.dev/docs/test-configuration. diff --git a/examples/api/next.config.mjs b/examples/api/next.config.mjs index 4678774e..24073f0a 100644 --- a/examples/api/next.config.mjs +++ b/examples/api/next.config.mjs @@ -1,3 +1,7 @@ +import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; + +initOpenNextCloudflareForDev(); + /** @type {import('next').NextConfig} */ const nextConfig = {}; diff --git a/examples/create-next-app/e2e/playwright.config.ts b/examples/create-next-app/e2e/playwright.config.ts index 6aad04dd..083a501c 100644 --- a/examples/create-next-app/e2e/playwright.config.ts +++ b/examples/create-next-app/e2e/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +import type nodeProcess from "node:process"; -declare const process: { env: Record<string, string> }; +declare const process: typeof nodeProcess; /** * See https://playwright.dev/docs/test-configuration. @@ -49,5 +50,6 @@ export default defineConfig({ command: "pnpm preview:worker", url: "http://localhost:8771", reuseExistingServer: !process.env.CI, + timeout: 70_000, }, }); diff --git a/examples/create-next-app/next.config.mjs b/examples/create-next-app/next.config.mjs index 4678774e..24073f0a 100644 --- a/examples/create-next-app/next.config.mjs +++ b/examples/create-next-app/next.config.mjs @@ -1,3 +1,7 @@ +import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; + +initOpenNextCloudflareForDev(); + /** @type {import('next').NextConfig} */ const nextConfig = {}; 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 <h1>Via middleware</h1>; + const cloudflareContextHeader = headers().get("x-cloudflare-context"); + + return ( + <> + <h1>Via middleware</h1> + <p> + The value of the <i>x-cloudflare-context</i> header is: <br /> + <span + style={{ + display: "inline-block", + margin: "1rem 2rem", + color: "grey", + fontSize: "1.2rem", + }} + data-testid="cloudflare-context-header" + > + {cloudflareContextHeader} + </span> + </p> + </> + ); } diff --git a/examples/middleware/e2e/cloudflare-context.spec.ts b/examples/middleware/e2e/cloudflare-context.spec.ts new file mode 100644 index 00000000..ed27b846 --- /dev/null +++ b/examples/middleware/e2e/cloudflare-context.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from "@playwright/test"; + +test("middlewares have access to the cloudflare context", async ({ page }) => { + await page.goto("/middleware"); + const cloudflareContextHeaderElement = page.getByTestId("cloudflare-context-header"); + expect(await cloudflareContextHeaderElement.textContent()).toContain( + "typeof `cloudflareContext.env` = object" + ); +}); diff --git a/examples/middleware/e2e/playwright.config.ts b/examples/middleware/e2e/playwright.config.ts index d6d8499c..66c22aa5 100644 --- a/examples/middleware/e2e/playwright.config.ts +++ b/examples/middleware/e2e/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +import type nodeProcess from "node:process"; -declare const process: { env: Record<string, string> }; +declare const process: typeof nodeProcess; /** * See https://playwright.dev/docs/test-configuration. @@ -49,5 +50,6 @@ export default defineConfig({ command: "pnpm preview:worker", url: "http://localhost:8774", reuseExistingServer: !process.env.CI, + timeout: 70_000, }, }); diff --git a/examples/middleware/e2e/playwright.dev.config.ts b/examples/middleware/e2e/playwright.dev.config.ts new file mode 100644 index 00000000..6fabd3da --- /dev/null +++ b/examples/middleware/e2e/playwright.dev.config.ts @@ -0,0 +1,54 @@ +import { defineConfig, devices } from "@playwright/test"; +import type nodeProcess from "node:process"; + +declare const process: typeof nodeProcess; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "http://localhost:3334", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "pnpm dev --port 3334", + url: "http://localhost:3334", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/examples/middleware/middleware.ts b/examples/middleware/middleware.ts index 9145eb73..d7facf06 100644 --- a/examples/middleware/middleware.ts +++ b/examples/middleware/middleware.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse, NextFetchEvent } from "next/server"; import { clerkMiddleware } from "@clerk/nextjs/server"; +import { getCloudflareContext } from "@opennextjs/cloudflare"; + export function middleware(request: NextRequest, event: NextFetchEvent) { console.log("middleware"); if (request.nextUrl.pathname === "/about") { @@ -16,7 +18,19 @@ export function middleware(request: NextRequest, event: NextFetchEvent) { })(request, event); } - return NextResponse.next(); + const requestHeaders = new Headers(request.headers); + const cloudflareContext = getCloudflareContext(); + + requestHeaders.set( + "x-cloudflare-context", + `typeof \`cloudflareContext.env\` = ${typeof cloudflareContext.env}` + ); + + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); } export const config = { diff --git a/examples/middleware/next.config.mjs b/examples/middleware/next.config.mjs index 4678774e..24073f0a 100644 --- a/examples/middleware/next.config.mjs +++ b/examples/middleware/next.config.mjs @@ -1,3 +1,7 @@ +import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; + +initOpenNextCloudflareForDev(); + /** @type {import('next').NextConfig} */ const nextConfig = {}; diff --git a/examples/middleware/package.json b/examples/middleware/package.json index e906c090..4b5dc8de 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -9,7 +9,8 @@ "build:worker": "pnpm opennextjs-cloudflare", "dev:worker": "wrangler dev --port 8774 --inspector-port 9334", "preview:worker": "pnpm build:worker && pnpm dev:worker", - "e2e": "playwright test -c e2e/playwright.config.ts" + "e2e": "playwright test -c e2e/playwright.config.ts", + "e2e:dev": "playwright test -c e2e/playwright.dev.config.ts" }, "dependencies": { "@clerk/nextjs": "6.9.6", diff --git a/examples/middleware/wrangler.json b/examples/middleware/wrangler.json index b9cced50..d4a80fd0 100644 --- a/examples/middleware/wrangler.json +++ b/examples/middleware/wrangler.json @@ -7,5 +7,9 @@ "assets": { "directory": ".open-next/assets", "binding": "ASSETS" - } + }, + "vars": { + "MY_VAR": "my-var" + }, + "kv_namespaces": [{ "binding": "MY_KV", "id": "<id>" }] } diff --git a/package.json b/package.json index 759db9d8..66c00555 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "prettier:fix": "prettier --write .", "lint:check": "pnpm -r lint:check", "lint:fix": "pnpm -r lint:fix", + "fix": "pnpm prettier:fix && pnpm lint:fix", "ts:check": "pnpm -r ts:check", "test": "pnpm -r test", "code:checks": "pnpm prettier:check && pnpm lint:check && pnpm ts:check", diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index fe083089..bfc0336a 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -6,72 +6,7 @@ Deploy Next.js apps to Cloudflare! ## Get started -You can use [`create-next-app`](https://nextjs.org/docs/pages/api-reference/cli/create-next-app) to start a new application or take an existing Next.js application and deploy it to Cloudflare using the following few steps: - -## Configure your app - -- add the following `devDependencies` to the `package.json`: - - ```bash - npm add -D wrangler@latest @opennextjs/cloudflare - # or - pnpm add -D wrangler@latest @opennextjs/cloudflare - # or - yarn add -D wrangler@latest @opennextjs/cloudflare - # or - bun add -D wrangler@latest @opennextjs/cloudflare - ``` - -- add a `wrangler.json` at the root of your project - -```json -{ - "$schema": "node_modules/wrangler/config-schema.json", - "main": ".open-next/worker.js", - "name": "<your-app-name>", - "compatibility_date": "2024-12-30", - "compatibility_flags": ["nodejs_compat"], - "assets": { - "directory": ".open-next/assets", - "binding": "ASSETS" - } -} -``` - -- add a `open-next.config.ts` at the root of your project: - -```ts -import type { OpenNextConfig } from "open-next/types/open-next"; - -const config: OpenNextConfig = { - default: { - override: { - wrapper: "cloudflare-node", - converter: "edge", - // Unused implementation - incrementalCache: "dummy", - tagCache: "dummy", - queue: "dummy", - }, - }, - - middleware: { - external: true, - override: { - wrapper: "cloudflare-edge", - converter: "edge", - proxyExternalRequest: "fetch", - }, - }, -}; - -export default config; -``` - -## Known issues - -- `▲ [WARNING] Suspicious assignment to defined constant "process.env.NODE_ENV" [assign-to-define]` can safely be ignored -- Maybe more, still experimental... +To get started with the adapter visit the [official get started documentation](https://opennext.js.org/cloudflare/get-started). ## Local development diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts new file mode 100644 index 00000000..e9c39edf --- /dev/null +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -0,0 +1,164 @@ +import type { Context, RunningCodeOptions } from "node:vm"; + +declare global { + interface CloudflareEnv { + NEXT_CACHE_WORKERS_KV?: KVNamespace; + ASSETS?: Fetcher; + } +} + +export type CloudflareContext< + CfProperties extends Record<string, unknown> = 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; +}; + +/** + * Symbol used as an index in the global scope to set and retrieve the Cloudflare context + * + * This is used both in production (in the actual built worker) and in development (`next dev`) + * + * 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__"); + +/** + * `globalThis` override for internal usage (simply the standard `globalThis`) enhanced with + * a property indexed by the `cloudflareContextSymbol` + */ +type InternalGlobalThis< + CfProperties extends Record<string, unknown> = IncomingRequestCfProperties, + Context = ExecutionContext, +> = typeof globalThis & { + [cloudflareContextSymbol]: CloudflareContext<CfProperties, Context> | undefined; +}; + +/** + * Utility to get the current Cloudflare context + * + * @returns the cloudflare context + */ +export function getCloudflareContext< + CfProperties extends Record<string, unknown> = IncomingRequestCfProperties, + Context = ExecutionContext, +>(): CloudflareContext<CfProperties, Context> { + const global = globalThis as InternalGlobalThis<CfProperties, Context>; + + const cloudflareContext = global[cloudflareContextSymbol]; + + if (!cloudflareContext) { + // the cloudflare context is initialized by the worker and is always present in production/preview + // during local development (`next dev`) it might be missing only if the developers hasn't called + // the `initOpenNextCloudflareForDev` function in their Next.js config file + const getContextFunctionName = getCloudflareContext.name; + const initFunctionName = initOpenNextCloudflareForDev.name; + throw new Error( + `\n\n\`${getContextFunctionName}\` has been called during development without having called` + + ` the \`${initFunctionName}\` function inside the Next.js config file.\n\n` + + `In order to use \`${getContextFunctionName}\` import and call ${initFunctionName} in the Next.js config file.\n\n` + + "Example: \n ```\n // next.config.mjs\n\n" + + ` import { ${initFunctionName} } from "@opennextjs/cloudflare";\n\n` + + ` ${initFunctionName}();\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" + ); + } + + return cloudflareContext; +} + +/** + * Performs some initial setup to integrate as best as possible the local Next.js dev server (run via `next dev`) + * with the open-next Cloudflare adapter + * + * Note: this function should only be called inside the Next.js config file, and although async it doesn't need to be `await`ed + */ +export async function initOpenNextCloudflareForDev() { + const context = await getCloudflareContextFromWrangler(); + + addCloudflareContextToNodejsGlobal(context); + + await monkeyPatchVmModuleEdgeContext(context); +} + +/** + * Adds the cloudflare context to the global scope in which the Next.js dev node.js process runs in, enabling + * future calls to `getCloudflareContext` to retrieve and return such context + * + * @param cloudflareContext the cloudflare context to add to the node.sj global scope + */ +function addCloudflareContextToNodejsGlobal(cloudflareContext: CloudflareContext<CfProperties, Context>) { + const global = globalThis as InternalGlobalThis<CfProperties, Context>; + global[cloudflareContextSymbol] = cloudflareContext; +} + +/** + * Next.js uses the Node.js vm module's `runInContext()` function to evaluate edge functions + * in a runtime context that tries to simulate as accurately as possible the actual production runtime + * behavior, see: https://github.com/vercel/next.js/blob/9a1cd3/packages/next/src/server/web/sandbox/context.ts#L525-L527 + * + * This function monkey-patches the Node.js `vm` module to override the `runInContext()` function so that the + * cloudflare context is added to the runtime context's global scope before edge functions are evaluated + * + * @param cloudflareContext the cloudflare context to patch onto the "edge" runtime context global scope + */ +async function monkeyPatchVmModuleEdgeContext(cloudflareContext: CloudflareContext<CfProperties, Context>) { + 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); + + vmModule.runInContext = ( + code: string, + contextifiedObject: Context, + options?: RunningCodeOptions | string + ) => { + type RuntimeContext = Record<string, unknown> & { + [cloudflareContextSymbol]?: CloudflareContext<CfProperties, Context>; + }; + const runtimeContext = contextifiedObject as RuntimeContext; + runtimeContext[cloudflareContextSymbol] ??= cloudflareContext; + return originalRunInContext(code, contextifiedObject, options); + }; +} + +/** + * Gets a cloudflare context object from wrangler + * + * @returns the cloudflare context ready for use + */ +async function getCloudflareContextFromWrangler< + CfProperties extends Record<string, unknown> = IncomingRequestCfProperties, + Context = ExecutionContext, +>(): Promise<CloudflareContext<CfProperties, Context>> { + // 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<string, unknown> = 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<string, unknown> = IncomingRequestCfProperties, - Context = ExecutionContext, ->(): Promise<CloudflareContext<CfProperties, Context>> { - const global = globalThis as unknown as { - [cloudflareContextSymbol]: CloudflareContext<CfProperties, Context> | 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<string, unknown> = IncomingRequestCfProperties, - Context = ExecutionContext, ->(): Promise<CloudflareContext<CfProperties, Context>> { - const global = globalThis as unknown as { - [cloudflareContextInNextDevSymbol]: CloudflareContext<CfProperties, Context> | 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..e7203a05 100644 --- a/packages/cloudflare/src/api/kvCache.ts +++ b/packages/cloudflare/src/api/kvCache.ts @@ -1,8 +1,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"; @@ -17,19 +16,16 @@ export const STATUS_DELETED = 1; */ class Cache implements IncrementalCache { readonly name = "cloudflare-kv"; - protected initialized = false; - protected kv: KVNamespace | undefined; - protected assets: Fetcher | undefined; async get<IsFetch extends boolean = false>( key: string, isFetch?: IsFetch ): Promise<WithLastModified<CacheValue<IsFetch>>> { - if (!this.initialized) { - await this.init(); - } + const cfEnv = getCloudflareContext().env; + const kv = cfEnv.NEXT_CACHE_WORKERS_KV; + const assets = cfEnv.ASSETS; - if (!(this.kv || this.assets)) { + if (!(kv || assets)) { throw new IgnorableError(`No KVNamespace nor Fetcher`); } @@ -42,19 +38,19 @@ class Cache implements IncrementalCache { status?: number; } | null = null; - if (this.kv) { + if (kv) { this.debug(`- From KV`); const kvKey = this.getKVKey(key, isFetch); - entry = await this.kv.get(kvKey, "json"); + entry = await kv.get(kvKey, "json"); if (entry?.status === STATUS_DELETED) { return {}; } } - if (!entry && this.assets) { + if (!entry && assets) { this.debug(`- From Assets`); const url = this.getAssetUrl(key, isFetch); - const response = await this.assets.fetch(url); + const response = await assets.fetch(url); if (response.ok) { // TODO: consider populating KV with the asset value if faster. // This could be optional as KV writes are $$. @@ -78,19 +74,20 @@ class Cache implements IncrementalCache { value: CacheValue<IsFetch>, isFetch?: IsFetch ): Promise<void> { - if (!this.initialized) { - await this.init(); - } - if (!this.kv) { + const kv = getCloudflareContext().env.NEXT_CACHE_WORKERS_KV; + + if (!kv) { throw new IgnorableError(`No KVNamespace`); } + this.debug(`Set ${key}`); + try { const kvKey = this.getKVKey(key, isFetch); // Note: We can not set a TTL as we might fallback to assets, // still removing old data (old BUILD_ID) could help avoiding // the cache growing too big. - await this.kv.put( + await kv.put( kvKey, JSON.stringify({ value, @@ -105,17 +102,18 @@ class Cache implements IncrementalCache { } async delete(key: string): Promise<void> { - if (!this.initialized) { - await this.init(); - } - if (!this.kv) { + const kv = getCloudflareContext().env.NEXT_CACHE_WORKERS_KV; + + if (!kv) { throw new IgnorableError(`No KVNamespace`); } + this.debug(`Delete ${key}`); + try { const kvKey = this.getKVKey(key, /* isFetch= */ false); // Do not delete the key as we would then fallback to the assets. - await this.kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED })); + await kv.put(kvKey, JSON.stringify({ status: STATUS_DELETED })); } catch { throw new RecoverableError(`Failed to delete cache [${key}]`); } @@ -140,13 +138,6 @@ class Cache implements IncrementalCache { protected getBuildId() { return process.env.NEXT_BUILD_ID ?? "no-build-id"; } - - protected async init() { - const env = (await getCloudflareContext()).env; - this.kv = env.NEXT_CACHE_WORKERS_KV; - this.assets = env.ASSETS; - this.initialized = true; - } } export default new Cache(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2068aee2..eac18364 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,8 +85,8 @@ catalogs: specifier: ^2.1.1 version: 2.1.1 wrangler: - specifier: ^3.103.0 - version: 3.104.0 + specifier: ^3.105.0 + version: 3.105.0 e2e: '@types/node': specifier: 20.17.6 @@ -171,7 +171,7 @@ importers: version: 22.2.0 wrangler: specifier: 'catalog:' - version: 3.104.0(@cloudflare/workers-types@4.20250109.0) + version: 3.105.0(@cloudflare/workers-types@4.20250109.0) examples/create-next-app: dependencies: @@ -217,7 +217,7 @@ importers: version: 5.7.3 wrangler: specifier: 'catalog:' - version: 3.104.0(@cloudflare/workers-types@4.20250109.0) + version: 3.105.0(@cloudflare/workers-types@4.20250109.0) examples/e2e/app-router: dependencies: @@ -316,7 +316,7 @@ importers: version: 5.7.3 wrangler: specifier: 'catalog:' - version: 3.104.0(@cloudflare/workers-types@4.20250109.0) + version: 3.105.0(@cloudflare/workers-types@4.20250109.0) examples/vercel-blog-starter: dependencies: @@ -371,7 +371,7 @@ importers: version: 5.7.3 wrangler: specifier: 'catalog:' - version: 3.104.0(@cloudflare/workers-types@4.20250109.0) + version: 3.105.0(@cloudflare/workers-types@4.20250109.0) examples/vercel-commerce: dependencies: @@ -438,7 +438,7 @@ importers: version: 5.7.3 wrangler: specifier: 'catalog:' - version: 3.104.0(@cloudflare/workers-types@4.20250109.0) + version: 3.105.0(@cloudflare/workers-types@4.20250109.0) packages/cloudflare: dependencies: @@ -462,7 +462,7 @@ importers: version: 23.0.0 wrangler: specifier: 'catalog:' - version: 3.104.0(@cloudflare/workers-types@4.20250109.0) + version: 3.105.0(@cloudflare/workers-types@4.20250109.0) yaml: specifier: ^2.7.0 version: 2.7.0 @@ -6085,8 +6085,8 @@ packages: engines: {node: '>=16'} hasBin: true - wrangler@3.104.0: - resolution: {integrity: sha512-txxgkKZwPQrX1PDgY+ATWnnx4GSeNxUrnBumudWPRmXG0JdLzCf09R+723slMMT1m+CKQXU1KvuUHc/GxTnTyA==} + wrangler@3.105.0: + resolution: {integrity: sha512-NX10iuUXtgiVRG9YJ7dwwEUuhQ38hu4stcxMWq4dbKCnfcOj7fLFh+HwaWudqOr1jDCPrnSOQVkgfAfG3ZH9Lw==} engines: {node: '>=16.17.0'} hasBin: true peerDependencies: @@ -13347,7 +13347,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20241230.0 '@cloudflare/workerd-windows-64': 1.20241230.0 - wrangler@3.104.0(@cloudflare/workers-types@4.20250109.0): + wrangler@3.105.0(@cloudflare/workers-types@4.20250109.0): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b406d915..4c4b5b30 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,7 +33,7 @@ catalog: typescript-eslint: ^8.7.0 typescript: ^5.7.3 vitest: ^2.1.1 - wrangler: ^3.103.0 + wrangler: ^3.105.0 # e2e tests catalogs: