@opennextjs/cloudflare 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/api/cloudflare-context.d.ts +4 -0
  2. package/dist/api/config.d.ts +16 -0
  3. package/dist/api/config.js +8 -0
  4. package/dist/api/index.d.ts +1 -1
  5. package/dist/api/index.js +1 -1
  6. package/dist/api/overrides/asset-resolver/index.d.ts +19 -0
  7. package/dist/api/overrides/asset-resolver/index.js +78 -0
  8. package/dist/cli/build/build.d.ts +2 -1
  9. package/dist/cli/build/build.js +4 -7
  10. package/dist/cli/build/open-next/compile-images.js +10 -2
  11. package/dist/cli/build/open-next/compile-init.d.ts +2 -1
  12. package/dist/cli/build/open-next/compile-init.js +4 -1
  13. package/dist/cli/build/open-next/compile-skew-protection.d.ts +3 -0
  14. package/dist/cli/build/open-next/compile-skew-protection.js +21 -0
  15. package/dist/cli/build/open-next/compileDurableObjects.js +1 -4
  16. package/dist/cli/commands/deploy.js +17 -1
  17. package/dist/cli/commands/helpers.d.ts +20 -0
  18. package/dist/cli/commands/helpers.js +37 -0
  19. package/dist/cli/commands/populate-cache.js +6 -28
  20. package/dist/cli/commands/skew-protection.d.ts +67 -0
  21. package/dist/cli/commands/skew-protection.js +193 -0
  22. package/dist/cli/commands/upload.js +15 -1
  23. package/dist/cli/index.js +10 -2
  24. package/dist/cli/templates/images.d.ts +13 -1
  25. package/dist/cli/templates/images.js +88 -2
  26. package/dist/cli/templates/init.d.ts +2 -0
  27. package/dist/cli/templates/init.js +7 -2
  28. package/dist/cli/templates/skew-protection.d.ts +28 -0
  29. package/dist/cli/templates/skew-protection.js +57 -0
  30. package/dist/cli/templates/worker.js +7 -1
  31. package/dist/cli/utils/run-wrangler.d.ts +18 -1
  32. package/dist/cli/utils/run-wrangler.js +39 -12
  33. package/package.json +3 -2
@@ -26,6 +26,10 @@ declare global {
26
26
  NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS?: string;
27
27
  CACHE_PURGE_ZONE_ID?: string;
28
28
  CACHE_PURGE_API_TOKEN?: string;
29
+ CF_WORKER_NAME?: string;
30
+ CF_PREVIEW_DOMAIN?: string;
31
+ CF_WORKERS_SCRIPTS_API_TOKEN?: string;
32
+ CF_ACCOUNT_ID?: string;
29
33
  }
30
34
  }
31
35
  export type CloudflareContext<CfProperties extends Record<string, unknown> = IncomingRequestCfProperties, Context = ExecutionContext> = {
@@ -63,6 +63,18 @@ interface OpenNextConfig extends AwsOpenNextConfig {
63
63
  * @default false
64
64
  */
65
65
  dangerousDisableConfigValidation?: boolean;
66
+ /**
67
+ * Skew protection.
68
+ *
69
+ * Note: Skew Protection is experimental and might break on minor releases.
70
+ *
71
+ * @default false
72
+ */
73
+ skewProtection?: {
74
+ enabled?: boolean;
75
+ maxNumberOfVersions?: number;
76
+ maxVersionAgeDays?: number;
77
+ };
66
78
  };
67
79
  }
68
80
  /**
@@ -70,4 +82,8 @@ interface OpenNextConfig extends AwsOpenNextConfig {
70
82
  * @returns The OpenConfig specific to cloudflare
71
83
  */
72
84
  export declare function getOpenNextConfig(buildOpts: BuildOptions): OpenNextConfig;
85
+ /**
86
+ * @returns Unique deployment ID
87
+ */
88
+ export declare function getDeploymentId(): string;
73
89
  export type { OpenNextConfig };
@@ -1,3 +1,4 @@
1
+ import assetResolver from "./overrides/asset-resolver/index.js";
1
2
  /**
2
3
  * Defines the OpenNext configuration that targets the Cloudflare adapter
3
4
  *
@@ -37,6 +38,7 @@ export function defineCloudflareConfig(config = {}) {
37
38
  tagCache: resolveTagCache(tagCache),
38
39
  queue: resolveQueue(queue),
39
40
  },
41
+ assetResolver: () => assetResolver,
40
42
  },
41
43
  };
42
44
  }
@@ -71,3 +73,9 @@ function resolveCdnInvalidation(value = "dummy") {
71
73
  export function getOpenNextConfig(buildOpts) {
72
74
  return buildOpts.config;
73
75
  }
76
+ /**
77
+ * @returns Unique deployment ID
78
+ */
79
+ export function getDeploymentId() {
80
+ return `dpl-${new Date().getTime().toString(36)}`;
81
+ }
@@ -1,2 +1,2 @@
1
1
  export * from "./cloudflare-context.js";
2
- export { defineCloudflareConfig, type OpenNextConfig } from "./config.js";
2
+ export { defineCloudflareConfig, getDeploymentId, type OpenNextConfig } from "./config.js";
package/dist/api/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from "./cloudflare-context.js";
2
- export { defineCloudflareConfig } from "./config.js";
2
+ export { defineCloudflareConfig, getDeploymentId } from "./config.js";
@@ -0,0 +1,19 @@
1
+ import type { AssetResolver } from "@opennextjs/aws/types/overrides";
2
+ /**
3
+ * Serves assets when `run_worker_first` is set to true.
4
+ *
5
+ * When `run_worker_first` is `false`, the assets are served directly bypassing Next routing.
6
+ *
7
+ * When it is `true`, assets are served from the routing layer. It should be used when assets
8
+ * should be behind the middleware or when skew protection is enabled.
9
+ *
10
+ * See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first
11
+ */
12
+ declare const resolver: AssetResolver;
13
+ /**
14
+ * @param runWorkerFirst `run_worker_first` config
15
+ * @param pathname pathname of the request
16
+ * @returns Whether the user worker runs first
17
+ */
18
+ export declare function isUserWorkerFirst(runWorkerFirst: boolean | string[] | undefined, pathname: string): boolean;
19
+ export default resolver;
@@ -0,0 +1,78 @@
1
+ import { getCloudflareContext } from "../../cloudflare-context.js";
2
+ /**
3
+ * Serves assets when `run_worker_first` is set to true.
4
+ *
5
+ * When `run_worker_first` is `false`, the assets are served directly bypassing Next routing.
6
+ *
7
+ * When it is `true`, assets are served from the routing layer. It should be used when assets
8
+ * should be behind the middleware or when skew protection is enabled.
9
+ *
10
+ * See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first
11
+ */
12
+ const resolver = {
13
+ name: "cloudflare-asset-resolver",
14
+ async maybeGetAssetResult(event) {
15
+ const { ASSETS } = getCloudflareContext().env;
16
+ if (!ASSETS || !isUserWorkerFirst(globalThis.__ASSETS_RUN_WORKER_FIRST__, event.rawPath)) {
17
+ // Only handle assets when the user worker runs first for the path
18
+ return undefined;
19
+ }
20
+ const { method, headers } = event;
21
+ if (method !== "GET" && method != "HEAD") {
22
+ return undefined;
23
+ }
24
+ const url = new URL(event.rawPath, "https://assets.local");
25
+ const response = await ASSETS.fetch(url, {
26
+ headers,
27
+ method,
28
+ });
29
+ if (response.status === 404) {
30
+ return undefined;
31
+ }
32
+ return {
33
+ type: "core",
34
+ statusCode: response.status,
35
+ headers: Object.fromEntries(response.headers.entries()),
36
+ // Workers and Node types differ.
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ body: response.body || new ReadableStream(),
39
+ isBase64Encoded: false,
40
+ };
41
+ },
42
+ };
43
+ /**
44
+ * @param runWorkerFirst `run_worker_first` config
45
+ * @param pathname pathname of the request
46
+ * @returns Whether the user worker runs first
47
+ */
48
+ export function isUserWorkerFirst(runWorkerFirst, pathname) {
49
+ if (!Array.isArray(runWorkerFirst)) {
50
+ return runWorkerFirst ?? false;
51
+ }
52
+ let hasPositiveMatch = false;
53
+ for (let rule of runWorkerFirst) {
54
+ let isPositiveRule = true;
55
+ if (rule.startsWith("!")) {
56
+ rule = rule.slice(1);
57
+ isPositiveRule = false;
58
+ }
59
+ else if (hasPositiveMatch) {
60
+ // Do not look for more positive rules once we have a match
61
+ continue;
62
+ }
63
+ // - Escapes special characters
64
+ // - Replaces * with .*
65
+ const match = new RegExp(`^${rule.replace(/([[\]().*+?^$|{}\\])/g, "\\$1").replace("\\*", ".*")}$`).test(pathname);
66
+ if (match) {
67
+ if (isPositiveRule) {
68
+ hasPositiveMatch = true;
69
+ }
70
+ else {
71
+ // Exit early when there is a negative match
72
+ return false;
73
+ }
74
+ }
75
+ }
76
+ return hasPositiveMatch;
77
+ }
78
+ export default resolver;
@@ -1,4 +1,5 @@
1
1
  import * as buildHelper from "@opennextjs/aws/build/helper.js";
