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/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/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..a1083079d8fd --- /dev/null +++ b/fixtures/pages-functions-test/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fixture/pages-functions-test", + "private": true, + "scripts": { + "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:*" + } +} diff --git a/fixtures/pages-functions-test/turbo.json b/fixtures/pages-functions-test/turbo.json new file mode 100644 index 000000000000..f1b13bd0d128 --- /dev/null +++ b/fixtures/pages-functions-test/turbo.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["@cloudflare/pages-functions#build"], + "outputs": ["dist/**"] + } + } +} 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/packages/pages-functions/README.md b/packages/pages-functions/README.md new file mode 100644 index 000000000000..f4390d365cbf --- /dev/null +++ b/packages/pages-functions/README.md @@ -0,0 +1,147 @@ +# @cloudflare/pages-functions + +Compile a Pages project's functions directory into a deployable worker entrypoint. + +## Installation + +```bash +npm install @cloudflare/pages-functions +``` + +## 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 + --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(".", { + fallbackService: "ASSETS", +}); + +// Write the generated worker entrypoint +await fs.writeFile("dist/worker.js", result.code); + +// Write _routes.json for Pages deployment +await fs.writeFile( + "_routes.json", + JSON.stringify(result.routesJson, null, "\t") +); +``` + +### `compileFunctions(projectDirectory, options?)` + +Compiles a Pages project's functions directory into a worker entrypoint. + +#### Parameters + +- `projectDirectory` (string): Path to the project root (containing 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 + +## Project Structure + +Your project should have a `functions` directory with file-based routing: + +``` +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 + +- `[param]` - Dynamic parameter (e.g., `[id].ts` → `/api/:id`) +- `[[catchall]]` - Catch-all parameter (e.g., `[[path]].ts` → `/api/:path*`) + +### Handler 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"); +``` + +### 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 from the functions directory +2. Creates a route configuration array +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. You need to install it in your project: + +```bash +npm install path-to-regexp +``` + +## 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..67b27509c46e --- /dev/null +++ b/packages/pages-functions/__tests__/codegen.test.ts @@ -0,0 +1,93 @@ +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", + }); + + // 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"'); + 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..057188952b79 --- /dev/null +++ b/packages/pages-functions/__tests__/compile.test.ts @@ -0,0 +1,74 @@ +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)); +const fixturesDir = path.join(__dirname, "fixtures"); + +describe("compileFunctions", () => { + 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(); + 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 projectDir = path.join(fixturesDir, "basic-project"); + const result = await compileFunctions(projectDir, { + fallbackService: "CUSTOM_ASSETS", + }); + + expect(result.code).toContain('"CUSTOM_ASSETS"'); + }); + + it("uses custom baseURL", async () => { + const projectDir = path.join(fixturesDir, "basic-project"); + const result = await compileFunctions(projectDir, { + baseURL: "/v1", + }); + + // Routes should be prefixed + for (const route of result.routes) { + expect(route.routePath.startsWith("/v1")).toBe(true); + } + }); + + it("throws FunctionsNoRoutesError for empty project", async () => { + const projectDir = path.join(fixturesDir, "empty-project"); + + await expect(compileFunctions(projectDir)).rejects.toThrow( + FunctionsNoRoutesError + ); + }); + + it("generates valid _routes.json", async () => { + const projectDir = path.join(fixturesDir, "basic-project"); + const result = await compileFunctions(projectDir); + + // 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..19553a15e1f8 --- /dev/null +++ b/packages/pages-functions/__tests__/filepath-routing.test.ts @@ -0,0 +1,376 @@ +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)", () => { + const functionsDir = path.join( + __dirname, + "fixtures/basic-project/functions" + ); + + it("generates routes from a functions directory", async () => { + const config = await generateConfigFromFileTree({ + baseDir: functionsDir, + }); + + 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 config = await generateConfigFromFileTree({ + baseDir: functionsDir, + }); + + // [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 config = await generateConfigFromFileTree({ + baseDir: functionsDir, + 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 config = await generateConfigFromFileTree({ + baseDir: functionsDir, + }); + + // 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-project/functions/_middleware.ts b/packages/pages-functions/__tests__/fixtures/basic-project/functions/_middleware.ts new file mode 100644 index 000000000000..0228ca4e53b8 --- /dev/null +++ b/packages/pages-functions/__tests__/fixtures/basic-project/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-project/functions/api/[id].ts b/packages/pages-functions/__tests__/fixtures/basic-project/functions/api/[id].ts new file mode 100644 index 000000000000..20ea7193e58b --- /dev/null +++ b/packages/pages-functions/__tests__/fixtures/basic-project/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-project/functions/index.ts b/packages/pages-functions/__tests__/fixtures/basic-project/functions/index.ts new file mode 100644 index 000000000000..495864cfab4c --- /dev/null +++ b/packages/pages-functions/__tests__/fixtures/basic-project/functions/index.ts @@ -0,0 +1 @@ +export const onRequest = () => new Response("Hello from index"); diff --git a/packages/pages-functions/__tests__/fixtures/empty-project/functions/.gitkeep b/packages/pages-functions/__tests__/fixtures/empty-project/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/__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/eslint.config.mjs b/packages/pages-functions/eslint.config.mjs new file mode 100644 index 000000000000..35beafeedef7 --- /dev/null +++ b/packages/pages-functions/eslint.config.mjs @@ -0,0 +1,14 @@ +import sharedConfig from "@cloudflare/eslint-config-shared"; +import { defineConfig } from "eslint/config"; + +export default defineConfig([ + ...sharedConfig, + { + ignores: [ + "__tests__/fixtures/**", + "__tests__/runtime/**", + "scripts/**", + "vitest.config.runtime.mts", + ], + }, +]); diff --git a/packages/pages-functions/package.json b/packages/pages-functions/package.json new file mode 100644 index 000000000000..c0d450137491 --- /dev/null +++ b/packages/pages-functions/package.json @@ -0,0 +1,70 @@ +{ + "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", + "bin": { + "pages-functions": "./dist/cli.js" + }, + "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 && vitest run -c vitest.config.runtime.mts", + "test:runtime": "vitest run -c vitest.config.runtime.mts", + "test:watch": "vitest" + }, + "dependencies": { + "esbuild": "catalog:default" + }, + "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", + "path-to-regexp": "^6.3.0", + "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/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", +]; diff --git a/packages/pages-functions/src/cli.ts b/packages/pages-functions/src/cli.ts new file mode 100644 index 000000000000..36acfe567d61 --- /dev/null +++ b/packages/pages-functions/src/cli.ts @@ -0,0 +1,100 @@ +#!/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 { parseArgs } from "node:util"; +import { compileFunctions } from "./index.js"; + +const HELP = ` +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 + --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 { 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 }, + "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 baseURL = values["base-url"] ?? "/"; + const fallbackService = values["fallback-service"] ?? "ASSETS"; + + try { + const result = await compileFunctions(projectDir, { + baseURL, + fallbackService, + }); + + // Ensure output directory exists + await fs.mkdir(path.dirname(outfile), { recursive: true }); + + // Write worker entrypoint + await fs.writeFile(outfile, result.code); + console.log(`✓ Generated ${outfile}`); + + // Write _routes.json if requested + if (routesJson) { + await fs.writeFile( + routesJson, + JSON.stringify(result.routesJson, null, "\t") + ); + console.log(`✓ Generated ${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/codegen.ts b/packages/pages-functions/src/codegen.ts new file mode 100644 index 000000000000..b538663e6340 --- /dev/null +++ b/packages/pages-functions/src/codegen.ts @@ -0,0 +1,179 @@ +/** + * 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..8ae64ac49d49 --- /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..38691a0afeea --- /dev/null +++ b/packages/pages-functions/src/index.ts @@ -0,0 +1,100 @@ +/** + * @cloudflare/pages-functions + * + * 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"; +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"; + +/** Default functions directory relative to project root */ +export const DEFAULT_FUNCTIONS_DIR = "functions"; + +/** + * 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 project's functions directory into a worker entrypoint. + * + * @param projectDirectory Path to the project root (containing 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('.', { + * fallbackService: 'ASSETS', + * }); + * + * // Write the generated entrypoint + * 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( + projectDirectory: string, + options: CompileOptions = {} +): Promise { + const { baseURL = "/" as UrlPath, fallbackService = "ASSETS" } = options; + + const functionsDirectory = path.join(projectDirectory, DEFAULT_FUNCTIONS_DIR); + + // 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..dc2b1c6803b6 --- /dev/null +++ b/packages/pages-functions/src/runtime.ts @@ -0,0 +1,153 @@ +/** + * 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 && env[fallbackService]) {", + " const response = await env[fallbackService].fetch(request);", + " return cloneResponse(response);", + " } else {", + ' return new Response("Not Found", { status: 404 });', + " }", + " };", + "", + " try {", + " return await next();", + " } catch (error) {", + " if (isFailOpen && fallbackService && env[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..d3ed63952072 --- /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", "__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"] + } + } +} 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 new file mode 100644 index 000000000000..0412994c3d30 --- /dev/null +++ b/packages/pages-functions/vitest.config.ts @@ -0,0 +1,8 @@ +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 5e72f2a93216..d7e727d1cc5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,6 +688,15 @@ importers: specifier: workspace:* version: link:../../packages/wrangler + fixtures/pages-functions-test: + devDependencies: + '@cloudflare/pages-functions': + specifier: workspace:* + version: link:../../packages/pages-functions + wrangler: + specifier: workspace:* + version: link:../../packages/wrangler + fixtures/pages-functions-unenv-alias: devDependencies: '@cloudflare/workers-tsconfig': @@ -2115,6 +2124,40 @@ 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/vitest-pool-workers': + specifier: catalog:default + 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.20260123.0 + '@types/node': + specifier: ^20.19.9 + version: 20.19.9 + 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 + 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: diff --git a/tools/deployments/__tests__/validate-changesets.test.ts b/tools/deployments/__tests__/validate-changesets.test.ts index aee3404fab06..7e3b423c1a42 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",