@timber-js/app 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +43 -58
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Dev-mode warnings for common timber.js misuse patterns.
3
+ *
4
+ * These fire in development only and are stripped from production builds.
5
+ * Each warning targets a specific misuse identified during design review.
6
+ *
7
+ * Warnings are deduplicated by warningId:filePath:line so the same warning
8
+ * is only emitted once per dev session (per unique source location).
9
+ *
10
+ * Warnings are written to stderr and, when a Vite dev server is available,
11
+ * forwarded to the browser console via Vite's WebSocket.
12
+ *
13
+ * See design/21-dev-server.md §"Dev-Mode Warnings"
14
+ * See design/11-platform.md §"Dev Mode"
15
+ */
16
+
17
+ import type { ViteDevServer } from 'vite';
18
+
19
+ // ─── Warning IDs ───────────────────────────────────────────────────────────
20
+
21
+ export const WarningId = {
22
+ SUSPENSE_WRAPS_CHILDREN: 'SUSPENSE_WRAPS_CHILDREN',
23
+ DENY_IN_SUSPENSE: 'DENY_IN_SUSPENSE',
24
+ REDIRECT_IN_SUSPENSE: 'REDIRECT_IN_SUSPENSE',
25
+ REDIRECT_IN_ACCESS: 'REDIRECT_IN_ACCESS',
26
+ STATIC_REQUEST_API: 'STATIC_REQUEST_API',
27
+ CACHE_REQUEST_PROPS: 'CACHE_REQUEST_PROPS',
28
+ SLOW_SLOT_NO_SUSPENSE: 'SLOW_SLOT_NO_SUSPENSE',
29
+ } as const;
30
+
31
+ export type WarningId = (typeof WarningId)[keyof typeof WarningId];
32
+
33
+ // ─── Configuration ──────────────────────────────────────────────────────────
34
+
35
+ /** Configuration for dev warning behavior. */
36
+ export interface DevWarningConfig {
37
+ /** Threshold in ms for "slow slot" warnings. Default: 200. */
38
+ slowSlotThresholdMs?: number;
39
+ }
40
+
41
+ // ─── Deduplication & Server ─────────────────────────────────────────────────
42
+
43
+ const _emitted = new Set<string>();
44
+
45
+ /** Vite dev server for forwarding warnings to browser console. */
46
+ let _viteServer: ViteDevServer | null = null;
47
+
48
+ /**
49
+ * Register the Vite dev server for browser console forwarding.
50
+ * Called by timber-dev-server during configureServer.
51
+ */
52
+ export function setViteServer(server: ViteDevServer | null): void {
53
+ _viteServer = server;
54
+ }
55
+
56
+ function isDev(): boolean {
57
+ return process.env.NODE_ENV !== 'production';
58
+ }
59
+
60
+ /**
61
+ * Emit a warning only once per dedup key.
62
+ *
63
+ * Writes to stderr and forwards to browser console via Vite WebSocket.
64
+ * Returns true if emitted (not deduplicated).
65
+ */
66
+ function emitOnce(
67
+ warningId: WarningId,
68
+ location: string,
69
+ level: 'warn' | 'error',
70
+ message: string
71
+ ): boolean {
72
+ if (!isDev()) return false;
73
+
74
+ const dedupKey = `${warningId}:${location}`;
75
+ if (_emitted.has(dedupKey)) return false;
76
+ _emitted.add(dedupKey);
77
+
78
+ // Write to stderr
79
+ const prefix = level === 'error' ? '\x1b[31m[timber]\x1b[0m' : '\x1b[33m[timber]\x1b[0m';
80
+ process.stderr.write(`${prefix} ${message}\n`);
81
+
82
+ // Forward to browser console via Vite WebSocket
83
+ if (_viteServer?.hot) {
84
+ _viteServer.hot.send('timber:dev-warning', {
85
+ warningId,
86
+ level,
87
+ message: `[timber] ${message}`,
88
+ });
89
+ }
90
+
91
+ return true;
92
+ }
93
+
94
+ // ─── Warning Functions ──────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Warn when a layout wraps {children} in <Suspense>.
98
+ *
99
+ * This defers the page content — the primary resource — behind a fallback.
100
+ * The page's data fetches won't affect the HTTP status code because they
101
+ * resolve after onShellReady. If the page calls deny(404), the status code
102
+ * is already committed as 200.
103
+ *
104
+ * @param layoutFile - Relative path to the layout file (e.g., "app/(dashboard)/layout.tsx")
105
+ */
106
+ export function warnSuspenseWrappingChildren(layoutFile: string): void {
107
+ emitOnce(
108
+ WarningId.SUSPENSE_WRAPS_CHILDREN,
109
+ layoutFile,
110
+ 'warn',
111
+ `Layout at ${layoutFile} wraps {children} in <Suspense>. ` +
112
+ 'This prevents child pages from setting HTTP status codes. ' +
113
+ 'Use useNavigationPending() for loading states instead.'
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Warn when deny() is called inside a Suspense boundary.
119
+ *
120
+ * After the shell has flushed and the status code is committed, deny()
121
+ * cannot change the HTTP response. The signal will be caught by the nearest
122
+ * error boundary instead of producing a correct status code.
123
+ *
124
+ * @param file - Relative path to the file
125
+ * @param line - Line number where deny() was called
126
+ */
127
+ export function warnDenyInSuspense(file: string, line?: number): void {
128
+ const location = line ? `${file}:${line}` : file;
129
+ emitOnce(
130
+ WarningId.DENY_IN_SUSPENSE,
131
+ location,
132
+ 'error',
133
+ `deny() called inside <Suspense> at ${location}. ` +
134
+ 'The HTTP status is already committed — this will trigger an error boundary with a 200 status. ' +
135
+ 'Move deny() outside <Suspense> for correct HTTP semantics.'
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Warn when redirect() is called inside a Suspense boundary.
141
+ *
142
+ * This will perform a client-side navigation instead of an HTTP redirect.
143
+ *
144
+ * @param file - Relative path to the file
145
+ * @param line - Line number where redirect() was called
146
+ */
147
+ export function warnRedirectInSuspense(file: string, line?: number): void {
148
+ const location = line ? `${file}:${line}` : file;
149
+ emitOnce(
150
+ WarningId.REDIRECT_IN_SUSPENSE,
151
+ location,
152
+ 'error',
153
+ `redirect() called inside <Suspense> at ${location}. ` +
154
+ 'This will perform a client-side navigation instead of an HTTP redirect.'
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Warn when redirect() is called in a slot's access.ts.
160
+ *
161
+ * Slots use deny() for graceful degradation. Redirecting from a slot would
162
+ * redirect the entire page, breaking the contract that slot failure is
163
+ * isolated to the slot.
164
+ *
165
+ * @param accessFile - Relative path to the access.ts file
166
+ * @param line - Line number where redirect() was called
167
+ */
168
+ export function warnRedirectInAccess(accessFile: string, line?: number): void {
169
+ const location = line ? `${accessFile}:${line}` : accessFile;
170
+ emitOnce(
171
+ WarningId.REDIRECT_IN_ACCESS,
172
+ location,
173
+ 'error',
174
+ `redirect() called in access.ts at ${location}. ` +
175
+ 'Only deny() is valid in slot access checks. ' +
176
+ 'Use deny() to block access or move redirect() to middleware.ts.'
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Warn when cookies() or headers() is called during a static build.
182
+ *
183
+ * In output: 'static' mode, there is no per-request context — these APIs
184
+ * read build-time values only. This is almost always a mistake.
185
+ *
186
+ * @param api - The dynamic API name ("cookies" or "headers")
187
+ * @param file - Relative path to the file calling the API
188
+ */
189
+ export function warnStaticRequestApi(api: 'cookies' | 'headers', file: string): void {
190
+ emitOnce(
191
+ WarningId.STATIC_REQUEST_API,
192
+ `${api}:${file}`,
193
+ 'error',
194
+ `${api}() called during static generation of ${file}. ` +
195
+ 'Dynamic request APIs are not available during prerendering.'
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Warn when a "use cache" component receives request-specific props.
201
+ *
202
+ * Cached components should not depend on per-request data — a userId or
203
+ * sessionId in the props means the cache will either be ineffective
204
+ * (key per user) or dangerous (serve one user's data to another).
205
+ *
206
+ * @param componentName - Name of the cached component
207
+ * @param propName - Name of the suspicious prop
208
+ * @param file - Relative path to the component file
209
+ * @param line - Line number
210
+ */
211
+ export function warnCacheRequestProps(
212
+ componentName: string,
213
+ propName: string,
214
+ file: string,
215
+ line?: number
216
+ ): void {
217
+ const location = line ? `${file}:${line}` : file;
218
+ emitOnce(
219
+ WarningId.CACHE_REQUEST_PROPS,
220
+ `${componentName}:${propName}:${location}`,
221
+ 'warn',
222
+ `Cached component ${componentName} receives prop "${propName}" which appears request-specific. ` +
223
+ 'Cached components should not depend on per-request data.'
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Warn when a parallel slot resolves slowly without a <Suspense> wrapper.
229
+ *
230
+ * A slow slot without Suspense blocks onShellReady — and therefore the
231
+ * status code commit — for the entire page. Wrapping it in <Suspense>
232
+ * lets the shell flush without waiting for the slot.
233
+ *
234
+ * @param slotName - The slot name (e.g., "@admin")
235
+ * @param durationMs - How long the slot took to resolve
236
+ */
237
+ export function warnSlowSlotWithoutSuspense(slotName: string, durationMs: number): void {
238
+ emitOnce(
239
+ WarningId.SLOW_SLOT_NO_SUSPENSE,
240
+ slotName,
241
+ 'warn',
242
+ `Slot ${slotName} resolved in ${durationMs}ms and is not wrapped in <Suspense>. ` +
243
+ 'Consider wrapping to avoid blocking the flush.'
244
+ );
245
+ }
246
+
247
+ // ─── Legacy aliases ─────────────────────────────────────────────────────────
248
+
249
+ /** @deprecated Use warnStaticRequestApi instead */
250
+ export const warnDynamicApiInStaticBuild = warnStaticRequestApi;
251
+
252
+ /** @deprecated Use warnRedirectInAccess instead */
253
+ export function warnRedirectInSlotAccess(slotName: string): void {
254
+ warnRedirectInAccess(`${slotName}/access.ts`);
255
+ }
256
+
257
+ /** @deprecated Use warnDenyInSuspense / warnRedirectInSuspense instead */
258
+ export function warnDenyAfterFlush(signal: 'deny' | 'redirect'): void {
259
+ if (signal === 'deny') {
260
+ warnDenyInSuspense('unknown');
261
+ } else {
262
+ warnRedirectInSuspense('unknown');
263
+ }
264
+ }
265
+
266
+ // ─── Testing ────────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Reset emitted warnings. For testing only.
270
+ * @internal
271
+ */
272
+ export function _resetWarnings(): void {
273
+ _emitted.clear();
274
+ }
275
+
276
+ /**
277
+ * Get the set of emitted dedup keys. For testing only.
278
+ * @internal
279
+ */
280
+ export function _getEmitted(): ReadonlySet<string> {
281
+ return _emitted;
282
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Per-request 103 Early Hints sender — ALS bridge for platform adapters.
3
+ *
4
+ * The pipeline collects Link headers for CSS, fonts, and JS chunks at
5
+ * route-match time. On platforms that support it (Node.js v18.11+, Bun),
6
+ * the adapter can send these as a 103 Early Hints interim response before
7
+ * the final response is ready.
8
+ *
9
+ * This module provides an ALS-based bridge: the generated entry point
10
+ * (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
11
+ * binding a per-request sender function. The pipeline calls
12
+ * `sendEarlyHints103()` to fire the 103 if a sender is available.
13
+ *
14
+ * On platforms where 103 is handled at the CDN level (e.g., Cloudflare
15
+ * converts Link headers into 103 automatically), no sender is installed
16
+ * and `sendEarlyHints103()` is a no-op.
17
+ *
18
+ * Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
19
+ */
20
+
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
22
+
23
+ /** Function that sends Link header values as a 103 Early Hints response. */
24
+ export type EarlyHintsSenderFn = (links: string[]) => void;
25
+
26
+ const earlyHintsSenderAls = new AsyncLocalStorage<EarlyHintsSenderFn>();
27
+
28
+ /**
29
+ * Run a function with a per-request early hints sender installed.
30
+ *
31
+ * Called by generated entry points (e.g., Nitro node-server/bun) to
32
+ * bind the platform's writeEarlyHints capability for the request duration.
33
+ */
34
+ export function runWithEarlyHintsSender<T>(sender: EarlyHintsSenderFn, fn: () => T): T {
35
+ return earlyHintsSenderAls.run(sender, fn);
36
+ }
37
+
38
+ /**
39
+ * Send collected Link headers as a 103 Early Hints response.
40
+ *
41
+ * No-op if no sender is installed for the current request (e.g., on
42
+ * Cloudflare where the CDN handles 103 automatically, or in dev mode).
43
+ *
44
+ * Non-fatal: errors from the sender are caught and silently ignored.
45
+ */
46
+ export function sendEarlyHints103(links: string[]): void {
47
+ if (!links.length) return;
48
+ const sender = earlyHintsSenderAls.getStore();
49
+ if (!sender) return;
50
+ try {
51
+ sender(links);
52
+ } catch {
53
+ // Sending 103 is best-effort — failure never blocks the request.
54
+ }
55
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * 103 Early Hints utilities.
3
+ *
4
+ * Early Hints are sent before the final response to let the browser
5
+ * start fetching critical resources (CSS, fonts, JS) while the server
6
+ * is still rendering.
7
+ *
8
+ * The framework collects hints from two sources:
9
+ * 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
10
+ * 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
11
+ *
12
+ * Both are emitted as Link headers. Cloudflare CDN automatically converts
13
+ * Link headers into 103 Early Hints responses.
14
+ *
15
+ * Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
16
+ */
17
+
18
+ import {
19
+ collectRouteCss,
20
+ collectRouteFonts,
21
+ collectRouteModulepreloads,
22
+ } from './build-manifest.js';
23
+ import type { BuildManifest } from './build-manifest.js';
24
+
25
+ /** Minimal segment shape needed for early hint collection. */
26
+ interface SegmentWithFiles {
27
+ layout?: { filePath: string };
28
+ page?: { filePath: string };
29
+ }
30
+
31
+ // ─── EarlyHint type ───────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * A single Link header hint for 103 Early Hints.
35
+ *
36
+ * ```ts
37
+ * ctx.earlyHints([
38
+ * { href: '/styles/critical.css', rel: 'preload', as: 'style' },
39
+ * { href: 'https://fonts.googleapis.com', rel: 'preconnect' },
40
+ * ])
41
+ * ```
42
+ */
43
+ export interface EarlyHint {
44
+ /** The resource URL (absolute or root-relative). */
45
+ href: string;
46
+ /** Link relation — `preload`, `modulepreload`, or `preconnect`. */
47
+ rel: 'preload' | 'modulepreload' | 'preconnect';
48
+ /** Resource type for `preload` hints (omit for `modulepreload` / `preconnect`). */
49
+ as?: 'style' | 'script' | 'font' | 'image' | 'fetch' | 'document';
50
+ /** Crossorigin attribute — required for font preloads per spec. */
51
+ crossOrigin?: 'anonymous' | 'use-credentials';
52
+ /** Fetch priority hint — `high`, `low`, or `auto`. */
53
+ fetchPriority?: 'high' | 'low' | 'auto';
54
+ }
55
+
56
+ // ─── formatLinkHeader ─────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Format a single EarlyHint as a Link header value.
60
+ *
61
+ * Examples:
62
+ * `</styles/root.css>; rel=preload; as=style`
63
+ * `</fonts/inter.woff2>; rel=preload; as=font; crossorigin=anonymous`
64
+ * `</_timber/client.js>; rel=modulepreload`
65
+ * `<https://fonts.googleapis.com>; rel=preconnect`
66
+ */
67
+ export function formatLinkHeader(hint: EarlyHint): string {
68
+ let value = `<${hint.href}>; rel=${hint.rel}`;
69
+ if (hint.as !== undefined) value += `; as=${hint.as}`;
70
+ if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
71
+ if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
72
+ return value;
73
+ }
74
+
75
+ // ─── collectEarlyHintHeaders ──────────────────────────────────────────────
76
+
77
+ /** Options for early hint collection. */
78
+ export interface EarlyHintOptions {
79
+ /** Skip JS modulepreload hints (e.g. when client JavaScript is disabled). */
80
+ skipJs?: boolean;
81
+ }
82
+
83
+ /**
84
+ * Collect all Link header strings for a matched route's segment chain.
85
+ *
86
+ * Walks the build manifest to emit hints for:
87
+ * - CSS stylesheets (rel=preload; as=style)
88
+ * - Font assets (rel=preload; as=font; crossorigin)
89
+ * - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
90
+ *
91
+ * Also emits global CSS from the `_global` manifest key. Route files
92
+ * are server components that don't appear in the client bundle, so
93
+ * per-route CSS keying doesn't work with the RSC plugin. The `_global`
94
+ * key contains all CSS assets from the client build — fine for early
95
+ * hints since they're just prefetch signals.
96
+ *
97
+ * Returns formatted Link header strings, deduplicated, root → leaf order.
98
+ * Returns an empty array in dev mode (manifest is empty).
99
+ */
100
+ export function collectEarlyHintHeaders(
101
+ segments: SegmentWithFiles[],
102
+ manifest: BuildManifest,
103
+ options?: EarlyHintOptions
104
+ ): string[] {
105
+ const result: string[] = [];
106
+ const seen = new Set<string>();
107
+
108
+ const add = (header: string) => {
109
+ if (!seen.has(header)) {
110
+ seen.add(header);
111
+ result.push(header);
112
+ }
113
+ };
114
+
115
+ // Per-route CSS — rel=preload; as=style
116
+ for (const url of collectRouteCss(segments, manifest)) {
117
+ add(formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
118
+ }
119
+
120
+ // Global CSS — all CSS assets from the client bundle.
121
+ // Covers CSS that the RSC plugin injects via data-rsc-css-href,
122
+ // which isn't keyed to route segments in our manifest.
123
+ for (const url of manifest.css['_global'] ?? []) {
124
+ add(formatLinkHeader({ href: url, rel: 'preload', as: 'style' }));
125
+ }
126
+
127
+ // Fonts — rel=preload; as=font; crossorigin (crossorigin required per spec)
128
+ for (const font of collectRouteFonts(segments, manifest)) {
129
+ add(
130
+ formatLinkHeader({ href: font.href, rel: 'preload', as: 'font', crossOrigin: 'anonymous' })
131
+ );
132
+ }
133
+
134
+ // JS chunks — rel=modulepreload (skip when client JS is disabled)
135
+ if (!options?.skipJs) {
136
+ for (const url of collectRouteModulepreloads(segments, manifest)) {
137
+ add(formatLinkHeader({ href: url, rel: 'modulepreload' }));
138
+ }
139
+ }
140
+
141
+ return result;
142
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Error boundary wrapper — wraps a React element in error boundaries from a route segment.
3
+ *
4
+ * Extracted to allow reuse by both rsc-entry.ts and route-element-builder.ts.
5
+ * See design/10-error-handling.md.
6
+ */
7
+
8
+ import { TimberErrorBoundary } from '#/client/error-boundary.js';
9
+ import type { ManifestSegmentNode } from './route-matcher.js';
10
+
11
+ /**
12
+ * Wrap an element in error boundaries defined by a route segment.
13
+ *
14
+ * Processing order (innermost to outermost):
15
+ * 1. Specific status files (e.g., 404.tsx, 500.tsx) — highest priority at runtime
16
+ * 2. Category catch-alls (4xx.tsx, 5xx.tsx)
17
+ * 3. error.tsx — catches anything not matched by status files
18
+ */
19
+ export async function wrapSegmentWithErrorBoundaries(
20
+ segment: ManifestSegmentNode,
21
+ element: React.ReactElement,
22
+ h: (...args: unknown[]) => React.ReactElement
23
+ ): Promise<React.ReactElement> {
24
+ // Specific status files (innermost — highest priority at runtime)
25
+ if (segment.statusFiles) {
26
+ for (const [key, file] of Object.entries(segment.statusFiles)) {
27
+ if (key !== '4xx' && key !== '5xx') {
28
+ const status = parseInt(key, 10);
29
+ if (!isNaN(status)) {
30
+ const mod = (await file.load()) as Record<string, unknown>;
31
+ if (mod.default) {
32
+ element = h(TimberErrorBoundary, {
33
+ fallbackComponent: mod.default,
34
+ status,
35
+ children: element,
36
+ });
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ // Category catch-alls (4xx.tsx, 5xx.tsx)
43
+ for (const [key, file] of Object.entries(segment.statusFiles)) {
44
+ if (key === '4xx' || key === '5xx') {
45
+ const mod = (await file.load()) as Record<string, unknown>;
46
+ if (mod.default) {
47
+ element = h(TimberErrorBoundary, {
48
+ fallbackComponent: mod.default,
49
+ status: key === '4xx' ? 400 : 500,
50
+ children: element,
51
+ });
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ // error.tsx (outermost — catches anything not matched by status files)
58
+ if (segment.error) {
59
+ const mod = (await segment.error.load()) as Record<string, unknown>;
60
+ if (mod.default) {
61
+ element = h(TimberErrorBoundary, {
62
+ fallbackComponent: mod.default,
63
+ children: element,
64
+ });
65
+ }
66
+ }
67
+
68
+ return element;
69
+ }