@timber-js/app 0.2.0-alpha.3 → 0.2.0-alpha.30

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 (140) hide show
  1. package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
  2. package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
  3. package/dist/_chunks/debug-B3Gypr3D.js +108 -0
  4. package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
  5. package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
  6. package/dist/_chunks/format-RyoGQL74.js.map +1 -0
  7. package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
  8. package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
  9. package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
  10. package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
  11. package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-BQUC8PHn.js} +5 -4
  12. package/dist/_chunks/request-context-BQUC8PHn.js.map +1 -0
  13. package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
  14. package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
  15. package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
  16. package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
  17. package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
  18. package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
  19. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
  20. package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
  21. package/dist/adapters/nitro.d.ts +17 -1
  22. package/dist/adapters/nitro.d.ts.map +1 -1
  23. package/dist/adapters/nitro.js +5 -5
  24. package/dist/adapters/nitro.js.map +1 -1
  25. package/dist/cache/fast-hash.d.ts +22 -0
  26. package/dist/cache/fast-hash.d.ts.map +1 -0
  27. package/dist/cache/index.js +52 -10
  28. package/dist/cache/index.js.map +1 -1
  29. package/dist/cache/register-cached-function.d.ts.map +1 -1
  30. package/dist/cache/timber-cache.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +1 -1
  32. package/dist/client/index.js +3 -3
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/client/link.d.ts.map +1 -1
  35. package/dist/client/router.d.ts.map +1 -1
  36. package/dist/client/segment-context.d.ts +1 -1
  37. package/dist/client/segment-context.d.ts.map +1 -1
  38. package/dist/client/segment-merger.d.ts.map +1 -1
  39. package/dist/client/stale-reload.d.ts.map +1 -1
  40. package/dist/client/top-loader.d.ts.map +1 -1
  41. package/dist/client/transition-root.d.ts +1 -1
  42. package/dist/client/transition-root.d.ts.map +1 -1
  43. package/dist/cookies/index.js +4 -4
  44. package/dist/fonts/css.d.ts +1 -0
  45. package/dist/fonts/css.d.ts.map +1 -1
  46. package/dist/fonts/local.d.ts +4 -2
  47. package/dist/fonts/local.d.ts.map +1 -1
  48. package/dist/index.d.ts +28 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +249 -21
  51. package/dist/index.js.map +1 -1
  52. package/dist/plugins/build-report.d.ts +11 -1
  53. package/dist/plugins/build-report.d.ts.map +1 -1
  54. package/dist/plugins/entries.d.ts +7 -0
  55. package/dist/plugins/entries.d.ts.map +1 -1
  56. package/dist/plugins/fonts.d.ts +9 -1
  57. package/dist/plugins/fonts.d.ts.map +1 -1
  58. package/dist/plugins/mdx.d.ts +6 -0
  59. package/dist/plugins/mdx.d.ts.map +1 -1
  60. package/dist/plugins/server-bundle.d.ts.map +1 -1
  61. package/dist/routing/index.js +1 -1
  62. package/dist/rsc-runtime/ssr.d.ts +12 -0
  63. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  64. package/dist/search-params/index.js +1 -1
  65. package/dist/server/access-gate.d.ts.map +1 -1
  66. package/dist/server/action-client.d.ts.map +1 -1
  67. package/dist/server/debug.d.ts +82 -0
  68. package/dist/server/debug.d.ts.map +1 -0
  69. package/dist/server/deny-renderer.d.ts.map +1 -1
  70. package/dist/server/dev-warnings.d.ts.map +1 -1
  71. package/dist/server/html-injectors.d.ts.map +1 -1
  72. package/dist/server/index.js +32 -23
  73. package/dist/server/index.js.map +1 -1
  74. package/dist/server/logger.d.ts.map +1 -1
  75. package/dist/server/node-stream-transforms.d.ts +65 -0
  76. package/dist/server/node-stream-transforms.d.ts.map +1 -0
  77. package/dist/server/pipeline.d.ts +7 -4
  78. package/dist/server/pipeline.d.ts.map +1 -1
  79. package/dist/server/primitives.d.ts.map +1 -1
  80. package/dist/server/request-context.d.ts.map +1 -1
  81. package/dist/server/route-element-builder.d.ts.map +1 -1
  82. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  83. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  84. package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
  85. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  86. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  87. package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
  88. package/dist/server/ssr-entry.d.ts.map +1 -1
  89. package/dist/server/ssr-render.d.ts +34 -21
  90. package/dist/server/ssr-render.d.ts.map +1 -1
  91. package/dist/server/tracing.d.ts +10 -0
  92. package/dist/server/tracing.d.ts.map +1 -1
  93. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  94. package/dist/shims/image.d.ts +15 -15
  95. package/package.json +1 -1
  96. package/src/adapters/nitro.ts +31 -5
  97. package/src/cache/fast-hash.ts +34 -0
  98. package/src/cache/register-cached-function.ts +7 -3
  99. package/src/cache/timber-cache.ts +17 -10
  100. package/src/client/browser-entry.ts +10 -6
  101. package/src/client/link.tsx +14 -9
  102. package/src/client/router.ts +4 -6
  103. package/src/client/segment-context.ts +6 -1
  104. package/src/client/segment-merger.ts +2 -8
  105. package/src/client/stale-reload.ts +5 -7
  106. package/src/client/top-loader.tsx +8 -7
  107. package/src/client/transition-root.tsx +7 -1
  108. package/src/fonts/css.ts +2 -1
  109. package/src/fonts/local.ts +7 -3
  110. package/src/index.ts +35 -2
  111. package/src/plugins/build-report.ts +23 -3
  112. package/src/plugins/entries.ts +9 -4
  113. package/src/plugins/fonts.ts +171 -19
  114. package/src/plugins/mdx.ts +9 -5
  115. package/src/plugins/server-bundle.ts +4 -0
  116. package/src/rsc-runtime/ssr.ts +50 -0
  117. package/src/rsc-runtime/vendor-types.d.ts +7 -0
  118. package/src/server/access-gate.tsx +3 -2
  119. package/src/server/action-client.ts +15 -5
  120. package/src/server/debug.ts +137 -0
  121. package/src/server/deny-renderer.ts +3 -2
  122. package/src/server/dev-warnings.ts +2 -1
  123. package/src/server/html-injectors.ts +30 -10
  124. package/src/server/logger.ts +4 -3
  125. package/src/server/node-stream-transforms.ts +315 -0
  126. package/src/server/pipeline.ts +34 -20
  127. package/src/server/primitives.ts +2 -1
  128. package/src/server/request-context.ts +3 -2
  129. package/src/server/route-element-builder.ts +1 -6
  130. package/src/server/rsc-entry/index.ts +50 -7
  131. package/src/server/rsc-entry/rsc-payload.ts +42 -7
  132. package/src/server/rsc-entry/rsc-stream.ts +10 -5
  133. package/src/server/rsc-entry/ssr-renderer.ts +12 -5
  134. package/src/server/rsc-prop-warnings.ts +3 -1
  135. package/src/server/ssr-entry.ts +128 -8
  136. package/src/server/ssr-render.ts +168 -57
  137. package/src/server/tracing.ts +23 -0
  138. package/src/server/waituntil-bridge.ts +4 -1
  139. package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
  140. package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
