The answer. A Worker can carry a folder of static files. By default a request that matches a file is answered from the asset store and your code never runs; only unmatched paths reach fetch(). That is perfect for a site with an API bolted on, and wrong the moment an asset must be gated. run_worker_first moves the decision into your code: true runs the Worker on every request (then you serve files yourself through env.ASSETS.fetch(request)), and an array of patterns runs it first only for those paths, negations winning. Pair it with not_found_handling: "single-page-application" so client-side routes get index.html with a 200, and keep /api/* in the array so the SPA fallback never swallows an API 404.
The pattern.
{
"main": "src/index.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/reports/*", "!/api/docs/*"]
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname.startsWith("/reports/")) {
if (!(await validToken(url.searchParams.get("t"), env))) return new Response("forbidden", { status: 403 });
return env.ASSETS.fetch(request); // the gated file, served from the asset store
}
return api(request, env); // /api/* lands here
},
} satisfies ExportedHandler<Env>;
Watch out.
- The array takes at most 100 entries and patterns must start with
/or!/. run_worker_first: truebills an invocation for every image and CSS file. Use the array form unless everything really is gated.- Asset-only Workers (no
main) are free to serve; addmainand the fallback path becomes Worker requests.
Related: pages-vs-workers-static-assets-start-new-on-workers · pages-middleware-accept-text-markdown-negotiation · cloudflare-access-in-front-of-a-worker-verify-the-jwt-too