@timber-js/app 0.2.0-alpha.96 → 0.2.0-alpha.98
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/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
- package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
- package/dist/_chunks/{interception-BsLCA9gk.js → walkers-VOXgavMF.js} +66 -92
- package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +55 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +189 -62
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +6 -4
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/dev-404-page.d.ts +8 -18
- package/dist/plugins/dev-404-page.d.ts.map +1 -1
- package/dist/routing/index.d.ts +5 -3
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts +1 -10
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/segment-classify.d.ts +37 -8
- package/dist/routing/segment-classify.d.ts.map +1 -1
- package/dist/routing/types.d.ts +63 -23
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/routing/walkers.d.ts +51 -0
- package/dist/routing/walkers.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/dev-holding-server.d.ts +4 -2
- package/dist/server/dev-holding-server.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +212 -0
- package/dist/server/html-injector-core.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +59 -59
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/internal.js +710 -563
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +46 -49
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +88 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -0
- package/dist/server/pipeline-phases.d.ts +97 -0
- package/dist/server/pipeline-phases.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +53 -32
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/port-resolution.d.ts +117 -0
- package/dist/server/port-resolution.d.ts.map +1 -0
- package/dist/server/route-matcher.d.ts +20 -47
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +16 -11
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/utils/directive-parser.d.ts +0 -45
- package/dist/utils/directive-parser.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/adapters/nitro.ts +55 -5
- package/src/cli.ts +0 -0
- package/src/index.ts +84 -31
- package/src/plugins/build-report.ts +13 -22
- package/src/plugins/dev-404-page.ts +15 -41
- package/src/plugins/routing.ts +14 -12
- package/src/routing/codegen.ts +1 -1
- package/src/routing/convention-lint.ts +4 -4
- package/src/routing/index.ts +5 -3
- package/src/routing/interception.ts +1 -1
- package/src/routing/link-codegen.ts +25 -13
- package/src/routing/scanner.ts +17 -93
- package/src/routing/segment-classify.ts +107 -8
- package/src/routing/status-file-lint.ts +3 -3
- package/src/routing/types.ts +63 -23
- package/src/routing/walkers.ts +90 -0
- package/src/server/action-handler.ts +6 -0
- package/src/server/deny-renderer.ts +5 -5
- package/src/server/dev-holding-server.ts +4 -2
- package/src/server/fallback-error.ts +1 -1
- package/src/server/html-injector-core.ts +403 -0
- package/src/server/html-injectors.ts +158 -297
- package/src/server/node-stream-transforms.ts +108 -248
- package/src/server/pipeline-helpers.ts +180 -0
- package/src/server/pipeline-phases.ts +591 -0
- package/src/server/pipeline.ts +76 -539
- package/src/server/port-resolution.ts +215 -0
- package/src/server/route-element-builder.ts +1 -1
- package/src/server/route-matcher.ts +28 -60
- package/src/server/rsc-entry/api-handler.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +1 -1
- package/src/server/rsc-entry/index.ts +52 -98
- package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
- package/src/server/sitemap-generator.ts +1 -1
- package/src/server/slot-resolver.ts +1 -1
- package/src/server/status-code-resolver.ts +112 -128
- package/src/server/tree-builder.ts +6 -4
- package/src/utils/directive-parser.ts +0 -392
- package/LICENSE +0 -8
- package/dist/_chunks/interception-BsLCA9gk.js.map +0 -1
- package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
- package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
- package/dist/server/manifest-status-resolver.d.ts +0 -58
- package/dist/server/manifest-status-resolver.d.ts.map +0 -1
- package/src/server/manifest-status-resolver.ts +0 -215
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action-dispatch wrapper around the route pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from rsc-entry/index.ts so the wiring can be unit-tested in
|
|
5
|
+
* isolation from Vite's virtual modules. The wrapper is responsible for
|
|
6
|
+
* three things, in order:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Pipeline-boundary CSRF validation** — runs on EVERY unsafe-method
|
|
9
|
+
* request, before any dispatch decision. This is the only line of
|
|
10
|
+
* defense for `route.ts` API handlers, which never see the action
|
|
11
|
+
* handler. See LOCAL-773.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Server action interception** — POST requests carrying an
|
|
14
|
+
* `x-rsc-action` header or React's `$ACTION_REF` form fields are
|
|
15
|
+
* handed to `handleActionRequest`, which executes the action and
|
|
16
|
+
* returns either an RSC response or a no-JS rerender signal.
|
|
17
|
+
*
|
|
18
|
+
* 3. **No-JS validation rerender** — when an action returns flash data
|
|
19
|
+
* instead of a redirect, the wrapper re-runs the page render via the
|
|
20
|
+
* pipeline with the post-action cookie state and `runWithFormFlash`
|
|
21
|
+
* so server components can read the flash. See TIM-836 / TIM-837.
|
|
22
|
+
*
|
|
23
|
+
* Anything else falls through to `pipeline(req)` for normal route handling.
|
|
24
|
+
*
|
|
25
|
+
* The wrapper takes its dependencies as parameters (no module-level
|
|
26
|
+
* imports of virtual modules) so tests can construct it with stub
|
|
27
|
+
* pipelines and stub revalidate renderers.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { FormRerender } from '../action-handler.js';
|
|
31
|
+
import { handleActionRequest, isActionRequest } from '../action-handler.js';
|
|
32
|
+
import type { BodyLimitsConfig } from '../body-limits.js';
|
|
33
|
+
import { validateCsrf, type CsrfConfig } from '../csrf.js';
|
|
34
|
+
import { runWithFormFlash } from '../form-flash.js';
|
|
35
|
+
import type { SensitiveFieldsOption } from '../sensitive-fields.js';
|
|
36
|
+
import type { RevalidateRenderer } from '../actions.js';
|
|
37
|
+
|
|
38
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a `RevalidateRenderer` for a specific request.
|
|
42
|
+
*
|
|
43
|
+
* The renderer needs to forward cookies/headers from the originating
|
|
44
|
+
* request when fetching the revalidated route. We pass the request in
|
|
45
|
+
* via a builder so the wrapper doesn't need to know about route matching,
|
|
46
|
+
* segment param coercion, or element building — those concerns stay in
|
|
47
|
+
* `rsc-entry/index.ts`.
|
|
48
|
+
*/
|
|
49
|
+
export type RevalidateRendererFactory = (req: Request) => RevalidateRenderer;
|
|
50
|
+
|
|
51
|
+
/** Dependencies for the action-dispatch wrapper. */
|
|
52
|
+
export interface ActionDispatchDeps {
|
|
53
|
+
/** CSRF configuration (Origin allow-list, on/off switch). */
|
|
54
|
+
csrfConfig: CsrfConfig;
|
|
55
|
+
/** Body size limits forwarded to `handleActionRequest`. */
|
|
56
|
+
bodyLimits?: BodyLimitsConfig['limits'];
|
|
57
|
+
/** Sensitive-field deny-list forwarded to `handleActionRequest`. */
|
|
58
|
+
sensitiveFields?: SensitiveFieldsOption;
|
|
59
|
+
/** Per-request factory that builds a `RevalidateRenderer`. */
|
|
60
|
+
buildRevalidateRenderer: RevalidateRendererFactory;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Implementation ───────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap a pipeline function with CSRF validation and server-action dispatch.
|
|
67
|
+
*
|
|
68
|
+
* The returned handler is the framework's outermost request entry point.
|
|
69
|
+
* Its responsibilities, in order:
|
|
70
|
+
*
|
|
71
|
+
* 1. CSRF (Origin/Host) validation on every unsafe-method request.
|
|
72
|
+
* Safe methods (GET/HEAD/OPTIONS) short-circuit inside `validateCsrf`,
|
|
73
|
+
* so this is a no-op for reads.
|
|
74
|
+
* 2. Server-action interception for POSTs that match `isActionRequest`.
|
|
75
|
+
* 3. No-JS validation rerender when an action returns a `FormRerender`
|
|
76
|
+
* signal instead of a redirect.
|
|
77
|
+
* 4. Otherwise, delegate to `pipeline(req)`.
|
|
78
|
+
*
|
|
79
|
+
* The duplicate `validateCsrf` call inside `handleActionRequest` is left in
|
|
80
|
+
* place as defense-in-depth (no-op on the happy path) so the action handler
|
|
81
|
+
* remains safe to call from any future entry point that bypasses this
|
|
82
|
+
* wrapper. See LOCAL-773.
|
|
83
|
+
*/
|
|
84
|
+
export function wrapPipelineWithActionDispatch(
|
|
85
|
+
pipeline: (req: Request) => Promise<Response>,
|
|
86
|
+
deps: ActionDispatchDeps
|
|
87
|
+
): (req: Request) => Promise<Response> {
|
|
88
|
+
return async (req: Request): Promise<Response> => {
|
|
89
|
+
// ─── 1. Pipeline-boundary CSRF validation (LOCAL-773) ─────────────
|
|
90
|
+
//
|
|
91
|
+
// Runs on EVERY unsafe-method request, before any dispatch decision.
|
|
92
|
+
// Without this, `route.ts` PUT/PATCH/DELETE handlers and POSTs with
|
|
93
|
+
// `Content-Type: application/json` or `text/plain` would reach the
|
|
94
|
+
// route handler with no Origin check at all — `isActionRequest` only
|
|
95
|
+
// matches POST + form/multipart/x-rsc-action.
|
|
96
|
+
//
|
|
97
|
+
// `text/plain` POST is a CORS-simple request, so a cross-site
|
|
98
|
+
// `<form enctype="text/plain">` submission carries `SameSite=Lax`
|
|
99
|
+
// cookies (the framework's own default).
|
|
100
|
+
const csrfResult = validateCsrf(req, deps.csrfConfig);
|
|
101
|
+
if (!csrfResult.ok) {
|
|
102
|
+
return new Response(null, { status: csrfResult.status });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── 2. Server action interception ────────────────────────────────
|
|
106
|
+
if (isActionRequest(req)) {
|
|
107
|
+
const actionResponse = await handleActionRequest(req, {
|
|
108
|
+
csrf: deps.csrfConfig,
|
|
109
|
+
bodyLimits: { limits: deps.bodyLimits },
|
|
110
|
+
sensitiveFields: deps.sensitiveFields,
|
|
111
|
+
revalidateRenderer: deps.buildRevalidateRenderer(req),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (actionResponse) {
|
|
115
|
+
// ─── 3. No-JS validation rerender ─────────────────────────────
|
|
116
|
+
if ('rerender' in actionResponse) {
|
|
117
|
+
const formRerender = actionResponse as FormRerender;
|
|
118
|
+
// Build a synthetic GET request for the rerender pipeline:
|
|
119
|
+
// - Same URL (so route matching lands on the same page)
|
|
120
|
+
// - Cookie header replaced with the post-action RYW snapshot
|
|
121
|
+
// so server components see the action's writes (TIM-837)
|
|
122
|
+
// - Method GET because the rerender is conceptually a page
|
|
123
|
+
// render, not a re-POST. The pipeline doesn't branch on
|
|
124
|
+
// method for page rendering, and constructing a POST without
|
|
125
|
+
// a body is awkward across Request implementations.
|
|
126
|
+
const rerenderHeaders = new Headers(req.headers);
|
|
127
|
+
if (formRerender.cookieHeader) {
|
|
128
|
+
rerenderHeaders.set('cookie', formRerender.cookieHeader);
|
|
129
|
+
} else {
|
|
130
|
+
rerenderHeaders.delete('cookie');
|
|
131
|
+
}
|
|
132
|
+
const rerenderReq = new Request(req.url, {
|
|
133
|
+
method: 'GET',
|
|
134
|
+
headers: rerenderHeaders,
|
|
135
|
+
});
|
|
136
|
+
const response = await runWithFormFlash(formRerender.rerender, () =>
|
|
137
|
+
pipeline(rerenderReq)
|
|
138
|
+
);
|
|
139
|
+
// Apply Set-Cookie headers snapshotted from the action's ALS scope.
|
|
140
|
+
// The pipeline above runs in its own request context with a fresh
|
|
141
|
+
// cookie jar, so cookies set inside the action would otherwise be
|
|
142
|
+
// silently dropped on the no-JS rerender path. See TIM-836
|
|
143
|
+
// (LOCAL-740).
|
|
144
|
+
for (const value of formRerender.setCookieHeaders) {
|
|
145
|
+
response.headers.append('Set-Cookie', value);
|
|
146
|
+
}
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
return actionResponse;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── 4. Normal route dispatch ─────────────────────────────────────
|
|
154
|
+
return pipeline(req);
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -352,7 +352,7 @@ interface SlotMatchResult {
|
|
|
352
352
|
* to find the deepest matching page.
|
|
353
353
|
*/
|
|
354
354
|
function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMatchResult | null {
|
|
355
|
-
const segments = match.segments
|
|
355
|
+
const segments = match.segments;
|
|
356
356
|
|
|
357
357
|
// Find the parent segment that owns this slot by comparing urlPaths.
|
|
358
358
|
// The slot's urlPath matches its parent's urlPath (slots don't add URL depth).
|
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
* correct file to render by walking the fallback chain described in
|
|
6
6
|
* design/10-error-handling.md §"Status-Code Files".
|
|
7
7
|
*
|
|
8
|
+
* **Generic over `TFile`** (TIM-848). Walks `SegmentNode<TFile>` trees
|
|
9
|
+
* regardless of whether `TFile` is the build-time `RouteFile` or the
|
|
10
|
+
* runtime `ManifestFile`. Before TIM-848 there were two near-identical
|
|
11
|
+
* resolvers — one for the Map-based scanner output and one for the
|
|
12
|
+
* object-based runtime manifest. Now there is one.
|
|
13
|
+
*
|
|
8
14
|
* Supports two format families:
|
|
9
15
|
* - 'component' (default): .tsx/.jsx/.mdx status files → React rendering pipeline
|
|
10
16
|
* - 'json': .json status files → raw JSON response, no React
|
|
@@ -17,17 +23,16 @@
|
|
|
17
23
|
* Pass 3 — error.tsx (leaf → root)
|
|
18
24
|
* Pass 4 — framework default (returns null)
|
|
19
25
|
*
|
|
20
|
-
* **JSON chain (4xx):**
|
|
21
|
-
* Pass 1 — json status files (leaf → root): {status}.json →
|
|
26
|
+
* **JSON chain (4xx and 5xx):**
|
|
27
|
+
* Pass 1 — json status files (leaf → root): {status}.json → {category}.json
|
|
22
28
|
* Pass 2 — framework default JSON (returns null, caller provides bare JSON)
|
|
23
29
|
*
|
|
24
|
-
* **5xx
|
|
30
|
+
* **5xx component:**
|
|
25
31
|
* Per-segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
|
|
26
|
-
* Then global-error.tsx (future)
|
|
27
32
|
* Then framework default (returns null)
|
|
28
33
|
*/
|
|
29
34
|
|
|
30
|
-
import type { SegmentNode
|
|
35
|
+
import type { SegmentNode } from '../routing/types.js';
|
|
31
36
|
|
|
32
37
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
33
38
|
|
|
@@ -42,9 +47,9 @@ export type StatusFileKind =
|
|
|
42
47
|
export type StatusFileFormat = 'component' | 'json';
|
|
43
48
|
|
|
44
49
|
/** Result of resolving a status-code file for a segment chain. */
|
|
45
|
-
export interface StatusFileResolution {
|
|
50
|
+
export interface StatusFileResolution<TFile> {
|
|
46
51
|
/** The matched route file. */
|
|
47
|
-
file:
|
|
52
|
+
file: TFile;
|
|
48
53
|
/** The HTTP status code (always the original status, not the file's code). */
|
|
49
54
|
status: number;
|
|
50
55
|
/** How the file was matched. */
|
|
@@ -57,9 +62,9 @@ export interface StatusFileResolution {
|
|
|
57
62
|
export type SlotDeniedKind = 'denied' | 'default';
|
|
58
63
|
|
|
59
64
|
/** Result of resolving a slot denied file. */
|
|
60
|
-
export interface SlotDeniedResolution {
|
|
65
|
+
export interface SlotDeniedResolution<TFile> {
|
|
61
66
|
/** The matched route file (denied.tsx or default.tsx). */
|
|
62
|
-
file:
|
|
67
|
+
file: TFile;
|
|
63
68
|
/** Slot name without @ prefix. */
|
|
64
69
|
slotName: string;
|
|
65
70
|
/** How the file was matched. */
|
|
@@ -78,6 +83,52 @@ const LEGACY_FILE_TO_STATUS: Record<string, number> = {
|
|
|
78
83
|
'unauthorized': 401,
|
|
79
84
|
};
|
|
80
85
|
|
|
86
|
+
/** Reverse index: status code → legacy file name. Built once at module load. */
|
|
87
|
+
const STATUS_TO_LEGACY_FILE: Record<number, string> = Object.fromEntries(
|
|
88
|
+
Object.entries(LEGACY_FILE_TO_STATUS).map(([name, status]) => [status, name])
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// ─── Lookup Helpers ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Look up `{statusStr}` then `{categoryKey}` (e.g. "4xx" / "5xx") in a
|
|
95
|
+
* status-file group on a single segment. Shared by all three fallback
|
|
96
|
+
* chains — the only structural difference between component 4xx,
|
|
97
|
+
* component 5xx, and JSON resolution is *which* group is searched and
|
|
98
|
+
* how the per-segment loop is layered around it.
|
|
99
|
+
*/
|
|
100
|
+
function lookupInGroup<TFile>(
|
|
101
|
+
group: Record<string, TFile> | undefined,
|
|
102
|
+
statusStr: string,
|
|
103
|
+
categoryKey: string,
|
|
104
|
+
segmentIndex: number,
|
|
105
|
+
status: number
|
|
106
|
+
): StatusFileResolution<TFile> | null {
|
|
107
|
+
if (!group) return null;
|
|
108
|
+
const exact = group[statusStr];
|
|
109
|
+
if (exact) return { file: exact, status, kind: 'exact', segmentIndex };
|
|
110
|
+
const category = group[categoryKey];
|
|
111
|
+
if (category) return { file: category, status, kind: 'category', segmentIndex };
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Look up the legacy convention file (`not-found.tsx` / `forbidden.tsx` /
|
|
117
|
+
* `unauthorized.tsx`) for `status` on a single segment. Returns null if
|
|
118
|
+
* `status` has no legacy mapping or the file isn't present.
|
|
119
|
+
*/
|
|
120
|
+
function lookupLegacy<TFile>(
|
|
121
|
+
group: Record<string, TFile> | undefined,
|
|
122
|
+
status: number,
|
|
123
|
+
segmentIndex: number
|
|
124
|
+
): StatusFileResolution<TFile> | null {
|
|
125
|
+
if (!group) return null;
|
|
126
|
+
const name = STATUS_TO_LEGACY_FILE[status];
|
|
127
|
+
if (!name) return null;
|
|
128
|
+
const file = group[name];
|
|
129
|
+
return file ? { file, status, kind: 'legacy', segmentIndex } : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
// ─── Resolver ────────────────────────────────────────────────────────────────
|
|
82
133
|
|
|
83
134
|
/**
|
|
@@ -91,101 +142,50 @@ const LEGACY_FILE_TO_STATUS: Record<string, number> = {
|
|
|
91
142
|
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
92
143
|
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
93
144
|
*/
|
|
94
|
-
export function resolveStatusFile(
|
|
145
|
+
export function resolveStatusFile<TFile>(
|
|
95
146
|
status: number,
|
|
96
|
-
segments: ReadonlyArray<SegmentNode
|
|
147
|
+
segments: ReadonlyArray<SegmentNode<TFile>>,
|
|
97
148
|
format: StatusFileFormat = 'component'
|
|
98
|
-
): StatusFileResolution | null {
|
|
99
|
-
if (status
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// JSON format for 5xx uses the same json chain pattern
|
|
104
|
-
return format === 'json' ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
|
|
105
|
-
}
|
|
106
|
-
return null;
|
|
149
|
+
): StatusFileResolution<TFile> | null {
|
|
150
|
+
if (status < 400 || status > 599) return null;
|
|
151
|
+
if (format === 'json') return resolveJson(status, segments);
|
|
152
|
+
if (status <= 499) return resolve4xx(status, segments);
|
|
153
|
+
return resolve5xx(status, segments);
|
|
107
154
|
}
|
|
108
155
|
|
|
109
156
|
/**
|
|
110
|
-
* 4xx component fallback chain
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
157
|
+
* 4xx component fallback chain — three separate full passes leaf→root.
|
|
158
|
+
*
|
|
159
|
+
* The passes must be separate (not interleaved per-segment) so that a
|
|
160
|
+
* root-level `404.tsx` beats a leaf-level `error.tsx`. The 5xx chain
|
|
161
|
+
* inverts this and is per-segment: a leaf's `error.tsx` beats a root's
|
|
162
|
+
* `5xx.tsx`. This asymmetry is the only reason these two functions exist
|
|
163
|
+
* separately.
|
|
164
|
+
*
|
|
165
|
+
* Pass 1 — {status}.tsx → 4xx.tsx (statusFiles)
|
|
166
|
+
* Pass 2 — not-found / forbidden / unauthorized (legacyStatusFiles)
|
|
167
|
+
* Pass 3 — error.tsx (error)
|
|
114
168
|
*/
|
|
115
|
-
function resolve4xx(
|
|
169
|
+
function resolve4xx<TFile>(
|
|
116
170
|
status: number,
|
|
117
|
-
segments: ReadonlyArray<SegmentNode
|
|
118
|
-
): StatusFileResolution | null {
|
|
171
|
+
segments: ReadonlyArray<SegmentNode<TFile>>
|
|
172
|
+
): StatusFileResolution<TFile> | null {
|
|
119
173
|
const statusStr = String(status);
|
|
120
174
|
|
|
121
|
-
// Pass 1: status files across all segments (leaf → root)
|
|
122
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
123
|
-
const segment = segments[i];
|
|
124
|
-
if (!segment.statusFiles) continue;
|
|
125
|
-
|
|
126
|
-
// Exact match first
|
|
127
|
-
const exact = segment.statusFiles.get(statusStr);
|
|
128
|
-
if (exact) {
|
|
129
|
-
return { file: exact, status, kind: 'exact', segmentIndex: i };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Category catch-all
|
|
133
|
-
const category = segment.statusFiles.get('4xx');
|
|
134
|
-
if (category) {
|
|
135
|
-
return { file: category, status, kind: 'category', segmentIndex: i };
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Pass 2: legacy compat files (leaf → root)
|
|
140
175
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) {
|
|
145
|
-
if (legacyStatus === status) {
|
|
146
|
-
const file = segment.legacyStatusFiles.get(name);
|
|
147
|
-
if (file) {
|
|
148
|
-
return { file, status, kind: 'legacy', segmentIndex: i };
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
176
|
+
const r = lookupInGroup(segments[i].statusFiles, statusStr, '4xx', i, status);
|
|
177
|
+
if (r) return r;
|
|
152
178
|
}
|
|
153
179
|
|
|
154
|
-
// Pass 3: error.tsx (leaf → root)
|
|
155
180
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
181
|
+
const r = lookupLegacy(segments[i].legacyStatusFiles, status, i);
|
|
182
|
+
if (r) return r;
|
|
159
183
|
}
|
|
160
184
|
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* 4xx JSON fallback chain (single pass):
|
|
166
|
-
* Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
|
|
167
|
-
* No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
|
|
168
|
-
*/
|
|
169
|
-
function resolve4xxJson(
|
|
170
|
-
status: number,
|
|
171
|
-
segments: ReadonlyArray<SegmentNode>
|
|
172
|
-
): StatusFileResolution | null {
|
|
173
|
-
const statusStr = String(status);
|
|
174
|
-
|
|
175
185
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
// Exact match first
|
|
180
|
-
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
181
|
-
if (exact) {
|
|
182
|
-
return { file: exact, status, kind: 'exact', segmentIndex: i };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Category catch-all
|
|
186
|
-
const category = segment.jsonStatusFiles.get('4xx');
|
|
187
|
-
if (category) {
|
|
188
|
-
return { file: category, status, kind: 'category', segmentIndex: i };
|
|
186
|
+
const errorFile = segments[i].error;
|
|
187
|
+
if (errorFile) {
|
|
188
|
+
return { file: errorFile, status, kind: 'error', segmentIndex: i };
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
@@ -193,33 +193,22 @@ function resolve4xxJson(
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
* 5xx component fallback chain
|
|
197
|
-
*
|
|
196
|
+
* 5xx component fallback chain — single pass, per-segment leaf→root.
|
|
197
|
+
*
|
|
198
|
+
* At each segment: {status}.tsx → 5xx.tsx → error.tsx. A leaf's
|
|
199
|
+
* `error.tsx` therefore beats a root's `5xx.tsx`, which is the
|
|
200
|
+
* intentional inverse of the 4xx chain.
|
|
198
201
|
*/
|
|
199
|
-
function resolve5xx(
|
|
202
|
+
function resolve5xx<TFile>(
|
|
200
203
|
status: number,
|
|
201
|
-
segments: ReadonlyArray<SegmentNode
|
|
202
|
-
): StatusFileResolution | null {
|
|
204
|
+
segments: ReadonlyArray<SegmentNode<TFile>>
|
|
205
|
+
): StatusFileResolution<TFile> | null {
|
|
203
206
|
const statusStr = String(status);
|
|
204
207
|
|
|
205
208
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
206
209
|
const segment = segments[i];
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (segment.statusFiles) {
|
|
210
|
-
const exact = segment.statusFiles.get(statusStr);
|
|
211
|
-
if (exact) {
|
|
212
|
-
return { file: exact, status, kind: 'exact', segmentIndex: i };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Category catch-all
|
|
216
|
-
const category = segment.statusFiles.get('5xx');
|
|
217
|
-
if (category) {
|
|
218
|
-
return { file: category, status, kind: 'category', segmentIndex: i };
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// error.tsx at this segment level (for 5xx, checked per-segment)
|
|
210
|
+
const r = lookupInGroup(segment.statusFiles, statusStr, '5xx', i, status);
|
|
211
|
+
if (r) return r;
|
|
223
212
|
if (segment.error) {
|
|
224
213
|
return { file: segment.error, status, kind: 'error', segmentIndex: i };
|
|
225
214
|
}
|
|
@@ -229,29 +218,22 @@ function resolve5xx(
|
|
|
229
218
|
}
|
|
230
219
|
|
|
231
220
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
221
|
+
* JSON fallback chain (for both 4xx and 5xx) — single pass leaf→root.
|
|
222
|
+
*
|
|
223
|
+
* At each segment: {status}.json → {category}.json. No legacy compat,
|
|
224
|
+
* no error.tsx — the JSON chain terminates at the category catch-all
|
|
225
|
+
* and the caller falls back to a bare-JSON framework default.
|
|
235
226
|
*/
|
|
236
|
-
function
|
|
227
|
+
function resolveJson<TFile>(
|
|
237
228
|
status: number,
|
|
238
|
-
segments: ReadonlyArray<SegmentNode
|
|
239
|
-
): StatusFileResolution | null {
|
|
229
|
+
segments: ReadonlyArray<SegmentNode<TFile>>
|
|
230
|
+
): StatusFileResolution<TFile> | null {
|
|
240
231
|
const statusStr = String(status);
|
|
232
|
+
const categoryKey = status >= 500 ? '5xx' : '4xx';
|
|
241
233
|
|
|
242
234
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
243
|
-
const
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
247
|
-
if (exact) {
|
|
248
|
-
return { file: exact, status, kind: 'exact', segmentIndex: i };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const category = segment.jsonStatusFiles.get('5xx');
|
|
252
|
-
if (category) {
|
|
253
|
-
return { file: category, status, kind: 'category', segmentIndex: i };
|
|
254
|
-
}
|
|
235
|
+
const r = lookupInGroup(segments[i].jsonStatusFiles, statusStr, categoryKey, i, status);
|
|
236
|
+
if (r) return r;
|
|
255
237
|
}
|
|
256
238
|
|
|
257
239
|
return null;
|
|
@@ -267,7 +249,9 @@ function resolve5xxJson(
|
|
|
267
249
|
*
|
|
268
250
|
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
269
251
|
*/
|
|
270
|
-
export function resolveSlotDenied(
|
|
252
|
+
export function resolveSlotDenied<TFile>(
|
|
253
|
+
slotNode: SegmentNode<TFile>
|
|
254
|
+
): SlotDeniedResolution<TFile> | null {
|
|
271
255
|
const slotName = slotNode.segmentName.replace(/^@/, '');
|
|
272
256
|
|
|
273
257
|
if (slotNode.denied) {
|
|
@@ -213,8 +213,10 @@ export async function buildElementTree(config: TreeBuilderConfig): Promise<TreeB
|
|
|
213
213
|
if (LayoutComponent) {
|
|
214
214
|
// Resolve parallel slots for this layout
|
|
215
215
|
const slotProps: Record<string, ReactElement> = {};
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
const slotNames = Object.keys(segment.slots);
|
|
217
|
+
if (slotNames.length > 0) {
|
|
218
|
+
for (const slotName of slotNames) {
|
|
219
|
+
const slotNode = segment.slots[slotName]!;
|
|
218
220
|
slotProps[slotName] = await buildSlotElement(
|
|
219
221
|
slotNode,
|
|
220
222
|
loadModule,
|
|
@@ -344,7 +346,7 @@ async function wrapWithErrorBoundaries(
|
|
|
344
346
|
|
|
345
347
|
if (segment.statusFiles) {
|
|
346
348
|
// Wrap with specific status files (innermost — highest priority at runtime)
|
|
347
|
-
for (const [key, file] of segment.statusFiles) {
|
|
349
|
+
for (const [key, file] of Object.entries(segment.statusFiles)) {
|
|
348
350
|
if (key !== '4xx' && key !== '5xx') {
|
|
349
351
|
const status = parseInt(key, 10);
|
|
350
352
|
if (!isNaN(status)) {
|
|
@@ -369,7 +371,7 @@ async function wrapWithErrorBoundaries(
|
|
|
369
371
|
}
|
|
370
372
|
|
|
371
373
|
// Wrap with category catch-alls (4xx.tsx, 5xx.tsx)
|
|
372
|
-
for (const [key, file] of segment.statusFiles) {
|
|
374
|
+
for (const [key, file] of Object.entries(segment.statusFiles)) {
|
|
373
375
|
if (key === '4xx' || key === '5xx') {
|
|
374
376
|
const mod = await loadModule(file);
|
|
375
377
|
const Component = mod.default;
|