@timber-js/app 0.2.0-alpha.57 → 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 (68) hide show
  1. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -1
  2. package/dist/_chunks/define-D5STJpIr.js +121 -0
  3. package/dist/_chunks/define-D5STJpIr.js.map +1 -0
  4. package/dist/_chunks/{define-cookie-k9btcEfI.js → define-cookie-DtAavax4.js} +4 -4
  5. package/dist/_chunks/{define-cookie-k9btcEfI.js.map → define-cookie-DtAavax4.js.map} +1 -1
  6. package/dist/_chunks/{error-boundary-B9vT_YK_.js → error-boundary-DpZJBCqh.js} +1 -1
  7. package/dist/_chunks/{error-boundary-B9vT_YK_.js.map → error-boundary-DpZJBCqh.js.map} +1 -1
  8. package/dist/_chunks/{request-context-0h-6Voad.js → request-context-0wfZsnhh.js} +3 -1
  9. package/dist/_chunks/request-context-0wfZsnhh.js.map +1 -0
  10. package/dist/_chunks/{segment-context-Bmugn-ao.js → segment-context-CyaM1mrD.js} +1 -1
  11. package/dist/_chunks/{segment-context-Bmugn-ao.js.map → segment-context-CyaM1mrD.js.map} +1 -1
  12. package/dist/_chunks/{stale-reload-Db4wqE46.js → stale-reload-DKN3aXxR.js} +1 -1
  13. package/dist/_chunks/{stale-reload-Db4wqE46.js.map → stale-reload-DKN3aXxR.js.map} +1 -1
  14. package/dist/_chunks/{tracing-JI4cYUdz.js → tracing-VYETCQsg.js} +1 -1
  15. package/dist/_chunks/{tracing-JI4cYUdz.js.map → tracing-VYETCQsg.js.map} +1 -1
  16. package/dist/_chunks/{wrappers-C9XPg7-U.js → wrappers-BaG1bnM3.js} +1 -1
  17. package/dist/_chunks/{wrappers-C9XPg7-U.js.map → wrappers-BaG1bnM3.js.map} +1 -1
  18. package/dist/cache/index.js +1 -1
  19. package/dist/client/error-boundary.js +1 -1
  20. package/dist/client/error-reconstituter.d.ts +54 -0
  21. package/dist/client/error-reconstituter.d.ts.map +1 -0
  22. package/dist/client/index.js +4 -4
  23. package/dist/client/segment-outlet.d.ts +63 -0
  24. package/dist/client/segment-outlet.d.ts.map +1 -0
  25. package/dist/cookies/index.js +1 -1
  26. package/dist/index.d.ts +15 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +172 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/params/define.d.ts +24 -0
  31. package/dist/params/define.d.ts.map +1 -1
  32. package/dist/params/index.js +2 -103
  33. package/dist/plugins/dev-browser-logs.d.ts +84 -0
  34. package/dist/plugins/dev-browser-logs.d.ts.map +1 -0
  35. package/dist/search-params/index.js +1 -1
  36. package/dist/server/als-registry.d.ts +7 -0
  37. package/dist/server/als-registry.d.ts.map +1 -1
  38. package/dist/server/deny-renderer.d.ts.map +1 -1
  39. package/dist/server/index.js +4 -4
  40. package/dist/server/request-context.d.ts.map +1 -1
  41. package/dist/server/rsc-entry/error-renderer.d.ts +9 -9
  42. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  43. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  44. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  45. package/dist/server/stream-utils.d.ts +36 -0
  46. package/dist/server/stream-utils.d.ts.map +1 -0
  47. package/package.json +1 -1
  48. package/src/client/browser-entry.ts +6 -7
  49. package/src/client/error-reconstituter.tsx +65 -0
  50. package/src/client/segment-outlet.tsx +86 -0
  51. package/src/index.ts +17 -0
  52. package/src/params/define.ts +60 -0
  53. package/src/plugins/dev-browser-logs.ts +274 -0
  54. package/src/server/als-registry.ts +7 -0
  55. package/src/server/deny-renderer.ts +2 -1
  56. package/src/server/request-context.ts +6 -0
  57. package/src/server/rsc-entry/error-renderer.ts +108 -173
  58. package/src/server/rsc-entry/index.ts +14 -10
  59. package/src/server/rsc-entry/ssr-renderer.ts +5 -1
  60. package/src/server/stream-utils.ts +209 -0
  61. package/dist/_chunks/request-context-0h-6Voad.js.map +0 -1
  62. package/dist/params/index.js.map +0 -1
  63. package/dist/server/rsc-entry/ssr-error-bridge.d.ts +0 -12
  64. package/dist/server/rsc-entry/ssr-error-bridge.d.ts.map +0 -1
  65. package/dist/server/ssr-error-entry.d.ts +0 -65
  66. package/dist/server/ssr-error-entry.d.ts.map +0 -1
  67. package/src/server/rsc-entry/ssr-error-bridge.ts +0 -20
  68. package/src/server/ssr-error-entry.ts +0 -237
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
+ }
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { AsyncLocalStorage } from 'node:async_hooks';
24
+ import type { DebugComponentEntry } from './rsc-entry/helpers.js';
24
25
 
