Skip to content

feat: add support for skew protection #746

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 10, 2025
Merged
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
5 changes: 5 additions & 0 deletions .changeset/curly-lions-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": minor
---

feat: add support for experimental skew protection
2 changes: 1 addition & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ pnpm-lock.yaml
.vscode/setting.json
test-fixtures
test-snapshots
playwright-report
playwright-report
2 changes: 1 addition & 1 deletion examples/playground14/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": "worker.ts",
"name": "api",
"name": "playground14",
"compatibility_date": "2024-12-30",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
Expand Down
4 changes: 3 additions & 1 deletion examples/playground15/app/api/env/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
export const dynamic = "force-dynamic";

export async function GET() {
return new Response(JSON.stringify(process.env));
return new Response(JSON.stringify(process.env, null, 2), {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used this API for debugging (the env contains the build id, deployment id, and deployment mapping) and the response was easier to read when formatted this way.

headers: { "content-type": "application/json" },
});
}
3 changes: 2 additions & 1 deletion examples/playground15/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
import { initOpenNextCloudflareForDev, getDeploymentId } from "@opennextjs/cloudflare";

initOpenNextCloudflareForDev();

Expand All @@ -10,6 +10,7 @@ const nextConfig = {
// Generate source map to validate the fix for opennextjs/opennextjs-cloudflare#341
serverSourceMaps: true,
},
deploymentId: getDeploymentId(),
};

