@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.
Files changed (103) hide show
  1. package/dist/_chunks/{interception-DIaZN1bF.js → interception-c-a3uODY.js} +8 -8
  2. package/dist/_chunks/interception-c-a3uODY.js.map +1 -0
  3. package/dist/_chunks/{registry-DUIpYD_x.js → registry-BfPM41ri.js} +1 -1
  4. package/dist/_chunks/{registry-DUIpYD_x.js.map → registry-BfPM41ri.js.map} +1 -1
  5. package/dist/_chunks/{request-context-D6XHINkR.js → request-context-BzES06i1.js} +2 -1
  6. package/dist/_chunks/request-context-BzES06i1.js.map +1 -0
  7. package/dist/_chunks/{use-cookie-8ZlA0rr3.js → use-cookie-HcvNlW4L.js} +1 -1
  8. package/dist/_chunks/{use-cookie-8ZlA0rr3.js.map → use-cookie-HcvNlW4L.js.map} +1 -1
  9. package/dist/adapters/cloudflare.d.ts +2 -2
  10. package/dist/adapters/cloudflare.js +4 -4
  11. package/dist/adapters/cloudflare.js.map +1 -1
  12. package/dist/adapters/nitro.d.ts +1 -1
  13. package/dist/adapters/nitro.js +4 -4
  14. package/dist/adapters/nitro.js.map +1 -1
  15. package/dist/cache/index.js.map +1 -1
  16. package/dist/{_chunks/error-boundary-dj-WO5uq.js → client/error-boundary.js} +4 -2
  17. package/dist/client/error-boundary.js.map +1 -0
  18. package/dist/client/form.d.ts +1 -1
  19. package/dist/client/index.js +10 -9
  20. package/dist/client/index.js.map +1 -1
  21. package/dist/client/link.d.ts.map +1 -1
  22. package/dist/client/slot-error-fallback.d.ts +13 -0
  23. package/dist/client/slot-error-fallback.d.ts.map +1 -0
  24. package/dist/client/use-link-status.d.ts +1 -1
  25. package/dist/client/use-navigation-pending.d.ts +1 -1
  26. package/dist/content/index.d.ts +1 -1
  27. package/dist/cookies/define-cookie.d.ts +2 -2
  28. package/dist/cookies/index.d.ts +1 -1
  29. package/dist/cookies/index.d.ts.map +1 -1
  30. package/dist/cookies/index.js +4 -4
  31. package/dist/cookies/index.js.map +1 -1
  32. package/dist/index.js +23 -22
  33. package/dist/index.js.map +1 -1
  34. package/dist/plugins/dev-logs.d.ts +1 -1
  35. package/dist/plugins/dynamic-transform.d.ts +1 -1
  36. package/dist/plugins/shims.d.ts +5 -0
  37. package/dist/plugins/shims.d.ts.map +1 -1
  38. package/dist/routing/codegen.d.ts +1 -1
  39. package/dist/routing/index.js +1 -1
  40. package/dist/search-params/codecs.d.ts +2 -2
  41. package/dist/search-params/create.d.ts +1 -1
  42. package/dist/search-params/index.js +5 -5
  43. package/dist/search-params/index.js.map +1 -1
  44. package/dist/server/action-client.d.ts +1 -1
  45. package/dist/server/dev-fetch-instrumentation.d.ts +22 -0
  46. package/dist/server/dev-fetch-instrumentation.d.ts.map +1 -0
  47. package/dist/server/dev-logger.d.ts.map +1 -1
  48. package/dist/server/form-flash.d.ts +1 -1
  49. package/dist/server/index.js +4 -4
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/primitives.d.ts +15 -0
  52. package/dist/server/primitives.d.ts.map +1 -1
  53. package/dist/server/request-context.d.ts +32 -0
  54. package/dist/server/request-context.d.ts.map +1 -1
  55. package/dist/server/route-element-builder.d.ts.map +1 -1
  56. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  57. package/dist/server/slot-resolver.d.ts.map +1 -1
  58. package/dist/server/ssr-entry.d.ts.map +1 -1
  59. package/dist/shims/headers.d.ts +5 -7
  60. package/dist/shims/headers.d.ts.map +1 -1
  61. package/dist/shims/link.d.ts +1 -1
  62. package/dist/shims/link.d.ts.map +1 -1
  63. package/dist/shims/navigation.d.ts +5 -15
  64. package/dist/shims/navigation.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/adapters/cloudflare.ts +4 -4
  67. package/src/adapters/nitro.ts +4 -4
  68. package/src/cache/index.ts +1 -1
  69. package/src/client/form.tsx +1 -1
  70. package/src/client/index.ts +1 -1
  71. package/src/client/link.tsx +2 -0
  72. package/src/client/slot-error-fallback.tsx +16 -0
  73. package/src/client/use-link-status.ts +1 -1
  74. package/src/client/use-navigation-pending.ts +1 -1
  75. package/src/content/index.ts +1 -1
  76. package/src/cookies/define-cookie.ts +2 -2
  77. package/src/cookies/index.ts +2 -6
  78. package/src/plugins/cache-transform.ts +1 -1
  79. package/src/plugins/dev-logs.ts +2 -2
  80. package/src/plugins/dynamic-transform.ts +2 -2
  81. package/src/plugins/shims.ts +48 -22
  82. package/src/routing/codegen.ts +9 -9
  83. package/src/search-params/codecs.ts +2 -2
  84. package/src/search-params/create.ts +3 -3
  85. package/src/search-params/index.ts +1 -1
  86. package/src/server/action-client.ts +1 -1
  87. package/src/server/asset-headers.ts +1 -1
  88. package/src/server/dev-fetch-instrumentation.ts +96 -0
  89. package/src/server/dev-logger.ts +49 -0
  90. package/src/server/form-flash.ts +1 -1
  91. package/src/server/index.ts +1 -1
  92. package/src/server/primitives.ts +24 -1
  93. package/src/server/request-context.ts +7 -1
  94. package/src/server/route-element-builder.ts +2 -5
  95. package/src/server/rsc-entry/index.ts +33 -1
  96. package/src/server/slot-resolver.ts +20 -1
  97. package/src/server/ssr-entry.ts +21 -5
  98. package/src/shims/headers.ts +5 -7
  99. package/src/shims/link.ts +3 -1
  100. package/src/shims/navigation.ts +7 -17
  101. package/dist/_chunks/error-boundary-dj-WO5uq.js.map +0 -1
  102. package/dist/_chunks/interception-DIaZN1bF.js.map +0 -1
  103. 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
 
