@timber-js/app 0.2.0-alpha.56 → 0.2.0-alpha.58

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 (90) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -1
  3. package/dist/_chunks/define-D5STJpIr.js +121 -0
  4. package/dist/_chunks/define-D5STJpIr.js.map +1 -0
  5. package/dist/_chunks/{define-cookie-k9btcEfI.js → define-cookie-DtAavax4.js} +4 -4
  6. package/dist/_chunks/{define-cookie-k9btcEfI.js.map → define-cookie-DtAavax4.js.map} +1 -1
  7. package/dist/_chunks/{error-boundary-B9vT_YK_.js → error-boundary-DpZJBCqh.js} +1 -1
  8. package/dist/_chunks/{error-boundary-B9vT_YK_.js.map → error-boundary-DpZJBCqh.js.map} +1 -1
  9. package/dist/_chunks/{interception-D2djYaIm.js → interception-Cey5DCGr.js} +18 -1
  10. package/dist/_chunks/interception-Cey5DCGr.js.map +1 -0
  11. package/dist/_chunks/{request-context-0h-6Voad.js → request-context-0wfZsnhh.js} +3 -1
  12. package/dist/_chunks/request-context-0wfZsnhh.js.map +1 -0
  13. package/dist/_chunks/{segment-context-Bmugn-ao.js → segment-context-CyaM1mrD.js} +1 -1
  14. package/dist/_chunks/{segment-context-Bmugn-ao.js.map → segment-context-CyaM1mrD.js.map} +1 -1
  15. package/dist/_chunks/{stale-reload-4L-_skC7.js → stale-reload-DKN3aXxR.js} +16 -2
  16. package/dist/_chunks/stale-reload-DKN3aXxR.js.map +1 -0
  17. package/dist/_chunks/{tracing-JI4cYUdz.js → tracing-VYETCQsg.js} +1 -1
  18. package/dist/_chunks/{tracing-JI4cYUdz.js.map → tracing-VYETCQsg.js.map} +1 -1
  19. package/dist/_chunks/{wrappers-C9XPg7-U.js → wrappers-BaG1bnM3.js} +1 -1
  20. package/dist/_chunks/{wrappers-C9XPg7-U.js.map → wrappers-BaG1bnM3.js.map} +1 -1
  21. package/dist/cache/index.js +1 -1
  22. package/dist/client/error-boundary.js +1 -1
  23. package/dist/client/error-reconstituter.d.ts +54 -0
  24. package/dist/client/error-reconstituter.d.ts.map +1 -0
  25. package/dist/client/index.js +4 -4
  26. package/dist/client/segment-outlet.d.ts +63 -0
  27. package/dist/client/segment-outlet.d.ts.map +1 -0
  28. package/dist/client/stale-reload.d.ts.map +1 -1
  29. package/dist/cookies/index.js +1 -1
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +176 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/params/define.d.ts +24 -0
  35. package/dist/params/define.d.ts.map +1 -1
  36. package/dist/params/index.js +2 -103
  37. package/dist/plugins/dev-browser-logs.d.ts +84 -0
  38. package/dist/plugins/dev-browser-logs.d.ts.map +1 -0
  39. package/dist/routing/index.js +1 -1
  40. package/dist/routing/scanner.d.ts.map +1 -1
  41. package/dist/routing/types.d.ts +10 -0
  42. package/dist/routing/types.d.ts.map +1 -1
  43. package/dist/search-params/index.js +1 -1
  44. package/dist/server/als-registry.d.ts +7 -0
  45. package/dist/server/als-registry.d.ts.map +1 -1
  46. package/dist/server/deny-renderer.d.ts.map +1 -1
  47. package/dist/server/fallback-error.d.ts +2 -1
  48. package/dist/server/fallback-error.d.ts.map +1 -1
  49. package/dist/server/index.js +4 -4
  50. package/dist/server/request-context.d.ts.map +1 -1
  51. package/dist/server/route-matcher.d.ts +7 -0
  52. package/dist/server/route-matcher.d.ts.map +1 -1
  53. package/dist/server/rsc-entry/error-renderer.d.ts +19 -10
  54. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  55. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  56. package/dist/server/rsc-entry/ssr-renderer.d.ts +3 -0
  57. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  58. package/dist/server/stream-utils.d.ts +36 -0
  59. package/dist/server/stream-utils.d.ts.map +1 -0
  60. package/package.json +6 -7
  61. package/src/cli.ts +0 -0
  62. package/src/client/browser-entry.ts +6 -7
  63. package/src/client/error-reconstituter.tsx +65 -0
  64. package/src/client/segment-outlet.tsx +86 -0
  65. package/src/client/stale-reload.ts +27 -3
  66. package/src/index.ts +17 -0
  67. package/src/params/define.ts +60 -0
  68. package/src/plugins/dev-browser-logs.ts +274 -0
  69. package/src/plugins/routing.ts +8 -0
  70. package/src/routing/scanner.ts +30 -0
  71. package/src/routing/types.ts +10 -0
  72. package/src/server/als-registry.ts +7 -0
  73. package/src/server/deny-renderer.ts +2 -1
  74. package/src/server/fallback-error.ts +5 -2
  75. package/src/server/request-context.ts +6 -0
  76. package/src/server/route-matcher.ts +7 -0
  77. package/src/server/rsc-entry/error-renderer.ts +151 -113
  78. package/src/server/rsc-entry/index.ts +30 -14
  79. package/src/server/rsc-entry/ssr-renderer.ts +15 -5
  80. package/src/server/stream-utils.ts +209 -0
  81. package/dist/_chunks/interception-D2djYaIm.js.map +0 -1
  82. package/dist/_chunks/request-context-0h-6Voad.js.map +0 -1
  83. package/dist/_chunks/stale-reload-4L-_skC7.js.map +0 -1
  84. package/dist/params/index.js.map +0 -1
  85. package/dist/server/rsc-entry/ssr-error-bridge.d.ts +0 -12
  86. package/dist/server/rsc-entry/ssr-error-bridge.d.ts.map +0 -1
  87. package/dist/server/ssr-error-entry.d.ts +0 -65
  88. package/dist/server/ssr-error-entry.d.ts.map +0 -1
  89. package/src/server/rsc-entry/ssr-error-bridge.ts +0 -20
  90. package/src/server/ssr-error-entry.ts +0 -237
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Reconstitutes a SerializableError into a real Error instance before
5
+ * passing to the user's error component.
6
+ *
7
+ * TSX error pages are 'use client' components that receive { error: Error, digest, reset }.
8
+ * Error objects are not RSC-serializable (React Flight throws "Only plain objects
9
+ * can be passed to Client Components"). This wrapper receives the error as a plain
10
+ * SerializableError object, reconstitutes a real Error instance, and passes it
11
+ * to the user's error component — ensuring error instanceof Error works correctly.
12
+ *
13
+ * See design/spike-TIM-565-unify-error-pages.md §"Edge Case B"
14
+ * See design/10-error-handling.md §"RSC → SSR for Error Pages via SerializableError"
15
+ */
16
+
17
+ import { createElement, type ReactNode, type ComponentType } from 'react';
18
+
19
+ /**
20
+ * Plain-object representation of an Error that can cross the RSC → client boundary.
21
+ * Stack is only included in dev mode (gated by isDevMode() on the server).
22
+ */
23
+ export interface SerializableError {
24
+ message: string;
25
+ name: string;
26
+ stack?: string;
27
+ }
28
+
29
+ /**
30
+ * Props for the ErrorReconstituter wrapper component.
31
+ * All props are RSC-serializable:
32
+ * - error: plain object (SerializableError)
33
+ * - digest: plain JSON or null
34
+ * - reset: undefined (only meaningful on client after boundary catch)
35
+ * - component: client module reference (RSC Flight serializes as opaque ref)
36
+ */
37
+ interface ErrorReconstituterProps {
38
+ error: SerializableError;
39
+ digest: { code: string; data: unknown } | null;
40
+ reset: undefined;
41
+ component: ComponentType<{
42
+ error: Error;
43
+ digest: { code: string; data: unknown } | null;
44
+ reset: (() => void) | undefined;
45
+ }>;
46
+ }
47
+
48
+ /**
49
+ * Reconstitute a SerializableError into a real Error instance and render
50
+ * the user's error component with the proper props.
51
+ */
52
+ export function ErrorReconstituter({
53
+ error: serialized,
54
+ digest,
55
+ reset,
56
+ component,
57
+ }: ErrorReconstituterProps): ReactNode {
58
+ // Reconstitute a real Error so instanceof checks work in user code
59
+ const error = Object.assign(new Error(serialized.message), {
60
+ name: serialized.name,
61
+ ...(serialized.stack != null ? { stack: serialized.stack } : {}),
62
+ });
63
+
64
+ return createElement(component, { error, digest, reset });
65
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * SegmentOutlet — client component boundary at each layout segment.
3
+ *
4
+ * Replaces the post-hoc tree walking in segment-merger.ts with an explicit
5
+ * client component at each segment boundary. Each outlet:
6
+ *
7
+ * 1. Knows its own segment path (prop from the server)
8
+ * 2. Caches its children in a ref across navigations
9
+ * 3. When `keepCurrent` is true (partial navigation, this segment skipped),
10
+ * returns the previously cached children — layout state is preserved
11
+ * 4. When `keepCurrent` is false (full navigation or this segment changed),
12
+ * stores and renders the new children
13
+ *
14
+ * This eliminates the need for client-side element tree walking, which
15
+ * breaks on real RSC trees due to opaque client component lazy refs,
16
+ * Suspense thenables, and AccessGate wrappers.
17
+ *
18
+ * Architecture is similar to Next.js's `<LayoutRouter>` client component —
19
+ * each layout boundary is an explicit client component that manages its
20
+ * own subtree. See design/19-client-navigation.md.
21
+ *
22
+ * Security: This is a performance optimization only. The server always
23
+ * runs all access.ts files regardless of segment skipping. A fabricated
24
+ * keepCurrent prop can only cause stale layouts — never auth bypass.
25
+ * See design/13-security.md §"State tree manipulation".
26
+ */
27
+
28
+ 'use client';
29
+
30
+ import { useRef, type ReactNode } from 'react';
31
+
32
+ export interface SegmentOutletProps {
33
+ /**
34
+ * Unique identifier for this segment. For normal segments this is the
35
+ * urlPath (e.g., "/", "/dashboard"). For route groups this includes the
36
+ * group name (e.g., "/(marketing)") to distinguish siblings that share
37
+ * the same urlPath. Must match the segmentId used in state-tree-diff.ts.
38
+ */
39
+ segmentPath: string;
40
+
41
+ /**
42
+ * When true, the outlet returns its previously cached children instead
43
+ * of rendering the new children prop. Set by the server when this
44
+ * segment was skipped (the client already has the layout mounted).
45
+ *
46
+ * On the first render (SSR/hydration), this is always false — there's
47
+ * no cached content yet. On subsequent partial navigations, the server
48
+ * sets this to true for segments it skipped rendering.
49
+ */
50
+ keepCurrent?: boolean;
51
+
52
+ /** The segment's React subtree (layout + inner content). */
53
+ children: ReactNode;
54
+ }
55
+
56
+ /**
57
+ * Client component boundary at each layout segment in the element tree.
58
+ *
59
+ * On full navigation: receives new children, stores them, renders them.
60
+ * On partial navigation (keepCurrent=true): ignores children prop,
61
+ * returns previously stored content — React reconciles the same elements,
62
+ * preserving all client component state in the layout subtree.
63
+ *
64
+ * React preserves the ref across `reactRoot.render()` calls because:
65
+ * - SegmentOutlet has a stable type (client component module reference)
66
+ * - It appears at the same tree position on every navigation
67
+ * - React reconciles same-type, same-position → instance preserved
68
+ */
69
+ export function SegmentOutlet({
70
+ segmentPath: _segmentPath,
71
+ keepCurrent = false,
72
+ children,
73
+ }: SegmentOutletProps) {
74
+ // Store content in a ref to avoid triggering re-renders on cache updates.
75
+ // The ref persists across reactRoot.render() calls because React reconciles
76
+ // the same component type at the same tree position.
77
+ const contentRef = useRef<ReactNode>(null);
78
+
79
+ if (!keepCurrent) {
80
+ // Full render or this segment was re-rendered — store and render new content
81
+ contentRef.current = children;
82
+ }
83
+ // else: keepCurrent=true — return previously cached content
84
+
85
+ return contentRef.current;
86
+ }
@@ -16,6 +16,16 @@
16
16
 
17
17
  const RELOAD_FLAG_KEY = '__timber_stale_reload';
18
18
 
19
+ /**
20
+ * In-memory fallback counter for environments where sessionStorage is
21
+ * unavailable (private browsing, storage full, extension interference).
22
+ * Incremented each time triggerStaleReload() falls into the catch path.
23
+ * If the counter exceeds 0 on a subsequent call, the reload is suppressed
24
+ * to prevent an infinite loop. Resets naturally on page load (module
25
+ * re-evaluates) and can be manually reset via clearStaleReloadFlag().
26
+ */
27
+ let memoryReloadCount = 0;
28
+
19
29
  /**
20
30
  * Check if an error is a stale client reference error from React's
21
31
  * Flight client. These errors have the message pattern:
@@ -93,9 +103,22 @@ export function triggerStaleReload(): boolean {
93
103
  window.location.reload();
94
104
  return true;
95
105
  } catch {
96
- // sessionStorage may be unavailable (private browsing, storage full, etc.)
97
- // Fall back to reloading without loop protection
98
- console.warn('[timber] Stale client reference detected. Reloading page.');
106
+ // sessionStorage unavailable (private browsing, storage full, etc.)
107
+ // Use in-memory counter as fallback loop guard
108
+ if (memoryReloadCount > 0) {
109
+ console.warn(
110
+ '[timber] Stale client reference detected again after reload. ' +
111
+ 'Not reloading to prevent infinite loop. ' +
112
+ 'This may indicate a deployment issue — try a hard refresh.'
113
+ );
114
+ return false;
115
+ }
116
+
117
+ memoryReloadCount++;
118
+ console.warn(
119
+ '[timber] Stale client reference detected — the server has been ' +
120
+ 'redeployed with new bundles. Reloading to pick up the new version.'
121
+ );
99
122
  window.location.reload();
100
123
  return true;
101
124
  }
@@ -107,6 +130,7 @@ export function triggerStaleReload(): boolean {
107
130
  * reference error should trigger a fresh reload attempt.
108
131
  */
109
132
  export function clearStaleReloadFlag(): void {
133
+ memoryReloadCount = 0;
110
134
  try {
111
135
  sessionStorage.removeItem(RELOAD_FLAG_KEY);
112
136
  } catch {
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { timberStaticBuild } from './plugins/static-build';
15
15
  import { timberServerActionExports } from './plugins/server-action-exports';
16
16
  import { timberBuildManifest } from './plugins/build-manifest';
17
17
  import { timberDevLogs } from './plugins/dev-logs';
18
+ import { timberDevBrowserLogs } from './plugins/dev-browser-logs';
18
19
  import { timberReactProd } from './plugins/react-prod';
19
20
  import { timberChunks } from './plugins/chunks';
20
21
  import { clientChunkGroup } from './plugins/client-chunks';
@@ -102,6 +103,21 @@ export interface TimberUserConfig {
102
103
  * See design/02-rendering-pipeline.md §"Streaming Constraints".
103
104
  */
104
105
  renderTimeoutMs?: number;
106
+ /**
107
+ * Forward browser console output to the server terminal in dev mode.
108
+ *
109
+ * Sets the minimum log level to forward:
110
+ * - `'error'` — only `console.error`
111
+ * - `'warn'` — `console.error` + `console.warn` (default)
112
+ * - `'info'` — `console.error` + `console.warn` + `console.info`
113
+ * - `'none'` — disabled
114
+ *
115
+ * Does not intercept `console.log` or `console.debug` (too noisy).
116
+ * No effect in production builds.
117
+ *
118
+ * See TIM-513.
119
+ */
120
+ devBrowserLogs?: 'error' | 'warn' | 'info' | 'none';
105
121
  /** Dev-mode options. These have no effect in production builds. */
106
122
  dev?: {
107
123
  /** Threshold in ms to highlight slow phases in dev logging output. Default: 200. */
@@ -644,6 +660,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
644
660
  timberBuildReport(ctx), // Post-build: route table with bundle sizes
645
661
  timberAdapterBuild(ctx), // Post-build: invoke adapter.buildOutput()
646
662
  timberDevLogs(ctx), // Dev-only: forward server console.* to browser console
663
+ timberDevBrowserLogs(ctx), // Dev-only: forward browser console.* to server terminal
647
664
  timberDevServer(ctx), // Must be last — configureServer post-hook runs after all watchers
648
665
  ];
649
666
  }
@@ -15,6 +15,33 @@
15
15
 
16
16
  import type { Codec } from '#/codec.js';
17
17
 
18
+ // ---------------------------------------------------------------------------
19
+ // Server-only ALS reference for .load()
20
+ // ---------------------------------------------------------------------------
21
+
22
+ // Same pattern as search-params: eagerly registered at server startup
23
+ // to avoid dynamic imports that lose ALS context. See TIM-523.
24
+ let _rawSegmentParams: (() => Promise<Record<string, string | string[]>>) | undefined;
25
+
26
+ /**
27
+ * Register the rawSegmentParams function. Called once at module load time
28
+ * from request-context.ts to avoid dynamic import at call time.
29
+ * @internal
30
+ */
31
+ export function _setRawSegmentParamsFn(fn: () => Promise<Record<string, string | string[]>>): void {
32
+ _rawSegmentParams = fn;
33
+ }
34
+
35
+ function getRawSegmentParams(): Promise<Record<string, string | string[]>> {
36
+ if (!_rawSegmentParams) {
37
+ throw new Error(
38
+ '[timber] segmentParams.load() is only available on the server. ' +
39
+ 'Use useSegmentParams() on the client.'
40
+ );
41
+ }
42
+ return _rawSegmentParams();
43
+ }
44
+
18
45
  // ---------------------------------------------------------------------------
19
46
  // Types
20
47
  // ---------------------------------------------------------------------------
@@ -39,6 +66,25 @@ export interface ParamsDefinition<T extends Record<string, unknown>> {
39
66
  /** Serialize typed values back to strings for URL construction. */
40
67
  serialize(values: T): Record<string, string>;
41
68
 
69
+ /**
70
+ * Load typed segment params from the current request context (ALS).
71
+ *
72
+ * Server-only. Reads rawSegmentParams() from ALS and coerces through
73
+ * this definition's codecs, returning fully typed params.
74
+ *
75
+ * ```ts
76
+ * // app/products/[id]/params.ts
77
+ * export const segmentParams = defineSegmentParams({ id: z.coerce.number() })
78
+ *
79
+ * // app/products/[id]/page.tsx
80
+ * import { segmentParams } from './params'
81
+ * export default async function Page() {
82
+ * const { id } = await segmentParams.load() // id: number
83
+ * }
84
+ * ```
85
+ */
86
+ load(): Promise<T>;
87
+
42
88
  /** Read-only codec map. */
43
89
  codecs: { [K in keyof T]: Codec<T[K]> };
44
90
  }
@@ -250,9 +296,23 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
250
296
  return result;
251
297
  }
252
298
 
299
+ // ---- load ----
300
+ // ALS-backed: reads rawSegmentParams() from the current request context
301
+ // and parses through codecs. Server-only — throws on client.
302
+ async function load(): Promise<T> {
303
+ if (typeof window !== 'undefined') {
304
+ throw new Error(
305
+ '[timber] segmentParams.load() is server-only. ' + 'Use useSegmentParams() on the client.'
306
+ );
307
+ }
308
+ const raw = await getRawSegmentParams();
309
+ return parse(raw);
310
+ }
311
+
253
312
  const definition: ParamsDefinition<T> = {
254
313
  parse,
255
314
  serialize,
315
+ load,
256
316
  codecs: resolvedCodecs as { [K in keyof T]: Codec<T[K]> },
257
317
  };
258
318
 
@@ -0,0 +1,274 @@
1
+ /**
2
+ * timber-dev-browser-logs — Forwards browser console output to the server terminal.
3
+ *
4
+ * Injects a small inline script in dev mode that intercepts browser
5
+ * `console.error`, `console.warn`, and `console.info`, then forwards
6
+ * messages to the server via Vite's HMR WebSocket.
7
+ *
8
+ * The server-side listener formats and prints the messages with color-coded
9
+ * prefixes: [browser:error], [browser:warn], [browser:info].
10
+ *
11
+ * Dev-only: this plugin only runs during `vite dev`.
12
+ * No runtime overhead in production.
13
+ *
14
+ * See TIM-513 for design context.
15
+ * See design/18-build-system.md §"Dev Server" for sub-plugin architecture.
16
+ */
17
+
18
+ import type { Plugin, ViteDevServer } from 'vite';
19
+ import type { PluginContext } from '#/index.js';
20
+
21
+ // ─── Types ───────────────────────────────────────────────────────────────
22
+
23
+ /** Log levels forwarded from the browser. */
24
+ export type BrowserLogLevel = 'error' | 'warn' | 'info';
25
+
26
+ /** Configuration value for devBrowserLogs in timber.config.ts. */
27
+ export type DevBrowserLogsConfig = BrowserLogLevel | 'none' | undefined;
28
+
29
+ /**
30
+ * Payload sent from browser to server via Vite's HMR WebSocket.
31
+ * Kept small — truncated before sending.
32
+ */
33
+ export interface BrowserLogPayload {
34
+ level: BrowserLogLevel;
35
+ /** Serialized message string. */
36
+ message: string;
37
+ /** Error stack trace, if the first argument was an Error. */
38
+ stack: string | null;
39
+ /** Source URL and line number, if available. */
40
+ source: string | null;
41
+ /** Timestamp in ms (Date.now()) from the browser. */
42
+ timestamp: number;
43
+ }
44
+
45
+ // ─── Constants ───────────────────────────────────────────────────────────
46
+
47
+ /** Max message size in bytes before truncation. */
48
+ const MAX_MESSAGE_BYTES = 2048;
49
+
50
+ /** HMR event name for browser→server log forwarding. */
51
+ const HMR_EVENT = 'timber:browser-log';
52
+
53
+ /** ANSI color codes for terminal output. */
54
+ const COLORS = {
55
+ red: '\x1b[31m',
56
+ yellow: '\x1b[33m',
57
+ blue: '\x1b[34m',
58
+ dim: '\x1b[2m',
59
+ reset: '\x1b[0m',
60
+ } as const;
61
+
62
+ /** Level severity ordering for threshold comparison. */
63
+ const LEVEL_SEVERITY: Record<BrowserLogLevel | 'none', number> = {
64
+ none: 0,
65
+ info: 1,
66
+ warn: 2,
67
+ error: 3,
68
+ };
69
+
70
+ // ─── Formatting ──────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Format a browser log payload for server terminal output.
74
+ *
75
+ * Produces color-coded output like:
76
+ * [browser:error] Uncaught TypeError: x is not a function
77
+ * at App (app.tsx:10:5)
78
+ * [browser:warn] Deprecation warning (app/page.tsx:42:12)
79
+ */
80
+ export function formatBrowserLog(payload: BrowserLogPayload): string {
81
+ const { level, message, stack, source } = payload;
82
+
83
+ // Color-coded prefix
84
+ const color = level === 'error' ? COLORS.red : level === 'warn' ? COLORS.yellow : COLORS.blue;
85
+ const prefix = `${color}[browser:${level}]${COLORS.reset}`;
86
+
87
+ // Source suffix
88
+ const sourceSuffix = source ? ` ${COLORS.dim}(${source})${COLORS.reset}` : '';
89
+
90
+ let output = `${prefix} ${message}${sourceSuffix}`;
91
+
92
+ // Append stack trace indented
93
+ if (stack) {
94
+ const indented = stack
95
+ .split('\n')
96
+ .map((line) => ` ${COLORS.dim}${line}${COLORS.reset}`)
97
+ .join('\n');
98
+ output += `\n${indented}`;
99
+ }
100
+
101
+ return output;
102
+ }
103
+
104
+ // ─── Level Filtering ─────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Check if a log at `level` should be forwarded given the configured threshold.
108
+ *
109
+ * The threshold acts as a minimum severity:
110
+ * - 'error' → only errors
111
+ * - 'warn' → errors + warnings
112
+ * - 'info' → errors + warnings + info
113
+ * - 'none' → nothing
114
+ */
115
+ export function shouldForwardLevel(
116
+ level: BrowserLogLevel,
117
+ threshold: BrowserLogLevel | 'none'
118
+ ): boolean {
119
+ if (threshold === 'none') return false;
120
+ return LEVEL_SEVERITY[level] >= LEVEL_SEVERITY[threshold];
121
+ }
122
+
123
+ // ─── Truncation ──────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Truncate a message to `maxBytes` to avoid flooding the terminal.
127
+ * Appends a suffix indicating how many bytes were dropped.
128
+ */
129
+ export function truncateMessage(message: string, maxBytes: number): string {
130
+ if (message.length <= maxBytes) return message;
131
+
132
+ const truncated = message.slice(0, maxBytes);
133
+ const droppedBytes = message.length - maxBytes;
134
+ return `${truncated}… [truncated ${droppedBytes} bytes]`;
135
+ }
136
+
137
+ // ─── Client Injection Script ─────────────────────────────────────────────
138
+
139
+ /**
140
+ * Generate the inline script injected into the browser in dev mode.
141
+ *
142
+ * This script:
143
+ * 1. Saves references to the original console methods
144
+ * 2. Wraps `console.error`, `console.warn`, `console.info`
145
+ * 3. Calls the original first (browser devtools still work)
146
+ * 4. Serializes and forwards via `import.meta.hot.send()`
147
+ * 5. Truncates messages to MAX_MESSAGE_BYTES
148
+ *
149
+ * The script is minimal and self-contained — no imports, no dependencies.
150
+ */
151
+ export function generateClientScript(threshold: BrowserLogLevel | 'none'): string {
152
+ if (threshold === 'none') return '';
153
+
154
+ // Only intercept levels that meet the threshold
155
+ const levels: BrowserLogLevel[] = ['error', 'warn', 'info'].filter((l) =>
156
+ shouldForwardLevel(l as BrowserLogLevel, threshold)
157
+ ) as BrowserLogLevel[];
158
+
159
+ if (levels.length === 0) return '';
160
+
161
+ return `
162
+ (function() {
163
+ if (!import.meta.hot) return;
164
+ var MAX_BYTES = ${MAX_MESSAGE_BYTES};
165
+ var levels = ${JSON.stringify(levels)};
166
+ var originals = {};
167
+
168
+ function serialize(arg) {
169
+ if (arg === null) return 'null';
170
+ if (arg === undefined) return 'undefined';
171
+ if (arg instanceof Error) return arg.stack || arg.message || String(arg);
172
+ if (typeof arg === 'object') {
173
+ try { return JSON.stringify(arg); } catch(e) { return String(arg); }
174
+ }
175
+ return String(arg);
176
+ }
177
+
178
+ function truncate(s) {
179
+ if (s.length <= MAX_BYTES) return s;
180
+ return s.slice(0, MAX_BYTES) + '... [truncated ' + (s.length - MAX_BYTES) + ' bytes]';
181
+ }
182
+
183
+ levels.forEach(function(level) {
184
+ originals[level] = console[level];
185
+ console[level] = function() {
186
+ originals[level].apply(console, arguments);
187
+ try {
188
+ var args = Array.prototype.slice.call(arguments);
189
+ var firstArg = args[0];
190
+ var stack = null;
191
+ var source = null;
192
+
193
+ if (firstArg instanceof Error) {
194
+ stack = firstArg.stack || null;
195
+ source = null;
196
+ }
197
+
198
+ var message = args.map(serialize).join(' ');
199
+ message = truncate(message);
200
+
201
+ import.meta.hot.send('${HMR_EVENT}', {
202
+ level: level,
203
+ message: message,
204
+ stack: stack,
205
+ source: source,
206
+ timestamp: Date.now()
207
+ });
208
+ } catch(e) {
209
+ // Never let log forwarding break the page
210
+ }
211
+ };
212
+ });
213
+ })();
214
+ `.trim();
215
+ }
216
+
217
+ // ─── Plugin ──────────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Create the timber-dev-browser-logs Vite plugin.
221
+ *
222
+ * - `configureServer`: Listens for HMR messages and prints them to the terminal
223
+ * - `transformIndexHtml`: Injects the client-side interception script
224
+ *
225
+ * Only active during `vite dev` (apply: 'serve').
226
+ */
227
+ export function timberDevBrowserLogs(ctx: PluginContext): Plugin {
228
+ return {
229
+ name: 'timber-dev-browser-logs',
230
+ apply: 'serve',
231
+
232
+ configureServer(server: ViteDevServer) {
233
+ const threshold = ctx.config.devBrowserLogs ?? 'warn';
234
+ if (threshold === 'none') return;
235
+
236
+ // Listen for browser log messages via HMR WebSocket
237
+ server.hot.on(HMR_EVENT, (payload: BrowserLogPayload) => {
238
+ try {
239
+ // Validate level
240
+ if (!shouldForwardLevel(payload.level, threshold)) return;
241
+
242
+ // Truncate server-side too (defense in depth)
243
+ payload.message = truncateMessage(payload.message, MAX_MESSAGE_BYTES);
244
+
245
+ const formatted = formatBrowserLog(payload);
246
+
247
+ // Use the correct console method for the log level
248
+ if (payload.level === 'error') {
249
+ process.stderr.write(formatted + '\n');
250
+ } else {
251
+ process.stdout.write(formatted + '\n');
252
+ }
253
+ } catch {
254
+ // Never let log forwarding crash the server
255
+ }
256
+ });
257
+ },
258
+
259
+ transformIndexHtml() {
260
+ const threshold = ctx.config.devBrowserLogs ?? 'warn';
261
+ const script = generateClientScript(threshold);
262
+ if (!script) return [];
263
+
264
+ return [
265
+ {
266
+ tag: 'script',
267
+ attrs: { type: 'module' },
268
+ children: script,
269
+ injectTo: 'head' as const,
270
+ },
271
+ ];
272
+ },
273
+ };
274
+ }
@@ -423,6 +423,13 @@ function generateManifestModule(tree: RouteTree): string {
423
423
  proxyLine = ` proxy: { load: ${v}, filePath: ${JSON.stringify(tree.proxy.filePath)} },`;
424
424
  }
425
425
 
426
+ // Global error page (Tier 2)
427
+ let globalErrorLine = '';
428
+ if (tree.globalError) {
429
+ const v = addImport(tree.globalError);
430
+ globalErrorLine = ` globalError: { load: ${v}, filePath: ${JSON.stringify(tree.globalError.filePath)} },`;
431
+ }
432
+
426
433
  // Interception rewrites — computed at build time from the route tree.
427
434
  // Only interceptedPattern and interceptingPrefix are needed at runtime.
428
435
  const rewrites = collectInterceptionRewrites(tree.root);
@@ -439,6 +446,7 @@ function generateManifestModule(tree: RouteTree): string {
439
446
  '',
440
447
  'const manifest = {',
441
448
  proxyLine,
449
+ globalErrorLine,
442
450
  rewritesLine,
443
451
  ` root: ${rootSerialized},`,
444
452
  '};',
@@ -83,6 +83,14 @@ export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTre
83
83
  tree.proxy = proxyFile;
84
84
  }
85
85
 
86
+ // Check for global-error.{tsx,ts,jsx,js} at app root.
87
+ // Tier 2 error page — renders standalone (no layouts) when no segment-level
88
+ // error file is found. See design/10-error-handling.md §"Tier 2".
89
+ const globalErrorFile = findPageExtFile(appDir, 'global-error', extSet);
90
+ if (globalErrorFile) {
91
+ tree.globalError = globalErrorFile;
92
+ }
93
+
86
94
  // Scan the root directory's files
87
95
  scanSegmentFiles(appDir, tree.root, extSet);
88
96
 
@@ -547,3 +555,25 @@ function findFixedFile(dirPath: string, name: string): RouteFile | undefined {
547
555
  }
548
556
  return undefined;
549
557
  }
558
+
559
+ /**
560
+ * Find a file using the configured page extensions (tsx, ts, jsx, js, mdx, etc.).
561
+ * Used for app-root conventions like global-error that aren't per-segment.
562
+ */
563
+ function findPageExtFile(
564
+ dirPath: string,
565
+ name: string,
566
+ extSet: Set<string>
567
+ ): RouteFile | undefined {
568
+ for (const ext of extSet) {
569
+ const fullPath = join(dirPath, `${name}.${ext}`);
570
+ try {
571
+ if (statSync(fullPath).isFile()) {
572
+ return { filePath: fullPath, extension: ext };
573
+ }
574
+ } catch {
575
+ // File doesn't exist
576
+ }
577
+ }
578
+ return undefined;
579
+ }
@@ -91,6 +91,16 @@ export interface RouteTree {
91
91
  root: SegmentNode;
92
92
  /** All discovered proxy.ts files (should be at most one, in app/) */
93
93
  proxy?: RouteFile;
94
+ /**
95
+ * Global error page: app/global-error.{tsx,ts,jsx,js}
96
+ *
97
+ * Rendered as a standalone full-page replacement (no layout wrapping)
98
+ * when no segment-level error file is found. SSR-only render path.
99
+ * Must provide its own <html> and <body>.
100
+ *
101
+ * See design/10-error-handling.md §"Tier 2 — Global Error Page"
102
+ */
103
+ globalError?: RouteFile;
94
104
  }
95
105
 
96
106
  /** Configuration passed to the scanner */