@timber-js/app 0.1.1 → 0.1.3

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 (143) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -7
  3. package/dist/index.js.map +1 -1
  4. package/dist/plugins/dev-server.d.ts.map +1 -1
  5. package/dist/plugins/entries.d.ts.map +1 -1
  6. package/package.json +5 -4
  7. package/src/adapters/cloudflare.ts +325 -0
  8. package/src/adapters/nitro.ts +366 -0
  9. package/src/adapters/types.ts +63 -0
  10. package/src/cache/index.ts +91 -0
  11. package/src/cache/redis-handler.ts +91 -0
  12. package/src/cache/register-cached-function.ts +99 -0
  13. package/src/cache/singleflight.ts +26 -0
  14. package/src/cache/stable-stringify.ts +21 -0
  15. package/src/cache/timber-cache.ts +116 -0
  16. package/src/cli.ts +201 -0
  17. package/src/client/browser-entry.ts +663 -0
  18. package/src/client/error-boundary.tsx +209 -0
  19. package/src/client/form.tsx +200 -0
  20. package/src/client/head.ts +61 -0
  21. package/src/client/history.ts +46 -0
  22. package/src/client/index.ts +60 -0
  23. package/src/client/link-navigate-interceptor.tsx +62 -0
  24. package/src/client/link-status-provider.tsx +40 -0
  25. package/src/client/link.tsx +310 -0
  26. package/src/client/nuqs-adapter.tsx +117 -0
  27. package/src/client/router-ref.ts +25 -0
  28. package/src/client/router.ts +563 -0
  29. package/src/client/segment-cache.ts +194 -0
  30. package/src/client/segment-context.ts +57 -0
  31. package/src/client/ssr-data.ts +95 -0
  32. package/src/client/types.ts +4 -0
  33. package/src/client/unload-guard.ts +34 -0
  34. package/src/client/use-cookie.ts +122 -0
  35. package/src/client/use-link-status.ts +46 -0
  36. package/src/client/use-navigation-pending.ts +47 -0
  37. package/src/client/use-params.ts +71 -0
  38. package/src/client/use-pathname.ts +43 -0
  39. package/src/client/use-query-states.ts +133 -0
  40. package/src/client/use-router.ts +77 -0
  41. package/src/client/use-search-params.ts +74 -0
  42. package/src/client/use-selected-layout-segment.ts +110 -0
  43. package/src/content/index.ts +13 -0
  44. package/src/cookies/define-cookie.ts +137 -0
  45. package/src/cookies/index.ts +9 -0
  46. package/src/fonts/ast.ts +359 -0
  47. package/src/fonts/css.ts +68 -0
  48. package/src/fonts/fallbacks.ts +248 -0
  49. package/src/fonts/google.ts +332 -0
  50. package/src/fonts/local.ts +177 -0
  51. package/src/fonts/types.ts +88 -0
  52. package/src/index.ts +420 -0
  53. package/src/plugins/adapter-build.ts +118 -0
  54. package/src/plugins/build-manifest.ts +323 -0
  55. package/src/plugins/build-report.ts +353 -0
  56. package/src/plugins/cache-transform.ts +199 -0
  57. package/src/plugins/chunks.ts +90 -0
  58. package/src/plugins/content.ts +136 -0
  59. package/src/plugins/dev-error-overlay.ts +230 -0
  60. package/src/plugins/dev-logs.ts +280 -0
  61. package/src/plugins/dev-server.ts +391 -0
  62. package/src/plugins/dynamic-transform.ts +161 -0
  63. package/src/plugins/entries.ts +214 -0
  64. package/src/plugins/fonts.ts +581 -0
  65. package/src/plugins/mdx.ts +179 -0
  66. package/src/plugins/react-prod.ts +56 -0
  67. package/src/plugins/routing.ts +419 -0
  68. package/src/plugins/server-action-exports.ts +220 -0
  69. package/src/plugins/server-bundle.ts +113 -0
  70. package/src/plugins/shims.ts +168 -0
  71. package/src/plugins/static-build.ts +207 -0
  72. package/src/routing/codegen.ts +396 -0
  73. package/src/routing/index.ts +14 -0
  74. package/src/routing/interception.ts +173 -0
  75. package/src/routing/scanner.ts +487 -0
  76. package/src/routing/status-file-lint.ts +114 -0
  77. package/src/routing/types.ts +100 -0
  78. package/src/search-params/analyze.ts +192 -0
  79. package/src/search-params/codecs.ts +153 -0
  80. package/src/search-params/create.ts +314 -0
  81. package/src/search-params/index.ts +23 -0
  82. package/src/search-params/registry.ts +31 -0
  83. package/src/server/access-gate.tsx +142 -0
  84. package/src/server/action-client.ts +473 -0
  85. package/src/server/action-handler.ts +325 -0
  86. package/src/server/actions.ts +236 -0
  87. package/src/server/asset-headers.ts +81 -0
  88. package/src/server/body-limits.ts +102 -0
  89. package/src/server/build-manifest.ts +234 -0
  90. package/src/server/canonicalize.ts +90 -0
  91. package/src/server/client-module-map.ts +58 -0
  92. package/src/server/csrf.ts +79 -0
  93. package/src/server/deny-renderer.ts +302 -0
  94. package/src/server/dev-logger.ts +419 -0
  95. package/src/server/dev-span-processor.ts +78 -0
  96. package/src/server/dev-warnings.ts +282 -0
  97. package/src/server/early-hints-sender.ts +55 -0
  98. package/src/server/early-hints.ts +142 -0
  99. package/src/server/error-boundary-wrapper.ts +69 -0
  100. package/src/server/error-formatter.ts +184 -0
  101. package/src/server/flush.ts +182 -0
  102. package/src/server/form-data.ts +176 -0
  103. package/src/server/form-flash.ts +93 -0
  104. package/src/server/html-injectors.ts +445 -0
  105. package/src/server/index.ts +222 -0
  106. package/src/server/instrumentation.ts +136 -0
  107. package/src/server/logger.ts +145 -0
  108. package/src/server/manifest-status-resolver.ts +215 -0
  109. package/src/server/metadata-render.ts +527 -0
  110. package/src/server/metadata-routes.ts +189 -0
  111. package/src/server/metadata.ts +263 -0
  112. package/src/server/middleware-runner.ts +32 -0
  113. package/src/server/nuqs-ssr-provider.tsx +63 -0
  114. package/src/server/pipeline.ts +555 -0
  115. package/src/server/prerender.ts +139 -0
  116. package/src/server/primitives.ts +264 -0
  117. package/src/server/proxy.ts +43 -0
  118. package/src/server/request-context.ts +554 -0
  119. package/src/server/route-element-builder.ts +395 -0
  120. package/src/server/route-handler.ts +153 -0
  121. package/src/server/route-matcher.ts +316 -0
  122. package/src/server/rsc-entry/api-handler.ts +112 -0
  123. package/src/server/rsc-entry/error-renderer.ts +177 -0
  124. package/src/server/rsc-entry/helpers.ts +147 -0
  125. package/src/server/rsc-entry/index.ts +688 -0
  126. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  127. package/src/server/slot-resolver.ts +359 -0
  128. package/src/server/ssr-entry.ts +161 -0
  129. package/src/server/ssr-render.ts +200 -0
  130. package/src/server/status-code-resolver.ts +282 -0
  131. package/src/server/tracing.ts +281 -0
  132. package/src/server/tree-builder.ts +354 -0
  133. package/src/server/types.ts +150 -0
  134. package/src/shims/font-google.ts +67 -0
  135. package/src/shims/headers.ts +11 -0
  136. package/src/shims/image.ts +48 -0
  137. package/src/shims/link.ts +9 -0
  138. package/src/shims/navigation-client.ts +52 -0
  139. package/src/shims/navigation.ts +31 -0
  140. package/src/shims/server-only-noop.js +5 -0
  141. package/src/utils/directive-parser.ts +529 -0
  142. package/src/utils/format.ts +10 -0
  143. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Instrumentation — loads and runs the user's instrumentation.ts file.
