akanjs 2.2.11 → 2.2.13-rc.0
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/CHANGELOG.md +7 -0
- package/client/cookie.ts +3 -13
- package/client/router.ts +11 -3
- package/fetch/requestStorage.ts +106 -39
- package/package.json +1 -1
- package/server/akanApp.ts +55 -0
- package/server/hmr/clientScript.ts +2 -8
- package/server/rscClient.tsx +17 -7
- package/server/rscHttp.ts +7 -0
- package/server/rscWorker.tsx +183 -46
- package/server/rscWorkerHost.ts +242 -70
- package/server/rscWorkerReplay.ts +35 -0
- package/server/ssrFromRscRenderer.tsx +562 -81
- package/server/ssrTypes.ts +10 -0
- package/server/webRouter.ts +128 -28
- package/service/ipcTypes.ts +2 -0
- package/types/client/router.d.ts +8 -2
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/fetch/requestStorage.d.ts +31 -6
- package/types/server/hmr/clientScript.d.ts +1 -1
- package/types/server/rscClient.d.ts +3 -2
- package/types/server/rscHttp.d.ts +2 -0
- package/types/server/rscWorkerHost.d.ts +31 -0
- package/types/server/rscWorkerReplay.d.ts +23 -0
- package/types/server/ssrFromRscRenderer.d.ts +31 -1
- package/types/server/ssrTypes.d.ts +9 -0
- package/types/server/webRouter.d.ts +8 -1
- package/types/service/ipcTypes.d.ts +2 -0
- package/ui/Link/SsrLink.tsx +0 -2
|
@@ -3,13 +3,527 @@ import { type AkanTheme, pushRequestFallback, requestStorage } from "akanjs/fetc
|
|
|
3
3
|
import { type ReactNode, use } from "react";
|
|
4
4
|
import { renderToReadableStream } from "react-dom/server.browser";
|
|
5
5
|
import { createFromNodeStream } from "react-server-dom-webpack/client.node";
|
|
6
|
-
import type { SsrChunkRegistryStats, SsrFromRscInput } from "./ssrTypes";
|
|
6
|
+
import type { SsrChunkRegistryStats, SsrFromRscInput, SsrLateRedirect } from "./ssrTypes";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES = 1024;
|
|
9
|
+
const DEFAULT_MAX_PENDING_INLINE_RSC_SCRIPTS = 32;
|
|
10
|
+
|
|
11
|
+
interface SsrChunkRegistryEntry<T> {
|
|
12
|
+
keys: Set<string>;
|
|
13
|
+
lruKey: string;
|
|
14
|
+
value: T;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SsrChunkRegistry<T> {
|
|
18
|
+
readonly #entriesByKey = new Map<string, SsrChunkRegistryEntry<T>>();
|
|
19
|
+
readonly #lru = new Map<string, SsrChunkRegistryEntry<T>>();
|
|
20
|
+
#evictionCount = 0;
|
|
21
|
+
|
|
22
|
+
constructor(readonly maxEntries = DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES) {}
|
|
23
|
+
|
|
24
|
+
get size(): number {
|
|
25
|
+
return this.#entriesByKey.size;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get evictionCount(): number {
|
|
29
|
+
return this.#evictionCount;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get(key: string): T | undefined {
|
|
33
|
+
const entry = this.#entriesByKey.get(key);
|
|
34
|
+
if (!entry) return undefined;
|
|
35
|
+
this.#touch(entry);
|
|
36
|
+
return entry.value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
set(keys: string[], value: T): void {
|
|
40
|
+
const uniqueKeys = [...new Set(keys)].filter(Boolean);
|
|
41
|
+
if (uniqueKeys.length === 0) return;
|
|
42
|
+
|
|
43
|
+
let entry = uniqueKeys
|
|
44
|
+
.map((key) => this.#entriesByKey.get(key))
|
|
45
|
+
.find((item): item is SsrChunkRegistryEntry<T> => Boolean(item));
|
|
46
|
+
if (!entry) {
|
|
47
|
+
entry = { keys: new Set(), lruKey: uniqueKeys[0] as string, value };
|
|
48
|
+
}
|
|
49
|
+
entry.value = value;
|
|
50
|
+
|
|
51
|
+
for (const key of uniqueKeys) {
|
|
52
|
+
const existing = this.#entriesByKey.get(key);
|
|
53
|
+
if (existing && existing !== entry) {
|
|
54
|
+
existing.keys.delete(key);
|
|
55
|
+
if (existing.keys.size === 0) this.#lru.delete(existing.lruKey);
|
|
56
|
+
}
|
|
57
|
+
entry.keys.add(key);
|
|
58
|
+
this.#entriesByKey.set(key, entry);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.#touch(entry);
|
|
62
|
+
this.#evict(entry);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#touch(entry: SsrChunkRegistryEntry<T>): void {
|
|
66
|
+
this.#lru.delete(entry.lruKey);
|
|
67
|
+
this.#lru.set(entry.lruKey, entry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#evict(protectedEntry: SsrChunkRegistryEntry<T>): void {
|
|
71
|
+
const maxEntries = this.maxEntries > 0 ? this.maxEntries : DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES;
|
|
72
|
+
while (this.#entriesByKey.size > maxEntries) {
|
|
73
|
+
const oldest = this.#lru.entries().next().value as [string, SsrChunkRegistryEntry<T>] | undefined;
|
|
74
|
+
if (!oldest) return;
|
|
75
|
+
const [lruKey, entry] = oldest;
|
|
76
|
+
if (entry === protectedEntry && this.#lru.size === 1) return;
|
|
77
|
+
if (entry === protectedEntry) {
|
|
78
|
+
this.#touch(entry);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
this.#lru.delete(lruKey);
|
|
82
|
+
for (const key of entry.keys) this.#entriesByKey.delete(key);
|
|
83
|
+
this.#evictionCount += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type InlineRscChunk = readonly [1, string] | readonly [3, string];
|
|
89
|
+
|
|
90
|
+
export function encodeInlineRscChunk(chunk: Uint8Array): InlineRscChunk {
|
|
91
|
+
try {
|
|
92
|
+
return [1, new TextDecoder("utf-8", { fatal: true }).decode(chunk)];
|
|
93
|
+
} catch {
|
|
94
|
+
return [3, Buffer.from(chunk).toString("base64")];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function htmlEscapeJsonString(value: string): string {
|
|
99
|
+
return JSON.stringify(value)
|
|
100
|
+
.replace(/</g, "\\u003c")
|
|
101
|
+
.replace(/>/g, "\\u003e")
|
|
102
|
+
.replace(/&/g, "\\u0026")
|
|
103
|
+
.replace(/\u2028/g, "\\u2028")
|
|
104
|
+
.replace(/\u2029/g, "\\u2029");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createInlineRscScript(chunk: Uint8Array): string {
|
|
108
|
+
const [type, data] = encodeInlineRscChunk(chunk);
|
|
109
|
+
return `<script>self.__RSC_PUSH__(${type},${htmlEscapeJsonString(data)})</script>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createSoftRedirectScript(redirect: SsrLateRedirect): string {
|
|
113
|
+
const method = redirect.method === "push" ? "assign" : "replace";
|
|
114
|
+
return `<script>window.location.${method}(${htmlEscapeJsonString(redirect.location)})</script>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sanitizeFlightRows(
|
|
118
|
+
stream: ReadableStream<Uint8Array>,
|
|
119
|
+
options: { rewriteStylesheetHints?: boolean } = {},
|
|
120
|
+
): ReadableStream<Uint8Array> {
|
|
121
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
122
|
+
const encoder = new TextEncoder();
|
|
123
|
+
const hlStylesheetRe = /(:HL\["[^"\\]*(?:\\.[^"\\]*)*",)"stylesheet"(\])/g;
|
|
124
|
+
const redirectErrorRowRe = /^([0-9a-z]+):E(\{[^\n]*"digest":"AKAN_REDIRECT"[^\n]*\})(\n?)$/;
|
|
125
|
+
let buffered: Uint8Array<ArrayBuffer> = new Uint8Array(0);
|
|
126
|
+
|
|
127
|
+
const concatBytes = (left: Uint8Array, right: Uint8Array): Uint8Array<ArrayBuffer> => {
|
|
128
|
+
const combined = new Uint8Array(left.byteLength + right.byteLength);
|
|
129
|
+
combined.set(left, 0);
|
|
130
|
+
combined.set(right, left.byteLength);
|
|
131
|
+
return combined;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const sanitizeRow = (row: Uint8Array): Uint8Array => {
|
|
135
|
+
let text: string;
|
|
136
|
+
try {
|
|
137
|
+
text = decoder.decode(row);
|
|
138
|
+
} catch {
|
|
139
|
+
return row;
|
|
140
|
+
}
|
|
141
|
+
const sanitized = (options.rewriteStylesheetHints ? text.replace(hlStylesheetRe, `$1"style"$2`) : text).replace(
|
|
142
|
+
redirectErrorRowRe,
|
|
143
|
+
"$1:null$3",
|
|
144
|
+
);
|
|
145
|
+
return sanitized === text ? row : encoder.encode(sanitized);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const enqueueCompleteRows = (chunk: Uint8Array, controller: TransformStreamDefaultController<Uint8Array>) => {
|
|
149
|
+
buffered = concatBytes(buffered, chunk);
|
|
150
|
+
let rowStart = 0;
|
|
151
|
+
for (let index = 0; index < buffered.byteLength; index += 1) {
|
|
152
|
+
if (buffered[index] !== 10) continue;
|
|
153
|
+
controller.enqueue(sanitizeRow(buffered.slice(rowStart, index + 1)));
|
|
154
|
+
rowStart = index + 1;
|
|
155
|
+
}
|
|
156
|
+
buffered = rowStart === 0 ? buffered : buffered.slice(rowStart);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return stream.pipeThrough(
|
|
160
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
161
|
+
transform(chunk, controller) {
|
|
162
|
+
enqueueCompleteRows(chunk, controller);
|
|
163
|
+
},
|
|
164
|
+
flush(controller) {
|
|
165
|
+
if (buffered.byteLength > 0) {
|
|
166
|
+
controller.enqueue(sanitizeRow(buffered));
|
|
167
|
+
buffered = new Uint8Array(0);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function sanitizeFlightForClientStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
175
|
+
return sanitizeFlightRows(stream, { rewriteStylesheetHints: true });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type StderrWrite = typeof process.stderr.write;
|
|
179
|
+
|
|
180
|
+
export class ExpectedLateRedirectStderrSuppressor {
|
|
181
|
+
static #active = new Set<ExpectedLateRedirectStderrSuppressor>();
|
|
182
|
+
static #originalWrite: StderrWrite | null = null;
|
|
183
|
+
static #buffer = "";
|
|
184
|
+
static #flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
185
|
+
static #suppressingBenignBlock = false;
|
|
186
|
+
static readonly #maxBufferLength = 64 * 1024;
|
|
187
|
+
#stopped = false;
|
|
188
|
+
#lateRedirect = false;
|
|
189
|
+
#lateControlSettled = false;
|
|
190
|
+
|
|
191
|
+
private constructor(lateControl: Promise<SsrLateRedirect | null>) {
|
|
192
|
+
lateControl
|
|
193
|
+
.then((control) => {
|
|
194
|
+
this.#lateRedirect = control?.type === "redirect";
|
|
195
|
+
})
|
|
196
|
+
.catch(() => {
|
|
197
|
+
this.#lateRedirect = false;
|
|
198
|
+
})
|
|
199
|
+
.finally(() => {
|
|
200
|
+
this.#lateControlSettled = true;
|
|
201
|
+
ExpectedLateRedirectStderrSuppressor.#tryResolveBufferedOutput();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
static start(lateControl?: Promise<SsrLateRedirect | null>): ExpectedLateRedirectStderrSuppressor | null {
|
|
206
|
+
if (!lateControl) return null;
|
|
207
|
+
|
|
208
|
+
if (process.env.NODE_ENV === "production" && process.env.AKAN_SUPPRESS_LATE_REDIRECT_STDERR !== "1") return null;
|
|
209
|
+
const suppressor = new ExpectedLateRedirectStderrSuppressor(lateControl);
|
|
210
|
+
ExpectedLateRedirectStderrSuppressor.#active.add(suppressor);
|
|
211
|
+
ExpectedLateRedirectStderrSuppressor.#install();
|
|
212
|
+
return suppressor;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
stop(): void {
|
|
216
|
+
if (this.#stopped) return;
|
|
217
|
+
this.#stopped = true;
|
|
218
|
+
ExpectedLateRedirectStderrSuppressor.#active.delete(this);
|
|
219
|
+
ExpectedLateRedirectStderrSuppressor.#tryResolveBufferedOutput();
|
|
220
|
+
if (ExpectedLateRedirectStderrSuppressor.#active.size === 0) ExpectedLateRedirectStderrSuppressor.#uninstall();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
static #install(): void {
|
|
224
|
+
if (ExpectedLateRedirectStderrSuppressor.#originalWrite) return;
|
|
225
|
+
ExpectedLateRedirectStderrSuppressor.#originalWrite = process.stderr.write;
|
|
226
|
+
process.stderr.write = ((chunk: unknown, ...args: unknown[]) => {
|
|
227
|
+
ExpectedLateRedirectStderrSuppressor.#write(chunk, args);
|
|
228
|
+
return true;
|
|
229
|
+
}) as StderrWrite;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
static #uninstall(): void {
|
|
233
|
+
ExpectedLateRedirectStderrSuppressor.#flushBufferedOutput();
|
|
234
|
+
if (!ExpectedLateRedirectStderrSuppressor.#originalWrite) return;
|
|
235
|
+
process.stderr.write = ExpectedLateRedirectStderrSuppressor.#originalWrite;
|
|
236
|
+
ExpectedLateRedirectStderrSuppressor.#originalWrite = null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
static #write(chunk: unknown, args: unknown[]): void {
|
|
240
|
+
const text =
|
|
241
|
+
typeof chunk === "string" ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);
|
|
242
|
+
ExpectedLateRedirectStderrSuppressor.#buffer += text;
|
|
243
|
+
const callback = args.find((arg): arg is () => void => typeof arg === "function");
|
|
244
|
+
callback?.();
|
|
245
|
+
|
|
246
|
+
if (ExpectedLateRedirectStderrSuppressor.#buffer.length > ExpectedLateRedirectStderrSuppressor.#maxBufferLength) {
|
|
247
|
+
ExpectedLateRedirectStderrSuppressor.#flushBufferedOutput();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
ExpectedLateRedirectStderrSuppressor.#tryResolveBufferedOutput();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
static #tryResolveBufferedOutput(): void {
|
|
254
|
+
if (!ExpectedLateRedirectStderrSuppressor.#buffer) return;
|
|
255
|
+
const hasBenignClose = ExpectedLateRedirectStderrSuppressor.#isBenignRsdwConnectionClose(
|
|
256
|
+
ExpectedLateRedirectStderrSuppressor.#buffer,
|
|
257
|
+
);
|
|
258
|
+
if (hasBenignClose && ExpectedLateRedirectStderrSuppressor.#hasLateRedirectOrPending()) {
|
|
259
|
+
if (!ExpectedLateRedirectStderrSuppressor.#hasLateRedirect()) {
|
|
260
|
+
ExpectedLateRedirectStderrSuppressor.#scheduleFlush();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
ExpectedLateRedirectStderrSuppressor.#suppressingBenignBlock = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (ExpectedLateRedirectStderrSuppressor.#suppressingBenignBlock) {
|
|
267
|
+
if (ExpectedLateRedirectStderrSuppressor.#buffer.includes("\n\n")) {
|
|
268
|
+
ExpectedLateRedirectStderrSuppressor.#clearBufferedOutput();
|
|
269
|
+
ExpectedLateRedirectStderrSuppressor.#suppressingBenignBlock = false;
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
ExpectedLateRedirectStderrSuppressor.#scheduleFlush();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
static #scheduleFlush(): void {
|
|
278
|
+
if (ExpectedLateRedirectStderrSuppressor.#flushTimer) return;
|
|
279
|
+
ExpectedLateRedirectStderrSuppressor.#flushTimer = setTimeout(() => {
|
|
280
|
+
ExpectedLateRedirectStderrSuppressor.#flushTimer = null;
|
|
281
|
+
if (
|
|
282
|
+
ExpectedLateRedirectStderrSuppressor.#isBenignRsdwConnectionClose(
|
|
283
|
+
ExpectedLateRedirectStderrSuppressor.#buffer,
|
|
284
|
+
) &&
|
|
285
|
+
ExpectedLateRedirectStderrSuppressor.#hasLateRedirect()
|
|
286
|
+
) {
|
|
287
|
+
ExpectedLateRedirectStderrSuppressor.#clearBufferedOutput();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
ExpectedLateRedirectStderrSuppressor.#flushBufferedOutput();
|
|
291
|
+
}, 25);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
static #flushBufferedOutput(): void {
|
|
295
|
+
if (!ExpectedLateRedirectStderrSuppressor.#buffer) return;
|
|
296
|
+
const text = ExpectedLateRedirectStderrSuppressor.#buffer;
|
|
297
|
+
ExpectedLateRedirectStderrSuppressor.#clearBufferedOutput();
|
|
298
|
+
ExpectedLateRedirectStderrSuppressor.#originalWrite?.call(process.stderr, text);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
static #clearBufferedOutput(): void {
|
|
302
|
+
ExpectedLateRedirectStderrSuppressor.#buffer = "";
|
|
303
|
+
if (ExpectedLateRedirectStderrSuppressor.#flushTimer) {
|
|
304
|
+
clearTimeout(ExpectedLateRedirectStderrSuppressor.#flushTimer);
|
|
305
|
+
ExpectedLateRedirectStderrSuppressor.#flushTimer = null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
static #isBenignRsdwConnectionClose(text: string): boolean {
|
|
310
|
+
return (
|
|
311
|
+
text.includes("Connection closed.") &&
|
|
312
|
+
(text.includes("react-server-dom-webpack-client.node") || text.includes("reportGlobalError"))
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
static #hasLateRedirect(): boolean {
|
|
317
|
+
return [...ExpectedLateRedirectStderrSuppressor.#active].some((suppressor) => suppressor.#lateRedirect);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
static #hasLateRedirectOrPending(): boolean {
|
|
321
|
+
return [...ExpectedLateRedirectStderrSuppressor.#active].some(
|
|
322
|
+
(suppressor) => suppressor.#lateRedirect || !suppressor.#lateControlSettled,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function interleaveRscScriptsWithHtml(
|
|
328
|
+
htmlStream: ReadableStream<Uint8Array>,
|
|
329
|
+
rscClientStream: ReadableStream<Uint8Array>,
|
|
330
|
+
options: {
|
|
331
|
+
bootstrapModuleScripts?: string;
|
|
332
|
+
lateControl?: Promise<SsrLateRedirect | null>;
|
|
333
|
+
maxPendingRscScripts?: number;
|
|
334
|
+
onPendingRscScriptsSize?: (size: number) => void;
|
|
335
|
+
onComplete?: () => void;
|
|
336
|
+
onCancel?: (reason?: unknown) => void;
|
|
337
|
+
request?: Request;
|
|
338
|
+
} = {},
|
|
339
|
+
): ReadableStream<Uint8Array> {
|
|
340
|
+
const encoder = new TextEncoder();
|
|
341
|
+
const bootstrapDetector = new InlineBootstrapDetector();
|
|
342
|
+
const pendingRscScripts: Uint8Array[] = [];
|
|
343
|
+
const pendingControlScripts: Uint8Array[] = [];
|
|
344
|
+
const maxPendingRscScripts = SsrFromRscRendererConfig.maxPendingInlineRscScripts(options.maxPendingRscScripts);
|
|
345
|
+
const queueDrainResolvers: Array<() => void> = [];
|
|
346
|
+
const scriptAvailableResolvers: Array<() => void> = [];
|
|
347
|
+
let errored = false;
|
|
348
|
+
let rscDone = false;
|
|
349
|
+
let lateControlDone = !options.lateControl;
|
|
350
|
+
let htmlReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
351
|
+
let rscReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
352
|
+
let cancelled = false;
|
|
353
|
+
|
|
354
|
+
const cancelUpstream = (reason?: unknown) => {
|
|
355
|
+
if (cancelled) return;
|
|
356
|
+
cancelled = true;
|
|
357
|
+
while (queueDrainResolvers.length > 0) queueDrainResolvers.shift()?.();
|
|
358
|
+
while (scriptAvailableResolvers.length > 0) scriptAvailableResolvers.shift()?.();
|
|
359
|
+
if (htmlReader) void htmlReader.cancel(reason).catch(() => {});
|
|
360
|
+
if (rscReader) void rscReader.cancel(reason).catch(() => {});
|
|
361
|
+
options.onCancel?.(reason);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
return new ReadableStream<Uint8Array>({
|
|
365
|
+
start(controller) {
|
|
366
|
+
const fail = (err: unknown) => {
|
|
367
|
+
if (errored) return;
|
|
368
|
+
errored = true;
|
|
369
|
+
cancelUpstream(err);
|
|
370
|
+
controller.error(err);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const flushPendingRscScripts = () => {
|
|
374
|
+
if (!bootstrapDetector.canFlushInlineScripts) return;
|
|
375
|
+
while (!errored && pendingControlScripts.length > 0) {
|
|
376
|
+
const script = pendingControlScripts.shift();
|
|
377
|
+
if (script) controller.enqueue(script);
|
|
378
|
+
}
|
|
379
|
+
while (!errored && pendingRscScripts.length > 0) {
|
|
380
|
+
const script = pendingRscScripts.shift();
|
|
381
|
+
if (script) controller.enqueue(script);
|
|
382
|
+
}
|
|
383
|
+
options.onPendingRscScriptsSize?.(pendingRscScripts.length);
|
|
384
|
+
while (queueDrainResolvers.length > 0) queueDrainResolvers.shift()?.();
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const notifyScriptAvailable = () => {
|
|
388
|
+
while (scriptAvailableResolvers.length > 0) scriptAvailableResolvers.shift()?.();
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const waitForRscQueueDrain = async () => {
|
|
392
|
+
while (!errored && pendingRscScripts.length >= maxPendingRscScripts) {
|
|
393
|
+
await new Promise<void>((resolve) => queueDrainResolvers.push(resolve));
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const waitForScriptAvailable = () => new Promise<void>((resolve) => scriptAvailableResolvers.push(resolve));
|
|
398
|
+
|
|
399
|
+
const pumpRscScripts = async () => {
|
|
400
|
+
rscReader = rscClientStream.getReader();
|
|
401
|
+
try {
|
|
402
|
+
while (true) {
|
|
403
|
+
const { value, done } = await rscReader.read();
|
|
404
|
+
if (done || errored) break;
|
|
405
|
+
await waitForRscQueueDrain();
|
|
406
|
+
if (errored) break;
|
|
407
|
+
pendingRscScripts.push(encoder.encode(createInlineRscScript(value)));
|
|
408
|
+
options.onPendingRscScriptsSize?.(pendingRscScripts.length);
|
|
409
|
+
notifyScriptAvailable();
|
|
410
|
+
}
|
|
411
|
+
} finally {
|
|
412
|
+
rscDone = true;
|
|
413
|
+
notifyScriptAvailable();
|
|
414
|
+
rscReader.releaseLock();
|
|
415
|
+
rscReader = null;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const pump = async () => {
|
|
420
|
+
const rscPump = pumpRscScripts();
|
|
421
|
+
const lateControlPump = options.lateControl?.then((control) => {
|
|
422
|
+
try {
|
|
423
|
+
if (!control || errored) return;
|
|
424
|
+
pendingControlScripts.push(encoder.encode(createSoftRedirectScript(control)));
|
|
425
|
+
notifyScriptAvailable();
|
|
426
|
+
} finally {
|
|
427
|
+
lateControlDone = true;
|
|
428
|
+
notifyScriptAvailable();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
if (!lateControlPump) lateControlDone = true;
|
|
432
|
+
void rscPump.catch(fail);
|
|
433
|
+
void lateControlPump?.catch(fail);
|
|
434
|
+
|
|
435
|
+
htmlReader = htmlStream.getReader();
|
|
436
|
+
try {
|
|
437
|
+
while (true) {
|
|
438
|
+
const { value, done } = await htmlReader.read();
|
|
439
|
+
if (done || errored) break;
|
|
440
|
+
controller.enqueue(value);
|
|
441
|
+
bootstrapDetector.observe(value);
|
|
442
|
+
flushPendingRscScripts();
|
|
443
|
+
}
|
|
444
|
+
} finally {
|
|
445
|
+
htmlReader.releaseLock();
|
|
446
|
+
htmlReader = null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (errored) return;
|
|
450
|
+
if (options.bootstrapModuleScripts) controller.enqueue(encoder.encode(options.bootstrapModuleScripts));
|
|
451
|
+
bootstrapDetector.forceAllow();
|
|
452
|
+
while (
|
|
453
|
+
!errored &&
|
|
454
|
+
(!rscDone || !lateControlDone || pendingControlScripts.length > 0 || pendingRscScripts.length > 0)
|
|
455
|
+
) {
|
|
456
|
+
flushPendingRscScripts();
|
|
457
|
+
if (!rscDone || !lateControlDone) await waitForScriptAvailable();
|
|
458
|
+
}
|
|
459
|
+
await Promise.all([rscPump, lateControlPump]);
|
|
460
|
+
if (errored) return;
|
|
461
|
+
flushPendingRscScripts();
|
|
462
|
+
controller.enqueue(encoder.encode(`<script>self.__RSC_CLOSE__()</script>`));
|
|
463
|
+
controller.close();
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const runPump = () => {
|
|
467
|
+
const cleanup = options.request ? pushRequestFallback(options.request) : undefined;
|
|
468
|
+
return pump()
|
|
469
|
+
.catch(fail)
|
|
470
|
+
.finally(() => {
|
|
471
|
+
cleanup?.();
|
|
472
|
+
options.onComplete?.();
|
|
473
|
+
});
|
|
474
|
+
};
|
|
475
|
+
if (options.request && requestStorage) void requestStorage.run(options.request, runPump);
|
|
476
|
+
else void runPump();
|
|
477
|
+
},
|
|
478
|
+
cancel(reason) {
|
|
479
|
+
errored = true;
|
|
480
|
+
cancelUpstream(reason);
|
|
481
|
+
options.onComplete?.();
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
class SsrFromRscRendererConfig {
|
|
487
|
+
static maxPendingInlineRscScripts(explicit?: number): number {
|
|
488
|
+
if (explicit !== undefined && Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
|
|
489
|
+
const parsed = Number.parseInt(process.env.AKAN_MAX_PENDING_INLINE_RSC_SCRIPTS ?? "", 10);
|
|
490
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PENDING_INLINE_RSC_SCRIPTS;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
class InlineBootstrapDetector {
|
|
495
|
+
readonly #decoder = new TextDecoder();
|
|
496
|
+
#buffer = "";
|
|
497
|
+
#bootstrapSeen = false;
|
|
498
|
+
#canFlushInlineScripts = false;
|
|
499
|
+
|
|
500
|
+
get canFlushInlineScripts(): boolean {
|
|
501
|
+
return this.#canFlushInlineScripts;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
observe(chunk: Uint8Array): void {
|
|
505
|
+
if (this.#canFlushInlineScripts) return;
|
|
506
|
+
this.#buffer = `${this.#buffer}${this.#decoder.decode(chunk, { stream: true })}`.slice(-8192);
|
|
507
|
+
if (!this.#bootstrapSeen) {
|
|
508
|
+
const bootstrapIndex = this.#buffer.indexOf("__RSC_PUSH__");
|
|
509
|
+
if (bootstrapIndex === -1) return;
|
|
510
|
+
this.#bootstrapSeen = true;
|
|
511
|
+
this.#buffer = this.#buffer.slice(bootstrapIndex);
|
|
512
|
+
}
|
|
513
|
+
if (this.#buffer.toLowerCase().includes("</script>")) this.#canFlushInlineScripts = true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
forceAllow(): void {
|
|
517
|
+
this.#canFlushInlineScripts = true;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
7
520
|
|
|
8
521
|
export class SsrFromRscRenderer {
|
|
9
522
|
static readonly #chunkRegistryStats: SsrChunkRegistryStats = {
|
|
10
523
|
ssrChunkRegistrySize: 0,
|
|
11
524
|
ssrChunkLoadCount: 0,
|
|
12
525
|
ssrChunkCacheHitCount: 0,
|
|
526
|
+
ssrChunkEvictionCount: 0,
|
|
13
527
|
};
|
|
14
528
|
|
|
15
529
|
static readonly #clientBootstrap = `(function(){
|
|
@@ -31,7 +545,7 @@ export class SsrFromRscRenderer {
|
|
|
31
545
|
self.__webpack_get_script_filename__ = function(chunkId) { return chunkId; };
|
|
32
546
|
self.__RSC_CHUNKS__ = [];
|
|
33
547
|
self.__RSC_CLOSED__ = false;
|
|
34
|
-
self.__RSC_PUSH__ = function(
|
|
548
|
+
self.__RSC_PUSH__ = function(type,data){ self.__RSC_CHUNKS__.push([type,data]); };
|
|
35
549
|
self.__RSC_CLOSE__ = function(){ self.__RSC_CLOSED__ = true; };
|
|
36
550
|
})();`;
|
|
37
551
|
|
|
@@ -56,8 +570,12 @@ export class SsrFromRscRenderer {
|
|
|
56
570
|
|
|
57
571
|
const [rscForSsr, rscForClient] = input.rscStream.tee();
|
|
58
572
|
|
|
59
|
-
const ssrNodeStream = Readable.fromWeb(rscForSsr as never);
|
|
60
|
-
const
|
|
573
|
+
const ssrNodeStream = Readable.fromWeb(sanitizeFlightRows(rscForSsr) as never);
|
|
574
|
+
const stderrSuppressor = ExpectedLateRedirectStderrSuppressor.start(input.lateControl);
|
|
575
|
+
const thenable = SsrFromRscRenderer.#suppressExpectedLateRedirectError(
|
|
576
|
+
createFromNodeStream(ssrNodeStream, input.ssrManifest),
|
|
577
|
+
input.lateControl,
|
|
578
|
+
);
|
|
61
579
|
|
|
62
580
|
function Root(): ReactNode {
|
|
63
581
|
return use(thenable);
|
|
@@ -86,6 +604,9 @@ export class SsrFromRscRenderer {
|
|
|
86
604
|
SsrFromRscRenderer.#sanitizeFlightForClient(rscForClient),
|
|
87
605
|
input.bootstrapModules,
|
|
88
606
|
input.request,
|
|
607
|
+
input.lateControl,
|
|
608
|
+
() => stderrSuppressor?.stop(),
|
|
609
|
+
input.onCancel,
|
|
89
610
|
);
|
|
90
611
|
}
|
|
91
612
|
|
|
@@ -98,18 +619,18 @@ export class SsrFromRscRenderer {
|
|
|
98
619
|
if (g.__rsc_ssr_shims_installed__) return;
|
|
99
620
|
g.__rsc_ssr_shims_installed__ = true;
|
|
100
621
|
|
|
101
|
-
const registry = new
|
|
622
|
+
const registry = new SsrChunkRegistry<Record<string, unknown>>(SsrFromRscRenderer.#getSsrChunkRegistryMaxEntries());
|
|
102
623
|
g.__webpack_chunk_load__ = async (chunkId: string) => {
|
|
103
|
-
if (registry.
|
|
624
|
+
if (registry.get(chunkId)) {
|
|
104
625
|
SsrFromRscRenderer.#chunkRegistryStats.ssrChunkCacheHitCount += 1;
|
|
105
626
|
return;
|
|
106
627
|
}
|
|
107
628
|
const mod = (await import(chunkId)) as Record<string, unknown>;
|
|
108
|
-
registry.set(chunkId, mod);
|
|
109
629
|
const canonical = chunkId.replace(/\?v=\d+$/, "");
|
|
110
|
-
registry.set(canonical, mod);
|
|
630
|
+
registry.set([chunkId, canonical], mod);
|
|
111
631
|
SsrFromRscRenderer.#chunkRegistryStats.ssrChunkLoadCount += 1;
|
|
112
632
|
SsrFromRscRenderer.#chunkRegistryStats.ssrChunkRegistrySize = registry.size;
|
|
633
|
+
SsrFromRscRenderer.#chunkRegistryStats.ssrChunkEvictionCount = registry.evictionCount;
|
|
113
634
|
};
|
|
114
635
|
g.__webpack_require__ = (id: string) => {
|
|
115
636
|
const mod = registry.get(id);
|
|
@@ -120,6 +641,29 @@ export class SsrFromRscRenderer {
|
|
|
120
641
|
};
|
|
121
642
|
}
|
|
122
643
|
|
|
644
|
+
static #suppressExpectedLateRedirectError(
|
|
645
|
+
thenable: PromiseLike<ReactNode>,
|
|
646
|
+
lateControl?: Promise<SsrLateRedirect | null>,
|
|
647
|
+
): Promise<ReactNode> {
|
|
648
|
+
const promise = Promise.resolve(thenable);
|
|
649
|
+
if (!lateControl) return promise;
|
|
650
|
+
return promise.catch(async (error) => {
|
|
651
|
+
const control = await lateControl;
|
|
652
|
+
if (control?.type === "redirect" && SsrFromRscRenderer.#isExpectedLateRedirectError(error)) return null;
|
|
653
|
+
throw error;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
static #isExpectedLateRedirectError(error: unknown): boolean {
|
|
658
|
+
if (!(error instanceof Error)) return false;
|
|
659
|
+
return error.message === "Connection closed." || error.name === "AkanRedirectError";
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
static #getSsrChunkRegistryMaxEntries(): number {
|
|
663
|
+
const parsed = Number.parseInt(process.env.AKAN_SSR_CHUNK_REGISTRY_MAX_ENTRIES ?? "", 10);
|
|
664
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES;
|
|
665
|
+
}
|
|
666
|
+
|
|
123
667
|
/**
|
|
124
668
|
* Splice bootstrap-only head scripts immediately after the `<head>` opening
|
|
125
669
|
* tag in the outgoing HTML stream.
|
|
@@ -219,27 +763,7 @@ export class SsrFromRscRenderer {
|
|
|
219
763
|
}
|
|
220
764
|
|
|
221
765
|
static #sanitizeFlightForClient(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
222
|
-
|
|
223
|
-
const encoder = new TextEncoder();
|
|
224
|
-
|
|
225
|
-
const hlStylesheetRe = /(:HL\["[^"\\]*(?:\\.[^"\\]*)*",)"stylesheet"(\])/g;
|
|
226
|
-
|
|
227
|
-
return stream.pipeThrough(
|
|
228
|
-
new TransformStream<Uint8Array, Uint8Array>({
|
|
229
|
-
transform(chunk, controller) {
|
|
230
|
-
const text = decoder.decode(chunk, { stream: true });
|
|
231
|
-
if (!text.includes(`"stylesheet"`)) {
|
|
232
|
-
controller.enqueue(chunk);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
controller.enqueue(encoder.encode(text.replace(hlStylesheetRe, `$1"style"$2`)));
|
|
236
|
-
},
|
|
237
|
-
flush(controller) {
|
|
238
|
-
const tail = decoder.decode();
|
|
239
|
-
if (tail) controller.enqueue(encoder.encode(tail));
|
|
240
|
-
},
|
|
241
|
-
}),
|
|
242
|
-
);
|
|
766
|
+
return sanitizeFlightForClientStream(stream);
|
|
243
767
|
}
|
|
244
768
|
|
|
245
769
|
static #appendRscScriptsAfterHtml(
|
|
@@ -247,61 +771,18 @@ export class SsrFromRscRenderer {
|
|
|
247
771
|
rscClientStream: ReadableStream<Uint8Array>,
|
|
248
772
|
bootstrapModules?: string[],
|
|
249
773
|
request?: Request,
|
|
774
|
+
lateControl?: Promise<SsrLateRedirect | null>,
|
|
775
|
+
onComplete?: () => void,
|
|
776
|
+
onCancel?: (reason?: unknown) => void,
|
|
250
777
|
): ReadableStream<Uint8Array> {
|
|
251
|
-
const encoder = new TextEncoder();
|
|
252
778
|
const bootstrapModuleScripts = SsrFromRscRenderer.#createBootstrapModuleScriptTags(bootstrapModules);
|
|
253
779
|
|
|
254
|
-
return
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
controller.error(err);
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const pump = async () => {
|
|
264
|
-
const reader = htmlStream.getReader();
|
|
265
|
-
try {
|
|
266
|
-
while (true) {
|
|
267
|
-
const { value, done } = await reader.read();
|
|
268
|
-
if (done) break;
|
|
269
|
-
if (errored) return;
|
|
270
|
-
controller.enqueue(value);
|
|
271
|
-
}
|
|
272
|
-
} finally {
|
|
273
|
-
reader.releaseLock();
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (bootstrapModuleScripts && !errored) controller.enqueue(encoder.encode(bootstrapModuleScripts));
|
|
277
|
-
|
|
278
|
-
const rscReader = rscClientStream.getReader();
|
|
279
|
-
try {
|
|
280
|
-
while (true) {
|
|
281
|
-
const { value, done } = await rscReader.read();
|
|
282
|
-
if (done) break;
|
|
283
|
-
if (errored) return;
|
|
284
|
-
const b64 = Buffer.from(value).toString("base64");
|
|
285
|
-
controller.enqueue(encoder.encode(`<script>self.__RSC_PUSH__(${JSON.stringify(b64)})</script>`));
|
|
286
|
-
}
|
|
287
|
-
} finally {
|
|
288
|
-
rscReader.releaseLock();
|
|
289
|
-
}
|
|
290
|
-
if (!errored) {
|
|
291
|
-
controller.enqueue(encoder.encode(`<script>self.__RSC_CLOSE__()</script>`));
|
|
292
|
-
controller.close();
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
const runPump = () => {
|
|
297
|
-
const cleanup = request ? pushRequestFallback(request) : undefined;
|
|
298
|
-
return pump()
|
|
299
|
-
.catch(fail)
|
|
300
|
-
.finally(() => cleanup?.());
|
|
301
|
-
};
|
|
302
|
-
if (request && requestStorage) void requestStorage.run(request, runPump);
|
|
303
|
-
else void runPump();
|
|
304
|
-
},
|
|
780
|
+
return interleaveRscScriptsWithHtml(htmlStream, rscClientStream, {
|
|
781
|
+
bootstrapModuleScripts,
|
|
782
|
+
lateControl,
|
|
783
|
+
onComplete,
|
|
784
|
+
onCancel,
|
|
785
|
+
request,
|
|
305
786
|
});
|
|
306
787
|
}
|
|
307
788
|
}
|