@timber-js/app 0.1.3 → 0.1.5
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/{interception-DIaZN1bF.js → interception-c-a3uODY.js} +8 -8
- package/dist/_chunks/interception-c-a3uODY.js.map +1 -0
- package/dist/_chunks/{registry-DUIpYD_x.js → registry-BfPM41ri.js} +1 -1
- package/dist/_chunks/{registry-DUIpYD_x.js.map → registry-BfPM41ri.js.map} +1 -1
- package/dist/_chunks/{request-context-D6XHINkR.js → request-context-BzES06i1.js} +2 -1
- package/dist/_chunks/request-context-BzES06i1.js.map +1 -0
- package/dist/_chunks/{use-cookie-8ZlA0rr3.js → use-cookie-HcvNlW4L.js} +1 -1
- package/dist/_chunks/{use-cookie-8ZlA0rr3.js.map → use-cookie-HcvNlW4L.js.map} +1 -1
- package/dist/adapters/cloudflare.d.ts +2 -2
- package/dist/adapters/cloudflare.js +4 -4
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.d.ts +1 -1
- package/dist/adapters/nitro.js +4 -4
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/{_chunks/error-boundary-dj-WO5uq.js → client/error-boundary.js} +4 -2
- package/dist/client/error-boundary.js.map +1 -0
- package/dist/client/form.d.ts +1 -1
- package/dist/client/index.js +10 -9
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/slot-error-fallback.d.ts +13 -0
- package/dist/client/slot-error-fallback.d.ts.map +1 -0
- package/dist/client/use-link-status.d.ts +1 -1
- package/dist/client/use-navigation-pending.d.ts +1 -1
- package/dist/content/index.d.ts +1 -1
- package/dist/cookies/define-cookie.d.ts +2 -2
- package/dist/cookies/index.d.ts +1 -1
- package/dist/cookies/index.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -4
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +23 -22
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-logs.d.ts +1 -1
- package/dist/plugins/dynamic-transform.d.ts +1 -1
- package/dist/plugins/shims.d.ts +5 -0
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/search-params/codecs.d.ts +2 -2
- package/dist/search-params/create.d.ts +1 -1
- package/dist/search-params/index.js +5 -5
- package/dist/search-params/index.js.map +1 -1
- package/dist/server/action-client.d.ts +1 -1
- package/dist/server/dev-fetch-instrumentation.d.ts +22 -0
- package/dist/server/dev-fetch-instrumentation.d.ts.map +1 -0
- package/dist/server/dev-logger.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts +1 -1
- package/dist/server/index.js +4 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/primitives.d.ts +15 -0
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +32 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/shims/headers.d.ts +5 -7
- package/dist/shims/headers.d.ts.map +1 -1
- package/dist/shims/link.d.ts +1 -1
- package/dist/shims/link.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +5 -15
- package/dist/shims/navigation.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/cloudflare.ts +4 -4
- package/src/adapters/nitro.ts +4 -4
- package/src/cache/index.ts +1 -1
- package/src/client/form.tsx +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/link.tsx +2 -0
- package/src/client/slot-error-fallback.tsx +16 -0
- package/src/client/use-link-status.ts +1 -1
- package/src/client/use-navigation-pending.ts +1 -1
- package/src/content/index.ts +1 -1
- package/src/cookies/define-cookie.ts +2 -2
- package/src/cookies/index.ts +2 -6
- package/src/plugins/cache-transform.ts +1 -1
- package/src/plugins/dev-logs.ts +2 -2
- package/src/plugins/dynamic-transform.ts +2 -2
- package/src/plugins/shims.ts +48 -22
- package/src/routing/codegen.ts +9 -9
- package/src/search-params/codecs.ts +2 -2
- package/src/search-params/create.ts +3 -3
- package/src/search-params/index.ts +1 -1
- package/src/server/action-client.ts +1 -1
- package/src/server/asset-headers.ts +1 -1
- package/src/server/dev-fetch-instrumentation.ts +96 -0
- package/src/server/dev-logger.ts +49 -0
- package/src/server/form-flash.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/primitives.ts +24 -1
- package/src/server/request-context.ts +7 -1
- package/src/server/route-element-builder.ts +2 -5
- package/src/server/rsc-entry/index.ts +33 -1
- package/src/server/slot-resolver.ts +20 -1
- package/src/server/ssr-entry.ts +21 -5
- package/src/shims/headers.ts +5 -7
- package/src/shims/link.ts +3 -1
- package/src/shims/navigation.ts +7 -17
- package/dist/_chunks/error-boundary-dj-WO5uq.js.map +0 -1
- package/dist/_chunks/interception-DIaZN1bF.js.map +0 -1
- package/dist/_chunks/request-context-D6XHINkR.js.map +0 -1
|
@@ -145,7 +145,7 @@ function getDefaultSerialized<T>(codec: SearchParamCodec<T>): string | null {
|
|
|
145
145
|
* Create a SearchParamsDefinition from a codec map and optional URL key aliases.
|
|
146
146
|
*
|
|
147
147
|
* ```ts
|
|
148
|
-
* import { createSearchParams, fromSchema } from '@timber/app/search-params'
|
|
148
|
+
* import { createSearchParams, fromSchema } from '@timber-js/app/search-params'
|
|
149
149
|
* import { z } from 'zod/v4'
|
|
150
150
|
*
|
|
151
151
|
* export default createSearchParams({
|
|
@@ -290,11 +290,11 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
290
290
|
// ---- useQueryStates ----
|
|
291
291
|
// This is a placeholder that will be replaced by the client runtime.
|
|
292
292
|
// At import time in a server context, calling this throws.
|
|
293
|
-
// The actual implementation wraps nuqs and lives in @timber/app/client.
|
|
293
|
+
// The actual implementation wraps nuqs and lives in @timber-js/app/client.
|
|
294
294
|
function useQueryStates(_options?: QueryStatesOptions): [T, SetParams<T>] {
|
|
295
295
|
throw new Error(
|
|
296
296
|
'useQueryStates() can only be called in a client component. ' +
|
|
297
|
-
'Import from @timber/app/client instead.'
|
|
297
|
+
'Import from @timber-js/app/client instead.'
|
|
298
298
|
);
|
|
299
299
|
}
|
|
300
300
|
|
|
@@ -386,7 +386,7 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
386
386
|
* @example
|
|
387
387
|
* ```ts
|
|
388
388
|
* 'use server'
|
|
389
|
-
* import { validated } from '@timber/app/server'
|
|
389
|
+
* import { validated } from '@timber-js/app/server'
|
|
390
390
|
* import { z } from 'zod'
|
|
391
391
|
*
|
|
392
392
|
* export const createTodo = validated(
|
|
@@ -69,7 +69,7 @@ export function getAssetCacheControl(pathname: string): string {
|
|
|
69
69
|
* Everything else (favicon.ico, robots.txt, etc.) gets a shorter cache.
|
|
70
70
|
*/
|
|
71
71
|
export function generateHeadersFile(): string {
|
|
72
|
-
return `# Auto-generated by @timber/app — static asset cache headers.
|
|
72
|
+
return `# Auto-generated by @timber-js/app — static asset cache headers.
|
|
73
73
|
# See design/25-production-deployments.md §"CDN / Edge Cache"
|
|
74
74
|
|
|
75
75
|
/assets/*
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-mode fetch instrumentation — patches globalThis.fetch to create OTEL
|
|
3
|
+
* spans for every fetch call, giving visibility into async data fetching
|
|
4
|
+
* in the dev request log tree.
|
|
5
|
+
*
|
|
6
|
+
* Only activated in dev mode — zero overhead in production.
|
|
7
|
+
*
|
|
8
|
+
* The spans are automatically children of the active OTEL context (e.g. a
|
|
9
|
+
* timber.page or timber.layout span), so they appear nested under the
|
|
10
|
+
* component that initiated the fetch in the dev log tree.
|
|
11
|
+
*
|
|
12
|
+
* Design ref: 17-logging.md §"Dev Logging", LOCAL-289
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as api from '@opentelemetry/api';
|
|
16
|
+
|
|
17
|
+
export type DevFetchCleanup = () => void;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Patch globalThis.fetch to wrap every call in an OTEL span.
|
|
21
|
+
*
|
|
22
|
+
* Returns a cleanup function that restores the original fetch.
|
|
23
|
+
* Only call this in dev mode.
|
|
24
|
+
*/
|
|
25
|
+
export function instrumentDevFetch(): DevFetchCleanup {
|
|
26
|
+
const originalFetch = globalThis.fetch;
|
|
27
|
+
const tracer = api.trace.getTracer('timber.js');
|
|
28
|
+
|
|
29
|
+
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
30
|
+
const { method, url } = extractFetchInfo(input, init);
|
|
31
|
+
|
|
32
|
+
return tracer.startActiveSpan(
|
|
33
|
+
'timber.fetch',
|
|
34
|
+
{
|
|
35
|
+
attributes: {
|
|
36
|
+
'http.request.method': method,
|
|
37
|
+
'http.url': url,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
async (span) => {
|
|
41
|
+
try {
|
|
42
|
+
const response = await originalFetch(input, init);
|
|
43
|
+
|
|
44
|
+
span.setAttribute('http.response.status_code', response.status);
|
|
45
|
+
|
|
46
|
+
// Surface cache status from standard headers
|
|
47
|
+
const cacheStatus =
|
|
48
|
+
response.headers.get('X-Cache') ?? response.headers.get('CF-Cache-Status');
|
|
49
|
+
if (cacheStatus) {
|
|
50
|
+
span.setAttribute('timber.cache_status', cacheStatus);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
span.setStatus({ code: api.SpanStatusCode.OK });
|
|
54
|
+
span.end();
|
|
55
|
+
return response;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
span.setStatus({ code: api.SpanStatusCode.ERROR });
|
|
58
|
+
if (error instanceof Error) {
|
|
59
|
+
span.setAttribute('timber.fetch_error', error.message);
|
|
60
|
+
span.recordException(error);
|
|
61
|
+
}
|
|
62
|
+
span.end();
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
globalThis.fetch = originalFetch;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract method and URL from the various fetch() call signatures.
|
|
76
|
+
*/
|
|
77
|
+
function extractFetchInfo(
|
|
78
|
+
input: RequestInfo | URL,
|
|
79
|
+
init?: RequestInit
|
|
80
|
+
): { method: string; url: string } {
|
|
81
|
+
let method = init?.method ?? 'GET';
|
|
82
|
+
let url: string;
|
|
83
|
+
|
|
84
|
+
if (input instanceof Request) {
|
|
85
|
+
url = input.url;
|
|
86
|
+
if (!init?.method) {
|
|
87
|
+
method = input.method;
|
|
88
|
+
}
|
|
89
|
+
} else if (input instanceof URL) {
|
|
90
|
+
url = input.toString();
|
|
91
|
+
} else {
|
|
92
|
+
url = input;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { method: method.toUpperCase(), url };
|
|
96
|
+
}
|
package/src/server/dev-logger.ts
CHANGED
|
@@ -107,6 +107,11 @@ function spanLabel(span: ReadableSpan): { label: string; env: string } {
|
|
|
107
107
|
const route = attrs['timber.route'] ?? '/';
|
|
108
108
|
return { label: `page ${route}`, env: 'rsc' };
|
|
109
109
|
}
|
|
110
|
+
case 'timber.fetch': {
|
|
111
|
+
const fetchMethod = attrs['http.request.method'] ?? 'GET';
|
|
112
|
+
const fetchUrl = attrs['http.url'] ?? '';
|
|
113
|
+
return { label: `fetch ${fetchMethod} ${fetchUrl}`, env: 'fetch' };
|
|
114
|
+
}
|
|
110
115
|
default:
|
|
111
116
|
return { label: span.name, env: 'rsc' };
|
|
112
117
|
}
|
|
@@ -282,6 +287,43 @@ export function formatSpanTree(spans: ReadableSpan[], config?: DevLoggerConfig):
|
|
|
282
287
|
return lines.join('\n') + '\n';
|
|
283
288
|
}
|
|
284
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Format a fetch span line with method, URL, timing, duration, and cache status.
|
|
292
|
+
*
|
|
293
|
+
* Output: `├─ fetch GET https://api.example.com/data 12ms → 89ms (77ms) [cache: HIT]`
|
|
294
|
+
*/
|
|
295
|
+
function formatFetchLine(
|
|
296
|
+
span: ReadableSpan,
|
|
297
|
+
prefix: string,
|
|
298
|
+
connector: string,
|
|
299
|
+
startMs: number,
|
|
300
|
+
endMs: number,
|
|
301
|
+
durationMs: number
|
|
302
|
+
): string {
|
|
303
|
+
const method = String(span.attributes['http.request.method'] ?? 'GET');
|
|
304
|
+
const url = String(span.attributes['http.url'] ?? '');
|
|
305
|
+
const statusCode = span.attributes['http.response.status_code'] as number | undefined;
|
|
306
|
+
const cacheStatus = span.attributes['timber.cache_status'] as string | undefined;
|
|
307
|
+
const fetchError = span.attributes['timber.fetch_error'] as string | undefined;
|
|
308
|
+
const isError = span.status.code === 2; // SpanStatusCode.ERROR
|
|
309
|
+
|
|
310
|
+
let line = `${prefix}${connector} ${DIM}fetch ${method}${RESET} ${url}`;
|
|
311
|
+
line += ` ${DIM}${startMs}ms → ${endMs}ms (${durationMs}ms)${RESET}`;
|
|
312
|
+
|
|
313
|
+
if (cacheStatus) {
|
|
314
|
+
line += ` ${DIM}[cdn: ${cacheStatus}]${RESET}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (isError) {
|
|
318
|
+
const errMsg = fetchError ? `: ${fetchError}` : '';
|
|
319
|
+
line += ` ${RED}ERROR${errMsg}${RESET}`;
|
|
320
|
+
} else if (statusCode && statusCode >= 400) {
|
|
321
|
+
line += ` ${YELLOW}${statusCode}${RESET}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return line;
|
|
325
|
+
}
|
|
326
|
+
|
|
285
327
|
/**
|
|
286
328
|
* Format a single span tree node with children, timing, and annotations.
|
|
287
329
|
*/
|
|
@@ -301,6 +343,13 @@ function formatSpanNode(
|
|
|
301
343
|
const durationMs = endMs - startMs;
|
|
302
344
|
const isSlow = durationMs > slowPhaseMs;
|
|
303
345
|
|
|
346
|
+
// Fetch spans get special formatting: no env tag, duration in parens, cache status
|
|
347
|
+
if (node.span.name === 'timber.fetch') {
|
|
348
|
+
const fetchLine = formatFetchLine(node.span, prefix, connector, startMs, endMs, durationMs);
|
|
349
|
+
lines.push(fetchLine);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
304
353
|
// Access results from span attributes
|
|
305
354
|
const accessResult = node.span.attributes['timber.result'] as string | undefined;
|
|
306
355
|
|
package/src/server/form-flash.ts
CHANGED
|
@@ -60,7 +60,7 @@ const formFlashAls = new AsyncLocalStorage<FormFlashData>();
|
|
|
60
60
|
*
|
|
61
61
|
* ```tsx
|
|
62
62
|
* // app/contact/page.tsx (server component)
|
|
63
|
-
* import { getFormFlash } from '@timber/app/server'
|
|
63
|
+
* import { getFormFlash } from '@timber-js/app/server'
|
|
64
64
|
*
|
|
65
65
|
* export default function ContactPage() {
|
|
66
66
|
* const flash = getFormFlash()
|
package/src/server/index.ts
CHANGED
package/src/server/primitives.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Server-side primitives: deny, redirect, redirectExternal, RenderError, waitUntil
|
|
1
|
+
// Server-side primitives: deny, redirect, redirectExternal, RenderError, waitUntil, SsrStreamError
|
|
2
2
|
//
|
|
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.
|
|
@@ -262,3 +262,26 @@ export function waitUntil(promise: Promise<unknown>, adapter: WaitUntilAdapter):
|
|
|
262
262
|
export function _resetWaitUntilWarning(): void {
|
|
263
263
|
_waitUntilWarned = false;
|
|
264
264
|
}
|
|
265
|
+
|
|
266
|
+
// ─── SsrStreamError ─────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Error thrown when SSR's renderToReadableStream fails due to an error
|
|
270
|
+
* in the decoded RSC stream (e.g., uncontained slot errors).
|
|
271
|
+
*
|
|
272
|
+
* The RSC entry checks for this error type in its catch block to avoid
|
|
273
|
+
* re-executing server components via renderDenyPage. Instead, it renders
|
|
274
|
+
* a bare deny/error page without layout wrapping.
|
|
275
|
+
*
|
|
276
|
+
* Defined in primitives.ts (not ssr-entry.ts) because ssr-entry.ts imports
|
|
277
|
+
* react-dom/server which cannot be loaded in the RSC environment.
|
|
278
|
+
*/
|
|
279
|
+
export class SsrStreamError extends Error {
|
|
280
|
+
constructor(
|
|
281
|
+
message: string,
|
|
282
|
+
public readonly cause: unknown
|
|
283
|
+
) {
|
|
284
|
+
super(message);
|
|
285
|
+
this.name = 'SsrStreamError';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -46,7 +46,13 @@ interface CookieEntry {
|
|
|
46
46
|
options: CookieOptions;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/** @internal */
|
|
50
|
+
export const requestContextAls = new AsyncLocalStorage<RequestContextStore>();
|
|
51
|
+
|
|
52
|
+
// No fallback needed — we use enterWith() instead of run() to ensure
|
|
53
|
+
// the ALS context persists for the entire request lifecycle including
|
|
54
|
+
// async stream consumption by React's renderToReadableStream.
|
|
55
|
+
|
|
50
56
|
|
|
51
57
|
// ─── Cookie Signing Secrets ──────────────────────────────────────────────
|
|
52
58
|
|
|
@@ -23,10 +23,7 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
23
23
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
24
24
|
import type { HeadElement as MetadataHeadElement } from './metadata.js';
|
|
25
25
|
import type { Metadata } from './types.js';
|
|
26
|
-
import {
|
|
27
|
-
METADATA_ROUTE_CONVENTIONS,
|
|
28
|
-
getMetadataRouteAutoLink,
|
|
29
|
-
} from './metadata-routes.js';
|
|
26
|
+
import { METADATA_ROUTE_CONVENTIONS, getMetadataRouteAutoLink } from './metadata-routes.js';
|
|
30
27
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
31
28
|
import { AccessGate } from './access-gate.js';
|
|
32
29
|
import { resolveSlotElement } from './slot-resolver.js';
|
|
@@ -155,7 +152,7 @@ export async function buildRouteElement(
|
|
|
155
152
|
// Load page (leaf segment only)
|
|
156
153
|
if (isLeaf && segment.page) {
|
|
157
154
|
// Load and apply search-params.ts definition before rendering so
|
|
158
|
-
// searchParams() from @timber/app/server returns parsed typed values.
|
|
155
|
+
// searchParams() from @timber-js/app/server returns parsed typed values.
|
|
159
156
|
if (segment.searchParams) {
|
|
160
157
|
const spMod = (await segment.searchParams.load()) as {
|
|
161
158
|
default?: SearchParamsDefinition<Record<string, unknown>>;
|
|
@@ -24,6 +24,7 @@ import buildManifest from 'virtual:timber-build-manifest';
|
|
|
24
24
|
|
|
25
25
|
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
|
|
26
26
|
|
|
27
|
+
import React, { createElement } from 'react';
|
|
27
28
|
import { createPipeline } from '#/server/pipeline.js';
|
|
28
29
|
import { initDevTracing } from '#/server/tracing.js';
|
|
29
30
|
import type { PipelineConfig, RouteMatch, InterceptionContext } from '#/server/pipeline.js';
|
|
@@ -31,7 +32,7 @@ import { logRenderError } from '#/server/logger.js';
|
|
|
31
32
|
import { resolveLogMode } from '#/server/dev-logger.js';
|
|
32
33
|
import { createRouteMatcher, createMetadataRouteMatcher } from '#/server/route-matcher.js';
|
|
33
34
|
import type { ManifestSegmentNode } from '#/server/route-matcher.js';
|
|
34
|
-
import { DenySignal, RedirectSignal, RenderError } from '#/server/primitives.js';
|
|
35
|
+
import { DenySignal, RedirectSignal, RenderError, SsrStreamError } from '#/server/primitives.js';
|
|
35
36
|
import { buildClientScripts } from '#/server/html-injectors.js';
|
|
36
37
|
import type { ClientBootstrapConfig } from '#/server/html-injectors.js';
|
|
37
38
|
import { renderDenyPage, renderDenyPageAsRsc } from '#/server/deny-renderer.js';
|
|
@@ -125,6 +126,10 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
125
126
|
const devLogMode = resolveLogMode();
|
|
126
127
|
if (devLogMode !== 'quiet') {
|
|
127
128
|
await initDevTracing({ mode: devLogMode, slowPhaseMs });
|
|
129
|
+
// Patch globalThis.fetch to create OTEL spans for fetch calls.
|
|
130
|
+
// Spans appear as children of the active component span in the dev log tree.
|
|
131
|
+
const { instrumentDevFetch } = await import('../dev-fetch-instrumentation.js');
|
|
132
|
+
instrumentDevFetch();
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
|
|
@@ -368,6 +373,7 @@ async function renderRoute(
|
|
|
368
373
|
let redirectSignal: RedirectSignal | null = null;
|
|
369
374
|
let renderError: { error: unknown; status: number } | null = null;
|
|
370
375
|
let rscStream: ReadableStream<Uint8Array> | undefined;
|
|
376
|
+
|
|
371
377
|
try {
|
|
372
378
|
rscStream = renderToReadableStream(
|
|
373
379
|
element,
|
|
@@ -671,6 +677,32 @@ async function renderRoute(
|
|
|
671
677
|
return new Response(null, { status: 499 });
|
|
672
678
|
}
|
|
673
679
|
|
|
680
|
+
// SsrStreamError: SSR's renderToReadableStream failed because the RSC
|
|
681
|
+
// stream contained an uncontained error (e.g., slot without error boundary).
|
|
682
|
+
// Render the deny/error page WITHOUT layout wrapping to avoid re-executing
|
|
683
|
+
// server components (which call headers()/cookies() and fail in SSR's
|
|
684
|
+
// separate ALS scope). See LOCAL-293.
|
|
685
|
+
if (ssrError instanceof SsrStreamError) {
|
|
686
|
+
const sig = redirectSignal as RedirectSignal | null;
|
|
687
|
+
if (sig) return buildRedirectResponse(_req, sig, responseHeaders);
|
|
688
|
+
if (denySignal) {
|
|
689
|
+
// Render deny page without layouts — pass empty layout list
|
|
690
|
+
return renderDenyPage(
|
|
691
|
+
denySignal, segments, [] as LayoutEntry[],
|
|
692
|
+
_req, match, responseHeaders, clientBootstrap, createDebugChannelSink, callSsr
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
const err = renderError as { error: unknown; status: number } | null;
|
|
696
|
+
if (err) {
|
|
697
|
+
return renderErrorPage(
|
|
698
|
+
err.error, err.status, segments, [] as LayoutEntry[],
|
|
699
|
+
_req, match, responseHeaders, clientBootstrap
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
// No captured signal — return bare 500
|
|
703
|
+
return new Response(null, { status: 500, headers: responseHeaders });
|
|
704
|
+
}
|
|
705
|
+
|
|
674
706
|
// SSR shell rendering failed — the error was outside Suspense.
|
|
675
707
|
// Check captured signals (redirect, deny, render error).
|
|
676
708
|
const signalResponse = checkCapturedSignals();
|
|
@@ -20,6 +20,8 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
20
20
|
import type { RouteMatch, InterceptionContext } from './pipeline.js';
|
|
21
21
|
import { SlotAccessGate } from './access-gate.js';
|
|
22
22
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
23
|
+
import { TimberErrorBoundary } from '#/client/error-boundary.js';
|
|
24
|
+
import SlotErrorFallback from '#/client/slot-error-fallback.js';
|
|
23
25
|
|
|
24
26
|
type CreateElementFn = (...args: unknown[]) => React.ReactElement;
|
|
25
27
|
|
|
@@ -142,6 +144,18 @@ export async function resolveSlotElement(
|
|
|
142
144
|
// Wrap with slot root's error boundaries (outermost)
|
|
143
145
|
element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
|
|
144
146
|
|
|
147
|
+
// Catch-all error boundary: ensures slot errors NEVER propagate to the
|
|
148
|
+
// parent layout. Without this, a slot without error.tsx that throws
|
|
149
|
+
// causes SSR's renderToReadableStream to reject, triggering renderDenyPage
|
|
150
|
+
// which re-executes all layout server components (including headers() calls
|
|
151
|
+
// that fail in the SSR environment). The null fallback means the slot
|
|
152
|
+
// degrades to nothing — consistent with the slot access denial behavior.
|
|
153
|
+
// See design/02-rendering-pipeline.md §"Slot Access Failure = Graceful Degradation"
|
|
154
|
+
element = h(TimberErrorBoundary, {
|
|
155
|
+
fallbackComponent: SlotErrorFallback,
|
|
156
|
+
children: element,
|
|
157
|
+
});
|
|
158
|
+
|
|
145
159
|
return element;
|
|
146
160
|
}
|
|
147
161
|
}
|
|
@@ -187,9 +201,14 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
|
|
|
187
201
|
|
|
188
202
|
// Find the parent segment that owns this slot by comparing urlPaths.
|
|
189
203
|
// The slot's urlPath matches its parent's urlPath (slots don't add URL depth).
|
|
204
|
+
// Search BACKWARDS to find the deepest (last) matching segment. Multiple
|
|
205
|
+
// segments can share the same urlPath when route groups are involved (e.g.,
|
|
206
|
+
// Root urlPath='/' and (browse) urlPath='/'). The slot's parent is always
|
|
207
|
+
// the deepest one — searching forward would incorrectly pick the root,
|
|
208
|
+
// making remainingSegments too long and breaking slot matching.
|
|
190
209
|
const slotUrlPath = slotNode.urlPath;
|
|
191
210
|
let parentIndex = -1;
|
|
192
|
-
for (let i =
|
|
211
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
193
212
|
if (segments[i].urlPath === slotUrlPath) {
|
|
194
213
|
parentIndex = i;
|
|
195
214
|
break;
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';
|
|
|
18
18
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
19
19
|
|
|
20
20
|
import { renderSsrStream, buildSsrResponse } from './ssr-render.js';
|
|
21
|
+
import { formatSsrError } from './error-formatter.js';
|
|
22
|
+
import { SsrStreamError } from './primitives.js';
|
|
21
23
|
import { injectHead, injectRscPayload } from './html-injectors.js';
|
|
22
24
|
import { withNuqsSsrAdapter } from './nuqs-ssr-provider.js';
|
|
23
25
|
import { withSpan } from './tracing.js';
|
|
@@ -141,11 +143,25 @@ export async function handleSsr(
|
|
|
141
143
|
// in the shell HTML. This executes immediately during parsing — even
|
|
142
144
|
// while Suspense boundaries are still streaming — triggering module
|
|
143
145
|
// loading via dynamic import() so hydration can start early.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
146
|
+
let htmlStream: ReadableStream<Uint8Array>;
|
|
147
|
+
try {
|
|
148
|
+
htmlStream = await renderSsrStream(wrappedElement, {
|
|
149
|
+
bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
|
|
150
|
+
deferSuspenseFor: navContext.deferSuspenseFor,
|
|
151
|
+
signal: navContext.signal,
|
|
152
|
+
});
|
|
153
|
+
} catch (renderError) {
|
|
154
|
+
// SSR shell rendering failed — the RSC stream contained an error
|
|
155
|
+
// that wasn't caught by any error boundary in the decoded tree.
|
|
156
|
+
// Wrap in SsrStreamError so the RSC entry can handle it without
|
|
157
|
+
// re-executing server components via renderDenyPage.
|
|
158
|
+
// See LOCAL-293.
|
|
159
|
+
console.error('[timber] SSR shell failed from RSC stream error:', formatSsrError(renderError));
|
|
160
|
+
throw new SsrStreamError(
|
|
161
|
+
'SSR renderToReadableStream failed due to RSC stream error',
|
|
162
|
+
renderError
|
|
163
|
+
);
|
|
164
|
+
}
|
|
149
165
|
|
|
150
166
|
// Inject metadata into <head>, then interleave RSC payload chunks
|
|
151
167
|
// into the body as they arrive from the tee'd RSC stream.
|
package/src/shims/headers.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shim: next/headers → timber
|
|
2
|
+
* Shim: next/headers → timber server
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* See design/14-ecosystem.md §"next/headers" for the full shim audit.
|
|
4
|
+
* Imports from @timber-js/app/server which Vite resolves to dist/server/index.js
|
|
5
|
+
* via native package.json exports. This ensures the same ALS singleton as the
|
|
6
|
+
* pipeline (both import from the same shared request-context chunk in dist/).
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
|
-
export { headers, cookies } from '
|
|
9
|
+
export { headers, cookies } from '@timber-js/app/server';
|
package/src/shims/link.ts
CHANGED
package/src/shims/navigation.ts
CHANGED
|
@@ -1,23 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shim: next/navigation → timber navigation primitives
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* that
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* Note: nuqs imports next/navigation.js (with .js extension).
|
|
9
|
-
* The timber-shims plugin strips .js before matching.
|
|
10
|
-
*
|
|
11
|
-
* Intentional divergences from Next.js:
|
|
12
|
-
* - useRouter().replace() currently uses pushState (same as push) —
|
|
13
|
-
* timber's router doesn't distinguish push/replace yet.
|
|
14
|
-
* - redirect() does not accept a RedirectType argument — timber
|
|
15
|
-
* always uses replace semantics for redirects.
|
|
16
|
-
* - permanentRedirect() delegates to redirect(path, 308).
|
|
17
|
-
* See design/14-ecosystem.md for the full shim audit.
|
|
4
|
+
* Client hooks use #/ source imports (individual files with 'use client' directives
|
|
5
|
+
* that the RSC plugin detects).
|
|
6
|
+
* Server functions use @timber-js/app/server (resolved to dist/ via native exports)
|
|
7
|
+
* for ALS singleton consistency.
|
|
18
8
|
*/
|
|
19
9
|
|
|
20
|
-
// Hooks (client-side)
|
|
10
|
+
// Hooks (client-side — must use source imports for RSC 'use client' detection)
|
|
21
11
|
export { useParams } from '#/client/use-params.js';
|
|
22
12
|
export { usePathname } from '#/client/use-pathname.js';
|
|
23
13
|
export { useSearchParams } from '#/client/use-search-params.js';
|
|
@@ -27,5 +17,5 @@ export {
|
|
|
27
17
|
useSelectedLayoutSegments,
|
|
28
18
|
} from '#/client/use-selected-layout-segment.js';
|
|
29
19
|
|
|
30
|
-
// Functions (server-side)
|
|
31
|
-
export { redirect, permanentRedirect, notFound, RedirectType } from '
|
|
20
|
+
// Functions (server-side — resolved to dist/ for ALS singleton consistency)
|
|
21
|
+
export { redirect, permanentRedirect, notFound, RedirectType } from '@timber-js/app/server';
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"error-boundary-dj-WO5uq.js","names":[],"sources":["../../src/client/error-boundary.tsx"],"sourcesContent":["'use client';\n\n/**\n * Framework-injected React error boundary.\n *\n * Catches errors thrown by children and renders a fallback component\n * with the appropriate props based on error type:\n * - DenySignal (4xx) → { status, dangerouslyPassData }\n * - RenderError (5xx) → { error, digest, reset }\n * - Unhandled error → { error, digest: null, reset }\n *\n * The `status` prop controls which errors this boundary catches:\n * - Specific code (e.g. 403) → only that status\n * - Category (400) → any 4xx\n * - Category (500) → any 5xx\n * - Omitted → catches everything (error.tsx behavior)\n *\n * See design/10-error-handling.md §\"Status-Code Files\"\n */\n\nimport { Component, createElement, type ReactNode } from 'react';\n\n// ─── Page Unload Detection ───────────────────────────────────────────────────\n// Track whether the page is being unloaded (user refreshed or navigated away).\n// When this is true, error boundaries suppress activation — the error is from\n// the aborted connection, not an application error.\nlet _isUnloading = false;\nif (typeof window !== 'undefined') {\n window.addEventListener('beforeunload', () => {\n _isUnloading = true;\n });\n window.addEventListener('pagehide', () => {\n _isUnloading = true;\n });\n}\n\n// ─── Digest Types ────────────────────────────────────────────────────────────\n\n/** Structured digest returned by RSC onError for DenySignal. */\ninterface DenyDigest {\n type: 'deny';\n status: number;\n data: unknown;\n}\n\n/** Structured digest returned by RSC onError for RenderError. */\ninterface RenderErrorDigest {\n type: 'render-error';\n code: string;\n data: unknown;\n status: number;\n}\n\n/** Structured digest returned by RSC onError for RedirectSignal. */\ninterface RedirectDigest {\n type: 'redirect';\n location: string;\n status: number;\n}\n\ntype ParsedDigest = DenyDigest | RenderErrorDigest | RedirectDigest;\n\n// ─── Props & State ───────────────────────────────────────────────────────────\n\nexport interface TimberErrorBoundaryProps {\n /** The component to render when an error is caught. */\n fallbackComponent: (...args: unknown[]) => ReactNode;\n /**\n * Status code filter. If set, only catches errors matching this status.\n * 400 = any 4xx, 500 = any 5xx, specific number = exact match.\n */\n status?: number;\n children: ReactNode;\n}\n\ninterface TimberErrorBoundaryState {\n hasError: boolean;\n error: Error | null;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport class TimberErrorBoundary extends Component<\n TimberErrorBoundaryProps,\n TimberErrorBoundaryState\n> {\n constructor(props: TimberErrorBoundaryProps) {\n super(props);\n this.state = { hasError: false, error: null };\n }\n\n static getDerivedStateFromError(error: Error): TimberErrorBoundaryState {\n // Suppress error boundaries during page unload (refresh/navigate away).\n // The aborted connection causes React's streaming hydration to error,\n // but the page is about to be replaced — showing an error boundary\n // would be a jarring flash for the user.\n if (_isUnloading) {\n return { hasError: false, error: null };\n }\n return { hasError: true, error };\n }\n\n componentDidUpdate(prevProps: TimberErrorBoundaryProps): void {\n // Reset error state when children change (e.g. client-side navigation).\n // Without this, navigating from one error page to another keeps the\n // stale error — getDerivedStateFromError doesn't re-fire for new children.\n if (this.state.hasError && prevProps.children !== this.props.children) {\n this.setState({ hasError: false, error: null });\n }\n }\n\n /** Reset the error state so children re-render. */\n private reset = () => {\n this.setState({ hasError: false, error: null });\n };\n\n render(): ReactNode {\n if (!this.state.hasError || !this.state.error) {\n return this.props.children;\n }\n\n const error = this.state.error;\n const parsed = parseDigest(error);\n\n // RedirectSignal errors must propagate through all error boundaries\n // so the SSR shell fails and the pipeline catch block can produce a\n // proper HTTP redirect response. See design/04-authorization.md.\n if (parsed?.type === 'redirect') {\n throw error;\n }\n\n // If this boundary has a status filter, check whether the error matches.\n // Non-matching errors re-throw so an outer boundary can catch them.\n if (this.props.status != null) {\n const errorStatus = getErrorStatus(parsed, error);\n if (errorStatus == null || !statusMatches(this.props.status, errorStatus)) {\n // Re-throw: this boundary doesn't handle this error.\n throw error;\n }\n }\n\n // Render the fallback component with the right props shape.\n if (parsed?.type === 'deny') {\n return createElement(this.props.fallbackComponent as never, {\n status: parsed.status,\n dangerouslyPassData: parsed.data,\n });\n }\n\n // 5xx / RenderError / unhandled error\n const digest =\n parsed?.type === 'render-error' ? { code: parsed.code, data: parsed.data } : null;\n\n return createElement(this.props.fallbackComponent as never, {\n error,\n digest,\n reset: this.reset,\n });\n }\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Parse the structured digest from the error.\n * React sets `error.digest` from the string returned by RSC's onError.\n */\nfunction parseDigest(error: Error): ParsedDigest | null {\n const raw = (error as { digest?: string }).digest;\n if (typeof raw !== 'string') return null;\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === 'object' && typeof parsed.type === 'string') {\n return parsed as ParsedDigest;\n }\n } catch {\n // Not JSON — legacy or unknown digest format\n }\n return null;\n}\n\n/**\n * Extract the HTTP status code from a parsed digest or error message.\n * Falls back to message pattern matching for errors without a digest.\n */\nfunction getErrorStatus(parsed: ParsedDigest | null, error: Error): number | null {\n if (parsed?.type === 'deny') return parsed.status;\n if (parsed?.type === 'render-error') return parsed.status;\n if (parsed?.type === 'redirect') return parsed.status;\n\n // Fallback: parse DenySignal message pattern for errors that lost their digest\n const match = error.message.match(/^Access denied with status (\\d+)$/);\n if (match) return parseInt(match[1], 10);\n\n // Unhandled errors are implicitly 500\n return 500;\n}\n\n/**\n * Check whether an error's status matches the boundary's status filter.\n * Category markers (400, 500) match any status in that range.\n */\nfunction statusMatches(boundaryStatus: number, errorStatus: number): boolean {\n // Category catch-all: 400 matches any 4xx, 500 matches any 5xx\n if (boundaryStatus === 400) return errorStatus >= 400 && errorStatus <= 499;\n if (boundaryStatus === 500) return errorStatus >= 500 && errorStatus <= 599;\n // Exact match\n return boundaryStatus === errorStatus;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA0BA,IAAI,eAAe;AACnB,IAAI,OAAO,WAAW,aAAa;AACjC,QAAO,iBAAiB,sBAAsB;AAC5C,iBAAe;GACf;AACF,QAAO,iBAAiB,kBAAkB;AACxC,iBAAe;GACf;;AAiDJ,IAAa,sBAAb,cAAyC,UAGvC;CACA,YAAY,OAAiC;AAC3C,QAAM,MAAM;AACZ,OAAK,QAAQ;GAAE,UAAU;GAAO,OAAO;GAAM;;CAG/C,OAAO,yBAAyB,OAAwC;AAKtE,MAAI,aACF,QAAO;GAAE,UAAU;GAAO,OAAO;GAAM;AAEzC,SAAO;GAAE,UAAU;GAAM;GAAO;;CAGlC,mBAAmB,WAA2C;AAI5D,MAAI,KAAK,MAAM,YAAY,UAAU,aAAa,KAAK,MAAM,SAC3D,MAAK,SAAS;GAAE,UAAU;GAAO,OAAO;GAAM,CAAC;;;CAKnD,cAAsB;AACpB,OAAK,SAAS;GAAE,UAAU;GAAO,OAAO;GAAM,CAAC;;CAGjD,SAAoB;AAClB,MAAI,CAAC,KAAK,MAAM,YAAY,CAAC,KAAK,MAAM,MACtC,QAAO,KAAK,MAAM;EAGpB,MAAM,QAAQ,KAAK,MAAM;EACzB,MAAM,SAAS,YAAY,MAAM;AAKjC,MAAI,QAAQ,SAAS,WACnB,OAAM;AAKR,MAAI,KAAK,MAAM,UAAU,MAAM;GAC7B,MAAM,cAAc,eAAe,QAAQ,MAAM;AACjD,OAAI,eAAe,QAAQ,CAAC,cAAc,KAAK,MAAM,QAAQ,YAAY,CAEvE,OAAM;;AAKV,MAAI,QAAQ,SAAS,OACnB,QAAO,cAAc,KAAK,MAAM,mBAA4B;GAC1D,QAAQ,OAAO;GACf,qBAAqB,OAAO;GAC7B,CAAC;EAIJ,MAAM,SACJ,QAAQ,SAAS,iBAAiB;GAAE,MAAM,OAAO;GAAM,MAAM,OAAO;GAAM,GAAG;AAE/E,SAAO,cAAc,KAAK,MAAM,mBAA4B;GAC1D;GACA;GACA,OAAO,KAAK;GACb,CAAC;;;;;;;AAUN,SAAS,YAAY,OAAmC;CACtD,MAAM,MAAO,MAA8B;AAC3C,KAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,KAAI;EACF,MAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,MAAI,UAAU,OAAO,WAAW,YAAY,OAAO,OAAO,SAAS,SACjE,QAAO;SAEH;AAGR,QAAO;;;;;;AAOT,SAAS,eAAe,QAA6B,OAA6B;AAChF,KAAI,QAAQ,SAAS,OAAQ,QAAO,OAAO;AAC3C,KAAI,QAAQ,SAAS,eAAgB,QAAO,OAAO;AACnD,KAAI,QAAQ,SAAS,WAAY,QAAO,OAAO;CAG/C,MAAM,QAAQ,MAAM,QAAQ,MAAM,oCAAoC;AACtE,KAAI,MAAO,QAAO,SAAS,MAAM,IAAI,GAAG;AAGxC,QAAO;;;;;;AAOT,SAAS,cAAc,gBAAwB,aAA8B;AAE3E,KAAI,mBAAmB,IAAK,QAAO,eAAe,OAAO,eAAe;AACxE,KAAI,mBAAmB,IAAK,QAAO,eAAe,OAAO,eAAe;AAExE,QAAO,mBAAmB"}
|