@tanstack/router-core 1.171.5 → 1.171.7

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.
Files changed (107) hide show
  1. package/dist/cjs/Matches.cjs.map +1 -1
  2. package/dist/cjs/config.cjs.map +1 -1
  3. package/dist/cjs/defer.cjs.map +1 -1
  4. package/dist/cjs/index.cjs +5 -1
  5. package/dist/cjs/index.d.cts +2 -2
  6. package/dist/cjs/invariant.cjs.map +1 -1
  7. package/dist/cjs/load-matches.cjs.map +1 -1
  8. package/dist/cjs/lru-cache.cjs.map +1 -1
  9. package/dist/cjs/manifest.cjs +43 -17
  10. package/dist/cjs/manifest.cjs.map +1 -1
  11. package/dist/cjs/manifest.d.cts +76 -24
  12. package/dist/cjs/new-process-route-tree.cjs.map +1 -1
  13. package/dist/cjs/not-found.cjs.map +1 -1
  14. package/dist/cjs/path.cjs.map +1 -1
  15. package/dist/cjs/qss.cjs.map +1 -1
  16. package/dist/cjs/redirect.cjs.map +1 -1
  17. package/dist/cjs/rewrite.cjs.map +1 -1
  18. package/dist/cjs/route.cjs.map +1 -1
  19. package/dist/cjs/router.cjs.map +1 -1
  20. package/dist/cjs/router.d.cts +31 -16
  21. package/dist/cjs/scroll-restoration-script/client.cjs.map +1 -1
  22. package/dist/cjs/scroll-restoration-script/server.cjs.map +1 -1
  23. package/dist/cjs/scroll-restoration.cjs.map +1 -1
  24. package/dist/cjs/searchMiddleware.cjs.map +1 -1
  25. package/dist/cjs/searchParams.cjs.map +1 -1
  26. package/dist/cjs/ssr/createRequestHandler.cjs +10 -8
  27. package/dist/cjs/ssr/createRequestHandler.cjs.map +1 -1
  28. package/dist/cjs/ssr/createRequestHandler.d.cts +2 -2
  29. package/dist/cjs/ssr/handlerCallback.cjs +46 -0
  30. package/dist/cjs/ssr/handlerCallback.cjs.map +1 -1
  31. package/dist/cjs/ssr/handlerCallback.d.cts +15 -1
  32. package/dist/cjs/ssr/headers.cjs.map +1 -1
  33. package/dist/cjs/ssr/json.cjs.map +1 -1
  34. package/dist/cjs/ssr/serializer/RawStream.cjs.map +1 -1
  35. package/dist/cjs/ssr/serializer/ShallowErrorPlugin.cjs.map +1 -1
  36. package/dist/cjs/ssr/serializer/seroval-plugins.cjs.map +1 -1
  37. package/dist/cjs/ssr/serializer/transformer.cjs.map +1 -1
  38. package/dist/cjs/ssr/server.cjs +6 -1
  39. package/dist/cjs/ssr/server.d.cts +3 -2
  40. package/dist/cjs/ssr/ssr-client.cjs.map +1 -1
  41. package/dist/cjs/ssr/ssr-match-id.cjs.map +1 -1
  42. package/dist/cjs/ssr/ssr-server.cjs +263 -132
  43. package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
  44. package/dist/cjs/ssr/ssr-server.d.cts +4 -19
  45. package/dist/cjs/ssr/transformStreamWithRouter.cjs +455 -203
  46. package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
  47. package/dist/cjs/ssr/transformStreamWithRouter.d.cts +14 -5
  48. package/dist/cjs/stores.cjs.map +1 -1
  49. package/dist/cjs/utils.cjs.map +1 -1
  50. package/dist/esm/Matches.js.map +1 -1
  51. package/dist/esm/config.js.map +1 -1
  52. package/dist/esm/defer.js.map +1 -1
  53. package/dist/esm/index.d.ts +2 -2
  54. package/dist/esm/index.js +2 -2
  55. package/dist/esm/invariant.js.map +1 -1
  56. package/dist/esm/load-matches.js.map +1 -1
  57. package/dist/esm/lru-cache.js.map +1 -1
  58. package/dist/esm/manifest.d.ts +76 -24
  59. package/dist/esm/manifest.js +39 -17
  60. package/dist/esm/manifest.js.map +1 -1
  61. package/dist/esm/new-process-route-tree.js.map +1 -1
  62. package/dist/esm/not-found.js.map +1 -1
  63. package/dist/esm/path.js.map +1 -1
  64. package/dist/esm/qss.js.map +1 -1
  65. package/dist/esm/redirect.js.map +1 -1
  66. package/dist/esm/rewrite.js.map +1 -1
  67. package/dist/esm/route.js.map +1 -1
  68. package/dist/esm/router.d.ts +31 -16
  69. package/dist/esm/router.js.map +1 -1
  70. package/dist/esm/scroll-restoration-script/client.js.map +1 -1
  71. package/dist/esm/scroll-restoration-script/server.js.map +1 -1
  72. package/dist/esm/scroll-restoration.js.map +1 -1
  73. package/dist/esm/searchMiddleware.js.map +1 -1
  74. package/dist/esm/searchParams.js.map +1 -1
  75. package/dist/esm/ssr/createRequestHandler.d.ts +2 -2
  76. package/dist/esm/ssr/createRequestHandler.js +10 -8
  77. package/dist/esm/ssr/createRequestHandler.js.map +1 -1
  78. package/dist/esm/ssr/handlerCallback.d.ts +15 -1
  79. package/dist/esm/ssr/handlerCallback.js +42 -1
  80. package/dist/esm/ssr/handlerCallback.js.map +1 -1
  81. package/dist/esm/ssr/headers.js.map +1 -1
  82. package/dist/esm/ssr/json.js.map +1 -1
  83. package/dist/esm/ssr/serializer/RawStream.js.map +1 -1
  84. package/dist/esm/ssr/serializer/ShallowErrorPlugin.js.map +1 -1
  85. package/dist/esm/ssr/serializer/seroval-plugins.js.map +1 -1
  86. package/dist/esm/ssr/serializer/transformer.js.map +1 -1
  87. package/dist/esm/ssr/server.d.ts +3 -2
  88. package/dist/esm/ssr/server.js +2 -2
  89. package/dist/esm/ssr/ssr-client.js.map +1 -1
  90. package/dist/esm/ssr/ssr-match-id.js.map +1 -1
  91. package/dist/esm/ssr/ssr-server.d.ts +4 -19
  92. package/dist/esm/ssr/ssr-server.js +264 -133
  93. package/dist/esm/ssr/ssr-server.js.map +1 -1
  94. package/dist/esm/ssr/transformStreamWithRouter.d.ts +14 -5
  95. package/dist/esm/ssr/transformStreamWithRouter.js +455 -203
  96. package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
  97. package/dist/esm/stores.js.map +1 -1
  98. package/dist/esm/utils.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/index.ts +21 -1
  101. package/src/manifest.ts +151 -59
  102. package/src/router.ts +37 -19
  103. package/src/ssr/createRequestHandler.ts +14 -13
  104. package/src/ssr/handlerCallback.ts +84 -1
  105. package/src/ssr/server.ts +14 -2
  106. package/src/ssr/ssr-server.ts +418 -222
  107. package/src/ssr/transformStreamWithRouter.ts +662 -281
