@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,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route Element Builder — constructs a React element tree from a matched route.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from rsc-entry.ts to enable reuse by the revalidation renderer
|
|
5
|
+
* (which needs the element tree without RSC serialization) and to keep
|
|
6
|
+
* rsc-entry.ts under the 500-line limit.
|
|
7
|
+
*
|
|
8
|
+
* This module handles:
|
|
9
|
+
* 1. Loading page/layout components from the segment chain
|
|
10
|
+
* 2. Running access.ts checks (DenySignal/RedirectSignal propagate to caller)
|
|
11
|
+
* 3. Resolving metadata (static object or async function, both exported as `metadata`)
|
|
12
|
+
* 4. Building the React element tree (page → error boundaries → access gates → layouts)
|
|
13
|
+
* 5. Resolving parallel slots
|
|
14
|
+
*
|
|
15
|
+
* See design/02-rendering-pipeline.md, design/04-authorization.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createElement } from 'react';
|
|
19
|
+
|
|
20
|
+
import { withSpan, setSpanAttribute } from './tracing.js';
|
|
21
|
+
import type { RouteMatch } from './pipeline.js';
|
|
22
|
+
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
23
|
+
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
24
|
+
import type { HeadElement as MetadataHeadElement } from './metadata.js';
|
|
25
|
+
import type { Metadata } from './types.js';
|
|
26
|
+
import {
|
|
27
|
+
METADATA_ROUTE_CONVENTIONS,
|
|
28
|
+
getMetadataRouteAutoLink,
|
|
29
|
+
} from './metadata-routes.js';
|
|
30
|
+
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
31
|
+
import { AccessGate } from './access-gate.js';
|
|
32
|
+
import { resolveSlotElement } from './slot-resolver.js';
|
|
33
|
+
import { SegmentProvider } from '#/client/segment-context.js';
|
|
34
|
+
import { setParsedSearchParams } from './request-context.js';
|
|
35
|
+
import type { SearchParamsDefinition } from '#/search-params/create.js';
|
|
36
|
+
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
37
|
+
import type { InterceptionContext } from './pipeline.js';
|
|
38
|
+
|
|
39
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** Head element for client-side metadata updates. */
|
|
42
|
+
export interface HeadElement {
|
|
43
|
+
tag: string;
|
|
44
|
+
content?: string;
|
|
45
|
+
attrs?: Record<string, string | null>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Layout entry with component and segment. */
|
|
49
|
+
export interface LayoutComponentEntry {
|
|
50
|
+
component: (...args: unknown[]) => unknown;
|
|
51
|
+
segment: ManifestSegmentNode;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Result of building a route element tree. */
|
|
55
|
+
export interface RouteElementResult {
|
|
56
|
+
/** The React element tree (page wrapped in layouts, access gates, error boundaries). */
|
|
57
|
+
element: React.ReactElement;
|
|
58
|
+
/** Resolved head elements for metadata. */
|
|
59
|
+
headElements: HeadElement[];
|
|
60
|
+
/** Layout components loaded along the segment chain. */
|
|
61
|
+
layoutComponents: LayoutComponentEntry[];
|
|
62
|
+
/** Segments from the route match. */
|
|
63
|
+
segments: ManifestSegmentNode[];
|
|
64
|
+
/** Max deferSuspenseFor hold window across all segments. */
|
|
65
|
+
deferSuspenseFor: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wraps a DenySignal or RedirectSignal with the layout components loaded
|
|
70
|
+
* so far, enabling the caller to render deny pages inside the layout shell.
|
|
71
|
+
*/
|
|
72
|
+
export class RouteSignalWithContext extends Error {
|
|
73
|
+
constructor(
|
|
74
|
+
public readonly signal: DenySignal | RedirectSignal,
|
|
75
|
+
public readonly layoutComponents: LayoutComponentEntry[],
|
|
76
|
+
public readonly segments: ManifestSegmentNode[]
|
|
77
|
+
) {
|
|
78
|
+
super(signal.message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Builder ──────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a React element tree from a matched route.
|
|
86
|
+
*
|
|
87
|
+
* Loads modules, runs access checks, resolves metadata, and constructs
|
|
88
|
+
* the element tree. DenySignal and RedirectSignal propagate to the caller
|
|
89
|
+
* for HTTP-level handling.
|
|
90
|
+
*
|
|
91
|
+
* Does NOT serialize to RSC Flight — the caller decides whether to render
|
|
92
|
+
* to a stream or use the element directly (e.g., for action revalidation).
|
|
93
|
+
*/
|
|
94
|
+
export async function buildRouteElement(
|
|
95
|
+
req: Request,
|
|
96
|
+
match: RouteMatch,
|
|
97
|
+
interception?: InterceptionContext
|
|
98
|
+
): Promise<RouteElementResult> {
|
|
99
|
+
const segments = match.segments as unknown as ManifestSegmentNode[];
|
|
100
|
+
|
|
101
|
+
// Params are passed as a Promise to match Next.js 15+ convention.
|
|
102
|
+
const paramsPromise = Promise.resolve(match.params);
|
|
103
|
+
|
|
104
|
+
// Load all modules along the segment chain
|
|
105
|
+
const metadataEntries: Array<{ metadata: Metadata; isPage: boolean }> = [];
|
|
106
|
+
const layoutComponents: LayoutComponentEntry[] = [];
|
|
107
|
+
let PageComponent: ((...args: unknown[]) => unknown) | null = null;
|
|
108
|
+
let deferSuspenseFor = 0;
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < segments.length; i++) {
|
|
111
|
+
const segment = segments[i];
|
|
112
|
+
const isLeaf = i === segments.length - 1;
|
|
113
|
+
|
|
114
|
+
// Load layout
|
|
115
|
+
if (segment.layout) {
|
|
116
|
+
const mod = (await segment.layout.load()) as Record<string, unknown>;
|
|
117
|
+
if (mod.default) {
|
|
118
|
+
layoutComponents.push({
|
|
119
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
120
|
+
segment,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// Reject legacy generateMetadata export — use `export async function metadata()` instead
|
|
124
|
+
if ('generateMetadata' in mod) {
|
|
125
|
+
const filePath = segment.layout.filePath ?? segment.urlPath;
|
|
126
|
+
throw new Error(
|
|
127
|
+
`${filePath}: "generateMetadata" is not a valid export. ` +
|
|
128
|
+
`Export an async function named "metadata" instead.\n\n` +
|
|
129
|
+
` // Before\n` +
|
|
130
|
+
` export async function generateMetadata({ params }) { ... }\n\n` +
|
|
131
|
+
` // After\n` +
|
|
132
|
+
` export async function metadata({ params }) { ... }`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
// Unified metadata export: static object or async function
|
|
136
|
+
if (typeof mod.metadata === 'function') {
|
|
137
|
+
type MetadataFn = (props: Record<string, unknown>) => Promise<Metadata>;
|
|
138
|
+
const generated = await withSpan(
|
|
139
|
+
'timber.metadata',
|
|
140
|
+
{ 'timber.segment': segment.segmentName ?? segment.urlPath },
|
|
141
|
+
() => (mod.metadata as MetadataFn)({ params: paramsPromise })
|
|
142
|
+
);
|
|
143
|
+
if (generated) {
|
|
144
|
+
metadataEntries.push({ metadata: generated, isPage: false });
|
|
145
|
+
}
|
|
146
|
+
} else if (mod.metadata) {
|
|
147
|
+
metadataEntries.push({ metadata: mod.metadata as Metadata, isPage: false });
|
|
148
|
+
}
|
|
149
|
+
// deferSuspenseFor hold window — max across all segments
|
|
150
|
+
if (typeof mod.deferSuspenseFor === 'number' && mod.deferSuspenseFor > deferSuspenseFor) {
|
|
151
|
+
deferSuspenseFor = mod.deferSuspenseFor;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Load page (leaf segment only)
|
|
156
|
+
if (isLeaf && segment.page) {
|
|
157
|
+
// Load and apply search-params.ts definition before rendering so
|
|
158
|
+
// searchParams() from @timber/app/server returns parsed typed values.
|
|
159
|
+
if (segment.searchParams) {
|
|
160
|
+
const spMod = (await segment.searchParams.load()) as {
|
|
161
|
+
default?: SearchParamsDefinition<Record<string, unknown>>;
|
|
162
|
+
};
|
|
163
|
+
if (spMod.default) {
|
|
164
|
+
const rawSearchParams = new URL(req.url).searchParams;
|
|
165
|
+
const parsed = spMod.default.parse(rawSearchParams);
|
|
166
|
+
setParsedSearchParams(parsed);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const mod = (await segment.page.load()) as Record<string, unknown>;
|
|
171
|
+
if (mod.default) {
|
|
172
|
+
PageComponent = mod.default as (...args: unknown[]) => unknown;
|
|
173
|
+
}
|
|
174
|
+
// Reject legacy generateMetadata export — use `export async function metadata()` instead
|
|
175
|
+
if ('generateMetadata' in mod) {
|
|
176
|
+
const filePath = segment.page.filePath ?? segment.urlPath;
|
|
177
|
+
throw new Error(
|
|
178
|
+
`${filePath}: "generateMetadata" is not a valid export. ` +
|
|
179
|
+
`Export an async function named "metadata" instead.\n\n` +
|
|
180
|
+
` // Before\n` +
|
|
181
|
+
` export async function generateMetadata({ params }) { ... }\n\n` +
|
|
182
|
+
` // After\n` +
|
|
183
|
+
` export async function metadata({ params }) { ... }`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
// Unified metadata export: static object or async function
|
|
187
|
+
if (typeof mod.metadata === 'function') {
|
|
188
|
+
type MetadataFn = (props: Record<string, unknown>) => Promise<Metadata>;
|
|
189
|
+
const generated = await withSpan(
|
|
190
|
+
'timber.metadata',
|
|
191
|
+
{ 'timber.segment': segment.segmentName ?? segment.urlPath },
|
|
192
|
+
() => (mod.metadata as MetadataFn)({ params: paramsPromise })
|
|
193
|
+
);
|
|
194
|
+
if (generated) {
|
|
195
|
+
metadataEntries.push({ metadata: generated, isPage: true });
|
|
196
|
+
}
|
|
197
|
+
} else if (mod.metadata) {
|
|
198
|
+
metadataEntries.push({ metadata: mod.metadata as Metadata, isPage: true });
|
|
199
|
+
}
|
|
200
|
+
// deferSuspenseFor hold window — max across all segments
|
|
201
|
+
if (typeof mod.deferSuspenseFor === 'number' && mod.deferSuspenseFor > deferSuspenseFor) {
|
|
202
|
+
deferSuspenseFor = mod.deferSuspenseFor;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!PageComponent) {
|
|
208
|
+
throw new Error(`No page component found for route: ${new URL(req.url).pathname}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Run access.ts checks before rendering — top-down through the segment chain.
|
|
212
|
+
// Verdicts are stored so AccessGate can replay them synchronously during
|
|
213
|
+
// render, avoiding duplicate execution and Suspense timing issues.
|
|
214
|
+
// DenySignal and RedirectSignal are wrapped with layout context so the caller
|
|
215
|
+
// can render deny pages inside the layout shell.
|
|
216
|
+
// See design/04-authorization.md §"access.ts Runs on Every Navigation".
|
|
217
|
+
const accessVerdicts = new Map<number, 'pass' | DenySignal | RedirectSignal>();
|
|
218
|
+
|
|
219
|
+
for (let si = 0; si < segments.length; si++) {
|
|
220
|
+
const segment = segments[si];
|
|
221
|
+
if (segment.access) {
|
|
222
|
+
const accessMod = (await segment.access.load()) as Record<string, unknown>;
|
|
223
|
+
const accessFn = accessMod.default as
|
|
224
|
+
| ((ctx: { params: Record<string, string | string[]>; searchParams: unknown }) => unknown)
|
|
225
|
+
| undefined;
|
|
226
|
+
if (accessFn) {
|
|
227
|
+
try {
|
|
228
|
+
await withSpan(
|
|
229
|
+
'timber.access',
|
|
230
|
+
{ 'timber.segment': segment.segmentName ?? 'unknown' },
|
|
231
|
+
async () => {
|
|
232
|
+
try {
|
|
233
|
+
await accessFn({ params: match.params, searchParams: {} });
|
|
234
|
+
await setSpanAttribute('timber.result', 'pass');
|
|
235
|
+
accessVerdicts.set(si, 'pass');
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof DenySignal) {
|
|
238
|
+
await setSpanAttribute('timber.result', 'deny');
|
|
239
|
+
await setSpanAttribute('timber.deny_status', error.status);
|
|
240
|
+
if (error.sourceFile) {
|
|
241
|
+
await setSpanAttribute('timber.deny_file', error.sourceFile);
|
|
242
|
+
}
|
|
243
|
+
accessVerdicts.set(si, error);
|
|
244
|
+
} else if (error instanceof RedirectSignal) {
|
|
245
|
+
await setSpanAttribute('timber.result', 'redirect');
|
|
246
|
+
accessVerdicts.set(si, error);
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error instanceof DenySignal || error instanceof RedirectSignal) {
|
|
254
|
+
throw new RouteSignalWithContext(error, layoutComponents, segments);
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Resolve metadata
|
|
263
|
+
const resolvedMetadata = resolveMetadata(metadataEntries);
|
|
264
|
+
const headElements = renderMetadataToElements(resolvedMetadata);
|
|
265
|
+
|
|
266
|
+
// Auto-link metadata route files (icon, apple-icon, manifest) from segments.
|
|
267
|
+
// See design/16-metadata.md §"Auto-Linking"
|
|
268
|
+
for (const segment of segments) {
|
|
269
|
+
if (!segment.metadataRoutes) continue;
|
|
270
|
+
for (const baseName of Object.keys(segment.metadataRoutes)) {
|
|
271
|
+
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
272
|
+
if (!convention) continue;
|
|
273
|
+
// Non-nestable routes only auto-link from root
|
|
274
|
+
if (!convention.nestable && segment.urlPath !== '/') continue;
|
|
275
|
+
// Build the href: segment urlPath + serve path
|
|
276
|
+
const prefix = segment.urlPath === '/' ? '' : segment.urlPath;
|
|
277
|
+
const href = `${prefix}/${convention.servePath}`;
|
|
278
|
+
const autoLink = getMetadataRouteAutoLink(convention.type, href);
|
|
279
|
+
if (autoLink) {
|
|
280
|
+
const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
|
|
281
|
+
if (autoLink.type) attrs.type = autoLink.type;
|
|
282
|
+
headElements.push({ tag: 'link', attrs } as MetadataHeadElement);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Build element tree: page wrapped in layouts (innermost to outermost)
|
|
288
|
+
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
289
|
+
|
|
290
|
+
// Wrap the page component in an OTEL span
|
|
291
|
+
const TracedPage = async (props: Record<string, unknown>) => {
|
|
292
|
+
return withSpan(
|
|
293
|
+
'timber.page',
|
|
294
|
+
{ 'timber.route': match.segments[match.segments.length - 1]?.urlPath ?? '/' },
|
|
295
|
+
() => (PageComponent as (props: Record<string, unknown>) => unknown)(props)
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
let element = h(TracedPage, {
|
|
300
|
+
params: paramsPromise,
|
|
301
|
+
searchParams: {},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Build a lookup of layout components by segment for O(1) access.
|
|
305
|
+
const layoutBySegment = new Map(
|
|
306
|
+
layoutComponents.map(({ component, segment }) => [segment, component])
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// Wrap from innermost (leaf) to outermost (root), processing every
|
|
310
|
+
// segment in the chain. Each segment may contribute:
|
|
311
|
+
// 1. Error boundaries (status files + error.tsx)
|
|
312
|
+
// 2. Layout component — wraps children + parallel slots
|
|
313
|
+
// 3. SegmentProvider — records position for useSelectedLayoutSegment
|
|
314
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
315
|
+
const segment = segments[i];
|
|
316
|
+
|
|
317
|
+
// Wrap with error boundaries from this segment (inside layout).
|
|
318
|
+
element = await wrapSegmentWithErrorBoundaries(segment, element, h);
|
|
319
|
+
|
|
320
|
+
// Wrap in AccessGate if segment has access.ts.
|
|
321
|
+
// Pass the pre-computed verdict so AccessGate replays it synchronously
|
|
322
|
+
// instead of re-calling accessFn (dedup + Suspense immunity).
|
|
323
|
+
if (segment.access) {
|
|
324
|
+
const accessMod = (await segment.access.load()) as Record<string, unknown>;
|
|
325
|
+
const accessFn = accessMod.default as
|
|
326
|
+
| ((ctx: { params: Record<string, string | string[]>; searchParams: unknown }) => unknown)
|
|
327
|
+
| undefined;
|
|
328
|
+
if (accessFn) {
|
|
329
|
+
element = h(AccessGate, {
|
|
330
|
+
accessFn,
|
|
331
|
+
params: match.params,
|
|
332
|
+
searchParams: {},
|
|
333
|
+
segmentName: segment.segmentName,
|
|
334
|
+
verdict: accessVerdicts.get(i),
|
|
335
|
+
children: element,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Wrap with layout if this segment has one — traced with OTEL span
|
|
341
|
+
const layoutComponent = layoutBySegment.get(segment);
|
|
342
|
+
if (layoutComponent) {
|
|
343
|
+
// Resolve parallel slots for this layout
|
|
344
|
+
const slotProps: Record<string, unknown> = {};
|
|
345
|
+
const slotEntries = Object.entries(segment.slots ?? {});
|
|
346
|
+
for (const [slotName, slotNode] of slotEntries) {
|
|
347
|
+
slotProps[slotName] = await resolveSlotElement(
|
|
348
|
+
slotNode as ManifestSegmentNode,
|
|
349
|
+
match,
|
|
350
|
+
paramsPromise,
|
|
351
|
+
h,
|
|
352
|
+
interception
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const segmentPath = segment.urlPath.split('/');
|
|
357
|
+
const parallelRouteKeys = Object.keys(segment.slots ?? {});
|
|
358
|
+
|
|
359
|
+
// Wrap the layout component in an OTEL span.
|
|
360
|
+
// For route groups, urlPath is "/" (groups don't add URL segments), so
|
|
361
|
+
// include the directory name to distinguish e.g. "layout /(pre-release)"
|
|
362
|
+
// from the root "layout /".
|
|
363
|
+
const segmentForSpan = segment;
|
|
364
|
+
const layoutComponentForSpan = layoutComponent;
|
|
365
|
+
const segmentLabel =
|
|
366
|
+
segmentForSpan.segmentType === 'group'
|
|
367
|
+
? `${segmentForSpan.urlPath === '/' ? '' : segmentForSpan.urlPath}/${segmentForSpan.segmentName}`
|
|
368
|
+
: segmentForSpan.urlPath;
|
|
369
|
+
const TracedLayout = async (props: Record<string, unknown>) => {
|
|
370
|
+
return withSpan('timber.layout', { 'timber.segment': segmentLabel }, () =>
|
|
371
|
+
(layoutComponentForSpan as (props: Record<string, unknown>) => unknown)(props)
|
|
372
|
+
);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
element = h(SegmentProvider, {
|
|
376
|
+
segments: segmentPath,
|
|
377
|
+
parallelRouteKeys,
|
|
378
|
+
children: h(TracedLayout, {
|
|
379
|
+
...slotProps,
|
|
380
|
+
params: paramsPromise,
|
|
381
|
+
searchParams: {},
|
|
382
|
+
children: element,
|
|
383
|
+
}),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
element,
|
|
390
|
+
headElements: headElements as HeadElement[],
|
|
391
|
+
layoutComponents,
|
|
392
|
+
segments,
|
|
393
|
+
deferSuspenseFor,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route handler for route.ts API endpoints.
|
|
3
|
+
*
|
|
4
|
+
* route.ts files export named HTTP method handlers (GET, POST, etc.).
|
|
5
|
+
* They share the same pipeline (proxy → match → middleware → access → handler)
|
|
6
|
+
* but don't render React trees.
|
|
7
|
+
*
|
|
8
|
+
* See design/07-routing.md §"route.ts — API Endpoints"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RouteContext } from './types.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** HTTP methods that route.ts can export as named handlers. */
|
|
16
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
|
17
|
+
|
|
18
|
+
/** A single route handler function — one-arg signature. */
|
|
19
|
+
export type RouteHandler = (ctx: RouteContext) => Response | Promise<Response>;
|
|
20
|
+
|
|
21
|
+
/** A route.ts module — named exports for each supported HTTP method. */
|
|
22
|
+
export type RouteModule = {
|
|
23
|
+
[K in HttpMethod]?: RouteHandler;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** All recognized HTTP method export names. */
|
|
27
|
+
const HTTP_METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
28
|
+
|
|
29
|
+
// ─── Allowed Methods ─────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the full list of allowed methods for a route module.
|
|
33
|
+
*
|
|
34
|
+
* Includes:
|
|
35
|
+
* - All explicitly exported methods
|
|
36
|
+
* - HEAD (implicit when GET is exported)
|
|
37
|
+
* - OPTIONS (always implicit)
|
|
38
|
+
*/
|
|
39
|
+
export function resolveAllowedMethods(mod: RouteModule): HttpMethod[] {
|
|
40
|
+
const methods: HttpMethod[] = [];
|
|
41
|
+
|
|
42
|
+
for (const method of HTTP_METHODS) {
|
|
43
|
+
if (method === 'HEAD' || method === 'OPTIONS') continue;
|
|
44
|
+
if (mod[method]) {
|
|
45
|
+
methods.push(method);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// HEAD is implicit when GET is exported
|
|
50
|
+
if (mod.GET && !mod.HEAD) {
|
|
51
|
+
methods.push('HEAD');
|
|
52
|
+
} else if (mod.HEAD) {
|
|
53
|
+
methods.push('HEAD');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// OPTIONS is always implicit
|
|
57
|
+
if (!mod.OPTIONS) {
|
|
58
|
+
methods.push('OPTIONS');
|
|
59
|
+
} else {
|
|
60
|
+
methods.push('OPTIONS');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return methods;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Route Request Handler ───────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handle an incoming request against a route.ts module.
|
|
70
|
+
*
|
|
71
|
+
* Dispatches to the named method handler, auto-generates 405/OPTIONS,
|
|
72
|
+
* and merges response headers from ctx.headers.
|
|
73
|
+
*/
|
|
74
|
+
export async function handleRouteRequest(mod: RouteModule, ctx: RouteContext): Promise<Response> {
|
|
75
|
+
const method = ctx.req.method.toUpperCase() as HttpMethod;
|
|
76
|
+
const allowed = resolveAllowedMethods(mod);
|
|
77
|
+
const allowHeader = allowed.join(', ');
|
|
78
|
+
|
|
79
|
+
// Auto OPTIONS — 204 with Allow header
|
|
80
|
+
if (method === 'OPTIONS') {
|
|
81
|
+
if (mod.OPTIONS) {
|
|
82
|
+
return runHandler(mod.OPTIONS, ctx);
|
|
83
|
+
}
|
|
84
|
+
return new Response(null, {
|
|
85
|
+
status: 204,
|
|
86
|
+
headers: { Allow: allowHeader },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// HEAD fallback — run GET, strip body
|
|
91
|
+
if (method === 'HEAD') {
|
|
92
|
+
if (mod.HEAD) {
|
|
93
|
+
return runHandler(mod.HEAD, ctx);
|
|
94
|
+
}
|
|
95
|
+
if (mod.GET) {
|
|
96
|
+
const res = await runHandler(mod.GET, ctx);
|
|
97
|
+
// Return headers + status but no body
|
|
98
|
+
return new Response(null, {
|
|
99
|
+
status: res.status,
|
|
100
|
+
headers: res.headers,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Dispatch to the named handler
|
|
106
|
+
const handler = mod[method];
|
|
107
|
+
if (!handler) {
|
|
108
|
+
return new Response(null, {
|
|
109
|
+
status: 405,
|
|
110
|
+
headers: { Allow: allowHeader },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return runHandler(handler, ctx);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run a handler, merge ctx.headers into the response, and catch errors.
|
|
119
|
+
*/
|
|
120
|
+
async function runHandler(handler: RouteHandler, ctx: RouteContext): Promise<Response> {
|
|
121
|
+
try {
|
|
122
|
+
const res = await handler(ctx);
|
|
123
|
+
return mergeResponseHeaders(res, ctx.headers);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('[timber] Uncaught error in route.ts handler:', error);
|
|
126
|
+
return new Response(null, { status: 500 });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Merge response headers from ctx.headers into the handler's response.
|
|
132
|
+
* ctx.headers (set by middleware or the handler) are applied to the final response.
|
|
133
|
+
* Handler-set headers take precedence over ctx.headers.
|
|
134
|
+
*/
|
|
135
|
+
function mergeResponseHeaders(res: Response, ctxHeaders: Headers): Response {
|
|
136
|
+
// If no ctx headers to merge, return as-is
|
|
137
|
+
let hasCtxHeaders = false;
|
|
138
|
+
ctxHeaders.forEach(() => {
|
|
139
|
+
hasCtxHeaders = true;
|
|
140
|
+
});
|
|
141
|
+
if (!hasCtxHeaders) return res;
|
|
142
|
+
|
|
143
|
+
// Merge: ctx.headers first, then handler response headers override
|
|
144
|
+
const merged = new Headers();
|
|
145
|
+
ctxHeaders.forEach((value, key) => merged.set(key, value));
|
|
146
|
+
res.headers.forEach((value, key) => merged.set(key, value));
|
|
147
|
+
|
|
148
|
+
return new Response(res.body, {
|
|
149
|
+
status: res.status,
|
|
150
|
+
statusText: res.statusText,
|
|
151
|
+
headers: merged,
|
|
152
|
+
});
|
|
153
|
+
}
|