···6565 CACHE_PURGE_ZONE_ID?: string;
6666 // The API token to use for the cache purge. It should have the `Cache Purge` permission
6767 CACHE_PURGE_API_TOKEN?: string;
6868+6969+ // The following variables must be provided when skew protection is enabled
7070+ // The name of the worker (as defined in the wrangler configuration)
7171+ // When a specific wrangler environment is used, it should be appended at the end:
7272+ // - Use `worker-name` when no wrangler environment is used
7373+ // - Use `worker-name-<environment>` when a wrangler environment is used via `wrangler --env=<environment>`
7474+ CF_WORKER_NAME?: string;
7575+ // The subdomain where the previews are deployed, i.e. `<version-name>.<domain>.workers.dev`
7676+ CF_PREVIEW_DOMAIN?: string;
7777+ // Should have the `Workers Scripts:Read` permission
7878+ CF_WORKERS_SCRIPTS_API_TOKEN?: string;
7979+ // Cloudflare account id
8080+ CF_ACCOUNT_ID?: string;
6881 }
6982}
7083
+25
packages/cloudflare/src/api/config.ts
···161161 * @default false
162162 */
163163 dangerousDisableConfigValidation?: boolean;
164164+165165+ /**
166166+ * Skew protection.
167167+ *
168168+ * Note: Skew Protection is experimental and might break on minor releases.
169169+ *
170170+ * @default false
171171+ */
172172+ skewProtection?: {
173173+ // Whether to enable skew protection
174174+ enabled?: boolean;
175175+ // Maximum number of versions to retrieve
176176+ // @default 20
177177+ maxNumberOfVersions?: number;
178178+ // Maximum age of versions to retrieve in days
179179+ // @default 7
180180+ maxVersionAgeDays?: number;
181181+ };
164182 };
165183}
166184···170188 */
171189export function getOpenNextConfig(buildOpts: BuildOptions): OpenNextConfig {
172190 return buildOpts.config;
191191+}
192192+193193+/**
194194+ * @returns Unique deployment ID
195195+ */
196196+export function getDeploymentId(): string {
197197+ return `dpl-${new Date().getTime().toString(36)}`;
173198}
174199175200export type { OpenNextConfig };
+1-1
packages/cloudflare/src/api/index.ts
···11export * from "./cloudflare-context.js";
22-export { defineCloudflareConfig, type OpenNextConfig } from "./config.js";
22+export { defineCloudflareConfig, getDeploymentId, type OpenNextConfig } from "./config.js";
···11+/**
22+ * We need to maintain a mapping of deployment id to worker version for skew protection.
33+ *
44+ * The mapping is used to request the correct version of the workers when Next attaches a deployment id to a request.
55+ *
66+ * The mapping is stored in a worker en var:
77+ *
88+ * {
99+ * latestDepId: "current",
1010+ * depIdx: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
1111+ * depIdy: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
1212+ * depIdz: "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
1313+ * }
1414+ *
1515+ * Note that the latest version is not known at build time as the version id only gets created on deployment.
1616+ * This is why we use the "current" placeholder.
1717+ *
1818+ * When a new version is deployed:
1919+ * - "current" is replaced with the latest version of the Worker
2020+ * - a new entry is added for the new deployment id with the "current" version
2121+ */
2222+2323+// re-enable when types are fixed in the cloudflare lib
2424+/* eslint-disable @typescript-eslint/no-explicit-any */
2525+import path from "node:path";
2626+2727+import { loadConfig } from "@opennextjs/aws/adapters/config/util.js";
2828+import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
2929+import logger from "@opennextjs/aws/logger.js";
3030+import { Cloudflare, NotFoundError } from "cloudflare";
3131+import type { VersionGetResponse } from "cloudflare/resources/workers/scripts/versions";
3232+3333+import type { OpenNextConfig } from "../../api";
3434+import { CURRENT_VERSION_ID, DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js";
3535+import type { WorkerEnvVar } from "./helpers.js";
3636+3737+/** Maximum number of versions to list */
3838+const MAX_NUMBER_OF_VERSIONS = 20;
3939+/** Maximum age of versions to list */
4040+const MAX_VERSION_AGE_DAYS = 7;
4141+const MS_PER_DAY = 24 * 3600 * 1000;
4242+4343+/**
4444+ * Compute the deployment mapping for a deployment.
4545+ *
4646+ * @param options Build options
4747+ * @param config OpenNext config
4848+ * @param envVars Environment variables
4949+ * @returns Deployment mapping or undefined
5050+ */
5151+export async function getDeploymentMapping(
5252+ options: BuildOptions,
5353+ config: OpenNextConfig,
5454+ envVars: WorkerEnvVar
5555+): Promise<Record<string, string> | undefined> {
5656+ if (config.cloudflare?.skewProtection?.enabled !== true) {
5757+ return undefined;
5858+ }
5959+6060+ const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
6161+ const deploymentId = nextConfig.deploymentId;
6262+6363+ if (!deploymentId) {
6464+ logger.error("Deployment ID should be set in the Next config when skew protection is enabled");
6565+ process.exit(1);
6666+ }
6767+6868+ if (!envVars.CF_WORKER_NAME) {
6969+ logger.error("CF_WORKER_NAME should be set when skew protection is enabled");
7070+ process.exit(1);
7171+ }
7272+7373+ if (!envVars.CF_PREVIEW_DOMAIN) {
7474+ logger.error("CF_PREVIEW_DOMAIN should be set when skew protection is enabled");
7575+ process.exit(1);
7676+ }
7777+7878+ if (!envVars.CF_WORKERS_SCRIPTS_API_TOKEN) {
7979+ logger.error("CF_WORKERS_SCRIPTS_API_TOKEN should be set when skew protection is enabled");
8080+ process.exit(1);
8181+ }
8282+8383+ if (!envVars.CF_ACCOUNT_ID) {
8484+ logger.error("CF_ACCOUNT_ID should be set when skew protection is enabled");
8585+ process.exit(1);
8686+ }
8787+8888+ const apiToken = envVars.CF_WORKERS_SCRIPTS_API_TOKEN!;
8989+ const accountId = envVars.CF_ACCOUNT_ID!;
9090+9191+ const client = new Cloudflare({ apiToken });
9292+ const scriptName = envVars.CF_WORKER_NAME!;
9393+9494+ const deployedVersions = await listWorkerVersions(scriptName, {
9595+ client,
9696+ accountId,
9797+ maxNumberOfVersions: config.cloudflare?.skewProtection?.maxNumberOfVersions,
9898+ afterTimeMs: config.cloudflare?.skewProtection?.maxVersionAgeDays
9999+ ? Date.now() - config.cloudflare?.skewProtection?.maxVersionAgeDays * MS_PER_DAY
100100+ : undefined,
101101+ });
102102+103103+ const existingMapping =
104104+ deployedVersions.length === 0
105105+ ? {}
106106+ : await getExistingDeploymentMapping(scriptName, deployedVersions[0]!.id, {
107107+ client,
108108+ accountId,
109109+ });
110110+111111+ if (deploymentId in existingMapping) {
112112+ logger.error(
113113+ `The deploymentId "${deploymentId}" has been used previously, update your next config and rebuild`
114114+ );
115115+ process.exit(1);
116116+ }
117117+118118+ const mapping = updateDeploymentMapping(existingMapping, deployedVersions, deploymentId);
119119+120120+ return mapping;
121121+}
122122+123123+/**
124124+ * Update an existing deployment mapping:
125125+ * - Replace the "current" version with the latest deployed version
126126+ * - Add a "current" version for the current deployment ID
127127+ * - Remove versions that are not passed in
128128+ *
129129+ * @param mapping Existing mapping
130130+ * @param versions Versions ordered by descending time
131131+ * @param deploymentId Deployment ID
132132+ * @returns The updated mapping
133133+ */
134134+export function updateDeploymentMapping(
135135+ mapping: Record<string, string>,
136136+ versions: { id: string }[],
137137+ deploymentId: string
138138+): Record<string, string> {
139139+ const newMapping: Record<string, string> = { [deploymentId]: CURRENT_VERSION_ID };
140140+ const versionIds = new Set(versions.map((v) => v.id));
141141+142142+ for (const [deployment, version] of Object.entries(mapping)) {
143143+ if (version === CURRENT_VERSION_ID && versions.length > 0) {
144144+ newMapping[deployment] = versions[0]!.id;
145145+ } else if (versionIds.has(version)) {
146146+ newMapping[deployment] = version;
147147+ }
148148+ }
149149+150150+ return newMapping;
151151+}
152152+153153+/**
154154+ * Retrieve the deployment mapping from the last deployed worker.
155155+ *
156156+ * NOTE: it is retrieved from the DEPLOYMENT_MAPPING_ENV_NAME env var.
157157+ *
158158+ * @param scriptName The name of the worker script
159159+ * @param versionId The version Id to retrieve
160160+ * @param options.client A Cloudflare API client
161161+ * @param options.accountId The Cloudflare account id
162162+ * @returns The deployment mapping
163163+ */
164164+async function getExistingDeploymentMapping(
165165+ scriptName: string,
166166+ versionId: string,
167167+ options: {
168168+ client: Cloudflare;
169169+ accountId: string;
170170+ }
171171+): Promise<Record<string, string>> {
172172+ // See https://github.com/cloudflare/cloudflare-typescript/issues/2652
173173+ const bindings =
174174+ ((await getVersionDetail(scriptName, versionId, options)).resources.bindings as any[]) ?? [];
175175+176176+ for (const binding of bindings) {
177177+ if (binding.name === DEPLOYMENT_MAPPING_ENV_NAME && binding.type == "plain_text") {
178178+ return JSON.parse(binding.text);
179179+ }
180180+ }
181181+182182+ return {};
183183+}
184184+185185+/**
186186+ * Retrieve the details of the version of a script
187187+ *
188188+ * @param scriptName The name of the worker script
189189+ * @param versionId The version Id to retrieve
190190+ * @param options.client A Cloudflare API client
191191+ * @param options.accountId The Cloudflare account id
192192+193193+ * @returns the version information
194194+ */
195195+async function getVersionDetail(
196196+ scriptName: string,
197197+ versionId: string,
198198+ options: {
199199+ client: Cloudflare;
200200+ accountId: string;
201201+ }
202202+): Promise<VersionGetResponse> {
203203+ const { client, accountId } = options;
204204+ return await client.workers.scripts.versions.get(scriptName, versionId, {
205205+ account_id: accountId,
206206+ });
207207+}
208208+209209+/**
210210+ * Retrieve the versions for the script
211211+ *
212212+ * @param scriptName The name of the worker script
213213+ * @param options.client A Cloudflare API client
214214+ * @param options.accountId The Cloudflare account id
215215+ * @param options.afterTimeMs Only list version more recent than this time - default to 7 days
216216+ * @param options.maxNumberOfVersions The maximum number of version to return - default to 20 versions.
217217+ * @returns A list of id and creation date ordered by descending creation date
218218+ */
219219+export async function listWorkerVersions(
220220+ scriptName: string,
221221+ options: {
222222+ client: Cloudflare;
223223+ accountId: string;
224224+ afterTimeMs?: number;
225225+ maxNumberOfVersions?: number;
226226+ }
227227+): Promise<{ id: string; createdOnMs: number }[]> {
228228+ const versions = [];
229229+ const {
230230+ client,
231231+ accountId,
232232+ afterTimeMs = new Date().getTime() - MAX_VERSION_AGE_DAYS * 24 * 3600 * 1000,
233233+ maxNumberOfVersions = MAX_NUMBER_OF_VERSIONS,
234234+ } = options;
235235+236236+ try {
237237+ for await (const version of client.workers.scripts.versions.list(scriptName, {
238238+ account_id: accountId,
239239+ })) {
240240+ const id = version.id;
241241+ const createdOn = version.metadata?.created_on;
242242+243243+ if (id && createdOn) {
244244+ const createdOnMs = new Date(createdOn).getTime();
245245+ if (createdOnMs < afterTimeMs) {
246246+ break;
247247+ }
248248+ versions.push({ id, createdOnMs });
249249+ if (versions.length >= maxNumberOfVersions) {
250250+ break;
251251+ }
252252+ }
253253+ }
254254+ } catch (e) {
255255+ if (e instanceof NotFoundError && e.status === 404) {
256256+ // The worker has not been deployed before, no previous versions.
257257+ return [];
258258+ }
259259+ throw e;
260260+ }
261261+262262+ return versions.sort((a, b) => b.createdOnMs - a.createdOnMs);
263263+}
···11+import process from "node:process";
22+33+/** Name of the env var containing the mapping */
44+export const DEPLOYMENT_MAPPING_ENV_NAME = "CF_DEPLOYMENT_MAPPING";
55+/** Version used for the latest worker */
66+export const CURRENT_VERSION_ID = "current";
77+88+/**
99+ * Routes the request to the requested deployment.
1010+ *
1111+ * A specific deployment can be requested via:
1212+ * - the `dpl` search parameter for assets
1313+ * - the `x-deployment-id` for other requests
1414+ *
1515+ * When a specific deployment is requested, we route to that deployment via the preview URLs.
1616+ * See https://developers.cloudflare.com/workers/configuration/previews/
1717+ *
1818+ * When the requested deployment is not supported a 400 response is returned.
1919+ *
2020+ * Notes:
2121+ * - The re-routing is only active for the deployed version of the app (on a custom domain)
2222+ * - Assets are also handled when `run_worker_first` is enabled.
2323+ * See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first
2424+ *
2525+ * @param request
2626+ * @returns
2727+ */
2828+export function maybeGetSkewProtectionResponse(request: Request): Promise<Response> | Response | undefined {
2929+ // no early return as esbuild would not treeshake the code.
3030+ if (__SKEW_PROTECTION_ENABLED__) {
3131+ const url = new URL(request.url);
3232+3333+ // Skew protection is only active for the latest version of the app served on a custom domain.
3434+ if (url.hostname === "localhost" || url.hostname.endsWith(".workers.dev")) {
3535+ return undefined;
3636+ }
3737+3838+ const requestDeploymentId = request.headers.get("x-deployment-id") ?? url.searchParams.get("dpl");
3939+4040+ if (!requestDeploymentId || requestDeploymentId === process.env.DEPLOYMENT_ID) {
4141+ // The request does not specify a deployment id or it is the current deployment id
4242+ return undefined;
4343+ }
4444+4545+ const mapping = process.env[DEPLOYMENT_MAPPING_ENV_NAME]
4646+ ? JSON.parse(process.env[DEPLOYMENT_MAPPING_ENV_NAME])
4747+ : {};
4848+4949+ if (!(requestDeploymentId in mapping)) {
5050+ // Unknown deployment id, serve the current version
5151+ return undefined;
5252+ }
5353+5454+ const version = mapping[requestDeploymentId];
5555+5656+ if (!version || version === CURRENT_VERSION_ID) {
5757+ return undefined;
5858+ }
5959+6060+ const versionDomain = version.split("-")[0];
6161+ const hostname = `${versionDomain}-${process.env.CF_WORKER_NAME}.${process.env.CF_PREVIEW_DOMAIN}.workers.dev`;
6262+ url.hostname = hostname;
6363+ const requestToOlderDeployment = new Request(url!, request);
6464+6565+ return fetch(requestToOlderDeployment);
6666+ }
6767+}
6868+6969+/* eslint-disable no-var */
7070+declare global {
7171+ // Replaced at build time with the value from Open Next config
7272+ var __SKEW_PROTECTION_ENABLED__: boolean;
7373+}
7474+/* eslint-enable no-var */
+8
packages/cloudflare/src/cli/templates/worker.ts
···22import { fetchImage } from "./cloudflare/images.js";
33//@ts-expect-error: Will be resolved by wrangler build
44import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
55+//@ts-expect-error: Will be resolved by wrangler build
66+import { maybeGetSkewProtectionResponse } from "./cloudflare/skew-protection.js";
57// @ts-expect-error: Will be resolved by wrangler build
68import { handler as middlewareHandler } from "./middleware/handler.mjs";
79···1517export default {
1618 async fetch(request, env, ctx) {
1719 return runWithCloudflareRequestContext(request, env, ctx, async () => {
2020+ const response = maybeGetSkewProtectionResponse(request);
2121+2222+ if (response) {
2323+ return response;
2424+ }
2525+1826 const url = new URL(request.url);
19272028 // Serve images in development.