Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
31442b8
[pages-functions] Add @cloudflare/pages-functions package
ascorbic Jan 21, 2026
eebf5c1
Add scripts/deps.ts for external dependencies
ascorbic Jan 21, 2026
3ea65c8
Add @cloudflare/pages-functions to validate-changesets test
ascorbic Jan 21, 2026
3078968
Resolve path-to-regexp at codegen time, add runtime tests
ascorbic Jan 21, 2026
e437fac
Add pages-functions-test fixture
ascorbic Jan 21, 2026
c296541
Fix CI checks: add turbo.json, exclude runtime tests from lint/type
ascorbic Jan 21, 2026
32327d7
Format _routes.json with tabs
ascorbic Jan 21, 2026
066101c
Merge branch 'main' into pages-functions-package
ascorbic Jan 21, 2026
71b9341
Accept projectDirectory, add CLI, update README
ascorbic Jan 22, 2026
4ba0a87
Fix fixture to use direct node invocation for CLI
ascorbic Jan 22, 2026
b9b5e30
Update lockfile
ascorbic Jan 22, 2026
36fee8d
Format _routes.json
ascorbic Jan 22, 2026
a9f328b
Gitignore generated files in fixture
ascorbic Jan 22, 2026
0ec790b
Use node:util parseArgs for CLI argument parsing
ascorbic Jan 22, 2026
430704c
Add esbuild to dependencies
ascorbic Jan 22, 2026
7dc9eb5
Remove functionsDir option - directory is always 'functions'
ascorbic Jan 22, 2026
4b203f8
Remove outdated fixture README
ascorbic Jan 22, 2026
9bb2109
Use bare import for path-to-regexp instead of absolute path
ascorbic Jan 22, 2026
cc8dc03
Update README to clarify path-to-regexp dependency
ascorbic Jan 22, 2026
5b930e5
Remove path-to-regexp dep, document it as user requirement
ascorbic Jan 22, 2026
cd3f55b
Remove unused deps script
ascorbic Jan 22, 2026
8a569e9
Add path-to-regexp to devDependencies for tests
ascorbic Jan 22, 2026
f304d4c
Switch to bundler moduleResolution, remove .js extensions
ascorbic Jan 22, 2026
a394015
Revert to NodeNext moduleResolution for Node ESM compatibility
ascorbic Jan 22, 2026
b7f1f77
Add deps.ts for external dependency check
ascorbic Jan 22, 2026
74ba005
Merge branch 'main' into pages-functions-package
ascorbic Jan 22, 2026
1643b04
Fix reserved keyword regex to properly group alternation
ascorbic Jan 23, 2026
b2f66b2
Merge branch 'main' into pages-functions-package
ascorbic Jan 23, 2026
22dfdee
Lock
ascorbic Jan 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/extract-pages-functions.md
Original file line number Diff line number Diff line change
@@ -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
```
35 changes: 35 additions & 0 deletions fixtures/pages-functions-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Pages Functions Test Fixture

Test fixture for `@cloudflare/pages-functions` package.

## Usage

From the workers-sdk root:

```bash
# Build the pages-functions package first
pnpm --filter @cloudflare/pages-functions build

# Install deps for this fixture
pnpm --filter pages-functions-test install

