@timber-js/app 0.1.20 → 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.
- package/dist/_chunks/als-registry-c0AGnbqS.js +39 -0
- package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
- package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
- package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
- package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
- package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
- package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
- package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
- package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
- package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
- package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
- package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
- package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
- package/dist/cache/index.js +2 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +40 -26
- package/dist/client/index.js.map +1 -1
- package/dist/client/router-ref.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/ssr-data.d.ts +3 -0
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/state.d.ts +47 -0
- package/dist/client/state.d.ts.map +1 -0
- package/dist/client/types.d.ts +10 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/unload-guard.d.ts +3 -0
- package/dist/client/unload-guard.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +19 -6
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +3 -0
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -2
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/rsc-runtime/browser.d.ts +13 -0
- package/dist/rsc-runtime/browser.d.ts.map +1 -0
- package/dist/rsc-runtime/rsc.d.ts +14 -0
- package/dist/rsc-runtime/rsc.d.ts.map +1 -0
- package/dist/rsc-runtime/ssr.d.ts +13 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -0
- package/dist/search-params/builtin-codecs.d.ts +105 -0
- package/dist/search-params/builtin-codecs.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +1 -0
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +167 -2
- package/dist/search-params/index.js.map +1 -1
- package/dist/server/actions.d.ts +2 -7
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +80 -0
- package/dist/server/als-registry.d.ts.map +1 -0
- package/dist/server/early-hints-sender.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +242 -76
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +27 -0
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +7 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +14 -6
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +2 -32
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +5 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
- package/dist/server/rsc-prop-warnings.d.ts +53 -0
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
- package/dist/server/server-timing.d.ts +49 -0
- package/dist/server/server-timing.d.ts.map +1 -0
- package/dist/server/tracing.d.ts +2 -6
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/types.d.ts +11 -0
- package/dist/server/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/router-ref.ts +6 -12
- package/src/client/router.ts +14 -4
- package/src/client/ssr-data.ts +25 -9
- package/src/client/state.ts +83 -0
- package/src/client/types.ts +18 -1
- package/src/client/unload-guard.ts +6 -3
- package/src/client/use-params.ts +42 -32
- package/src/client/use-search-params.ts +9 -5
- package/src/plugins/shims.ts +26 -2
- package/src/routing/scanner.ts +18 -2
- package/src/rsc-runtime/browser.ts +18 -0
- package/src/rsc-runtime/rsc.ts +19 -0
- package/src/rsc-runtime/ssr.ts +13 -0
- package/src/search-params/builtin-codecs.ts +228 -0
- package/src/search-params/index.ts +11 -0
- package/src/server/action-handler.ts +1 -1
- package/src/server/actions.ts +4 -10
- package/src/server/als-registry.ts +116 -0
- package/src/server/deny-renderer.ts +1 -1
- package/src/server/early-hints-sender.ts +1 -3
- package/src/server/form-flash.ts +1 -5
- package/src/server/index.ts +1 -0
- package/src/server/metadata-routes.ts +61 -0
- package/src/server/pipeline.ts +164 -38
- package/src/server/primitives.ts +110 -6
- package/src/server/request-context.ts +8 -36
- package/src/server/route-matcher.ts +25 -2
- package/src/server/rsc-entry/error-renderer.ts +1 -1
- package/src/server/rsc-entry/index.ts +42 -380
- package/src/server/rsc-entry/rsc-payload.ts +126 -0
- package/src/server/rsc-entry/rsc-stream.ts +162 -0
- package/src/server/rsc-entry/ssr-renderer.ts +228 -0
- package/src/server/rsc-prop-warnings.ts +187 -0
- package/src/server/server-timing.ts +132 -0
- package/src/server/ssr-entry.ts +1 -1
- package/src/server/tracing.ts +3 -11
- package/src/server/types.ts +16 -0
- package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
- package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
- package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
- package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
- package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
+
}
|