Describe the bug
The adapter-cloudflare worker template runs a worktop cache lookup before any routing:
|
|
|
// skip cache if "cache-control: no-cache" in request |
|
let pragma = req.headers.get('cache-control') || ''; |
|
let res = !pragma.includes('no-cache') && (await Cache.lookup(req)); |
// skip cache if "cache-control: no-cache" in request
let pragma = req.headers.get('cache-control') || '';
let res = !pragma.includes('no-cache') && (await Cache.lookup(req));
if (res) return res;
Cache.lookup is worktop's (worktop/cfw.cache, worktop@0.8.0-next.18 pinned by the adapter). For HEAD requests it rebuilds the incoming request into a GET cache key, using the incoming request as the init source (de-minified from the shipped package):
let isHEAD = typeof req !== 'string' && req.method === 'HEAD';
if (isHEAD) req = new Request(req, { method: 'GET' });
let res = await cache.match(req);
On the production edge, workerd represents an incoming request that declares a body as having a non-null (empty) body stream — and HEAD with Content-Length: 0 is exactly that shape. The header is unusual on HEAD but real traffic sends it constantly: uptime monitors and .NET HttpClient are the two we caught in the wild.
For such a request, new Request(req, { method: 'GET' }) inherits the non-null body and hits the Fetch spec rule that a GET/HEAD request cannot have one:
TypeError: Request with a GET or HEAD method cannot have a body.
The throw happens in the adapter prologue — before server.respond, so before any route, hook, or handleError runs. It surfaces as an unhandled exception ("Exception Thrown" in wrangler pages deployment tail), and every HEAD request that declares a body, to every route of every adapter-cloudflare deployment, returns 500. The site looks healthy in browsers and to plain curl -I, while uptime monitors report it down — and no app-level error handling or observability ever fires, because app code never runs.
Why this hides from every local check:
- Plain
curl -I sends no Content-Length, so it does not reproduce — you must add -H "Content-Length: 0".
vite dev has no adapter prologue.
wrangler pages dev doesn't reproduce either — the local ingress doesn't materialize the declared body the way the production edge does.
- Node/undici enforce the same spec rule at construction time, so the failing
Request cannot even be built in a unit test. The production edge is the only place this input exists.
Verified on @sveltejs/adapter-cloudflare 7.2.7; 7.2.8/7.2.9 ship a byte-identical built worker template, so the latest release behaves the same. Confirmed live on three separate production Cloudflare Pages projects (re-confirmed today: 35/35 such requests → 500, while plain HEAD → 200/302). GET with Content-Length: 0 is unaffected (verified — worktop only rebuilds HEAD).
Reproduction
This is edge-runtime behavior, so it can't be demonstrated on StackBlitz — the reproduction is any adapter-cloudflare deployment plus one curl flag. No app code is involved; the skeleton app reproduces it on every route:
npx sv create repro (skeleton defaults), swap the adapter to @sveltejs/adapter-cloudflare
npm run build && npx wrangler pages deploy .svelte-kit/cloudflare
- Then:
curl -I https://<deployment>/ # 200 — looks healthy
curl -I -H "Content-Length: 0" https://<deployment>/ # 500 — and this is what monitors send
Any existing adapter-cloudflare deployment shows the same two results. Happy to push a minimal repro repo if that helps triage.
Logs
$ curl -I https://<site>/
HTTP/1.1 302 Found
Location: /login
Content-Security-Policy: base-uri 'self'; ... <- app responded; its headers are present
$ curl -I -H "Content-Length: 0" https://<site>/
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=UTF-8
Cache-Control: private, max-age=0, no-store, no-cache, must-revalidate, ...
Server: cloudflare <- generic edge error; none of the app's headers
# wrangler pages deployment tail, same request: logged with outcome "Exception Thrown" and
TypeError: Request with a GET or HEAD method cannot have a body.
System Info
System:
OS: Windows 11 10.0.26200 (build machine - the failure is in production workerd, not local tooling)
Binaries:
Node: 22.13.1
npmPackages:
@sveltejs/adapter-cloudflare: 7.2.7 (bug also verified against 7.2.9 - built worker template is byte-identical)
@sveltejs/kit: 2.50.1
svelte: 5.48.5
vite: 6.4.1
wrangler: 4.80.0
adapter dependency:
worktop: 0.8.0-next.18 (pinned)
Severity
serious, but I can work around it
Additional Information
Either of these would fix it:
-
Build the cache key without the incoming request as init source, so a body can never be inherited:
let res =
!pragma.includes('no-cache') &&
(await Cache.lookup(new Request(req.url, { method: 'GET', headers: req.headers })));
(Cache.save receives the original request and already ignores non-GET methods, so the save path is unchanged.)
-
Normalize declared-body GET/HEAD requests once at the top of fetch, which also shields server.respond and any future prologue code from the same input:
if (
req.body !== null &&
(req.method === 'HEAD' || (req.method === 'GET' && req.headers.get('content-length') === '0'))
) {
req = new Request(req.url, { method: req.method, headers: req.headers, redirect: req.redirect, cf: req.cf });
}
Related: #15813 proposes removing this pre-routing cache lookup entirely (for unrelated reasons) — that would also fix this, and this bug is perhaps extra motivation: the lookup currently runs (and can throw) even for clients that will never be served from cache. #9884 tracks another defect in the same code path.
Our production workaround, in case someone else hits this before a fix ships: a postbuild script that wraps the built _worker.js default export with exactly the normalization in suggestion 2 (idempotent; it fails the build if the adapter's export tail ever changes, so an adapter upgrade can't silently drop it). Verified live: before the wrapper, these requests were 500 with "Exception Thrown" in the deployment tail; after, 200/302.
Describe the bug
The
adapter-cloudflareworker template runs a worktop cache lookup before any routing:kit/packages/adapter-cloudflare/src/worker.js
Lines 53 to 56 in 2122259
Cache.lookupis worktop's (worktop/cfw.cache,worktop@0.8.0-next.18pinned by the adapter). For HEAD requests it rebuilds the incoming request into a GET cache key, using the incoming request as the init source (de-minified from the shipped package):On the production edge, workerd represents an incoming request that declares a body as having a non-null (empty) body stream — and
HEADwithContent-Length: 0is exactly that shape. The header is unusual on HEAD but real traffic sends it constantly: uptime monitors and .NETHttpClientare the two we caught in the wild.For such a request,
new Request(req, { method: 'GET' })inherits the non-null body and hits the Fetch spec rule that a GET/HEAD request cannot have one:The throw happens in the adapter prologue — before
server.respond, so before any route, hook, orhandleErrorruns. It surfaces as an unhandled exception ("Exception Thrown" inwrangler pages deployment tail), and every HEAD request that declares a body, to every route of every adapter-cloudflare deployment, returns 500. The site looks healthy in browsers and to plaincurl -I, while uptime monitors report it down — and no app-level error handling or observability ever fires, because app code never runs.Why this hides from every local check:
curl -Isends noContent-Length, so it does not reproduce — you must add-H "Content-Length: 0".vite devhas no adapter prologue.wrangler pages devdoesn't reproduce either — the local ingress doesn't materialize the declared body the way the production edge does.Requestcannot even be built in a unit test. The production edge is the only place this input exists.Verified on
@sveltejs/adapter-cloudflare7.2.7; 7.2.8/7.2.9 ship a byte-identical built worker template, so the latest release behaves the same. Confirmed live on three separate production Cloudflare Pages projects (re-confirmed today: 35/35 such requests → 500, while plain HEAD → 200/302). GET withContent-Length: 0is unaffected (verified — worktop only rebuilds HEAD).Reproduction
This is edge-runtime behavior, so it can't be demonstrated on StackBlitz — the reproduction is any adapter-cloudflare deployment plus one curl flag. No app code is involved; the skeleton app reproduces it on every route:
npx sv create repro(skeleton defaults), swap the adapter to@sveltejs/adapter-cloudflarenpm run build && npx wrangler pages deploy .svelte-kit/cloudflareAny existing adapter-cloudflare deployment shows the same two results. Happy to push a minimal repro repo if that helps triage.
Logs
System Info
Severity
serious, but I can work around it
Additional Information
Either of these would fix it:
Build the cache key without the incoming request as init source, so a body can never be inherited:
(
Cache.savereceives the original request and already ignores non-GET methods, so the save path is unchanged.)Normalize declared-body GET/HEAD requests once at the top of
fetch, which also shieldsserver.respondand any future prologue code from the same input:Related: #15813 proposes removing this pre-routing cache lookup entirely (for unrelated reasons) — that would also fix this, and this bug is perhaps extra motivation: the lookup currently runs (and can throw) even for clients that will never be served from cache. #9884 tracks another defect in the same code path.
Our production workaround, in case someone else hits this before a fix ships: a postbuild script that wraps the built
_worker.jsdefault export with exactly the normalization in suggestion 2 (idempotent; it fails the build if the adapter's export tail ever changes, so an adapter upgrade can't silently drop it). Verified live: before the wrapper, these requests were 500 with "Exception Thrown" in the deployment tail; after, 200/302.