@timber-js/app 0.1.21 → 0.1.22

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 (135) hide show
  1. package/dist/_chunks/als-registry-c0AGnbqS.js +39 -0
  2. package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
  3. package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
  4. package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
  6. package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
  7. package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
  8. package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
  9. package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
  10. package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
  11. package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
  12. package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
  13. package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
  14. package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
  15. package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
  16. package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
  17. package/dist/cache/index.js +2 -1
  18. package/dist/cache/index.js.map +1 -1
  19. package/dist/client/error-boundary.js +1 -1
  20. package/dist/client/index.d.ts +1 -1
  21. package/dist/client/index.d.ts.map +1 -1
  22. package/dist/client/index.js +18 -17
  23. package/dist/client/index.js.map +1 -1
  24. package/dist/client/router-ref.d.ts.map +1 -1
  25. package/dist/client/ssr-data.d.ts +3 -0
  26. package/dist/client/ssr-data.d.ts.map +1 -1
  27. package/dist/client/state.d.ts +47 -0
  28. package/dist/client/state.d.ts.map +1 -0
  29. package/dist/client/types.d.ts +10 -1
  30. package/dist/client/types.d.ts.map +1 -1
  31. package/dist/client/unload-guard.d.ts +3 -0
  32. package/dist/client/unload-guard.d.ts.map +1 -1
  33. package/dist/client/use-params.d.ts +3 -0
  34. package/dist/client/use-params.d.ts.map +1 -1
  35. package/dist/client/use-search-params.d.ts +3 -0
  36. package/dist/client/use-search-params.d.ts.map +1 -1
  37. package/dist/cookies/index.js +4 -2
  38. package/dist/cookies/index.js.map +1 -1
  39. package/dist/index.js +4 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/plugins/shims.d.ts.map +1 -1
  42. package/dist/routing/index.js +1 -1
  43. package/dist/routing/scanner.d.ts.map +1 -1
  44. package/dist/rsc-runtime/browser.d.ts +13 -0
  45. package/dist/rsc-runtime/browser.d.ts.map +1 -0
  46. package/dist/rsc-runtime/rsc.d.ts +14 -0
  47. package/dist/rsc-runtime/rsc.d.ts.map +1 -0
  48. package/dist/rsc-runtime/ssr.d.ts +13 -0
  49. package/dist/rsc-runtime/ssr.d.ts.map +1 -0
  50. package/dist/search-params/builtin-codecs.d.ts +105 -0
  51. package/dist/search-params/builtin-codecs.d.ts.map +1 -0
  52. package/dist/search-params/index.d.ts +1 -0
  53. package/dist/search-params/index.d.ts.map +1 -1
  54. package/dist/search-params/index.js +167 -2
  55. package/dist/search-params/index.js.map +1 -1
  56. package/dist/server/actions.d.ts +2 -7
  57. package/dist/server/actions.d.ts.map +1 -1
  58. package/dist/server/als-registry.d.ts +80 -0
  59. package/dist/server/als-registry.d.ts.map +1 -0
  60. package/dist/server/early-hints-sender.d.ts.map +1 -1
  61. package/dist/server/form-flash.d.ts.map +1 -1
  62. package/dist/server/index.d.ts +1 -0
  63. package/dist/server/index.d.ts.map +1 -1
  64. package/dist/server/index.js +242 -76
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/server/metadata-routes.d.ts +27 -0
  67. package/dist/server/metadata-routes.d.ts.map +1 -1
  68. package/dist/server/pipeline.d.ts +7 -0
  69. package/dist/server/pipeline.d.ts.map +1 -1
  70. package/dist/server/primitives.d.ts +14 -6
  71. package/dist/server/primitives.d.ts.map +1 -1
  72. package/dist/server/request-context.d.ts +2 -32
  73. package/dist/server/request-context.d.ts.map +1 -1
  74. package/dist/server/route-matcher.d.ts +5 -0
  75. package/dist/server/route-matcher.d.ts.map +1 -1
  76. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  77. package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
  78. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
  79. package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
  80. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
  81. package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
  82. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
  83. package/dist/server/rsc-prop-warnings.d.ts +53 -0
  84. package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
  85. package/dist/server/server-timing.d.ts +49 -0
  86. package/dist/server/server-timing.d.ts.map +1 -0
  87. package/dist/server/tracing.d.ts +2 -6
  88. package/dist/server/tracing.d.ts.map +1 -1
  89. package/dist/server/types.d.ts +11 -0
  90. package/dist/server/types.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/src/client/browser-entry.ts +1 -1
  93. package/src/client/index.ts +1 -1
  94. package/src/client/router-ref.ts +6 -12
  95. package/src/client/ssr-data.ts +25 -9
  96. package/src/client/state.ts +83 -0
  97. package/src/client/types.ts +18 -1
  98. package/src/client/unload-guard.ts +6 -3
  99. package/src/client/use-params.ts +10 -13
  100. package/src/client/use-search-params.ts +9 -5
  101. package/src/plugins/shims.ts +26 -2
  102. package/src/routing/scanner.ts +18 -2
  103. package/src/rsc-runtime/browser.ts +18 -0
  104. package/src/rsc-runtime/rsc.ts +19 -0
  105. package/src/rsc-runtime/ssr.ts +13 -0
  106. package/src/search-params/builtin-codecs.ts +228 -0
  107. package/src/search-params/index.ts +11 -0
  108. package/src/server/action-handler.ts +1 -1
  109. package/src/server/actions.ts +4 -10
  110. package/src/server/als-registry.ts +116 -0
  111. package/src/server/deny-renderer.ts +1 -1
  112. package/src/server/early-hints-sender.ts +1 -3
  113. package/src/server/form-flash.ts +1 -5
  114. package/src/server/index.ts +1 -0
  115. package/src/server/metadata-routes.ts +61 -0
  116. package/src/server/pipeline.ts +164 -38
  117. package/src/server/primitives.ts +110 -6
  118. package/src/server/request-context.ts +8 -36
  119. package/src/server/route-matcher.ts +25 -2
  120. package/src/server/rsc-entry/error-renderer.ts +1 -1
  121. package/src/server/rsc-entry/index.ts +42 -380
  122. package/src/server/rsc-entry/rsc-payload.ts +126 -0
  123. package/src/server/rsc-entry/rsc-stream.ts +162 -0
  124. package/src/server/rsc-entry/ssr-renderer.ts +228 -0
  125. package/src/server/rsc-prop-warnings.ts +187 -0
  126. package/src/server/server-timing.ts +132 -0
  127. package/src/server/ssr-entry.ts +1 -1
  128. package/src/server/tracing.ts +3 -11
  129. package/src/server/types.ts +16 -0
  130. package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
  131. package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
  132. package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
  133. package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
  134. package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
  135. package/dist/_chunks/tracing-BtOwb8O6.js.map +0 -1
