Skip to content

Commit 18ba05a

Browse files
committed
Add support for skew protection
1 parent 2f6dd81 commit 18ba05a

22 files changed

+598
-78
lines changed

examples/playground14/wrangler.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "node_modules/wrangler/config-schema.json",
33
"main": "worker.ts",
4-
"name": "api",
4+
"name": "playground14",
55
"compatibility_date": "2024-12-30",
66
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
77
"assets": {

examples/playground15/next.config.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare";
1+
import { initOpenNextCloudflareForDev, getDeploymentId } from "@opennextjs/cloudflare";
22

33
initOpenNextCloudflareForDev();
44

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

1516
export default nextConfig;
Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
2-
import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache";
1+
import { defineCloudflareConfig, type OpenNextConfig } from "@opennextjs/cloudflare";
2+
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
33

4-
export default defineCloudflareConfig({
5-
incrementalCache: kvIncrementalCache,
6-
enableCacheInterception: true,
7-
});
4+
export default {
5+
...defineCloudflareConfig({
6+
incrementalCache: r2IncrementalCache,
7+
}),
8+
cloudflare: {
9+
skewProtectionEnabled: true,
10+
},
11+
} satisfies OpenNextConfig;

examples/playground15/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"cf-typegen": "wrangler types --env-interface CloudflareEnv"
1717
},
1818
"dependencies": {
19-
"next": "^15.1.7",
19+
"next": "^15.3.4",
2020
"react": "^19.0.0",
2121
"react-dom": "^19.0.0"
2222
},

examples/playground15/wrangler.jsonc

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
{
22
"$schema": "node_modules/wrangler/config-schema.json",
33
"main": ".open-next/worker.js",
4-
"name": "api",
4+
"name": "playground15",
55
"compatibility_date": "2024-12-30",
66
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
77
"assets": {
88
"directory": ".open-next/assets",
99
"binding": "ASSETS"
1010
},
11-
"kv_namespaces": [
11+
"r2_buckets": [
1212
{
13-
"binding": "NEXT_INC_CACHE_KV",
14-
"id": "<BINDING_ID>"
13+
"binding": "NEXT_INC_CACHE_R2_BUCKET",
14+
"bucket_name": "pg15"
1515
}
1616
],
1717
"vars": {

packages/cloudflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@types/mock-fs": "catalog:",
6666
"@types/node": "catalog:",
6767
"@types/picomatch": "^4.0.0",
68+
"cloudflare": "^4.4.1",
6869
"diff": "^8.0.2",
6970
"esbuild": "catalog:",
7071
"eslint": "catalog:",

packages/cloudflare/src/api/cloudflare-context.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ declare global {
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;
68+
69+
// The following variables must be provided when skew protection is enabled
70+
// The name of the worker (as defined in the wrangler configuration)
71+
CF_WORKER_NAME?: string;
72+
// The subdomain where the previews are deployed, i.e. `<version-name>.<domain>.workers.dev`
73+
CF_PREVIEW_DOMAIN?: string;
74+
// Should have the `Workers Scripts:Read` permission
75+
CF_WORKERS_SCRIPTS_API_TOKEN?: string;
76+
// Cloudflare account id
77+
CF_ACCOUNT_ID?: string;
6878
}
6979
}
7080

packages/cloudflare/src/api/config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,15 @@ interface OpenNextConfig extends AwsOpenNextConfig {
158158
* @default false
159159
*/
160160
dangerousDisableConfigValidation?: boolean;
161+
162+
/**
163+
* Enable skew protection.
164+
*
165+
* Note: Skew Protection is experimental and might break on minor releases.
166+
*
167+
* @default false
168+
*/
169+
skewProtectionEnabled?: boolean;
161170
};
162171
}
163172

@@ -169,4 +178,11 @@ export function getOpenNextConfig(buildOpts: BuildOptions): OpenNextConfig {
169178
return buildOpts.config;
170179
}
171180

181+
/**
182+
* @returns Unique deployment ID
183+
*/
184+
export function getDeploymentId(): string {
185+
return `dpl-${new Date().getTime().toString(36)}`;
186+
}
187+
172188
export type { OpenNextConfig };

packages/cloudflare/src/api/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export * from "./cloudflare-context.js";
2-
export { defineCloudflareConfig, type OpenNextConfig } from "./config.js";
2+
export { defineCloudflareConfig, getDeploymentId, type OpenNextConfig } from "./config.js";

packages/cloudflare/src/cli/build/build.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { compileCacheAssetsManifestSqlFile } from "./open-next/compile-cache-ass
1313
import { compileEnvFiles } from "./open-next/compile-env-files.js";
1414
import { compileImages } from "./open-next/compile-images.js";
1515
import { compileInit } from "./open-next/compile-init.js";
16+
import { compileSkewProtection } from "./open-next/compile-skew-protection.js";
1617
import { compileDurableObjects } from "./open-next/compileDurableObjects.js";
1718
import { createServerBundle } from "./open-next/createServerBundle.js";
1819
import { createWranglerConfigIfNotExistent } from "./utils/index.js";
@@ -58,17 +59,11 @@ export async function build(
5859
printHeader("Generating bundle");
5960
buildHelper.initOutputDir(options);
6061

61-
// Compile cache.ts
6262
compileCache(options);
63-
64-
// Compile .env files
6563
compileEnvFiles(options);
66-
67-
// Compile workerd init
6864
compileInit(options);
69-
70-
// Compile image helpers
7165
compileImages(options);
66+
compileSkewProtection(options, config);
7267

7368
// Compile middleware
7469
await createMiddleware(options, { forceOnlyBuildOnce: true });

packages/cloudflare/src/cli/build/open-next/compile-init.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export async function compileInit(options: BuildOptions) {
1515

1616
const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
1717
const basePath = nextConfig.basePath ?? "";
18+
const deploymentId = nextConfig.deploymentId ?? "";
1819

1920
await build({
2021
entryPoints: [initPath],
@@ -27,6 +28,7 @@ export async function compileInit(options: BuildOptions) {
2728
define: {
2829
__BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()),
2930
__NEXT_BASE_PATH__: JSON.stringify(basePath),
31+
__DEPLOYMENT_ID__: JSON.stringify(deploymentId),
3032
},
3133
});
3234
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
4+
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
5+
import { build } from "esbuild";
6+
7+
import type { OpenNextConfig } from "../../../api";
8+
9+
export async function compileSkewProtection(options: BuildOptions, config: OpenNextConfig) {
10+
const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url)));
11+
const templatesDir = path.join(currentDir, "../../templates");
12+
const initPath = path.join(templatesDir, "skew-protection.js");
13+
14+
await build({
15+
entryPoints: [initPath],
16+
outdir: path.join(options.outputDir, "cloudflare"),
17+
bundle: false,
18+
minify: false,
19+
format: "esm",
20+
target: "esnext",
21+
platform: "node",
22+
define: {
23+
__SKEW_PROTECTION_ENABLED__: JSON.stringify(config.cloudflare?.skewProtectionEnabled ?? false),
24+
},
25+
});
26+
}
Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,41 @@
11
import { BuildOptions } from "@opennextjs/aws/build/helper.js";
22