2
+ import type { Unstable_Config } from "wrangler";
2
3
  import { OpenNextConfig } from "../../api/config.js";
3
4
  import type { ProjectOptions } from "../project-options.js";
4
5
  /**
@@ -10,4 +11,4 @@ import type { ProjectOptions } from "../project-options.js";
10
11
  * @param config The OpenNext config
11
12
  * @param projectOpts The options for the project
12
13
  */
13
- export declare function build(options: buildHelper.BuildOptions, config: OpenNextConfig, projectOpts: ProjectOptions): Promise<void>;
14
+ export declare function build(options: buildHelper.BuildOptions, config: OpenNextConfig, projectOpts: ProjectOptions, wranglerConfig: Unstable_Config): Promise<void>;
@@ -10,6 +10,7 @@ import { compileCacheAssetsManifestSqlFile } from "./open-next/compile-cache-ass
10
10
  import { compileEnvFiles } from "./open-next/compile-env-files.js";
11
11
  import { compileImages } from "./open-next/compile-images.js";
12
12
  import { compileInit } from "./open-next/compile-init.js";
13
+ import { compileSkewProtection } from "./open-next/compile-skew-protection.js";
13
14
  import { compileDurableObjects } from "./open-next/compileDurableObjects.js";
14
15
  import { createServerBundle } from "./open-next/createServerBundle.js";
15
16
  import { createWranglerConfigIfNotExistent } from "./utils/index.js";
@@ -23,9 +24,8 @@ import { getVersion } from "./utils/version.js";
23
24
  * @param config The OpenNext config
24
25
  * @param projectOpts The options for the project
25
26
  */
26
- export async function build(options, config, projectOpts) {
27
+ export async function build(options, config, projectOpts, wranglerConfig) {
27
28
  // Do not minify the code so that we can apply string replacement patch.
28
- // Note that wrangler will still minify the bundle.
29
29
  options.minify = false;
30
30
  // Pre-build validation
31
31
  buildHelper.checkRunningInsideNextjsApp(options);
@@ -47,14 +47,11 @@ export async function build(options, config, projectOpts) {
47
47
  // Generate deployable bundle
48
48
  printHeader("Generating bundle");
49
49
  buildHelper.initOutputDir(options);
50
- // Compile cache.ts
51
50
  compileCache(options);
52
- // Compile .env files
53
51
  compileEnvFiles(options);
54
- // Compile workerd init
55
- compileInit(options);
56
- // Compile image helpers
52
+ compileInit(options, wranglerConfig);
57
53
  compileImages(options);
54
+ compileSkewProtection(options, config);
58
55
  // Compile middleware
59
56
  await createMiddleware(options, { forceOnlyBuildOnce: true });
60
57
  createStaticAssets(options, { useBasePath: true });
@@ -13,6 +13,11 @@ export async function compileImages(options) {
13
13
  const imagesManifest = fs.existsSync(imagesManifestPath)
14
14
  ? JSON.parse(fs.readFileSync(imagesManifestPath, { encoding: "utf-8" }))
15
15
  : {};
16
+ const __IMAGES_REMOTE_PATTERNS__ = JSON.stringify(imagesManifest?.images?.remotePatterns ?? []);
17
+ const __IMAGES_LOCAL_PATTERNS__ = JSON.stringify(imagesManifest?.images?.localPatterns ?? []);
18
+ const __IMAGES_ALLOW_SVG__ = JSON.stringify(Boolean(imagesManifest?.images?.dangerouslyAllowSVG));
19
+ const __IMAGES_CONTENT_SECURITY_POLICY__ = JSON.stringify(imagesManifest?.images?.contentSecurityPolicy ?? "script-src 'none'; frame-src 'none'; sandbox;");
20
+ const __IMAGES_CONTENT_DISPOSITION__ = JSON.stringify(imagesManifest?.images?.contentDispositionType ?? "attachment");
16
21
  await build({
17
22
  entryPoints: [imagesPath],
18
23
  outdir: path.join(options.outputDir, "cloudflare"),
@@ -22,8 +27,11 @@ export async function compileImages(options) {
22
27
  target: "esnext",
23
28
  platform: "node",
24
29
  define: {
25
- __IMAGES_REMOTE_PATTERNS__: JSON.stringify(imagesManifest?.images?.remotePatterns ?? []),
26
- __IMAGES_LOCAL_PATTERNS__: JSON.stringify(imagesManifest?.images?.localPatterns ?? []),
30
+ __IMAGES_REMOTE_PATTERNS__,
31
+ __IMAGES_LOCAL_PATTERNS__,
32
+ __IMAGES_ALLOW_SVG__,
33
+ __IMAGES_CONTENT_SECURITY_POLICY__,
34
+ __IMAGES_CONTENT_DISPOSITION__,
27
35
  },
28
36
  });
29
37
  }
@@ -1,5 +1,6 @@
1
1
  import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
2
+ import type { Unstable_Config } from "wrangler";
2
3
  /**
3
4
  * Compiles the initialization code for the workerd runtime
4
5
  */
5
- export declare function compileInit(options: BuildOptions): Promise<void>;
6
+ export declare function compileInit(options: BuildOptions, wranglerConfig: Unstable_Config): Promise<void>;
@@ -5,12 +5,13 @@ import { build } from "esbuild";
5
5
  /**
6
6
  * Compiles the initialization code for the workerd runtime
7
7
  */
8
- export async function compileInit(options) {
8
+ export async function compileInit(options, wranglerConfig) {
9
9
  const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url)));
10
10
  const templatesDir = path.join(currentDir, "../../templates");
11
11
  const initPath = path.join(templatesDir, "init.js");
12
12
  const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
13
13
  const basePath = nextConfig.basePath ?? "";
14
+ const deploymentId = nextConfig.deploymentId ?? "";
14
15
  await build({
15
16
  entryPoints: [initPath],
16
17
  outdir: path.join(options.outputDir, "cloudflare"),
@@ -22,6 +23,8 @@ export async function compileInit(options) {
22
23
  define: {
23
24
  __BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()),
24
25
  __NEXT_BASE_PATH__: JSON.stringify(basePath),
26
+ __ASSETS_RUN_WORKER_FIRST__: JSON.stringify(wranglerConfig.assets?.run_worker_first ?? false),
27
+ __DEPLOYMENT_ID__: JSON.stringify(deploymentId),
25
28
  },
26
29
  });
27
30
  }
@@ -0,0 +1,3 @@
1
+ import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
2
+ import type { OpenNextConfig } from "../../../api";
3
+ export declare function compileSkewProtection(options: BuildOptions, config: OpenNextConfig): Promise<void>;
@@ -0,0 +1,21 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { build } from "esbuild";
4
+ export async function compileSkewProtection(options, config) {
5
+ const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url)));
6
+ const templatesDir = path.join(currentDir, "../../templates");
7
+ const initPath = path.join(templatesDir, "skew-protection.js");
8
+ const skewProtectionEnabled = config.cloudflare?.skewProtection?.enabled === true;
9
+ await build({
10
+ entryPoints: [initPath],
11
+ outdir: path.join(options.outputDir, "cloudflare"),
12
+ bundle: false,
13
+ minify: false,
14
+ format: "esm",
15
+ target: "esnext",
16
+ platform: "node",
17
+ define: {
18
+ __SKEW_PROTECTION_ENABLED__: JSON.stringify(skewProtectionEnabled),
19
+ },
20
+ });
21
+ }
@@ -9,10 +9,7 @@ export function compileDurableObjects(buildOpts) {
9
9
  _require.resolve("@opennextjs/cloudflare/durable-objects/sharded-tag-cache"),
10
10
  _require.resolve("@opennextjs/cloudflare/durable-objects/bucket-cache-purge"),
11
11
  ];
