Skip to content

Commit 8419129

Browse files
authored
feat: experimentalStreaming mode (#634)
1 parent 77f9c51 commit 8419129

24 files changed

Lines changed: 1134 additions & 105 deletions

File tree

‎docs/content/2.advanced/2.performance.md‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,48 @@ export default defineNuxtConfig({
6060
```
6161

6262
Additionally, you may want to consider the following experimental options that may help with performance:
63-
- `experimentalCompression` - Gzips and streams the sitemap
64-
- `experimentalWarmUp` - Creates the sitemaps when Nitro starts
63+
64+
- `experimentalStreaming`: Streams XML serialization in roughly 64 KB chunks instead of building the complete XML string in memory
65+
- `experimentalCompression`: Streams gzip or deflate compression when the client supports it
66+
- `experimentalWarmUp`: Creates the sitemaps when Nitro starts
67+
68+
To reduce peak XML serialization memory while retaining compressed responses, enable streaming and compression together:
69+
70+
```ts [nuxt.config.ts]
71+
export default defineNuxtConfig({
72+
sitemap: {
73+
experimentalStreaming: true,
74+
experimentalCompression: true,
75+
},
76+
})
77+
```
78+
79+
Streaming applies to dynamic XML serialization. URL source fetching, normalization, filtering, deduplication, sorting, and the `sitemap:resolved` hook finish before the first XML chunk. The resolved URL plan remains in memory. Prerendering and `zeroRuntime` produce static files instead.
80+
81+
Keep URL sources in their documented JSON formats. Streaming a source response does not make URL resolution incremental because the global processing stages require the complete URL plan.
82+
83+
When `cacheMaxAgeSeconds` is enabled in production, streaming caches this finalized URL plan with stale-while-revalidate. Source resolution and the `sitemap:resolved` hook run on cache misses and refreshes; XML serialization and optional compression remain pull-driven for each response.
84+
85+
The `sitemap:output` hook remains backwards compatible. If a hook reads or replaces `ctx.sitemap`, that response is buffered before being streamed to the client. Hooks that do not access `ctx.sitemap` keep the streaming serializer path.
86+
87+
A CDN, reverse proxy, or deployment adapter may buffer the response after Nuxt sends it. Verify behavior in the production path rather than relying on local timing alone.
88+
89+
### Verify the render mode
90+
91+
Enable `debug` temporarily and inspect `X-Sitemap-Render-Mode`:
92+
93+
- `stream`: XML serialization stayed pull driven
94+
- `buffered-hook`: A `sitemap:output` hook accessed the XML string
95+
96+
A streamed response omits `Content-Length`. `Content-Encoding: gzip` or `deflate` confirms transport compression, but does not prove that XML serialization streamed.
6597

6698
**Very large sites (100k+ URLs).** For sites at this scale, two practices matter most:
6799

68100
1. **Cache the source endpoint.** Use `defineCachedEventHandler` on any `/api/*` route fed into `sources`. Without this, every cache miss (and every fresh chunk) re-hits your backend.
69101

70102
2. **Set generous chunk sizes.** Search engines accept up to 50,000 URLs per file. The default `defaultSitemapsChunkSize` of 1000 generates 50× more chunks than necessary; bumping to `5000`–`50000` directly reduces total work and cache entries.
71103

72-
Within a single sitemap, all chunks share one resolved-URLs computation (sources are fetched, normalized, and sorted once per `cacheMaxAgeSeconds` window — not once per chunk). Splitting one large sitemap into per-shard sitemaps (e.g. one per locale or content type) is still useful when shards have different cache lifetimes or different sources.
104+
With `cacheMaxAgeSeconds` enabled in production, chunks of the same base sitemap share one resolved URL computation per cache window. If caching is disabled, each requested chunk processes the complete base URL set before slicing its own URLs. Splitting one large sitemap into per-shard sitemaps, such as one per locale or content type, is still useful when shards have different cache lifetimes or sources.
73105

74106
## Zero Runtime Mode
75107

‎docs/content/3.api/0.config.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,15 @@ Whether to warm up the sitemaps when Nitro starts. This can be useful for large
306306

307307
- Default: `false`
308308

309-
Whether to compress and stream the sitemaps when the request accepts it.
309+
Whether to stream gzip or deflate compression when the request accepts it. This streams the transport even when XML serialization is buffered. Combine it with `experimentalStreaming` to stream both serialization and compression.
310+
311+
## `experimentalStreaming: boolean`
312+
313+
- Default: `false`
314+
315+
Whether to serialize dynamic sitemap and sitemap index responses as a pull-driven stream. URL sources and the finalized URL plan are still resolved in full before serialization begins. Streaming a source response does not make source processing incremental.
316+
317+
Prerendered output remains buffered. Accessing `ctx.sitemap` from the `sitemap:output` hook buffers that response for backwards compatibility. Enable `debug` to expose the `X-Sitemap-Render-Mode` response header. See the [performance guide](/docs/sitemap/advanced/performance#verify-the-render-mode) for constraints and verification.
310318

311319
## `credits: boolean`
312320

‎docs/content/4.nitro-api/nitro-hooks.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export default defineNitroPlugin((nitroApp) => {
4343

4444
Triggered once the final structure of the XML is generated, provides the URLs as objects.
4545

46+
With `experimentalStreaming` and production caching enabled, the finalized URL plan is cached. This hook runs on a plan cache miss or stale-while-revalidate refresh rather than on every sitemap response.
47+
4648
For new URLs it's recommended to use `sitemap:input` instead. Use this hook for modifying entries or removing them.
4749

4850
```ts [server/plugins/sitemap.ts]
@@ -119,6 +121,8 @@ export default defineNitroPlugin((nitroApp) => {
119121
Triggered before the sitemap is sent to the client.
120122
It provides the sitemap as an XML string.
121123

124+
When `experimentalStreaming` is enabled, the XML string is created lazily. Reading or replacing `ctx.sitemap` buffers the complete XML response. A hook that only observes the event or sitemap name preserves streaming serialization. With `debug` enabled, check `X-Sitemap-Render-Mode` for `stream` or `buffered-hook`.
125+
122126
```ts [server/plugins/sitemap.ts]
123127
import { defineNitroPlugin } from 'nitropack/runtime'
124128

‎src/module.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export default defineNuxtModule<ModuleOptions>({
106106
cacheMaxAgeSeconds: 60 * 10, // cache for 10 minutes
107107
minify: false,
108108
debug: false,
109+
experimentalStreaming: false,
109110
defaultSitemapsChunkSize: 1000,
110111
autoLastmod: false,
111112
discoverImages: true,
@@ -440,13 +441,15 @@ export default defineNuxtModule<ModuleOptions>({
440441
}
441442

442443
// skip experimental runtime plugins in zeroRuntime mode
443-
if (config.zeroRuntime && (config.experimentalWarmUp || config.experimentalCompression))
444-
logger.warn('`experimentalWarmUp` and `experimentalCompression` are ignored in zeroRuntime mode.')
444+
if (config.zeroRuntime && (config.experimentalWarmUp || config.experimentalCompression || config.experimentalStreaming))
445+
logger.warn('`experimentalWarmUp`, `experimentalCompression`, and `experimentalStreaming` are ignored in zeroRuntime mode.')
445446
if (!config.zeroRuntime) {
446447
if (config.experimentalWarmUp)
447448
addServerPlugin(resolve('./runtime/server/plugins/warm-up'))
448449
if (config.experimentalCompression)
449450
addServerPlugin(resolve('./runtime/server/plugins/compression'))
451+
if (config.experimentalStreaming || config.experimentalCompression)
452+
addServerPlugin(resolve('./runtime/server/plugins/stream-transport'))
450453
}
451454

452455
// @ts-expect-error untyped
@@ -782,6 +785,7 @@ export default defineNuxtModule<ModuleOptions>({
782785
isMultiSitemap: usingMultiSitemaps,
783786
excludeAppSources: config.excludeAppSources,
784787
cacheMaxAgeSeconds: nuxt.options.dev ? 0 : config.cacheMaxAgeSeconds,
788+
experimentalStreaming: config.experimentalStreaming,
785789

786790
autoLastmod: config.autoLastmod,
787791
defaultSitemapsChunkSize: config.defaultSitemapsChunkSize,
Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,89 @@
11
import type { H3Event } from 'h3'
2-
import { getRequestHeader, setResponseHeader } from 'h3'
2+
import { appendResponseHeader, getRequestHeader, getResponseHeader, removeResponseHeader, setResponseHeader } from 'h3'
33
import { defineNitroPlugin } from 'nitropack/runtime'
4+
import { logger } from '../../utils-pure'
5+
import { hasNonIdentityEncoding, isReadableStream, negotiateCompressionEncoding } from '../sitemap/stream'
46

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')
1255
}
1356

1457
export default defineNitroPlugin((nitro) => {
1558
nitro.hooks.hook('beforeResponse', (event, response) => {
1659
if (!event.context._isSitemap || !response.body)
1760
return
1861

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') || '')
2067
if (!encoding)
2168
return
2269

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')
2587
setResponseHeader(event, 'Content-Encoding', encoding)
2688
})
2789
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { removeResponseHeader } from 'h3'
2+
import { defineNitroPlugin } from 'nitropack/runtime'
3+
import { logger } from '../../utils-pure'
4+
import { createNodeResponseStream, isReadableStream } from '../sitemap/stream'
5+
6+
export default defineNitroPlugin((nitro) => {
7+
nitro.hooks.hook('beforeResponse', (event, response) => {
8+
const nodeResponse = event.node.res
9+
if (!event.context._isSitemap
10+
|| !isReadableStream(response.body)
11+
// Fetch/edge adapters use a socket-less response shim and can consume the
12+
// Web stream directly. Only H3's live Node transport needs this adapter.
13+
|| !nodeResponse?.socket
14+
|| typeof nodeResponse?.write !== 'function'
15+
|| typeof nodeResponse?.once !== 'function') {
16+
return
17+
}
18+
19+
response.body = createNodeResponseStream(response.body, (error) => {
20+
logger.error('Failed to cancel sitemap response stream after the client disconnected.', error)
21+
})
22+
removeResponseHeader(event, 'Content-Length')
23+
})
24+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { ModuleRuntimeConfig, NitroUrlResolvers, SitemapIndexEntry } from '../../../types'
2+
import { withQuery } from 'ufo'
3+
import { createChunkedXmlStream } from '../stream'
4+
import { escapeValueForXml } from './xml'
5+
6+
export function* renderSitemapIndexXmlChunks(sitemaps: SitemapIndexEntry[], resolvers: NitroUrlResolvers, { version, xsl, credits, minify }: Pick<ModuleRuntimeConfig, 'version' | 'xsl' | 'credits' | 'minify'>, errorInfo?: { messages: string[], urls: string[] }): Generator<string> {
7+
const NL = minify ? '' : '\n'
8+
const I1 = minify ? '' : ' '
9+
const I2 = minify ? '' : ' '
10+
11+
yield '<?xml version="1.0" encoding="UTF-8"?>'
12+
13+
if (xsl) {
14+
let relativeBaseUrl = resolvers.relativeBaseUrlResolver?.(xsl) ?? xsl
15+
if (errorInfo && errorInfo.messages.length > 0) {
16+
relativeBaseUrl = withQuery(relativeBaseUrl, {
17+
errors: 'true',
18+
error_messages: errorInfo.messages,
19+
error_urls: errorInfo.urls,
20+
})
21+
}
22+
yield `${NL}<?xml-stylesheet type="text/xsl" href="${escapeValueForXml(relativeBaseUrl)}"?>`
23+
}
24+
25+
yield `${NL}<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${NL}`
26+
for (let i = 0; i < sitemaps.length; i++) {
27+
const entry = sitemaps[i]!
28+
if (i > 0)
29+
yield NL
30+
yield `${I1}<sitemap>${NL}${I2}<loc>${escapeValueForXml(entry.sitemap)}</loc>${NL}`
31+
if (entry.lastmod)
32+
yield `${I2}<lastmod>${escapeValueForXml(entry.lastmod)}</lastmod>${NL}`
33+
yield `${I1}</sitemap>`
34+
}
35+
yield `${NL}</sitemapindex>`
36+
37+
if (credits)
38+
yield `${NL}<!-- XML Sitemap Index generated by @nuxtjs/sitemap v${version} at ${new Date().toISOString()} -->`
39+
}
40+
41+
export function urlsToIndexXml(sitemaps: SitemapIndexEntry[], resolvers: NitroUrlResolvers, { version, xsl, credits, minify }: Pick<ModuleRuntimeConfig, 'version' | 'xsl' | 'credits' | 'minify'>, errorInfo?: { messages: string[], urls: string[] }) {
42+
const NL = minify ? '' : '\n'
43+
const I1 = minify ? '' : ' '
44+
const I2 = minify ? '' : ' '
45+
let sitemapXml = ''
46+
for (const entry of sitemaps) {
47+
if (sitemapXml)
48+
sitemapXml += NL
49+
sitemapXml += `${I1}<sitemap>${NL}${I2}<loc>${escapeValueForXml(entry.sitemap)}</loc>${NL}`
50+
if (entry.lastmod)
51+
sitemapXml += `${I2}<lastmod>${escapeValueForXml(entry.lastmod)}</lastmod>${NL}`
52+
sitemapXml += `${I1}</sitemap>`
53+
}
54+
55+
const xmlParts = ['<?xml version="1.0" encoding="UTF-8"?>']
56+
57+
if (xsl) {
58+
let relativeBaseUrl = resolvers.relativeBaseUrlResolver?.(xsl) ?? xsl
59+
if (errorInfo && errorInfo.messages.length > 0) {
60+
relativeBaseUrl = withQuery(relativeBaseUrl, {
61+
errors: 'true',
62+
error_messages: errorInfo.messages,
63+
error_urls: errorInfo.urls,
64+
})
65+
}
66+
xmlParts.push(`<?xml-stylesheet type="text/xsl" href="${escapeValueForXml(relativeBaseUrl)}"?>`)
67+
}
68+
69+
xmlParts.push(
70+
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
71+
sitemapXml,
72+
'</sitemapindex>',
73+
)
74+
75+
if (credits)
76+
xmlParts.push(`<!-- XML Sitemap Index generated by @nuxtjs/sitemap v${version} at ${new Date().toISOString()} -->`)
77+
78+
return xmlParts.join(NL)
79+
}
80+
81+
export function urlsToIndexXmlStream(sitemaps: SitemapIndexEntry[], resolvers: NitroUrlResolvers, config: Pick<ModuleRuntimeConfig, 'version' | 'xsl' | 'credits' | 'minify'>, errorInfo?: { messages: string[], urls: string[] }): ReadableStream<Uint8Array> {
82+
return createChunkedXmlStream(renderSitemapIndexXmlChunks(sitemaps, resolvers, config, errorInfo))
83+
}

0 commit comments

Comments
 (0)