@timber-js/app 0.1.1 → 0.1.3

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 (143) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -7
  3. package/dist/index.js.map +1 -1
  4. package/dist/plugins/dev-server.d.ts.map +1 -1
  5. package/dist/plugins/entries.d.ts.map +1 -1
  6. package/package.json +5 -4
  7. package/src/adapters/cloudflare.ts +325 -0
  8. package/src/adapters/nitro.ts +366 -0
  9. package/src/adapters/types.ts +63 -0
  10. package/src/cache/index.ts +91 -0
  11. package/src/cache/redis-handler.ts +91 -0
  12. package/src/cache/register-cached-function.ts +99 -0
  13. package/src/cache/singleflight.ts +26 -0
  14. package/src/cache/stable-stringify.ts +21 -0
  15. package/src/cache/timber-cache.ts +116 -0
  16. package/src/cli.ts +201 -0
  17. package/src/client/browser-entry.ts +663 -0
  18. package/src/client/error-boundary.tsx +209 -0
  19. package/src/client/form.tsx +200 -0
  20. package/src/client/head.ts +61 -0
  21. package/src/client/history.ts +46 -0
  22. package/src/client/index.ts +60 -0
  23. package/src/client/link-navigate-interceptor.tsx +62 -0
  24. package/src/client/link-status-provider.tsx +40 -0
  25. package/src/client/link.tsx +310 -0
  26. package/src/client/nuqs-adapter.tsx +117 -0
  27. package/src/client/router-ref.ts +25 -0
  28. package/src/client/router.ts +563 -0
  29. package/src/client/segment-cache.ts +194 -0
  30. package/src/client/segment-context.ts +57 -0
  31. package/src/client/ssr-data.ts +95 -0
  32. package/src/client/types.ts +4 -0
  33. package/src/client/unload-guard.ts +34 -0
  34. package/src/client/use-cookie.ts +122 -0
  35. package/src/client/use-link-status.ts +46 -0
  36. package/src/client/use-navigation-pending.ts +47 -0
  37. package/src/client/use-params.ts +71 -0
  38. package/src/client/use-pathname.ts +43 -0
  39. package/src/client/use-query-states.ts +133 -0
  40. package/src/client/use-router.ts +77 -0
  41. package/src/client/use-search-params.ts +74 -0
  42. package/src/client/use-selected-layout-segment.ts +110 -0
  43. package/src/content/index.ts +13 -0
  44. package/src/cookies/define-cookie.ts +137 -0
  45. package/src/cookies/index.ts +9 -0
  46. package/src/fonts/ast.ts +359 -0
  47. package/src/fonts/css.ts +68 -0
  48. package/src/fonts/fallbacks.ts +248 -0
  49. package/src/fonts/google.ts +332 -0
  50. package/src/fonts/local.ts +177 -0
  51. package/src/fonts/types.ts +88 -0
  52. package/src/index.ts +420 -0
  53. package/src/plugins/adapter-build.ts +118 -0
  54. package/src/plugins/build-manifest.ts +323 -0
  55. package/src/plugins/build-report.ts +353 -0
  56. package/src/plugins/cache-transform.ts +199 -0
  57. package/src/plugins/chunks.ts +90 -0
  58. package/src/plugins/content.ts +136 -0
  59. package/src/plugins/dev-error-overlay.ts +230 -0
  60. package/src/plugins/dev-logs.ts +280 -0
  61. package/src/plugins/dev-server.ts +391 -0
  62. package/src/plugins/dynamic-transform.ts +161 -0
  63. package/src/plugins/entries.ts +214 -0
  64. package/src/plugins/fonts.ts +581 -0
  65. package/src/plugins/mdx.ts +179 -0
  66. package/src/plugins/react-prod.ts +56 -0
  67. package/src/plugins/routing.ts +419 -0
  68. package/src/plugins/server-action-exports.ts +220 -0
  69. package/src/plugins/server-bundle.ts +113 -0
  70. package/src/plugins/shims.ts +168 -0
  71. package/src/plugins/static-build.ts +207 -0
  72. package/src/routing/codegen.ts +396 -0
  73. package/src/routing/index.ts +14 -0
  74. package/src/routing/interception.ts +173 -0
  75. package/src/routing/scanner.ts +487 -0
  76. package/src/routing/status-file-lint.ts +114 -0
  77. package/src/routing/types.ts +100 -0
  78. package/src/search-params/analyze.ts +192 -0
  79. package/src/search-params/codecs.ts +153 -0
  80. package/src/search-params/create.ts +314 -0
  81. package/src/search-params/index.ts +23 -0
  82. package/src/search-params/registry.ts +31 -0
  83. package/src/server/access-gate.tsx +142 -0
  84. package/src/server/action-client.ts +473 -0
  85. package/src/server/action-handler.ts +325 -0
  86. package/src/server/actions.ts +236 -0
  87. package/src/server/asset-headers.ts +81 -0
  88. package/src/server/body-limits.ts +102 -0
  89. package/src/server/build-manifest.ts +234 -0
  90. package/src/server/canonicalize.ts +90 -0
  91. package/src/server/client-module-map.ts +58 -0
  92. package/src/server/csrf.ts +79 -0
  93. package/src/server/deny-renderer.ts +302 -0
  94. package/src/server/dev-logger.ts +419 -0
  95. package/src/server/dev-span-processor.ts +78 -0
  96. package/src/server/dev-warnings.ts +282 -0
  97. package/src/server/early-hints-sender.ts +55 -0
  98. package/src/server/early-hints.ts +142 -0
  99. package/src/server/error-boundary-wrapper.ts +69 -0
  100. package/src/server/error-formatter.ts +184 -0
  101. package/src/server/flush.ts +182 -0
  102. package/src/server/form-data.ts +176 -0
  103. package/src/server/form-flash.ts +93 -0
  104. package/src/server/html-injectors.ts +445 -0
  105. package/src/server/index.ts +222 -0
  106. package/src/server/instrumentation.ts +136 -0
  107. package/src/server/logger.ts +145 -0
  108. package/src/server/manifest-status-resolver.ts +215 -0
  109. package/src/server/metadata-render.ts +527 -0
  110. package/src/server/metadata-routes.ts +189 -0
  111. package/src/server/metadata.ts +263 -0
  112. package/src/server/middleware-runner.ts +32 -0
  113. package/src/server/nuqs-ssr-provider.tsx +63 -0
  114. package/src/server/pipeline.ts +555 -0
  115. package/src/server/prerender.ts +139 -0
  116. package/src/server/primitives.ts +264 -0
  117. package/src/server/proxy.ts +43 -0
  118. package/src/server/request-context.ts +554 -0
  119. package/src/server/route-element-builder.ts +395 -0
  120. package/src/server/route-handler.ts +153 -0
  121. package/src/server/route-matcher.ts +316 -0
  122. package/src/server/rsc-entry/api-handler.ts +112 -0
  123. package/src/server/rsc-entry/error-renderer.ts +177 -0
  124. package/src/server/rsc-entry/helpers.ts +147 -0
  125. package/src/server/rsc-entry/index.ts +688 -0
  126. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  127. package/src/server/slot-resolver.ts +359 -0
  128. package/src/server/ssr-entry.ts +161 -0
  129. package/src/server/ssr-render.ts +200 -0
  130. package/src/server/status-code-resolver.ts +282 -0
  131. package/src/server/tracing.ts +281 -0
  132. package/src/server/tree-builder.ts +354 -0
  133. package/src/server/types.ts +150 -0
  134. package/src/shims/font-google.ts +67 -0
  135. package/src/shims/headers.ts +11 -0
  136. package/src/shims/image.ts +48 -0
  137. package/src/shims/link.ts +9 -0
  138. package/src/shims/navigation-client.ts +52 -0
  139. package/src/shims/navigation.ts +31 -0
  140. package/src/shims/server-only-noop.js +5 -0
  141. package/src/utils/directive-parser.ts +529 -0
  142. package/src/utils/format.ts +10 -0
  143. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,688 @@
