From 31442b8c07eb01594f6606e07459c7d8025b3b9d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 21 Jan 2026 11:59:01 +0000 Subject: [PATCH 01/26] [pages-functions] Add @cloudflare/pages-functions package Extracts the Pages functions-to-worker compilation logic from wrangler into a standalone package. This is the first step towards Autoconfig Pages (DEVX-2407). --- .changeset/extract-pages-functions.md | 17 + packages/pages-functions/README.md | 94 +++++ .../pages-functions/__tests__/codegen.test.ts | 92 +++++ .../pages-functions/__tests__/compile.test.ts | 73 ++++ .../__tests__/filepath-routing.test.ts | 369 ++++++++++++++++++ .../fixtures/basic-functions/_middleware.ts | 5 + .../fixtures/basic-functions/api/[id].ts | 5 + .../fixtures/basic-functions/index.ts | 1 + .../fixtures/empty-functions/.gitkeep | 0 .../__tests__/identifiers.test.ts | 44 +++ .../__tests__/routes-consolidation.test.ts | 218 +++++++++++ .../__tests__/routes-transformation.test.ts | 275 +++++++++++++ packages/pages-functions/eslint.config.mjs | 9 + packages/pages-functions/package.json | 63 +++ packages/pages-functions/src/codegen.ts | 178 +++++++++ .../pages-functions/src/filepath-routing.ts | 282 +++++++++++++ packages/pages-functions/src/identifiers.ts | 83 ++++ packages/pages-functions/src/index.ts | 94 +++++ .../src/routes-consolidation.ts | 86 ++++ .../src/routes-transformation.ts | 125 ++++++ packages/pages-functions/src/runtime.ts | 154 ++++++++ packages/pages-functions/src/types.ts | 75 ++++ packages/pages-functions/tsconfig.build.json | 11 + packages/pages-functions/tsconfig.json | 10 + packages/pages-functions/vitest.config.ts | 7 + pnpm-lock.yaml | 25 ++ 26 files changed, 2395 insertions(+) create mode 100644 .changeset/extract-pages-functions.md create mode 100644 packages/pages-functions/README.md create mode 100644 packages/pages-functions/__tests__/codegen.test.ts create mode 100644 packages/pages-functions/__tests__/compile.test.ts create mode 100644 packages/pages-functions/__tests__/filepath-routing.test.ts create mode 100644 packages/pages-functions/__tests__/fixtures/basic-functions/_middleware.ts create mode 100644 packages/pages-functions/__tests__/fixtures/basic-functions/api/[id].ts create mode 100644 packages/pages-functions/__tests__/fixtures/basic-functions/index.ts create mode 100644 packages/pages-functions/__tests__/fixtures/empty-functions/.gitkeep create mode 100644 packages/pages-functions/__tests__/identifiers.test.ts create mode 100644 packages/pages-functions/__tests__/routes-consolidation.test.ts create mode 100644 packages/pages-functions/__tests__/routes-transformation.test.ts create mode 100644 packages/pages-functions/eslint.config.mjs create mode 100644 packages/pages-functions/package.json create mode 100644 packages/pages-functions/src/codegen.ts create mode 100644 packages/pages-functions/src/filepath-routing.ts create mode 100644 packages/pages-functions/src/identifiers.ts create mode 100644 packages/pages-functions/src/index.ts create mode 100644 packages/pages-functions/src/routes-consolidation.ts create mode 100644 packages/pages-functions/src/routes-transformation.ts create mode 100644 packages/pages-functions/src/runtime.ts create mode 100644 packages/pages-functions/src/types.ts create mode 100644 packages/pages-functions/tsconfig.build.json create mode 100644 packages/pages-functions/tsconfig.json create mode 100644 packages/pages-functions/vitest.config.ts diff --git a/.changeset/extract-pages-functions.md b/.changeset/extract-pages-functions.md new file mode 100644 index 000000000000..e1309ee50221 --- /dev/null +++ b/.changeset/extract-pages-functions.md @@ -0,0 +1,17 @@ +--- +"@cloudflare/pages-functions": minor +--- + +Add @cloudflare/pages-functions package + +Extracts the Pages functions-to-worker compilation logic from wrangler into a standalone package. + +This enables converting a Pages functions directory into a deployable worker entrypoint, which is needed for the Autoconfig Pages work where `wrangler deploy` should "just work" for Pages projects. + +```ts +import { compileFunctions } from "@cloudflare/pages-functions"; + +const result = await compileFunctions("./functions"); +// result.code - generated worker entrypoint +// result.routesJson - _routes.json for Pages deployment +``` diff --git a/packages/pages-functions/README.md b/packages/pages-functions/README.md new file mode 100644 index 000000000000..a56ac19e7066 --- /dev/null +++ b/packages/pages-functions/README.md @@ -0,0 +1,94 @@ +# @cloudflare/pages-functions + +Compile a Pages functions directory into a deployable worker entrypoint. + +## Installation + +```bash +npm install @cloudflare/pages-functions +``` + +## Usage + +```typescript +import * as fs from "node:fs/promises"; +import { compileFunctions } from "@cloudflare/pages-functions"; + +const result = await compileFunctions("./functions", { + fallbackService: "ASSETS", +}); + +// Write the generated worker entrypoint +await fs.writeFile("worker.js", result.code); + +// Write _routes.json for Pages deployment +await fs.writeFile("_routes.json", JSON.stringify(result.routesJson, null, 2)); +``` + +## API + +### `compileFunctions(functionsDirectory, options?)` + +Compiles a Pages functions directory into a worker entrypoint. + +#### Parameters + +- `functionsDirectory` (string): Path to the functions directory +- `options` (object, optional): + - `baseURL` (string): Base URL prefix for all routes. Default: `"/"` + - `fallbackService` (string): Fallback service binding name. Default: `"ASSETS"` + +#### Returns + +A `Promise` with: + +- `code` (string): Generated JavaScript worker entrypoint +- `routes` (RouteConfig[]): Parsed route configuration +- `routesJson` (RoutesJSONSpec): `_routes.json` content for Pages deployment + +## Functions Directory Structure + +The functions directory uses file-based routing: + +``` +functions/ +├── index.ts # Handles / +├── _middleware.ts # Middleware for all routes +├── api/ +│ ├── index.ts # Handles /api +│ ├── [id].ts # Handles /api/:id +│ └── [[catchall]].ts # Handles /api/* +``` + +### Route Parameters + +- `[param]` - Dynamic parameter (e.g., `[id].ts` → `/api/:id`) +- `[[catchall]]` - Catch-all parameter (e.g., `[[path]].ts` → `/api/:path*`) + +### Exports + +Export handlers from your function files: + +```typescript +// Handle all methods +export const onRequest = (context) => new Response("Hello"); + +// Handle specific methods +export const onRequestGet = (context) => new Response("GET"); +export const onRequestPost = (context) => new Response("POST"); +``` + +## Generated Output + +The generated code: + +1. Imports all function handlers +2. Creates a route configuration array +3. Includes the Pages Functions runtime (inlined) +4. Exports a default handler that routes requests + +The output requires `path-to-regexp` as a runtime dependency (resolved during bundling). + +## License + +MIT OR Apache-2.0 diff --git a/packages/pages-functions/__tests__/codegen.test.ts b/packages/pages-functions/__tests__/codegen.test.ts new file mode 100644 index 000000000000..26e5896b9d01 --- /dev/null +++ b/packages/pages-functions/__tests__/codegen.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { generateWorkerEntrypoint } from "../src/codegen.js"; +import type { RouteConfig } from "../src/types.js"; + +describe("codegen", () => { + describe("generateWorkerEntrypoint", () => { + it("generates imports and routes array", () => { + const routes: RouteConfig[] = [ + { + routePath: "/api/:id", + mountPath: "/api", + method: "GET", + module: ["api/[id].ts:onRequestGet"], + }, + ]; + + const code = generateWorkerEntrypoint(routes, { + functionsDirectory: "/project/functions", + fallbackService: "ASSETS", + }); + + expect(code).toContain('import { match } from "path-to-regexp"'); + expect(code).toContain("import { onRequestGet as"); + expect(code).toContain('routePath: "/api/:id"'); + expect(code).toContain('mountPath: "/api"'); + expect(code).toContain('method: "GET"'); + expect(code).toContain( + "createPagesHandler(routes, __FALLBACK_SERVICE__)" + ); + }); + + it("handles middleware routes", () => { + const routes: RouteConfig[] = [ + { + routePath: "/", + mountPath: "/", + middleware: ["_middleware.ts:onRequest"], + module: ["index.ts:onRequest"], + }, + ]; + + const code = generateWorkerEntrypoint(routes, { + functionsDirectory: "/project/functions", + }); + + expect(code).toContain("middlewares: ["); + expect(code).toContain("modules: ["); + }); + + it("generates unique identifiers for duplicate export names", () => { + const routes: RouteConfig[] = [ + { + routePath: "/a", + mountPath: "/a", + module: ["a.ts:onRequest"], + }, + { + routePath: "/b", + mountPath: "/b", + module: ["b.ts:onRequest"], + }, + ]; + + const code = generateWorkerEntrypoint(routes, { + functionsDirectory: "/project/functions", + }); + + // Should have two different identifiers + const matches = code.match(/import \{ onRequest as (\w+) \}/g); + expect(matches).toHaveLength(2); + }); + + it("includes runtime code", () => { + const routes: RouteConfig[] = [ + { + routePath: "/", + mountPath: "/", + module: ["index.ts:onRequest"], + }, + ]; + + const code = generateWorkerEntrypoint(routes, { + functionsDirectory: "/project/functions", + }); + + // Runtime code should be inlined + expect(code).toContain("function* executeRequest"); + expect(code).toContain("function createPagesHandler"); + expect(code).toContain("cloneResponse"); + }); + }); +}); diff --git a/packages/pages-functions/__tests__/compile.test.ts b/packages/pages-functions/__tests__/compile.test.ts new file mode 100644 index 000000000000..9a41e86c74ca --- /dev/null +++ b/packages/pages-functions/__tests__/compile.test.ts @@ -0,0 +1,73 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { compileFunctions, FunctionsNoRoutesError } from "../src/index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe("compileFunctions", () => { + it("compiles a functions directory to worker code", async () => { + const functionsDir = path.join(__dirname, "fixtures/basic-functions"); + const result = await compileFunctions(functionsDir); + + // Should have generated code + expect(result.code).toBeDefined(); + expect(result.code).toContain("import { match }"); + expect(result.code).toContain("createPagesHandler"); + expect(result.code).toContain("export default"); + + // Should have routes + expect(result.routes).toBeDefined(); + expect(result.routes.length).toBeGreaterThan(0); + + // Should have _routes.json spec + expect(result.routesJson).toBeDefined(); + expect(result.routesJson.version).toBe(1); + expect(result.routesJson.include).toBeDefined(); + expect(Array.isArray(result.routesJson.include)).toBe(true); + }); + + it("uses custom fallbackService", async () => { + const functionsDir = path.join(__dirname, "fixtures/basic-functions"); + const result = await compileFunctions(functionsDir, { + fallbackService: "CUSTOM_ASSETS", + }); + + expect(result.code).toContain('"CUSTOM_ASSETS"'); + }); + + it("uses custom baseURL", async () => { + const functionsDir = path.join(__dirname, "fixtures/basic-functions"); + const result = await compileFunctions(functionsDir, { + baseURL: "/v1", + }); + + // Routes should be prefixed + for (const route of result.routes) { + expect(route.routePath.startsWith("/v1")).toBe(true); + } + }); + + it("throws FunctionsNoRoutesError for empty directory", async () => { + const emptyDir = path.join(__dirname, "fixtures/empty-functions"); + + await expect(compileFunctions(emptyDir)).rejects.toThrow( + FunctionsNoRoutesError + ); + }); + + it("generates valid _routes.json", async () => { + const functionsDir = path.join(__dirname, "fixtures/basic-functions"); + const result = await compileFunctions(functionsDir); + + // Validate structure + expect(result.routesJson.version).toBe(1); + expect(result.routesJson.include.length).toBeGreaterThan(0); + expect(result.routesJson.exclude).toEqual([]); + + // Routes should be glob patterns + for (const route of result.routesJson.include) { + expect(route.startsWith("/")).toBe(true); + } + }); +}); diff --git a/packages/pages-functions/__tests__/filepath-routing.test.ts b/packages/pages-functions/__tests__/filepath-routing.test.ts new file mode 100644 index 000000000000..9284dc49dbcd --- /dev/null +++ b/packages/pages-functions/__tests__/filepath-routing.test.ts @@ -0,0 +1,369 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, test } from "vitest"; +import { + compareRoutes, + generateConfigFromFileTree, +} from "../src/filepath-routing.js"; +import type { RouteConfig, UrlPath } from "../src/types.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function routeConfig(routePath: string, method?: string): RouteConfig { + return { + routePath: routePath as UrlPath, + mountPath: "/" as UrlPath, + method: method as RouteConfig["method"], + }; +} + +describe("filepath-routing", () => { + describe("compareRoutes()", () => { + test("routes / last", () => { + expect( + compareRoutes(routeConfig("/"), routeConfig("/foo")) + ).toBeGreaterThanOrEqual(1); + expect( + compareRoutes(routeConfig("/"), routeConfig("/:foo")) + ).toBeGreaterThanOrEqual(1); + expect( + compareRoutes(routeConfig("/"), routeConfig("/:foo*")) + ).toBeGreaterThanOrEqual(1); + }); + + test("routes with fewer segments come after those with more segments", () => { + expect( + compareRoutes(routeConfig("/foo"), routeConfig("/foo/bar")) + ).toBeGreaterThanOrEqual(1); + expect( + compareRoutes(routeConfig("/foo"), routeConfig("/foo/bar/cat")) + ).toBeGreaterThanOrEqual(1); + }); + + test("routes with wildcard segments come after those without", () => { + expect(compareRoutes(routeConfig("/:foo*"), routeConfig("/foo"))).toBe(1); + expect(compareRoutes(routeConfig("/:foo*"), routeConfig("/:foo"))).toBe( + 1 + ); + }); + + test("routes with dynamic segments come after those without", () => { + expect(compareRoutes(routeConfig("/:foo"), routeConfig("/foo"))).toBe(1); + }); + + test("routes with dynamic segments occurring earlier come after those with dynamic segments in later positions", () => { + expect( + compareRoutes(routeConfig("/foo/:id/bar"), routeConfig("/foo/bar/:id")) + ).toBe(1); + }); + + test("routes with no HTTP method come after those specifying a method", () => { + expect( + compareRoutes(routeConfig("/foo"), routeConfig("/foo", "GET")) + ).toBe(1); + }); + + test("two equal routes are sorted according to their original position in the list", () => { + expect( + compareRoutes(routeConfig("/foo", "GET"), routeConfig("/foo", "GET")) + ).toBe(0); + }); + + test("it returns -1 if the first argument should appear first in the list", () => { + expect( + compareRoutes(routeConfig("/foo", "GET"), routeConfig("/foo")) + ).toBe(-1); + }); + }); + + describe("generateConfigFromFileTree", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pages-functions-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("should generate a route entry for each file in the tree", async () => { + fs.writeFileSync( + path.join(tmpDir, "foo.ts"), + ` + export function onRequestGet() {} + export function onRequestPost() {} + ` + ); + fs.writeFileSync( + path.join(tmpDir, "bar.ts"), + ` + export function onRequestPut() {} + export function onRequestDelete() {} + ` + ); + + fs.mkdirSync(path.join(tmpDir, "todos")); + fs.writeFileSync( + path.join(tmpDir, "todos/[id].ts"), + ` + export function onRequestPost() {} + export function onRequestDelete() {} + ` + ); + + fs.mkdirSync(path.join(tmpDir, "authors")); + fs.mkdirSync(path.join(tmpDir, "authors/[authorId]")); + fs.mkdirSync(path.join(tmpDir, "authors/[authorId]/todos")); + fs.writeFileSync( + path.join(tmpDir, "authors/[authorId]/todos/[todoId].ts"), + ` + export function onRequestPost() {} + ` + ); + + fs.mkdirSync(path.join(tmpDir, "books")); + fs.writeFileSync( + path.join(tmpDir, "books/[[title]].ts"), + ` + export function onRequestPost() {} + ` + ); + + fs.mkdirSync(path.join(tmpDir, "cats")); + fs.mkdirSync(path.join(tmpDir, "cats/[[breed]]")); + fs.writeFileSync( + path.join(tmpDir, "cats/[[breed]]/blah.ts"), + ` + export function onRequestPost() {} + ` + ); + + // This won't actually work at runtime but should parse + fs.writeFileSync( + path.join(tmpDir, "cats/[[breed]]/[[name]].ts"), + ` + export function onRequestPost() {} + ` + ); + + const entries = await generateConfigFromFileTree({ + baseDir: tmpDir, + baseURL: "/base" as UrlPath, + }); + + // Check we got the expected routes + expect(entries.routes.length).toBe(10); + + // Check specific routes exist + const authorsTodosRoute = entries.routes.find( + (r) => r.routePath === "/base/authors/:authorId/todos/:todoId" + ); + expect(authorsTodosRoute).toBeDefined(); + expect(authorsTodosRoute?.method).toBe("POST"); + + const catsBlahRoute = entries.routes.find( + (r) => r.routePath === "/base/cats/:breed*/blah" + ); + expect(catsBlahRoute).toBeDefined(); + + const catsNameRoute = entries.routes.find( + (r) => r.routePath === "/base/cats/:breed*/:name*" + ); + expect(catsNameRoute).toBeDefined(); + + const todosRoute = entries.routes.find( + (r) => r.routePath === "/base/todos/:id" && r.method === "POST" + ); + expect(todosRoute).toBeDefined(); + + const booksRoute = entries.routes.find( + (r) => r.routePath === "/base/books/:title*" + ); + expect(booksRoute).toBeDefined(); + + // Routes should be sorted by specificity + const routePaths = entries.routes.map((r) => r.routePath); + const authorsTodosIndex = routePaths.indexOf( + "/base/authors/:authorId/todos/:todoId" + ); + const fooIndex = routePaths.indexOf("/base/foo"); + expect(authorsTodosIndex).toBeLessThan(fooIndex); + }); + + it("should display an error if a simple route param name is invalid", async () => { + fs.mkdirSync(path.join(tmpDir, "foo")); + fs.writeFileSync( + path.join(tmpDir, "foo/[hyphen-not-allowed].ts"), + "export function onRequestPost() {}" + ); + + await expect( + generateConfigFromFileTree({ + baseDir: tmpDir, + baseURL: "/base" as UrlPath, + }) + ).rejects.toThrow( + 'Invalid Pages function route parameter - "[hyphen-not-allowed]". Parameter names must only contain alphanumeric and underscore characters.' + ); + }); + + it("should display an error if a catch-all route param name is invalid", async () => { + fs.mkdirSync(path.join(tmpDir, "foo")); + fs.writeFileSync( + path.join(tmpDir, "foo/[[hyphen-not-allowed]].ts"), + "export function onRequestPost() {}" + ); + + await expect( + generateConfigFromFileTree({ + baseDir: tmpDir, + baseURL: "/base" as UrlPath, + }) + ).rejects.toThrow( + 'Invalid Pages function route parameter - "[[hyphen-not-allowed]]". Parameter names must only contain alphanumeric and underscore characters.' + ); + }); + + it("should handle middleware files", async () => { + fs.writeFileSync( + path.join(tmpDir, "_middleware.ts"), + "export function onRequest() {}" + ); + fs.writeFileSync( + path.join(tmpDir, "index.ts"), + "export function onRequest() {}" + ); + + const entries = await generateConfigFromFileTree({ + baseDir: tmpDir, + baseURL: "/" as UrlPath, + }); + + const middlewareRoute = entries.routes.find((r) => r.middleware); + expect(middlewareRoute).toBeDefined(); + expect(middlewareRoute?.middleware).toContain("_middleware.ts:onRequest"); + + const indexRoute = entries.routes.find((r) => r.module); + expect(indexRoute).toBeDefined(); + }); + + it("should handle index files", async () => { + fs.mkdirSync(path.join(tmpDir, "api")); + fs.writeFileSync( + path.join(tmpDir, "api/index.ts"), + "export function onRequest() {}" + ); + + const entries = await generateConfigFromFileTree({ + baseDir: tmpDir, + baseURL: "/" as UrlPath, + }); + + const apiRoute = entries.routes.find((r) => r.routePath === "/api"); + expect(apiRoute).toBeDefined(); + }); + + it("should support various file extensions", async () => { + fs.writeFileSync( + path.join(tmpDir, "a.js"), + "export function onRequest() {}" + ); + fs.writeFileSync( + path.join(tmpDir, "b.mjs"), + "export function onRequest() {}" + ); + fs.writeFileSync( + path.join(tmpDir, "c.ts"), + "export function onRequest() {}" + ); + fs.writeFileSync( + path.join(tmpDir, "d.tsx"), + "export function onRequest() {}" + ); + fs.writeFileSync( + path.join(tmpDir, "e.jsx"), + "export function onRequest() {}" + ); + + const entries = await generateConfigFromFileTree({ + baseDir: tmpDir, + baseURL: "/" as UrlPath, + }); + + expect(entries.routes.length).toBe(5); + }); + }); + + describe("generateConfigFromFileTree (fixture)", () => { + it("generates routes from a functions directory", async () => { + const baseDir = path.join(__dirname, "fixtures/basic-functions"); + const config = await generateConfigFromFileTree({ baseDir }); + + expect(config.routes).toBeDefined(); + expect(config.routes.length).toBeGreaterThan(0); + + // Should have root middleware + const middlewareRoute = config.routes.find((r) => + r.middleware?.includes("_middleware.ts:onRequest") + ); + expect(middlewareRoute).toBeDefined(); + + // Should have index route + const indexRoute = config.routes.find( + (r) => r.routePath === "/" && r.module?.includes("index.ts:onRequest") + ); + expect(indexRoute).toBeDefined(); + + // Should have parameterized API routes + const getRoute = config.routes.find( + (r) => r.routePath === "/api/:id" && r.method === "GET" + ); + expect(getRoute).toBeDefined(); + + const postRoute = config.routes.find( + (r) => r.routePath === "/api/:id" && r.method === "POST" + ); + expect(postRoute).toBeDefined(); + }); + + it("converts bracket params to path-to-regexp format", async () => { + const baseDir = path.join(__dirname, "fixtures/basic-functions"); + const config = await generateConfigFromFileTree({ baseDir }); + + // [id] should become :id + const apiRoute = config.routes.find((r) => r.routePath.includes("/api/")); + expect(apiRoute?.routePath).toContain(":id"); + expect(apiRoute?.routePath).not.toContain("[id]"); + }); + + it("respects baseURL option", async () => { + const baseDir = path.join(__dirname, "fixtures/basic-functions"); + const config = await generateConfigFromFileTree({ + baseDir, + baseURL: "/prefix" as UrlPath, + }); + + // All routes should start with /prefix + for (const route of config.routes) { + expect(route.routePath.startsWith("/prefix")).toBe(true); + } + }); + + it("sorts routes by specificity", async () => { + const baseDir = path.join(__dirname, "fixtures/basic-functions"); + const config = await generateConfigFromFileTree({ baseDir }); + + // More specific routes should come before less specific ones + const routePaths = config.routes.map((r) => r.routePath); + + // /api/:id should come before / + const apiIndex = routePaths.findIndex((p) => p.includes("/api/")); + const rootIndex = routePaths.findIndex((p) => p === "/"); + + expect(apiIndex).toBeLessThan(rootIndex); + }); + }); +}); diff --git a/packages/pages-functions/__tests__/fixtures/basic-functions/_middleware.ts b/packages/pages-functions/__tests__/fixtures/basic-functions/_middleware.ts new file mode 100644 index 000000000000..0228ca4e53b8 --- /dev/null +++ b/packages/pages-functions/__tests__/fixtures/basic-functions/_middleware.ts @@ -0,0 +1,5 @@ +export const onRequest = async (context: { next: () => Promise }) => { + const response = await context.next(); + response.headers.set("X-Middleware", "true"); + return response; +}; diff --git a/packages/pages-functions/__tests__/fixtures/basic-functions/api/[id].ts b/packages/pages-functions/__tests__/fixtures/basic-functions/api/[id].ts new file mode 100644 index 000000000000..20ea7193e58b --- /dev/null +++ b/packages/pages-functions/__tests__/fixtures/basic-functions/api/[id].ts @@ -0,0 +1,5 @@ +export const onRequestGet = (context: { params: { id: string } }) => + new Response(`GET item ${context.params.id}`); + +export const onRequestPost = (context: { params: { id: string } }) => + new Response(`POST item ${context.params.id}`); diff --git a/packages/pages-functions/__tests__/fixtures/basic-functions/index.ts b/packages/pages-functions/__tests__/fixtures/basic-functions/index.ts new file mode 100644 index 000000000000..495864cfab4c --- /dev/null +++ b/packages/pages-functions/__tests__/fixtures/basic-functions/index.ts @@ -0,0 +1 @@ +export const onRequest = () => new Response("Hello from index"); diff --git a/packages/pages-functions/__tests__/fixtures/empty-functions/.gitkeep b/packages/pages-functions/__tests__/fixtures/empty-functions/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/pages-functions/__tests__/identifiers.test.ts b/packages/pages-functions/__tests__/identifiers.test.ts new file mode 100644 index 000000000000..e35c54fbf183 --- /dev/null +++ b/packages/pages-functions/__tests__/identifiers.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { isValidIdentifier, normalizeIdentifier } from "../src/identifiers.js"; + +describe("identifiers", () => { + describe("isValidIdentifier", () => { + it("accepts valid identifiers", () => { + expect(isValidIdentifier("foo")).toBe(true); + expect(isValidIdentifier("_foo")).toBe(true); + expect(isValidIdentifier("$foo")).toBe(true); + expect(isValidIdentifier("foo123")).toBe(true); + expect(isValidIdentifier("myFunction")).toBe(true); + }); + + it("rejects reserved keywords", () => { + expect(isValidIdentifier("const")).toBe(false); + expect(isValidIdentifier("let")).toBe(false); + expect(isValidIdentifier("class")).toBe(false); + expect(isValidIdentifier("function")).toBe(false); + expect(isValidIdentifier("return")).toBe(false); + }); + + it("rejects invalid identifiers", () => { + expect(isValidIdentifier("123foo")).toBe(false); + expect(isValidIdentifier("foo-bar")).toBe(false); + expect(isValidIdentifier("foo bar")).toBe(false); + }); + }); + + describe("normalizeIdentifier", () => { + it("replaces invalid characters with underscores", () => { + expect(normalizeIdentifier("foo-bar")).toBe("foo_bar"); + expect(normalizeIdentifier("foo.bar")).toBe("foo_bar"); + expect(normalizeIdentifier("foo/bar")).toBe("foo_bar"); + expect(normalizeIdentifier("123foo")).toBe("_23foo"); + }); + + it("preserves valid characters", () => { + expect(normalizeIdentifier("foo")).toBe("foo"); + expect(normalizeIdentifier("_foo")).toBe("_foo"); + expect(normalizeIdentifier("$foo")).toBe("$foo"); + expect(normalizeIdentifier("foo123")).toBe("foo123"); + }); + }); +}); diff --git a/packages/pages-functions/__tests__/routes-consolidation.test.ts b/packages/pages-functions/__tests__/routes-consolidation.test.ts new file mode 100644 index 000000000000..187c1757d32c --- /dev/null +++ b/packages/pages-functions/__tests__/routes-consolidation.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; +import { + consolidateRoutes, + MAX_FUNCTIONS_ROUTES_RULE_LENGTH, + shortenRoute, +} from "../src/routes-consolidation.js"; + +const maxRuleLength = MAX_FUNCTIONS_ROUTES_RULE_LENGTH; + +describe("routes-consolidation", () => { + describe("consolidateRoutes()", () => { + it("should consolidate redundant routes", () => { + expect(consolidateRoutes(["/api/foo", "/api/*"])).toEqual(["/api/*"]); + expect( + consolidateRoutes([ + "/api/foo", + "/api/foo/*", + "/api/bar/*", + "/api/*", + "/foo", + "/foo/bar", + "/bar/*", + "/bar/baz/*", + "/bar/baz/hello", + ]) + ).toEqual(["/api/*", "/foo", "/foo/bar", "/bar/*"]); + }); + + it("should consolidate thousands of redundant routes", () => { + // Test to make sure the consolidator isn't horribly slow + const routes: string[] = []; + const limit = 1000; + for (let i = 0; i < limit; i++) { + // Add 3 routes per id + const id = `some-id-${i}`; + routes.push(`/${id}/*`, `/${id}/foo`, `/${id}/bar/*`); + } + const consolidated = consolidateRoutes(routes); + expect(consolidated.length).toEqual(limit); + // Should be all unique + expect(Array.from(new Set(consolidated)).length).toEqual(limit); + // Should all have pattern `/$id/*` + expect( + consolidated.every((route) => route.match(/\/[a-z0-9-]+\/\*/) !== null) + ).toEqual(true); + }); + + it("should consolidate many redundant sub-routes", () => { + const routes: string[] = []; + const limit = 15; + + // Create $limit of top-level catch-all routes, with a lot of sub-routes + for (let i = 0; i < limit; i++) { + routes.push(`/foo-${i}/*`); + for (let j = 0; j < limit; j++) { + routes.push(`/foo-${i}/bar-${j}/hello`); + for (let k = 0; k < limit; k++) { + routes.push(`/foo-${i}/bar-${j}/baz-${k}/*`); + routes.push(`/foo-${i}/bar-${j}/baz-${k}/profile`); + } + } + } + + const consolidated = consolidateRoutes(routes); + expect(consolidated.length).toEqual(limit); + // Should be all unique + expect(Array.from(new Set(consolidated)).length).toEqual(limit); + // Should all have pattern `/$id/*` + expect( + consolidated.every((route) => route.match(/\/[a-z0-9-]+\/\*/) !== null) + ).toEqual(true); + }); + + it("should truncate long single-level path into catch-all path, removing other paths", () => { + expect( + consolidateRoutes([ + "/" + "a".repeat(maxRuleLength * 2), + "/foo", + "/bar/*", + "/baz/bagel/coffee", + ]) + ).toEqual(["/*"]); + }); + + it("should truncate long nested path, removing other paths", () => { + expect( + consolidateRoutes(["/foo/" + "a".repeat(maxRuleLength * 2), "/foo/bar"]) + ).toEqual(["/foo/*"]); + }); + + it("keeps non-redundant routes", () => { + const routes = ["/api/foo", "/other/bar"]; + expect(consolidateRoutes(routes)).toEqual(["/api/foo", "/other/bar"]); + }); + + it("deduplicates routes", () => { + const routes = ["/api/foo", "/api/foo", "/api/foo"]; + expect(consolidateRoutes(routes)).toEqual(["/api/foo"]); + }); + + it("keeps wildcard routes that don't overlap", () => { + const routes = ["/api/*", "/other/*"]; + expect(consolidateRoutes(routes)).toEqual(["/api/*", "/other/*"]); + }); + }); + + describe("shortenRoute()", () => { + it("should allow max length path", () => { + const route = "/" + "a".repeat(maxRuleLength - 1); + expect(route.length).toEqual(maxRuleLength); + expect(shortenRoute(route)).toEqual(route); + }); + + it("should allow max length path (with slash)", () => { + const route = "/" + "a".repeat(maxRuleLength - 2) + "/"; + expect(route.length).toEqual(maxRuleLength); + expect(shortenRoute(route)).toEqual(route); + }); + + it("should allow max length wildcard path", () => { + const route = "/" + "a".repeat(maxRuleLength - 3) + "/*"; + expect(route.length).toEqual(maxRuleLength); + expect(shortenRoute(route)).toEqual(route); + }); + + it("should truncate long specific path to shorter wildcard path", () => { + const short = shortenRoute( + "/" + + "a".repeat(maxRuleLength * 0.6) + + "/" + + "b".repeat(maxRuleLength * 0.6) + ); + expect(short).toEqual("/" + "a".repeat(maxRuleLength * 0.6) + "/*"); + expect(short.length).toBeLessThanOrEqual(maxRuleLength); + }); + + it("should truncate long specific path (with slash) to shorter wildcard path", () => { + const short = shortenRoute( + "/" + + "a".repeat(maxRuleLength * 0.6) + + "/" + + "b".repeat(maxRuleLength * 0.6) + + "/" + ); + expect(short).toEqual("/" + "a".repeat(maxRuleLength * 0.6) + "/*"); + expect(short.length).toBeLessThanOrEqual(maxRuleLength); + }); + + it("should truncate long wildcard path to shorter wildcard path", () => { + const short = shortenRoute( + "/" + + "a".repeat(maxRuleLength * 0.6) + + "/" + + "b".repeat(maxRuleLength * 0.6) + + "/*" + ); + expect(short).toEqual("/" + "a".repeat(maxRuleLength * 0.6) + "/*"); + expect(short.length).toBeLessThanOrEqual(maxRuleLength); + }); + + it("should truncate long single-level specific path to catch-all path", () => { + expect(shortenRoute("/" + "a".repeat(maxRuleLength * 2))).toEqual("/*"); + }); + + it("should truncate long single-level specific path (with slash) to catch-all path", () => { + expect(shortenRoute("/" + "a".repeat(maxRuleLength * 2) + "/")).toEqual( + "/*" + ); + }); + + it("should truncate long single-level wildcard path to catch-all path", () => { + expect(shortenRoute("/" + "a".repeat(maxRuleLength * 2) + "/*")).toEqual( + "/*" + ); + }); + + it("should truncate many single-character segments", () => { + const short = shortenRoute("/a".repeat(maxRuleLength)); + expect(short).toEqual("/a".repeat(maxRuleLength / 2 - 1) + "/*"); + expect(short.length).toEqual(maxRuleLength); + }); + + it("should truncate many double-character segments", () => { + const short = shortenRoute("/aa".repeat(maxRuleLength)); + expect(short).toEqual("/aa".repeat(maxRuleLength / 3 - 1) + "/*"); + expect(short.length).toEqual(maxRuleLength - 2); + }); + + it("should truncate many single-character segments with wildcard", () => { + const short = shortenRoute("/a".repeat(maxRuleLength) + "/*"); + expect(short).toEqual("/a".repeat(maxRuleLength / 2 - 1) + "/*"); + expect(short.length).toEqual(maxRuleLength); + }); + + it("should truncate many double-character segments with wildcard", () => { + const short = shortenRoute("/aa".repeat(maxRuleLength) + "/*"); + expect(short).toEqual("/aa".repeat(maxRuleLength / 3 - 1) + "/*"); + expect(short.length).toEqual(maxRuleLength - 2); + }); + + // Test variable-length segments to ensure it's always able to shorten rules + for (const suffix of ["", "/", "/*"]) { + it(`should truncate many variable-character segments (suffix="${suffix}") without truncating to /*`, () => { + for (let i = 1; i < maxRuleLength - 2; i++) { + const segment = "/" + "a".repeat(i); + expect(segment.length).toBeLessThanOrEqual(maxRuleLength); + const route = + segment.repeat((maxRuleLength / segment.length) * 2) + suffix; + expect(route.length).toBeGreaterThan(maxRuleLength); + const short = shortenRoute(route); + + expect(short.length).toBeLessThanOrEqual(maxRuleLength); + expect(short).not.toEqual("/*"); + } + }); + } + }); +}); diff --git a/packages/pages-functions/__tests__/routes-transformation.test.ts b/packages/pages-functions/__tests__/routes-transformation.test.ts new file mode 100644 index 000000000000..dadd4618e2c4 --- /dev/null +++ b/packages/pages-functions/__tests__/routes-transformation.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it, test } from "vitest"; +import { MAX_FUNCTIONS_ROUTES_RULES } from "../src/routes-consolidation.js"; +import { + compareRoutes, + convertRoutesToGlobPatterns, + convertRoutesToRoutesJSONSpec, + optimizeRoutesJSONSpec, + ROUTES_SPEC_VERSION, +} from "../src/routes-transformation.js"; +import type { UrlPath } from "../src/types.js"; + +function toUrlPath(path: string): UrlPath { + return path as UrlPath; +} + +describe("routes-transformation", () => { + describe("convertRoutesToGlobPatterns()", () => { + it("should pass through routes with no wildcards", () => { + expect( + convertRoutesToGlobPatterns([{ routePath: toUrlPath("/api/foo") }]) + ).toEqual(["/api/foo"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/foo") }, + { routePath: toUrlPath("/api/bar") }, + ]) + ).toEqual(["/api/foo", "/api/bar"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/foo") }, + { routePath: toUrlPath("/api/bar/foo") }, + { routePath: toUrlPath("/foo/bar") }, + ]) + ).toEqual(["/api/foo", "/api/bar/foo", "/foo/bar"]); + }); + + it("should escalate a single param route to a wildcard", () => { + expect( + convertRoutesToGlobPatterns([{ routePath: toUrlPath("/api/:foo") }]) + ).toEqual(["/api/*"]); + expect( + convertRoutesToGlobPatterns([{ routePath: toUrlPath("/api/foo/:bar") }]) + ).toEqual(["/api/foo/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/bar/:barId/foo") }, + ]) + ).toEqual(["/bar/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/bar/:barId/foo/:fooId") }, + ]) + ).toEqual(["/bar/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/:foo") }, + { routePath: toUrlPath("/bar/:barName/profile") }, + { routePath: toUrlPath("/foo/bar/:barId/:fooId") }, + ]) + ).toEqual(["/api/*", "/bar/*", "/foo/bar/*"]); + }); + + it("should pass through a single wildcard route", () => { + expect( + convertRoutesToGlobPatterns([{ routePath: toUrlPath("/api/:baz*") }]) + ).toEqual(["/api/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/foo/bar/:baz*") }, + ]) + ).toEqual(["/api/foo/bar/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/:foo/:bar*") }, + ]) + ).toEqual(["/api/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/foo/:foo*/bar/:bar*") }, + ]) + ).toEqual(["/foo/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/foo/:foo/bar/:bar*") }, + ]) + ).toEqual(["/foo/*"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/:baz*") }, + { routePath: toUrlPath("/api/foo/bar/:baz*") }, + { routePath: toUrlPath("/api/:foo/:bar*") }, + ]) + ).toEqual(["/api/*", "/api/foo/bar/*"]); + }); + + it("should deduplicate identical rules", () => { + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/foo") }, + { routePath: toUrlPath("/api/foo") }, + ]) + ).toEqual(["/api/foo"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/foo/bar") }, + { routePath: toUrlPath("/foo/bar") }, + { routePath: toUrlPath("/api/foo/bar") }, + ]) + ).toEqual(["/api/foo/bar", "/foo/bar"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/foo/:bar") }, + { routePath: toUrlPath("/api/foo") }, + { routePath: toUrlPath("/api/foo/:fooId/bar") }, + { routePath: toUrlPath("/api/foo/*") }, + ]) + ).toEqual(["/api/foo/*", "/api/foo"]); + expect( + convertRoutesToGlobPatterns([ + { routePath: toUrlPath("/api/:baz*") }, + { routePath: toUrlPath("/api/:foo") }, + ]) + ).toEqual(["/api/*"]); + }); + + it("should handle middleware mounting", () => { + expect( + convertRoutesToGlobPatterns([ + { + routePath: toUrlPath("/middleware"), + middleware: ["./some-middleware.ts"], + }, + ]) + ).toEqual(["/middleware/*"]); + + expect( + convertRoutesToGlobPatterns([ + { + routePath: toUrlPath("/middleware"), + middleware: "./some-middleware.ts", + }, + ]) + ).toEqual(["/middleware/*"]); + + expect( + convertRoutesToGlobPatterns([ + { + routePath: toUrlPath("/middleware"), + middleware: [], + }, + ]) + ).toEqual(["/middleware"]); + }); + }); + + describe("convertRoutesToRoutesJSONSpec()", () => { + it("should convert and consolidate routes into JSONSpec", () => { + const result = 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", + }, + ]); + + expect(result.version).toBe(ROUTES_SPEC_VERSION); + expect(result.include).toContain("/middleware/*"); + expect(result.include).toContain("/foo/*"); + expect(result.include).toContain("/api/foo/bar"); + expect(result.exclude).toEqual([]); + }); + + it("should truncate all routes if over limit", () => { + const routes = []; + for (let i = 0; i < MAX_FUNCTIONS_ROUTES_RULES + 1; i++) { + routes.push({ routePath: toUrlPath(`/api/foo-${i}`) }); + } + const result = convertRoutesToRoutesJSONSpec(routes); + expect(result.include).toEqual(["/*"]); + }); + + it("should allow max routes", () => { + const routes = []; + for (let i = 0; i < MAX_FUNCTIONS_ROUTES_RULES; i++) { + routes.push({ routePath: toUrlPath(`/api/foo-${i}`) }); + } + expect(convertRoutesToRoutesJSONSpec(routes).include.length).toEqual( + MAX_FUNCTIONS_ROUTES_RULES + ); + }); + }); + + describe("optimizeRoutesJSONSpec()", () => { + it("should convert and consolidate routes into JSONSpec", () => { + expect( + optimizeRoutesJSONSpec({ + version: ROUTES_SPEC_VERSION, + description: "test", + exclude: [], + include: [ + "/api/foo/bar", + "/foo/bar", + "/foo/*", + "/api/foo/bar", + "/middleware/*", + ], + }) + ).toEqual({ + version: ROUTES_SPEC_VERSION, + description: "test", + include: ["/middleware/*", "/foo/*", "/api/foo/bar"], + exclude: [], + }); + }); + + it("should truncate all routes if over limit", () => { + const include: string[] = []; + for (let i = 0; i < MAX_FUNCTIONS_ROUTES_RULES + 1; i++) { + include.push(`/api/foo-${i}`); + } + expect( + optimizeRoutesJSONSpec({ + version: ROUTES_SPEC_VERSION, + description: "test", + include, + exclude: [], + }) + ).toEqual({ + version: ROUTES_SPEC_VERSION, + description: "test", + include: ["/*"], + exclude: [], + }); + }); + + it("should allow max routes", () => { + const include: string[] = []; + for (let i = 0; i < MAX_FUNCTIONS_ROUTES_RULES; i++) { + include.push(`/api/foo-${i}`); + } + expect( + optimizeRoutesJSONSpec({ + version: ROUTES_SPEC_VERSION, + description: "test", + include, + exclude: [], + }).include.length + ).toEqual(MAX_FUNCTIONS_ROUTES_RULES); + }); + }); + + describe("compareRoutes()", () => { + test("routes / last", () => { + expect(compareRoutes("/", "/foo")).toBeGreaterThanOrEqual(1); + expect(compareRoutes("/", "/*")).toBeGreaterThanOrEqual(1); + }); + + test("routes with fewer segments come after those with more segments", () => { + expect(compareRoutes("/foo", "/foo/bar")).toBeGreaterThanOrEqual(1); + expect(compareRoutes("/foo", "/foo/bar/cat")).toBeGreaterThanOrEqual(1); + }); + + test("routes with wildcard segments come after those without", () => { + expect(compareRoutes("/*", "/foo")).toBe(1); + expect(compareRoutes("/foo/*", "/foo/bar")).toBe(1); + }); + + test("routes with dynamic segments occurring earlier come after those with dynamic segments in later positions", () => { + expect(compareRoutes("/foo/*/bar", "/foo/bar/*")).toBe(1); + }); + }); +}); diff --git a/packages/pages-functions/eslint.config.mjs b/packages/pages-functions/eslint.config.mjs new file mode 100644 index 000000000000..5fca8d5d96e0 --- /dev/null +++ b/packages/pages-functions/eslint.config.mjs @@ -0,0 +1,9 @@ +import sharedConfig from "@cloudflare/eslint-config-shared"; +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + ...sharedConfig, + { + ignores: ["__tests__/fixtures/**"], + }, +]); diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json new file mode 100644 index 000000000000..a31af41de990 --- /dev/null +++ b/packages/pages-functions/package.json @@ -0,0 +1,63 @@ +{ + "name": "@cloudflare/pages-functions", + "version": "0.0.1", + "description": "Compile a Pages functions directory into a deployable worker", + "keywords": [ + "cloudflare", + "workers", + "pages", + "functions" + ], + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/pages-functions#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/pages-functions" + }, + "license": "MIT OR Apache-2.0", + "author": "wrangler@cloudflare.com", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check:lint": "eslint . --max-warnings=0", + "check:type": "tsc --noEmit", + "test": "vitest run", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "esbuild": "catalog:default" + }, + "devDependencies": { + "@cloudflare/eslint-config-shared": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@types/node": "catalog:default", + "eslint": "catalog:default", + "typescript": "catalog:default", + "vitest": "catalog:default" + }, + "engines": { + "node": ">=18.0.0" + }, + "volta": { + "extends": "../../package.json" + }, + "workers-sdk": { + "prerelease": true + } +} diff --git a/packages/pages-functions/src/codegen.ts b/packages/pages-functions/src/codegen.ts new file mode 100644 index 000000000000..acb1ceac6659 --- /dev/null +++ b/packages/pages-functions/src/codegen.ts @@ -0,0 +1,178 @@ +/** + * Code generation for Pages Functions. + * + * Generates a worker entrypoint from route configuration. + */ + +import * as path from "node:path"; +import { isValidIdentifier, normalizeIdentifier } from "./identifiers.js"; +import { generateRuntimeCode } from "./runtime.js"; +import type { RouteConfig } from "./types.js"; + +/** + * Internal representation of routes with resolved identifiers. + */ +interface ResolvedRoute { + routePath: string; + mountPath: string; + method: string; + middlewares: string[]; + modules: string[]; +} + +/** + * Import map entry. + */ +interface ImportEntry { + filepath: string; + name: string; + identifier: string; +} + +/** + * Options for generating the worker entrypoint. + */ +export interface GenerateOptions { + /** Base directory containing the functions */ + functionsDirectory: string; + /** Fallback service binding name (default: "ASSETS") */ + fallbackService?: string; +} + +/** + * Generate a worker entrypoint from route configuration. + * + * @param routes Route configuration from filepath-routing + * @param options Generation options + * @returns Generated JavaScript code + */ +export function generateWorkerEntrypoint( + routes: RouteConfig[], + options: GenerateOptions +): string { + const { functionsDirectory, fallbackService = "ASSETS" } = options; + + const { importMap, resolvedRoutes } = resolveRoutes( + routes, + functionsDirectory + ); + const imports = generateImports(importMap); + const routesArray = generateRoutesArray(resolvedRoutes); + const runtime = generateRuntimeCode(); + + return [ + "// Generated by @cloudflare/pages-functions", + 'import { match } from "path-to-regexp";', + "", + "// User function imports", + imports, + "", + "// Route configuration", + routesArray, + "", + `const __FALLBACK_SERVICE__ = ${JSON.stringify(fallbackService)};`, + "", + "// Runtime", + runtime, + "", + "// Export the handler", + "export default createPagesHandler(routes, __FALLBACK_SERVICE__);", + ].join("\n"); +} + +/** + * Resolve route module references to import identifiers. + */ +function resolveRoutes( + routes: RouteConfig[], + baseDir: string +): { importMap: Map; resolvedRoutes: ResolvedRoute[] } { + const importMap = new Map(); + const identifierCount = new Map(); + + function resolveModuleReferences( + paths: string | string[] | undefined + ): string[] { + if (typeof paths === "undefined") { + return []; + } + + if (typeof paths === "string") { + paths = [paths]; + } + + return paths.map((modulePath) => { + const [filepath, name = "default"] = modulePath.split(":"); + let entry = importMap.get(modulePath); + + if (!entry) { + const resolvedPath = path.resolve(baseDir, filepath); + + // Validate module name to guard against injection attacks + if (name !== "default" && !isValidIdentifier(name)) { + throw new Error(`Invalid module identifier "${name}"`); + } + + let identifier = normalizeIdentifier(`__${filepath}_${name}`); + + let count = identifierCount.get(identifier) ?? 0; + identifierCount.set(identifier, ++count); + + if (count > 1) { + identifier += `_${count}`; + } + + entry = { filepath: resolvedPath, name, identifier }; + importMap.set(modulePath, entry); + } + + return entry.identifier; + }); + } + + const resolvedRoutes: ResolvedRoute[] = routes.map( + ({ routePath, mountPath, method, middleware, module }) => ({ + routePath, + mountPath, + method: method ?? "", + middlewares: resolveModuleReferences(middleware), + modules: resolveModuleReferences(module), + }) + ); + + return { importMap, resolvedRoutes }; +} + +/** + * Generate import statements from the import map. + */ +function generateImports(importMap: Map): string { + return [...importMap.values()] + .map( + ({ filepath, name, identifier }) => + `import { ${name} as ${identifier} } from ${JSON.stringify(filepath)};` + ) + .join("\n"); +} + +/** + * Generate the routes array declaration. + */ +function generateRoutesArray(routes: ResolvedRoute[]): string { + const routeStrings = routes.map((route) => { + const middlewares = `[${route.middlewares.join(", ")}]`; + const modules = `[${route.modules.join(", ")}]`; + + return [ + " {", + ` routePath: ${JSON.stringify(route.routePath)},`, + ` mountPath: ${JSON.stringify(route.mountPath)},`, + ` method: ${JSON.stringify(route.method)},`, + ` middlewares: ${middlewares},`, + ` modules: ${modules},`, + " }", + ].join("\n"); + }); + + return `const routes = [\n${routeStrings.join(",\n")}\n];`; +} diff --git a/packages/pages-functions/src/filepath-routing.ts b/packages/pages-functions/src/filepath-routing.ts new file mode 100644 index 000000000000..10cec5176933 --- /dev/null +++ b/packages/pages-functions/src/filepath-routing.ts @@ -0,0 +1,282 @@ +/** + * File-based routing for Pages Functions. + * + * Scans a functions directory and generates route configuration from the + * file structure and exported handlers. + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { build } from "esbuild"; +import type { + FunctionsConfig, + HTTPMethod, + RouteConfig, + UrlPath, +} from "./types.js"; + +/** + * Error thrown when building/parsing a function file fails. + */ +export class FunctionsBuildError extends Error { + constructor(message: string) { + super(message); + this.name = "FunctionsBuildError"; + } +} + +/** + * Convert a path to a URL path format (forward slashes). + */ +function toUrlPath(p: string): UrlPath { + return p.replace(/\\/g, "/") as UrlPath; +} + +/** + * Generate route configuration from a functions directory. + * + * @param baseDir The functions directory path + * @param baseURL The base URL prefix for all routes (default: "/") + */ +export async function generateConfigFromFileTree({ + baseDir, + baseURL = "/" as UrlPath, +}: { + baseDir: string; + baseURL?: UrlPath; +}): Promise { + let routeEntries: RouteConfig[] = []; + + let normalizedBaseURL = baseURL; + if (!normalizedBaseURL.startsWith("/")) { + normalizedBaseURL = `/${normalizedBaseURL}` as UrlPath; + } + if (normalizedBaseURL.endsWith("/")) { + normalizedBaseURL = normalizedBaseURL.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); + }); + + 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 = `${normalizedBaseURL}/${routePath}`; + mountPath = `${normalizedBaseURL}/${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, + }; +} + +/** + * Compare routes for sorting by specificity. + * + * Ensures 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 +): number { + 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 method 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); +} + +/** + * Recursively iterate over all files in a directory. + */ +async function forEachFile( + baseDir: string, + fn: (filepath: string) => T | Promise +): 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 Error( + `Invalid Pages function route parameter - "[[${param}]]". Parameter names must only contain alphanumeric and underscore characters.` + ); + } + }); +} + +/** + * Transform all [id] => :id + */ +function convertSimpleParams(routePath: string): string { + return routePath.replace(/\[([^\]]+)\]/g, (_, param) => { + if (validParamNameRegExp.test(param)) { + return `:${param}`; + } else { + throw new Error( + `Invalid Pages function route parameter - "[${param}]". Parameter names must only contain alphanumeric and underscore characters.` + ); + } + }); +} diff --git a/packages/pages-functions/src/identifiers.ts b/packages/pages-functions/src/identifiers.ts new file mode 100644 index 000000000000..daef3a5c3b23 --- /dev/null +++ b/packages/pages-functions/src/identifiers.ts @@ -0,0 +1,83 @@ +/** + * JavaScript identifier validation and normalization utilities. + */ + +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 if a string is a valid JavaScript identifier. + */ +export const isValidIdentifier = (identifier: string): boolean => + validIdentifierRegex.test(identifier); + +/** + * Normalize a string to a valid JavaScript identifier by replacing + * invalid characters with underscores. + */ +export const normalizeIdentifier = (identifier: string): string => + identifier.replace( + /(?:^[^$_\p{ID_Start}])|[^$_\u200C\u200D\p{ID_Continue}]/gu, + "_" + ); diff --git a/packages/pages-functions/src/index.ts b/packages/pages-functions/src/index.ts new file mode 100644 index 000000000000..94ba3f8a4921 --- /dev/null +++ b/packages/pages-functions/src/index.ts @@ -0,0 +1,94 @@ +/** + * @cloudflare/pages-functions + * + * Compile a Pages functions directory into a deployable worker entrypoint. + */ + +import { generateWorkerEntrypoint } from "./codegen.js"; +import { generateConfigFromFileTree } from "./filepath-routing.js"; +import { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; +import type { CompileOptions, CompileResult, UrlPath } from "./types.js"; + +// Re-export types +export type { + CompileOptions, + CompileResult, + HTTPMethod, + RouteConfig, + RoutesJSONSpec, + UrlPath, +} from "./types.js"; + +// Re-export utilities that may be useful +export { generateConfigFromFileTree } from "./filepath-routing.js"; +export { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; +export { generateWorkerEntrypoint } from "./codegen.js"; + +/** + * Error thrown when no routes are found in the functions directory. + */ +export class FunctionsNoRoutesError extends Error { + constructor(message: string) { + super(message); + this.name = "FunctionsNoRoutesError"; + } +} + +/** + * Compile a Pages functions directory into a worker entrypoint. + * + * @param functionsDirectory Path to the functions directory + * @param options Compilation options + * @returns Compilation result containing generated code and route info + * + * @example + * ```ts + * import { compileFunctions } from '@cloudflare/pages-functions'; + * + * const result = await compileFunctions('./functions', { + * fallbackService: 'ASSETS', + * }); + * + * // Write the generated entrypoint + * await fs.writeFile('worker.js', result.code); + * + * // Write _routes.json for Pages deployment + * await fs.writeFile('_routes.json', JSON.stringify(result.routesJson, null, 2)); + * ``` + */ +export async function compileFunctions( + functionsDirectory: string, + options: CompileOptions = {} +): Promise { + const { baseURL = "/" as UrlPath, fallbackService = "ASSETS" } = options; + + // Generate route configuration from the file tree + const config = await generateConfigFromFileTree({ + baseDir: functionsDirectory, + baseURL: baseURL as UrlPath, + }); + + if (!config.routes || config.routes.length === 0) { + throw new FunctionsNoRoutesError( + `Failed to find any routes while compiling Functions in: ${functionsDirectory}` + ); + } + + // Generate the worker entrypoint code + const code = generateWorkerEntrypoint(config.routes, { + functionsDirectory, + fallbackService, + }); + + // Generate _routes.json for Pages deployment + const routesJson = convertRoutesToRoutesJSONSpec( + config.routes, + "Generated by @cloudflare/pages-functions" + ); + + return { + code, + routes: config.routes, + routesJson, + }; +} diff --git a/packages/pages-functions/src/routes-consolidation.ts b/packages/pages-functions/src/routes-consolidation.ts new file mode 100644 index 000000000000..da85b563661d --- /dev/null +++ b/packages/pages-functions/src/routes-consolidation.ts @@ -0,0 +1,86 @@ +/** + * Route consolidation utilities for optimizing _routes.json. + */ + +/** Max char length of each rule in _routes.json */ +export const MAX_FUNCTIONS_ROUTES_RULE_LENGTH = 100; + +/** Max number of rules in _routes.json */ +export const MAX_FUNCTIONS_ROUTES_RULES = 100; + +/** + * Consolidates redundant routes - e.g., ["/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. + * E.g., /aaa/bbb/ccc... -> /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; +} diff --git a/packages/pages-functions/src/routes-transformation.ts b/packages/pages-functions/src/routes-transformation.ts new file mode 100644 index 000000000000..4ec7f8d5aec5 --- /dev/null +++ b/packages/pages-functions/src/routes-transformation.ts @@ -0,0 +1,125 @@ +/** + * Transform route configurations into _routes.json format for Pages deployment. + */ + +import { join as pathJoin } from "node:path"; +import { + consolidateRoutes, + MAX_FUNCTIONS_ROUTES_RULES, +} from "./routes-consolidation.js"; +import type { RouteConfig, RoutesJSONSpec, UrlPath } from "./types.js"; + +/** Version of the _routes.json specification */ +export const ROUTES_SPEC_VERSION = 1; + +/** + * Convert a path to a URL path format (forward slashes). + */ +function toUrlPath(p: string): UrlPath { + return p.replace(/\\/g, "/") as UrlPath; +} + +type RoutesJSONRouteInput = Pick[]; + +/** + * Convert route configurations to glob patterns for _routes.json. + */ +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, + 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: [], + }); +} + +/** + * 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 for sorting. + * Sorts most-specific to least-specific. + */ +export function compareRoutes(routeA: string, routeB: string): number { + 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/runtime.ts b/packages/pages-functions/src/runtime.ts new file mode 100644 index 000000000000..46568a488fc3 --- /dev/null +++ b/packages/pages-functions/src/runtime.ts @@ -0,0 +1,154 @@ +/** + * Runtime code template for Pages Functions. + * + * This module contains the runtime code that gets inlined into the generated + * worker entrypoint. It handles route matching and middleware execution. + */ + +/** + * Generate the runtime code that handles request routing and middleware execution. + * + * This code is inlined into the generated worker entrypoint to avoid requiring + * an additional runtime dependency. + */ +export function generateRuntimeCode(): string { + // Using an array join to avoid template literal parsing issues with regex + const lines = [ + "// Pages Functions Runtime", + "// This code is generated by @cloudflare/pages-functions", + "", + "const escapeRegex = /[.+?^${}()|[\\]\\\\]/g;", + "", + "function* executeRequest(request, routes) {", + " 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;", + " }", + "", + ' 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,", + " 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,", + " path: matchResult.path,", + " };", + " }", + " break;", + " }", + " }", + "}", + "", + "function createPagesHandler(routes, fallbackService) {", + " return {", + " async fetch(originalRequest, env, workerContext) {", + " let request = originalRequest;", + " const handlerIterator = executeRequest(request, routes);", + " let data = {};", + " let isFailOpen = false;", + "", + " const next = async (input, init) => {", + " 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();", + " 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");', + " }", + " 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 (fallbackService) {", + " const response = await env[fallbackService].fetch(request);", + " return cloneResponse(response);", + " } else {", + " const response = await fetch(request);", + " return cloneResponse(response);", + " }", + " };", + "", + " try {", + " return await next();", + " } catch (error) {", + " if (isFailOpen && fallbackService) {", + " const response = await env[fallbackService].fetch(request);", + " return cloneResponse(response);", + " }", + " throw error;", + " }", + " },", + " };", + "}", + "", + "const cloneResponse = (response) =>", + " new Response(", + " [101, 204, 205, 304].includes(response.status) ? null : response.body,", + " response", + " );", + ]; + + return lines.join("\n"); +} diff --git a/packages/pages-functions/src/types.ts b/packages/pages-functions/src/types.ts new file mode 100644 index 000000000000..2f5f1ebe4dcd --- /dev/null +++ b/packages/pages-functions/src/types.ts @@ -0,0 +1,75 @@ +/** + * Types for Pages Functions routing and compilation. + */ + +/** + * HTTP methods supported by Pages Functions. + */ +export type HTTPMethod = + | "HEAD" + | "OPTIONS" + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE"; + +/** + * A URL path that starts with a forward slash. + */ +export type UrlPath = `/${string}`; + +/** + * Configuration for a single route in a Pages Functions project. + */ +export interface RouteConfig { + /** The path pattern for this route (e.g., "/api/:id") */ + routePath: UrlPath; + /** The mount path for middleware matching */ + mountPath: UrlPath; + /** Optional HTTP method restriction */ + method?: HTTPMethod; + /** Middleware handler references */ + middleware?: string | string[]; + /** Module handler references */ + module?: string | string[]; +} + +/** + * Parsed configuration from a functions directory. + */ +export interface FunctionsConfig { + routes: RouteConfig[]; +} + +/** + * The _routes.json specification for Pages deployment. + */ +export interface RoutesJSONSpec { + version: number; + description?: string; + include: string[]; + exclude: string[]; +} + +/** + * Options for compiling a functions directory. + */ +export interface CompileOptions { + /** Base URL for routes. Default: "/" */ + baseURL?: string; + /** Fallback service binding name. Default: "ASSETS" */ + fallbackService?: string; +} + +/** + * Result of compiling a functions directory. + */ +export interface CompileResult { + /** Generated worker entrypoint code (JavaScript) */ + code: string; + /** Route configuration for tooling/debugging */ + routes: RouteConfig[]; + /** _routes.json content for Pages deployment */ + routesJson: RoutesJSONSpec; +} diff --git a/packages/pages-functions/tsconfig.build.json b/packages/pages-functions/tsconfig.build.json new file mode 100644 index 000000000000..29eab0ad6a54 --- /dev/null +++ b/packages/pages-functions/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/packages/pages-functions/tsconfig.json b/packages/pages-functions/tsconfig.json new file mode 100644 index 000000000000..9a99a9101e82 --- /dev/null +++ b/packages/pages-functions/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cloudflare/workers-tsconfig", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"] + }, + "include": ["src/**/*.ts", "__tests__/**/*.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist", "__tests__/fixtures"] +} diff --git a/packages/pages-functions/vitest.config.ts b/packages/pages-functions/vitest.config.ts new file mode 100644 index 000000000000..b6b2964d1a0b --- /dev/null +++ b/packages/pages-functions/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["__tests__/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51b8d6490a04..47db96e452fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2097,6 +2097,31 @@ 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.27.0 + devDependencies: + '@cloudflare/eslint-config-shared': + specifier: workspace:* + version: link:../eslint-config-shared + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + '@types/node': + specifier: ^20.19.9 + version: 20.19.9 + eslint: + specifier: catalog:default + version: 9.39.1(jiti@2.6.0) + typescript: + specifier: catalog:default + version: 5.8.3 + vitest: + specifier: catalog:default + version: 3.2.3(@types/debug@4.1.12)(@types/node@20.19.9)(@vitest/ui@3.2.3)(jiti@2.6.0)(lightningcss@1.30.2)(msw@2.12.0(@types/node@20.19.9)(typescript@5.8.3))(supports-color@9.2.2)(yaml@2.8.1) + packages/pages-shared: dependencies: miniflare: From eebf5c19301396acf8ae69508e920117caca6524 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 21 Jan 2026 12:07:26 +0000 Subject: [PATCH 02/26] Add scripts/deps.ts for external dependencies --- packages/pages-functions/scripts/deps.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/pages-functions/scripts/deps.ts diff --git a/packages/pages-functions/scripts/deps.ts b/packages/pages-functions/scripts/deps.ts new file mode 100644 index 000000000000..82975366a3f4 --- /dev/null +++ b/packages/pages-functions/scripts/deps.ts @@ -0,0 +1,11 @@ +/** + * Dependencies that _are not_ bundled along with 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 = [ + // Native binary with platform-specific builds - cannot be bundled. + // Used to parse function files and extract exports. + "esbuild", +]; From 3ea65c8732e393e2e2828aca1fd2a8488b3944a8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 21 Jan 2026 12:09:20 +0000 Subject: [PATCH 03/26] Add @cloudflare/pages-functions to validate-changesets test --- tools/deployments/__tests__/validate-changesets.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/deployments/__tests__/validate-changesets.test.ts b/tools/deployments/__tests__/validate-changesets.test.ts index bd8c2559cd67..bfce4142d8a4 100644 --- a/tools/deployments/__tests__/validate-changesets.test.ts +++ b/tools/deployments/__tests__/validate-changesets.test.ts @@ -23,6 +23,7 @@ describe("findPackageNames()", () => { "@cloudflare/chrome-devtools-patches", "@cloudflare/cli", "@cloudflare/kv-asset-handler", + "@cloudflare/pages-functions", "@cloudflare/pages-shared", "@cloudflare/quick-edit", "@cloudflare/unenv-preset", From 307896887f52f41e8ce84942c8abbec03d0180f8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 21 Jan 2026 13:59:13 +0000 Subject: [PATCH 04/26] Resolve path-to-regexp at codegen time, add runtime tests - Resolve path-to-regexp path at codegen time using require.resolve() - Wrangler/esbuild bundles it when building the final worker - Remove esbuild dependency (no longer needed for bundling) - Return 404 when no route matches and fallback binding unavailable - Add vitest-pool-workers runtime tests (9 tests covering route matching, middleware execution, and 404 handling) --- .../pages-functions/__tests__/codegen.test.ts | 3 +- .../__tests__/runtime/runtime.test.ts | 78 ++++++ .../__tests__/runtime/tsconfig.json | 10 + .../__tests__/runtime/worker.ts | 260 ++++++++++++++++++ .../__tests__/runtime/wrangler.jsonc | 6 + packages/pages-functions/package.json | 7 +- packages/pages-functions/scripts/deps.ts | 6 +- packages/pages-functions/src/codegen.ts | 15 +- packages/pages-functions/src/runtime.ts | 7 +- .../pages-functions/vitest.config.runtime.mts | 14 + packages/pages-functions/vitest.config.ts | 1 + pnpm-lock.yaml | 18 +- 12 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 packages/pages-functions/__tests__/runtime/runtime.test.ts create mode 100644 packages/pages-functions/__tests__/runtime/tsconfig.json create mode 100644 packages/pages-functions/__tests__/runtime/worker.ts create mode 100644 packages/pages-functions/__tests__/runtime/wrangler.jsonc create mode 100644 packages/pages-functions/vitest.config.runtime.mts diff --git a/packages/pages-functions/__tests__/codegen.test.ts b/packages/pages-functions/__tests__/codegen.test.ts index 26e5896b9d01..67b27509c46e 100644 --- a/packages/pages-functions/__tests__/codegen.test.ts +++ b/packages/pages-functions/__tests__/codegen.test.ts @@ -19,7 +19,8 @@ describe("codegen", () => { fallbackService: "ASSETS", }); - expect(code).toContain('import { match } from "path-to-regexp"'); + // path-to-regexp is imported from its resolved absolute path + expect(code).toMatch(/import \{ match \} from ".*path-to-regexp.*"/); expect(code).toContain("import { onRequestGet as"); expect(code).toContain('routePath: "/api/:id"'); expect(code).toContain('mountPath: "/api"'); diff --git a/packages/pages-functions/__tests__/runtime/runtime.test.ts b/packages/pages-functions/__tests__/runtime/runtime.test.ts new file mode 100644 index 000000000000..c8423fb52a4e --- /dev/null +++ b/packages/pages-functions/__tests__/runtime/runtime.test.ts @@ -0,0 +1,78 @@ +import { SELF } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +describe("Pages Functions Runtime", () => { + describe("route matching", () => { + it("matches the index route", async () => { + const response = await SELF.fetch("https://example.com/"); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello from index"); + }); + + it("matches static API routes", async () => { + const response = await SELF.fetch("https://example.com/api/hello"); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ message: "Hello from GET /api/hello" }); + }); + + it("matches dynamic API routes with params", async () => { + const response = await SELF.fetch("https://example.com/api/123"); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ id: "123", method: "GET" }); + }); + + it("matches routes by HTTP method", async () => { + const response = await SELF.fetch("https://example.com/api/456", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ test: true }), + }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ id: "456", method: "PUT", body: { test: true } }); + }); + + it("handles POST with JSON body", async () => { + const response = await SELF.fetch("https://example.com/api/hello", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }), + }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json).toEqual({ + message: "Hello from POST", + received: { name: "test" }, + }); + }); + }); + + describe("middleware", () => { + it("executes middleware and adds headers", async () => { + const response = await SELF.fetch("https://example.com/"); + expect(response.headers.get("X-Middleware")).toBe("active"); + }); + + it("executes middleware for API routes", async () => { + const response = await SELF.fetch("https://example.com/api/hello"); + expect(response.headers.get("X-Middleware")).toBe("active"); + }); + }); + + describe("404 handling", () => { + it("returns 404 for unmatched routes", async () => { + const response = await SELF.fetch("https://example.com/nonexistent"); + expect(response.status).toBe(404); + expect(await response.text()).toBe("Not Found"); + }); + + it("returns 404 for unmatched methods", async () => { + const response = await SELF.fetch("https://example.com/api/hello", { + method: "DELETE", + }); + expect(response.status).toBe(404); + }); + }); +}); diff --git a/packages/pages-functions/__tests__/runtime/tsconfig.json b/packages/pages-functions/__tests__/runtime/tsconfig.json new file mode 100644 index 000000000000..18cd9abff055 --- /dev/null +++ b/packages/pages-functions/__tests__/runtime/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "types": [ + "@cloudflare/workers-types/experimental", + "@cloudflare/vitest-pool-workers" + ] + }, + "include": ["**/*.ts"] +} diff --git a/packages/pages-functions/__tests__/runtime/worker.ts b/packages/pages-functions/__tests__/runtime/worker.ts new file mode 100644 index 000000000000..d285f7891dec --- /dev/null +++ b/packages/pages-functions/__tests__/runtime/worker.ts @@ -0,0 +1,260 @@ +/** + * Test worker for runtime tests. + * + * This worker defines simple Pages function handlers that can be tested + * against the generated runtime code. + */ + +// Import path-to-regexp (will be resolved at bundle time) +import { match } from "path-to-regexp"; + +// Simple handler functions for testing +const indexHandler = () => { + return new Response("Hello from index"); +}; + +const apiHelloGet = () => { + return Response.json({ message: "Hello from GET /api/hello" }); +}; + +const apiHelloPost = async (context: { request: Request }) => { + const body = await context.request.json(); + return Response.json({ message: "Hello from POST", received: body }); +}; + +const apiIdGet = (context: { params: Record }) => { + return Response.json({ id: context.params.id, method: "GET" }); +}; + +const apiIdPut = async (context: { + params: Record; + request: Request; +}) => { + const body = await context.request.json(); + return Response.json({ id: context.params.id, method: "PUT", body }); +}; + +const middleware = async (context: { next: () => Promise }) => { + const response = await context.next(); + const newResponse = new Response(response.body, response); + newResponse.headers.set("X-Middleware", "active"); + return newResponse; +}; + +// Route configuration (similar to what codegen produces) +const routes = [ + { + routePath: "/api/hello", + mountPath: "/api", + method: "GET", + middlewares: [middleware], + modules: [apiHelloGet], + }, + { + routePath: "/api/hello", + mountPath: "/api", + method: "POST", + middlewares: [middleware], + modules: [apiHelloPost], + }, + { + routePath: "/api/:id", + mountPath: "/api", + method: "GET", + middlewares: [middleware], + modules: [apiIdGet], + }, + { + routePath: "/api/:id", + mountPath: "/api", + method: "PUT", + middlewares: [middleware], + modules: [apiIdPut], + }, + { + routePath: "/", + mountPath: "/", + method: "", + middlewares: [middleware], + modules: [indexHandler], + }, +]; + +// Runtime code (copied from runtime.ts output) +const escapeRegex = /[.+?^${}()|[\]\\]/g; + +type RouteHandler = (context: RouteContext) => Response | Promise; + +interface Route { + routePath: string; + mountPath: string; + method: string; + middlewares: RouteHandler[]; + modules: RouteHandler[]; +} + +interface RouteContext { + request: Request; + functionPath: string; + next: (input?: RequestInfo, init?: RequestInit) => Promise; + params: Record; + data: Record; + env: Record; + waitUntil: (promise: Promise) => void; + passThroughOnException: () => void; +} + +function* executeRequest(request: Request, routeList: Route[]) { + const requestPath = new URL(request.url).pathname; + + // First, iterate through the routes (backwards) and execute "middlewares" on partial route matches + for (const route of [...routeList].reverse()) { + if (route.method && route.method !== request.method) { + continue; + } + + 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 Record, + path: mountMatchResult.path, + }; + } + } + } + + // Then look for the first exact route match and execute its "modules" + for (const route of routeList) { + 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 Record, + path: matchResult.path, + }; + } + break; + } + } +} + +function createPagesHandler( + routeList: Route[], + fallbackService: string | null +) { + return { + async fetch( + originalRequest: Request, + env: Record, + workerContext: ExecutionContext + ) { + let request = originalRequest; + const handlerIterator = executeRequest(request, routeList); + let data: Record = {}; + let isFailOpen = false; + + const next = async ( + input?: RequestInfo, + init?: RequestInit + ): Promise => { + if (input !== undefined) { + let url: RequestInfo = input; + if (typeof input === "string") { + url = new URL(input, request.url).toString(); + } + request = new Request(url, init); + } + + const result = handlerIterator.next(); + if (result.done === false) { + const { handler, params, path } = result.value; + const context: RouteContext = { + 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"); + } + 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 ( + fallbackService && + env[fallbackService] && + typeof (env[fallbackService] as Fetcher).fetch === "function" + ) { + const response = await (env[fallbackService] as Fetcher).fetch( + request + ); + return cloneResponse(response); + } else { + return new Response("Not Found", { status: 404 }); + } + }; + + try { + return await next(); + } catch (error) { + if ( + isFailOpen && + fallbackService && + env[fallbackService] && + typeof (env[fallbackService] as Fetcher).fetch === "function" + ) { + const response = await (env[fallbackService] as Fetcher).fetch( + request + ); + return cloneResponse(response); + } + throw error; + } + }, + }; +} + +const cloneResponse = (response: Response) => + new Response( + [101, 204, 205, 304].includes(response.status) ? null : response.body, + response + ); + +// Export the handler +export default createPagesHandler(routes, "ASSETS"); diff --git a/packages/pages-functions/__tests__/runtime/wrangler.jsonc b/packages/pages-functions/__tests__/runtime/wrangler.jsonc new file mode 100644 index 000000000000..ade0b4f908f4 --- /dev/null +++ b/packages/pages-functions/__tests__/runtime/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "pages-functions-runtime-test", + "main": "./worker.ts", + "compatibility_date": "2026-01-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json index a31af41de990..57d252c405ef 100644 --- a/packages/pages-functions/package.json +++ b/packages/pages-functions/package.json @@ -37,15 +37,18 @@ "check:lint": "eslint . --max-warnings=0", "check:type": "tsc --noEmit", "test": "vitest run", - "test:ci": "vitest run", + "test:ci": "vitest run && vitest run -c vitest.config.runtime.mts", + "test:runtime": "vitest run -c vitest.config.runtime.mts", "test:watch": "vitest" }, "dependencies": { - "esbuild": "catalog:default" + "path-to-regexp": "^6.3.0" }, "devDependencies": { "@cloudflare/eslint-config-shared": "workspace:*", + "@cloudflare/vitest-pool-workers": "catalog:default", "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", "@types/node": "catalog:default", "eslint": "catalog:default", "typescript": "catalog:default", diff --git a/packages/pages-functions/scripts/deps.ts b/packages/pages-functions/scripts/deps.ts index 82975366a3f4..337188768caa 100644 --- a/packages/pages-functions/scripts/deps.ts +++ b/packages/pages-functions/scripts/deps.ts @@ -5,7 +5,7 @@ * This list is validated by `tools/deployments/validate-package-dependencies.ts`. */ export const EXTERNAL_DEPENDENCIES = [ - // Native binary with platform-specific builds - cannot be bundled. - // Used to parse function files and extract exports. - "esbuild", + // Imported via resolved absolute path into generated worker code. + // Wrangler/esbuild bundles it when building the final worker. + "path-to-regexp", ]; diff --git a/packages/pages-functions/src/codegen.ts b/packages/pages-functions/src/codegen.ts index acb1ceac6659..ad15a60645da 100644 --- a/packages/pages-functions/src/codegen.ts +++ b/packages/pages-functions/src/codegen.ts @@ -4,11 +4,22 @@ * Generates a worker entrypoint from route configuration. */ +import { createRequire } from "node:module"; import * as path from "node:path"; import { isValidIdentifier, normalizeIdentifier } from "./identifiers.js"; import { generateRuntimeCode } from "./runtime.js"; import type { RouteConfig } from "./types.js"; +const require = createRequire(import.meta.url); + +/** + * Resolve the path to path-to-regexp module. + * This gets resolved at codegen time; wrangler/esbuild will bundle it. + */ +function getPathToRegexpPath(): string { + return require.resolve("path-to-regexp"); +} + /** * Internal representation of routes with resolved identifiers. */ @@ -59,10 +70,12 @@ export function generateWorkerEntrypoint( const imports = generateImports(importMap); const routesArray = generateRoutesArray(resolvedRoutes); const runtime = generateRuntimeCode(); + const pathToRegexpPath = getPathToRegexpPath(); return [ "// Generated by @cloudflare/pages-functions", - 'import { match } from "path-to-regexp";', + "", + `import { match } from ${JSON.stringify(pathToRegexpPath)};`, "", "// User function imports", imports, diff --git a/packages/pages-functions/src/runtime.ts b/packages/pages-functions/src/runtime.ts index 46568a488fc3..dc2b1c6803b6 100644 --- a/packages/pages-functions/src/runtime.ts +++ b/packages/pages-functions/src/runtime.ts @@ -121,19 +121,18 @@ export function generateRuntimeCode(): string { " }", "", " return cloneResponse(response);", - " } else if (fallbackService) {", + " } else if (fallbackService && env[fallbackService]) {", " const response = await env[fallbackService].fetch(request);", " return cloneResponse(response);", " } else {", - " const response = await fetch(request);", - " return cloneResponse(response);", + ' return new Response("Not Found", { status: 404 });', " }", " };", "", " try {", " return await next();", " } catch (error) {", - " if (isFailOpen && fallbackService) {", + " if (isFailOpen && fallbackService && env[fallbackService]) {", " const response = await env[fallbackService].fetch(request);", " return cloneResponse(response);", " }", diff --git a/packages/pages-functions/vitest.config.runtime.mts b/packages/pages-functions/vitest.config.runtime.mts new file mode 100644 index 000000000000..07d19f6b5d4f --- /dev/null +++ b/packages/pages-functions/vitest.config.runtime.mts @@ -0,0 +1,14 @@ +import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; + +export default defineWorkersConfig({ + test: { + include: ["__tests__/runtime/**/*.test.ts"], + poolOptions: { + workers: { + wrangler: { + configPath: "./__tests__/runtime/wrangler.jsonc", + }, + }, + }, + }, +}); diff --git a/packages/pages-functions/vitest.config.ts b/packages/pages-functions/vitest.config.ts index b6b2964d1a0b..0412994c3d30 100644 --- a/packages/pages-functions/vitest.config.ts +++ b/packages/pages-functions/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["__tests__/**/*.test.ts"], + exclude: ["__tests__/runtime/**/*.test.ts"], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47db96e452fa..25d2996c0a66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,6 +688,12 @@ importers: specifier: workspace:* version: link:../../packages/wrangler + fixtures/pages-functions-test: + devDependencies: + wrangler: + specifier: workspace:* + version: link:../../packages/wrangler + fixtures/pages-functions-unenv-alias: devDependencies: '@cloudflare/workers-tsconfig': @@ -2099,16 +2105,22 @@ importers: packages/pages-functions: dependencies: - esbuild: - specifier: catalog:default - version: 0.27.0 + path-to-regexp: + specifier: ^6.3.0 + version: 6.3.0 devDependencies: '@cloudflare/eslint-config-shared': specifier: workspace:* version: link:../eslint-config-shared + '@cloudflare/vitest-pool-workers': + specifier: catalog:default + version: 0.10.15(@cloudflare/workers-types@4.20260120.0)(@vitest/runner@3.2.3)(@vitest/snapshot@3.2.3)(vitest@3.2.3) '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 4.20260120.0 '@types/node': specifier: ^20.19.9 version: 20.19.9 From e437facec199d24e761295f3ff32fb4e5cac7dd1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 21 Jan 2026 13:59:19 +0000 Subject: [PATCH 05/26] Add pages-functions-test fixture Manual test fixture for the @cloudflare/pages-functions package. Demonstrates compiling a functions directory and running with wrangler. --- fixtures/pages-functions-test/README.md | 35 +++++++++++++++++++ fixtures/pages-functions-test/_routes.json | 8 +++++ fixtures/pages-functions-test/build.mjs | 27 ++++++++++++++ .../functions/_middleware.ts | 5 +++ .../functions/api/[id].ts | 15 ++++++++ .../functions/api/hello.ts | 11 ++++++ .../pages-functions-test/functions/index.ts | 3 ++ fixtures/pages-functions-test/package.json | 14 ++++++++ fixtures/pages-functions-test/turbo.json | 8 +++++ fixtures/pages-functions-test/wrangler.jsonc | 5 +++ pnpm-lock.yaml | 4 +++ 11 files changed, 135 insertions(+) create mode 100644 fixtures/pages-functions-test/README.md create mode 100644 fixtures/pages-functions-test/_routes.json create mode 100644 fixtures/pages-functions-test/build.mjs create mode 100644 fixtures/pages-functions-test/functions/_middleware.ts create mode 100644 fixtures/pages-functions-test/functions/api/[id].ts create mode 100644 fixtures/pages-functions-test/functions/api/hello.ts create mode 100644 fixtures/pages-functions-test/functions/index.ts create mode 100644 fixtures/pages-functions-test/package.json create mode 100644 fixtures/pages-functions-test/turbo.json create mode 100644 fixtures/pages-functions-test/wrangler.jsonc diff --git a/fixtures/pages-functions-test/README.md b/fixtures/pages-functions-test/README.md new file mode 100644 index 000000000000..209f3d0fe7f7 --- /dev/null +++ b/fixtures/pages-functions-test/README.md @@ -0,0 +1,35 @@ +# Pages Functions Test Fixture + +Test fixture for `@cloudflare/pages-functions` package. + +## Usage + +From the workers-sdk root: + +```bash +# Build the pages-functions package first +pnpm --filter @cloudflare/pages-functions build + +# Install deps for this fixture +pnpm --filter pages-functions-test install + +# Compile functions -> worker, then bundle, then run dev +pnpm --filter pages-functions-test dev +``` + +## What it does + +1. `build.mjs` uses `compileFunctions()` to compile `functions/` into `dist/worker.js` +2. esbuild bundles `dist/worker.js` (with path-to-regexp) into `dist/bundled.js` +3. wrangler runs the bundled worker + +## Test endpoints + +- `GET /` - Index route +- `GET /api/hello` - Static API route +- `POST /api/hello` - POST handler +- `GET /api/:id` - Dynamic route +- `PUT /api/:id` - Update handler +- `DELETE /api/:id` - Delete handler + +All routes go through the root middleware which adds `X-Middleware: active` header. diff --git a/fixtures/pages-functions-test/_routes.json b/fixtures/pages-functions-test/_routes.json new file mode 100644 index 000000000000..5aeface7f7e6 --- /dev/null +++ b/fixtures/pages-functions-test/_routes.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "description": "Generated by @cloudflare/pages-functions", + "include": [ + "/*" + ], + "exclude": [] +} \ No newline at end of file diff --git a/fixtures/pages-functions-test/build.mjs b/fixtures/pages-functions-test/build.mjs new file mode 100644 index 000000000000..f54698aa594c --- /dev/null +++ b/fixtures/pages-functions-test/build.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** + * Build script that uses @cloudflare/pages-functions to compile + * the functions directory into a worker entrypoint. + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { compileFunctions } from "@cloudflare/pages-functions"; + +// Ensure dist directory exists +mkdirSync("./dist", { recursive: true }); + +// Compile the functions directory +const result = await compileFunctions("./functions"); + +// Write the generated worker entrypoint +writeFileSync("./dist/worker.js", result.code); +console.log("✓ Generated dist/worker.js"); + +// Write _routes.json +writeFileSync("./_routes.json", JSON.stringify(result.routesJson, null, 2)); +console.log("✓ Generated _routes.json"); + +console.log("\nRoutes:"); +for (const route of result.routes) { + const method = route.method || "ALL"; + console.log(` ${method.padEnd(6)} ${route.routePath}`); +} diff --git a/fixtures/pages-functions-test/functions/_middleware.ts b/fixtures/pages-functions-test/functions/_middleware.ts new file mode 100644 index 000000000000..febbdee17eaa --- /dev/null +++ b/fixtures/pages-functions-test/functions/_middleware.ts @@ -0,0 +1,5 @@ +export const onRequest = async (context) => { + const response = await context.next(); + response.headers.set("X-Middleware", "active"); + return response; +}; diff --git a/fixtures/pages-functions-test/functions/api/[id].ts b/fixtures/pages-functions-test/functions/api/[id].ts new file mode 100644 index 000000000000..658e779a8aff --- /dev/null +++ b/fixtures/pages-functions-test/functions/api/[id].ts @@ -0,0 +1,15 @@ +export const onRequestGet = (context) => { + const { id } = context.params; + return Response.json({ message: `Getting item ${id}` }); +}; + +export const onRequestPut = async (context) => { + const { id } = context.params; + const body = await context.request.json(); + return Response.json({ message: `Updating item ${id}`, data: body }); +}; + +export const onRequestDelete = (context) => { + const { id } = context.params; + return Response.json({ message: `Deleted item ${id}` }); +}; diff --git a/fixtures/pages-functions-test/functions/api/hello.ts b/fixtures/pages-functions-test/functions/api/hello.ts new file mode 100644 index 000000000000..33b173158e39 --- /dev/null +++ b/fixtures/pages-functions-test/functions/api/hello.ts @@ -0,0 +1,11 @@ +export const onRequestGet = () => { + return Response.json({ message: "Hello from GET /api/hello" }); +}; + +export const onRequestPost = async (context) => { + const body = await context.request.json(); + return Response.json({ + message: "Hello from POST /api/hello", + received: body, + }); +}; diff --git a/fixtures/pages-functions-test/functions/index.ts b/fixtures/pages-functions-test/functions/index.ts new file mode 100644 index 000000000000..524e33ddc2d9 --- /dev/null +++ b/fixtures/pages-functions-test/functions/index.ts @@ -0,0 +1,3 @@ +export const onRequest = () => { + return new Response("Hello from the index!"); +}; diff --git a/fixtures/pages-functions-test/package.json b/fixtures/pages-functions-test/package.json new file mode 100644 index 000000000000..9694c52015c0 --- /dev/null +++ b/fixtures/pages-functions-test/package.json @@ -0,0 +1,14 @@ +{ + "name": "@fixture/pages-functions-test", + "private": true, + "scripts": { + "build": "node build.mjs", + "dev": "node build.mjs && wrangler dev" + }, + "dependencies": { + "@cloudflare/pages-functions": "workspace:*" + }, + "devDependencies": { + "wrangler": "workspace:*" + } +} diff --git a/fixtures/pages-functions-test/turbo.json b/fixtures/pages-functions-test/turbo.json new file mode 100644 index 000000000000..4a8db2b8c648 --- /dev/null +++ b/fixtures/pages-functions-test/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["@cloudflare/pages-functions#build"] + } + } +} diff --git a/fixtures/pages-functions-test/wrangler.jsonc b/fixtures/pages-functions-test/wrangler.jsonc new file mode 100644 index 000000000000..35ab28604082 --- /dev/null +++ b/fixtures/pages-functions-test/wrangler.jsonc @@ -0,0 +1,5 @@ +{ + "name": "pages-functions-test", + "main": "dist/worker.js", + "compatibility_date": "2026-01-20", +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25d2996c0a66..743bc3a34c0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,6 +689,10 @@ importers: version: link:../../packages/wrangler fixtures/pages-functions-test: + dependencies: + '@cloudflare/pages-functions': + specifier: workspace:* + version: link:../../packages/pages-functions devDependencies: wrangler: specifier: workspace:* From c296541a1277d575266cfff8c0fb15512b1e468c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 21 Jan 2026 15:31:44 +0000 Subject: [PATCH 06/26] Fix CI checks: add turbo.json, exclude runtime tests from lint/type --- fixtures/pages-functions-test/turbo.json | 4 +++- packages/pages-functions/eslint.config.mjs | 7 ++++++- packages/pages-functions/tsconfig.json | 2 +- packages/pages-functions/turbo.json | 13 +++++++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 packages/pages-functions/turbo.json diff --git a/fixtures/pages-functions-test/turbo.json b/fixtures/pages-functions-test/turbo.json index 4a8db2b8c648..f1b13bd0d128 100644 --- a/fixtures/pages-functions-test/turbo.json +++ b/fixtures/pages-functions-test/turbo.json @@ -1,8 +1,10 @@ { + "$schema": "http://turbo.build/schema.json", "extends": ["//"], "tasks": { "build": { - "dependsOn": ["@cloudflare/pages-functions#build"] + "dependsOn": ["@cloudflare/pages-functions#build"], + "outputs": ["dist/**"] } } } diff --git a/packages/pages-functions/eslint.config.mjs b/packages/pages-functions/eslint.config.mjs index 5fca8d5d96e0..35beafeedef7 100644 --- a/packages/pages-functions/eslint.config.mjs +++ b/packages/pages-functions/eslint.config.mjs @@ -4,6 +4,11 @@ import { defineConfig } from "eslint/config"; export default defineConfig([ ...sharedConfig, { - ignores: ["__tests__/fixtures/**"], + ignores: [ + "__tests__/fixtures/**", + "__tests__/runtime/**", + "scripts/**", + "vitest.config.runtime.mts", + ], }, ]); diff --git a/packages/pages-functions/tsconfig.json b/packages/pages-functions/tsconfig.json index 9a99a9101e82..d3ed63952072 100644 --- a/packages/pages-functions/tsconfig.json +++ b/packages/pages-functions/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["src/**/*.ts", "__tests__/**/*.ts", "vitest.config.ts"], - "exclude": ["node_modules", "dist", "__tests__/fixtures"] + "exclude": ["node_modules", "dist", "__tests__/fixtures", "__tests__/runtime"] } diff --git a/packages/pages-functions/turbo.json b/packages/pages-functions/turbo.json new file mode 100644 index 000000000000..67fb24261e98 --- /dev/null +++ b/packages/pages-functions/turbo.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "!__tests__/**"], + "outputs": ["dist/**"] + }, + "test:ci": { + "dependsOn": ["build"] + } + } +} From 32327d7b29e652abb4e9527ccf7c5baa1b62a5d2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 21 Jan 2026 15:52:42 +0000 Subject: [PATCH 07/26] Format _routes.json with tabs --- fixtures/pages-functions-test/_routes.json | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/fixtures/pages-functions-test/_routes.json b/fixtures/pages-functions-test/_routes.json index 5aeface7f7e6..294ae982a7e4 100644 --- a/fixtures/pages-functions-test/_routes.json +++ b/fixtures/pages-functions-test/_routes.json @@ -1,8 +1,6 @@ { - "version": 1, - "description": "Generated by @cloudflare/pages-functions", - "include": [ - "/*" - ], - "exclude": [] -} \ No newline at end of file + "version": 1, + "description": "Generated by @cloudflare/pages-functions", + "include": ["/*"], + "exclude": [] +} From 71b9341623f3cd9c34cd10b7ff3d46e8877102b2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:09:04 +0000 Subject: [PATCH 08/26] Accept projectDirectory, add CLI, update README - Change compileFunctions() to accept project directory instead of functions directory - Add functionsDir option (default: 'functions') - Add pages-functions CLI for command-line compilation - Update README with CLI documentation and accurate API reference - Restructure test fixtures to match project-based API --- fixtures/pages-functions-test/_routes.json | 6 +- fixtures/pages-functions-test/build.mjs | 27 ---- fixtures/pages-functions-test/package.json | 4 +- packages/pages-functions/README.md | 97 +++++++++---- .../pages-functions/__tests__/compile.test.ts | 25 ++-- .../__tests__/filepath-routing.test.ts | 23 ++-- .../functions}/_middleware.ts | 0 .../functions}/api/[id].ts | 0 .../functions}/index.ts | 0 .../functions}/.gitkeep | 0 packages/pages-functions/package.json | 3 + packages/pages-functions/src/cli.ts | 130 ++++++++++++++++++ packages/pages-functions/src/index.ts | 22 ++- packages/pages-functions/src/types.ts | 2 + 14 files changed, 259 insertions(+), 80 deletions(-) delete mode 100644 fixtures/pages-functions-test/build.mjs rename packages/pages-functions/__tests__/fixtures/{basic-functions => basic-project/functions}/_middleware.ts (100%) rename packages/pages-functions/__tests__/fixtures/{basic-functions => basic-project/functions}/api/[id].ts (100%) rename packages/pages-functions/__tests__/fixtures/{basic-functions => basic-project/functions}/index.ts (100%) rename packages/pages-functions/__tests__/fixtures/{empty-functions => empty-project/functions}/.gitkeep (100%) create mode 100644 packages/pages-functions/src/cli.ts diff --git a/fixtures/pages-functions-test/_routes.json b/fixtures/pages-functions-test/_routes.json index 294ae982a7e4..8153c0c325f5 100644 --- a/fixtures/pages-functions-test/_routes.json +++ b/fixtures/pages-functions-test/_routes.json @@ -1,6 +1,8 @@ { "version": 1, "description": "Generated by @cloudflare/pages-functions", - "include": ["/*"], + "include": [ + "/*" + ], "exclude": [] -} +} \ No newline at end of file diff --git a/fixtures/pages-functions-test/build.mjs b/fixtures/pages-functions-test/build.mjs deleted file mode 100644 index f54698aa594c..000000000000 --- a/fixtures/pages-functions-test/build.mjs +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -/** - * Build script that uses @cloudflare/pages-functions to compile - * the functions directory into a worker entrypoint. - */ -import { mkdirSync, writeFileSync } from "node:fs"; -import { compileFunctions } from "@cloudflare/pages-functions"; - -// Ensure dist directory exists -mkdirSync("./dist", { recursive: true }); - -// Compile the functions directory -const result = await compileFunctions("./functions"); - -// Write the generated worker entrypoint -writeFileSync("./dist/worker.js", result.code); -console.log("✓ Generated dist/worker.js"); - -// Write _routes.json -writeFileSync("./_routes.json", JSON.stringify(result.routesJson, null, 2)); -console.log("✓ Generated _routes.json"); - -console.log("\nRoutes:"); -for (const route of result.routes) { - const method = route.method || "ALL"; - console.log(` ${method.padEnd(6)} ${route.routePath}`); -} diff --git a/fixtures/pages-functions-test/package.json b/fixtures/pages-functions-test/package.json index 9694c52015c0..b3353dd179f3 100644 --- a/fixtures/pages-functions-test/package.json +++ b/fixtures/pages-functions-test/package.json @@ -2,8 +2,8 @@ "name": "@fixture/pages-functions-test", "private": true, "scripts": { - "build": "node build.mjs", - "dev": "node build.mjs && wrangler dev" + "build": "pages-functions", + "dev": "pages-functions && wrangler dev" }, "dependencies": { "@cloudflare/pages-functions": "workspace:*" diff --git a/packages/pages-functions/README.md b/packages/pages-functions/README.md index a56ac19e7066..824ff2b54f59 100644 --- a/packages/pages-functions/README.md +++ b/packages/pages-functions/README.md @@ -1,6 +1,6 @@ # @cloudflare/pages-functions -Compile a Pages functions directory into a deployable worker entrypoint. +Compile a Pages project's functions directory into a deployable worker entrypoint. ## Installation @@ -8,33 +8,69 @@ Compile a Pages functions directory into a deployable worker entrypoint. npm install @cloudflare/pages-functions ``` -## Usage +## CLI Usage + +```bash +# Compile the current project (looks for ./functions) +pages-functions + +# Compile a specific project +pages-functions ./my-project + +# Custom output location +pages-functions -o worker.js + +# See all options +pages-functions --help +``` + +### CLI Options + +``` +Usage: pages-functions [options] [project-dir] + +Arguments: + project-dir Path to the project root (default: ".") + +Options: + -o, --outfile Output file for the worker entrypoint (default: "dist/worker.js") + --routes-json Output path for _routes.json (default: "_routes.json") + --no-routes-json Don't generate _routes.json + --functions-dir Functions directory relative to project (default: "functions") + --base-url Base URL for routes (default: "/") + --fallback-service Fallback service binding name (default: "ASSETS") + -h, --help Show this help message +``` + +## Programmatic API ```typescript import * as fs from "node:fs/promises"; import { compileFunctions } from "@cloudflare/pages-functions"; -const result = await compileFunctions("./functions", { +const result = await compileFunctions(".", { fallbackService: "ASSETS", }); // Write the generated worker entrypoint -await fs.writeFile("worker.js", result.code); +await fs.writeFile("dist/worker.js", result.code); // Write _routes.json for Pages deployment -await fs.writeFile("_routes.json", JSON.stringify(result.routesJson, null, 2)); +await fs.writeFile( + "_routes.json", + JSON.stringify(result.routesJson, null, "\t") +); ``` -## API +### `compileFunctions(projectDirectory, options?)` -### `compileFunctions(functionsDirectory, options?)` - -Compiles a Pages functions directory into a worker entrypoint. +Compiles a Pages project's functions directory into a worker entrypoint. #### Parameters -- `functionsDirectory` (string): Path to the functions directory +- `projectDirectory` (string): Path to the project root (containing the functions directory) - `options` (object, optional): + - `functionsDir` (string): Functions directory relative to project root. Default: `"functions"` - `baseURL` (string): Base URL prefix for all routes. Default: `"/"` - `fallbackService` (string): Fallback service binding name. Default: `"ASSETS"` @@ -46,18 +82,21 @@ A `Promise` with: - `routes` (RouteConfig[]): Parsed route configuration - `routesJson` (RoutesJSONSpec): `_routes.json` content for Pages deployment -## Functions Directory Structure +## Project Structure -The functions directory uses file-based routing: +Your project should have a `functions` directory with file-based routing: ``` -functions/ -├── index.ts # Handles / -├── _middleware.ts # Middleware for all routes -├── api/ -│ ├── index.ts # Handles /api -│ ├── [id].ts # Handles /api/:id -│ └── [[catchall]].ts # Handles /api/* +my-project/ +├── functions/ +│ ├── index.ts # Handles / +│ ├── _middleware.ts # Middleware for all routes +│ └── api/ +│ ├── index.ts # Handles /api +│ ├── [id].ts # Handles /api/:id +│ └── [[catchall]].ts # Handles /api/* +├── wrangler.jsonc +└── package.json ``` ### Route Parameters @@ -65,7 +104,7 @@ functions/ - `[param]` - Dynamic parameter (e.g., `[id].ts` → `/api/:id`) - `[[catchall]]` - Catch-all parameter (e.g., `[[path]].ts` → `/api/:path*`) -### Exports +### Handler Exports Export handlers from your function files: @@ -78,16 +117,28 @@ export const onRequestGet = (context) => new Response("GET"); export const onRequestPost = (context) => new Response("POST"); ``` +### Middleware + +Create a `_middleware.ts` file to run code before your handlers: + +```typescript +export const onRequest = async (context) => { + const response = await context.next(); + response.headers.set("X-Custom-Header", "value"); + return response; +}; +``` + ## Generated Output The generated code: -1. Imports all function handlers +1. Imports all function handlers from the functions directory 2. Creates a route configuration array -3. Includes the Pages Functions runtime (inlined) +3. Includes the Pages Functions runtime (route matching, middleware execution) 4. Exports a default handler that routes requests -The output requires `path-to-regexp` as a runtime dependency (resolved during bundling). +The output imports `path-to-regexp` for route matching. When bundled by wrangler or esbuild, this dependency is resolved and included automatically. ## License diff --git a/packages/pages-functions/__tests__/compile.test.ts b/packages/pages-functions/__tests__/compile.test.ts index 9a41e86c74ca..057188952b79 100644 --- a/packages/pages-functions/__tests__/compile.test.ts +++ b/packages/pages-functions/__tests__/compile.test.ts @@ -4,11 +4,12 @@ import { describe, expect, it } from "vitest"; import { compileFunctions, FunctionsNoRoutesError } from "../src/index.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, "fixtures"); describe("compileFunctions", () => { - it("compiles a functions directory to worker code", async () => { - const functionsDir = path.join(__dirname, "fixtures/basic-functions"); - const result = await compileFunctions(functionsDir); + it("compiles a project to worker code", async () => { + const projectDir = path.join(fixturesDir, "basic-project"); + const result = await compileFunctions(projectDir); // Should have generated code expect(result.code).toBeDefined(); @@ -28,8 +29,8 @@ describe("compileFunctions", () => { }); it("uses custom fallbackService", async () => { - const functionsDir = path.join(__dirname, "fixtures/basic-functions"); - const result = await compileFunctions(functionsDir, { + const projectDir = path.join(fixturesDir, "basic-project"); + const result = await compileFunctions(projectDir, { fallbackService: "CUSTOM_ASSETS", }); @@ -37,8 +38,8 @@ describe("compileFunctions", () => { }); it("uses custom baseURL", async () => { - const functionsDir = path.join(__dirname, "fixtures/basic-functions"); - const result = await compileFunctions(functionsDir, { + const projectDir = path.join(fixturesDir, "basic-project"); + const result = await compileFunctions(projectDir, { baseURL: "/v1", }); @@ -48,17 +49,17 @@ describe("compileFunctions", () => { } }); - it("throws FunctionsNoRoutesError for empty directory", async () => { - const emptyDir = path.join(__dirname, "fixtures/empty-functions"); + it("throws FunctionsNoRoutesError for empty project", async () => { + const projectDir = path.join(fixturesDir, "empty-project"); - await expect(compileFunctions(emptyDir)).rejects.toThrow( + await expect(compileFunctions(projectDir)).rejects.toThrow( FunctionsNoRoutesError ); }); it("generates valid _routes.json", async () => { - const functionsDir = path.join(__dirname, "fixtures/basic-functions"); - const result = await compileFunctions(functionsDir); + const projectDir = path.join(fixturesDir, "basic-project"); + const result = await compileFunctions(projectDir); // Validate structure expect(result.routesJson.version).toBe(1); diff --git a/packages/pages-functions/__tests__/filepath-routing.test.ts b/packages/pages-functions/__tests__/filepath-routing.test.ts index 9284dc49dbcd..19553a15e1f8 100644 --- a/packages/pages-functions/__tests__/filepath-routing.test.ts +++ b/packages/pages-functions/__tests__/filepath-routing.test.ts @@ -298,9 +298,15 @@ describe("filepath-routing", () => { }); describe("generateConfigFromFileTree (fixture)", () => { + const functionsDir = path.join( + __dirname, + "fixtures/basic-project/functions" + ); + it("generates routes from a functions directory", async () => { - const baseDir = path.join(__dirname, "fixtures/basic-functions"); - const config = await generateConfigFromFileTree({ baseDir }); + const config = await generateConfigFromFileTree({ + baseDir: functionsDir, + }); expect(config.routes).toBeDefined(); expect(config.routes.length).toBeGreaterThan(0); @@ -330,8 +336,9 @@ describe("filepath-routing", () => { }); it("converts bracket params to path-to-regexp format", async () => { - const baseDir = path.join(__dirname, "fixtures/basic-functions"); - const config = await generateConfigFromFileTree({ baseDir }); + const config = await generateConfigFromFileTree({ + baseDir: functionsDir, + }); // [id] should become :id const apiRoute = config.routes.find((r) => r.routePath.includes("/api/")); @@ -340,9 +347,8 @@ describe("filepath-routing", () => { }); it("respects baseURL option", async () => { - const baseDir = path.join(__dirname, "fixtures/basic-functions"); const config = await generateConfigFromFileTree({ - baseDir, + baseDir: functionsDir, baseURL: "/prefix" as UrlPath, }); @@ -353,8 +359,9 @@ describe("filepath-routing", () => { }); it("sorts routes by specificity", async () => { - const baseDir = path.join(__dirname, "fixtures/basic-functions"); - const config = await generateConfigFromFileTree({ baseDir }); + const config = await generateConfigFromFileTree({ + baseDir: functionsDir, + }); // More specific routes should come before less specific ones const routePaths = config.routes.map((r) => r.routePath); diff --git a/packages/pages-functions/__tests__/fixtures/basic-functions/_middleware.ts b/packages/pages-functions/__tests__/fixtures/basic-project/functions/_middleware.ts similarity index 100% rename from packages/pages-functions/__tests__/fixtures/basic-functions/_middleware.ts rename to packages/pages-functions/__tests__/fixtures/basic-project/functions/_middleware.ts diff --git a/packages/pages-functions/__tests__/fixtures/basic-functions/api/[id].ts b/packages/pages-functions/__tests__/fixtures/basic-project/functions/api/[id].ts similarity index 100% rename from packages/pages-functions/__tests__/fixtures/basic-functions/api/[id].ts rename to packages/pages-functions/__tests__/fixtures/basic-project/functions/api/[id].ts diff --git a/packages/pages-functions/__tests__/fixtures/basic-functions/index.ts b/packages/pages-functions/__tests__/fixtures/basic-project/functions/index.ts similarity index 100% rename from packages/pages-functions/__tests__/fixtures/basic-functions/index.ts rename to packages/pages-functions/__tests__/fixtures/basic-project/functions/index.ts diff --git a/packages/pages-functions/__tests__/fixtures/empty-functions/.gitkeep b/packages/pages-functions/__tests__/fixtures/empty-project/functions/.gitkeep similarity index 100% rename from packages/pages-functions/__tests__/fixtures/empty-functions/.gitkeep rename to packages/pages-functions/__tests__/fixtures/empty-project/functions/.gitkeep diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json index 57d252c405ef..16f1ef697575 100644 --- a/packages/pages-functions/package.json +++ b/packages/pages-functions/package.json @@ -28,6 +28,9 @@ }, "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "pages-functions": "./dist/cli.js" + }, "files": [ "dist", "README.md" diff --git a/packages/pages-functions/src/cli.ts b/packages/pages-functions/src/cli.ts new file mode 100644 index 000000000000..38580b038f29 --- /dev/null +++ b/packages/pages-functions/src/cli.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * CLI for @cloudflare/pages-functions + * + * Compiles a Pages project's functions directory into a worker entrypoint. + */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { compileFunctions, DEFAULT_FUNCTIONS_DIR } from "./index.js"; + +interface CliArgs { + projectDir: string; + outfile: string; + routesJson: string | null; + functionsDir: string; + baseURL: string; + fallbackService: string; + help: boolean; +} + +function parseArgs(args: string[]): CliArgs { + const result: CliArgs = { + projectDir: ".", + outfile: "dist/worker.js", + routesJson: "_routes.json", + functionsDir: DEFAULT_FUNCTIONS_DIR, + baseURL: "/", + fallbackService: "ASSETS", + help: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "-h" || arg === "--help") { + result.help = true; + } else if (arg === "-o" || arg === "--outfile") { + result.outfile = args[++i]; + } else if (arg === "--routes-json") { + result.routesJson = args[++i]; + } else if (arg === "--no-routes-json") { + result.routesJson = null; + } else if (arg === "--functions-dir") { + result.functionsDir = args[++i]; + } else if (arg === "--base-url") { + result.baseURL = args[++i]; + } else if (arg === "--fallback-service") { + result.fallbackService = args[++i]; + } else if (!arg.startsWith("-")) { + result.projectDir = arg; + } + } + + return result; +} + +function printHelp() { + console.log(` +Usage: pages-functions [options] [project-dir] + +Compiles a Pages project's functions directory into a worker entrypoint. + +Arguments: + project-dir Path to the project root (default: ".") + +Options: + -o, --outfile Output file for the worker entrypoint (default: "dist/worker.js") + --routes-json Output path for _routes.json (default: "_routes.json") + --no-routes-json Don't generate _routes.json + --functions-dir Functions directory relative to project (default: "functions") + --base-url Base URL for routes (default: "/") + --fallback-service Fallback service binding name (default: "ASSETS") + -h, --help Show this help message + +Examples: + pages-functions # Compile ./functions to dist/worker.js + pages-functions ./my-project # Compile my-project/functions + pages-functions -o worker.js # Output to worker.js instead + pages-functions --base-url /api # Prefix all routes with /api +`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + process.exit(0); + } + + try { + const result = await compileFunctions(args.projectDir, { + functionsDir: args.functionsDir, + baseURL: args.baseURL, + fallbackService: args.fallbackService, + }); + + // Ensure output directory exists + await fs.mkdir(path.dirname(args.outfile), { recursive: true }); + + // Write worker entrypoint + await fs.writeFile(args.outfile, result.code); + console.log(`✓ Generated ${args.outfile}`); + + // Write _routes.json if requested + if (args.routesJson) { + await fs.writeFile( + args.routesJson, + JSON.stringify(result.routesJson, null, "\t") + ); + console.log(`✓ Generated ${args.routesJson}`); + } + + // Print routes summary + console.log("\nRoutes:"); + for (const route of result.routes) { + const method = route.method || "ALL"; + console.log(` ${method.padEnd(6)} ${route.routePath}`); + } + } catch (error) { + if (error instanceof Error) { + console.error(`Error: ${error.message}`); + } else { + console.error("An unexpected error occurred"); + } + process.exit(1); + } +} + +void main(); diff --git a/packages/pages-functions/src/index.ts b/packages/pages-functions/src/index.ts index 94ba3f8a4921..979ed816f3c6 100644 --- a/packages/pages-functions/src/index.ts +++ b/packages/pages-functions/src/index.ts @@ -4,6 +4,7 @@ * Compile a Pages functions directory into a deployable worker entrypoint. */ +import * as path from "node:path"; import { generateWorkerEntrypoint } from "./codegen.js"; import { generateConfigFromFileTree } from "./filepath-routing.js"; import { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; @@ -24,6 +25,9 @@ export { generateConfigFromFileTree } from "./filepath-routing.js"; export { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; export { generateWorkerEntrypoint } from "./codegen.js"; +/** Default functions directory relative to project root */ +export const DEFAULT_FUNCTIONS_DIR = "functions"; + /** * Error thrown when no routes are found in the functions directory. */ @@ -35,9 +39,9 @@ export class FunctionsNoRoutesError extends Error { } /** - * Compile a Pages functions directory into a worker entrypoint. + * Compile a Pages project's functions directory into a worker entrypoint. * - * @param functionsDirectory Path to the functions directory + * @param projectDirectory Path to the project root (containing the functions directory) * @param options Compilation options * @returns Compilation result containing generated code and route info * @@ -45,22 +49,28 @@ export class FunctionsNoRoutesError extends Error { * ```ts * import { compileFunctions } from '@cloudflare/pages-functions'; * - * const result = await compileFunctions('./functions', { + * const result = await compileFunctions('.', { * fallbackService: 'ASSETS', * }); * * // Write the generated entrypoint - * await fs.writeFile('worker.js', result.code); + * await fs.writeFile('dist/worker.js', result.code); * * // Write _routes.json for Pages deployment * await fs.writeFile('_routes.json', JSON.stringify(result.routesJson, null, 2)); * ``` */ export async function compileFunctions( - functionsDirectory: string, + projectDirectory: string, options: CompileOptions = {} ): Promise { - const { baseURL = "/" as UrlPath, fallbackService = "ASSETS" } = options; + const { + functionsDir = DEFAULT_FUNCTIONS_DIR, + baseURL = "/" as UrlPath, + fallbackService = "ASSETS", + } = options; + + const functionsDirectory = path.join(projectDirectory, functionsDir); // Generate route configuration from the file tree const config = await generateConfigFromFileTree({ diff --git a/packages/pages-functions/src/types.ts b/packages/pages-functions/src/types.ts index 2f5f1ebe4dcd..353d14d678ab 100644 --- a/packages/pages-functions/src/types.ts +++ b/packages/pages-functions/src/types.ts @@ -56,6 +56,8 @@ export interface RoutesJSONSpec { * Options for compiling a functions directory. */ export interface CompileOptions { + /** Path to the functions directory relative to the project root. Default: "functions" */ + functionsDir?: string; /** Base URL for routes. Default: "/" */ baseURL?: string; /** Fallback service binding name. Default: "ASSETS" */ From 4ba0a87081d75403a351a73aaead3e1bd3ca7d76 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:14:06 +0000 Subject: [PATCH 09/26] Fix fixture to use direct node invocation for CLI --- fixtures/pages-functions-test/package.json | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/fixtures/pages-functions-test/package.json b/fixtures/pages-functions-test/package.json index b3353dd179f3..a1083079d8fd 100644 --- a/fixtures/pages-functions-test/package.json +++ b/fixtures/pages-functions-test/package.json @@ -2,13 +2,11 @@ "name": "@fixture/pages-functions-test", "private": true, "scripts": { - "build": "pages-functions", - "dev": "pages-functions && wrangler dev" - }, - "dependencies": { - "@cloudflare/pages-functions": "workspace:*" + "build": "node ../../packages/pages-functions/dist/cli.js", + "dev": "node ../../packages/pages-functions/dist/cli.js && wrangler dev" }, "devDependencies": { + "@cloudflare/pages-functions": "workspace:*", "wrangler": "workspace:*" } } From b9b5e30c02c406828a9543493bc17c651a56201a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:16:21 +0000 Subject: [PATCH 10/26] Update lockfile --- pnpm-lock.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a1498951b33..6d4845d7c9ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,11 +689,10 @@ importers: version: link:../../packages/wrangler fixtures/pages-functions-test: - dependencies: + devDependencies: '@cloudflare/pages-functions': specifier: workspace:* version: link:../../packages/pages-functions - devDependencies: wrangler: specifier: workspace:* version: link:../../packages/wrangler From 36fee8d4932cce2fe9ba0b9c45428f7d9bab7c6d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:19:27 +0000 Subject: [PATCH 11/26] Format _routes.json --- fixtures/pages-functions-test/_routes.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fixtures/pages-functions-test/_routes.json b/fixtures/pages-functions-test/_routes.json index 8153c0c325f5..294ae982a7e4 100644 --- a/fixtures/pages-functions-test/_routes.json +++ b/fixtures/pages-functions-test/_routes.json @@ -1,8 +1,6 @@ { "version": 1, "description": "Generated by @cloudflare/pages-functions", - "include": [ - "/*" - ], + "include": ["/*"], "exclude": [] -} \ No newline at end of file +} From a9f328b06ea458e974e21f05e8af7b2fa6e2c7a6 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:22:55 +0000 Subject: [PATCH 12/26] Gitignore generated files in fixture --- fixtures/pages-functions-test/.gitignore | 3 +++ fixtures/pages-functions-test/_routes.json | 6 ------ 2 files changed, 3 insertions(+), 6 deletions(-) create mode 100644 fixtures/pages-functions-test/.gitignore delete mode 100644 fixtures/pages-functions-test/_routes.json diff --git a/fixtures/pages-functions-test/.gitignore b/fixtures/pages-functions-test/.gitignore new file mode 100644 index 000000000000..52df4fb6f598 --- /dev/null +++ b/fixtures/pages-functions-test/.gitignore @@ -0,0 +1,3 @@ +# Generated outputs +dist/ +_routes.json diff --git a/fixtures/pages-functions-test/_routes.json b/fixtures/pages-functions-test/_routes.json deleted file mode 100644 index 294ae982a7e4..000000000000 --- a/fixtures/pages-functions-test/_routes.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 1, - "description": "Generated by @cloudflare/pages-functions", - "include": ["/*"], - "exclude": [] -} From 0ec790b7404b57bd0a606bb74600f00e3d2d8570 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:43:45 +0000 Subject: [PATCH 13/26] Use node:util parseArgs for CLI argument parsing --- packages/pages-functions/src/cli.ts | 102 +++++++++++----------------- 1 file changed, 38 insertions(+), 64 deletions(-) diff --git a/packages/pages-functions/src/cli.ts b/packages/pages-functions/src/cli.ts index 38580b038f29..b84bff02769f 100644 --- a/packages/pages-functions/src/cli.ts +++ b/packages/pages-functions/src/cli.ts @@ -6,56 +6,10 @@ */ import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { parseArgs } from "node:util"; import { compileFunctions, DEFAULT_FUNCTIONS_DIR } from "./index.js"; -interface CliArgs { - projectDir: string; - outfile: string; - routesJson: string | null; - functionsDir: string; - baseURL: string; - fallbackService: string; - help: boolean; -} - -function parseArgs(args: string[]): CliArgs { - const result: CliArgs = { - projectDir: ".", - outfile: "dist/worker.js", - routesJson: "_routes.json", - functionsDir: DEFAULT_FUNCTIONS_DIR, - baseURL: "/", - fallbackService: "ASSETS", - help: false, - }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === "-h" || arg === "--help") { - result.help = true; - } else if (arg === "-o" || arg === "--outfile") { - result.outfile = args[++i]; - } else if (arg === "--routes-json") { - result.routesJson = args[++i]; - } else if (arg === "--no-routes-json") { - result.routesJson = null; - } else if (arg === "--functions-dir") { - result.functionsDir = args[++i]; - } else if (arg === "--base-url") { - result.baseURL = args[++i]; - } else if (arg === "--fallback-service") { - result.fallbackService = args[++i]; - } else if (!arg.startsWith("-")) { - result.projectDir = arg; - } - } - - return result; -} - -function printHelp() { - console.log(` +const HELP = ` Usage: pages-functions [options] [project-dir] Compiles a Pages project's functions directory into a worker entrypoint. @@ -77,38 +31,58 @@ Examples: pages-functions ./my-project # Compile my-project/functions pages-functions -o worker.js # Output to worker.js instead pages-functions --base-url /api # Prefix all routes with /api -`); -} +`; async function main() { - const args = parseArgs(process.argv.slice(2)); - - if (args.help) { - printHelp(); + const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + outfile: { type: "string", short: "o", default: "dist/worker.js" }, + "routes-json": { type: "string", default: "_routes.json" }, + "no-routes-json": { type: "boolean", default: false }, + "functions-dir": { type: "string", default: DEFAULT_FUNCTIONS_DIR }, + "base-url": { type: "string", default: "/" }, + "fallback-service": { type: "string", default: "ASSETS" }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: true, + }); + + if (values.help) { + console.log(HELP); process.exit(0); } + const projectDir = positionals[0] ?? "."; + const outfile = values.outfile ?? "dist/worker.js"; + const routesJson = values["no-routes-json"] + ? null + : values["routes-json"] ?? "_routes.json"; + const functionsDir = values["functions-dir"] ?? DEFAULT_FUNCTIONS_DIR; + const baseURL = values["base-url"] ?? "/"; + const fallbackService = values["fallback-service"] ?? "ASSETS"; + try { - const result = await compileFunctions(args.projectDir, { - functionsDir: args.functionsDir, - baseURL: args.baseURL, - fallbackService: args.fallbackService, + const result = await compileFunctions(projectDir, { + functionsDir, + baseURL, + fallbackService, }); // Ensure output directory exists - await fs.mkdir(path.dirname(args.outfile), { recursive: true }); + await fs.mkdir(path.dirname(outfile), { recursive: true }); // Write worker entrypoint - await fs.writeFile(args.outfile, result.code); - console.log(`✓ Generated ${args.outfile}`); + await fs.writeFile(outfile, result.code); + console.log(`✓ Generated ${outfile}`); // Write _routes.json if requested - if (args.routesJson) { + if (routesJson) { await fs.writeFile( - args.routesJson, + routesJson, JSON.stringify(result.routesJson, null, "\t") ); - console.log(`✓ Generated ${args.routesJson}`); + console.log(`✓ Generated ${routesJson}`); } // Print routes summary From 430704c7bbb09d0c953d3f9d57065fc67ee9539f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:44:49 +0000 Subject: [PATCH 14/26] Add esbuild to dependencies --- packages/pages-functions/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json index 16f1ef697575..9dd9ecf22b66 100644 --- a/packages/pages-functions/package.json +++ b/packages/pages-functions/package.json @@ -45,6 +45,7 @@ "test:watch": "vitest" }, "dependencies": { + "esbuild": "catalog:default", "path-to-regexp": "^6.3.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d4845d7c9ee..3d0bc2887a0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2108,6 +2108,9 @@ importers: packages/pages-functions: dependencies: + esbuild: + specifier: catalog:default + version: 0.27.0 path-to-regexp: specifier: ^6.3.0 version: 6.3.0 From 7dc9eb571c20d2779b1adb97b4dde0949de42643 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:47:09 +0000 Subject: [PATCH 15/26] Remove functionsDir option - directory is always 'functions' --- packages/pages-functions/README.md | 4 +--- packages/pages-functions/src/cli.ts | 6 +----- packages/pages-functions/src/index.ts | 8 ++------ packages/pages-functions/src/types.ts | 2 -- 4 files changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/pages-functions/README.md b/packages/pages-functions/README.md index 824ff2b54f59..c8fbf02d3ca7 100644 --- a/packages/pages-functions/README.md +++ b/packages/pages-functions/README.md @@ -36,7 +36,6 @@ Options: -o, --outfile Output file for the worker entrypoint (default: "dist/worker.js") --routes-json Output path for _routes.json (default: "_routes.json") --no-routes-json Don't generate _routes.json - --functions-dir Functions directory relative to project (default: "functions") --base-url Base URL for routes (default: "/") --fallback-service Fallback service binding name (default: "ASSETS") -h, --help Show this help message @@ -68,9 +67,8 @@ Compiles a Pages project's functions directory into a worker entrypoint. #### Parameters -- `projectDirectory` (string): Path to the project root (containing the functions directory) +- `projectDirectory` (string): Path to the project root (containing the `functions` directory) - `options` (object, optional): - - `functionsDir` (string): Functions directory relative to project root. Default: `"functions"` - `baseURL` (string): Base URL prefix for all routes. Default: `"/"` - `fallbackService` (string): Fallback service binding name. Default: `"ASSETS"` diff --git a/packages/pages-functions/src/cli.ts b/packages/pages-functions/src/cli.ts index b84bff02769f..36acfe567d61 100644 --- a/packages/pages-functions/src/cli.ts +++ b/packages/pages-functions/src/cli.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { parseArgs } from "node:util"; -import { compileFunctions, DEFAULT_FUNCTIONS_DIR } from "./index.js"; +import { compileFunctions } from "./index.js"; const HELP = ` Usage: pages-functions [options] [project-dir] @@ -21,7 +21,6 @@ Options: -o, --outfile Output file for the worker entrypoint (default: "dist/worker.js") --routes-json Output path for _routes.json (default: "_routes.json") --no-routes-json Don't generate _routes.json - --functions-dir Functions directory relative to project (default: "functions") --base-url Base URL for routes (default: "/") --fallback-service Fallback service binding name (default: "ASSETS") -h, --help Show this help message @@ -40,7 +39,6 @@ async function main() { outfile: { type: "string", short: "o", default: "dist/worker.js" }, "routes-json": { type: "string", default: "_routes.json" }, "no-routes-json": { type: "boolean", default: false }, - "functions-dir": { type: "string", default: DEFAULT_FUNCTIONS_DIR }, "base-url": { type: "string", default: "/" }, "fallback-service": { type: "string", default: "ASSETS" }, help: { type: "boolean", short: "h", default: false }, @@ -58,13 +56,11 @@ async function main() { const routesJson = values["no-routes-json"] ? null : values["routes-json"] ?? "_routes.json"; - const functionsDir = values["functions-dir"] ?? DEFAULT_FUNCTIONS_DIR; const baseURL = values["base-url"] ?? "/"; const fallbackService = values["fallback-service"] ?? "ASSETS"; try { const result = await compileFunctions(projectDir, { - functionsDir, baseURL, fallbackService, }); diff --git a/packages/pages-functions/src/index.ts b/packages/pages-functions/src/index.ts index 979ed816f3c6..38691a0afeea 100644 --- a/packages/pages-functions/src/index.ts +++ b/packages/pages-functions/src/index.ts @@ -64,13 +64,9 @@ export async function compileFunctions( projectDirectory: string, options: CompileOptions = {} ): Promise { - const { - functionsDir = DEFAULT_FUNCTIONS_DIR, - baseURL = "/" as UrlPath, - fallbackService = "ASSETS", - } = options; + const { baseURL = "/" as UrlPath, fallbackService = "ASSETS" } = options; - const functionsDirectory = path.join(projectDirectory, functionsDir); + const functionsDirectory = path.join(projectDirectory, DEFAULT_FUNCTIONS_DIR); // Generate route configuration from the file tree const config = await generateConfigFromFileTree({ diff --git a/packages/pages-functions/src/types.ts b/packages/pages-functions/src/types.ts index 353d14d678ab..2f5f1ebe4dcd 100644 --- a/packages/pages-functions/src/types.ts +++ b/packages/pages-functions/src/types.ts @@ -56,8 +56,6 @@ export interface RoutesJSONSpec { * Options for compiling a functions directory. */ export interface CompileOptions { - /** Path to the functions directory relative to the project root. Default: "functions" */ - functionsDir?: string; /** Base URL for routes. Default: "/" */ baseURL?: string; /** Fallback service binding name. Default: "ASSETS" */ From 4b203f810e800630e7a516e49d81f3ab8f235287 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:51:16 +0000 Subject: [PATCH 16/26] Remove outdated fixture README --- fixtures/pages-functions-test/README.md | 35 ------------------------- 1 file changed, 35 deletions(-) delete mode 100644 fixtures/pages-functions-test/README.md diff --git a/fixtures/pages-functions-test/README.md b/fixtures/pages-functions-test/README.md deleted file mode 100644 index 209f3d0fe7f7..000000000000 --- a/fixtures/pages-functions-test/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Pages Functions Test Fixture - -Test fixture for `@cloudflare/pages-functions` package. - -## Usage - -From the workers-sdk root: - -```bash -# Build the pages-functions package first -pnpm --filter @cloudflare/pages-functions build - -# Install deps for this fixture -pnpm --filter pages-functions-test install - -# Compile functions -> worker, then bundle, then run dev -pnpm --filter pages-functions-test dev -``` - -## What it does - -1. `build.mjs` uses `compileFunctions()` to compile `functions/` into `dist/worker.js` -2. esbuild bundles `dist/worker.js` (with path-to-regexp) into `dist/bundled.js` -3. wrangler runs the bundled worker - -## Test endpoints - -- `GET /` - Index route -- `GET /api/hello` - Static API route -- `POST /api/hello` - POST handler -- `GET /api/:id` - Dynamic route -- `PUT /api/:id` - Update handler -- `DELETE /api/:id` - Delete handler - -All routes go through the root middleware which adds `X-Middleware: active` header. From 9bb21092c4619e88ca0a11d02eb321cdc8ab778a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:58:49 +0000 Subject: [PATCH 17/26] Use bare import for path-to-regexp instead of absolute path --- packages/pages-functions/src/codegen.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/pages-functions/src/codegen.ts b/packages/pages-functions/src/codegen.ts index ad15a60645da..b538663e6340 100644 --- a/packages/pages-functions/src/codegen.ts +++ b/packages/pages-functions/src/codegen.ts @@ -4,22 +4,11 @@ * Generates a worker entrypoint from route configuration. */ -import { createRequire } from "node:module"; import * as path from "node:path"; import { isValidIdentifier, normalizeIdentifier } from "./identifiers.js"; import { generateRuntimeCode } from "./runtime.js"; import type { RouteConfig } from "./types.js"; -const require = createRequire(import.meta.url); - -/** - * Resolve the path to path-to-regexp module. - * This gets resolved at codegen time; wrangler/esbuild will bundle it. - */ -function getPathToRegexpPath(): string { - return require.resolve("path-to-regexp"); -} - /** * Internal representation of routes with resolved identifiers. */ @@ -70,12 +59,11 @@ export function generateWorkerEntrypoint( const imports = generateImports(importMap); const routesArray = generateRoutesArray(resolvedRoutes); const runtime = generateRuntimeCode(); - const pathToRegexpPath = getPathToRegexpPath(); return [ "// Generated by @cloudflare/pages-functions", "", - `import { match } from ${JSON.stringify(pathToRegexpPath)};`, + 'import { match } from "path-to-regexp";', "", "// User function imports", imports, From cc8dc03ac34220dfa2f5c4236b37be8e7e0d1a29 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 11:59:07 +0000 Subject: [PATCH 18/26] Update README to clarify path-to-regexp dependency --- packages/pages-functions/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pages-functions/README.md b/packages/pages-functions/README.md index c8fbf02d3ca7..3c3f86d348df 100644 --- a/packages/pages-functions/README.md +++ b/packages/pages-functions/README.md @@ -136,7 +136,7 @@ The generated code: 3. Includes the Pages Functions runtime (route matching, middleware execution) 4. Exports a default handler that routes requests -The output imports `path-to-regexp` for route matching. When bundled by wrangler or esbuild, this dependency is resolved and included automatically. +The output imports `path-to-regexp` for route matching. This package includes `path-to-regexp` as a dependency, so it will be available when you bundle with wrangler or esbuild. ## License From 5b930e5776c20c282ddc1b47deb20fbd70cc1be9 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 12:00:15 +0000 Subject: [PATCH 19/26] Remove path-to-regexp dep, document it as user requirement --- packages/pages-functions/README.md | 6 +++++- packages/pages-functions/package.json | 3 +-- pnpm-lock.yaml | 3 --- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/pages-functions/README.md b/packages/pages-functions/README.md index 3c3f86d348df..f4390d365cbf 100644 --- a/packages/pages-functions/README.md +++ b/packages/pages-functions/README.md @@ -136,7 +136,11 @@ The generated code: 3. Includes the Pages Functions runtime (route matching, middleware execution) 4. Exports a default handler that routes requests -The output imports `path-to-regexp` for route matching. This package includes `path-to-regexp` as a dependency, so it will be available when you bundle with wrangler or esbuild. +The output imports `path-to-regexp` for route matching. You need to install it in your project: + +```bash +npm install path-to-regexp +``` ## License diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json index 9dd9ecf22b66..d9b8a64aa444 100644 --- a/packages/pages-functions/package.json +++ b/packages/pages-functions/package.json @@ -45,8 +45,7 @@ "test:watch": "vitest" }, "dependencies": { - "esbuild": "catalog:default", - "path-to-regexp": "^6.3.0" + "esbuild": "catalog:default" }, "devDependencies": { "@cloudflare/eslint-config-shared": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d0bc2887a0c..fecc1c092f9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2111,9 +2111,6 @@ importers: esbuild: specifier: catalog:default version: 0.27.0 - path-to-regexp: - specifier: ^6.3.0 - version: 6.3.0 devDependencies: '@cloudflare/eslint-config-shared': specifier: workspace:* From cd3f55b742fa02dacdb63c58d2da0cb30e0f5b20 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 12:00:27 +0000 Subject: [PATCH 20/26] Remove unused deps script --- packages/pages-functions/scripts/deps.ts | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 packages/pages-functions/scripts/deps.ts diff --git a/packages/pages-functions/scripts/deps.ts b/packages/pages-functions/scripts/deps.ts deleted file mode 100644 index 337188768caa..000000000000 --- a/packages/pages-functions/scripts/deps.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Dependencies that _are not_ bundled along with 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 = [ - // Imported via resolved absolute path into generated worker code. - // Wrangler/esbuild bundles it when building the final worker. - "path-to-regexp", -]; From 8a569e999ce1c30cc4db13d0ab53d924a3d84aa5 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 12:01:50 +0000 Subject: [PATCH 21/26] Add path-to-regexp to devDependencies for tests --- packages/pages-functions/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json index d9b8a64aa444..c0d450137491 100644 --- a/packages/pages-functions/package.json +++ b/packages/pages-functions/package.json @@ -54,6 +54,7 @@ "@cloudflare/workers-types": "catalog:default", "@types/node": "catalog:default", "eslint": "catalog:default", + "path-to-regexp": "^6.3.0", "typescript": "catalog:default", "vitest": "catalog:default" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fecc1c092f9d..7a86ba70413c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2130,6 +2130,9 @@ importers: eslint: specifier: catalog:default version: 9.39.1(jiti@2.6.0) + path-to-regexp: + specifier: ^6.3.0 + version: 6.3.0 typescript: specifier: catalog:default version: 5.8.3 From f304d4ce20cb96448f177772156fb4b1175567b3 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 12:09:07 +0000 Subject: [PATCH 22/26] Switch to bundler moduleResolution, remove .js extensions --- packages/pages-functions/src/cli.ts | 2 +- packages/pages-functions/src/codegen.ts | 6 +++--- packages/pages-functions/src/filepath-routing.ts | 2 +- packages/pages-functions/src/index.ts | 16 ++++++++-------- .../pages-functions/src/routes-transformation.ts | 4 ++-- packages/pages-functions/tsconfig.json | 4 ++-- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/pages-functions/src/cli.ts b/packages/pages-functions/src/cli.ts index 36acfe567d61..7e74bbb28485 100644 --- a/packages/pages-functions/src/cli.ts +++ b/packages/pages-functions/src/cli.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { parseArgs } from "node:util"; -import { compileFunctions } from "./index.js"; +import { compileFunctions } from "./index"; const HELP = ` Usage: pages-functions [options] [project-dir] diff --git a/packages/pages-functions/src/codegen.ts b/packages/pages-functions/src/codegen.ts index b538663e6340..be9a5f15d626 100644 --- a/packages/pages-functions/src/codegen.ts +++ b/packages/pages-functions/src/codegen.ts @@ -5,9 +5,9 @@ */ import * as path from "node:path"; -import { isValidIdentifier, normalizeIdentifier } from "./identifiers.js"; -import { generateRuntimeCode } from "./runtime.js"; -import type { RouteConfig } from "./types.js"; +import { isValidIdentifier, normalizeIdentifier } from "./identifiers"; +import { generateRuntimeCode } from "./runtime"; +import type { RouteConfig } from "./types"; /** * Internal representation of routes with resolved identifiers. diff --git a/packages/pages-functions/src/filepath-routing.ts b/packages/pages-functions/src/filepath-routing.ts index 10cec5176933..de398a431590 100644 --- a/packages/pages-functions/src/filepath-routing.ts +++ b/packages/pages-functions/src/filepath-routing.ts @@ -13,7 +13,7 @@ import type { HTTPMethod, RouteConfig, UrlPath, -} from "./types.js"; +} from "./types"; /** * Error thrown when building/parsing a function file fails. diff --git a/packages/pages-functions/src/index.ts b/packages/pages-functions/src/index.ts index 38691a0afeea..79ee597a1c5c 100644 --- a/packages/pages-functions/src/index.ts +++ b/packages/pages-functions/src/index.ts @@ -5,10 +5,10 @@ */ import * as path from "node:path"; -import { generateWorkerEntrypoint } from "./codegen.js"; -import { generateConfigFromFileTree } from "./filepath-routing.js"; -import { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; -import type { CompileOptions, CompileResult, UrlPath } from "./types.js"; +import { generateWorkerEntrypoint } from "./codegen"; +import { generateConfigFromFileTree } from "./filepath-routing"; +import { convertRoutesToRoutesJSONSpec } from "./routes-transformation"; +import type { CompileOptions, CompileResult, UrlPath } from "./types"; // Re-export types export type { @@ -18,12 +18,12 @@ export type { RouteConfig, RoutesJSONSpec, UrlPath, -} from "./types.js"; +} from "./types"; // Re-export utilities that may be useful -export { generateConfigFromFileTree } from "./filepath-routing.js"; -export { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; -export { generateWorkerEntrypoint } from "./codegen.js"; +export { generateConfigFromFileTree } from "./filepath-routing"; +export { convertRoutesToRoutesJSONSpec } from "./routes-transformation"; +export { generateWorkerEntrypoint } from "./codegen"; /** Default functions directory relative to project root */ export const DEFAULT_FUNCTIONS_DIR = "functions"; diff --git a/packages/pages-functions/src/routes-transformation.ts b/packages/pages-functions/src/routes-transformation.ts index 4ec7f8d5aec5..498e99121272 100644 --- a/packages/pages-functions/src/routes-transformation.ts +++ b/packages/pages-functions/src/routes-transformation.ts @@ -6,8 +6,8 @@ import { join as pathJoin } from "node:path"; import { consolidateRoutes, MAX_FUNCTIONS_ROUTES_RULES, -} from "./routes-consolidation.js"; -import type { RouteConfig, RoutesJSONSpec, UrlPath } from "./types.js"; +} from "./routes-consolidation"; +import type { RouteConfig, RoutesJSONSpec, UrlPath } from "./types"; /** Version of the _routes.json specification */ export const ROUTES_SPEC_VERSION = 1; diff --git a/packages/pages-functions/tsconfig.json b/packages/pages-functions/tsconfig.json index d3ed63952072..4262545c3965 100644 --- a/packages/pages-functions/tsconfig.json +++ b/packages/pages-functions/tsconfig.json @@ -1,8 +1,8 @@ { "extends": "@cloudflare/workers-tsconfig", "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "bundler", "types": ["node"] }, "include": ["src/**/*.ts", "__tests__/**/*.ts", "vitest.config.ts"], From a39401598a67c7bfa0ca3698963145490b3b43c7 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 13:12:07 +0000 Subject: [PATCH 23/26] Revert to NodeNext moduleResolution for Node ESM compatibility --- packages/pages-functions/src/cli.ts | 2 +- packages/pages-functions/src/codegen.ts | 6 +++--- packages/pages-functions/src/filepath-routing.ts | 2 +- packages/pages-functions/src/index.ts | 16 ++++++++-------- .../pages-functions/src/routes-transformation.ts | 4 ++-- packages/pages-functions/tsconfig.json | 4 ++-- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/pages-functions/src/cli.ts b/packages/pages-functions/src/cli.ts index 7e74bbb28485..36acfe567d61 100644 --- a/packages/pages-functions/src/cli.ts +++ b/packages/pages-functions/src/cli.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { parseArgs } from "node:util"; -import { compileFunctions } from "./index"; +import { compileFunctions } from "./index.js"; const HELP = ` Usage: pages-functions [options] [project-dir] diff --git a/packages/pages-functions/src/codegen.ts b/packages/pages-functions/src/codegen.ts index be9a5f15d626..b538663e6340 100644 --- a/packages/pages-functions/src/codegen.ts +++ b/packages/pages-functions/src/codegen.ts @@ -5,9 +5,9 @@ */ import * as path from "node:path"; -import { isValidIdentifier, normalizeIdentifier } from "./identifiers"; -import { generateRuntimeCode } from "./runtime"; -import type { RouteConfig } from "./types"; +import { isValidIdentifier, normalizeIdentifier } from "./identifiers.js"; +import { generateRuntimeCode } from "./runtime.js"; +import type { RouteConfig } from "./types.js"; /** * Internal representation of routes with resolved identifiers. diff --git a/packages/pages-functions/src/filepath-routing.ts b/packages/pages-functions/src/filepath-routing.ts index de398a431590..10cec5176933 100644 --- a/packages/pages-functions/src/filepath-routing.ts +++ b/packages/pages-functions/src/filepath-routing.ts @@ -13,7 +13,7 @@ import type { HTTPMethod, RouteConfig, UrlPath, -} from "./types"; +} from "./types.js"; /** * Error thrown when building/parsing a function file fails. diff --git a/packages/pages-functions/src/index.ts b/packages/pages-functions/src/index.ts index 79ee597a1c5c..38691a0afeea 100644 --- a/packages/pages-functions/src/index.ts +++ b/packages/pages-functions/src/index.ts @@ -5,10 +5,10 @@ */ import * as path from "node:path"; -import { generateWorkerEntrypoint } from "./codegen"; -import { generateConfigFromFileTree } from "./filepath-routing"; -import { convertRoutesToRoutesJSONSpec } from "./routes-transformation"; -import type { CompileOptions, CompileResult, UrlPath } from "./types"; +import { generateWorkerEntrypoint } from "./codegen.js"; +import { generateConfigFromFileTree } from "./filepath-routing.js"; +import { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; +import type { CompileOptions, CompileResult, UrlPath } from "./types.js"; // Re-export types export type { @@ -18,12 +18,12 @@ export type { RouteConfig, RoutesJSONSpec, UrlPath, -} from "./types"; +} from "./types.js"; // Re-export utilities that may be useful -export { generateConfigFromFileTree } from "./filepath-routing"; -export { convertRoutesToRoutesJSONSpec } from "./routes-transformation"; -export { generateWorkerEntrypoint } from "./codegen"; +export { generateConfigFromFileTree } from "./filepath-routing.js"; +export { convertRoutesToRoutesJSONSpec } from "./routes-transformation.js"; +export { generateWorkerEntrypoint } from "./codegen.js"; /** Default functions directory relative to project root */ export const DEFAULT_FUNCTIONS_DIR = "functions"; diff --git a/packages/pages-functions/src/routes-transformation.ts b/packages/pages-functions/src/routes-transformation.ts index 498e99121272..4ec7f8d5aec5 100644 --- a/packages/pages-functions/src/routes-transformation.ts +++ b/packages/pages-functions/src/routes-transformation.ts @@ -6,8 +6,8 @@ import { join as pathJoin } from "node:path"; import { consolidateRoutes, MAX_FUNCTIONS_ROUTES_RULES, -} from "./routes-consolidation"; -import type { RouteConfig, RoutesJSONSpec, UrlPath } from "./types"; +} from "./routes-consolidation.js"; +import type { RouteConfig, RoutesJSONSpec, UrlPath } from "./types.js"; /** Version of the _routes.json specification */ export const ROUTES_SPEC_VERSION = 1; diff --git a/packages/pages-functions/tsconfig.json b/packages/pages-functions/tsconfig.json index 4262545c3965..d3ed63952072 100644 --- a/packages/pages-functions/tsconfig.json +++ b/packages/pages-functions/tsconfig.json @@ -1,8 +1,8 @@ { "extends": "@cloudflare/workers-tsconfig", "compilerOptions": { - "module": "ESNext", - "moduleResolution": "bundler", + "module": "NodeNext", + "moduleResolution": "NodeNext", "types": ["node"] }, "include": ["src/**/*.ts", "__tests__/**/*.ts", "vitest.config.ts"], From b7f1f77659e24e51eded906bd6666ec60809f05a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 22 Jan 2026 14:11:28 +0000 Subject: [PATCH 24/26] Add deps.ts for external dependency check --- packages/pages-functions/scripts/deps.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/pages-functions/scripts/deps.ts diff --git a/packages/pages-functions/scripts/deps.ts b/packages/pages-functions/scripts/deps.ts new file mode 100644 index 000000000000..eea343864682 --- /dev/null +++ b/packages/pages-functions/scripts/deps.ts @@ -0,0 +1,7 @@ +/** + * Dependencies that are not bundled with @cloudflare/pages-functions + */ +export const EXTERNAL_DEPENDENCIES = [ + // esbuild has native binaries and cannot be bundled + "esbuild", +]; From 1643b04746d3d22c2e0a71978ac6bb11ba6b41a4 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 23 Jan 2026 11:00:42 +0000 Subject: [PATCH 25/26] Fix reserved keyword regex to properly group alternation --- packages/pages-functions/src/identifiers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pages-functions/src/identifiers.ts b/packages/pages-functions/src/identifiers.ts index daef3a5c3b23..8ae64ac49d49 100644 --- a/packages/pages-functions/src/identifiers.ts +++ b/packages/pages-functions/src/identifiers.ts @@ -56,7 +56,7 @@ const RESERVED_KEYWORDS = [ "undefined", ]; -const reservedKeywordRegex = new RegExp(`^${RESERVED_KEYWORDS.join("|")}$`); +const reservedKeywordRegex = new RegExp(`^(${RESERVED_KEYWORDS.join("|")})$`); const identifierNameRegex = /^(?:[$_\p{ID_Start}])(?:[$_\u200C\u200D\p{ID_Continue}])*$/u; From 22dfdeec73a90c547d40efa7e585b2ba73ee808c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 23 Jan 2026 11:28:22 +0000 Subject: [PATCH 26/26] Lock --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69d0f3b03ee2..d7e727d1cc5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2135,13 +2135,13 @@ importers: version: link:../eslint-config-shared '@cloudflare/vitest-pool-workers': specifier: catalog:default - version: 0.10.15(@cloudflare/workers-types@4.20260120.0)(@vitest/runner@3.2.3)(@vitest/snapshot@3.2.3)(vitest@3.2.3) + version: 0.10.15(@cloudflare/workers-types@4.20260123.0)(@vitest/runner@3.2.3)(@vitest/snapshot@3.2.3)(vitest@3.2.3) '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig '@cloudflare/workers-types': specifier: catalog:default - version: 4.20260120.0 + version: 4.20260123.0 '@types/node': specifier: ^20.19.9 version: 20.19.9