@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const web = require("node:stream/web");
|
|
4
4
|
const node_stream = require("node:stream");
|
|
5
|
-
const
|
|
5
|
+
const constants = require("./constants.cjs");
|
|
6
6
|
function transformReadableStreamWithRouter(router, routerStream) {
|
|
7
7
|
return transformStreamWithRouter(router, routerStream);
|
|
8
8
|
}
|
|
@@ -11,224 +11,241 @@ function transformPipeableStreamWithRouter(router, routerStream) {
|
|
|
11
11
|
transformStreamWithRouter(router, node_stream.Readable.toWeb(routerStream))
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
14
|
+
const BODY_END_TAG = "</body>";
|
|
15
|
+
const HTML_END_TAG = "</html>";
|
|
16
|
+
const MIN_CLOSING_TAG_LENGTH = 4;
|
|
17
|
+
const DEFAULT_SERIALIZATION_TIMEOUT_MS = 6e4;
|
|
18
|
+
const DEFAULT_LIFETIME_TIMEOUT_MS = 6e4;
|
|
19
|
+
const textEncoder = new TextEncoder();
|
|
20
|
+
function findLastClosingTagEnd(str) {
|
|
21
|
+
const len = str.length;
|
|
22
|
+
if (len < MIN_CLOSING_TAG_LENGTH) return -1;
|
|
23
|
+
let i = len - 1;
|
|
24
|
+
while (i >= MIN_CLOSING_TAG_LENGTH - 1) {
|
|
25
|
+
if (str.charCodeAt(i) === 62) {
|
|
26
|
+
let j = i - 1;
|
|
27
|
+
while (j >= 1) {
|
|
28
|
+
const code = str.charCodeAt(j);
|
|
29
|
+
if (code >= 97 && code <= 122 || // a-z
|
|
30
|
+
code >= 65 && code <= 90 || // A-Z
|
|
31
|
+
code >= 48 && code <= 57 || // 0-9
|
|
32
|
+
code === 95 || // _
|
|
33
|
+
code === 58 || // :
|
|
34
|
+
code === 46 || // .
|
|
35
|
+
code === 45) {
|
|
36
|
+
j--;
|
|
37
|
+
} else {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
38
40
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const tagNameStart = j + 1;
|
|
42
|
+
if (tagNameStart < i) {
|
|
43
|
+
const startCode = str.charCodeAt(tagNameStart);
|
|
44
|
+
if (startCode >= 97 && startCode <= 122 || startCode >= 65 && startCode <= 90) {
|
|
45
|
+
if (j >= 1 && str.charCodeAt(j) === 47 && str.charCodeAt(j - 1) === 60) {
|
|
46
|
+
return i + 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
44
49
|
}
|
|
45
|
-
res.destroyed = true;
|
|
46
|
-
controller.close();
|
|
47
|
-
},
|
|
48
|
-
destroy: (error) => {
|
|
49
|
-
if (res.destroyed) return;
|
|
50
|
-
res.destroyed = true;
|
|
51
|
-
controller.error(error);
|
|
52
|
-
},
|
|
53
|
-
destroyed: false
|
|
54
|
-
};
|
|
55
|
-
return res;
|
|
56
|
-
}
|
|
57
|
-
async function readStream(stream, opts) {
|
|
58
|
-
const reader = stream.getReader();
|
|
59
|
-
try {
|
|
60
|
-
let chunk;
|
|
61
|
-
while (!(chunk = await reader.read()).done) {
|
|
62
|
-
opts.onData?.(chunk);
|
|
63
50
|
}
|
|
64
|
-
|
|
65
|
-
} catch (error) {
|
|
66
|
-
opts.onError?.(error);
|
|
67
|
-
} finally {
|
|
68
|
-
reader.releaseLock();
|
|
51
|
+
i--;
|
|
69
52
|
}
|
|
53
|
+
return -1;
|
|
70
54
|
}
|
|
71
55
|
function transformStreamWithRouter(router, appStream, opts) {
|
|
72
|
-
let stopListeningToInjectedHtml
|
|
73
|
-
let
|
|
56
|
+
let stopListeningToInjectedHtml;
|
|
57
|
+
let stopListeningToSerializationFinished;
|
|
58
|
+
let serializationTimeoutHandle;
|
|
59
|
+
let lifetimeTimeoutHandle;
|
|
74
60
|
let cleanedUp = false;
|
|
61
|
+
let controller;
|
|
62
|
+
let isStreamClosed = false;
|
|
63
|
+
const serializationAlreadyFinished = router.serverSsr?.isSerializationFinished() ?? false;
|
|
75
64
|
function cleanup() {
|
|
76
65
|
if (cleanedUp) return;
|
|
77
66
|
cleanedUp = true;
|
|
78
|
-
|
|
79
|
-
stopListeningToInjectedHtml();
|
|
80
|
-
|
|
67
|
+
try {
|
|
68
|
+
stopListeningToInjectedHtml?.();
|
|
69
|
+
stopListeningToSerializationFinished?.();
|
|
70
|
+
} catch (e) {
|
|
71
|
+
}
|
|
72
|
+
stopListeningToInjectedHtml = void 0;
|
|
73
|
+
stopListeningToSerializationFinished = void 0;
|
|
74
|
+
if (serializationTimeoutHandle !== void 0) {
|
|
75
|
+
clearTimeout(serializationTimeoutHandle);
|
|
76
|
+
serializationTimeoutHandle = void 0;
|
|
81
77
|
}
|
|
82
|
-
|
|
78
|
+
if (lifetimeTimeoutHandle !== void 0) {
|
|
79
|
+
clearTimeout(lifetimeTimeoutHandle);
|
|
80
|
+
lifetimeTimeoutHandle = void 0;
|
|
81
|
+
}
|
|
82
|
+
pendingRouterHtmlParts = [];
|
|
83
|
+
leftover = "";
|
|
84
|
+
pendingClosingTags = "";
|
|
83
85
|
router.serverSsr?.cleanup();
|
|
84
86
|
}
|
|
85
|
-
const finalPassThrough = createPassthrough(cleanup);
|
|
86
87
|
const textDecoder = new TextDecoder();
|
|
88
|
+
function safeEnqueue(chunk) {
|
|
89
|
+
if (isStreamClosed) return;
|
|
90
|
+
if (typeof chunk === "string") {
|
|
91
|
+
controller.enqueue(textEncoder.encode(chunk));
|
|
92
|
+
} else {
|
|
93
|
+
controller.enqueue(chunk);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function safeClose() {
|
|
97
|
+
if (isStreamClosed) return;
|
|
98
|
+
isStreamClosed = true;
|
|
99
|
+
try {
|
|
100
|
+
controller.close();
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function safeError(error) {
|
|
105
|
+
if (isStreamClosed) return;
|
|
106
|
+
isStreamClosed = true;
|
|
107
|
+
try {
|
|
108
|
+
controller.error(error);
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const stream = new web.ReadableStream({
|
|
113
|
+
start(c) {
|
|
114
|
+
controller = c;
|
|
115
|
+
},
|
|
116
|
+
cancel() {
|
|
117
|
+
isStreamClosed = true;
|
|
118
|
+
cleanup();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
87
121
|
let isAppRendering = true;
|
|
88
|
-
let routerStreamBuffer = "";
|
|
89
|
-
let pendingClosingTags = "";
|
|
90
122
|
let streamBarrierLifted = false;
|
|
91
123
|
let leftover = "";
|
|
92
|
-
let
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
let pendingClosingTags = "";
|
|
125
|
+
let serializationFinished = serializationAlreadyFinished;
|
|
126
|
+
let pendingRouterHtmlParts = [];
|
|
127
|
+
const bufferedHtml = router.serverSsr?.takeBufferedHtml();
|
|
128
|
+
if (bufferedHtml) {
|
|
129
|
+
pendingRouterHtmlParts.push(bufferedHtml);
|
|
97
130
|
}
|
|
98
|
-
function
|
|
99
|
-
if (
|
|
100
|
-
|
|
131
|
+
function flushPendingRouterHtml() {
|
|
132
|
+
if (pendingRouterHtmlParts.length > 0) {
|
|
133
|
+
safeEnqueue(pendingRouterHtmlParts.join(""));
|
|
134
|
+
pendingRouterHtmlParts = [];
|
|
101
135
|
}
|
|
102
|
-
return String(chunk);
|
|
103
136
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
);
|
|
111
|
-
function handleInjectedHtml() {
|
|
112
|
-
if (cleanedUp) return;
|
|
113
|
-
router.serverSsr.injectedHtml.forEach((promise) => {
|
|
114
|
-
processingCount++;
|
|
115
|
-
promise.then((html) => {
|
|
116
|
-
if (cleanedUp || finalPassThrough.destroyed) {
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
if (isAppRendering) {
|
|
120
|
-
routerStreamBuffer += html;
|
|
121
|
-
} else {
|
|
122
|
-
finalPassThrough.write(html);
|
|
123
|
-
}
|
|
124
|
-
}).catch((err) => {
|
|
125
|
-
injectedHtmlDonePromise.reject(err);
|
|
126
|
-
}).finally(() => {
|
|
127
|
-
processingCount--;
|
|
128
|
-
if (!isAppRendering && processingCount === 0) {
|
|
129
|
-
injectedHtmlDonePromise.resolve();
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
router.serverSsr.injectedHtml = [];
|
|
134
|
-
}
|
|
135
|
-
injectedHtmlDonePromise.then(() => {
|
|
136
|
-
if (cleanedUp || finalPassThrough.destroyed) {
|
|
137
|
-
return;
|
|
137
|
+
function tryFinish() {
|
|
138
|
+
if (isAppRendering || !serializationFinished) return;
|
|
139
|
+
if (cleanedUp || isStreamClosed) return;
|
|
140
|
+
if (serializationTimeoutHandle !== void 0) {
|
|
141
|
+
clearTimeout(serializationTimeoutHandle);
|
|
142
|
+
serializationTimeoutHandle = void 0;
|
|
138
143
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
pendingClosingTags
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
const decoderRemainder = textDecoder.decode();
|
|
145
|
+
if (leftover) safeEnqueue(leftover);
|
|
146
|
+
if (decoderRemainder) safeEnqueue(decoderRemainder);
|
|
147
|
+
flushPendingRouterHtml();
|
|
148
|
+
if (pendingClosingTags) safeEnqueue(pendingClosingTags);
|
|
149
|
+
safeClose();
|
|
150
|
+
cleanup();
|
|
151
|
+
}
|
|
152
|
+
const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS;
|
|
153
|
+
lifetimeTimeoutHandle = setTimeout(() => {
|
|
154
|
+
if (!cleanedUp && !isStreamClosed) {
|
|
155
|
+
console.warn(
|
|
156
|
+
`SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`
|
|
157
|
+
);
|
|
158
|
+
safeError(new Error("Stream lifetime exceeded"));
|
|
159
|
+
cleanup();
|
|
148
160
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const text = decodeChunk(chunk.value);
|
|
158
|
-
const chunkString = leftover + text;
|
|
159
|
-
const bodyEndMatch = chunkString.match(patternBodyEnd);
|
|
160
|
-
const htmlEndMatch = chunkString.match(patternHtmlEnd);
|
|
161
|
-
if (!streamBarrierLifted) {
|
|
162
|
-
const streamBarrierIdIncluded = chunkString.includes(
|
|
163
|
-
TSR_SCRIPT_BARRIER_ID
|
|
164
|
-
);
|
|
165
|
-
if (streamBarrierIdIncluded) {
|
|
166
|
-
streamBarrierLifted = true;
|
|
167
|
-
router.serverSsr.liftScriptBarrier();
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (bodyEndMatch && htmlEndMatch && bodyEndMatch.index < htmlEndMatch.index) {
|
|
171
|
-
const bodyEndIndex = bodyEndMatch.index;
|
|
172
|
-
pendingClosingTags = chunkString.slice(bodyEndIndex);
|
|
173
|
-
finalPassThrough.write(
|
|
174
|
-
chunkString.slice(0, bodyEndIndex) + getBufferedRouterStream() + leftoverHtml
|
|
175
|
-
);
|
|
176
|
-
leftover = "";
|
|
177
|
-
leftoverHtml = "";
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
let result;
|
|
181
|
-
let lastIndex = 0;
|
|
182
|
-
patternClosingTag.lastIndex = 0;
|
|
183
|
-
while ((result = patternClosingTag.exec(chunkString)) !== null) {
|
|
184
|
-
lastIndex = result.index + result[0].length;
|
|
185
|
-
}
|
|
186
|
-
if (lastIndex > 0) {
|
|
187
|
-
const processed = chunkString.slice(0, lastIndex) + getBufferedRouterStream() + leftoverHtml;
|
|
188
|
-
finalPassThrough.write(processed);
|
|
189
|
-
leftover = chunkString.slice(lastIndex);
|
|
190
|
-
leftoverHtml = "";
|
|
161
|
+
}, lifetimeMs);
|
|
162
|
+
if (!serializationAlreadyFinished) {
|
|
163
|
+
stopListeningToInjectedHtml = router.subscribe("onInjectedHtml", () => {
|
|
164
|
+
if (cleanedUp || isStreamClosed) return;
|
|
165
|
+
const html = router.serverSsr?.takeBufferedHtml();
|
|
166
|
+
if (!html) return;
|
|
167
|
+
if (isAppRendering) {
|
|
168
|
+
pendingRouterHtmlParts.push(html);
|
|
191
169
|
} else {
|
|
192
|
-
|
|
193
|
-
leftoverHtml += getBufferedRouterStream();
|
|
170
|
+
safeEnqueue(html);
|
|
194
171
|
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
172
|
+
});
|
|
173
|
+
stopListeningToSerializationFinished = router.subscribe(
|
|
174
|
+
"onSerializationFinished",
|
|
175
|
+
() => {
|
|
176
|
+
serializationFinished = true;
|
|
177
|
+
tryFinish();
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
(async () => {
|
|
182
|
+
const reader = appStream.getReader();
|
|
183
|
+
try {
|
|
184
|
+
while (true) {
|
|
185
|
+
const { done, value } = await reader.read();
|
|
186
|
+
if (done) break;
|
|
187
|
+
if (cleanedUp || isStreamClosed) return;
|
|
188
|
+
const text = value instanceof Uint8Array ? textDecoder.decode(value, { stream: true }) : String(value);
|
|
189
|
+
const chunkString = leftover + text;
|
|
190
|
+
if (!streamBarrierLifted) {
|
|
191
|
+
if (chunkString.includes(constants.TSR_SCRIPT_BARRIER_ID)) {
|
|
192
|
+
streamBarrierLifted = true;
|
|
193
|
+
router.serverSsr?.liftScriptBarrier();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const bodyEndIndex = chunkString.indexOf(BODY_END_TAG);
|
|
197
|
+
const htmlEndIndex = chunkString.indexOf(HTML_END_TAG);
|
|
198
|
+
if (bodyEndIndex !== -1 && htmlEndIndex !== -1 && bodyEndIndex < htmlEndIndex) {
|
|
199
|
+
pendingClosingTags = chunkString.slice(bodyEndIndex);
|
|
200
|
+
safeEnqueue(chunkString.slice(0, bodyEndIndex));
|
|
201
|
+
flushPendingRouterHtml();
|
|
202
|
+
leftover = "";
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const lastClosingTagEnd = findLastClosingTagEnd(chunkString);
|
|
206
|
+
if (lastClosingTagEnd > 0) {
|
|
207
|
+
safeEnqueue(chunkString.slice(0, lastClosingTagEnd));
|
|
208
|
+
flushPendingRouterHtml();
|
|
209
|
+
leftover = chunkString.slice(lastClosingTagEnd);
|
|
210
|
+
} else {
|
|
211
|
+
leftover = chunkString;
|
|
212
|
+
}
|
|
199
213
|
}
|
|
214
|
+
if (cleanedUp || isStreamClosed) return;
|
|
200
215
|
isAppRendering = false;
|
|
201
|
-
router.serverSsr
|
|
202
|
-
if (
|
|
203
|
-
|
|
216
|
+
router.serverSsr?.setRenderFinished();
|
|
217
|
+
if (serializationFinished) {
|
|
218
|
+
tryFinish();
|
|
204
219
|
} else {
|
|
205
|
-
const timeoutMs = opts?.timeoutMs ??
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS;
|
|
221
|
+
serializationTimeoutHandle = setTimeout(() => {
|
|
222
|
+
if (!cleanedUp && !isStreamClosed) {
|
|
223
|
+
console.error("Serialization timeout after app render finished");
|
|
224
|
+
safeError(
|
|
225
|
+
new Error("Serialization timeout after app render finished")
|
|
226
|
+
);
|
|
227
|
+
cleanup();
|
|
228
|
+
}
|
|
210
229
|
}, timeoutMs);
|
|
211
230
|
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (cleanedUp) {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (cleanedUp) return;
|
|
217
233
|
console.error("Error reading appStream:", error);
|
|
218
234
|
isAppRendering = false;
|
|
219
|
-
router.serverSsr
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
pendingClosingTags = "";
|
|
225
|
-
finalPassThrough.destroy(error);
|
|
226
|
-
injectedHtmlDonePromise.reject(error);
|
|
235
|
+
router.serverSsr?.setRenderFinished();
|
|
236
|
+
safeError(error);
|
|
237
|
+
cleanup();
|
|
238
|
+
} finally {
|
|
239
|
+
reader.releaseLock();
|
|
227
240
|
}
|
|
241
|
+
})().catch((error) => {
|
|
242
|
+
if (cleanedUp) return;
|
|
243
|
+
console.error("Error in stream transform:", error);
|
|
244
|
+
safeError(error);
|
|
245
|
+
cleanup();
|
|
228
246
|
});
|
|
229
|
-
return
|
|
247
|
+
return stream;
|
|
230
248
|
}
|
|
231
|
-
exports.TSR_SCRIPT_BARRIER_ID = TSR_SCRIPT_BARRIER_ID;
|
|
232
249
|
exports.transformPipeableStreamWithRouter = transformPipeableStreamWithRouter;
|
|
233
250
|
exports.transformReadableStreamWithRouter = transformReadableStreamWithRouter;
|
|
234
251
|
exports.transformStreamWithRouter = transformStreamWithRouter;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transformStreamWithRouter.cjs","sources":["../../../src/ssr/transformStreamWithRouter.ts"],"sourcesContent":["import { ReadableStream } from 'node:stream/web'\nimport { Readable } from 'node:stream'\nimport { createControlledPromise } from '../utils'\nimport type { AnyRouter } from '../router'\n\nexport function transformReadableStreamWithRouter(\n router: AnyRouter,\n routerStream: ReadableStream,\n) {\n return transformStreamWithRouter(router, routerStream)\n}\n\nexport function transformPipeableStreamWithRouter(\n router: AnyRouter,\n routerStream: Readable,\n) {\n return Readable.fromWeb(\n transformStreamWithRouter(router, Readable.toWeb(routerStream)),\n )\n}\n\nexport const TSR_SCRIPT_BARRIER_ID = '$tsr-stream-barrier'\n\n// regex pattern for matching closing body and html tags\nconst patternBodyEnd = /(<\\/body>)/\nconst patternHtmlEnd = /(<\\/html>)/\n// regex pattern for matching closing tags\nconst patternClosingTag = /(<\\/[a-zA-Z][\\w:.-]*?>)/g\n\ntype ReadablePassthrough = {\n stream: ReadableStream\n write: (chunk: unknown) => void\n end: (chunk?: string) => void\n destroy: (error: unknown) => void\n destroyed: boolean\n}\n\nfunction createPassthrough(onCancel: () => void) {\n let controller: ReadableStreamDefaultController<any>\n const encoder = new TextEncoder()\n const stream = new ReadableStream({\n start(c) {\n controller = c\n },\n cancel() {\n res.destroyed = true\n onCancel()\n },\n })\n\n const res: ReadablePassthrough = {\n stream,\n write: (chunk) => {\n // Don't write to destroyed stream\n if (res.destroyed) return\n if (typeof chunk === 'string') {\n controller.enqueue(encoder.encode(chunk))\n } else {\n controller.enqueue(chunk)\n }\n },\n end: (chunk) => {\n // Don't end already destroyed stream\n if (res.destroyed) return\n if (chunk) {\n res.write(chunk)\n }\n res.destroyed = true\n controller.close()\n },\n destroy: (error) => {\n // Don't destroy already destroyed stream\n if (res.destroyed) return\n res.destroyed = true\n controller.error(error)\n },\n destroyed: false,\n }\n\n return res\n}\n\nasync function readStream(\n stream: ReadableStream,\n opts: {\n onData?: (chunk: ReadableStreamReadValueResult<any>) => void\n onEnd?: () => void\n onError?: (error: unknown) => void\n },\n) {\n const reader = stream.getReader()\n try {\n let chunk\n while (!(chunk = await reader.read()).done) {\n opts.onData?.(chunk)\n }\n opts.onEnd?.()\n } catch (error) {\n opts.onError?.(error)\n } finally {\n reader.releaseLock()\n }\n}\n\nexport function transformStreamWithRouter(\n router: AnyRouter,\n appStream: ReadableStream,\n opts?: {\n timeoutMs?: number\n },\n) {\n let stopListeningToInjectedHtml: (() => void) | undefined = undefined\n let timeoutHandle: NodeJS.Timeout\n let cleanedUp = false\n\n function cleanup() {\n if (cleanedUp) return\n cleanedUp = true\n if (stopListeningToInjectedHtml) {\n stopListeningToInjectedHtml()\n stopListeningToInjectedHtml = undefined\n }\n clearTimeout(timeoutHandle)\n router.serverSsr?.cleanup()\n }\n\n const finalPassThrough = createPassthrough(cleanup)\n const textDecoder = new TextDecoder()\n\n let isAppRendering = true\n let routerStreamBuffer = ''\n let pendingClosingTags = ''\n let streamBarrierLifted = false\n let leftover = ''\n let leftoverHtml = ''\n\n function getBufferedRouterStream() {\n const html = routerStreamBuffer\n routerStreamBuffer = ''\n return html\n }\n\n function decodeChunk(chunk: unknown): string {\n if (chunk instanceof Uint8Array) {\n return textDecoder.decode(chunk, { stream: true })\n }\n return String(chunk)\n }\n\n const injectedHtmlDonePromise = createControlledPromise<void>()\n\n let processingCount = 0\n\n // Process any already-injected HTML\n handleInjectedHtml()\n\n // Listen for any new injected HTML\n stopListeningToInjectedHtml = router.subscribe(\n 'onInjectedHtml',\n handleInjectedHtml,\n )\n\n function handleInjectedHtml() {\n // Don't process if already cleaned up\n if (cleanedUp) return\n\n router.serverSsr!.injectedHtml.forEach((promise) => {\n processingCount++\n\n promise\n .then((html) => {\n // Don't write to destroyed stream or after cleanup\n if (cleanedUp || finalPassThrough.destroyed) {\n return\n }\n if (isAppRendering) {\n routerStreamBuffer += html\n } else {\n finalPassThrough.write(html)\n }\n })\n .catch((err) => {\n injectedHtmlDonePromise.reject(err)\n })\n .finally(() => {\n processingCount--\n\n if (!isAppRendering && processingCount === 0) {\n injectedHtmlDonePromise.resolve()\n }\n })\n })\n router.serverSsr!.injectedHtml = []\n }\n\n injectedHtmlDonePromise\n .then(() => {\n // Don't process if already cleaned up or destroyed\n if (cleanedUp || finalPassThrough.destroyed) {\n return\n }\n\n clearTimeout(timeoutHandle)\n const finalHtml =\n leftover + leftoverHtml + getBufferedRouterStream() + pendingClosingTags\n\n leftover = ''\n leftoverHtml = ''\n pendingClosingTags = ''\n\n finalPassThrough.end(finalHtml)\n })\n .catch((err) => {\n // Don't process if already cleaned up\n if (cleanedUp || finalPassThrough.destroyed) {\n return\n }\n\n console.error('Error reading routerStream:', err)\n finalPassThrough.destroy(err)\n })\n .finally(cleanup)\n\n // Transform the appStream\n readStream(appStream, {\n onData: (chunk) => {\n // Don't process if already cleaned up\n if (cleanedUp || finalPassThrough.destroyed) {\n return\n }\n\n const text = decodeChunk(chunk.value)\n const chunkString = leftover + text\n const bodyEndMatch = chunkString.match(patternBodyEnd)\n const htmlEndMatch = chunkString.match(patternHtmlEnd)\n\n if (!streamBarrierLifted) {\n const streamBarrierIdIncluded = chunkString.includes(\n TSR_SCRIPT_BARRIER_ID,\n )\n if (streamBarrierIdIncluded) {\n streamBarrierLifted = true\n router.serverSsr!.liftScriptBarrier()\n }\n }\n\n // If either the body end or html end is in the chunk,\n // We need to get all of our data in asap\n if (\n bodyEndMatch &&\n htmlEndMatch &&\n bodyEndMatch.index! < htmlEndMatch.index!\n ) {\n const bodyEndIndex = bodyEndMatch.index!\n pendingClosingTags = chunkString.slice(bodyEndIndex)\n\n finalPassThrough.write(\n chunkString.slice(0, bodyEndIndex) +\n getBufferedRouterStream() +\n leftoverHtml,\n )\n\n leftover = ''\n leftoverHtml = ''\n return\n }\n\n let result: RegExpExecArray | null\n let lastIndex = 0\n // Reset regex lastIndex since it's global and stateful across exec() calls\n patternClosingTag.lastIndex = 0\n while ((result = patternClosingTag.exec(chunkString)) !== null) {\n lastIndex = result.index + result[0].length\n }\n\n if (lastIndex > 0) {\n const processed =\n chunkString.slice(0, lastIndex) +\n getBufferedRouterStream() +\n leftoverHtml\n\n finalPassThrough.write(processed)\n leftover = chunkString.slice(lastIndex)\n leftoverHtml = ''\n } else {\n leftover = chunkString\n leftoverHtml += getBufferedRouterStream()\n }\n },\n onEnd: () => {\n // Don't process if stream was already destroyed/cancelled or cleaned up\n if (cleanedUp || finalPassThrough.destroyed) {\n return\n }\n\n // Mark the app as done rendering\n isAppRendering = false\n router.serverSsr!.setRenderFinished()\n\n // If there are no pending promises, resolve the injectedHtmlDonePromise\n if (processingCount === 0) {\n injectedHtmlDonePromise.resolve()\n } else {\n const timeoutMs = opts?.timeoutMs ?? 60000\n timeoutHandle = setTimeout(() => {\n injectedHtmlDonePromise.reject(\n new Error('Injected HTML timeout after app render finished'),\n )\n }, timeoutMs)\n }\n },\n onError: (error) => {\n // Don't process if already cleaned up\n if (cleanedUp) {\n return\n }\n\n console.error('Error reading appStream:', error)\n isAppRendering = false\n router.serverSsr!.setRenderFinished()\n // Clear timeout to prevent it from firing after error\n clearTimeout(timeoutHandle)\n // Clear string buffers to prevent memory leaks\n leftover = ''\n leftoverHtml = ''\n routerStreamBuffer = ''\n pendingClosingTags = ''\n finalPassThrough.destroy(error)\n injectedHtmlDonePromise.reject(error)\n },\n })\n\n return finalPassThrough.stream\n}\n"],"names":["Readable","ReadableStream","createControlledPromise"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"transformStreamWithRouter.cjs","sources":["../../../src/ssr/transformStreamWithRouter.ts"],"sourcesContent":["import { ReadableStream } from 'node:stream/web'\nimport { Readable } from 'node:stream'\nimport { TSR_SCRIPT_BARRIER_ID } from './constants'\nimport type { AnyRouter } from '../router'\n\nexport function transformReadableStreamWithRouter(\n router: AnyRouter,\n routerStream: ReadableStream,\n) {\n return transformStreamWithRouter(router, routerStream)\n}\n\nexport function transformPipeableStreamWithRouter(\n router: AnyRouter,\n routerStream: Readable,\n) {\n return Readable.fromWeb(\n transformStreamWithRouter(router, Readable.toWeb(routerStream)),\n )\n}\n\n// Use string constants for simple indexOf matching\nconst BODY_END_TAG = '</body>'\nconst HTML_END_TAG = '</html>'\n\n// Minimum length of a valid closing tag: </a> = 4 characters\nconst MIN_CLOSING_TAG_LENGTH = 4\n\n// Default timeout values (in milliseconds)\nconst DEFAULT_SERIALIZATION_TIMEOUT_MS = 60000\nconst DEFAULT_LIFETIME_TIMEOUT_MS = 60000\n\n// Module-level encoder (stateless, safe to reuse)\nconst textEncoder = new TextEncoder()\n\n/**\n * Finds the position just after the last valid HTML closing tag in the string.\n *\n * Valid closing tags match the pattern: </[a-zA-Z][\\w:.-]*>\n * Examples: </div>, </my-component>, </slot:name.nested>\n *\n * @returns Position after the last closing tag, or -1 if none found\n */\nfunction findLastClosingTagEnd(str: string): number {\n const len = str.length\n if (len < MIN_CLOSING_TAG_LENGTH) return -1\n\n let i = len - 1\n\n while (i >= MIN_CLOSING_TAG_LENGTH - 1) {\n // Look for > (charCode 62)\n if (str.charCodeAt(i) === 62) {\n // Look backwards for valid tag name characters\n let j = i - 1\n\n // Skip through valid tag name characters\n while (j >= 1) {\n const code = str.charCodeAt(j)\n // Check if it's a valid tag name char: [a-zA-Z0-9_:.-]\n if (\n (code >= 97 && code <= 122) || // a-z\n (code >= 65 && code <= 90) || // A-Z\n (code >= 48 && code <= 57) || // 0-9\n code === 95 || // _\n code === 58 || // :\n code === 46 || // .\n code === 45 // -\n ) {\n j--\n } else {\n break\n }\n }\n\n // Check if the first char after </ is a valid start char (letter only)\n const tagNameStart = j + 1\n if (tagNameStart < i) {\n const startCode = str.charCodeAt(tagNameStart)\n // Tag name must start with a letter (a-z or A-Z)\n if (\n (startCode >= 97 && startCode <= 122) ||\n (startCode >= 65 && startCode <= 90)\n ) {\n // Check for </ (charCodes: < = 60, / = 47)\n if (\n j >= 1 &&\n str.charCodeAt(j) === 47 &&\n str.charCodeAt(j - 1) === 60\n ) {\n return i + 1 // Return position after the closing >\n }\n }\n }\n }\n i--\n }\n return -1\n}\n\nexport function transformStreamWithRouter(\n router: AnyRouter,\n appStream: ReadableStream,\n opts?: {\n /** Timeout for serialization to complete after app render finishes (default: 60000ms) */\n timeoutMs?: number\n /** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */\n lifetimeMs?: number\n },\n) {\n let stopListeningToInjectedHtml: (() => void) | undefined\n let stopListeningToSerializationFinished: (() => void) | undefined\n let serializationTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n let lifetimeTimeoutHandle: ReturnType<typeof setTimeout> | undefined\n let cleanedUp = false\n\n let controller: ReadableStreamDefaultController<any>\n let isStreamClosed = false\n\n // Check upfront if serialization already finished synchronously\n // This is the fast path for routes with no deferred data\n const serializationAlreadyFinished =\n router.serverSsr?.isSerializationFinished() ?? false\n\n /**\n * Cleanup function with guards against multiple calls.\n * Unsubscribes listeners, clears timeouts, frees buffers, and cleans up router SSR state.\n */\n function cleanup() {\n // Guard against multiple cleanup calls - set flag first to prevent re-entry\n if (cleanedUp) return\n cleanedUp = true\n\n // Unsubscribe listeners first (wrap in try-catch for safety)\n try {\n stopListeningToInjectedHtml?.()\n stopListeningToSerializationFinished?.()\n } catch (e) {\n // Ignore errors during unsubscription\n }\n stopListeningToInjectedHtml = undefined\n stopListeningToSerializationFinished = undefined\n\n // Clear all timeouts\n if (serializationTimeoutHandle !== undefined) {\n clearTimeout(serializationTimeoutHandle)\n serializationTimeoutHandle = undefined\n }\n if (lifetimeTimeoutHandle !== undefined) {\n clearTimeout(lifetimeTimeoutHandle)\n lifetimeTimeoutHandle = undefined\n }\n\n // Clear buffers to free memory\n pendingRouterHtmlParts = []\n leftover = ''\n pendingClosingTags = ''\n\n // Clean up router SSR state (has its own guard)\n router.serverSsr?.cleanup()\n }\n\n const textDecoder = new TextDecoder()\n\n function safeEnqueue(chunk: string | Uint8Array) {\n if (isStreamClosed) return\n if (typeof chunk === 'string') {\n controller.enqueue(textEncoder.encode(chunk))\n } else {\n controller.enqueue(chunk)\n }\n }\n\n function safeClose() {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller.close()\n } catch {\n // Stream may already be errored or closed by consumer - safe to ignore\n }\n }\n\n function safeError(error: unknown) {\n if (isStreamClosed) return\n isStreamClosed = true\n try {\n controller.error(error)\n } catch {\n // Stream may already be errored or closed by consumer - safe to ignore\n }\n }\n\n const stream = new ReadableStream({\n start(c) {\n controller = c\n },\n cancel() {\n isStreamClosed = true\n cleanup()\n },\n })\n\n let isAppRendering = true\n let streamBarrierLifted = false\n let leftover = ''\n let pendingClosingTags = ''\n let serializationFinished = serializationAlreadyFinished\n\n let pendingRouterHtmlParts: Array<string> = []\n\n // Take any HTML that was buffered before we started listening\n const bufferedHtml = router.serverSsr?.takeBufferedHtml()\n if (bufferedHtml) {\n pendingRouterHtmlParts.push(bufferedHtml)\n }\n\n function flushPendingRouterHtml() {\n if (pendingRouterHtmlParts.length > 0) {\n safeEnqueue(pendingRouterHtmlParts.join(''))\n pendingRouterHtmlParts = []\n }\n }\n\n /**\n * Attempts to finish the stream if all conditions are met.\n */\n function tryFinish() {\n // Can only finish when app is done rendering and serialization is complete\n if (isAppRendering || !serializationFinished) return\n if (cleanedUp || isStreamClosed) return\n\n // Clear serialization timeout since we're finishing\n if (serializationTimeoutHandle !== undefined) {\n clearTimeout(serializationTimeoutHandle)\n serializationTimeoutHandle = undefined\n }\n\n // Flush any remaining bytes in the TextDecoder\n const decoderRemainder = textDecoder.decode()\n\n if (leftover) safeEnqueue(leftover)\n if (decoderRemainder) safeEnqueue(decoderRemainder)\n flushPendingRouterHtml()\n if (pendingClosingTags) safeEnqueue(pendingClosingTags)\n\n safeClose()\n cleanup()\n }\n\n // Set up lifetime timeout as a safety net\n // This ensures cleanup happens even if the stream is never consumed or gets stuck\n const lifetimeMs = opts?.lifetimeMs ?? DEFAULT_LIFETIME_TIMEOUT_MS\n lifetimeTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.warn(\n `SSR stream transform exceeded maximum lifetime (${lifetimeMs}ms), forcing cleanup`,\n )\n safeError(new Error('Stream lifetime exceeded'))\n cleanup()\n }\n }, lifetimeMs)\n\n // Only set up listeners if serialization hasn't already finished\n // This avoids unnecessary subscriptions for the common case of no deferred data\n if (!serializationAlreadyFinished) {\n // Listen for injected HTML (for deferred data that resolves later)\n stopListeningToInjectedHtml = router.subscribe('onInjectedHtml', () => {\n if (cleanedUp || isStreamClosed) return\n\n // Retrieve buffered HTML\n const html = router.serverSsr?.takeBufferedHtml()\n if (!html) return\n\n if (isAppRendering) {\n // Buffer for insertion at next valid position\n pendingRouterHtmlParts.push(html)\n } else {\n // App is done rendering, write directly to output\n safeEnqueue(html)\n }\n })\n\n // Listen for serialization finished\n stopListeningToSerializationFinished = router.subscribe(\n 'onSerializationFinished',\n () => {\n serializationFinished = true\n tryFinish()\n },\n )\n }\n\n // Transform the appStream\n ;(async () => {\n const reader = appStream.getReader()\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n\n // Don't process if already cleaned up\n if (cleanedUp || isStreamClosed) return\n\n const text =\n value instanceof Uint8Array\n ? textDecoder.decode(value, { stream: true })\n : String(value)\n const chunkString = leftover + text\n\n // Check for stream barrier (script placeholder) - use indexOf for efficiency\n if (!streamBarrierLifted) {\n if (chunkString.includes(TSR_SCRIPT_BARRIER_ID)) {\n streamBarrierLifted = true\n router.serverSsr?.liftScriptBarrier()\n }\n }\n\n // Check for body/html end tags\n const bodyEndIndex = chunkString.indexOf(BODY_END_TAG)\n const htmlEndIndex = chunkString.indexOf(HTML_END_TAG)\n\n // If we have both </body> and </html> in proper order,\n // insert router HTML before </body> and hold the closing tags\n if (\n bodyEndIndex !== -1 &&\n htmlEndIndex !== -1 &&\n bodyEndIndex < htmlEndIndex\n ) {\n pendingClosingTags = chunkString.slice(bodyEndIndex)\n\n safeEnqueue(chunkString.slice(0, bodyEndIndex))\n flushPendingRouterHtml()\n\n leftover = ''\n continue\n }\n\n // Handling partial closing tags split across chunks:\n //\n // Since `chunkString = leftover + text`, any incomplete tag fragment from the\n // previous chunk is prepended to the current chunk, allowing split tags like\n // \"</di\" + \"v>\" to be re-detected as a complete \"</div>\" in the combined string.\n //\n // - If a closing tag IS found (lastClosingTagEnd > 0): We enqueue content up to\n // the end of that tag, flush router HTML, and store the remainder in `leftover`.\n // This remainder may contain a partial tag (e.g., \"</sp\") which will be\n // prepended to the next chunk for re-detection.\n //\n // - If NO closing tag is found: The entire chunk is buffered in `leftover` and\n // will be prepended to the next chunk. This ensures partial tags are never\n // lost and will be detected once the rest of the tag arrives.\n //\n // This approach guarantees correct injection points even when closing tags span\n // chunk boundaries.\n const lastClosingTagEnd = findLastClosingTagEnd(chunkString)\n\n if (lastClosingTagEnd > 0) {\n // Found a closing tag - insert router HTML after it\n safeEnqueue(chunkString.slice(0, lastClosingTagEnd))\n flushPendingRouterHtml()\n\n leftover = chunkString.slice(lastClosingTagEnd)\n } else {\n // No closing tag found - buffer the entire chunk\n leftover = chunkString\n // Any pending router HTML will be inserted when we find a valid position\n }\n }\n\n // Stream ended\n if (cleanedUp || isStreamClosed) return\n\n // Mark the app as done rendering\n isAppRendering = false\n router.serverSsr?.setRenderFinished()\n\n // Try to finish if serialization is already done\n if (serializationFinished) {\n tryFinish()\n } else {\n // Set a timeout for serialization to complete\n const timeoutMs = opts?.timeoutMs ?? DEFAULT_SERIALIZATION_TIMEOUT_MS\n serializationTimeoutHandle = setTimeout(() => {\n if (!cleanedUp && !isStreamClosed) {\n console.error('Serialization timeout after app render finished')\n safeError(\n new Error('Serialization timeout after app render finished'),\n )\n cleanup()\n }\n }, timeoutMs)\n }\n } catch (error) {\n if (cleanedUp) return\n console.error('Error reading appStream:', error)\n isAppRendering = false\n router.serverSsr?.setRenderFinished()\n safeError(error)\n cleanup()\n } finally {\n reader.releaseLock()\n }\n })().catch((error) => {\n // Handle any errors that occur outside the try block (e.g., getReader() failure)\n if (cleanedUp) return\n console.error('Error in stream transform:', error)\n safeError(error)\n cleanup()\n })\n\n return stream\n}\n"],"names":["Readable","ReadableStream","TSR_SCRIPT_BARRIER_ID"],"mappings":"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"}
|
|
@@ -3,7 +3,9 @@ import { Readable } from 'node:stream';
|
|
|
3
3
|
import { AnyRouter } from '../router.cjs';
|
|
4
4
|
export declare function transformReadableStreamWithRouter(router: AnyRouter, routerStream: ReadableStream): ReadableStream<any>;
|
|
5
5
|
export declare function transformPipeableStreamWithRouter(router: AnyRouter, routerStream: Readable): Readable;
|
|
6
|
-
export declare const TSR_SCRIPT_BARRIER_ID = "$tsr-stream-barrier";
|
|
7
6
|
export declare function transformStreamWithRouter(router: AnyRouter, appStream: ReadableStream, opts?: {
|
|
7
|
+
/** Timeout for serialization to complete after app render finishes (default: 60000ms) */
|
|
8
8
|
timeoutMs?: number;
|
|
9
|
+
/** Maximum lifetime of the stream transform (default: 60000ms). Safety net for cleanup. */
|
|
10
|
+
lifetimeMs?: number;
|
|
9
11
|
}): ReadableStream<any>;
|
package/dist/esm/router.d.ts
CHANGED
|
@@ -485,15 +485,27 @@ export type ClearCacheFn<TRouter extends AnyRouter> = (opts?: {
|
|
|
485
485
|
filter?: (d: MakeRouteMatchUnion<TRouter>) => boolean;
|
|
486
486
|
}) => void;
|
|
487
487
|
export interface ServerSsr {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
488
|
+
/**
|
|
489
|
+
* Injects HTML synchronously into the stream.
|
|
490
|
+
* Emits an onInjectedHtml event that listeners can handle.
|
|
491
|
+
* If no subscriber is listening, the HTML is buffered and can be retrieved via takeBufferedHtml().
|
|
492
|
+
*/
|
|
493
|
+
injectHtml: (html: string) => void;
|
|
494
|
+
/**
|
|
495
|
+
* Injects a script tag synchronously into the stream.
|
|
496
|
+
*/
|
|
497
|
+
injectScript: (script: string) => void;
|
|
493
498
|
isDehydrated: () => boolean;
|
|
499
|
+
isSerializationFinished: () => boolean;
|
|
494
500
|
onRenderFinished: (listener: () => void) => void;
|
|
501
|
+
onSerializationFinished: (listener: () => void) => void;
|
|
495
502
|
dehydrate: () => Promise<void>;
|
|
496
503
|
takeBufferedScripts: () => RouterManagedTag | undefined;
|
|
504
|
+
/**
|
|
505
|
+
* Takes any buffered HTML that was injected.
|
|
506
|
+
* Returns the buffered HTML string (which may include multiple script tags) or undefined if empty.
|
|
507
|
+
*/
|
|
508
|
+
takeBufferedHtml: () => string | undefined;
|
|
497
509
|
liftScriptBarrier: () => void;
|
|
498
510
|
}
|
|
499
511
|
export type AnyRouterWithContext<TContext> = RouterCore<AnyRouteWithContext<TContext>, any, any, any, any>;
|