Skip to content

Commit 3078968

Browse files
committed
Resolve path-to-regexp at codegen time, add runtime tests
- Resolve path-to-regexp path at codegen time using require.resolve() - Wrangler/esbuild bundles it when building the final worker - Remove esbuild dependency (no longer needed for bundling) - Return 404 when no route matches and fallback binding unavailable - Add vitest-pool-workers runtime tests (9 tests covering route matching, middleware execution, and 404 handling)
1 parent 3ea65c8 commit 3078968

File tree

12 files changed

+411
-14
lines changed

12 files changed

+411
-14
lines changed

packages/pages-functions/__tests__/codegen.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ describe("codegen", () => {
1919
fallbackService: "ASSETS",
2020
});
2121

22-
expect(code).toContain('import { match } from "path-to-regexp"');
22+
// path-to-regexp is imported from its resolved absolute path
23+
expect(code).toMatch(/import \{ match \} from ".*path-to-regexp.*"/);
2324
expect(code).toContain("import { onRequestGet as");
2425
expect(code).toContain('routePath: "/api/:id"');
2526
expect(code).toContain('mountPath: "/api"');
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { SELF } from "cloudflare:test";
2+
import { describe, expect, it } from "vitest";
3+
4+
describe("Pages Functions Runtime", () => {
5+
describe("route matching", () => {
6+
it("matches the index route", async () => {
7+
const response = await SELF.fetch("https://example.com/");
8+
expect(response.status).toBe(200);
9+
expect(await response.text()).toBe("Hello from index");
10+
});
11+
12+
it("matches static API routes", async () => {
13+
const response = await SELF.fetch("https://example.com/api/hello");
14+
expect(response.status).toBe(200);
15+
const json = await response.json();
16+
expect(json).toEqual({ message: "Hello from GET /api/hello" });
17+
});
18+
19+
it("matches dynamic API routes with params", async () => {
20+
const response = await SELF.fetch("https://example.com/api/123");
21+
expect(response.status).toBe(200);
22+
const json = await response.json();
23+
expect(json).toEqual({ id: "123", method: "GET" });
24+
});
25+
26+
it("matches routes by HTTP method", async () => {
27+
const response = await SELF.fetch("https://example.com/api/456", {
28+
method: "PUT",
29+
headers: { "Content-Type": "application/json" },
30+
body: JSON.stringify({ test: true }),
31+
});
32+
expect(response.status).toBe(200);
33+
const json = await response.json();
34+
expect(json).toEqual({ id: "456", method: "PUT", body: { test: true } });
35+
});
36+
37+
it("handles POST with JSON body", async () => {
38+
const response = await SELF.fetch("https://example.com/api/hello", {
39+
method: "POST",
40+
headers: { "Content-Type": "application/json" },
41+
body: JSON.stringify({ name: "test" }),
42+
});
43+
expect(response.status).toBe(200);
44+
const json = await response.json();
45+
expect(json).toEqual({
46+
message: "Hello from POST",
47+
received: { name: "test" },
48+
});
49+
});
50+
});
51+
52+
describe("middleware", () => {
53+
it("executes middleware and adds headers", async () => {
54+
const response = await SELF.fetch("https://example.com/");
55+
expect(response.headers.get("X-Middleware")).toBe("active");
56+
});
57+
58+
it("executes middleware for API routes", async () => {
59+
const response = await SELF.fetch("https://example.com/api/hello");
60+
expect(response.headers.get("X-Middleware")).toBe("active");
61+
});
62+
});
63+
64+
describe("404 handling", () => {
65+
it("returns 404 for unmatched routes", async () => {
66+
const response = await SELF.fetch("https://example.com/nonexistent");
67+
expect(response.status).toBe(404);
68+
expect(await response.text()).toBe("Not Found");
69+
});
70+
71+
it("returns 404 for unmatched methods", async () => {
72+
const response = await SELF.fetch("https://example.com/api/hello", {
73+
method: "DELETE",
74+
});
75+
expect(response.status).toBe(404);
76+
});
77+
});
78+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
3+
"compilerOptions": {
4+
"types": [
5+
"@cloudflare/workers-types/experimental",
6+
"@cloudflare/vitest-pool-workers"
7+
]
8+
},
9+
"include": ["**/*.ts"]
10+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* Test worker for runtime tests.
3+
*
4+
* This worker defines simple Pages function handlers that can be tested
5+
* against the generated runtime code.
6+
*/
7+
8+
// Import path-to-regexp (will be resolved at bundle time)
9+
import { match } from "path-to-regexp";
10+
11+
// Simple handler functions for testing
12+
const indexHandler = () => {
13+
return new Response("Hello from index");
14+
};
15+
16+
const apiHelloGet = () => {
17+
return Response.json({ message: "Hello from GET /api/hello" });
18+
};
19+
20+
const apiHelloPost = async (context: { request: Request }) => {
21+
const body = await context.request.json();
22+
return Response.json({ message: "Hello from POST", received: body });
23+
};
24+
25+
const apiIdGet = (context: { params: Record<string, string> }) => {
26+
return Response.json({ id: context.params.id, method: "GET" });
27+
};
28+
29+
const apiIdPut = async (context: {
30+
params: Record<string, string>;
31+
request: Request;
32+
}) => {
33+
const body = await context.request.json();
34+
return Response.json({ id: context.params.id, method: "PUT", body });
35+
};
36+
37+
const middleware = async (context: { next: () => Promise<Response> }) => {
38+
const response = await context.next();
39+
const newResponse = new Response(response.body, response);
40+
newResponse.headers.set("X-Middleware", "active");
41+
return newResponse;
42+
};
43+
44+
// Route configuration (similar to what codegen produces)
45+
const routes = [
46+
{
47+
routePath: "/api/hello",
48+
mountPath: "/api",
49+
method: "GET",
50+
middlewares: [middleware],
51+
modules: [apiHelloGet],
52+
},
53+
{
54+
routePath: "/api/hello",
55+
mountPath: "/api",
56+
method: "POST",
57+
middlewares: [middleware],
58+
modules: [apiHelloPost],
59+
},
60+
{
61+
routePath: "/api/:id",
62+
mountPath: "/api",
63+
method: "GET",
64+
middlewares: [middleware],
65+
modules: [apiIdGet],
66+
},
67+
{
68+
routePath: "/api/:id",
69+
mountPath: "/api",
70+
method: "PUT",
71+
middlewares: [middleware],
72+
modules: [apiIdPut],
73+
},
74+
{
75+
routePath: "/",
76+
mountPath: "/",
77+
method: "",
78+
middlewares: [middleware],
79+
modules: [indexHandler],
80+
},
81+
];
82+
83+
// Runtime code (copied from runtime.ts output)
84+
const escapeRegex = /[.+?^${}()|[\]\\]/g;
85+
86+
type RouteHandler = (context: RouteContext) => Response | Promise<Response>;
87+
88+
interface Route {
89+
routePath: string;
90+
mountPath: string;
91+
method: string;
92+
middlewares: RouteHandler[];
93+
modules: RouteHandler[];
94+
}
95+
96+
interface RouteContext {
97+
request: Request;
98+
functionPath: string;
99+
next: (input?: RequestInfo, init?: RequestInit) => Promise<Response>;
100+
params: Record<string, string>;
101+
data: Record<string, unknown>;
102+
env: Record<string, unknown>;
103+
waitUntil: (promise: Promise<unknown>) => void;
104+
passThroughOnException: () => void;
105+
}
106+
107+
function* executeRequest(request: Request, routeList: Route[]) {
108+
const requestPath = new URL(request.url).pathname;
109+
110+
// First, iterate through the routes (backwards) and execute "middlewares" on partial route matches
111+
for (const route of [...routeList].reverse()) {
112+
if (route.method && route.method !== request.method) {
113+
continue;
114+
}
115+
116+
const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
117+
end: false,
118+
});
119+
const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
120+
end: false,
121+
});
122+
const matchResult = routeMatcher(requestPath);
123+
const mountMatchResult = mountMatcher(requestPath);
124+
if (matchResult && mountMatchResult) {
125+
for (const handler of route.middlewares.flat()) {
126+
yield {
127+
handler,
128+
params: matchResult.params as Record<string, string>,
129+
path: mountMatchResult.path,
130+
};
131+
}
132+
}
133+
}
134+
135+
// Then look for the first exact route match and execute its "modules"
136+
for (const route of routeList) {
137+
if (route.method && route.method !== request.method) {
138+
continue;
139+
}
140+
const routeMatcher = match(route.routePath.replace(escapeRegex, "\\$&"), {
141+
end: true,
142+
});
143+
const mountMatcher = match(route.mountPath.replace(escapeRegex, "\\$&"), {
144+
end: false,
145+
});
146+
const matchResult = routeMatcher(requestPath);
147+
const mountMatchResult = mountMatcher(requestPath);
148+
if (matchResult && mountMatchResult && route.modules.length) {
149+
for (const handler of route.modules.flat()) {
150+
yield {
151+
handler,
152+
params: matchResult.params as Record<string, string>,
153+
path: matchResult.path,
154+
};
155+
}
156+
break;
157+
}
158+
}
159+
}
160+
161+
function createPagesHandler(
162+
routeList: Route[],
163+
fallbackService: string | null
164+
) {
165+
return {
166+
async fetch(
167+
originalRequest: Request,
168+
env: Record<string, unknown>,
169+
workerContext: ExecutionContext
170+
) {
171+
let request = originalRequest;
172+
const handlerIterator = executeRequest(request, routeList);
173+
let data: Record<string, unknown> = {};
174+
let isFailOpen = false;
175+
176+
const next = async (
177+
input?: RequestInfo,
178+
init?: RequestInit
179+
): Promise<Response> => {
180+
if (input !== undefined) {
181+
let url: RequestInfo = input;
182+
if (typeof input === "string") {
183+
url = new URL(input, request.url).toString();
184+
}
185+
request = new Request(url, init);
186+
}
187+
188+
const result = handlerIterator.next();
189+
if (result.done === false) {
190+
const { handler, params, path } = result.value;
191+
const context: RouteContext = {
192+
request: new Request(request.clone()),
193+
functionPath: path,
194+
next,
195+
params,
196+
get data() {
197+
return data;
198+
},
199+
set data(value) {
200+
if (typeof value !== "object" || value === null) {
201+
throw new Error("context.data must be an object");
202+
}
203+
data = value;
204+
},
205+
env,
206+
waitUntil: workerContext.waitUntil.bind(workerContext),
207+
passThroughOnException: () => {
208+
isFailOpen = true;
209+
},
210+
};
211+
212+
const response = await handler(context);
213+
214+
if (!(response instanceof Response)) {
215+
throw new Error("Your Pages function should return a Response");
216+
}
217+
218+
return cloneResponse(response);
219+
} else if (
220+
fallbackService &&
221+
env[fallbackService] &&
222+
typeof (env[fallbackService] as Fetcher).fetch === "function"
223+
) {
224+
const response = await (env[fallbackService] as Fetcher).fetch(
225+
request
226+
);
227+
return cloneResponse(response);
228+
} else {
229+
return new Response("Not Found", { status: 404 });
230+
}
231+
};
232+
233+
try {
234+
return await next();
235+
} catch (error) {
236+
if (
237+
isFailOpen &&
238+
fallbackService &&
239+
env[fallbackService] &&
240+
typeof (env[fallbackService] as Fetcher).fetch === "function"
241+
) {
242+
const response = await (env[fallbackService] as Fetcher).fetch(
243+
request
244+
);
245+
return cloneResponse(response);
246+
}
247+
throw error;
248+
}
249+
},
250+
};
251+
}
252+
253+
const cloneResponse = (response: Response) =>
254+
new Response(
255+
[101, 204, 205, 304].includes(response.status) ? null : response.body,
256+
response
257+
);
258+
259+
// Export the handler
260+
export default createPagesHandler(routes, "ASSETS");
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "pages-functions-runtime-test",
3+
"main": "./worker.ts",
4+
"compatibility_date": "2026-01-20",
5+
"compatibility_flags": ["nodejs_compat"],
6+
}