12
- const { outputDir } = buildOpts;
13
- const baseManifestPath = path.join(outputDir, "server-functions/default", getPackagePath(buildOpts), ".next");
14
- // We need to change the type in aws
15
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
+ const baseManifestPath = path.join(buildOpts.outputDir, "server-functions/default", getPackagePath(buildOpts), ".next");
16
13
  const prerenderManifest = loadPrerenderManifest(baseManifestPath);
17
14
  const previewModeId = prerenderManifest.preview.previewModeId;
18
15
  const BUILD_ID = loadBuildId(baseManifestPath);
@@ -1,10 +1,26 @@
1
+ import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js";
1
2
  import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js";
3
+ import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js";
2
4
  import { populateCache } from "./populate-cache.js";
5
+ import { getDeploymentMapping } from "./skew-protection.js";
3
6
  export async function deploy(options, config, deployOptions) {
7
+ const envVars = await getEnvFromPlatformProxy({
8
+ // TODO: Pass the configPath, update everywhere applicable
9
+ environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs),
10
+ });
11
+ const deploymentMapping = await getDeploymentMapping(options, config, envVars);
4
12
  await populateCache(options, config, {
5
13
  target: "remote",
6
14
  environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs),
7
15
  cacheChunkSize: deployOptions.cacheChunkSize,
8
16
  });
9
- runWrangler(options, ["deploy", ...deployOptions.passthroughArgs], { logging: "all" });
17
+ runWrangler(options, [
18
+ "deploy",
19
+ ...deployOptions.passthroughArgs,
20
+ ...(deploymentMapping
21
+ ? [`--var ${DEPLOYMENT_MAPPING_ENV_NAME}:${quoteShellMeta(JSON.stringify(deploymentMapping))}`]
22
+ : []),
23
+ ], {
24
+ logging: "all",
25
+ });
10
26
  }
@@ -0,0 +1,20 @@
1
+ import { type GetPlatformProxyOptions } from "wrangler";
2
+ export type WorkerEnvVar = Record<keyof CloudflareEnv, string | undefined>;
3
+ /**
4
+ * Return the string env vars from the worker.
5
+ *
6
+ * @param options Options to pass to `getPlatformProxy`, i.e. to set the environment
7
+ * @returns the env vars
8
+ */
9
+ export declare function getEnvFromPlatformProxy(options: GetPlatformProxyOptions): Promise<WorkerEnvVar>;
10
+ /**
11
+ * Escape shell metacharacters.
12
+ *
13
+ * When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped.
14
+ *
15
+ * Based on https://github.com/ljharb/shell-quote/blob/main/quote.js
16
+ *
17
+ * @param arg
18
+ * @returns escaped arg
19
+ */
20
+ export declare function quoteShellMeta(arg: string): string;
@@ -0,0 +1,37 @@
1
+ import { getPlatformProxy } from "wrangler";
2
+ /**
3
+ * Return the string env vars from the worker.
4
+ *
5
+ * @param options Options to pass to `getPlatformProxy`, i.e. to set the environment
6
+ * @returns the env vars
7
+ */
8
+ export async function getEnvFromPlatformProxy(options) {
9
+ const envVars = {};
10
+ const proxy = await getPlatformProxy(options);
11
+ Object.entries(proxy.env).forEach(([key, value]) => {
12
+ if (typeof value === "string") {
13
+ envVars[key] = value;
14
+ }
15
+ });
16
+ await proxy.dispose();
17
+ return envVars;
18
+ }
19
+ /**
20
+ * Escape shell metacharacters.
21
+ *
22
+ * When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped.
23
+ *
24
+ * Based on https://github.com/ljharb/shell-quote/blob/main/quote.js
25
+ *
26
+ * @param arg
27
+ * @returns escaped arg
28
+ */
29
+ export function quoteShellMeta(arg) {
30
+ if (/["\s]/.test(arg) && !/'/.test(arg)) {
31
+ return `'${arg.replace(/(['\\])/g, "\\$1")}'`;
32
+ }
33
+ if (/["'\s]/.test(arg)) {
34
+ return `"${arg.replace(/(["\\$`!])/g, "\\$1")}"`;
35
+ }
36
+ return arg.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2");
37
+ }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import logger from "@opennextjs/aws/logger.js";
4
4
  import { globSync } from "glob";
5
5
  import { tqdm } from "ts-tqdm";
6
- import { getPlatformProxy, unstable_readConfig } from "wrangler";
6
+ import { unstable_readConfig } from "wrangler";
7
7
  import { BINDING_NAME as KV_CACHE_BINDING_NAME, NAME as KV_CACHE_NAME, PREFIX_ENV_NAME as KV_CACHE_PREFIX_ENV_NAME, } from "../../api/overrides/incremental-cache/kv-incremental-cache.js";
8
8
  import { BINDING_NAME as R2_CACHE_BINDING_NAME, NAME as R2_CACHE_NAME, PREFIX_ENV_NAME as R2_CACHE_PREFIX_ENV_NAME, } from "../../api/overrides/incremental-cache/r2-incremental-cache.js";
9
9
  import { CACHE_DIR as STATIC_ASSETS_CACHE_DIR, NAME as STATIC_ASSETS_CACHE_NAME, } from "../../api/overrides/incremental-cache/static-assets-incremental-cache.js";
@@ -11,6 +11,7 @@ import { computeCacheKey } from "../../api/overrides/internal.js";
11
11
  import { BINDING_NAME as D1_TAG_BINDING_NAME, NAME as D1_TAG_NAME, } from "../../api/overrides/tag-cache/d1-next-tag-cache.js";
