@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,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, '&')
|
|
551
|
+
.replace(/</g, '<')
|
|
552
|
+
.replace(/>/g, '>')
|
|
553
|
+
.replace(/"/g, '"')
|
|
554
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|