@timber-js/app 0.1.1 → 0.1.3

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 (143) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -7
  3. package/dist/index.js.map +1 -1
  4. package/dist/plugins/dev-server.d.ts.map +1 -1
  5. package/dist/plugins/entries.d.ts.map +1 -1
  6. package/package.json +5 -4
  7. package/src/adapters/cloudflare.ts +325 -0
  8. package/src/adapters/nitro.ts +366 -0
  9. package/src/adapters/types.ts +63 -0
  10. package/src/cache/index.ts +91 -0
  11. package/src/cache/redis-handler.ts +91 -0
  12. package/src/cache/register-cached-function.ts +99 -0
  13. package/src/cache/singleflight.ts +26 -0
  14. package/src/cache/stable-stringify.ts +21 -0
  15. package/src/cache/timber-cache.ts +116 -0
  16. package/src/cli.ts +201 -0
  17. package/src/client/browser-entry.ts +663 -0
  18. package/src/client/error-boundary.tsx +209 -0
  19. package/src/client/form.tsx +200 -0
  20. package/src/client/head.ts +61 -0
  21. package/src/client/history.ts +46 -0
  22. package/src/client/index.ts +60 -0
  23. package/src/client/link-navigate-interceptor.tsx +62 -0
  24. package/src/client/link-status-provider.tsx +40 -0
  25. package/src/client/link.tsx +310 -0
  26. package/src/client/nuqs-adapter.tsx +117 -0
  27. package/src/client/router-ref.ts +25 -0
  28. package/src/client/router.ts +563 -0
  29. package/src/client/segment-cache.ts +194 -0
  30. package/src/client/segment-context.ts +57 -0
  31. package/src/client/ssr-data.ts +95 -0
  32. package/src/client/types.ts +4 -0
  33. package/src/client/unload-guard.ts +34 -0
  34. package/src/client/use-cookie.ts +122 -0
  35. package/src/client/use-link-status.ts +46 -0
  36. package/src/client/use-navigation-pending.ts +47 -0
  37. package/src/client/use-params.ts +71 -0
  38. package/src/client/use-pathname.ts +43 -0
  39. package/src/client/use-query-states.ts +133 -0
  40. package/src/client/use-router.ts +77 -0
  41. package/src/client/use-search-params.ts +74 -0
  42. package/src/client/use-selected-layout-segment.ts +110 -0
  43. package/src/content/index.ts +13 -0
  44. package/src/cookies/define-cookie.ts +137 -0
  45. package/src/cookies/index.ts +9 -0
  46. package/src/fonts/ast.ts +359 -0
  47. package/src/fonts/css.ts +68 -0
  48. package/src/fonts/fallbacks.ts +248 -0
  49. package/src/fonts/google.ts +332 -0
  50. package/src/fonts/local.ts +177 -0
  51. package/src/fonts/types.ts +88 -0
  52. package/src/index.ts +420 -0
  53. package/src/plugins/adapter-build.ts +118 -0
  54. package/src/plugins/build-manifest.ts +323 -0
  55. package/src/plugins/build-report.ts +353 -0
  56. package/src/plugins/cache-transform.ts +199 -0
  57. package/src/plugins/chunks.ts +90 -0
  58. package/src/plugins/content.ts +136 -0
  59. package/src/plugins/dev-error-overlay.ts +230 -0
  60. package/src/plugins/dev-logs.ts +280 -0
  61. package/src/plugins/dev-server.ts +391 -0
  62. package/src/plugins/dynamic-transform.ts +161 -0
  63. package/src/plugins/entries.ts +214 -0
  64. package/src/plugins/fonts.ts +581 -0
  65. package/src/plugins/mdx.ts +179 -0
  66. package/src/plugins/react-prod.ts +56 -0
  67. package/src/plugins/routing.ts +419 -0
  68. package/src/plugins/server-action-exports.ts +220 -0
  69. package/src/plugins/server-bundle.ts +113 -0
  70. package/src/plugins/shims.ts +168 -0
  71. package/src/plugins/static-build.ts +207 -0
  72. package/src/routing/codegen.ts +396 -0
  73. package/src/routing/index.ts +14 -0
  74. package/src/routing/interception.ts +173 -0
  75. package/src/routing/scanner.ts +487 -0
  76. package/src/routing/status-file-lint.ts +114 -0
  77. package/src/routing/types.ts +100 -0
  78. package/src/search-params/analyze.ts +192 -0
  79. package/src/search-params/codecs.ts +153 -0
  80. package/src/search-params/create.ts +314 -0
  81. package/src/search-params/index.ts +23 -0
  82. package/src/search-params/registry.ts +31 -0
  83. package/src/server/access-gate.tsx +142 -0
  84. package/src/server/action-client.ts +473 -0
  85. package/src/server/action-handler.ts +325 -0
  86. package/src/server/actions.ts +236 -0
  87. package/src/server/asset-headers.ts +81 -0
  88. package/src/server/body-limits.ts +102 -0
  89. package/src/server/build-manifest.ts +234 -0
  90. package/src/server/canonicalize.ts +90 -0
  91. package/src/server/client-module-map.ts +58 -0
  92. package/src/server/csrf.ts +79 -0
  93. package/src/server/deny-renderer.ts +302 -0
  94. package/src/server/dev-logger.ts +419 -0
  95. package/src/server/dev-span-processor.ts +78 -0
  96. package/src/server/dev-warnings.ts +282 -0
  97. package/src/server/early-hints-sender.ts +55 -0
  98. package/src/server/early-hints.ts +142 -0
  99. package/src/server/error-boundary-wrapper.ts +69 -0
  100. package/src/server/error-formatter.ts +184 -0
  101. package/src/server/flush.ts +182 -0
  102. package/src/server/form-data.ts +176 -0
  103. package/src/server/form-flash.ts +93 -0
  104. package/src/server/html-injectors.ts +445 -0
  105. package/src/server/index.ts +222 -0
  106. package/src/server/instrumentation.ts +136 -0
  107. package/src/server/logger.ts +145 -0
  108. package/src/server/manifest-status-resolver.ts +215 -0
  109. package/src/server/metadata-render.ts +527 -0
  110. package/src/server/metadata-routes.ts +189 -0
  111. package/src/server/metadata.ts +263 -0
  112. package/src/server/middleware-runner.ts +32 -0
  113. package/src/server/nuqs-ssr-provider.tsx +63 -0
  114. package/src/server/pipeline.ts +555 -0
  115. package/src/server/prerender.ts +139 -0
  116. package/src/server/primitives.ts +264 -0
  117. package/src/server/proxy.ts +43 -0
  118. package/src/server/request-context.ts +554 -0
  119. package/src/server/route-element-builder.ts +395 -0
  120. package/src/server/route-handler.ts +153 -0
  121. package/src/server/route-matcher.ts +316 -0
  122. package/src/server/rsc-entry/api-handler.ts +112 -0
  123. package/src/server/rsc-entry/error-renderer.ts +177 -0
  124. package/src/server/rsc-entry/helpers.ts +147 -0
  125. package/src/server/rsc-entry/index.ts +688 -0
  126. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  127. package/src/server/slot-resolver.ts +359 -0
  128. package/src/server/ssr-entry.ts +161 -0
  129. package/src/server/ssr-render.ts +200 -0
  130. package/src/server/status-code-resolver.ts +282 -0
  131. package/src/server/tracing.ts +281 -0
  132. package/src/server/tree-builder.ts +354 -0
  133. package/src/server/types.ts +150 -0
  134. package/src/shims/font-google.ts +67 -0
  135. package/src/shims/headers.ts +11 -0
  136. package/src/shims/image.ts +48 -0
  137. package/src/shims/link.ts +9 -0
  138. package/src/shims/navigation-client.ts +52 -0
  139. package/src/shims/navigation.ts +31 -0
  140. package/src/shims/server-only-noop.js +5 -0
  141. package/src/utils/directive-parser.ts +529 -0
  142. package/src/utils/format.ts +10 -0
  143. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * timber-build-manifest — Vite sub-plugin for build asset manifest generation.
