@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.146

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 (35) hide show
  1. package/dist/bin/rango.js +1 -40
  2. package/dist/vite/index.js +35 -9
  3. package/package.json +1 -1
  4. package/skills/ppr/SKILL.md +29 -23
  5. package/src/browser/logging.ts +18 -0
  6. package/src/browser/rsc-router.tsx +43 -0
  7. package/src/cache/cache-runtime.ts +41 -51
  8. package/src/cache/cache-scope.ts +30 -1
  9. package/src/cache/cf/cf-cache-store.ts +4 -0
  10. package/src/cache/handle-snapshot.ts +22 -1
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +31 -4
  13. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/router/loader-resolution.ts +16 -0
  16. package/src/router/match-api.ts +9 -2
  17. package/src/router/match-handlers.ts +13 -0
  18. package/src/router/segment-resolution/loader-cache.ts +19 -3
  19. package/src/router/segment-resolution/loader-mask.ts +4 -11
  20. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  21. package/src/router/segment-resolution/mask-nested.ts +99 -0
  22. package/src/rsc/rsc-rendering.ts +139 -16
  23. package/src/rsc/shell-capture.ts +122 -0
  24. package/src/rsc/shell-serve.ts +37 -6
  25. package/src/segment-loader-promise.ts +18 -0
  26. package/src/segment-system.tsx +123 -9
  27. package/src/server/request-context.ts +47 -0
  28. package/src/ssr/index.tsx +118 -18
  29. package/src/ssr/inject-rsc-eager.ts +167 -0
  30. package/src/ssr/preinit-client-references.ts +106 -0
  31. package/src/vite/index.ts +8 -0
  32. package/src/vite/plugin-types.ts +33 -0
  33. package/src/vite/plugins/virtual-entries.ts +37 -4
  34. package/src/vite/rango.ts +10 -2
  35. package/src/vite/utils/shared-utils.ts +4 -2
@@ -17,6 +17,28 @@ import {
17
17
  getMemoizedLoaderPromise,
18
18
  } from "./segment-loader-promise.js";
19
19
 
20
+ /**
21
+ * Debug log for the segment tree build, gated on the baked flag. Runs on BOTH
22
+ * sides now, environment-tagged: `[Browser][segments]` lines up with the
23
+ * `[Browser][boot]` sequence around hydrateRoot; `[Server][segments]` exposes
24
+ * the SSR/RSC tree-build stalls (blocking loader awaits during fizz are what
25
+ * dominate MISS TTFB) that used to be invisible because the logs were
26
+ * window-gated. Server lines have no request correlation — segment-system is
27
+ * shared client code and cannot import request-context (node:async_hooks
28
+ * would enter the browser bundle) — so on a busy server, correlate by
29
+ * timestamp + segment ids.
30
+ */
31
+ function segDebugLog(msg: string, details?: Record<string, unknown>): void {
32
+ if (!INTERNAL_RANGO_DEBUG) return;
33
+ const env = typeof window === "object" ? "[Browser]" : "[Server]";
34
+ const prefix = `${env}[segments] ${msg} @ ${Math.round(performance.now())}ms`;
35
+ if (details) {
36
+ console.log(prefix, details);
37
+ return;
38
+ }
39
+ console.log(prefix);
40
+ }
41
+
20
42
  // ViewTransition is only available in React experimental.
21
43
  // Access via namespace import to avoid compile-time errors on stable React.
22
44
  const ReactViewTransition: any =
@@ -212,6 +234,17 @@ export async function renderSegments(
212
234
  rootLayout: RootLayout,
213
235
  } = options || {};
214
236
 
237
+ const segDebug = INTERNAL_RANGO_DEBUG;
238
+ const segDebugStart = segDebug ? performance.now() : 0;
239
+ if (segDebug) {
240
+ segDebugLog("renderSegments start", {
241
+ segments: segments.map((s) => `${s.id}:${s.type}`),
242
+ isAction: !!isAction,
243
+ forceAwait: !!forceAwait,
244
+ intercepts: interceptSegments?.length ?? 0,
245
+ });
246
+ }
247
+
215
248
  const temporalLazyRefs: Promise<any>[] = [];