# Compile functions -> worker, then bundle, then run dev
pnpm --filter pages-functions-test dev
```

## What it does

1. `build.mjs` uses `compileFunctions()` to compile `functions/` into `dist/worker.js`
2. esbuild bundles `dist/worker.js` (with path-to-regexp) into `dist/bundled.js`
3. wrangler runs the bundled worker

## Test endpoints

- `GET /` - Index route
- `GET /api/hello` - Static API route
- `POST /api/hello` - POST handler
- `GET /api/:id` - Dynamic route
- `PUT /api/:id` - Update handler
- `DELETE /api/:id` - Delete handler

All routes go through the root middleware which adds `X-Middleware: active` header.
6 changes: 6 additions & 0 deletions fixtures/pages-functions-test/_routes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"version": 1,
"description": "Generated by @cloudflare/pages-functions",
"include": ["/*"],
"exclude": []
}
27 changes: 27 additions & 0 deletions fixtures/pages-functions-test/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env node
/**
* Build script that uses @cloudflare/pages-functions to compile
* the functions directory into a worker entrypoint.
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { compileFunctions } from "@cloudflare/pages-functions";

// Ensure dist directory exists
mkdirSync("./dist", { recursive: true });

// Compile the functions directory
const result = await compileFunctions("./functions");

// Write the generated worker entrypoint
writeFileSync("./dist/worker.js", result.code);
console.log("✓ Generated dist/worker.js");

// Write _routes.json
writeFileSync("./_routes.json", JSON.stringify(result.routesJson, null, 2));
console.log("✓ Generated _routes.json");

console.log("\nRoutes:");
for (const route of result.routes) {
const method = route.method || "ALL";
console.log(` ${method.padEnd(6)} ${route.routePath}`);
}
5 changes: 5 additions & 0 deletions fixtures/pages-functions-test/functions/_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const onRequest = async (context) => {
const response = await context.next();
response.headers.set("X-Middleware", "active");
return response;
};
15 changes: 15 additions & 0 deletions fixtures/pages-functions-test/functions/api/[id].ts
Original file line number Diff line number Diff line change
@@ -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}` });
};
11 changes: 11 additions & 0 deletions fixtures/pages-functions-test/functions/api/hello.ts
Original file line number Diff line number Diff line change
@@ -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,
});
};
3 changes: 3 additions & 0 deletions fixtures/pages-functions-test/functions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const onRequest = () => {
return new Response("Hello from the index!");
};
14 changes: 14 additions & 0 deletions fixtures/pages-functions-test/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@fixture/pages-functions-test",
"private": true,
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs && wrangler dev"
},
"dependencies": {
"@cloudflare/pages-functions": "workspace:*"
},
"devDependencies": {
"wrangler": "workspace:*"
}
}
10 changes: 10 additions & 0 deletions fixtures/pages-functions-test/turbo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$schema": "http://turbo.build/schema.json",
"extends": ["//"],
"tasks": {
"build": {
"dependsOn": ["@cloudflare/pages-functions#build"],
"outputs": ["dist/**"]
}
}
}
5 changes: 5 additions & 0 deletions fixtures/pages-functions-test/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "pages-functions-test",
"main": "dist/worker.js",
"compatibility_date": "2026-01-20",
}
94 changes: 94 additions & 0 deletions packages/pages-functions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# @cloudflare/pages-functions

Compile a Pages functions directory into a deployable worker entrypoint.

## Installation

```bash
npm install @cloudflare/pages-functions
```

## Usage

```typescript
import * as fs from "node:fs/promises";
import { compileFunctions } from "@cloudflare/pages-functions";

const result = await compileFunctions("./functions", {
fallbackService: "ASSETS",
});

// Write the generated worker entrypoint
await fs.writeFile("worker.js", result.code);

// Write _routes.json for Pages deployment
await fs.writeFile("_routes.json", JSON.stringify(result.routesJson, null, 2));
```

## API

### `compileFunctions(functionsDirectory, options?)`

Compiles a Pages functions directory into a worker entrypoint.

#### Parameters

- `functionsDirectory` (string): Path to the functions directory
- `options` (object, optional):
- `baseURL` (string): Base URL prefix for all routes. Default: `"/"`
- `fallbackService` (string): Fallback service binding name. Default: `"ASSETS"`

#### Returns

A `Promise<CompileResult>` with:

- `code` (string): Generated JavaScript worker entrypoint
- `routes` (RouteConfig[]): Parsed route configuration
- `routesJson` (RoutesJSONSpec): `_routes.json` content for Pages deployment

## Functions Directory Structure

The functions directory uses file-based routing:

```
functions/
├── index.ts # Handles /
├── _middleware.ts # Middleware for all routes
├── api/
│ ├── index.ts # Handles /api
│ ├── [id].ts # Handles /api/:id
│ └── [[catchall]].ts # Handles /api/*
```

### Route Parameters

- `[param]` - Dynamic parameter (e.g., `[id].ts` → `/api/:id`)
- `[[catchall]]` - Catch-all parameter (e.g., `[[path]].ts` → `/api/:path*`)

### Exports

Export handlers from your function files:

```typescript
// Handle all methods
export const onRequest = (context) => new Response("Hello");

// Handle specific methods
export const onRequestGet = (context) => new Response("GET");
export const onRequestPost = (context) => new Response("POST");
```

## Generated Output

The generated code:

1. Imports all function handlers
2. Creates a route configuration array
3. Includes the Pages Functions runtime (inlined)
4. Exports a default handler that routes requests

The output requires `path-to-regexp` as a runtime dependency (resolved during bundling).

## License

MIT OR Apache-2.0
93 changes: 93 additions & 0 deletions packages/pages-functions/__tests__/codegen.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
Loading
Loading