@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.
- package/dist/cjs/Matches.cjs.map +1 -1
- package/dist/cjs/config.cjs.map +1 -1
- package/dist/cjs/defer.cjs.map +1 -1
- package/dist/cjs/index.cjs +5 -1
- package/dist/cjs/index.d.cts +2 -2
- package/dist/cjs/invariant.cjs.map +1 -1
- package/dist/cjs/load-matches.cjs.map +1 -1
- package/dist/cjs/lru-cache.cjs.map +1 -1
- package/dist/cjs/manifest.cjs +43 -17
- package/dist/cjs/manifest.cjs.map +1 -1
- package/dist/cjs/manifest.d.cts +76 -24
- package/dist/cjs/new-process-route-tree.cjs.map +1 -1
- package/dist/cjs/not-found.cjs.map +1 -1
- package/dist/cjs/path.cjs.map +1 -1
- package/dist/cjs/qss.cjs.map +1 -1
- package/dist/cjs/redirect.cjs.map +1 -1
- package/dist/cjs/rewrite.cjs.map +1 -1
- package/dist/cjs/route.cjs.map +1 -1
- package/dist/cjs/router.cjs.map +1 -1
- package/dist/cjs/router.d.cts +31 -16
- package/dist/cjs/scroll-restoration-script/client.cjs.map +1 -1
- package/dist/cjs/scroll-restoration-script/server.cjs.map +1 -1
- package/dist/cjs/scroll-restoration.cjs.map +1 -1
- package/dist/cjs/searchMiddleware.cjs.map +1 -1
- package/dist/cjs/searchParams.cjs.map +1 -1
- package/dist/cjs/ssr/createRequestHandler.cjs +10 -8
- package/dist/cjs/ssr/createRequestHandler.cjs.map +1 -1
- package/dist/cjs/ssr/createRequestHandler.d.cts +2 -2
- package/dist/cjs/ssr/handlerCallback.cjs +46 -0
- package/dist/cjs/ssr/handlerCallback.cjs.map +1 -1
- package/dist/cjs/ssr/handlerCallback.d.cts +15 -1
- package/dist/cjs/ssr/headers.cjs.map +1 -1
- package/dist/cjs/ssr/json.cjs.map +1 -1
- package/dist/cjs/ssr/serializer/RawStream.cjs.map +1 -1
- package/dist/cjs/ssr/serializer/ShallowErrorPlugin.cjs.map +1 -1
- package/dist/cjs/ssr/serializer/seroval-plugins.cjs.map +1 -1
- package/dist/cjs/ssr/serializer/transformer.cjs.map +1 -1
- package/dist/cjs/ssr/server.cjs +6 -1
- package/dist/cjs/ssr/server.d.cts +3 -2
- package/dist/cjs/ssr/ssr-client.cjs.map +1 -1
- package/dist/cjs/ssr/ssr-match-id.cjs.map +1 -1
- package/dist/cjs/ssr/ssr-server.cjs +263 -132
- package/dist/cjs/ssr/ssr-server.cjs.map +1 -1
- package/dist/cjs/ssr/ssr-server.d.cts +4 -19
- package/dist/cjs/ssr/transformStreamWithRouter.cjs +455 -203
- package/dist/cjs/ssr/transformStreamWithRouter.cjs.map +1 -1
- package/dist/cjs/ssr/transformStreamWithRouter.d.cts +14 -5
- package/dist/cjs/stores.cjs.map +1 -1
- package/dist/cjs/utils.cjs.map +1 -1
- package/dist/esm/Matches.js.map +1 -1
- package/dist/esm/config.js.map +1 -1
- package/dist/esm/defer.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/invariant.js.map +1 -1
- package/dist/esm/load-matches.js.map +1 -1
- package/dist/esm/lru-cache.js.map +1 -1
- package/dist/esm/manifest.d.ts +76 -24
- package/dist/esm/manifest.js +39 -17
- package/dist/esm/manifest.js.map +1 -1
- package/dist/esm/new-process-route-tree.js.map +1 -1
- package/dist/esm/not-found.js.map +1 -1
- package/dist/esm/path.js.map +1 -1
- package/dist/esm/qss.js.map +1 -1
- package/dist/esm/redirect.js.map +1 -1
- package/dist/esm/rewrite.js.map +1 -1
- package/dist/esm/route.js.map +1 -1
- package/dist/esm/router.d.ts +31 -16
- package/dist/esm/router.js.map +1 -1
- package/dist/esm/scroll-restoration-script/client.js.map +1 -1
- package/dist/esm/scroll-restoration-script/server.js.map +1 -1
- package/dist/esm/scroll-restoration.js.map +1 -1
- package/dist/esm/searchMiddleware.js.map +1 -1
- package/dist/esm/searchParams.js.map +1 -1
- package/dist/esm/ssr/createRequestHandler.d.ts +2 -2
- package/dist/esm/ssr/createRequestHandler.js +10 -8
- package/dist/esm/ssr/createRequestHandler.js.map +1 -1
- package/dist/esm/ssr/handlerCallback.d.ts +15 -1
- package/dist/esm/ssr/handlerCallback.js +42 -1
- package/dist/esm/ssr/handlerCallback.js.map +1 -1
- package/dist/esm/ssr/headers.js.map +1 -1
- package/dist/esm/ssr/json.js.map +1 -1
- package/dist/esm/ssr/serializer/RawStream.js.map +1 -1
- package/dist/esm/ssr/serializer/ShallowErrorPlugin.js.map +1 -1
- package/dist/esm/ssr/serializer/seroval-plugins.js.map +1 -1
- package/dist/esm/ssr/serializer/transformer.js.map +1 -1
- package/dist/esm/ssr/server.d.ts +3 -2
- package/dist/esm/ssr/server.js +2 -2
- package/dist/esm/ssr/ssr-client.js.map +1 -1
- package/dist/esm/ssr/ssr-match-id.js.map +1 -1
- package/dist/esm/ssr/ssr-server.d.ts +4 -19
- package/dist/esm/ssr/ssr-server.js +264 -133
- package/dist/esm/ssr/ssr-server.js.map +1 -1
- package/dist/esm/ssr/transformStreamWithRouter.d.ts +14 -5
- package/dist/esm/ssr/transformStreamWithRouter.js +455 -203
- package/dist/esm/ssr/transformStreamWithRouter.js.map +1 -1
- package/dist/esm/stores.js.map +1 -1
- package/dist/esm/utils.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +21 -1
- package/src/manifest.ts +151 -59
- package/src/router.ts +37 -19
- package/src/ssr/createRequestHandler.ts +14 -13
- package/src/ssr/handlerCallback.ts +84 -1
- package/src/ssr/server.ts +14 -2
- package/src/ssr/ssr-server.ts +418 -222
- 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 =
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
) {
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
lifetimeTimeoutHandle = undefined
|
|
133
|
-
}
|
|
212
|
+
return makeMainStream(serverSsr, appStream, opts)
|
|
213
|
+
}
|
|
134
214
|
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
} catch {
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
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 ||
|
|
334
|
+
if (cleanedUp || isDone()) return
|
|
190
335
|
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
+
if (cleanedUp || isDone()) {
|
|
353
|
+
readerState.release()
|
|
354
|
+
}
|
|
202
355
|
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
356
|
+
},
|
|
357
|
+
cancel(reason) {
|
|
358
|
+
state = MergeState.Done
|
|
359
|
+
return cleanup(reason)
|
|
360
|
+
},
|
|
361
|
+
})
|
|
209
362
|
|
|
210
|
-
|
|
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<
|
|
220
|
-
let
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
231
|
-
|
|
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
|
-
//
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 (
|
|
251
|
-
|
|
478
|
+
if (isDone()) return
|
|
479
|
+
state = MergeState.Done
|
|
252
480
|
try {
|
|
253
|
-
controller
|
|
481
|
+
controller?.close()
|
|
254
482
|
} catch {
|
|
255
483
|
// ignore
|
|
256
484
|
}
|
|
257
485
|
}
|
|
258
486
|
|
|
259
487
|
function safeError(error: unknown) {
|
|
260
|
-
if (
|
|
261
|
-
|
|
488
|
+
if (isDone()) return
|
|
489
|
+
state = MergeState.Done
|
|
262
490
|
try {
|
|
263
|
-
controller
|
|
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
|
-
|
|
522
|
+
clearPendingRouterHtml()
|
|
295
523
|
leftover = ''
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
629
|
+
if (!pendingRouterHtml.length) return
|
|
630
|
+
for (const html of pendingRouterHtml) {
|
|
631
|
+
writeChunk(html)
|
|
632
|
+
}
|
|
633
|
+
clearPendingRouterHtml()
|
|
315
634
|
}
|
|
316
635
|
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
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 (
|
|
327
|
-
if (cleanedUp ||
|
|
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)
|
|
338
|
-
if (
|
|
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 (
|
|
695
|
+
if (cleanedUp || isDone()) return
|
|
696
|
+
if (pendingTail) writeChunk(pendingTail)
|
|
697
|
+
if (cleanedUp || isDone()) return
|
|
698
|
+
|
|
699
|
+
leftover = ''
|
|
700
|
+
pendingTail = ''
|
|
341
701
|
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
730
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS
|
|
731
|
+
const lifetimeMs = opts?.lifetimeMs ?? timeoutMs * 2
|
|
348
732
|
lifetimeTimeoutHandle = setTimeout(() => {
|
|
349
|
-
if (!cleanedUp && !
|
|
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(
|
|
354
|
-
cleanup()
|
|
738
|
+
safeError(err)
|
|
739
|
+
cleanup(err)
|
|
355
740
|
}
|
|
356
741
|
}, lifetimeMs)
|
|
357
742
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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 ||
|
|
779
|
+
if (cleanedUp || isDone()) return
|
|
394
780
|
|
|
395
781
|
const text =
|
|
396
|
-
value
|
|
397
|
-
?
|
|
398
|
-
:
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
bodyEndIndex
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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 =
|
|
812
|
+
const lastClosingTagEnd = boundary
|
|
433
813
|
|
|
434
814
|
if (lastClosingTagEnd > 0) {
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
851
|
+
if (cleanedUp || isDone()) return
|
|
460
852
|
|
|
461
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|