@@ -14,13 +14,15 @@
14
14
  * Design doc: 24-fonts.md
15
15
  */
16
16
 
17
- import type { Plugin } from 'vite';
17
+ import type { Plugin, ViteDevServer } from 'vite';
18
+ import { readFileSync, existsSync } from 'node:fs';
19
+ import { resolve, normalize } from 'node:path';
18
20
  import type { PluginContext } from '#/index.js';
19
21
  import type { ExtractedFont, GoogleFontConfig } from '#/fonts/types.js';
20
22
  import type { ManifestFontEntry } from '#/server/build-manifest.js';
21
- import { generateVariableClass, generateFontFamilyClass } from '#/fonts/css.js';
23
+ import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from '#/fonts/css.js';
22
24
  import { generateFallbackCss, buildFontStack } from '#/fonts/fallbacks.js';
23
- import { processLocalFont } from '#/fonts/local.js';
25
+ import { processLocalFont, generateLocalFontFaces } from '#/fonts/local.js';
24
26
  import { inferFontFormat } from '#/fonts/local.js';
25
27
  import { downloadAndCacheFonts, type CachedFont } from '#/fonts/google.js';
26
28
  import {
@@ -34,6 +36,23 @@ const VIRTUAL_LOCAL = '@timber/fonts/local';
34
36
  const RESOLVED_GOOGLE = '\0@timber/fonts/google';
35
37
  const RESOLVED_LOCAL = '\0@timber/fonts/local';
36
38
 
39
+ /**
40
+ * Virtual side-effect module that registers font CSS on globalThis.
41
+ *
42
+ * When a file calls localFont() or a Google font function, the transform
43
+ * hook injects `import 'virtual:timber-font-css-register'` into that file.
44
+ * This virtual module sets `globalThis.__timber_font_css` with the combined
45
+ * @font-face CSS. The RSC entry reads it at render time to inline a <style> tag.
46
+ *
47
+ * This approach avoids timing issues because:
48
+ * 1. The font file is in the RSC module graph (imported by layout.tsx)
49
+ * 2. The side-effect import is added to the font file during transform
50
+ * 3. When layout.tsx is loaded, fonts.ts runs → side-effect module runs → globalThis is set
51
+ * 4. RSC entry renders → reads globalThis → inlines <style>
52
+ */
53
+ const VIRTUAL_FONT_CSS_REGISTER = 'virtual:timber-font-css-register';
54
+ const RESOLVED_FONT_CSS_REGISTER = '\0virtual:timber-font-css-register';
55
+
37
56
  /**
38
57
  * Registry of fonts extracted during transform.
39
58
  * Keyed by a unique font ID derived from family + config.
@@ -242,27 +261,44 @@ function generateLocalVirtualModule(): string {
242
261
  ].join('\n');
243
262
  }
244
263
 
264
+ /**
265
+ * Generate CSS for a single extracted font.
266
+ *
267
+ * Includes @font-face rules (for local fonts), fallback @font-face,
268
+ * and the scoped class rule.
269
+ */
270
+ export function generateFontCss(font: ExtractedFont): string {
271
+ const cssParts: string[] = [];
272
+
273
+ if (font.provider === 'local' && font.localSources) {
274
+ const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
275
+ const faceCss = generateFontFaces(faces);
276
+ if (faceCss) cssParts.push(faceCss);
277
+ }
278
+
279
+ const fallbackCss = generateFallbackCss(font.family);
280
+ if (fallbackCss) cssParts.push(fallbackCss);
281
+
282
+ if (font.variable) {
283
+ cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
284
+ } else {
285
+ cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
286
+ }
287
+
288
+ return cssParts.join('\n\n');
289
+ }
290
+
245
291
  /**
246
292
  * Generate the CSS output for all extracted fonts.
247
293
  *
248
- * Includes @font-face rules, fallback @font-face rules, and scoped classes.
294
+ * Includes @font-face rules for local fonts, fallback @font-face rules,
295
+ * and scoped classes.
249
296
  */
250
297
  export function generateAllFontCss(registry: FontRegistry): string {
251
298
  const cssParts: string[] = [];
252
-
253
299
  for (const font of registry.values()) {
254
- // Generate fallback @font-face if metrics are available
255
- const fallbackCss = generateFallbackCss(font.family);
256
- if (fallbackCss) cssParts.push(fallbackCss);
257
-
258
- // Generate scoped class
259
- if (font.variable) {
260
- cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
261
- } else {
262
- cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
263
- }
300
+ cssParts.push(generateFontCss(font));
264
301
  }
265
-
266
302
  return cssParts.join('\n\n');
267
303
  }
268
304
 
@@ -359,23 +395,112 @@ export function timberFonts(ctx: PluginContext): Plugin {
359
395
  name: 'timber-fonts',
360
396
 
361
397
  /**
362
- * Resolve `@timber/fonts/google` and `@timber/fonts/local` to virtual modules.
398
+ * Resolve `@timber/fonts/google`, `@timber/fonts/local`,
399
+ * and `virtual:timber-font-css` virtual modules.
400
+ *
401
+ * Handles \0 prefix and root prefix stripping for RSC/SSR
402
+ * environments where the RSC plugin re-imports virtual modules
403
+ * with additional prefixes.
363
404
  */
364
405
  resolveId(id: string) {
365
- if (id === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
366
- if (id === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
406
+ // Strip \0 prefix (RSC plugin re-imports)
407
+ let cleanId = id.startsWith('\0') ? id.slice(1) : id;
408
+ // Strip root prefix (SSR build entries)
409
+ if (cleanId.startsWith(ctx.root)) {
410
+ const stripped = cleanId.slice(ctx.root.length);
411
+ if (stripped.startsWith('/') || stripped.startsWith('\\')) {
412
+ cleanId = stripped.slice(1);
413
+ } else {
414
+ cleanId = stripped;
415
+ }
416
+ }
417
+
418
+ if (cleanId === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
419
+ if (cleanId === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
420
+ if (cleanId === VIRTUAL_FONT_CSS_REGISTER) return RESOLVED_FONT_CSS_REGISTER;
367
421
  return null;
368
422
  },
369
423
 
370
424
  /**
371
425
  * Return generated source for font virtual modules.
426
+ *
427
+ * `virtual:timber-font-css` exports the combined @font-face CSS
428
+ * as a string. The RSC entry imports it and inlines a <style> tag.
429
+ * Because this is loaded lazily (on first request), the font
430
+ * registry is always populated by the time it's needed.
372
431
  */
373
432
  load(id: string) {
374
433
  if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(registry);
375
434
  if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
435
+
436
+ if (id === RESOLVED_FONT_CSS_REGISTER) {
437
+ const css = generateAllFontCss(registry);
438
+ // Side-effect module: sets font CSS on globalThis for the RSC entry to read.
439
+ return `globalThis.__timber_font_css = ${JSON.stringify(css)};`;
440
+ }
376
441
  return null;
377
442
  },
378
443
 
444
+ /**
445
+ * Serve local font files and font CSS in dev mode under `/_timber/fonts/`.
446
+ *
447
+ * Serves:
448
+ * - `/_timber/fonts/fonts.css` — combined @font-face + scoped class CSS
449
+ * - `/_timber/fonts/<filename>` — individual font files from the registry
450
+ *
451
+ * Only files registered in the font registry are served.
452
+ * Paths are validated to prevent directory traversal.
453
+ */
454
+ configureServer(server: ViteDevServer) {
455
+ server.middlewares.use((req, res, next) => {
456
+ const url = req.url;
457
+ if (!url || !url.startsWith('/_timber/fonts/')) return next();
458
+
459
+ const requestedFilename = url.slice('/_timber/fonts/'.length);
460
+ // Reject path traversal attempts
461
+ if (requestedFilename.includes('..') || requestedFilename.includes('/')) {
462
+ res.statusCode = 400;
463
+ res.end('Bad request');
464
+ return;
465
+ }
466
+
467
+ // Font CSS is now injected via Vite's CSS pipeline (virtual:timber-font-css modules).
468
+ // This middleware only serves font binary files (woff2, etc.).
469
+
470
+ // Find the matching font file in the registry
471
+ for (const font of registry.values()) {
472
+ if (font.provider !== 'local' || !font.localSources) continue;
473
+ for (const src of font.localSources) {
474
+ const basename = src.path.split('/').pop() ?? '';
475
+ if (basename === requestedFilename) {
476
+ const absolutePath = normalize(resolve(src.path));
477
+ if (!existsSync(absolutePath)) {
478
+ res.statusCode = 404;
479
+ res.end('Not found');
480
+ return;
481
+ }
482
+ const data = readFileSync(absolutePath);
483
+ const ext = absolutePath.split('.').pop()?.toLowerCase();
484
+ const mimeMap: Record<string, string> = {
485
+ woff2: 'font/woff2',
486
+ woff: 'font/woff',
487
+ ttf: 'font/ttf',
488
+ otf: 'font/otf',
489
+ eot: 'application/vnd.ms-fontopen',
490
+ };
491
+ res.setHeader('Content-Type', mimeMap[ext ?? ''] ?? 'application/octet-stream');
492
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
493
+ res.setHeader('Access-Control-Allow-Origin', '*');
494
+ res.end(data);
495
+ return;
496
+ }
497
+ }
498
+ }
499
+
500
+ next();
501
+ });
502
+ },
503
+
379
504
  /**
380
505
  * Download and cache Google Fonts during production builds.
381
506
  *
@@ -499,6 +624,11 @@ export function timberFonts(ctx: PluginContext): Plugin {
499
624
  }
500
625
 
501
626
  if (transformedCode !== code) {
627
+ // Inject side-effect import that registers font CSS on globalThis.
628
+ // The RSC entry reads globalThis.__timber_font_css to inline a <style> tag.
629
+ if (registry.size > 0) {
630
+ transformedCode = `import '${VIRTUAL_FONT_CSS_REGISTER}';\n` + transformedCode;
631
+ }
502
632
  return { code: transformedCode, map: null };
503
633
  }
504
634
 
@@ -525,6 +655,28 @@ export function timberFonts(ctx: PluginContext): Plugin {
525
655
  });
526
656
  }
527
657
 
658
+ // Emit local font files as assets
659
+ for (const font of registry.values()) {
660
+ if (font.provider !== 'local' || !font.localSources) continue;
661
+ for (const src of font.localSources) {
662
+ const absolutePath = normalize(resolve(src.path));
663
+ if (!existsSync(absolutePath)) {
664
+ this.warn(`Local font file not found: ${absolutePath}`);
665
+ continue;
666
+ }
667
+ const basename = src.path.split('/').pop() ?? src.path;
668
+ const data = readFileSync(absolutePath);
669
+ this.emitFile({
670
+ type: 'asset',
671
+ fileName: `_timber/fonts/${basename}`,
672
+ source: data,
673
+ });
674
+ }
675
+ }
676
+
677
+ // Font CSS is emitted by Vite's CSS pipeline via virtual:timber-font-css modules.
678
+ // We only need to emit font binary files and update the build manifest here.
679
+
528
680
  if (!ctx.buildManifest) return;
529
681
 
530
682
  // Build a lookup from font family → cached files for manifest entries
@@ -16,19 +16,23 @@ import type { PluginContext } from '#/index.js';
16
16
  const MDX_EXTENSIONS = ['mdx', 'md'];
17
17
 
18
18
  /**
19
- * Check if mdx-components.tsx (or .ts, .jsx, .js) exists at the project root.
19
+ * Check if mdx-components.tsx (or .ts, .jsx, .js) exists at the project root
20
+ * or in src/. Root takes precedence, matching Next.js behavior.
20
21
  * Returns the absolute path if found, otherwise undefined.
21
22
  */
22
- function findMdxComponents(root: string): string | undefined {
23
+ export function findMdxComponents(root: string): string | undefined {
23
24
  const candidates = [
24
25
  'mdx-components.tsx',
25
26
  'mdx-components.ts',
26
27
  'mdx-components.jsx',
27
28
  'mdx-components.js',
28
29
  ];
29
- for (const name of candidates) {
30
- const p = join(root, name);
31
- if (existsSync(p)) return p;
30
+ const dirs = [root, join(root, 'src')];
31
+ for (const dir of dirs) {
32
+ for (const name of candidates) {
33
+ const p = join(dir, name);
34
+ if (existsSync(p)) return p;
35
+ }
32
36
  }
33
37
  return undefined;
34
38
  }
@@ -65,6 +65,10 @@ export function timberServerBundle(): Plugin[] {
65
65
  // eliminated by Rollup's tree-shaking. Without this, the runtime
66
66
  // check falls through on platforms where process.env is empty
67
67
  // (e.g. Cloudflare Workers), causing dev code to run in production.
68
+ // Define process.env.NODE_ENV so dev-only React code is tree-shaken.
69
+ // TIMBER_DEBUG is intentionally NOT defined here — it must remain a
70
+ // runtime check so `isDebug()` in server/debug.ts can read it at
71
+ // request time. See TIM-365.
68
72
  const serverDefine = {
69
73
  'process.env.NODE_ENV': JSON.stringify('production'),
70
74
  };
@@ -11,3 +11,53 @@
11
11
  */
12
12
 
13
13
  export { createFromReadableStream } from '@vitejs/plugin-rsc/ssr';
14
+
15
+ // ─── Node.js Stream Path ─────────────────────────────────────────────────────
16
+ //
17
+ // On Node.js, createFromNodeStream reads RSC Flight data from a Node.js
18
+ // Readable (C++ backed) instead of a Web ReadableStream (JS reimplementation).
19
+ // Eliminates Promise-per-chunk overhead on the SSR decode path.
20
+ //
21
+ // The plugin only exports createFromReadableStream (via client.edge.js).
22
+ // createFromNodeStream lives in the vendored client.node.js. We import it
23
+ // directly and wire up the same serverConsumerManifest the plugin uses.
24
+
25
+ import { createServerConsumerManifest } from '@vitejs/plugin-rsc/ssr';
26
+
27
+ type CreateFromNodeStreamFn = (
28
+ stream: import('node:stream').Readable,
29
+ manifest: ReturnType<typeof createServerConsumerManifest>,
30
+ options?: Record<string, unknown>
31
+ ) => React.ReactNode;
32
+
33
+ let _createFromNodeStream: CreateFromNodeStreamFn | null = null;
34
+
35
+ try {
36
+ if (typeof process !== 'undefined' && process.release?.name === 'node') {
37
+ const clientNode = await import('@vitejs/plugin-rsc/vendor/react-server-dom/client.node');
38
+ if (typeof clientNode.createFromNodeStream === 'function') {
39
+ _createFromNodeStream = clientNode.createFromNodeStream;
40
+ }
41
+ }
42
+ } catch {
43
+ // Not available — fall back to createFromReadableStream
44
+ }
45
+
46
+ /**
47
+ * Decode an RSC Flight stream from a Node.js Readable.
48
+ *
49
+ * Uses the vendored createFromNodeStream which reads via Node.js native
50
+ * streams (C++ libuv) instead of Web ReadableStream (JS Promise per chunk).
51
+ *
52
+ * Returns null if createFromNodeStream isn't available (CF Workers, etc),
53
+ * signaling the caller to use createFromReadableStream instead.
54
+ */
55
+ export function createFromNodeStream(
56
+ stream: import('node:stream').Readable
57
+ ): React.ReactNode | null {
58
+ if (!_createFromNodeStream) return null;
59
+ return _createFromNodeStream(stream, createServerConsumerManifest());
60
+ }
61
+
62
+ /** Whether the Node.js stream RSC decode path is available. */
63
+ export const hasNodeStreamDecode = _createFromNodeStream !== null;
@@ -0,0 +1,7 @@
1
+ declare module '@vitejs/plugin-rsc/vendor/react-server-dom/client.node' {
2
+ export function createFromNodeStream(
3
+ stream: import('node:stream').Readable,
4
+ manifest: unknown,
5
+ options?: Record<string, unknown>
6
+ ): React.ReactNode;
7
+ }
@@ -16,6 +16,7 @@
16
16
  import { DenySignal, RedirectSignal } from './primitives.js';
17
17
  import type { AccessGateProps, SlotAccessGateProps, ReactElement } from './tree-builder.js';
18
18
  import { withSpan, setSpanAttribute } from './tracing.js';
19
+ import { isDebug } from './debug.js';
19
20
 
20
21
  // ─── AccessGate ─────────────────────────────────────────────────────────────
21
22
 
@@ -114,7 +115,7 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactE
114
115
  // slot would redirect the entire page, which breaks the contract that
115
116
  // slot failure is graceful degradation.
116
117
  if (error instanceof RedirectSignal) {
117
- if (process.env.NODE_ENV !== 'production') {
118
+ if (isDebug()) {
118
119
  console.error(
119
120
  '[timber] redirect() is not allowed in slot access.ts. ' +
120
121
  'Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. ' +
@@ -127,7 +128,7 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactE
127
128
 
128
129
  // Unhandled error — re-throw so error boundaries can catch it.
129
130
  // Dev-mode warning: slot access should use deny(), not throw.
130
- if (process.env.NODE_ENV !== 'production') {
131
+ if (isDebug()) {
131
132
  console.warn(
132
133
  '[timber] Unhandled error in slot access.ts. ' +
133
134
  'Use deny() for access control, not unhandled throws.',
@@ -184,6 +184,7 @@ async function runActionMiddleware<TCtx>(
184
184
  // Re-export parseFormData for use throughout the framework
185
185
  import { parseFormData } from './form-data.js';
186
186
  import { formatSize } from '#/utils/format.js';
187
+ import { isDebug, isDevMode } from './debug.js';
187
188
 
188
189
  /**
189
190
  * Extract validation errors from a schema error.
@@ -246,12 +247,15 @@ export function handleActionError(error: unknown): ActionResult<never> {
246
247
  };
247
248
  }
248
249
 
249
- // In dev, include the message for debugging
250
- const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
250
+ // In dev, include the message for debugging.
251
+ // Uses isDevMode() NOT isDebug() because this data is sent to the
252
+ // browser. TIMBER_DEBUG must never cause error messages to leak to clients.
253
+ // See design/13-security.md principle 4: "Errors don't leak."
254
+ const devMode = isDevMode();
251
255
  return {
252
256
  serverError: {
253
257
  code: 'INTERNAL_ERROR',
254
- ...(isDev && error instanceof Error ? { data: { message: error.message } } : {}),
258
+ ...(devMode && error instanceof Error ? { data: { message: error.message } } : {}),
255
259
  },
256
260
  };
257
261
  }
@@ -291,8 +295,14 @@ export function createActionClient<TCtx = Record<string, never>>(
291
295
  // Determine input — either FormData (from useActionState) or direct arg
292
296
  let rawInput: unknown;
293
297
  if (args.length === 2 && args[1] instanceof FormData) {
294
- // Called as (prevState, formData) by React useActionState
298
+ // Called as (prevState, formData) by React useActionState (with-JS path)
295
299
  rawInput = schema ? parseFormData(args[1]) : args[1];
300
+ } else if (args.length === 1 && args[0] instanceof FormData) {
301
+ // No-JS path: React's decodeAction binds FormData as the sole argument.
302
+ // The form POSTs without JavaScript, decodeAction resolves the server
303
+ // reference and binds the FormData, then executeAction calls fn() with
304
+ // no additional args — so the bound FormData arrives as args[0].
305
+ rawInput = schema ? parseFormData(args[0]) : args[0];
296
306
  } else {
297
307
  // Direct call: action(input)
298
308
  rawInput = args[0];
@@ -413,7 +423,7 @@ export function validated<TInput, TData>(
413
423
  * In production, validation errors are only returned to the client.
414
424
  */
415
425
  function logValidationFailure(errors: ValidationErrors): void {
416
- const isDev = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
426
+ const isDev = isDebug();
417
427
  if (!isDev) return;
418
428
 
419
429
  const fields = Object.entries(errors)
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Runtime debug flag for timber.js.
3
+ *
4
+ * Two distinct functions for two distinct security levels:
5
+ *
6
+ * ## `isDebug()` — server-side logging only
7
+ *
8
+ * Returns true when timber's debug/warning messages should be written to
9
+ * stderr / the server console. This NEVER affects what is sent to the
10
+ * client (no error details, no timing headers, no stack traces).
11
+ *
12
+ * Active when any of:
13
+ * - `NODE_ENV !== 'production'` (standard dev mode)
14
+ * - `TIMBER_DEBUG` env var is set to a truthy value at runtime
15
+ * - `timber.config.ts` has `debug: true`
16
+ *
17
+ * ## `isDevMode()` — client-visible dev behavior
18
+ *
19
+ * Returns true ONLY when `NODE_ENV !== 'production'`. This gates anything
20
+ * that changes what clients can observe:
21
+ * - Dev error pages with stack traces (fallback-error.ts)
22
+ * - Detailed Server-Timing headers (pipeline.ts)
23
+ * - Error messages in action INTERNAL_ERROR payloads (action-client.ts)
24
+ * - Pipeline error handler wiring (Vite overlay)
25
+ *
26
+ * `isDevMode()` is statically replaced in production builds → the guarded
27
+ * code is tree-shaken to zero bytes. TIMBER_DEBUG cannot enable it.
28
+ *
29
+ * Usage:
30
+ * In Cloudflare Workers wrangler.toml:
31
+ * [vars]
32
+ * TIMBER_DEBUG = "1"
33
+ *
34
+ * In Node.js:
35
+ * TIMBER_DEBUG=1 node server.js
36
+ *
37
+ * In timber.config.ts:
38
+ * export default { debug: true }
39
+ *
40
+ * See design/13-security.md for the security taxonomy.
41
+ * See design/18-build-system.md for build pipeline details.
42
+ */
43
+
44
+ // ─── Dev Mode (client-visible) ──────────────────────────────────────────────
45
+
46
+ /**
47
+ * Check if the application is running in development mode.
48
+ *
49
+ * This is the ONLY function that should gate client-visible dev behavior:
50
+ * - Dev error pages with stack traces
51
+ * - Server-Timing mode default (`'detailed'` in dev, `'total'` in prod)
52
+ * - Error messages in action `INTERNAL_ERROR` payloads
53
+ * - Pipeline error handler wiring (Vite overlay)
54
+ *
55
+ * Returns `process.env.NODE_ENV !== 'production'`, which is statically
56
+ * replaced by the bundler in production builds. Code guarded by this
57
+ * function is tree-shaken to zero bytes in production.
58
+ *
59
+ * TIMBER_DEBUG does NOT enable this — that would leak server internals
60
+ * to clients. Use `isDebug()` for server-side-only logging.
61
+ */
62
+ export function isDevMode(): boolean {
63
+ return process.env.NODE_ENV !== 'production';
64
+ }
65
+
66
+ // ─── Debug Flag (server-side logging only) ──────────────────────────────────
67
+
68
+ /**
69
+ * Config-level debug override. Set via `setDebugFromConfig()` during
70
+ * initialization when timber.config.ts has `debug: true`.
71
+ */
72
+ let _configDebug = false;
73
+
74
+ /**
75
+ * Set the debug flag from timber.config.ts.
76
+ * Called during handler initialization.
77
+ */
78
+ export function setDebugFromConfig(debug: boolean): void {
79
+ _configDebug = debug;
80
+ }
81
+
82
+ /**
83
+ * Check if timber debug logging is active (server-side only).
84
+ *
85
+ * Returns true if ANY of these conditions hold:
86
+ * - NODE_ENV is not 'production' (standard dev mode)
87
+ * - TIMBER_DEBUG environment variable is set to a truthy value at runtime
88
+ * - timber.config.ts has `debug: true`
89
+ *
90
+ * This function controls ONLY server-side logging — messages written to
91
+ * stderr or the server console. It NEVER affects client-visible behavior
92
+ * (error pages, response headers, action payloads). For client-visible
93
+ * behavior, use `isDevMode()`.
94
+ *
95
+ * The TIMBER_DEBUG check is deliberately written as a dynamic property
96
+ * access so bundlers cannot statically replace it.
97
+ */
98
+ export function isDebug(): boolean {
99
+ // Fast path: dev mode (statically replaced to `true` in dev, `false` in prod)
100
+ if (process.env.NODE_ENV !== 'production') return true;
101
+
102
+ // Config override
103
+ if (_configDebug) return true;
104
+
105
+ // Runtime env var check — uses dynamic access to prevent static replacement.
106
+ // In production builds, process.env.NODE_ENV is statically replaced, but
107
+ // TIMBER_DEBUG must survive as a runtime check. The dynamic key access
108
+ // pattern ensures the bundler treats this as opaque.
109
+ return _readTimberDebugEnv();
110
+ }
111
+
112
+ /**
113
+ * Read TIMBER_DEBUG from the environment at runtime.
114
+ *
115
+ * Extracted to a separate function to:
116
+ * 1. Prevent bundler inlining (cross-module function calls are not inlined)
117
+ * 2. Handle platforms where `process` may not exist (Cloudflare Workers)
118
+ * 3. Support globalThis.__TIMBER_DEBUG for programmatic control
119
+ */
120
+ function _readTimberDebugEnv(): boolean {
121
+ // globalThis override — useful for programmatic control and testing
122
+ if ((globalThis as Record<string, unknown>).__TIMBER_DEBUG) return true;
123
+
124
+ // process.env — works in Node.js and platforms that polyfill process
125
+ try {
126
+ const key = 'TIMBER_DEBUG';
127
+ const val =
128
+ typeof process !== 'undefined' && process.env
129
+ ? (process.env as Record<string, string | undefined>)[key]
130
+ : undefined;
131
+ if (val && val !== '0' && val !== 'false') return true;
132
+ } catch {
133
+ // process may not exist or env may throw — safe to ignore
134
+ }
135
+
136
+ return false;
137
+ }
@@ -20,6 +20,7 @@ import { renderToReadableStream } from '#/rsc-runtime/rsc.js';
20
20
 
21
21
  import { DenySignal } from './primitives.js';
22
22
  import { logRenderError } from './logger.js';
23
+ import { isDebug } from './debug.js';
23
24
  import { resolveMetadata, renderMetadataToElements } from './metadata.js';
24
25
  import { resolveManifestStatusFile } from './manifest-status-resolver.js';
25
26
  import type { ManifestSegmentNode } from './route-matcher.js';
@@ -94,7 +95,7 @@ export async function renderDenyPage(
94
95
 
95
96
  // Dev warning: JSON status file exists but is shadowed by the component chain.
96
97
  // This helps developers understand why their .json file isn't being served.
97
- if (process.env.NODE_ENV !== 'production') {
98
+ if (isDebug()) {
98
99
  const jsonResolution = resolveManifestStatusFile(deny.status, segments, 'json');
99
100
  if (jsonResolution) {
100
101
  console.warn(
@@ -133,7 +134,7 @@ export async function renderDenyPage(
133
134
  const { component } = layoutsToWrap[i];
134
135
  element = h(component, null, element);
135
136
  }
136
- } else if (process.env.NODE_ENV !== 'production') {
137
+ } else if (isDebug()) {
137
138
  // Dev-mode: warn if shell=false might conflict with Suspense
138
139
  // The actual Suspense boundary check happens at render time in the pipeline.
139
140
  // This is a preemptive log for developer awareness.
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { ViteDevServer } from 'vite';
18
+ import { isDebug } from './debug.js';
18
19
 
19
20
  // ─── Warning IDs ───────────────────────────────────────────────────────────
20
21
 
@@ -54,7 +55,7 @@ export function setViteServer(server: ViteDevServer | null): void {
54
55
  }
55
56
 
56
57
  function isDev(): boolean {
57
- return process.env.NODE_ENV !== 'production';
58
+ return isDebug();
58
59
  }
59
60
 
60
61
  /**