packages/pages-functions/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,18 @@
3737
"check:lint": "eslint . --max-warnings=0",
3838
"check:type": "tsc --noEmit",
3939
"test": "vitest run",
40-
"test:ci": "vitest run",
40+
"test:ci": "vitest run && vitest run -c vitest.config.runtime.mts",
41+
"test:runtime": "vitest run -c vitest.config.runtime.mts",
4142
"test:watch": "vitest"
4243
},
4344
"dependencies": {
44-
"esbuild": "catalog:default"
45+
"path-to-regexp": "^6.3.0"
4546
},
4647
"devDependencies": {
4748
"@cloudflare/eslint-config-shared": "workspace:*",
49+
"@cloudflare/vitest-pool-workers": "catalog:default",
4850
"@cloudflare/workers-tsconfig": "workspace:*",
51+
"@cloudflare/workers-types": "catalog:default",
4952
"@types/node": "catalog:default",
5053
"eslint": "catalog:default",
5154
"typescript": "catalog:default",

packages/pages-functions/scripts/deps.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* This list is validated by `tools/deployments/validate-package-dependencies.ts`.
66
*/
77
export const EXTERNAL_DEPENDENCIES = [
8-
// Native binary with platform-specific builds - cannot be bundled.
9-
// Used to parse function files and extract exports.
10-
"esbuild",
8+
// Imported via resolved absolute path into generated worker code.
9+
// Wrangler/esbuild bundles it when building the final worker.
10+
"path-to-regexp",
1111
];

0 commit comments

Comments
 (0)