3
+ *
4
+ * instrumentation.ts is a file convention at the project root that exports:
5
+ * - register() — called once at server startup, before the first request
6
+ * - onRequestError() — called for every unhandled server error
7
+ * - logger — any object with info/warn/error/debug methods
8
+ *
9
+ * See design/17-logging.md §"instrumentation.ts — The Entry Point"
10
+ */
11
+
12
+ import { setLogger, type TimberLogger } from './logger.js';
13
+
14
+ // ─── Instrumentation Types ────────────────────────────────────────────────
15
+
16
+ export type InstrumentationOnRequestError = (
17
+ error: unknown,
18
+ request: InstrumentationRequestInfo,
19
+ context: InstrumentationErrorContext
20
+ ) => void | Promise<void>;
21
+
22
+ export interface InstrumentationRequestInfo {
23
+ /** HTTP method: 'GET', 'POST', etc. */
24
+ method: string;
25
+ /** Request path: '/dashboard/projects/123' */
26
+ path: string;
27
+ /** Request headers as a plain object. */
28
+ headers: Record<string, string>;
29
+ }
30
+
31
+ export interface InstrumentationErrorContext {
32
+ /** Which pipeline phase the error occurred in. */
33
+ phase: 'proxy' | 'handler' | 'render' | 'action' | 'route';
34
+ /** The route pattern: '/dashboard/projects/[id]' */
35
+ routePath: string;
36
+ /** Type of route that was matched. */
37
+ routeType: 'page' | 'route' | 'action';
38
+ /** Always set — OTEL trace ID or UUID fallback. */
39
+ traceId: string;
40
+ }
41
+
42
+ // ─── Instrumentation Module Shape ─────────────────────────────────────────
43
+
44
+ interface InstrumentationModule {
45
+ register?: () => void | Promise<void>;
46
+ onRequestError?: InstrumentationOnRequestError;
47
+ logger?: TimberLogger;
48
+ }
49
+
50
+ // ─── State ────────────────────────────────────────────────────────────────
51
+ //
52
+ // Intentional per-app singletons (not per-request). Instrumentation loads
53
+ // once at server startup and persists for the lifetime of the process/isolate.
54
+ // These must NOT be migrated to ALS — they are correctly scoped to the app.
55
+
56
+ let _initialized = false;
57
+ let _onRequestError: InstrumentationOnRequestError | null = null;
58
+
59
+ /**
60
+ * Load and initialize the user's instrumentation.ts module.
61
+ *
62
+ * - Awaits register() before returning (server blocks on this).
63
+ * - Picks up the logger export and wires it into the framework logger.
64
+ * - Stores onRequestError for later invocation.
65
+ *
66
+ * @param loader - Function that dynamically imports the user's instrumentation module.
67
+ * Returns null if no instrumentation.ts exists.
68
+ */
69
+ export async function loadInstrumentation(
70
+ loader: () => Promise<InstrumentationModule | null>
71
+ ): Promise<void> {
72
+ if (_initialized) return;
73
+ _initialized = true;
74
+
75
+ let mod: InstrumentationModule | null;
76
+ try {
77
+ mod = await loader();
78
+ } catch (error) {
79
+ console.error('[timber] Failed to load instrumentation.ts:', error);
80
+ return;
81
+ }
82
+
83
+ if (!mod) return;
84
+
85
+ // Wire up the logger export
86
+ if (mod.logger && typeof mod.logger.info === 'function') {
87
+ setLogger(mod.logger);
88
+ }
89
+
90
+ // Store onRequestError for later
91
+ if (typeof mod.onRequestError === 'function') {
92
+ _onRequestError = mod.onRequestError;
93
+ }
94
+
95
+ // Await register() — server does not accept requests until this resolves
96
+ if (typeof mod.register === 'function') {
97
+ try {
98
+ await mod.register();
99
+ } catch (error) {
100
+ console.error('[timber] instrumentation.ts register() threw:', error);
101
+ throw error;
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Call the user's onRequestError hook. Catches and logs any errors thrown
108
+ * by the hook itself — it must not affect the response.
109
+ */
110
+ export async function callOnRequestError(
111
+ error: unknown,
112
+ request: InstrumentationRequestInfo,
113
+ context: InstrumentationErrorContext
114
+ ): Promise<void> {
115
+ if (!_onRequestError) return;
116
+ try {
117
+ await _onRequestError(error, request, context);
118
+ } catch (hookError) {
119
+ console.error('[timber] onRequestError hook threw:', hookError);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Check if onRequestError is registered.
125
+ */
126
+ export function hasOnRequestError(): boolean {
127
+ return _onRequestError !== null;
128
+ }
129
+
130
+ /**
131
+ * Reset instrumentation state. Test-only.
132
+ */
133
+ export function resetInstrumentation(): void {
134
+ _initialized = false;
135
+ _onRequestError = null;
136
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Logger — structured logging with environment-aware formatting.
3
+ *
4
+ * timber.js does not ship a logger. Users export any object with
5
+ * info/warn/error/debug methods from instrumentation.ts and the framework
6
+ * picks it up. Silent if no logger export is present.
7
+ *
8
+ * See design/17-logging.md §"Production Logging"
9
+ */
10
+
11
+ import { getTraceStore } from './tracing.js';
12
+ import { formatSsrError } from './error-formatter.js';
13
+
14
+ // ─── Logger Interface ─────────────────────────────────────────────────────
15
+
16
+ /** Any object with standard log methods satisfies this — pino, winston, consola, console. */
17
+ export interface TimberLogger {
18
+ info(msg: string, data?: Record<string, unknown>): void;
19
+ warn(msg: string, data?: Record<string, unknown>): void;
20
+ error(msg: string, data?: Record<string, unknown>): void;
21
+ debug(msg: string, data?: Record<string, unknown>): void;
22
+ }
23
+
24
+ // ─── Logger Registry ──────────────────────────────────────────────────────
25
+
26
+ let _logger: TimberLogger | null = null;
27
+
28
+ /**
29
+ * Set the user-provided logger. Called by the instrumentation loader
30
+ * when it finds a `logger` export in instrumentation.ts.
31
+ */
32
+ export function setLogger(logger: TimberLogger): void {
33
+ _logger = logger;
34
+ }
35
+
36
+ /**
37
+ * Get the current logger, or null if none configured.
38
+ * Framework-internal — used at framework event points to emit structured logs.
39
+ */
40
+ export function getLogger(): TimberLogger | null {
41
+ return _logger;
42
+ }
43
+
44
+ // ─── Framework Log Helpers ────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Inject trace_id and span_id into log data for log–trace correlation.
48
+ * Always injects trace_id (never undefined). Injects span_id only when OTEL is active.
49
+ */
50
+ function withTraceContext(data?: Record<string, unknown>): Record<string, unknown> {
51
+ const store = getTraceStore();
52
+ const enriched: Record<string, unknown> = { ...data };
53
+ if (store) {
54
+ enriched.trace_id = store.traceId;
55
+ if (store.spanId) {
56
+ enriched.span_id = store.spanId;
57
+ }
58
+ }
59
+ return enriched;
60
+ }
61
+
62
+ // ─── Framework Event Emitters ─────────────────────────────────────────────
63
+
64
+ /** Log a completed request. Level: info. */
65
+ export function logRequestCompleted(data: {
66
+ method: string;
67
+ path: string;
68
+ status: number;
69
+ durationMs: number;
70
+ }): void {
71
+ _logger?.info('request completed', withTraceContext(data));
72
+ }
73
+
74
+ /** Log request received. Level: debug. */
75
+ export function logRequestReceived(data: { method: string; path: string }): void {
76
+ _logger?.debug('request received', withTraceContext(data));
77
+ }
78
+
79
+ /** Log a slow request warning. Level: warn. */
80
+ export function logSlowRequest(data: {
81
+ method: string;
82
+ path: string;
83
+ durationMs: number;
84
+ threshold: number;
85
+ }): void {
86
+ _logger?.warn('slow request exceeded threshold', withTraceContext(data));
87
+ }
88
+
89
+ /** Log middleware short-circuit. Level: debug. */
90
+ export function logMiddlewareShortCircuit(data: {
91
+ method: string;
92
+ path: string;
93
+ status: number;
94
+ }): void {
95
+ _logger?.debug('middleware short-circuited', withTraceContext(data));
96
+ }
97
+
98
+ /** Log unhandled error in middleware phase. Level: error. */
99
+ export function logMiddlewareError(data: { method: string; path: string; error: unknown }): void {
100
+ if (_logger) {
101
+ _logger.error('unhandled error in middleware phase', withTraceContext(data));
102
+ } else if (process.env.NODE_ENV !== 'production') {
103
+ console.error('[timber] middleware error', data.error);
104
+ }
105
+ }
106
+
107
+ /** Log unhandled render-phase error. Level: error. */
108
+ export function logRenderError(data: { method: string; path: string; error: unknown }): void {
109
+ if (_logger) {
110
+ _logger.error('unhandled render-phase error', withTraceContext(data));
111
+ } else if (process.env.NODE_ENV !== 'production') {
112
+ // No logger configured — fall back to console.error in dev with
113
+ // cleaned-up error messages (vendor paths rewritten, hints added).
114
+ console.error('[timber] render error:', formatSsrError(data.error));
115
+ }
116
+ }
117
+
118
+ /** Log proxy.ts uncaught error. Level: error. */
119
+ export function logProxyError(data: { error: unknown }): void {
120
+ if (_logger) {
121
+ _logger.error('proxy.ts threw uncaught error', withTraceContext(data));
122
+ } else if (process.env.NODE_ENV !== 'production') {
123
+ console.error('[timber] proxy error', data.error);
124
+ }
125
+ }
126
+
127
+ /** Log waitUntil() adapter missing (once at startup). Level: warn. */
128
+ export function logWaitUntilUnsupported(): void {
129
+ _logger?.warn('adapter does not support waitUntil()');
130
+ }
131
+
132
+ /** Log waitUntil() promise rejection. Level: warn. */
133
+ export function logWaitUntilRejected(data: { error: unknown }): void {
134
+ _logger?.warn('waitUntil() promise rejected', withTraceContext(data));
135
+ }
136
+
137
+ /** Log staleWhileRevalidate refetch failure. Level: warn. */
138
+ export function logSwrRefetchFailed(data: { cacheKey: string; error: unknown }): void {
139
+ _logger?.warn('staleWhileRevalidate refetch failed', withTraceContext(data));
140
+ }
141
+
142
+ /** Log cache miss. Level: debug. */
143
+ export function logCacheMiss(data: { cacheKey: string }): void {
144
+ _logger?.debug('timber.cache MISS', withTraceContext(data));
145
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Manifest-compatible status-code file resolver.
3
+ *
4
+ * The existing status-code-resolver.ts works with SegmentNode (Map-based).
5
+ * This module works with ManifestSegmentNode (object-based) for use at
6
+ * runtime in the RSC/SSR entries, where the route manifest provides
7
+ * plain objects instead of Maps.
8
+ *
9
+ * Supports two format families:
10
+ * - 'component' (default): .tsx/.jsx/.mdx status files → React rendering pipeline
11
+ * - 'json': .json status files → raw JSON response, no React
12
+ *
13
+ * Follows the same fallback chains as status-code-resolver.ts:
14
+ *
15
+ * **Component chain (4xx):**
16
+ * Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
17
+ * Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
18
+ * Pass 3 — error.tsx (leaf → root)
19
+ * Pass 4 — framework default (returns null)
20
+ *
21
+ * **JSON chain (4xx):**
22
+ * Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
23
+ * Pass 2 — framework default JSON (returns null, caller provides bare JSON)
24
+ *
25
+ * See design/10-error-handling.md §"Status-Code Files"
26
+ */
27
+
28
+ import type { ManifestSegmentNode } from './route-matcher.js';
29
+
30
+ // ─── Types ───────────────────────────────────────────────────────────────────
31
+
32
+ /** A file reference in the manifest (lazy import + path). */
33
+ interface ManifestFile {
34
+ load: () => Promise<unknown>;
35
+ filePath: string;
36
+ }
37
+
38
+ /** How the status-code file was matched. */
39
+ export type ManifestStatusFileKind =
40
+ | 'exact' // e.g. 403.tsx matched status 403
41
+ | 'category' // e.g. 4xx.tsx matched status 403
42
+ | 'legacy' // e.g. not-found.tsx matched status 404
43
+ | 'error'; // error.tsx as last resort
44
+
45
+ /** Response format family for status-code resolution. */
46
+ export type ManifestStatusFileFormat = 'component' | 'json';
47
+
48
+ /** Result of resolving a status-code file from manifest segments. */
49
+ export interface ManifestStatusFileResolution {
50
+ /** The matched manifest file (has load() and filePath). */
51
+ file: ManifestFile;
52
+ /** The HTTP status code (always the original status, not the file's code). */
53
+ status: number;
54
+ /** How the file was matched. */
55
+ kind: ManifestStatusFileKind;
56
+ /** Index into the segments array where the file was found. */
57
+ segmentIndex: number;
58
+ }
59
+
60
+ // ─── Legacy Compat Mapping ───────────────────────────────────────────────────
61
+
62
+ const LEGACY_FILE_TO_STATUS: Record<string, number> = {
63
+ 'not-found': 404,
64
+ 'forbidden': 403,
65
+ 'unauthorized': 401,
66
+ };
67
+
68
+ // ─── Resolver ────────────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Resolve the status-code file to render for a given HTTP status code,
72
+ * using manifest segment nodes (plain objects, not Maps).
73
+ *
74
+ * @param status - The HTTP status code (4xx or 5xx).
75
+ * @param segments - The matched segment chain from root (index 0) to leaf (last).
76
+ * @param format - The response format family ('component' or 'json'). Defaults to 'component'.
77
+ */
78
+ export function resolveManifestStatusFile(
79
+ status: number,
80
+ segments: ReadonlyArray<ManifestSegmentNode>,
81
+ format: ManifestStatusFileFormat = 'component'
82
+ ): ManifestStatusFileResolution | null {
83
+ if (status < 400 || status > 599) {
84
+ return null;
85
+ }
86
+
87
+ if (format === 'json') {
88
+ return resolveJson(status, segments);
89
+ }
90
+
91
+ if (status <= 499) {
92
+ return resolve4xx(status, segments);
93
+ }
94
+
95
+ return resolve5xx(status, segments);
96
+ }
97
+
98
+ /**
99
+ * 4xx component fallback chain (three separate passes):
100
+ * Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
101
+ * Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
102
+ * Pass 3 — error.tsx (leaf → root)
103
+ */
104
+ function resolve4xx(
105
+ status: number,
106
+ segments: ReadonlyArray<ManifestSegmentNode>
107
+ ): ManifestStatusFileResolution | null {
108
+ const statusStr = String(status);
109
+
110
+ // Pass 1: status files across all segments (leaf → root)
111
+ for (let i = segments.length - 1; i >= 0; i--) {
112
+ const segment = segments[i];
113
+ if (!segment.statusFiles) continue;
114
+
115
+ // Exact match first
116
+ const exact = segment.statusFiles[statusStr];
117
+ if (exact) {
118
+ return { file: exact, status, kind: 'exact', segmentIndex: i };
119
+ }
120
+
121
+ // Category catch-all
122
+ const category = segment.statusFiles['4xx'];
123
+ if (category) {
124
+ return { file: category, status, kind: 'category', segmentIndex: i };
125
+ }
126
+ }
127
+
128
+ // Pass 2: legacy compat files (leaf → root)
129
+ for (let i = segments.length - 1; i >= 0; i--) {
130
+ const segment = segments[i];
131
+ if (!segment.legacyStatusFiles) continue;
132
+
133
+ for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) {
134
+ if (legacyStatus === status) {
135
+ const file = segment.legacyStatusFiles[name];
136
+ if (file) {
137
+ return { file, status, kind: 'legacy', segmentIndex: i };
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ // Pass 3: error.tsx (leaf → root)
144
+ for (let i = segments.length - 1; i >= 0; i--) {
145
+ if (segments[i].error) {
146
+ return { file: segments[i].error!, status, kind: 'error', segmentIndex: i };
147
+ }
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ /**
154
+ * 5xx component fallback chain (single pass, per-segment):
155
+ * At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
156
+ */
157
+ function resolve5xx(
158
+ status: number,
159
+ segments: ReadonlyArray<ManifestSegmentNode>
160
+ ): ManifestStatusFileResolution | null {
161
+ const statusStr = String(status);
162
+
163
+ for (let i = segments.length - 1; i >= 0; i--) {
164
+ const segment = segments[i];
165
+
166
+ if (segment.statusFiles) {
167
+ const exact = segment.statusFiles[statusStr];
168
+ if (exact) {
169
+ return { file: exact, status, kind: 'exact', segmentIndex: i };
170
+ }
171
+
172
+ const categoryKey = '5xx';
173
+ const category = segment.statusFiles[categoryKey];
174
+ if (category) {
175
+ return { file: category, status, kind: 'category', segmentIndex: i };
176
+ }
177
+ }
178
+
179
+ if (segment.error) {
180
+ return { file: segment.error, status, kind: 'error', segmentIndex: i };
181
+ }
182
+ }
183
+
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * JSON fallback chain (for both 4xx and 5xx):
189
+ * At each segment (leaf → root): {status}.json → {category}.json
190
+ * No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
191
+ */
192
+ function resolveJson(
193
+ status: number,
194
+ segments: ReadonlyArray<ManifestSegmentNode>
195
+ ): ManifestStatusFileResolution | null {
196
+ const statusStr = String(status);
197
+ const categoryKey = status >= 500 ? '5xx' : '4xx';
198
+
199
+ for (let i = segments.length - 1; i >= 0; i--) {
200
+ const segment = segments[i];
201
+ if (!segment.jsonStatusFiles) continue;
202
+
203
+ const exact = segment.jsonStatusFiles[statusStr];
204
+ if (exact) {
205
+ return { file: exact, status, kind: 'exact', segmentIndex: i };
206
+ }
207
+
208
+ const category = segment.jsonStatusFiles[categoryKey];
209
+ if (category) {
210
+ return { file: category, status, kind: 'category', segmentIndex: i };
211
+ }
212
+ }
213
+
214
+ return null;
215
+ }