3
+ *
4
+ * Provides `virtual:timber-build-manifest` which exports a BuildManifest
5
+ * mapping route segment file paths to their CSS, JS, and modulepreload
6
+ * output chunks.
7
+ *
8
+ * - Dev mode: exports an empty manifest (Vite HMR handles CSS/JS).
9
+ * - Build mode: virtual module reads from globalThis.__TIMBER_BUILD_MANIFEST__
10
+ * at runtime. The actual manifest data is injected by the adapter via a
11
+ * _timber-manifest-init.js module that runs before the RSC handler.
12
+ *
13
+ * The generateBundle hook (client env only) extracts CSS/JS/modulepreload
14
+ * data from the Rollup bundle and populates ctx.buildManifest.
15
+ *
16
+ * Design docs: 18-build-system.md §"Build Manifest", 02-rendering-pipeline.md §"Early Hints"
17
+ */
18
+
19
+ import type { Plugin, ResolvedConfig } from 'vite';
20
+ import type { PluginContext } from '#/index.js';
21
+ import type { BuildManifest } from '#/server/build-manifest.js';
22
+
23
+ // Rollup types used by generateBundle hook — imported from vite which re-exports them.
24
+ // We define minimal interfaces here to avoid a direct 'rollup' dependency.
25
+ interface OutputChunk {
26
+ type: 'chunk';
27
+ fileName: string;
28
+ facadeModuleId: string | null;
29
+ imports: string[];
30
+ name: string;
31
+ code: string;
32
+ viteMetadata?: { importedCss?: Set<string> };
33
+ }
34
+
35
+ interface OutputAsset {
36
+ type: 'asset';
37
+ fileName: string;
38
+ }
39
+
40
+ type OutputBundle = Record<string, OutputChunk | OutputAsset>;
41
+
42
+ const VIRTUAL_MODULE_ID = 'virtual:timber-build-manifest';
43
+ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
44
+
45
+ /**
46
+ * Vite's manifest.json entry shape (subset we need).
47
+ * See https://vite.dev/guide/backend-integration.html
48
+ */
49
+ interface ViteManifestEntry {
50
+ file: string;
51
+ css?: string[];
52
+ imports?: string[];
53
+ }
54
+
55
+ /**
56
+ * Parse Vite's .vite/manifest.json into a BuildManifest.
57
+ *
58
+ * Walks each entry and collects:
59
+ * - `css`: CSS output URLs per input file (transitive CSS included by Vite)
60
+ * - `js`: Hashed JS chunk URL per input file
61
+ * - `modulepreload`: Transitive JS dependency URLs per input file
62
+ *
63
+ * Keys are input file paths (relative to project root).
64
+ */
65
+ export function parseViteManifest(
66
+ viteManifest: Record<string, ViteManifestEntry>,
67
+ base: string
68
+ ): BuildManifest {
69
+ const css: Record<string, string[]> = {};
70
+ const js: Record<string, string> = {};
71
+ const modulepreload: Record<string, string[]> = {};
72
+
73
+ for (const [inputPath, entry] of Object.entries(viteManifest)) {
74
+ // JS chunk mapping
75
+ js[inputPath] = base + entry.file;
76
+
77
+ // CSS mapping
78
+ if (entry.css && entry.css.length > 0) {
79
+ css[inputPath] = entry.css.map((cssPath) => base + cssPath);
80
+ }
81
+
82
+ // Collect transitive JS dependencies for modulepreload
83
+ modulepreload[inputPath] = collectTransitiveDeps(inputPath, viteManifest, base);
84
+ }
85
+
86
+ return { css, js, modulepreload, fonts: {} };
87
+ }
88
+
89
+ /**
90
+ * Recursively collect transitive JS dependency URLs for an entry.
91
+ *
92
+ * Walks the `imports` graph in Vite's manifest, resolving each import
93
+ * key to its output `file` URL. Deduplicates to avoid cycles and
94
+ * redundant preloads.
95
+ */
96
+ function collectTransitiveDeps(
97
+ entryKey: string,
98
+ manifest: Record<string, ViteManifestEntry>,
99
+ base: string
100
+ ): string[] {
101
+ const seen = new Set<string>();
102
+ const result: string[] = [];
103
+
104
+ function walk(key: string) {
105
+ const entry = manifest[key];
106
+ if (!entry?.imports) return;
107
+
108
+ for (const importKey of entry.imports) {
109
+ if (seen.has(importKey)) continue;
110
+ seen.add(importKey);
111
+
112
+ const dep = manifest[importKey];
113
+ if (dep) {
114
+ result.push(base + dep.file);
115
+ walk(importKey);
116
+ }
117
+ }
118
+ }
119
+
120
+ walk(entryKey);
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Build a BuildManifest from a Rollup output bundle.
126
+ *
127
+ * Unlike parseViteManifest (which reads Vite's manifest.json), this
128
+ * works directly with the Rollup bundle output. This is necessary because
129
+ * the RSC plugin doesn't produce a standard Vite manifest.json.
130
+ *
131
+ * Walks each chunk in the bundle and collects:
132
+ * - `css`: CSS files imported by each chunk (via viteMetadata.importedCss)
133
+ * - `js`: The output filename for each chunk with a facadeModuleId
134
+ * - `modulepreload`: Transitive JS imports for each entry chunk
135
+ *
136
+ * Keys are input file paths relative to root.
137
+ */
138
+ export function buildManifestFromBundle(
139
+ bundle: OutputBundle,
140
+ base: string,
141
+ root: string
142
+ ): BuildManifest {
143
+ const css: Record<string, string[]> = {};
144
+ const js: Record<string, string> = {};
145
+ const modulepreload: Record<string, string[]> = {};
146
+
147
+ // Build a map of chunk fileName → chunk for transitive dep resolution
148
+ const chunksByFileName = new Map<string, OutputChunk>();
149
+ for (const item of Object.values(bundle) as (OutputChunk | OutputAsset)[]) {
150
+ if (item.type === 'chunk') {
151
+ chunksByFileName.set(item.fileName, item);
152
+ }
153
+ }
154
+
155
+ for (const item of Object.values(bundle) as (OutputChunk | OutputAsset)[]) {
156
+ if (item.type !== 'chunk') continue;
157
+
158
+ const chunk = item;
159
+ if (!chunk.facadeModuleId) continue;
160
+
161
+ // Convert absolute facadeModuleId to root-relative path
162
+ let inputPath = chunk.facadeModuleId;
163
+ if (inputPath.startsWith(root)) {
164
+ inputPath = inputPath.slice(root.length + 1);
165
+ }
166
+
167
+ // JS chunk mapping
168
+ js[inputPath] = base + chunk.fileName;
169
+
170
+ // CSS mapping via viteMetadata
171
+ const viteMetadata = chunk.viteMetadata;
172
+ if (viteMetadata?.importedCss && viteMetadata.importedCss.size > 0) {
173
+ css[inputPath] = Array.from(viteMetadata.importedCss).map((cssFile) => base + cssFile);
174
+ }
175
+
176
+ // Collect transitive JS dependencies for modulepreload
177
+ const deps = collectTransitiveBundleDeps(chunk, chunksByFileName, base);
178
+ if (deps.length > 0) {
179
+ modulepreload[inputPath] = deps;
180
+ }
181
+ }
182
+
183
+ // Collect ALL CSS assets from the bundle under the `_global` key.
184
+ // Route files (app/layout.tsx, app/page.tsx) are server components —
185
+ // they don't appear in the client bundle, so per-route CSS keying
186
+ // via facadeModuleId doesn't work. The RSC plugin handles per-route
187
+ // CSS injection via data-rsc-css-href. For Link headers (103 Early
188
+ // Hints), we emit all CSS files — they're just prefetch hints.
189
+ const allCss: string[] = [];
190
+ for (const item of Object.values(bundle) as (OutputChunk | OutputAsset)[]) {
191
+ if (item.type === 'asset' && item.fileName.endsWith('.css')) {
192
+ allCss.push(base + item.fileName);
193
+ }
194
+ }
195
+ if (allCss.length > 0) {
196
+ css['_global'] = allCss;
197
+ }
198
+
199
+ return { css, js, modulepreload, fonts: {} };
200
+ }
201
+
202
+ /**
203
+ * Recursively collect transitive JS dependency URLs from a bundle chunk.
204
+ */
205
+ function collectTransitiveBundleDeps(
206
+ chunk: OutputChunk,
207
+ chunksByFileName: Map<string, OutputChunk>,
208
+ base: string
209
+ ): string[] {
210
+ const seen = new Set<string>();
211
+ const result: string[] = [];
212
+
213
+ function walk(imports: string[]) {
214
+ for (const importFileName of imports) {
215
+ if (seen.has(importFileName)) continue;
216
+ seen.add(importFileName);
217
+
218
+ result.push(base + importFileName);
219
+ const dep = chunksByFileName.get(importFileName);
220
+ if (dep) {
221
+ walk(dep.imports);
222
+ }
223
+ }
224
+ }
225
+
226
+ walk(chunk.imports);
227
+ return result;
228
+ }
229
+
230
+ /**
231
+ * Create the timber-build-manifest Vite plugin.
232
+ *
233
+ * Hooks: configResolved, resolveId, load, generateBundle (client env only)
234
+ */
235
+ export function timberBuildManifest(ctx: PluginContext): Plugin {
236
+ let resolvedBase = '/';
237
+ let isDev = false;
238
+
239
+ return {
240
+ name: 'timber-build-manifest',
241
+
242
+ configResolved(config: ResolvedConfig) {
243
+ resolvedBase = config.base;
244
+ isDev = config.command === 'serve';
245
+ },
246
+
247
+ resolveId(id: string) {
248
+ const cleanId = id.startsWith('\0') ? id.slice(1) : id;
249
+
250
+ if (cleanId === VIRTUAL_MODULE_ID) {
251
+ return RESOLVED_VIRTUAL_ID;
252
+ }
253
+
254
+ if (cleanId.endsWith(`/${VIRTUAL_MODULE_ID}`)) {
255
+ return RESOLVED_VIRTUAL_ID;
256
+ }
257
+
258
+ return null;
259
+ },
260
+
261
+ load(id: string) {
262
+ if (id !== RESOLVED_VIRTUAL_ID) return null;
263
+
264
+ // In dev mode, return empty manifest — Vite HMR handles CSS.
265
+ if (isDev) {
266
+ return [
267
+ '// Auto-generated build manifest — do not edit.',
268
+ '// Dev mode: empty manifest (Vite HMR handles CSS/JS).',
269
+ '',
270
+ 'const manifest = { css: {}, js: {}, modulepreload: {}, fonts: {} };',
271
+ '',
272
+ 'export default manifest;',
273
+ ].join('\n');
274
+ }
275
+
276
+ // In production, read from globalThis at runtime.
277
+ // The adapter writes a _timber-manifest-init.js that sets this global
278
+ // before the RSC handler imports this module. ESM evaluation order
279
+ // guarantees the global is set by the time this code runs.
280
+ return [
281
+ '// Auto-generated build manifest — do not edit.',
282
+ '// Production: reads manifest from globalThis (set by _timber-manifest-init.js).',
283
+ '',
284
+ 'const manifest = globalThis.__TIMBER_BUILD_MANIFEST__ ?? { css: {}, js: {}, modulepreload: {}, fonts: {} };',
285
+ '',
286
+ 'export default manifest;',
287
+ ].join('\n');
288
+ },
289
+
290
+ // Extract manifest data from the client bundle only.
291
+ // The RSC plugin runs builds in order: RSC → client → SSR.
292
+ // We only want client env data (CSS assets, client JS chunks).
293
+ generateBundle(_options, bundle) {
294
+ if (isDev) return;
295
+
296
+ const envName = (this as { environment?: { name: string } }).environment?.name;
297
+
298
+ if (envName === 'client') {
299
+ ctx.buildManifest = buildManifestFromBundle(bundle, resolvedBase, ctx.root);
300
+
301
+ // When client JavaScript is disabled, strip JS chunks from the bundle
302
+ // so Rollup never writes them to disk. CSS assets are preserved —
303
+ // they're still needed for server-rendered HTML.
304
+ //
305
+ // This is an optimization: adapter-build.ts still strips JS from the
306
+ // build manifest and RSC assets manifest as a defense-in-depth fallback.
307
+ if (ctx.clientJavascript.disabled) {
308
+ // Clear JS and modulepreload from the manifest (they'd be stripped
309
+ // by adapter-build.ts anyway, but doing it here avoids the round-trip).
310
+ ctx.buildManifest.js = {};
311
+ ctx.buildManifest.modulepreload = {};
312
+
313
+ // Remove JS chunks from the Rollup bundle — prevents disk writes.
314
+ for (const [fileName, item] of Object.entries(bundle)) {
315
+ if (item.type === 'chunk') {
316
+ delete bundle[fileName];
317
+ }
318
+ }
319
+ }
320
+ }
321
+ },
322
+ };
323
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * timber-build-report — Post-build route summary table.
3
+ *
4
+ * After a production build, logs a per-route table showing:
5
+ * - Route type (○ static, λ dynamic, ƒ function)
6
+ * - Route-specific client JS size
7
+ * - First-load JS size (gzip) — route-specific + shared chunks
8
+ *
9
+ * Only active during production builds. Sizes are computed from the
10
+ * already-generated Vite client bundle — no extra analysis passes.
11
+ *
12
+ * Design docs: 18-build-system.md §"Build Pipeline", 07-routing.md
13
+ * Task: TIM-287
14
+ */
15
+
16
+ import { gzipSync } from 'node:zlib';
17
+ import type { Plugin, Logger } from 'vite';
18
+ import type { PluginContext } from '#/index.js';
19
+ import type { SegmentNode, RouteTree } from '#/routing/types.js';
20
+ import { formatSize } from '#/utils/format.js';
21
+
22
+ // ─── Public types ─────────────────────────────────────────────────────────
23
+
24
+ export type RouteType = 'static' | 'dynamic' | 'function';
25
+
26
+ export interface RouteEntry {
27
+ path: string;
28
+ type: RouteType;
29
+ /** Route-specific client JS size in bytes (raw). */
30
+ size: number;
31
+ /** Total first-load JS in bytes (gzip): route-specific + shared. */
32
+ firstLoadSize: number;
33
+ }
34
+
35
+ // ─── Route classification ─────────────────────────────────────────────────
36
+
37
+ const ROUTE_TYPE_ICONS: Record<RouteType, string> = {
38
+ static: '○',
39
+ dynamic: 'λ',
40
+ function: 'ƒ',
41
+ };
42
+
43
+ /**
44
+ * Classify a route by its segment chain and output mode.
45
+ *
46
+ * In server mode (default), all pages are dynamic (rendered per-request).
47
+ * In static mode, only pages with dynamic/catch-all segments are dynamic.
48
+ * API routes (route.ts) are always classified as function.
49
+ */
50
+ export function classifyRoute(
51
+ segments: SegmentNode[],
52
+ outputMode: 'server' | 'static' = 'server'
53
+ ): RouteType {
54
+ const leaf = segments[segments.length - 1];
55
+ if (leaf?.route) return 'function';
56
+ if (outputMode === 'server') return 'dynamic';
57
+
58
+ const isDynamic = segments.some(
59
+ (s) =>
60
+ s.segmentType === 'dynamic' ||
61
+ s.segmentType === 'catch-all' ||
62
+ s.segmentType === 'optional-catch-all'
63
+ );
64
+ return isDynamic ? 'dynamic' : 'static';
65
+ }
66
+
67
+ // ─── Size helpers ─────────────────────────────────────────────────────────
68
+
69
+ export { formatSize };
70
+
71
+ function green(text: string): string {
72
+ return `\x1b[92m${text}\x1b[39m`; // bright/light green (ANSI 92)
73
+ }
74
+
75
+ // ─── Route tree collection ────────────────────────────────────────────────
76
+
77
+ interface RouteInfo {
78
+ path: string;
79
+ segments: SegmentNode[];
80
+ entryFilePath: string | null;
81
+ }
82
+
83
+ /** Walk the route tree and collect all leaf routes (pages + API endpoints). */
84
+ export function collectRoutes(tree: RouteTree): RouteInfo[] {
85
+ const routes: RouteInfo[] = [];
86
+
87
+ function walk(node: SegmentNode, chain: SegmentNode[]): void {
88
+ const currentChain = [...chain, node];
89
+ const path = node.urlPath || '/';
90
+
91
+ if (node.page) {
92
+ routes.push({ path, segments: currentChain, entryFilePath: node.page.filePath });
93
+ }
94
+ if (node.route) {
95
+ routes.push({ path, segments: currentChain, entryFilePath: node.route.filePath });
96
+ }
97
+
98
+ for (const child of node.children) walk(child, currentChain);
99
+ for (const slot of node.slots.values()) walk(slot, currentChain);
100
+ }
101
+
102
+ walk(tree.root, []);
103
+ return routes;
104
+ }
105
+
106
+ // ─── Report formatting ────────────────────────────────────────────────────
107
+
108
+ /** Produce formatted report lines for the Vite logger. */
109
+ export function buildRouteReport(entries: RouteEntry[], sharedSize: number): string[] {
110
+ const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));
111
+
112
+ const header = 'Route (app)';
113
+ const sizeHeader = 'Size';
114
+ const firstLoadHeader = 'First Load JS';
115
+
116
+ const pathW = Math.max(header.length + 2, ...sorted.map((e) => e.path.length + 6));
117
+ const sizeW = Math.max(sizeHeader.length, 14);
118
+ const flW = Math.max(firstLoadHeader.length, 10);
119
+ const totalW = pathW + sizeW + flW + 4;
120
+ const sep = '─'.repeat(totalW);
121
+
122
+ const lines: string[] = [];
123
+
124
+ // Header
125
+ lines.push(
126
+ `${pad(header, pathW)} ${pad(sizeHeader, sizeW, 'left')} ${pad(firstLoadHeader, flW, 'left')}`
127
+ );
128
+ lines.push(sep);
129
+
130
+ // Routes
131
+ for (const entry of sorted) {
132
+ const icon = ROUTE_TYPE_ICONS[entry.type];
133
+ const pathStr = ` ${icon} ${entry.path}`;
134
+ const sizeStr = entry.size === 0 ? green('zero unique JS') : formatSize(entry.size);
135
+ const flStr = formatSize(entry.firstLoadSize);
136
+ lines.push(
137
+ `${pad(pathStr, pathW)} ${pad(sizeStr, sizeW, 'left')} ${pad(flStr, flW, 'left')}`
138
+ );
139
+ }
140
+
141
+ // Footer
142
+ lines.push(sep);
143
+ lines.push(
144
+ `${pad(' Shared by all', pathW)} ${pad('', sizeW, 'left')} ${pad(formatSize(sharedSize), flW, 'left')}`
145
+ );
146
+ lines.push('');
147
+ lines.push('○ (Static) λ (Dynamic) ƒ (Function)');
148
+
149
+ return lines;
150
+ }
151
+
152
+ function pad(str: string, width: number, align: 'left' | 'right' = 'right'): string {
153
+ const gap = Math.max(0, width - stripAnsi(str).length);
154
+ return align === 'left' ? ' '.repeat(gap) + str : str + ' '.repeat(gap);
155
+ }
156
+
157
+ function stripAnsi(str: string): string {
158
+ // eslint-disable-next-line no-control-regex
159
+ return str.replace(/\u001b\[\d+m/g, '');
160
+ }
161
+
162
+ // ─── Bundle analysis ──────────────────────────────────────────────────────
163
+
164
+ interface ChunkSize {
165
+ raw: number;
166
+ gzip: number;
167
+ }
168
+
169
+ interface OutputChunkLike {
170
+ type: 'chunk' | 'asset';
171
+ fileName: string;
172
+ code?: string;
173
+ source?: string | Uint8Array;
174
+ modules?: Record<string, unknown>;
175
+ facadeModuleId?: string | null;
176
+ }
177
+
178
+ /** Measure raw + gzip sizes for all JS chunks and CSS assets in a bundle. */
179
+ export function collectChunkSizes(bundle: Record<string, OutputChunkLike>): Map<string, ChunkSize> {
180
+ const sizes = new Map<string, ChunkSize>();
181
+ for (const [fileName, item] of Object.entries(bundle)) {
182
+ if (item.type === 'chunk' && item.code) {
183
+ sizes.set(fileName, measure(item.code));
184
+ } else if (item.type === 'asset' && fileName.endsWith('.css') && item.source != null) {
185
+ const src =
186
+ typeof item.source === 'string' ? item.source : new TextDecoder().decode(item.source);
187
+ sizes.set(fileName, measure(src));
188
+ }
189
+ }
190
+ return sizes;
191
+ }
192
+
193
+ function measure(content: string): ChunkSize {
194
+ const buf = new TextEncoder().encode(content);
195
+ return { raw: buf.length, gzip: gzipSync(buf).length };
196
+ }
197
+
198
+ /** Find the output chunk that contains a given input file. */
199
+ export function findChunkForFile(
200
+ filePath: string,
201
+ bundle: Record<string, OutputChunkLike>
202
+ ): string | null {
203
+ for (const [fileName, item] of Object.entries(bundle)) {
204
+ if (item.type !== 'chunk') continue;
205
+ if (item.facadeModuleId === filePath) return fileName;
206
+ if (item.modules && filePath in item.modules) return fileName;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ // ─── Build route entries from collected data ──────────────────────────────
212
+
213
+ function buildEntries(
214
+ routeTree: RouteTree,
215
+ chunkSizes: Map<string, ChunkSize>,
216
+ bundle: Record<string, OutputChunkLike>,
217
+ outputMode: 'server' | 'static'
218
+ ): { entries: RouteEntry[]; sharedGzip: number } {
219
+ const routeInfos = collectRoutes(routeTree);
220
+
221
+ // Total gzip across all client chunks
222
+ let totalGzip = 0;
223
+ for (const s of chunkSizes.values()) totalGzip += s.gzip;
224
+
225
+ // Per-route sizes and track route-specific chunks
226
+ const routeChunkFiles = new Set<string>();
227
+ const entries: RouteEntry[] = [];
228
+
229
+ for (const info of routeInfos) {
230
+ let raw = 0;
231
+ let gzip = 0;
232
+
233
+ if (info.entryFilePath) {
234
+ const chunk = findChunkForFile(info.entryFilePath, bundle);
235
+ if (chunk) {
236
+ const s = chunkSizes.get(chunk);
237
+ if (s) {
238
+ raw = s.raw;
239
+ gzip = s.gzip;
240
+ }
241
+ routeChunkFiles.add(chunk);
242
+ }
243
+ }
244
+
245
+ entries.push({
246
+ path: info.path,
247
+ type: classifyRoute(info.segments, outputMode),
248
+ size: raw,
249
+ firstLoadSize: gzip, // route-specific gzip — shared added below
250
+ });
251
+ }
252
+
253
+ // Shared = total gzip minus route-specific gzip
254
+ let routeGzip = 0;
255
+ for (const f of routeChunkFiles) routeGzip += chunkSizes.get(f)?.gzip ?? 0;
256
+ const sharedGzip = totalGzip - routeGzip;
257
+
258
+ for (const e of entries) e.firstLoadSize += sharedGzip;
259
+
260
+ return { entries, sharedGzip };
261
+ }
262
+
263
+ // ─── Vite plugin ──────────────────────────────────────────────────────────
264
+
265
+ /**
266
+ * Suppress RSC/SSR per-chunk build log lines.
267
+ *
268
+ * Vite logs each output chunk via config.logger.info(). We wrap that method
269
+ * to filter lines matching dist/rsc/ or dist/ssr/ paths. The regex is
270
+ * non-anchored because Vite prepends ANSI color codes.
271
+ */
272
+ function suppressNonClientLogs(config: { command: string; logger: Logger }): void {
273
+ if (config.command !== 'build') return;
274
+ const orig = config.logger.info.bind(config.logger);
275
+ config.logger.info = (msg: string, opts?: { timestamp?: boolean }) => {
276
+ if (typeof msg === 'string' && /dist\/(rsc|ssr)\//.test(msg)) return;
277
+ orig(msg, opts);
278
+ };
279
+ }
280
+
281
+ export function timberBuildReport(ctx: PluginContext): Plugin {
282
+ let logger: Logger | null = null;
283
+ let chunkSizes: Map<string, ChunkSize> | null = null;
284
+ let clientBundle: Record<string, OutputChunkLike> | null = null;
285
+ let reported = false;
286
+ let deferReport = false;
287
+ const buildStart = performance.now();
288
+
289
+ return {
290
+ name: 'timber-build-report',
291
+
292
+ config(_cfg, { command }) {
293
+ if (command !== 'build') return;
294
+ return {
295
+ environments: {
296
+ rsc: { build: { reportCompressedSize: false } },
297
+ ssr: { build: { reportCompressedSize: false } },
298
+ },
299
+ };
300
+ },
301
+
302
+ configResolved(config) {
303
+ logger = config.logger;
304
+ suppressNonClientLogs(config);
305
+ },
306
+
307
+ generateBundle(_options, bundle) {
308
+ if (ctx.dev) return;
309
+ if (this.environment?.name && this.environment.name !== 'client') return;
310
+
311
+ chunkSizes = collectChunkSizes(bundle as Record<string, OutputChunkLike>);
312
+ clientBundle = { ...bundle } as Record<string, OutputChunkLike>;
313
+ deferReport = true; // skip client's closeBundle; emit after SSR's
314
+ },
315
+
316
+ closeBundle() {
317
+ if (ctx.dev || reported) return;
318
+
319
+ // The client build's closeBundle fires before SSR starts.
320
+ // Defer one cycle so the report appears after all builds complete.
321
+ if (deferReport) {
322
+ deferReport = false;
323
+ return;
324
+ }
325
+ if (!ctx.routeTree || !chunkSizes || !clientBundle || !logger) return;
326
+
327
+ reported = true;
328
+ const outputMode = ctx.config.output ?? 'server';
329
+ const clientJsDisabled = ctx.clientJavascript.disabled;
330
+ const { entries, sharedGzip } = clientJsDisabled
331
+ ? {
332
+ entries: collectRoutes(ctx.routeTree).map((info) => ({
333
+ path: info.path,
334
+ type: classifyRoute(info.segments, outputMode),
335
+ size: 0,
336
+ firstLoadSize: 0,
337
+ })),
338
+ sharedGzip: 0,
339
+ }
340
+ : buildEntries(ctx.routeTree, chunkSizes, clientBundle, outputMode);
341
+ const elapsed = ((performance.now() - buildStart) / 1000).toFixed(2);
342
+ const lines = buildRouteReport(entries, sharedGzip);
343
+
344
+ logger.info('');
345
+ for (const line of lines) logger.info(line);
346
+ logger.info('');
347
+ logger.info(
348
+ `✓ built ${entries.length} routes for all three environments (rsc, ssr, client) in ${elapsed}s`
349
+ );
350
+ logger.info('');
351
+ },
352
+ };
353
+ }