@@ -3,264 +3,492 @@ import { Readable } from 'node:stream'
3
3
  import { TSR_SCRIPT_BARRIER_ID } from './constants'
4
4
  import type { AnyRouter } from '../router'
5
5
 
6
+ export type TransformStreamWithRouterOptions = {
7
+ /** Timeout for serialization to complete after app render finishes (default: 60000ms) */
8
+ timeoutMs?: number
9
+ /** Maximum lifetime of the stream transform (default: 120000ms). Safety net for cleanup. */
10
+ lifetimeMs?: number
11
+ /**
12
+ * Called exactly once when the stream is torn down due to abort/error/
13
+ * cancel/timeout — NOT on natural successful completion. Use this to
14
+ * abort a hidden producer upstream of any PassThrough you passed in
15
+ * (e.g. React `renderToPipeableStream`'s `abort()`).
16
+ * Errors thrown from this callback are swallowed.
17
+ */
18
+ onAbort?: (reason?: unknown) => void
19
+ }
20
+
6
21
  export function transformReadableStreamWithRouter(
7
22
  router: AnyRouter,
8
23
  routerStream: ReadableStream,
24
+ opts?: TransformStreamWithRouterOptions,
9
25
  ) {
10
- return transformStreamWithRouter(router, routerStream)
26
+ return transformStreamWithRouter(router, routerStream, opts)
11
27
  }
12
28
 
13
29
  export function transformPipeableStreamWithRouter(
14
30
  router: AnyRouter,
15
31
  routerStream: Readable,
32
+ opts?: TransformStreamWithRouterOptions,
16
33
  ) {
17
34
  return Readable.fromWeb(
18
- transformStreamWithRouter(router, Readable.toWeb(routerStream)),
35
+ transformStreamWithRouter(router, Readable.toWeb(routerStream), opts),
19
36
  )
20
37
  }
21
38
 
22
- // Use string constants for simple indexOf matching
23
- const BODY_END_TAG = '</body>'
24
- const HTML_END_TAG = '</html>'
25
-
26
39
  // Minimum length of a valid closing tag: </a> = 4 characters
27
40
  const MIN_CLOSING_TAG_LENGTH = 4
28
41
 
29
42
  // Default timeout values (in milliseconds)
30
43
  const DEFAULT_SERIALIZATION_TIMEOUT_MS = 60000
31
- const DEFAULT_LIFETIME_TIMEOUT_MS = 60000
44
+ const DEFAULT_LIFETIME_TIMEOUT_MS = DEFAULT_SERIALIZATION_TIMEOUT_MS * 2
45
+ const MAX_LEFTOVER_CHARS = 2048
46
+ const MAX_TAIL_CHARS = 64 * 1024
47
+ const MAX_ROUTER_HTML_CHARS = 16 * 1024 * 1024
48
+ const MAX_PENDING_WRITE_CHARS = 16 * 1024 * 1024
49
+
50
+ // Merge lifecycle: body bytes can stream, router HTML must precede tail,
51
+ // terminal states own close/error/cleanup exactly once.
52
+ const MergeState = {
53
+ ReadingBody: 0,
54
+ HoldingTail: 1,
55
+ AppDone: 2,
56
+ Draining: 3,
57
+ Done: 4,
58
+ } as const
59
+
60
+ type MergeState = (typeof MergeState)[keyof typeof MergeState]
32
61
 
33
62
  // Module-level encoder (stateless, safe to reuse)
34
63
  const textEncoder = new TextEncoder()
