GET /_worker.js returns 200 and every route it declares 404s

5 min read CloudflareCloudflare PagesDeploymentWorkers

A hand-written _worker.js deployed to Cloudflare Pages, the deployment reported success, the site worked, and the entire API was gone. The file had been uploaded as an asset instead of compiled as a worker, so Pages served the source code at its own path.

TL;DR · THE FIX

wrangler pages deploy does not upload _worker.js as an asset. It compiles the script and posts it as a separate part of the deployment request. A raw-API deploy that walks dist/ and posts every file lands _worker.js in the asset manifest, where Pages serves it like any other static file: 200 on /_worker.js, 404 on every route it declares. Check the route, not the upload.

The symptom

I added an advanced-mode _worker.js at the root of dist/, deployed, and the deployment reported success. The site came up and the homepage was fine. Every route the worker declared returned 404.

curl -sk -o /dev/null -w "%{http_code}\n" https://unstucked.dev/api/health
# 404
curl -sk -o /dev/null -w "%{http_code}\n" https://unstucked.dev/_worker.js
# 200
curl -sk -o /dev/null -w "%{http_code}\n" https://unstucked.dev/
# 200

All three are from the same deployment. The second line is the bug, and it took me a long time to think of running it. There was no error in the deploy output, the Pages dashboard or the build log. By every signal available to me the deployment was a complete success, and it was serving my worker’s source code to anyone who asked for it.

What I tried first

The natural assumption is that the worker is running and the routing is wrong, so I spent a while on the routing. Pages uses _routes.json to decide which paths go to the worker and which are served as static assets; adding one did nothing and removing it did nothing. I checked whether the export shape was wrong, since a worker that fails to export a fetch handler is a plausible cause of everything 404ing and Pages is not always loud about it. The shape was correct, a default export with a fetch method copied from a working Worker. Then I checked the compatibility date and the Node compatibility flag, the kind of checks you run because you have run out of ideas rather than because you have a hypothesis.

What I had not done was ask whether the worker existed at all as a worker.

What was happening

wrangler pages deploy does not upload _worker.js as an asset. It reads the file, compiles it, and posts it as a separate part of the deployment request, alongside the asset manifest rather than inside it. The file never becomes a static asset, because wrangler pulls it out of the directory before the assets are computed.

Our deploy does not use wrangler and cannot, because the local TLS proxy breaks it, which is a fix post of its own. So we deploy through the raw Cloudflare API, and our deploy script walks dist/, hashes every file, and posts them all. _worker.js is a file in dist/, so it got walked, hashed, and posted exactly like index.html and favicon.svg, landed in the asset manifest, and Pages served it the way it serves every asset in the manifest: at its own path, with a 200, as text.

The deployment had no reason to complain. I had uploaded a JavaScript file and it was serving that JavaScript file. Advanced mode was never activated because nothing in the request said “this is a worker”, and the 404s were correct too: there was no worker, so there were no routes.

The failure is invisible from the direction you look from. You deploy, the site works, and the missing part is a set of paths that never existed before, so there is no “it used to work” to anchor on. The one piece of evidence that settles it in a second, GET /_worker.js returning your own source, is a request you have no reason to make, because why would you fetch a file you wrote.

The fix

The file is fine. The fix is in how it reaches Cloudflare, and there are two routes, neither free.

The first is to reproduce wrangler’s multipart shape. The deployment API accepts the worker as its own part of the request rather than as an asset. This is the correct fix, and it is undocumented enough that you end up reverse-engineering wrangler’s network traffic to get there.

The second is to deploy the API as a standalone Worker and call it from the page. That is cleaner, and it separates two things that were only coupled by convenience, but it needs Workers Scripts:Edit on your API token, which a Pages-scoped token does not carry:

curl -sk -H "Authorization: Bearer $CF_TOKEN" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/workers/scripts"
# 403

That 403 is worth knowing about before you start, because a Pages token that happily deploys your whole site fails at the last step of this approach, after you have written the worker.

Whichever you pick, add the check to your deploy script so this cannot come back silently:

# after deploy, assert the worker is a worker and not an asset
code=$(curl -sk -o /dev/null -w "%{http_code}" "$SITE/_worker.js")
[ "$code" = "404" ] || { echo "FAIL: _worker.js is being served as an asset ($code)"; exit 1; }

A 404 on /_worker.js is the success condition. That reads wrong until you say it out loud: if the platform is treating your worker as a worker, there is no asset at that path, so asking for one should fail.

The lesson

A file being present in the output is different from the platform treating it as that kind of file. Every deploy pipeline has a set of magic filenames it handles specially, and each of those is a place where “I put the file in the right directory” and “the file is doing its job” can come apart without anything raising a hand.

So check the route rather than the upload. Confirming the file shipped tells you about your build. Confirming the behaviour it was supposed to cause tells you about the platform, which is the part you did not write and cannot see. And when a magic file goes quiet, fetch it by name. Publishing source you assumed was being executed is the failure mode nobody thinks to test for, and it is one request away from being obvious.

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.