@timber-js/app 0.2.0-alpha.3 → 0.2.0-alpha.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{als-registry-k-AtAQ9R.js → als-registry-B7DbZ2hS.js} +1 -1
- package/dist/_chunks/{als-registry-k-AtAQ9R.js.map → als-registry-B7DbZ2hS.js.map} +1 -1
- package/dist/_chunks/debug-B3Gypr3D.js +108 -0
- package/dist/_chunks/debug-B3Gypr3D.js.map +1 -0
- package/dist/_chunks/{format-DNt20Kt8.js → format-RyoGQL74.js} +3 -2
- package/dist/_chunks/format-RyoGQL74.js.map +1 -0
- package/dist/_chunks/{interception-DGDIjDbR.js → interception-BOoWmLUA.js} +2 -2
- package/dist/_chunks/{interception-DGDIjDbR.js.map → interception-BOoWmLUA.js.map} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js → metadata-routes-Cjmvi3rQ.js} +1 -1
- package/dist/_chunks/{metadata-routes-CQCnF4VK.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
- package/dist/_chunks/{request-context-CRj2Zh1E.js → request-context-BQUC8PHn.js} +5 -4
- package/dist/_chunks/request-context-BQUC8PHn.js.map +1 -0
- package/dist/_chunks/{ssr-data-DLnbYpj1.js → ssr-data-MjmprTmO.js} +1 -1
- package/dist/_chunks/{ssr-data-DLnbYpj1.js.map → ssr-data-MjmprTmO.js.map} +1 -1
- package/dist/_chunks/{tracing-DF0G3FB7.js → tracing-CemImE6h.js} +17 -3
- package/dist/_chunks/{tracing-DF0G3FB7.js.map → tracing-CemImE6h.js.map} +1 -1
- package/dist/_chunks/{use-cookie-dDbpCTx-.js → use-cookie-DX-l1_5E.js} +2 -2
- package/dist/_chunks/{use-cookie-dDbpCTx-.js.map → use-cookie-DX-l1_5E.js.map} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-D5KaffOK.js} +1 -1
- package/dist/_chunks/{use-query-states-DAhgj8Gx.js.map → use-query-states-D5KaffOK.js.map} +1 -1
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts +17 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +26 -9
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/fast-hash.d.ts +22 -0
- package/dist/cache/fast-hash.d.ts.map +1 -0
- package/dist/cache/index.js +52 -10
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/register-cached-function.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +1 -1
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/cookies/index.js +4 -4
- package/dist/fonts/css.d.ts +1 -0
- package/dist/fonts/css.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +4 -2
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +249 -21
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +11 -1
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts +7 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +9 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/mdx.d.ts +6 -0
- package/dist/plugins/mdx.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/rsc-runtime/ssr.d.ts +12 -0
- package/dist/rsc-runtime/ssr.d.ts.map +1 -1
- package/dist/search-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/debug.d.ts +82 -0
- package/dist/server/debug.d.ts.map +1 -0
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/dev-warnings.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +32 -23
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +65 -0
- package/dist/server/node-stream-transforms.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +7 -4
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +6 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/rsc-prop-warnings.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +34 -21
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/tracing.d.ts +10 -0
- package/dist/server/tracing.d.ts.map +1 -1
- package/dist/server/waituntil-bridge.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +1 -1
- package/src/adapters/compress-module.ts +21 -4
- package/src/adapters/nitro.ts +31 -5
- package/src/cache/fast-hash.ts +34 -0
- package/src/cache/register-cached-function.ts +7 -3
- package/src/cache/timber-cache.ts +17 -10
- package/src/client/browser-entry.ts +10 -6
- package/src/client/link.tsx +14 -9
- package/src/client/router.ts +4 -6
- package/src/client/segment-context.ts +6 -1
- package/src/client/segment-merger.ts +2 -8
- package/src/client/stale-reload.ts +5 -7
- package/src/client/top-loader.tsx +8 -7
- package/src/client/transition-root.tsx +7 -1
- package/src/fonts/css.ts +2 -1
- package/src/fonts/local.ts +7 -3
- package/src/index.ts +35 -2
- package/src/plugins/build-report.ts +23 -3
- package/src/plugins/entries.ts +9 -4
- package/src/plugins/fonts.ts +171 -19
- package/src/plugins/mdx.ts +9 -5
- package/src/plugins/server-bundle.ts +4 -0
- package/src/rsc-runtime/ssr.ts +50 -0
- package/src/rsc-runtime/vendor-types.d.ts +7 -0
- package/src/server/access-gate.tsx +3 -2
- package/src/server/action-client.ts +15 -5
- package/src/server/debug.ts +137 -0
- package/src/server/deny-renderer.ts +3 -2
- package/src/server/dev-warnings.ts +2 -1
- package/src/server/html-injectors.ts +30 -10
- package/src/server/logger.ts +4 -3
- package/src/server/node-stream-transforms.ts +315 -0
- package/src/server/pipeline.ts +34 -20
- package/src/server/primitives.ts +2 -1
- package/src/server/request-context.ts +3 -2
- package/src/server/route-element-builder.ts +1 -6
- package/src/server/rsc-entry/index.ts +50 -7
- package/src/server/rsc-entry/rsc-payload.ts +42 -7
- package/src/server/rsc-entry/rsc-stream.ts +10 -5
- package/src/server/rsc-entry/ssr-renderer.ts +12 -5
- package/src/server/rsc-prop-warnings.ts +3 -1
- package/src/server/ssr-entry.ts +130 -8
- package/src/server/ssr-render.ts +168 -57
- package/src/server/tracing.ts +23 -0
- package/src/server/waituntil-bridge.ts +4 -1
- package/dist/_chunks/format-DNt20Kt8.js.map +0 -1
- package/dist/_chunks/request-context-CRj2Zh1E.js.map +0 -1
package/src/plugins/fonts.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
366
|
-
|
|
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
|
package/src/plugins/mdx.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
};
|
package/src/rsc-runtime/ssr.ts
CHANGED
|
@@ -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;
|
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
...(
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
58
|
+
return isDebug();
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|