@@ -22,8 +22,6 @@ import config from 'virtual:timber-config';
22
22
  // @ts-expect-error — virtual module provided by timber-build-manifest plugin
23
23
  import buildManifest from 'virtual:timber-build-manifest';
24
24
 
25
- import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
26
-
27
25
  import type { FormRerender } from '#/server/action-handler.js';
28
26
  import { handleActionRequest, isActionRequest } from '#/server/action-handler.js';
29
27
  import type { BodyLimitsConfig } from '#/server/body-limits.js';
@@ -44,14 +42,12 @@ import { collectEarlyHintHeaders } from '#/server/early-hints.js';
44
42
  import { runWithFormFlash } from '#/server/form-flash.js';
45
43
  import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
46
44
  import { buildClientScripts } from '#/server/html-injectors.js';
47
- import { logRenderError } from '#/server/logger.js';
48
45
  import type { InterceptionContext, PipelineConfig, RouteMatch } from '#/server/pipeline.js';
49
46
  import { createPipeline } from '#/server/pipeline.js';
50
- import { DenySignal, RedirectSignal, RenderError, SsrStreamError } from '#/server/primitives.js';
47
+ import { DenySignal, RedirectSignal } from '#/server/primitives.js';
51
48
  import { buildRouteElement, RouteSignalWithContext } from '#/server/route-element-builder.js';
52
49
  import type { ManifestSegmentNode } from '#/server/route-matcher.js';
53
50
  import { createMetadataRouteMatcher, createRouteMatcher } from '#/server/route-matcher.js';
54
- import type { NavContext } from '#/server/ssr-entry.js';
55
51
  import { initDevTracing } from '#/server/tracing.js';
56
52
 
