diff --git a/.changeset/curly-lions-build.md b/.changeset/curly-lions-build.md new file mode 100644 index 00000000..beb42ec0 --- /dev/null +++ b/.changeset/curly-lions-build.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": minor +--- + +feat: add support for experimental skew protection diff --git a/.prettierignore b/.prettierignore index b5a7cacb..c90c76a3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,4 @@ pnpm-lock.yaml .vscode/setting.json test-fixtures test-snapshots -playwright-report +playwright-report \ No newline at end of file diff --git a/examples/playground14/wrangler.jsonc b/examples/playground14/wrangler.jsonc index 9160b166..f7c13c4e 100644 --- a/examples/playground14/wrangler.jsonc +++ b/examples/playground14/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "main": "worker.ts", - "name": "api", + "name": "playground14", "compatibility_date": "2024-12-30", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "assets": { diff --git a/examples/playground15/app/api/env/route.ts b/examples/playground15/app/api/env/route.ts index d51838cf..3d80e805 100644 --- a/examples/playground15/app/api/env/route.ts +++ b/examples/playground15/app/api/env/route.ts @@ -4,5 +4,7 @@ export const dynamic = "force-dynamic"; export async function GET() { - return new Response(JSON.stringify(process.env)); + return new Response(JSON.stringify(process.env, null, 2), { + headers: { "content-type": "application/json" }, + }); } diff --git a/examples/playground15/next.config.mjs b/examples/playground15/next.config.mjs index 99b33437..0d2f9dfd 100644 --- a/examples/playground15/next.config.mjs +++ b/examples/playground15/next.config.mjs @@ -1,4 +1,4 @@ -import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"; +import { initOpenNextCloudflareForDev, getDeploymentId } from "@opennextjs/cloudflare"; initOpenNextCloudflareForDev(); @@ -10,6 +10,7 @@ const nextConfig = { // Generate source map to validate the fix for opennextjs/opennextjs-cloudflare#341 serverSourceMaps: true, }, + deploymentId: getDeploymentId(), }; export default nextConfig; diff --git a/examples/playground15/open-next.config.ts b/examples/playground15/open-next.config.ts index 6ea08a5a..ace7d48b 100644 --- a/examples/playground15/open-next.config.ts +++ b/examples/playground15/open-next.config.ts @@ -1,7 +1,13 @@ -import { defineCloudflareConfig } from "@opennextjs/cloudflare"; -import kvIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache"; +import { defineCloudflareConfig, type OpenNextConfig } from "@opennextjs/cloudflare"; +import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache"; -export default defineCloudflareConfig({ - incrementalCache: kvIncrementalCache, - enableCacheInterception: true, -}); +export default { + ...defineCloudflareConfig({ + incrementalCache: r2IncrementalCache, + }), + cloudflare: { + skewProtection: { + enabled: false, + }, + }, +} satisfies OpenNextConfig; diff --git a/examples/playground15/package.json b/examples/playground15/package.json index 45406c06..b7729538 100644 --- a/examples/playground15/package.json +++ b/examples/playground15/package.json @@ -16,7 +16,7 @@ "cf-typegen": "wrangler types --env-interface CloudflareEnv" }, "dependencies": { - "next": "^15.1.7", + "next": "^15.3.4", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/examples/playground15/wrangler.jsonc b/examples/playground15/wrangler.jsonc index e4e8e88f..8d4791c4 100644 --- a/examples/playground15/wrangler.jsonc +++ b/examples/playground15/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "main": ".open-next/worker.js", - "name": "api", + "name": "playground15", "compatibility_date": "2024-12-30", "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "assets": { @@ -9,10 +9,10 @@ "binding": "ASSETS", "run_worker_first": true }, - "kv_namespaces": [ + "r2_buckets": [ { - "binding": "NEXT_INC_CACHE_KV", - "id": "" + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "pg15" } ], "vars": { diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index c61d7b8b..2c6bd7ca 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -65,6 +65,7 @@ "@types/mock-fs": "catalog:", "@types/node": "catalog:", "@types/picomatch": "^4.0.0", + "cloudflare": "^4.4.1", "diff": "^8.0.2", "esbuild": "catalog:", "eslint": "catalog:", diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index 064e9c83..6543a61e 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -65,6 +65,19 @@ declare global { CACHE_PURGE_ZONE_ID?: string; // The API token to use for the cache purge. It should have the `Cache Purge` permission CACHE_PURGE_API_TOKEN?: string; + + // The following variables must be provided when skew protection is enabled + // The name of the worker (as defined in the wrangler configuration) + // When a specific wrangler environment is used, it should be appended at the end: + // - Use `worker-name` when no wrangler environment is used + // - Use `worker-name-` when a wrangler environment is used via `wrangler --env=` + CF_WORKER_NAME?: string; + // The subdomain where the previews are deployed, i.e. `..workers.dev` + CF_PREVIEW_DOMAIN?: string; + // Should have the `Workers Scripts:Read` permission + CF_WORKERS_SCRIPTS_API_TOKEN?: string; + // Cloudflare account id + CF_ACCOUNT_ID?: string; } } diff --git a/packages/cloudflare/src/api/config.ts b/packages/cloudflare/src/api/config.ts index ec3f7b56..7469375c 100644 --- a/packages/cloudflare/src/api/config.ts +++ b/packages/cloudflare/src/api/config.ts @@ -161,6 +161,24 @@ interface OpenNextConfig extends AwsOpenNextConfig { * @default false */ dangerousDisableConfigValidation?: boolean; + + /** + * Skew protection. + * + * Note: Skew Protection is experimental and might break on minor releases. + * + * @default false + */ + skewProtection?: { + // Whether to enable skew protection + enabled?: boolean; + // Maximum number of versions to retrieve + // @default 20 + maxNumberOfVersions?: number; + // Maximum age of versions to retrieve in days + // @default 7 + maxVersionAgeDays?: number; + }; }; } @@ -172,4 +190,11 @@ export function getOpenNextConfig(buildOpts: BuildOptions): OpenNextConfig { return buildOpts.config; } +/** + * @returns Unique deployment ID + */ +export function getDeploymentId(): string { + return `dpl-${new Date().getTime().toString(36)}`; +} + export type { OpenNextConfig }; diff --git a/packages/cloudflare/src/api/index.ts b/packages/cloudflare/src/api/index.ts index 8368bf61..c52d9975 100644 --- a/packages/cloudflare/src/api/index.ts +++ b/packages/cloudflare/src/api/index.ts @@ -1,2 +1,2 @@ export * from "./cloudflare-context.js"; -export { defineCloudflareConfig, type OpenNextConfig } from "./config.js"; +export { defineCloudflareConfig, getDeploymentId, type OpenNextConfig } from "./config.js"; diff --git a/packages/cloudflare/src/cli/build/build.ts b/packages/cloudflare/src/cli/build/build.ts index 58bdd6f0..8b25ab88 100644 --- a/packages/cloudflare/src/cli/build/build.ts +++ b/packages/cloudflare/src/cli/build/build.ts @@ -14,6 +14,7 @@ import { compileCacheAssetsManifestSqlFile } from "./open-next/compile-cache-ass import { compileEnvFiles } from "./open-next/compile-env-files.js"; import { compileImages } from "./open-next/compile-images.js"; import { compileInit } from "./open-next/compile-init.js"; +import { compileSkewProtection } from "./open-next/compile-skew-protection.js"; import { compileDurableObjects } from "./open-next/compileDurableObjects.js"; import { createServerBundle } from "./open-next/createServerBundle.js"; import { createWranglerConfigIfNotExistent } from "./utils/index.js"; @@ -59,17 +60,11 @@ export async function build( printHeader("Generating bundle"); buildHelper.initOutputDir(options); - // Compile cache.ts compileCache(options); - - // Compile .env files compileEnvFiles(options); - - // Compile workerd init compileInit(options, wranglerConfig); - - // Compile image helpers compileImages(options); + compileSkewProtection(options, config); // Compile middleware await createMiddleware(options, { forceOnlyBuildOnce: true }); diff --git a/packages/cloudflare/src/cli/build/open-next/compile-init.ts b/packages/cloudflare/src/cli/build/open-next/compile-init.ts index 842102ec..5dc2f9fa 100644 --- a/packages/cloudflare/src/cli/build/open-next/compile-init.ts +++ b/packages/cloudflare/src/cli/build/open-next/compile-init.ts @@ -16,6 +16,7 @@ export async function compileInit(options: BuildOptions, wranglerConfig: Unstabl const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next")); const basePath = nextConfig.basePath ?? ""; + const deploymentId = nextConfig.deploymentId ?? ""; await build({ entryPoints: [initPath], @@ -29,6 +30,7 @@ export async function compileInit(options: BuildOptions, wranglerConfig: Unstabl __BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()), __NEXT_BASE_PATH__: JSON.stringify(basePath), __ASSETS_RUN_WORKER_FIRST__: JSON.stringify(wranglerConfig.assets?.run_worker_first ?? false), + __DEPLOYMENT_ID__: JSON.stringify(deploymentId), }, }); } diff --git a/packages/cloudflare/src/cli/build/open-next/compile-skew-protection.ts b/packages/cloudflare/src/cli/build/open-next/compile-skew-protection.ts new file mode 100644 index 00000000..52ef805c --- /dev/null +++ b/packages/cloudflare/src/cli/build/open-next/compile-skew-protection.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; +import { build } from "esbuild"; + +import type { OpenNextConfig } from "../../../api"; + +export async function compileSkewProtection(options: BuildOptions, config: OpenNextConfig) { + const currentDir = path.join(path.dirname(fileURLToPath(import.meta.url))); + const templatesDir = path.join(currentDir, "../../templates"); + const initPath = path.join(templatesDir, "skew-protection.js"); + + const skewProtectionEnabled = config.cloudflare?.skewProtection?.enabled === true; + + await build({ + entryPoints: [initPath], + outdir: path.join(options.outputDir, "cloudflare"), + bundle: false, + minify: false, + format: "esm", + target: "esnext", + platform: "node", + define: { + __SKEW_PROTECTION_ENABLED__: JSON.stringify(skewProtectionEnabled), + }, + }); +} diff --git a/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts b/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts index e1384254..548be29e 100644 --- a/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts +++ b/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts @@ -12,20 +12,15 @@ export function compileDurableObjects(buildOpts: BuildOptions) { _require.resolve("@opennextjs/cloudflare/durable-objects/bucket-cache-purge"), ]; - const { outputDir } = buildOpts; - const baseManifestPath = path.join( - outputDir, + buildOpts.outputDir, "server-functions/default", getPackagePath(buildOpts), ".next" ); - // We need to change the type in aws - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prerenderManifest = loadPrerenderManifest(baseManifestPath) as any; + const prerenderManifest = loadPrerenderManifest(baseManifestPath); const previewModeId = prerenderManifest.preview.previewModeId; - const BUILD_ID = loadBuildId(baseManifestPath); return esbuildSync( diff --git a/packages/cloudflare/src/cli/commands/deploy.ts b/packages/cloudflare/src/cli/commands/deploy.ts index 5c0016ab..f8edb26f 100644 --- a/packages/cloudflare/src/cli/commands/deploy.ts +++ b/packages/cloudflare/src/cli/commands/deploy.ts @@ -1,19 +1,41 @@ import { BuildOptions } from "@opennextjs/aws/build/helper.js"; import type { OpenNextConfig } from "../../api/config.js"; +import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js"; import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js"; +import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js"; import { populateCache } from "./populate-cache.js"; +import { getDeploymentMapping } from "./skew-protection.js"; export async function deploy( options: BuildOptions, config: OpenNextConfig, deployOptions: { passthroughArgs: string[]; cacheChunkSize?: number } ) { + const envVars = await getEnvFromPlatformProxy({ + // TODO: Pass the configPath, update everywhere applicable + environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs), + }); + + const deploymentMapping = await getDeploymentMapping(options, config, envVars); + await populateCache(options, config, { target: "remote", environment: getWranglerEnvironmentFlag(deployOptions.passthroughArgs), cacheChunkSize: deployOptions.cacheChunkSize, }); - runWrangler(options, ["deploy", ...deployOptions.passthroughArgs], { logging: "all" }); + runWrangler( + options, + [ + "deploy", + ...deployOptions.passthroughArgs, + ...(deploymentMapping + ? [`--var ${DEPLOYMENT_MAPPING_ENV_NAME}:${quoteShellMeta(JSON.stringify(deploymentMapping))}`] + : []), + ], + { + logging: "all", + } + ); } diff --git a/packages/cloudflare/src/cli/commands/helpers.ts b/packages/cloudflare/src/cli/commands/helpers.ts new file mode 100644 index 00000000..38306d3b --- /dev/null +++ b/packages/cloudflare/src/cli/commands/helpers.ts @@ -0,0 +1,41 @@ +import { getPlatformProxy, type GetPlatformProxyOptions } from "wrangler"; + +export type WorkerEnvVar = Record; + +/** + * Return the string env vars from the worker. + * + * @param options Options to pass to `getPlatformProxy`, i.e. to set the environment + * @returns the env vars + */ +export async function getEnvFromPlatformProxy(options: GetPlatformProxyOptions) { + const envVars = {} as WorkerEnvVar; + const proxy = await getPlatformProxy(options); + Object.entries(proxy.env).forEach(([key, value]) => { + if (typeof value === "string") { + envVars[key as keyof CloudflareEnv] = value; + } + }); + await proxy.dispose(); + return envVars; +} + +/** + * Escape shell metacharacters. + * + * When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped. + * + * Based on https://github.com/ljharb/shell-quote/blob/main/quote.js + * + * @param arg + * @returns escaped arg + */ +export function quoteShellMeta(arg: string) { + if (/["\s]/.test(arg) && !/'/.test(arg)) { + return `'${arg.replace(/(['\\])/g, "\\$1")}'`; + } + if (/["'\s]/.test(arg)) { + return `"${arg.replace(/(["\\$`!])/g, "\\$1")}"`; + } + return arg.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2"); +} diff --git a/packages/cloudflare/src/cli/commands/populate-cache.ts b/packages/cloudflare/src/cli/commands/populate-cache.ts index 3c1521c7..70dbe5e1 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.ts @@ -12,7 +12,7 @@ import type { import type { IncrementalCache, TagCache } from "@opennextjs/aws/types/overrides.js"; import { globSync } from "glob"; import { tqdm } from "ts-tqdm"; -import { getPlatformProxy, type GetPlatformProxyOptions, unstable_readConfig } from "wrangler"; +import { unstable_readConfig } from "wrangler"; import { BINDING_NAME as KV_CACHE_BINDING_NAME, @@ -36,6 +36,7 @@ import { import { normalizePath } from "../build/utils/normalize-path.js"; import type { WranglerTarget } from "../utils/run-wrangler.js"; import { runWrangler } from "../utils/run-wrangler.js"; +import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js"; async function resolveCacheName( value: @@ -93,13 +94,6 @@ export function getCacheAssets(opts: BuildOptions): CacheAsset[] { return assets; } -async function getPlatformProxyEnv(options: GetPlatformProxyOptions, key: T) { - const proxy = await getPlatformProxy(options); - const prefix = proxy.env[key]; - await proxy.dispose(); - return prefix; -} - async function populateR2IncrementalCache( options: BuildOptions, populateCacheOptions: { target: WranglerTarget; environment?: string } @@ -118,7 +112,8 @@ async function populateR2IncrementalCache( throw new Error(`R2 binding ${JSON.stringify(R2_CACHE_BINDING_NAME)} should have a 'bucket_name'`); } - const prefix = await getPlatformProxyEnv(populateCacheOptions, R2_CACHE_PREFIX_ENV_NAME); + const envVars = await getEnvFromPlatformProxy(populateCacheOptions); + const prefix = envVars[R2_CACHE_PREFIX_ENV_NAME]; const assets = getCacheAssets(options); @@ -156,7 +151,8 @@ async function populateKVIncrementalCache( throw new Error(`No KV binding ${JSON.stringify(KV_CACHE_BINDING_NAME)} found!`); } - const prefix = await getPlatformProxyEnv(populateCacheOptions, KV_CACHE_PREFIX_ENV_NAME); + const envVars = await getEnvFromPlatformProxy(populateCacheOptions); + const prefix = envVars[KV_CACHE_PREFIX_ENV_NAME]; const assets = getCacheAssets(options); @@ -270,23 +266,3 @@ export async function populateCache( } } } - -/** - * Escape shell metacharacters. - * - * When `spawnSync` is invoked with `shell: true`, metacharacters need to be escaped. - * - * Based on https://github.com/ljharb/shell-quote/blob/main/quote.js - * - * @param arg - * @returns escaped arg - */ -function quoteShellMeta(arg: string) { - if (/["\s]/.test(arg) && !/'/.test(arg)) { - return `'${arg.replace(/(['\\])/g, "\\$1")}'`; - } - if (/["'\s]/.test(arg)) { - return `"${arg.replace(/(["\\$`!])/g, "\\$1")}"`; - } - return arg.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, "$1\\$2"); -} diff --git a/packages/cloudflare/src/cli/commands/skew-protection.spec.ts b/packages/cloudflare/src/cli/commands/skew-protection.spec.ts new file mode 100644 index 00000000..9c0ef558 --- /dev/null +++ b/packages/cloudflare/src/cli/commands/skew-protection.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, test, vi } from "vitest"; + +import { CURRENT_VERSION_ID } from "../templates/skew-protection"; +import { listWorkerVersions, updateDeploymentMapping } from "./skew-protection"; + +describe("skew protection", () => { + describe("listWorkerVersions", () => { + test("listWorkerVersions return versions ordered by time DESC", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client: any = { + workers: { + scripts: { + versions: { + list: () => [], + }, + }, + }, + }; + + const now = Date.now(); + + client.workers.scripts.versions.list = vi.fn().mockReturnValue([ + { + id: "HEAD", + metadata: { created_on: new Date(now) }, + }, + { + id: "HEAD~2", + metadata: { created_on: new Date(now - 2000) }, + }, + { + id: "HEAD~1", + metadata: { created_on: new Date(now - 1000) }, + }, + ]); + + expect(await listWorkerVersions("scriptName", { client, accountId: "accountId" })).toMatchObject([ + { + createdOnMs: now, + id: "HEAD", + }, + { + createdOnMs: now - 1000, + id: "HEAD~1", + }, + { + createdOnMs: now - 2000, + id: "HEAD~2", + }, + ]); + }); + }); +}); + +describe("updateDeploymentMapping", () => { + test("Update", () => { + const mapping = { + N: CURRENT_VERSION_ID, + "N-1": "vN-1", + "N-2": "vN-2", + }; + const versions = [{ id: "vN" }, { id: "vN-1" }]; // "vN-2" is deleted + expect(updateDeploymentMapping(mapping, versions, "N+1")).toMatchObject({ + "N+1": CURRENT_VERSION_ID, + N: "vN", + "N-1": "vN-1", + }); + }); +}); diff --git a/packages/cloudflare/src/cli/commands/skew-protection.ts b/packages/cloudflare/src/cli/commands/skew-protection.ts new file mode 100644 index 00000000..e6456848 --- /dev/null +++ b/packages/cloudflare/src/cli/commands/skew-protection.ts @@ -0,0 +1,263 @@ +/** + * We need to maintain a mapping of deployment id to worker version for skew protection. + * + * The mapping is used to request the correct version of the workers when Next attaches a deployment id to a request. + * + * The mapping is stored in a worker en var: + * + * { + * latestDepId: "current", + * depIdx: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + * depIdy: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + * depIdz: "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz", + * } + * + * Note that the latest version is not known at build time as the version id only gets created on deployment. + * This is why we use the "current" placeholder. + * + * When a new version is deployed: + * - "current" is replaced with the latest version of the Worker + * - a new entry is added for the new deployment id with the "current" version + */ + +// re-enable when types are fixed in the cloudflare lib +/* eslint-disable @typescript-eslint/no-explicit-any */ +import path from "node:path"; + +import { loadConfig } from "@opennextjs/aws/adapters/config/util.js"; +import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; +import logger from "@opennextjs/aws/logger.js"; +import { Cloudflare, NotFoundError } from "cloudflare"; +import type { VersionGetResponse } from "cloudflare/resources/workers/scripts/versions"; + +import type { OpenNextConfig } from "../../api"; +import { CURRENT_VERSION_ID, DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js"; +import type { WorkerEnvVar } from "./helpers.js"; + +/** Maximum number of versions to list */ +const MAX_NUMBER_OF_VERSIONS = 20; +/** Maximum age of versions to list */ +const MAX_VERSION_AGE_DAYS = 7; +const MS_PER_DAY = 24 * 3600 * 1000; + +/** + * Compute the deployment mapping for a deployment. + * + * @param options Build options + * @param config OpenNext config + * @param envVars Environment variables + * @returns Deployment mapping or undefined + */ +export async function getDeploymentMapping( + options: BuildOptions, + config: OpenNextConfig, + envVars: WorkerEnvVar +): Promise | undefined> { + if (config.cloudflare?.skewProtection?.enabled !== true) { + return undefined; + } + + const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next")); + const deploymentId = nextConfig.deploymentId; + + if (!deploymentId) { + logger.error("Deployment ID should be set in the Next config when skew protection is enabled"); + process.exit(1); + } + + if (!envVars.CF_WORKER_NAME) { + logger.error("CF_WORKER_NAME should be set when skew protection is enabled"); + process.exit(1); + } + + if (!envVars.CF_PREVIEW_DOMAIN) { + logger.error("CF_PREVIEW_DOMAIN should be set when skew protection is enabled"); + process.exit(1); + } + + if (!envVars.CF_WORKERS_SCRIPTS_API_TOKEN) { + logger.error("CF_WORKERS_SCRIPTS_API_TOKEN should be set when skew protection is enabled"); + process.exit(1); + } + + if (!envVars.CF_ACCOUNT_ID) { + logger.error("CF_ACCOUNT_ID should be set when skew protection is enabled"); + process.exit(1); + } + + const apiToken = envVars.CF_WORKERS_SCRIPTS_API_TOKEN!; + const accountId = envVars.CF_ACCOUNT_ID!; + + const client = new Cloudflare({ apiToken }); + const scriptName = envVars.CF_WORKER_NAME!; + + const deployedVersions = await listWorkerVersions(scriptName, { + client, + accountId, + maxNumberOfVersions: config.cloudflare?.skewProtection?.maxNumberOfVersions, + afterTimeMs: config.cloudflare?.skewProtection?.maxVersionAgeDays + ? Date.now() - config.cloudflare?.skewProtection?.maxVersionAgeDays * MS_PER_DAY + : undefined, + }); + + const existingMapping = + deployedVersions.length === 0 + ? {} + : await getExistingDeploymentMapping(scriptName, deployedVersions[0]!.id, { + client, + accountId, + }); + + if (deploymentId in existingMapping) { + logger.error( + `The deploymentId "${deploymentId}" has been used previously, update your next config and rebuild` + ); + process.exit(1); + } + + const mapping = updateDeploymentMapping(existingMapping, deployedVersions, deploymentId); + + return mapping; +} + +/** + * Update an existing deployment mapping: + * - Replace the "current" version with the latest deployed version + * - Add a "current" version for the current deployment ID + * - Remove versions that are not passed in + * + * @param mapping Existing mapping + * @param versions Versions ordered by descending time + * @param deploymentId Deployment ID + * @returns The updated mapping + */ +export function updateDeploymentMapping( + mapping: Record, + versions: { id: string }[], + deploymentId: string +): Record { + const newMapping: Record = { [deploymentId]: CURRENT_VERSION_ID }; + const versionIds = new Set(versions.map((v) => v.id)); + + for (const [deployment, version] of Object.entries(mapping)) { + if (version === CURRENT_VERSION_ID && versions.length > 0) { + newMapping[deployment] = versions[0]!.id; + } else if (versionIds.has(version)) { + newMapping[deployment] = version; + } + } + + return newMapping; +} + +/** + * Retrieve the deployment mapping from the last deployed worker. + * + * NOTE: it is retrieved from the DEPLOYMENT_MAPPING_ENV_NAME env var. + * + * @param scriptName The name of the worker script + * @param versionId The version Id to retrieve + * @param options.client A Cloudflare API client + * @param options.accountId The Cloudflare account id + * @returns The deployment mapping + */ +async function getExistingDeploymentMapping( + scriptName: string, + versionId: string, + options: { + client: Cloudflare; + accountId: string; + } +): Promise> { + // See https://github.com/cloudflare/cloudflare-typescript/issues/2652 + const bindings = + ((await getVersionDetail(scriptName, versionId, options)).resources.bindings as any[]) ?? []; + + for (const binding of bindings) { + if (binding.name === DEPLOYMENT_MAPPING_ENV_NAME && binding.type == "plain_text") { + return JSON.parse(binding.text); + } + } + + return {}; +} + +/** + * Retrieve the details of the version of a script + * + * @param scriptName The name of the worker script + * @param versionId The version Id to retrieve + * @param options.client A Cloudflare API client + * @param options.accountId The Cloudflare account id + + * @returns the version information + */ +async function getVersionDetail( + scriptName: string, + versionId: string, + options: { + client: Cloudflare; + accountId: string; + } +): Promise { + const { client, accountId } = options; + return await client.workers.scripts.versions.get(scriptName, versionId, { + account_id: accountId, + }); +} + +/** + * Retrieve the versions for the script + * + * @param scriptName The name of the worker script + * @param options.client A Cloudflare API client + * @param options.accountId The Cloudflare account id + * @param options.afterTimeMs Only list version more recent than this time - default to 7 days + * @param options.maxNumberOfVersions The maximum number of version to return - default to 20 versions. + * @returns A list of id and creation date ordered by descending creation date + */ +export async function listWorkerVersions( + scriptName: string, + options: { + client: Cloudflare; + accountId: string; + afterTimeMs?: number; + maxNumberOfVersions?: number; + } +): Promise<{ id: string; createdOnMs: number }[]> { + const versions = []; + const { + client, + accountId, + afterTimeMs = new Date().getTime() - MAX_VERSION_AGE_DAYS * 24 * 3600 * 1000, + maxNumberOfVersions = MAX_NUMBER_OF_VERSIONS, + } = options; + + try { + for await (const version of client.workers.scripts.versions.list(scriptName, { + account_id: accountId, + })) { + const id = version.id; + const createdOn = version.metadata?.created_on; + + if (id && createdOn) { + const createdOnMs = new Date(createdOn).getTime(); + if (createdOnMs < afterTimeMs) { + break; + } + versions.push({ id, createdOnMs }); + if (versions.length >= maxNumberOfVersions) { + break; + } + } + } + } catch (e) { + if (e instanceof NotFoundError && e.status === 404) { + // The worker has not been deployed before, no previous versions. + return []; + } + throw e; + } + + return versions.sort((a, b) => b.createdOnMs - a.createdOnMs); +} diff --git a/packages/cloudflare/src/cli/commands/upload.ts b/packages/cloudflare/src/cli/commands/upload.ts index 07778b7d..24200abf 100644 --- a/packages/cloudflare/src/cli/commands/upload.ts +++ b/packages/cloudflare/src/cli/commands/upload.ts @@ -1,19 +1,39 @@ import { BuildOptions } from "@opennextjs/aws/build/helper.js"; import type { OpenNextConfig } from "../../api/config.js"; +import { DEPLOYMENT_MAPPING_ENV_NAME } from "../templates/skew-protection.js"; import { getWranglerEnvironmentFlag, runWrangler } from "../utils/run-wrangler.js"; +import { getEnvFromPlatformProxy, quoteShellMeta } from "./helpers.js"; import { populateCache } from "./populate-cache.js"; +import { getDeploymentMapping } from "./skew-protection.js"; export async function upload( options: BuildOptions, config: OpenNextConfig, uploadOptions: { passthroughArgs: string[]; cacheChunkSize?: number } ) { + const envVars = await getEnvFromPlatformProxy({ + // TODO: Pass the configPath, update everywhere applicable + environment: getWranglerEnvironmentFlag(uploadOptions.passthroughArgs), + }); + + const deploymentMapping = await getDeploymentMapping(options, config, envVars); + await populateCache(options, config, { target: "remote", environment: getWranglerEnvironmentFlag(uploadOptions.passthroughArgs), cacheChunkSize: uploadOptions.cacheChunkSize, }); - runWrangler(options, ["versions upload", ...uploadOptions.passthroughArgs], { logging: "all" }); + runWrangler( + options, + [ + "versions upload", + ...uploadOptions.passthroughArgs, + ...(deploymentMapping + ? [`--var ${DEPLOYMENT_MAPPING_ENV_NAME}:${quoteShellMeta(JSON.stringify(deploymentMapping))}`] + : []), + ], + { logging: "all" } + ); } diff --git a/packages/cloudflare/src/cli/index.ts b/packages/cloudflare/src/cli/index.ts index e9ded66d..d3139faf 100644 --- a/packages/cloudflare/src/cli/index.ts +++ b/packages/cloudflare/src/cli/index.ts @@ -28,6 +28,7 @@ async function runCommand(args: Arguments) { const require = createRequire(import.meta.url); const openNextDistDir = path.dirname(require.resolve("@opennextjs/aws/index.js")); + // TODO: retrieve the compiled version if command != build await createOpenNextConfigIfNotExistent(baseDir); const { config, buildDir } = await compileOpenNextConfig(baseDir, undefined, { compileEdge: true, diff --git a/packages/cloudflare/src/cli/templates/init.ts b/packages/cloudflare/src/cli/templates/init.ts index 6dff1033..832e74c2 100644 --- a/packages/cloudflare/src/cli/templates/init.ts +++ b/packages/cloudflare/src/cli/templates/init.ts @@ -139,6 +139,11 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) { * https://github.com/vercel/next.js/blob/6b1e48080e896e0d44a05fe009cb79d2d3f91774/packages/next/src/server/app-render/action-handler.ts#L307-L316 */ process.env.__NEXT_PRIVATE_ORIGIN = url.origin; + + // `__DEPLOYMENT_ID__` is a string (passed via ESBuild). + if (__DEPLOYMENT_ID__) { + process.env.DEPLOYMENT_ID = __DEPLOYMENT_ID__; + } } /* eslint-disable no-var */ @@ -149,5 +154,7 @@ declare global { var __NEXT_BASE_PATH__: string; // Value of `run_worker_first` for the asset binding var __ASSETS_RUN_WORKER_FIRST__: boolean | string[] | undefined; + // Deployment ID + var __DEPLOYMENT_ID__: string; } /* eslint-enable no-var */ diff --git a/packages/cloudflare/src/cli/templates/skew-protection.ts b/packages/cloudflare/src/cli/templates/skew-protection.ts new file mode 100644 index 00000000..ed231d86 --- /dev/null +++ b/packages/cloudflare/src/cli/templates/skew-protection.ts @@ -0,0 +1,74 @@ +import process from "node:process"; + +/** Name of the env var containing the mapping */ +export const DEPLOYMENT_MAPPING_ENV_NAME = "CF_DEPLOYMENT_MAPPING"; +/** Version used for the latest worker */ +export const CURRENT_VERSION_ID = "current"; + +/** + * Routes the request to the requested deployment. + * + * A specific deployment can be requested via: + * - the `dpl` search parameter for assets + * - the `x-deployment-id` for other requests + * + * When a specific deployment is requested, we route to that deployment via the preview URLs. + * See https://developers.cloudflare.com/workers/configuration/previews/ + * + * When the requested deployment is not supported a 400 response is returned. + * + * Notes: + * - The re-routing is only active for the deployed version of the app (on a custom domain) + * - Assets are also handled when `run_worker_first` is enabled. + * See https://developers.cloudflare.com/workers/static-assets/binding/#run_worker_first + * + * @param request + * @returns + */ +export function maybeGetSkewProtectionResponse(request: Request): Promise | Response | undefined { + // no early return as esbuild would not treeshake the code. + if (__SKEW_PROTECTION_ENABLED__) { + const url = new URL(request.url); + + // Skew protection is only active for the latest version of the app served on a custom domain. + if (url.hostname === "localhost" || url.hostname.endsWith(".workers.dev")) { + return undefined; + } + + const requestDeploymentId = request.headers.get("x-deployment-id") ?? url.searchParams.get("dpl"); + + if (!requestDeploymentId || requestDeploymentId === process.env.DEPLOYMENT_ID) { + // The request does not specify a deployment id or it is the current deployment id + return undefined; + } + + const mapping = process.env[DEPLOYMENT_MAPPING_ENV_NAME] + ? JSON.parse(process.env[DEPLOYMENT_MAPPING_ENV_NAME]) + : {}; + + if (!(requestDeploymentId in mapping)) { + // Unknown deployment id, serve the current version + return undefined; + } + + const version = mapping[requestDeploymentId]; + + if (!version || version === CURRENT_VERSION_ID) { + return undefined; + } + + const versionDomain = version.split("-")[0]; + const hostname = `${versionDomain}-${process.env.CF_WORKER_NAME}.${process.env.CF_PREVIEW_DOMAIN}.workers.dev`; + url.hostname = hostname; + const requestToOlderDeployment = new Request(url!, request); + + return fetch(requestToOlderDeployment); + } +} + +/* eslint-disable no-var */ +declare global { + // Replaced at build time with the value from Open Next config + var __SKEW_PROTECTION_ENABLED__: boolean; +} +/* eslint-enable no-var */ diff --git a/packages/cloudflare/src/cli/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index e95a9871..a5ca1cd0 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -2,6 +2,8 @@ import { fetchImage } from "./cloudflare/images.js"; //@ts-expect-error: Will be resolved by wrangler build import { runWithCloudflareRequestContext } from "./cloudflare/init.js"; +//@ts-expect-error: Will be resolved by wrangler build +import { maybeGetSkewProtectionResponse } from "./cloudflare/skew-protection.js"; // @ts-expect-error: Will be resolved by wrangler build import { handler as middlewareHandler } from "./middleware/handler.mjs"; @@ -15,6 +17,12 @@ export { BucketCachePurge } from "./.build/durable-objects/bucket-cache-purge.js export default { async fetch(request, env, ctx) { return runWithCloudflareRequestContext(request, env, ctx, async () => { + const response = maybeGetSkewProtectionResponse(request); + + if (response) { + return response; + } + const url = new URL(request.url); // Serve images in development. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df08d165..37585a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -878,8 +878,8 @@ importers: examples/playground15: dependencies: next: - specifier: ^15.1.7 - 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) + specifier: ^15.3.4 + 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) react: specifier: ^19.0.0 version: 19.0.0 @@ -1068,6 +1068,9 @@ importers: '@types/picomatch': specifier: ^4.0.0 version: 4.0.0 + cloudflare: + specifier: ^4.4.1 + version: 4.4.1 diff: specifier: ^8.0.2 version: 8.0.2 @@ -4711,12 +4714,18 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + '@types/node-fetch@2.6.12': + resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} '@types/node@16.18.11': resolution: {integrity: sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==} + '@types/node@18.19.112': + resolution: {integrity: sha512-i+Vukt9POdS/MBI7YrrkkI5fMfwFtOjphSmt4WXYLfwqsfr6z/HdCx7LqT9M7JktGob8WNgj8nFB4TbGNE4Cog==} + '@types/node@20.14.10': resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==} @@ -4985,6 +4994,10 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -5351,6 +5364,9 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cloudflare@4.4.1: + resolution: {integrity: sha512-wrtQ9WMflnfRcmdQZf/XfVVkeucgwzzYeqFDfgbNdADTaexsPwrtt3etzUvPGvVUeEk9kOPfNkl8MSzObxrIsg==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -6437,10 +6453,21 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + form-data@2.5.2: resolution: {integrity: sha512-GgwY0PS7DbXqajuGf4OYlsrIu3zgxD6Vvql43IBhm6MahqA5SK/7mwhtNj2AdH2z35YR34ujJ7BN+3fFC3jP5Q==} engines: {node: '>= 0.12'} + form-data@4.0.3: + resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -6732,6 +6759,9 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -9367,6 +9397,10 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + web-vitals@0.2.4: resolution: {integrity: sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==} @@ -12109,7 +12143,7 @@ snapshots: '@grpc/grpc-js@1.9.15': dependencies: '@grpc/proto-loader': 0.7.13 - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@grpc/proto-loader@0.7.13': dependencies: @@ -14123,14 +14157,14 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@types/caseless@0.12.5': optional: true '@types/connect@3.4.38': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@types/debug@4.1.12': dependencies: @@ -14142,7 +14176,7 @@ snapshots: '@types/express-serve-static-core@4.19.6': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -14167,7 +14201,7 @@ snapshots: '@types/jsonwebtoken@9.0.8': dependencies: '@types/ms': 0.7.34 - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@types/long@4.0.2': optional: true @@ -14184,10 +14218,19 @@ snapshots: '@types/ms@0.7.34': {} + '@types/node-fetch@2.6.12': + dependencies: + '@types/node': 20.14.10 + form-data: 4.0.3 + '@types/node@12.20.55': {} '@types/node@16.18.11': {} + '@types/node@18.19.112': + dependencies: + undici-types: 5.26.5 + '@types/node@20.14.10': dependencies: undici-types: 5.26.5 @@ -14242,7 +14285,7 @@ snapshots: '@types/request@2.48.12': dependencies: '@types/caseless': 0.12.5 - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@types/tough-cookie': 4.0.5 form-data: 2.5.2 optional: true @@ -14250,12 +14293,12 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@types/send': 0.17.4 '@types/tough-cookie@4.0.5': @@ -14267,7 +14310,7 @@ snapshots: '@types/ws@8.5.14': dependencies: - '@types/node': 20.17.6 + '@types/node': 20.14.10 '@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)': dependencies: @@ -14663,7 +14706,6 @@ snapshots: abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 - optional: true accepts@2.0.0: dependencies: @@ -14707,6 +14749,10 @@ snapshots: agent-base@7.1.3: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -14849,8 +14895,7 @@ snapshots: async-sema@3.1.1: {} - asynckit@0.4.0: - optional: true + asynckit@0.4.0: {} autoprefixer@10.4.15(postcss@8.4.27): dependencies: @@ -15095,6 +15140,18 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cloudflare@4.4.1: + dependencies: + '@types/node': 18.19.112 + '@types/node-fetch': 2.6.12 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + clsx@2.1.1: {} code-block-writer@10.1.1: {} @@ -15121,7 +15178,6 @@ snapshots: combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 - optional: true comma-separated-tokens@2.0.3: {} @@ -15285,8 +15341,7 @@ snapshots: defu@6.1.4: {} - delayed-stream@1.0.0: - optional: true + delayed-stream@1.0.0: {} depd@1.1.2: {} @@ -16516,8 +16571,7 @@ snapshots: etag@1.8.1: {} - event-target-shim@5.0.1: - optional: true + event-target-shim@5.0.1: {} events-intercept@2.0.0: {} @@ -16777,6 +16831,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} + form-data@2.5.2: dependencies: asynckit: 0.4.0 @@ -16785,6 +16841,19 @@ snapshots: safe-buffer: 5.2.1 optional: true + form-data@4.0.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -17157,6 +17226,10 @@ snapshots: human-signals@2.1.0: {} + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -17903,15 +17976,13 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: - optional: true + mime-db@1.52.0: {} mime-db@1.54.0: {} mime-types@2.1.35: dependencies: mime-db: 1.52.0 - optional: true mime-types@3.0.1: dependencies: @@ -18853,7 +18924,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.17.6 + '@types/node': 20.14.10 long: 5.2.4 proxy-addr@2.0.7: @@ -20340,6 +20411,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-streams-polyfill@4.0.0-beta.3: {} + web-vitals@0.2.4: {} webidl-conversions@3.0.1: {}