33
import type { OpenNextConfig } from "../../api/config.js";
4+
import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js";
45
import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js";
6+
import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js";
57
import { populateCache } from "./populate-cache.js";
8+
import { getDeploymentMapping } from "./skew-protection.js";
69

710
export async function deploy(
811
options: BuildOptions,
912
config: OpenNextConfig,
1013
deployOptions: { passthroughArgs: string[]; cacheChunkSize?: number }
1114
) {
15+
const envVars = await getEnvFromPlatformProxy({
16+
// TODO: Pass the configPath, update everywhere applicable
17+
environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs),
18+
});
19+
20+
const deploymentMapping = await getDeploymentMapping(options, config, envVars);
21+
1222
await populateCache(options, config, {
1323
target: "remote",
1424
environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs),
1525
cacheChunkSize: deployOptions.cacheChunkSize,
1626
});
1727

18-
runWrangler(options, ["deploy", ...deployOptions.passthroughArgs], { logging: "all" });
28+
runWrangler(
29+
options,
30+
[
31+
"deploy",
32+
...deployOptions.passthroughArgs,
33+
...(deploymentMapping
34+
? [`--var ${DEPLOYMENT_MAPPING_ENV_NAME}:${quoteShellMeta(JSON.stringify(deploymentMapping))}`]
35+
: []),
36+
],
37+
{
38+
logging: "all",
39+
}
40+
);
1941
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { getPlatformProxy, type GetPlatformProxyOptions } from "wrangler";
2+
3+
export type WorkerEnvVar = Record<keyof CloudflareEnv, string | undefined>;
4+
5+
/**
6+
* Return the string env vars from the worker.
7+
*
8+
* @param options Options to pass to `getPlatformProxy`, i.e. to set the environment
9+
* @returns the env vars
10+
*/
11+
export async function getEnvFromPlatformProxy(options: GetPlatformProxyOptions) {
12+
const envVars = {} as WorkerEnvVar;
13+
const proxy = await getPlatformProxy<CloudflareEnv>(options);
14+
Object.entries(proxy.env).forEach(([key, value]) => {
15+
if (typeof value === "string") {
16+
envVars[key as keyof CloudflareEnv] = value;
17+
}
18+
});
19+
await proxy.dispose();
20+
return envVars;
21+
}
22+
23+
/**
24+
* Escape shell metacharacters.
25+
*
26+
* When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped.
27+
*
28+
* Based on https://github.com/ljharb/shell-quote/blob/main/quote.js
29+
*
30+
* @param arg
31+
* @returns escaped arg
32+
*/
33+
export function quoteShellMeta(arg: string) {
34+
if (/["\s]/.test(arg) && !/'/.test(arg)) {
35+
return `'${arg.replace(/(['\\])/g, "\\$1")}'`;
36+
}
37+
if (/["'\s]/.test(arg)) {
38+
return `"${arg.replace(/(["\\$`!])/g, "\\$1")}"`;
39+
}
40+
return arg.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2");
41+
}

