@tanstack/router-core 1.142.7 → 1.142.11
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/router.cjs.map +1 -1
- package/dist/cjs/router.d.cts +17 -5
- package/dist/cjs/ssr/constants.cjs +2 -0
- package/dist/cjs/ssr/constants.cjs.map +1 -1
- package/dist/cjs/ssr/constants.d.cts +1 -0
- package/dist/cjs/ssr/json.cjs +1 -8
- package/dist/cjs/ssr/json.cjs.map +1 -1
- package/dist/cjs/ssr/json.d.cts +6 -0
- package/dist/cjs/ssr/ssr-server.cjs +85 -39
- package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
- package/dist/cjs/ssr/ssr-server.d.cts +3 -1
- package/dist/cjs/ssr/transformStreamWithRouter.cjs +202 -185
- package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
- package/dist/cjs/ssr/transformStreamWithRouter.d.cts +3 -1
- package/dist/esm/router.d.ts +17 -5
- package/dist/esm/router.js.map +1 -1
- package/dist/esm/ssr/constants.d.ts +1 -0
- package/dist/esm/ssr/constants.js +3 -1
- package/dist/esm/ssr/constants.js.map +1 -1
- package/dist/esm/ssr/json.d.ts +6 -0
- package/dist/esm/ssr/json.js +1 -8
- package/dist/esm/ssr/json.js.map +1 -1
- package/dist/esm/ssr/ssr-server.d.ts +3 -1
- package/dist/esm/ssr/ssr-server.js +85 -39
- package/dist/esm/ssr/ssr-server.js.map +1 -1
- package/dist/esm/ssr/transformStreamWithRouter.d.ts +3 -1
- package/dist/esm/ssr/transformStreamWithRouter.js +202 -185
- package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
- package/package.json +1 -1
- package/src/router.ts +17 -6
- package/src/ssr/constants.ts +1 -0
- package/src/ssr/json.ts +7 -9
- package/src/ssr/ssr-server.ts +107 -46
- package/src/ssr/transformStreamWithRouter.ts +331 -253
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ReadableStream } from 'node:stream/web'
|
|
2
2
|
import { Readable } from 'node:stream'
|
|
3
|
-
import {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
let
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
144
|
-
if (
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
193
|
+
const stream = new ReadableStream({
|
|
194
|
+
start(c) {
|
|
195
|
+
controller = c
|
|
196
|
+
},
|
|
197
|
+
cancel() {
|
|
198
|
+
isStreamClosed = true
|
|
199
|
+
cleanup()
|
|
200
|
+
},
|
|
201
|
+
})
|
|
156
202
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
203
|
+
let isAppRendering = true
|
|
204
|
+
let streamBarrierLifted = false
|
|
205
|
+
let leftover = ''
|
|
206
|
+
let pendingClosingTags = ''
|
|
207
|
+
let serializationFinished = serializationAlreadyFinished
|
|
162
208
|
|
|
163
|
-
|
|
164
|
-
// Don't process if already cleaned up
|
|
165
|
-
if (cleanedUp) return
|
|
209
|
+
let pendingRouterHtmlParts: Array<string> = []
|
|
166
210
|
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
leftover + leftoverHtml + getBufferedRouterStream() + pendingClosingTags
|
|
238
|
+
// Flush any remaining bytes in the TextDecoder
|
|
239
|
+
const decoderRemainder = textDecoder.decode()
|
|
206
240
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
241
|
+
if (leftover) safeEnqueue(leftover)
|
|
242
|
+
if (decoderRemainder) safeEnqueue(decoderRemainder)
|
|
243
|
+
flushPendingRouterHtml()
|
|
244
|
+
if (pendingClosingTags) safeEnqueue(pendingClosingTags)
|
|
210
245
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// Don't process if already cleaned up
|
|
215
|
-
if (cleanedUp || finalPassThrough.destroyed) {
|
|
216
|
-
return
|
|
217
|
-
}
|
|
246
|
+
safeClose()
|
|
247
|
+
cleanup()
|
|
248
|
+
}
|
|
218
249
|
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
283
|
+
// Listen for serialization finished
|
|
284
|
+
stopListeningToSerializationFinished = router.subscribe(
|
|
285
|
+
'onSerializationFinished',
|
|
286
|
+
() => {
|
|
287
|
+
serializationFinished = true
|
|
288
|
+
tryFinish()
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
}
|
|
231
292
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
chunkString.slice(0, lastIndex) +
|
|
279
|
-
getBufferedRouterStream() +
|
|
280
|
-
leftoverHtml
|
|
331
|
+
safeEnqueue(chunkString.slice(0, bodyEndIndex))
|
|
332
|
+
flushPendingRouterHtml()
|
|
281
333
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
375
|
+
router.serverSsr?.setRenderFinished()
|
|
299
376
|
|
|
300
|
-
//
|
|
301
|
-
if (
|
|
302
|
-
|
|
377
|
+
// Try to finish if serialization is already done
|
|
378
|
+
if (serializationFinished) {
|
|
379
|
+
tryFinish()
|
|
303
380
|
} else {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
411
|
+
return stream
|
|
334
412
|
}
|