25
26
  // ─── Request Context ──────────────────────────────────────────────────────
26
27
  // Used by: request-context.ts (headers(), cookies(), searchParams())
@@ -64,6 +65,12 @@ export interface RequestContextStore {
64
65
  flushed: boolean;
65
66
  /** Whether the current context allows cookie mutation. */
66
67
  mutableContext: boolean;
68
+ /**
69
+ * Dev-only: getter for the current request's RSC debug components.
70
+ * Set by renderRoute() so onPipelineError can include component tree
71
+ * context for render-phase errors without module-level shared state.
72
+ */
73
+ debugComponentsGetter?: () => DebugComponentEntry[];
67
74
  }
68
75
 
69
76
  /** A single outgoing cookie entry in the cookie jar. */
@@ -29,6 +29,7 @@ import type { NavContext } from './ssr-entry.js';
29
29
  import { flightInitScript } from './flight-scripts.js';
30
30
  import type { ClientBootstrapConfig } from './html-injectors.js';
31
31
  import type { Metadata } from './types.js';
32
+ import { teeWithErrorPropagation } from './stream-utils.js';
32
33
 
33
34
  /** RSC content type for client navigation payload requests. */
34
35
  const RSC_CONTENT_TYPE = 'text/x-component';
@@ -171,7 +172,7 @@ export async function renderDenyPage(
171
172
  debugChannel: createDebugChannelSink(),
172
173
  });
173
174
 
174
- const [ssrStream, inlineStream] = rscStream.tee();
175
+ const [ssrStream, inlineStream] = teeWithErrorPropagation(rscStream);
175
176
 
176
177
  const navContext: NavContext = {
177
178
  pathname: new URL(req.url).pathname,
@@ -13,6 +13,7 @@
13
13
  import { requestContextAls, type RequestContextStore, type CookieEntry } from './als-registry.js';
14
14
  import { isDebug } from './debug.js';
15
15
  import { _setRawSearchParamsFn } from '#/search-params/define.js';
16
+ import { _setRawSegmentParamsFn } from '#/params/define.js';
16
17
 
17
18
  // Re-export the ALS for framework-internal consumers that need direct access.
18
19
  export { requestContextAls };
@@ -185,6 +186,11 @@ export function rawSearchParams(): Promise<URLSearchParams> {
185
186
  // breaking rawSearchParams() in parallel slot pages. See TIM-523.
186
187
  _setRawSearchParamsFn(rawSearchParams);
187
188
 
189
+ // Eagerly register rawSegmentParams with the params module so
190
+ // segmentParams.load() can call it synchronously without a dynamic import.
191
+ // Same pattern as search params — dynamic imports lose ALS context. See TIM-523.
192
+ _setRawSegmentParamsFn(rawSegmentParams);
193
+
188
194
  /**
189
195
  * Returns a Promise resolving to the current request's coerced segment params.
190
196
  *