@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.
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +43 -58
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- 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;
|