Unified · Split
| @@ -93,6 +93,38 @@ describe("makeClientIpStamper without a trusted proxy", () => { · 92 unmodified lines | ||
| 93 | 93 | expect(out.headers.get("x-forwarded-for")).toBe("203.0.113.9"); |
| 94 | 94 | expect(await out.json()).toEqual({ email: "a@example.com", password: "pw" }); |
| 95 | 95 | }); |
| 96 | + | |
| 97 | + // The Vite dev middleware builds the request over a stream the runtime did | |
| 98 | + // not create itself (`Readable.toWeb(req)`); the stamped request must still | |
| 99 | + // deliver it in full. | |
| 100 | + it("passes a streamed body through", async () => { | |
| 101 | + const payload = JSON.stringify({ email: "a@example.com", password: "pw" }); | |
| 102 | + const init: RequestInit & { duplex?: "half" } = { | |
| 103 | + method: "POST", | |
| 104 | + headers: { "content-type": "application/json" }, | |
| 105 | + body: new ReadableStream<Uint8Array>({ | |
| 106 | + start(controller) { | |
| 107 | + controller.enqueue(new TextEncoder().encode(payload)); | |
| 108 | + controller.close(); | |
| 109 | + }, | |
| 110 | + }), | |
| 111 | + duplex: "half", | |
| 112 | + }; | |
| 113 | + const streamed = new Request("https://host.example/api/auth/sign-in/email", init); | |
| 114 | + const out = stamp(streamed, Option.some("198.51.100.4")); | |
| 115 | + expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4"); | |
| 116 | + expect(await out.text()).toBe(payload); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + it("stamps a bodiless request without inventing a body", () => { | |
| 120 | + const out = stamp( | |
| 121 | + new Request("https://host.example/api/auth/get-session", { method: "GET" }), | |
| 122 | + Option.some("198.51.100.4"), | |
| 123 | + ); | |
| 124 | + expect(out.method).toBe("GET"); | |
| 125 | + expect(out.body).toBeNull(); | |
| 126 | + expect(out.headers.get(CLIENT_IP_HEADER)).toBe("198.51.100.4"); | |
| 127 | + }); | |
| 96 | 128 | }); |
| 97 | 129 | |
| 98 | 130 | describe("makeClientIpStamper behind a trusted proxy", () => { |
| @@ -107,6 +107,30 @@ export const clientIpAddressOptions = ( · 106 unmodified lines | ||
| 107 | 107 | } |
| 108 | 108 | : { ipAddressHeaders: [CLIENT_IP_HEADER] }; |
| 109 | 109 | |
| 110 | +/** | |
| 111 | + * The request with its headers replaced, everything else passed through. | |
| 112 | + * | |
| 113 | + * Deliberately NOT `new Request(request, { headers })`: Bun's copy constructor | |
| 114 | + * never delivers a body that is not one of its own native streams, and the | |
| 115 | + * Vite dev middleware hands the handler `Readable.toWeb(req)`, so every | |
| 116 | + * sign-in POST hung until the client gave up (the production Bun.serve path | |
| 117 | + * was unaffected). Rebuilding from the parts with the body passed explicitly | |
| 118 | + * behaves the same on both paths; `duplex: "half"` is what the Fetch spec | |
| 119 | + * requires when a request body is a stream. | |
| 120 | + */ | |
| 121 | +const withHeaders = (request: Request, headers: Headers): Request => { | |
| 122 | + const init: RequestInit & { duplex?: "half" } = { | |
| 123 | + method: request.method, | |
| 124 | + headers, | |
| 125 | + signal: request.signal, | |
| 126 | + }; | |
| 127 | + if (request.body) { | |
| 128 | + init.body = request.body; | |
| 129 | + init.duplex = "half"; | |
| 130 | + } | |
| 131 | + return new Request(request.url, init); | |
| 132 | +}; | |
| 133 | + | |
| 110 | 134 | export interface ClientIpStamperOptions { |
| 111 | 135 | /** Where the one-time unconfigured-proxy warning goes. Defaults to console.warn. */ |
| 112 | 136 | readonly warn?: (message: string) => void; |
| @@ -153,6 +177,6 @@ export const makeClientIpStamper = ( · 40 unmodified lines | ||
| 153 | 177 | warn(unconfiguredProxyWarning(hint)); |
| 154 | 178 | } |
| 155 | 179 | } |
| 156 | − return new Request(request, { headers }); | |
| 180 | + return withHeaders(request, headers); | |
| 157 | 181 | }; |
| 158 | 182 | }; |