12
12
  import { normalizePath } from "../build/utils/normalize-path.js";
13
13
  import { runWrangler } from "../utils/run-wrangler.js";
14
+ import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js";
14
15
  async function resolveCacheName(value) {
15
16
  return typeof value === "function" ? (await value()).name : value;
16
17
  }
@@ -50,12 +51,6 @@ export function getCacheAssets(opts) {
50
51
  }
51
52
  return assets;
52
53
  }
53
- async function getPlatformProxyEnv(options, key) {
54
- const proxy = await getPlatformProxy(options);
55
- const prefix = proxy.env[key];
56
- await proxy.dispose();
57
- return prefix;
58
- }
59
54
  async function populateR2IncrementalCache(options, populateCacheOptions) {
60
55
  logger.info("\nPopulating R2 incremental cache...");
61
56
  const config = unstable_readConfig({ env: populateCacheOptions.environment });
@@ -67,7 +62,8 @@ async function populateR2IncrementalCache(options, populateCacheOptions) {
67
62
  if (!bucket) {
68
63
  throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`);
69
64
  }
70
- const prefix = await getPlatformProxyEnv(populateCacheOptions, R2_CACHE_PREFIX_ENV_NAME);
65
+ const envVars = await getEnvFromPlatformProxy(populateCacheOptions);
66
+ const prefix = envVars[R2_CACHE_PREFIX_ENV_NAME];
71
67
  const assets = getCacheAssets(options);
72
68
  for (const { fullPath, key, buildId, isFetch } of tqdm(assets)) {
73
69
  const cacheKey = computeCacheKey(key, {
@@ -93,7 +89,8 @@ async function populateKVIncrementalCache(options, populateCacheOptions) {
93
89
  if (!binding) {
94
90
  throw new Error(`No KV binding ${JSON.stringify(KV_CACHE_BINDING_NAME)} found!`);
95
91
  }
96
- const prefix = await getPlatformProxyEnv(populateCacheOptions, KV_CACHE_PREFIX_ENV_NAME);
92
+ const envVars = await getEnvFromPlatformProxy(populateCacheOptions);
93
+ const prefix = envVars[KV_CACHE_PREFIX_ENV_NAME];
97
94
  const assets = getCacheAssets(options);
98
95
  const chunkSize = Math.max(1, populateCacheOptions.cacheChunkSize ?? 25);
99
96
  const totalChunks = Math.ceil(assets.length / chunkSize);
@@ -171,22 +168,3 @@ export async function populateCache(options, config, populateCacheOptions) {
171
168
  }
172
169
  }
173
170
  }
174
- /**
175
- * Escape shell metacharacters.
176
- *
177
- * When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped.
178
- *
179
- * Based on https://github.com/ljharb/shell-quote/blob/main/quote.js
180
- *
181
- * @param arg
182
- * @returns escaped arg
183
- */
184
- function quoteShellMeta(arg) {
185
- if (/["\s]/.test(arg) && !/'/.test(arg)) {
186
- return `'${arg.replace(/(['\\])/g, "\\$1")}'`;
187
- }
188
- if (/["'\s]/.test(arg)) {
189
- return `"${arg.replace(/(["\\$`!])/g, "\\$1")}"`;
190
- }
191
- return arg.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2");
192
- }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * We need to maintain a mapping of deployment id to worker version for skew protection.
3
+ *
4
+ * The mapping is used to request the correct version of the workers when Next attaches a deployment id to a request.
5
+ *
6
+ * The mapping is stored in a worker en var:
7
+ *
8
+ * {
9
+ * latestDepId: "current",
10
+ * depIdx: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
11
+ * depIdy: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
12
+ * depIdz: "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
13
+ * }
14
+ *
15
+ * Note that the latest version is not known at build time as the version id only gets created on deployment.
16
+ * This is why we use the "current" placeholder.
17
+ *
18
+ * When a new version is deployed:
19
+ * - "current" is replaced with the latest version of the Worker
20
+ * - a new entry is added for the new deployment id with the "current" version
21
+ */
22
+ import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
23
+ import { Cloudflare } from "cloudflare";
24
+ import type { OpenNextConfig } from "../../api";
25
+ import type { WorkerEnvVar } from "./helpers.js";
26
+ /**
27
+ * Compute the deployment mapping for a deployment.
28
+ *
29
+ * @param options Build options
30
+ * @param config OpenNext config
31
+ * @param envVars Environment variables
32
+ * @returns Deployment mapping or undefined
33
+ */
34
+ export declare function getDeploymentMapping(options: BuildOptions, config: OpenNextConfig, envVars: WorkerEnvVar): Promise<Record<string, string> | undefined>;
35
+ /**
36
+ * Update an existing deployment mapping:
37
+ * - Replace the "current" version with the latest deployed version
38
+ * - Add a "current" version for the current deployment ID
39
+ * - Remove versions that are not passed in
40
+ *
41
+ * @param mapping Existing mapping
42
+ * @param versions Versions ordered by descending time
43
+ * @param deploymentId Deployment ID
44
+ * @returns The updated mapping
45
+ */
46
+ export declare function updateDeploymentMapping(mapping: Record<string, string>, versions: {
47
+ id: string;
48
+ }[], deploymentId: string): Record<string, string>;
49
+ /**
50
+ * Retrieve the versions for the script
51
+ *
52
+ * @param scriptName The name of the worker script
53
+ * @param options.client A Cloudflare API client
54
+ * @param options.accountId The Cloudflare account id
55
+ * @param options.afterTimeMs Only list version more recent than this time - default to 7 days
56
+ * @param options.maxNumberOfVersions The maximum number of version to return - default to 20 versions.
57
+ * @returns A list of id and creation date ordered by descending creation date
58
+ */
59
+ export declare function listWorkerVersions(scriptName: string, options: {
60
+ client: Cloudflare;
61
+ accountId: string;
62
+ afterTimeMs?: number;
63
+ maxNumberOfVersions?: number;
64
+ }): Promise<{
65
+ id: string;
66
+ createdOnMs: number;
67
+ }[]>;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * We need to maintain a mapping of deployment id to worker version for skew protection.
3
+ *
4
+ * The mapping is used to request the correct version of the workers when Next attaches a deployment id to a request.
5
+ *
6
+ * The mapping is stored in a worker en var:
7
+ *
8
+ * {
9
+ * latestDepId: "current",
10
+ * depIdx: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
11
+ * depIdy: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy",
12
+ * depIdz: "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz",
13
+ * }
14
+ *
15
+ * Note that the latest version is not known at build time as the version id only gets created on deployment.
16
+ * This is why we use the "current" placeholder.
17
+ *
18
+ * When a new version is deployed:
19
+ * - "current" is replaced with the latest version of the Worker
20
+ * - a new entry is added for the new deployment id with the "current" version
21
+ */
22
+ // re-enable when types are fixed in the cloudflare lib
23
+ /* eslint-disable @typescript-eslint/no-explicit-any */
24
+ import path from "node:path";
25
+ import { loadConfig } from "@opennextjs/aws/adapters/config/util.js";
26
+ import logger from "@opennextjs/aws/logger.js";
27
+ import { Cloudflare, NotFoundError } from "cloudflare";
28
+ import { CURRENT_VERSION_ID, DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js";
29
+ /** Maximum number of versions to list */
30
+ const MAX_NUMBER_OF_VERSIONS = 20;
31
+ /** Maximum age of versions to list */
32
+ const MAX_VERSION_AGE_DAYS = 7;
33
+ const MS_PER_DAY = 24 * 3600 * 1000;
34
+ /**
35
+ * Compute the deployment mapping for a deployment.
36
+ *
37
+ * @param options Build options
38
+ * @param config OpenNext config
39
+ * @param envVars Environment variables
40
+ * @returns Deployment mapping or undefined
41
+ */
42
+ export async function getDeploymentMapping(options, config, envVars) {
43
+ if (config.cloudflare?.skewProtection?.enabled !== true) {
44
+ return undefined;
45
+ }
46
+ const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
47
+ const deploymentId = nextConfig.deploymentId;
48
+ if (!deploymentId) {
49
+ logger.error("Deployment ID should be set in the Next config when skew protection is enabled");
50
+ process.exit(1);
51
+ }
52
+ if (!envVars.CF_WORKER_NAME) {
53
+ logger.error("CF_WORKER_NAME should be set when skew protection is enabled");
54
+ process.exit(1);
55
+ }
56
+ if (!envVars.CF_PREVIEW_DOMAIN) {
57
+ logger.error("CF_PREVIEW_DOMAIN should be set when skew protection is enabled");
58
+ process.exit(1);
59
+ }
60
+ if (!envVars.CF_WORKERS_SCRIPTS_API_TOKEN) {
61
+ logger.error("CF_WORKERS_SCRIPTS_API_TOKEN should be set when skew protection is enabled");
62
+ process.exit(1);
63
+ }
64
+ if (!envVars.CF_ACCOUNT_ID) {
65
+ logger.error("CF_ACCOUNT_ID should be set when skew protection is enabled");
66
+ process.exit(1);
67
+ }
68
+ const apiToken = envVars.CF_WORKERS_SCRIPTS_API_TOKEN;
69
+ const accountId = envVars.CF_ACCOUNT_ID;
70
+ const client = new Cloudflare({ apiToken });
71
+ const scriptName = envVars.CF_WORKER_NAME;
72
+ const deployedVersions = await listWorkerVersions(scriptName, {
73
+ client,
74
+ accountId,
75
+ maxNumberOfVersions: config.cloudflare?.skewProtection?.maxNumberOfVersions,
76
+ afterTimeMs: config.cloudflare?.skewProtection?.maxVersionAgeDays
77
+ ? Date.now() - config.cloudflare?.skewProtection?.maxVersionAgeDays * MS_PER_DAY
78
+ : undefined,
79
+ });
80
+ const existingMapping = deployedVersions.length === 0
81
+ ? {}
82
+ : await getExistingDeploymentMapping(scriptName, deployedVersions[0].id, {
83
+ client,
84
+ accountId,
85
+ });
86
+ if (deploymentId in existingMapping) {
87
+ logger.error(`The deploymentId "${deploymentId}" has been used previously, update your next config and rebuild`);
88
+ process.exit(1);
89
+ }
90
+ const mapping = updateDeploymentMapping(existingMapping, deployedVersions, deploymentId);
91
+ return mapping;
92
+ }
93
+ /**
94
+ * Update an existing deployment mapping:
95
+ * - Replace the "current" version with the latest deployed version
96
+ * - Add a "current" version for the current deployment ID
97
+ * - Remove versions that are not passed in
98
+ *
99
+ * @param mapping Existing mapping
100
+ * @param versions Versions ordered by descending time
101
+ * @param deploymentId Deployment ID
102
+ * @returns The updated mapping
103
+ */
104
+ export function updateDeploymentMapping(mapping, versions, deploymentId) {
105
+ const newMapping = { [deploymentId]: CURRENT_VERSION_ID };
106
+ const versionIds = new Set(versions.map((v) => v.id));
107
+ for (const [deployment, version] of Object.entries(mapping)) {
108
+ if (version === CURRENT_VERSION_ID && versions.length > 0) {
109
+ newMapping[deployment] = versions[0].id;
110
+ }
111
+ else if (versionIds.has(version)) {
112
+ newMapping[deployment] = version;
113
+ }
114
+ }
115
+ return newMapping;
116
+ }
117
+ /**
118
+ * Retrieve the deployment mapping from the last deployed worker.
119
+ *
120
+ * NOTE: it is retrieved from the DEPLOYMENT_MAPPING_ENV_NAME env var.
121
+ *
122
+ * @param scriptName The name of the worker script
123
+ * @param versionId The version Id to retrieve
124
+ * @param options.client A Cloudflare API client
125
+ * @param options.accountId The Cloudflare account id
126
+ * @returns The deployment mapping
127
+ */
128
+ async function getExistingDeploymentMapping(scriptName, versionId, options) {
129
+ // See https://github.com/cloudflare/cloudflare-typescript/issues/2652
130
+ const bindings = (await getVersionDetail(scriptName, versionId, options)).resources.bindings ?? [];
131
+ for (const binding of bindings) {
132
+ if (binding.name === DEPLOYMENT_MAPPING_ENV_NAME && binding.type == "plain_text") {
133
+ return JSON.parse(binding.text);
134
+ }
135
+ }
136
+ return {};
137
+ }
138
+ /**
139
+ * Retrieve the details of the version of a script
140
+ *
141
+ * @param scriptName The name of the worker script
142
+ * @param versionId The version Id to retrieve
143
+ * @param options.client A Cloudflare API client
144
+ * @param options.accountId The Cloudflare account id
145
+
146
+ * @returns the version information
147
+ */
148
+ async function getVersionDetail(scriptName, versionId, options) {
149
+ const { client, accountId } = options;
150
+ return await client.workers.scripts.versions.get(scriptName, versionId, {
151
+ account_id: accountId,
152
+ });
153
+ }
154
+ /**
155
+ * Retrieve the versions for the script
156
+ *
157
+ * @param scriptName The name of the worker script
158
+ * @param options.client A Cloudflare API client
159
+ * @param options.accountId The Cloudflare account id
160
+ * @param options.afterTimeMs Only list version more recent than this time - default to 7 days
161
+ * @param options.maxNumberOfVersions The maximum number of version to return - default to 20 versions.
162
+ * @returns A list of id and creation date ordered by descending creation date
163
+ */
164
+ export async function listWorkerVersions(scriptName, options) {
165
+ const versions = [];
166
+ const { client, accountId, afterTimeMs = new Date().getTime() - MAX_VERSION_AGE_DAYS * 24 * 3600 * 1000, maxNumberOfVersions = MAX_NUMBER_OF_VERSIONS, } = options;
167
+ try {
168
+ for await (const version of client.workers.scripts.versions.list(scriptName, {
169
+ account_id: accountId,
170
+ })) {
171
+ const id = version.id;
172
+ const createdOn = version.metadata?.created_on;
173
+ if (id && createdOn) {
174
+ const createdOnMs = new Date(createdOn).getTime();
175
+ if (createdOnMs < afterTimeMs) {
176
+ break;
177
+ }
178
+ versions.push({ id, createdOnMs });
179
+ if (versions.length >= maxNumberOfVersions) {
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ catch (e) {
186
+ if (e instanceof NotFoundError && e.status === 404) {
187
+ // The worker has not been deployed before, no previous versions.
188
+ return [];
189
+ }
190
+ throw e;
191
+ }
192
+ return versions.sort((a, b) => b.createdOnMs - a.createdOnMs);
193
+ }
@@ -1,10 +1,24 @@
1
+ import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js";
1
2
  import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js";
3
+ import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js";
2
4
  import { populateCache } from "./populate-cache.js";
5
+ import { getDeploymentMapping } from "./skew-protection.js";
3
6
  export async function upload(options, config, uploadOptions) {
7
+ const envVars = await getEnvFromPlatformProxy({
8
+ // TODO: Pass the configPath, update everywhere applicable
9
+ environment: getWranglerEnvironmentFlag(uploadOptions.passthroughArgs),
10
+ });
11
+ const deploymentMapping = await getDeploymentMapping(options, config, envVars);
4
12
  await populateCache(options, config, {
5
13
  target: "remote",
6
14
  environment: getWranglerEnvironmentFlag(uploadOptions.passthroughArgs),
7
15
  cacheChunkSize: uploadOptions.cacheChunkSize,
8
16
  });
9
- runWrangler(options, ["versions upload", ...uploadOptions.passthroughArgs], { logging: "all" });
17
+ runWrangler(options, [
18
+ "versions upload",
19
+ ...uploadOptions.passthroughArgs,
20
+ ...(deploymentMapping
21
+ ? [`--var ${DEPLOYMENT_MAPPING_ENV_NAME}:${quoteShellMeta(JSON.stringify(deploymentMapping))}`]
22
+ : []),
23
+ ], { logging: "all" });
10
24
  }
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { compileOpenNextConfig } from "@opennextjs/aws/build/compileConfig.js";
5
5
  import { normalizeOptions } from "@opennextjs/aws/build/helper.js";
6
6
  import { printHeader, showWarningOnWindows } from "@opennextjs/aws/build/utils.js";
7
7
  import logger from "@opennextjs/aws/logger.js";
8
+ import { unstable_readConfig } from "wrangler";
8
9
  import { getArgs } from "./args.js";
9
10
  import { build } from "./build/build.js";
10
11
  import { createOpenNextConfigIfNotExistent, ensureCloudflareConfig } from "./build/utils/index.js";
@@ -12,6 +13,7 @@ import { deploy } from "./commands/deploy.js";
12
13
  import { populateCache } from "./commands/populate-cache.js";
13
14
  import { preview } from "./commands/preview.js";
14
15
  import { upload } from "./commands/upload.js";
16
+ import { getWranglerConfigFlag, getWranglerEnvironmentFlag } from "./utils/run-wrangler.js";
15
17
  const nextAppDir = process.cwd();
16
18
  async function runCommand(args) {
17
19
  printHeader(`Cloudflare ${args.command}`);
@@ -19,6 +21,7 @@ async function runCommand(args) {
19
21
  const baseDir = nextAppDir;
20
22
  const require = createRequire(import.meta.url);
21
23
  const openNextDistDir = path.dirname(require.resolve("@opennextjs/aws/index.js"));
24
+ // TODO: retrieve the compiled version if command != build
22
25
  await createOpenNextConfigIfNotExistent(baseDir);
23
26
  const { config, buildDir } = await compileOpenNextConfig(baseDir, undefined, {
24
27
  compileEdge: true,
@@ -28,8 +31,13 @@ async function runCommand(args) {
28
31
  const options = normalizeOptions(config, openNextDistDir, buildDir);
29
32
  logger.setLevel(options.debug ? "debug" : "info");
30
33
  switch (args.command) {
31
- case "build":
32
- return build(options, config, { ...args, sourceDir: baseDir });
34
+ case "build": {
35
+ const argv = process.argv.slice(2);
36
+ const wranglerEnv = getWranglerEnvironmentFlag(argv);
37
+ const wranglerConfigFile = getWranglerConfigFlag(argv);
38
+ const wranglerConfig = unstable_readConfig({ env: wranglerEnv, config: wranglerConfigFile });
39
+ return build(options, config, { ...args, sourceDir: baseDir }, wranglerConfig);
40
+ }
33
41
  case "preview":
34
42
  return preview(options, config, args);
35
43
  case "deploy":
@@ -15,10 +15,22 @@ export type LocalPattern = {
15
15
  * Local images (starting with a '/' as fetched using the passed fetcher).
16
16
  * Remote images should match the configured remote patterns or a 404 response is returned.
17
17
  */
18
- export declare function fetchImage(fetcher: Fetcher | undefined, imageUrl: string): Response | Promise<Response> | undefined;
18
+ export declare function fetchImage(fetcher: Fetcher | undefined, imageUrl: string, ctx: ExecutionContext): Promise<Response | undefined>;
19
19
  export declare function matchRemotePattern(pattern: RemotePattern, url: URL): boolean;
20
20
  export declare function matchLocalPattern(pattern: LocalPattern, url: URL): boolean;
21
+ /**
22
+ * Detects the content type by looking at the first few bytes of a file
23
+ *
24
+ * Based on https://github.com/vercel/next.js/blob/72c9635/packages/next/src/server/image-optimizer.ts#L155
25
+ *
26
+ * @param buffer The image bytes
27
+ * @returns a content type of undefined for unsupported content
28
+ */
29
+ export declare function detectContentType(buffer: Uint8Array): "image/svg+xml" | "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/avif" | "image/x-icon" | "image/x-icns" | "image/tiff" | "image/bmp" | undefined;
21
30
  declare global {
22
31
  var __IMAGES_REMOTE_PATTERNS__: RemotePattern[];
23
32
  var __IMAGES_LOCAL_PATTERNS__: LocalPattern[];
33
+ var __IMAGES_ALLOW_SVG__: boolean;
34
+ var __IMAGES_CONTENT_SECURITY_POLICY__: string;
35
+ var __IMAGES_CONTENT_DISPOSITION__: string;
24
36
  }
@@ -4,7 +4,7 @@
4
4
  * Local images (starting with a '/' as fetched using the passed fetcher).
5
5
  * Remote images should match the configured remote patterns or a 404 response is returned.
6
6
  */
7
- export function fetchImage(fetcher, imageUrl) {
7
+ export async function fetchImage(fetcher, imageUrl, ctx) {
8
8
  // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L208
9
9
  if (!imageUrl || imageUrl.length > 3072 || imageUrl.startsWith("//")) {
10
10
  return getUrlErrorResponse();
@@ -46,7 +46,40 @@ export function fetchImage(fetcher, imageUrl) {
46
46
  if (!__IMAGES_REMOTE_PATTERNS__.some((p) => matchRemotePattern(p, url))) {
47
47
  return getUrlErrorResponse();
48
48
  }
49
- return fetch(imageUrl, { cf: { cacheEverything: true } });
49
+ const imgResponse = await fetch(imageUrl, { cf: { cacheEverything: true } });
50
+ if (!imgResponse.body) {
51
+ return imgResponse;
52
+ }
53
+ const buffer = new ArrayBuffer(32);
54
+ try {
55
+ let contentType;
56
+ // body1 is eventually used for the response
57
+ // body2 is used to detect the content type
58
+ const [body1, body2] = imgResponse.body.tee();
59
+ const reader = body2.getReader({ mode: "byob" });
60
+ const { value } = await reader.read(new Uint8Array(buffer));
61
+ // Release resources by calling `reader.cancel()`
62
+ // `ctx.waitUntil` keeps the runtime running until the promise settles without having to wait here.
63
+ ctx.waitUntil(reader.cancel());
64
+ if (value) {
65
+ contentType = detectContentType(value);
66
+ }
67
+ if (contentType && !(contentType === SVG && !__IMAGES_ALLOW_SVG__)) {
68
+ const headers = new Headers(imgResponse.headers);
69
+ headers.set("content-type", contentType);
70
+ headers.set("content-disposition", __IMAGES_CONTENT_DISPOSITION__);
71
+ headers.set("content-security-policy", __IMAGES_CONTENT_SECURITY_POLICY__);
72
+ return new Response(body1, { ...imgResponse, headers });
73
+ }
74
+ return new Response('"url" parameter is valid but image type is not allowed', {
75
+ status: 400,
76
+ });
77
+ }
78
+ catch {
79
+ return new Response('"url" parameter is valid but upstream response is invalid', {
80
+ status: 400,
81
+ });
82
+ }
50
83
  }
51
84
  export function matchRemotePattern(pattern, url) {
52
85
  // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/shared/lib/match-remote-pattern.ts
@@ -79,4 +112,57 @@ export function matchLocalPattern(pattern, url) {
79
112
  function getUrlErrorResponse() {
80
113
  return new Response(`"url" parameter is not allowed`, { status: 400 });
81
114
  }
115
+ const AVIF = "image/avif";
116
+ const WEBP = "image/webp";
117
+ const PNG = "image/png";
118
+ const JPEG = "image/jpeg";
119
+ const GIF = "image/gif";
120
+ const SVG = "image/svg+xml";
121
+ const ICO = "image/x-icon";
122
+ const ICNS = "image/x-icns";
123
+ const TIFF = "image/tiff";
124
+ const BMP = "image/bmp";
125
+ /**
126
+ * Detects the content type by looking at the first few bytes of a file
127
+ *
128
+ * Based on https://github.com/vercel/next.js/blob/72c9635/packages/next/src/server/image-optimizer.ts#L155
129
+ *
130
+ * @param buffer The image bytes
131
+ * @returns a content type of undefined for unsupported content
132
+ */
133
+ export function detectContentType(buffer) {
134
+ if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
135
+ return JPEG;
136
+ }
137
+ if ([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => buffer[i] === b)) {
138
+ return PNG;
139
+ }
140
+ if ([0x47, 0x49, 0x46, 0x38].every((b, i) => buffer[i] === b)) {
141
+ return GIF;
142
+ }
143
+ if ([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50].every((b, i) => !b || buffer[i] === b)) {
144
+ return WEBP;
145
+ }
146
+ if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) {
147
+ return SVG;
148
+ }
149
+ if ([0x3c, 0x73, 0x76, 0x67].every((b, i) => buffer[i] === b)) {
150
+ return SVG;
151
+ }
152
+ if ([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every((b, i) => !b || buffer[i] === b)) {
153
+ return AVIF;
154
+ }
155
+ if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) {
156
+ return ICO;
157
+ }
158
+ if ([0x69, 0x63, 0x6e, 0x73].every((b, i) => buffer[i] === b)) {
159
+ return ICNS;
160
+ }
161
+ if ([0x49, 0x49, 0x2a, 0x00].every((b, i) => buffer[i] === b)) {
162
+ return TIFF;
163
+ }
164
+ if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) {
165
+ return BMP;
166
+ }
167
+ }
82
168
  /* eslint-enable no-var */
@@ -10,4 +10,6 @@ export declare function runWithCloudflareRequestContext(request: Request, env: C
10
10
  declare global {
11
11
  var __BUILD_TIMESTAMP_MS__: number;
12
12
  var __NEXT_BASE_PATH__: string;
13
+ var __ASSETS_RUN_WORKER_FIRST__: boolean | string[] | undefined;
14
+ var __DEPLOYMENT_ID__: string;
13
15
  }
@@ -73,8 +73,9 @@ function initRuntime() {
73
73
  };
74
74
  Object.assign(globalThis, {
75
75
  Request: CustomRequest,
76
- __BUILD_TIMESTAMP_MS__: __BUILD_TIMESTAMP_MS__,
77
- __NEXT_BASE_PATH__: __NEXT_BASE_PATH__,
76
+ __BUILD_TIMESTAMP_MS__,
77
+ __NEXT_BASE_PATH__,
78
+ __ASSETS_RUN_WORKER_FIRST__,
78
79
  // The external middleware will use the convertTo function of the `edge` converter
79
80
  // by default it will try to fetch the request, but since we are running everything in the same worker
80
81
  // we need to use the request as is.
@@ -113,5 +114,9 @@ function populateProcessEnv(url, env) {
113
114
  * https://github.com/vercel/next.js/blob/6b1e48080e896e0d44a05fe009cb79d2d3f91774/packages/next/src/server/app-render/action-handler.ts#L307-L316
114
115
  */
115
116
  process.env.__NEXT_PRIVATE_ORIGIN = url.origin;
117
+ // `__DEPLOYMENT_ID__` is a string (passed via ESBuild).
118
+ if (__DEPLOYMENT_ID__) {
119
+ process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__;
120
+ }
116
121
  }
117
122
  /* eslint-enable no-var */
@@ -0,0 +1,28 @@
1
+ /** Name of the env var containing the mapping */
2
+ export declare const DEPLOYMENT_MAPPING_ENV_NAME = "CF_DEPLOYMENT_MAPPING";
3
+ /** Version used for the latest worker */
4
+ export declare const CURRENT_VERSION_ID = "current";
5
+ /**
6
+ * Routes the request to the requested deployment.
7
+ *
8
+ * A specific deployment can be requested via:
9
+ * - the `dpl` search parameter for assets
10
+ * - the `x-deployment-id` for other requests
11
+ *
12
+ * When a specific deployment is requested, we route to that deployment via the preview URLs.
13
+ * See https://developers.cloudflare.com/workers/configuration/previews/
14
+ *
15
+ * When the requested deployment is not supported a 400 response is returned.
16
+ *
17
+ * Notes:
18
+ * - The re-routing is only active for the deployed version of the app (on a custom domain)
19
+ * - Assets are also handled when `run_worker_first` is enabled.
20
+ * See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first
21
+ *
22
+ * @param request
23
+ * @returns
24
+ */
25
+ export declare function maybeGetSkewProtectionResponse(request: Request): Promise<Response> | Response | undefined;
26
+ declare global {
27
+ var __SKEW_PROTECTION_ENABLED__: boolean;
28
+ }
@@ -0,0 +1,57 @@
1
+ import process from "node:process";
2
+ /** Name of the env var containing the mapping */
3
+ export const DEPLOYMENT_MAPPING_ENV_NAME = "CF_DEPLOYMENT_MAPPING";
4
+ /** Version used for the latest worker */
5
+ export const CURRENT_VERSION_ID = "current";
6
+ /**
7
+ * Routes the request to the requested deployment.
8
+ *
9
+ * A specific deployment can be requested via:
10
+ * - the `dpl` search parameter for assets
11
+ * - the `x-deployment-id` for other requests
12
+ *
13
+ * When a specific deployment is requested, we route to that deployment via the preview URLs.
14
+ * See https://developers.cloudflare.com/workers/configuration/previews/
15
+ *
16
+ * When the requested deployment is not supported a 400 response is returned.
17
+ *
18
+ * Notes:
19
+ * - The re-routing is only active for the deployed version of the app (on a custom domain)
20
+ * - Assets are also handled when `run_worker_first` is enabled.
21
+ * See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first
22
+ *
23
+ * @param request
24
+ * @returns
25
+ */
26
+ export function maybeGetSkewProtectionResponse(request) {
27
+ // no early return as esbuild would not treeshake the code.
28
+ if (__SKEW_PROTECTION_ENABLED__) {
29
+ const url = new URL(request.url);
30
+ // Skew protection is only active for the latest version of the app served on a custom domain.
31
+ if (url.hostname === "localhost" || url.hostname.endsWith(".workers.dev")) {
32
+ return undefined;
33
+ }
34
+ const requestDeploymentId = request.headers.get("x-deployment-id") ?? url.searchParams.get("dpl");
35
+ if (!requestDeploymentId || requestDeploymentId === process.env.DEPLOYMENT_ID) {
36
+ // The request does not specify a deployment id or it is the current deployment id
37
+ return undefined;
38
+ }
39
+ const mapping = process.env[DEPLOYMENT_MAPPING_ENV_NAME]
40
+ ? JSON.parse(process.env[DEPLOYMENT_MAPPING_ENV_NAME])
41
+ : {};
42
+ if (!(requestDeploymentId in mapping)) {
43
+ // Unknown deployment id, serve the current version
44
+ return undefined;
45
+ }
46
+ const version = mapping[requestDeploymentId];
47
+ if (!version || version === CURRENT_VERSION_ID) {
48
+ return undefined;
49
+ }
50
+ const versionDomain = version.split("-")[0];
51
+ const hostname = `${versionDomain}-${process.env.CF_WORKER_NAME}.${process.env.CF_PREVIEW_DOMAIN}.workers.dev`;
52
+ url.hostname = hostname;
53
+ const requestToOlderDeployment = new Request(url, request);
54
+ return fetch(requestToOlderDeployment);
55
+ }
56
+ }
57
+ /* eslint-enable no-var */
@@ -2,6 +2,8 @@
2
2
  import { fetchImage } from "./cloudflare/images.js";
3
3
  //@ts-expect-error: Will be resolved by wrangler build
4
4
  import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
5
+ //@ts-expect-error: Will be resolved by wrangler build
6
+ import { maybeGetSkewProtectionResponse } from "./cloudflare/skew-protection.js";
5
7
  // @ts-expect-error: Will be resolved by wrangler build
6
8
  import { handler as middlewareHandler } from "./middleware/handler.mjs";
7
9
  //@ts-expect-error: Will be resolved by wrangler build
@@ -13,6 +15,10 @@ export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js
13
15
  export default {
14
16
  async fetch(request, env, ctx) {
15
17
  return runWithCloudflareRequestContext(request, env, ctx, async () => {
18
+ const response = maybeGetSkewProtectionResponse(request);
19
+ if (response) {
20
+ return response;
21
+ }
16
22
  const url = new URL(request.url);
17
23
  // Serve images in development.
18
24
  // Note: "/cdn-cgi/image/..." requests do not reach production workers.
@@ -29,7 +35,7 @@ export default {
29
35
  // Fallback for the Next default image loader.
30
36
  if (url.pathname === `${globalThis.__NEXT_BASE_PATH__}/_next/image`) {
31
37
  const imageUrl = url.searchParams.get("url") ?? "";
32
- return fetchImage(env.ASSETS, imageUrl);
38
+ return await fetchImage(env.ASSETS, imageUrl, ctx);
33
39
  }
34
40
  // - `Request`s are handled by the Next server
35
41
  const reqOrResp = await middlewareHandler(request, env, ctx);
@@ -7,11 +7,28 @@ type WranglerOptions = {
7
7
  };
8
8
  export declare function runWrangler(options: BuildOptions, args: string[], wranglerOpts?: WranglerOptions): void;
9
9
  export declare function isWranglerTarget(v: string | undefined): v is WranglerTarget;
10
+ /**
11
+ * Returns the value of the flag.
12
+ *
13
+ * The value is retrieved for `<argName> value` or `<argName>=value`.
14
+ *
15
+ * @param args List of args
16
+ * @param argName The arg name with leading dashes, i.e. `--env` or `-e`
17
+ * @returns The value or undefined when not found
18
+ */
19
+ export declare function getFlagValue(args: string[], ...argNames: string[]): string | undefined;
10
20
  /**
11
21
  * Find the value of the environment flag (`--env` / `-e`) used by Wrangler.
12
22
  *
13
23
  * @param args - CLI arguments.
14
- * @returns Value of the environment flag.
24
+ * @returns Value of the environment flag or undefined when not found
15
25
  */
16
26
  export declare function getWranglerEnvironmentFlag(args: string[]): string | undefined;
27
+ /**
28
+ * Find the value of the config flag (`--config` / `-c`) used by Wrangler.
29
+ *
30
+ * @param args - CLI arguments.
31
+ * @returns Value of the config flag or undefined when not found
32
+ */
33
+ export declare function getWranglerConfigFlag(args: string[]): string | undefined;
17
34
  export {};
@@ -62,22 +62,49 @@ export function runWrangler(options, args, wranglerOpts = {}) {
62
62
  export function isWranglerTarget(v) {
63
63
  return !!v && ["local", "remote"].includes(v);
64
64
  }
65
+ /**
66
+ * Returns the value of the flag.
67
+ *
68
+ * The value is retrieved for `<argName> value` or `<argName>=value`.
69
+ *
70
+ * @param args List of args
71
+ * @param argName The arg name with leading dashes, i.e. `--env` or `-e`
72
+ * @returns The value or undefined when not found
73
+ */
74
+ export function getFlagValue(args, ...argNames) {
75
+ if (argNames.some((name) => !name.startsWith("-"))) {
76
+ // Names should start with "-" or "--"
77
+ throw new Error(`Invalid arg names: ${argNames}`);
78
+ }
79
+ for (const argName of argNames) {
80
+ for (let i = 0; i <= args.length; i++) {
81
+ const arg = args[i];
82
+ if (!arg)
83
+ continue;
84
+ if (arg === argName) {
85
+ return args[i + 1];
86
+ }
87
+ if (arg.startsWith(argName)) {
88
+ return arg.split("=")[1];
89
+ }
90
+ }
91
+ }
92
+ }
65
93
  /**
66
94
  * Find the value of the environment flag (`--env` / `-e`) used by Wrangler.
67
95
  *
68
96
  * @param args - CLI arguments.
69
- * @returns Value of the environment flag.
97
+ * @returns Value of the environment flag or undefined when not found
70
98
  */
71
99
  export function getWranglerEnvironmentFlag(args) {
72
- for (let i = 0; i <= args.length; i++) {
73
- const arg = args[i];
74
- if (!arg)
75
- continue;
76
- if (arg === "--env" || arg === "-e") {
77
- return args[i + 1];
78
- }
79
- if (arg.startsWith("--env=") || arg.startsWith("-e=")) {
80
- return arg.split("=")[1];
81
- }
82
- }
100
+ return getFlagValue(args, "--env", "-e");
101
+ }
102
+ /**
103
+ * Find the value of the config flag (`--config` / `-c`) used by Wrangler.
104
+ *
105
+ * @param args - CLI arguments.
106
+ * @returns Value of the config flag or undefined when not found
107
+ */
108
+ export function getWranglerConfigFlag(args) {
109
+ return getFlagValue(args, "--config", "-c");
83
110
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opennextjs/cloudflare",
3
3
  "description": "Cloudflare builder for next apps",
4
- "version": "1.4.0",
4
+ "version": "1.5.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opennextjs-cloudflare": "dist/cli/index.js"
@@ -55,6 +55,7 @@
55
55
  "@types/mock-fs": "^4.13.4",
56
56
  "@types/node": "^22.2.0",
57
57
  "@types/picomatch": "^4.0.0",
58
+ "cloudflare": "^4.4.1",
58
59
  "diff": "^8.0.2",
59
60
  "esbuild": "^0.25.4",
60
61
  "eslint": "^9.11.1",
@@ -71,7 +72,7 @@
71
72
  "vitest": "^2.1.1"
72
73
  },
73
74
  "peerDependencies": {
74
- "wrangler": "^4.19.1"
75
+ "wrangler": "^4.23.0"
75
76
  },
76
77
  "scripts": {
77
78
  "clean": "rimraf dist",