export default nextConfig;
18 changes: 12 additions & 6 deletions examples/playground15/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache";
import { defineCloudflareConfig, type OpenNextConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";

export default defineCloudflareConfig({
incrementalCache: kvIncrementalCache,
enableCacheInterception: true,
});
export default {
...defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
}),
cloudflare: {
skewProtection: {
enabled: false,
},
},
} satisfies OpenNextConfig;
2 changes: 1 addition & 1 deletion examples/playground15/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"cf-typegen": "wrangler types --env-interface CloudflareEnv"
},
"dependencies": {
"next": "^15.1.7",
"next": "^15.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Expand Down
8 changes: 4 additions & 4 deletions examples/playground15/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "api",
"name": "playground15",
"compatibility_date": "2024-12-30",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
"run_worker_first": true
},
"kv_namespaces": [
"r2_buckets": [
{
"binding": "NEXT_INC_CACHE_KV",
"id": "<BINDING_ID>"
"binding": "NEXT_INC_CACHE_R2_BUCKET",
"bucket_name": "pg15"
}
],
"vars": {
Expand Down
1 change: 1 addition & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@types/mock-fs": "catalog:",
"@types/node": "catalog:",
"@types/picomatch": "^4.0.0",
"cloudflare": "^4.4.1",
"diff": "^8.0.2",
"esbuild": "catalog:",
"eslint": "catalog:",
Expand Down
13 changes: 13 additions & 0 deletions packages/cloudflare/src/api/cloudflare-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ declare global {
CACHE_PURGE_ZONE_ID?: string;
// The API token to use for the cache purge. It should have the `Cache Purge` permission
CACHE_PURGE_API_TOKEN?: string;

// The following variables must be provided when skew protection is enabled
// The name of the worker (as defined in the wrangler configuration)
// When a specific wrangler environment is used, it should be appended at the end:
// - Use `worker-name` when no wrangler environment is used
// - Use `worker-name-<environment>` when a wrangler environment is used via `wrangler --env=<environment>`
CF_WORKER_NAME?: string;
// The subdomain where the previews are deployed, i.e. `<version-name>.<domain>.workers.dev`
CF_PREVIEW_DOMAIN?: string;
// Should have the `Workers Scripts:Read` permission
CF_WORKERS_SCRIPTS_API_TOKEN?: string;
// Cloudflare account id
CF_ACCOUNT_ID?: string;
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/cloudflare/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,24 @@ interface OpenNextConfig extends AwsOpenNextConfig {
* @default false
*/
dangerousDisableConfigValidation?: boolean;

/**
* Skew protection.
*
* Note: Skew Protection is experimental and might break on minor releases.
*
* @default false
*/
skewProtection?: {
// Whether to enable skew protection
enabled?: boolean;
// Maximum number of versions to retrieve
// @default 20
maxNumberOfVersions?: number;
// Maximum age of versions to retrieve in days
// @default 7
maxVersionAgeDays?: number;
};
};
}

Expand All @@ -172,4 +190,11 @@ export function getOpenNextConfig(buildOpts: BuildOptions): OpenNextConfig {
return buildOpts.config;
}

/**
* @returns Unique deployment ID
*/
export function getDeploymentId(): string {
return `dpl-${new Date().getTime().toString(36)}`;
}

export type { OpenNextConfig };
2 changes: 1 addition & 1 deletion packages/cloudflare/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./cloudflare-context.js";
export { defineCloudflareConfig, type OpenNextConfig } from "./config.js";
export { defineCloudflareConfig, getDeploymentId, type OpenNextConfig } from "./config.js";
9 changes: 2 additions & 7 deletions packages/cloudflare/src/cli/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { compileCacheAssetsManifestSqlFile } from "./open-next/compile-cache-ass
import { compileEnvFiles } from "./open-next/compile-env-files.js";
import { compileImages } from "./open-next/compile-images.js";
import { compileInit } from "./open-next/compile-init.js";
import { compileSkewProtection } from "./open-next/compile-skew-protection.js";
import { compileDurableObjects } from "./open-next/compileDurableObjects.js";
import { createServerBundle } from "./open-next/createServerBundle.js";
import { createWranglerConfigIfNotExistent } from "./utils/index.js";
Expand Down Expand Up @@ -59,17 +60,11 @@ export async function build(
printHeader("Generating bundle");
buildHelper.initOutputDir(options);

// Compile cache.ts
compileCache(options);

// Compile .env files
compileEnvFiles(options);

// Compile workerd init
compileInit(options, wranglerConfig);

// Compile image helpers
compileImages(options);
compileSkewProtection(options, config);

// Compile middleware
await createMiddleware(options, { forceOnlyBuildOnce: true });
Expand Down
2 changes: 2 additions & 0 deletions packages/cloudflare/src/cli/build/open-next/compile-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export async function compileInit(options: BuildOptions, wranglerConfig: Unstabl

const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
const basePath = nextConfig.basePath ?? "";
const deploymentId = nextConfig.deploymentId ?? "";

await build({
entryPoints: [initPath],
Expand All @@ -29,6 +30,7 @@ export async function compileInit(options: BuildOptions, wranglerConfig: Unstabl
__BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()),
__NEXT_BASE_PATH__: JSON.stringify(basePath),
__ASSETS_RUN_WORKER_FIRST__: JSON.stringify(wranglerConfig.assets?.run_worker_first ?? false),
__DEPLOYMENT_ID__: JSON.stringify(deploymentId),
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import path from "node:path";
import { fileURLToPath } from "node:url";

import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
import { build } from "esbuild";

import type { OpenNextConfig } from "../../../api";

export async function compileSkewProtection(options: BuildOptions, config: OpenNextConfig) {
const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url)));
const templatesDir = path.join(currentDir, "../../templates");
const initPath = path.join(templatesDir, "skew-protection.js");

const skewProtectionEnabled = config.cloudflare?.skewProtection?.enabled === true;

await build({
entryPoints: [initPath],
outdir: path.join(options.outputDir, "cloudflare"),
bundle: false,
minify: false,
format: "esm",
target: "esnext",
platform: "node",
define: {
__SKEW_PROTECTION_ENABLED__: JSON.stringify(skewProtectionEnabled),
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,15 @@ export function compileDurableObjects(buildOpts: BuildOptions) {
_require.resolve("@opennextjs/cloudflare/durable-objects/bucket-cache-purge"),
];

const { outputDir } = buildOpts;

const baseManifestPath = path.join(
outputDir,
buildOpts.outputDir,
"server-functions/default",
getPackagePath(buildOpts),
".next"
);

// We need to change the type in aws
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prerenderManifest = loadPrerenderManifest(baseManifestPath) as any;
const prerenderManifest = loadPrerenderManifest(baseManifestPath);
const previewModeId = prerenderManifest.preview.previewModeId;

const BUILD_ID = loadBuildId(baseManifestPath);

return esbuildSync(
Expand Down
24 changes: 23 additions & 1 deletion packages/cloudflare/src/cli/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
import { BuildOptions } from "@opennextjs/aws/build/helper.js";

import type { OpenNextConfig } from "../../api/config.js";
import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js";
import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js";
import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js";
import { populateCache } from "./populate-cache.js";
import { getDeploymentMapping } from "./skew-protection.js";

export async function deploy(
options: BuildOptions,
config: OpenNextConfig,
deployOptions: { passthroughArgs: string[]; cacheChunkSize?: number }
) {
const envVars = await getEnvFromPlatformProxy({
// TODO: Pass the configPath, update everywhere applicable
environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs),
});

const deploymentMapping = await getDeploymentMapping(options, config, envVars);

await populateCache(options, config, {
target: "remote",
environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs),
cacheChunkSize: deployOptions.cacheChunkSize,
});

runWrangler(options, ["deploy", ...deployOptions.passthroughArgs], { logging: "all" });
runWrangler(
options,
[
"deploy",
...deployOptions.passthroughArgs,
...(deploymentMapping
? [`--var ${DEPLOYMENT_MAPPING_ENV_NAME}:${quoteShellMeta(JSON.stringify(deploymentMapping))}`]
: []),
],
{
logging: "all",
}
);
}
41 changes: 41 additions & 0 deletions packages/cloudflare/src/cli/commands/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { getPlatformProxy, type GetPlatformProxyOptions } from "wrangler";

export type WorkerEnvVar = Record<keyof CloudflareEnv, string | undefined>;

/**
* Return the string env vars from the worker.
*
* @param options Options to pass to `getPlatformProxy`, i.e. to set the environment
* @returns the env vars
*/
export async function getEnvFromPlatformProxy(options: GetPlatformProxyOptions) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should have only one instance of this, that we spawn at the beginning and that we dispose at the end. It's a bit wasteful to launch multiple workerd process.
And once we have support for remote bindings here, we could reuse it to populate the cache directly (which should be way faster)

const envVars = {} as WorkerEnvVar;
const proxy = await getPlatformProxy<CloudflareEnv>(options);
Object.entries(proxy.env).forEach(([key, value]) => {
if (typeof value === "string") {
envVars[key as keyof CloudflareEnv] = value;
}
});
await proxy.dispose();
return envVars;
}

/**
* Escape shell metacharacters.
*
* When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped.
*
* Based on https://github.com/ljharb/shell-quote/blob/main/quote.js
*
* @param arg
* @returns escaped arg
*/
export function quoteShellMeta(arg: string) {
if (/["\s]/.test(arg) && !/'/.test(arg)) {
return `'${arg.replace(/(['\\])/g, "\\$1")}'`;
}
if (/["'\s]/.test(arg)) {
return `"${arg.replace(/(["\\$`!])/g, "\\$1")}"`;
}
return arg.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2");
}
Loading