@timber-js/app 0.1.20 → 0.1.22
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/_chunks/als-registry-c0AGnbqS.js +39 -0
- package/dist/_chunks/als-registry-c0AGnbqS.js.map +1 -0
- package/dist/_chunks/{interception-c-a3uODY.js → interception-DGDIjDbR.js} +10 -3
- package/dist/_chunks/interception-DGDIjDbR.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BDnswgRO.js → metadata-routes-CQCnF4VK.js} +14 -2
- package/dist/_chunks/metadata-routes-CQCnF4VK.js.map +1 -0
- package/dist/_chunks/{request-context-BzES06i1.js → request-context-C69VW4xS.js} +2 -4
- package/dist/_chunks/request-context-C69VW4xS.js.map +1 -0
- package/dist/_chunks/ssr-data-B2yikEEB.js +90 -0
- package/dist/_chunks/ssr-data-B2yikEEB.js.map +1 -0
- package/dist/_chunks/{tracing-BtOwb8O6.js → tracing-tIvqStk8.js} +2 -3
- package/dist/_chunks/tracing-tIvqStk8.js.map +1 -0
- package/dist/_chunks/{use-cookie-D2cZu0jK.js → use-cookie-D5aS4slY.js} +2 -2
- package/dist/_chunks/{use-cookie-D2cZu0jK.js.map → use-cookie-D5aS4slY.js.map} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js → use-query-states-DAhgj8Gx.js} +1 -1
- package/dist/_chunks/{use-query-states-wEXY2JQB.js.map → use-query-states-DAhgj8Gx.js.map} +1 -1
- package/dist/cache/index.js +2 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +40 -26
- package/dist/client/index.js.map +1 -1
- package/dist/client/router-ref.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/ssr-data.d.ts +3 -0
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/state.d.ts +47 -0
- package/dist/client/state.d.ts.map +1 -0
- package/dist/client/types.d.ts +10 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/unload-guard.d.ts +3 -0
- package/dist/client/unload-guard.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +19 -6
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +3 -0
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -2
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/rsc-runtime/browser.d.ts +13 -0
- package/dist/rsc-runtime/browser.d.ts.map +1 -0
- package/dist/rsc-runtime/rsc.d.ts +14 -0
- package/dist/rsc-runtime/rsc.d.ts.map +1 -0
- package/dist/rsc-runtime/ssr.d.ts +13 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -0
- package/dist/search-params/builtin-codecs.d.ts +105 -0
- package/dist/search-params/builtin-codecs.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +1 -0
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +167 -2
- package/dist/search-params/index.js.map +1 -1
- package/dist/server/actions.d.ts +2 -7
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +80 -0
- package/dist/server/als-registry.d.ts.map +1 -0
- package/dist/server/early-hints-sender.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +242 -76
- package/dist/server/index.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +27 -0
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +7 -0
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +14 -6
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +2 -32
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +5 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +25 -0
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts +43 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts +52 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -0
- package/dist/server/rsc-prop-warnings.d.ts +53 -0
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -0
- package/dist/server/server-timing.d.ts +49 -0
- package/dist/server/server-timing.d.ts.map +1 -0
- package/dist/server/tracing.d.ts +2 -6
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/types.d.ts +11 -0
- package/dist/server/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/router-ref.ts +6 -12
- package/src/client/router.ts +14 -4
- package/src/client/ssr-data.ts +25 -9
- package/src/client/state.ts +83 -0
- package/src/client/types.ts +18 -1
- package/src/client/unload-guard.ts +6 -3
- package/src/client/use-params.ts +42 -32
- package/src/client/use-search-params.ts +9 -5
- package/src/plugins/shims.ts +26 -2
- package/src/routing/scanner.ts +18 -2
- package/src/rsc-runtime/browser.ts +18 -0
- package/src/rsc-runtime/rsc.ts +19 -0
- package/src/rsc-runtime/ssr.ts +13 -0
- package/src/search-params/builtin-codecs.ts +228 -0
- package/src/search-params/index.ts +11 -0
- package/src/server/action-handler.ts +1 -1
- package/src/server/actions.ts +4 -10
- package/src/server/als-registry.ts +116 -0
- package/src/server/deny-renderer.ts +1 -1
- package/src/server/early-hints-sender.ts +1 -3
- package/src/server/form-flash.ts +1 -5
- package/src/server/index.ts +1 -0
- package/src/server/metadata-routes.ts +61 -0
- package/src/server/pipeline.ts +164 -38
- package/src/server/primitives.ts +110 -6
- package/src/server/request-context.ts +8 -36
- package/src/server/route-matcher.ts +25 -2
- package/src/server/rsc-entry/error-renderer.ts +1 -1
- package/src/server/rsc-entry/index.ts +42 -380
- package/src/server/rsc-entry/rsc-payload.ts +126 -0
- package/src/server/rsc-entry/rsc-stream.ts +162 -0
- package/src/server/rsc-entry/ssr-renderer.ts +228 -0
- package/src/server/rsc-prop-warnings.ts +187 -0
- package/src/server/server-timing.ts +132 -0
- package/src/server/ssr-entry.ts +1 -1
- package/src/server/tracing.ts +3 -11
- package/src/server/types.ts +16 -0
- package/dist/_chunks/interception-c-a3uODY.js.map +0 -1
- package/dist/_chunks/metadata-routes-BDnswgRO.js.map +0 -1
- package/dist/_chunks/request-context-BzES06i1.js.map +0 -1
- package/dist/_chunks/ssr-data-BgSwMbN9.js +0 -38
- package/dist/_chunks/ssr-data-BgSwMbN9.js.map +0 -1
- package/dist/_chunks/tracing-BtOwb8O6.js.map +0 -1
package/src/server/pipeline.ts
CHANGED
|
@@ -11,9 +11,15 @@
|
|
|
11
11
|
* and design/17-logging.md §"Production Logging"
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
14
15
|
import { canonicalize } from './canonicalize.js';
|
|
15
16
|
import { runProxy, type ProxyExport } from './proxy.js';
|
|
16
17
|
import { runMiddleware, type MiddlewareFn } from './middleware-runner.js';
|
|
18
|
+
import {
|
|
19
|
+
runWithTimingCollector,
|
|
20
|
+
withTiming,
|
|
21
|
+
getServerTimingHeader,
|
|
22
|
+
} from './server-timing.js';
|
|
17
23
|
import {
|
|
18
24
|
runWithRequestContext,
|
|
19
25
|
applyRequestHeaderOverlay,
|
|
@@ -113,6 +119,13 @@ export interface PipelineConfig {
|
|
|
113
119
|
* See design/07-routing.md §"Intercepting Routes"
|
|
114
120
|
*/
|
|
115
121
|
interceptionRewrites?: import('#/routing/interception.js').InterceptionRewrite[];
|
|
122
|
+
/**
|
|
123
|
+
* Emit Server-Timing header on responses for Chrome DevTools visibility.
|
|
124
|
+
* Only enable in dev mode — exposes internal timing data.
|
|
125
|
+
*
|
|
126
|
+
* Default: false (production-safe).
|
|
127
|
+
*/
|
|
128
|
+
enableServerTiming?: boolean;
|
|
116
129
|
/**
|
|
117
130
|
* Dev pipeline error callback — called when a pipeline phase (proxy,
|
|
118
131
|
* middleware, render) catches an unhandled error. Used to wire the error
|
|
@@ -155,6 +168,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
155
168
|
earlyHints,
|
|
156
169
|
stripTrailingSlash = true,
|
|
157
170
|
slowRequestMs = 3000,
|
|
171
|
+
enableServerTiming = false,
|
|
158
172
|
onPipelineError,
|
|
159
173
|
} = config;
|
|
160
174
|
|
|
@@ -173,43 +187,65 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
173
187
|
// Establish request context ALS scope so headers() and cookies() work
|
|
174
188
|
// throughout the entire request lifecycle (proxy, middleware, render).
|
|
175
189
|
return runWithRequestContext(req, async () => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
// In dev mode, wrap with timing collector for Server-Timing header.
|
|
191
|
+
// The collector uses ALS so timing entries are per-request.
|
|
192
|
+
const runRequest = async () => {
|
|
193
|
+
logRequestReceived({ method, path });
|
|
194
|
+
|
|
195
|
+
const response = await withSpan(
|
|
196
|
+
'http.server.request',
|
|
197
|
+
{ 'http.request.method': method, 'url.path': path },
|
|
198
|
+
async () => {
|
|
199
|
+
// If OTEL is active, the root span now exists — replace the UUID
|
|
200
|
+
// fallback with the real OTEL trace ID for log–trace correlation.
|
|
201
|
+
const otelIds = await getOtelTraceId();
|
|
202
|
+
if (otelIds) {
|
|
203
|
+
replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let result: Response;
|
|
207
|
+
if (proxy || config.proxyLoader) {
|
|
208
|
+
result = await runProxyPhase(req, method, path);
|
|
209
|
+
} else {
|
|
210
|
+
result = await handleRequest(req, method, path);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Set response status on the root span before it ends —
|
|
214
|
+
// DevSpanProcessor reads this for tree/summary output.
|
|
215
|
+
await setSpanAttribute('http.response.status_code', result.status);
|
|
216
|
+
|
|
217
|
+
// Append Server-Timing header in dev mode.
|
|
218
|
+
// At this point, pre-flush phases (proxy, middleware, render)
|
|
219
|
+
// have completed and their timing entries are collected.
|
|
220
|
+
// Response.redirect() creates immutable headers, so we must
|
|
221
|
+
// ensure mutability before writing Server-Timing.
|
|
222
|
+
if (enableServerTiming) {
|
|
223
|
+
const serverTiming = getServerTimingHeader();
|
|
224
|
+
if (serverTiming) {
|
|
225
|
+
result = ensureMutableResponse(result);
|
|
226
|
+
result.headers.set('Server-Timing', serverTiming);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
187
231
|
}
|
|
232
|
+
);
|
|
188
233
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
result = await handleRequest(req, method, path);
|
|
194
|
-
}
|
|
234
|
+
// Post-span: structured production logging
|
|
235
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
236
|
+
const status = response.status;
|
|
237
|
+
logRequestCompleted({ method, path, status, durationMs });
|
|
195
238
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
await setSpanAttribute('http.response.status_code', result.status);
|
|
199
|
-
return result;
|
|
239
|
+
if (slowRequestMs > 0 && durationMs > slowRequestMs) {
|
|
240
|
+
logSlowRequest({ method, path, durationMs, threshold: slowRequestMs });
|
|
200
241
|
}
|
|
201
|
-
);
|
|
202
242
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const status = response.status;
|
|
206
|
-
logRequestCompleted({ method, path, status, durationMs });
|
|
207
|
-
|
|
208
|
-
if (slowRequestMs > 0 && durationMs > slowRequestMs) {
|
|
209
|
-
logSlowRequest({ method, path, durationMs, threshold: slowRequestMs });
|
|
210
|
-
}
|
|
243
|
+
return response;
|
|
244
|
+
};
|
|
211
245
|
|
|
212
|
-
return
|
|
246
|
+
return enableServerTiming
|
|
247
|
+
? runWithTimingCollector(runRequest)
|
|
248
|
+
: runRequest();
|
|
213
249
|
});
|
|
214
250
|
});
|
|
215
251
|
};
|
|
@@ -225,8 +261,12 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
225
261
|
} else {
|
|
226
262
|
proxyExport = config.proxy!;
|
|
227
263
|
}
|
|
264
|
+
const proxyFn = () =>
|
|
265
|
+
runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
228
266
|
return await withSpan('timber.proxy', {}, () =>
|
|
229
|
-
|
|
267
|
+
enableServerTiming
|
|
268
|
+
? withTiming('proxy', 'proxy.ts', proxyFn)
|
|
269
|
+
: proxyFn()
|
|
230
270
|
);
|
|
231
271
|
} catch (error) {
|
|
232
272
|
// Uncaught proxy.ts error → bare HTTP 500
|
|
@@ -253,6 +293,13 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
253
293
|
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
254
294
|
if (metaMatch) {
|
|
255
295
|
try {
|
|
296
|
+
// Static metadata files (.xml, .txt, .png, .ico, etc.) are served
|
|
297
|
+
// directly from disk. Dynamic metadata routes (.ts, .tsx) export a
|
|
298
|
+
// handler function that generates the response.
|
|
299
|
+
if (metaMatch.isStatic) {
|
|
300
|
+
return await serveStaticMetadataFile(metaMatch);
|
|
301
|
+
}
|
|
302
|
+
|
|
256
303
|
const mod = (await metaMatch.file.load()) as { default?: Function };
|
|
257
304
|
if (typeof mod.default !== 'function') {
|
|
258
305
|
return new Response('Metadata route must export a default function', { status: 500 });
|
|
@@ -360,15 +407,21 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
360
407
|
try {
|
|
361
408
|
// Enable cookie mutation during middleware (design/29-cookies.md §"Context Tracking")
|
|
362
409
|
setMutableCookieContext(true);
|
|
410
|
+
const middlewareFn = () => runMiddleware(match.middleware!, ctx);
|
|
363
411
|
const middlewareResponse = await withSpan('timber.middleware', {}, () =>
|
|
364
|
-
|
|
412
|
+
enableServerTiming
|
|
413
|
+
? withTiming('mw', 'middleware.ts', middlewareFn)
|
|
414
|
+
: middlewareFn()
|
|
365
415
|
);
|
|
366
416
|
setMutableCookieContext(false);
|
|
367
417
|
if (middlewareResponse) {
|
|
368
|
-
// Apply cookie jar to short-circuit response
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
418
|
+
// Apply cookie jar to short-circuit response.
|
|
419
|
+
// Response.redirect() creates immutable headers, so ensure
|
|
420
|
+
// mutability before appending Set-Cookie entries.
|
|
421
|
+
const finalResponse = ensureMutableResponse(middlewareResponse);
|
|
422
|
+
applyCookieJar(finalResponse.headers);
|
|
423
|
+
logMiddlewareShortCircuit({ method, path, status: finalResponse.status });
|
|
424
|
+
return finalResponse;
|
|
372
425
|
}
|
|
373
426
|
// Middleware succeeded without short-circuiting — apply any
|
|
374
427
|
// injected request headers so headers() returns them downstream.
|
|
@@ -410,8 +463,12 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
410
463
|
|
|
411
464
|
// Stage 4: Render (access gates + element tree + renderToReadableStream)
|
|
412
465
|
try {
|
|
466
|
+
const renderFn = () =>
|
|
467
|
+
render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
413
468
|
const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
|
|
414
|
-
|
|
469
|
+
enableServerTiming
|
|
470
|
+
? withTiming('render', 'RSC + SSR render', renderFn)
|
|
471
|
+
: renderFn()
|
|
415
472
|
);
|
|
416
473
|
markResponseFlushed();
|
|
417
474
|
return response;
|
|
@@ -533,6 +590,36 @@ function applyCookieJar(headers: Headers): void {
|
|
|
533
590
|
}
|
|
534
591
|
}
|
|
535
592
|
|
|
593
|
+
// ─── Immutable Response Helpers ──────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Ensure a Response has mutable headers so the pipeline can safely append
|
|
597
|
+
* Set-Cookie and Server-Timing entries.
|
|
598
|
+
*
|
|
599
|
+
* `Response.redirect()` and some platform-level responses return objects
|
|
600
|
+
* with immutable headers. Calling `.set()` or `.append()` on them throws
|
|
601
|
+
* `TypeError: immutable`. This helper detects the immutable case by
|
|
602
|
+
* attempting a no-op write and, on failure, clones into a fresh Response
|
|
603
|
+
* with mutable headers.
|
|
604
|
+
*/
|
|
605
|
+
function ensureMutableResponse(response: Response): Response {
|
|
606
|
+
try {
|
|
607
|
+
// Probe mutability with a benign operation that we immediately undo.
|
|
608
|
+
// We pick a header name that is extremely unlikely to collide with
|
|
609
|
+
// anything meaningful and delete it right away.
|
|
610
|
+
response.headers.set('X-Timber-Probe', '1');
|
|
611
|
+
response.headers.delete('X-Timber-Probe');
|
|
612
|
+
return response;
|
|
613
|
+
} catch {
|
|
614
|
+
// Headers are immutable — rebuild with mutable headers.
|
|
615
|
+
return new Response(response.body, {
|
|
616
|
+
status: response.status,
|
|
617
|
+
statusText: response.statusText,
|
|
618
|
+
headers: new Headers(response.headers),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
536
623
|
// ─── Metadata Route Helpers ──────────────────────────────────────────────
|
|
537
624
|
|
|
538
625
|
/**
|
|
@@ -577,3 +664,42 @@ function escapeXml(str: string): string {
|
|
|
577
664
|
.replace(/"/g, '"')
|
|
578
665
|
.replace(/'/g, ''');
|
|
579
666
|
}
|
|
667
|
+
|
|
668
|
+
// ─── Static Metadata File Serving ────────────────────────────────────────
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Content types that are text-based and should include charset=utf-8.
|
|
672
|
+
* Binary formats (images) should not include charset.
|
|
673
|
+
*/
|
|
674
|
+
const TEXT_CONTENT_TYPES = new Set([
|
|
675
|
+
'application/xml',
|
|
676
|
+
'text/plain',
|
|
677
|
+
'application/json',
|
|
678
|
+
'application/manifest+json',
|
|
679
|
+
'image/svg+xml',
|
|
680
|
+
]);
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Serve a static metadata file by reading it from disk.
|
|
684
|
+
*
|
|
685
|
+
* Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
|
|
686
|
+
* are served as-is with the appropriate Content-Type header.
|
|
687
|
+
* Text files include charset=utf-8; binary files do not.
|
|
688
|
+
*
|
|
689
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
690
|
+
*/
|
|
691
|
+
async function serveStaticMetadataFile(
|
|
692
|
+
metaMatch: import('./route-matcher.js').MetadataRouteMatch
|
|
693
|
+
): Promise<Response> {
|
|
694
|
+
const { contentType, file } = metaMatch;
|
|
695
|
+
const isText = TEXT_CONTENT_TYPES.has(contentType);
|
|
696
|
+
|
|
697
|
+
const body = await readFile(file.filePath);
|
|
698
|
+
|
|
699
|
+
const headers: Record<string, string> = {
|
|
700
|
+
'Content-Type': isText ? `${contentType}; charset=utf-8` : contentType,
|
|
701
|
+
'Content-Length': String(body.byteLength),
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
return new Response(body, { status: 200, headers });
|
|
705
|
+
}
|
package/src/server/primitives.ts
CHANGED
|
@@ -3,6 +3,101 @@
|
|
|
3
3
|
// These are the core runtime signals that components, middleware, and access gates
|
|
4
4
|
// use to control request flow. See design/10-error-handling.md.
|
|
5
5
|
|
|
6
|
+
import type { JsonSerializable } from './types.js';
|
|
7
|
+
|
|
8
|
+
// ─── Dev-mode validation ────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a value is JSON-serializable without data loss.
|
|
12
|
+
* Returns a description of the first non-serializable value found, or null if OK.
|
|
13
|
+
*
|
|
14
|
+
* @internal Exported for testing only.
|
|
15
|
+
*/
|
|
16
|
+
export function findNonSerializable(value: unknown, path = 'data'): string | null {
|
|
17
|
+
if (value === null || value === undefined) return null;
|
|
18
|
+
|
|
19
|
+
switch (typeof value) {
|
|
20
|
+
case 'string':
|
|
21
|
+
case 'number':
|
|
22
|
+
case 'boolean':
|
|
23
|
+
return null;
|
|
24
|
+
case 'bigint':
|
|
25
|
+
return `${path} contains a BigInt — BigInt throws in JSON.stringify`;
|
|
26
|
+
case 'function':
|
|
27
|
+
return `${path} is a function — functions are not JSON-serializable`;
|
|
28
|
+
case 'symbol':
|
|
29
|
+
return `${path} is a symbol — symbols are not JSON-serializable`;
|
|
30
|
+
case 'object':
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
return `${path} has unsupported type "${typeof value}"`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (value instanceof Date) {
|
|
37
|
+
return `${path} is a Date — Dates silently coerce to strings in JSON.stringify`;
|
|
38
|
+
}
|
|
39
|
+
if (value instanceof Map) {
|
|
40
|
+
return `${path} is a Map — Maps serialize as {} in JSON.stringify (data loss)`;
|
|
41
|
+
}
|
|
42
|
+
if (value instanceof Set) {
|
|
43
|
+
return `${path} is a Set — Sets serialize as {} in JSON.stringify (data loss)`;
|
|
44
|
+
}
|
|
45
|
+
if (value instanceof RegExp) {
|
|
46
|
+
return `${path} is a RegExp — RegExps serialize as {} in JSON.stringify`;
|
|
47
|
+
}
|
|
48
|
+
if (value instanceof Error) {
|
|
49
|
+
return `${path} is an Error — Errors serialize as {} in JSON.stringify`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
for (let i = 0; i < value.length; i++) {
|
|
54
|
+
const result = findNonSerializable(value[i], `${path}[${i}]`);
|
|
55
|
+
if (result) return result;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Plain object — only Object.prototype is safe. Null-prototype objects
|
|
61
|
+
// (Object.create(null)) survive JSON.stringify but React Flight rejects
|
|
62
|
+
// them with "Classes or null prototypes are not supported", so the
|
|
63
|
+
// pre-flush deny path (renderDenyPage → renderToReadableStream) would throw.
|
|
64
|
+
const proto = Object.getPrototypeOf(value);
|
|
65
|
+
if (proto === null) {
|
|
66
|
+
return `${path} is a null-prototype object — React Flight rejects null prototypes`;
|
|
67
|
+
}
|
|
68
|
+
if (proto !== Object.prototype) {
|
|
69
|
+
const name = (value as object).constructor?.name ?? 'unknown';
|
|
70
|
+
return `${path} is a ${name} instance — class instances may lose data in JSON.stringify`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
74
|
+
const result = findNonSerializable(
|
|
75
|
+
(value as Record<string, unknown>)[key],
|
|
76
|
+
`${path}.${key}`
|
|
77
|
+
);
|
|
78
|
+
if (result) return result;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Emit a dev-mode warning if data is not JSON-serializable.
|
|
85
|
+
* No-op in production.
|
|
86
|
+
*/
|
|
87
|
+
function warnIfNotSerializable(data: unknown, callerName: string): void {
|
|
88
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
89
|
+
if (data === undefined) return;
|
|
90
|
+
|
|
91
|
+
const issue = findNonSerializable(data);
|
|
92
|
+
if (issue) {
|
|
93
|
+
console.warn(
|
|
94
|
+
`[timber] ${callerName}: ${issue}. ` +
|
|
95
|
+
'Data passed to deny() or RenderError must be JSON-serializable because ' +
|
|
96
|
+
'the post-flush path uses JSON.stringify, not React Flight.'
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
6
101
|
// ─── DenySignal ─────────────────────────────────────────────────────────────
|
|
7
102
|
|
|
8
103
|
/**
|
|
@@ -11,9 +106,9 @@
|
|
|
11
106
|
*/
|
|
12
107
|
export class DenySignal extends Error {
|
|
13
108
|
readonly status: number;
|
|
14
|
-
readonly data:
|
|
109
|
+
readonly data: JsonSerializable | undefined;
|
|
15
110
|
|
|
16
|
-
constructor(status: number, data?:
|
|
111
|
+
constructor(status: number, data?: JsonSerializable) {
|
|
17
112
|
super(`Access denied with status ${status}`);
|
|
18
113
|
this.name = 'DenySignal';
|
|
19
114
|
this.status = status;
|
|
@@ -58,15 +153,16 @@ export class DenySignal extends Error {
|
|
|
58
153
|
* - Inside Suspense (after flush): error boundary + noindex meta
|
|
59
154
|
*
|
|
60
155
|
* @param status - Any 4xx HTTP status code. Defaults to 403.
|
|
61
|
-
* @param data - Optional data passed as `dangerouslyPassData` prop to status-code files.
|
|
156
|
+
* @param data - Optional JSON-serializable data passed as `dangerouslyPassData` prop to status-code files.
|
|
62
157
|
*/
|
|
63
|
-
export function deny(status: number = 403, data?:
|
|
158
|
+
export function deny(status: number = 403, data?: JsonSerializable): never {
|
|
64
159
|
if (status < 400 || status > 499) {
|
|
65
160
|
throw new Error(
|
|
66
161
|
`deny() requires a 4xx status code, got ${status}. ` +
|
|
67
162
|
'For 5xx errors, throw a RenderError instead.'
|
|
68
163
|
);
|
|
69
164
|
}
|
|
165
|
+
warnIfNotSerializable(data, 'deny()');
|
|
70
166
|
throw new DenySignal(status, data);
|
|
71
167
|
}
|
|
72
168
|
|
|
@@ -181,7 +277,10 @@ export function redirectExternal(url: string, allowList: string[], status: numbe
|
|
|
181
277
|
* Typed digest that crosses the RSC → client boundary.
|
|
182
278
|
* The `code` identifies the error class; `data` carries JSON-serializable context.
|
|
183
279
|
*/
|
|
184
|
-
export interface RenderErrorDigest<
|
|
280
|
+
export interface RenderErrorDigest<
|
|
281
|
+
TCode extends string = string,
|
|
282
|
+
TData extends JsonSerializable = JsonSerializable,
|
|
283
|
+
> {
|
|
185
284
|
code: TCode;
|
|
186
285
|
data: TData;
|
|
187
286
|
}
|
|
@@ -200,7 +299,10 @@ export interface RenderErrorDigest<TCode extends string = string, TData = unknow
|
|
|
200
299
|
* })
|
|
201
300
|
* ```
|
|
202
301
|
*/
|
|
203
|
-
export class RenderError<
|
|
302
|
+
export class RenderError<
|
|
303
|
+
TCode extends string = string,
|
|
304
|
+
TData extends JsonSerializable = JsonSerializable,
|
|
305
|
+
> extends Error {
|
|
204
306
|
readonly code: TCode;
|
|
205
307
|
readonly digest: RenderErrorDigest<TCode, TData>;
|
|
206
308
|
readonly status: number;
|
|
@@ -211,6 +313,8 @@ export class RenderError<TCode extends string = string, TData = unknown> extends
|
|
|
211
313
|
this.code = code;
|
|
212
314
|
this.digest = { code, data };
|
|
213
315
|
|
|
316
|
+
warnIfNotSerializable(data, 'RenderError');
|
|
317
|
+
|
|
214
318
|
const status = options?.status ?? 500;
|
|
215
319
|
if (status < 400 || status > 599) {
|
|
216
320
|
throw new Error(`RenderError status must be 4xx or 5xx, got ${status}.`);
|
|
@@ -10,44 +10,16 @@
|
|
|
10
10
|
* See design/29-cookies.md for cookie mutation semantics.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
13
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
15
14
|
import type { Routes } from '#/index.js';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/** Lazily-parsed cookie map (mutable — reflects write-overlay from set()). */
|
|
25
|
-
parsedCookies?: Map<string, string>;
|
|
26
|
-
/** Original (pre-overlay) frozen headers, kept for overlay merging. */
|
|
27
|
-
originalHeaders: Headers;
|
|
28
|
-
/**
|
|
29
|
-
* Promise resolving to the route's typed search params (when search-params.ts
|
|
30
|
-
* exists) or to the raw URLSearchParams. Stored as a Promise so the framework
|
|
31
|
-
* can later support partial pre-rendering where param resolution is deferred.
|
|
32
|
-
*/
|
|
33
|
-
searchParamsPromise: Promise<URLSearchParams | Record<string, unknown>>;
|
|
34
|
-
/** Outgoing Set-Cookie entries (name → serialized value + options). Last write wins. */
|
|
35
|
-
cookieJar: Map<string, CookieEntry>;
|
|
36
|
-
/** Whether the response has flushed (headers committed). */
|
|
37
|
-
flushed: boolean;
|
|
38
|
-
/** Whether the current context allows cookie mutation. */
|
|
39
|
-
mutableContext: boolean;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** A single outgoing cookie entry in the cookie jar. */
|
|
43
|
-
interface CookieEntry {
|
|
44
|
-
name: string;
|
|
45
|
-
value: string;
|
|
46
|
-
options: CookieOptions;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** @internal */
|
|
50
|
-
export const requestContextAls = new AsyncLocalStorage<RequestContextStore>();
|
|
15
|
+
import {
|
|
16
|
+
requestContextAls,
|
|
17
|
+
type RequestContextStore,
|
|
18
|
+
type CookieEntry,
|
|
19
|
+
} from './als-registry.js';
|
|
20
|
+
|
|
21
|
+
// Re-export the ALS for framework-internal consumers that need direct access.
|
|
22
|
+
export { requestContextAls };
|
|
51
23
|
|
|
52
24
|
// No fallback needed — we use enterWith() instead of run() to ensure
|
|
53
25
|
// the ALS context persists for the entire request lifecycle including
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import type { RouteMatch } from './pipeline.js';
|
|
12
12
|
import type { MiddlewareFn } from './middleware-runner.js';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
METADATA_ROUTE_CONVENTIONS,
|
|
15
|
+
isStaticMetadataExtension,
|
|
16
|
+
resolveStaticContentType,
|
|
17
|
+
type MetadataRouteType,
|
|
18
|
+
} from './metadata-routes.js';
|
|
14
19
|
|
|
15
20
|
// ─── Manifest Types ───────────────────────────────────────────────────────
|
|
16
21
|
// The virtual module manifest has a slightly different shape than SegmentNode:
|
|
@@ -292,6 +297,11 @@ export interface MetadataRouteMatch {
|
|
|
292
297
|
file: ManifestFile;
|
|
293
298
|
/** The matched segment (for context/params if needed). */
|
|
294
299
|
segment: ManifestSegmentNode;
|
|
300
|
+
/**
|
|
301
|
+
* Whether this is a static file (e.g. sitemap.xml, favicon.ico, icon.png)
|
|
302
|
+
* that should be served from disk rather than executed as a handler.
|
|
303
|
+
*/
|
|
304
|
+
isStatic: boolean;
|
|
295
305
|
}
|
|
296
306
|
|
|
297
307
|
/**
|
|
@@ -331,11 +341,24 @@ function collectMetadataRoutes(
|
|
|
331
341
|
const prefix = node.urlPath === '/' ? '' : node.urlPath;
|
|
332
342
|
const pathname = `${prefix}/${convention.servePath}`;
|
|
333
343
|
|
|
344
|
+
// Determine if this is a static file based on its extension.
|
|
345
|
+
// Static files (.xml, .txt, .png, .ico, etc.) are served from disk.
|
|
346
|
+
// Dynamic files (.ts, .tsx) export a handler function.
|
|
347
|
+
const ext = file.filePath.slice(file.filePath.lastIndexOf('.') + 1);
|
|
348
|
+
const isStatic = isStaticMetadataExtension(baseName, ext);
|
|
349
|
+
|
|
350
|
+
// Resolve generic content types (e.g. image/*) to concrete MIME types
|
|
351
|
+
// based on the file extension for static files.
|
|
352
|
+
const contentType = isStatic
|
|
353
|
+
? resolveStaticContentType(convention.contentType, ext)
|
|
354
|
+
: convention.contentType;
|
|
355
|
+
|
|
334
356
|
map.set(pathname, {
|
|
335
357
|
type: convention.type,
|
|
336
|
-
contentType
|
|
358
|
+
contentType,
|
|
337
359
|
file,
|
|
338
360
|
segment: node,
|
|
361
|
+
isStatic,
|
|
339
362
|
});
|
|
340
363
|
}
|
|
341
364
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { createElement } from 'react';
|
|
8
|
-
import { renderToReadableStream } from '
|
|
8
|
+
import { renderToReadableStream } from '#/rsc-runtime/rsc.js';
|
|
9
9
|
|
|
10
10
|
import type { RouteMatch } from '#/server/pipeline.js';
|
|
11
11
|
import { logRenderError } from '#/server/logger.js';
|