@timber-js/app 0.1.0 → 0.1.2

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 (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +43 -58
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * SSR Bridge — loads the SSR entry and passes the RSC stream for HTML rendering.
3
+ */
4
+
5
+ /// <reference types="@vitejs/plugin-rsc/types" />
6
+
7
+ import type { NavContext } from '#/server/ssr-entry.js';
8
+
9
+ export async function callSsr(
10
+ rscStream: ReadableStream<Uint8Array>,
11
+ navContext: NavContext
12
+ ): Promise<Response> {
13
+ const ssrEntry = await import.meta.viteRsc.import<typeof import('#/server/ssr-entry.js')>(
14
+ '../ssr-entry.js',
15
+ { environment: 'ssr' }
16
+ );
17
+ return ssrEntry.handleSsr(rscStream, navContext);
18
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Parallel slot resolution for RSC rendering.
3
+ *
4
+ * Resolves slot elements for a layout's parallel routes (@slot directories).
5
+ * Each slot either matches the current URL (renders its page) or doesn't
6
+ * match (renders default.tsx fallback).
7
+ *
8
+ * Slots are rendered within the single renderToReadableStream call as
9
+ * named props to their parent layout — no separate render passes.
10
+ *
11
+ * Each slot gets its own error boundaries (from error.tsx / status files
12
+ * along the matched slot segment chain) and layouts (from layout.tsx files
13
+ * in the slot's sub-tree). This enables independent error handling and
14
+ * chrome per slot.
15
+ *
16
+ * See design/02-rendering-pipeline.md §"Parallel Slots"
17
+ */
18
+
19
+ import type { ManifestSegmentNode } from './route-matcher.js';
20
+ import type { RouteMatch, InterceptionContext } from './pipeline.js';
21
+ import { SlotAccessGate } from './access-gate.js';
22
+ import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
23
+
24
+ type CreateElementFn = (...args: unknown[]) => React.ReactElement;
25
+
26
+ /**
27
+ * Resolve the element for a parallel slot.
28
+ *
29
+ * Finds a matching page in the slot's sub-tree for the current route.
30
+ * Falls back to default.tsx if no match, or null if no default.
31
+ *
32
+ * When a match is found, the element is wrapped with:
33
+ * 1. Error boundaries from each segment in the slot's matched chain
34
+ * 2. Layouts from each segment in the slot's matched chain
35
+ * 3. SlotAccessGate if the slot root has access.ts
36
+ */
37
+ export async function resolveSlotElement(
38
+ slotNode: ManifestSegmentNode,
39
+ match: RouteMatch,
40
+ paramsPromise: Promise<Record<string, string | string[]>>,
41
+ h: CreateElementFn,
42
+ interception?: InterceptionContext
43
+ ): Promise<React.ReactElement | null> {
44
+ // When interception is active, try to match intercepting children in this
45
+ // slot against the target pathname. If an intercepting child matches, render
46
+ // it instead of the normal slot match. This enables the modal pattern:
47
+ // the slot shows the intercepted content on soft navigation.
48
+ const slotMatch = interception
49
+ ? (findInterceptingMatch(slotNode, interception.targetPathname) ??
50
+ findSlotMatch(slotNode, match))
51
+ : findSlotMatch(slotNode, match);
52
+
53
+ if (slotMatch) {
54
+ const mod = (await slotMatch.page.load()) as Record<string, unknown>;
55
+ if (mod.default) {
56
+ const SlotPage = mod.default as (...args: unknown[]) => unknown;
57
+ let element: React.ReactElement = h(SlotPage, {
58
+ params: paramsPromise,
59
+ searchParams: {},
60
+ });
61
+
62
+ // Wrap with error boundaries and layouts from intermediate slot segments
63
+ // (everything between slot root and leaf). Process innermost-first, same
64
+ // order as route-element-builder.ts handles main segments. The slot root
65
+ // (index 0) is handled separately after the access gate below.
66
+ for (let i = slotMatch.chain.length - 1; i > 0; i--) {
67
+ const seg = slotMatch.chain[i];
68
+
69
+ // Error boundaries from this segment
70
+ element = await wrapSegmentWithErrorBoundaries(seg, element, h);
71
+
72
+ // Layout from this segment
73
+ if (seg.layout) {
74
+ const layoutMod = (await seg.layout.load()) as Record<string, unknown>;
75
+ if (layoutMod.default) {
76
+ const Layout = layoutMod.default as (...args: unknown[]) => unknown;
77
+ element = h(Layout, {
78
+ params: paramsPromise,
79
+ searchParams: {},
80
+ children: element,
81
+ });
82
+ }
83
+ }
84
+ }
85
+
86
+ // Wrap in SlotAccessGate if slot root has access.ts.
87
+ // On denial: denied.tsx → default.tsx → null (graceful degradation).
88
+ // See design/04-authorization.md §"Slot-Level Auth".
89
+ if (slotNode.access) {
90
+ const accessMod = (await slotNode.access.load()) as Record<string, unknown>;
91
+ const accessFn = accessMod.default as
92
+ | ((ctx: { params: Record<string, string | string[]>; searchParams: unknown }) => unknown)
93
+ | undefined;
94
+ if (accessFn) {
95
+ // Load denied.tsx fallback
96
+ let deniedFallback: React.ReactElement | null = null;
97
+ if (slotNode.denied) {
98
+ const deniedMod = (await slotNode.denied.load()) as Record<string, unknown>;
99
+ const DeniedComponent = deniedMod.default as
100
+ | ((...args: unknown[]) => unknown)
101
+ | undefined;
102
+ if (DeniedComponent) {
103
+ deniedFallback = h(DeniedComponent, {});
104
+ }
105
+ }
106
+
107
+ // Load default.tsx fallback
108
+ let defaultFallback: React.ReactElement | null = null;
109
+ if (slotNode.default) {
110
+ const defaultMod = (await slotNode.default.load()) as Record<string, unknown>;
111
+ const DefaultComp = defaultMod.default as ((...args: unknown[]) => unknown) | undefined;
112
+ if (DefaultComp) {
113
+ defaultFallback = h(DefaultComp, { params: paramsPromise, searchParams: {} });
114
+ }
115
+ }
116
+
117
+ const params = await paramsPromise;
118
+ element = h(SlotAccessGate, {
119
+ accessFn,
120
+ params,
121
+ searchParams: {},
122
+ deniedFallback,
123
+ defaultFallback,
124
+ children: element,
125
+ });
126
+ }
127
+ }
128
+
129
+ // Wrap with slot root's layout (outermost, outside access gate)
130
+ if (slotNode.layout) {
131
+ const layoutMod = (await slotNode.layout.load()) as Record<string, unknown>;
132
+ if (layoutMod.default) {
133
+ const Layout = layoutMod.default as (...args: unknown[]) => unknown;
134
+ element = h(Layout, {
135
+ params: paramsPromise,
136
+ searchParams: {},
137
+ children: element,
138
+ });
139
+ }
140
+ }
141
+
142
+ // Wrap with slot root's error boundaries (outermost)
143
+ element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
144
+
145
+ return element;
146
+ }
147
+ }
148
+
149
+ // No matching page — render default.tsx fallback
150
+ if (slotNode.default) {
151
+ const mod = (await slotNode.default.load()) as Record<string, unknown>;
152
+ if (mod.default) {
153
+ const DefaultComponent = mod.default as (...args: unknown[]) => unknown;
154
+ return h(DefaultComponent, { params: paramsPromise, searchParams: {} });
155
+ }
156
+ }
157
+
158
+ // No page and no default — slot renders nothing
159
+ return null;
160
+ }
161
+
162
+ /** Result of matching a slot's sub-tree against the current route. */
163
+ interface SlotMatchResult {
164
+ /** The page file at the matched leaf. */
165
+ page: NonNullable<ManifestSegmentNode['page']>;
166
+ /** The full chain of slot nodes traversed (slot root → … → leaf with page). */
167
+ chain: ManifestSegmentNode[];
168
+ }
169
+
170
+ /**
171
+ * Find a matching page in a slot's sub-tree for the current route.
172
+ *
173
+ * Returns the matched page AND the full chain of nodes traversed, so the
174
+ * caller can apply error boundaries and layouts from each intermediate segment.
175
+ *
176
+ * Slots don't add URL depth (they're at the same level as their parent).
177
+ * A slot at segment /parallel with children /parallel/projects means:
178
+ * - URL /parallel → slot's own page.tsx
179
+ * - URL /parallel/projects → slot's projects/page.tsx
180
+ * - URL /parallel/about → no match (use default.tsx)
181
+ *
182
+ * We compare the matched route's segment chain against the slot's children
183
+ * to find the deepest matching page.
184
+ */
185
+ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMatchResult | null {
186
+ const segments = match.segments as unknown as ManifestSegmentNode[];
187
+
188
+ // Find the parent segment that owns this slot by comparing urlPaths.
189
+ // The slot's urlPath matches its parent's urlPath (slots don't add URL depth).
190
+ const slotUrlPath = slotNode.urlPath;
191
+ let parentIndex = -1;
192
+ for (let i = 0; i < segments.length; i++) {
193
+ if (segments[i].urlPath === slotUrlPath) {
194
+ parentIndex = i;
195
+ break;
196
+ }
197
+ }
198
+
199
+ // The remaining segments after the parent are what we need to match
200
+ // against the slot's children.
201
+ const remainingSegments = parentIndex >= 0 ? segments.slice(parentIndex + 1) : [];
202
+
203
+ // If no remaining segments, the slot's own page matches
204
+ if (remainingSegments.length === 0) {
205
+ if (slotNode.page) {
206
+ return { page: slotNode.page, chain: [slotNode] };
207
+ }
208
+ return null;
209
+ }
210
+
211
+ // Walk the slot's children to match remaining URL segments.
212
+ // Track the chain so we can apply error boundaries and layouts.
213
+ const chain: ManifestSegmentNode[] = [slotNode];
214
+ let currentNode = slotNode;
215
+ for (const seg of remainingSegments) {
216
+ const childName = seg.segmentName;
217
+ const directChildren = currentNode.children ?? [];
218
+
219
+ let found: ManifestSegmentNode | null = null;
220
+ for (const child of directChildren) {
221
+ // Exact static match
222
+ if (child.segmentType === 'static' && child.segmentName === childName) {
223
+ found = child;
224
+ break;
225
+ }
226
+ }
227
+
228
+ // Try dynamic segments if no static match
229
+ if (!found) {
230
+ for (const child of directChildren) {
231
+ if (child.segmentType === 'dynamic' || child.segmentType === 'catch-all') {
232
+ found = child;
233
+ break;
234
+ }
235
+ }
236
+ }
237
+
238
+ // Try group children (transparent)
239
+ if (!found) {
240
+ for (const child of directChildren) {
241
+ if (child.segmentType === 'group') {
242
+ for (const groupChild of child.children ?? []) {
243
+ if (groupChild.segmentName === childName) {
244
+ found = groupChild;
245
+ break;
246
+ }
247
+ }
248
+ if (found) break;
249
+ }
250
+ }
251
+ }
252
+
253
+ if (!found) {
254
+ // No matching child in slot tree — slot doesn't match this URL
255
+ return null;
256
+ }
257
+ chain.push(found);
258
+ currentNode = found;
259
+ }
260
+
261
+ if (currentNode.page) {
262
+ return { page: currentNode.page, chain };
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * Find a matching intercepting route in a slot's children for the target pathname.
269
+ *
270
+ * When interception is active, the pipeline has already re-matched the source URL.
271
+ * Here we check the slot's intercepting children (e.g. `(.)photo/[id]`) against
272
+ * the target pathname to find which intercepting page to render.
273
+ *
274
+ * The interceptedSegmentName tells us the first URL segment to look for in the
275
+ * target pathname. We then walk the intercepting child's sub-tree to match
276
+ * remaining segments.
277
+ */
278
+ function findInterceptingMatch(
279
+ slotNode: ManifestSegmentNode,
280
+ targetPathname: string
281
+ ): SlotMatchResult | null {
282
+ const targetParts = targetPathname === '/' ? [] : targetPathname.slice(1).split('/');
283
+
284
+ for (const child of slotNode.children) {
285
+ if (child.segmentType !== 'intercepting' || !child.interceptedSegmentName) continue;
286
+
287
+ const segName = child.interceptedSegmentName;
288
+
289
+ // Find where the intercepted segment name appears in the target parts.
290
+ // Search from the end since intercepted routes match the URL tail.
291
+ let matchIdx = -1;
292
+ for (let i = targetParts.length - 1; i >= 0; i--) {
293
+ if (targetParts[i] === segName) {
294
+ matchIdx = i;
295
+ break;
296
+ }
297
+ }
298
+ if (matchIdx < 0) continue;
299
+
300
+ // Walk the intercepting child's sub-tree to match remaining target parts
301
+ const remaining = targetParts.slice(matchIdx + 1);
302
+ const chain: ManifestSegmentNode[] = [slotNode, child];
303
+
304
+ if (remaining.length === 0) {
305
+ if (child.page) {
306
+ return { page: child.page, chain };
307
+ }
308
+ continue;
309
+ }
310
+
311
+ let currentNode = child;
312
+ let matched = true;
313
+ for (const part of remaining) {
314
+ const children = currentNode.children ?? [];
315
+ let found: ManifestSegmentNode | null = null;
316
+
317
+ // Static match
318
+ for (const c of children) {
319
+ if (c.segmentType === 'static' && c.segmentName === part) {
320
+ found = c;
321
+ break;
322
+ }
323
+ }
324
+
325
+ // Dynamic match
326
+ if (!found) {
327
+ for (const c of children) {
328
+ if (c.segmentType === 'dynamic') {
329
+ found = c;
330
+ break;
331
+ }
332
+ }
333
+ }
334
+
335
+ // Catch-all match
336
+ if (!found) {
337
+ for (const c of children) {
338
+ if (c.segmentType === 'catch-all' || c.segmentType === 'optional-catch-all') {
339
+ found = c;
340
+ break;
341
+ }
342
+ }
343
+ }
344
+
345
+ if (!found) {
346
+ matched = false;
347
+ break;
348
+ }
349
+ chain.push(found);
350
+ currentNode = found;
351
+ }
352
+
353
+ if (matched && currentNode.page) {
354
+ return { page: currentNode.page, chain };
355
+ }
356
+ }
357
+
358
+ return null;
359
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * SSR Entry — Receives RSC stream and renders HTML with hydration markers.
3
+ *
4
+ * This is a real TypeScript file, not codegen. The RSC environment calls
5
+ * handleSsr() to convert the RSC stream + navigation context into
6
+ * an HTML Response with React hydration support.
7
+ *
8
+ * The RSC and SSR environments are separate Vite module graphs with
9
+ * separate module instances. Per-request state is explicitly passed
10
+ * via NavContext.
11
+ *
12
+ * Design docs: 18-build-system.md §"Entry Files", 02-rendering-pipeline.md
13
+ */
14
+
15
+ // @ts-expect-error — virtual module provided by timber-entries plugin
16
+ import config from 'virtual:timber-config';
17
+ import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';
18
+ import { AsyncLocalStorage } from 'node:async_hooks';
19
+
20
+ import { renderSsrStream, buildSsrResponse } from './ssr-render.js';
21
+ import { injectHead, injectRscPayload } from './html-injectors.js';
22
+ import { withNuqsSsrAdapter } from './nuqs-ssr-provider.js';
23
+ import { withSpan } from './tracing.js';
24
+ import { setCurrentParams } from '#/client/use-params.js';
25
+ import { registerSsrDataProvider, type SsrData } from '#/client/ssr-data.js';
26
+
27
+ // ─── SSR Data ALS ─────────────────────────────────────────────────────────
28
+ //
29
+ // Per-request SSR data stored in AsyncLocalStorage, ensuring correct
30
+ // isolation even when Suspense boundaries resolve asynchronously across
31
+ // concurrent requests. The ALS is created here (server-only module) and
32
+ // exposed to client hooks via the registration pattern — ssr-data.ts
33
+ // never imports node:async_hooks directly.
34
+
35
+ const ssrDataAls = new AsyncLocalStorage<SsrData>();
36
+
37
+ // Register the ALS-backed provider so getSsrData() reads from ALS.
38
+ registerSsrDataProvider(() => ssrDataAls.getStore());
39
+
40
+ /**
41
+ * Navigation context passed from the RSC environment to SSR.
42
+ *
43
+ * Per-request state must be explicitly passed across the RSC→SSR
44
+ * environment boundary since they are separate Vite module graphs.
45
+ */
46
+ export interface NavContext {
47
+ /** The requested pathname */
48
+ pathname: string;
49
+ /** Extracted route params (catch-all segments produce string[]) */
50
+ params: Record<string, string | string[]>;
51
+ /** Search params from the URL */
52
+ searchParams: Record<string, string>;
53
+ /** The committed HTTP status code */
54
+ statusCode: number;
55
+ /** Response headers from middleware/proxy */
56
+ responseHeaders: Headers;
57
+ /** Pre-rendered metadata HTML to inject before </head> */
58
+ headHtml: string;
59
+ /** Inline JS for React's bootstrapScriptContent — kicks off module loading */
60
+ bootstrapScriptContent: string;
61
+ /** Tee'd RSC stream for client-side hydration (inlined into HTML) */
62
+ rscStream?: ReadableStream<Uint8Array>;
63
+ /** Max Suspense hold window (ms). SSR delays the first flush by this
64
+ * duration, racing allReady so that fast-resolving boundaries render inline
65
+ * without ever showing a fallback. Derived from route `deferSuspenseFor` exports.
66
+ * See design/05-streaming.md §"deferSuspenseFor". */
67
+ deferSuspenseFor?: number;
68
+ /** Request abort signal. When the client disconnects (page refresh,
69
+ * navigation away), this signal fires. Passed to renderToReadableStream
70
+ * so React stops rendering and doesn't fire error boundaries for aborts. */
71
+ signal?: AbortSignal;
72
+ /** Request cookies as name→value pairs. Used by useCookie() during SSR
73
+ * to return correct cookie values before hydration. */
74
+ cookies?: Map<string, string>;
75
+ }
76
+
77
+ /**
78
+ * Handle SSR: decode an RSC stream and render it to hydration-ready HTML.
79
+ *
80
+ * Steps:
81
+ * 1. Decode the RSC stream into a React element tree via createFromReadableStream
82
+ * (resolves "use client" references to actual component modules for SSR)
83
+ * 2. Render the decoded tree to HTML via renderToReadableStream (streaming)
84
+ * 3. Wait for onShellReady before flushing (handled by renderSsrStream)
85
+ * 4. Inject metadata into <head> and client scripts before </body>
86
+ * 5. Return Response with navContext.statusCode and navContext.responseHeaders
87
+ *
88
+ * The RSC stream is piped progressively — not buffered. For deny() outside
89
+ * Suspense, the RSC stream encodes an error in the shell region, causing
90
+ * renderToReadableStream to reject. The error propagates back to the RSC
91
+ * entry which renders the deny page. For deny() inside Suspense, the shell
92
+ * succeeds and the error streams as a React error boundary after flush.
93
+ *
94
+ * @param rscStream - The ReadableStream from the RSC environment
95
+ * @param navContext - Per-request state passed across RSC→SSR boundary
96
+ * @returns A Response containing the HTML stream with hydration markers
97
+ */
98
+ export async function handleSsr(
99
+ rscStream: ReadableStream<Uint8Array>,
100
+ navContext: NavContext
101
+ ): Promise<Response> {
102
+ return withSpan('timber.ssr', { 'timber.environment': 'ssr' }, async () => {
103
+ const _runtimeConfig = config;
104
+
105
+ // Build per-request SSR data for client hooks (usePathname,
106
+ // useSearchParams, useCookie, useParams, etc.).
107
+ const ssrData: SsrData = {
108
+ pathname: navContext.pathname,
109
+ searchParams: navContext.searchParams,
110
+ cookies: navContext.cookies ?? new Map(),
111
+ params: navContext.params,
112
+ };
113
+
114
+ // Run the entire render inside the SSR data ALS scope.
115
+ // This ensures correct per-request isolation even when Suspense
116
+ // boundaries resolve asynchronously across concurrent requests.
117
+ // Client hooks read from getSsrData() which delegates to this
118
+ // ALS store via the registered provider.
119
+ return ssrDataAls.run(ssrData, async () => {
120
+ // Also set the module-level currentParams for useParams().
121
+ // useParams reads from getSsrData() during SSR (ALS-backed),
122
+ // but setCurrentParams is kept for the client-side path where
123
+ // the segment router updates params on navigation.
124
+ setCurrentParams(navContext.params);
125
+
126
+ // Decode the RSC stream into a React element tree.
127
+ // createFromReadableStream resolves client component references
128
+ // (from "use client" modules) using the SSR environment's module
129
+ // map, importing the actual components for server-side rendering.
130
+ const element = createFromReadableStream(rscStream) as React.ReactNode;
131
+
132
+ // Wrap with a server-safe nuqs adapter so that 'use client' components
133
+ // that call nuqs hooks (useQueryStates, useQueryState) can SSR correctly.
134
+ // The client-side TimberNuqsAdapter (injected by browser-entry.ts) takes
135
+ // over after hydration. This provider supplies the request's search params
136
+ // as a static snapshot so nuqs renders the right initial values on the server.
137
+ const wrappedElement = withNuqsSsrAdapter(navContext.searchParams, element);
138
+
139
+ // Render to HTML stream (waits for onShellReady).
140
+ // Pass bootstrapScriptContent so React injects a non-deferred <script>
141
+ // in the shell HTML. This executes immediately during parsing — even
142
+ // while Suspense boundaries are still streaming — triggering module
143
+ // loading via dynamic import() so hydration can start early.
144
+ const htmlStream = await renderSsrStream(wrappedElement, {
145
+ bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
146
+ deferSuspenseFor: navContext.deferSuspenseFor,
147
+ signal: navContext.signal,
148
+ });
149
+
150
+ // Inject metadata into <head>, then interleave RSC payload chunks
151
+ // into the body as they arrive from the tee'd RSC stream.
152
+ let outputStream = injectHead(htmlStream, navContext.headHtml);
153
+ outputStream = injectRscPayload(outputStream, navContext.rscStream);
154
+
155
+ // Build and return the Response.
156
+ return buildSsrResponse(outputStream, navContext.statusCode, navContext.responseHeaders);
157
+ });
158
+ });
159
+ }
160
+
161
+ export default handleSsr;