@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,555 @@
1
+ /**
2
+ * Request pipeline — the central dispatch for all timber.js requests.
3
+ *
4
+ * Pipeline stages (in order):
5
+ * proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
6
+ *
7
+ * Each stage is a pure function or returns a Response to short-circuit.
8
+ * Each request gets a trace ID, structured logging, and OTEL spans.
9
+ *
10
+ * See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
11
+ * and design/17-logging.md §"Production Logging"
12
+ */
13
+
14
+ import { canonicalize } from './canonicalize.js';
15
+ import { runProxy, type ProxyExport } from './proxy.js';
16
+ import { runMiddleware, type MiddlewareFn } from './middleware-runner.js';
17
+ import {
18
+ runWithRequestContext,
19
+ applyRequestHeaderOverlay,
20
+ setMutableCookieContext,
21
+ getSetCookieHeaders,
22
+ markResponseFlushed,
23
+ } from './request-context.js';
24
+ import {
25
+ generateTraceId,
26
+ runWithTraceId,
27
+ getOtelTraceId,
28
+ replaceTraceId,
29
+ withSpan,
30
+ setSpanAttribute,
31
+ traceId,
32
+ } from './tracing.js';
33
+ import {
34
+ logRequestReceived,
35
+ logRequestCompleted,
36
+ logSlowRequest,
37
+ logProxyError,
38
+ logMiddlewareError,
39
+ logMiddlewareShortCircuit,
40
+ logRenderError,
41
+ } from './logger.js';
42
+ import { callOnRequestError } from './instrumentation.js';
43
+ import { RedirectSignal, DenySignal } from './primitives.js';
44
+ import type { MiddlewareContext } from './types.js';
45
+ import type { SegmentNode } from '#/routing/types.js';
46
+
47
+ // ─── Route Match Result ────────────────────────────────────────────────────
48
+
49
+ /** Result of matching a canonical pathname against the route tree. */
50
+ export interface RouteMatch {
51
+ /** The matched segment chain from root to leaf. */
52
+ segments: SegmentNode[];
53
+ /** Extracted route params (catch-all segments produce string[]). */
54
+ params: Record<string, string | string[]>;
55
+ /** The leaf segment's middleware.ts export, if any. */
56
+ middleware?: MiddlewareFn;
57
+ }
58
+
59
+ /** Function that matches a canonical pathname to a route. */
60
+ export type RouteMatcher = (pathname: string) => RouteMatch | null;
61
+
62
+ /** Function that matches a canonical pathname to a metadata route. */
63
+ export type MetadataRouteMatcher = (
64
+ pathname: string
65
+ ) => import('./route-matcher.js').MetadataRouteMatch | null;
66
+
67
+ /** Context for intercepting route resolution (modal pattern). */
68
+ export interface InterceptionContext {
69
+ /** The URL the user is navigating TO (the intercepted route). */
70
+ targetPathname: string;
71
+ }
72
+
73
+ /** Function that renders a matched route into a Response. */
74
+ export type RouteRenderer = (
75
+ req: Request,
76
+ match: RouteMatch,
77
+ responseHeaders: Headers,
78
+ requestHeaderOverlay: Headers,
79
+ interception?: InterceptionContext
80
+ ) => Response | Promise<Response>;
81
+
82
+ /** Function that sends 103 Early Hints for a matched route. */
83
+ export type EarlyHintsEmitter = (
84
+ match: RouteMatch,
85
+ req: Request,
86
+ responseHeaders: Headers
87
+ ) => void | Promise<void>;
88
+
89
+ // ─── Pipeline Configuration ────────────────────────────────────────────────
90
+
91
+ export interface PipelineConfig {
92
+ /** The proxy.ts default export (function or array). Undefined if no proxy.ts. */
93
+ proxy?: ProxyExport;
94
+ /** Lazy loader for proxy.ts — called per-request so HMR updates take effect. */
95
+ proxyLoader?: () => Promise<{ default: ProxyExport }>;
96
+ /** Route matcher — resolves a canonical pathname to a RouteMatch. */
97
+ matchRoute: RouteMatcher;
98
+ /** Metadata route matcher — resolves metadata route pathnames (sitemap.xml, robots.txt, etc.) */
99
+ matchMetadataRoute?: MetadataRouteMatcher;
100
+ /** Renderer — produces the final Response for a matched route. */
101
+ render: RouteRenderer;
102
+ /** Renderer for no-match 404 — renders 404.tsx in root layout. */
103
+ renderNoMatch?: (req: Request, responseHeaders: Headers) => Response | Promise<Response>;
104
+ /** Early hints emitter — fires 103 hints after route match, before middleware. */
105
+ earlyHints?: EarlyHintsEmitter;
106
+ /** Whether to strip trailing slashes during canonicalization. Default: true. */
107
+ stripTrailingSlash?: boolean;
108
+ /** Slow request threshold in ms. Requests exceeding this emit a warning. 0 to disable. Default: 3000. */
109
+ slowRequestMs?: number;
110
+ /**
111
+ * Interception rewrites — conditional routes for the modal pattern.
112
+ * Generated at build time from intercepting route directories.
113
+ * See design/07-routing.md §"Intercepting Routes"
114
+ */
115
+ interceptionRewrites?: import('#/routing/interception.js').InterceptionRewrite[];
116
+ /**
117
+ * Dev pipeline error callback — called when a pipeline phase (proxy,
118
+ * middleware, render) catches an unhandled error. Used to wire the error
119
+ * into the Vite browser error overlay in dev mode.
120
+ *
121
+ * Undefined in production — zero overhead.
122
+ */
123
+ onPipelineError?: (error: Error, phase: string) => void;
124
+ }
125
+
126
+ // ─── Pipeline ──────────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Create the request handler from a pipeline configuration.
130
+ *
131
+ * Returns a function that processes an incoming Request through all pipeline stages
132
+ * and produces a Response. This is the top-level entry point for the server.
133
+ */
134
+ export function createPipeline(config: PipelineConfig): (req: Request) => Promise<Response> {
135
+ const {
136
+ proxy,
137
+ matchRoute,
138
+ render,
139
+ earlyHints,
140
+ stripTrailingSlash = true,
141
+ slowRequestMs = 3000,
142
+ onPipelineError,
143
+ } = config;
144
+
145
+ return async (req: Request): Promise<Response> => {
146
+ const url = new URL(req.url);
147
+ const method = req.method;
148
+ const path = url.pathname;
149
+ const startTime = performance.now();
150
+
151
+ // Establish per-request trace ID scope (design/17-logging.md §"trace_id is Always Set").
152
+ // This runs before runWithRequestContext so traceId() is available from the
153
+ // very first line of proxy.ts, middleware.ts, and all server code.
154
+ const traceIdValue = generateTraceId();
155
+
156
+ return runWithTraceId(traceIdValue, async () => {
157
+ // Establish request context ALS scope so headers() and cookies() work
158
+ // throughout the entire request lifecycle (proxy, middleware, render).
159
+ return runWithRequestContext(req, async () => {
160
+ logRequestReceived({ method, path });
161
+
162
+ const response = await withSpan(
163
+ 'http.server.request',
164
+ { 'http.request.method': method, 'url.path': path },
165
+ async () => {
166
+ // If OTEL is active, the root span now exists — replace the UUID
167
+ // fallback with the real OTEL trace ID for log–trace correlation.
168
+ const otelIds = await getOtelTraceId();
169
+ if (otelIds) {
170
+ replaceTraceId(otelIds.traceId, otelIds.spanId);
171
+ }
172
+
173
+ let result: Response;
174
+ if (proxy || config.proxyLoader) {
175
+ result = await runProxyPhase(req, method, path);
176
+ } else {
177
+ result = await handleRequest(req, method, path);
178
+ }
179
+
180
+ // Set response status on the root span before it ends —
181
+ // DevSpanProcessor reads this for tree/summary output.
182
+ await setSpanAttribute('http.response.status_code', result.status);
183
+ return result;
184
+ }
185
+ );
186
+
187
+ // Post-span: structured production logging
188
+ const durationMs = Math.round(performance.now() - startTime);
189
+ const status = response.status;
190
+ logRequestCompleted({ method, path, status, durationMs });
191
+
192
+ if (slowRequestMs > 0 && durationMs > slowRequestMs) {
193
+ logSlowRequest({ method, path, durationMs, threshold: slowRequestMs });
194
+ }
195
+
196
+ return response;
197
+ });
198
+ });
199
+ };
200
+
201
+ async function runProxyPhase(req: Request, method: string, path: string): Promise<Response> {
202
+ try {
203
+ // Resolve the proxy export. When a proxyLoader is provided (lazy import),
204
+ // it is called per-request so HMR updates in dev take effect immediately.
205
+ let proxyExport: ProxyExport;
206
+ if (config.proxyLoader) {
207
+ const mod = await config.proxyLoader();
208
+ proxyExport = mod.default;
209
+ } else {
210
+ proxyExport = config.proxy!;
211
+ }
212
+ return await withSpan('timber.proxy', {}, () =>
213
+ runProxy(proxyExport, req, () => handleRequest(req, method, path))
214
+ );
215
+ } catch (error) {
216
+ // Uncaught proxy.ts error → bare HTTP 500
217
+ logProxyError({ error });
218
+ await fireOnRequestError(error, req, 'proxy');
219
+ if (onPipelineError && error instanceof Error) onPipelineError(error, 'proxy');
220
+ return new Response(null, { status: 500 });
221
+ }
222
+ }
223
+
224
+ async function handleRequest(req: Request, method: string, path: string): Promise<Response> {
225
+ // Stage 1: URL canonicalization
226
+ const url = new URL(req.url);
227
+ const result = canonicalize(url.pathname, stripTrailingSlash);
228
+ if (!result.ok) {
229
+ return new Response(null, { status: result.status });
230
+ }
231
+ const canonicalPathname = result.pathname;
232
+
233
+ // Stage 1b: Metadata route matching — runs before regular route matching.
234
+ // Metadata routes skip middleware.ts and access.ts (public endpoints for crawlers).
235
+ // See design/16-metadata.md §"Pipeline Integration"
236
+ if (config.matchMetadataRoute) {
237
+ const metaMatch = config.matchMetadataRoute(canonicalPathname);
238
+ if (metaMatch) {
239
+ try {
240
+ const mod = (await metaMatch.file.load()) as { default?: Function };
241
+ if (typeof mod.default !== 'function') {
242
+ return new Response('Metadata route must export a default function', { status: 500 });
243
+ }
244
+ const handlerResult = await mod.default();
245
+ // If the handler returns a Response, use it directly
246
+ if (handlerResult instanceof Response) {
247
+ return handlerResult;
248
+ }
249
+ // Otherwise, serialize based on content type
250
+ const contentType = metaMatch.contentType;
251
+ let body: string;
252
+ if (typeof handlerResult === 'string') {
253
+ body = handlerResult;
254
+ } else if (contentType === 'application/xml') {
255
+ body = serializeSitemap(handlerResult);
256
+ } else if (contentType === 'application/manifest+json') {
257
+ body = JSON.stringify(handlerResult, null, 2);
258
+ } else {
259
+ body = typeof handlerResult === 'string' ? handlerResult : String(handlerResult);
260
+ }
261
+ return new Response(body, {
262
+ status: 200,
263
+ headers: { 'Content-Type': `${contentType}; charset=utf-8` },
264
+ });
265
+ } catch (error) {
266
+ logRenderError({ method, path, error });
267
+ if (onPipelineError && error instanceof Error) onPipelineError(error, 'metadata-route');
268
+ return new Response(null, { status: 500 });
269
+ }
270
+ }
271
+ }
272
+
273
+ // Stage 2: Route matching
274
+ let match = matchRoute(canonicalPathname);
275
+ let interception: InterceptionContext | undefined;
276
+
277
+ // Stage 2a: Intercepting route resolution (modal pattern).
278
+ // On soft navigation, check if an intercepting route should render instead.
279
+ // The client sends X-Timber-URL with the current pathname (where they're
280
+ // navigating FROM). If a rewrite matches, re-route to the source URL so
281
+ // the source layout renders with the intercepted content in the slot.
282
+ const sourceUrl = req.headers.get('X-Timber-URL');
283
+ if (sourceUrl && config.interceptionRewrites?.length) {
284
+ const intercepted = findInterceptionMatch(
285
+ canonicalPathname,
286
+ sourceUrl,
287
+ config.interceptionRewrites
288
+ );
289
+ if (intercepted) {
290
+ const sourceMatch = matchRoute(intercepted.sourcePathname);
291
+ if (sourceMatch) {
292
+ match = sourceMatch;
293
+ interception = { targetPathname: canonicalPathname };
294
+ }
295
+ }
296
+ }
297
+
298
+ if (!match) {
299
+ // No route matched — render 404.tsx in root layout if available,
300
+ // otherwise fall back to a bare 404 Response.
301
+ if (config.renderNoMatch) {
302
+ const responseHeaders = new Headers();
303
+ return config.renderNoMatch(req, responseHeaders);
304
+ }
305
+ return new Response(null, { status: 404 });
306
+ }
307
+
308
+ // Response and request header containers — created before early hints so
309
+ // the emitter can append Link headers (e.g. for Cloudflare CDN → 103).
310
+ const responseHeaders = new Headers();
311
+ const requestHeaderOverlay = new Headers();
312
+
313
+ // Stage 2b: 103 Early Hints (before middleware, after match)
314
+ // Fires before middleware so the browser can begin fetching critical
315
+ // assets while middleware runs. Non-fatal — a failing emitter never
316
+ // blocks the request.
317
+ if (earlyHints) {
318
+ try {
319
+ await earlyHints(match, req, responseHeaders);
320
+ } catch {
321
+ // Early hints failure is non-fatal
322
+ }
323
+ }
324
+
325
+ // Stage 3: Leaf middleware.ts (only the leaf route's middleware runs)
326
+ if (match.middleware) {
327
+ const ctx: MiddlewareContext = {
328
+ req,
329
+ requestHeaders: requestHeaderOverlay,
330
+ headers: responseHeaders,
331
+ params: match.params,
332
+ searchParams: new URL(req.url).searchParams,
333
+ earlyHints: (hints) => {
334
+ for (const hint of hints) {
335
+ let value = `<${hint.href}>; rel=${hint.rel}`;
336
+ if (hint.as !== undefined) value += `; as=${hint.as}`;
337
+ if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
338
+ if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
339
+ responseHeaders.append('Link', value);
340
+ }
341
+ },
342
+ };
343
+
344
+ try {
345
+ // Enable cookie mutation during middleware (design/29-cookies.md §"Context Tracking")
346
+ setMutableCookieContext(true);
347
+ const middlewareResponse = await withSpan('timber.middleware', {}, () =>
348
+ runMiddleware(match.middleware!, ctx)
349
+ );
350
+ setMutableCookieContext(false);
351
+ if (middlewareResponse) {
352
+ // Apply cookie jar to short-circuit response
353
+ applyCookieJar(middlewareResponse.headers);
354
+ logMiddlewareShortCircuit({ method, path, status: middlewareResponse.status });
355
+ return middlewareResponse;
356
+ }
357
+ // Middleware succeeded without short-circuiting — apply any
358
+ // injected request headers so headers() returns them downstream.
359
+ applyRequestHeaderOverlay(requestHeaderOverlay);
360
+ } catch (error) {
361
+ setMutableCookieContext(false);
362
+ // RedirectSignal from middleware → HTTP redirect (not an error).
363
+ // For RSC payload requests (client navigation), return 204 + X-Timber-Redirect
364
+ // so the client router can perform a soft SPA redirect. A raw 302 would be
365
+ // turned into an opaque redirect by fetch({redirect:'manual'}), crashing
366
+ // createFromFetch. See design/19-client-navigation.md.
367
+ if (error instanceof RedirectSignal) {
368
+ applyCookieJar(responseHeaders);
369
+ const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
370
+ if (isRsc) {
371
+ responseHeaders.set('X-Timber-Redirect', error.location);
372
+ return new Response(null, { status: 204, headers: responseHeaders });
373
+ }
374
+ responseHeaders.set('Location', error.location);
375
+ return new Response(null, { status: error.status, headers: responseHeaders });
376
+ }
377
+ // DenySignal from middleware → HTTP deny status
378
+ if (error instanceof DenySignal) {
379
+ return new Response(null, { status: error.status });
380
+ }
381
+ // Middleware throw → HTTP 500 (middleware runs before rendering,
382
+ // no error boundary to catch it)
383
+ logMiddlewareError({ method, path, error });
384
+ await fireOnRequestError(error, req, 'handler');
385
+ if (onPipelineError && error instanceof Error) onPipelineError(error, 'middleware');
386
+ return new Response(null, { status: 500 });
387
+ }
388
+ }
389
+
390
+ // Apply cookie jar to response headers before render commits them.
391
+ // Middleware may have set cookies; they need to be on responseHeaders
392
+ // before flushResponse creates the Response object.
393
+ applyCookieJar(responseHeaders);
394
+
395
+ // Stage 4: Render (access gates + element tree + renderToReadableStream)
396
+ try {
397
+ const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
398
+ render(req, match, responseHeaders, requestHeaderOverlay, interception)
399
+ );
400
+ markResponseFlushed();
401
+ return response;
402
+ } catch (error) {
403
+ logRenderError({ method, path, error });
404
+ await fireOnRequestError(error, req, 'render');
405
+ if (onPipelineError && error instanceof Error) onPipelineError(error, 'render');
406
+ return new Response(null, { status: 500 });
407
+ }
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Fire the user's onRequestError hook with request context.
413
+ * Extracts request info from the Request object and calls the instrumentation hook.
414
+ */
415
+ async function fireOnRequestError(
416
+ error: unknown,
417
+ req: Request,
418
+ phase: 'proxy' | 'handler' | 'render' | 'action' | 'route'
419
+ ): Promise<void> {
420
+ const url = new URL(req.url);
421
+ const headersObj: Record<string, string> = {};
422
+ req.headers.forEach((v, k) => {
423
+ headersObj[k] = v;
424
+ });
425
+
426
+ await callOnRequestError(
427
+ error,
428
+ { method: req.method, path: url.pathname, headers: headersObj },
429
+ { phase, routePath: url.pathname, routeType: 'page', traceId: traceId() }
430
+ );
431
+ }
432
+
433
+ // ─── Interception Matching ────────────────────────────────────────────────
434
+
435
+ interface InterceptionMatchResult {
436
+ /** The pathname to re-match (the source/intercepting route's parent). */
437
+ sourcePathname: string;
438
+ }
439
+
440
+ /**
441
+ * Check if an intercepting route applies for this soft navigation.
442
+ *
443
+ * Matches the target pathname against interception rewrites, constrained
444
+ * by the source URL (X-Timber-URL header — where the user navigates FROM).
445
+ *
446
+ * Returns the source pathname to re-match if interception applies, or null.
447
+ */
448
+ function findInterceptionMatch(
449
+ targetPathname: string,
450
+ sourceUrl: string,
451
+ rewrites: import('#/routing/interception.js').InterceptionRewrite[]
452
+ ): InterceptionMatchResult | null {
453
+ for (const rewrite of rewrites) {
454
+ // Check if the source URL starts with the intercepting prefix
455
+ if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
456
+
457
+ // Check if the target URL matches the intercepted pattern.
458
+ // Dynamic segments in the pattern match any single URL segment.
459
+ if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) {
460
+ return { sourcePathname: rewrite.interceptingPrefix };
461
+ }
462
+ }
463
+ return null;
464
+ }
465
+
466
+ /**
467
+ * Check if a pathname matches a URL pattern with dynamic segments.
468
+ *
469
+ * Supports [param] (single segment) and [...param] (one or more segments).
470
+ * Static segments must match exactly.
471
+ */
472
+ function pathnameMatchesPattern(pathname: string, pattern: string): boolean {
473
+ const pathParts = pathname === '/' ? [] : pathname.slice(1).split('/');
474
+ const patternParts = pattern === '/' ? [] : pattern.slice(1).split('/');
475
+
476
+ let pi = 0;
477
+ for (let i = 0; i < patternParts.length; i++) {
478
+ const segment = patternParts[i];
479
+
480
+ // Catch-all: [...param] or [[...param]] — matches rest of URL
481
+ if (segment.startsWith('[...') || segment.startsWith('[[...')) {
482
+ return pi < pathParts.length || segment.startsWith('[[...');
483
+ }
484
+
485
+ // Dynamic: [param] — matches any single segment
486
+ if (segment.startsWith('[') && segment.endsWith(']')) {
487
+ if (pi >= pathParts.length) return false;
488
+ pi++;
489
+ continue;
490
+ }
491
+
492
+ // Static — must match exactly
493
+ if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
494
+ pi++;
495
+ }
496
+
497
+ return pi === pathParts.length;
498
+ }
499
+
500
+ // ─── Cookie Helpers ──────────────────────────────────────────────────────
501
+
502
+ /**
503
+ * Apply all Set-Cookie headers from the cookie jar to a Headers object.
504
+ * Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
505
+ */
506
+ function applyCookieJar(headers: Headers): void {
507
+ for (const value of getSetCookieHeaders()) {
508
+ headers.append('Set-Cookie', value);
509
+ }
510
+ }
511
+
512
+ // ─── Metadata Route Helpers ──────────────────────────────────────────────
513
+
514
+ /**
515
+ * Serialize a sitemap array to XML.
516
+ * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
517
+ */
518
+ function serializeSitemap(
519
+ entries: Array<{
520
+ url: string;
521
+ lastModified?: string | Date;
522
+ changeFrequency?: string;
523
+ priority?: number;
524
+ }>
525
+ ): string {
526
+ const urls = entries
527
+ .map((e) => {
528
+ let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
529
+ if (e.lastModified) {
530
+ const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
531
+ xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
532
+ }
533
+ if (e.changeFrequency) {
534
+ xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
535
+ }
536
+ if (e.priority !== undefined) {
537
+ xml += `\n <priority>${e.priority}</priority>`;
538
+ }
539
+ xml += '\n </url>';
540
+ return xml;
541
+ })
542
+ .join('\n');
543
+
544
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`;
545
+ }
546
+
547
+ /** Escape special XML characters. */
548
+ function escapeXml(str: string): string {
549
+ return str
550
+ .replace(/&/g, '&amp;')
551
+ .replace(/</g, '&lt;')
552
+ .replace(/>/g, '&gt;')
553
+ .replace(/"/g, '&quot;')
554
+ .replace(/'/g, '&apos;');
555
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pre-rendering types and utilities.
3
+ *
4
+ * A `prerender.ts` file in a route segment signals the framework to
5
+ * pre-render the route's shell at build time. This module defines the
6
+ * types that a user exports from `prerender.ts` and utilities for
7
+ * loading and validating those exports.
8
+ *
9
+ * Design doc: design/15-future-prerendering.md
10
+ */
11
+
12
+ import { parseCacheLife } from '#/plugins/cache-transform.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types — user-facing exports from prerender.ts
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * The shape of a prerender.ts module's exports.
20
+ *
21
+ * ```ts
22
+ * // app/docs/[slug]/prerender.ts
23
+ * export async function generateParams() {
24
+ * return docs.map(d => ({ slug: d.slug }))
25
+ * }
26
+ * export const ttl = '1h'
27
+ * export const tags = ['docs']
28
+ * ```
29
+ */
30
+ export interface PrerenderConfig {
31
+ /**
32
+ * Generate the set of params to pre-render at build time.
33
+ * Required for dynamic segments (`[param]`).
34
+ * Optional for static segments (the single URL is pre-rendered automatically).
35
+ */
36
+ generateParams?: () => Promise<Record<string, string>[]> | Record<string, string>[];
37
+
38
+ /**
39
+ * How long the pre-rendered shell is considered fresh.
40
+ * Accepts duration strings ('30s', '5m', '1h', '2d', '1w') or seconds as a number.
41
+ * Default: Infinity (cache until explicit invalidation).
42
+ */
43
+ ttl?: string | number;
44
+
45
+ /**
46
+ * Invalidation tags. Calling `revalidateTag('docs')` purges all
47
+ * pre-rendered shells with that tag.
48
+ */
49
+ tags?: string[];
50
+
51
+ /**
52
+ * Fallback strategy for dynamic routes without `generateParams`.
53
+ * Only valid in `output: 'static'` mode.
54
+ * - `'shell'`: emit a single pre-rendered shell that serves as client-side fallback
55
+ */
56
+ fallback?: 'shell';
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Parsed prerender config — framework-internal, with TTL resolved to seconds
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface ResolvedPrerenderConfig {
64
+ /** TTL in seconds. Infinity if not set. */
65
+ ttlSeconds: number;
66
+ /** Invalidation tags */
67
+ tags: string[];
68
+ /** The generateParams function, if provided */
69
+ generateParams?: () => Promise<Record<string, string>[]> | Record<string, string>[];
70
+ /** Fallback strategy */
71
+ fallback?: 'shell';
72
+ }
73
+
74
+ /**
75
+ * Resolve raw prerender.ts exports into a normalized config.
76
+ *
77
+ * Validates:
78
+ * - `ttl` is a valid duration string or number
79
+ * - `tags` is an array of strings
80
+ * - `fallback` is 'shell' or undefined
81
+ */
82
+ export function resolvePrerenderConfig(raw: PrerenderConfig): ResolvedPrerenderConfig {
83
+ const ttlSeconds = raw.ttl != null ? parseCacheLife(raw.ttl) : Infinity;
84
+
85
+ const tags = raw.tags ?? [];
86
+ if (!Array.isArray(tags) || tags.some((t) => typeof t !== 'string')) {
87
+ throw new Error(
88
+ `prerender.ts: tags must be an array of strings. Got: ${JSON.stringify(raw.tags)}`
89
+ );
90
+ }
91
+
92
+ if (raw.fallback != null && raw.fallback !== 'shell') {
93
+ throw new Error(
94
+ `prerender.ts: fallback must be 'shell' or omitted. Got: ${JSON.stringify(raw.fallback)}`
95
+ );
96
+ }
97
+
98
+ return {
99
+ ttlSeconds,
100
+ tags,
101
+ generateParams: raw.generateParams,
102
+ fallback: raw.fallback,
103
+ };
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Build diagnostics
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export interface PrerenderDiagnostic {
111
+ type: 'DYNAMIC_SEGMENT_NO_PARAMS';
112
+ segmentPath: string;
113
+ message: string;
114
+ }
115
+
116
+ /**
117
+ * Check if a dynamic segment has `generateParams` when prerender.ts is present.
118
+ * If not, emit a diagnostic — the route falls back to SSR.
119
+ */
120
+ export function checkDynamicSegmentParams(
121
+ segmentPath: string,
122
+ isDynamic: boolean,
123
+ hasGenerateParams: boolean,
124
+ fallback?: 'shell'
125
+ ): PrerenderDiagnostic | null {
126
+ if (!isDynamic) return null;
127
+ if (hasGenerateParams) return null;
128
+ if (fallback === 'shell') return null;
129
+
130
+ return {
131
+ type: 'DYNAMIC_SEGMENT_NO_PARAMS',
132
+ segmentPath,
133
+ message:
134
+ `Dynamic segment "${segmentPath}" has prerender.ts but no generateParams(). ` +
135
+ `The route will fall back to SSR at request time. ` +
136
+ `Add generateParams() to pre-render specific param values, ` +
137
+ `or set fallback: 'shell' (static mode only) for a client-side fallback shell.`,
138
+ };
139
+ }