Skip to content

Commit 0b2ba45

Browse files
authored
run_worker_first support in wrangler (#9509)
* add static_routing validation * wrangler plumbing * e2e test * add to asset config * unbork pnpm lock * add to deploy * sort of simplify uploading run_worker_first * pr feedback * fixup * pr feedback * changeset
1 parent 54ee0af commit 0b2ba45

File tree

28 files changed

+644
-27
lines changed

28 files changed

+644
-27
lines changed

.changeset/stale-boats-fold.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"miniflare": minor
3+
"wrangler": minor
4+
---
5+
6+
feat: add static routing options via 'run_worker_first' to Wrangler
7+
8+
Implements the proposal noted here https://github.com/cloudflare/workers-sdk/discussions/9143.
9+
10+
This is now usable in `wrangler dev` and in production - just specify the routes that should hit the worker first with `run_worker_first` in your Wrangler config. You can also omit certain paths with `!` negative rules.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@fixture/workers-with-assets-static-routing",
3+
"private": true,
4+
"scripts": {
5+
"check:type": "tsc",
6+
"dev": "wrangler dev",
7+
"dev:spa": "wrangler dev -c spa.wrangler.jsonc",
8+
"pretest:ci": "pnpm playwright install chromium",
9+
"test:ci": "vitest run",
10+
"test:watch": "vitest",
11+
"type:tests": "tsc -p ./test/tsconfig.json"
12+
},
13+
"devDependencies": {
14+
"@cloudflare/workers-tsconfig": "workspace:*",
15+
"@cloudflare/workers-types": "^4.20250508.0",
16+
"playwright-chromium": "catalog:default",
17+
"typescript": "catalog:default",
18+
"undici": "catalog:default",
19+
"vitest": "catalog:default",
20+
"wrangler": "workspace:*"
21+
},
22+
"volta": {
23+
"node": "20.19.2",
24+
"extends": "../../package.json"
25+
}
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h1>A normal asset</h1>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h1>Hello, I'm an asset!</h1>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h1>Hello, I'm an asset at /worker/worker-runs.html!</h1>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>I'm an index.html for a SPA</title>
5+
</head>
6+
<body>
7+
<h1>Here I am, at <span id="client-rendered">/</span>!</h1>
8+
</body>
9+
<script>
10+
const path = window.location.pathname;
11+
const updateme = document.getElementById("client-rendered");
12+
updateme.innerText = path;
13+
</script>
14+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "workers-with-assets-static-routing",
3+
"main": "src/index.ts",
4+
"compatibility_date": "2025-05-20",
5+
"assets": {
6+
"binding": "ASSETS",
7+
"directory": "./spa-assets",
8+
"not_found_handling": "single-page-application",
9+
"run_worker_first": ["/api/*", "!/api/asset"],
10+
},
11+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export type Env = {
2+
ASSETS: Fetcher;
3+
};
4+
5+
export default {
6+
async fetch(request, env, ctx): Promise<Response> {
7+
const { pathname } = new URL(request.url);
8+
9+
// api routes
10+
if (pathname.startsWith("/api/")) {
11+
return Response.json({ some: ["json", "response"] });
12+
}
13+
14+
// asset middleware
15+
const assetResp = await env.ASSETS.fetch(request);
16+
if (assetResp.ok) {
17+
let text = await assetResp.text();
18+
text = text.replace(
19+
"I'm an asset",
20+
"I'm an asset (and was intercepted by the User Worker)"
21+
);
22+
return new Response(text, {
23+
headers: assetResp.headers,
24+
status: assetResp.status,
25+
});
26+
}
27+
28+
// default handling
29+
return new Response("404 from the User Worker", { status: 404 });
30+
},
31+
} satisfies ExportedHandler<Env>;
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { resolve } from "node:path";
2+
import { Browser, chromium } from "playwright-chromium";
3+
import { afterAll, beforeAll, describe, it } from "vitest";
4+
import { runWranglerDev } from "../../shared/src/run-wrangler-long-lived";
5+
6+
describe("[Workers + Assets] static routing", () => {
7+
describe("static routing behavior", () => {
8+
let ip: string, port: number, stop: (() => Promise<unknown>) | undefined;
9+
10+
beforeAll(async () => {
11+
({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [
12+
"--port=0",
13+
"--inspector-port=0",
14+
]));
15+
});
16+
17+
afterAll(async () => {
18+
await stop?.();
19+
});
20+
21+
it("should serve assets when they exist for a path", async ({ expect }) => {
22+
let response = await fetch(`http://${ip}:${port}/static/page`);
23+
expect(response.status).toBe(200);
24+
expect(await response.text()).toContain(`<h1>A normal asset</h1>`);
25+
});
26+
27+
it("should run the worker when no assets exist for a path", async ({
28+
expect,
29+
}) => {
30+
let response = await fetch(`http://${ip}:${port}/`);
31+
expect(response.status).toBe(404);
32+
expect(await response.text()).toContain(`404 from the User Worker`);
33+
});
34+
35+
it("should run the worker when a positive run_worker_first rule matches", async ({
36+
expect,
37+
}) => {
38+
let response = await fetch(`http://${ip}:${port}/worker/worker-runs`);
39+
expect(response.status).toBe(200);
40+
expect(await response.text()).toContain(
41+
`<h1>Hello, I'm an asset (and was intercepted by the User Worker) at /worker/worker-runs.html!</h1>`
42+
);
43+
});
44+
45+
it("should serve an asset when a negative run_worker_first rule matches", async ({
46+
expect,
47+
}) => {
48+
let response = await fetch(`http://${ip}:${port}/missing-asset`);
49+
expect(response.status).toBe(404);
50+
expect(await response.text()).toEqual("");
51+
});
52+
53+
it("should serve an asset when both a positive and negative (asset) run_worker_first matches", async ({
54+
expect,
55+
}) => {
56+
let response = await fetch(`http://${ip}:${port}/worker/asset`);
57+
expect(response.status).toBe(200);
58+
expect(await response.text()).toContain(`<h1>Hello, I'm an asset!</h1>`);
59+
});
60+
});
61+
62+
describe("static routing + SPA behavior", async () => {
63+
let ip: string, port: number, stop: (() => Promise<unknown>) | undefined;
64+
65+
beforeAll(async () => {
66+
({ ip, port, stop } = await runWranglerDev(resolve(__dirname, ".."), [
67+
"-c=spa.wrangler.jsonc",
68+
"--port=0",
69+
"--inspector-port=0",
70+
]));
71+
});
72+
73+
afterAll(async () => {
74+
await stop?.();
75+
});
76+
77+
describe("browser navigation", () => {
78+
let browser: Browser | undefined;
79+
80+
beforeAll(async () => {
81+
browser = await chromium.launch({
82+
headless: !process.env.VITE_DEBUG_SERVE,
83+
args: process.env.CI
84+
? ["--no-sandbox", "--disable-setuid-sandbox"]
85+
: undefined,
86+
});
87+
});
88+
89+
afterAll(async () => {
90+
await browser?.close();
91+
});
92+
93+
it("renders the root with index.html", async ({ expect }) => {
94+
if (!browser) {
95+
throw new Error("Browser couldn't be initialized");
96+
}
97+
98+
const page = await browser.newPage({
99+
baseURL: `http://${ip}:${port}`,
100+
});
101+
await page.goto("/");
102+
expect(await page.getByRole("heading").innerText()).toBe(
103+
"Here I am, at /!"
104+
);
105+
});
106+
107+
it("renders another path with index.html", async ({ expect }) => {
108+
if (!browser) {
109+
throw new Error("Browser couldn't be initialized");
110+
}
111+
112+
const page = await browser.newPage({
113+
baseURL: `http://${ip}:${port}`,
114+
});
115+
await page.goto("/some/page");
116+
expect(await page.getByRole("heading").innerText()).toBe(
117+
"Here I am, at /some/page!"
118+
);
119+
});
120+
121+
it("renders an include path with the User worker", async ({ expect }) => {
122+
if (!browser) {
123+
throw new Error("Browser couldn't be initialized");
124+
}
125+
126+
const page = await browser.newPage({
127+
baseURL: `http://${ip}:${port}`,
128+
});
129+
const response = await page.goto("/api/route");
130+
expect(response?.headers()).toHaveProperty(
131+
"content-type",
132+
"application/json"
133+
);
134+
expect(await page.content()).toContain(`{"some":["json","response"]}`);
135+
});
136+
137+
it("renders an exclude path with index.html", async ({ expect }) => {
138+
if (!browser) {
139+
throw new Error("Browser couldn't be initialized");
140+
}
141+
142+
const page = await browser.newPage({
143+
baseURL: `http://${ip}:${port}`,
144+
});
145+
await page.goto("/api/asset");
146+
expect(await page.getByRole("heading").innerText()).toBe(
147+
"Here I am, at /api/asset!"
148+
);
149+
});
150+
});
151+
152+
describe("non-browser navigation", () => {
153+
it("renders the root with index.html", async ({ expect }) => {
154+
let response = await fetch(`http://${ip}:${port}`);
155+
expect(response.status).toBe(200);
156+
expect(await response.text()).toContain(`I'm an index.html for a SPA`);
157+
});
158+
159+
it("renders another path with index.html", async ({ expect }) => {
160+
let response = await fetch(`http://${ip}:${port}/some/page`);
161+
expect(response.status).toBe(200);
162+
expect(await response.text()).toContain(`I'm an index.html for a SPA`);
163+
});
164+
165+
it("renders an include path with the User worker", async ({ expect }) => {
166+
let response = await fetch(`http://${ip}:${port}/api/route`);
167+
expect(response.status).toBe(200);
168+
expect(response.headers.get("content-type")).toEqual(
169+
"application/json"
170+
);
171+
expect(await response.text()).toContain(`{"some":["json","response"]}`);
172+
});
173+
174+
it("renders an exclude path with index.html", async ({ expect }) => {
175+
let response = await fetch(`http://${ip}:${port}/api/asset`);
176+
expect(response.status).toBe(200);
177+
expect(await response.text()).toContain(`I'm an index.html for a SPA`);
178+
});
179+
});
180+
});
181+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
3+
"compilerOptions": {
4+
"types": ["node"]
5+
},
6+
"include": ["**/*.ts", "../../../node-types.d.ts"]
7+
}

0 commit comments

Comments
 (0)