@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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route matcher — resolves a canonical pathname to a RouteMatch.
|
|
3
|
+
*
|
|
4
|
+
* Walks the manifest segment tree to find the best matching route.
|
|
5
|
+
* Priority: static > dynamic > catch-all > optional-catch-all.
|
|
6
|
+
* Groups are transparent (don't add URL depth).
|
|
7
|
+
*
|
|
8
|
+
* See design/07-routing.md §"Request Lifecycle"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RouteMatch } from './pipeline.js';
|
|
12
|
+
import type { MiddlewareFn } from './middleware-runner.js';
|
|
13
|
+
import {
|
|
14
|
+
METADATA_ROUTE_CONVENTIONS,
|
|
15
|
+
type MetadataRouteType,
|
|
16
|
+
} from './metadata-routes.js';
|
|
17
|
+
|
|
18
|
+
// ─── Manifest Types ───────────────────────────────────────────────────────
|
|
19
|
+
// The virtual module manifest has a slightly different shape than SegmentNode:
|
|
20
|
+
// file references are { load, filePath } instead of RouteFile.
|
|
21
|
+
|
|
22
|
+
/** A file reference in the manifest (lazy import + path). */
|
|
23
|
+
interface ManifestFile {
|
|
24
|
+
load: () => Promise<unknown>;
|
|
25
|
+
filePath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A segment node as it appears in the virtual:timber-route-manifest module. */
|
|
29
|
+
export interface ManifestSegmentNode {
|
|
30
|
+
segmentName: string;
|
|
31
|
+
segmentType:
|
|
32
|
+
| 'static'
|
|
33
|
+
| 'dynamic'
|
|
34
|
+
| 'catch-all'
|
|
35
|
+
| 'optional-catch-all'
|
|
36
|
+
| 'group'
|
|
37
|
+
| 'slot'
|
|
38
|
+
| 'intercepting';
|
|
39
|
+
urlPath: string;
|
|
40
|
+
paramName?: string;
|
|
41
|
+
/** For intercepting segments: the marker used, e.g. "(.)". */
|
|
42
|
+
interceptionMarker?: '(.)' | '(..)' | '(...)' | '(..)(..)';
|
|
43
|
+
/** For intercepting segments: the segment name after stripping the marker. */
|
|
44
|
+
interceptedSegmentName?: string;
|
|
45
|
+
|
|
46
|
+
page?: ManifestFile;
|
|
47
|
+
layout?: ManifestFile;
|
|
48
|
+
middleware?: ManifestFile;
|
|
49
|
+
access?: ManifestFile;
|
|
50
|
+
route?: ManifestFile;
|
|
51
|
+
error?: ManifestFile;
|
|
52
|
+
default?: ManifestFile;
|
|
53
|
+
denied?: ManifestFile;
|
|
54
|
+
searchParams?: ManifestFile;
|
|
55
|
+
statusFiles?: Record<string, ManifestFile>;
|
|
56
|
+
jsonStatusFiles?: Record<string, ManifestFile>;
|
|
57
|
+
legacyStatusFiles?: Record<string, ManifestFile>;
|
|
58
|
+
prerender?: ManifestFile;
|
|
59
|
+
/** Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.) keyed by base name */
|
|
60
|
+
metadataRoutes?: Record<string, ManifestFile>;
|
|
61
|
+
|
|
62
|
+
children: ManifestSegmentNode[];
|
|
63
|
+
slots: Record<string, ManifestSegmentNode>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The manifest shape from virtual:timber-route-manifest. */
|
|
67
|
+
export interface ManifestRoot {
|
|
68
|
+
root: ManifestSegmentNode;
|
|
69
|
+
proxy?: ManifestFile;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Matcher ──────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a route matcher function from a manifest.
|
|
76
|
+
*
|
|
77
|
+
* The returned function takes a canonical pathname and returns a RouteMatch
|
|
78
|
+
* or null if no route matches.
|
|
79
|
+
*/
|
|
80
|
+
export function createRouteMatcher(
|
|
81
|
+
manifest: ManifestRoot
|
|
82
|
+
): (pathname: string) => RouteMatch | null {
|
|
83
|
+
return (pathname: string) => matchPathname(manifest.root, pathname);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Match a canonical pathname against the segment tree.
|
|
88
|
+
*
|
|
89
|
+
* Splits the pathname into segments and walks the tree depth-first.
|
|
90
|
+
* Returns the segment chain and extracted params on match.
|
|
91
|
+
*/
|
|
92
|
+
function matchPathname(root: ManifestSegmentNode, pathname: string): RouteMatch | null {
|
|
93
|
+
// Split pathname into segments: "/blog/hello-world" → ["blog", "hello-world"]
|
|
94
|
+
// "/" → [] (empty segments)
|
|
95
|
+
const parts = pathname === '/' ? [] : pathname.slice(1).split('/');
|
|
96
|
+
|
|
97
|
+
const segments: ManifestSegmentNode[] = [];
|
|
98
|
+
const params: Record<string, string | string[]> = {};
|
|
99
|
+
|
|
100
|
+
const matched = matchSegments(root, parts, 0, segments, params);
|
|
101
|
+
if (!matched) return null;
|
|
102
|
+
|
|
103
|
+
// Convert ManifestSegmentNodes to the SegmentNode shape expected by RouteMatch.
|
|
104
|
+
// The pipeline and tree builder use SegmentNode which has RouteFile references,
|
|
105
|
+
// but we pass the manifest nodes directly — they're structurally compatible
|
|
106
|
+
// for the fields the pipeline cares about (segments array + params).
|
|
107
|
+
// Resolve the leaf segment's middleware.ts if present.
|
|
108
|
+
// Only the leaf route's middleware runs — no chain, no inheritance.
|
|
109
|
+
const leafSegment = segments[segments.length - 1];
|
|
110
|
+
let middleware: MiddlewareFn | undefined;
|
|
111
|
+
if (leafSegment?.middleware) {
|
|
112
|
+
const loader = leafSegment.middleware.load;
|
|
113
|
+
middleware = async (ctx) => {
|
|
114
|
+
const mod = (await loader()) as { default?: MiddlewareFn };
|
|
115
|
+
if (mod.default) {
|
|
116
|
+
return mod.default(ctx);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
// The pipeline uses segments as opaque objects passed to the renderer.
|
|
123
|
+
// Cast is safe — the renderer receives what the manifest provides.
|
|
124
|
+
segments: segments as unknown as RouteMatch['segments'],
|
|
125
|
+
params,
|
|
126
|
+
middleware,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Recursively match URL segments against the segment tree.
|
|
132
|
+
*
|
|
133
|
+
* Priority order for children at each level:
|
|
134
|
+
* 1. Static segments (exact match)
|
|
135
|
+
* 2. Dynamic segments ([param])
|
|
136
|
+
* 3. Catch-all segments ([...param])
|
|
137
|
+
* 4. Optional catch-all segments ([[...param]])
|
|
138
|
+
*
|
|
139
|
+
* Groups are transparent — they don't consume URL segments but their
|
|
140
|
+
* children are checked as if they were direct children of the parent.
|
|
141
|
+
*/
|
|
142
|
+
function matchSegments(
|
|
143
|
+
node: ManifestSegmentNode,
|
|
144
|
+
parts: string[],
|
|
145
|
+
index: number,
|
|
146
|
+
segments: ManifestSegmentNode[],
|
|
147
|
+
params: Record<string, string | string[]>
|
|
148
|
+
): boolean {
|
|
149
|
+
segments.push(node);
|
|
150
|
+
|
|
151
|
+
// All parts consumed — check if this node has a page or route
|
|
152
|
+
if (index >= parts.length) {
|
|
153
|
+
if (node.page || node.route) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check group children (they don't consume URL segments)
|
|
158
|
+
for (const child of node.children) {
|
|
159
|
+
if (child.segmentType === 'group') {
|
|
160
|
+
if (matchSegments(child, parts, index, segments, params)) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check optional catch-all children (they can match zero segments)
|
|
167
|
+
for (const child of node.children) {
|
|
168
|
+
if (child.segmentType === 'optional-catch-all') {
|
|
169
|
+
if (child.page || child.route) {
|
|
170
|
+
segments.push(child);
|
|
171
|
+
// Zero segments → param is undefined (not set), matching Next.js semantics
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
segments.pop();
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const part = parts[index];
|
|
182
|
+
|
|
183
|
+
// Try children in priority order
|
|
184
|
+
|
|
185
|
+
// 1. Static segments
|
|
186
|
+
for (const child of node.children) {
|
|
187
|
+
if (child.segmentType === 'static' && child.segmentName === part) {
|
|
188
|
+
if (matchSegments(child, parts, index + 1, segments, params)) {
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 2. Group segments (transparent — recurse without consuming)
|
|
195
|
+
for (const child of node.children) {
|
|
196
|
+
if (child.segmentType === 'group') {
|
|
197
|
+
if (matchSegments(child, parts, index, segments, params)) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 3. Dynamic segments ([param])
|
|
204
|
+
for (const child of node.children) {
|
|
205
|
+
if (child.segmentType === 'dynamic' && child.paramName) {
|
|
206
|
+
const prevParam = params[child.paramName];
|
|
207
|
+
params[child.paramName] = part;
|
|
208
|
+
if (matchSegments(child, parts, index + 1, segments, params)) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
// Backtrack
|
|
212
|
+
if (prevParam !== undefined) {
|
|
213
|
+
params[child.paramName] = prevParam;
|
|
214
|
+
} else {
|
|
215
|
+
delete params[child.paramName];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 4. Catch-all segments ([...param])
|
|
221
|
+
for (const child of node.children) {
|
|
222
|
+
if (child.segmentType === 'catch-all' && child.paramName) {
|
|
223
|
+
if (child.page || child.route) {
|
|
224
|
+
const remaining = parts.slice(index);
|
|
225
|
+
segments.push(child);
|
|
226
|
+
params[child.paramName] = remaining;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 5. Optional catch-all segments ([[...param]])
|
|
233
|
+
for (const child of node.children) {
|
|
234
|
+
if (child.segmentType === 'optional-catch-all' && child.paramName) {
|
|
235
|
+
if (child.page || child.route) {
|
|
236
|
+
const remaining = parts.slice(index);
|
|
237
|
+
segments.push(child);
|
|
238
|
+
params[child.paramName] = remaining;
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
segments.pop();
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Metadata Route Matcher ─────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
/** Result of matching a metadata route. */
|
|
251
|
+
export interface MetadataRouteMatch {
|
|
252
|
+
/** The metadata route type (sitemap, robots, icon, etc.) */
|
|
253
|
+
type: MetadataRouteType;
|
|
254
|
+
/** Content-Type header for the response. */
|
|
255
|
+
contentType: string;
|
|
256
|
+
/** The manifest file reference for the handler module. */
|
|
257
|
+
file: ManifestFile;
|
|
258
|
+
/** The matched segment (for context/params if needed). */
|
|
259
|
+
segment: ManifestSegmentNode;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Create a metadata route matcher from a manifest.
|
|
264
|
+
*
|
|
265
|
+
* Walks the segment tree and builds a map from serve paths to handler modules.
|
|
266
|
+
* Metadata routes are matched by exact pathname (e.g., /sitemap.xml, /blog/sitemap.xml).
|
|
267
|
+
*
|
|
268
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
269
|
+
*/
|
|
270
|
+
export function createMetadataRouteMatcher(
|
|
271
|
+
manifest: ManifestRoot
|
|
272
|
+
): (pathname: string) => MetadataRouteMatch | null {
|
|
273
|
+
// Build a static lookup map: pathname → match info
|
|
274
|
+
const routeMap = new Map<string, MetadataRouteMatch>();
|
|
275
|
+
collectMetadataRoutes(manifest.root, routeMap);
|
|
276
|
+
|
|
277
|
+
return (pathname: string) => routeMap.get(pathname) ?? null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Recursively collect metadata routes from the segment tree into a lookup map.
|
|
282
|
+
*/
|
|
283
|
+
function collectMetadataRoutes(
|
|
284
|
+
node: ManifestSegmentNode,
|
|
285
|
+
map: Map<string, MetadataRouteMatch>
|
|
286
|
+
): void {
|
|
287
|
+
if (node.metadataRoutes) {
|
|
288
|
+
for (const [baseName, file] of Object.entries(node.metadataRoutes)) {
|
|
289
|
+
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
290
|
+
if (!convention) continue;
|
|
291
|
+
|
|
292
|
+
// Non-nestable routes (robots, manifest, favicon) only serve from root
|
|
293
|
+
if (!convention.nestable && node.urlPath !== '/') continue;
|
|
294
|
+
|
|
295
|
+
// Build the serve pathname: segment urlPath + serve path
|
|
296
|
+
const prefix = node.urlPath === '/' ? '' : node.urlPath;
|
|
297
|
+
const pathname = `${prefix}/${convention.servePath}`;
|
|
298
|
+
|
|
299
|
+
map.set(pathname, {
|
|
300
|
+
type: convention.type,
|
|
301
|
+
contentType: convention.contentType,
|
|
302
|
+
file,
|
|
303
|
+
segment: node,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const child of node.children) {
|
|
309
|
+
collectMetadataRoutes(child, map);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Also check inside group segments (they're transparent for URL paths)
|
|
313
|
+
for (const slotNode of Object.values(node.slots)) {
|
|
314
|
+
collectMetadataRoutes(slotNode, map);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSC API Route Handler — handles route.ts requests (non-React).
|
|
3
|
+
*
|
|
4
|
+
* Runs access.ts standalone for all segments in the chain (no React render
|
|
5
|
+
* pass, no AccessGate component). Then dispatches to the route handler.
|
|
6
|
+
* See design/04-authorization.md §"Auth in API Routes".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { withSpan, setSpanAttribute } from '#/server/tracing.js';
|
|
10
|
+
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
11
|
+
import type { RouteMatch } from '#/server/pipeline.js';
|
|
12
|
+
import { DenySignal, RedirectSignal } from '#/server/primitives.js';
|
|
13
|
+
import { handleRouteRequest } from '#/server/route-handler.js';
|
|
14
|
+
import type { RouteModule } from '#/server/route-handler.js';
|
|
15
|
+
import type { RouteContext } from '#/server/types.js';
|
|
16
|
+
|
|
17
|
+
export async function handleApiRoute(
|
|
18
|
+
req: Request,
|
|
19
|
+
match: RouteMatch,
|
|
20
|
+
segments: ManifestSegmentNode[],
|
|
21
|
+
responseHeaders: Headers
|
|
22
|
+
): Promise<Response> {
|
|
23
|
+
const leaf = segments[segments.length - 1];
|
|
24
|
+
|
|
25
|
+
// Run access.ts for every segment in the chain, top-down.
|
|
26
|
+
// Each access.ts is independent — deny()/redirect() throws a signal.
|
|
27
|
+
for (const segment of segments) {
|
|
28
|
+
if (segment.access) {
|
|
29
|
+
const accessMod = (await segment.access.load()) as Record<string, unknown>;
|
|
30
|
+
const accessFn = accessMod.default as
|
|
31
|
+
| ((ctx: { params: Record<string, string | string[]>; searchParams: unknown }) => unknown)
|
|
32
|
+
| undefined;
|
|
33
|
+
if (accessFn) {
|
|
34
|
+
try {
|
|
35
|
+
await withSpan(
|
|
36
|
+
'timber.access',
|
|
37
|
+
{ 'timber.segment': segment.segmentName ?? 'unknown' },
|
|
38
|
+
async () => {
|
|
39
|
+
try {
|
|
40
|
+
await accessFn({ params: match.params, searchParams: {} });
|
|
41
|
+
await setSpanAttribute('timber.result', 'pass');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof DenySignal) {
|
|
44
|
+
await setSpanAttribute('timber.result', 'deny');
|
|
45
|
+
await setSpanAttribute('timber.deny_status', error.status);
|
|
46
|
+
if (error.sourceFile) {
|
|
47
|
+
await setSpanAttribute('timber.deny_file', error.sourceFile);
|
|
48
|
+
}
|
|
49
|
+
} else if (error instanceof RedirectSignal) {
|
|
50
|
+
await setSpanAttribute('timber.result', 'redirect');
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof DenySignal) {
|
|
58
|
+
return renderApiDeny(error, segments, responseHeaders);
|
|
59
|
+
}
|
|
60
|
+
if (error instanceof RedirectSignal) {
|
|
61
|
+
responseHeaders.set('Location', error.location);
|
|
62
|
+
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Load route.ts module and dispatch
|
|
71
|
+
const routeMod = (await leaf.route!.load()) as RouteModule;
|
|
72
|
+
const ctx: RouteContext = {
|
|
73
|
+
req,
|
|
74
|
+
params: match.params,
|
|
75
|
+
searchParams: new URL(req.url).searchParams,
|
|
76
|
+
headers: responseHeaders,
|
|
77
|
+
};
|
|
78
|
+
return handleRouteRequest(routeMod, ctx);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render a deny response for an API route (route.ts).
|
|
83
|
+
*
|
|
84
|
+
* Tries JSON status file chain first. Falls back to bare JSON response.
|
|
85
|
+
* Never renders a component — API consumers get structured JSON, not HTML.
|
|
86
|
+
* See design/10-error-handling.md §"Format Selection for deny()"
|
|
87
|
+
*/
|
|
88
|
+
async function renderApiDeny(
|
|
89
|
+
deny: DenySignal,
|
|
90
|
+
segments: ManifestSegmentNode[],
|
|
91
|
+
responseHeaders: Headers
|
|
92
|
+
): Promise<Response> {
|
|
93
|
+
const { resolveManifestStatusFile } = await import('#/server/manifest-status-resolver.js');
|
|
94
|
+
|
|
95
|
+
const resolution = resolveManifestStatusFile(deny.status, segments, 'json');
|
|
96
|
+
if (resolution) {
|
|
97
|
+
const mod = (await resolution.file.load()) as Record<string, unknown>;
|
|
98
|
+
const jsonContent = mod.default ?? mod;
|
|
99
|
+
responseHeaders.set('content-type', 'application/json; charset=utf-8');
|
|
100
|
+
return new Response(JSON.stringify(jsonContent), {
|
|
101
|
+
status: deny.status,
|
|
102
|
+
headers: responseHeaders,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// No JSON status file — bare JSON fallback
|
|
107
|
+
responseHeaders.set('content-type', 'application/json; charset=utf-8');
|
|
108
|
+
return new Response(JSON.stringify({ error: true, status: deny.status }), {
|
|
109
|
+
status: deny.status,
|
|
110
|
+
headers: responseHeaders,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSC Error & No-Match Renderers — handles error pages and 404s.
|
|
3
|
+
*
|
|
4
|
+
* Renders error.tsx / status files and 404 pages through the RSC → SSR pipeline.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createElement } from 'react';
|
|
8
|
+
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
|
|
9
|
+
|
|
10
|
+
import type { RouteMatch } from '#/server/pipeline.js';
|
|
11
|
+
import { logRenderError } from '#/server/logger.js';
|
|
12
|
+
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
13
|
+
import { DenySignal, RenderError } from '#/server/primitives.js';
|
|
14
|
+
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
15
|
+
import { renderDenyPage } from '#/server/deny-renderer.js';
|
|
16
|
+
import type { LayoutEntry } from '#/server/deny-renderer.js';
|
|
17
|
+
import type { NavContext } from '#/server/ssr-entry.js';
|
|
18
|
+
import { createDebugChannelSink, parseCookiesFromHeader } from './helpers.js';
|
|
19
|
+
import { callSsr } from './ssr-bridge.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render an error page for unhandled throws or RenderError outside Suspense.
|
|
23
|
+
*
|
|
24
|
+
* Walks the segment chain from leaf to root looking for:
|
|
25
|
+
* 1. Specific status file (e.g. 503.tsx) matching the error's status
|
|
26
|
+
* 2. 5xx.tsx category catch-all
|
|
27
|
+
* 3. error.tsx
|
|
28
|
+
*
|
|
29
|
+
* Renders the found component with { error, digest, reset } props
|
|
30
|
+
* wrapped in layouts, with the correct HTTP status code.
|
|
31
|
+
*/
|
|
32
|
+
export async function renderErrorPage(
|
|
33
|
+
error: unknown,
|
|
34
|
+
status: number,
|
|
35
|
+
segments: ManifestSegmentNode[],
|
|
36
|
+
layoutComponents: LayoutEntry[],
|
|
37
|
+
req: Request,
|
|
38
|
+
match: RouteMatch,
|
|
39
|
+
responseHeaders: Headers,
|
|
40
|
+
clientBootstrap: ClientBootstrapConfig
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
43
|
+
|
|
44
|
+
// Walk segments from leaf to root to find the error component
|
|
45
|
+
let errorComponent: ((...args: unknown[]) => unknown) | null = null;
|
|
46
|
+
let foundSegmentIndex = -1;
|
|
47
|
+
|
|
48
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
49
|
+
const segment = segments[i];
|
|
50
|
+
|
|
51
|
+
// Check specific status file (e.g. 503.tsx)
|
|
52
|
+
if (segment.statusFiles) {
|
|
53
|
+
const statusKey = String(status);
|
|
54
|
+
const specificFile = segment.statusFiles[statusKey];
|
|
55
|
+
if (specificFile) {
|
|
56
|
+
const mod = (await specificFile.load()) as Record<string, unknown>;
|
|
57
|
+
if (mod.default) {
|
|
58
|
+
errorComponent = mod.default as (...args: unknown[]) => unknown;
|
|
59
|
+
foundSegmentIndex = i;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check 5xx.tsx category catch-all
|
|
65
|
+
const categoryFile = segment.statusFiles['5xx'];
|
|
66
|
+
if (categoryFile && status >= 500 && status <= 599) {
|
|
67
|
+
const mod = (await categoryFile.load()) as Record<string, unknown>;
|
|
68
|
+
if (mod.default) {
|
|
69
|
+
errorComponent = mod.default as (...args: unknown[]) => unknown;
|
|
70
|
+
foundSegmentIndex = i;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check error.tsx
|
|
77
|
+
if (segment.error) {
|
|
78
|
+
const mod = (await segment.error.load()) as Record<string, unknown>;
|
|
79
|
+
if (mod.default) {
|
|
80
|
+
errorComponent = mod.default as (...args: unknown[]) => unknown;
|
|
81
|
+
foundSegmentIndex = i;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// No error component found — fall back to bare response
|
|
88
|
+
if (!errorComponent) {
|
|
89
|
+
return new Response(null, { status, headers: responseHeaders });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build digest prop for RenderError, null for unhandled errors
|
|
93
|
+
const digest =
|
|
94
|
+
error instanceof RenderError ? { code: error.code, data: error.digest.data } : null;
|
|
95
|
+
|
|
96
|
+
// Error pages receive { error, digest, reset } per design/10-error-handling.md
|
|
97
|
+
let element = h(errorComponent, {
|
|
98
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
99
|
+
digest,
|
|
100
|
+
reset: undefined, // reset is only meaningful on the client
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Wrap in layouts from root up to the segment where the error file was found
|
|
104
|
+
const resolvedSegments = new Set(segments.slice(0, foundSegmentIndex + 1));
|
|
105
|
+
const layoutsToWrap = layoutComponents.filter((lc) => resolvedSegments.has(lc.segment));
|
|
106
|
+
for (let i = layoutsToWrap.length - 1; i >= 0; i--) {
|
|
107
|
+
const { component } = layoutsToWrap[i];
|
|
108
|
+
element = h(component, null, element);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Render to fresh RSC Flight stream
|
|
112
|
+
const rscStream = renderToReadableStream(element, {
|
|
113
|
+
onError(err: unknown) {
|
|
114
|
+
logRenderError({ method: req.method, path: new URL(req.url).pathname, error: err });
|
|
115
|
+
},
|
|
116
|
+
debugChannel: createDebugChannelSink(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const [ssrStream, inlineStream] = rscStream.tee();
|
|
120
|
+
|
|
121
|
+
const navContext: NavContext = {
|
|
122
|
+
pathname: new URL(req.url).pathname,
|
|
123
|
+
params: match.params,
|
|
124
|
+
searchParams: Object.fromEntries(new URL(req.url).searchParams),
|
|
125
|
+
statusCode: status,
|
|
126
|
+
responseHeaders,
|
|
127
|
+
headHtml: '',
|
|
128
|
+
bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
|
|
129
|
+
rscStream: inlineStream,
|
|
130
|
+
cookies: parseCookiesFromHeader(req.headers.get('cookie') ?? ''),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return callSsr(ssrStream, navContext);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Render a 404 page for URLs that don't match any route.
|
|
138
|
+
*
|
|
139
|
+
* Uses the root segment's 404.tsx (or 4xx.tsx / error.tsx fallback)
|
|
140
|
+
* wrapped in the root layout, via the same renderDenyPage path
|
|
141
|
+
* used for in-route deny() calls.
|
|
142
|
+
*/
|
|
143
|
+
export async function renderNoMatchPage(
|
|
144
|
+
req: Request,
|
|
145
|
+
rootSegment: ManifestSegmentNode,
|
|
146
|
+
responseHeaders: Headers,
|
|
147
|
+
clientBootstrap: ClientBootstrapConfig
|
|
148
|
+
): Promise<Response> {
|
|
149
|
+
const segments = [rootSegment];
|
|
150
|
+
|
|
151
|
+
// Load root layout if present
|
|
152
|
+
const layoutComponents: LayoutEntry[] = [];
|
|
153
|
+
if (rootSegment.layout) {
|
|
154
|
+
const mod = (await rootSegment.layout.load()) as Record<string, unknown>;
|
|
155
|
+
if (mod.default) {
|
|
156
|
+
layoutComponents.push({
|
|
157
|
+
component: mod.default as (...args: unknown[]) => unknown,
|
|
158
|
+
segment: rootSegment,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const deny = new DenySignal(404);
|
|
164
|
+
const match: RouteMatch = { segments: segments as never, params: {} };
|
|
165
|
+
|
|
166
|
+
return renderDenyPage(
|
|
167
|
+
deny,
|
|
168
|
+
segments,
|
|
169
|
+
layoutComponents,
|
|
170
|
+
req,
|
|
171
|
+
match,
|
|
172
|
+
responseHeaders,
|
|
173
|
+
clientBootstrap,
|
|
174
|
+
createDebugChannelSink,
|
|
175
|
+
callSsr
|
|
176
|
+
);
|
|
177
|
+
}
|