akanjs 2.2.12 → 2.2.13-rc.1

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.
Files changed (38) hide show
  1. package/client/csrTypes.ts +37 -6
  2. package/client/makePageProto.tsx +8 -8
  3. package/client/router.ts +5 -2
  4. package/fetch/requestStorage.ts +41 -11
  5. package/package.json +1 -1
  6. package/server/akanApp.ts +55 -0
  7. package/server/cachePolicy.ts +192 -0
  8. package/server/metadata.tsx +114 -0
  9. package/server/routeElementComposer.tsx +21 -1
  10. package/server/routeTreeBuilder.ts +44 -5
  11. package/server/rscClient.tsx +127 -50
  12. package/server/rscHttp.ts +120 -0
  13. package/server/rscNavigationState.ts +95 -0
  14. package/server/rscWorker.tsx +318 -121
  15. package/server/rscWorkerHost.ts +281 -66
  16. package/server/rscWorkerReplay.ts +40 -0
  17. package/server/ssrFromRscRenderer.tsx +462 -77
  18. package/server/ssrTypes.ts +11 -1
  19. package/server/webRouter.ts +173 -88
  20. package/service/ipcTypes.ts +1 -0
  21. package/types/client/csrTypes.d.ts +37 -6
  22. package/types/dictionary/base.dictionary.d.ts +1 -1
  23. package/types/dictionary/dictionary.d.ts +8 -8
  24. package/types/fetch/requestStorage.d.ts +16 -6
  25. package/types/server/cachePolicy.d.ts +55 -0
  26. package/types/server/metadata.d.ts +13 -0
  27. package/types/server/routeElementComposer.d.ts +6 -1
  28. package/types/server/rscHttp.d.ts +16 -0
  29. package/types/server/rscNavigationState.d.ts +35 -0
  30. package/types/server/rscWorkerHost.d.ts +38 -0
  31. package/types/server/rscWorkerReplay.d.ts +29 -0
  32. package/types/server/ssrFromRscRenderer.d.ts +20 -1
  33. package/types/server/ssrTypes.d.ts +10 -1
  34. package/types/server/webRouter.d.ts +27 -1
  35. package/types/service/ipcTypes.d.ts +1 -0
  36. package/types/webkit/useCsrValues.d.ts +1 -1
  37. package/ui/Link/SsrLink.tsx +0 -2
  38. package/webkit/bootCsr.tsx +16 -2
@@ -1,11 +1,12 @@
1
1
  import { Readable } from "node:stream";
2
- import { type AkanTheme, pushRequestFallback, requestStorage } from "akanjs/fetch";
2
+ import { type AkanRequestStore, type AkanTheme, pushRequestFallback, requestStorage } from "akanjs/fetch";
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,423 @@ export function createInlineRscScript(chunk: Uint8Array): string {
108
109
  return `<script>self.__RSC_PUSH__(${type},${htmlEscapeJsonString(data)})</script>`;
109
110
  }
110
111
 
