@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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR rendering utilities — testable core of the SSR entry.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ssr-entry.ts so the rendering logic can be tested
|
|
5
|
+
* independently of the Vite RSC plugin runtime (which provides
|
|
6
|
+
* createFromReadableStream for decoding RSC streams).
|
|
7
|
+
*
|
|
8
|
+
* Design docs: 02-rendering-pipeline.md §"Single-Pass Rendering",
|
|
9
|
+
* 18-build-system.md §"Entry Files"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { renderToReadableStream } from 'react-dom/server';
|
|
14
|
+
|
|
15
|
+
import { formatSsrError } from './error-formatter.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Inline script that injects <meta name="robots" content="noindex"> into <head>.
|
|
19
|
+
*
|
|
20
|
+
* Used when a post-flush error (deny() or throw inside Suspense after the shell
|
|
21
|
+
* has been flushed) is detected. Since <head> has already been sent to the client,
|
|
22
|
+
* we use a script to dynamically add the meta tag. This signals search engines
|
|
23
|
+
* not to index the page, mitigating the SEO impact of a 200 status code on what
|
|
24
|
+
* is effectively an error/deny page.
|
|
25
|
+
*
|
|
26
|
+
* See design/05-streaming.md §"deny() inside Suspense"
|
|
27
|
+
*/
|
|
28
|
+
const NOINDEX_SCRIPT =
|
|
29
|
+
'<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render a React element tree to a ReadableStream of HTML.
|
|
33
|
+
*
|
|
34
|
+
* Uses renderToReadableStream (NOT renderToString) for streaming SSR.
|
|
35
|
+
* The returned stream begins yielding after onShellReady — everything
|
|
36
|
+
* outside <Suspense> boundaries is in the shell.
|
|
37
|
+
*
|
|
38
|
+
* With progressive streaming, the RSC stream is piped directly to SSR
|
|
39
|
+
* without buffering. If deny() was called outside a Suspense boundary,
|
|
40
|
+
* the RSC stream encodes an error in the shell — renderToReadableStream
|
|
41
|
+
* rejects, and the RSC entry catches this to render a deny page with
|
|
42
|
+
* the correct HTTP status code. If deny() was inside Suspense, the shell
|
|
43
|
+
* succeeds (200 committed) and the error streams as an error boundary.
|
|
44
|
+
*
|
|
45
|
+
* @param element - The React element tree decoded from the RSC stream
|
|
46
|
+
* @param options - Optional configuration
|
|
47
|
+
* @param options.bootstrapScriptContent - Inline JS injected by React as a
|
|
48
|
+
* non-deferred `<script>` in the shell HTML. Executes immediately during
|
|
49
|
+
* parsing — even while Suspense boundaries are still streaming. Used to
|
|
50
|
+
* kick off module loading via dynamic `import()` so hydration can start
|
|
51
|
+
* before the HTML stream closes.
|
|
52
|
+
* @returns A ReadableStream of HTML bytes with hydration markers
|
|
53
|
+
*/
|
|
54
|
+
export async function renderSsrStream(
|
|
55
|
+
element: ReactNode,
|
|
56
|
+
options?: { bootstrapScriptContent?: string; deferSuspenseFor?: number; signal?: AbortSignal }
|
|
57
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
58
|
+
const signal = options?.signal;
|
|
59
|
+
const stream = await renderToReadableStream(element, {
|
|
60
|
+
bootstrapScriptContent: options?.bootstrapScriptContent || undefined,
|
|
61
|
+
signal,
|
|
62
|
+
onError(error: unknown) {
|
|
63
|
+
// Suppress logging for connection aborts — the user refreshed or
|
|
64
|
+
// navigated away, not an application error.
|
|
65
|
+
if (isAbortError(error) || signal?.aborted) return;
|
|
66
|
+
console.error('[timber] SSR render error:', formatSsrError(error));
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Prevent unhandled promise rejection from streaming-phase errors.
|
|
71
|
+
// React DOM Server exposes `allReady` — a promise that resolves when
|
|
72
|
+
// ALL content (including Suspense boundaries) has been rendered. If a
|
|
73
|
+
// streaming-phase error occurs (e.g. React boundary flush failure),
|
|
74
|
+
// `allReady` rejects independently of the stream. Without this catch,
|
|
75
|
+
// the rejection becomes an unhandled promise rejection that crashes
|
|
76
|
+
// the Node.js process.
|
|
77
|
+
stream.allReady.catch(() => {});
|
|
78
|
+
|
|
79
|
+
// deferSuspenseFor hold: delay the first read so React can resolve
|
|
80
|
+
// fast-completing Suspense boundaries before we read the shell HTML.
|
|
81
|
+
// renderToReadableStream generates HTML lazily on pull — if we wait
|
|
82
|
+
// before reading, React resolves pending boundaries and inlines their
|
|
83
|
+
// content instead of serializing fallbacks. Race allReady against
|
|
84
|
+
// deferSuspenseFor so we don't wait longer than necessary.
|
|
85
|
+
// See design/05-streaming.md §"deferSuspenseFor"
|
|
86
|
+
const deferMs = options?.deferSuspenseFor;
|
|
87
|
+
if (deferMs && deferMs > 0) {
|
|
88
|
+
await Promise.race([
|
|
89
|
+
stream.allReady,
|
|
90
|
+
new Promise<void>((resolve) => setTimeout(resolve, deferMs)),
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// renderToReadableStream resolves after onShellReady by default.
|
|
95
|
+
// The stream is ready to read — the shell (everything outside
|
|
96
|
+
// Suspense boundaries) is available. Suspense content streams
|
|
97
|
+
// into the open connection as it resolves.
|
|
98
|
+
//
|
|
99
|
+
// Wrap the stream in an error-resilient transform. With progressive
|
|
100
|
+
// streaming, errors inside Suspense boundaries (e.g. deny() or throws
|
|
101
|
+
// in async components) cause React's stream to error during the flush
|
|
102
|
+
// phase. The onError callback logs the error, but the stream error
|
|
103
|
+
// would become an unhandled promise rejection and crash the process.
|
|
104
|
+
// The transform catches these post-shell streaming errors and closes
|
|
105
|
+
// the stream cleanly — the shell (with correct status code) has
|
|
106
|
+
// already been sent.
|
|
107
|
+
return wrapStreamWithErrorHandling(stream, signal);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wrap an HTML stream with error handling for the streaming phase.
|
|
112
|
+
*
|
|
113
|
+
* During progressive RSC→SSR streaming, errors in Suspense boundaries
|
|
114
|
+
* (e.g. deny() inside Suspense, throws in async components) cause
|
|
115
|
+
* React DOM's renderToReadableStream to error after the shell has been
|
|
116
|
+
* flushed. Without this wrapper, the stream error becomes an unhandled
|
|
117
|
+
* promise rejection that crashes the process.
|
|
118
|
+
*
|
|
119
|
+
* The wrapper catches streaming-phase errors, logs them, and closes
|
|
120
|
+
* the output stream cleanly. The shell (headers, status code, content
|
|
121
|
+
* outside Suspense) has already been sent to the client.
|
|
122
|
+
*/
|
|
123
|
+
/** @internal Exported for testing only. */
|
|
124
|
+
export function wrapStreamWithErrorHandling(
|
|
125
|
+
stream: ReadableStream<Uint8Array>,
|
|
126
|
+
signal?: AbortSignal
|
|
127
|
+
): ReadableStream<Uint8Array> {
|
|
128
|
+
const reader = stream.getReader();
|
|
129
|
+
const encoder = new TextEncoder();
|
|
130
|
+
|
|
131
|
+
return new ReadableStream<Uint8Array>({
|
|
132
|
+
async pull(controller) {
|
|
133
|
+
try {
|
|
134
|
+
const { done, value } = await reader.read();
|
|
135
|
+
if (done) {
|
|
136
|
+
controller.close();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
controller.enqueue(value);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// Connection abort (user refreshed or navigated away) — close
|
|
142
|
+
// silently without logging. This is not an application error.
|
|
143
|
+
if (isAbortError(error) || signal?.aborted) {
|
|
144
|
+
controller.close();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// Streaming-phase error (e.g. React boundary flush failure,
|
|
148
|
+
// deny() or throw inside Suspense after flush).
|
|
149
|
+
// The shell has already been sent with status 200. Inject a
|
|
150
|
+
// noindex meta tag so search engines don't index this error page,
|
|
151
|
+
// then close cleanly. See design/05-streaming.md.
|
|
152
|
+
console.error('[timber] SSR streaming error (post-shell):', formatSsrError(error));
|
|
153
|
+
controller.enqueue(encoder.encode(NOINDEX_SCRIPT));
|
|
154
|
+
controller.close();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
cancel() {
|
|
158
|
+
reader.cancel().catch(() => {});
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if an error is an abort error (connection closed by client).
|
|
165
|
+
*
|
|
166
|
+
* When the browser aborts a request (page refresh, navigation away),
|
|
167
|
+
* the AbortSignal fires and React/streams throw an AbortError. This
|
|
168
|
+
* is not an application error — suppress it from error boundaries and logs.
|
|
169
|
+
*/
|
|
170
|
+
function isAbortError(error: unknown): boolean {
|
|
171
|
+
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
|
172
|
+
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build a Response from the SSR HTML stream with the correct
|
|
178
|
+
* status code and headers from the navigation context.
|
|
179
|
+
*
|
|
180
|
+
* Sets content-type to text/html if not already set by middleware.
|
|
181
|
+
*
|
|
182
|
+
* @param htmlStream - The HTML stream from renderSsrStream
|
|
183
|
+
* @param statusCode - The committed HTTP status code from RSC
|
|
184
|
+
* @param responseHeaders - Response headers from middleware/proxy
|
|
185
|
+
* @returns A Response ready to send to the client
|
|
186
|
+
*/
|
|
187
|
+
export function buildSsrResponse(
|
|
188
|
+
htmlStream: ReadableStream<Uint8Array>,
|
|
189
|
+
statusCode: number,
|
|
190
|
+
responseHeaders: Headers
|
|
191
|
+
): Response {
|
|
192
|
+
if (!responseHeaders.has('content-type')) {
|
|
193
|
+
responseHeaders.set('content-type', 'text/html; charset=utf-8');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return new Response(htmlStream, {
|
|
197
|
+
status: statusCode,
|
|
198
|
+
headers: responseHeaders,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-code file resolver for timber.js error/denial rendering.
|
|
3
|
+
*
|
|
4
|
+
* Given an HTTP status code and a matched segment chain, resolves the
|
|
5
|
+
* correct file to render by walking the fallback chain described in
|
|
6
|
+
* design/10-error-handling.md §"Status-Code Files".
|
|
7
|
+
*
|
|
8
|
+
* Supports two format families:
|
|
9
|
+
* - 'component' (default): .tsx/.jsx/.mdx status files → React rendering pipeline
|
|
10
|
+
* - 'json': .json status files → raw JSON response, no React
|
|
11
|
+
*
|
|
12
|
+
* Fallback chains operate within the same format family (no cross-format fallback).
|
|
13
|
+
*
|
|
14
|
+
* **Component chain (4xx):**
|
|
15
|
+
* Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
|
|
16
|
+
* Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
|
|
17
|
+
* Pass 3 — error.tsx (leaf → root)
|
|
18
|
+
* Pass 4 — framework default (returns null)
|
|
19
|
+
*
|
|
20
|
+
* **JSON chain (4xx):**
|
|
21
|
+
* Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
|
|
22
|
+
* Pass 2 — framework default JSON (returns null, caller provides bare JSON)
|
|
23
|
+
*
|
|
24
|
+
* **5xx (component only):**
|
|
25
|
+
* Per-segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
|
|
26
|
+
* Then global-error.tsx (future)
|
|
27
|
+
* Then framework default (returns null)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { SegmentNode, RouteFile } from '#/routing/types.js';
|
|
31
|
+
|
|
32
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** How the status-code file was matched. */
|
|
35
|
+
export type StatusFileKind =
|
|
36
|
+
| 'exact' // e.g. 403.tsx matched status 403
|
|
37
|
+
| 'category' // e.g. 4xx.tsx matched status 403
|
|
38
|
+
| 'legacy' // e.g. not-found.tsx matched status 404
|
|
39
|
+
| 'error'; // error.tsx as last resort
|
|
40
|
+
|
|
41
|
+
/** Response format family for status-code resolution. */
|
|
42
|
+
export type StatusFileFormat = 'component' | 'json';
|
|
43
|
+
|
|
44
|
+
/** Result of resolving a status-code file for a segment chain. */
|
|
45
|
+
export interface StatusFileResolution {
|
|
46
|
+
/** The matched route file. */
|
|
47
|
+
file: RouteFile;
|
|
48
|
+
/** The HTTP status code (always the original status, not the file's code). */
|
|
49
|
+
status: number;
|
|
50
|
+
/** How the file was matched. */
|
|
51
|
+
kind: StatusFileKind;
|
|
52
|
+
/** Index into the segments array where the file was found. */
|
|
53
|
+
segmentIndex: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** How a slot denial file was matched. */
|
|
57
|
+
export type SlotDeniedKind = 'denied' | 'default';
|
|
58
|
+
|
|
59
|
+
/** Result of resolving a slot denied file. */
|
|
60
|
+
export interface SlotDeniedResolution {
|
|
61
|
+
/** The matched route file (denied.tsx or default.tsx). */
|
|
62
|
+
file: RouteFile;
|
|
63
|
+
/** Slot name without @ prefix. */
|
|
64
|
+
slotName: string;
|
|
65
|
+
/** How the file was matched. */
|
|
66
|
+
kind: SlotDeniedKind;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Legacy Compat Mapping ───────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Maps legacy file convention names to their corresponding HTTP status codes.
|
|
73
|
+
* Only used in the 4xx component fallback chain.
|
|
74
|
+
*/
|
|
75
|
+
const LEGACY_FILE_TO_STATUS: Record<string, number> = {
|
|
76
|
+
'not-found': 404,
|
|
77
|
+
'forbidden': 403,
|
|
78
|
+
'unauthorized': 401,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ─── Resolver ────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the status-code file to render for a given HTTP status code.
|
|
85
|
+
*
|
|
86
|
+
* Walks the segment chain from leaf to root following the fallback chain
|
|
87
|
+
* defined in design/10-error-handling.md. Returns null if no file is found
|
|
88
|
+
* (caller should render the framework default).
|
|
89
|
+
*
|
|
90
|
+
* @param status - The HTTP status code (4xx or 5xx).
|
|
91
|
+
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
92
|
+
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveStatusFile(
|
|
95
|
+
status: number,
|
|
96
|
+
segments: ReadonlyArray<SegmentNode>,
|
|
97
|
+
format: StatusFileFormat = 'component'
|
|
98
|
+
): StatusFileResolution | null {
|
|
99
|
+
if (status >= 400 && status <= 499) {
|
|
100
|
+
return format === 'json' ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
|
|
101
|
+
}
|
|
102
|
+
if (status >= 500 && status <= 599) {
|
|
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;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 4xx component fallback chain (three separate passes):
|
|
111
|
+
* Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
|
|
112
|
+
* Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
|
|
113
|
+
* Pass 3 — error.tsx (leaf → root)
|
|
114
|
+
*/
|
|
115
|
+
function resolve4xx(
|
|
116
|
+
status: number,
|
|
117
|
+
segments: ReadonlyArray<SegmentNode>
|
|
118
|
+
): StatusFileResolution | null {
|
|
119
|
+
const statusStr = String(status);
|
|
120
|
+
|
|
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
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
141
|
+
const segment = segments[i];
|
|
142
|
+
if (!segment.legacyStatusFiles) continue;
|
|
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
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Pass 3: error.tsx (leaf → root)
|
|
155
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
156
|
+
if (segments[i].error) {
|
|
157
|
+
return { file: segments[i].error!, status, kind: 'error', segmentIndex: i };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
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
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
176
|
+
const segment = segments[i];
|
|
177
|
+
if (!segment.jsonStatusFiles) continue;
|
|
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 };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 5xx component fallback chain (single pass, per-segment):
|
|
197
|
+
* At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
|
|
198
|
+
*/
|
|
199
|
+
function resolve5xx(
|
|
200
|
+
status: number,
|
|
201
|
+
segments: ReadonlyArray<SegmentNode>
|
|
202
|
+
): StatusFileResolution | null {
|
|
203
|
+
const statusStr = String(status);
|
|
204
|
+
|
|
205
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
206
|
+
const segment = segments[i];
|
|
207
|
+
|
|
208
|
+
// Exact status file
|
|
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)
|
|
223
|
+
if (segment.error) {
|
|
224
|
+
return { file: segment.error, status, kind: 'error', segmentIndex: i };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 5xx JSON fallback chain (single pass):
|
|
233
|
+
* At each segment (leaf → root): {status}.json → 5xx.json
|
|
234
|
+
* No error.tsx equivalent — JSON chain terminates at category catch-all.
|
|
235
|
+
*/
|
|
236
|
+
function resolve5xxJson(
|
|
237
|
+
status: number,
|
|
238
|
+
segments: ReadonlyArray<SegmentNode>
|
|
239
|
+
): StatusFileResolution | null {
|
|
240
|
+
const statusStr = String(status);
|
|
241
|
+
|
|
242
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
243
|
+
const segment = segments[i];
|
|
244
|
+
if (!segment.jsonStatusFiles) continue;
|
|
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
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Slot Denied Resolver ────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Resolve the denial file for a parallel route slot.
|
|
264
|
+
*
|
|
265
|
+
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
266
|
+
* Fallback chain: denied.tsx → default.tsx → null.
|
|
267
|
+
*
|
|
268
|
+
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
269
|
+
*/
|
|
270
|
+
export function resolveSlotDenied(slotNode: SegmentNode): SlotDeniedResolution | null {
|
|
271
|
+
const slotName = slotNode.segmentName.replace(/^@/, '');
|
|
272
|
+
|
|
273
|
+
if (slotNode.denied) {
|
|
274
|
+
return { file: slotNode.denied, slotName, kind: 'denied' };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (slotNode.default) {
|
|
278
|
+
return { file: slotNode.default, slotName, kind: 'default' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return null;
|
|
282
|
+
}
|