packages/cloudflare/src/cli/commands/populate-cache.ts

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
import type { IncrementalCache, TagCache } from "@opennextjs/aws/types/overrides.js";
1313
import { globSync } from "glob";
1414
import { tqdm } from "ts-tqdm";
15-
import { getPlatformProxy, type GetPlatformProxyOptions, unstable_readConfig } from "wrangler";
15+
import { unstable_readConfig } from "wrangler";
1616

1717
import {
1818
BINDING_NAME as KV_CACHE_BINDING_NAME,
@@ -36,6 +36,7 @@ import {
3636
import { normalizePath } from "../build/utils/normalize-path.js";
3737
import type { WranglerTarget } from "../utils/run-wrangler.js";
3838
import { runWrangler } from "../utils/run-wrangler.js";
39+
import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js";
3940

4041
async function resolveCacheName(
4142
value:
@@ -93,13 +94,6 @@ export function getCacheAssets(opts: BuildOptions): CacheAsset[] {
9394
return assets;
9495
}
9596

96-
async function getPlatformProxyEnv<T extends keyof CloudflareEnv>(options: GetPlatformProxyOptions, key: T) {
97-
const proxy = await getPlatformProxy<CloudflareEnv>(options);
98-
const prefix = proxy.env[key];
99-
await proxy.dispose();
100-
return prefix;
101-
}
102-
10397
async function populateR2IncrementalCache(
10498
options: BuildOptions,
10599
populateCacheOptions: { target: WranglerTarget; environment?: string }
@@ -118,7 +112,8 @@ async function populateR2IncrementalCache(
118112
throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`);
119113
}
120114

121-
const prefix = await getPlatformProxyEnv(populateCacheOptions, R2_CACHE_PREFIX_ENV_NAME);
115+
const envVars = await getEnvFromPlatformProxy(populateCacheOptions);
116+
const prefix = envVars[R2_CACHE_PREFIX_ENV_NAME];
122117

123118
const assets = getCacheAssets(options);
124119

@@ -156,7 +151,8 @@ async function populateKVIncrementalCache(
156151
throw new Error(`No KV binding ${JSON.stringify(KV_CACHE_BINDING_NAME)} found!`);
157152
}
158153

159-
const prefix = await getPlatformProxyEnv(populateCacheOptions, KV_CACHE_PREFIX_ENV_NAME);
154+
const envVars = await getEnvFromPlatformProxy(populateCacheOptions);
155+
const prefix = envVars[KV_CACHE_PREFIX_ENV_NAME];
160156

161157
const assets = getCacheAssets(options);
162158

@@ -270,23 +266,3 @@ export async function populateCache(
270266
}
271267
}
272268
}
273-
274-
/**
275-
* Escape shell metacharacters.
276-
*
277-
* When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped.
278-
*
279-
* Based on https://github.com/ljharb/shell-quote/blob/main/quote.js
280-
*
281-
* @param arg
282-
* @returns escaped arg
283-
*/
284-
function quoteShellMeta(arg: string) {
285-
if (/["\s]/.test(arg) && !/'/.test(arg)) {
286-
return `'${arg.replace(/(['\\])/g, "\\$1")}'`;
287-
}
288-
if (/["'\s]/.test(arg)) {
289-
return `"${arg.replace(/(["\\$`!])/g, "\\$1")}"`;
290-
}
291-
return arg.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2");
292-
}

0 commit comments

Comments
 (0)