|
1 | 1 | import { Readable } from 'node:stream' |
| 2 | +import zlib from 'node:zlib' |
2 | 3 | import dns from 'dns/promises' |
3 | 4 | import http from 'http' |
4 | 5 | import https from 'https' |
@@ -676,6 +677,28 @@ function toUndiciRequestBody( |
676 | 677 | return body as unknown as string | Buffer | Uint8Array | Readable |
677 | 678 | } |
678 | 679 |
|
| 680 | +/** |
| 681 | + * Decompression transform for a `Content-Encoding`, or `null` to pass the body through. |
| 682 | + * `undici.fetch` decodes the body automatically; `undici.request` does not, so this restores |
| 683 | + * fetch parity for gzip/deflate/br responses (common behind CDNs). Unknown/absent encodings |
| 684 | + * pass through untouched. |
| 685 | + */ |
| 686 | +function contentEncodingDecoder( |
| 687 | + encoding: string |
| 688 | +): zlib.Gunzip | zlib.Inflate | zlib.BrotliDecompress | null { |
| 689 | + switch (encoding) { |
| 690 | + case 'gzip': |
| 691 | + case 'x-gzip': |
| 692 | + return zlib.createGunzip() |
| 693 | + case 'deflate': |
| 694 | + return zlib.createInflate() |
| 695 | + case 'br': |
| 696 | + return zlib.createBrotliDecompress() |
| 697 | + default: |
| 698 | + return null |
| 699 | + } |
| 700 | +} |
| 701 | + |
679 | 702 | /** |
680 | 703 | * Bridges an undici `request()` Node `Readable` into a WHATWG `ReadableStream` for a |
681 | 704 | * `Response` body. Node's built-in `Readable.toWeb` is NOT used: its adapter throws an |
@@ -811,8 +834,32 @@ async function undiciRequestAsResponse( |
811 | 834 | // 204/205/304 carry no body; `new Response` rejects a non-null body for them. Drain |
812 | 835 | // undici's (empty) stream so its socket returns to the Agent pool instead of leaking. |
813 | 836 | const isNullBody = statusCode === 204 || statusCode === 205 || statusCode === 304 |
814 | | - if (isNullBody) body.resume() |
815 | | - const response = new Response(isNullBody ? null : nodeReadableToWebStream(body), { |
| 837 | + if (isNullBody) { |
| 838 | + body.resume() |
| 839 | + const response = new Response(null, { status: statusCode, headers: responseHeaders }) |
| 840 | + Object.defineProperty(response, 'url', { value: url, configurable: true }) |
| 841 | + return response |
| 842 | + } |
| 843 | + |
| 844 | + // Decode Content-Encoding like `fetch` does (`request()` returns raw bytes). `maxResponseSize` |
| 845 | + // still caps the compressed wire bytes on `body`; forward its errors into the decoder so the |
| 846 | + // cap and any socket reset still surface as a stream error, and tear the source down when the |
| 847 | + // decoded stream ends or is cancelled so the socket can't leak. |
| 848 | + const contentEncoding = String(headers['content-encoding'] ?? '') |
| 849 | + .toLowerCase() |
| 850 | + .trim() |
| 851 | + const decoder = contentEncodingDecoder(contentEncoding) |
| 852 | + let bodyStream: Readable = body |
| 853 | + if (decoder) { |
| 854 | + body.once('error', (err) => decoder.destroy(err)) |
| 855 | + decoder.once('close', () => body.destroy()) |
| 856 | + bodyStream = body.pipe(decoder) |
| 857 | + // The bridged body is now decoded; drop framing headers that would misdescribe it. |
| 858 | + responseHeaders.delete('content-encoding') |
| 859 | + responseHeaders.delete('content-length') |
| 860 | + } |
| 861 | + |
| 862 | + const response = new Response(nodeReadableToWebStream(bodyStream), { |
816 | 863 | status: statusCode, |
817 | 864 | headers: responseHeaders, |
818 | 865 | }) |
|
0 commit comments