From 69ef8228fd96b4df192195d93c33e56ae665500a Mon Sep 17 00:00:00 2001 From: emily-shen <69125074+emily-shen@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:26:48 +0100 Subject: [PATCH 1/9] semi-unquarantine nuxt c3 tests (#14803) --- .../e2e/tests/frameworks/test-config.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts index cc79047221..fb60bec344 100644 --- a/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts +++ b/packages/create-cloudflare/e2e/tests/frameworks/test-config.ts @@ -361,7 +361,6 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { }, { name: "nuxt:pages", - quarantine: true, promptHandlers: [ { matcher: /Would you like to .* install .*modules\?/, @@ -371,7 +370,9 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { argv: ["--platform", "pages"], testCommitMessage: true, timeout: LONG_TIMEOUT, - unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 + // yarn: nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18. + // npm: nuxt project creation fails on npm with "Cannot read properties of null (reading 'edgesOut')". + unsupportedPms: ["yarn", "npm"], unsupportedOSs: ["win32"], // The Nuxt `ui` template pins `packageManager: pnpm@11.9.0`, so run // it only on pnpm 11+: under pnpm 10 the cross-version self-provision @@ -391,7 +392,6 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { }, { name: "nuxt:workers", - quarantine: true, promptHandlers: [ { matcher: /Would you like to .* install .*modules\?/, @@ -401,7 +401,9 @@ function getFrameworkTestConfig(pm: string): NamedFrameworkTestConfig[] { argv: ["--platform", "workers"], testCommitMessage: true, timeout: LONG_TIMEOUT, - unsupportedPms: ["yarn"], // Currently nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18 + // yarn: nitro requires youch which expects Node 20+, and yarn will fail hard since we run on Node 18. + // npm: nuxt project creation fails on npm with "Cannot read properties of null (reading 'edgesOut')". + unsupportedPms: ["yarn", "npm"], unsupportedOSs: ["win32"], // See note on nuxt:pages above. unsupportedPmRanges: { pnpm: "<11.0.0" }, From beec0fbc9d3adec24bc42e31a21fe7f82badb543 Mon Sep 17 00:00:00 2001 From: Ben <4991309+NuroDev@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:38:01 +0100 Subject: [PATCH 2/9] feat(wrangler): automate names for agent-driven deploys (#14907) --- .changeset/curvy-rivers-name.md | 7 ++ .../deploy/helpers/validate-worker-props.ts | 1 + packages/deploy-helpers/src/shared/types.ts | 2 + .../deploy-helpers/src/triggers/subdomain.ts | 84 ++++++++++++++++--- .../tests/triggers-subdomain.test.ts | 61 ++++++++++++++ .../deploy/deploy-interactive-prompts.test.ts | 28 +++++++ .../deploy/name-collision-guard.test.ts | 40 +++++++-- .../src/__tests__/deploy/workers-dev.test.ts | 61 ++++++++++++++ packages/wrangler/src/deploy/autoconfig.ts | 36 +++++--- packages/wrangler/src/deploy/index.ts | 40 ++++++--- 10 files changed, 315 insertions(+), 45 deletions(-) create mode 100644 .changeset/curvy-rivers-name.md create mode 100644 packages/deploy-helpers/tests/triggers-subdomain.test.ts diff --git a/.changeset/curvy-rivers-name.md b/.changeset/curvy-rivers-name.md new file mode 100644 index 0000000000..616a74acdf --- /dev/null +++ b/.changeset/curvy-rivers-name.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Avoid Worker and workers.dev naming prompts in agent-driven deploys + +Wrangler now derives the Worker name from the project and automatically registers the same project-derived workers.dev account subdomain on a first deploy when running in a detected agent environment. The deploy output explains how to change both names. diff --git a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts index ee48a53d71..3e266e2e52 100644 --- a/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts +++ b/packages/deploy-helpers/src/deploy/helpers/validate-worker-props.ts @@ -330,6 +330,7 @@ export async function preUploadApiChecks( // such deploys still get a correctly-worded prompt in the triggers phase). if (!workerExists && wantsWorkersDev) { await getWorkersDevSubdomain(config, accountId, { + autoRegisterSubdomain: props.autoRegisterWorkersDevSubdomain, configPath: config.configPath, }); } diff --git a/packages/deploy-helpers/src/shared/types.ts b/packages/deploy-helpers/src/shared/types.ts index e8fcd21684..229e75366e 100644 --- a/packages/deploy-helpers/src/shared/types.ts +++ b/packages/deploy-helpers/src/shared/types.ts @@ -109,6 +109,8 @@ export type SharedDeployVersionsProps = { export type DeployProps = SharedDeployVersionsProps & { /** Discriminant for DeployProps vs VersionsUploadProps */ command: "deploy"; + /** If set, automatically register this `workers.dev` account subdomain when the account has none. */ + autoRegisterWorkersDevSubdomain?: string; /** Merged from --site arg and config.site. */ legacyAssetPaths: LegacyAssetPaths | undefined; /** Merged: --triggers arg ?? config.triggers.crons. */ diff --git a/packages/deploy-helpers/src/triggers/subdomain.ts b/packages/deploy-helpers/src/triggers/subdomain.ts index 423accd949..50bea8d788 100644 --- a/packages/deploy-helpers/src/triggers/subdomain.ts +++ b/packages/deploy-helpers/src/triggers/subdomain.ts @@ -10,11 +10,23 @@ import type { ComplianceConfig } from "@cloudflare/workers-utils"; type WorkersDevSubdomainRegistrationContext = "workers_dev" | "workflows"; type GetWorkersDevSubdomainOptions = { - configPath?: string | undefined; abortSignal?: AbortSignal | undefined; + autoRegisterSubdomain?: string | undefined; + configPath?: string | undefined; registrationContext?: WorkersDevSubdomainRegistrationContext | undefined; }; +function toValidSubdomain(input: string): string { + const subdomain = input + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+/, "") + .slice(0, 63) + .replace(/-+$/, ""); + + return subdomain || "my-worker"; +} + /** * Gets the .(fed.)workers.dev URL for the given account. */ @@ -24,8 +36,9 @@ export async function getWorkersDevSubdomain( options: GetWorkersDevSubdomainOptions = {} ): Promise { const { - configPath, abortSignal, + autoRegisterSubdomain, + configPath, registrationContext = "workers_dev", } = options; @@ -48,6 +61,15 @@ export async function getWorkersDevSubdomain( // 10007 error code: not found // https://api.cloudflare.com/#worker-subdomain-get-subdomain logger.warn(getRegistrationWarning(registrationContext)); + if (autoRegisterSubdomain) { + return await registerSubdomain( + complianceConfig, + accountId, + configPath, + registrationContext, + autoRegisterSubdomain + ); + } const wantsToRegister = await confirm( "Would you like to register a workers.dev subdomain now?", @@ -116,14 +138,21 @@ async function registerSubdomain( complianceConfig: ComplianceConfig, accountId: string, configPath: string | undefined, - registrationContext: WorkersDevSubdomainRegistrationContext + registrationContext: WorkersDevSubdomainRegistrationContext, + automaticSubdomain?: string ): Promise { let subdomain: string | undefined; + let suggestedSubdomain = automaticSubdomain + ? toValidSubdomain(automaticSubdomain) + : undefined; while (subdomain === undefined) { - const potentialName = await prompt( - "What would you like your workers.dev subdomain to be? It will be accessible at https://.workers.dev" - ); + const potentialName = + suggestedSubdomain ?? + (await prompt( + "What would you like your workers.dev subdomain to be? It will be accessible at https://.workers.dev" + )); + suggestedSubdomain = undefined; if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(potentialName)) { logger.warn( @@ -148,24 +177,44 @@ async function registerSubdomain( // oddly enough, this is a `subdomain_unavailable` error, meaning...that the subdomain // doesn't exist. and we can register it. this is exactly how the dashboard does it. } else if (subdomainAvailabilityCheckError.code === 10031) { + if (automaticSubdomain) { + throw new UserError( + `Wrangler could not automatically register "${potentialName}" as your workers.dev subdomain because the name is unavailable. Register a different subdomain at https://dash.cloudflare.com/${accountId}/workers/onboarding.`, + { + telemetryMessage: + "workers dev automatic registration name unavailable", + } + ); + } logger.error( "Subdomain is unavailable, please try a different subdomain" ); continue; } else { + if (automaticSubdomain) { + throw new UserError( + `Wrangler could not verify whether "${potentialName}" is available as your \`workers.dev\` subdomain. Register a subdomain at https://dash.cloudflare.com/${accountId}/workers/onboarding.`, + { + telemetryMessage: + "workers dev automatic registration availability check failed", + } + ); + } logger.error("An unexpected error occurred, please try again."); continue; } } } - const ok = await confirm( - `Creating a workers.dev subdomain for your account at ${chalk.blue( - chalk.underline( - `https://${potentialName}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev` - ) - )}. Ok to proceed?` - ); + const ok = + automaticSubdomain !== undefined || + (await confirm( + `Creating a workers.dev subdomain for your account at ${chalk.blue( + chalk.underline( + `https://${potentialName}${getComplianceRegionSubdomain(complianceConfig)}.workers.dev` + ) + )}. Ok to proceed?` + )); if (!ok) { throw getRegistrationDeclinedError( registrationContext, @@ -186,6 +235,15 @@ async function registerSubdomain( subdomain = result.subdomain; } catch (err) { const subdomainCreationError = err as { code?: number }; + if (automaticSubdomain) { + throw new UserError( + `Wrangler could not automatically register "${potentialName}" as your \`workers.dev\` subdomain. Register a subdomain at https://dash.cloudflare.com/${accountId}/workers/onboarding.`, + { + telemetryMessage: + "workers dev automatic registration creation failed", + } + ); + } if ( typeof subdomainCreationError === "object" && !!subdomainCreationError && diff --git a/packages/deploy-helpers/tests/triggers-subdomain.test.ts b/packages/deploy-helpers/tests/triggers-subdomain.test.ts new file mode 100644 index 0000000000..0cc775d820 --- /dev/null +++ b/packages/deploy-helpers/tests/triggers-subdomain.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, it, vi } from "vitest"; +import { initDeployHelpersContext } from "../src/shared/context"; +import { getWorkersDevSubdomain } from "../src/triggers/subdomain"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; + +const ACCOUNT_ID = "some-account-id"; + +describe("getWorkersDevSubdomain", () => { + const confirm = vi.fn(); + const prompt = vi.fn(); + + beforeEach(() => { + initDeployHelpersContext({ + confirm, + fetchKVGetValue: (() => {}) as never, + fetchListResult: (() => {}) as never, + fetchPagedListResult: (() => {}) as never, + fetchResult: (async ( + _config: ComplianceConfig, + path: string, + init?: RequestInit + ) => { + if (path.endsWith("/workers/subdomain") && !init) { + throw Object.assign(new Error("Subdomain not found"), { + code: 10007, + }); + } + if (path.endsWith("/workers/subdomains/my-project")) { + throw Object.assign(new Error("Subdomain is available"), { + code: 10032, + }); + } + if (path.endsWith("/workers/subdomain") && init?.method === "PUT") { + return { subdomain: "my-project" }; + } + throw new Error(`Unexpected request: ${init?.method ?? "GET"} ${path}`); + }) as never, + logger: { + debug() {}, + error() {}, + info() {}, + log() {}, + warn() {}, + }, + prompt, + select: (() => {}) as never, + }); + }); + + it("normalizes an automatic subdomain without prompting", async ({ + expect, + }) => { + const subdomain = await getWorkersDevSubdomain({}, ACCOUNT_ID, { + autoRegisterSubdomain: "My Project!", + }); + + expect(subdomain).toBe("my-project.workers.dev"); + expect(confirm).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts b/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts index e4bdd9a2dd..b317c5b03d 100644 --- a/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts +++ b/packages/wrangler/src/__tests__/deploy/deploy-interactive-prompts.test.ts @@ -7,6 +7,7 @@ import { import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; import { clearOutputFilePath } from "../../output"; +import { detectAgent } from "../../utils/detect-agent"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm, mockPrompt } from "../helpers/mock-dialogs"; @@ -76,6 +77,7 @@ describe("deploy: interactive deploy config prompts", () => { }); afterEach(() => { + vi.mocked(detectAgent).mockReturnValue({ isAgent: false, id: null }); vi.unstubAllGlobals(); clearDialogs(); clearOutputFilePath(); @@ -325,6 +327,32 @@ describe("deploy: interactive deploy config prompts", () => { expect(std.out).not.toContain("Proceeding with deployment..."); }); + it("should use the project name without prompting when run by an agent", async ({ + expect, + }) => { + setIsTTY(false); + writeWorkerSource(); + fs.writeFileSync( + "package.json", + JSON.stringify({ name: "agent-project-name" }) + ); + writeWranglerConfig({ name: undefined as unknown as string }); + vi.mocked(detectAgent).mockReturnValue({ + isAgent: true, + id: "test-agent", + }); + + await runWrangler("deploy ./index.js --dry-run"); + + expect(std.out).toContain( + 'Using the project name "agent-project-name" as the Worker name.' + ); + expect(std.out).toContain( + "set the `name` field in your Wrangler configuration file or pass `--name `" + ); + expect(std.out).not.toContain("What do you want to name your project?"); + }); + it("should not prompt for name when config file provides one", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/deploy/name-collision-guard.test.ts b/packages/wrangler/src/__tests__/deploy/name-collision-guard.test.ts index 6fae7e67a4..85e2e4323b 100644 --- a/packages/wrangler/src/__tests__/deploy/name-collision-guard.test.ts +++ b/packages/wrangler/src/__tests__/deploy/name-collision-guard.test.ts @@ -1,9 +1,11 @@ +import { writeFileSync } from "node:fs"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { http, HttpResponse } from "msw"; -import { beforeEach, describe, it } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import { readConfig } from "../../config"; import { runDeployCommandHandler, type DeployArgs } from "../../deploy"; import { run } from "../../experimental-flags"; +import { detectAgent } from "../../utils/detect-agent"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { useMockIsTTY } from "../helpers/mock-istty"; @@ -12,12 +14,10 @@ import { mockSubDomainRequest } from "../helpers/mock-workers-subdomain"; import { createFetchResult, msw } from "../helpers/msw"; import { writeWorkerSource } from "../helpers/write-worker-source"; -// The Pages-to-Workers delegation deploys non-interactively on behalf of an -// agent. When the target Worker name was not proven to belong to this project -// (no config file naming it), an existing Worker of the same name is a probable -// collision, and there is no prompt to resolve it. These tests exercise that -// guard directly through `runDeployCommandHandler` with -// `pagesToWorkersDelegation: true`. +// When an agent or the Pages-to-Workers delegation chooses a Worker name without +// a config file proving ownership, an existing Worker of the same name is a +// probable collision. These tests exercise that guard directly through +// `runDeployCommandHandler`. describe("deploy name-collision guard (non-interactive)", () => { mockAccountId(); mockApiToken(); @@ -72,10 +72,32 @@ describe("deploy name-collision guard (non-interactive)", () => { beforeEach(() => { // The delegation always runs non-interactively. + vi.mocked(detectAgent).mockReturnValue({ isAgent: false, id: null }); setIsTTY(false); writeWorkerSource(); }); + it("aborts when an agent-generated name matches an existing Worker", async ({ + expect, + }) => { + writeFileSync("package.json", JSON.stringify({ name: "existing-worker" })); + vi.mocked(detectAgent).mockReturnValue({ + isAgent: true, + id: "test-agent", + }); + const args = delegatedDeployArgs({}); + const config = readConfig(args, { useRedirectIfAvailable: true }); + config.main = "index.js"; + config.compatibility_date = "2024-01-01"; + mockExistingWorker(); + + await expect( + run(experimentalFlags, () => runDeployCommandHandler(args, { config })) + ).rejects.toThrow( + 'A Worker named "existing-worker" already exists in your account.' + ); + }); + it("aborts when the delegated name was auto-generated (no --name)", async ({ expect, }) => { @@ -131,8 +153,8 @@ describe("deploy name-collision guard (non-interactive)", () => { // Regression test for #14312: a plain `wrangler deploy` in CI with no // config file and no --name (e.g. an autoconfigured project whose // generated config PR has not been merged) is routinely re-run against - // the same, already-deployed Worker. Only the Pages-to-Workers delegation - // guards name ownership, so this must deploy normally instead of aborting. + // the same, already-deployed Worker. A plain non-agent deploy does not guard + // name ownership, so this must deploy normally instead of aborting. const args = delegatedDeployArgs({}); const config = readConfig(args, { useRedirectIfAvailable: true }); config.name = "existing-worker"; diff --git a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts index 84c659c941..bef88bc118 100644 --- a/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts +++ b/packages/wrangler/src/__tests__/deploy/workers-dev.test.ts @@ -1,3 +1,4 @@ +import { writeFileSync } from "node:fs"; import { getInstalledPackageVersion } from "@cloudflare/autoconfig"; import { getSubdomainValues } from "@cloudflare/deploy-helpers"; import { @@ -11,6 +12,7 @@ import { http, HttpResponse } from "msw"; */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearOutputFilePath } from "../../output"; +import { detectAgent } from "../../utils/detect-agent"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { clearDialogs, mockConfirm, mockPrompt } from "../helpers/mock-dialogs"; @@ -96,6 +98,7 @@ describe("deploy", () => { }); afterEach(() => { + vi.mocked(detectAgent).mockReturnValue({ isAgent: false, id: null }); vi.unstubAllGlobals(); clearDialogs(); clearOutputFilePath(); @@ -1014,6 +1017,64 @@ describe("deploy", () => { `); }); + it("uses the project name without prompting when run by an agent", async ({ + expect, + }) => { + writeFileSync( + "package.json", + JSON.stringify({ name: "agent-project-name" }) + ); + writeWranglerConfig({ name: undefined as unknown as string }); + writeWorkerSource(); + mockWorkerDoesNotExist(); + mockSubDomainRequest("agent-project-name", true, false); + mockSubDomainRequest("agent-project-name", false, true); + mockUploadWorkerRequest({ + expectedScriptName: "agent-project-name", + useOldUploadApi: true, + }); + vi.mocked(detectAgent).mockReturnValue({ + isAgent: true, + id: "test-agent", + }); + msw.use( + http.get( + "*/accounts/:accountId/workers/subdomains/:subdomain", + () => + HttpResponse.json( + createFetchResult(null, false, [ + { code: 10032, message: "subdomain_unavailable" }, + ]) + ), + { once: true } + ), + http.put( + "*/accounts/:accountId/workers/subdomain", + async ({ request }) => { + expect(await request.json()).toEqual({ + subdomain: "agent-project-name", + }); + return HttpResponse.json( + createFetchResult({ subdomain: "agent-project-name" }) + ); + }, + { once: true } + ) + ); + + await runWrangler("deploy ./index"); + + expect(std.out).toContain( + 'Using the project name "agent-project-name" as the Worker name.' + ); + expect(std.out).toContain( + "Visit https://dash.cloudflare.com/some-account-id/workers/subdomain to edit your workers.dev subdomain" + ); + expect(std.out).toContain( + "https://agent-project-name.agent-project-name.workers.dev" + ); + }); + it("fails before uploading when the user declines to register a subdomain", async ({ expect, }) => { diff --git a/packages/wrangler/src/deploy/autoconfig.ts b/packages/wrangler/src/deploy/autoconfig.ts index 7a97664526..59f9f56a3d 100644 --- a/packages/wrangler/src/deploy/autoconfig.ts +++ b/packages/wrangler/src/deploy/autoconfig.ts @@ -2,6 +2,7 @@ import { writeFileSync } from "node:fs"; import path from "node:path"; import { configFileName, + getWorkerNameFromProject, getTodaysCompatDate, UserError, type Config, @@ -180,9 +181,11 @@ export async function maybeRunAutoConfig( */ export async function promptForMissingDeployConfig( args: Args, - config: { configPath?: string; compatibility_date?: string; name?: string } + config: { configPath?: string; compatibility_date?: string; name?: string }, + options: { useProjectName?: boolean } = {} ): Promise { - if (isNonInteractiveOrCI()) { + const nonInteractiveOrCI = isNonInteractiveOrCI(); + if (nonInteractiveOrCI && !options.useProjectName) { return args; } @@ -190,21 +193,28 @@ export async function promptForMissingDeployConfig( // Prompt for name when missing from both CLI args and config if (!args.name && !config.name) { - const defaultName = process - .cwd() - .split(path.sep) - .pop() - ?.replaceAll("_", "-") - .trim(); - const isValidName = defaultName && /^[a-zA-Z0-9-]+$/.test(defaultName); - const projectName = await prompt("What do you want to name your project?", { - defaultValue: isValidName ? defaultName : "my-project", - }); - args.name = projectName; + if (options.useProjectName) { + args.name = getWorkerNameFromProject(process.cwd()); + } else { + const defaultName = process + .cwd() + .split(path.sep) + .pop() + ?.replaceAll("_", "-") + .trim(); + const isValidName = defaultName && /^[a-zA-Z0-9-]+$/.test(defaultName); + args.name = await prompt("What do you want to name your project?", { + defaultValue: isValidName ? defaultName : "my-project", + }); + } logger.log(""); promptedForMissing = true; } + if (nonInteractiveOrCI) { + return args; + } + // Prompt for compatibility date when missing if (!args.latest && !args.compatibilityDate && !config.compatibility_date) { const compatibilityDateStr = getTodaysCompatDate(); diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index 5db3222d21..5820d2f539 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -1,5 +1,8 @@ import { deploy } from "@cloudflare/deploy-helpers"; -import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; +import { + getWorkerNameFromProject, + isNonInteractiveOrCI, +} from "@cloudflare/workers-utils"; import { analyseBundle } from "../check/commands"; import { buildContainer } from "../containers/build"; import { getNormalizedContainerOptions } from "../containers/config"; @@ -15,9 +18,11 @@ import { mergeDeployConfigArgs, } from "../deployment-bundle/merge-config-args"; import { experimentalNewConfigArg } from "../experimental-config/cli-flag"; +import { logger } from "../logger"; import * as metrics from "../metrics"; import { writeOutput } from "../output"; import { syncWorkersSite } from "../sites"; +import { detectAgent } from "../utils/detect-agent"; import { getScriptName } from "../utils/getScriptName"; import { maybeRunAutoConfig, promptForMissingDeployConfig } from "./autoconfig"; import { maybeDelegateToOpenNextDeployCommand } from "./open-next"; @@ -117,28 +122,32 @@ export async function runDeployCommandHandler( pagesToWorkersDelegation = false, }: { config: Config; pagesToWorkersDelegation?: boolean } ): Promise { + const detectedAgent = detectAgent(); + const shouldUseProjectName = + detectedAgent.isAgent && !args.name && !config.name; + // Capture whether this project can prove it owns the target Worker name, // BEFORE autoconfig generates or rewrites the config. Ownership is proven by // a config file that names the Worker; without one a same-named remote Worker // could be a collision rather than a redeploy. // - // We only guard the Pages-to-Workers delegation: there the name is a Pages - // project name carried across (or auto-generated), so an existing Worker of - // the same name is a different resource we must not clobber, and being - // non-interactive there is no prompt to resolve it. Repeat delegations are - // unaffected because the first one writes a config file (so `configPath` is - // then set). + // We guard both agent-generated names and the Pages-to-Workers delegation. + // In either case an existing Worker with the same name may be a different + // resource that we must not clobber. Repeat deploys are unaffected because + // the first one writes a config file (so `configPath` is then set). // // Plain `wrangler deploy` is NOT guarded, even in CI with an autoconfigured // name: autoconfigured projects are routinely redeployed in CI (e.g. when the // auto-generated config PR has not been merged), and blocking that regressed // those workflows. See `failIfWorkerNameTaken` in preUploadApiChecks. const nameOwnershipUnverified = - !config.configPath && isNonInteractiveOrCI() && pagesToWorkersDelegation; + !config.configPath && + ((isNonInteractiveOrCI() && pagesToWorkersDelegation) || + shouldUseProjectName); // --- Step 0. Auto-config --- // const autoConfigResult = await maybeRunAutoConfig(args, config, { - skipConfirmations: pagesToWorkersDelegation, + skipConfirmations: pagesToWorkersDelegation || detectedAgent.isAgent, }); if (autoConfigResult.aborted) { return; @@ -146,7 +155,15 @@ export async function runDeployCommandHandler( config = autoConfigResult.config; // Interatively handle missing/incorrect --assets, --script, --name, --compatibility-date - args = await promptForMissingDeployConfig(args, config); + args = await promptForMissingDeployConfig(args, config, { + useProjectName: detectedAgent.isAgent, + }); + if (shouldUseProjectName) { + const workerName = args.name ?? config.name; + logger.log( + `Using the project name "${workerName}" as the Worker name. To change it, set the \`name\` field in your Wrangler configuration file or pass \`--name \` when deploying.` + ); + } // Needs to happen after auto-config logic to capture newly auto-configured open-next apps. // As a precaution we're gating the feature under the autoconfig flag for the time being. @@ -164,6 +181,9 @@ export async function runDeployCommandHandler( // Merge CLI args with config into props for building and deploying const { props, buildProps } = await mergeDeployConfigArgs(args, config); props.failIfWorkerNameTaken = nameOwnershipUnverified; + props.autoRegisterWorkersDevSubdomain = detectedAgent.isAgent + ? getWorkerNameFromProject(process.cwd()) + : undefined; try { // Derive workerNameOverridden by comparing pre-merge name with post-merge name From e31ab0f40c5310babd8b493490c0e1ca677e9a8c Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Wed, 29 Jul 2026 11:30:20 -0500 Subject: [PATCH 3/9] [wrangler] `useConfigRedirectIfAvailable` for "wrangler triggers deploy" (#14897) --- .changeset/tidy-pears-travel.md | 7 +++++ .../wrangler/src/__tests__/triggers.test.ts | 31 +++++++++++++++++++ packages/wrangler/src/triggers/index.ts | 1 + 3 files changed, 39 insertions(+) create mode 100644 .changeset/tidy-pears-travel.md create mode 100644 packages/wrangler/src/__tests__/triggers.test.ts diff --git a/.changeset/tidy-pears-travel.md b/.changeset/tidy-pears-travel.md new file mode 100644 index 0000000000..f2908183cc --- /dev/null +++ b/.changeset/tidy-pears-travel.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Fix `wrangler triggers deploy` to use Vite-generated redirected configuration + +The command now reads `.wrangler/deploy/config.json`, matching `wrangler deploy` and `wrangler versions upload`, so generated Worker names and trigger settings are applied. diff --git a/packages/wrangler/src/__tests__/triggers.test.ts b/packages/wrangler/src/__tests__/triggers.test.ts new file mode 100644 index 0000000000..ba5d2aac30 --- /dev/null +++ b/packages/wrangler/src/__tests__/triggers.test.ts @@ -0,0 +1,31 @@ +import { + runInTempDir, + writeRedirectedWranglerConfig, + writeWranglerConfig, +} from "@cloudflare/workers-utils/test-helpers"; +import { describe, it } from "vitest"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { runWrangler } from "./helpers/run-wrangler"; + +describe("triggers deploy", () => { + runInTempDir(); + const std = mockConsoleMethods(); + + it("uses a redirected deploy configuration", async ({ expect }) => { + writeWranglerConfig({ name: undefined }); + writeRedirectedWranglerConfig( + { + name: "generated-worker", + userConfigPath: "./wrangler.toml", + }, + "./dist/wrangler.json" + ); + + await runWrangler("triggers deploy --dry-run"); + + expect(std.info).toContain( + 'Using redirected Wrangler configuration.\n - Configuration being used: "dist/wrangler.json"' + ); + expect(std.out).toContain("--dry-run: exiting now."); + }); +}); diff --git a/packages/wrangler/src/triggers/index.ts b/packages/wrangler/src/triggers/index.ts index 059fd3e29f..5e5668827a 100644 --- a/packages/wrangler/src/triggers/index.ts +++ b/packages/wrangler/src/triggers/index.ts @@ -55,6 +55,7 @@ export const triggersDeployCommand = createCommand({ }, behaviour: { supportTemporary: true, + useConfigRedirectIfAvailable: true, warnIfMultipleEnvsConfiguredButNoneSpecified: true, suggestSkillsAfterHandler: true, }, From 6a03ffae1190d4be3532568b02b95830c694603e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:53:00 +0100 Subject: [PATCH 4/9] [C3] Bump create-vike from 0.0.668 to 0.0.670 in /packages/create-cloudflare/src/frameworks (#14910) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Wrangler automated PR updater --- .changeset/c3-frameworks-update-14910.md | 11 +++++++++++ .../create-cloudflare/src/frameworks/package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/c3-frameworks-update-14910.md diff --git a/.changeset/c3-frameworks-update-14910.md b/.changeset/c3-frameworks-update-14910.md new file mode 100644 index 0000000000..7d2a74cc4c --- /dev/null +++ b/.changeset/c3-frameworks-update-14910.md @@ -0,0 +1,11 @@ +--- +"create-cloudflare": patch +--- + +Update dependencies of "create-cloudflare" + +The following dependency versions have been updated: + +| Dependency | From | To | +| ----------- | ------- | ------- | +| create-vike | 0.0.668 | 0.0.670 | diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index 3c4919ba3e..f8684fc656 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -12,7 +12,7 @@ "create-react-router": "8.3.0", "create-rwsdk": "3.1.3", "create-solid": "0.8.0", - "create-vike": "0.0.668", + "create-vike": "0.0.670", "create-vite": "9.1.1", "create-vue": "3.23.0", "create-waku": "0.12.5-1.0.0-alpha.10-0", From e09da32b58bc3f6808bce9696e80af0d5f8652b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:53:28 +0100 Subject: [PATCH 5/9] [C3] Bump create-astro from 5.2.2 to 5.2.3 in /packages/create-cloudflare/src/frameworks (#14911) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Wrangler automated PR updater --- .changeset/c3-frameworks-update-14911.md | 11 +++++++++++ .../create-cloudflare/src/frameworks/package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/c3-frameworks-update-14911.md diff --git a/.changeset/c3-frameworks-update-14911.md b/.changeset/c3-frameworks-update-14911.md new file mode 100644 index 0000000000..685358bb47 --- /dev/null +++ b/.changeset/c3-frameworks-update-14911.md @@ -0,0 +1,11 @@ +--- +"create-cloudflare": patch +--- + +Update dependencies of "create-cloudflare" + +The following dependency versions have been updated: + +| Dependency | From | To | +| ------------ | ----- | ----- | +| create-astro | 5.2.2 | 5.2.3 | diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index f8684fc656..e638d74923 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -4,7 +4,7 @@ "@angular/create": "22.0.8", "@tanstack/cli": "0.70.1", "create-analog": "2.6.4", - "create-astro": "5.2.2", + "create-astro": "5.2.3", "create-docusaurus": "3.10.2", "create-hono": "0.19.4", "create-next-app": "16.2.11", From 5e6556a0c788679b6ac149ba3018a2cfd7cc73e9 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Wed, 29 Jul 2026 18:20:46 +0100 Subject: [PATCH 6/9] extract Pages Functions compiler into a standalone package (#14785) Co-authored-by: Edmund Hung Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/add-pages-functions-package.md | 13 + .changeset/move-url-path-to-workers-utils.md | 7 + packages/pages-functions/README.md | 206 +++++++ packages/pages-functions/bin/pages-functions | 2 + packages/pages-functions/package.json | 65 +++ packages/pages-functions/scripts/deps.ts | 18 + .../src/__tests__/build.test.ts | 237 ++++++++ .../pages-functions/src/__tests__/cli.test.ts | 98 ++++ .../src/__tests__}/filepath-routing.test.ts | 26 +- .../src/__tests__/identifiers.test.ts | 17 + .../src/__tests__/paths.test.ts | 9 + .../__tests__}/routes-consolidation.test.ts | 5 +- .../src/__tests__}/routes-module.test.ts | 42 +- .../__tests__}/routes-transformation.test.ts | 42 +- .../src/__tests__/routes-validation.test.ts | 425 +++++++++++++++ packages/pages-functions/src/build.ts | 493 +++++++++++++++++ packages/pages-functions/src/cli.ts | 128 +++++ packages/pages-functions/src/index.ts | 37 ++ .../pages-functions/src/routing/constants.ts | 8 + .../src/routing/filepath-routing.ts | 327 +++++++++++ .../src/routing/identifiers.ts | 88 +++ packages/pages-functions/src/routing/index.ts | 29 + .../src/routing/routes-consolidation.ts | 78 +++ .../src/routing/routes-transformation.ts | 131 +++++ .../src/routing/routes-validation.ts | 237 ++++++++ .../pages-functions/src/routing/routes.ts | 191 +++++++ .../src/templates/pages-template-worker.ts | 200 +++++++ packages/pages-functions/tsconfig.json | 8 + packages/pages-functions/tsdown.config.ts | 27 + packages/pages-functions/turbo.json | 11 + packages/pages-functions/vitest.config.mts | 11 + packages/workers-utils/src/index.ts | 3 + packages/workers-utils/src/url-path.ts | 29 + packages/wrangler/package.json | 1 + .../pages/build-functions-errors.test.ts | 71 +++ .../__tests__/pages/functions-build.test.ts | 29 + .../__tests__/pages/routes-validation.test.ts | 509 ++++-------------- packages/wrangler/src/pages/buildFunctions.ts | 104 +++- packages/wrangler/src/pages/constants.ts | 15 +- .../src/pages/functions/buildWorker.ts | 23 + .../src/pages/functions/filepath-routing.ts | 255 +-------- .../src/pages/functions/identifiers.ts | 80 +-- .../pages/functions/routes-consolidation.ts | 78 +-- .../pages/functions/routes-transformation.ts | 130 +---- .../src/pages/functions/routes-validation.ts | 263 +++------ .../wrangler/src/pages/functions/routes.ts | 209 ++----- packages/wrangler/src/paths.ts | 31 +- .../templates/pages-template-worker.ts | 2 + pnpm-lock.yaml | 43 +- .../__tests__/validate-changesets.test.ts | 1 + 50 files changed, 3752 insertions(+), 1340 deletions(-) create mode 100644 .changeset/add-pages-functions-package.md create mode 100644 .changeset/move-url-path-to-workers-utils.md create mode 100644 packages/pages-functions/README.md create mode 100755 packages/pages-functions/bin/pages-functions create mode 100644 packages/pages-functions/package.json create mode 100644 packages/pages-functions/scripts/deps.ts create mode 100644 packages/pages-functions/src/__tests__/build.test.ts create mode 100644 packages/pages-functions/src/__tests__/cli.test.ts rename packages/{wrangler/src/__tests__/pages => pages-functions/src/__tests__}/filepath-routing.test.ts (88%) create mode 100644 packages/pages-functions/src/__tests__/identifiers.test.ts create mode 100644 packages/pages-functions/src/__tests__/paths.test.ts rename packages/{wrangler/src/__tests__/pages => pages-functions/src/__tests__}/routes-consolidation.test.ts (98%) rename packages/{wrangler/src/__tests__/pages => pages-functions/src/__tests__}/routes-module.test.ts (55%) rename packages/{wrangler/src/__tests__/pages => pages-functions/src/__tests__}/routes-transformation.test.ts (92%) create mode 100644 packages/pages-functions/src/__tests__/routes-validation.test.ts create mode 100644 packages/pages-functions/src/build.ts create mode 100644 packages/pages-functions/src/cli.ts create mode 100644 packages/pages-functions/src/index.ts create mode 100644 packages/pages-functions/src/routing/constants.ts create mode 100644 packages/pages-functions/src/routing/filepath-routing.ts create mode 100644 packages/pages-functions/src/routing/identifiers.ts create mode 100644 packages/pages-functions/src/routing/index.ts create mode 100644 packages/pages-functions/src/routing/routes-consolidation.ts create mode 100644 packages/pages-functions/src/routing/routes-transformation.ts create mode 100644 packages/pages-functions/src/routing/routes-validation.ts create mode 100644 packages/pages-functions/src/routing/routes.ts create mode 100644 packages/pages-functions/src/templates/pages-template-worker.ts create mode 100644 packages/pages-functions/tsconfig.json create mode 100644 packages/pages-functions/tsdown.config.ts create mode 100644 packages/pages-functions/turbo.json create mode 100644 packages/pages-functions/vitest.config.mts create mode 100644 packages/workers-utils/src/url-path.ts create mode 100644 packages/wrangler/src/__tests__/pages/build-functions-errors.test.ts diff --git a/.changeset/add-pages-functions-package.md b/.changeset/add-pages-functions-package.md new file mode 100644 index 0000000000..58e0911814 --- /dev/null +++ b/.changeset/add-pages-functions-package.md @@ -0,0 +1,13 @@ +--- +"@cloudflare/pages-functions": minor +--- + +Publish helpers for compiling Pages Functions directories into Workers bundle + +Provides both a programmatic API and a CLI (`pages-functions build`) for converting a Cloudflare Pages `functions/` directory into a Cloudflare Workers bundle: + +```sh +npx @cloudflare/pages-functions build ./functions --outdir ./dist +``` + +The package compiles the Worker and its auxiliary modules, but does not deploy them or generate deployment configuration. Consumers must provide the appropriate Wrangler configuration, including the selected fallback service binding (`ASSETS` by default) when applicable. diff --git a/.changeset/move-url-path-to-workers-utils.md b/.changeset/move-url-path-to-workers-utils.md new file mode 100644 index 0000000000..4273216957 --- /dev/null +++ b/.changeset/move-url-path-to-workers-utils.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/workers-utils": minor +--- + +Add `toUrlPath` and `UrlPath` exports + +`toUrlPath(filePath)` converts a file-system path into a URL-safe path by replacing backslashes with forward slashes and rejecting Windows drive-letter prefixes (e.g. `C:`). `UrlPath` is the branded string type it returns, letting callers prove at the type level that a string has been normalized for use in URLs. diff --git a/packages/pages-functions/README.md b/packages/pages-functions/README.md new file mode 100644 index 0000000000..a3e5d396e2 --- /dev/null +++ b/packages/pages-functions/README.md @@ -0,0 +1,206 @@ +# `@cloudflare/pages-functions` + +`@cloudflare/pages-functions` compiles a [Cloudflare Pages Functions](https://developers.cloudflare.com/pages/functions/) `functions/` directory into a single [Cloudflare Worker](https://developers.cloudflare.com/workers/) bundle. It provides both a command-line interface and a programmatic API for build tools. + +The package discovers file-based routes, bundles their handlers into an ES module Worker, collects imported Wasm, text, and binary modules, and returns Pages routing metadata through its programmatic API. + +> [!WARNING] +> Pages Functions are not recommended for new development. Build new applications and new server-side functionality with Workers instead. This package is intended to support existing Pages Functions projects and help migrate them incrementally to Workers. + +## Installation + +Install the package as a development dependency: + +```sh +npm install --save-dev @cloudflare/pages-functions +``` + +## CLI + +Compile the default `functions/` directory into `dist/`: + +```sh +npx @cloudflare/pages-functions build +``` + +To specify the input and output directories: + +```sh +npx @cloudflare/pages-functions build ./functions --outdir ./dist/worker +``` + +The command writes the following files: + +- `index.js`: the compiled ES module Worker +- `index.js.map`: the source map, when `--sourcemap` is used +- Imported Wasm, text, binary, and `assets:` modules required by the Worker + +The generated Worker contains its own router and does not require a `_routes.json` file. To produce one (for example when deploying to Pages), pass `--routes-output `. Pages deployment tools such as Wrangler generate this file on their own and do not need it from the CLI. + +The command only builds the Worker. It does not deploy it or create deployment configuration. By default, the generated Worker falls back to `env.ASSETS.fetch()` when no Function route handles a request, so the deployment must provide an `ASSETS` binding or select another binding with `--fallback-service`. + +Runtime-native imports such as `node:*` and `cloudflare:*` are not automatically excluded from the bundle. Pass them through with repeatable `--external` options when the surrounding Worker build or runtime provides them: + +```sh +npx @cloudflare/pages-functions build --external "node:*" --external "cloudflare:*" +``` + +### CLI options + +| Option | Description | Default | +| --------------------------- | ------------------------------------------------------ | ----------- | +| `[directory]` | Pages Functions directory | `functions` | +| `--outdir ` | Output directory | `dist` | +| `--minify` | Minify the Worker bundle | Disabled | +| `--sourcemap` | Generate an external source map | Disabled | +| `--fallback-service ` | Fetcher binding used when no route handles the request | `ASSETS` | +| `--external ` | Exclude a module from the bundle; may be repeated | None | +| `--routes-output ` | Write the generated `_routes.json` spec to this path | Not written | +| `-h`, `--help` | Display usage information | | +| `-v`, `--version` | Display the package version | | + +## Programmatic API + +Use `buildPagesFunctions()` when integrating the compiler into another build tool: + +```ts +import { buildPagesFunctions } from "@cloudflare/pages-functions"; + +const result = await buildPagesFunctions({ + functionsDirectory: "./functions", + outputDirectory: "./dist/worker", + sourcemap: true, +}); + +console.log(result.entryPointPath); +console.log(result.routesJSON); +``` + +`buildPagesFunctions()` returns the generated routing specification as `routesJSON` but does not write a `_routes.json` file. Consumers such as Wrangler can use this metadata when deploying to Pages. + +### Build options + +| Option | Type | Description | Default | +| ----------------------- | ---------- | --------------------------------------------------------- | ----------------- | +| `functionsDirectory` | `string` | Pages Functions directory to compile | Required | +| `outputDirectory` | `string` | Directory for `index.js` and collected modules | Required | +| `assetsOutputDirectory` | `string` | Directory for files referenced by `assets:` imports | `outputDirectory` | +| `fallbackService` | `string` | Fetcher binding used when no route handles the request | `ASSETS` | +| `minify` | `boolean` | Minify the Worker bundle | `false` | +| `sourcemap` | `boolean` | Generate an external source map | `false` | +| `external` | `string[]` | Module specifiers to exclude from the bundle | `undefined` | +| `routesDescription` | `string` | Description included in the returned routes specification | `undefined` | +| `metafile` | `boolean` | Include the esbuild metafile in the result | `false` | + +### Build result + +`buildPagesFunctions()` returns: + +| Property | Description | +| ----------------------- | ----------------------------------------------------- | +| `entryPointPath` | Absolute path to the generated `index.js` | +| `bundleType` | Always `"esm"` | +| `modules` | Collected Wasm, text, and binary modules | +| `dependencies` | Inputs included in the generated bundle | +| `sourceMapPath` | Absolute source map path when source maps are enabled | +| `routesJSON` | Generated `_routes.json` data | +| `filepathRoutingConfig` | Discovered Pages Functions routes and base URL | +| `metafile` | esbuild metafile when requested | + +The package also exports lower-level route discovery, generation, optimization, and validation helpers for tooling authors. Their TypeScript declarations are included with the package. + +## Migrate to Workers + +[Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/) supports deploying front-end assets and Worker code together. Workers also provides access to more Cloudflare platform features than Pages Functions. New routes and application logic should therefore be written in a Worker rather than added to an existing `functions/` directory. + +For an existing Pages Functions project, you can compile the old Functions during the Worker build and delegate requests to them from your Worker. This lets you migrate one route at a time. + +For example, configure Wrangler to compile the legacy `functions/` directory before it bundles the Worker: + +```jsonc +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "my-worker", + "main": "./src/index.js", + // Preserve the compatibility date and flags from your Pages project. + "compatibility_date": "", + "assets": { + "directory": "./dist/client", + "binding": "ASSETS", + "run_worker_first": true, + }, + "build": { + "command": "npx @cloudflare/pages-functions build ./functions --outdir ./dist/pages-functions --external \"node:*\" --external \"cloudflare:*\"", + "watch_dir": ["./functions", "./src"], + }, +} +``` + +Adjust `assets.directory` and combine the build command with your existing front-end build command as needed. Preserve any compatibility flags used by the Pages project. The `--external` options leave runtime-native imports for Wrangler to process. The `ASSETS` binding is required by the compiler's default fallback behavior. `run_worker_first` ensures the Worker can run Pages Functions routes and middleware before falling back to static assets. + +Import the compiled handler from your Worker, handle new or migrated routes first, and use the old Pages Functions handler as a fallback: + +```js +import legacyPagesFunctions from "../dist/pages-functions/index.js"; + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + + if (url.pathname === "/api/new-route") { + return new Response("Handled by the Worker"); + } + + return legacyPagesFunctions.fetch(request, env, ctx); + }, +}; +``` + +This ordering means Pages Functions middleware does not run for routes handled directly by the Worker. Migrate authentication, authorization, logging, and other applicable middleware to the Worker before moving routes that depend on it. + +As routes are migrated, remove their files from `functions/` and implement them directly in the Worker. Once no legacy Pages Functions remain, remove the compiler build step and import. + +If the legacy Functions use `assets:` imports, the imported assets must be written into the directory configured by `assets.directory`. The CLI writes them beneath its output directory, so use the programmatic API to select separate Worker and asset outputs: + +```js +// scripts/build-pages-functions.mjs +import { buildPagesFunctions } from "@cloudflare/pages-functions"; + +await buildPagesFunctions({ + functionsDirectory: "./functions", + outputDirectory: "./dist/pages-functions", + assetsOutputDirectory: "./dist/client", + external: ["node:*", "cloudflare:*"], +}); +``` + +Then set `build.command` to `node ./scripts/build-pages-functions.mjs`. The `assets:` integration always uses the `ASSETS` binding, even when a different general fallback service is configured. + +Workers do not use Pages `_routes.json` metadata. Configure [Workers Static Assets routing](https://developers.cloudflare.com/workers/static-assets/routing/) explicitly, especially if the Pages project used custom `_routes.json` rules or middleware. See the complete [Pages-to-Workers migration guide](https://developers.cloudflare.com/workers/static-assets/migration-guides/migrate-from-pages/) for bindings, assets, compatibility settings, and deployment changes. + +## Contributing + +See the Workers SDK [contributing guide](../../CONTRIBUTING.md) for repository setup and contribution requirements. + +From the repository root, install dependencies and run package tasks with: + +```sh +pnpm install +pnpm run build --filter @cloudflare/pages-functions +pnpm run check:type --filter @cloudflare/pages-functions +pnpm run test:ci --filter @cloudflare/pages-functions +pnpm run dev --filter @cloudflare/pages-functions +``` + +The `dev` task watches this package's implementation; it does not watch an end-user Pages Functions project. + +Before submitting changes, run: + +```sh +pnpm prettify +pnpm check +``` + +## License + +Licensed under either the [Apache 2.0 license](../../LICENSE-APACHE) or the [MIT license](../../LICENSE-MIT) at your option. diff --git a/packages/pages-functions/bin/pages-functions b/packages/pages-functions/bin/pages-functions new file mode 100755 index 0000000000..717119c0aa --- /dev/null +++ b/packages/pages-functions/bin/pages-functions @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import("../dist/cli.mjs"); diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json new file mode 100644 index 0000000000..38d86338da --- /dev/null +++ b/packages/pages-functions/package.json @@ -0,0 +1,65 @@ +{ + "name": "@cloudflare/pages-functions", + "version": "0.0.1", + "description": "Compile a Pages Functions directory into a Cloudflare Worker bundle", + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/pages-functions#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/pages-functions" + }, + "bin": { + "pages-functions": "./bin/pages-functions" + }, + "files": [ + "bin", + "dist", + "src/templates" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsdown", + "check:type": "tsc", + "dev": "tsdown --watch", + "test": "vitest", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "esbuild": "catalog:default", + "path-to-regexp": "6.3.0" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", + "@types/node": "catalog:default", + "tsdown": "0.16.3", + "typescript": "catalog:default", + "vitest": "catalog:default" + }, + "engines": { + "node": ">=22.0.0" + }, + "volta": { + "extends": "../../package.json" + }, + "workers-sdk": { + "prerelease": true + } +} diff --git a/packages/pages-functions/scripts/deps.ts b/packages/pages-functions/scripts/deps.ts new file mode 100644 index 0000000000..5d97997542 --- /dev/null +++ b/packages/pages-functions/scripts/deps.ts @@ -0,0 +1,18 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/pages-functions. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // esbuild ships platform-specific native binaries and must be resolved + // from the consumer's node_modules at install time — not inlined into our + // bundle. It is invoked at runtime to compile user function code and to + // inspect exports via metafile. + "esbuild", + + // path-to-regexp is used at runtime by the Pages Worker template to match + // URL patterns. It must be resolvable from the consumer's node_modules so + // the template can import it when esbuild compiles the final Worker bundle. + "path-to-regexp", +]; diff --git a/packages/pages-functions/src/__tests__/build.test.ts b/packages/pages-functions/src/__tests__/build.test.ts new file mode 100644 index 0000000000..23e7ee8c81 --- /dev/null +++ b/packages/pages-functions/src/__tests__/build.test.ts @@ -0,0 +1,237 @@ +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeDir } from "@cloudflare/workers-utils"; +import { afterEach, assert, describe, it } from "vitest"; +import { buildPagesFunctions, PagesFunctionsNoRoutesError } from "../index"; + +describe("buildPagesFunctions", () => { + let testDir: string; + + afterEach(async () => { + if (testDir && existsSync(testDir)) { + await removeDir(testDir); + } + }); + + function setupFunctionsDir(files: Record): { + functionsDir: string; + outputDir: string; + } { + testDir = mkdtempSync(join(tmpdir(), "pages-functions-test-")); + const functionsDir = join(testDir, "functions"); + const outputDir = join(testDir, "dist"); + mkdirSync(functionsDir, { recursive: true }); + mkdirSync(outputDir, { recursive: true }); + + for (const [filePath, content] of Object.entries(files)) { + const fullPath = join(functionsDir, filePath); + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, content); + } + + return { functionsDir, outputDir }; + } + + it("should compile a simple functions directory", async ({ expect }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "hello.ts": ` + export const onRequest = () => new Response("hello"); + `, + }); + + const result = await buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + }); + + expect(result.entryPointPath).toContain("index.js"); + expect(result.bundleType).toBe("esm"); + expect(result.filepathRoutingConfig.routes.length).toBe(1); + expect(result.routesJSON.include.length).toBeGreaterThan(0); + expect(existsSync(result.entryPointPath)).toBe(true); + + // Check the bundle contains the handler + const bundle = readFileSync(result.entryPointPath, "utf-8"); + expect(bundle).toContain("hello"); + }); + + it("should compile multiple routes with method-specific handlers", async ({ + expect, + }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "api/users.ts": ` + export const onRequestGet = () => new Response("list users"); + export const onRequestPost = () => new Response("create user"); + `, + "api/users/[id].ts": ` + export const onRequestGet = () => new Response("get user"); + `, + }); + + const result = await buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + }); + + expect(result.filepathRoutingConfig.routes.length).toBe(3); + expect(existsSync(result.entryPointPath)).toBe(true); + }); + + it("should handle middleware", async ({ expect }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "_middleware.ts": ` + export const onRequest = async ({ next }) => { + const response = await next(); + response.headers.set("X-Custom", "true"); + return response; + }; + `, + "index.ts": ` + export const onRequest = () => new Response("home"); + `, + }); + + const result = await buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + }); + + // _middleware and index.ts share the same route path "/" and get + // combined into a single route entry with both middleware and module + expect(result.filepathRoutingConfig.routes.length).toBe(1); + expect(existsSync(result.entryPointPath)).toBe(true); + }); + + it("should throw PagesFunctionsNoRoutesError for empty directory", async ({ + expect, + }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "README.md": "# Not a function", + }); + + await expect( + buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + }) + ).rejects.toThrow(PagesFunctionsNoRoutesError); + }); + + it("should generate source maps when enabled", async ({ expect }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "index.ts": ` + export const onRequest = () => new Response("hello"); + `, + }); + + const result = await buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + sourcemap: true, + }); + + assert(result.sourceMapPath); + expect(existsSync(result.sourceMapPath)).toBe(true); + }); + + it("should include dynamic route parameters", async ({ expect }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "users/[id].ts": ` + export const onRequest = ({ params }) => new Response(params.id); + `, + "posts/[[path]].ts": ` + export const onRequest = ({ params }) => new Response(params.path); + `, + }); + + const result = await buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + }); + + const routes = result.filepathRoutingConfig.routes; + expect(routes.some((r) => r.routePath.includes(":id"))).toBe(true); + expect(routes.some((r) => r.routePath.includes(":path*"))).toBe(true); + }); + + it("should support metafile output", async ({ expect }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "index.ts": ` + export const onRequest = () => new Response("hello"); + `, + }); + + const result = await buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + metafile: true, + }); + + assert(result.metafile); + expect(result.metafile.inputs).toBeDefined(); + expect(result.metafile.outputs).toBeDefined(); + }); + + it("should write resolved non-JS imports as external modules", async ({ + expect, + }) => { + const { functionsDir, outputDir } = setupFunctionsDir({ + "index.ts": "", + "module.wasm": "wasm contents", + }); + const absoluteTextPath = join(testDir, "absolute.txt"); + writeFileSync(absoluteTextPath, "text contents"); + + const packageDir = join(testDir, "node_modules", "test-pages-module"); + mkdirSync(packageDir, { recursive: true }); + writeFileSync( + join(packageDir, "package.json"), + JSON.stringify({ + name: "test-pages-module", + exports: { "./data.bin": "./data.bin" }, + }) + ); + writeFileSync(join(packageDir, "data.bin"), "binary contents"); + + writeFileSync( + join(functionsDir, "index.ts"), + `import wasm from "./module.wasm"; + import text from ${JSON.stringify(absoluteTextPath)}; + import data from "test-pages-module/data.bin"; + export const onRequest = () => new Response(String(wasm) + text + String(data));` + ); + + const result = await buildPagesFunctions({ + functionsDirectory: functionsDir, + outputDirectory: outputDir, + }); + + expect(result.modules).toHaveLength(3); + expect(result.modules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: expect.stringMatching(/^\.\/[a-f0-9]{8}-module\.wasm$/), + type: "compiled-wasm", + }), + expect.objectContaining({ + name: expect.stringMatching(/^\.\/[a-f0-9]{8}-absolute\.txt$/), + type: "text", + }), + expect.objectContaining({ + name: expect.stringMatching(/^\.\/[a-f0-9]{8}-data\.bin$/), + type: "buffer", + }), + ]) + ); + + const bundle = readFileSync(result.entryPointPath, "utf-8"); + for (const module of result.modules) { + expect(bundle).toContain(`from "${module.name}"`); + expect(readFileSync(join(outputDir, module.name), "utf-8")).toBe( + Buffer.from(module.content).toString() + ); + } + }); +}); diff --git a/packages/pages-functions/src/__tests__/cli.test.ts b/packages/pages-functions/src/__tests__/cli.test.ts new file mode 100644 index 0000000000..b1f4f38813 --- /dev/null +++ b/packages/pages-functions/src/__tests__/cli.test.ts @@ -0,0 +1,98 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { removeDir } from "@cloudflare/workers-utils"; +import { afterEach, describe, it } from "vitest"; + +describe("pages-functions CLI", () => { + let testDir: string; + + afterEach(async () => { + if (testDir && existsSync(testDir)) { + await removeDir(testDir); + } + }); + + function setupTestDir(): { + functionsDir: string; + outputDir: string; + } { + testDir = mkdtempSync(join(tmpdir(), "pages-functions-cli-test-")); + const functionsDir = join(testDir, "functions"); + const outputDir = join(testDir, "dist"); + mkdirSync(functionsDir, { recursive: true }); + return { functionsDir, outputDir }; + } + + function runCli(...args: string[]) { + execFileSync( + process.execPath, + [resolve(import.meta.dirname, "../../dist/cli.mjs"), ...args], + { stdio: "pipe" } + ); + } + + it("writes imported modules to the output directory", ({ expect }) => { + const { functionsDir, outputDir } = setupTestDir(); + writeFileSync( + join(functionsDir, "index.ts"), + `import wasm from "./module.wasm"; + export const onRequest = () => new Response(wasm);` + ); + writeFileSync(join(functionsDir, "module.wasm"), "wasm contents"); + + runCli("build", functionsDir, "--outdir", outputDir); + + const bundle = readFileSync(join(outputDir, "index.js"), "utf-8"); + const moduleName = bundle.match( + /from "(\.\/[a-f0-9]{8}-module\.wasm)"/ + )?.[1]; + expect(moduleName).toBeDefined(); + expect(readFileSync(join(outputDir, moduleName ?? ""), "utf-8")).toBe( + "wasm contents" + ); + }); + + it("does not write _routes.json by default", ({ expect }) => { + const { functionsDir, outputDir } = setupTestDir(); + writeFileSync( + join(functionsDir, "index.ts"), + `export const onRequest = () => new Response("ok");` + ); + + runCli("build", functionsDir, "--outdir", outputDir); + + expect(existsSync(join(outputDir, "_routes.json"))).toBe(false); + }); + + it("writes _routes.json when --routes-output is passed", ({ expect }) => { + const { functionsDir, outputDir } = setupTestDir(); + writeFileSync( + join(functionsDir, "hello.ts"), + `export const onRequest = () => new Response("hello");` + ); + const routesPath = join(outputDir, "_routes.json"); + + runCli( + "build", + functionsDir, + "--outdir", + outputDir, + "--routes-output", + routesPath + ); + + expect(existsSync(routesPath)).toBe(true); + const routesJSON = JSON.parse(readFileSync(routesPath, "utf-8")); + expect(routesJSON.version).toBeDefined(); + expect(routesJSON.include).toEqual(expect.arrayContaining(["/hello"])); + expect(routesJSON.exclude).toEqual(expect.arrayContaining([])); + }); +}); diff --git a/packages/wrangler/src/__tests__/pages/filepath-routing.test.ts b/packages/pages-functions/src/__tests__/filepath-routing.test.ts similarity index 88% rename from packages/wrangler/src/__tests__/pages/filepath-routing.test.ts rename to packages/pages-functions/src/__tests__/filepath-routing.test.ts index 0c6f75f314..6ca98924a3 100644 --- a/packages/wrangler/src/__tests__/pages/filepath-routing.test.ts +++ b/packages/pages-functions/src/__tests__/filepath-routing.test.ts @@ -1,13 +1,10 @@ import { mkdirSync, writeFileSync } from "node:fs"; +import { toUrlPath } from "@cloudflare/workers-utils"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { describe, it, test } from "vitest"; -import { - compareRoutes, - generateConfigFromFileTree, -} from "../../pages/functions/filepath-routing"; -import { toUrlPath } from "../../paths"; -import type { HTTPMethod, RouteConfig } from "../../pages/functions/routes"; -import type { UrlPath } from "../../paths"; +import { compareRoutes, generateConfigFromFileTree } from "../index"; +import type { HTTPMethod, RouteConfig } from "../index"; +import type { UrlPath } from "@cloudflare/workers-utils"; describe("filepath-routing", () => { describe("compareRoutes()", () => { @@ -257,7 +254,7 @@ describe("filepath-routing", () => { baseURL: "/base" as UrlPath, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid Pages function route parameter - "[hyphen-not-allowed]". Parameter names must only contain alphanumeric and underscore characters.]` + `[PagesFunctionsError: Invalid Pages function route parameter - "[hyphen-not-allowed]". Parameter names must only contain alphanumeric and underscore characters.]` ); }); @@ -275,16 +272,23 @@ describe("filepath-routing", () => { baseURL: "/base" as UrlPath, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Invalid Pages function route parameter - "[[hyphen-not-allowed]]". Parameters names must only contain alphanumeric and underscore characters.]` + `[PagesFunctionsError: Invalid Pages function route parameter - "[[hyphen-not-allowed]]". Parameters names must only contain alphanumeric and underscore characters.]` ); }); }); }); -function routeConfig(routePath: string, method?: string): RouteConfig { +/** + * Create a minimal route config for route ordering tests. + * + * @param routePath - URL path represented by the route. + * @param method - Optional HTTP method handled by the route. + * @returns A route config suitable for `compareRoutes`. + */ +function routeConfig(routePath: string, method?: HTTPMethod): RouteConfig { return { routePath: toUrlPath(routePath), mountPath: toUrlPath("/"), - method: method as HTTPMethod, + method, }; } diff --git a/packages/pages-functions/src/__tests__/identifiers.test.ts b/packages/pages-functions/src/__tests__/identifiers.test.ts new file mode 100644 index 0000000000..dff2a1d82c --- /dev/null +++ b/packages/pages-functions/src/__tests__/identifiers.test.ts @@ -0,0 +1,17 @@ +import { describe, it } from "vitest"; +import { isValidIdentifier, normalizeIdentifier } from "../index"; + +describe("identifiers", () => { + it("should validate identifiers correctly", ({ expect }) => { + expect(isValidIdentifier("foo")).toBe(true); + expect(isValidIdentifier("_bar")).toBe(true); + expect(isValidIdentifier("$baz")).toBe(true); + expect(isValidIdentifier("class")).toBe(false); + expect(isValidIdentifier("123")).toBe(false); + }); + + it("should normalize identifiers", ({ expect }) => { + expect(normalizeIdentifier("hello-world")).toBe("hello_world"); + expect(normalizeIdentifier("123abc")).toBe("_23abc"); + }); +}); diff --git a/packages/pages-functions/src/__tests__/paths.test.ts b/packages/pages-functions/src/__tests__/paths.test.ts new file mode 100644 index 0000000000..fbc17a0bca --- /dev/null +++ b/packages/pages-functions/src/__tests__/paths.test.ts @@ -0,0 +1,9 @@ +import { toUrlPath } from "@cloudflare/workers-utils"; +import { describe, it } from "vitest"; + +describe("toUrlPath", () => { + it("should convert backslashes to forward slashes", ({ expect }) => { + expect(toUrlPath("foo\\bar")).toBe("foo/bar"); + expect(toUrlPath("foo/bar")).toBe("foo/bar"); + }); +}); diff --git a/packages/wrangler/src/__tests__/pages/routes-consolidation.test.ts b/packages/pages-functions/src/__tests__/routes-consolidation.test.ts similarity index 98% rename from packages/wrangler/src/__tests__/pages/routes-consolidation.test.ts rename to packages/pages-functions/src/__tests__/routes-consolidation.test.ts index b70f269d88..cbfc593f38 100644 --- a/packages/wrangler/src/__tests__/pages/routes-consolidation.test.ts +++ b/packages/pages-functions/src/__tests__/routes-consolidation.test.ts @@ -1,11 +1,12 @@ import { describe, it } from "vitest"; import { consolidateRoutes, + MAX_FUNCTIONS_ROUTES_RULE_LENGTH, shortenRoute, -} from "../../pages/functions/routes-consolidation"; +} from "../index"; describe("route-consolidation", () => { - const maxRuleLength = 100; // from constants.MAX_FUNCTIONS_ROUTES_RULE_LENGTH + const maxRuleLength = MAX_FUNCTIONS_ROUTES_RULE_LENGTH; describe("consolidateRoutes()", () => { it("should consolidate redundant routes", ({ expect }) => { expect(consolidateRoutes(["/api/foo", "/api/*"])).toEqual(["/api/*"]); diff --git a/packages/wrangler/src/__tests__/pages/routes-module.test.ts b/packages/pages-functions/src/__tests__/routes-module.test.ts similarity index 55% rename from packages/wrangler/src/__tests__/pages/routes-module.test.ts rename to packages/pages-functions/src/__tests__/routes-module.test.ts index ed7e23869f..efda649271 100644 --- a/packages/wrangler/src/__tests__/pages/routes-module.test.ts +++ b/packages/pages-functions/src/__tests__/routes-module.test.ts @@ -1,8 +1,12 @@ -import { UserError } from "@cloudflare/workers-utils"; +import { toUrlPath } from "@cloudflare/workers-utils"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import { describe, it } from "vitest"; -import { writeRoutesModule } from "../../pages/functions/routes"; -import { toUrlPath } from "../../paths"; +import { + PagesFunctionsError, + PagesFunctionsErrorCode, + writeRoutesModule, +} from "../index"; + describe("routes module", () => { runInTempDir(); @@ -26,6 +30,29 @@ describe("routes module", () => { ).resolves.toBeDefined(); }); + it("rejects invalid module identifiers", async ({ expect }) => { + await expect( + writeRoutesModule({ + config: { + routes: [ + { + routePath: toUrlPath("/"), + mountPath: toUrlPath("/"), + module: "hello.js:not-valid", + }, + ], + }, + srcDir: "functions", + outfile: "_routes.js", + }) + ).rejects.toEqual( + new PagesFunctionsError( + 'Invalid module identifier "not-valid"', + PagesFunctionsErrorCode.INVALID_MODULE_IDENTIFIER + ) + ); + }); + it.skipIf(process.platform !== "win32")( "rejects module paths on a different drive", async ({ expect }) => { @@ -46,10 +73,11 @@ describe("routes module", () => { srcDir: String.raw`C:\project`, outfile: "_routes.js", }) - ).rejects.toThrow( - new UserError(`Invalid module path "${modulePath}"`, { - telemetryMessage: "pages functions invalid module path", - }) + ).rejects.toEqual( + new PagesFunctionsError( + `Invalid module path "${modulePath}"`, + PagesFunctionsErrorCode.INVALID_MODULE_PATH + ) ); } ); diff --git a/packages/wrangler/src/__tests__/pages/routes-transformation.test.ts b/packages/pages-functions/src/__tests__/routes-transformation.test.ts similarity index 92% rename from packages/wrangler/src/__tests__/pages/routes-transformation.test.ts rename to packages/pages-functions/src/__tests__/routes-transformation.test.ts index fe643d3b11..46b7fe68fc 100644 --- a/packages/wrangler/src/__tests__/pages/routes-transformation.test.ts +++ b/packages/pages-functions/src/__tests__/routes-transformation.test.ts @@ -1,16 +1,15 @@ +import { toUrlPath } from "@cloudflare/workers-utils"; import { describe, it, test } from "vitest"; import { - MAX_FUNCTIONS_ROUTES_RULES, - ROUTES_SPEC_DESCRIPTION, - ROUTES_SPEC_VERSION, -} from "../../pages/constants"; -import { - compareRoutes, + compareRoutesSimplified as compareRoutes, convertRoutesToGlobPatterns, convertRoutesToRoutesJSONSpec, + MAX_FUNCTIONS_ROUTES_RULES, optimizeRoutesJSONSpec, -} from "../../pages/functions/routes-transformation"; -import { toUrlPath } from "../../paths"; + ROUTES_SPEC_VERSION, +} from "../index"; + +const ROUTES_SPEC_DESCRIPTION = "Generated for a test"; // TODO: make a convenience function for creating a list // of `convertRoutesToGlobPatterns` inputs from a string array @@ -157,16 +156,19 @@ describe("route-paths-to-glob-patterns", () => { describe("convertRoutesToRoutesJSONSpec()", () => { it("should convert and consolidate routes into JSONSpec", ({ expect }) => { expect( - convertRoutesToRoutesJSONSpec([ - { routePath: toUrlPath("/api/foo/bar") }, - { routePath: toUrlPath("/foo/bar") }, - { routePath: toUrlPath("/foo/:bar") }, - { routePath: toUrlPath("/api/foo/bar") }, - { - routePath: toUrlPath("/middleware"), - middleware: "./some-middleware.ts", - }, - ]) + convertRoutesToRoutesJSONSpec( + [ + { routePath: toUrlPath("/api/foo/bar") }, + { routePath: toUrlPath("/foo/bar") }, + { routePath: toUrlPath("/foo/:bar") }, + { routePath: toUrlPath("/api/foo/bar") }, + { + routePath: toUrlPath("/middleware"), + middleware: "./some-middleware.ts", + }, + ], + ROUTES_SPEC_DESCRIPTION + ) ).toEqual({ version: ROUTES_SPEC_VERSION, description: ROUTES_SPEC_DESCRIPTION, @@ -180,7 +182,9 @@ describe("route-paths-to-glob-patterns", () => { for (let i = 0; i < MAX_FUNCTIONS_ROUTES_RULES + 1; i++) { routes.push({ routePath: toUrlPath(`/api/foo-${i}`) }); } - expect(convertRoutesToRoutesJSONSpec(routes)).toEqual({ + expect( + convertRoutesToRoutesJSONSpec(routes, ROUTES_SPEC_DESCRIPTION) + ).toEqual({ version: ROUTES_SPEC_VERSION, description: ROUTES_SPEC_DESCRIPTION, include: ["/*"], diff --git a/packages/pages-functions/src/__tests__/routes-validation.test.ts b/packages/pages-functions/src/__tests__/routes-validation.test.ts new file mode 100644 index 0000000000..a54fa293f8 --- /dev/null +++ b/packages/pages-functions/src/__tests__/routes-validation.test.ts @@ -0,0 +1,425 @@ +import { describe, it } from "vitest"; +import { + getRoutesValidationErrorMessage, + isRoutesJSONSpec, + MAX_FUNCTIONS_ROUTES_RULE_LENGTH, + MAX_FUNCTIONS_ROUTES_RULES, + ROUTES_SPEC_VERSION, + RoutesValidationError, + validateRoutes, +} from "../index"; +import type { RoutesJSONSpec } from "../index"; + +describe("routes-validation", () => { + describe("isRoutesJSONSpec", () => { + it("should return true if the given routes are in a valid RoutesJSONSpec format", ({ + expect, + }) => { + const routes = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: [], + exclude: [], + }; + const routesWithoutDescription = { + version: ROUTES_SPEC_VERSION, + include: [], + exclude: [], + }; + + expect(isRoutesJSONSpec(routes)).toBeTruthy(); + expect(isRoutesJSONSpec(routesWithoutDescription)).toBeTruthy(); + }); + + it("should return false otherwise", ({ expect }) => { + const routesWithMissingVersion = { + include: [], + exclude: [], + }; + const routesWithIncorrectVersionNumber = { + version: 1000, + include: [], + exclude: [], + }; + const routesWithIncorrectVersionType = { + version: "1000", + include: [], + exclude: [], + }; + const routesWithMissingInclude = { + version: ROUTES_SPEC_VERSION, + exclude: [], + }; + const routesWithMissingExclude = { + version: ROUTES_SPEC_VERSION, + include: [], + }; + const routesWithIncorrectIncludeType = { + version: ROUTES_SPEC_VERSION, + include: "[]", + exclude: [], + }; + const routesWithIncorrectExcludeType = { + version: ROUTES_SPEC_VERSION, + include: [], + exclude: { route: "/hello" }, + }; + + expect(isRoutesJSONSpec(null)).toBeFalsy(); + expect(isRoutesJSONSpec({})).toBeFalsy(); + expect(isRoutesJSONSpec([])).toBeFalsy(); + expect(isRoutesJSONSpec(routesWithMissingVersion)).toBeFalsy(); + expect(isRoutesJSONSpec(routesWithIncorrectVersionNumber)).toBeFalsy(); + expect(isRoutesJSONSpec(routesWithIncorrectVersionType)).toBeFalsy(); + expect(isRoutesJSONSpec(routesWithMissingInclude)).toBeFalsy(); + expect(isRoutesJSONSpec(routesWithMissingExclude)).toBeFalsy(); + expect(isRoutesJSONSpec(routesWithIncorrectIncludeType)).toBeFalsy(); + expect(isRoutesJSONSpec(routesWithIncorrectExcludeType)).toBeFalsy(); + }); + }); + + describe("validateRoutes", () => { + const testRoutesPath = "/public"; + + /** + * Generate unique route rules for testing aggregate limits. + * + * @param count - Number of rules to generate. + * @returns The generated route rules. + */ + function generateUniqueRoutingRules(count: number): string[] { + let counter = 0; + return Array(count) + .fill("/abc") + .map((route) => `${route}${counter++}`); + } + + /** + * Generate a routing rule with the requested number of path characters. + * + * @param charCount - Number of characters after the leading slash. + * @returns The generated routing rule. + */ + function generateRoutingRule(charCount: number): string { + return `/${Array(charCount).fill("a").join("")}`; + } + + it("should return if the given routes are valid", ({ expect }) => { + const routes: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: ["/*"], + exclude: [], + }; + const routesWithoutDescription: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + include: ["/hello"], + exclude: [], + }; + + expect(() => validateRoutes(routes, testRoutesPath)).not.toThrow(); + expect(() => + validateRoutes(routesWithoutDescription, testRoutesPath) + ).not.toThrow(); + }); + + it("should throw an error if the routes are not a valid RoutesJSONSpec object", ({ + expect, + }) => { + // @ts-expect-error -- Intentionally testing invalid types + const routesWithoutVersion: RoutesJSONSpec = { + include: ["/*"], + exclude: [], + }; + // @ts-expect-error -- Intentionally testing invalid types + const routesWithoutInclude: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + exclude: [], + }; + + expect(() => + // wrap the code in a function, otherwise the error will not be caught + // and the assertion will fail + validateRoutes(routesWithoutVersion, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithoutVersion, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.INVALID_JSON_SPEC, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithoutInclude, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithoutInclude, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.INVALID_JSON_SPEC, + testRoutesPath + ) + ); + }); + + it("should throw an error if there are no include routing rules", ({ + expect, + }) => { + const routesWithoutIncludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: [], + exclude: [], + }; + + expect(() => + validateRoutes(routesWithoutIncludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithoutIncludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.NO_INCLUDE_RULES, + testRoutesPath + ) + ); + }); + + it("should throw an error if there are more than MAX_FUNCTIONS_ROUTES_RULES include and exclude routing rules combined", ({ + expect, + }) => { + const routesWithMaxIncludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: generateUniqueRoutingRules(MAX_FUNCTIONS_ROUTES_RULES + 1), + exclude: [], + }; + const routesWithMaxExcludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: ["/hello"], + exclude: generateUniqueRoutingRules(MAX_FUNCTIONS_ROUTES_RULES + 1), + }; + const routesWithMaxIncludeExcludeRulesCombined: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: generateUniqueRoutingRules( + Math.floor(MAX_FUNCTIONS_ROUTES_RULES / 2) + 1 + ), + exclude: generateUniqueRoutingRules( + Math.floor(MAX_FUNCTIONS_ROUTES_RULES / 2) + ), + }; + + expect(() => + validateRoutes(routesWithMaxIncludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithMaxIncludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.TOO_MANY_RULES, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithMaxExcludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithMaxExcludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.TOO_MANY_RULES, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithMaxIncludeExcludeRulesCombined, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithMaxIncludeExcludeRulesCombined, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.TOO_MANY_RULES, + testRoutesPath + ) + ); + }); + + it("should throw an error if any include or exclude routing rule is more than MAX_FUNCTIONS_ROUTES_RULE_LENGTH chars long", ({ + expect, + }) => { + const routesWithMaxCharLengthIncludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], + exclude: [], + }; + const routesWithMaxCharLengthExcludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: ["/*"], + exclude: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], + }; + const routesWithMaxCharLengthRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], + exclude: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], + }; + + expect(() => + validateRoutes(routesWithMaxCharLengthIncludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithMaxCharLengthIncludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.RULE_TOO_LONG, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithMaxCharLengthExcludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithMaxCharLengthExcludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.RULE_TOO_LONG, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithMaxCharLengthRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithMaxCharLengthRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.RULE_TOO_LONG, + testRoutesPath + ) + ); + }); + + it("should throw an error if any include or exclude routing rule does not start with a `/`", ({ + expect, + }) => { + const routesWithInvalidIncludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: ["hello"], + exclude: [], + }; + const routesWithInvalidExcludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: ["/*"], + exclude: ["hello"], + }; + const routesWithInvalidRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + description: "Test routes Object", + include: ["hello"], + exclude: ["goodbye"], + }; + + expect(() => + validateRoutes(routesWithInvalidIncludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithInvalidIncludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.INVALID_RULES, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithInvalidExcludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithInvalidExcludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.INVALID_RULES, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithInvalidRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithInvalidRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.INVALID_RULES, + testRoutesPath + ) + ); + }); + + it("should throw an error if there are overlapping include rules or overlapping exclude rules", ({ + expect, + }) => { + const routesWithOverlappingIncludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + include: [ + "/greeting/hello", + "/api/time", + "/date", + "/greeting/*", + "/greeting/goodbye", + "/api/*", + ], + exclude: [], + }; + const routesWithOverlappingExcludeRules: RoutesJSONSpec = { + version: ROUTES_SPEC_VERSION, + include: ["/hello"], + exclude: [ + "/greeting/hello", + "/api/time", + "/date", + "/*", + "/greeting/*", + "/greeting/goodbye", + "/api/*", + ], + }; + + expect(() => + validateRoutes(routesWithOverlappingIncludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithOverlappingIncludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.OVERLAPPING_RULES, + testRoutesPath + ) + ); + + expect(() => + validateRoutes(routesWithOverlappingExcludeRules, testRoutesPath) + ).toThrow(Error); + expect(() => + validateRoutes(routesWithOverlappingExcludeRules, testRoutesPath) + ).toThrow( + getRoutesValidationErrorMessage( + RoutesValidationError.OVERLAPPING_RULES, + testRoutesPath + ) + ); + }); + }); +}); diff --git a/packages/pages-functions/src/build.ts b/packages/pages-functions/src/build.ts new file mode 100644 index 0000000000..1019afd098 --- /dev/null +++ b/packages/pages-functions/src/build.ts @@ -0,0 +1,493 @@ +import crypto from "node:crypto"; +import { access, cp, lstat, mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { removeDir, toUrlPath } from "@cloudflare/workers-utils"; +import * as esbuild from "esbuild"; +import { + generateConfigFromFileTree, + writeRoutesModule, + convertRoutesToRoutesJSONSpec, +} from "./routing"; +import type { Config, RouteConfig, RoutesJSONSpec } from "./routing"; +import type { UrlPath } from "@cloudflare/workers-utils"; + +/** + * Options for compiling a Pages Functions directory into a Worker. + */ +export type BuildPagesFunctionsOptions = { + /** Path to the functions directory containing route handler files. */ + functionsDirectory: string; + + /** Directory where the compiled Worker and its modules will be written. */ + outputDirectory: string; + + /** + * Directory to output static assets referenced via `assets:` imports. + * Defaults to `outputDirectory` if not specified. + */ + assetsOutputDirectory?: string; + + /** + * The service binding name to fall back to when no route matches. + * Defaults to `"ASSETS"`. + */ + fallbackService?: string; + + /** Whether to minify the output. Defaults to `false`. */ + minify?: boolean; + + /** Whether to generate source maps. Defaults to `false`. */ + sourcemap?: boolean; + + /** Module specifiers to exclude from the bundle. */ + external?: string[]; + + /** + * Optional description to include in the generated `_routes.json`. + * If not provided, no description is included. + */ + routesDescription?: string; + + /** Whether to output esbuild metafile. Defaults to `false`. */ + metafile?: boolean; +}; + +/** + * A collected non-JS module (WASM, text, or binary data) that should be + * uploaded alongside the Worker entrypoint. + */ +export type CollectedModule = { + /** The module name as referenced in the Worker bundle. */ + name: string; + + /** The module file content. */ + content: Uint8Array; + + /** The module type for the Workers upload API. */ + type: "compiled-wasm" | "text" | "buffer"; +}; + +/** + * The result of compiling a Pages Functions directory. + */ +export type BuildPagesFunctionsResult = { + /** Absolute path to the compiled Worker entrypoint. */ + entryPointPath: string; + + /** The bundle format — always `"esm"` for Pages Functions. */ + bundleType: "esm"; + + /** Non-JS modules (WASM, text, binary) collected during bundling. */ + modules: CollectedModule[]; + + /** esbuild dependency graph for the bundle. */ + dependencies: esbuild.Metafile["outputs"][string]["inputs"]; + + /** Path to the source map file, if source maps were enabled. */ + sourceMapPath?: string; + + /** The generated `_routes.json` routing spec. */ + routesJSON: RoutesJSONSpec; + + /** The filepath routing configuration (routes + baseURL). */ + filepathRoutingConfig: { + routes: RouteConfig[]; + baseURL: UrlPath; + }; + + /** The esbuild metafile, if `metafile` was enabled. */ + metafile?: esbuild.Metafile; +}; + +/** + * Compile a Pages Functions directory into a Cloudflare Worker bundle. + * + * This function: + * 1. Scans the functions directory for route handler exports + * 2. Generates a routes module mapping URL patterns to handlers + * 3. Bundles the Pages Worker runtime template with the routes + * 4. Collects non-JS modules (WASM, text, binary) + * 5. Writes the output to the specified directory + * + * @param options - Build configuration + * @returns The build result including entrypoint path, modules, and routing metadata + * @throws When no routes are found in the functions directory + * @throws When the functions directory does not exist + */ +export async function buildPagesFunctions( + options: BuildPagesFunctionsOptions +): Promise { + const { + functionsDirectory, + outputDirectory, + assetsOutputDirectory, + fallbackService = "ASSETS", + minify = false, + sourcemap = false, + external, + routesDescription, + metafile = false, + } = options; + + const absoluteFunctionsDirectory = resolve(functionsDirectory); + const absoluteOutputDirectory = resolve(outputDirectory); + const baseURL = toUrlPath("/"); + + const { config, routesJSON } = await discoverRoutes( + functionsDirectory, + absoluteFunctionsDirectory, + baseURL, + routesDescription + ); + + const { routesModulePath, tmpDir } = await writeTemporaryRoutesModule( + config, + absoluteFunctionsDirectory + ); + + try { + // Resolve the template path. + // The template is a raw .ts file that esbuild compiles at runtime. + // It is shipped in src/templates/ relative to the package root. + // When running from source (src/build.ts): import.meta.dirname is src/ -> ../src/templates/ + // When running from dist (dist/index.mjs): import.meta.dirname is dist/ -> ../src/templates/ + const templatePath = resolve( + import.meta.dirname, + "..", + "src", + "templates", + "pages-template-worker.ts" + ); + + const collectedModules: CollectedModule[] = []; + const outfile = join(absoluteOutputDirectory, "index.js"); + + const result = await esbuild.build({ + entryPoints: [templatePath], + outfile, + bundle: true, + format: "esm", + target: "es2024", + supported: { "import-source": true }, + loader: { ".js": "jsx", ".mjs": "jsx", ".cjs": "jsx" }, + conditions: ["workerd", "worker", "browser"], + inject: [routesModulePath], + define: { + __FALLBACK_SERVICE__: JSON.stringify(fallbackService), + }, + minify, + keepNames: true, + sourcemap, + metafile: true, + external, + plugins: [ + createModuleCollectorPlugin(collectedModules), + createAssetsPlugin(assetsOutputDirectory, absoluteOutputDirectory), + ], + }); + + await Promise.all( + collectedModules.map(async (module) => { + const modulePath = join(absoluteOutputDirectory, module.name); + await mkdir(dirname(modulePath), { recursive: true }); + await writeFile(modulePath, module.content); + }) + ); + + const { dependencies, sourceMapPath } = await extractBuildMetadata( + result, + outfile, + sourcemap + ); + + return { + entryPointPath: outfile, + bundleType: "esm", + modules: collectedModules, + dependencies, + sourceMapPath, + routesJSON, + filepathRoutingConfig: { + routes: config.routes ?? [], + baseURL, + }, + metafile: metafile ? result.metafile : undefined, + }; + } finally { + await removeDir(tmpDir); + } +} + +/** + * Error thrown when no routes are found in the functions directory. + */ +export class PagesFunctionsNoRoutesError extends Error { + constructor(message: string) { + super(message); + this.name = "PagesFunctionsNoRoutesError"; + } +} + +/** + * Scan the functions directory for route handler exports and generate the + * `_routes.json` spec from them. + * + * @param functionsDirectory - The raw (user-provided) functions directory path, used in error messages. + * @param absoluteFunctionsDirectory - The resolved absolute path to scan. + * @param baseURL - The base URL prefix for all routes. + * @param routesDescription - Optional description to embed in `_routes.json`. + * @returns The parsed route config and the generated `_routes.json` spec. + * @throws {PagesFunctionsNoRoutesError} When the directory contains no route handlers. + */ +async function discoverRoutes( + functionsDirectory: string, + absoluteFunctionsDirectory: string, + baseURL: UrlPath, + routesDescription: string | undefined +): Promise<{ config: Config; routesJSON: RoutesJSONSpec }> { + const config: Config = await generateConfigFromFileTree({ + baseDir: absoluteFunctionsDirectory, + baseURL, + }); + + if (!config.routes || config.routes.length === 0) { + throw new PagesFunctionsNoRoutesError( + `Failed to find any routes while compiling Functions in: ${functionsDirectory}` + ); + } + + const routesJSON = convertRoutesToRoutesJSONSpec( + config.routes, + routesDescription + ); + + return { config, routesJSON }; +} + +/** + * Write a temporary ES module that re-exports every route handler discovered + * by {@link discoverRoutes}. The module is injected into the esbuild bundle + * so that the Pages template worker can import the route table at runtime. + * + * @param config - The route configuration returned by {@link discoverRoutes}. + * @param absoluteFunctionsDirectory - Absolute path to the functions source directory. + * @returns An object containing the absolute path to the generated routes module + * and the temporary directory that should be cleaned up after the build. + */ +async function writeTemporaryRoutesModule( + config: Config, + absoluteFunctionsDirectory: string +): Promise<{ routesModulePath: string; tmpDir: string }> { + const tmpDir = await mkdtemp(join(tmpdir(), "pages-functions-")); + const routesModulePath = join(tmpDir, "functionsRoutes.mjs"); + + await writeRoutesModule({ + config, + srcDir: absoluteFunctionsDirectory, + outfile: routesModulePath, + }); + + return { routesModulePath, tmpDir }; +} + +/** + * Read a file, hash its contents, record it in `collectedModules`, and return + * an external esbuild resolution using the hashed module name. + * + * This is the shared implementation behind the WASM, text, and binary module + * loaders inside the module-collector plugin. + * + * @param filePath - Absolute path to the module file on disk. + * @param moduleType - The Workers upload type (`compiled-wasm`, `text`, or `buffer`). + * @param collectedModules - Mutable array to push the collected module into. + * @returns An esbuild `OnResolveResult` that preserves an import of the hashed module name. + */ +async function collectModule( + filePath: string, + moduleType: CollectedModule["type"], + collectedModules: CollectedModule[] +): Promise { + const { readFile } = await import("node:fs/promises"); + const content = await readFile(filePath); + const hash = crypto + .createHash("sha1") + .update(content) + .digest("hex") + .slice(0, 8); + const moduleName = `./${hash}-${basename(filePath)}`; + if (!collectedModules.some((module) => module.name === moduleName)) { + collectedModules.push({ + name: moduleName, + content: new Uint8Array(content), + type: moduleType, + }); + } + return { + path: moduleName, + external: true, + }; +} + +const moduleCollectorResolution = {}; + +/** + * Create an esbuild plugin that intercepts `.wasm`, text (`.txt`, `.html`, + * `.sql`), and binary (`.bin`) imports, hashes their contents, and records + * them as {@link CollectedModule} entries for later upload. + * + * @param collectedModules - Mutable array that the plugin appends to during the build. + * @returns An esbuild plugin. + */ +function createModuleCollectorPlugin( + collectedModules: CollectedModule[] +): esbuild.Plugin { + return { + name: "pages-functions-module-collector", + setup(build) { + for (const [filter, moduleType] of [ + [/\.wasm(\?module)?$/, "compiled-wasm"], + [/\.(txt|html|sql)$/, "text"], + [/\.bin$/, "buffer"], + ] as const) { + build.onResolve({ filter }, async (args) => { + if (args.pluginData === moduleCollectorResolution) { + return; + } + + const modulePath = args.path.replace(/\?module$/, ""); + const resolved = await build.resolve(modulePath, { + importer: args.importer, + kind: args.kind, + namespace: args.namespace, + pluginData: moduleCollectorResolution, + resolveDir: args.resolveDir, + with: args.with, + }); + + if (resolved.errors.length > 0 || resolved.external) { + return resolved; + } + + return collectModule(resolved.path, moduleType, collectedModules); + }); + } + }, + }; +} + +/** + * Create an esbuild plugin that handles `assets:` import specifiers. + * + * When a route handler writes `import "assets:./static"`, this plugin copies + * the referenced directory into a deterministic path under the output + * directory and replaces the import with an `onRequest` handler that proxies + * to the asset-serving binding. + * + * @param assetsOutputDirectory - Optional override for the directory where static assets are written. + * @param absoluteOutputDirectory - The default output directory (used when `assetsOutputDirectory` is not set). + * @returns An esbuild plugin. + */ +function createAssetsPlugin( + assetsOutputDirectory: string | undefined, + absoluteOutputDirectory: string +): esbuild.Plugin { + return { + name: "pages-functions-assets", + setup(pluginBuild) { + const identifiers = new Map(); + + pluginBuild.onResolve({ filter: /^assets:/ }, async (args) => { + const directory = resolve( + args.resolveDir, + args.path.slice("assets:".length) + ); + + const exists = await access(directory) + .then(() => true) + .catch(() => false); + + const isDirectory = exists && (await lstat(directory)).isDirectory(); + + if (!isDirectory) { + return { + errors: [ + { + text: `'${directory}' does not exist or is not a directory.`, + }, + ], + }; + } + + identifiers.set(directory, crypto.randomUUID()); + return { path: directory, namespace: "assets" }; + }); + + pluginBuild.onLoad( + { filter: /.*/, namespace: "assets" }, + async (args) => { + const identifier = identifiers.get(args.path); + const targetDir = assetsOutputDirectory || absoluteOutputDirectory; + + const staticAssetsOutputDirectory = join( + targetDir, + "cdn-cgi", + "pages-plugins", + identifier as string + ); + await cp(args.path, staticAssetsOutputDirectory, { + force: true, + recursive: true, + }); + + return { + contents: `export const onRequest = ({ request, env, functionPath }) => { + const url = new URL(request.url); + const relativePathname = \`/\${url.pathname.replace(functionPath, "") || ""}\`.replace(/^\\/\\//, '/'); + url.pathname = '/cdn-cgi/pages-plugins/${identifier}' + relativePathname; + request = new Request(url.toString(), request); + return env.ASSETS.fetch(request); + }`, + }; + } + ); + }, + }; +} + +/** + * Extract dependency metadata and source-map path from an esbuild build + * result. + * + * @param result - The esbuild build result (must have `metafile` enabled). + * @param outfile - The absolute path to the compiled Worker entrypoint. + * @param sourcemap - Whether source maps were requested. + * @returns The dependency inputs map and the optional source-map file path. + */ +async function extractBuildMetadata( + result: esbuild.BuildResult<{ metafile: true }>, + outfile: string, + sourcemap: boolean +): Promise<{ + dependencies: esbuild.Metafile["outputs"][string]["inputs"]; + sourceMapPath: string | undefined; +}> { + const metaOutputs = result.metafile?.outputs ?? {}; + const entryOutput = Object.values(metaOutputs).find( + (output) => output.entryPoint !== undefined + ); + const dependencies = entryOutput?.inputs ?? {}; + + let sourceMapPath: string | undefined; + if (sourcemap) { + const mapFile = `${outfile}.map`; + try { + await access(mapFile); + sourceMapPath = mapFile; + } catch { + // source map may be inline + } + } + + return { dependencies, sourceMapPath }; +} diff --git a/packages/pages-functions/src/cli.ts b/packages/pages-functions/src/cli.ts new file mode 100644 index 0000000000..45385dd3f9 --- /dev/null +++ b/packages/pages-functions/src/cli.ts @@ -0,0 +1,128 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { buildPagesFunctions, PagesFunctionsNoRoutesError } from "./build"; + +/** + * CLI entry point for `pages-functions build`. + * + * Parses command-line arguments and invokes the Pages Functions compiler. + */ +async function main() { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + outdir: { type: "string", default: "dist" }, + minify: { type: "boolean", default: false }, + sourcemap: { type: "boolean", default: false }, + "fallback-service": { type: "string", default: "ASSETS" }, + external: { type: "string", multiple: true }, + "routes-output": { type: "string" }, + help: { type: "boolean", short: "h", default: false }, + version: { type: "boolean", short: "v", default: false }, + }, + }); + + if (values.version) { + const { readFile } = await import("node:fs/promises"); + const pkgPath = resolve(import.meta.dirname, "..", "package.json"); + const pkg = JSON.parse(await readFile(pkgPath, "utf-8")); + console.log(pkg.version); + process.exit(0); + } + + if (values.help) { + printUsage(); + process.exit(0); + } + + const [command, functionsDirectory] = positionals; + + if (command !== "build") { + console.error( + command + ? `Unknown command: ${command}` + : "No command specified. Use 'pages-functions build'." + ); + printUsage(); + process.exit(1); + } + + const directory = functionsDirectory || "functions"; + + if (!existsSync(directory)) { + console.error( + `Functions directory not found: ${directory}\nPlease ensure the directory exists and contains your Pages Functions.` + ); + process.exit(1); + } + + const outdir = values.outdir; + + await mkdir(outdir, { recursive: true }); + + try { + const result = await buildPagesFunctions({ + functionsDirectory: directory, + outputDirectory: outdir, + minify: values.minify, + sourcemap: values.sourcemap, + fallbackService: values["fallback-service"], + external: values.external, + }); + + if (values["routes-output"]) { + const routesOutputPath = resolve(values["routes-output"]); + await mkdir(dirname(routesOutputPath), { recursive: true }); + await writeFile( + routesOutputPath, + JSON.stringify(result.routesJSON, null, 2) + ); + } + + console.log(`Compiled Worker successfully to ${outdir}/index.js`); + } catch (error) { + if (error instanceof PagesFunctionsNoRoutesError) { + console.error( + `No routes found in Functions directory: ${directory}\n` + + "Please ensure your functions export onRequest handlers." + ); + process.exit(156); + } + throw error; + } +} + +/** + * Print CLI usage information. + */ +function printUsage() { + console.log(` +Usage: pages-functions build [directory] [options] + +Compile a Pages Functions directory into a Cloudflare Worker bundle. + +This command only compiles your Functions. It does not deploy them or create +deployment configuration. Your deployment must configure the selected fallback +service binding (ASSETS by default), including when using assets: imports. + +Arguments: + directory Path to the functions directory (default: "functions") + +Options: + --outdir Output directory for the compiled Worker (default: "dist") + --minify Minify the output Worker script + --sourcemap Generate a source map + --fallback-service Fallback service binding name (default: "ASSETS") + --external Module specifiers to exclude from bundling (repeatable) + --routes-output Path to write the generated _routes.json + -h, --help Show this help message + -v, --version Show version number +`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/pages-functions/src/index.ts b/packages/pages-functions/src/index.ts new file mode 100644 index 0000000000..5ffef4d8ff --- /dev/null +++ b/packages/pages-functions/src/index.ts @@ -0,0 +1,37 @@ +export { buildPagesFunctions, PagesFunctionsNoRoutesError } from "./build"; +export type { + BuildPagesFunctionsOptions, + BuildPagesFunctionsResult, + CollectedModule, +} from "./build"; + +export { + generateConfigFromFileTree, + compareRoutes, + PagesFunctionsBuildError, + PagesFunctionsError, + PagesFunctionsErrorCode, + writeRoutesModule, + generateRoutesModuleSource, + convertRoutesToGlobPatterns, + convertRoutesToRoutesJSONSpec, + optimizeRoutesJSONSpec, + compareRoutesSimplified, + consolidateRoutes, + shortenRoute, + RoutesValidationError, + isRoutesJSONSpec, + validateRoutes, + getRoutesValidationErrorMessage, + MAX_FUNCTIONS_ROUTES_RULES, + MAX_FUNCTIONS_ROUTES_RULE_LENGTH, + ROUTES_SPEC_VERSION, + isValidIdentifier, + normalizeIdentifier, +} from "./routing"; +export type { + HTTPMethod, + Config, + RouteConfig, + RoutesJSONSpec, +} from "./routing"; diff --git a/packages/pages-functions/src/routing/constants.ts b/packages/pages-functions/src/routing/constants.ts new file mode 100644 index 0000000000..9772f1c1a9 --- /dev/null +++ b/packages/pages-functions/src/routing/constants.ts @@ -0,0 +1,8 @@ +/** Max number of rules in _routes.json */ +export const MAX_FUNCTIONS_ROUTES_RULES = 100; + +/** Max char length of each rule in _routes.json */ +export const MAX_FUNCTIONS_ROUTES_RULE_LENGTH = 100; + +/** The _routes.json spec version */ +export const ROUTES_SPEC_VERSION = 1; diff --git a/packages/pages-functions/src/routing/filepath-routing.ts b/packages/pages-functions/src/routing/filepath-routing.ts new file mode 100644 index 0000000000..4bfe16c9a8 --- /dev/null +++ b/packages/pages-functions/src/routing/filepath-routing.ts @@ -0,0 +1,327 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { toUrlPath } from "@cloudflare/workers-utils"; +import { build } from "esbuild"; +import type { HTTPMethod, RouteConfig } from "./routes"; +import type { UrlPath } from "@cloudflare/workers-utils"; + +/** + * Scan a functions directory and generate a routing configuration + * based on the file tree. + * + * Each `.mjs`, `.js`, `.ts`, `.tsx`, or `.jsx` file is inspected for + * `onRequest` / `onRequestGet` / `onRequestPost` / ... exports. + * File and directory names determine route paths; `[param]` becomes + * `:param` and `[[param]]` becomes `:param*`. + * + * @param options - The base directory and base URL for route discovery + * @returns An object containing the discovered `routes` array + */ +export async function generateConfigFromFileTree({ + baseDir, + baseURL, +}: { + baseDir: string; + baseURL: UrlPath; +}) { + let routeEntries: RouteConfig[] = []; + + if (!baseURL.startsWith("/")) { + baseURL = `/${baseURL}` as UrlPath; + } + + if (baseURL.endsWith("/")) { + baseURL = baseURL.slice(0, -1) as UrlPath; + } + + await forEachFile(baseDir, async (filepath) => { + const ext = path.extname(filepath); + if (/^\.(mjs|js|ts|tsx|jsx)$/.test(ext)) { + const buildResult = await build({ + metafile: true, + write: false, + bundle: false, + entryPoints: [path.resolve(filepath)], + }).catch((e) => { + throw new PagesFunctionsBuildError(e.message); + }); + const exportNames: string[] = []; + if (buildResult.metafile) { + for (const output of Object.values(buildResult.metafile.outputs)) { + exportNames.push(...output.exports); + } + } + for (const exportName of exportNames) { + const [match, method = ""] = (exportName.match( + /^onRequest(Get|Post|Put|Patch|Delete|Options|Head)?$/ + ) ?? []) as (string | undefined)[]; + + if (match) { + const basename = path.basename(filepath).slice(0, -ext.length); + + const isIndexFile = basename === "index"; + const isMiddlewareFile = + basename === "_middleware" || basename === "_middleware_"; + + let routePath = path + .relative(baseDir, filepath) + .slice(0, -ext.length); + let mountPath = path.dirname(routePath); + + if (isIndexFile || isMiddlewareFile) { + routePath = path.dirname(routePath); + } + + if (routePath === ".") { + routePath = ""; + } + if (mountPath === ".") { + mountPath = ""; + } + + routePath = `${baseURL}/${routePath}`; + mountPath = `${baseURL}/${mountPath}`; + + routePath = convertCatchallParams(routePath); + routePath = convertSimpleParams(routePath); + mountPath = convertCatchallParams(mountPath); + mountPath = convertSimpleParams(mountPath); + + // These are used as module specifiers so UrlPaths are okay to use even on Windows + const modulePath = toUrlPath(path.relative(baseDir, filepath)); + + const routeEntry: RouteConfig = { + routePath: toUrlPath(routePath), + mountPath: toUrlPath(mountPath), + method: method.toUpperCase() as HTTPMethod, + [isMiddlewareFile ? "middleware" : "module"]: [ + `${modulePath}:${exportName}`, + ], + }; + + routeEntries.push(routeEntry); + } + } + } + }); + // Combine together any routes (index routes) which contain both a module and a middleware + routeEntries = routeEntries.reduce( + (acc: typeof routeEntries, { routePath, ...rest }) => { + const existingRouteEntry = acc.find( + (routeEntry) => + routeEntry.routePath === routePath && + routeEntry.method === rest.method + ); + if (existingRouteEntry !== undefined) { + Object.assign(existingRouteEntry, rest); + } else { + acc.push({ routePath, ...rest }); + } + return acc; + }, + [] + ); + + routeEntries.sort((a, b) => compareRoutes(a, b)); + + return { + routes: routeEntries, + }; +} + +/** + * Ensure routes are produced in order of precedence so that + * more specific routes aren't occluded from matching due to + * less specific routes appearing first in the route list. + * + * @param a - First route to compare + * @param b - Second route to compare + * @returns A negative number if `a` is more specific, positive if `b` is, or 0 if equal + */ +export function compareRoutes( + { routePath: routePathA, method: methodA }: RouteConfig, + { routePath: routePathB, method: methodB }: RouteConfig +) { + function parseRoutePath(routePath: UrlPath): string[] { + return routePath.slice(1).split("/").filter(Boolean); + } + + const segmentsA = parseRoutePath(routePathA); + const segmentsB = parseRoutePath(routePathB); + + // sort routes with fewer segments after those with more segments + if (segmentsA.length !== segmentsB.length) { + return segmentsB.length - segmentsA.length; + } + + for (let i = 0; i < segmentsA.length; i++) { + const segA = segmentsA[i] ?? ""; + const segB = segmentsB[i] ?? ""; + const isWildcardA = segA.includes("*"); + const isWildcardB = segB.includes("*"); + const isParamA = segA.includes(":"); + const isParamB = segB.includes(":"); + + // sort wildcard segments after non-wildcard segments + if (isWildcardA && !isWildcardB) { + return 1; + } + if (!isWildcardA && isWildcardB) { + return -1; + } + + // sort dynamic param segments after non-param segments + if (isParamA && !isParamB) { + return 1; + } + if (!isParamA && isParamB) { + return -1; + } + } + + // sort routes that specify an HTTP before those that don't + if (methodA && !methodB) { + return -1; + } + if (!methodA && methodB) { + return 1; + } + + // all else equal, just sort the paths lexicographically + return routePathA.localeCompare(routePathB); +} + +async function forEachFile( + baseDir: string, + fn: (filepath: string) => T | Promise +) { + const searchPaths = [baseDir]; + const returnValues: T[] = []; + + while (isNotEmpty(searchPaths)) { + const cwd = searchPaths.shift(); + const dir = await fs.readdir(cwd, { withFileTypes: true }); + for (const entry of dir) { + const pathname = path.join(cwd, entry.name); + if (entry.isDirectory()) { + searchPaths.push(pathname); + } else if (entry.isFile()) { + returnValues.push(await fn(pathname)); + } + } + } + + return returnValues; +} + +interface NonEmptyArray extends Array { + shift(): T; +} +function isNotEmpty(array: T[]): array is NonEmptyArray { + return array.length > 0; +} + +/** + * See https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#named-parameters + */ +const validParamNameRegExp = /^[A-Za-z0-9_]+$/; + +/** + * Transform all [[id]] => :id* + * + * @param routePath - A route path potentially containing catch-all parameters + * @returns The route path with catch-all parameters converted + */ +function convertCatchallParams(routePath: string): string { + return routePath.replace(/\[\[([^\]]+)\]\]/g, (_, param) => { + if (validParamNameRegExp.test(param)) { + return `:${param}*`; + } else { + throw new PagesFunctionsError( + `Invalid Pages function route parameter - "[[${param}]]". Parameters names must only contain alphanumeric and underscore characters.`, + PagesFunctionsErrorCode.INVALID_CATCHALL_ROUTE_PARAMETER + ); + } + }); +} + +/** + * Transform all [id] => :id + * + * @param routePath - A route path potentially containing simple parameters + * @returns The route path with simple parameters converted + */ +function convertSimpleParams(routePath: string): string { + return routePath.replace(/\[([^\]]+)\]/g, (_, param) => { + if (validParamNameRegExp.test(param)) { + return `:${param}`; + } else { + throw new PagesFunctionsError( + `Invalid Pages function route parameter - "[${param}]". Parameter names must only contain alphanumeric and underscore characters.`, + PagesFunctionsErrorCode.INVALID_ROUTE_PARAMETER + ); + } + }); +} + +/** + * Error codes for classifiable failures from the Pages Functions package. + * + * Consumers (e.g. Wrangler) can inspect `PagesFunctionsError.code` to map + * each failure to the appropriate error class and telemetry label. + */ +export const PagesFunctionsErrorCode = { + /** An esbuild build of a function file failed while inspecting exports. */ + ROUTE_BUILD_FAILED: "ROUTE_BUILD_FAILED", + /** A catch-all route parameter `[[param]]` has an invalid name. */ + INVALID_CATCHALL_ROUTE_PARAMETER: "INVALID_CATCHALL_ROUTE_PARAMETER", + /** A simple route parameter `[param]` has an invalid name. */ + INVALID_ROUTE_PARAMETER: "INVALID_ROUTE_PARAMETER", + /** A module path resolved outside the project root (path-traversal guard). */ + INVALID_MODULE_PATH: "INVALID_MODULE_PATH", + /** A module identifier is not a valid JavaScript identifier (injection guard). */ + INVALID_MODULE_IDENTIFIER: "INVALID_MODULE_IDENTIFIER", +} as const; + +/** + * Union of all classifiable error codes from the Pages Functions package. + */ +export type PagesFunctionsErrorCode = + (typeof PagesFunctionsErrorCode)[keyof typeof PagesFunctionsErrorCode]; + +/** + * Typed error thrown by the Pages Functions package. + * + * Carries a `code` from {@link PagesFunctionsErrorCode} so callers can + * distinguish different failure modes without parsing the message string. + */ +export class PagesFunctionsError extends Error { + /** The classifiable error code for this failure. */ + readonly code: PagesFunctionsErrorCode; + + /** + * @param message - Human-readable error description + * @param code - The classifiable error code + */ + constructor(message: string, code: PagesFunctionsErrorCode) { + super(message); + this.name = "PagesFunctionsError"; + this.code = code; + } +} + +/** + * Error thrown when building a Pages function file to inspect its exports fails. + * + * A convenience subclass of {@link PagesFunctionsError} that sets the error + * code to {@link PagesFunctionsErrorCode.ROUTE_BUILD_FAILED} automatically. + */ +export class PagesFunctionsBuildError extends PagesFunctionsError { + /** + * @param message - The esbuild error message + */ + constructor(message: string) { + super(message, PagesFunctionsErrorCode.ROUTE_BUILD_FAILED); + this.name = "PagesFunctionsBuildError"; + } +} diff --git a/packages/pages-functions/src/routing/identifiers.ts b/packages/pages-functions/src/routing/identifiers.ts new file mode 100644 index 0000000000..fec760fbd9 --- /dev/null +++ b/packages/pages-functions/src/routing/identifiers.ts @@ -0,0 +1,88 @@ +const RESERVED_KEYWORDS = [ + "do", + "if", + "in", + "for", + "let", + "new", + "try", + "var", + "case", + "else", + "enum", + "eval", + "null", + "this", + "true", + "void", + "with", + "await", + "break", + "catch", + "class", + "const", + "false", + "super", + "throw", + "while", + "yield", + "delete", + "export", + "import", + "public", + "return", + "static", + "switch", + "typeof", + "default", + "extends", + "finally", + "package", + "private", + "continue", + "debugger", + "function", + "arguments", + "interface", + "protected", + "implements", + "instanceof", + "NaN", + "Infinity", + "undefined", +]; + +const reservedKeywordRegex = new RegExp(`^${RESERVED_KEYWORDS.join("|")}$`); + +const identifierNameRegex = + /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u; + +const validIdentifierRegex = new RegExp( + `(?!(${reservedKeywordRegex.source})$)${identifierNameRegex.source}`, + "u" +); + +/** + * Check whether the given string is a valid JavaScript identifier + * that is not a reserved keyword. + * + * @param identifier - The string to validate + * @returns `true` when the identifier is safe to use in generated code + */ +export function isValidIdentifier(identifier: string) { + return validIdentifierRegex.test(identifier); +} + +/** + * Normalize a string into a valid JavaScript identifier by replacing + * invalid characters with underscores. + * + * @param identifier - The raw string to normalize + * @returns A string suitable for use as a JavaScript identifier + */ +export function normalizeIdentifier(identifier: string) { + return identifier.replace( + /(?:^[^$_\p{ID_Start}])|[^$_\u200C\u200D\p{ID_Continue}]/gu, + "_" + ); +} diff --git a/packages/pages-functions/src/routing/index.ts b/packages/pages-functions/src/routing/index.ts new file mode 100644 index 0000000000..a0c881d0c9 --- /dev/null +++ b/packages/pages-functions/src/routing/index.ts @@ -0,0 +1,29 @@ +export { + generateConfigFromFileTree, + compareRoutes, + PagesFunctionsBuildError, + PagesFunctionsError, + PagesFunctionsErrorCode, +} from "./filepath-routing"; +export { writeRoutesModule, generateRoutesModuleSource } from "./routes"; +export type { HTTPMethod, Config, RouteConfig } from "./routes"; +export { + convertRoutesToGlobPatterns, + convertRoutesToRoutesJSONSpec, + optimizeRoutesJSONSpec, + compareRoutes as compareRoutesSimplified, +} from "./routes-transformation"; +export type { RoutesJSONSpec } from "./routes-transformation"; +export { consolidateRoutes, shortenRoute } from "./routes-consolidation"; +export { + RoutesValidationError, + isRoutesJSONSpec, + validateRoutes, + getRoutesValidationErrorMessage, +} from "./routes-validation"; +export { + MAX_FUNCTIONS_ROUTES_RULES, + MAX_FUNCTIONS_ROUTES_RULE_LENGTH, + ROUTES_SPEC_VERSION, +} from "./constants"; +export { isValidIdentifier, normalizeIdentifier } from "./identifiers"; diff --git a/packages/pages-functions/src/routing/routes-consolidation.ts b/packages/pages-functions/src/routing/routes-consolidation.ts new file mode 100644 index 0000000000..46ca1c1c23 --- /dev/null +++ b/packages/pages-functions/src/routing/routes-consolidation.ts @@ -0,0 +1,78 @@ +import { MAX_FUNCTIONS_ROUTES_RULE_LENGTH } from "./constants"; + +/** + * Consolidate redundant routes — e.g. `["/api/*", "/api/foo"]` becomes `["/api/*"]`. + * + * @param routes - Array of route patterns. If this is in the same order as Functions + * routes (most-specific first), it will be more efficient to reverse it first. + * Routes should be in the format: `/api/foo`, `/api/*`. + * @returns Non-redundant list of routes + */ +export function consolidateRoutes(routes: string[]): string[] { + // First we need to trim any rules that are too long and deduplicate the result + const routesShortened = Array.from( + new Set(routes.map((route) => shortenRoute(route))) + ); + + // create a map of the routes + const routesMap = new Map(); + for (const route of routesShortened) { + routesMap.set(route, true); + } + // Find routes that might render other routes redundant + for (const route of routesShortened.filter((r) => r.endsWith("/*"))) { + // Make sure the route still exists in the map + if (routesMap.has(route)) { + // Remove splat at the end, leaving the / + // eg. /api/* -> /api/ + const routeTrimmed = route.substring(0, route.length - 1); + for (const nextRoute of routesMap.keys()) { + // Delete any route that has the wildcard route as a prefix + if (nextRoute !== route && nextRoute.startsWith(routeTrimmed)) { + routesMap.delete(nextRoute); + } + } + } + } + return Array.from(routesMap.keys()); +} + +/** + * Shorten a route until it is within the rule length limit defined by + * {@link MAX_FUNCTIONS_ROUTES_RULE_LENGTH}. + * + * For example `/aaa/bbb` may become `/aaa/*`. + * + * @param routeToShorten - Route to shorten if needed + * @param maxLength - Max length of route to try to shorten to + * @returns The shortened route + */ +export function shortenRoute( + routeToShorten: string, + maxLength: number = MAX_FUNCTIONS_ROUTES_RULE_LENGTH +): string { + if (routeToShorten.length <= maxLength) { + return routeToShorten; + } + + let route = routeToShorten; + // May have to try multiple times for longer segments + for (let i = 0; i < routeToShorten.length; i++) { + // Shorten to the first slash within the limit + for (let j = maxLength - 1 - i; j > 0; j--) { + if (route[j] === "/") { + route = route.slice(0, j) + "/*"; + break; + } + } + if (route.length <= maxLength) { + break; + } + } + + // If we failed to shorten it, fall back to include-all rather than breaking + if (route.length > maxLength) { + route = "/*"; + } + return route; +} diff --git a/packages/pages-functions/src/routing/routes-transformation.ts b/packages/pages-functions/src/routing/routes-transformation.ts new file mode 100644 index 0000000000..13f77105bb --- /dev/null +++ b/packages/pages-functions/src/routing/routes-transformation.ts @@ -0,0 +1,131 @@ +import { join as pathJoin } from "node:path"; +import { toUrlPath } from "@cloudflare/workers-utils"; +import { MAX_FUNCTIONS_ROUTES_RULES, ROUTES_SPEC_VERSION } from "./constants"; +import { consolidateRoutes } from "./routes-consolidation"; +import type { RouteConfig } from "./routes"; + +/** Interface for _routes.json */ +export interface RoutesJSONSpec { + version: typeof ROUTES_SPEC_VERSION; + description?: string; + include: string[]; + exclude: string[]; +} + +type RoutesJSONRouteInput = Pick[]; + +/** + * Convert route configs into glob patterns suitable for _routes.json `include` rules. + * + * @param routes - Route configs containing `routePath` and optional `middleware` + * @returns Deduplicated array of glob patterns + */ +export function convertRoutesToGlobPatterns( + routes: RoutesJSONRouteInput +): string[] { + const convertedRoutes = routes.map(({ routePath, middleware }) => { + const globbedRoutePath: string = routePath.replace(/:\w+\*?.*/, "*"); + + // Middleware mountings need to end in glob so that they can handle their + // own sub-path routes + if ( + typeof middleware === "string" || + (Array.isArray(middleware) && middleware.length > 0) + ) { + if (!globbedRoutePath.endsWith("*")) { + return toUrlPath(pathJoin(globbedRoutePath, "*")); + } + } + + return toUrlPath(globbedRoutePath); + }); + + return Array.from(new Set(convertedRoutes)); +} + +/** + * Convert Functions routes like `/foo/:bar` to a `RoutesJSONSpec` object + * used to determine if a request should run in the Functions user-worker. + * Also consolidates redundant routes such as `[/foo/bar, /foo/:bar]` -> `/foo/*`. + * + * @param routes - Route configs from filepath discovery + * @param description - Optional description string for the spec + * @returns A `RoutesJSONSpec` to be written to `_routes.json` + */ +export function convertRoutesToRoutesJSONSpec( + routes: RoutesJSONRouteInput, + description?: string +): RoutesJSONSpec { + // The initial routes coming in are sorted most-specific to least-specific. + // The order doesn't have any affect on the output of this function, but + // it should speed up route consolidation with less-specific routes being first. + const reversedRoutes = [...routes].reverse(); + const include = convertRoutesToGlobPatterns(reversedRoutes); + return optimizeRoutesJSONSpec({ + version: ROUTES_SPEC_VERSION, + description, + include, + exclude: [], + }); +} + +/** + * Optimize and return a new Routes JSON Spec instance, performing + * de-duping, consolidation, truncation, and sorting. + * + * @param spec - The routes spec to optimize + * @returns The optimized routes spec + */ +export function optimizeRoutesJSONSpec(spec: RoutesJSONSpec): RoutesJSONSpec { + const optimizedSpec = { ...spec }; + + let consolidatedRoutes = consolidateRoutes(optimizedSpec.include); + if (consolidatedRoutes.length > MAX_FUNCTIONS_ROUTES_RULES) { + consolidatedRoutes = ["/*"]; + } + // Sort so that least-specific routes are first + consolidatedRoutes.sort((a, b) => compareRoutes(b, a)); + + optimizedSpec.include = consolidatedRoutes; + + return optimizedSpec; +} + +/** + * Simplified routes comparison (copied from the one in filepath-routing.) + * This version will sort most-specific to least-specific, but the input is simplified + * routes like `/foo/*`, `/foo`, etc. + * + * @param routeA - First route to compare + * @param routeB - Second route to compare + * @returns A negative number, zero, or a positive number for sorting + */ +export function compareRoutes(routeA: string, routeB: string) { + function parseRoutePath(routePath: string): string[] { + return routePath.slice(1).split("/").filter(Boolean); + } + + const segmentsA = parseRoutePath(routeA); + const segmentsB = parseRoutePath(routeB); + + // sort routes with fewer segments after those with more segments + if (segmentsA.length !== segmentsB.length) { + return segmentsB.length - segmentsA.length; + } + + for (let i = 0; i < segmentsA.length; i++) { + const isWildcardA = (segmentsA[i] ?? "").includes("*"); + const isWildcardB = (segmentsB[i] ?? "").includes("*"); + + // sort wildcard segments after non-wildcard segments + if (isWildcardA && !isWildcardB) { + return 1; + } + if (!isWildcardA && isWildcardB) { + return -1; + } + } + + // all else equal, just sort the paths lexicographically + return routeA.localeCompare(routeB); +} diff --git a/packages/pages-functions/src/routing/routes-validation.ts b/packages/pages-functions/src/routing/routes-validation.ts new file mode 100644 index 0000000000..4db16335ac --- /dev/null +++ b/packages/pages-functions/src/routing/routes-validation.ts @@ -0,0 +1,237 @@ +import assert from "node:assert"; +import { + MAX_FUNCTIONS_ROUTES_RULE_LENGTH, + MAX_FUNCTIONS_ROUTES_RULES, + ROUTES_SPEC_VERSION, +} from "./constants"; +import type { RoutesJSONSpec } from "./routes-transformation"; + +export enum RoutesValidationError { + INVALID_JSON_SPEC, + NO_INCLUDE_RULES, + INVALID_RULES, + TOO_MANY_RULES, + RULE_TOO_LONG, + OVERLAPPING_RULES, +} + +/** + * Check if given routes data is a valid `RoutesJSONSpec`. + * + * @param data - The data to validate + * @returns `true` when `data` conforms to the `RoutesJSONSpec` shape + */ +export function isRoutesJSONSpec(data: unknown): data is RoutesJSONSpec { + return ( + (typeof data === "object" && + data && + "version" in data && + typeof (data as RoutesJSONSpec).version === "number" && + (data as RoutesJSONSpec).version === ROUTES_SPEC_VERSION && + Array.isArray((data as RoutesJSONSpec).include) && + Array.isArray((data as RoutesJSONSpec).exclude)) || + false + ); +} + +/** + * Validate a `RoutesJSONSpec` and throw a descriptive error if it is invalid. + * + * @param routesJSON - The routes spec to validate + * @param routesPath - The file path of the `_routes.json` (used in error messages) + * @throws Error when the spec is invalid + */ +export function validateRoutes(routesJSON: RoutesJSONSpec, routesPath: string) { + if (!isRoutesJSONSpec(routesJSON)) { + throw new Error( + getRoutesValidationErrorMessage( + RoutesValidationError.INVALID_JSON_SPEC, + routesPath + ) + ); + } + + if (!hasIncludeRules(routesJSON)) { + throw new Error( + getRoutesValidationErrorMessage( + RoutesValidationError.NO_INCLUDE_RULES, + routesPath + ) + ); + } + + if (!hasValidRulesCount(routesJSON)) { + throw new Error( + getRoutesValidationErrorMessage( + RoutesValidationError.TOO_MANY_RULES, + routesPath + ) + ); + } + + if (!hasValidRuleCharCount(routesJSON)) { + throw new Error( + getRoutesValidationErrorMessage( + RoutesValidationError.RULE_TOO_LONG, + routesPath + ) + ); + } + + if (!hasValidRules(routesJSON)) { + throw new Error( + getRoutesValidationErrorMessage( + RoutesValidationError.INVALID_RULES, + routesPath + ) + ); + } + + if ( + hasOverlappingRules(routesJSON.include) || + hasOverlappingRules(routesJSON.exclude) + ) { + throw new Error( + getRoutesValidationErrorMessage( + RoutesValidationError.OVERLAPPING_RULES, + routesPath + ) + ); + } +} + +/** + * Returns true if the given `routingSpec` object contains at least one include routing rule. + */ +function hasIncludeRules(routesJSON: RoutesJSONSpec): boolean { + if (!routesJSON || !routesJSON.include) { + throw new Error( + "Function `hasIncludeRules` was called out of context. Attempting to validate include rules for routes that are undefined or an invalid RoutesJSONSpec" + ); + } + + return routesJSON?.include?.length > 0; +} + +/** + * Returns true if the given `routesJSON` object contains at most MAX_FUNCTIONS_ROUTES_RULES + * include and exclude routing rules, combined. + */ +function hasValidRulesCount(routesJSON: RoutesJSONSpec): boolean { + if (!routesJSON || !routesJSON.include || !routesJSON.exclude) { + throw new Error( + "Function `hasValidRulesCount` was called out of context. Attempting to validate maximum rules count for routes that are undefined or an invalid RoutesJSONSpec" + ); + } + + return ( + routesJSON.include.length + routesJSON.exclude.length <= + MAX_FUNCTIONS_ROUTES_RULES + ); +} + +/** + * Returns true if each individual routing rule of the given `routesJSON` object is at most + * MAX_FUNCTIONS_ROUTES_RULE_LENGTH characters long. + */ +function hasValidRuleCharCount(routesJSON: RoutesJSONSpec): boolean { + if (!routesJSON || !routesJSON.include || !routesJSON.exclude) { + throw new Error( + "Function `hasValidRuleCharCount` was called out of context. Attempting to validate rules maximum character count for routes that are undefined or an invalid RoutesJSONSpec" + ); + } + + const rules = [...routesJSON.include, ...routesJSON.exclude]; + return ( + rules.filter((rule) => rule.length > MAX_FUNCTIONS_ROUTES_RULE_LENGTH) + .length === 0 + ); +} + +/** + * Returns true if each individual routing rule of the given `routesJSON` object is valid. + * We consider a rule to be valid if it is prefixed by slash ('/'). + */ +function hasValidRules(routesJSON: RoutesJSONSpec): boolean { + if (!routesJSON || !routesJSON.include || !routesJSON.exclude) { + throw new Error( + "Function `hasValidRules` was called out of context. Attempting to validate rules for routes that are undefined or an invalid RoutesJSONSpec" + ); + } + + const rules = [...routesJSON.include, ...routesJSON.exclude]; + return rules.filter((rule) => !rule.match(/^\//)).length === 0; +} + +/** + * Returns true if the given routes array has overlapping routing rules (eg. ["/api/*", "/api/foo"]). + */ +function hasOverlappingRules(routes: string[]): boolean { + if (!routes) { + throw new Error( + "Function `hasOverlappingRules` was called out of context. Attempting to validate rules for routes that are undefined" + ); + } + + // Find routes that might render other routes redundant + const endingSplatRoutes = routes.filter((route) => route.endsWith("/*")); + + for (let i = 0; i < endingSplatRoutes.length; i++) { + const crrRoute = endingSplatRoutes[i]; + assert(crrRoute); + // Remove splat at the end, leaving the / + // eg. /api/* -> /api/ + const crrRouteTrimmed = crrRoute.substring(0, crrRoute.length - 1); + + for (let j = 0; j < routes.length; j++) { + const nextRoute = routes[j]; + assert(nextRoute); + if (nextRoute !== crrRoute && nextRoute.startsWith(crrRouteTrimmed)) { + return true; + } + } + } + + return false; +} + +/** + * Get a human-readable error message for a routes validation error. + * + * @param errorCode - The type of validation error + * @param routesPath - Path to the _routes.json file + * @returns A descriptive error message + */ +export function getRoutesValidationErrorMessage( + errorCode: RoutesValidationError, + routesPath: string +): string { + switch (errorCode) { + case RoutesValidationError.NO_INCLUDE_RULES: + return `Invalid _routes.json file found at: ${routesPath} +Routes must have at least 1 include rule, but no include rules were detected.`; + case RoutesValidationError.TOO_MANY_RULES: + return `Invalid _routes.json file found at: ${routesPath} +Detected rules that are over the ${MAX_FUNCTIONS_ROUTES_RULES} rule limit. Please make sure you have a total of ${MAX_FUNCTIONS_ROUTES_RULES} include and exclude rules combined.`; + case RoutesValidationError.RULE_TOO_LONG: + return `Invalid _routes.json file found at: ${routesPath} +Detected rules the are over the ${MAX_FUNCTIONS_ROUTES_RULE_LENGTH} character limit. Please make sure that each include and exclude routing rule is at most ${MAX_FUNCTIONS_ROUTES_RULE_LENGTH} characters long.`; + case RoutesValidationError.INVALID_RULES: + return `Invalid _routes.json file found at: ${routesPath} +All rules must start with '/'.`; + case RoutesValidationError.OVERLAPPING_RULES: + return `Invalid _routes.json file found at: ${routesPath} +Overlapping rules found. Please make sure that rules ending with a splat (eg. "/api/*") don't overlap any other rules (eg. "/api/foo"). This applies to both include and exclude rules individually.`; + case RoutesValidationError.INVALID_JSON_SPEC: + default: + return `Invalid _routes.json file found at: ${routesPath} +Please make sure the JSON object has the following format: +{ + version: ${ROUTES_SPEC_VERSION}; + include: string[]; + exclude: string[]; +} +and that at least one include rule is provided. + `; + } +} diff --git a/packages/pages-functions/src/routing/routes.ts b/packages/pages-functions/src/routing/routes.ts new file mode 100644 index 0000000000..38d6765f78 --- /dev/null +++ b/packages/pages-functions/src/routing/routes.ts @@ -0,0 +1,191 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + PagesFunctionsError, + PagesFunctionsErrorCode, +} from "./filepath-routing"; +import { isValidIdentifier, normalizeIdentifier } from "./identifiers"; +import type { UrlPath } from "@cloudflare/workers-utils"; + +export type HTTPMethod = + | "HEAD" + | "OPTIONS" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; + +type RoutesCollection = Array<{ + routePath: UrlPath; + mountPath: UrlPath; + method?: HTTPMethod; + modules: string[]; + middlewares: string[]; +}>; + +export type Config = { + routes?: RouteConfig[]; + schedules?: unknown; +}; + +export type RouteConfig = { + routePath: UrlPath; + mountPath: UrlPath; + method?: HTTPMethod; + middleware?: string | string[]; + module?: string | string[]; +}; + +type ImportMap = Map< + string, + { + filepath: string; + name: string; + identifier: string; + } +>; + +type Arguments = { + config: Config; + outfile: string; + srcDir: string; +}; + +/** + * Write a JavaScript module that imports all discovered route handlers + * and re-exports them as a `routes` array suitable for injection into + * the Pages Worker runtime template. + * + * @param args - Route configuration, source directory, and output path + * @returns The path the module was written to + */ +export async function writeRoutesModule({ + config, + srcDir, + outfile = "_routes.js", +}: Arguments) { + const { importMap, routes } = parseConfig(config, srcDir); + const routesModule = generateRoutesModule(importMap, routes); + + await fs.writeFile(outfile, routesModule); + + return outfile; +} + +/** + * Generate the routes module source code as a string without writing + * it to disk. + * + * @param config - Route configuration from filepath discovery + * @param srcDir - Absolute path to the Functions source directory + * @returns The generated JavaScript module source code + */ +export function generateRoutesModuleSource(config: Config, srcDir: string) { + const { importMap, routes } = parseConfig(config, srcDir); + return generateRoutesModule(importMap, routes); +} + +function parseConfig(config: Config, baseDir: string) { + baseDir = path.resolve(baseDir); + const routes: RoutesCollection = []; + const importMap: ImportMap = new Map(); + const identifierCount = new Map(); + + function parseModuleIdentifiers(paths: string | string[] | undefined) { + if (typeof paths === "undefined") { + paths = []; + } + + if (typeof paths === "string") { + paths = [paths]; + } + + return paths.map((modulePath) => { + const resolvedPath = path.resolve(baseDir, modulePath); + const moduleRoot = path.parse(resolvedPath).root; + + // Strip the drive letter (if any) to avoid confusing the drive colon with the export name separator + const strippedPath = resolvedPath.slice(moduleRoot.length - 1); + const [filepath = "", name = "default"] = strippedPath.split(":"); + + const fullFilepath = path.resolve(moduleRoot, filepath); + const relativePath = path.relative(baseDir, fullFilepath); + + // ensure the filepath isn't attempting to resolve to anything outside of the project + if ( + moduleRoot !== path.parse(baseDir).root || + relativePath.startsWith("..") + ) { + throw new PagesFunctionsError( + `Invalid module path "${fullFilepath}"`, + PagesFunctionsErrorCode.INVALID_MODULE_PATH + ); + } + + // ensure the module name (if provided) is a valid identifier to guard against injection attacks + if (name !== "default" && !isValidIdentifier(name)) { + throw new PagesFunctionsError( + `Invalid module identifier "${name}"`, + PagesFunctionsErrorCode.INVALID_MODULE_IDENTIFIER + ); + } + + let { identifier } = importMap.get(resolvedPath) ?? {}; + if (!identifier) { + identifier = normalizeIdentifier(`__${relativePath}_${name}`); + + let count = identifierCount.get(identifier) ?? 0; + identifierCount.set(identifier, ++count); + + if (count > 1) { + identifier += `_${count}`; + } + + importMap.set(resolvedPath, { + filepath: fullFilepath, + name, + identifier, + }); + } + + return identifier; + }); + } + + for (const { routePath, mountPath, method, ...props } of config.routes ?? + []) { + routes.push({ + routePath, + mountPath, + method, + middlewares: parseModuleIdentifiers(props.middleware), + modules: parseModuleIdentifiers(props.module), + }); + } + + return { routes, importMap }; +} + +function generateRoutesModule(importMap: ImportMap, routes: RoutesCollection) { + return `${[...importMap.values()] + .map( + ({ filepath, name, identifier }) => + `import { ${name} as ${identifier} } from ${JSON.stringify(filepath)}` + ) + .join("\n")} + +export const routes = [ + ${routes + .map( + (route) => ` { + routePath: "${route.routePath}", + mountPath: "${route.mountPath}", + method: "${route.method}", + middlewares: [${route.middlewares.join(", ")}], + modules: [${route.modules.join(", ")}], + },` + ) + .join("\n")} + ]`; +} diff --git a/packages/pages-functions/src/templates/pages-template-worker.ts b/packages/pages-functions/src/templates/pages-template-worker.ts new file mode 100644 index 0000000000..d88c44632b --- /dev/null +++ b/packages/pages-functions/src/templates/pages-template-worker.ts @@ -0,0 +1,200 @@ +// This file is a copy of packages/wrangler/templates/pages-template-worker.ts. +// Changes here should be reflected there and vice-versa. +import { match } from "path-to-regexp"; + +//note: this explicitly does not include the * character, as pages requires this +const escapeRegex = /[.+?^${}()|[\]\\]/g; + +type HTTPMethod = + | "HEAD" + | "OPTIONS" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; + +/* TODO: Grab these from @cloudflare/workers-types instead */ +type Params

= Record; + +type EventContext = { + request: Request; + functionPath: string; + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; + next: (input?: Request | string, init?: RequestInit) => Promise; + env: Env & { ASSETS: { fetch: typeof fetch } }; + params: Params

; + data: Data; +}; + +declare type PagesFunction< + Env = unknown, + P extends string = string, + Data extends Record = Record, +> = (context: EventContext) => Response | Promise; +/* end @cloudflare/workers-types */ + +type RouteHandler = { + routePath: string; + mountPath: string; + method?: HTTPMethod; + modules: PagesFunction[]; + middlewares: PagesFunction[]; +}; + +// inject `routes` via ESBuild +declare const routes: RouteHandler[]; +// define `__FALLBACK_SERVICE__` via ESBuild +declare const __FALLBACK_SERVICE__: string; + +// expect an ASSETS fetcher binding pointing to the asset-server stage +type FetchEnv = { + [name: string]: { fetch: typeof fetch }; + ASSETS: { fetch: typeof fetch }; +}; + +type WorkerContext = { + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; +}; + +function* executeRequest(request: Request) { + const requestPath = new URL(request.url).pathname; + + // First, iterate through the routes (backwards) and execute "middlewares" on partial route matches + for (const route of [...routes].reverse()) { + if (route.method && route.method !== request.method) { + continue; + } + + // replaces with "\\$&", this prepends a backslash to the matched string, e.g. "[" becomes "\[" + const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), { + end: false, + }); + const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), { + end: false, + }); + const matchResult = routeMatcher(requestPath); + const mountMatchResult = mountMatcher(requestPath); + if (matchResult && mountMatchResult) { + for (const handler of route.middlewares.flat()) { + yield { + handler, + params: matchResult.params as Params, + path: mountMatchResult.path, + }; + } + } + } + + // Then look for the first exact route match and execute its "modules" + for (const route of routes) { + if (route.method && route.method !== request.method) { + continue; + } + const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), { + end: true, + }); + const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), { + end: false, + }); + const matchResult = routeMatcher(requestPath); + const mountMatchResult = mountMatcher(requestPath); + if (matchResult && mountMatchResult && route.modules.length) { + for (const handler of route.modules.flat()) { + yield { + handler, + params: matchResult.params as Params, + path: matchResult.path, + }; + } + break; + } + } +} + +export default { + async fetch( + originalRequest: Request, + env: FetchEnv, + workerContext: WorkerContext + ) { + let request = originalRequest; + const handlerIterator = executeRequest(request); + let data = {}; // arbitrary data the user can set between functions + let isFailOpen = false; + + const next = async (input?: RequestInfo, init?: RequestInit) => { + if (input !== undefined) { + let url = input; + if (typeof input === "string") { + url = new URL(input, request.url).toString(); + } + request = new Request(url, init); + } + + const result = handlerIterator.next(); + // Note we can't use `!result.done` because this doesn't narrow to the correct type + if (result.done === false) { + const { handler, params, path } = result.value; + const context = { + request: new Request(request.clone()), + functionPath: path, + next, + params, + get data() { + return data; + }, + set data(value) { + if (typeof value !== "object" || value === null) { + throw new Error("context.data must be an object"); + } + // user has overridden context.data, so we need to merge it with the existing data + data = value; + }, + env, + waitUntil: workerContext.waitUntil.bind(workerContext), + passThroughOnException: () => { + isFailOpen = true; + }, + }; + + const response = await handler(context); + + if (!(response instanceof Response)) { + throw new Error("Your Pages function should return a Response"); + } + + return cloneResponse(response); + } else if (__FALLBACK_SERVICE__) { + // There are no more handlers so finish with the fallback service (`env.ASSETS.fetch` in Pages' case) + const response = await env[__FALLBACK_SERVICE__].fetch(request); + return cloneResponse(response); + } else { + // There was not fallback service so actually make the request to the origin. + const response = await fetch(request); + return cloneResponse(response); + } + }; + + try { + return await next(); + } catch (error) { + if (isFailOpen) { + const response = await env[__FALLBACK_SERVICE__].fetch(request); + return cloneResponse(response); + } + + throw error; + } + }, +}; + +// This makes a Response mutable +const cloneResponse = (response: Response) => + // https://fetch.spec.whatwg.org/#null-body-status + new Response( + [101, 204, 205, 304].includes(response.status) ? null : response.body, + response + ); diff --git a/packages/pages-functions/tsconfig.json b/packages/pages-functions/tsconfig.json new file mode 100644 index 0000000000..89334ea4cb --- /dev/null +++ b/packages/pages-functions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "compilerOptions": { + "types": ["@types/node"] + }, + "include": ["src"], + "exclude": ["src/templates"] +} diff --git a/packages/pages-functions/tsdown.config.ts b/packages/pages-functions/tsdown.config.ts new file mode 100644 index 0000000000..72beb70822 --- /dev/null +++ b/packages/pages-functions/tsdown.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig([ + { + entry: { index: "src/index.ts" }, + platform: "node", + format: "esm", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", + sourcemap: process.env.SOURCEMAPS !== "false", + // esbuild is invoked at runtime to compile user code; it ships + // platform-specific binaries and must be resolved from the consumer's + // node_modules at install time — not inlined into our bundle. + external: ["esbuild"], + }, + { + entry: { cli: "src/cli.ts" }, + platform: "node", + format: "esm", + outDir: "dist", + dts: false, + tsconfig: "tsconfig.json", + sourcemap: process.env.SOURCEMAPS !== "false", + external: ["esbuild"], + }, +]); diff --git a/packages/pages-functions/turbo.json b/packages/pages-functions/turbo.json new file mode 100644 index 0000000000..94510acaa3 --- /dev/null +++ b/packages/pages-functions/turbo.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "!**/__tests__/**"], + "outputs": ["dist/**"], + "env": ["SOURCEMAPS"] + } + } +} diff --git a/packages/pages-functions/vitest.config.mts b/packages/pages-functions/vitest.config.mts new file mode 100644 index 0000000000..0fc84cecc4 --- /dev/null +++ b/packages/pages-functions/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineProject, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineProject({ + test: { + include: ["**/__tests__/**/*.{test,spec}.{ts,js,tsx,jsx}"], + }, + }) +); diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index 833b0cd0ae..b692d2228b 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -171,3 +171,6 @@ export { getWorkerName, getWorkerNameFromProject, } from "./worker-name"; + +export { toUrlPath } from "./url-path"; +export type { UrlPath } from "./url-path"; diff --git a/packages/workers-utils/src/url-path.ts b/packages/workers-utils/src/url-path.ts new file mode 100644 index 0000000000..b8aa27df39 --- /dev/null +++ b/packages/workers-utils/src/url-path.ts @@ -0,0 +1,29 @@ +import { assert } from "node:console"; + +type DiscriminatedPath = string & { + _discriminator: Discriminator; +}; + +/** + * A branded string that expects to be URL compatible. + * + * Require this type when you want callers to ensure that they have converted file-path strings into URL-safe paths. + */ +export type UrlPath = DiscriminatedPath<"UrlPath">; + +/** + * Convert a file-path string to a URL-path string. + * + * Use this helper to convert a `string` to a `UrlPath` when it is not clear whether the string needs normalizing. + * Replaces all back-slashes with forward-slashes, and throws an error if the path contains a drive letter (e.g. `C:`). + * + * @param filePath - The file path to convert + * @returns The URL-safe path + */ +export function toUrlPath(filePath: string): UrlPath { + assert( + !/^[a-z]:/i.test(filePath), + "Tried to convert a Windows file path with a drive to a URL path." + ); + return filePath.replace(/\\/g, "/") as UrlPath; +} diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index 9487ea6dca..89da3e4c82 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -96,6 +96,7 @@ "@cloudflare/config": "workspace:*", "@cloudflare/containers-shared": "workspace:*", "@cloudflare/deploy-helpers": "workspace:*", + "@cloudflare/pages-functions": "workspace:*", "@cloudflare/pages-shared": "workspace:^", "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/runtime-types": "workspace:*", diff --git a/packages/wrangler/src/__tests__/pages/build-functions-errors.test.ts b/packages/wrangler/src/__tests__/pages/build-functions-errors.test.ts new file mode 100644 index 0000000000..1eec0c1b8e --- /dev/null +++ b/packages/wrangler/src/__tests__/pages/build-functions-errors.test.ts @@ -0,0 +1,71 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { beforeEach, describe, it, vi } from "vitest"; +import { buildFunctions } from "../../pages/buildFunctions"; +import { toUrlPath } from "../../paths"; + +const mocks = vi.hoisted(() => ({ + generateConfigFromFileTree: vi.fn(), + writeRoutesModule: vi.fn(), +})); + +vi.mock("@cloudflare/pages-functions", async (importOriginal) => ({ + ...(await importOriginal()), + generateConfigFromFileTree: mocks.generateConfigFromFileTree, + writeRoutesModule: mocks.writeRoutesModule, +})); + +describe("Pages Functions build error adapter", () => { + beforeEach(() => { + mocks.generateConfigFromFileTree.mockReset(); + mocks.writeRoutesModule.mockReset(); + }); + + it("preserves unexpected route discovery errors", async ({ expect }) => { + const error = new TypeError("unexpected route discovery failure"); + mocks.generateConfigFromFileTree.mockRejectedValue(error); + + let caughtError: unknown; + try { + await buildFunctions({ + functionsDirectory: "functions", + local: true, + defineNavigatorUserAgent: false, + checkFetch: false, + }); + } catch (caught) { + caughtError = caught; + } + + expect(caughtError).toBe(error); + expect(caughtError).not.toBeInstanceOf(UserError); + }); + + it("preserves unexpected routes module errors", async ({ expect }) => { + const error = new TypeError("unexpected routes module failure"); + mocks.generateConfigFromFileTree.mockResolvedValue({ + routes: [ + { + routePath: toUrlPath("/"), + mountPath: toUrlPath("/"), + module: "index.ts:onRequest", + }, + ], + }); + mocks.writeRoutesModule.mockRejectedValue(error); + + let caughtError: unknown; + try { + await buildFunctions({ + functionsDirectory: "functions", + local: true, + defineNavigatorUserAgent: false, + checkFetch: false, + }); + } catch (caught) { + caughtError = caught; + } + + expect(caughtError).toBe(error); + expect(caughtError).not.toBeInstanceOf(UserError); + }); +}); diff --git a/packages/wrangler/src/__tests__/pages/functions-build.test.ts b/packages/wrangler/src/__tests__/pages/functions-build.test.ts index 1f8756ea4a..a757e84fd6 100644 --- a/packages/wrangler/src/__tests__/pages/functions-build.test.ts +++ b/packages/wrangler/src/__tests__/pages/functions-build.test.ts @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, } from "node:fs"; +import { FatalError } from "@cloudflare/workers-utils"; import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; import dedent from "ts-dedent"; import { afterEach, beforeEach, describe, it, vi } from "vitest"; @@ -30,6 +31,34 @@ describe("pages functions build", () => { expect(std.err).toContain("Could not find anything to build."); }); + for (const { filename, telemetryMessage } of [ + { + filename: "[hyphen-not-allowed].ts", + telemetryMessage: "pages functions invalid route parameter", + }, + { + filename: "[[hyphen-not-allowed]].ts", + telemetryMessage: "pages functions invalid catchall route parameter", + }, + ]) { + it(`converts an invalid route in ${filename} to FatalError`, async ({ + expect, + }) => { + mkdirSync("functions"); + writeFileSync(`functions/${filename}`, "export function onRequest() {}"); + + let error: unknown; + try { + await runWrangler("pages functions build"); + } catch (caughtError) { + error = caughtError; + } + + expect(error).toBeInstanceOf(FatalError); + expect(error).toMatchObject({ telemetryMessage }); + }); + } + it("should build functions", async ({ expect }) => { /* ---------------------------- */ /* Set up Functions */ diff --git a/packages/wrangler/src/__tests__/pages/routes-validation.test.ts b/packages/wrangler/src/__tests__/pages/routes-validation.test.ts index 7ca5cbfbfc..a81b1216d7 100644 --- a/packages/wrangler/src/__tests__/pages/routes-validation.test.ts +++ b/packages/wrangler/src/__tests__/pages/routes-validation.test.ts @@ -5,412 +5,125 @@ import { MAX_FUNCTIONS_ROUTES_RULES, ROUTES_SPEC_VERSION, } from "../../pages/constants"; -import { getRoutesValidationErrorMessage } from "../../pages/errors"; import { - isRoutesJSONSpec, + getRoutesValidationErrorMessage, RoutesValidationError, validateRoutes, } from "../../pages/functions/routes-validation"; import type { RoutesJSONSpec } from "../../pages/functions/routes-transformation"; -describe("routes-validation", () => { - describe("isRoutesJSONSpec", () => { - it("should return true if the given routes are in a valid RoutesJSONSpec format", ({ - expect, - }) => { - const routes = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: [], - exclude: [], - }; - const routesWithoutDescription = { - version: ROUTES_SPEC_VERSION, - include: [], - exclude: [], - }; - - expect(isRoutesJSONSpec(routes)).toBeTruthy(); - expect(isRoutesJSONSpec(routesWithoutDescription)).toBeTruthy(); - }); - - it("should return false otherwise", ({ expect }) => { - const routesWithMissingVersion = { - include: [], - exclude: [], - }; - const routesWithIncorrectVersionNumber = { - version: 1000, - include: [], - exclude: [], - }; - const routesWithIncorrectVersionType = { - version: "1000", - include: [], - exclude: [], - }; - const routesWithMissingInclude = { - version: ROUTES_SPEC_VERSION, - exclude: [], - }; - const routesWithMissingExclude = { - version: ROUTES_SPEC_VERSION, - include: [], - }; - const routesWithIncorrectIncludeType = { - version: ROUTES_SPEC_VERSION, - include: "[]", - exclude: [], - }; - const routesWithIncorrectExcludeType = { - version: ROUTES_SPEC_VERSION, - include: [], - exclude: { route: "/hello" }, - }; - - expect(isRoutesJSONSpec(null)).toBeFalsy(); - expect(isRoutesJSONSpec({})).toBeFalsy(); - expect(isRoutesJSONSpec([])).toBeFalsy(); - expect(isRoutesJSONSpec(routesWithMissingVersion)).toBeFalsy(); - expect(isRoutesJSONSpec(routesWithIncorrectVersionNumber)).toBeFalsy(); - expect(isRoutesJSONSpec(routesWithIncorrectVersionType)).toBeFalsy(); - expect(isRoutesJSONSpec(routesWithMissingInclude)).toBeFalsy(); - expect(isRoutesJSONSpec(routesWithMissingExclude)).toBeFalsy(); - expect(isRoutesJSONSpec(routesWithIncorrectIncludeType)).toBeFalsy(); - expect(isRoutesJSONSpec(routesWithIncorrectExcludeType)).toBeFalsy(); +const testRoutesPath = "/public"; + +const validationCases: Array<{ + name: string; + routes: RoutesJSONSpec; + code: RoutesValidationError; + telemetryMessage: string; +}> = [ + { + name: "invalid JSON specs", + routes: {} as RoutesJSONSpec, + code: RoutesValidationError.INVALID_JSON_SPEC, + telemetryMessage: "pages functions routes invalid json spec", + }, + { + name: "missing include rules", + routes: { + version: ROUTES_SPEC_VERSION, + include: [], + exclude: [], + }, + code: RoutesValidationError.NO_INCLUDE_RULES, + telemetryMessage: "pages functions routes missing include rules", + }, + { + name: "too many rules", + routes: { + version: ROUTES_SPEC_VERSION, + include: Array.from( + { length: MAX_FUNCTIONS_ROUTES_RULES + 1 }, + (_, index) => `/route-${index}` + ), + exclude: [], + }, + code: RoutesValidationError.TOO_MANY_RULES, + telemetryMessage: "pages functions routes too many rules", + }, + { + name: "rules that are too long", + routes: { + version: ROUTES_SPEC_VERSION, + include: [`/${"a".repeat(MAX_FUNCTIONS_ROUTES_RULE_LENGTH)}`], + exclude: [], + }, + code: RoutesValidationError.RULE_TOO_LONG, + telemetryMessage: "pages functions routes rule too long", + }, + { + name: "invalid rules", + routes: { + version: ROUTES_SPEC_VERSION, + include: ["route"], + exclude: [], + }, + code: RoutesValidationError.INVALID_RULES, + telemetryMessage: "pages functions routes invalid rules", + }, + { + name: "overlapping rules", + routes: { + version: ROUTES_SPEC_VERSION, + include: ["/api/*", "/api/route"], + exclude: [], + }, + code: RoutesValidationError.OVERLAPPING_RULES, + telemetryMessage: "pages functions routes overlapping rules", + }, +]; + +describe("routes-validation adapter", () => { + for (const { name, routes, code, telemetryMessage } of validationCases) { + it(`converts ${name} to FatalError`, ({ expect }) => { + let error: unknown; + try { + validateRoutes(routes, testRoutesPath); + } catch (caughtError) { + error = caughtError; + } + + expect(error).toBeInstanceOf(FatalError); + expect(error).toMatchObject({ + message: getRoutesValidationErrorMessage(code, testRoutesPath), + telemetryMessage, + }); }); - }); - - describe("validateRoutes", () => { - const testRoutesPath = "/public"; - - const generateUniqueRoutingRules = (count: number): string[] => { - let counter = 0; - return Array(count) - .fill("/abc") - .map((route) => `${route}${counter++}`); - }; - - const generateRoutingRule = (charCount: number): string => { - return `/${Array(charCount).fill("a").join("")}`; - }; - - it("should return if the given routes are valid", ({ expect }) => { - const routes: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: ["/*"], - exclude: [], - }; - const routesWithoutDescription: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - include: ["/hello"], - exclude: [], - }; - - expect(() => validateRoutes(routes, testRoutesPath)).not.toThrow(); - expect(() => - validateRoutes(routesWithoutDescription, testRoutesPath) - ).not.toThrow(); - }); - - it("should throw a fatal error if the routes are not a valid RoutesJSONSpec object", ({ - expect, - }) => { - // @ts-expect-error -- Intentionally testing invalid types - const routesWithoutVersion: RoutesJSONSpec = { - include: ["/*"], - exclude: [], - }; - // @ts-expect-error -- Intentionally testing invalid types - const routesWithoutInclude: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - exclude: [], - }; - - expect(() => - // wrap the code in a function, otherwise the error will not be caught - // and the assertion will fail - validateRoutes(routesWithoutVersion, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithoutVersion, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.INVALID_JSON_SPEC, - testRoutesPath - ) - ); - - expect(() => - validateRoutes(routesWithoutInclude, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithoutInclude, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.INVALID_JSON_SPEC, - testRoutesPath - ) - ); - }); - - it("should throw a fatal error if there are no include routing rules", ({ - expect, - }) => { - const routesWithoutIncludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: [], - exclude: [], - }; - - expect(() => - validateRoutes(routesWithoutIncludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithoutIncludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.NO_INCLUDE_RULES, - testRoutesPath - ) - ); - }); - - it("should throw a fatal error if there are more than MAX_FUNCTIONS_ROUTES_RULES include and exclude routing rules combined", ({ - expect, - }) => { - const routesWithMaxIncludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: generateUniqueRoutingRules(MAX_FUNCTIONS_ROUTES_RULES + 1), - exclude: [], - }; - const routesWithMaxExcludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: ["/hello"], - exclude: generateUniqueRoutingRules(MAX_FUNCTIONS_ROUTES_RULES + 1), - }; - const routesWithMaxIncludeExcludeRulesCombined: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: generateUniqueRoutingRules( - Math.floor(MAX_FUNCTIONS_ROUTES_RULES / 2) + 1 - ), - exclude: generateUniqueRoutingRules( - Math.floor(MAX_FUNCTIONS_ROUTES_RULES / 2) - ), - }; - - expect(() => - validateRoutes(routesWithMaxIncludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithMaxIncludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.TOO_MANY_RULES, - testRoutesPath - ) - ); - - expect(() => - validateRoutes(routesWithMaxExcludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithMaxExcludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.TOO_MANY_RULES, - testRoutesPath - ) + } + + it("preserves unexpected validation errors", ({ expect }) => { + const error = new TypeError("unexpected validation failure"); + const invalidRule = { + get length(): number { + throw error; + }, + match: () => null, + endsWith: () => false, + } as unknown as string; + + let caughtError: unknown; + try { + validateRoutes( + { + version: ROUTES_SPEC_VERSION, + include: [invalidRule], + exclude: [], + }, + testRoutesPath ); + } catch (caught) { + caughtError = caught; + } - expect(() => - validateRoutes(routesWithMaxIncludeExcludeRulesCombined, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithMaxIncludeExcludeRulesCombined, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.TOO_MANY_RULES, - testRoutesPath - ) - ); - }); - - it("should throw a fatal error if any include or exclude routing rule is more than MAX_FUNCTIONS_ROUTES_RULE_LENGTH chars long", ({ - expect, - }) => { - const routesWithMaxCharLengthIncludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], - exclude: [], - }; - const routesWithMaxCharLengthExcludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: ["/*"], - exclude: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], - }; - const routesWithMaxCharLengthRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], - exclude: [generateRoutingRule(MAX_FUNCTIONS_ROUTES_RULE_LENGTH + 1)], - }; - - expect(() => - validateRoutes(routesWithMaxCharLengthIncludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithMaxCharLengthIncludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.RULE_TOO_LONG, - testRoutesPath - ) - ); - - expect(() => - validateRoutes(routesWithMaxCharLengthExcludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithMaxCharLengthExcludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.RULE_TOO_LONG, - testRoutesPath - ) - ); - - expect(() => - validateRoutes(routesWithMaxCharLengthRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithMaxCharLengthRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.RULE_TOO_LONG, - testRoutesPath - ) - ); - }); - - it("should throw a fatal error if any include or exclude routing rule does not start with a `/`", ({ - expect, - }) => { - const routesWithInvalidIncludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: ["hello"], - exclude: [], - }; - const routesWithInvalidExcludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: ["/*"], - exclude: ["hello"], - }; - const routesWithInvalidRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - description: "Test routes Object", - include: ["hello"], - exclude: ["goodbye"], - }; - - expect(() => - validateRoutes(routesWithInvalidIncludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithInvalidIncludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.INVALID_RULES, - testRoutesPath - ) - ); - - expect(() => - validateRoutes(routesWithInvalidExcludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithInvalidExcludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.INVALID_RULES, - testRoutesPath - ) - ); - - expect(() => - validateRoutes(routesWithInvalidRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithInvalidRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.INVALID_RULES, - testRoutesPath - ) - ); - }); - - it("should throw a fatal error if there are overlapping include rules or overlapping exclude rules", ({ - expect, - }) => { - const routesWithOverlappingIncludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - include: [ - "/greeting/hello", - "/api/time", - "/date", - "/greeting/*", - "/greeting/goodbye", - "/api/*", - ], - exclude: [], - }; - const routesWithOverlappingExcludeRules: RoutesJSONSpec = { - version: ROUTES_SPEC_VERSION, - include: ["/hello"], - exclude: [ - "/greeting/hello", - "/api/time", - "/date", - "/*", - "/greeting/*", - "/greeting/goodbye", - "/api/*", - ], - }; - - expect(() => - validateRoutes(routesWithOverlappingIncludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithOverlappingIncludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.OVERLAPPING_RULES, - testRoutesPath - ) - ); - - expect(() => - validateRoutes(routesWithOverlappingExcludeRules, testRoutesPath) - ).toThrow(FatalError); - expect(() => - validateRoutes(routesWithOverlappingExcludeRules, testRoutesPath) - ).toThrow( - getRoutesValidationErrorMessage( - RoutesValidationError.OVERLAPPING_RULES, - testRoutesPath - ) - ); - }); + expect(caughtError).toBe(error); + expect(caughtError).not.toBeInstanceOf(FatalError); }); }); diff --git a/packages/wrangler/src/pages/buildFunctions.ts b/packages/wrangler/src/pages/buildFunctions.ts index 46e0684b62..2046919b7c 100644 --- a/packages/wrangler/src/pages/buildFunctions.ts +++ b/packages/wrangler/src/pages/buildFunctions.ts @@ -1,19 +1,79 @@ import { writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { FatalError } from "@cloudflare/workers-utils"; +import { + convertRoutesToRoutesJSONSpec, + generateConfigFromFileTree, + PagesFunctionsError, + PagesFunctionsErrorCode, + writeRoutesModule, +} from "@cloudflare/pages-functions"; +import { FatalError, UserError } from "@cloudflare/workers-utils"; import { toUrlPath } from "../paths"; -import { FunctionsNoRoutesError } from "./errors"; +import { ROUTES_SPEC_DESCRIPTION } from "./constants"; +import { FunctionsBuildError, FunctionsNoRoutesError } from "./errors"; import { buildPluginFromFunctions } from "./functions/buildPlugin"; import { buildWorkerFromFunctions } from "./functions/buildWorker"; -import { generateConfigFromFileTree } from "./functions/filepath-routing"; -import { writeRoutesModule } from "./functions/routes"; -import { convertRoutesToRoutesJSONSpec } from "./functions/routes-transformation"; import { getPagesTmpDir, RUNNING_BUILDERS } from "./utils"; import type { BundleResult } from "../deployment-bundle/bundle"; import type { pagesFunctionsBuildCommand } from "./build"; -import type { Config } from "./functions/routes"; +import type { Config } from "@cloudflare/pages-functions"; import type { NodeJSCompatMode } from "miniflare"; +/** + * Map a {@link PagesFunctionsErrorCode} to the original Wrangler error class + * and telemetry label that the pre-extraction code used. + * + * This ensures that Wrangler's top-level error handler still: + * - classifies user-config mistakes as `UserError`/`FatalError` (not Sentry bugs) + * - preserves the static telemetry labels for each failure mode + */ +const ERROR_CODE_TO_TELEMETRY: Record< + PagesFunctionsErrorCode, + { telemetryMessage: string; ErrorClass: typeof UserError | typeof FatalError } +> = { + [PagesFunctionsErrorCode.ROUTE_BUILD_FAILED]: { + telemetryMessage: "pages functions route build failed", + ErrorClass: FunctionsBuildError, + }, + [PagesFunctionsErrorCode.INVALID_CATCHALL_ROUTE_PARAMETER]: { + telemetryMessage: "pages functions invalid catchall route parameter", + ErrorClass: FatalError, + }, + [PagesFunctionsErrorCode.INVALID_ROUTE_PARAMETER]: { + telemetryMessage: "pages functions invalid route parameter", + ErrorClass: FatalError, + }, + [PagesFunctionsErrorCode.INVALID_MODULE_PATH]: { + telemetryMessage: "pages functions invalid module path", + ErrorClass: UserError, + }, + [PagesFunctionsErrorCode.INVALID_MODULE_IDENTIFIER]: { + telemetryMessage: "pages functions invalid module identifier", + ErrorClass: UserError, + }, +}; + +/** + * Re-throw a {@link PagesFunctionsError} as the appropriate Wrangler error class + * with the original telemetry label. Non-PagesFunctionsError exceptions and + * errors that are already UserError/FatalError are re-thrown unchanged. + * + * @param e - The caught exception + */ +function rethrowAsWranglerError(e: unknown): never { + if (e instanceof FatalError || e instanceof UserError) { + throw e; + } + if (!(e instanceof PagesFunctionsError)) { + throw e; + } + + const mapping = ERROR_CODE_TO_TELEMETRY[e.code]; + throw new mapping.ErrorClass(e.message, { + telemetryMessage: mapping.telemetryMessage, + }); +} + /** * Builds a Functions worker based on the functions directory, with filepath and handler based routing. * @throws FunctionsNoRoutesError when there are no routes found in the functions directory @@ -75,10 +135,15 @@ export async function buildFunctions({ const baseURL = toUrlPath("/"); - const config: Config = await generateConfigFromFileTree({ - baseDir: functionsDirectory, - baseURL, - }); + let config: Config; + try { + config = await generateConfigFromFileTree({ + baseDir: functionsDirectory, + baseURL, + }); + } catch (e) { + rethrowAsWranglerError(e); + } if (!config.routes || config.routes.length === 0) { throw new FunctionsNoRoutesError( @@ -88,7 +153,10 @@ export async function buildFunctions({ } if (routesOutputPath) { - const routesJSON = convertRoutesToRoutesJSONSpec(config.routes); + const routesJSON = convertRoutesToRoutesJSONSpec( + config.routes, + ROUTES_SPEC_DESCRIPTION + ); writeFileSync(routesOutputPath, JSON.stringify(routesJSON, null, 2)); } @@ -99,11 +167,15 @@ export async function buildFunctions({ ); } - await writeRoutesModule({ - config, - srcDir: functionsDirectory, - outfile: routesModule, - }); + try { + await writeRoutesModule({ + config, + srcDir: functionsDirectory, + outfile: routesModule, + }); + } catch (e) { + rethrowAsWranglerError(e); + } const absoluteFunctionsDirectory = resolve(functionsDirectory); let bundle: BundleResult; diff --git a/packages/wrangler/src/pages/constants.ts b/packages/wrangler/src/pages/constants.ts index e94d3b0421..0bfae49bd3 100644 --- a/packages/wrangler/src/pages/constants.ts +++ b/packages/wrangler/src/pages/constants.ts @@ -1,5 +1,15 @@ import { version as wranglerVersion } from "../../package.json"; +// Re-export from @cloudflare/pages-functions — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +export { + MAX_FUNCTIONS_ROUTES_RULES, + MAX_FUNCTIONS_ROUTES_RULE_LENGTH, + ROUTES_SPEC_VERSION, +} from "@cloudflare/pages-functions"; + const isWindows = process.platform === "win32"; export const MAX_ASSET_COUNT_DEFAULT = 20_000; @@ -16,9 +26,4 @@ export const MAX_DEPLOYMENT_ATTEMPTS = 3; export const MAX_DEPLOYMENT_STATUS_ATTEMPTS = 5; export const MAX_CHECK_MISSING_ATTEMPTS = 5; export const SECONDS_TO_WAIT_FOR_PROXY = 5; -/** Max number of rules in _routes.json */ -export const MAX_FUNCTIONS_ROUTES_RULES = 100; -/** Max char length of each rule in _routes.json */ -export const MAX_FUNCTIONS_ROUTES_RULE_LENGTH = 100; -export const ROUTES_SPEC_VERSION = 1; export const ROUTES_SPEC_DESCRIPTION = `Generated by wrangler@${wranglerVersion}`; diff --git a/packages/wrangler/src/pages/functions/buildWorker.ts b/packages/wrangler/src/pages/functions/buildWorker.ts index b2c2ca525a..3b0bc12305 100644 --- a/packages/wrangler/src/pages/functions/buildWorker.ts +++ b/packages/wrangler/src/pages/functions/buildWorker.ts @@ -37,6 +37,29 @@ export type Options = { metafile?: string | boolean; }; +/** + * Builds Pages Functions using Wrangler's existing bundling pipeline. + * + * Route discovery and route-module generation have moved to + * `@cloudflare/pages-functions`, but this final bundling step intentionally + * remains in Wrangler. Wrangler integrates with `bundleWorker()` to support + * behavior not currently provided by the standalone package, including watch + * mode, local-development checks, Node.js compatibility, build notifications, + * Wrangler-specific output options, asset handling, module collection, and + * bundle metadata. + * + * This implementation also requires Wrangler's copy of + * `templates/pages-template-worker.ts` as its esbuild entry point. That + * template cannot be removed until Wrangler delegates this entire build step + * to `@cloudflare/pages-functions`. + * + * TODO: Move the remaining Wrangler-specific build capabilities into + * `@cloudflare/pages-functions`, update Wrangler to delegate to that package, + * and then remove this function and Wrangler's template copy. + * + * @param options - Options controlling the Pages Functions bundle. + * @returns The resulting Wrangler Worker bundle. + */ export function buildWorkerFromFunctions({ routesModule, outfile = join(getPagesTmpDir(), `./functionsWorker-${Math.random()}.js`), diff --git a/packages/wrangler/src/pages/functions/filepath-routing.ts b/packages/wrangler/src/pages/functions/filepath-routing.ts index d8fc822965..ed66209ea5 100644 --- a/packages/wrangler/src/pages/functions/filepath-routing.ts +++ b/packages/wrangler/src/pages/functions/filepath-routing.ts @@ -1,244 +1,11 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { FatalError } from "@cloudflare/workers-utils"; -import { build } from "esbuild"; -import { toUrlPath } from "../../paths"; -import { FunctionsBuildError } from "../errors"; -import type { UrlPath } from "../../paths"; -import type { HTTPMethod, RouteConfig } from "./routes"; - -export async function generateConfigFromFileTree({ - baseDir, - baseURL, -}: { - baseDir: string; - baseURL: UrlPath; -}) { - let routeEntries: RouteConfig[] = []; - - if (!baseURL.startsWith("/")) { - baseURL = `/${baseURL}` as UrlPath; - } - - if (baseURL.endsWith("/")) { - baseURL = baseURL.slice(0, -1) as UrlPath; - } - - await forEachFile(baseDir, async (filepath) => { - const ext = path.extname(filepath); - if (/^\.(mjs|js|ts|tsx|jsx)$/.test(ext)) { - const { metafile } = await build({ - metafile: true, - write: false, - bundle: false, - entryPoints: [path.resolve(filepath)], - }).catch((e) => { - throw new FunctionsBuildError(e.message, { - telemetryMessage: "pages functions route build failed", - }); - }); - const exportNames: string[] = []; - if (metafile) { - for (const output in metafile?.outputs) { - exportNames.push(...metafile.outputs[output].exports); - } - } - for (const exportName of exportNames) { - const [match, method = ""] = (exportName.match( - /^onRequest(Get|Post|Put|Patch|Delete|Options|Head)?$/ - ) ?? []) as (string | undefined)[]; - - if (match) { - const basename = path.basename(filepath).slice(0, -ext.length); - - const isIndexFile = basename === "index"; - // TODO: deprecate _middleware_ in favor of _middleware - const isMiddlewareFile = - basename === "_middleware" || basename === "_middleware_"; - - let routePath = path - .relative(baseDir, filepath) - .slice(0, -ext.length); - let mountPath = path.dirname(routePath); - - if (isIndexFile || isMiddlewareFile) { - routePath = path.dirname(routePath); - } - - if (routePath === ".") { - routePath = ""; - } - if (mountPath === ".") { - mountPath = ""; - } - - routePath = `${baseURL}/${routePath}`; - mountPath = `${baseURL}/${mountPath}`; - - routePath = convertCatchallParams(routePath); - routePath = convertSimpleParams(routePath); - mountPath = convertCatchallParams(mountPath); - mountPath = convertSimpleParams(mountPath); - - // These are used as module specifiers so UrlPaths are okay to use even on Windows - const modulePath = toUrlPath(path.relative(baseDir, filepath)); - - const routeEntry: RouteConfig = { - routePath: toUrlPath(routePath), - mountPath: toUrlPath(mountPath), - method: method.toUpperCase() as HTTPMethod, - [isMiddlewareFile ? "middleware" : "module"]: [ - `${modulePath}:${exportName}`, - ], - }; - - routeEntries.push(routeEntry); - } - } - } - }); - // Combine together any routes (index routes) which contain both a module and a middleware - routeEntries = routeEntries.reduce( - (acc: typeof routeEntries, { routePath, ...rest }) => { - const existingRouteEntry = acc.find( - (routeEntry) => - routeEntry.routePath === routePath && - routeEntry.method === rest.method - ); - if (existingRouteEntry !== undefined) { - Object.assign(existingRouteEntry, rest); - } else { - acc.push({ routePath, ...rest }); - } - return acc; - }, - [] - ); - - routeEntries.sort((a, b) => compareRoutes(a, b)); - - return { - routes: routeEntries, - }; -} - -// Ensure routes are produced in order of precedence so that -// more specific routes aren't occluded from matching due to -// less specific routes appearing first in the route list. -export function compareRoutes( - { routePath: routePathA, method: methodA }: RouteConfig, - { routePath: routePathB, method: methodB }: RouteConfig -) { - function parseRoutePath(routePath: UrlPath): string[] { - return routePath.slice(1).split("/").filter(Boolean); - } - - const segmentsA = parseRoutePath(routePathA); - const segmentsB = parseRoutePath(routePathB); - - // sort routes with fewer segments after those with more segments - if (segmentsA.length !== segmentsB.length) { - return segmentsB.length - segmentsA.length; - } - - for (let i = 0; i < segmentsA.length; i++) { - const isWildcardA = segmentsA[i].includes("*"); - const isWildcardB = segmentsB[i].includes("*"); - const isParamA = segmentsA[i].includes(":"); - const isParamB = segmentsB[i].includes(":"); - - // sort wildcard segments after non-wildcard segments - if (isWildcardA && !isWildcardB) { - return 1; - } - if (!isWildcardA && isWildcardB) { - return -1; - } - - // sort dynamic param segments after non-param segments - if (isParamA && !isParamB) { - return 1; - } - if (!isParamA && isParamB) { - return -1; - } - } - - // sort routes that specify an HTTP before those that don't - if (methodA && !methodB) { - return -1; - } - if (!methodA && methodB) { - return 1; - } - - // all else equal, just sort the paths lexicographically - return routePathA.localeCompare(routePathB); -} - -async function forEachFile( - baseDir: string, - fn: (filepath: string) => T | Promise -) { - const searchPaths = [baseDir]; - const returnValues: T[] = []; - - while (isNotEmpty(searchPaths)) { - const cwd = searchPaths.shift(); - const dir = await fs.readdir(cwd, { withFileTypes: true }); - for (const entry of dir) { - const pathname = path.join(cwd, entry.name); - if (entry.isDirectory()) { - searchPaths.push(pathname); - } else if (entry.isFile()) { - returnValues.push(await fn(pathname)); - } - } - } - - return returnValues; -} - -interface NonEmptyArray extends Array { - shift(): T; -} -function isNotEmpty(array: T[]): array is NonEmptyArray { - return array.length > 0; -} - -/** - * See https://github.com/pillarjs/path-to-regexp?tab=readme-ov-file#named-parameters - */ -const validParamNameRegExp = /^[A-Za-z0-9_]+$/; - -/** - * Transform all [[id]] => :id* - */ -function convertCatchallParams(routePath: string): string { - return routePath.replace(/\[\[([^\]]+)\]\]/g, (_, param) => { - if (validParamNameRegExp.test(param)) { - return `:${param}*`; - } else { - throw new FatalError( - `Invalid Pages function route parameter - "[[${param}]]". Parameters names must only contain alphanumeric and underscore characters.`, - { telemetryMessage: "pages functions invalid catchall route parameter" } - ); - } - }); -} - -/** - * Transform all [id] => :id - */ -function convertSimpleParams(routePath: string): string { - return routePath.replace(/\[([^\]]+)\]/g, (_, param) => { - if (validParamNameRegExp.test(param)) { - return `:${param}`; - } else { - throw new FatalError( - `Invalid Pages function route parameter - "[${param}]". Parameter names must only contain alphanumeric and underscore characters.`, - { telemetryMessage: "pages functions invalid route parameter" } - ); - } - }); -} +// Re-export from @cloudflare/pages-functions — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +export { + generateConfigFromFileTree, + compareRoutes, + PagesFunctionsBuildError, + PagesFunctionsError, + PagesFunctionsErrorCode, +} from "@cloudflare/pages-functions"; diff --git a/packages/wrangler/src/pages/functions/identifiers.ts b/packages/wrangler/src/pages/functions/identifiers.ts index 4f4012c203..0254ae9d0a 100644 --- a/packages/wrangler/src/pages/functions/identifiers.ts +++ b/packages/wrangler/src/pages/functions/identifiers.ts @@ -1,72 +1,8 @@ -const RESERVED_KEYWORDS = [ - "do", - "if", - "in", - "for", - "let", - "new", - "try", - "var", - "case", - "else", - "enum", - "eval", - "null", - "this", - "true", - "void", - "with", - "await", - "break", - "catch", - "class", - "const", - "false", - "super", - "throw", - "while", - "yield", - "delete", - "export", - "import", - "public", - "return", - "static", - "switch", - "typeof", - "default", - "extends", - "finally", - "package", - "private", - "continue", - "debugger", - "function", - "arguments", - "interface", - "protected", - "implements", - "instanceof", - "NaN", - "Infinity", - "undefined", -]; - -const reservedKeywordRegex = new RegExp(`^${RESERVED_KEYWORDS.join("|")}$`); - -const identifierNameRegex = - /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u; - -const validIdentifierRegex = new RegExp( - `(?!(${reservedKeywordRegex.source})$)${identifierNameRegex.source}`, - "u" -); - -export const isValidIdentifier = (identifier: string) => - validIdentifierRegex.test(identifier); - -export const normalizeIdentifier = (identifier: string) => - identifier.replace( - /(?:^[^$_\p{ID_Start}])|[^$_\u200C\u200D\p{ID_Continue}]/gu, - "_" - ); +// Re-export from @cloudflare/pages-functions — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +export { + isValidIdentifier, + normalizeIdentifier, +} from "@cloudflare/pages-functions"; diff --git a/packages/wrangler/src/pages/functions/routes-consolidation.ts b/packages/wrangler/src/pages/functions/routes-consolidation.ts index c833682548..9f4b96b0e8 100644 --- a/packages/wrangler/src/pages/functions/routes-consolidation.ts +++ b/packages/wrangler/src/pages/functions/routes-consolidation.ts @@ -1,73 +1,5 @@ -import { MAX_FUNCTIONS_ROUTES_RULE_LENGTH } from "../constants"; - -/** - * consolidateRoutes consolidates redundant routes - eg. ["/api/*"", "/api/foo"] -> ["/api/*""] - * @param routes If this is the same order as Functions routes (with most-specific first), - * it will be more efficient to reverse it first. Should be in the format: /api/foo, /api/* - * @returns Non-redundant list of routes - */ -export function consolidateRoutes(routes: string[]): string[] { - // First we need to trim any rules that are too long and deduplicate the result - const routesShortened = Array.from( - new Set(routes.map((route) => shortenRoute(route))) - ); - - // create a map of the routes - const routesMap = new Map(); - for (const route of routesShortened) { - routesMap.set(route, true); - } - // Find routes that might render other routes redundant - for (const route of routesShortened.filter((r) => r.endsWith("/*"))) { - // Make sure the route still exists in the map - if (routesMap.has(route)) { - // Remove splat at the end, leaving the / - // eg. /api/* -> /api/ - const routeTrimmed = route.substring(0, route.length - 1); - for (const nextRoute of routesMap.keys()) { - // Delete any route that has the wildcard route as a prefix - if (nextRoute !== route && nextRoute.startsWith(routeTrimmed)) { - routesMap.delete(nextRoute); - } - } - } - } - return Array.from(routesMap.keys()); -} - -/** - * Shortens a route until it's within the rule length limit defined in - * constants.MAX_FUNCTIONS_ROUTES_RULE_LENGTH - * Eg. /aaa/bbb -> /aaa/* - * @param routeToShorten Route to shorten if needed - * @param maxLength Max length of route to try to shorten to - */ -export function shortenRoute( - routeToShorten: string, - maxLength: number = MAX_FUNCTIONS_ROUTES_RULE_LENGTH -): string { - if (routeToShorten.length <= maxLength) { - return routeToShorten; - } - - let route = routeToShorten; - // May have to try multiple times for longer segments - for (let i = 0; i < routeToShorten.length; i++) { - // Shorten to the first slash within the limit - for (let j = maxLength - 1 - i; j > 0; j--) { - if (route[j] === "/") { - route = route.slice(0, j) + "/*"; - break; - } - } - if (route.length <= maxLength) { - break; - } - } - - // If we failed to shorten it, fall back to include-all rather than breaking - if (route.length > maxLength) { - route = "/*"; - } - return route; -} +// Re-export from @cloudflare/pages-functions — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +export { consolidateRoutes, shortenRoute } from "@cloudflare/pages-functions"; diff --git a/packages/wrangler/src/pages/functions/routes-transformation.ts b/packages/wrangler/src/pages/functions/routes-transformation.ts index a9dde50e28..f5017ab98d 100644 --- a/packages/wrangler/src/pages/functions/routes-transformation.ts +++ b/packages/wrangler/src/pages/functions/routes-transformation.ts @@ -1,119 +1,11 @@ -import { join as pathJoin } from "node:path"; -import { toUrlPath } from "../../paths"; -import { - MAX_FUNCTIONS_ROUTES_RULES, - ROUTES_SPEC_DESCRIPTION, - ROUTES_SPEC_VERSION, -} from "../constants"; -import { consolidateRoutes } from "./routes-consolidation"; -import type { RouteConfig } from "./routes"; - -/** Interface for _routes.json */ -export interface RoutesJSONSpec { - version: typeof ROUTES_SPEC_VERSION; - description?: string; - include: string[]; - exclude: string[]; -} - -type RoutesJSONRouteInput = Pick[]; - -export function convertRoutesToGlobPatterns( - routes: RoutesJSONRouteInput -): string[] { - const convertedRoutes = routes.map(({ routePath, middleware }) => { - const globbedRoutePath: string = routePath.replace(/:\w+\*?.*/, "*"); - - // Middleware mountings need to end in glob so that they can handle their - // own sub-path routes - if ( - typeof middleware === "string" || - (Array.isArray(middleware) && middleware.length > 0) - ) { - if (!globbedRoutePath.endsWith("*")) { - return toUrlPath(pathJoin(globbedRoutePath, "*")); - } - } - - return toUrlPath(globbedRoutePath); - }); - - return Array.from(new Set(convertedRoutes)); -} - -/** - * Converts Functions routes like /foo/:bar to a Routing object that's used - * to determine if a request should run in the Functions user-worker. - * Also consolidates redundant routes such as [/foo/bar, /foo/:bar] -> /foo/* - * - * @returns RoutesJSONSpec to be written to _routes.json - */ -export function convertRoutesToRoutesJSONSpec( - routes: RoutesJSONRouteInput -): RoutesJSONSpec { - // The initial routes coming in are sorted most-specific to least-specific. - // The order doesn't have any affect on the output of this function, but - // it should speed up route consolidation with less-specific routes being first. - const reversedRoutes = [...routes].reverse(); - const include = convertRoutesToGlobPatterns(reversedRoutes); - return optimizeRoutesJSONSpec({ - version: ROUTES_SPEC_VERSION, - description: ROUTES_SPEC_DESCRIPTION, - include, - exclude: [], - }); -} - -/** - * Optimizes and returns a new Routes JSON Spec instance performing - * de-duping, consolidation, truncation, and sorting - */ -export function optimizeRoutesJSONSpec(spec: RoutesJSONSpec): RoutesJSONSpec { - const optimizedSpec = { ...spec }; - - let consolidatedRoutes = consolidateRoutes(optimizedSpec.include); - if (consolidatedRoutes.length > MAX_FUNCTIONS_ROUTES_RULES) { - consolidatedRoutes = ["/*"]; - } - // Sort so that least-specific routes are first - consolidatedRoutes.sort((a, b) => compareRoutes(b, a)); - - optimizedSpec.include = consolidatedRoutes; - - return optimizedSpec; -} - -/** - * Simplified routes comparison (copied from the one in filepath-routing.) - * This version will sort most-specific to least-specific, but the input is simplified - * routes like /foo/*, /foo, etc - */ -export function compareRoutes(routeA: string, routeB: string) { - function parseRoutePath(routePath: string): string[] { - return routePath.slice(1).split("/").filter(Boolean); - } - - const segmentsA = parseRoutePath(routeA); - const segmentsB = parseRoutePath(routeB); - - // sort routes with fewer segments after those with more segments - if (segmentsA.length !== segmentsB.length) { - return segmentsB.length - segmentsA.length; - } - - for (let i = 0; i < segmentsA.length; i++) { - const isWildcardA = segmentsA[i].includes("*"); - const isWildcardB = segmentsB[i].includes("*"); - - // sort wildcard segments after non-wildcard segments - if (isWildcardA && !isWildcardB) { - return 1; - } - if (!isWildcardA && isWildcardB) { - return -1; - } - } - - // all else equal, just sort the paths lexicographically - return routeA.localeCompare(routeB); -} +// Re-export from @cloudflare/pages-functions — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +export { + convertRoutesToGlobPatterns, + convertRoutesToRoutesJSONSpec, + optimizeRoutesJSONSpec, + compareRoutesSimplified as compareRoutes, +} from "@cloudflare/pages-functions"; +export type { RoutesJSONSpec } from "@cloudflare/pages-functions"; diff --git a/packages/wrangler/src/pages/functions/routes-validation.ts b/packages/wrangler/src/pages/functions/routes-validation.ts index d537f6b89e..001b0664d3 100644 --- a/packages/wrangler/src/pages/functions/routes-validation.ts +++ b/packages/wrangler/src/pages/functions/routes-validation.ts @@ -1,204 +1,95 @@ -import { FatalError } from "@cloudflare/workers-utils"; import { - MAX_FUNCTIONS_ROUTES_RULE_LENGTH, - MAX_FUNCTIONS_ROUTES_RULES, - ROUTES_SPEC_VERSION, -} from "../constants"; -import { getRoutesValidationErrorMessage } from "../errors"; -import type { RoutesJSONSpec } from "./routes-transformation"; - -export enum RoutesValidationError { - INVALID_JSON_SPEC, - NO_INCLUDE_RULES, - INVALID_RULES, - TOO_MANY_RULES, - RULE_TOO_LONG, - OVERLAPPING_RULES, -} - -/** - * Check if given routes data is a valid RoutesJSONSpec - */ -export function isRoutesJSONSpec(data: unknown): data is RoutesJSONSpec { - return ( - (typeof data === "object" && - data && - "version" in data && - typeof (data as RoutesJSONSpec).version === "number" && - (data as RoutesJSONSpec).version === ROUTES_SPEC_VERSION && - Array.isArray((data as RoutesJSONSpec).include) && - Array.isArray((data as RoutesJSONSpec).exclude)) || - false - ); -} - -export function validateRoutes(routesJSON: RoutesJSONSpec, routesPath: string) { - if (!isRoutesJSONSpec(routesJSON)) { - throw new FatalError( - getRoutesValidationErrorMessage( - RoutesValidationError.INVALID_JSON_SPEC, - routesPath - ), - { code: 1, telemetryMessage: "pages functions routes invalid json spec" } - ); - } - - if (!hasIncludeRules(routesJSON)) { - throw new FatalError( - getRoutesValidationErrorMessage( - RoutesValidationError.NO_INCLUDE_RULES, - routesPath - ), - { - code: 1, - telemetryMessage: "pages functions routes missing include rules", - } - ); - } - - if (!hasValidRulesCount(routesJSON)) { - throw new FatalError( - getRoutesValidationErrorMessage( - RoutesValidationError.TOO_MANY_RULES, - routesPath - ), - { code: 1, telemetryMessage: "pages functions routes too many rules" } - ); - } - - if (!hasValidRuleCharCount(routesJSON)) { - throw new FatalError( - getRoutesValidationErrorMessage( - RoutesValidationError.RULE_TOO_LONG, - routesPath - ), - { code: 1, telemetryMessage: "pages functions routes rule too long" } - ); - } - - if (!hasValidRules(routesJSON)) { - throw new FatalError( - getRoutesValidationErrorMessage( - RoutesValidationError.INVALID_RULES, - routesPath - ), - { code: 1, telemetryMessage: "pages functions routes invalid rules" } - ); - } - - if ( - hasOverlappingRules(routesJSON.include) || - hasOverlappingRules(routesJSON.exclude) - ) { - throw new FatalError( - getRoutesValidationErrorMessage( - RoutesValidationError.OVERLAPPING_RULES, - routesPath - ), - { code: 1, telemetryMessage: "pages functions routes overlapping rules" } - ); - } -} - -/** - * Returns true if the given `routingSpec` object contains at least - * `MIN_FUNCTIONS_ROUTES_INCLUDE_RULES` include routing rules - */ -function hasIncludeRules(routesJSON: RoutesJSONSpec): boolean { - // sanity check - // this should never be the case, because of the context from which these validation fns are - // called, but let's not assume anything - if (!routesJSON || !routesJSON.include) { - throw new Error( - "Function `hasIncludeRules` was called out of context. Attempting to validate include rules for routes that are undefined or an invalid RoutesJSONSpec" - ); - } - - return routesJSON?.include?.length > 0; -} - -/** - * Returns true if the given `routesJSON` object contains at most MAX_FUNCTIONS_ROUTES_RULES - * include and exclude routing rules, combined - */ -function hasValidRulesCount(routesJSON: RoutesJSONSpec): boolean { - // sanity check - if (!routesJSON || !routesJSON.include || !routesJSON.exclude) { - throw new Error( - "Function `hasValidRulesCount` was called out of context. Attempting to validate maximum rules count for routes that are undefined or an invalid RoutesJSONSpec" - ); - } + RoutesValidationError, + isRoutesJSONSpec, + validateRoutes as packageValidateRoutes, + getRoutesValidationErrorMessage, +} from "@cloudflare/pages-functions"; +// Re-export from @cloudflare/pages-functions — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +// +// The package's validateRoutes throws plain Error; Wrangler callers +// expect FatalError, so we wrap it here with per-validation telemetry labels. +import { FatalError } from "@cloudflare/workers-utils"; - return ( - routesJSON.include.length + routesJSON.exclude.length <= - MAX_FUNCTIONS_ROUTES_RULES - ); -} +export { + RoutesValidationError, + isRoutesJSONSpec, + getRoutesValidationErrorMessage, +}; /** - * Returns true if each individual routing rule of the given `routesJSON` object is at most - * MAX_FUNCTIONS_ROUTES_RULE_LENGTH characters long + * Map each `RoutesValidationError` code to the telemetry label that Wrangler + * emitted before the pages-functions extraction. This preserves per-validation + * telemetry granularity. */ -function hasValidRuleCharCount(routesJSON: RoutesJSONSpec): boolean { - // sanity check - if (!routesJSON || !routesJSON.include || !routesJSON.exclude) { - throw new Error( - "Function `hasValidRuleCharCount` was called out of context. Attempting to validate rules maximum character count for routes that are undefined or an invalid RoutesJSONSpec" - ); - } - - const rules = [...routesJSON.include, ...routesJSON.exclude]; - return ( - rules.filter((rule) => rule.length > MAX_FUNCTIONS_ROUTES_RULE_LENGTH) - .length === 0 - ); -} +const VALIDATION_TELEMETRY: Record = { + [RoutesValidationError.INVALID_JSON_SPEC]: + "pages functions routes invalid json spec", + [RoutesValidationError.NO_INCLUDE_RULES]: + "pages functions routes missing include rules", + [RoutesValidationError.TOO_MANY_RULES]: + "pages functions routes too many rules", + [RoutesValidationError.RULE_TOO_LONG]: "pages functions routes rule too long", + [RoutesValidationError.INVALID_RULES]: "pages functions routes invalid rules", + [RoutesValidationError.OVERLAPPING_RULES]: + "pages functions routes overlapping rules", +}; /** - * Returns true if each individual routing rule of the given `routesJSON` object is valid. - * We consider a rule to be valid if it is prefixed by slash ('/') + * Determine which `RoutesValidationError` code produced the given error message + * by comparing against the known error messages for the given `routesPath`. + * + * @param message - The error message thrown by the package + * @param routesPath - The routes path passed to `validateRoutes` + * @returns The matching error code, or `undefined` if none matched */ -function hasValidRules(routesJSON: RoutesJSONSpec): boolean { - // sanity check - if (!routesJSON || !routesJSON.include || !routesJSON.exclude) { - throw new Error( - "Function `hasValidRules` was called out of context. Attempting to validate rules for routes that are undefined or an invalid RoutesJSONSpec" - ); +function identifyValidationError( + message: string, + routesPath: string +): RoutesValidationError | undefined { + for (const code of Object.values(RoutesValidationError)) { + if (typeof code === "number") { + const expected = getRoutesValidationErrorMessage(code, routesPath); + if (message === expected) { + return code; + } + } } - - const rules = [...routesJSON.include, ...routesJSON.exclude]; - return rules.filter((rule) => !rule.match(/^\//)).length === 0; + return undefined; } /** - * Returns true if the given routes array has overlapping routing rules (eg. ["/api/*"", "/api/foo"]) + * Validate a RoutesJSONSpec, throwing a FatalError on failure. + * + * Wraps the package-level `validateRoutes` to convert plain errors into + * Wrangler-compatible `FatalError` instances for consistent CLI error handling, + * preserving the original per-validation telemetry labels. * - * based on `consolidateRoutes()` - * 🚨 O(n2) time complexity 🚨 + * @param routesJSON - The routes spec to validate + * @param routesPath - Path to the _routes.json file + * @throws FatalError when the spec is invalid */ -function hasOverlappingRules(routes: string[]): boolean { - if (!routes) { - throw new Error( - "Function `hasverlappingRules` was called out of context. Attempting to validate rules for routes that are undefined" - ); - } - - // Find routes that might render other routes redundant - const endingSplatRoutes = routes.filter((route) => route.endsWith("/*")); - - for (let i = 0; i < endingSplatRoutes.length; i++) { - const crrRoute = endingSplatRoutes[i]; - // Remove splat at the end, leaving the / - // eg. /api/* -> /api/ - const crrRouteTrimmed = crrRoute.substring(0, crrRoute.length - 1); +export function validateRoutes( + routesJSON: Parameters[0], + routesPath: string +) { + try { + packageValidateRoutes(routesJSON, routesPath); + } catch (e) { + if (!(e instanceof Error)) { + throw e; + } - for (let j = 0; j < routes.length; j++) { - const nextRoute = routes[j]; - if (nextRoute !== crrRoute && nextRoute.startsWith(crrRouteTrimmed)) { - return true; - } + const errorCode = identifyValidationError(e.message, routesPath); + if (errorCode === undefined) { + throw e; } - } - return false; + throw new FatalError(e.message, { + code: 1, + telemetryMessage: VALIDATION_TELEMETRY[errorCode], + }); + } } diff --git a/packages/wrangler/src/pages/functions/routes.ts b/packages/wrangler/src/pages/functions/routes.ts index 8889d97cf4..e53aea5ee6 100755 --- a/packages/wrangler/src/pages/functions/routes.ts +++ b/packages/wrangler/src/pages/functions/routes.ts @@ -1,165 +1,60 @@ -import fs from "node:fs/promises"; -import path from "node:path"; +// Re-export from @cloudflare/pages-functions — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +// +// writeRoutesModule is wrapped to convert PagesFunctionsError into +// the Wrangler UserError subclass with the original telemetry labels. +import { + writeRoutesModule as packageWriteRoutesModule, + PagesFunctionsError, + PagesFunctionsErrorCode, +} from "@cloudflare/pages-functions"; import { UserError } from "@cloudflare/workers-utils"; -import { isValidIdentifier, normalizeIdentifier } from "./identifiers"; -import type { UrlPath } from "../../paths"; -export type HTTPMethod = - | "HEAD" - | "OPTIONS" - | "GET" - | "POST" - | "PUT" - | "PATCH" - | "DELETE"; - -type RoutesCollection = Array<{ - routePath: UrlPath; - mountPath: UrlPath; - method?: HTTPMethod; - modules: string[]; - middlewares: string[]; -}>; - -export type Config = { - routes?: RouteConfig[]; - schedules?: unknown; +export { generateRoutesModuleSource } from "@cloudflare/pages-functions"; +export type { + HTTPMethod, + Config, + RouteConfig, +} from "@cloudflare/pages-functions"; + +/** + * Map the package's error codes for module-path / module-identifier + * validation to the original Wrangler telemetry labels. + */ +const ROUTES_MODULE_TELEMETRY: Partial< + Record +> = { + [PagesFunctionsErrorCode.INVALID_MODULE_PATH]: + "pages functions invalid module path", + [PagesFunctionsErrorCode.INVALID_MODULE_IDENTIFIER]: + "pages functions invalid module identifier", }; -export type RouteConfig = { - routePath: UrlPath; - mountPath: UrlPath; - method?: HTTPMethod; - middleware?: string | string[]; - module?: string | string[]; -}; - -type ImportMap = Map< - string, - { - filepath: string; - name: string; - identifier: string; - } ->; - -type Arguments = { - config: Config; - outfile: string; - srcDir: string; -}; - -export async function writeRoutesModule({ - config, - srcDir, - outfile = "_routes.js", -}: Arguments) { - const { importMap, routes } = parseConfig(config, srcDir); - const routesModule = generateRoutesModule(importMap, routes); - - await fs.writeFile(outfile, routesModule); - - return outfile; -} - -function parseConfig(config: Config, baseDir: string) { - baseDir = path.resolve(baseDir); - const routes: RoutesCollection = []; - const importMap: ImportMap = new Map(); - const identifierCount = new Map(); // to keep track of identifier collisions - - function parseModuleIdentifiers(paths: string | string[] | undefined) { - if (typeof paths === "undefined") { - paths = []; +/** + * Write a JavaScript routes module, wrapping package errors into + * Wrangler `UserError` instances with the original telemetry labels. + * + * @param args - Route configuration, source directory, and output path + * @returns The path the module was written to + * @throws UserError when the module path or identifier is invalid + */ +export async function writeRoutesModule( + ...args: Parameters +) { + try { + return await packageWriteRoutesModule(...args); + } catch (e) { + if (e instanceof UserError) { + throw e; } - - if (typeof paths === "string") { - paths = [paths]; - } - - return paths.map((modulePath) => { - const resolvedPath = path.resolve(baseDir, modulePath); - const moduleRoot = path.parse(resolvedPath).root; - - // Strip the drive letter (if any) to avoid confusing the drive colon with the export name separator - const strippedPath = resolvedPath.slice(moduleRoot.length - 1); - const [filepath, name = "default"] = strippedPath.split(":"); - - const fullFilepath = path.resolve(moduleRoot, filepath); - const relativePath = path.relative(baseDir, fullFilepath); - - // ensure the filepath isn't attempting to resolve to anything outside of the project - if ( - moduleRoot !== path.parse(baseDir).root || - relativePath.startsWith("..") - ) { - throw new UserError(`Invalid module path "${fullFilepath}"`, { - telemetryMessage: "pages functions invalid module path", - }); - } - - // ensure the module name (if provided) is a valid identifier to guard against injection attacks - if (name !== "default" && !isValidIdentifier(name)) { - throw new UserError(`Invalid module identifier "${name}"`, { - telemetryMessage: "pages functions invalid module identifier", - }); - } - - let { identifier } = importMap.get(resolvedPath) ?? {}; - if (!identifier) { - identifier = normalizeIdentifier(`__${relativePath}_${name}`); - - let count = identifierCount.get(identifier) ?? 0; - identifierCount.set(identifier, ++count); - - if (count > 1) { - identifier += `_${count}`; - } - - importMap.set(resolvedPath, { - filepath: fullFilepath, - name, - identifier, - }); + if (e instanceof PagesFunctionsError) { + const telemetry = ROUTES_MODULE_TELEMETRY[e.code]; + if (telemetry) { + throw new UserError(e.message, { telemetryMessage: telemetry }); } - - return identifier; - }); - } - - for (const { routePath, mountPath, method, ...props } of config.routes ?? - []) { - routes.push({ - routePath, - mountPath, - method, - middlewares: parseModuleIdentifiers(props.middleware), - modules: parseModuleIdentifiers(props.module), - }); + } + throw e; } - - return { routes, importMap }; -} - -function generateRoutesModule(importMap: ImportMap, routes: RoutesCollection) { - return `${[...importMap.values()] - .map( - ({ filepath, name, identifier }) => - `import { ${name} as ${identifier} } from ${JSON.stringify(filepath)}` - ) - .join("\n")} - -export const routes = [ - ${routes - .map( - (route) => ` { - routePath: "${route.routePath}", - mountPath: "${route.mountPath}", - method: "${route.method}", - middlewares: [${route.middlewares.join(", ")}], - modules: [${route.modules.join(", ")}], - },` - ) - .join("\n")} - ]`; } diff --git a/packages/wrangler/src/paths.ts b/packages/wrangler/src/paths.ts index 354a1126f8..3e9e700cf1 100644 --- a/packages/wrangler/src/paths.ts +++ b/packages/wrangler/src/paths.ts @@ -1,30 +1,11 @@ -import { assert } from "node:console"; import path from "node:path"; -type DiscriminatedPath = string & { - _discriminator: Discriminator; -}; - -/** - * A branded string that expects to be URL compatible. - * - * Require this type when you want callers to ensure that they have converted file-path strings into URL-safe paths. - */ -export type UrlPath = DiscriminatedPath<"UrlPath">; - -/** - * Convert a file-path string to a URL-path string. - * - * Use this helper to convert a `string` to a `UrlPath` when it is not clear whether the string needs normalizing. - * Replaces all back-slashes with forward-slashes, and throws an error if the path contains a drive letter (e.g. `C:`). - */ -export function toUrlPath(filePath: string): UrlPath { - assert( - !/^[a-z]:/i.test(filePath), - "Tried to convert a Windows file path with a drive to a URL path." - ); - return filePath.replace(/\\/g, "/") as UrlPath; -} +// Re-export from @cloudflare/workers-utils — this file is kept for +// backward compatibility with existing Wrangler-internal imports and to keep +// the initial migration minimal without changing lots and lots of files +// TODO(dario): after the initial pages-functions migration remove these re-exports +export { toUrlPath } from "@cloudflare/workers-utils"; +export type { UrlPath } from "@cloudflare/workers-utils"; /** * Get a human-readable path, relative to process.cwd(), prefixed with ./ if diff --git a/packages/wrangler/templates/pages-template-worker.ts b/packages/wrangler/templates/pages-template-worker.ts index 9bddfc8330..7a802d334f 100644 --- a/packages/wrangler/templates/pages-template-worker.ts +++ b/packages/wrangler/templates/pages-template-worker.ts @@ -1,3 +1,5 @@ +// This file is a copy of packages/pages-functions/src/templates/pages-template-worker.ts. +// Changes here should be reflected there and vice-versa. import { match } from "path-to-regexp"; //note: this explicitly does not include the * character, as pages requires this diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f15f4ec7e..c883b15d58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2467,6 +2467,34 @@ importers: specifier: ^6.0.5 version: 6.0.5(encoding@0.1.13)(typanion@3.14.0) + packages/pages-functions: + dependencies: + esbuild: + specifier: catalog:default + version: 0.28.1 + path-to-regexp: + specifier: 6.3.0 + version: 6.3.0 + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + '@types/node': + specifier: 22.15.17 + version: 22.15.17 + tsdown: + specifier: 0.16.3 + version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + typescript: + specifier: catalog:default + version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.1.5(@types/node@22.15.17)(esbuild@0.28.1)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) + packages/pages-shared: dependencies: miniflare: @@ -4448,6 +4476,9 @@ importers: '@cloudflare/deploy-helpers': specifier: workspace:* version: link:../deploy-helpers + '@cloudflare/pages-functions': + specifier: workspace:* + version: link:../pages-functions '@cloudflare/pages-shared': specifier: workspace:^ version: link:../pages-shared @@ -19492,7 +19523,7 @@ snapshots: '@rolldown/binding-wasm32-wasi@1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -27562,7 +27593,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.2.4 @@ -27591,7 +27622,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.2.4 @@ -27620,7 +27651,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.2.4 @@ -27649,7 +27680,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.2.4 @@ -27670,7 +27701,7 @@ snapshots: dependencies: '@volar/typescript': 2.4.0-alpha.18 '@vue/language-core': 2.0.29(typescript@5.8.3) - semver: 7.8.2 + semver: 7.8.5 typescript: 5.8.3 w3c-keyname@2.2.8: {} diff --git a/tools/deployments/__tests__/validate-changesets.test.ts b/tools/deployments/__tests__/validate-changesets.test.ts index 50ba902523..79bdcf72c9 100644 --- a/tools/deployments/__tests__/validate-changesets.test.ts +++ b/tools/deployments/__tests__/validate-changesets.test.ts @@ -35,6 +35,7 @@ describe("findPackageNames()", () => { "@cloudflare/format-errors", "@cloudflare/kv-asset-handler", "@cloudflare/local-explorer-ui", + "@cloudflare/pages-functions", "@cloudflare/pages-shared", "@cloudflare/playground-preview-worker", "@cloudflare/quick-edit", From 62dd693d2802817a077df091b834952e546de0ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:27:12 +0000 Subject: [PATCH 7/9] [C3] Bump sv from 0.16.5 to 0.16.6 in /packages/create-cloudflare/src/frameworks (#14909) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Wrangler automated PR updater --- .changeset/c3-frameworks-update-14909.md | 11 +++++++++++ .../create-cloudflare/src/frameworks/package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/c3-frameworks-update-14909.md diff --git a/.changeset/c3-frameworks-update-14909.md b/.changeset/c3-frameworks-update-14909.md new file mode 100644 index 0000000000..acc978989a --- /dev/null +++ b/.changeset/c3-frameworks-update-14909.md @@ -0,0 +1,11 @@ +--- +"create-cloudflare": patch +--- + +Update dependencies of "create-cloudflare" + +The following dependency versions have been updated: + +| Dependency | From | To | +| ---------- | ------ | ------ | +| sv | 0.16.5 | 0.16.6 | diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index e638d74923..b3c03a8e4e 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -18,7 +18,7 @@ "create-waku": "0.12.5-1.0.0-alpha.10-0", "gatsby": "5.16.1", "nuxi": "3.37.0", - "sv": "0.16.5" + "sv": "0.16.6" }, "info": [ "This package.json is only used to keep track of the frameworks cli dependencies", From 301d6bed3ec1d4edcaac8f62ced89e5519fd298f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:28:56 +0000 Subject: [PATCH 8/9] [C3] Bump create-next-app from 16.2.11 to 16.2.12 in /packages/create-cloudflare/src/frameworks (#14908) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Wrangler automated PR updater --- .changeset/c3-frameworks-update-14908.md | 11 +++++++++++ .../create-cloudflare/src/frameworks/package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/c3-frameworks-update-14908.md diff --git a/.changeset/c3-frameworks-update-14908.md b/.changeset/c3-frameworks-update-14908.md new file mode 100644 index 0000000000..e77cb598fa --- /dev/null +++ b/.changeset/c3-frameworks-update-14908.md @@ -0,0 +1,11 @@ +--- +"create-cloudflare": patch +--- + +Update dependencies of "create-cloudflare" + +The following dependency versions have been updated: + +| Dependency | From | To | +| --------------- | ------- | ------- | +| create-next-app | 16.2.11 | 16.2.12 | diff --git a/packages/create-cloudflare/src/frameworks/package.json b/packages/create-cloudflare/src/frameworks/package.json index b3c03a8e4e..f0a9f61c7e 100644 --- a/packages/create-cloudflare/src/frameworks/package.json +++ b/packages/create-cloudflare/src/frameworks/package.json @@ -7,7 +7,7 @@ "create-astro": "5.2.3", "create-docusaurus": "3.10.2", "create-hono": "0.19.4", - "create-next-app": "16.2.11", + "create-next-app": "16.2.12", "create-qwik": "1.20.0", "create-react-router": "8.3.0", "create-rwsdk": "3.1.3", From 5c25cfe4e03e0d3d42ddab57adc3274d6f6a1a30 Mon Sep 17 00:00:00 2001 From: "Sakamoto, Kazunori" Date: Thu, 30 Jul 2026 03:02:18 +0900 Subject: [PATCH 9/9] [miniflare] Disable keep-alive timeout on the loopback server (#14850) Co-authored-by: Ben <4991309+NuroDev@users.noreply.github.com> --- .changeset/miniflare-loopback-keepalive.md | 7 +++ packages/miniflare/src/index.ts | 8 +++ packages/miniflare/test/index.spec.ts | 70 ++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 .changeset/miniflare-loopback-keepalive.md diff --git a/.changeset/miniflare-loopback-keepalive.md b/.changeset/miniflare-loopback-keepalive.md new file mode 100644 index 0000000000..0aac83f3fa --- /dev/null +++ b/.changeset/miniflare-loopback-keepalive.md @@ -0,0 +1,7 @@ +--- +"miniflare": patch +--- + +Disable the keep-alive timeout on the loopback server + +The loopback server (which serves custom service bindings, `@cloudflare/vite-plugin`'s module transport, and other workerd → Node callbacks) used Node's default `server.keepAliveTimeout` of 5 seconds. workerd pools and reuses connections to the loopback server, so Node closing an idle pooled socket raced with workerd sending the next request on it, making that request fail with `Network connection lost`. The failure is probabilistic and load-dependent; under `@cloudflare/vite-plugin` with a large SSR module graph and a cold optimizer cache (thousands of `fetchModule` calls with multi-second idle gaps between bursts), it broke most dev sessions. Disable the idle keep-alive timeout on the loopback server, mirroring the undici pools used for dispatch in the opposite direction. diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index a34e0aae7d..756cc35db8 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1899,6 +1899,14 @@ export class Miniflare { http.createServer(this.#handleLoopback), /* grace */ 0 ); + // Disable the idle keep-alive timeout for local dev — workerd pools + // and reuses connections to the loopback server, and Node's default + // `keepAliveTimeout` (5s) races with that reuse: Node closes an idle + // pooled socket just as workerd sends the next request on it, which + // surfaces in the Worker as "Network connection lost". This mirrors + // the undici pools used for dispatch in the opposite direction, which + // already disable their timeouts. + server.keepAliveTimeout = 0; server.on("upgrade", this.#handleLoopbackUpgrade); server.listen(0, hostname, () => resolve(server)); }); diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 3de2c426c8..1b71ba832a 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -258,6 +258,76 @@ test("Miniflare: setOptions: can update host/port", async ({ expect }) => { expect(state2.loopbackPort).toBe(state3.loopbackPort); }); +test("Miniflare: loopback server keeps idle keep-alive connections open", async ({ + expect, +}) => { + // Regression test for https://github.com/cloudflare/workers-sdk/issues/14848: + // workerd pools and reuses connections to the loopback server, and Node's + // default `keepAliveTimeout` (5s) closed idle pooled sockets, racing with + // workerd reusing them and failing requests with "Network connection lost". + + // Extract loopback port from injected live reload script + const loopbackPortRegexp = /\/\/ Miniflare Live Reload.+url\.port = (\d+)/s; + const mf = new Miniflare({ + port: 0, + liveReload: true, + modules: true, + script: `export default { + fetch() { + return new Response("

👋

", { + headers: { "Content-Type": "text/html;charset=utf-8" } + }); + } + }`, + }); + useDispose(mf); + const res = await mf.dispatchFetch("http://localhost"); + const loopbackPort = loopbackPortRegexp.exec(await res.text())?.[1]; + assert(loopbackPort !== undefined); + + const socket = net.connect(parseInt(loopbackPort), "127.0.0.1"); + await once(socket, "connect"); + + // The loopback server responds 404 to unknown paths, which is enough to + // exercise keep-alive connection reuse + function sendRequest(): Promise { + return new Promise((resolve, reject) => { + const onData = (chunk: Buffer) => { + cleanup(); + resolve(chunk.toString("utf8").split("\r\n")[0]); + }; + const onCloseOrError = (errorOrHadError?: unknown) => { + cleanup(); + reject( + errorOrHadError instanceof Error + ? errorOrHadError + : new Error("Socket closed before response received") + ); + }; + function cleanup() { + socket.off("data", onData); + socket.off("close", onCloseOrError); + socket.off("error", onCloseOrError); + } + socket.on("data", onData); + socket.on("close", onCloseOrError); + socket.on("error", onCloseOrError); + socket.write( + "GET /unknown HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n" + ); + }); + } + + expect(await sendRequest()).toBe("HTTP/1.1 404 Not Found"); + + // Node's default `keepAliveTimeout` is 5 seconds, so without the fix this + // deterministically closes the idle socket after ~5 seconds and the second + // request fails + await new Promise((resolve) => setTimeout(resolve, 6000)); + expect(await sendRequest()).toBe("HTTP/1.1 404 Not Found"); + socket.destroy(); +}); + const interfaces = os.networkInterfaces(); const localInterface = (interfaces["en0"] ?? interfaces["eth0"])?.find( ({ family }) => family === "IPv4"