112
+ function escapeHtmlAttr(value: string): string {
113
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
114
+ }
115
+
116
+ export function createSoftRedirectScript(redirect: SsrLateRedirect): string {
117
+ const method = redirect.method === "push" ? "assign" : "replace";
118
+ const fallback = `<noscript><meta http-equiv="refresh" content="0;url=${escapeHtmlAttr(redirect.location)}"></noscript>`;
119
+ return `${fallback}<script>window.location.${method}(${htmlEscapeJsonString(redirect.location)})</script>`;
120
+ }
121
+
122
+ function sanitizeFlightRows(
123
+ stream: ReadableStream<Uint8Array>,
124
+ options: { rewriteStylesheetHints?: boolean } = {},
125
+ ): ReadableStream<Uint8Array> {
126
+ const decoder = new TextDecoder("utf-8", { fatal: true });
127
+ const encoder = new TextEncoder();
128
+ const hlStylesheetRe = /(:HL\["[^"\\]*(?:\\.[^"\\]*)*",)"stylesheet"(\])/g;
129
+ const redirectErrorRowRe = /^([0-9a-z]+):E(\{[^\n]*"digest":"AKAN_REDIRECT(?:;[^"]*)?"[^\n]*\})(\n?)$/;
130
+ let buffered: Uint8Array<ArrayBuffer> = new Uint8Array(0);
131
+
132
+ const concatBytes = (left: Uint8Array, right: Uint8Array): Uint8Array<ArrayBuffer> => {
133
+ const combined = new Uint8Array(left.byteLength + right.byteLength);
134
+ combined.set(left, 0);
135
+ combined.set(right, left.byteLength);
136
+ return combined;
137
+ };
138
+
139
+ const sanitizeRow = (row: Uint8Array): Uint8Array => {
140
+ let text: string;
141
+ try {
142
+ text = decoder.decode(row);
143
+ } catch {
144
+ return row;
145
+ }
146
+ const sanitized = (options.rewriteStylesheetHints ? text.replace(hlStylesheetRe, `$1"style"$2`) : text).replace(
147
+ redirectErrorRowRe,
148
+ "$1:null$3",
149
+ );
150
+ return sanitized === text ? row : encoder.encode(sanitized);
151
+ };
152
+
153
+ const enqueueCompleteRows = (chunk: Uint8Array, controller: TransformStreamDefaultController<Uint8Array>) => {
154
+ buffered = concatBytes(buffered, chunk);
155
+ let rowStart = 0;
156
+ for (let index = 0; index < buffered.byteLength; index += 1) {
157
+ if (buffered[index] !== 10) continue;
158
+ controller.enqueue(sanitizeRow(buffered.slice(rowStart, index + 1)));
159
+ rowStart = index + 1;
160
+ }
161
+ buffered = rowStart === 0 ? buffered : buffered.slice(rowStart);
162
+ };
163
+
164
+ return stream.pipeThrough(
165
+ new TransformStream<Uint8Array, Uint8Array>({
166
+ transform(chunk, controller) {
167
+ enqueueCompleteRows(chunk, controller);
168
+ },
169
+ flush(controller) {
170
+ if (buffered.byteLength > 0) {
171
+ controller.enqueue(sanitizeRow(buffered));
172
+ buffered = new Uint8Array(0);
173
+ }
174
+ },
175
+ }),
176
+ );
177
+ }
178
+
179
+ export function sanitizeFlightForClientStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
180
+ return sanitizeFlightRows(stream, { rewriteStylesheetHints: true });
181
+ }
182
+
183
+ type StderrWrite = typeof process.stderr.write;
184
+
185
+ export class ExpectedLateRedirectStderrSuppressor {
186
+ static #active = new Set<ExpectedLateRedirectStderrSuppressor>();
187
+ static #originalWrite: StderrWrite | null = null;
188
+ static #buffer = "";
189
+ static #flushTimer: ReturnType<typeof setTimeout> | null = null;
190
+ static #suppressingBenignBlock = false;
191
+ static readonly #maxBufferLength = 64 * 1024;
192
+ #stopped = false;
193
+ #lateRedirect = false;
194
+ #lateControlSettled = false;
195
+
196
+ private constructor(lateControl: Promise<SsrLateRedirect | null>) {
197
+ lateControl
198
+ .then((control) => {
199
+ this.#lateRedirect = control?.type === "redirect";
200
+ })
201
+ .catch(() => {
202
+ this.#lateRedirect = false;
203
+ })
204
+ .finally(() => {
205
+ this.#lateControlSettled = true;
206
+ ExpectedLateRedirectStderrSuppressor.#tryResolveBufferedOutput();
207
+ });
208
+ }
209
+
210
+ static start(lateControl?: Promise<SsrLateRedirect | null>): ExpectedLateRedirectStderrSuppressor | null {
211
+ if (!lateControl) return null;
212
+
213
+ if (process.env.NODE_ENV === "production" && process.env.AKAN_SUPPRESS_LATE_REDIRECT_STDERR !== "1") return null;
214
+ const suppressor = new ExpectedLateRedirectStderrSuppressor(lateControl);
215
+ ExpectedLateRedirectStderrSuppressor.#active.add(suppressor);
216
+ ExpectedLateRedirectStderrSuppressor.#install();
217
+ return suppressor;
218
+ }
219
+
220
+ stop(): void {
221
+ if (this.#stopped) return;
222
+ this.#stopped = true;
223
+ ExpectedLateRedirectStderrSuppressor.#active.delete(this);
224
+ ExpectedLateRedirectStderrSuppressor.#tryResolveBufferedOutput();
225
+ if (ExpectedLateRedirectStderrSuppressor.#active.size === 0) ExpectedLateRedirectStderrSuppressor.#uninstall();
226
+ }
227
+
228
+ static #install(): void {
229
+ if (ExpectedLateRedirectStderrSuppressor.#originalWrite) return;
230
+ ExpectedLateRedirectStderrSuppressor.#originalWrite = process.stderr.write;
231
+ process.stderr.write = ((chunk: unknown, ...args: unknown[]) => {
232
+ ExpectedLateRedirectStderrSuppressor.#write(chunk, args);
233
+ return true;
234
+ }) as StderrWrite;
235
+ }
236
+
237
+ static #uninstall(): void {
238
+ ExpectedLateRedirectStderrSuppressor.#flushBufferedOutput();
239
+ if (!ExpectedLateRedirectStderrSuppressor.#originalWrite) return;
240
+ process.stderr.write = ExpectedLateRedirectStderrSuppressor.#originalWrite;
241
+ ExpectedLateRedirectStderrSuppressor.#originalWrite = null;
242
+ }
243
+
244
+ static #write(chunk: unknown, args: unknown[]): void {
245
+ const text =
246
+ typeof chunk === "string" ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);
247
+ ExpectedLateRedirectStderrSuppressor.#buffer += text;
248
+ const callback = args.find((arg): arg is () => void => typeof arg === "function");
249
+ callback?.();
250
+
251
+ if (ExpectedLateRedirectStderrSuppressor.#buffer.length > ExpectedLateRedirectStderrSuppressor.#maxBufferLength) {
252
+ ExpectedLateRedirectStderrSuppressor.#flushBufferedOutput();
253
+ return;
254
+ }
255
+ ExpectedLateRedirectStderrSuppressor.#tryResolveBufferedOutput();
256
+ }
257
+
258
+ static #tryResolveBufferedOutput(): void {
259
+ if (!ExpectedLateRedirectStderrSuppressor.#buffer) return;
260
+ const hasBenignClose = ExpectedLateRedirectStderrSuppressor.#isBenignRsdwConnectionClose(
261
+ ExpectedLateRedirectStderrSuppressor.#buffer,
262
+ );
263
+ if (hasBenignClose && ExpectedLateRedirectStderrSuppressor.#hasLateRedirectOrPending()) {
264
+ if (!ExpectedLateRedirectStderrSuppressor.#hasLateRedirect()) {
265
+ ExpectedLateRedirectStderrSuppressor.#scheduleFlush();
266
+ return;
267
+ }
268
+ ExpectedLateRedirectStderrSuppressor.#suppressingBenignBlock = true;
269
+ }
270
+
271
+ if (ExpectedLateRedirectStderrSuppressor.#suppressingBenignBlock) {
272
+ if (ExpectedLateRedirectStderrSuppressor.#buffer.includes("\n\n")) {
273
+ ExpectedLateRedirectStderrSuppressor.#clearBufferedOutput();
274
+ ExpectedLateRedirectStderrSuppressor.#suppressingBenignBlock = false;
275
+ }
276
+ return;
277
+ }
278
+
279
+ ExpectedLateRedirectStderrSuppressor.#scheduleFlush();
280
+ }
281
+
282
+ static #scheduleFlush(): void {
283
+ if (ExpectedLateRedirectStderrSuppressor.#flushTimer) return;
284
+ ExpectedLateRedirectStderrSuppressor.#flushTimer = setTimeout(() => {
285
+ ExpectedLateRedirectStderrSuppressor.#flushTimer = null;
286
+ if (
287
+ ExpectedLateRedirectStderrSuppressor.#isBenignRsdwConnectionClose(
288
+ ExpectedLateRedirectStderrSuppressor.#buffer,
289
+ ) &&
290
+ ExpectedLateRedirectStderrSuppressor.#hasLateRedirect()
291
+ ) {
292
+ ExpectedLateRedirectStderrSuppressor.#clearBufferedOutput();
293
+ return;
294
+ }
295
+ ExpectedLateRedirectStderrSuppressor.#flushBufferedOutput();
296
+ }, 25);
297
+ }
298
+
299
+ static #flushBufferedOutput(): void {
300
+ if (!ExpectedLateRedirectStderrSuppressor.#buffer) return;
301
+ const text = ExpectedLateRedirectStderrSuppressor.#buffer;
302
+ ExpectedLateRedirectStderrSuppressor.#clearBufferedOutput();
303
+ ExpectedLateRedirectStderrSuppressor.#originalWrite?.call(process.stderr, text);
304
+ }
305
+
306
+ static #clearBufferedOutput(): void {
307
+ ExpectedLateRedirectStderrSuppressor.#buffer = "";
308
+ if (ExpectedLateRedirectStderrSuppressor.#flushTimer) {
309
+ clearTimeout(ExpectedLateRedirectStderrSuppressor.#flushTimer);
310
+ ExpectedLateRedirectStderrSuppressor.#flushTimer = null;
311
+ }
312
+ }
313
+
314
+ static #isBenignRsdwConnectionClose(text: string): boolean {
315
+ return (
316
+ text.includes("Connection closed.") &&
317
+ (text.includes("react-server-dom-webpack-client.node") || text.includes("reportGlobalError"))
318
+ );
319
+ }
320
+
321
+ static #hasLateRedirect(): boolean {
322
+ return [...ExpectedLateRedirectStderrSuppressor.#active].some((suppressor) => suppressor.#lateRedirect);
323
+ }
324
+
325
+ static #hasLateRedirectOrPending(): boolean {
326
+ return [...ExpectedLateRedirectStderrSuppressor.#active].some(
327
+ (suppressor) => suppressor.#lateRedirect || !suppressor.#lateControlSettled,
328
+ );
329
+ }
330
+ }
331
+
332
+ export function interleaveRscScriptsWithHtml(
333
+ htmlStream: ReadableStream<Uint8Array>,
334
+ rscClientStream: ReadableStream<Uint8Array>,
335
+ options: {
336
+ bootstrapModuleScripts?: string;
337
+ lateControl?: Promise<SsrLateRedirect | null>;
338
+ maxPendingRscScripts?: number;
339
+ onPendingRscScriptsSize?: (size: number) => void;
340
+ onComplete?: () => void;
341
+ onCancel?: (reason?: unknown) => void;
342
+ request?: Request;
343
+ requestStore?: AkanRequestStore;
344
+ } = {},
345
+ ): ReadableStream<Uint8Array> {
346
+ const encoder = new TextEncoder();
347
+ const bootstrapDetector = new InlineBootstrapDetector();
348
+ const pendingRscScripts: Uint8Array[] = [];
349
+ const pendingControlScripts: Uint8Array[] = [];
350
+ const maxPendingRscScripts = SsrFromRscRendererConfig.maxPendingInlineRscScripts(options.maxPendingRscScripts);
351
+ const queueDrainResolvers: Array<() => void> = [];
352
+ const scriptAvailableResolvers: Array<() => void> = [];
353
+ let errored = false;
354
+ let rscDone = false;
355
+ let lateControlDone = !options.lateControl;
356
+ let htmlReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
357
+ let rscReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
358
+ let cancelled = false;
359
+
360
+ const cancelUpstream = (reason?: unknown) => {
361
+ if (cancelled) return;
362
+ cancelled = true;
363
+ while (queueDrainResolvers.length > 0) queueDrainResolvers.shift()?.();
364
+ while (scriptAvailableResolvers.length > 0) scriptAvailableResolvers.shift()?.();
365
+ if (htmlReader) void htmlReader.cancel(reason).catch(() => {});
366
+ if (rscReader) void rscReader.cancel(reason).catch(() => {});
367
+ options.onCancel?.(reason);
368
+ };
369
+
370
+ return new ReadableStream<Uint8Array>({
371
+ start(controller) {
372
+ const fail = (err: unknown) => {
373
+ if (errored) return;
374
+ errored = true;
375
+ cancelUpstream(err);
376
+ controller.error(err);
377
+ };
378
+
379
+ const flushPendingRscScripts = () => {
380
+ if (!bootstrapDetector.canFlushInlineScripts) return;
381
+ while (!errored && pendingControlScripts.length > 0) {
382
+ const script = pendingControlScripts.shift();
383
+ if (script) controller.enqueue(script);
384
+ }
385
+ while (!errored && pendingRscScripts.length > 0) {
386
+ const script = pendingRscScripts.shift();
387
+ if (script) controller.enqueue(script);
388
+ }
389
+ options.onPendingRscScriptsSize?.(pendingRscScripts.length);
390
+ while (queueDrainResolvers.length > 0) queueDrainResolvers.shift()?.();
391
+ };
392
+
393
+ const notifyScriptAvailable = () => {
394
+ while (scriptAvailableResolvers.length > 0) scriptAvailableResolvers.shift()?.();
395
+ };
396
+
397
+ const waitForRscQueueDrain = async () => {
398
+ while (!errored && pendingRscScripts.length >= maxPendingRscScripts) {
399
+ await new Promise<void>((resolve) => queueDrainResolvers.push(resolve));
400
+ }
401
+ };
402
+
403
+ const waitForScriptAvailable = () => new Promise<void>((resolve) => scriptAvailableResolvers.push(resolve));
404
+
405
+ const pumpRscScripts = async () => {
406
+ rscReader = rscClientStream.getReader();
407
+ try {
408
+ while (true) {
409
+ const { value, done } = await rscReader.read();
410
+ if (done || errored) break;
411
+ await waitForRscQueueDrain();
412
+ if (errored) break;
413
+ pendingRscScripts.push(encoder.encode(createInlineRscScript(value)));
414
+ options.onPendingRscScriptsSize?.(pendingRscScripts.length);
415
+ notifyScriptAvailable();
416
+ }
417
+ } finally {
418
+ rscDone = true;
419
+ notifyScriptAvailable();
420
+ rscReader.releaseLock();
421
+ rscReader = null;
422
+ }
423
+ };
424
+
425
+ const pump = async () => {
426
+ const rscPump = pumpRscScripts();
427
+ const lateControlPump = options.lateControl?.then((control) => {
428
+ try {
429
+ if (!control || errored) return;
430
+ pendingControlScripts.push(encoder.encode(createSoftRedirectScript(control)));
431
+ notifyScriptAvailable();
432
+ } finally {
433
+ lateControlDone = true;
434
+ notifyScriptAvailable();
435
+ }
436
+ });
437
+ if (!lateControlPump) lateControlDone = true;
438
+ void rscPump.catch(fail);
439
+ void lateControlPump?.catch(fail);
440
+
441
+ htmlReader = htmlStream.getReader();
442
+ try {
443
+ while (true) {
444
+ const { value, done } = await htmlReader.read();
445
+ if (done || errored) break;
446
+ controller.enqueue(value);
447
+ bootstrapDetector.observe(value);
448
+ flushPendingRscScripts();
449
+ }
450
+ } finally {
451
+ htmlReader.releaseLock();
452
+ htmlReader = null;
453
+ }
454
+
455
+ if (errored) return;
456
+ if (options.bootstrapModuleScripts) controller.enqueue(encoder.encode(options.bootstrapModuleScripts));
457
+ bootstrapDetector.forceAllow();
458
+ while (
459
+ !errored &&
460
+ (!rscDone || !lateControlDone || pendingControlScripts.length > 0 || pendingRscScripts.length > 0)
461
+ ) {
462
+ flushPendingRscScripts();
463
+ if (!rscDone || !lateControlDone) await waitForScriptAvailable();
464
+ }
465
+ await Promise.all([rscPump, lateControlPump]);
466
+ if (errored) return;
467
+ flushPendingRscScripts();
468
+ controller.enqueue(encoder.encode(`<script>self.__RSC_CLOSE__()</script>`));
469
+ controller.close();
470
+ };
471
+
472
+ const runPump = () => {
473
+ const requestContext = options.requestStore ?? options.request;
474
+ const cleanup = requestContext ? pushRequestFallback(requestContext) : undefined;
475
+ return pump()
476
+ .catch(fail)
477
+ .finally(() => {
478
+ cleanup?.();
479
+ options.onComplete?.();
480
+ });
481
+ };
482
+ const requestContext = options.requestStore ?? options.request;
483
+ if (requestContext && requestStorage) void requestStorage.run(requestContext, runPump);
484
+ else void runPump();
485
+ },
486
+ cancel(reason) {
487
+ errored = true;
488
+ cancelUpstream(reason);
489
+ options.onComplete?.();
490
+ },
491
+ });
492
+ }
493
+
494
+ class SsrFromRscRendererConfig {
495
+ static maxPendingInlineRscScripts(explicit?: number): number {
496
+ if (explicit !== undefined && Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
497
+ const parsed = Number.parseInt(process.env.AKAN_MAX_PENDING_INLINE_RSC_SCRIPTS ?? "", 10);
498
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_PENDING_INLINE_RSC_SCRIPTS;
499
+ }
500
+ }
501
+
502
+ class InlineBootstrapDetector {
503
+ readonly #decoder = new TextDecoder();
504
+ #buffer = "";
505
+ #bootstrapSeen = false;
506
+ #canFlushInlineScripts = false;
507
+
508
+ get canFlushInlineScripts(): boolean {
509
+ return this.#canFlushInlineScripts;
510
+ }
511
+
512
+ observe(chunk: Uint8Array): void {
513
+ if (this.#canFlushInlineScripts) return;
514
+ this.#buffer = `${this.#buffer}${this.#decoder.decode(chunk, { stream: true })}`.slice(-8192);
515
+ if (!this.#bootstrapSeen) {
516
+ const bootstrapIndex = this.#buffer.indexOf("__RSC_PUSH__");
517
+ if (bootstrapIndex === -1) return;
518
+ this.#bootstrapSeen = true;
519
+ this.#buffer = this.#buffer.slice(bootstrapIndex);
520
+ }
521
+ if (this.#buffer.toLowerCase().includes("</script>")) this.#canFlushInlineScripts = true;
522
+ }
523
+
524
+ forceAllow(): void {
525
+ this.#canFlushInlineScripts = true;
526
+ }
527
+ }
528
+
111
529
  export class SsrFromRscRenderer {
112
530
  static readonly #chunkRegistryStats: SsrChunkRegistryStats = {
113
531
  ssrChunkRegistrySize: 0,
@@ -160,8 +578,12 @@ export class SsrFromRscRenderer {
160
578
 
161
579
  const [rscForSsr, rscForClient] = input.rscStream.tee();
162
580
 
163
- const ssrNodeStream = Readable.fromWeb(rscForSsr as never);
164
- const thenable = createFromNodeStream(ssrNodeStream, input.ssrManifest) as Promise<ReactNode>;
581
+ const ssrNodeStream = Readable.fromWeb(sanitizeFlightRows(rscForSsr) as never);
582
+ const stderrSuppressor = ExpectedLateRedirectStderrSuppressor.start(input.lateControl);
583
+ const thenable = SsrFromRscRenderer.#suppressExpectedLateRedirectError(
584
+ createFromNodeStream(ssrNodeStream, input.ssrManifest),
585
+ input.lateControl,
586
+ );
165
587
 
166
588
  function Root(): ReactNode {
167
589
  return use(thenable);
@@ -175,8 +597,9 @@ export class SsrFromRscRenderer {
175
597
  renderToReadableStream(<Root />, {
176
598
  bootstrapScriptContent: bootstrap,
177
599
  });
600
+ const requestContext = input.requestStore ?? input.request;
178
601
  const htmlStream =
179
- input.request && requestStorage ? await requestStorage.run(input.request, renderHtml) : await renderHtml();
602
+ requestContext && requestStorage ? await requestStorage.run(requestContext, renderHtml) : await renderHtml();
180
603
 
181
604
  const withHeadScripts = SsrFromRscRenderer.#injectHeadScriptsIntoHead(htmlStream, {
182
605
  importmap: input.importmap,
@@ -190,6 +613,10 @@ export class SsrFromRscRenderer {
190
613
  SsrFromRscRenderer.#sanitizeFlightForClient(rscForClient),
191
614
  input.bootstrapModules,
192
615
  input.request,
616
+ input.requestStore,
617
+ input.lateControl,
618
+ () => stderrSuppressor?.stop(),
619
+ input.onCancel,
193
620
  );
194
621
  }
195
622
 
@@ -224,6 +651,24 @@ export class SsrFromRscRenderer {
224
651
  };
225
652
  }
226
653
 
654
+ static #suppressExpectedLateRedirectError(
655
+ thenable: PromiseLike<ReactNode>,
656
+ lateControl?: Promise<SsrLateRedirect | null>,
657
+ ): Promise<ReactNode> {
658
+ const promise = Promise.resolve(thenable);
659
+ if (!lateControl) return promise;
660
+ return promise.catch(async (error) => {
661
+ const control = await lateControl;
662
+ if (control?.type === "redirect" && SsrFromRscRenderer.#isExpectedLateRedirectError(error)) return null;
663
+ throw error;
664
+ });
665
+ }
666
+
667
+ static #isExpectedLateRedirectError(error: unknown): boolean {
668
+ if (!(error instanceof Error)) return false;
669
+ return error.message === "Connection closed." || error.name === "AkanRedirectError";
670
+ }
671
+
227
672
  static #getSsrChunkRegistryMaxEntries(): number {
228
673
  const parsed = Number.parseInt(process.env.AKAN_SSR_CHUNK_REGISTRY_MAX_ENTRIES ?? "", 10);
229
674
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SSR_CHUNK_REGISTRY_MAX_ENTRIES;
@@ -328,27 +773,7 @@ export class SsrFromRscRenderer {
328
773
  }
329
774
 
330
775
  static #sanitizeFlightForClient(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
331
- const decoder = new TextDecoder();
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
- );
776
+ return sanitizeFlightForClientStream(stream);
352
777
  }
353
778
 
354
779
  static #appendRscScriptsAfterHtml(
@@ -356,60 +781,20 @@ export class SsrFromRscRenderer {
356
781
  rscClientStream: ReadableStream<Uint8Array>,
357
782
  bootstrapModules?: string[],
358
783
  request?: Request,
784
+ requestStore?: AkanRequestStore,
785
+ lateControl?: Promise<SsrLateRedirect | null>,
786
+ onComplete?: () => void,
787
+ onCancel?: (reason?: unknown) => void,
359
788
  ): ReadableStream<Uint8Array> {
360
- const encoder = new TextEncoder();
361
789
  const bootstrapModuleScripts = SsrFromRscRenderer.#createBootstrapModuleScriptTags(bootstrapModules);
362
790
 
363
- return new ReadableStream<Uint8Array>({
364
- start(controller) {
365
- let errored = false;
366
- const fail = (err: unknown) => {
367
- if (errored) return;
368
- errored = true;
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
- },
791
+ return interleaveRscScriptsWithHtml(htmlStream, rscClientStream, {
792
+ bootstrapModuleScripts,
793
+ lateControl,
794
+ onComplete,
795
+ onCancel,
796
+ request,
797
+ requestStore,
413
798
  });
414
799
  }
415
800
  }
@@ -1,4 +1,4 @@
1
- import type { AkanTheme } from "akanjs/fetch";
1
+ import type { AkanRequestStore, AkanTheme } from "akanjs/fetch";
2
2
 
3
3
  export interface SsrManifestEntry {
4
4
  id: string;
@@ -19,8 +19,16 @@ 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;
31
+ requestStore?: AkanRequestStore;
24
32
  rscStream: ReadableStream<Uint8Array>;
25
33
  ssrManifest: SsrManifest;
26
34
  bootstrapModules?: string[];
@@ -45,4 +53,6 @@ export interface SsrFromRscInput {
45
53
  importmap?: Record<string, string>;
46
54
  theme?: AkanTheme;
47
55
  injectThemeInitScript?: boolean;
56
+ lateControl?: Promise<SsrLateRedirect | null>;
57
+ onCancel?: (reason?: unknown) => void;
48
58
  }