cloudflare / cloudflare/vinext
Streaming-lane responses drop ETag and Content-Length added by a custom worker entry (headers present at return, absent on the wire)
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Summary
A custom `worker/index.ts` entry that wraps `vinext/server/fetch-handler` and adds validators to responses (buffer body → set `ETag` → return a fresh `Response`) works for most routes — but for responses served through vinext's **streaming/rewrite lane**, the `ETag` and `Content-Length` the wrapper sets never reach the client, on both `1.0.0-beta.5` and `1.0.0-beta.6`.
## Environment
- vinext `1.0.0-beta.5` and `1.0.0-beta.6` + `@vinext/cloudflare` (same versions), wrangler 4.120–4.123, workerd via deployed Workers (custom worker entry as `main`, assets binding, `nodejs_compat`, compat date 2026-08-01)
- next 16.3.1 (also reproduced on 16.3.0-canary.83 and 16.1.1)
## What we observe
Wrapper shape (simplified):
```ts
const response = await handler.fetch(request, env, ctx);
const bytes = await response.arrayBuffer();
const headers = new Headers(response.headers);
headers.set("etag", weakEtag(bytes));
return new Response(bytes, { status: 200, statusText: response.statusText, headers });
```
- On an app-router app where pages render through the plain lane, the wire shows the `ETag` **and** `Content-Length` (buffered) — works as expected.
- For pages served through the **locale-rewrite lane** (a root `/` that internally rewrites to `/en`) — and for another app of ours where *every* page rides the streaming lane — the wire response has **no `ETag` and no `Content-Length`** (chunked), despite the wrapper demonstrably attaching both.
## Evidence that the wrapper's response is not what gets serialized
1. Instrumented build (deployed as a version preview): a header set **on the final returned Response object** recorded `etagAtReturn=W/"…"` — that debug header reaches the wire, but the `etag` header on the same object does not, and `Content-Length` is absent (body arrives chunked as if streamed, not the buffered ArrayBuffer we returned).
2. An internal probe route that calls `handler.fetch()` directly and serializes the final headers into a JSON body shows the `etag` present in `final.headers` — i.e. the wrapper pipeline is correct; the loss happens between the fetch handler's return and wire serialization.
3. Synthetic responses from the same worker (a probe route constructing `new Response(html, { headers: { etag, "cache-control": … } })`) keep their `ETag` on the wire for every cache-control shape and body size we tried — so this is not Cloudflare stripping validators.
4. Eliminated: zone settings (reproduces on workers.dev), cache-control shape (private/public/s-maxage/max-age=0 all tested), body size (5B–400KB), Response-rewrap header-mutation semantics (verified they survive), HTML feature transforms (no injected artifacts).
Responses on the affected lane carry `x-vinext-cache: MISS|HIT` markers; unaffected pages don't, which suggests the cache/streaming serving path replays or re-streams a response that bypasses (or snapshots headers before) the worker entry's decoration — yet other header *mutations* (cache-control set by the same wrapper) DO reach the wire, which is the confusing part.
## Expected
Headers set on the Response returned by the custom worker entry reach the client for every lane, and a buffered body returns with its `Content-Length`.
## Why it matters
It makes conditional revalidation (`If-None-Match` → 304) impossible to implement in a worker entry for exactly the routes that would benefit most, and the missing `Content-Length` forces chunked transfer for fully-buffered bodies.
Happy to run instrumented builds against a canary/debug branch if that helps narrow the egress path.
Contributor guide
Research direction
Start with the custom worker/index.ts entry and vinext/server/fetch-handler, then reproduce a locale-rewrite or streaming-lane response and compare its returned headers with the deployed wire response. Trace the cache or streaming serialization path indicated by x-vinext-cache markers. Done means wrapper-set ETag and Content-Length survive serialization for affected routes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100