@@ -1,4 +1,4 @@
1
- // @timber/app/search-params — Typed search params
1
+ // @timber-js/app/search-params — Typed search params
2
2
 
3
3
  // Core types and factory
4
4
  export type {
@@ -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
+ }
@@ -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
 
@@ -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()
@@ -1,4 +1,4 @@
1
- // @timber/app/server — Server-side primitives
1
+ // @timber-js/app/server — Server-side primitives
2
2
  // These are the primary imports for server components, middleware, and access files.
3
3
 
4
4
  export type { AccessContext } from './types';
@@ -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
- const requestContextAls = new AsyncLocalStorage<RequestContextStore>();
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 = 0; i < segments.length; 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;
@@ -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
- const htmlStream = await renderSsrStream(wrappedElement, {
145
- bootstrapScriptContent: navContext.bootstrapScriptContent || undefined,
146
- deferSuspenseFor: navContext.deferSuspenseFor,
147
- signal: navContext.signal,
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.
@@ -1,11 +1,9 @@
1
1
  /**
2
- * Shim: next/headers → timber request context
2
+ * Shim: next/headers → timber server
3
3
  *
4
- * Re-exports timber's ALS-backed headers() and cookies() for libraries
5
- * that import from next/headers. These are real implementations backed
6
- * by AsyncLocalStorage, not stubs.
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 '#/server/request-context.js';
9
+ export { headers, cookies } from '@timber-js/app/server';
package/src/shims/link.ts CHANGED
@@ -1,5 +1,7 @@
1
+ 'use client';
2
+
1
3
  /**
2
- * Shim: next/link → @timber/app/client Link
4
+ * Shim: next/link → @timber-js/app/client Link
3
5
  *
4
6
  * Re-exports timber's Link component so libraries that import next/link
5
7
  * get the timber equivalent without modification.
@@ -1,23 +1,13 @@
1
1
  /**
2
2
  * Shim: next/navigation → timber navigation primitives
3
3
  *
4
- * Re-exports timber's navigation hooks and functions for libraries
5
- * that import from next/navigation. Covers the App Router API surface
6
- * used by ecosystem libraries (nuqs, next-intl, etc.).
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 '#/server/primitives.js';
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"}