216
249
  const normalizedSegments = restoreParallelLoaderMarkers(segments);
217
250
  const normalizedInterceptSegments = interceptSegments
@@ -272,6 +305,7 @@ export async function renderSegments(
272
305
  `Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
273
306
  );
274
307
  const { component, id, params, loading } = node.segment;
308
+ const segNodeStart = segDebug ? performance.now() : 0;
275
309
 
276
310
  // Param-agnostic keys are opt-in via the transition() DSL (see
277
311
  // inTransitionScope above). A route (and its route-owned layouts) inside a
@@ -314,7 +348,13 @@ export async function renderSegments(
314
348
 
315
349
  let resolvedComponent = component;
316
350
  if (isAction && component instanceof Promise) {
351
+ const componentAwaitStart = segDebug ? performance.now() : 0;
317
352
  resolvedComponent = await component;
353
+ if (segDebug) {
354
+ segDebugLog(`segment ${id}: component awaited (action)`, {
355
+ ms: Math.round(performance.now() - componentAwaitStart),
356
+ });
357
+ }
318
358
  }
319
359
 
320
360
  let nodeContent: ReactNode = null;
@@ -331,9 +371,16 @@ export async function renderSegments(
331
371
  // suspends on mount inside the content still reveals a fallback (it is not
332
372
  // pre-resolved).
333
373
  const contentPromise = getMemoizedContentPromise(resolvedComponent);
334
- const loadingContent: Promise<ReactNode> | ReactNode = forceAwait
335
- ? await contentPromise
336
- : contentPromise;
374
+ let loadingContent: Promise<ReactNode> | ReactNode = contentPromise;
375
+ if (forceAwait) {
376
+ const contentAwaitStart = segDebug ? performance.now() : 0;
377
+ loadingContent = await contentPromise;
378
+ if (segDebug) {
379
+ segDebugLog(`segment ${id}: content awaited (forceAwait)`, {
380
+ ms: Math.round(performance.now() - contentAwaitStart),
381
+ });
382
+ }
383
+ }
337
384
  nodeContent = createElement(RouteContentWrapper, {
338
385
  key: `suspense-loading-${id}`,
339
386
  content: loadingContent,
@@ -403,10 +450,25 @@ export async function renderSegments(
403
450
 
404
451
  if (loading !== undefined && loading !== null) {
405
452
  const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
453
+ let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
454
+ if (forceAwait || isAction) {
455
+ const awaitStart = segDebug ? performance.now() : 0;
456
+ boundaryLoaderData = await loaderDataPromise;
457
+ if (segDebug) {
458
+ segDebugLog(`segment ${id}: loaders awaited (forceAwait/action)`, {
459
+ loaderIds,
460
+ ms: Math.round(performance.now() - awaitStart),
461
+ });
462
+ }
463
+ } else if (segDebug) {
464
+ segDebugLog(
465
+ `segment ${id}: streaming loaders via LoaderBoundary (suspense)`,
466
+ { loaderIds },
467
+ );
468
+ }
406
469
  content = createElement(LoaderBoundary, {
407
470
  key: `loader-boundary-${key}`,
408
- loaderDataPromise:
409
- forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
471
+ loaderDataPromise: boundaryLoaderData,
410
472
  loaderIds,
411
473
  fallback: loading,
412
474
  outletKey: key,
@@ -430,11 +492,30 @@ export async function renderSegments(
430
492
  );
431
493
 
432
494
  const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
495
+ // No loading() on this segment, so its loader data cannot stream behind
496
+ // a Suspense fallback — the tree build BLOCKS here until the data
497
+ // arrives. On the initial document this await runs before hydrateRoot.
498
+ const layoutAwaitStart = segDebug ? performance.now() : 0;
433
499
  const resolvedData = await buildLoaderPromise(layoutLoaders);
500
+ if (segDebug) {
501
+ segDebugLog(`segment ${id}: layout loaders awaited (blocking)`, {
502
+ loaderIds: layoutLoaderIds,
503
+ ms: Math.round(performance.now() - layoutAwaitStart),
504
+ });
505
+ }
506
+ const decodeStart = segDebug ? performance.now() : 0;
434
507
  const { loaderData, errorFallback } = decodeLoaderResults(
435
508
  resolvedData,
436
509
  layoutLoaderIds,
437
510
  );
511
+ if (segDebug) {
512
+ const decodeMs = Math.round(performance.now() - decodeStart);
513
+ if (decodeMs > 0) {
514
+ segDebugLog(`segment ${id}: loader results decoded`, {
515
+ ms: decodeMs,
516
+ });
517
+ }
518
+ }
438
519
 
439
520
  if (parallelOwnedLoaders.length > 0) {
440
521
  const loadersByParallelNamespace = new Map<string, ResolvedSegment[]>();
@@ -463,10 +544,27 @@ export async function renderSegments(
463
544
 
464
545
  p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
465
546
  const aggregated = getMemoizedLoaderPromise(ownedLoaders);
466
- p.loaderDataPromise =
467
- (forceAwait || isAction) && aggregated instanceof Promise
468
- ? await aggregated
469
- : aggregated;
547
+ if ((forceAwait || isAction) && aggregated instanceof Promise) {
548
+ const parallelAwaitStart = segDebug ? performance.now() : 0;
549
+ p.loaderDataPromise = await aggregated;
550
+ if (segDebug) {
551
+ segDebugLog(
552
+ `segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action)`,
553
+ {
554
+ loaderIds: p.loaderIds,
555
+ ms: Math.round(performance.now() - parallelAwaitStart),
556
+ },
557
+ );
558
+ }
559
+ } else {
560
+ p.loaderDataPromise = aggregated;
561
+ if (segDebug) {
562
+ segDebugLog(
563
+ `segment ${id}: parallel ${p.id} loaders streaming (suspense)`,
564
+ { loaderIds: p.loaderIds },
565
+ );
566
+ }
567
+ }
470
568
  }
471
569
  }
472
570
 
@@ -490,6 +588,16 @@ export async function renderSegments(
490
588
  children: content,
491
589
  });
492
590
  }
591
+
592
+ if (segDebug) {
593
+ segDebugLog(`segment ${id} built`, {
594
+ type: node.segment.type,
595
+ ms: Math.round(performance.now() - segNodeStart),
596
+ loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
597
+ hasLoading: loading !== undefined && loading !== null,
598
+ parallel: node.parallel.map((p) => p.id),
599
+ });
600
+ }
493
601
  }
494
602
 
495
603
  const errorBoundaryWrapped = createElement(RootErrorBoundary, {
@@ -524,6 +632,12 @@ export async function renderSegments(
524
632
  });
525
633
  }
526
634
 
635
+ if (segDebug) {
636
+ segDebugLog("renderSegments complete", {
637
+ ms: Math.round(performance.now() - segDebugStart),
638
+ });
639
+ }
640
+
527
641
  return result;
528
642
  }
529
643
 
@@ -210,6 +210,53 @@ export interface RequestContext<
210
210
  */
211
211
  _shellLoaderSeed?: Map<string, unknown>;
212
212
 
213
+ /**
214
+ * @internal Shell fast-path marker: makes the NEXT full match treat the whole
215
+ * matched route as an implicit doc-level cache() boundary (see
216
+ * resolveShellImplicitCacheScope in cache/cache-scope.ts). Set ONLY on
217
+ * (a) the capture's derived context — with a record-only store so the
218
+ * capture's cacheRoute write lands in the snapshot, never the real store —
219
+ * and (b) a HIT tail's seeded context when the entry is eligible
220
+ * (!handlerLiveHoles), where the SeededShellStore serves the recorded doc
221
+ * entry and the match skips handler execution entirely. Routes with their
222
+ * own cache() config (including cache(false)) are never overridden: the
223
+ * marker only applies when the route tree derived NO cache scope.
224
+ */
225
+ _shellImplicitCache?: {
226
+ ttl?: number;
227
+ swr?: number;
228
+ store?: SegmentCacheStore;
229
+ };
230
+
231
+ /**
232
+ * @internal Handler-layer liveness observed DURING a shell capture, from
233
+ * three sources: (a) the capture handle-store push wrapper (shell-capture.ts)
234
+ * when a push made OUTSIDE a DSL loader scope carries a nested thenable
235
+ * (masked to a never-filling hole); (b) still-pending top-level handler
236
+ * pushes (liveness unknowable at the barrier); (c) a handler-invoked loader
237
+ * executing during the capture (loader-resolution.ts — its consumption-lane
238
+ * value would freeze on a handler-free HIT). captureAndStoreShell folds it
239
+ * into ShellCacheEntry.handlerLiveHoles at the putShell barrier. Own
240
+ * property of the capture's derived context only.
241
+ */
242
+ _shellCaptureHandleLiveness?: {
243
+ holes: boolean;
244
+ pendingPushes: number;
245
+ handlerInvokedLoader: boolean;
246
+ };
247
+
248
+ /**
249
+ * @internal Handle values pushed from a DSL loader scope DURING a shell
250
+ * capture (identity set; populated by the capture push wrapper in
251
+ * shell-capture.ts). cacheRoute threads it into captureHandles so those
252
+ * values stay out of cache-write handle records — loaders re-run fresh on
253
+ * every HIT, so replaying their captured (masked) values would duplicate
254
+ * the fresh push and stall the Flight handle encode. Own property of the
255
+ * capture's derived context only; render-time handle consumers are
256
+ * unaffected (the exclusion applies only at the captureHandles call site).
257
+ */
258
+ _shellCaptureLoaderHandleValues?: WeakSet<object>;
259
+
213
260
  /**
214
261
  * @internal Set (to the offending fn name) by the cookies()/headers()
215
262
  * capture guard when it throws DURING a capture render. Load-bearing for the
package/src/ssr/index.tsx CHANGED
@@ -1,6 +1,11 @@
1
1
  import React from "react";
2
2
  import { createSsrRootComponent } from "./ssr-root.js";
3
+ import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
4
+ import { runWithPreinitNonce } from "./preinit-client-references.js";
3
5
  import type { ErrorPhase } from "../types.js";
6
+ import type { HeadScriptsOption } from "../vite/plugin-types.js";
7
+
8
+ export { installClientReferencePreinit } from "./preinit-client-references.js";
4
9
 
5
10
  /**
6
11
  * Options for injectRSCPayload
@@ -17,6 +22,7 @@ export interface InjectRSCPayloadOptions {
17
22
  */
18
23
  interface RenderToReadableStreamOptions {
19
24
  bootstrapScriptContent?: string;
25
+ bootstrapModules?: string[];
20
26
  nonce?: string;
21
27
  formState?: unknown;
22
28
  }
@@ -34,6 +40,7 @@ interface ReactDOMReadableStream extends ReadableStream<Uint8Array> {
34
40
  interface PrerenderOptions {
35
41
  signal?: AbortSignal;
36
42
  bootstrapScriptContent?: string;
43
+ bootstrapModules?: string[];
37
44
  onError?: (error: unknown) => void;
38
45
  }
39
46
 
@@ -132,6 +139,17 @@ export interface SSRDependencies<TEnv = unknown> {
132
139
  */
133
140
  loadBootstrapScriptContent: () => Promise<string>;
134
141
 
142
+ /**
143
+ * Document script strategy; the generated virtual SSR entry threads the
144
+ * `rango({ headScripts })` plugin option here (canonical docs on
145
+ * `RangoBaseOptions.headScripts` in vite/plugin-types.ts). The
146
+ * bootstrapModules conversion runs ONLY on an explicit `"preinit"`:
147
+ * undefined keeps the inline bootstrap verbatim, so a custom SSR entry that
148
+ * never installed the preinit hook cannot drift into the half-converted
149
+ * state on upgrade (the generated entry always passes an explicit value).
150
+ */
151
+ headScripts?: HeadScriptsOption;
152
+
135
153
  /**
136
154
  * prerender from react-dom/static.edge. Optional; required only by
137
155
  * {@link createShellCaptureHandler} for PPR shell capture.
@@ -325,6 +343,47 @@ interface ShellResumeOptions {
325
343
  nonce?: string;
326
344
  }
327
345
 
346
+ /**
347
+ * The exact shape plugin-rsc's loadBootstrapScriptContent returns in both dev
348
+ * and build: a single dynamic import of the browser entry, nothing else.
349
+ * Escapes/other statements never appear in that generated content; anything
350
+ * that doesn't match falls back to inline bootstrapScriptContent unchanged.
351
+ */
352
+ const BOOTSTRAP_IMPORT_ONLY_RE =
353
+ /^\s*import\(\s*(["'])([^"'\\]+)\1\s*\)\s*;?\s*$/;
354
+
355
+ /**
356
+ * Prefer bootstrapModules over the inline import() bootstrap. When the content
357
+ * is exactly `import("<entry-url>")`, hand Fizz the URL instead: React then
358
+ * emits a `<link rel="modulepreload" fetchpriority="low">` hint in the head
359
+ * plus the executing `<script type="module" src async>` at end of shell — the
360
+ * entry fetch starts with the first flushed bytes instead of when the parser
361
+ * reaches an opaque inline script that only reveals the URL once executed.
362
+ * Fizz stamps the request nonce on both tags (the inline form needed that
363
+ * too), and under PPR both land in the stored prelude; on resume React has
364
+ * already cleared the bootstrap fields from the postponed state, so nothing
365
+ * re-emits.
366
+ */
367
+ function resolveBootstrapOptions(
368
+ content: string,
369
+ headScripts: SSRDependencies["headScripts"],
370
+ ): Pick<
371
+ RenderToReadableStreamOptions,
372
+ "bootstrapScriptContent" | "bootstrapModules"
373
+ > {
374
+ // Explicit opt-in only: undefined (a custom SSR entry that predates the
375
+ // option, which also never installed the preinit hook) keeps the inline
376
+ // bootstrap byte-for-byte — converting by default would break CSPs that
377
+ // allowlist the known inline import() via a script hash.
378
+ if (headScripts !== "preinit") {
379
+ return { bootstrapScriptContent: content };
380
+ }
381
+ const match = BOOTSTRAP_IMPORT_ONLY_RE.exec(content);
382
+ return match
383
+ ? { bootstrapModules: [match[2]!] }
384
+ : { bootstrapScriptContent: content };
385
+ }
386
+
328
387
  /**
329
388
  * Create an SSR handler that converts RSC streams to HTML.
330
389
  *
@@ -382,12 +441,16 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
382
441
 
383
442
  // Render React tree to HTML stream
384
443
  // Pass formState for useActionState progressive enhancement if provided
385
- // Pass nonce for CSP if provided
386
- const htmlStream = await renderToReadableStream(<SsrRoot />, {
387
- bootstrapScriptContent,
388
- formState,
389
- nonce,
390
- });
444
+ // Pass nonce for CSP if provided. runWithPreinitNonce makes the same
445
+ // nonce visible to the client-reference preinit hook (ALS — the hook is
446
+ // isolate-global, the nonce per request).
447
+ const htmlStream = await runWithPreinitNonce(nonce, () =>
448
+ renderToReadableStream(<SsrRoot />, {
449
+ ...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
450
+ formState,
451
+ nonce,
452
+ }),
453
+ );
391
454
 
392
455
  // Wait for all Suspense boundaries to resolve when streamMode is "allReady".
393
456
  // This buffers the entire HTML before flushing — used for bots that
@@ -489,7 +552,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
489
552
  const abortReason = { rangoShellCaptureAbort: true };
490
553
  const prerenderPromise = prerender(<SsrRoot />, {
491
554
  signal: controller.signal,
492
- bootstrapScriptContent,
555
+ ...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
493
556
  // Abort is how capture WORKS: once the shell is quiet we abort() to
494
557
  // freeze the prelude and let the still-pending holes postpone. React
495
558
  // reports the abort reason for each pending boundary through onError.
@@ -594,7 +657,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
594
657
  export function createShellResumeHandler<TEnv = unknown>(
595
658
  deps: SSRDependencies<TEnv>,
596
659
  ) {
597
- const { createFromReadableStream, injectRSCPayload, resume, onError } = deps;
660
+ const { createFromReadableStream, resume, onError } = deps;
598
661
 
599
662
  /**
600
663
  * @param rscStream - Fresh full Flight stream for this request.
@@ -609,11 +672,12 @@ export function createShellResumeHandler<TEnv = unknown>(
609
672
  try {
610
673
  if (postponed === null) {
611
674
  // DATA variant: the stored prelude is the complete shell. No fizz runs;
612
- // feed injectRSCPayload a minimal HTML stream so its flush appends the
613
- // fresh Flight payload scripts after the shell. The stream must emit at
614
- // least one chunk — see createDataVariantHtmlStream.
675
+ // the eager injector pumps the fresh Flight payload scripts without
676
+ // needing an HTML chunk to trigger it (the stock injector deadlocked on
677
+ // a chunkless stream — see createDataVariantHtmlStream, kept for the
678
+ // batching invariant's sake).
615
679
  return createDataVariantHtmlStream().pipeThrough(
616
- injectRSCPayload(rscStream, { nonce }),
680
+ injectRSCPayloadEager(rscStream, { nonce }),
617
681
  );
618
682
  }
619
683
 
@@ -635,12 +699,48 @@ export function createShellResumeHandler<TEnv = unknown>(
635
699
  nonce,
636
700
  });
637
701
 
638
- const resumed = await resume(<SsrRoot />, JSON.parse(postponed), {
639
- onError: (error) => reportRenderError(onError, error),
640
- nonce,
641
- });
642
-
643
- return resumed.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
702
+ // EAGER injection (resume-only): the stored prelude — a complete document
703
+ // through </body></html> — is already on the wire ahead of this stream,
704
+ // so a Flight <script> is valid as the first tail byte. The stock
705
+ // injector waits for the first fizz chunk, which only appears when the
706
+ // first hole's loaders resolve — parking the whole hydration payload
707
+ // (root row included) behind the slowest live loader. See
708
+ // inject-rsc-eager.ts for the measured failure mode.
709
+ //
710
+ // EAGER HANDOVER: return the injector's readable BEFORE awaiting
711
+ // resume(). react-dom's resume() promise resolves only when the resumed
712
+ // shell (everything above the postponed holes) completes — which waits
713
+ // on the live loaders — so `await resume(...).pipeThrough(...)` parked
714
+ // the already-flowing Flight bytes a second time, behind the handshake
715
+ // instead of the stream (measured: injector output at +9ms, first tail
716
+ // byte on the wire at +735ms). Piping fizz in when it materializes lets
717
+ // serveShellHit start draining Flight immediately; pipeTo closes the
718
+ // writable on completion, which runs the injector's flush (trailer). A
719
+ // resume() rejection aborts the writable so the response errors instead
720
+ // of hanging.
721
+ const injector = injectRSCPayloadEager(rscStream2, { nonce });
722
+ void (async () => {
723
+ try {
724
+ // Nonce wrap mirrors renderHTML: client references first discovered
725
+ // during resume (holes the shell never rendered) preinit into the
726
+ // resumed stream and need the per-request nonce.
727
+ const resumed = await runWithPreinitNonce(nonce, () =>
728
+ resume(<SsrRoot />, JSON.parse(postponed), {
729
+ onError: (error) => reportRenderError(onError, error),
730
+ nonce,
731
+ }),
732
+ );
733
+ await resumed.pipeTo(injector.writable);
734
+ } catch (error) {
735
+ reportRenderError(onError, error);
736
+ try {
737
+ await injector.writable.abort(error);
738
+ } catch {
739
+ // Writable already errored/closed; the readable side has the error.
740
+ }
741
+ }
742
+ })();
743
+ return injector.readable;
644
744
  } catch (error) {
645
745
  reportRenderError(onError, error);
646
746
  throw error;
@@ -0,0 +1,167 @@
1
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
2
+
3
+ /**
4
+ * Eager Flight-payload injector for the PPR resume path.
5
+ *
6
+ * rsc-html-stream's injectRSCPayload starts forwarding Flight chunks only from
7
+ * inside its first transform() callback — i.e. AFTER the first HTML chunk flows.
8
+ * That policy exists for the normal document path (a <script> must not precede
9
+ * the doctype). On a PPR shell HIT it parks the ENTIRE hydration payload: the
10
+ * resumed fizz render emits its first chunk only when the first hole's data
11
+ * resolves (live loaders — measured ~1.5s on SFCC-backed pages), while the
12
+ * Flight root row is ready within ~30ms of the tail render starting. The client
13
+ * cannot call hydrateRoot until that root row arrives, so the lazy start held
14
+ * hydration hostage to the slowest loader for no structural reason: the stored
15
+ * prelude (a complete document through </body></html>) is already on the wire
16
+ * before the tail, so every tail byte is foster-parented and a Flight <script>
17
+ * is valid as the FIRST tail byte.
18
+ *
19
+ * This injector starts pumping Flight chunks immediately in start(). Ordering
20
+ * safety is kept by serializing ALL writes through one promise chain: fizz
21
+ * chunks buffered within a tick flush as one atomic task (same batching idea as
22
+ * the stock injector — never inject between two partial HTML chunks), and each
23
+ * Flight script is its own task, so scripts land only between batches. The
24
+ * trailer is stripped from passing HTML and re-appended once, after both
25
+ * streams complete — identical to the stock contract.
26
+ *
27
+ * RESUME/DATA-VARIANT ONLY. The normal document path must keep the stock
28
+ * injector: there the first bytes are the document head, and an eager script
29
+ * would precede the doctype.
30
+ */
31
+
32
+ const encoder = new TextEncoder();
33
+ const TRAILER = "</body></html>";
34
+
35
+ // Escape closing script tags and HTML comments in JS content (ported from
36
+ // rsc-html-stream/server; escapes the "s" instead of the slash so a regexp
37
+ // literal like `0</script/` stays valid JS).
38
+ function escapeScript(script: string): string {
39
+ return script.replace(/<!--/g, "<\\!--").replace(/<\/(script)/gi, "</\\$1");
40
+ }
41
+
42
+ function writeScript(
43
+ controller: TransformStreamDefaultController<Uint8Array>,
44
+ jsExpr: string,
45
+ nonce: string | undefined,
46
+ ): void {
47
+ controller.enqueue(
48
+ encoder.encode(
49
+ `<script${nonce ? ` nonce="${nonce}"` : ""}>${escapeScript(
50
+ `(self.__FLIGHT_DATA||=[]).push(${jsExpr})`,
51
+ )}</script>`,
52
+ ),
53
+ );
54
+ }
55
+
56
+ export function injectRSCPayloadEager(
57
+ rscStream: ReadableStream<Uint8Array>,
58
+ options?: { nonce?: string },
59
+ ): TransformStream<Uint8Array, Uint8Array> {
60
+ const nonce = options?.nonce;
61
+ const htmlDecoder = new TextDecoder();
62
+ const t0 = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
63
+ let loggedFirstFlight = false;
64
+ let loggedFirstHtml = false;
65
+
66
+ // All output goes through this chain: one task per Flight script, one task
67
+ // per buffered-HTML batch. A script can therefore never split a batch.
68
+ let queue: Promise<void> = Promise.resolve();
69
+ const enqueueTask = (fn: () => void): Promise<void> => {
70
+ queue = queue.then(fn);
71
+ return queue;
72
+ };
73
+
74
+ let buffered: Uint8Array[] = [];
75
+ let timeout: ReturnType<typeof setTimeout> | null = null;
76
+ let rscDone: Promise<void> = Promise.resolve();
77
+
78
+ function flushBufferedHTML(
79
+ controller: TransformStreamDefaultController<Uint8Array>,
80
+ ): void {
81
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstHtml && buffered.length > 0) {
82
+ loggedFirstHtml = true;
83
+ console.log(
84
+ `[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
85
+ );
86
+ }
87
+ for (const chunk of buffered) {
88
+ let buf = htmlDecoder.decode(chunk, { stream: true });
89
+ if (buf.endsWith(TRAILER)) buf = buf.slice(0, -TRAILER.length);
90
+ controller.enqueue(encoder.encode(buf));
91
+ }
92
+ const remaining = htmlDecoder.decode();
93
+ if (remaining.length) {
94
+ const out = remaining.endsWith(TRAILER)
95
+ ? remaining.slice(0, -TRAILER.length)
96
+ : remaining;
97
+ controller.enqueue(encoder.encode(out));
98
+ }
99
+ buffered.length = 0;
100
+ timeout = null;
101
+ }
102
+
103
+ async function pumpRSC(
104
+ controller: TransformStreamDefaultController<Uint8Array>,
105
+ ): Promise<void> {
106
+ const rscDecoder = new TextDecoder("utf-8", { fatal: true });
107
+ const reader = rscStream.getReader();
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ // String when the chunk is valid unicode, base64 round-trip otherwise —
112
+ // same fallback the stock injector uses.
113
+ let jsExpr: string;
114
+ try {
115
+ jsExpr = JSON.stringify(rscDecoder.decode(value, { stream: true }));
116
+ } catch {
117
+ const base64 = JSON.stringify(
118
+ btoa(String.fromCodePoint(...(value as Uint8Array))),
119
+ );
120
+ jsExpr = `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`;
121
+ }
122
+ await enqueueTask(() => {
123
+ if (INTERNAL_RANGO_DEBUG && !loggedFirstFlight) {
124
+ loggedFirstFlight = true;
125
+ console.log(
126
+ `[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
127
+ );
128
+ }
129
+ writeScript(controller, jsExpr, nonce);
130
+ });
131
+ }
132
+ const remaining = rscDecoder.decode();
133
+ if (remaining.length) {
134
+ await enqueueTask(() =>
135
+ writeScript(controller, JSON.stringify(remaining), nonce),
136
+ );
137
+ }
138
+ }
139
+
140
+ return new TransformStream<Uint8Array, Uint8Array>({
141
+ start(controller) {
142
+ // The eager part: pump Flight immediately, before any HTML arrives.
143
+ rscDone = pumpRSC(controller).catch((err) => {
144
+ try {
145
+ controller.error(err);
146
+ } catch {
147
+ // Stream already errored/closed; nothing to signal.
148
+ }
149
+ });
150
+ },
151
+ transform(chunk, controller) {
152
+ buffered.push(chunk);
153
+ if (timeout) return;
154
+ // Batch same-tick fizz chunks so a Flight script cannot land between two
155
+ // partial HTML chunks of one logical write (stock injector's invariant).
156
+ timeout = setTimeout(() => {
157
+ void enqueueTask(() => flushBufferedHTML(controller));
158
+ }, 0);
159
+ },
160
+ async flush(controller) {
161
+ await rscDone;
162
+ if (timeout) clearTimeout(timeout);
163
+ await enqueueTask(() => flushBufferedHTML(controller));
164
+ controller.enqueue(encoder.encode(TRAILER));
165
+ },
166
+ });
167
+ }