@tanstack/router-core 1.157.3 → 1.157.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/ssr/ssr-server.cjs +48 -29
- package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
- package/dist/cjs/ssr/transformStreamWithRouter.cjs +138 -42
- package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
- package/dist/esm/ssr/ssr-server.js +48 -29
- package/dist/esm/ssr/ssr-server.js.map +1 -1
- package/dist/esm/ssr/transformStreamWithRouter.js +138 -42
- package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
- package/package.json +1 -1
- package/src/ssr/ssr-server.ts +69 -32
- package/src/ssr/transformStreamWithRouter.ts +187 -106
package/src/ssr/ssr-server.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { crossSerializeStream, getCrossReferenceHeader } from 'seroval'
|
|
2
2
|
import invariant from 'tiny-invariant'
|
|
3
3
|
import { decodePath } from '../utils'
|
|
4
|
+
import { createLRUCache } from '../lru-cache'
|
|
4
5
|
import minifiedTsrBootStrapScript from './tsrScript?script-string'
|
|
5
6
|
import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants'
|
|
6
7
|
import { defaultSerovalPlugins } from './serializer/seroval-plugins'
|
|
7
8
|
import { makeSsrSerovalPlugin } from './serializer/transformer'
|
|
9
|
+
import type { LRUCache } from '../lru-cache'
|
|
8
10
|
import type { DehydratedMatch, DehydratedRouter } from './types'
|
|
9
11
|
import type { AnySerializationAdapter } from './serializer/transformer'
|
|
10
12
|
import type { AnyRouter } from '../router'
|
|
@@ -28,6 +30,10 @@ declare module '../router' {
|
|
|
28
30
|
|
|
29
31
|
const SCOPE_ID = 'tsr'
|
|
30
32
|
|
|
33
|
+
const TSR_PREFIX = GLOBAL_TSR + '.router='
|
|
34
|
+
const P_PREFIX = GLOBAL_TSR + '.p(()=>'
|
|
35
|
+
const P_SUFFIX = ')'
|
|
36
|
+
|
|
31
37
|
export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch {
|
|
32
38
|
const dehydratedMatch: DehydratedMatch = {
|
|
33
39
|
i: match.id,
|
|
@@ -116,6 +122,10 @@ class ScriptBuffer {
|
|
|
116
122
|
if (bufferedScripts.length === 0) {
|
|
117
123
|
return undefined
|
|
118
124
|
}
|
|
125
|
+
// Optimization: if only one script, avoid join
|
|
126
|
+
if (bufferedScripts.length === 1) {
|
|
127
|
+
return bufferedScripts[0] + ';document.currentScript.remove()'
|
|
128
|
+
}
|
|
119
129
|
// Append cleanup script and join - avoid push() to not mutate then iterate
|
|
120
130
|
return bufferedScripts.join(';') + ';document.currentScript.remove()'
|
|
121
131
|
}
|
|
@@ -137,6 +147,23 @@ class ScriptBuffer {
|
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
const isProd = process.env.NODE_ENV === 'production'
|
|
151
|
+
|
|
152
|
+
type FilteredRoutes = Manifest['routes']
|
|
153
|
+
|
|
154
|
+
type ManifestLRU = LRUCache<string, FilteredRoutes>
|
|
155
|
+
|
|
156
|
+
const MANIFEST_CACHE_SIZE = 100
|
|
157
|
+
const manifestCaches = new WeakMap<Manifest, ManifestLRU>()
|
|
158
|
+
|
|
159
|
+
function getManifestCache(manifest: Manifest): ManifestLRU {
|
|
160
|
+
const cache = manifestCaches.get(manifest)
|
|
161
|
+
if (cache) return cache
|
|
162
|
+
const newCache = createLRUCache<string, FilteredRoutes>(MANIFEST_CACHE_SIZE)
|
|
163
|
+
manifestCaches.set(manifest, newCache)
|
|
164
|
+
return newCache
|
|
165
|
+
}
|
|
166
|
+
|
|
140
167
|
export function attachRouterServerSsrUtils({
|
|
141
168
|
router,
|
|
142
169
|
manifest,
|
|
@@ -152,13 +179,13 @@ export function attachRouterServerSsrUtils({
|
|
|
152
179
|
const renderFinishedListeners: Array<() => void> = []
|
|
153
180
|
const serializationFinishedListeners: Array<() => void> = []
|
|
154
181
|
const scriptBuffer = new ScriptBuffer(router)
|
|
155
|
-
let injectedHtmlBuffer
|
|
182
|
+
let injectedHtmlBuffer = ''
|
|
156
183
|
|
|
157
184
|
router.serverSsr = {
|
|
158
185
|
injectHtml: (html: string) => {
|
|
159
186
|
if (!html) return
|
|
160
187
|
// Buffer the HTML so it can be retrieved via takeBufferedHtml()
|
|
161
|
-
injectedHtmlBuffer
|
|
188
|
+
injectedHtmlBuffer += html
|
|
162
189
|
// Emit event to notify subscribers that new HTML is available
|
|
163
190
|
router.emit({
|
|
164
191
|
type: 'onInjectedHtml',
|
|
@@ -182,31 +209,41 @@ export function attachRouterServerSsrUtils({
|
|
|
182
209
|
// For currently matched routes, send full manifest (preloads + assets)
|
|
183
210
|
// For all other routes, only send assets (no preloads as they are handled via dynamic imports)
|
|
184
211
|
if (manifest) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
212
|
+
// Prod-only caching; in dev manifests may be replaced/updated (HMR)
|
|
213
|
+
const currentRouteIdsList = matchesToDehydrate.map((m) => m.routeId)
|
|
214
|
+
const manifestCacheKey = currentRouteIdsList.join('\0')
|
|
215
|
+
|
|
216
|
+
let filteredRoutes: FilteredRoutes | undefined
|
|
217
|
+
|
|
218
|
+
if (isProd) {
|
|
219
|
+
filteredRoutes = getManifestCache(manifest).get(manifestCacheKey)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!filteredRoutes) {
|
|
223
|
+
const currentRouteIds = new Set(currentRouteIdsList)
|
|
224
|
+
const nextFilteredRoutes: FilteredRoutes = {}
|
|
225
|
+
|
|
226
|
+
for (const routeId in manifest.routes) {
|
|
227
|
+
const routeManifest = manifest.routes[routeId]!
|
|
228
|
+
if (currentRouteIds.has(routeId)) {
|
|
229
|
+
nextFilteredRoutes[routeId] = routeManifest
|
|
230
|
+
} else if (
|
|
231
|
+
routeManifest.assets &&
|
|
232
|
+
routeManifest.assets.length > 0
|
|
233
|
+
) {
|
|
234
|
+
nextFilteredRoutes[routeId] = {
|
|
235
|
+
assets: routeManifest.assets,
|
|
205
236
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (isProd) {
|
|
241
|
+
getManifestCache(manifest).set(manifestCacheKey, nextFilteredRoutes)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
filteredRoutes = nextFilteredRoutes
|
|
245
|
+
}
|
|
246
|
+
|
|
210
247
|
manifestToDehydrate = {
|
|
211
248
|
routes: filteredRoutes,
|
|
212
249
|
}
|
|
@@ -252,9 +289,9 @@ export function attachRouterServerSsrUtils({
|
|
|
252
289
|
refs: new Map(),
|
|
253
290
|
plugins,
|
|
254
291
|
onSerialize: (data, initial) => {
|
|
255
|
-
let serialized = initial ?
|
|
292
|
+
let serialized = initial ? TSR_PREFIX + data : data
|
|
256
293
|
if (trackPlugins.didRun) {
|
|
257
|
-
serialized =
|
|
294
|
+
serialized = P_PREFIX + serialized + P_SUFFIX
|
|
258
295
|
}
|
|
259
296
|
scriptBuffer.enqueue(serialized)
|
|
260
297
|
},
|
|
@@ -310,11 +347,11 @@ export function attachRouterServerSsrUtils({
|
|
|
310
347
|
scriptBuffer.liftBarrier()
|
|
311
348
|
},
|
|
312
349
|
takeBufferedHtml() {
|
|
313
|
-
if (injectedHtmlBuffer
|
|
350
|
+
if (!injectedHtmlBuffer) {
|
|
314
351
|
return undefined
|
|
315
352
|
}
|
|
316
|
-
const buffered = injectedHtmlBuffer
|
|
317
|
-
injectedHtmlBuffer =
|
|
353
|
+
const buffered = injectedHtmlBuffer
|
|
354
|
+
injectedHtmlBuffer = ''
|
|
318
355
|
return buffered
|
|
319
356
|
},
|
|
320
357
|
cleanup() {
|
|
@@ -322,7 +359,7 @@ export function attachRouterServerSsrUtils({
|
|
|
322
359
|
if (!router.serverSsr) return
|
|
323
360
|
renderFinishedListeners.length = 0
|
|
324
361
|
serializationFinishedListeners.length = 0
|
|
325
|
-
injectedHtmlBuffer =
|
|
362
|
+
injectedHtmlBuffer = ''
|
|
326
363
|
scriptBuffer.cleanup()
|
|
327
364
|
router.serverSsr = undefined
|
|
328
365
|
},
|
|
@@ -107,60 +107,136 @@ export function transformStreamWithRouter(
|
|
|
107
107
|
lifetimeMs?: number
|
|
108
108
|
},
|
|
109
109
|
) {
|
|
110
|
-
let stopListeningToInjectedHtml: (() => void) | undefined
|
|
111
|
-
let stopListeningToSerializationFinished: (() => void) | undefined
|
|
112
|
-
let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
113
|
-
let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
114
|
-
let cleanedUp = false
|
|
115
|
-
|
|
116
|
-
let controller: ReadableStreamDefaultController<any>
|
|
117
|
-
let isStreamClosed = false
|
|
118
|
-
|
|
119
110
|
// Check upfront if serialization already finished synchronously
|
|
120
111
|
// This is the fast path for routes with no deferred data
|
|
121
112
|
const serializationAlreadyFinished =
|
|
122
113
|
router.serverSsr?.isSerializationFinished() ?? false
|
|
123
114
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
* Unsubscribes listeners, clears timeouts, frees buffers, and cleans up router SSR state.
|
|
127
|
-
*/
|
|
128
|
-
function cleanup() {
|
|
129
|
-
// Guard against multiple cleanup calls - set flag first to prevent re-entry
|
|
130
|
-
if (cleanedUp) return
|
|
131
|
-
cleanedUp = true
|
|
115
|
+
// Take any HTML that was buffered before we started listening
|
|
116
|
+
const initialBufferedHtml = router.serverSsr?.takeBufferedHtml()
|
|
132
117
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
118
|
+
// True passthrough: if serialization already finished and nothing buffered,
|
|
119
|
+
// we can avoid any decoding/scanning while still honoring cleanup + setRenderFinished.
|
|
120
|
+
if (serializationAlreadyFinished && !initialBufferedHtml) {
|
|
121
|
+
let cleanedUp = false
|
|
122
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
123
|
+
let isStreamClosed = false
|
|
124
|
+
let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
125
|
+
|
|
126
|
+
const cleanup = () => {
|
|
127
|
+
if (cleanedUp) return
|
|
128
|
+
cleanedUp = true
|
|
129
|
+
|
|
130
|
+
if (lifetimeTimeoutHandle !== undefined) {
|
|
131
|
+
clearTimeout(lifetimeTimeoutHandle)
|
|
132
|
+
lifetimeTimeoutHandle = undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
router.serverSsr?.cleanup()
|
|
139
136
|
}
|
|
140
|
-
stopListeningToInjectedHtml = undefined
|
|
141
|
-
stopListeningToSerializationFinished = undefined
|
|
142
137
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
const safeClose = () => {
|
|
139
|
+
if (isStreamClosed) return
|
|
140
|
+
isStreamClosed = true
|
|
141
|
+
try {
|
|
142
|
+
controller?.close()
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
147
146
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
|
|
148
|
+
const safeError = (error: unknown) => {
|
|
149
|
+
if (isStreamClosed) return
|
|
150
|
+
isStreamClosed = true
|
|
151
|
+
try {
|
|
152
|
+
controller?.error(error)
|
|
153
|
+
} catch {
|
|
154
|
+
// ignore
|
|
155
|
+
}
|
|
151
156
|
}
|
|
152
157
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
|
|
159
|
+
lifetimeTimeoutHandle = setTimeout(() => {
|
|
160
|
+
if (!cleanedUp && !isStreamClosed) {
|
|
161
|
+
console.warn(
|
|
162
|
+
`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
|
|
163
|
+
)
|
|
164
|
+
safeError(new Error('Stream lifetime exceeded'))
|
|
165
|
+
cleanup()
|
|
166
|
+
}
|
|
167
|
+
}, lifetimeMs)
|
|
157
168
|
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
170
|
+
start(c) {
|
|
171
|
+
controller = c
|
|
172
|
+
},
|
|
173
|
+
cancel() {
|
|
174
|
+
isStreamClosed = true
|
|
175
|
+
cleanup()
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
;(async () => {
|
|
180
|
+
const reader = appStream.getReader()
|
|
181
|
+
try {
|
|
182
|
+
while (true) {
|
|
183
|
+
const { done, value } = await reader.read()
|
|
184
|
+
if (done) break
|
|
185
|
+
if (cleanedUp || isStreamClosed) return
|
|
186
|
+
controller?.enqueue(value as unknown as Uint8Array)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (cleanedUp || isStreamClosed) return
|
|
190
|
+
|
|
191
|
+
router.serverSsr?.setRenderFinished()
|
|
192
|
+
safeClose()
|
|
193
|
+
cleanup()
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (cleanedUp) return
|
|
196
|
+
console.error('Error reading appStream:', error)
|
|
197
|
+
router.serverSsr?.setRenderFinished()
|
|
198
|
+
safeError(error)
|
|
199
|
+
cleanup()
|
|
200
|
+
} finally {
|
|
201
|
+
reader.releaseLock()
|
|
202
|
+
}
|
|
203
|
+
})().catch((error) => {
|
|
204
|
+
if (cleanedUp) return
|
|
205
|
+
console.error('Error in stream transform:', error)
|
|
206
|
+
safeError(error)
|
|
207
|
+
cleanup()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return stream
|
|
160
211
|
}
|
|
161
212
|
|
|
213
|
+
let stopListeningToInjectedHtml: (() => void) | undefined
|
|
214
|
+
let stopListeningToSerializationFinished: (() => void) | undefined
|
|
215
|
+
let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
216
|
+
let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
|
|
217
|
+
let cleanedUp = false
|
|
218
|
+
|
|
219
|
+
let controller: ReadableStreamDefaultController<any>
|
|
220
|
+
let isStreamClosed = false
|
|
221
|
+
|
|
162
222
|
const textDecoder = new TextDecoder()
|
|
163
223
|
|
|
224
|
+
// concat'd router HTML; avoids array joins on each flush
|
|
225
|
+
let pendingRouterHtml = initialBufferedHtml ?? ''
|
|
226
|
+
|
|
227
|
+
// between-chunk text buffer; keep bounded to avoid unbounded memory
|
|
228
|
+
let leftover = ''
|
|
229
|
+
|
|
230
|
+
// captured closing tags from </body> onward
|
|
231
|
+
let pendingClosingTags = ''
|
|
232
|
+
|
|
233
|
+
// conservative cap: enough to hold any partial closing tag + a bit
|
|
234
|
+
const MAX_LEFTOVER_CHARS = 2048
|
|
235
|
+
|
|
236
|
+
let isAppRendering = true
|
|
237
|
+
let streamBarrierLifted = false
|
|
238
|
+
let serializationFinished = serializationAlreadyFinished
|
|
239
|
+
|
|
164
240
|
function safeEnqueue(chunk: string | Uint8Array) {
|
|
165
241
|
if (isStreamClosed) return
|
|
166
242
|
if (typeof chunk === 'string') {
|
|
@@ -176,7 +252,7 @@ export function transformStreamWithRouter(
|
|
|
176
252
|
try {
|
|
177
253
|
controller.close()
|
|
178
254
|
} catch {
|
|
179
|
-
//
|
|
255
|
+
// ignore
|
|
180
256
|
}
|
|
181
257
|
}
|
|
182
258
|
|
|
@@ -186,10 +262,42 @@ export function transformStreamWithRouter(
|
|
|
186
262
|
try {
|
|
187
263
|
controller.error(error)
|
|
188
264
|
} catch {
|
|
189
|
-
//
|
|
265
|
+
// ignore
|
|
190
266
|
}
|
|
191
267
|
}
|
|
192
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Cleanup with guards; must be idempotent.
|
|
271
|
+
*/
|
|
272
|
+
function cleanup() {
|
|
273
|
+
if (cleanedUp) return
|
|
274
|
+
cleanedUp = true
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
stopListeningToInjectedHtml?.()
|
|
278
|
+
stopListeningToSerializationFinished?.()
|
|
279
|
+
} catch {
|
|
280
|
+
// ignore
|
|
281
|
+
}
|
|
282
|
+
stopListeningToInjectedHtml = undefined
|
|
283
|
+
stopListeningToSerializationFinished = undefined
|
|
284
|
+
|
|
285
|
+
if (serializationTimeoutHandle !== undefined) {
|
|
286
|
+
clearTimeout(serializationTimeoutHandle)
|
|
287
|
+
serializationTimeoutHandle = undefined
|
|
288
|
+
}
|
|
289
|
+
if (lifetimeTimeoutHandle !== undefined) {
|
|
290
|
+
clearTimeout(lifetimeTimeoutHandle)
|
|
291
|
+
lifetimeTimeoutHandle = undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
pendingRouterHtml = ''
|
|
295
|
+
leftover = ''
|
|
296
|
+
pendingClosingTags = ''
|
|
297
|
+
|
|
298
|
+
router.serverSsr?.cleanup()
|
|
299
|
+
}
|
|
300
|
+
|
|
193
301
|
const stream = new ReadableStream({
|
|
194
302
|
start(c) {
|
|
195
303
|
controller = c
|
|
@@ -200,36 +308,24 @@ export function transformStreamWithRouter(
|
|
|
200
308
|
},
|
|
201
309
|
})
|
|
202
310
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
let serializationFinished = serializationAlreadyFinished
|
|
208
|
-
|
|
209
|
-
let pendingRouterHtmlParts: Array<string> = []
|
|
210
|
-
|
|
211
|
-
// Take any HTML that was buffered before we started listening
|
|
212
|
-
const bufferedHtml = router.serverSsr?.takeBufferedHtml()
|
|
213
|
-
if (bufferedHtml) {
|
|
214
|
-
pendingRouterHtmlParts.push(bufferedHtml)
|
|
311
|
+
function flushPendingRouterHtml() {
|
|
312
|
+
if (!pendingRouterHtml) return
|
|
313
|
+
safeEnqueue(pendingRouterHtml)
|
|
314
|
+
pendingRouterHtml = ''
|
|
215
315
|
}
|
|
216
316
|
|
|
217
|
-
function
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
pendingRouterHtmlParts = []
|
|
221
|
-
}
|
|
317
|
+
function appendRouterHtml(html: string) {
|
|
318
|
+
if (!html) return
|
|
319
|
+
pendingRouterHtml += html
|
|
222
320
|
}
|
|
223
321
|
|
|
224
322
|
/**
|
|
225
|
-
*
|
|
323
|
+
* Finish only when app done and serialization complete.
|
|
226
324
|
*/
|
|
227
325
|
function tryFinish() {
|
|
228
|
-
// Can only finish when app is done rendering and serialization is complete
|
|
229
326
|
if (isAppRendering || !serializationFinished) return
|
|
230
327
|
if (cleanedUp || isStreamClosed) return
|
|
231
328
|
|
|
232
|
-
// Clear serialization timeout since we're finishing
|
|
233
329
|
if (serializationTimeoutHandle !== undefined) {
|
|
234
330
|
clearTimeout(serializationTimeoutHandle)
|
|
235
331
|
serializationTimeoutHandle = undefined
|
|
@@ -247,8 +343,7 @@ export function transformStreamWithRouter(
|
|
|
247
343
|
cleanup()
|
|
248
344
|
}
|
|
249
345
|
|
|
250
|
-
//
|
|
251
|
-
// This ensures cleanup happens even if the stream is never consumed or gets stuck
|
|
346
|
+
// Safety net: cleanup even if consumer never reads
|
|
252
347
|
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
|
|
253
348
|
lifetimeTimeoutHandle = setTimeout(() => {
|
|
254
349
|
if (!cleanedUp && !isStreamClosed) {
|
|
@@ -260,27 +355,21 @@ export function transformStreamWithRouter(
|
|
|
260
355
|
}
|
|
261
356
|
}, lifetimeMs)
|
|
262
357
|
|
|
263
|
-
// Only set up listeners if serialization hasn't already finished
|
|
264
|
-
// This avoids unnecessary subscriptions for the common case of no deferred data
|
|
265
358
|
if (!serializationAlreadyFinished) {
|
|
266
|
-
// Listen for injected HTML (for deferred data that resolves later)
|
|
267
359
|
stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {
|
|
268
360
|
if (cleanedUp || isStreamClosed) return
|
|
269
|
-
|
|
270
|
-
// Retrieve buffered HTML
|
|
271
361
|
const html = router.serverSsr?.takeBufferedHtml()
|
|
272
362
|
if (!html) return
|
|
273
363
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
364
|
+
// If we've already captured </body> (pendingClosingTags), we must keep appending
|
|
365
|
+
// so injection stays before the stored closing tags.
|
|
366
|
+
if (isAppRendering || leftover || pendingClosingTags) {
|
|
367
|
+
appendRouterHtml(html)
|
|
277
368
|
} else {
|
|
278
|
-
// App is done rendering, write directly to output
|
|
279
369
|
safeEnqueue(html)
|
|
280
370
|
}
|
|
281
371
|
})
|
|
282
372
|
|
|
283
|
-
// Listen for serialization finished
|
|
284
373
|
stopListeningToSerializationFinished = router.subscribe(
|
|
285
374
|
'onSerializationFinished',
|
|
286
375
|
() => {
|
|
@@ -298,16 +387,16 @@ export function transformStreamWithRouter(
|
|
|
298
387
|
const { done, value } = await reader.read()
|
|
299
388
|
if (done) break
|
|
300
389
|
|
|
301
|
-
// Don't process if already cleaned up
|
|
302
390
|
if (cleanedUp || isStreamClosed) return
|
|
303
391
|
|
|
304
392
|
const text =
|
|
305
393
|
value instanceof Uint8Array
|
|
306
394
|
? textDecoder.decode(value, { stream: true })
|
|
307
395
|
: String(value)
|
|
308
|
-
const chunkString = leftover + text
|
|
309
396
|
|
|
310
|
-
//
|
|
397
|
+
// Fast path: most chunks have no pending left-over.
|
|
398
|
+
const chunkString = leftover ? leftover + text : text
|
|
399
|
+
|
|
311
400
|
if (!streamBarrierLifted) {
|
|
312
401
|
if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {
|
|
313
402
|
streamBarrierLifted = true
|
|
@@ -315,70 +404,63 @@ export function transformStreamWithRouter(
|
|
|
315
404
|
}
|
|
316
405
|
}
|
|
317
406
|
|
|
318
|
-
//
|
|
407
|
+
// If we already saw </body>, everything else is part of tail; buffer it.
|
|
408
|
+
if (pendingClosingTags) {
|
|
409
|
+
pendingClosingTags += chunkString
|
|
410
|
+
leftover = ''
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
|
|
319
414
|
const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)
|
|
320
415
|
const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)
|
|
321
416
|
|
|
322
|
-
// If we have both </body> and </html> in proper order,
|
|
323
|
-
// insert router HTML before </body> and hold the closing tags
|
|
324
417
|
if (
|
|
325
418
|
bodyEndIndex !== -1 &&
|
|
326
419
|
htmlEndIndex !== -1 &&
|
|
327
420
|
bodyEndIndex < htmlEndIndex
|
|
328
421
|
) {
|
|
329
422
|
pendingClosingTags = chunkString.slice(bodyEndIndex)
|
|
330
|
-
|
|
331
423
|
safeEnqueue(chunkString.slice(0, bodyEndIndex))
|
|
332
424
|
flushPendingRouterHtml()
|
|
333
|
-
|
|
334
425
|
leftover = ''
|
|
335
426
|
continue
|
|
336
427
|
}
|
|
337
428
|
|
|
338
|
-
// Handling partial closing tags split across chunks:
|
|
339
|
-
//
|
|
340
|
-
// Since `chunkString = leftover + text`, any incomplete tag fragment from the
|
|
341
|
-
// previous chunk is prepended to the current chunk, allowing split tags like
|
|
342
|
-
// "</di" + "v>" to be re-detected as a complete "</div>" in the combined string.
|
|
343
|
-
//
|
|
344
|
-
// - If a closing tag IS found (lastClosingTagEnd > 0): We enqueue content up to
|
|
345
|
-
// the end of that tag, flush router HTML, and store the remainder in `leftover`.
|
|
346
|
-
// This remainder may contain a partial tag (e.g., "</sp") which will be
|
|
347
|
-
// prepended to the next chunk for re-detection.
|
|
348
|
-
//
|
|
349
|
-
// - If NO closing tag is found: The entire chunk is buffered in `leftover` and
|
|
350
|
-
// will be prepended to the next chunk. This ensures partial tags are never
|
|
351
|
-
// lost and will be detected once the rest of the tag arrives.
|
|
352
|
-
//
|
|
353
|
-
// This approach guarantees correct injection points even when closing tags span
|
|
354
|
-
// chunk boundaries.
|
|
355
429
|
const lastClosingTagEnd = findLastClosingTagEnd(chunkString)
|
|
356
430
|
|
|
357
431
|
if (lastClosingTagEnd > 0) {
|
|
358
|
-
// Found a closing tag - insert router HTML after it
|
|
359
432
|
safeEnqueue(chunkString.slice(0, lastClosingTagEnd))
|
|
360
433
|
flushPendingRouterHtml()
|
|
361
434
|
|
|
362
435
|
leftover = chunkString.slice(lastClosingTagEnd)
|
|
436
|
+
if (leftover.length > MAX_LEFTOVER_CHARS) {
|
|
437
|
+
// Ensure bounded memory even if a consumer streams long text sequences
|
|
438
|
+
// without any closing tags. This may reduce injection granularity but is correct.
|
|
439
|
+
safeEnqueue(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS))
|
|
440
|
+
leftover = leftover.slice(-MAX_LEFTOVER_CHARS)
|
|
441
|
+
}
|
|
363
442
|
} else {
|
|
364
|
-
// No closing tag found
|
|
365
|
-
|
|
366
|
-
|
|
443
|
+
// No closing tag found; keep small tail to handle split closing tags,
|
|
444
|
+
// but stream older bytes to prevent unbounded buffering.
|
|
445
|
+
const combined = chunkString
|
|
446
|
+
if (combined.length > MAX_LEFTOVER_CHARS) {
|
|
447
|
+
const flushUpto = combined.length - MAX_LEFTOVER_CHARS
|
|
448
|
+
safeEnqueue(combined.slice(0, flushUpto))
|
|
449
|
+
leftover = combined.slice(flushUpto)
|
|
450
|
+
} else {
|
|
451
|
+
leftover = combined
|
|
452
|
+
}
|
|
367
453
|
}
|
|
368
454
|
}
|
|
369
455
|
|
|
370
|
-
// Stream ended
|
|
371
456
|
if (cleanedUp || isStreamClosed) return
|
|
372
457
|
|
|
373
|
-
// Mark the app as done rendering
|
|
374
458
|
isAppRendering = false
|
|
375
459
|
router.serverSsr?.setRenderFinished()
|
|
376
460
|
|
|
377
|
-
// Try to finish if serialization is already done
|
|
378
461
|
if (serializationFinished) {
|
|
379
462
|
tryFinish()
|
|
380
463
|
} else {
|
|
381
|
-
// Set a timeout for serialization to complete
|
|
382
464
|
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
|
|
383
465
|
serializationTimeoutHandle = setTimeout(() => {
|
|
384
466
|
if (!cleanedUp && !isStreamClosed) {
|
|
@@ -401,7 +483,6 @@ export function transformStreamWithRouter(
|
|
|
401
483
|
reader.releaseLock()
|
|
402
484
|
}
|
|
403
485
|
})().catch((error) => {
|
|
404
|
-
// Handle any errors that occur outside the try block (e.g., getReader() failure)
|
|
405
486
|
if (cleanedUp) return
|
|
406
487
|
console.error('Error in stream transform:', error)
|
|
407
488
|
safeError(error)
|