this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add support for skew protection (#746)

Co-authored-by: conico974 <nicodorseuil@yahoo.fr>

authored by

Victor Berchet
conico974
and committed by
GitHub
6d020fe2 40f7e724

+712 -85
+5
.changeset/curly-lions-build.md
··· 1 + --- 2 + "@opennextjs/cloudflare": minor 3 + --- 4 + 5 + feat: add support for experimental skew protection
+1 -1
.prettierignore
··· 4 4 .vscode/setting.json 5 5 test-fixtures 6 6 test-snapshots 7 - playwright-report 7 + playwright-report
+1 -1
examples/playground14/wrangler.jsonc
··· 1 1 { 2 2 "$schema": "node_modules/wrangler/config-schema.json", 3 3 "main": "worker.ts", 4 - "name": "api", 4 + "name": "playground14", 5 5 "compatibility_date": "2024-12-30", 6 6 "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], 7 7 "assets": {
+3 -1
examples/playground15/app/api/env/route.ts
··· 4 4 export const dynamic = "force-dynamic"; 5 5 6 6 export async function GET() { 7 - return new Response(JSON.stringify(process.env)); 7 + return new Response(JSON.stringify(process.env, null, 2), { 8 + headers: { "content-type": "application/json" }, 9 + }); 8 10 }
+2 -1
examples/playground15/next.config.mjs
··· 1 - import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; 1 + import { initOpenNextCloudflareForDev, getDeploymentId } from "@opennextjs/cloudflare"; 2 2 3 3 initOpenNextCloudflareForDev(); 4 4 ··· 10 10 // Generate source map to validate the fix for opennextjs/opennextjs-cloudflare#341 11 11 serverSourceMaps: true, 12 12 }, 13 + deploymentId: getDeploymentId(), 13 14 }; 14 15 15 16 export default nextConfig;
+12 -6
examples/playground15/open-next.config.ts
··· 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"; 3 3 4 - export default defineCloudflareConfig({ 5 - incrementalCache: kvIncrementalCache, 6 - enableCacheInterception: true, 7 - }); 4 + export default { 5 + ...defineCloudflareConfig({ 6 + incrementalCache: r2IncrementalCache, 7 + }), 8 + cloudflare: { 9 + skewProtection: { 10 + enabled: false, 11 + }, 12 + }, 13 + } satisfies OpenNextConfig;
+1 -1
examples/playground15/package.json
··· 16 16 "cf-typegen": "wrangler types --env-interface CloudflareEnv" 17 17 }, 18 18 "dependencies": { 19 - "next": "^15.1.7", 19 + "next": "^15.3.4", 20 20 "react": "^19.0.0", 21 21 "react-dom": "^19.0.0" 22 22 },
+4 -4
examples/playground15/wrangler.jsonc
··· 1 1 { 2 2 "$schema": "node_modules/wrangler/config-schema.json", 3 3 "main": ".open-next/worker.js", 4 - "name": "api", 4 + "name": "playground15", 5 5 "compatibility_date": "2024-12-30", 6 6 "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], 7 7 "assets": { ··· 9 9 "binding": "ASSETS", 10 10 "run_worker_first": true 11 11 }, 12 - "kv_namespaces": [ 12 + "r2_buckets": [ 13 13 { 14 - "binding": "NEXT_INC_CACHE_KV", 15 - "id": "<BINDING_ID>" 14 + "binding": "NEXT_INC_CACHE_R2_BUCKET", 15 + "bucket_name": "pg15" 16 16 } 17 17 ], 18 18 "vars": {
+1
packages/cloudflare/package.json
··· 65 65 "@types/mock-fs": "catalog:", 66 66 "@types/node": "catalog:", 67 67 "@types/picomatch": "^4.0.0", 68 + "cloudflare": "^4.4.1", 68 69 "diff": "^8.0.2", 69 70 "esbuild": "catalog:", 70 71 "eslint": "catalog:",
+13
packages/cloudflare/src/api/cloudflare-context.ts
··· 65 65 CACHE_PURGE_ZONE_ID?: string; 66 66 // The API token to use for the cache purge. It should have the `Cache Purge` permission 67 67 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 + // When a specific wrangler environment is used, it should be appended at the end: 72 + // - Use `worker-name` when no wrangler environment is used 73 + // - Use `worker-name-<environment>` when a wrangler environment is used via `wrangler --env=<environment>` 74 + CF_WORKER_NAME?: string; 75 + // The subdomain where the previews are deployed, i.e. `<version-name>.<domain>.workers.dev` 76 + CF_PREVIEW_DOMAIN?: string; 77 + // Should have the `Workers Scripts:Read` permission 78 + CF_WORKERS_SCRIPTS_API_TOKEN?: string; 79 + // Cloudflare account id 80 + CF_ACCOUNT_ID?: string; 68 81 } 69 82 } 70 83
+25
packages/cloudflare/src/api/config.ts
··· 161 161 * @default false 162 162 */ 163 163 dangerousDisableConfigValidation?: boolean; 164 + 165 + /** 166 + * Skew protection. 167 + * 168 + * Note: Skew Protection is experimental and might break on minor releases. 169 + * 170 + * @default false 171 + */ 172 + skewProtection?: { 173 + // Whether to enable skew protection 174 + enabled?: boolean; 175 + // Maximum number of versions to retrieve 176 + // @default 20 177 + maxNumberOfVersions?: number; 178 + // Maximum age of versions to retrieve in days 179 + // @default 7 180 + maxVersionAgeDays?: number; 181 + }; 164 182 }; 165 183 } 166 184 ··· 170 188 */ 171 189 export function getOpenNextConfig(buildOpts: BuildOptions): OpenNextConfig { 172 190 return buildOpts.config; 191 + } 192 + 193 + /** 194 + * @returns Unique deployment ID 195 + */ 196 + export function getDeploymentId(): string { 197 + return `dpl-${new Date().getTime().toString(36)}`; 173 198 } 174 199 175 200 export type { OpenNextConfig };
+1 -1
packages/cloudflare/src/api/index.ts
··· 1 1 export * from "./cloudflare-context.js"; 2 - export { defineCloudflareConfig, type OpenNextConfig } from "./config.js"; 2 + export { defineCloudflareConfig, getDeploymentId, type OpenNextConfig } from "./config.js";
+2 -7
packages/cloudflare/src/cli/build/build.ts
··· 14 14 import { compileEnvFiles } from "./open-next/compile-env-files.js"; 15 15 import { compileImages } from "./open-next/compile-images.js"; 16 16 import { compileInit } from "./open-next/compile-init.js"; 17 + import { compileSkewProtection } from "./open-next/compile-skew-protection.js"; 17 18 import { compileDurableObjects } from "./open-next/compileDurableObjects.js"; 18 19 import { createServerBundle } from "./open-next/createServerBundle.js"; 19 20 import { createWranglerConfigIfNotExistent } from "./utils/index.js"; ··· 59 60 printHeader("Generating bundle"); 60 61 buildHelper.initOutputDir(options); 61 62 62 - // Compile cache.ts 63 63 compileCache(options); 64 - 65 - // Compile .env files 66 64 compileEnvFiles(options); 67 - 68 - // Compile workerd init 69 65 compileInit(options, wranglerConfig); 70 - 71 - // Compile image helpers 72 66 compileImages(options); 67 + compileSkewProtection(options, config); 73 68 74 69 // Compile middleware 75 70 await createMiddleware(options, { forceOnlyBuildOnce: true });
+2
packages/cloudflare/src/cli/build/open-next/compile-init.ts
··· 16 16 17 17 const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next")); 18 18 const basePath = nextConfig.basePath ?? ""; 19 + const deploymentId = nextConfig.deploymentId ?? ""; 19 20 20 21 await build({ 21 22 entryPoints: [initPath], ··· 29 30 __BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()), 30 31 __NEXT_BASE_PATH__: JSON.stringify(basePath), 31 32 __ASSETS_RUN_WORKER_FIRST__: JSON.stringify(wranglerConfig.assets?.run_worker_first ?? false), 33 + __DEPLOYMENT_ID__: JSON.stringify(deploymentId), 32 34 }, 33 35 }); 34 36 }
+28
packages/cloudflare/src/cli/build/open-next/compile-skew-protection.ts
··· 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 + const skewProtectionEnabled = config.cloudflare?.skewProtection?.enabled === true; 15 + 16 + await build({ 17 + entryPoints: [initPath], 18 + outdir: path.join(options.outputDir, "cloudflare"), 19 + bundle: false, 20 + minify: false, 21 + format: "esm", 22 + target: "esnext", 23 + platform: "node", 24 + define: { 25 + __SKEW_PROTECTION_ENABLED__: JSON.stringify(skewProtectionEnabled), 26 + }, 27 + }); 28 + }
+2 -7
packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts
··· 12 12 _require.resolve("@opennextjs/cloudflare/durable-objects/bucket-cache-purge"), 13 13 ]; 14 14 15 - const { outputDir } = buildOpts; 16 - 17 15 const baseManifestPath = path.join( 18 - outputDir, 16 + buildOpts.outputDir, 19 17 "server-functions/default", 20 18 getPackagePath(buildOpts), 21 19 ".next" 22 20 ); 23 21 24 - // We need to change the type in aws 25 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 - const prerenderManifest = loadPrerenderManifest(baseManifestPath) as any; 22 + const prerenderManifest = loadPrerenderManifest(baseManifestPath); 27 23 const previewModeId = prerenderManifest.preview.previewModeId; 28 - 29 24 const BUILD_ID = loadBuildId(baseManifestPath); 30 25 31 26 return esbuildSync(
+23 -1
packages/cloudflare/src/cli/commands/deploy.ts
··· 1 1 import { BuildOptions } from "@opennextjs/aws/build/helper.js"; 2 2 3 3 import type { OpenNextConfig } from "../../api/config.js"; 4 + import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js"; 4 5 import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js"; 6 + import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js"; 5 7 import { populateCache } from "./populate-cache.js"; 8 + import { getDeploymentMapping } from "./skew-protection.js"; 6 9 7 10 export async function deploy( 8 11 options: BuildOptions, 9 12 config: OpenNextConfig, 10 13 deployOptions: { passthroughArgs: string[]; cacheChunkSize?: number } 11 14 ) { 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 + 12 22 await populateCache(options, config, { 13 23 target: "remote", 14 24 environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs), 15 25 cacheChunkSize: deployOptions.cacheChunkSize, 16 26 }); 17 27 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 + ); 19 41 }
+41
packages/cloudflare/src/cli/commands/helpers.ts
··· 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 + }
+6 -30
packages/cloudflare/src/cli/commands/populate-cache.ts
··· 12 12 import type { IncrementalCache, TagCache } from "@opennextjs/aws/types/overrides.js"; 13 13 import { globSync } from "glob"; 14 14 import { tqdm } from "ts-tqdm"; 15 - import { getPlatformProxy, type GetPlatformProxyOptions, unstable_readConfig } from "wrangler"; 15 + import { unstable_readConfig } from "wrangler"; 16 16 17 17 import { 18 18 BINDING_NAME as KV_CACHE_BINDING_NAME, ··· 36 36 import { normalizePath } from "../build/utils/normalize-path.js"; 37 37 import type { WranglerTarget } from "../utils/run-wrangler.js"; 38 38 import { runWrangler } from "../utils/run-wrangler.js"; 39 + import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js"; 39 40 40 41 async function resolveCacheName( 41 42 value: ··· 93 94 return assets; 94 95 } 95 96 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 - 103 97 async function populateR2IncrementalCache( 104 98 options: BuildOptions, 105 99 populateCacheOptions: { target: WranglerTarget; environment?: string } ··· 118 112 throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`); 119 113 } 120 114 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]; 122 117 123 118 const assets = getCacheAssets(options); 124 119 ··· 156 151 throw new Error(`No KV binding ${JSON.stringify(KV_CACHE_BINDING_NAME)} found!`); 157 152 } 158 153 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]; 160 156 161 157 const assets = getCacheAssets(options); 162 158 ··· 270 266 } 271 267 } 272 268 } 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 - }
+69
packages/cloudflare/src/cli/commands/skew-protection.spec.ts
··· 1 + import { describe, expect, test, vi } from "vitest"; 2 + 3 + import { CURRENT_VERSION_ID } from "../templates/skew-protection"; 4 + import { listWorkerVersions, updateDeploymentMapping } from "./skew-protection"; 5 + 6 + describe("skew protection", () => { 7 + describe("listWorkerVersions", () => { 8 + test("listWorkerVersions return versions ordered by time DESC", async () => { 9 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 + const client: any = { 11 + workers: { 12 + scripts: { 13 + versions: { 14 + list: () => [], 15 + }, 16 + }, 17 + }, 18 + }; 19 + 20 + const now = Date.now(); 21 + 22 + client.workers.scripts.versions.list = vi.fn().mockReturnValue([ 23 + { 24 + id: "HEAD", 25 + metadata: { created_on: new Date(now) }, 26 + }, 27 + { 28 + id: "HEAD~2", 29 + metadata: { created_on: new Date(now - 2000) }, 30 + }, 31 + { 32 + id: "HEAD~1", 33 + metadata: { created_on: new Date(now - 1000) }, 34 + }, 35 + ]); 36 + 37 + expect(await listWorkerVersions("scriptName", { client, accountId: "accountId" })).toMatchObject([ 38 + { 39 + createdOnMs: now, 40 + id: "HEAD", 41 + }, 42 + { 43 + createdOnMs: now - 1000, 44 + id: "HEAD~1", 45 + }, 46 + { 47 + createdOnMs: now - 2000, 48 + id: "HEAD~2", 49 + }, 50 + ]); 51 + }); 52 + }); 53 + }); 54 + 55 + describe("updateDeploymentMapping", () => { 56 + test("Update", () => { 57 + const mapping = { 58 + N: CURRENT_VERSION_ID, 59 + "N-1": "vN-1", 60 + "N-2": "vN-2", 61 + }; 62 + const versions = [{ id: "vN" }, { id: "vN-1" }]; // "vN-2" is deleted 63 + expect(updateDeploymentMapping(mapping, versions, "N+1")).toMatchObject({ 64 + "N+1": CURRENT_VERSION_ID, 65 + N: "vN", 66 + "N-1": "vN-1", 67 + }); 68 + }); 69 + });
+263
packages/cloudflare/src/cli/commands/skew-protection.ts
··· 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 + 23 + // re-enable when types are fixed in the cloudflare lib 24 + /* eslint-disable @typescript-eslint/no-explicit-any */ 25 + import path from "node:path"; 26 + 27 + import { loadConfig } from "@opennextjs/aws/adapters/config/util.js"; 28 + import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; 29 + import logger from "@opennextjs/aws/logger.js"; 30 + import { Cloudflare, NotFoundError } from "cloudflare"; 31 + import type { VersionGetResponse } from "cloudflare/resources/workers/scripts/versions"; 32 + 33 + import type { OpenNextConfig } from "../../api"; 34 + import { CURRENT_VERSION_ID, DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js"; 35 + import type { WorkerEnvVar } from "./helpers.js"; 36 + 37 + /** Maximum number of versions to list */ 38 + const MAX_NUMBER_OF_VERSIONS = 20; 39 + /** Maximum age of versions to list */ 40 + const MAX_VERSION_AGE_DAYS = 7; 41 + const MS_PER_DAY = 24 * 3600 * 1000; 42 + 43 + /** 44 + * Compute the deployment mapping for a deployment. 45 + * 46 + * @param options Build options 47 + * @param config OpenNext config 48 + * @param envVars Environment variables 49 + * @returns Deployment mapping or undefined 50 + */ 51 + export async function getDeploymentMapping( 52 + options: BuildOptions, 53 + config: OpenNextConfig, 54 + envVars: WorkerEnvVar 55 + ): Promise<Record<string, string> | undefined> { 56 + if (config.cloudflare?.skewProtection?.enabled !== true) { 57 + return undefined; 58 + } 59 + 60 + const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next")); 61 + const deploymentId = nextConfig.deploymentId; 62 + 63 + if (!deploymentId) { 64 + logger.error("Deployment ID should be set in the Next config when skew protection is enabled"); 65 + process.exit(1); 66 + } 67 + 68 + if (!envVars.CF_WORKER_NAME) { 69 + logger.error("CF_WORKER_NAME should be set when skew protection is enabled"); 70 + process.exit(1); 71 + } 72 + 73 + if (!envVars.CF_PREVIEW_DOMAIN) { 74 + logger.error("CF_PREVIEW_DOMAIN should be set when skew protection is enabled"); 75 + process.exit(1); 76 + } 77 + 78 + if (!envVars.CF_WORKERS_SCRIPTS_API_TOKEN) { 79 + logger.error("CF_WORKERS_SCRIPTS_API_TOKEN should be set when skew protection is enabled"); 80 + process.exit(1); 81 + } 82 + 83 + if (!envVars.CF_ACCOUNT_ID) { 84 + logger.error("CF_ACCOUNT_ID should be set when skew protection is enabled"); 85 + process.exit(1); 86 + } 87 + 88 + const apiToken = envVars.CF_WORKERS_SCRIPTS_API_TOKEN!; 89 + const accountId = envVars.CF_ACCOUNT_ID!; 90 + 91 + const client = new Cloudflare({ apiToken }); 92 + const scriptName = envVars.CF_WORKER_NAME!; 93 + 94 + const deployedVersions = await listWorkerVersions(scriptName, { 95 + client, 96 + accountId, 97 + maxNumberOfVersions: config.cloudflare?.skewProtection?.maxNumberOfVersions, 98 + afterTimeMs: config.cloudflare?.skewProtection?.maxVersionAgeDays 99 + ? Date.now() - config.cloudflare?.skewProtection?.maxVersionAgeDays * MS_PER_DAY 100 + : undefined, 101 + }); 102 + 103 + const existingMapping = 104 + deployedVersions.length === 0 105 + ? {} 106 + : await getExistingDeploymentMapping(scriptName, deployedVersions[0]!.id, { 107 + client, 108 + accountId, 109 + }); 110 + 111 + if (deploymentId in existingMapping) { 112 + logger.error( 113 + `The deploymentId "${deploymentId}" has been used previously, update your next config and rebuild` 114 + ); 115 + process.exit(1); 116 + } 117 + 118 + const mapping = updateDeploymentMapping(existingMapping, deployedVersions, deploymentId); 119 + 120 + return mapping; 121 + } 122 + 123 + /** 124 + * Update an existing deployment mapping: 125 + * - Replace the "current" version with the latest deployed version 126 + * - Add a "current" version for the current deployment ID 127 + * - Remove versions that are not passed in 128 + * 129 + * @param mapping Existing mapping 130 + * @param versions Versions ordered by descending time 131 + * @param deploymentId Deployment ID 132 + * @returns The updated mapping 133 + */ 134 + export function updateDeploymentMapping( 135 + mapping: Record<string, string>, 136 + versions: { id: string }[], 137 + deploymentId: string 138 + ): Record<string, string> { 139 + const newMapping: Record<string, string> = { [deploymentId]: CURRENT_VERSION_ID }; 140 + const versionIds = new Set(versions.map((v) => v.id)); 141 + 142 + for (const [deployment, version] of Object.entries(mapping)) { 143 + if (version === CURRENT_VERSION_ID && versions.length > 0) { 144 + newMapping[deployment] = versions[0]!.id; 145 + } else if (versionIds.has(version)) { 146 + newMapping[deployment] = version; 147 + } 148 + } 149 + 150 + return newMapping; 151 + } 152 + 153 + /** 154 + * Retrieve the deployment mapping from the last deployed worker. 155 + * 156 + * NOTE: it is retrieved from the DEPLOYMENT_MAPPING_ENV_NAME env var. 157 + * 158 + * @param scriptName The name of the worker script 159 + * @param versionId The version Id to retrieve 160 + * @param options.client A Cloudflare API client 161 + * @param options.accountId The Cloudflare account id 162 + * @returns The deployment mapping 163 + */ 164 + async function getExistingDeploymentMapping( 165 + scriptName: string, 166 + versionId: string, 167 + options: { 168 + client: Cloudflare; 169 + accountId: string; 170 + } 171 + ): Promise<Record<string, string>> { 172 + // See https://github.com/cloudflare/cloudflare-typescript/issues/2652 173 + const bindings = 174 + ((await getVersionDetail(scriptName, versionId, options)).resources.bindings as any[]) ?? []; 175 + 176 + for (const binding of bindings) { 177 + if (binding.name === DEPLOYMENT_MAPPING_ENV_NAME && binding.type == "plain_text") { 178 + return JSON.parse(binding.text); 179 + } 180 + } 181 + 182 + return {}; 183 + } 184 + 185 + /** 186 + * Retrieve the details of the version of a script 187 + * 188 + * @param scriptName The name of the worker script 189 + * @param versionId The version Id to retrieve 190 + * @param options.client A Cloudflare API client 191 + * @param options.accountId The Cloudflare account id 192 + 193 + * @returns the version information 194 + */ 195 + async function getVersionDetail( 196 + scriptName: string, 197 + versionId: string, 198 + options: { 199 + client: Cloudflare; 200 + accountId: string; 201 + } 202 + ): Promise<VersionGetResponse> { 203 + const { client, accountId } = options; 204 + return await client.workers.scripts.versions.get(scriptName, versionId, { 205 + account_id: accountId, 206 + }); 207 + } 208 + 209 + /** 210 + * Retrieve the versions for the script 211 + * 212 + * @param scriptName The name of the worker script 213 + * @param options.client A Cloudflare API client 214 + * @param options.accountId The Cloudflare account id 215 + * @param options.afterTimeMs Only list version more recent than this time - default to 7 days 216 + * @param options.maxNumberOfVersions The maximum number of version to return - default to 20 versions. 217 + * @returns A list of id and creation date ordered by descending creation date 218 + */ 219 + export async function listWorkerVersions( 220 + scriptName: string, 221 + options: { 222 + client: Cloudflare; 223 + accountId: string; 224 + afterTimeMs?: number; 225 + maxNumberOfVersions?: number; 226 + } 227 + ): Promise<{ id: string; createdOnMs: number }[]> { 228 + const versions = []; 229 + const { 230 + client, 231 + accountId, 232 + afterTimeMs = new Date().getTime() - MAX_VERSION_AGE_DAYS * 24 * 3600 * 1000, 233 + maxNumberOfVersions = MAX_NUMBER_OF_VERSIONS, 234 + } = options; 235 + 236 + try { 237 + for await (const version of client.workers.scripts.versions.list(scriptName, { 238 + account_id: accountId, 239 + })) { 240 + const id = version.id; 241 + const createdOn = version.metadata?.created_on; 242 + 243 + if (id && createdOn) { 244 + const createdOnMs = new Date(createdOn).getTime(); 245 + if (createdOnMs < afterTimeMs) { 246 + break; 247 + } 248 + versions.push({ id, createdOnMs }); 249 + if (versions.length >= maxNumberOfVersions) { 250 + break; 251 + } 252 + } 253 + } 254 + } catch (e) { 255 + if (e instanceof NotFoundError && e.status === 404) { 256 + // The worker has not been deployed before, no previous versions. 257 + return []; 258 + } 259 + throw e; 260 + } 261 + 262 + return versions.sort((a, b) => b.createdOnMs - a.createdOnMs); 263 + }
+21 -1
packages/cloudflare/src/cli/commands/upload.ts
··· 1 1 import { BuildOptions } from "@opennextjs/aws/build/helper.js"; 2 2 3 3 import type { OpenNextConfig } from "../../api/config.js"; 4 + import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js"; 4 5 import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js"; 6 + import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js"; 5 7 import { populateCache } from "./populate-cache.js"; 8 + import { getDeploymentMapping } from "./skew-protection.js"; 6 9 7 10 export async function upload( 8 11 options: BuildOptions, 9 12 config: OpenNextConfig, 10 13 uploadOptions: { passthroughArgs: string[]; cacheChunkSize?: number } 11 14 ) { 15 + const envVars = await getEnvFromPlatformProxy({ 16 + // TODO: Pass the configPath, update everywhere applicable 17 + environment: getWranglerEnvironmentFlag(uploadOptions.passthroughArgs), 18 + }); 19 + 20 + const deploymentMapping = await getDeploymentMapping(options, config, envVars); 21 + 12 22 await populateCache(options, config, { 13 23 target: "remote", 14 24 environment: getWranglerEnvironmentFlag(uploadOptions.passthroughArgs), 15 25 cacheChunkSize: uploadOptions.cacheChunkSize, 16 26 }); 17 27 18 - runWrangler(options, ["versions upload", ...uploadOptions.passthroughArgs], { logging: "all" }); 28 + runWrangler( 29 + options, 30 + [ 31 + "versions upload", 32 + ...uploadOptions.passthroughArgs, 33 + ...(deploymentMapping 34 + ? [`--var ${DEPLOYMENT_MAPPING_ENV_NAME}:${quoteShellMeta(JSON.stringify(deploymentMapping))}`] 35 + : []), 36 + ], 37 + { logging: "all" } 38 + ); 19 39 }
+1
packages/cloudflare/src/cli/index.ts
··· 28 28 const require = createRequire(import.meta.url); 29 29 const openNextDistDir = path.dirname(require.resolve("@opennextjs/aws/index.js")); 30 30 31 + // TODO: retrieve the compiled version if command != build 31 32 await createOpenNextConfigIfNotExistent(baseDir); 32 33 const { config, buildDir } = await compileOpenNextConfig(baseDir, undefined, { 33 34 compileEdge: true,
+7
packages/cloudflare/src/cli/templates/init.ts
··· 139 139 * https://github.com/vercel/next.js/blob/6b1e48080e896e0d44a05fe009cb79d2d3f91774/packages/next/src/server/app-render/action-handler.ts#L307-L316 140 140 */ 141 141 process.env.__NEXT_PRIVATE_ORIGIN = url.origin; 142 + 143 + // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). 144 + if (__DEPLOYMENT_ID__) { 145 + process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; 146 + } 142 147 } 143 148 144 149 /* eslint-disable no-var */ ··· 149 154 var __NEXT_BASE_PATH__: string; 150 155 // Value of `run_worker_first` for the asset binding 151 156 var __ASSETS_RUN_WORKER_FIRST__: boolean | string[] | undefined; 157 + // Deployment ID 158 + var __DEPLOYMENT_ID__: string; 152 159 } 153 160 /* eslint-enable no-var */
+74
packages/cloudflare/src/cli/templates/skew-protection.ts
··· 1 + import process from "node:process"; 2 + 3 + /** Name of the env var containing the mapping */ 4 + export const DEPLOYMENT_MAPPING_ENV_NAME = "CF_DEPLOYMENT_MAPPING"; 5 + /** Version used for the latest worker */ 6 + export const CURRENT_VERSION_ID = "current"; 7 + 8 + /** 9 + * Routes the request to the requested deployment. 10 + * 11 + * A specific deployment can be requested via: 12 + * - the `dpl` search parameter for assets 13 + * - the `x-deployment-id` for other requests 14 + * 15 + * When a specific deployment is requested, we route to that deployment via the preview URLs. 16 + * See https://developers.cloudflare.com/workers/configuration/previews/ 17 + * 18 + * When the requested deployment is not supported a 400 response is returned. 19 + * 20 + * Notes: 21 + * - The re-routing is only active for the deployed version of the app (on a custom domain) 22 + * - Assets are also handled when `run_worker_first` is enabled. 23 + * See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first 24 + * 25 + * @param request 26 + * @returns 27 + */ 28 + export function maybeGetSkewProtectionResponse(request: Request): Promise<Response> | Response | undefined { 29 + // no early return as esbuild would not treeshake the code. 30 + if (__SKEW_PROTECTION_ENABLED__) { 31 + const url = new URL(request.url); 32 + 33 + // Skew protection is only active for the latest version of the app served on a custom domain. 34 + if (url.hostname === "localhost" || url.hostname.endsWith(".workers.dev")) { 35 + return undefined; 36 + } 37 + 38 + const requestDeploymentId = request.headers.get("x-deployment-id") ?? url.searchParams.get("dpl"); 39 + 40 + if (!requestDeploymentId || requestDeploymentId === process.env.DEPLOYMENT_ID) { 41 + // The request does not specify a deployment id or it is the current deployment id 42 + return undefined; 43 + } 44 + 45 + const mapping = process.env[DEPLOYMENT_MAPPING_ENV_NAME] 46 + ? JSON.parse(process.env[DEPLOYMENT_MAPPING_ENV_NAME]) 47 + : {}; 48 + 49 + if (!(requestDeploymentId in mapping)) { 50 + // Unknown deployment id, serve the current version 51 + return undefined; 52 + } 53 + 54 + const version = mapping[requestDeploymentId]; 55 + 56 + if (!version || version === CURRENT_VERSION_ID) { 57 + return undefined; 58 + } 59 + 60 + const versionDomain = version.split("-")[0]; 61 + const hostname = `${versionDomain}-${process.env.CF_WORKER_NAME}.${process.env.CF_PREVIEW_DOMAIN}.workers.dev`; 62 + url.hostname = hostname; 63 + const requestToOlderDeployment = new Request(url!, request); 64 + 65 + return fetch(requestToOlderDeployment); 66 + } 67 + } 68 + 69 + /* eslint-disable no-var */ 70 + declare global { 71 + // Replaced at build time with the value from Open Next config 72 + var __SKEW_PROTECTION_ENABLED__: boolean; 73 + } 74 + /* eslint-enable no-var */
+8
packages/cloudflare/src/cli/templates/worker.ts
··· 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 ··· 15 17 export default { 16 18 async fetch(request, env, ctx) { 17 19 return runWithCloudflareRequestContext(request, env, ctx, async () => { 20 + const response = maybeGetSkewProtectionResponse(request); 21 + 22 + if (response) { 23 + return response; 24 + } 25 + 18 26 const url = new URL(request.url); 19 27 20 28 // Serve images in development.
+96 -23
pnpm-lock.yaml
··· 878 878 examples/playground15: 879 879 dependencies: 880 880 next: 881 - specifier: ^15.1.7 882 - version: 15.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 881 + specifier: ^15.3.4 882 + version: 15.3.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 883 883 react: 884 884 specifier: ^19.0.0 885 885 version: 19.0.0 ··· 1068 1068 '@types/picomatch': 1069 1069 specifier: ^4.0.0 1070 1070 version: 4.0.0 1071 + cloudflare: 1072 + specifier: ^4.4.1 1073 + version: 4.4.1 1071 1074 diff: 1072 1075 specifier: ^8.0.2 1073 1076 version: 8.0.2 ··· 4711 4714 '@types/ms@0.7.34': 4712 4715 resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} 4713 4716 4717 + '@types/node-fetch@2.6.12': 4718 + resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} 4719 + 4714 4720 '@types/node@12.20.55': 4715 4721 resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} 4716 4722 4717 4723 '@types/node@16.18.11': 4718 4724 resolution: {integrity: sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==} 4725 + 4726 + '@types/node@18.19.112': 4727 + resolution: {integrity: sha512-i+Vukt9POdS/MBI7YrrkkI5fMfwFtOjphSmt4WXYLfwqsfr6z/HdCx7LqT9M7JktGob8WNgj8nFB4TbGNE4Cog==} 4719 4728 4720 4729 '@types/node@20.14.10': 4721 4730 resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} ··· 4984 4993 agent-base@7.1.3: 4985 4994 resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} 4986 4995 engines: {node: '>= 14'} 4996 + 4997 + agentkeepalive@4.6.0: 4998 + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} 4999 + engines: {node: '>= 8.0.0'} 4987 5000 4988 5001 ajv@6.12.6: 4989 5002 resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} ··· 5350 5363 cliui@8.0.1: 5351 5364 resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} 5352 5365 engines: {node: '>=12'} 5366 + 5367 + cloudflare@4.4.1: 5368 + resolution: {integrity: sha512-wrtQ9WMflnfRcmdQZf/XfVVkeucgwzzYeqFDfgbNdADTaexsPwrtt3etzUvPGvVUeEk9kOPfNkl8MSzObxrIsg==} 5353 5369 5354 5370 clsx@2.1.1: 5355 5371 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} ··· 6437 6453 resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} 6438 6454 engines: {node: '>=14'} 6439 6455 6456 + form-data-encoder@1.7.2: 6457 + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} 6458 + 6440 6459 form-data@2.5.2: 6441 6460 resolution: {integrity: sha512-GgwY0PS7DbXqajuGf4OYlsrIu3zgxD6Vvql43IBhm6MahqA5SK/7mwhtNj2AdH2z35YR34ujJ7BN+3fFC3jP5Q==} 6442 6461 engines: {node: '>= 0.12'} 6443 6462 6463 + form-data@4.0.3: 6464 + resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} 6465 + engines: {node: '>= 6'} 6466 + 6467 + formdata-node@4.4.1: 6468 + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} 6469 + engines: {node: '>= 12.20'} 6470 + 6444 6471 formdata-polyfill@4.0.10: 6445 6472 resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} 6446 6473 engines: {node: '>=12.20.0'} ··· 6731 6758 human-signals@2.1.0: 6732 6759 resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} 6733 6760 engines: {node: '>=10.17.0'} 6761 + 6762 + humanize-ms@1.2.1: 6763 + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} 6734 6764 6735 6765 iconv-lite@0.4.24: 6736 6766 resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} ··· 9366 9396 web-streams-polyfill@3.3.3: 9367 9397 resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} 9368 9398 engines: {node: '>= 8'} 9399 + 9400 + web-streams-polyfill@4.0.0-beta.3: 9401 + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} 9402 + engines: {node: '>= 14'} 9369 9403 9370 9404 web-vitals@0.2.4: 9371 9405 resolution: {integrity: sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==} ··· 12109 12143 '@grpc/grpc-js@1.9.15': 12110 12144 dependencies: 12111 12145 '@grpc/proto-loader': 0.7.13 12112 - '@types/node': 20.17.6 12146 + '@types/node': 20.14.10 12113 12147 12114 12148 '@grpc/proto-loader@0.7.13': 12115 12149 dependencies: ··· 14123 14157 '@types/body-parser@1.19.5': 14124 14158 dependencies: 14125 14159 '@types/connect': 3.4.38 14126 - '@types/node': 20.17.6 14160 + '@types/node': 20.14.10 14127 14161 14128 14162 '@types/caseless@0.12.5': 14129 14163 optional: true 14130 14164 14131 14165 '@types/connect@3.4.38': 14132 14166 dependencies: 14133 - '@types/node': 20.17.6 14167 + '@types/node': 20.14.10 14134 14168 14135 14169 '@types/debug@4.1.12': 14136 14170 dependencies: ··· 14142 14176 14143 14177 '@types/express-serve-static-core@4.19.6': 14144 14178 dependencies: 14145 - '@types/node': 20.17.6 14179 + '@types/node': 20.14.10 14146 14180 '@types/qs': 6.9.18 14147 14181 '@types/range-parser': 1.2.7 14148 14182 '@types/send': 0.17.4 ··· 14167 14201 '@types/jsonwebtoken@9.0.8': 14168 14202 dependencies: 14169 14203 '@types/ms': 0.7.34 14170 - '@types/node': 20.17.6 14204 + '@types/node': 20.14.10 14171 14205 14172 14206 '@types/long@4.0.2': 14173 14207 optional: true ··· 14183 14217 '@types/node': 22.2.0 14184 14218 14185 14219 '@types/ms@0.7.34': {} 14220 + 14221 + '@types/node-fetch@2.6.12': 14222 + dependencies: 14223 + '@types/node': 20.14.10 14224 + form-data: 4.0.3 14186 14225 14187 14226 '@types/node@12.20.55': {} 14188 14227 14189 14228 '@types/node@16.18.11': {} 14190 14229 14230 + '@types/node@18.19.112': 14231 + dependencies: 14232 + undici-types: 5.26.5 14233 + 14191 14234 '@types/node@20.14.10': 14192 14235 dependencies: 14193 14236 undici-types: 5.26.5 ··· 14242 14285 '@types/request@2.48.12': 14243 14286 dependencies: 14244 14287 '@types/caseless': 0.12.5 14245 - '@types/node': 20.17.6 14288 + '@types/node': 20.14.10 14246 14289 '@types/tough-cookie': 4.0.5 14247 14290 form-data: 2.5.2 14248 14291 optional: true ··· 14250 14293 '@types/send@0.17.4': 14251 14294 dependencies: 14252 14295 '@types/mime': 1.3.5 14253 - '@types/node': 20.17.6 14296 + '@types/node': 20.14.10 14254 14297 14255 14298 '@types/serve-static@1.15.7': 14256 14299 dependencies: 14257 14300 '@types/http-errors': 2.0.4 14258 - '@types/node': 20.17.6 14301 + '@types/node': 20.14.10 14259 14302 '@types/send': 0.17.4 14260 14303 14261 14304 '@types/tough-cookie@4.0.5': ··· 14267 14310 14268 14311 '@types/ws@8.5.14': 14269 14312 dependencies: 14270 - '@types/node': 20.17.6 14313 + '@types/node': 20.14.10 14271 14314 14272 14315 '@typescript-eslint/eslint-plugin@8.7.0(@typescript-eslint/parser@8.7.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3)': 14273 14316 dependencies: ··· 14663 14706 abort-controller@3.0.0: 14664 14707 dependencies: 14665 14708 event-target-shim: 5.0.1 14666 - optional: true 14667 14709 14668 14710 accepts@2.0.0: 14669 14711 dependencies: ··· 14707 14749 14708 14750 agent-base@7.1.3: {} 14709 14751 14752 + agentkeepalive@4.6.0: 14753 + dependencies: 14754 + humanize-ms: 1.2.1 14755 + 14710 14756 ajv@6.12.6: 14711 14757 dependencies: 14712 14758 fast-deep-equal: 3.1.3 ··· 14849 14895 14850 14896 async-sema@3.1.1: {} 14851 14897 14852 - asynckit@0.4.0: 14853 - optional: true 14898 + asynckit@0.4.0: {} 14854 14899 14855 14900 autoprefixer@10.4.15(postcss@8.4.27): 14856 14901 dependencies: ··· 15095 15140 strip-ansi: 6.0.1 15096 15141 wrap-ansi: 7.0.0 15097 15142 15143 + cloudflare@4.4.1: 15144 + dependencies: 15145 + '@types/node': 18.19.112 15146 + '@types/node-fetch': 2.6.12 15147 + abort-controller: 3.0.0 15148 + agentkeepalive: 4.6.0 15149 + form-data-encoder: 1.7.2 15150 + formdata-node: 4.4.1 15151 + node-fetch: 2.7.0 15152 + transitivePeerDependencies: 15153 + - encoding 15154 + 15098 15155 clsx@2.1.1: {} 15099 15156 15100 15157 code-block-writer@10.1.1: {} ··· 15121 15178 combined-stream@1.0.8: 15122 15179 dependencies: 15123 15180 delayed-stream: 1.0.0 15124 - optional: true 15125 15181 15126 15182 comma-separated-tokens@2.0.3: {} 15127 15183 ··· 15285 15341 15286 15342 defu@6.1.4: {} 15287 15343 15288 - delayed-stream@1.0.0: 15289 - optional: true 15344 + delayed-stream@1.0.0: {} 15290 15345 15291 15346 depd@1.1.2: {} 15292 15347 ··· 16516 16571 16517 16572 etag@1.8.1: {} 16518 16573 16519 - event-target-shim@5.0.1: 16520 - optional: true 16574 + event-target-shim@5.0.1: {} 16521 16575 16522 16576 events-intercept@2.0.0: {} 16523 16577 ··· 16777 16831 cross-spawn: 7.0.6 16778 16832 signal-exit: 4.1.0 16779 16833 16834 + form-data-encoder@1.7.2: {} 16835 + 16780 16836 form-data@2.5.2: 16781 16837 dependencies: 16782 16838 asynckit: 0.4.0 ··· 16785 16841 safe-buffer: 5.2.1 16786 16842 optional: true 16787 16843 16844 + form-data@4.0.3: 16845 + dependencies: 16846 + asynckit: 0.4.0 16847 + combined-stream: 1.0.8 16848 + es-set-tostringtag: 2.1.0 16849 + hasown: 2.0.2 16850 + mime-types: 2.1.35 16851 + 16852 + formdata-node@4.4.1: 16853 + dependencies: 16854 + node-domexception: 1.0.0 16855 + web-streams-polyfill: 4.0.0-beta.3 16856 + 16788 16857 formdata-polyfill@4.0.10: 16789 16858 dependencies: 16790 16859 fetch-blob: 3.2.0 ··· 17156 17225 human-signals@1.1.1: {} 17157 17226 17158 17227 human-signals@2.1.0: {} 17228 + 17229 + humanize-ms@1.2.1: 17230 + dependencies: 17231 + ms: 2.1.3 17159 17232 17160 17233 iconv-lite@0.4.24: 17161 17234 dependencies: ··· 17903 17976 braces: 3.0.3 17904 17977 picomatch: 2.3.1 17905 17978 17906 - mime-db@1.52.0: 17907 - optional: true 17979 + mime-db@1.52.0: {} 17908 17980 17909 17981 mime-db@1.54.0: {} 17910 17982 17911 17983 mime-types@2.1.35: 17912 17984 dependencies: 17913 17985 mime-db: 1.52.0 17914 - optional: true 17915 17986 17916 17987 mime-types@3.0.1: 17917 17988 dependencies: ··· 18853 18924 '@protobufjs/path': 1.1.2 18854 18925 '@protobufjs/pool': 1.1.0 18855 18926 '@protobufjs/utf8': 1.1.0 18856 - '@types/node': 20.17.6 18927 + '@types/node': 20.14.10 18857 18928 long: 5.2.4 18858 18929 18859 18930 proxy-addr@2.0.7: ··· 20339 20410 - terser 20340 20411 20341 20412 web-streams-polyfill@3.3.3: {} 20413 + 20414 + web-streams-polyfill@4.0.0-beta.3: {} 20342 20415 20343 20416 web-vitals@0.2.4: {} 20344 20417