@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,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
+ }