Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,9 @@ jobs:
- name: Run E2E Test in Cloudflare Environment
shell: bash
run: pnpm turbo e2e:cf

- name: Run Cloudflare Container E2E Test
shell: bash
run: |
pnpm --filter examples-cloudflare/e2e-app-router build:worker:container
pnpm --filter examples-cloudflare/e2e-app-router e2e:container
33 changes: 26 additions & 7 deletions examples-cloudflare/common/config-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,21 @@ import { getAppPort, getInspectorPort, type AppName } from "./apps";

declare const process: typeof nodeProcess;

export function configurePlaywright(
app: AppName,
{
type ConfigurePlaywrightOptions = {
/** Whether the Playwright run is executing in CI. */
isCI?: boolean;
/** Whether the app runs in a Worker instead of through `next dev`. */
isWorker?: boolean;
multipleBrowsers?: boolean;
parallel?: boolean;
useTurbopack?: boolean;
workerBuildScript?: string;
workerPreviewScript?: string;
testMatch?: string | string[];
};

export function configurePlaywright(app: AppName, options: ConfigurePlaywrightOptions = {}) {
const {
// Do we run on CI?
isCI = Boolean(process.env.CI),
// Do we run on workers (`wrangler dev`) or on Node (`next dev`)
Expand All @@ -17,19 +29,25 @@ export function configurePlaywright(
parallel = true,
// Use the turbopack runtime
useTurbopack = false,
} = {}
) {
// Script used to build the Worker before starting it
workerBuildScript = "build:worker",
// Script used to start the Worker
workerPreviewScript = "preview:worker",
// Test file or glob to run
testMatch,
} = options;
const port = getAppPort(app, { isWorker });
const inspectorPort = getInspectorPort(app);
const baseURL = `http://localhost:${port}`;
let command: string;
let timeout: number;
if (isWorker) {
// Do not build on CI - there is a preceding build step
command = isCI ? "" : `pnpm ${useTurbopack ? "build:worker-turbopack" : "build:worker"} && `;
const buildScript = useTurbopack ? "build:worker-turbopack" : workerBuildScript;
command = isCI ? "" : `pnpm ${buildScript} && `;

const env = app === "r2-incremental-cache" ? "--env e2e" : "";
command += `pnpm preview:worker -- --port ${port} --inspector-port ${inspectorPort} ${env}`;
command += `pnpm ${workerPreviewScript} -- --port ${port} --inspector-port ${inspectorPort} ${env}`;
timeout = 800_000;
} else {
timeout = 100_000;
Expand Down Expand Up @@ -63,6 +81,7 @@ export function configurePlaywright(
testIgnore: isWorker ? "*next.spec.ts" : "*cloudflare.spec.ts",
/* Run tests in files in parallel */
fullyParallel: parallel,
...(testMatch ? { testMatch } : {}),
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: isCI,
/* Retry on CI only */
Expand Down
14 changes: 14 additions & 0 deletions examples-cloudflare/e2e/app-router/e2e/container.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, test } from "@playwright/test";

test("serves SSR from the Node.js container", async ({ page }) => {
await page.goto("/ssr");

await expect(page.getByText("Time:")).toBeVisible();
});

test("runs external middleware before forwarding to the container", async ({ page }) => {
await page.goto("/rewrite");

await expect(page).toHaveURL(/\/rewrite$/);
await expect(page.getByText("Rewritten Destination", { exact: true })).toBeVisible();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { configurePlaywright } from "../../../common/config-e2e";

export default configurePlaywright("app-router", {
useTurbopack: false,
workerBuildScript: "build:worker:container",
workerPreviewScript: "preview:container",
testMatch: "container.test.ts",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";

export default defineCloudflareConfig({
container: true,
});
3 changes: 3 additions & 0 deletions examples-cloudflare/e2e/app-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
"lint": "next lint",
"clean": "rm -rf .turbo node_modules .next .open-next",
"build:worker:cf": "pnpm opennextjs-cloudflare build",
"build:worker:container": "pnpm opennextjs-cloudflare build --openNextConfigPath open-next.container.config.ts --config wrangler.container.jsonc",
"preview:worker": "pnpm opennextjs-cloudflare preview",
"preview:container": "pnpm opennextjs-cloudflare preview --config wrangler.container.jsonc",
"preview": "pnpm build:worker && pnpm preview:worker",
"e2e:cf": "playwright test -c e2e/playwright.config.ts",
"e2e:container": "playwright test -c e2e/playwright.container.config.ts",
"build:worker-turbopack": "pnpm build:worker --openNextConfigPath open-next.turbopack.config.ts",
"e2e-turbopack": "playwright test -c e2e/playwright.turbopack.config.ts"
},
Expand Down
33 changes: 33 additions & 0 deletions examples-cloudflare/e2e/app-router/wrangler.container.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "app-router-container",
"compatibility_date": "2024-12-30",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
},
"containers": [
{
"class_name": "OpenNextContainer",
"image": ".open-next/server-functions/default/Dockerfile",
"max_instances": 1,
"instance_type": "lite",
},
],
"durable_objects": {
"bindings": [
{
"name": "OPEN_NEXT_CONTAINER",
"class_name": "OpenNextContainer",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["OpenNextContainer"],
},
],
}
1 change: 1 addition & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
},
"dependencies": {
"@ast-grep/napi": "0.40.5",
"@cloudflare/containers": "^0.3.7",
"@dotenvx/dotenvx": "catalog:",
"@opennextjs/core": "workspace:*",
"cloudflare": "^4.4.1",
Expand Down
47 changes: 47 additions & 0 deletions packages/cloudflare/src/api/config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";

import { defineCloudflareConfig } from "./config.js";

describe("defineCloudflareConfig", () => {
test("uses the Worker defaults by default", () => {
const config = defineCloudflareConfig();

expect(config.cloudflare?.container).toBe(false);
expect(config.default.override).toMatchObject({
wrapper: "cloudflare-node",
converter: "edge",
proxyExternalRequest: "fetch",
});
});

test("configures a Node.js container default function", () => {
const config = defineCloudflareConfig({ container: true });

expect(config.cloudflare?.container).toBe(true);
expect(config.default.override).toEqual({
wrapper: "node",
converter: "node",
generateDockerfile: true,
incrementalCache: "dummy",
tagCache: "dummy",
queue: "dummy",
});
expect(config.middleware).toMatchObject({
external: true,
override: {
wrapper: "cloudflare-edge",
converter: "edge",
proxyExternalRequest: "fetch",
},
});
});

test("rejects Cloudflare binding-backed overrides for containers", () => {
expect(() =>
defineCloudflareConfig({
container: true,
incrementalCache: () => ({ name: "unsupported" }) as never,
})
).toThrow("Container mode");
});
});
61 changes: 52 additions & 9 deletions packages/cloudflare/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ export type Override<T extends BaseOverride> = "dummy" | T | LazyLoadedOverride<
* See the [Caching documentation](https://opennext.js.org/cloudflare/caching))
*/
export type CloudflareOverrides = {
/**
* Run the default Next.js server in a Cloudflare Container.
*
* The Cloudflare Worker continues to run the external middleware and forwards
* requests that reach the default server to one container instance.
*
* Cloudflare binding-backed caches are not available from the Node.js
* container in this mode.
*/
container?: true;

/**
* Sets the incremental cache implementation.
*/
Expand Down Expand Up @@ -57,25 +68,51 @@ export type CloudflareOverrides = {
* @returns the OpenNext configuration object
*/
export function defineCloudflareConfig(config: CloudflareOverrides = {}): OpenNextConfig {
const { incrementalCache, tagCache, queue, cachePurge, routePreloadingBehavior = "none" } = config;

return {
default: {
override: {
wrapper: "cloudflare-node",
converter: "edge",
proxyExternalRequest: "fetch",
const {
container = false,
incrementalCache,
tagCache,
queue,
cachePurge,
routePreloadingBehavior = "none",
} = config;
if (
container &&
[incrementalCache, tagCache, queue, cachePurge].some((value) => value !== undefined && value !== "dummy")
) {
throw new Error(
"Cloudflare Container mode only supports the default dummy cache, tag cache, queue, and cache purge overrides."
);
}
const defaultOverride = container
? {
wrapper: "node" as const,
converter: "node" as const,
generateDockerfile: true,
incrementalCache: "dummy" as const,
tagCache: "dummy" as const,
queue: "dummy" as const,
}
: {
wrapper: "cloudflare-node" as const,
converter: "edge" as const,
proxyExternalRequest: "fetch" as const,
incrementalCache: resolveIncrementalCache(incrementalCache),
tagCache: resolveTagCache(tagCache),
queue: resolveQueue(queue),
cdnInvalidation: resolveCdnInvalidation(cachePurge),
},
};

return {
default: {
override: defaultOverride,
routePreloadingBehavior,
},
// node:crypto is used to compute cache keys
edgeExternals: ["node:crypto"],
cloudflare: {
useWorkerdCondition: true,
container,
},
middleware: {
external: true,
Expand Down Expand Up @@ -126,6 +163,12 @@ function resolveCdnInvalidation(value: CloudflareOverrides["cachePurge"] = "dumm

interface OpenNextConfig extends AwsOpenNextConfig {
cloudflare?: {
/**
* Whether the default function runs in a Cloudflare Container.
* @default false
*/
container?: boolean;

/**
* Whether to use the "workerd" build conditions when bundling the server.
* It is recommended to set it to `true` so that code specifically targeted to the
Expand Down
37 changes: 37 additions & 0 deletions packages/cloudflare/src/api/container.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Container, getContainer } from "@cloudflare/containers";

export const OPEN_NEXT_CONTAINER_BINDING = "OPEN_NEXT_CONTAINER";
export const OPEN_NEXT_CONTAINER_NAME = "default";

/**
* The Durable Object controller for the Node.js OpenNext server container.
*
* The matching Wrangler configuration must declare this class as a container
* and bind it as `OPEN_NEXT_CONTAINER`.
*/
export class OpenNextContainer extends Container {
override defaultPort = 3000;

/**
* Normalize the Durable Object request before forwarding it to the Container.
*
* In local workerd, the request received by a Durable Object can originate
* from another runtime realm. The Container base class checks it with
* `instanceof Request`, which then fails and coerces it to "[object Request]".
*/
override fetch(request: Request): Promise<Response> {
return this.containerFetch(
request.url,
{
method: request.method,
headers: request.headers,
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
},
this.defaultPort
);
}
}

export function getOpenNextContainer(containerNamespace: DurableObjectNamespace<OpenNextContainer>) {
return getContainer(containerNamespace, OPEN_NEXT_CONTAINER_NAME);
}
Loading
Loading