|
1 | 1 | import type { H3Event } from 'h3' |
2 | | -import { getRequestHeader, setResponseHeader } from 'h3' |
| 2 | +import { appendResponseHeader, getRequestHeader, getResponseHeader, removeResponseHeader, setResponseHeader } from 'h3' |
3 | 3 | import { defineNitroPlugin } from 'nitropack/runtime' |
| 4 | +import { logger } from '../../utils-pure' |
| 5 | +import { hasNonIdentityEncoding, isReadableStream, negotiateCompressionEncoding } from '../sitemap/stream' |
4 | 6 |
|
5 | | -function getPreferredEncoding(event: H3Event): 'gzip' | 'deflate' | null { |
6 | | - const acceptEncoding = getRequestHeader(event, 'accept-encoding') || '' |
7 | | - if (acceptEncoding.includes('gzip')) |
8 | | - return 'gzip' |
9 | | - if (acceptEncoding.includes('deflate')) |
10 | | - return 'deflate' |
11 | | - return null |
| 7 | +let warnedAboutCompressionStream = false |
| 8 | +const NODE_COMPRESSION_INPUT_BATCH_BYTES = 512 * 1024 |
| 9 | + |
| 10 | +function toByteStream(body: unknown): ReadableStream<Uint8Array> { |
| 11 | + if (isReadableStream(body)) |
| 12 | + return body |
| 13 | + if (body instanceof Blob) |
| 14 | + return body.stream() |
| 15 | + |
| 16 | + const value = typeof body === 'string' || body instanceof ArrayBuffer || ArrayBuffer.isView(body) |
| 17 | + ? body |
| 18 | + : JSON.stringify(body) |
| 19 | + return new Blob([value as BlobPart]).stream() |
| 20 | +} |
| 21 | + |
| 22 | +function createNodeCompressionInputStream(source: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> { |
| 23 | + const reader = source.getReader() |
| 24 | + let bytesRead = 0 |
| 25 | + |
| 26 | + return new ReadableStream<Uint8Array>({ |
| 27 | + async pull(controller) { |
| 28 | + if (bytesRead >= NODE_COMPRESSION_INPUT_BATCH_BYTES) { |
| 29 | + bytesRead = 0 |
| 30 | + await new Promise<void>(resolve => setTimeout(resolve, 0)) |
| 31 | + } |
| 32 | + |
| 33 | + const result = await reader.read() |
| 34 | + if (result.done) { |
| 35 | + controller.close() |
| 36 | + return |
| 37 | + } |
| 38 | + |
| 39 | + bytesRead += result.value.byteLength |
| 40 | + controller.enqueue(result.value) |
| 41 | + }, |
| 42 | + cancel(reason) { |
| 43 | + return reader.cancel(reason) |
| 44 | + }, |
| 45 | + }) |
| 46 | +} |
| 47 | + |
| 48 | +function addVaryAcceptEncoding(event: H3Event) { |
| 49 | + const vary = getResponseHeader(event, 'Vary') |
| 50 | + const values = (Array.isArray(vary) ? vary : [vary]) |
| 51 | + .flatMap(value => String(value || '').split(',')) |
| 52 | + .map(value => value.trim().toLowerCase()) |
| 53 | + if (!values.includes('*') && !values.includes('accept-encoding')) |
| 54 | + appendResponseHeader(event, 'Vary', 'Accept-Encoding') |
12 | 55 | } |
13 | 56 |
|
14 | 57 | export default defineNitroPlugin((nitro) => { |
15 | 58 | nitro.hooks.hook('beforeResponse', (event, response) => { |
16 | 59 | if (!event.context._isSitemap || !response.body) |
17 | 60 | return |
18 | 61 |
|
19 | | - const encoding = getPreferredEncoding(event) |
| 62 | + addVaryAcceptEncoding(event) |
| 63 | + if (hasNonIdentityEncoding(getResponseHeader(event, 'Content-Encoding'))) |
| 64 | + return |
| 65 | + |
| 66 | + const encoding = negotiateCompressionEncoding(getRequestHeader(event, 'accept-encoding') || '') |
20 | 67 | if (!encoding) |
21 | 68 | return |
22 | 69 |
|
23 | | - const body = typeof response.body === 'string' ? response.body : JSON.stringify(response.body) |
24 | | - response.body = new Blob([body]).stream().pipeThrough(new CompressionStream(encoding)) |
| 70 | + if (typeof CompressionStream === 'undefined') { |
| 71 | + if (!warnedAboutCompressionStream) { |
| 72 | + warnedAboutCompressionStream = true |
| 73 | + logger.warn('Sitemap compression was requested, but CompressionStream is unavailable in this runtime. Sending the uncompressed response.') |
| 74 | + } |
| 75 | + return |
| 76 | + } |
| 77 | + |
| 78 | + const compression = new CompressionStream(encoding) as unknown as TransformStream<Uint8Array, Uint8Array> |
| 79 | + const source = toByteStream(response.body) |
| 80 | + // Node's CompressionStream can drain synchronous input before exposing output. |
| 81 | + // Yield in bounded batches so socket backpressure and cancellation reach the source. |
| 82 | + const compressionInput = event.node.res?.socket |
| 83 | + ? createNodeCompressionInputStream(source) |
| 84 | + : source |
| 85 | + response.body = compressionInput.pipeThrough(compression) |
| 86 | + removeResponseHeader(event, 'Content-Length') |
25 | 87 | setResponseHeader(event, 'Content-Encoding', encoding) |
26 | 88 | }) |
27 | 89 | }) |
0 commit comments