57
53
  import { renderFallbackError as renderFallback } from '#/server/fallback-error.js';
@@ -59,14 +55,13 @@ import { handleApiRoute } from './api-handler.js';
59
55
  import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
60
56
  import {
61
57
  buildRedirectResponse,
62
- buildSegmentInfo,
63
58
  createDebugChannelSink,
64
59
  escapeHtml,
65
- isAbortError,
66
60
  isRscPayloadRequest,
67
- parseCookiesFromHeader,
68
- RSC_CONTENT_TYPE,
69
61
  } from './helpers.js';
62
+ import { buildRscPayloadResponse } from './rsc-payload.js';
63
+ import { renderRscStream } from './rsc-stream.js';
64
+ import { renderSsrResponse } from './ssr-renderer.js';
70
65
  import { callSsr } from './ssr-bridge.js';
71
66
 
72
67
  // Dev-only pipeline error handler, set by the dev server after import.
@@ -178,6 +173,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
178
173
  return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
179
174
  },
180
175
  interceptionRewrites: manifest.interceptionRewrites,
176
+ enableServerTiming: isDev,
181
177
  onPipelineError: isDev
182
178
  ? (error: Error, phase: string) => {
183
179
  if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
@@ -316,16 +312,16 @@ async function renderRoute(
316
312
 
317
313
  const { element, headElements, layoutComponents, deferSuspenseFor } = routeResult;
318
314
 
319
- // Build head HTML for injection into the SSR output
320
- let headHtml = '';
321
-
322
- // Collect CSS, fonts, and modulepreload from the build manifest for matched segments.
315
+ // Build head HTML for injection into the SSR output.
316
+ // Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
323
317
  // In dev mode the manifest is empty — Vite HMR handles CSS/JS.
324
318
  //
325
319
  // Link headers (for 103 Early Hints) are emitted by the earlyHints pipeline
326
320
  // stage before middleware runs. Here we only emit the <head> HTML fallback tags
327
321
  // — these ensure resources load even on platforms without Early Hints support.
328
322
  const typedManifest = buildManifest as BuildManifest;
323
+ let headHtml = '';
324
+
329
325
  const cssUrls = collectRouteCss(segments, typedManifest);
330
326
  if (cssUrls.length > 0) {
331
327
  headHtml += buildCssLinkTags(cssUrls);
@@ -356,131 +352,21 @@ async function renderRoute(
356
352
  }
357
353
  }
358
354
 
359
- // Render to RSC Flight stream.
360
- // renderToReadableStream from @vitejs/plugin-rsc/rsc serializes:
361
- // - Server components: rendered output (HTML-like structure)
362
- // - Client components ("use client"): serialized references with module ID + export name
363
- //
364
- // The RSC plugin's renderToReadableStream(data, reactOptions, extraOptions):
365
- // - reactOptions: passed to React (onError, signal, etc.)
366
- // - extraOptions: { onClientReference } for tracking client deps
367
- // The client manifest is created internally by the plugin.
368
- //
369
- // DenySignal detection: deny() in sync components throws during
370
- // renderToReadableStream (caught in try/catch). deny() in async components
371
- // fires onError during stream consumption. We capture it here and let
372
- // SSR determine whether it was pre-flush (outside Suspense) or post-flush
373
- // (inside Suspense) based on whether the SSR shell renders successfully.
374
- let denySignal: DenySignal | null = null;
375
- let redirectSignal: RedirectSignal | null = null;
376
- let renderError: { error: unknown; status: number } | null = null;
377
- let rscStream: ReadableStream<Uint8Array> | undefined;
378
-
379
- try {
380
- rscStream = renderToReadableStream(
381
- element,
382
- {
383
- signal: _req.signal,
384
- onError(error: unknown) {
385
- // Connection abort (user refreshed or navigated away) — suppress.
386
- // Not an application error; no need to track or log.
387
- if (isAbortError(error) || _req.signal?.aborted) return;
388
- if (error instanceof DenySignal) {
389
- denySignal = error;
390
- // Return structured digest for client-side error boundaries
391
- return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
392
- }
393
- if (error instanceof RedirectSignal) {
394
- redirectSignal = error;
395
- return JSON.stringify({
396
- type: 'redirect',
397
- location: error.location,
398
- status: error.status,
399
- });
400
- }
401
- if (error instanceof RenderError) {
402
- // Track the first render error for pre-flush handling
403
- if (!renderError) {
404
- renderError = { error, status: error.status };
405
- }
406
- logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
407
- return JSON.stringify({
408
- type: 'render-error',
409
- code: error.code,
410
- data: error.digest.data,
411
- status: error.status,
412
- });
413
- }
414
- // Dev diagnostic: detect "Invalid hook call" errors which indicate
415
- // a 'use client' component is being executed during RSC rendering
416
- // instead of being serialized as a client reference. This happens when
417
- // the RSC plugin's transform doesn't detect the directive — e.g., the
418
- // directive isn't at the very top of the file, or the component is
419
- // re-exported through a barrel file without 'use client'.
420
- // See LOCAL-297.
421
- if (
422
- process.env.NODE_ENV !== 'production' &&
423
- error instanceof Error &&
424
- error.message.includes('Invalid hook call')
425
- ) {
426
- console.error(
427
- '[timber] A React hook was called during RSC rendering. This usually means a ' +
428
- "'use client' component is being executed as a server component instead of " +
429
- 'being serialized as a client reference.\n\n' +
430
- 'Common causes:\n' +
431
- " 1. The 'use client' directive is not the FIRST statement in the file (before any imports)\n" +
432
- " 2. The component is re-exported through a barrel file (index.ts) that lacks 'use client'\n" +
433
- ' 3. @vitejs/plugin-rsc is not loaded or is misconfigured\n\n' +
434
- `Request: ${_req.method} ${new URL(_req.url).pathname}`
435
- );
436
- }
437
-
438
- // Track unhandled errors for pre-flush handling (500 status)
439
- if (!renderError) {
440
- renderError = { error, status: 500 };
441
- }
442
- logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
443
- },
444
- debugChannel: createDebugChannelSink(),
445
- },
446
- {
447
- onClientReference(info: { id: string; name: string; deps: unknown }) {
448
- // Client reference callback — invoked when a "use client"
449
- // component is serialized into the RSC stream. Can be extended
450
- // for CSS dep collection and Early Hints.
451
- void info;
452
- },
453
- }
454
- );
455
- } catch (error) {
456
- if (error instanceof DenySignal) {
457
- denySignal = error;
458
- } else if (error instanceof RedirectSignal) {
459
- redirectSignal = error;
460
- } else {
461
- // Synchronous render error — component threw during
462
- // renderToReadableStream creation. Capture instead of crashing
463
- // the server; the error page will be rendered below.
464
- renderError = {
465
- error,
466
- status: error instanceof RenderError ? error.status : 500,
467
- };
468
- logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
469
- }
470
- }
355
+ // Render to RSC Flight stream with signal tracking.
356
+ const { rscStream, signals } = renderRscStream(element, _req);
471
357
 
472
358
  // Synchronous redirect — redirect() in access.ts or a non-async component
473
359
  // throws during renderToReadableStream creation. Return HTTP redirect.
474
- if (redirectSignal) {
475
- return buildRedirectResponse(_req, redirectSignal, responseHeaders);
360
+ if (signals.redirectSignal) {
361
+ return buildRedirectResponse(_req, signals.redirectSignal, responseHeaders);
476
362
  }
477
363
 
478
364
  // Synchronous deny — deny() in a non-async component throws during
479
365
  // renderToReadableStream creation, caught in the try/catch above.
480
- if (denySignal) {
366
+ if (signals.denySignal) {
481
367
  if (isRscPayloadRequest(_req)) {
482
368
  return renderDenyPageAsRsc(
483
- denySignal,
369
+ signals.denySignal,
484
370
  segments,
485
371
  layoutComponents as LayoutEntry[],
486
372
  responseHeaders,
@@ -488,7 +374,7 @@ async function renderRoute(
488
374
  );
489
375
  }
490
376
  return renderDenyPage(
491
- denySignal,
377
+ signals.denySignal,
492
378
  segments,
493
379
  layoutComponents as LayoutEntry[],
494
380
  _req,
@@ -503,10 +389,10 @@ async function renderRoute(
503
389
  // Synchronous render error — renderToReadableStream threw before
504
390
  // creating the stream. Render the error page with correct 5xx status.
505
391
  // (Async render errors are tracked in onError and handled after SSR.)
506
- if (renderError && !rscStream) {
392
+ if (signals.renderError && !rscStream) {
507
393
  return renderErrorPage(
508
- renderError.error,
509
- renderError.status,
394
+ signals.renderError.error,
395
+ signals.renderError.status,
510
396
  segments,
511
397
  layoutComponents as LayoutEntry[],
512
398
  _req,
@@ -520,256 +406,32 @@ async function renderRoute(
520
406
  // stream directly — skip SSR HTML rendering entirely.
521
407
  // See design/19-client-navigation.md §"RSC Payload Handling"
522
408
  if (isRscPayloadRequest(_req)) {
523
- // Read the first chunk from the RSC stream before committing headers.
524
- // Async components (including page components wrapped in TracedPage)
525
- // throw during stream consumption, not during renderToReadableStream.
526
- // Reading one chunk triggers rendering of the initial component tree,
527
- // allowing onError to capture DenySignal/RedirectSignal before we
528
- // commit the response. Without this, the redirect digest is embedded
529
- // in the RSC stream and surfaces as an unhandled error on the client.
530
- // See TIM-344.
531
- const reader = rscStream!.getReader();
532
- const firstRead = await reader.read();
533
-
534
- // Yield to the microtask queue so that async component rejections
535
- // (e.g. an async-wrapped page component that throws redirect())
536
- // propagate to the onError callback before we check the signals.
537
- // The rejected Promise from an async component resolves in the next
538
- // microtask after read(), so we need at least one tick.
539
- await new Promise<void>((r) => setTimeout(r, 0));
540
-
541
- // Check for redirect/deny signals detected during initial rendering
542
- const trackedRedirect = redirectSignal as RedirectSignal | null;
543
- if (trackedRedirect) {
544
- reader.cancel();
545
- return buildRedirectResponse(_req, trackedRedirect, responseHeaders);
546
- }
547
- if (denySignal) {
548
- reader.cancel();
549
- return renderDenyPageAsRsc(
550
- denySignal,
551
- segments,
552
- layoutComponents as LayoutEntry[],
553
- responseHeaders,
554
- createDebugChannelSink
555
- );
556
- }
557
-
558
- // Reconstruct the stream: prepend the buffered first chunk,
559
- // then continue piping from the original reader.
560
- const patchedStream = new ReadableStream<Uint8Array>({
561
- start(controller) {
562
- if (firstRead.value) controller.enqueue(firstRead.value);
563
- if (firstRead.done) {
564
- controller.close();
565
- return;
566
- }
567
- },
568
- async pull(controller) {
569
- const { value, done } = await reader.read();
570
- if (done) {
571
- controller.close();
572
- return;
573
- }
574
- controller.enqueue(value);
575
- },
576
- cancel() {
577
- reader.cancel();
578
- },
579
- });
580
-
581
- responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
582
- // Vary on Accept so CDNs cache HTML and RSC responses separately
583
- // for the same URL. The client appends ?_rsc=<id> as a cache-bust,
584
- // but Vary ensures correct behavior even without the query param.
585
- responseHeaders.set('Vary', 'Accept');
586
-
587
- // Send resolved head elements so the client can update document.title
588
- // and <meta> tags after SPA navigation. See design/16-metadata.md.
589
- const encoded = encodeURIComponent(JSON.stringify(headElements));
590
- if (encoded.length <= 4096) {
591
- responseHeaders.set('X-Timber-Head', encoded);
592
- }
593
-
594
- // Send segment metadata so the client can populate its segment cache
595
- // for state tree diffing on subsequent navigations.
596
- // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
597
- const segmentInfo = buildSegmentInfo(segments, layoutComponents);
598
- responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
599
-
600
- // Send route params so the client can populate useParams() after
601
- // SPA navigation. Without this, useParams() returns {}.
602
- if (Object.keys(match.params).length > 0) {
603
- responseHeaders.set('X-Timber-Params', JSON.stringify(match.params));
604
- }
605
-
606
- return new Response(patchedStream, {
607
- status: 200,
608
- headers: responseHeaders,
609
- });
409
+ return buildRscPayloadResponse(
410
+ _req,
411
+ rscStream!,
412
+ signals,
413
+ segments,
414
+ layoutComponents,
415
+ headElements,
416
+ match,
417
+ responseHeaders
418
+ );
610
419
  }
611
420
 
612
- // Progressive streaming: pipe the RSC stream directly to SSR without
613
- // buffering. This enables proper Suspense streaming behavior.
614
- //
615
- // For async deny() (inside components that await before calling deny()),
616
- // SSR will attempt to render the element tree progressively. Two outcomes:
617
- //
618
- // 1. deny() outside Suspense: the error appears in the RSC shell. SSR's
619
- // renderToReadableStream fails (rejects). We catch the failure, check
620
- // denySignal, and render the deny page with the correct status code.
621
- //
622
- // 2. deny() inside Suspense: the SSR shell succeeds (200 committed). The
623
- // error streams into the connection as a React error boundary. The
624
- // status is already committed — per design/05-streaming.md this is the
625
- // expected degraded behavior for deny inside Suspense.
626
- //
627
- // Tee the RSC stream — one copy goes to SSR for HTML rendering,
628
- // the other is inlined in the HTML for client-side hydration.
629
- const [ssrStream, inlineStream] = rscStream!.tee();
630
-
631
- // Embed segment metadata in HTML for initial hydration.
632
- // The client reads this to populate its segment cache before the
633
- // first navigation, enabling state tree diffing from the start.
634
- // Skipped when client JS is disabled — no client JS to consume it.
635
- const segmentScript = clientJsDisabled
636
- ? ''
637
- : `<script>self.__timber_segments=${JSON.stringify(buildSegmentInfo(segments, layoutComponents))}</script>`;
638
-
639
- // Embed route params in HTML so useParams() works on initial hydration.
640
- // Without this, useParams() returns {} until the first client navigation.
641
- const paramsScript =
642
- clientJsDisabled || Object.keys(match.params).length === 0
643
- ? ''
644
- : `<script>self.__timber_params=${JSON.stringify(match.params)}</script>`;
645
-
646
- const navContext: NavContext = {
647
- pathname: new URL(_req.url).pathname,
648
- params: match.params,
649
- searchParams: Object.fromEntries(new URL(_req.url).searchParams),
650
- statusCode: 200,
421
+ // Pipe through SSR for HTML rendering with streaming Suspense support.
422
+ return renderSsrResponse({
423
+ req: _req,
424
+ rscStream: rscStream!,
425
+ signals,
426
+ segments,
427
+ layoutComponents,
428
+ match,
651
429
  responseHeaders,
652
- headHtml: headHtml + clientBootstrap.preloadLinks + segmentScript + paramsScript,
653
- bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
654
- // Skip RSC inline stream when client JS is disabled — no client to hydrate.
655
- rscStream: clientJsDisabled ? undefined : inlineStream,
656
- deferSuspenseFor: deferSuspenseFor > 0 ? deferSuspenseFor : undefined,
657
- signal: _req.signal,
658
- cookies: parseCookiesFromHeader(_req.headers.get('cookie') ?? ''),
659
- };
660
-
661
- // Helper: check if render-phase signals were captured and return the
662
- // appropriate HTTP response. Used after both successful SSR (signal
663
- // promotion from Suspense) and failed SSR (signal outside Suspense).
664
- //
665
- // When `skipHandledDeny` is true (SSR success path), skip DenySignal
666
- // promotion if the denial was already handled by a TimberErrorBoundary
667
- // (e.g., slot error boundary). The boundary sets navContext._denyHandledByBoundary
668
- // during SSR rendering. See LOCAL-298.
669
- function checkCapturedSignals(
670
- skipHandledDeny = false
671
- ): Response | Promise<Response> | null {
672
- const sig = redirectSignal as RedirectSignal | null;
673
- if (sig) return buildRedirectResponse(_req, sig, responseHeaders);
674
- if (denySignal && !(skipHandledDeny && navContext._denyHandledByBoundary)) {
675
- return renderDenyPage(
676
- denySignal,
677
- segments,
678
- layoutComponents as LayoutEntry[],
679
- _req,
680
- match,
681
- responseHeaders,
682
- clientBootstrap,
683
- createDebugChannelSink,
684
- callSsr
685
- );
686
- }
687
- const err = renderError as { error: unknown; status: number } | null;
688
- if (err) {
689
- return renderErrorPage(
690
- err.error,
691
- err.status,
692
- segments,
693
- layoutComponents as LayoutEntry[],
694
- _req,
695
- match,
696
- responseHeaders,
697
- clientBootstrap
698
- );
699
- }
700
- return null;
701
- }
702
-
703
- try {
704
- const ssrResponse = await callSsr(ssrStream, navContext);
705
-
706
- // Signal promotion: yield one tick so async component rejections
707
- // propagate to the RSC onError callback, then check if any signals
708
- // were captured during rendering inside Suspense boundaries.
709
- // The Response hasn't been sent yet — it's an unconsumed stream.
710
- // See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
711
- await new Promise<void>((r) => setTimeout(r, 0));
712
-
713
- const promoted = checkCapturedSignals(/* skipHandledDeny */ true);
714
- if (promoted) {
715
- ssrResponse.body?.cancel();
716
- return promoted;
717
- }
718
- return ssrResponse;
719
- } catch (ssrError) {
720
- // Connection abort — the client disconnected (page refresh, navigation
721
- // away). No response needed; return empty 499 (client closed request).
722
- if (isAbortError(ssrError) || _req.signal?.aborted) {
723
- return new Response(null, { status: 499 });
724
- }
725
-
726
- // SsrStreamError: SSR's renderToReadableStream failed because the RSC
727
- // stream contained an uncontained error (e.g., slot without error boundary).
728
- // Render the deny/error page WITHOUT layout wrapping to avoid re-executing
729
- // server components (which call headers()/cookies() and fail in SSR's
730
- // separate ALS scope). See LOCAL-293.
731
- if (ssrError instanceof SsrStreamError) {
732
- const sig = redirectSignal as RedirectSignal | null;
733
- if (sig) return buildRedirectResponse(_req, sig, responseHeaders);
734
- if (denySignal) {
735
- // Render deny page without layouts — pass empty layout list
736
- return renderDenyPage(
737
- denySignal,
738
- segments,
739
- [] as LayoutEntry[],
740
- _req,
741
- match,
742
- responseHeaders,
743
- clientBootstrap,
744
- createDebugChannelSink,
745
- callSsr
746
- );
747
- }
748
- const err = renderError as { error: unknown; status: number } | null;
749
- if (err) {
750
- return renderErrorPage(
751
- err.error,
752
- err.status,
753
- segments,
754
- [] as LayoutEntry[],
755
- _req,
756
- match,
757
- responseHeaders,
758
- clientBootstrap
759
- );
760
- }
761
- // No captured signal — return bare 500
762
- return new Response(null, { status: 500, headers: responseHeaders });
763
- }
764
-
765
- // SSR shell rendering failed — the error was outside Suspense.
766
- // Check captured signals (redirect, deny, render error).
767
- const signalResponse = checkCapturedSignals();
768
- if (signalResponse) return signalResponse;
769
-
770
- // No tracked error — rethrow (infrastructure failure)
771
- throw ssrError;
772
- }
430
+ clientBootstrap,
431
+ clientJsDisabled,
432
+ headHtml,
433
+ deferSuspenseFor,
434
+ });
773
435
  }
774
436
 
775
437
  // Re-export for generated entry points (e.g., Nitro node-server/bun) to wrap
@@ -0,0 +1,126 @@
1
+ /**
2
+ * RSC Payload Response — Handles client-side navigation requests.
3
+ *
4
+ * For requests with `Accept: text/x-component`, the RSC Flight stream is
5
+ * returned directly without SSR HTML rendering. The client decodes it via
6
+ * `createFromFetch` and renders into the hydrated React root.
7
+ *
8
+ * Design docs: 19-client-navigation.md §"RSC Payload Handling",
9
+ * 16-metadata.md §"Head Elements"
10
+ */
11
+
12
+ import type { LayoutEntry } from '#/server/deny-renderer.js';
13
+ import { renderDenyPageAsRsc } from '#/server/deny-renderer.js';
14
+ import type { RouteMatch } from '#/server/pipeline.js';
15
+ import type { RedirectSignal } from '#/server/primitives.js';
16
+ import type { HeadElement, LayoutComponentEntry } from '#/server/route-element-builder.js';
17
+ import type { ManifestSegmentNode } from '#/server/route-matcher.js';
18
+
19
+ import {
20
+ buildRedirectResponse,
21
+ buildSegmentInfo,
22
+ createDebugChannelSink,
23
+ RSC_CONTENT_TYPE,
24
+ } from './helpers.js';
25
+ import type { RenderSignals } from './rsc-stream.js';
26
+
27
+ /**
28
+ * Build an RSC payload Response for a client-side navigation request.
29
+ *
30
+ * Reads the first chunk from the RSC stream before committing headers.
31
+ * Async components throw during stream consumption, not during
32
+ * renderToReadableStream. Reading one chunk triggers rendering of the
33
+ * initial component tree, allowing onError to capture DenySignal/
34
+ * RedirectSignal before we commit the response. See TIM-344.
35
+ */
36
+ export async function buildRscPayloadResponse(
37
+ req: Request,
38
+ rscStream: ReadableStream<Uint8Array>,
39
+ signals: RenderSignals,
40
+ segments: ManifestSegmentNode[],
41
+ layoutComponents: LayoutComponentEntry[],
42
+ headElements: HeadElement[],
43
+ match: RouteMatch,
44
+ responseHeaders: Headers
45
+ ): Promise<Response> {
46
+ // Read the first chunk from the RSC stream before committing headers.
47
+ const reader = rscStream.getReader();
48
+ const firstRead = await reader.read();
49
+
50
+ // Yield to the microtask queue so that async component rejections
51
+ // (e.g. an async-wrapped page component that throws redirect())
52
+ // propagate to the onError callback before we check the signals.
53
+ // The rejected Promise from an async component resolves in the next
54
+ // microtask after read(), so we need at least one tick.
55
+ await new Promise<void>((r) => setTimeout(r, 0));
56
+
57
+ // Check for redirect/deny signals detected during initial rendering
58
+ const trackedRedirect = signals.redirectSignal as RedirectSignal | null;
59
+ if (trackedRedirect) {
60
+ reader.cancel();
61
+ return buildRedirectResponse(req, trackedRedirect, responseHeaders);
62
+ }
63
+ if (signals.denySignal) {
64
+ reader.cancel();
65
+ return renderDenyPageAsRsc(
66
+ signals.denySignal,
67
+ segments,
68
+ layoutComponents as LayoutEntry[],
69
+ responseHeaders,
70
+ createDebugChannelSink
71
+ );
72
+ }
73
+
74
+ // Reconstruct the stream: prepend the buffered first chunk,
75
+ // then continue piping from the original reader.
76
+ const patchedStream = new ReadableStream<Uint8Array>({
77
+ start(controller) {
78
+ if (firstRead.value) controller.enqueue(firstRead.value);
79
+ if (firstRead.done) {
80
+ controller.close();
81
+ return;
82
+ }
83
+ },
84
+ async pull(controller) {
85
+ const { value, done } = await reader.read();
86
+ if (done) {
87
+ controller.close();
88
+ return;
89
+ }
90
+ controller.enqueue(value);
91
+ },
92
+ cancel() {
93
+ reader.cancel();
94
+ },
95
+ });
96
+
97
+ responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
98
+ // Vary on Accept so CDNs cache HTML and RSC responses separately
99
+ // for the same URL. The client appends ?_rsc=<id> as a cache-bust,
100
+ // but Vary ensures correct behavior even without the query param.
101
+ responseHeaders.set('Vary', 'Accept');
102
+
103
+ // Send resolved head elements so the client can update document.title
104
+ // and <meta> tags after SPA navigation. See design/16-metadata.md.
105
+ const encoded = encodeURIComponent(JSON.stringify(headElements));
106
+ if (encoded.length <= 4096) {
107
+ responseHeaders.set('X-Timber-Head', encoded);
108
+ }
109
+
110
+ // Send segment metadata so the client can populate its segment cache
111
+ // for state tree diffing on subsequent navigations.
112
+ // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
113
+ const segmentInfo = buildSegmentInfo(segments, layoutComponents);
114
+ responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
115
+
116
+ // Send route params so the client can populate useParams() after
117
+ // SPA navigation. Without this, useParams() returns {}.
118
+ if (Object.keys(match.params).length > 0) {
119
+ responseHeaders.set('X-Timber-Params', JSON.stringify(match.params));
120
+ }
121
+
122
+ return new Response(patchedStream, {
123
+ status: 200,
124
+ headers: responseHeaders,
125
+ });
126
+ }