Skip to content

Commit

Permalink
enable getCloudflareContext to work in middlewares via a new `enabl…
Browse files Browse the repository at this point in the history
…eEdgeDevGetCloudflareContext` utility
  • Loading branch information
dario-piotrowicz committed Jan 20, 2025
1 parent 0b54cf4 commit 59419ca
Show file tree
Hide file tree
Showing 13 changed files with 301 additions and 93 deletions.
30 changes: 30 additions & 0 deletions .changeset/chilly-dryers-begin.md
Original file line number Diff line number Diff line change
@@ -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
24 changes: 23 additions & 1 deletion examples/middleware/app/middleware/page.tsx
Original file line number Diff line number Diff line change
@@ -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>
</>
);
}
14 changes: 14 additions & 0 deletions examples/middleware/e2e/cloudflare-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { test, expect } from "@playwright/test";

test("cloudflare context env object is populated", async ({ page }) => {
await page.goto("/middleware");
const cloudflareContextHeaderElement = page.getByTestId("cloudflare-context-header");
// Note: the text in the span is "keys of `cloudflareContext.env`: MY_VAR, MY_KV, ASSETS" for previews
// and "keys of `cloudflareContext.env`: MY_VAR, MY_KV" in dev (`next dev`)
// that's why we use `toContain` instead of `toEqual`, this is incorrect and the `ASSETS` binding
// should ideally also be part of the dev cloudflare context
// (this is an upstream wrangler issue: https://github.com/cloudflare/workers-sdk/issues/7812)
expect(await cloudflareContextHeaderElement.textContent()).toContain(
"keys of `cloudflareContext.env`: MY_VAR, MY_KV"
);
});
3 changes: 3 additions & 0 deletions examples/middleware/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,8 @@ export default defineConfig({
command: "pnpm preview:worker",
url: "http://localhost:8774",
reuseExistingServer: !process.env.CI,
// the app uses the `enableEdgeDevGetCloudflareContext` which apparently causes the boot up to
// take slightly longer, that's why we need a longer timeout here (we just add 10 seconds to the default 60)
timeout: 60 * 1000 + 10 * 1000,
},
});
53 changes: 53 additions & 0 deletions examples/middleware/e2e/playwright.dev.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { defineConfig, devices } from "@playwright/test";

declare var process: { env: Record<string, string> };

/**
* 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,
},
});
18 changes: 16 additions & 2 deletions examples/middleware/middleware.ts
Original file line number Diff line number Diff line change
@@ -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));
Expand All @@ -16,7 +18,19 @@ 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 = {
Expand Down
4 changes: 4 additions & 0 deletions examples/middleware/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { enableEdgeDevGetCloudflareContext } from "@opennextjs/cloudflare";

/** @type {import('next').NextConfig} */
const nextConfig = {};

enableEdgeDevGetCloudflareContext();

export default nextConfig;
3 changes: 2 additions & 1 deletion examples/middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion examples/middleware/wrangler.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
}
},
"vars": {
"MY_VAR": "my-var"
},
"kv_namespaces": [{ "binding": "MY_KV", "id": "<id>" }]
}
149 changes: 149 additions & 0 deletions packages/cloudflare/src/api/cloudflare-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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]) {
try {
const cloudflareContext = await getCloudflareContextFromWrangler<CfProperties, Context>();
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<string, unknown> & {
process?: { env?: Record<string | symbol, unknown> };
[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<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,
};
}
Loading

0 comments on commit 59419ca

Please sign in to comment.