1
+ /// <reference types="@vitejs/plugin-rsc/types" />
2
+
3
+ /**
4
+ * RSC Entry — Request handler for the RSC environment.
5
+ *
6
+ * This is a real TypeScript file, not codegen. It imports the route
7
+ * manifest from a virtual module and creates the request handler.
8
+ *
9
+ * The RSC entry renders the React element tree into an RSC Flight stream
10
+ * using @vitejs/plugin-rsc/rsc. This stream encodes server components as
11
+ * rendered output and client components ("use client") as serialized
12
+ * references. The stream is then passed to the SSR entry (in a separate
13
+ * Vite environment) which decodes it and renders HTML.
14
+ *
15
+ * Design docs: 18-build-system.md §"Entry Files", 02-rendering-pipeline.md
16
+ */
17
+
18
+ // @ts-expect-error — virtual module provided by timber-routing plugin
19
+ import routeManifest from 'virtual:timber-route-manifest';
20
+ // @ts-expect-error — virtual module provided by timber-entries plugin
21
+ import config from 'virtual:timber-config';
22
+ // @ts-expect-error — virtual module provided by timber-build-manifest plugin
23
+ import buildManifest from 'virtual:timber-build-manifest';
24
+
25
+ import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
26
+
27
+ import { createPipeline } from '#/server/pipeline.js';
28
+ import { initDevTracing } from '#/server/tracing.js';
29
+ import type { PipelineConfig, RouteMatch, InterceptionContext } from '#/server/pipeline.js';
30
+ import { logRenderError } from '#/server/logger.js';
31
+ import { resolveLogMode } from '#/server/dev-logger.js';
32
+ import { createRouteMatcher, createMetadataRouteMatcher } from '#/server/route-matcher.js';
33
+ import type { ManifestSegmentNode } from '#/server/route-matcher.js';
34
+ import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js';
35
+ import { buildClientScripts } from '#/server/html-injectors.js';
36
+ import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
37
+ import { renderDenyPage, renderDenyPageAsRsc } from '#/server/deny-renderer.js';
38
+ import type { LayoutEntry } from '#/server/deny-renderer.js';
39
+ import {
40
+ collectRouteCss,
41
+ collectRouteFonts,
42
+ collectRouteModulepreloads,
43
+ buildCssLinkTags,
44
+ buildFontPreloadTags,
45
+ buildModulepreloadTags,
46
+ } from '#/server/build-manifest.js';
47
+ import type { BuildManifest } from '#/server/build-manifest.js';
48
+ import { collectEarlyHintHeaders } from '#/server/early-hints.js';
49
+ import { sendEarlyHints103 } from '#/server/early-hints-sender.js';
50
+ import type { NavContext } from '#/server/ssr-entry.js';
51
+ import { buildRouteElement, RouteSignalWithContext } from '#/server/route-element-builder.js';
52
+ import { isActionRequest, handleActionRequest } from '#/server/action-handler.js';
53
+ import type { FormRerender } from '#/server/action-handler.js';
54
+ import type { BodyLimitsConfig } from '#/server/body-limits.js';
55
+ import { runWithFormFlash } from '#/server/form-flash.js';
56
+
57
+ import {
58
+ createDebugChannelSink,
59
+ buildSegmentInfo,
60
+ isRscPayloadRequest,
61
+ buildRedirectResponse,
62
+ escapeHtml,
63
+ isAbortError,
64
+ parseCookiesFromHeader,
65
+ RSC_CONTENT_TYPE,
66
+ } from './helpers.js';
67
+ import { handleApiRoute } from './api-handler.js';
68
+ import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
69
+ import { callSsr } from './ssr-bridge.js';
70
+
71
+ // Dev-only pipeline error handler, set by the dev server after import.
72
+ // In production this is always undefined — no overhead.
73
+ let _devPipelineErrorHandler: ((error: Error, phase: string) => void) | undefined;
74
+
75
+ /**
76
+ * Set the dev pipeline error handler.
77
+ *
78
+ * Called by the dev server after importing this module to wire pipeline
79
+ * errors into the Vite browser error overlay. No-op in production.
80
+ */
81
+ export function setDevPipelineErrorHandler(handler: (error: Error, phase: string) => void): void {
82
+ _devPipelineErrorHandler = handler;
83
+ }
84
+
85
+ /**
86
+ * Create the RSC request handler from the route manifest.
87
+ *
88
+ * The pipeline handles: proxy.ts → canonicalize → route match →
89
+ * 103 Early Hints → middleware.ts → render (RSC → SSR → HTML).
90
+ */
91
+ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfig: typeof config) {
92
+ // Initialize cookie signing secrets from config (design/29-cookies.md §"Signed Cookies")
93
+ const cookieSecrets = (runtimeConfig as Record<string, unknown>).cookieSecrets as
94
+ | string[]
95
+ | undefined;
96
+ if (cookieSecrets?.length) {
97
+ const { setCookieSecrets } = await import('#/server/request-context.js');
98
+ setCookieSecrets(cookieSecrets);
99
+ }
100
+
101
+ const matchRoute = createRouteMatcher(manifest);
102
+ const matchMetadataRoute = createMetadataRouteMatcher(manifest);
103
+
104
+ // Build the client bootstrap configuration.
105
+ // When client JavaScript is disabled, no scripts are injected
106
+ // (unless enableHMRInDev is true in dev mode — then only HMR client).
107
+ // In production, uses hashed chunk URLs from the build manifest.
108
+ const clientJsConfig = (runtimeConfig as Record<string, unknown>).clientJavascript as
109
+ | { disabled: boolean; enableHMRInDev: boolean }
110
+ | undefined;
111
+ const clientJsDisabled = clientJsConfig?.disabled ?? false;
112
+ const clientBootstrap = buildClientScripts({
113
+ ...runtimeConfig,
114
+ clientJavascript: clientJsConfig ?? { disabled: false, enableHMRInDev: false },
115
+ buildManifest: buildManifest as BuildManifest,
116
+ });
117
+
118
+ // Dev logging — initialize OTEL-based dev tracing once at handler creation.
119
+ // In production, isDev is false — no tracing, no overhead.
120
+ // The DevSpanProcessor handles all formatting and stderr output.
121
+ const isDev = process.env.NODE_ENV !== 'production';
122
+ const slowPhaseMs = (runtimeConfig as Record<string, unknown>).slowPhaseMs as number | undefined;
123
+
124
+ if (isDev) {
125
+ const devLogMode = resolveLogMode();
126
+ if (devLogMode !== 'quiet') {
127
+ await initDevTracing({ mode: devLogMode, slowPhaseMs });
128
+ }
129
+ }
130
+
131
+ const typedBuildManifest = buildManifest as BuildManifest;
132
+
133
+ const pipelineConfig: PipelineConfig = {
134
+ proxyLoader: manifest.proxy?.load,
135
+ matchRoute,
136
+ matchMetadataRoute,
137
+ // 103 Early Hints — fires after route match, before middleware.
138
+ // Collects CSS, font, and JS chunk Link headers from the build manifest
139
+ // so the browser starts fetching critical resources while the server renders.
140
+ // In dev mode the manifest is empty — no hints are sent.
141
+ earlyHints: (match: RouteMatch, _req: Request, responseHeaders: Headers) => {
142
+ const segments = match.segments as unknown as Array<{
143
+ layout?: { filePath: string };
144
+ page?: { filePath: string };
145
+ }>;
146
+ const headers = collectEarlyHintHeaders(segments, typedBuildManifest, {
147
+ skipJs: clientJsDisabled,
148
+ });
149
+ for (const h of headers) {
150
+ responseHeaders.append('Link', h);
151
+ }
152
+ // Send 103 Early Hints if the platform supports it (Node.js, Bun).
153
+ // On Cloudflare, the CDN converts Link headers into 103 automatically.
154
+ sendEarlyHints103(headers);
155
+ },
156
+ render: async (
157
+ req: Request,
158
+ match: RouteMatch,
159
+ responseHeaders: Headers,
160
+ _requestHeaderOverlay: Headers,
161
+ interception?: InterceptionContext
162
+ ) => {
163
+ return renderRoute(
164
+ req,
165
+ match,
166
+ responseHeaders,
167
+ clientBootstrap,
168
+ clientJsDisabled,
169
+ interception
170
+ );
171
+ },
172
+ renderNoMatch: async (req: Request, responseHeaders: Headers) => {
173
+ return renderNoMatchPage(req, manifest.root, responseHeaders, clientBootstrap);
174
+ },
175
+ interceptionRewrites: manifest.interceptionRewrites,
176
+ onPipelineError: isDev
177
+ ? (error: Error, phase: string) => {
178
+ if (_devPipelineErrorHandler) _devPipelineErrorHandler(error, phase);
179
+ }
180
+ : undefined,
181
+ };
182
+
183
+ const pipeline = createPipeline(pipelineConfig);
184
+
185
+ // Wrap the pipeline to intercept server action requests before rendering.
186
+ // Actions bypass the normal pipeline (no route matching, no middleware)
187
+ // per design/08-forms-and-actions.md §"Middleware for Server Actions".
188
+ const csrfConfig = {
189
+ csrf: runtimeConfig.csrf,
190
+ allowedOrigins: (runtimeConfig as Record<string, unknown>).allowedOrigins as
191
+ | string[]
192
+ | undefined,
193
+ };
194
+
195
+ return async (req: Request): Promise<Response> => {
196
+ if (isActionRequest(req)) {
197
+ const actionResponse = await handleActionRequest(req, {
198
+ csrf: csrfConfig,
199
+ bodyLimits: {
200
+ limits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
201
+ },
202
+ revalidateRenderer: async (path: string) => {
203
+ // Build the React element tree for the route at `path`.
204
+ // Returns the element tree (not serialized) so the action handler can
205
+ // combine it with the action result in a single renderToReadableStream call.
206
+ // Forward original request headers (cookies, session IDs, etc.).
207
+ const revalidateHeaders = new Headers(req.headers);
208
+ revalidateHeaders.set('Accept', 'text/x-component');
209
+ const revalidateReq = new Request(new URL(path, req.url), {
210
+ headers: revalidateHeaders,
211
+ });
212
+ const revalidateMatch = matchRoute(new URL(revalidateReq.url).pathname);
213
+ if (!revalidateMatch) {
214
+ throw new Error(`revalidatePath('${path}') — no matching route`);
215
+ }
216
+ const routeResult = await buildRouteElement(revalidateReq, revalidateMatch);
217
+ return {
218
+ element: routeResult.element,
219
+ headElements: routeResult.headElements,
220
+ };
221
+ },
222
+ });
223
+ if (actionResponse) {
224
+ // Check if this is a re-render signal (no-JS validation failure)
225
+ if ('rerender' in actionResponse) {
226
+ const formRerender = actionResponse as FormRerender;
227
+ // Re-render the page with the action result as flash data.
228
+ // Server components read it via getFormFlash() and pass it to
229
+ // client form components as the initial useActionState value.
230
+ const response = await runWithFormFlash(formRerender.rerender, () => pipeline(req));
231
+ return response;
232
+ }
233
+ return actionResponse;
234
+ }
235
+ }
236
+ return pipeline(req);
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Render a matched route to an HTML Response via RSC → SSR pipeline,
242
+ * or return a raw RSC Flight stream for client-side navigation requests.
243
+ *
244
+ * 1. Load page/layout components from the segment chain
245
+ * 2. Resolve metadata
246
+ * 3. Render to RSC Flight stream (serializes "use client" as references)
247
+ * 4. If Accept: text/x-component → return RSC stream directly
248
+ * Otherwise → pass RSC stream to SSR entry for HTML rendering
249
+ */
250
+ async function renderRoute(
251
+ _req: Request,
252
+ match: RouteMatch,
253
+ responseHeaders: Headers,
254
+ clientBootstrap: ClientBootstrapConfig,
255
+ clientJsDisabled: boolean,
256
+ interception?: InterceptionContext
257
+ ): Promise<Response> {
258
+ const segments = match.segments as unknown as ManifestSegmentNode[];
259
+ const leaf = segments[segments.length - 1];
260
+
261
+ // API routes (route.ts) — run access.ts standalone then dispatch to handler.
262
+ // No React render pass — AccessGate is not used, React.cache is not active.
263
+ // See design/04-authorization.md §"Auth in API Routes".
264
+ if (leaf.route && !leaf.page) {
265
+ return handleApiRoute(_req, match, segments, responseHeaders);
266
+ }
267
+
268
+ // Build the React element tree — loads modules, runs access checks,
269
+ // resolves metadata. DenySignal/RedirectSignal propagate for HTTP handling.
270
+ let routeResult;
271
+ try {
272
+ routeResult = await buildRouteElement(_req, match, interception);
273
+ } catch (error) {
274
+ // RouteSignalWithContext wraps DenySignal/RedirectSignal with layout context
275
+ if (error instanceof RouteSignalWithContext) {
276
+ const { signal, layoutComponents: lc, segments: segs } = error;
277
+ if (signal instanceof DenySignal) {
278
+ if (isRscPayloadRequest(_req)) {
279
+ return renderDenyPageAsRsc(
280
+ signal,
281
+ segs,
282
+ lc as LayoutEntry[],
283
+ responseHeaders,
284
+ createDebugChannelSink
285
+ );
286
+ }
287
+ return renderDenyPage(
288
+ signal,
289
+ segs,
290
+ lc as LayoutEntry[],
291
+ _req,
292
+ match,
293
+ responseHeaders,
294
+ clientBootstrap,
295
+ createDebugChannelSink,
296
+ callSsr
297
+ );
298
+ }
299
+ if (signal instanceof RedirectSignal) {
300
+ return buildRedirectResponse(_req, signal, responseHeaders);
301
+ }
302
+ }
303
+ // No PageComponent found
304
+ if (error instanceof Error && error.message.startsWith('No page component')) {
305
+ return new Response(null, { status: 404 });
306
+ }
307
+ throw error;
308
+ }
309
+
310
+ const { element, headElements, layoutComponents, deferSuspenseFor } = routeResult;
311
+
312
+ // Build head HTML for injection into the SSR output
313
+ let headHtml = '';
314
+
315
+ // Collect CSS, fonts, and modulepreload from the build manifest for matched segments.
316
+ // In dev mode the manifest is empty — Vite HMR handles CSS/JS.
317
+ //
318
+ // Link headers (for 103 Early Hints) are emitted by the earlyHints pipeline
319
+ // stage before middleware runs. Here we only emit the <head> HTML fallback tags
320
+ // — these ensure resources load even on platforms without Early Hints support.
321
+ const typedManifest = buildManifest as BuildManifest;
322
+ const cssUrls = collectRouteCss(segments, typedManifest);
323
+ if (cssUrls.length > 0) {
324
+ headHtml += buildCssLinkTags(cssUrls);
325
+ }
326
+
327
+ const fontEntries = collectRouteFonts(segments, typedManifest);
328
+ if (fontEntries.length > 0) {
329
+ headHtml += buildFontPreloadTags(fontEntries);
330
+ }
331
+
332
+ // Skip modulepreload tags when client JavaScript is disabled — no JS to preload.
333
+ if (!clientJsDisabled) {
334
+ const preloadUrls = collectRouteModulepreloads(segments, typedManifest);
335
+ if (preloadUrls.length > 0) {
336
+ headHtml += buildModulepreloadTags(preloadUrls);
337
+ }
338
+ }
339
+
340
+ for (const el of headElements) {
341
+ if (el.tag === 'title' && el.content) {
342
+ headHtml += `<title>${escapeHtml(el.content)}</title>`;
343
+ } else if (el.attrs) {
344
+ const attrs = Object.entries(el.attrs)
345
+ .filter(([, v]) => v != null)
346
+ .map(([k, v]) => `${k}="${escapeHtml(v as string)}"`)
347
+ .join(' ');
348
+ headHtml += `<${el.tag} ${attrs}>`;
349
+ }
350
+ }
351
+
352
+ // Render to RSC Flight stream.
353
+ // renderToReadableStream from @vitejs/plugin-rsc/rsc serializes:
354
+ // - Server components: rendered output (HTML-like structure)
355
+ // - Client components ("use client"): serialized references with module ID + export name
356
+ //
357
+ // The RSC plugin's renderToReadableStream(data, reactOptions, extraOptions):
358
+ // - reactOptions: passed to React (onError, signal, etc.)
359
+ // - extraOptions: { onClientReference } for tracking client deps
360
+ // The client manifest is created internally by the plugin.
361
+ //
362
+ // DenySignal detection: deny() in sync components throws during
363
+ // renderToReadableStream (caught in try/catch). deny() in async components
364
+ // fires onError during stream consumption. We capture it here and let
365
+ // SSR determine whether it was pre-flush (outside Suspense) or post-flush
366
+ // (inside Suspense) based on whether the SSR shell renders successfully.
367
+ let denySignal: DenySignal | null = null;
368
+ let redirectSignal: RedirectSignal | null = null;
369
+ let renderError: { error: unknown; status: number } | null = null;
370
+ let rscStream: ReadableStream<Uint8Array> | undefined;
371
+ try {
372
+ rscStream = renderToReadableStream(
373
+ element,
374
+ {
375
+ signal: _req.signal,
376
+ onError(error: unknown) {
377
+ // Connection abort (user refreshed or navigated away) — suppress.
378
+ // Not an application error; no need to track or log.
379
+ if (isAbortError(error) || _req.signal?.aborted) return;
380
+ if (error instanceof DenySignal) {
381
+ denySignal = error;
382
+ // Return structured digest for client-side error boundaries
383
+ return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
384
+ }
385
+ if (error instanceof RedirectSignal) {
386
+ redirectSignal = error;
387
+ return JSON.stringify({
388
+ type: 'redirect',
389
+ location: error.location,
390
+ status: error.status,
391
+ });
392
+ }
393
+ if (error instanceof RenderError) {
394
+ // Track the first render error for pre-flush handling
395
+ if (!renderError) {
396
+ renderError = { error, status: error.status };
397
+ }
398
+ logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
399
+ return JSON.stringify({
400
+ type: 'render-error',
401
+ code: error.code,
402
+ data: error.digest.data,
403
+ status: error.status,
404
+ });
405
+ }
406
+ // Track unhandled errors for pre-flush handling (500 status)
407
+ if (!renderError) {
408
+ renderError = { error, status: 500 };
409
+ }
410
+ logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
411
+ },
412
+ debugChannel: createDebugChannelSink(),
413
+ },
414
+ {
415
+ onClientReference(info: { id: string; name: string; deps: unknown }) {
416
+ // Client reference callback — invoked when a "use client"
417
+ // component is serialized into the RSC stream. Can be extended
418
+ // for CSS dep collection and Early Hints.
419
+ void info;
420
+ },
421
+ }
422
+ );
423
+ } catch (error) {
424
+ if (error instanceof DenySignal) {
425
+ denySignal = error;
426
+ } else if (error instanceof RedirectSignal) {
427
+ redirectSignal = error;
428
+ } else {
429
+ // Synchronous render error — component threw during
430
+ // renderToReadableStream creation. Capture instead of crashing
431
+ // the server; the error page will be rendered below.
432
+ renderError = {
433
+ error,
434
+ status: error instanceof RenderError ? error.status : 500,
435
+ };
436
+ logRenderError({ method: _req.method, path: new URL(_req.url).pathname, error });
437
+ }
438
+ }
439
+
440
+ // Synchronous redirect — redirect() in access.ts or a non-async component
441
+ // throws during renderToReadableStream creation. Return HTTP redirect.
442
+ if (redirectSignal) {
443
+ return buildRedirectResponse(_req, redirectSignal, responseHeaders);
444
+ }
445
+
446
+ // Synchronous deny — deny() in a non-async component throws during
447
+ // renderToReadableStream creation, caught in the try/catch above.
448
+ if (denySignal) {
449
+ if (isRscPayloadRequest(_req)) {
450
+ return renderDenyPageAsRsc(
451
+ denySignal,
452
+ segments,
453
+ layoutComponents as LayoutEntry[],
454
+ responseHeaders,
455
+ createDebugChannelSink
456
+ );
457
+ }
458
+ return renderDenyPage(
459
+ denySignal,
460
+ segments,
461
+ layoutComponents as LayoutEntry[],
462
+ _req,
463
+ match,
464
+ responseHeaders,
465
+ clientBootstrap,
466
+ createDebugChannelSink,
467
+ callSsr
468
+ );
469
+ }
470
+
471
+ // Synchronous render error — renderToReadableStream threw before
472
+ // creating the stream. Render the error page with correct 5xx status.
473
+ // (Async render errors are tracked in onError and handled after SSR.)
474
+ if (renderError && !rscStream) {
475
+ return renderErrorPage(
476
+ renderError.error,
477
+ renderError.status,
478
+ segments,
479
+ layoutComponents as LayoutEntry[],
480
+ _req,
481
+ match,
482
+ responseHeaders,
483
+ clientBootstrap
484
+ );
485
+ }
486
+
487
+ // For RSC payload requests (client navigation), return the RSC Flight
488
+ // stream directly — skip SSR HTML rendering entirely.
489
+ // See design/19-client-navigation.md §"RSC Payload Handling"
490
+ if (isRscPayloadRequest(_req)) {
491
+ // Read the first chunk from the RSC stream before committing headers.
492
+ // Async components (including page components wrapped in TracedPage)
493
+ // throw during stream consumption, not during renderToReadableStream.
494
+ // Reading one chunk triggers rendering of the initial component tree,
495
+ // allowing onError to capture DenySignal/RedirectSignal before we
496
+ // commit the response. Without this, the redirect digest is embedded
497
+ // in the RSC stream and surfaces as an unhandled error on the client.
498
+ // See TIM-344.
499
+ const reader = rscStream!.getReader();
500
+ const firstRead = await reader.read();
501
+
502
+ // Yield to the microtask queue so that async component rejections
503
+ // (e.g. an async-wrapped page component that throws redirect())
504
+ // propagate to the onError callback before we check the signals.
505
+ // The rejected Promise from an async component resolves in the next
506
+ // microtask after read(), so we need at least one tick.
507
+ await new Promise<void>((r) => setTimeout(r, 0));
508
+
509
+ // Check for redirect/deny signals detected during initial rendering
510
+ const trackedRedirect = redirectSignal as RedirectSignal | null;
511
+ if (trackedRedirect) {
512
+ reader.cancel();
513
+ return buildRedirectResponse(_req, trackedRedirect, responseHeaders);
514
+ }
515
+ if (denySignal) {
516
+ reader.cancel();
517
+ return renderDenyPageAsRsc(
518
+ denySignal,
519
+ segments,
520
+ layoutComponents as LayoutEntry[],
521
+ responseHeaders,
522
+ createDebugChannelSink
523
+ );
524
+ }
525
+
526
+ // Reconstruct the stream: prepend the buffered first chunk,
527
+ // then continue piping from the original reader.
528
+ const patchedStream = new ReadableStream<Uint8Array>({
529
+ start(controller) {
530
+ if (firstRead.value) controller.enqueue(firstRead.value);
531
+ if (firstRead.done) {
532
+ controller.close();
533
+ return;
534
+ }
535
+ },
536
+ async pull(controller) {
537
+ const { value, done } = await reader.read();
538
+ if (done) {
539
+ controller.close();
540
+ return;
541
+ }
542
+ controller.enqueue(value);
543
+ },
544
+ cancel() {
545
+ reader.cancel();
546
+ },
547
+ });
548
+
549
+ responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
550
+ // Vary on Accept so CDNs cache HTML and RSC responses separately
551
+ // for the same URL. The client appends ?_rsc=<id> as a cache-bust,
552
+ // but Vary ensures correct behavior even without the query param.
553
+ responseHeaders.set('Vary', 'Accept');
554
+
555
+ // Send resolved head elements so the client can update document.title
556
+ // and <meta> tags after SPA navigation. See design/16-metadata.md.
557
+ const encoded = encodeURIComponent(JSON.stringify(headElements));
558
+ if (encoded.length <= 4096) {
559
+ responseHeaders.set('X-Timber-Head', encoded);
560
+ }
561
+
562
+ // Send segment metadata so the client can populate its segment cache
563
+ // for state tree diffing on subsequent navigations.
564
+ // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
565
+ const segmentInfo = buildSegmentInfo(segments, layoutComponents);
566
+ responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
567
+
568
+ // Send route params so the client can populate useParams() after
569
+ // SPA navigation. Without this, useParams() returns {}.
570
+ if (Object.keys(match.params).length > 0) {
571
+ responseHeaders.set('X-Timber-Params', JSON.stringify(match.params));
572
+ }
573
+
574
+ return new Response(patchedStream, {
575
+ status: 200,
576
+ headers: responseHeaders,
577
+ });
578
+ }
579
+
580
+ // Progressive streaming: pipe the RSC stream directly to SSR without
581
+ // buffering. This enables proper Suspense streaming behavior.
582
+ //
583
+ // For async deny() (inside components that await before calling deny()),
584
+ // SSR will attempt to render the element tree progressively. Two outcomes:
585
+ //
586
+ // 1. deny() outside Suspense: the error appears in the RSC shell. SSR's
587
+ // renderToReadableStream fails (rejects). We catch the failure, check
588
+ // denySignal, and render the deny page with the correct status code.
589
+ //
590
+ // 2. deny() inside Suspense: the SSR shell succeeds (200 committed). The
591
+ // error streams into the connection as a React error boundary. The
592
+ // status is already committed — per design/05-streaming.md this is the
593
+ // expected degraded behavior for deny inside Suspense.
594
+ //
595
+ // Tee the RSC stream — one copy goes to SSR for HTML rendering,
596
+ // the other is inlined in the HTML for client-side hydration.
597
+ const [ssrStream, inlineStream] = rscStream!.tee();
598
+
599
+ // Embed segment metadata in HTML for initial hydration.
600
+ // The client reads this to populate its segment cache before the
601
+ // first navigation, enabling state tree diffing from the start.
602
+ // Skipped when client JS is disabled — no client JS to consume it.
603
+ const segmentScript = clientJsDisabled
604
+ ? ''
605
+ : `<script>self.__timber_segments=${JSON.stringify(buildSegmentInfo(segments, layoutComponents))}</script>`;
606
+
607
+ // Embed route params in HTML so useParams() works on initial hydration.
608
+ // Without this, useParams() returns {} until the first client navigation.
609
+ const paramsScript =
610
+ clientJsDisabled || Object.keys(match.params).length === 0
611
+ ? ''
612
+ : `<script>self.__timber_params=${JSON.stringify(match.params)}</script>`;
613
+
614
+ const navContext: NavContext = {
615
+ pathname: new URL(_req.url).pathname,
616
+ params: match.params,
617
+ searchParams: Object.fromEntries(new URL(_req.url).searchParams),
618
+ statusCode: 200,
619
+ responseHeaders,
620
+ headHtml: headHtml + clientBootstrap.preloadLinks + segmentScript + paramsScript,
621
+ bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
622
+ // Skip RSC inline stream when client JS is disabled — no client to hydrate.
623
+ rscStream: clientJsDisabled ? undefined : inlineStream,
624
+ deferSuspenseFor: deferSuspenseFor > 0 ? deferSuspenseFor : undefined,
625
+ signal: _req.signal,
626
+ cookies: parseCookiesFromHeader(_req.headers.get('cookie') ?? ''),
627
+ };
628
+
629
+ // Helper: check if render-phase signals were captured and return the
630
+ // appropriate HTTP response. Used after both successful SSR (signal
631
+ // promotion from Suspense) and failed SSR (signal outside Suspense).
632
+ function checkCapturedSignals(): Response | Promise<Response> | null {
633
+ const sig = redirectSignal as RedirectSignal | null;
634
+ if (sig) return buildRedirectResponse(_req, sig, responseHeaders);
635
+ if (denySignal) {
636
+ return renderDenyPage(
637
+ denySignal, segments, layoutComponents as LayoutEntry[],
638
+ _req, match, responseHeaders, clientBootstrap, createDebugChannelSink, callSsr
639
+ );
640
+ }
641
+ const err = renderError as { error: unknown; status: number } | null;
642
+ if (err) {
643
+ return renderErrorPage(
644
+ err.error, err.status, segments, layoutComponents as LayoutEntry[],
645
+ _req, match, responseHeaders, clientBootstrap
646
+ );
647
+ }
648
+ return null;
649
+ }
650
+
651
+ try {
652
+ const ssrResponse = await callSsr(ssrStream, navContext);
653
+
654
+ // Signal promotion: yield one tick so async component rejections
655
+ // propagate to the RSC onError callback, then check if any signals
656
+ // were captured during rendering inside Suspense boundaries.
657
+ // The Response hasn't been sent yet — it's an unconsumed stream.
658
+ // See design/05-streaming.md §"deferSuspenseFor and the Hold Window"
659
+ await new Promise<void>((r) => setTimeout(r, 0));
660
+
661
+ const promoted = checkCapturedSignals();
662
+ if (promoted) {
663
+ ssrResponse.body?.cancel();
664
+ return promoted;
665
+ }
666
+ return ssrResponse;
667
+ } catch (ssrError) {
668
+ // Connection abort — the client disconnected (page refresh, navigation
669
+ // away). No response needed; return empty 499 (client closed request).
670
+ if (isAbortError(ssrError) || _req.signal?.aborted) {
671
+ return new Response(null, { status: 499 });
672
+ }
673
+
674
+ // SSR shell rendering failed — the error was outside Suspense.
675
+ // Check captured signals (redirect, deny, render error).
676
+ const signalResponse = checkCapturedSignals();
677
+ if (signalResponse) return signalResponse;
678
+
679
+ // No tracked error — rethrow (infrastructure failure)
680
+ throw ssrError;
681
+ }
682
+ }
683
+
684
+ // Re-export for generated entry points (e.g., Nitro node-server/bun) to wrap
685
+ // the handler with per-request 103 Early Hints sender via ALS.
686
+ export { runWithEarlyHintsSender } from '#/server/early-hints-sender.js';
687
+
688
+ export default await createRequestHandler(routeManifest, config);