@tanstack/router-core 1.142.6 → 1.142.8

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.
@@ -1,6 +1,6 @@
1
1
  import { ReadableStream } from 'node:stream/web'
2
2
  import { Readable } from 'node:stream'
3
- import { createControlledPromise } from '../utils'
3
+ import { TSR_SCRIPT_BARRIER_ID } from './constants'
4
4
  import type { AnyRouter } from '../router'
5
5
 
6
6
  export function transformReadableStreamWithRouter(
@@ -19,316 +19,394 @@ export function transformPipeableStreamWithRouter(
19
19
  )
20
20
  }
21
21
 
22
- export const TSR_SCRIPT_BARRIER_ID = '$tsr-stream-barrier'
23
-
24
- // regex pattern for matching closing body and html tags
25
- const patternBodyEnd = /(<\/body>)/
26
- const patternHtmlEnd = /(<\/html>)/
27
- // regex pattern for matching closing tags
28
- const patternClosingTag = /(<\/[a-zA-Z][\w:.-]*?>)/g
29
-
30
- type ReadablePassthrough = {
31
- stream: ReadableStream
32
- write: (chunk: unknown) => void
33
- end: (chunk?: string) => void
34
- destroy: (error: unknown) => void
35
- destroyed: boolean
36
- }
37
-
38
- function createPassthrough(onCancel: () => void) {
39
- let controller: ReadableStreamDefaultController<any>
40
- const encoder = new TextEncoder()
41
- const stream = new ReadableStream({
42
- start(c) {
43
- controller = c
44
- },
45
- cancel() {
46
- res.destroyed = true
47
- onCancel()
48
- },
49
- })
50
-
51
- const res: ReadablePassthrough = {
52
- stream,
53
- write: (chunk) => {
54
- // Don't write to destroyed stream
55
- if (res.destroyed) return
56
- if (typeof chunk === 'string') {
57
- controller.enqueue(encoder.encode(chunk))
58
- } else {
59
- controller.enqueue(chunk)
60
- }
61
- },
62
- end: (chunk) => {
63
- // Don't end already destroyed stream
64
- if (res.destroyed) return
65
- if (chunk) {
66
- res.write(chunk)
22
+ // Use string constants for simple indexOf matching
23
+ const BODY_END_TAG = '</body>'
24
+ const HTML_END_TAG = '</html>'
25
+
26
+ // Minimum length of a valid closing tag: </a> = 4 characters
27
+ const MIN_CLOSING_TAG_LENGTH = 4
28
+
29
+ // Default timeout values (in milliseconds)
30
+ const DEFAULT_SERIALIZATION_TIMEOUT_MS = 60000
31
+ const DEFAULT_LIFETIME_TIMEOUT_MS = 60000
32
+
33
+ // Module-level encoder (stateless, safe to reuse)
34
+ const textEncoder = new TextEncoder()
35
+
36
+ /**
37
+ * Finds the position just after the last valid HTML closing tag in the string.
38
+ *
39
+ * Valid closing tags match the pattern: </[a-zA-Z][\w:.-]*>
40
+ * Examples: </div>, </my-component>, </slot:name.nested>
41
+ *
42
+ * @returns Position after the last closing tag, or -1 if none found
43
+ */
44
+ function findLastClosingTagEnd(str: string): number {
45
+ const len = str.length
46
+ if (len < MIN_CLOSING_TAG_LENGTH) return -1
47
+
48
+ let i = len - 1
49
+
50
+ while (i >= MIN_CLOSING_TAG_LENGTH - 1) {
51
+ // Look for > (charCode 62)
52
+ if (str.charCodeAt(i) === 62) {
53
+ // Look backwards for valid tag name characters
54
+ let j = i - 1
55
+
56
+ // Skip through valid tag name characters
57
+ while (j >= 1) {
58
+ const code = str.charCodeAt(j)
59
+ // Check if it's a valid tag name char: [a-zA-Z0-9_:.-]
60
+ if (
61
+ (code >= 97 && code <= 122) || // a-z
62
+ (code >= 65 && code <= 90) || // A-Z
63
+ (code >= 48 && code <= 57) || // 0-9
64
+ code === 95 || // _
65
+ code === 58 || // :
66
+ code === 46 || // .
67
+ code === 45 // -
68
+ ) {
69
+ j--
70
+ } else {
71
+ break
72
+ }
67
73
  }
68
- res.destroyed = true
69
- controller.close()
70
- },
71
- destroy: (error) => {
72
- // Don't destroy already destroyed stream
73
- if (res.destroyed) return
74
- res.destroyed = true
75
- controller.error(error)
76
- },
77
- destroyed: false,
78
- }
79
74
 
80
- return res
81
- }
82
-
83
- async function readStream(
84
- stream: ReadableStream,
85
- opts: {
86
- onData?: (chunk: ReadableStreamReadValueResult<any>) => void
87
- onEnd?: () => void
88
- onError?: (error: unknown) => void
89
- },
90
- ) {
91
- const reader = stream.getReader()
92
- try {
93
- let chunk
94
- while (!(chunk = await reader.read()).done) {
95
- opts.onData?.(chunk)
75
+ // Check if the first char after </ is a valid start char (letter only)
76
+ const tagNameStart = j + 1
77
+ if (tagNameStart < i) {
78
+ const startCode = str.charCodeAt(tagNameStart)
79
+ // Tag name must start with a letter (a-z or A-Z)
80
+ if (
81
+ (startCode >= 97 && startCode <= 122) ||
82
+ (startCode >= 65 && startCode <= 90)
83
+ ) {
84
+ // Check for </ (charCodes: < = 60, / = 47)
85
+ if (
86
+ j >= 1 &&
87
+ str.charCodeAt(j) === 47 &&
88
+ str.charCodeAt(j - 1) === 60
89
+ ) {
90
+ return i + 1 // Return position after the closing >
91
+ }
92
+ }
93
+ }
96
94
  }
97
- opts.onEnd?.()
98
- } catch (error) {
99
- opts.onError?.(error)
100
- } finally {
101
- reader.releaseLock()
95
+ i--
102
96
  }
97
+ return -1
103
98
  }
104
99
 
105
100
  export function transformStreamWithRouter(
106
101
  router: AnyRouter,
107
102
  appStream: ReadableStream,
108
103
  opts?: {
104
+ /** Timeout for serialization to complete after app render finishes (default: 60000ms) */
109
105
  timeoutMs?: number
106
+ /** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */
107
+ lifetimeMs?: number
110
108
  },
111
109
  ) {
112
- let stopListeningToInjectedHtml: (() => void) | undefined = undefined
113
- let timeoutHandle: NodeJS.Timeout
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
114
  let cleanedUp = false
115
115
 
116
+ let controller: ReadableStreamDefaultController<any>
117
+ let isStreamClosed = false
118
+
119
+ // Check upfront if serialization already finished synchronously
120
+ // This is the fast path for routes with no deferred data
121
+ const serializationAlreadyFinished =
122
+ router.serverSsr?.isSerializationFinished() ?? false
123
+
124
+ /**
125
+ * Cleanup function with guards against multiple calls.
126
+ * Unsubscribes listeners, clears timeouts, frees buffers, and cleans up router SSR state.
127
+ */
116
128
  function cleanup() {
129
+ // Guard against multiple cleanup calls - set flag first to prevent re-entry
117
130
  if (cleanedUp) return
118
131
  cleanedUp = true
119
- if (stopListeningToInjectedHtml) {
120
- stopListeningToInjectedHtml()
121
- stopListeningToInjectedHtml = undefined
132
+
133
+ // Unsubscribe listeners first (wrap in try-catch for safety)
134
+ try {
135
+ stopListeningToInjectedHtml?.()
136
+ stopListeningToSerializationFinished?.()
137
+ } catch (e) {
138
+ // Ignore errors during unsubscription
139
+ }
140
+ stopListeningToInjectedHtml = undefined
141
+ stopListeningToSerializationFinished = undefined
142
+
143
+ // Clear all timeouts
144
+ if (serializationTimeoutHandle !== undefined) {
145
+ clearTimeout(serializationTimeoutHandle)
146
+ serializationTimeoutHandle = undefined
147
+ }
148
+ if (lifetimeTimeoutHandle !== undefined) {
149
+ clearTimeout(lifetimeTimeoutHandle)
150
+ lifetimeTimeoutHandle = undefined
122
151
  }
123
- clearTimeout(timeoutHandle)
152
+
153
+ // Clear buffers to free memory
154
+ pendingRouterHtmlParts = []
155
+ leftover = ''
156
+ pendingClosingTags = ''
157
+
158
+ // Clean up router SSR state (has its own guard)
124
159
  router.serverSsr?.cleanup()
125
160
  }
126
161
 
127
- const finalPassThrough = createPassthrough(cleanup)
128
162
  const textDecoder = new TextDecoder()
129
163
 
130
- let isAppRendering = true
131
- let routerStreamBuffer = ''
132
- let pendingClosingTags = ''
133
- let streamBarrierLifted = false
134
- let leftover = ''
135
- let leftoverHtml = ''
136
-
137
- function getBufferedRouterStream() {
138
- const html = routerStreamBuffer
139
- routerStreamBuffer = ''
140
- return html
164
+ function safeEnqueue(chunk: string | Uint8Array) {
165
+ if (isStreamClosed) return
166
+ if (typeof chunk === 'string') {
167
+ controller.enqueue(textEncoder.encode(chunk))
168
+ } else {
169
+ controller.enqueue(chunk)
170
+ }
141
171
  }
142
172
 
143
- function decodeChunk(chunk: unknown): string {
144
- if (chunk instanceof Uint8Array) {
145
- return textDecoder.decode(chunk, { stream: true })
173
+ function safeClose() {
174
+ if (isStreamClosed) return
175
+ isStreamClosed = true
176
+ try {
177
+ controller.close()
178
+ } catch {
179
+ // Stream may already be errored or closed by consumer - safe to ignore
146
180
  }
147
- return String(chunk)
148
181
  }
149
182
 
150
- const injectedHtmlDonePromise = createControlledPromise<void>()
151
-
152
- let processingCount = 0
183
+ function safeError(error: unknown) {
184
+ if (isStreamClosed) return
185
+ isStreamClosed = true
186
+ try {
187
+ controller.error(error)
188
+ } catch {
189
+ // Stream may already be errored or closed by consumer - safe to ignore
190
+ }
191
+ }
153
192
 
154
- // Process any already-injected HTML
155
- handleInjectedHtml()
193
+ const stream = new ReadableStream({
194
+ start(c) {
195
+ controller = c
196
+ },
197
+ cancel() {
198
+ isStreamClosed = true
199
+ cleanup()
200
+ },
201
+ })
156
202
 
157
- // Listen for any new injected HTML
158
- stopListeningToInjectedHtml = router.subscribe(
159
- 'onInjectedHtml',
160
- handleInjectedHtml,
161
- )
203
+ let isAppRendering = true
204
+ let streamBarrierLifted = false
205
+ let leftover = ''
206
+ let pendingClosingTags = ''
207
+ let serializationFinished = serializationAlreadyFinished
162
208
 
163
- function handleInjectedHtml() {
164
- // Don't process if already cleaned up
165
- if (cleanedUp) return
209
+ let pendingRouterHtmlParts: Array<string> = []
166
210
 
167
- router.serverSsr!.injectedHtml.forEach((promise) => {
168
- processingCount++
211
+ // Take any HTML that was buffered before we started listening
212
+ const bufferedHtml = router.serverSsr?.takeBufferedHtml()
213
+ if (bufferedHtml) {
214
+ pendingRouterHtmlParts.push(bufferedHtml)
215
+ }
169
216
 
170
- promise
171
- .then((html) => {
172
- // Don't write to destroyed stream or after cleanup
173
- if (cleanedUp || finalPassThrough.destroyed) {
174
- return
175
- }
176
- if (isAppRendering) {
177
- routerStreamBuffer += html
178
- } else {
179
- finalPassThrough.write(html)
180
- }
181
- })
182
- .catch((err) => {
183
- injectedHtmlDonePromise.reject(err)
184
- })
185
- .finally(() => {
186
- processingCount--
187
-
188
- if (!isAppRendering && processingCount === 0) {
189
- injectedHtmlDonePromise.resolve()
190
- }
191
- })
192
- })
193
- router.serverSsr!.injectedHtml = []
217
+ function flushPendingRouterHtml() {
218
+ if (pendingRouterHtmlParts.length > 0) {
219
+ safeEnqueue(pendingRouterHtmlParts.join(''))
220
+ pendingRouterHtmlParts = []
221
+ }
194
222
  }
195
223
 
196
- injectedHtmlDonePromise
197
- .then(() => {
198
- // Don't process if already cleaned up or destroyed
199
- if (cleanedUp || finalPassThrough.destroyed) {
200
- return
201
- }
224
+ /**
225
+ * Attempts to finish the stream if all conditions are met.
226
+ */
227
+ function tryFinish() {
228
+ // Can only finish when app is done rendering and serialization is complete
229
+ if (isAppRendering || !serializationFinished) return
230
+ if (cleanedUp || isStreamClosed) return
231
+
232
+ // Clear serialization timeout since we're finishing
233
+ if (serializationTimeoutHandle !== undefined) {
234
+ clearTimeout(serializationTimeoutHandle)
235
+ serializationTimeoutHandle = undefined
236
+ }
202
237
 
203
- clearTimeout(timeoutHandle)
204
- const finalHtml =
205
- leftover + leftoverHtml + getBufferedRouterStream() + pendingClosingTags
238
+ // Flush any remaining bytes in the TextDecoder
239
+ const decoderRemainder = textDecoder.decode()
206
240
 
207
- leftover = ''
208
- leftoverHtml = ''
209
- pendingClosingTags = ''
241
+ if (leftover) safeEnqueue(leftover)
242
+ if (decoderRemainder) safeEnqueue(decoderRemainder)
243
+ flushPendingRouterHtml()
244
+ if (pendingClosingTags) safeEnqueue(pendingClosingTags)
210
245
 
211
- finalPassThrough.end(finalHtml)
212
- })
213
- .catch((err) => {
214
- // Don't process if already cleaned up
215
- if (cleanedUp || finalPassThrough.destroyed) {
216
- return
217
- }
246
+ safeClose()
247
+ cleanup()
248
+ }
218
249
 
219
- console.error('Error reading routerStream:', err)
220
- finalPassThrough.destroy(err)
250
+ // Set up lifetime timeout as a safety net
251
+ // This ensures cleanup happens even if the stream is never consumed or gets stuck
252
+ const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
253
+ lifetimeTimeoutHandle = setTimeout(() => {
254
+ if (!cleanedUp && !isStreamClosed) {
255
+ console.warn(
256
+ `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
257
+ )
258
+ safeError(new Error('Stream lifetime exceeded'))
259
+ cleanup()
260
+ }
261
+ }, lifetimeMs)
262
+
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
+ if (!serializationAlreadyFinished) {
266
+ // Listen for injected HTML (for deferred data that resolves later)
267
+ stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {
268
+ if (cleanedUp || isStreamClosed) return
269
+
270
+ // Retrieve buffered HTML
271
+ const html = router.serverSsr?.takeBufferedHtml()
272
+ if (!html) return
273
+
274
+ if (isAppRendering) {
275
+ // Buffer for insertion at next valid position
276
+ pendingRouterHtmlParts.push(html)
277
+ } else {
278
+ // App is done rendering, write directly to output
279
+ safeEnqueue(html)
280
+ }
221
281
  })
222
- .finally(cleanup)
223
282
 
224
- // Transform the appStream
225
- readStream(appStream, {
226
- onData: (chunk) => {
227
- // Don't process if already cleaned up
228
- if (cleanedUp || finalPassThrough.destroyed) {
229
- return
230
- }
283
+ // Listen for serialization finished
284
+ stopListeningToSerializationFinished = router.subscribe(
285
+ 'onSerializationFinished',
286
+ () => {
287
+ serializationFinished = true
288
+ tryFinish()
289
+ },
290
+ )
291
+ }
231
292
 
232
- const text = decodeChunk(chunk.value)
233
- const chunkString = leftover + text
234
- const bodyEndMatch = chunkString.match(patternBodyEnd)
235
- const htmlEndMatch = chunkString.match(patternHtmlEnd)
236
-
237
- if (!streamBarrierLifted) {
238
- const streamBarrierIdIncluded = chunkString.includes(
239
- TSR_SCRIPT_BARRIER_ID,
240
- )
241
- if (streamBarrierIdIncluded) {
242
- streamBarrierLifted = true
243
- router.serverSsr!.liftScriptBarrier()
293
+ // Transform the appStream
294
+ ;(async () => {
295
+ const reader = appStream.getReader()
296
+ try {
297
+ while (true) {
298
+ const { done, value } = await reader.read()
299
+ if (done) break
300
+
301
+ // Don't process if already cleaned up
302
+ if (cleanedUp || isStreamClosed) return
303
+
304
+ const text =
305
+ value instanceof Uint8Array
306
+ ? textDecoder.decode(value, { stream: true })
307
+ : String(value)
308
+ const chunkString = leftover + text
309
+
310
+ // Check for stream barrier (script placeholder) - use indexOf for efficiency
311
+ if (!streamBarrierLifted) {
312
+ if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {
313
+ streamBarrierLifted = true
314
+ router.serverSsr?.liftScriptBarrier()
315
+ }
244
316
  }
245
- }
246
317
 
247
- // If either the body end or html end is in the chunk,
248
- // We need to get all of our data in asap
249
- if (
250
- bodyEndMatch &&
251
- htmlEndMatch &&
252
- bodyEndMatch.index! < htmlEndMatch.index!
253
- ) {
254
- const bodyEndIndex = bodyEndMatch.index!
255
- pendingClosingTags = chunkString.slice(bodyEndIndex)
256
-
257
- finalPassThrough.write(
258
- chunkString.slice(0, bodyEndIndex) +
259
- getBufferedRouterStream() +
260
- leftoverHtml,
261
- )
262
-
263
- leftover = ''
264
- leftoverHtml = ''
265
- return
266
- }
318
+ // Check for body/html end tags
319
+ const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)
320
+ const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)
267
321
 
268
- let result: RegExpExecArray | null
269
- let lastIndex = 0
270
- // Reset regex lastIndex since it's global and stateful across exec() calls
271
- patternClosingTag.lastIndex = 0
272
- while ((result = patternClosingTag.exec(chunkString)) !== null) {
273
- lastIndex = result.index + result[0].length
274
- }
322
+ // If we have both </body> and </html> in proper order,
323
+ // insert router HTML before </body> and hold the closing tags
324
+ if (
325
+ bodyEndIndex !== -1 &&
326
+ htmlEndIndex !== -1 &&
327
+ bodyEndIndex < htmlEndIndex
328
+ ) {
329
+ pendingClosingTags = chunkString.slice(bodyEndIndex)
275
330
 
276
- if (lastIndex > 0) {
277
- const processed =
278
- chunkString.slice(0, lastIndex) +
279
- getBufferedRouterStream() +
280
- leftoverHtml
331
+ safeEnqueue(chunkString.slice(0, bodyEndIndex))
332
+ flushPendingRouterHtml()
281
333
 
282
- finalPassThrough.write(processed)
283
- leftover = chunkString.slice(lastIndex)
284
- leftoverHtml = ''
285
- } else {
286
- leftover = chunkString
287
- leftoverHtml += getBufferedRouterStream()
288
- }
289
- },
290
- onEnd: () => {
291
- // Don't process if stream was already destroyed/cancelled or cleaned up
292
- if (cleanedUp || finalPassThrough.destroyed) {
293
- return
334
+ leftover = ''
335
+ continue
336
+ }
337
+
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
+ const lastClosingTagEnd = findLastClosingTagEnd(chunkString)
356
+
357
+ if (lastClosingTagEnd > 0) {
358
+ // Found a closing tag - insert router HTML after it
359
+ safeEnqueue(chunkString.slice(0, lastClosingTagEnd))
360
+ flushPendingRouterHtml()
361
+
362
+ leftover = chunkString.slice(lastClosingTagEnd)
363
+ } else {
364
+ // No closing tag found - buffer the entire chunk
365
+ leftover = chunkString
366
+ // Any pending router HTML will be inserted when we find a valid position
367
+ }
294
368
  }
295
369
 
370
+ // Stream ended
371
+ if (cleanedUp || isStreamClosed) return
372
+
296
373
  // Mark the app as done rendering
297
374
  isAppRendering = false
298
- router.serverSsr!.setRenderFinished()
375
+ router.serverSsr?.setRenderFinished()
299
376
 
300
- // If there are no pending promises, resolve the injectedHtmlDonePromise
301
- if (processingCount === 0) {
302
- injectedHtmlDonePromise.resolve()
377
+ // Try to finish if serialization is already done
378
+ if (serializationFinished) {
379
+ tryFinish()
303
380
  } else {
304
- const timeoutMs = opts?.timeoutMs ?? 60000
305
- timeoutHandle = setTimeout(() => {
306
- injectedHtmlDonePromise.reject(
307
- new Error('Injected HTML timeout after app render finished'),
308
- )
381
+ // Set a timeout for serialization to complete
382
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
383
+ serializationTimeoutHandle = setTimeout(() => {
384
+ if (!cleanedUp && !isStreamClosed) {
385
+ console.error('Serialization timeout after app render finished')
386
+ safeError(
387
+ new Error('Serialization timeout after app render finished'),
388
+ )
389
+ cleanup()
390
+ }
309
391
  }, timeoutMs)
310
392
  }
311
- },
312
- onError: (error) => {
313
- // Don't process if already cleaned up
314
- if (cleanedUp) {
315
- return
316
- }
317
-
393
+ } catch (error) {
394
+ if (cleanedUp) return
318
395
  console.error('Error reading appStream:', error)
319
396
  isAppRendering = false
320
- router.serverSsr!.setRenderFinished()
321
- // Clear timeout to prevent it from firing after error
322
- clearTimeout(timeoutHandle)
323
- // Clear string buffers to prevent memory leaks
324
- leftover = ''
325
- leftoverHtml = ''
326
- routerStreamBuffer = ''
327
- pendingClosingTags = ''
328
- finalPassThrough.destroy(error)
329
- injectedHtmlDonePromise.reject(error)
330
- },
397
+ router.serverSsr?.setRenderFinished()
398
+ safeError(error)
399
+ cleanup()
400
+ } finally {
401
+ reader.releaseLock()
402
+ }
403
+ })().catch((error) => {
404
+ // Handle any errors that occur outside the try block (e.g., getReader() failure)
405
+ if (cleanedUp) return
406
+ console.error('Error in stream transform:', error)
407
+ safeError(error)
408
+ cleanup()
331
409
  })
332
410
 
333
- return finalPassThrough.stream
411
+ return stream
334
412
  }