akanjs 2.2.12 → 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/package.json +1 -1
- package/server/akanApp.ts +55 -0
- package/server/rscWorker.tsx +145 -39
- package/server/rscWorkerHost.ts +235 -66
- package/server/rscWorkerReplay.ts +35 -0
- package/server/ssrFromRscRenderer.tsx +448 -75
- package/server/ssrTypes.ts +9 -0
- package/server/webRouter.ts +91 -10
- package/service/ipcTypes.ts +1 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/server/rscWorkerHost.d.ts +29 -0
- package/types/server/rscWorkerReplay.d.ts +23 -0
- package/types/server/ssrFromRscRenderer.d.ts +18 -1
- package/types/server/ssrTypes.d.ts +8 -0
- package/types/server/webRouter.d.ts +6 -1
- package/types/service/ipcTypes.d.ts +1 -0
- package/ui/Link/SsrLink.tsx +0 -2
|
@@ -3,9 +3,10 @@ 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
7
|
|
|
8
8
|
const DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES = 1024;
|
|
9
|
+
const DEFAULT_MAX_PENDING_INLINE_RSC_SCRIPTS = 32;
|
|
9
10
|
|
|
10
11
|
interface SsrChunkRegistryEntry<T> {
|
|
11
12
|
keys: Set<string>;
|
|
@@ -108,6 +109,415 @@ export function createInlineRscScript(chunk: Uint8Array): string {
|
|
|
108
109
|
return `<script>self.__RSC_PUSH__(${type},${htmlEscapeJsonString(data)})</script>`;
|
|
109
110
|
}
|
|
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
|
+
}
|
|
520
|
+
|
|
111
521
|
export class SsrFromRscRenderer {
|
|
112
522
|
static readonly #chunkRegistryStats: SsrChunkRegistryStats = {
|
|
113
523
|
ssrChunkRegistrySize: 0,
|
|
@@ -160,8 +570,12 @@ export class SsrFromRscRenderer {
|
|
|
160
570
|
|
|
161
571
|
const [rscForSsr, rscForClient] = input.rscStream.tee();
|
|
162
572
|
|
|
163
|
-
const ssrNodeStream = Readable.fromWeb(rscForSsr as never);
|
|
164
|
-
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
|
+
);
|
|
165
579
|
|
|
166
580
|
function Root(): ReactNode {
|
|
167
581
|
return use(thenable);
|
|
@@ -190,6 +604,9 @@ export class SsrFromRscRenderer {
|
|
|
190
604
|
SsrFromRscRenderer.#sanitizeFlightForClient(rscForClient),
|
|
191
605
|
input.bootstrapModules,
|
|
192
606
|
input.request,
|
|
607
|
+
input.lateControl,
|
|
608
|
+
() => stderrSuppressor?.stop(),
|
|
609
|
+
input.onCancel,
|
|
193
610
|
);
|
|
194
611
|
}
|
|
195
612
|
|
|
@@ -224,6 +641,24 @@ export class SsrFromRscRenderer {
|
|
|
224
641
|
};
|
|
225
642
|
}
|
|
226
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
|
+
|
|
227
662
|
static #getSsrChunkRegistryMaxEntries(): number {
|
|
228
663
|
const parsed = Number.parseInt(process.env.AKAN_SSR_CHUNK_REGISTRY_MAX_ENTRIES ?? "", 10);
|
|
229
664
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES;
|
|
@@ -328,27 +763,7 @@ export class SsrFromRscRenderer {
|
|
|
328
763
|
}
|
|
329
764
|
|
|
330
765
|
static #sanitizeFlightForClient(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
331
|
-
|
|
332
|
-
const encoder = new TextEncoder();
|
|
333
|
-
|
|
334
|
-
const hlStylesheetRe = /(:HL\["[^"\\]*(?:\\.[^"\\]*)*",)"stylesheet"(\])/g;
|
|
335
|
-
|
|
336
|
-
return stream.pipeThrough(
|
|
337
|
-
new TransformStream<Uint8Array, Uint8Array>({
|
|
338
|
-
transform(chunk, controller) {
|
|
339
|
-
const text = decoder.decode(chunk, { stream: true });
|
|
340
|
-
if (!text.includes(`"stylesheet"`)) {
|
|
341
|
-
controller.enqueue(chunk);
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
controller.enqueue(encoder.encode(text.replace(hlStylesheetRe, `$1"style"$2`)));
|
|
345
|
-
},
|
|
346
|
-
flush(controller) {
|
|
347
|
-
const tail = decoder.decode();
|
|
348
|
-
if (tail) controller.enqueue(encoder.encode(tail));
|
|
349
|
-
},
|
|
350
|
-
}),
|
|
351
|
-
);
|
|
766
|
+
return sanitizeFlightForClientStream(stream);
|
|
352
767
|
}
|
|
353
768
|
|
|
354
769
|
static #appendRscScriptsAfterHtml(
|
|
@@ -356,60 +771,18 @@ export class SsrFromRscRenderer {
|
|
|
356
771
|
rscClientStream: ReadableStream<Uint8Array>,
|
|
357
772
|
bootstrapModules?: string[],
|
|
358
773
|
request?: Request,
|
|
774
|
+
lateControl?: Promise<SsrLateRedirect | null>,
|
|
775
|
+
onComplete?: () => void,
|
|
776
|
+
onCancel?: (reason?: unknown) => void,
|
|
359
777
|
): ReadableStream<Uint8Array> {
|
|
360
|
-
const encoder = new TextEncoder();
|
|
361
778
|
const bootstrapModuleScripts = SsrFromRscRenderer.#createBootstrapModuleScriptTags(bootstrapModules);
|
|
362
779
|
|
|
363
|
-
return
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
controller.error(err);
|
|
370
|
-
};
|
|
371
|
-
|
|
372
|
-
const pump = async () => {
|
|
373
|
-
const reader = htmlStream.getReader();
|
|
374
|
-
try {
|
|
375
|
-
while (true) {
|
|
376
|
-
const { value, done } = await reader.read();
|
|
377
|
-
if (done) break;
|
|
378
|
-
if (errored) return;
|
|
379
|
-
controller.enqueue(value);
|
|
380
|
-
}
|
|
381
|
-
} finally {
|
|
382
|
-
reader.releaseLock();
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
if (bootstrapModuleScripts && !errored) controller.enqueue(encoder.encode(bootstrapModuleScripts));
|
|
386
|
-
|
|
387
|
-
const rscReader = rscClientStream.getReader();
|
|
388
|
-
try {
|
|
389
|
-
while (true) {
|
|
390
|
-
const { value, done } = await rscReader.read();
|
|
391
|
-
if (done) break;
|
|
392
|
-
if (errored) return;
|
|
393
|
-
controller.enqueue(encoder.encode(createInlineRscScript(value)));
|
|
394
|
-
}
|
|
395
|
-
} finally {
|
|
396
|
-
rscReader.releaseLock();
|
|
397
|
-
}
|
|
398
|
-
if (!errored) {
|
|
399
|
-
controller.enqueue(encoder.encode(`<script>self.__RSC_CLOSE__()</script>`));
|
|
400
|
-
controller.close();
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const runPump = () => {
|
|
405
|
-
const cleanup = request ? pushRequestFallback(request) : undefined;
|
|
406
|
-
return pump()
|
|
407
|
-
.catch(fail)
|
|
408
|
-
.finally(() => cleanup?.());
|
|
409
|
-
};
|
|
410
|
-
if (request && requestStorage) void requestStorage.run(request, runPump);
|
|
411
|
-
else void runPump();
|
|
412
|
-
},
|
|
780
|
+
return interleaveRscScriptsWithHtml(htmlStream, rscClientStream, {
|
|
781
|
+
bootstrapModuleScripts,
|
|
782
|
+
lateControl,
|
|
783
|
+
onComplete,
|
|
784
|
+
onCancel,
|
|
785
|
+
request,
|
|
413
786
|
});
|
|
414
787
|
}
|
|
415
788
|
}
|
package/server/ssrTypes.ts
CHANGED
|
@@ -19,6 +19,13 @@ export interface SsrChunkRegistryStats {
|
|
|
19
19
|
ssrChunkEvictionCount: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface SsrLateRedirect {
|
|
23
|
+
type: "redirect";
|
|
24
|
+
location: string;
|
|
25
|
+
method: "replace" | "push";
|
|
26
|
+
status: 303 | 307 | 308;
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
export interface SsrFromRscInput {
|
|
23
30
|
request?: Request;
|
|
24
31
|
rscStream: ReadableStream<Uint8Array>;
|
|
@@ -45,4 +52,6 @@ export interface SsrFromRscInput {
|
|
|
45
52
|
importmap?: Record<string, string>;
|
|
46
53
|
theme?: AkanTheme;
|
|
47
54
|
injectThemeInitScript?: boolean;
|
|
55
|
+
lateControl?: Promise<SsrLateRedirect | null>;
|
|
56
|
+
onCancel?: (reason?: unknown) => void;
|
|
48
57
|
}
|