Repository navigation
fix: stream proxy body, forward Range/conditional and custom headers (#10) - #11
Conversation
createBackendProxyHandler buffered the entire request body via req.arrayBuffer() and forwarded only Content-Type/Accept, which broke large binary uploads and browser media seeking. - Stream the request body with req.body + duplex: "half" so uploads of any size pass through with constant memory instead of the heap. - Always forward the range/conditional headers (Range, If-Range, If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since) so media seeking (206 Partial Content) and conditional caching work. - Add optional forwardRequestHeaders allowlist (case-insensitive) for application headers such as idempotency keys and checksums, with a hard deny-set that is never forwarded: Cookie, Host, Authorization, Content-Length, Connection. - Add route-handlers tests covering streaming, range forwarding, 206 passthrough, custom-header allowlisting, and Cookie/Host exclusion. - Document the new behavior and forwardRequestHeaders in the README. Fixes #10 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The change is well-scoped, preserves a strict security posture for headers, and includes targeted tests that cover the new streaming and forwarding behavior.
Pull request overview
This PR fixes the backend proxy route handler so it can handle large binary uploads efficiently and supports browser media seeking by streaming request bodies and forwarding range/conditional headers, while keeping a security-focused request-header allowlist.
Changes:
- Stream proxied request bodies via
req.body(usingduplex: "half") instead of buffering witharrayBuffer(). - Always forward
Range+ conditional headers needed for partial content and caching, and add an optionalforwardRequestHeadersallowlist with a hard deny-set. - Add Vitest coverage for streaming behavior, header forwarding/denial, and
206 Partial Contentpassthrough; update README with the new behavior and config.
File summaries
| File | Description |
|---|---|
| src/server/route-handlers.ts | Switch to streaming request proxying and introduce safe, configurable request-header forwarding. |
| src/server/tests/route-handlers.test.ts | Add regression tests covering streaming, range/conditional forwarding, and header allow/deny behavior. |
| README.md | Document streaming semantics and the header forwarding/denial rules, including forwardRequestHeaders. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
danielmarv
left a comment
There was a problem hiding this comment.
Looks good overall streaming the request body is the right fix, and the header filtering is handled safely.
i would suggest to consider;
-
Set
redirect: "manual"on the upstreamfetch.fetchfollows redirects by default, and307/308responses require it to repeat the request with the same method and body. That worked while the body was buffered as anArrayBuffer, butreq.bodyis now a single-use stream and cannot be replayed after the first request starts consuming it. The redirected fetch can therefore fail. Using manual redirect handling lets the proxy return the backend’s3xxresponse andLocationheader to the client instead. -
Pass
signal: req.signalso cancelling an upload also aborts the upstream request. Otherwise, the backend connection can remain open after the client disconnects.
… abort Follow-up on the streaming proxy fix, addressing the review of #11 and two defects found while verifying it against a real HTTP server. Review feedback (danielmarv): - `redirect: "manual"`: a streamed body is single-use, so following a 307/308 — which must repeat the request with the same method and body — fails once the stream has been consumed. The 3xx and its `Location` are relayed instead. Verified that undici returns the real response here rather than the browser Fetch spec's opaque redirect with status 0, which `new Response()` rejects. - `signal: req.signal`: a cancelled upload now aborts the upstream request, so the backend stops reading and can roll back its partial write. `Content-Length` is forwarded instead of denied. Node's fetch does not enforce the browser's forbidden-header list, so the header does reach the backend. It has to: with a streamed body and no declared length the upstream request is framed as `Transfer-Encoding: chunked`, and a backend asking for the length (Servlet `getContentLengthLong()`) gets `-1`. Backends use that value to reject an over-sized upload before reading the body, so dropping the header silently disabled the check and turned a cheap rejection into a full transfer. The body is relayed byte for byte, so the incoming length stays accurate. Response headers are corrected rather than relayed verbatim. Node's fetch adds `Accept-Encoding: gzip, deflate` on its own and transparently decodes the response, but leaves `Content-Encoding` and `Content-Length` describing the encoded bytes; relaying them hands the client plain bytes labelled `gzip`, which browsers report as ERR_CONTENT_DECODING_FAILED. Both are dropped when the upstream response was encoded — and only then, so an uncompressed 206 keeps its `Content-Length` and `Content-Range`. Hop-by-hop headers are never relayed: a chunked upstream response carries `Transfer-Encoding` and `Connection` in its headers, which contradict the framing Next.js applies to the response it sends. This defect predates #11 and is invisible today only because Spring's `server.compression.enabled` defaults to false. Tests: 69 -> 79. The existing suite mocks fetch and can only assert the shape of `init.body`; it proves nothing about undici. A new integration suite runs the handler against a real `node:http` server with the runtime's real fetch: the streaming case withholds its second body chunk until the server confirms the first, so a handler that buffers fails on an explicit assertion instead of hanging. Confirmed to fail when `arrayBuffer()` is put back. This is the canary for a Node or Next upgrade breaking `duplex: "half"`. `@types/node` is added as a devDependency (^24, matching .nvmrc) because the repository carried no Node types and the integration suite needs them. It is dev-only; `dist/` stays free of tests. docs/TODO.md records the `Expect: 100-continue` extension that would let a backend reject an upload before any body byte flows, with its two prerequisites (undici instead of fetch, and Tomcat's `continueResponseTiming`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed @danielmarv — both adopted
So the real 3xx and its
|
| upstream | after the proxy | |
|---|---|---|
Content-Encoding |
gzip |
gzip ← wrong |
Content-Length |
49 |
49 ← wrong |
| body | 49 B gzip | 226 B plain |
The client is handed plain bytes labelled gzip with a length that does not match → ERR_CONTENT_DECODING_FAILED. It is invisible today only because Spring's server.compression.enabled defaults to false; turning it on — an ordinary production tuning step — would break every proxied response in every app at once.
relayedResponseHeaders() drops Content-Encoding and Content-Length only when the upstream response was encoded, so an uncompressed 206 keeps its Content-Length and Content-Range. Hop-by-hop headers are always dropped: a chunked upstream response does carry them, and they contradict the framing Next.js applies to the response it actually sends —
chunked resp headers: [["connection","keep-alive"], ..., ["transfer-encoding","chunked"]]
Tests: 69 → 79
The existing suite mocks fetch, so it can assert the shape of init.body but proves nothing about undici — and acceptance criterion "arbitrary-sized bodies pass through with memory staying flat" was ticked by a test that never moves a byte. Added route-handlers.streaming.test.ts (@vitest-environment node): the handler runs against a real node:http server with the runtime's real fetch. The streaming case withholds its second body chunk until the server confirms the first, so a buffering handler waits on a stream that waits on it, and the test fails on an explicit assertion rather than hanging:
AssertionError: expected false to be true
❯ Object.pull route-handlers.streaming.test.ts:109:28
That is the output with arrayBuffer() put back — verified, so the canary can actually die. It is also the regression test for a Node or Next upgrade breaking duplex: "half".
One necessary side effect
@types/node as a devDependency (^24, matching .nvmrc). The repository carried no Node types at all, and the integration suite needs them. Dev-only; dist/ stays free of tests.
typecheck, lint, format:check, test (79 passed), build — all clean.
Recorded, not built
docs/TODO.md gains the Expect: 100-continue extension: it would let a backend reject an upload before any body byte flows, which is what a 412-on-retry currently pays 512 MB for. Two prerequisites, both real: fetch cannot request it (undici.request({ expectContinue: true }) can, so it means dropping fetch), and Tomcat answers it itself unless continueResponseTiming is set to ON_REQUEST_BODY_READ — its default is IMMEDIATELY, checked in the 10.1.54 bytecode.
Fixes #10.
createBackendProxyHandlerbuffered the entire request body viareq.arrayBuffer()and forwarded onlyContent-Type/Accept. That made the proxy unusable for large binary uploads (memory proportional to file size) and broke browser media seeking (noRangeforwarding).Changes
req.body+duplex: "half"(undici requirement) instead of buffering — arbitrary-sized uploads now pass through with constant memory.Range,If-Range,If-Match,If-None-Match,If-Modified-Since,If-Unmodified-Since, alongsideContent-Type/Accept. Media seeking (206 Partial Content) and conditional caching now work.forwardRequestHeadersallowlist (case-insensitive) for application headers like idempotency keys and checksums. Hard deny-set that is never forwarded regardless of the list:Cookie,Host,Authorization(proxy sets its own bearer),Content-Length,Connection.src/server/__tests__/route-handlers.test.ts, 9 cases): streaming (duplex: "half", stream body, body not consumed), GET has no body, range/conditional forwarding,206status+body passthrough, custom-header allowlisting,Cookie/Hostexclusion regression, and spoofed-Authorizationoverride.forwardRequestHeaderswith security exclusions.Acceptance criteria
206 Partial ContentresponsesCookieexclusionVerification
pnpm typecheck,pnpm lint,pnpm format:check,pnpm buildall clean;pnpm test→ 69 passed (10 files), including the 9 new tests.🤖 Generated with Claude Code