35
64
 
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
- }
73
- }
65
+ const noop = () => {}
66
+ const resolvedPromise = Promise.resolve()
67
+
68
+ // Returns -bodyEndIndex - 2 when </body> is found; otherwise returns
69
+ // the position after the last valid closing tag, or -1 when none exists.
70
+ function findHtmlBoundary(str: string): number {
71
+ let lastClosingTagEnd = -1
72
+ let searchFrom = str.length - MIN_CLOSING_TAG_LENGTH
73
+
74
+ while (searchFrom >= 0) {
75
+ const openSlash = str.lastIndexOf('</', searchFrom)
76
+ if (openSlash === -1) break
77
+
78
+ // Fast case-insensitive match for </body>. Negative return encodes the
79
+ // body start index without allocating a result object.
80
+ if (
81
+ (str.charCodeAt(openSlash + 2) | 32) === 98 &&
82
+ (str.charCodeAt(openSlash + 3) | 32) === 111 &&
83
+ (str.charCodeAt(openSlash + 4) | 32) === 100 &&
84
+ (str.charCodeAt(openSlash + 5) | 32) === 121 &&
85
+ str.charCodeAt(openSlash + 6) === 62
86
+ ) {
87
+ return -openSlash - 2
88
+ }
74
89
 
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)
90
+ if (lastClosingTagEnd === -1) {
91
+ let i = openSlash + 2
92
+ const startCode = str.charCodeAt(i)
93
+ if (
94
+ (startCode >= 97 && startCode <= 122) ||
95
+ (startCode >= 65 && startCode <= 90)
96
+ ) {
97
+ i++
98
+ while (i < str.length) {
99
+ const code = str.charCodeAt(i)
85
100
  if (
86
- j >= 1 &&
87
- str.charCodeAt(j) === 47 &&
88
- str.charCodeAt(j - 1) === 60
101
+ (code >= 97 && code <= 122) || // a-z
102
+ (code >= 65 && code <= 90) || // A-Z
103
+ (code >= 48 && code <= 57) || // 0-9
104
+ code === 95 || // _
105
+ code === 58 || // :
106
+ code === 46 || // .
107
+ code === 45 // -
89
108
  ) {
90
- return i + 1 // Return position after the closing >
109
+ i++
110
+ } else {
111
+ break
91
112
  }
92
113
  }
114
+
115
+ if (str.charCodeAt(i) === 62) {
116
+ lastClosingTagEnd = i + 1
117
+ }
93
118
  }
94
119
  }
95
- i--
120
+
121
+ searchFrom = openSlash - 1
122
+ }
123
+
124
+ return lastClosingTagEnd
125
+ }
126
+
127
+ /**
128
+ * Releasing the lock can throw if a pending read is still settling or if the
129
+ * lock was already released.
130
+ */
131
+ type ReaderOps = {
132
+ cancel: (reason?: unknown) => Promise<unknown>
133
+ releaseLock: () => void
134
+ }
135
+
136
+ function safeReleaseReader(reader: ReaderOps) {
137
+ try {
138
+ reader.releaseLock()
139
+ return true
140
+ } catch {
141
+ return false
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Cancel a reader without producing an unhandled rejection. `reader.cancel()`
147
+ * can reject (e.g. when the underlying source's cancel() throws), and
148
+ * downstream cancel() should still wait for upstream teardown when possible.
149
+ */
150
+ function safeCancelReader(reader: ReaderOps, reason?: unknown): Promise<void> {
151
+ let cancelPromise: Promise<unknown> | undefined
152
+ try {
153
+ cancelPromise = reader.cancel(reason)
154
+ } catch {
155
+ // ignore
156
+ }
157
+
158
+ if (!safeReleaseReader(reader) && cancelPromise) {
159
+ return cancelPromise.then(noop, noop).then(() => {
160
+ safeReleaseReader(reader)
161
+ })
162
+ }
163
+
164
+ return cancelPromise ? cancelPromise.then(noop, noop) : resolvedPromise
165
+ }
166
+
167
+ function createReaderState<T>(appStream: ReadableStream<T>) {
168
+ const reader = appStream.getReader()
169
+ let released = false
170
+
171
+ return {
172
+ reader,
173
+ cancel: (reason?: unknown) => {
174
+ if (released) return resolvedPromise
175
+ released = true
176
+ return safeCancelReader(reader, reason)
177
+ },
178
+ release: () => {
179
+ if (released) return
180
+ released = true
181
+ safeReleaseReader(reader)
182
+ },
183
+ }
184
+ }
185
+
186
+ function createAbortNotifier(opts?: TransformStreamWithRouterOptions) {
187
+ let abortNotified = false
188
+ return (reason?: unknown) => {
189
+ if (abortNotified) return
190
+ abortNotified = true
191
+ try {
192
+ opts?.onAbort?.(reason)
193
+ } catch {
194
+ // swallow user errors
195
+ }
96
196
  }
97
- return -1
98
197
  }
99
198
 
100
199
  export function transformStreamWithRouter(
101
200
  router: AnyRouter,
102
201
  appStream: ReadableStream,
103
- opts?: {
104
- /** Timeout for serialization to complete after app render finishes (default: 60000ms) */
105
- timeoutMs?: number
106
- /** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */
107
- lifetimeMs?: number
108
- },
202
+ opts?: TransformStreamWithRouterOptions,
109
203
  ) {
110
- // Check upfront if serialization already finished synchronously
111
- // This is the fast path for routes with no deferred data
112
- const serializationAlreadyFinished =
113
- router.serverSsr?.isSerializationFinished() ?? false
114
-
115
- // Take any HTML that was buffered before we started listening
116
- const initialBufferedHtml = router.serverSsr?.takeBufferedHtml()
117
-
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
204
+ const serverSsr = router.serverSsr
205
+ if (!serverSsr) {
206
+ throw new Error('Invariant failed: router.serverSsr is required')
207
+ }
208
+ if (serverSsr.reserveStreamFastPath()) {
209
+ return makeFastPathStream(appStream, opts, serverSsr)
210
+ }
129
211
 
130
- if (lifetimeTimeoutHandle !== undefined) {
131
- clearTimeout(lifetimeTimeoutHandle)
132
- lifetimeTimeoutHandle = undefined
133
- }
212
+ return makeMainStream(serverSsr, appStream, opts)
213
+ }
134
214
 
135
- router.serverSsr?.cleanup()
215
+ // =====================================================================
216
+ // Fast path: passthrough with cleanup + backpressure on app reads.
217
+ // =====================================================================
218
+ function makeFastPathStream(
219
+ appStream: ReadableStream<Uint8Array>,
220
+ opts?: TransformStreamWithRouterOptions,
221
+ serverSsr?: NonNullable<AnyRouter['serverSsr']>,
222
+ ) {
223
+ let cleanedUp = false
224
+ let controller: ReadableStreamDefaultController<Uint8Array> | undefined
225
+ let state: MergeState = MergeState.ReadingBody
226
+ let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
227
+ let stopListeningToInjectedHtml: (() => void) | undefined
228
+ const readerState = createReaderState(appStream)
229
+ const notifyAbort = createAbortNotifier(opts)
230
+ const isDone = () => state === MergeState.Done
231
+ let renderFinished = false
232
+
233
+ const finishSsrRendering = () => {
234
+ if (!serverSsr || renderFinished) return true
235
+ renderFinished = true
236
+ try {
237
+ serverSsr.setRenderFinished()
238
+ return true
239
+ } catch (error) {
240
+ safeError(error)
241
+ cleanup(error)
242
+ return false
136
243
  }
244
+ }
137
245
 
138
- const safeClose = () => {
139
- if (isStreamClosed) return
140
- isStreamClosed = true
141
- try {
142
- controller?.close()
143
- } catch {
144
- // ignore
145
- }
246
+ const cleanup = (reason?: unknown, cancelReader = true) => {
247
+ if (cleanedUp) return resolvedPromise
248
+ cleanedUp = true
249
+
250
+ if (lifetimeTimeoutHandle !== undefined) {
251
+ clearTimeout(lifetimeTimeoutHandle)
252
+ lifetimeTimeoutHandle = undefined
253
+ }
254
+ try {
255
+ stopListeningToInjectedHtml?.()
256
+ } catch {
257
+ // ignore
146
258
  }
259
+ stopListeningToInjectedHtml = undefined
147
260
 
148
- const safeError = (error: unknown) => {
149
- if (isStreamClosed) return
150
- isStreamClosed = true
261
+ if (cancelReader) {
262
+ // Notify the producer immediately. Reader cancellation may take time to
263
+ // settle, and upstream renderers must tolerate abort + cancel overlap.
264
+ notifyAbort(reason)
265
+ }
266
+ const readerDone = cancelReader
267
+ ? readerState.cancel(reason)
268
+ : (readerState.release(), resolvedPromise)
269
+ if (serverSsr) {
151
270
  try {
152
- controller?.error(error)
153
- } catch {
154
- // ignore
271
+ serverSsr.cleanup()
272
+ } catch (error) {
273
+ console.error('Error in SSR cleanup:', error)
155
274
  }
156
275
  }
276
+ return readerDone
277
+ }
157
278
 
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)
168
-
169
- const stream = new ReadableStream<Uint8Array>({
170
- start(c: ReadableStreamDefaultController<Uint8Array>) {
171
- controller = c
172
- },
173
- cancel() {
174
- isStreamClosed = true
175
- cleanup()
176
- },
279
+ const safeClose = () => {
280
+ if (isDone()) return
281
+ state = MergeState.Done
282
+ try {
283
+ controller?.close()
284
+ } catch {
285
+ // ignore
286
+ }
287
+ }
288
+
289
+ const safeError = (error: unknown) => {
290
+ if (isDone()) return
291
+ state = MergeState.Done
292
+ try {
293
+ controller?.error(error)
294
+ } catch {
295
+ // ignore
296
+ }
297
+ }
298
+
299
+ if (serverSsr) {
300
+ stopListeningToInjectedHtml = serverSsr.onInjectedHtml(() => {
301
+ const err = new Error('SSR router HTML injected during fast path')
302
+ safeError(err)
303
+ cleanup(err)
177
304
  })
305
+ }
306
+
307
+ const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
308
+ lifetimeTimeoutHandle = setTimeout(() => {
309
+ if (!cleanedUp && !isDone()) {
310
+ const err = new Error('Stream lifetime exceeded')
311
+ console.warn(
312
+ `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
313
+ )
314
+ safeError(err)
315
+ cleanup(err)
316
+ }
317
+ }, lifetimeMs)
178
318
 
179
- ;(async () => {
180
- const reader = appStream.getReader()
319
+ const stream = new ReadableStream<Uint8Array>({
320
+ start(c) {
321
+ controller = c
322
+ },
323
+ async pull(c) {
324
+ if (cleanedUp || isDone()) return
181
325
  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)
326
+ const { done, value } = await readerState.reader.read()
327
+ if (!done) {
328
+ if (!cleanedUp && !isDone()) {
329
+ c.enqueue(value)
330
+ }
331
+ return
187
332
  }
188
333
 
189
- if (cleanedUp || isStreamClosed) return
334
+ if (cleanedUp || isDone()) return
190
335
 
191
- router.serverSsr?.setRenderFinished()
336
+ if (!finishSsrRendering()) return
192
337
  safeClose()
193
- cleanup()
338
+ return cleanup(undefined, false)
194
339
  } catch (error) {
195
340
  if (cleanedUp) return
196
341
  console.error('Error reading appStream:', error)
197
- router.serverSsr?.setRenderFinished()
342
+ if (state < MergeState.AppDone) {
343
+ try {
344
+ serverSsr?.setRenderFinished()
345
+ } catch {
346
+ // ignore
347
+ }
348
+ }
198
349
  safeError(error)
199
- cleanup()
350
+ return cleanup(error)
200
351
  } finally {
201
- reader.releaseLock()
352
+ if (cleanedUp || isDone()) {
353
+ readerState.release()
354
+ }
202
355
  }
203
- })().catch((error) => {
204
- if (cleanedUp) return
205
- console.error('Error in stream transform:', error)
206
- safeError(error)
207
- cleanup()
208
- })
356
+ },
357
+ cancel(reason) {
358
+ state = MergeState.Done
359
+ return cleanup(reason)
360
+ },
361
+ })
209
362
 
210
- return stream
211
- }
363
+ return stream
364
+ }
212
365
 
366
+ // =====================================================================
367
+ // Main path: scan + inject router HTML/scripts with full backpressure.
368
+ //
369
+ // ALL output (app chunks AND router-injected HTML/scripts) flows through a
370
+ // single pendingWrites queue and is only enqueued onto the downstream
371
+ // controller when desiredSize > 0. This prevents native-memory growth of
372
+ // queued Uint8Arrays under slow HTTP consumers.
373
+ // =====================================================================
374
+ function makeMainStream(
375
+ serverSsr: NonNullable<AnyRouter['serverSsr']>,
376
+ appStream: ReadableStream,
377
+ opts?: TransformStreamWithRouterOptions,
378
+ ) {
213
379
  let stopListeningToInjectedHtml: (() => void) | undefined
214
380
  let stopListeningToSerializationFinished: (() => void) | undefined
215
381
  let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined
216
382
  let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined
217
383
  let cleanedUp = false
218
384
 
219
- let controller: ReadableStreamDefaultController<any>
220
- let isStreamClosed = false
221
-
222
- const textDecoder = new TextDecoder()
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 = ''
385
+ let controller: ReadableStreamDefaultController<Uint8Array> | undefined
386
+ let closeWhenDrained = false
387
+ let state: MergeState = MergeState.ReadingBody
388
+
389
+ const readerState = createReaderState(appStream)
390
+ const notifyAbort = createAbortNotifier(opts)
391
+
392
+ // Single output queue: app chunks + router-injected HTML/scripts.
393
+ // Stored as STRINGS to avoid holding native-backed Uint8Arrays in our queue
394
+ // while waiting for downstream capacity. Encoding happens at enqueue time
395
+ // (drainPending) so the bytes live only inside the controller's internal
396
+ // queue, not in two places.
397
+ //
398
+ // Uses an index pointer instead of Array.prototype.shift() (which is O(n))
399
+ // so many small router-injected script chunks stay O(1) per chunk.
400
+ const pendingWrites: Array<string> = []
401
+ let pendingWriteHead = 0
402
+ let pendingWriteChars = 0
403
+
404
+ function clearPending() {
405
+ pendingWrites.length = 0
406
+ pendingWriteHead = 0
407
+ pendingWriteChars = 0
408
+ }
232
409
 
233
- // conservative cap: enough to hold any partial closing tag + a bit
234
- const MAX_LEFTOVER_CHARS = 2048
410
+ // Backpressure: pull() resolves drainResolve to let the read loop advance.
411
+ let drainResolve: (() => void) | null = null
412
+ const waitForDrain = () =>
413
+ new Promise<void>((r) => {
414
+ drainResolve = r
415
+ })
416
+ const signalDrain = () => {
417
+ if (drainResolve) {
418
+ const r = drainResolve
419
+ drainResolve = null
420
+ r()
421
+ }
422
+ }
235
423
 
236
- let isAppRendering = true
237
- let streamBarrierLifted = false
238
- let serializationFinished = serializationAlreadyFinished
424
+ const isDone = () => state === MergeState.Done
425
+
426
+ function drainPending() {
427
+ if (!controller || isDone()) return
428
+ while (pendingWriteHead < pendingWrites.length) {
429
+ const ds = controller.desiredSize
430
+ if (ds !== null && ds <= 0) return
431
+ const next = pendingWrites[pendingWriteHead]!
432
+ // Release reference for GC; compact when fully drained.
433
+ pendingWrites[pendingWriteHead] = ''
434
+ pendingWriteHead++
435
+ pendingWriteChars -= next.length
436
+ try {
437
+ controller.enqueue(textEncoder.encode(next))
438
+ } catch (error) {
439
+ safeError(error)
440
+ cleanup(error)
441
+ return
442
+ }
443
+ }
444
+ // Fully drained: reset array so it doesn't grow unbounded across SSR.
445
+ if (pendingWriteHead >= pendingWrites.length) {
446
+ pendingWrites.length = 0
447
+ pendingWriteHead = 0
448
+ }
449
+ // If we've flushed everything and tryFinish requested close, close now.
450
+ if (closeWhenDrained && pendingWriteHead >= pendingWrites.length) {
451
+ closeWhenDrained = false
452
+ safeClose()
453
+ cleanup(undefined, false)
454
+ }
455
+ }
239
456
 
240
- function safeEnqueue(chunk: string | Uint8Array) {
241
- if (isStreamClosed) return
242
- if (typeof chunk === 'string') {
243
- controller.enqueue(textEncoder.encode(chunk))
244
- } else {
245
- controller.enqueue(chunk)
457
+ /**
458
+ * Enqueue a string chunk through the backpressure queue. Stored as a
459
+ * string and encoded only when the downstream actually accepts the chunk
460
+ * — keeps native-memory pressure inside the controller's queue (which
461
+ * honors desiredSize) rather than ours.
462
+ */
463
+ function writeChunk(chunk: string) {
464
+ if (cleanedUp || isDone()) return
465
+ if (!chunk.length) return
466
+ if (pendingWriteChars + chunk.length > MAX_PENDING_WRITE_CHARS) {
467
+ const err = new Error('SSR stream pending output exceeded maximum buffer')
468
+ safeError(err)
469
+ cleanup(err)
470
+ return
246
471
  }
472
+ pendingWrites.push(chunk)
473
+ pendingWriteChars += chunk.length
474
+ drainPending()
247
475
  }
248
476
 
249
477
  function safeClose() {
250
- if (isStreamClosed) return
251
- isStreamClosed = true
478
+ if (isDone()) return
479
+ state = MergeState.Done
252
480
  try {
253
- controller.close()
481
+ controller?.close()
254
482
  } catch {
255
483
  // ignore
256
484
  }
257
485
  }
258
486
 
259
487
  function safeError(error: unknown) {
260
- if (isStreamClosed) return
261
- isStreamClosed = true
488
+ if (isDone()) return
489
+ state = MergeState.Done
262
490
  try {
263
- controller.error(error)
491
+ controller?.error(error)
264
492
  } catch {
265
493
  // ignore
266
494
  }
@@ -269,8 +497,8 @@ export function transformStreamWithRouter(
269
497
  /**
270
498
  * Cleanup with guards; must be idempotent.
271
499
  */
272
- function cleanup() {
273
- if (cleanedUp) return
500
+ function cleanup(reason?: unknown, cancelReader = true) {
501
+ if (cleanedUp) return resolvedPromise
274
502
  cleanedUp = true
275
503
 
276
504
  try {
@@ -291,155 +519,317 @@ export function transformStreamWithRouter(
291
519
  lifetimeTimeoutHandle = undefined
292
520
  }
293
521
 
294
- pendingRouterHtml = ''
522
+ clearPendingRouterHtml()
295
523
  leftover = ''
296
- pendingClosingTags = ''
524
+ pendingTail = ''
525
+ clearPending()
526
+
527
+ if (cancelReader) {
528
+ // Notify the producer immediately. Reader cancellation may take time to
529
+ // settle, and upstream renderers must tolerate abort + cancel overlap.
530
+ notifyAbort(reason)
531
+ }
532
+ const readerDone = cancelReader
533
+ ? readerState.cancel(reason)
534
+ : (readerState.release(), resolvedPromise)
535
+ signalDrain()
536
+ try {
537
+ serverSsr.cleanup()
538
+ } catch (error) {
539
+ console.error('Error in SSR cleanup:', error)
540
+ }
541
+ return readerDone
542
+ }
543
+
544
+ const textDecoder = new TextDecoder()
545
+
546
+ // Router-injected scripts/HTML waiting for the next safe body boundary.
547
+ // Keep chunks separate so flushing does not flatten a large rope string.
548
+ const pendingRouterHtml: Array<string> = []
549
+ let pendingRouterHtmlChars = 0
297
550
 
298
- router.serverSsr?.cleanup()
551
+ // between-chunk text buffer; keep bounded to avoid unbounded memory
552
+ let leftover = ''
553
+
554
+ // captured bytes from </body> onward; must stay behind router scripts.
555
+ let pendingTail = ''
556
+
557
+ let streamBarrierLifted = false
558
+ let streamBarrierMarkerSeen = false
559
+ let serializationFinished = false
560
+
561
+ function noteBarrierMarker(chunk: string) {
562
+ if (streamBarrierMarkerSeen) return
563
+ if (chunk.includes(TSR_SCRIPT_BARRIER_ID)) {
564
+ streamBarrierMarkerSeen = true
565
+ }
299
566
  }
300
567
 
301
- const stream = new ReadableStream({
302
- start(c: ReadableStreamDefaultController<any>) {
568
+ function liftBarrierAfterBoundary() {
569
+ if (streamBarrierLifted) return
570
+ if (!streamBarrierMarkerSeen) return
571
+ streamBarrierLifted = true
572
+ serverSsr.liftScriptBarrier()
573
+ }
574
+
575
+ const stream = new ReadableStream<Uint8Array>({
576
+ start(c) {
303
577
  controller = c
578
+ // If anything queued before start (shouldn't happen but be safe), drain.
579
+ drainPending()
580
+ },
581
+ pull() {
582
+ // Consumer has capacity; flush queue then unblock read loop.
583
+ drainPending()
584
+ signalDrain()
304
585
  },
305
- cancel() {
306
- isStreamClosed = true
307
- cleanup()
586
+ cancel(reason) {
587
+ state = MergeState.Done
588
+ return cleanup(reason)
308
589
  },
309
590
  })
310
591
 
592
+ function drainRouterHtml() {
593
+ if (cleanedUp || isDone()) return
594
+ let html: string | undefined
595
+ try {
596
+ html = serverSsr.takeBufferedHtml()
597
+ } catch (error) {
598
+ safeError(error)
599
+ cleanup(error)
600
+ return
601
+ }
602
+ if (!html) return
603
+ if (state >= MergeState.Draining) {
604
+ // At this point final tail/close has already been queued. Emitting late
605
+ // router HTML would put scripts after </body> or drop them silently.
606
+ const err = new Error(
607
+ 'SSR router HTML injected after stream finalization',
608
+ )
609
+ safeError(err)
610
+ cleanup(err)
611
+ return
612
+ }
613
+ if (state === MergeState.HoldingTail) {
614
+ flushPendingRouterHtml()
615
+ writeChunk(html)
616
+ } else {
617
+ if (pendingRouterHtmlChars + html.length > MAX_ROUTER_HTML_CHARS) {
618
+ const err = new Error('SSR router HTML exceeded maximum buffer')
619
+ safeError(err)
620
+ cleanup(err)
621
+ return
622
+ }
623
+ pendingRouterHtml.push(html)
624
+ pendingRouterHtmlChars += html.length
625
+ }
626
+ }
627
+
311
628
  function flushPendingRouterHtml() {
312
- if (!pendingRouterHtml) return
313
- safeEnqueue(pendingRouterHtml)
314
- pendingRouterHtml = ''
629
+ if (!pendingRouterHtml.length) return
630
+ for (const html of pendingRouterHtml) {
631
+ writeChunk(html)
632
+ }
633
+ clearPendingRouterHtml()
315
634
  }
316
635
 
317
- function appendRouterHtml(html: string) {
318
- if (!html) return
319
- pendingRouterHtml += html
636
+ function clearPendingRouterHtml() {
637
+ pendingRouterHtml.length = 0
638
+ pendingRouterHtmlChars = 0
639
+ }
640
+
641
+ function appendTail(chunk: string) {
642
+ pendingTail += chunk
643
+ if (pendingTail.length > MAX_TAIL_CHARS) {
644
+ throw new Error('SSR stream tail exceeded maximum buffer')
645
+ }
646
+ }
647
+
648
+ function waitForBackpressure() {
649
+ return !!(
650
+ controller &&
651
+ controller.desiredSize !== null &&
652
+ controller.desiredSize <= 0
653
+ )
654
+ }
655
+
656
+ function startSerializationTimeout() {
657
+ if (cleanedUp || isDone()) return
658
+ if (serializationTimeoutHandle !== undefined) return
659
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
660
+ serializationTimeoutHandle = setTimeout(() => {
661
+ if (!cleanedUp && !isDone()) {
662
+ const err = new Error('Serialization timeout after app render finished')
663
+ console.error('Serialization timeout after app render finished')
664
+ safeError(err)
665
+ cleanup(err)
666
+ }
667
+ }, timeoutMs)
320
668
  }
321
669
 
322
670
  /**
323
- * Finish only when app done and serialization complete.
671
+ * Finish only when app done and serialization complete. Queues final
672
+ * output and requests close-when-drained so we don't close ahead of
673
+ * pending writes still waiting on downstream capacity.
324
674
  */
325
675
  function tryFinish() {
326
- if (isAppRendering || !serializationFinished) return
327
- if (cleanedUp || isStreamClosed) return
676
+ if (state !== MergeState.AppDone || !serializationFinished) return
677
+ if (cleanedUp || isDone()) return
328
678
 
329
679
  if (serializationTimeoutHandle !== undefined) {
330
680
  clearTimeout(serializationTimeoutHandle)
331
681
  serializationTimeoutHandle = undefined
332
682
  }
333
683
 
684
+ drainRouterHtml()
685
+ if (cleanedUp || isDone()) return
686
+
334
687
  // Flush any remaining bytes in the TextDecoder
335
688
  const decoderRemainder = textDecoder.decode()
336
689
 
337
- if (leftover) safeEnqueue(leftover)
338
- if (decoderRemainder) safeEnqueue(decoderRemainder)
690
+ if (leftover) writeChunk(leftover)
691
+ if (cleanedUp || isDone()) return
692
+ if (decoderRemainder) writeChunk(decoderRemainder)
693
+ if (cleanedUp || isDone()) return
339
694
  flushPendingRouterHtml()
340
- if (pendingClosingTags) safeEnqueue(pendingClosingTags)
695
+ if (cleanedUp || isDone()) return
696
+ if (pendingTail) writeChunk(pendingTail)
697
+ if (cleanedUp || isDone()) return
698
+
699
+ leftover = ''
700
+ pendingTail = ''
341
701
 
342
- safeClose()
343
- cleanup()
702
+ state = MergeState.Draining
703
+ closeWhenDrained = true
704
+ // Try immediately; if queue not drained yet, pull() will retry.
705
+ drainPending()
706
+ }
707
+
708
+ function finishAppRendering() {
709
+ if (state >= MergeState.AppDone) return
710
+ state = MergeState.AppDone
711
+ try {
712
+ serverSsr.setRenderFinished()
713
+ } catch (error) {
714
+ safeError(error)
715
+ cleanup(error)
716
+ return
717
+ }
718
+ drainRouterHtml()
719
+ if (cleanedUp || isDone()) return
720
+ serializationFinished =
721
+ serializationFinished || serverSsr.isSerializationFinished()
722
+ if (serializationFinished) {
723
+ tryFinish()
724
+ } else {
725
+ startSerializationTimeout()
726
+ }
344
727
  }
345
728
 
346
729
  // Safety net: cleanup even if consumer never reads
347
- const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS
730
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
731
+ const lifetimeMs = opts?.lifetimeMs ?? timeoutMs * 2
348
732
  lifetimeTimeoutHandle = setTimeout(() => {
349
- if (!cleanedUp && !isStreamClosed) {
733
+ if (!cleanedUp && !isDone()) {
734
+ const err = new Error('Stream lifetime exceeded')
350
735
  console.warn(
351
736
  `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,
352
737
  )
353
- safeError(new Error('Stream lifetime exceeded'))
354
- cleanup()
738
+ safeError(err)
739
+ cleanup(err)
355
740
  }
356
741
  }, lifetimeMs)
357
742
 
358
- if (!serializationAlreadyFinished) {
359
- stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {
360
- if (cleanedUp || isStreamClosed) return
361
- const html = router.serverSsr?.takeBufferedHtml()
362
- if (!html) return
363
-
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)
368
- } else {
369
- // App is done rendering - flush any pending buffer first to maintain order,
370
- // then write the new HTML directly
371
- flushPendingRouterHtml()
372
- safeEnqueue(html)
373
- }
374
- })
743
+ stopListeningToInjectedHtml = serverSsr.onInjectedHtml(() => {
744
+ drainRouterHtml()
745
+ })
375
746
 
376
- stopListeningToSerializationFinished = router.subscribe(
377
- 'onSerializationFinished',
378
- () => {
379
- serializationFinished = true
380
- tryFinish()
381
- },
382
- )
747
+ stopListeningToSerializationFinished = serverSsr.onSerializationFinished(
748
+ () => {
749
+ serializationFinished = true
750
+ drainRouterHtml()
751
+ tryFinish()
752
+ },
753
+ )
754
+
755
+ // Subscriptions are installed before snapshots, so missed events are
756
+ // recovered by these synchronous drains/rechecks.
757
+ drainRouterHtml()
758
+ if (cleanedUp || isDone()) return stream
759
+ serializationFinished =
760
+ serializationFinished || serverSsr.isSerializationFinished()
761
+ if (serializationFinished) {
762
+ drainRouterHtml()
763
+ if (cleanedUp || isDone()) return stream
383
764
  }
384
765
 
385
766
  // Transform the appStream
386
767
  ;(async () => {
387
- const reader = appStream.getReader()
388
768
  try {
389
769
  while (true) {
390
- const { done, value } = await reader.read()
770
+ // Backpressure: pause upstream reads while downstream is full.
771
+ if (waitForBackpressure()) {
772
+ await waitForDrain()
773
+ if (cleanedUp || isDone()) return
774
+ }
775
+
776
+ const { done, value } = await readerState.reader.read()
391
777
  if (done) break
392
778
 
393
- if (cleanedUp || isStreamClosed) return
779
+ if (cleanedUp || isDone()) return
394
780
 
395
781
  const text =
396
- value instanceof Uint8Array
397
- ? textDecoder.decode(value, { stream: true })
398
- : String(value)
782
+ typeof value === 'string'
783
+ ? value
784
+ : textDecoder.decode(value as ArrayBufferView, { stream: true })
399
785
 
400
- // Fast path: most chunks have no pending left-over.
401
786
  const chunkString = leftover ? leftover + text : text
402
787
 
403
- if (!streamBarrierLifted) {
404
- if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {
405
- streamBarrierLifted = true
406
- router.serverSsr?.liftScriptBarrier()
407
- }
408
- }
409
-
410
- // If we already saw </body>, everything else is part of tail; buffer it.
411
- if (pendingClosingTags) {
412
- pendingClosingTags += chunkString
788
+ // If we already saw </body>, everything else is tail. Keep it bounded
789
+ // and held until router scripts are ready so injection remains before </body>.
790
+ if (state >= MergeState.HoldingTail) {
791
+ appendTail(chunkString)
413
792
  leftover = ''
414
793
  continue
415
794
  }
416
795
 
417
- const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)
418
- const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)
419
-
420
- if (
421
- bodyEndIndex !== -1 &&
422
- htmlEndIndex !== -1 &&
423
- bodyEndIndex < htmlEndIndex
424
- ) {
425
- pendingClosingTags = chunkString.slice(bodyEndIndex)
426
- safeEnqueue(chunkString.slice(0, bodyEndIndex))
796
+ const boundary = findHtmlBoundary(chunkString)
797
+ if (boundary < -1) {
798
+ const bodyEndIndex = -boundary - 2
799
+ state = MergeState.HoldingTail
800
+ appendTail(chunkString.slice(bodyEndIndex))
801
+ const bodyChunk = chunkString.slice(0, bodyEndIndex)
802
+ writeChunk(bodyChunk)
803
+ if (cleanedUp || isDone()) return
804
+ noteBarrierMarker(bodyChunk)
805
+ liftBarrierAfterBoundary()
806
+ if (cleanedUp || isDone()) return
427
807
  flushPendingRouterHtml()
428
808
  leftover = ''
429
809
  continue
430
810
  }
431
811
 
432
- const lastClosingTagEnd = findLastClosingTagEnd(chunkString)
812
+ const lastClosingTagEnd = boundary
433
813
 
434
814
  if (lastClosingTagEnd > 0) {
435
- safeEnqueue(chunkString.slice(0, lastClosingTagEnd))
815
+ const safeChunk = chunkString.slice(0, lastClosingTagEnd)
816
+ writeChunk(safeChunk)
817
+ if (cleanedUp || isDone()) return
818
+ noteBarrierMarker(safeChunk)
819
+ liftBarrierAfterBoundary()
820
+ if (cleanedUp || isDone()) return
436
821
  flushPendingRouterHtml()
437
822
 
438
823
  leftover = chunkString.slice(lastClosingTagEnd)
439
824
  if (leftover.length > MAX_LEFTOVER_CHARS) {
440
825
  // Ensure bounded memory even if a consumer streams long text sequences
441
826
  // without any closing tags. This may reduce injection granularity but is correct.
442
- safeEnqueue(leftover.slice(0, leftover.length - MAX_LEFTOVER_CHARS))
827
+ noteBarrierMarker(leftover)
828
+ const flushed = leftover.slice(
829
+ 0,
830
+ leftover.length - MAX_LEFTOVER_CHARS,
831
+ )
832
+ writeChunk(flushed)
443
833
  leftover = leftover.slice(-MAX_LEFTOVER_CHARS)
444
834
  }
445
835
  } else {
@@ -447,8 +837,10 @@ export function transformStreamWithRouter(
447
837
  // but stream older bytes to prevent unbounded buffering.
448
838
  const combined = chunkString
449
839
  if (combined.length > MAX_LEFTOVER_CHARS) {
840
+ noteBarrierMarker(combined)
450
841
  const flushUpto = combined.length - MAX_LEFTOVER_CHARS
451
- safeEnqueue(combined.slice(0, flushUpto))
842
+ const flushed = combined.slice(0, flushUpto)
843
+ writeChunk(flushed)
452
844
  leftover = combined.slice(flushUpto)
453
845
  } else {
454
846
  leftover = combined
@@ -456,40 +848,29 @@ export function transformStreamWithRouter(
456
848
  }
457
849
  }
458
850
 
459
- if (cleanedUp || isStreamClosed) return
851
+ if (cleanedUp || isDone()) return
460
852
 
461
- isAppRendering = false
462
- router.serverSsr?.setRenderFinished()
463
-
464
- if (serializationFinished) {
465
- tryFinish()
466
- } else {
467
- const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
468
- serializationTimeoutHandle = setTimeout(() => {
469
- if (!cleanedUp && !isStreamClosed) {
470
- console.error('Serialization timeout after app render finished')
471
- safeError(
472
- new Error('Serialization timeout after app render finished'),
473
- )
474
- cleanup()
475
- }
476
- }, timeoutMs)
477
- }
853
+ finishAppRendering()
478
854
  } catch (error) {
479
855
  if (cleanedUp) return
480
856
  console.error('Error reading appStream:', error)
481
- isAppRendering = false
482
- router.serverSsr?.setRenderFinished()
857
+ if (state < MergeState.AppDone) {
858
+ try {
859
+ serverSsr.setRenderFinished()
860
+ } catch {
861
+ // ignore
862
+ }
863
+ }
483
864
  safeError(error)
484
- cleanup()
865
+ cleanup(error)
485
866
  } finally {
486
- reader.releaseLock()
867
+ readerState.release()
487
868
  }
488
869
  })().catch((error) => {
489
870
  if (cleanedUp) return
490
871
  console.error('Error in stream transform:', error)
491
872
  safeError(error)
492
- cleanup()
873
+ cleanup(error)
493
874
  })
494
875
 
495
876
  return stream