@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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +5 -4
- 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 +420 -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 +391 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +214 -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,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
|
+
}
|