@timber-js/app 0.1.1 → 0.1.2

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 (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +2 -1
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,199 @@
1
+ import type { Plugin } from 'vite';
2
+ import { findFunctionsWithDirective, containsDirective } from '#/utils/directive-parser.js';
3
+
4
+ /**
5
+ * Parse a cacheLife duration string to seconds.
6
+ * Supports: '30s', '5m', '1h', '2d', '1w', or a plain number (seconds).
7
+ */
8
+ export function parseCacheLife(value: string | number): number {
9
+ if (typeof value === 'number') return value;
10
+
11
+ const match = value.match(/^(\d+)(s|m|h|d|w)$/);
12
+ if (!match) {
13
+ throw new Error(
14
+ `Invalid cacheLife value: "${value}". Expected format: "30s", "5m", "1h", "2d", "1w", or a number.`
15
+ );
16
+ }
17
+
18
+ const amount = parseInt(match[1], 10);
19
+ const unit = match[2];
20
+ const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
21
+ return amount * multipliers[unit];
22
+ }
23
+
24
+ // Default TTL when no cacheLife() is specified (Infinity means cache until explicit invalidation).
25
+ const DEFAULT_TTL = Infinity;
26
+
27
+ export interface CacheTransformWarning {
28
+ message: string;
29
+ functionName: string;
30
+ }
31
+
32
+ interface TransformResult {
33
+ code: string;
34
+ map?: null;
35
+ warnings?: CacheTransformWarning[];
36
+ }
37
+
38
+ /**
39
+ * Match cacheLife() calls: cacheLife('1h'), cacheLife("5m"), cacheLife(300)
40
+ */
41
+ const CACHE_LIFE_PATTERN = /cacheLife\(\s*(?:'([^']+)'|"([^"]+)"|(\d+))\s*\)/;
42
+
43
+ /**
44
+ * Strip the 'use cache' directive and cacheLife() call from a function body.
45
+ * Returns the cleaned body and the extracted TTL.
46
+ */
47
+ function extractCacheDirectives(body: string): { cleanBody: string; ttl: number } {
48
+ let ttl = DEFAULT_TTL;
49
+
50
+ // Remove 'use cache' / "use cache" directive (including optional semicolon and newline)
51
+ let cleanBody = body.replace(/\s*['"]use cache['"];?\s*\n?/, '\n');
52
+
53
+ // Extract and remove cacheLife() calls
54
+ const lifeMatch = cleanBody.match(CACHE_LIFE_PATTERN);
55
+ if (lifeMatch) {
56
+ const value = lifeMatch[1] || lifeMatch[2] || parseInt(lifeMatch[3], 10);
57
+ ttl = parseCacheLife(value);
58
+ cleanBody = cleanBody.replace(/\s*cacheLife\([^)]*\);?\s*\n?/, '\n');
59
+ }
60
+
61
+ return { cleanBody, ttl };
62
+ }
63
+
64
+ /**
65
+ * Determine if a function name is a React component (PascalCase).
66
+ */
67
+ function isComponentName(name: string): boolean {
68
+ return /^[A-Z]/.test(name);
69
+ }
70
+
71
+ /**
72
+ * Pattern matching page/layout file conventions in a dynamic route segment.
73
+ * Matches paths like: app/[slug]/page.tsx, app/[id]/layout.ts, etc.
74
+ */
75
+ const DYNAMIC_ROUTE_PAGE_PATTERN = /\/\[[^\]]+\].*\/(page|layout)\.[jt]sx?$/;
76
+
77
+ /**
78
+ * Detect if a function declaration has Promise-typed parameters.
79
+ *
80
+ * Checks for common patterns:
81
+ * - `params: Promise<...>`
82
+ * - `{ params }: { params: Promise<...> }`
83
+ * - `props: { params: Promise<...> }`
84
+ */
85
+ const PROMISE_PARAMS_PATTERN = /params\s*(?::|.*?:)\s*Promise\s*</;
86
+
87
+ /**
88
+ * Check if a 'use cache' function in a dynamic route page/layout receives
89
+ * Promise-typed params, which are not serializable as cache keys.
90
+ */
91
+ export function detectPromiseParamsWarning(
92
+ declaration: string,
93
+ functionName: string,
94
+ fileId: string
95
+ ): CacheTransformWarning | null {
96
+ if (!DYNAMIC_ROUTE_PAGE_PATTERN.test(fileId)) return null;
97
+ if (!PROMISE_PARAMS_PATTERN.test(declaration)) return null;
98
+
99
+ return {
100
+ message:
101
+ `'use cache' on "${functionName}" in "${fileId}" receives Promise params. ` +
102
+ `Promise is not serializable as a cache key and will cause runtime errors. ` +
103
+ `Either remove 'use cache' or await the params before using them in a separate cached function.`,
104
+ functionName,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Transform source code containing 'use cache' directives into
110
+ * registerCachedFunction() calls.
111
+ *
112
+ * Returns null if no transformations were made.
113
+ */
114
+ export function transformUseCache(code: string, fileId: string): TransformResult | null {
115
+ if (!containsDirective(code, 'use cache')) return null;
116
+
117
+ const functions = findFunctionsWithDirective(code, 'use cache');
118
+ if (functions.length === 0) return null;
119
+
120
+ let result = code;
121
+ let needsImport = false;
122
+ const warnings: CacheTransformWarning[] = [];
123
+
124
+ // Process functions from end to start (sorted descending by start position)
125
+ for (const fn of functions) {
126
+ // Warn if function receives Promise params in a dynamic route page/layout
127
+ const promiseWarning = detectPromiseParamsWarning(fn.declaration, fn.name, fileId);
128
+ if (promiseWarning) warnings.push(promiseWarning);
129
+ const { cleanBody, ttl } = extractCacheDirectives(fn.bodyContent);
130
+ const stableId = `${fileId}#${fn.name}`;
131
+ const isComponent = isComponentName(fn.name);
132
+
133
+ // Build the options object
134
+ const optsParts = [`ttl: ${ttl === Infinity ? 'Infinity' : ttl}`];
135
+ optsParts.push(`id: '${stableId}'`);
136
+ if (isComponent) {
137
+ optsParts.push('isComponent: true');
138
+ }
139
+ const optsStr = `{ ${optsParts.join(', ')} }`;
140
+
141
+ // Build the replacement
142
+ let replacement: string;
143
+ if (fn.isArrow) {
144
+ // const Name = async (...) => { body } → const Name = registerCachedFunction(async (...) => { body }, opts)
145
+ const arrowSig = fn.declaration.replace(/^(?:const|let|var)\s+\w+\s*=\s*/, '');
146
+ replacement = `const ${fn.name} = registerCachedFunction(${arrowSig} {${cleanBody}}, ${optsStr})`;
147
+ } else {
148
+ // async function Name(...) { body } → const Name = registerCachedFunction(async function Name(...) { body }, opts)
149
+ const fnDecl = fn.declaration.replace(/^(?:export\s+default\s+|export\s+)?/, '');
150
+ const exportPrefix = fn.prefix.includes('default')
151
+ ? 'export default '
152
+ : fn.prefix.includes('export')
153
+ ? 'export '
154
+ : '';
155
+
156
+ replacement = `${exportPrefix}const ${fn.name} = registerCachedFunction(${fnDecl} {${cleanBody}}, ${optsStr})`;
157
+ }
158
+
159
+ result = result.slice(0, fn.start) + replacement + result.slice(fn.end);
160
+ needsImport = true;
161
+ }
162
+
163
+ if (needsImport) {
164
+ // Add the import at the top of the file
165
+ result = `import { registerCachedFunction } from '@timber/app/cache';\n` + result;
166
+ }
167
+
168
+ return { code: result, map: null, warnings: warnings.length > 0 ? warnings : undefined };
169
+ }
170
+
171
+ /**
172
+ * Vite plugin: timber-cache
173
+ *
174
+ * Transforms 'use cache' directives into registerCachedFunction() calls.
175
+ * Only runs in the RSC environment.
176
+ */
177
+ export function cacheTransformPlugin(): Plugin {
178
+ return {
179
+ name: 'timber-cache',
180
+
181
+ transform(code, id) {
182
+ // Only transform in RSC environment
183
+ // Skip node_modules and non-JS/TS files
184
+ if (id.includes('node_modules')) return null;
185
+ if (!/\.[jt]sx?$/.test(id)) return null;
186
+
187
+ // Quick bail-out: no 'use cache' directive in this file
188
+ if (!containsDirective(code, 'use cache')) return null;
189
+
190
+ const result = transformUseCache(code, id);
191
+ if (result?.warnings) {
192
+ for (const w of result.warnings) {
193
+ this.warn(`[timber] ${w.message}`);
194
+ }
195
+ }
196
+ return result;
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * timber-chunks — Vite sub-plugin for intelligent client chunk splitting.
3
+ *
4
+ * Splits client bundles into cache tiers based on update frequency:
5
+ *
6
+ * Tier 1: vendor-react — react, react-dom, scheduler (changes rarely)
7
+ * Tier 2: vendor-timber — timber runtime, RSC runtime (changes per framework update)
8
+ * Tier 3: [route]-* — per-route app code (changes per deploy, handled by Vite defaults)
9
+ *
10
+ * Server environments (RSC, SSR) are left to Vite's default chunking since
11
+ * Cloudflare Workers load all code from a single deployment bundle with no
12
+ * benefit from cache-tier separation.
13
+ *
14
+ * Design docs: 27-chunking-strategy.md
15
+ */
16
+
17
+ import type { Plugin } from 'vite';
18
+
19
+ /**
20
+ * Categorize a module ID into a cache tier chunk name.
21
+ *
22
+ * Returns a chunk name for vendor modules, or undefined to let
23
+ * Rollup's default splitting handle app/route code.
24
+ */
25
+ export function assignChunk(id: string): string | undefined {
26
+ // Tier 1: React ecosystem — changes on version bumps only
27
+ if (
28
+ id.includes('node_modules/react-dom') ||
29
+ id.includes('node_modules/react/') ||
30
+ id.includes('node_modules/scheduler')
31
+ ) {
32
+ return 'vendor-react';
33
+ }
34
+
35
+ // Tier 2: timber framework runtime — changes on framework updates
36
+ if (
37
+ id.includes('/timber-app/') ||
38
+ id.includes('react-server-dom') ||
39
+ id.includes('@vitejs/plugin-rsc')
40
+ ) {
41
+ return 'vendor-timber';
42
+ }
43
+
44
+ // Everything else: Rollup's default splitting (per-route chunks)
45
+ }
46
+
47
+ /**
48
+ * Group timber's internal 'use client' modules into the vendor-timber chunk.
49
+ *
50
+ * The RSC plugin creates separate entry points for each 'use client' module,
51
+ * which manualChunks can't merge. This function is passed as the RSC plugin's
52
+ * `clientChunks` callback to group timber internals into a single chunk.
53
+ * User and third-party client components are left to default per-route splitting.
54
+ */
55
+ export function assignClientChunk(meta: {
56
+ id: string;
57
+ normalizedId: string;
58
+ serverChunk: string;
59
+ }): string | undefined {
60
+ if (meta.id.includes('/timber-app/')) return 'vendor-timber';
61
+ }
62
+
63
+ /**
64
+ * Create the timber-chunks Vite plugin.
65
+ *
66
+ * Uses Vite's per-environment config to apply manualChunks only to
67
+ * the client build. The config hook runs before environments are
68
+ * created, so we use `environments.client` to target the client.
69
+ */
70
+ export function timberChunks(): Plugin {
71
+ return {
72
+ name: 'timber-chunks',
73
+
74
+ config() {
75
+ return {
76
+ environments: {
77
+ client: {
78
+ build: {
79
+ rollupOptions: {
80
+ output: {
81
+ manualChunks: assignChunk,
82
+ },
83
+ },
84
+ },
85
+ },
86
+ },
87
+ };
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * timber-content — Vite sub-plugin for content collections.
3
+ *
4
+ * Wraps @content-collections/vite to provide content collection support.
5
+ * Activates only when a content-collections.ts config file exists at the
6
+ * project root.
7
+ *
8
+ * Design doc: 20-content-collections.md §"Content Collections"
9
+ */
10
+
11
+ import type { Plugin } from 'vite';
12
+ import { existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import type { PluginContext } from '#/index.js';
15
+
16
+ const CONFIG_FILE_NAMES = [
17
+ 'content-collections.ts',
18
+ 'content-collections.js',
19
+ 'content-collections.mts',
20
+ 'content-collections.mjs',
21
+ ];
22
+
23
+ /**
24
+ * Find the content-collections config file at the project root.
25
+ * Returns the filename (not full path) if found, otherwise undefined.
26
+ */
27
+ function findConfigFile(root: string): string | undefined {
28
+ for (const name of CONFIG_FILE_NAMES) {
29
+ if (existsSync(join(root, name))) return name;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * Create the timber-content Vite plugin.
36
+ *
37
+ * Delegates all content scanning, validation, code generation, and file watching
38
+ * to @content-collections/vite. This plugin only handles detection and activation.
39
+ */
40
+ export function timberContent(ctx: PluginContext): Plugin {
41
+ let innerPlugin: Plugin | null = null;
42
+
43
+ async function activate(root: string): Promise<void> {
44
+ if (innerPlugin !== null) return;
45
+
46
+ const configFile = findConfigFile(root);
47
+ if (!configFile) return;
48
+
49
+ let createPlugin: ((options?: { configPath?: string }) => Plugin) | undefined;
50
+ try {
51
+ const mod = await import('@content-collections/vite');
52
+ createPlugin = (mod.default ?? mod) as typeof createPlugin;
53
+ } catch {
54
+ throw new Error(
55
+ [
56
+ '[timber] Content collections are enabled but @content-collections/vite is not installed.',
57
+ '',
58
+ 'Install content-collections:',
59
+ ' pnpm add -D @content-collections/core @content-collections/vite',
60
+ '',
61
+ 'For MDX content, also install:',
62
+ ' pnpm add -D @content-collections/mdx',
63
+ '',
64
+ 'Content collections are activated because a content-collections.ts file exists.',
65
+ ].join('\n')
66
+ );
67
+ }
68
+
69
+ if (createPlugin) {
70
+ innerPlugin = createPlugin({ configPath: configFile });
71
+ }
72
+ }
73
+
74
+ return {
75
+ name: 'timber-content',
76
+
77
+ async config(config, env) {
78
+ const root = config.root ?? ctx.root;
79
+ ctx.timer.start('content-activate');
80
+ await activate(root);
81
+ ctx.timer.end('content-activate');
82
+ if (!innerPlugin) return;
83
+ if (typeof innerPlugin.config === 'function') {
84
+ return innerPlugin.config.call(this, config, env);
85
+ }
86
+ },
87
+
88
+ async configResolved(config) {
89
+ if (!innerPlugin) return;
90
+ if (typeof innerPlugin.configResolved === 'function') {
91
+ await (innerPlugin.configResolved as (...args: unknown[]) => unknown).call(this, config);
92
+ }
93
+ },
94
+
95
+ async buildStart(options) {
96
+ if (!innerPlugin) return;
97
+ if (typeof innerPlugin.buildStart === 'function') {
98
+ await (innerPlugin.buildStart as (...args: unknown[]) => unknown).call(this, options);
99
+ }
100
+ },
101
+
102
+ async resolveId(source: string, importer: string | undefined, options: unknown) {
103
+ if (!innerPlugin) return null;
104
+ if (typeof innerPlugin.resolveId === 'function') {
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ return (innerPlugin.resolveId as any).call(this, source, importer, options);
107
+ }
108
+ return null;
109
+ },
110
+
111
+ async load(id: string) {
112
+ if (!innerPlugin) return null;
113
+ if (typeof innerPlugin.load === 'function') {
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ return (innerPlugin.load as any).call(this, id);
116
+ }
117
+ return null;
118
+ },
119
+
120
+ async transform(code: string, id: string) {
121
+ if (!innerPlugin) return null;
122
+ if (typeof innerPlugin.transform === 'function') {
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+ return (innerPlugin.transform as any).call(this, code, id);
125
+ }
126
+ return null;
127
+ },
128
+
129
+ async configureServer(server) {
130
+ if (!innerPlugin) return;
131
+ if (typeof innerPlugin.configureServer === 'function') {
132
+ await (innerPlugin.configureServer as (...args: unknown[]) => unknown).call(this, server);
133
+ }
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Dev error overlay — formats and sends errors to Vite's browser overlay and stderr.
3
+ *
4
+ * Integrates with Vite's built-in error overlay (`server.ssrFixStacktrace` +
5
+ * `server.hot.send`) rather than implementing a custom overlay.
6
+ *
7
+ * Design doc: 21-dev-server.md §"Error Overlay"
8
+ */
9
+
10
+ import type { ViteDevServer } from 'vite';
11
+
12
+ // ─── Types ──────────────────────────────────────────────────────────────────
13
+
14
+ /** The phase of the pipeline where the error occurred. */
15
+ export type ErrorPhase =
16
+ | 'module-transform'
17
+ | 'proxy'
18
+ | 'middleware'
19
+ | 'access'
20
+ | 'render'
21
+ | 'handler';
22
+
23
+ /** Labels for terminal output. */
24
+ const PHASE_LABELS: Record<ErrorPhase, string> = {
25
+ 'module-transform': 'Module Transform',
26
+ 'proxy': 'Proxy',
27
+ 'middleware': 'Middleware',
28
+ 'access': 'Access Check',
29
+ 'render': 'RSC Render',
30
+ 'handler': 'Route Handler',
31
+ };
32
+
33
+ // ─── Frame Classification ───────────────────────────────────────────────────
34
+
35
+ export type FrameType = 'app' | 'framework' | 'internal';
36
+
37
+ /**
38
+ * Classify a stack frame line by origin.
39
+ *
40
+ * - 'app': user application code (in project root, not node_modules)
41
+ * - 'framework': timber-app internal code
42
+ * - 'internal': node_modules, Node.js internals
43
+ */
44
+ export function classifyFrame(frameLine: string, projectRoot: string): FrameType {
45
+ // Strip leading whitespace and "at "
46
+ const trimmed = frameLine.trim();
47
+
48
+ if (trimmed.includes('packages/timber-app/')) return 'framework';
49
+ if (trimmed.includes('node_modules/')) return 'internal';
50
+ if (trimmed.startsWith('at node:') || trimmed.includes('(node:')) return 'internal';
51
+ if (trimmed.includes(projectRoot)) return 'app';
52
+
53
+ return 'internal';
54
+ }
55
+
56
+ // ─── Component Stack Extraction ─────────────────────────────────────────────
57
+
58
+ /**
59
+ * Extract the React component stack from an error, if present.
60
+ * React attaches this as `componentStack` during renderToReadableStream errors.
61
+ */
62
+ export function extractComponentStack(error: unknown): string | null {
63
+ if (
64
+ error &&
65
+ typeof error === 'object' &&
66
+ 'componentStack' in error &&
67
+ typeof (error as Record<string, unknown>).componentStack === 'string'
68
+ ) {
69
+ return (error as Record<string, unknown>).componentStack as string;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ // ─── First App Frame Parsing ────────────────────────────────────────────────
75
+
76
+ interface SourceLocation {
77
+ file: string;
78
+ line: number;
79
+ column: number;
80
+ }
81
+
82
+ /**
83
+ * Parse the first application frame from a stack trace.
84
+ * Returns file/line/column for the overlay's `loc` field.
85
+ */
86
+ export function parseFirstAppFrame(stack: string, projectRoot: string): SourceLocation | null {
87
+ const lines = stack.split('\n');
88
+ // Match patterns like:
89
+ // at functionName (/absolute/path:line:column)
90
+ // at /absolute/path:line:column
91
+ const parenRegex = /\(([^)]+):(\d+):(\d+)\)/;
92
+ const bareRegex = /at (\/[^:]+):(\d+):(\d+)/;
93
+
94
+ for (const line of lines) {
95
+ if (classifyFrame(line, projectRoot) !== 'app') continue;
96
+
97
+ const match = parenRegex.exec(line) ?? bareRegex.exec(line);
98
+ if (!match) continue;
99
+
100
+ const [, file, lineNum, col] = match;
101
+ if (file && lineNum && col) {
102
+ return { file, line: parseInt(lineNum, 10), column: parseInt(col, 10) };
103
+ }
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ // ─── Error Phase Classification ─────────────────────────────────────────────
110
+
111
+ /**
112
+ * Classify the error phase by inspecting the error's stack trace.
113
+ * Falls back to 'render' if no specific phase can be determined.
114
+ */
115
+ export function classifyErrorPhase(error: Error, projectRoot: string): ErrorPhase {
116
+ const stack = error.stack ?? '';
117
+
118
+ // Check for React component stack (render error)
119
+ if (extractComponentStack(error)) return 'render';
120
+
121
+ // Check for specific file patterns in app frames
122
+ const appRoot = projectRoot.replace(/\/$/, '');
123
+ if (stack.includes(`${appRoot}/app/`) || stack.includes('/app/')) {
124
+ if (stack.includes('/middleware.ts') || stack.includes('/middleware.js')) return 'middleware';
125
+ if (stack.includes('/access.ts') || stack.includes('/access.js')) return 'access';
126
+ if (stack.includes('/route.ts') || stack.includes('/route.js')) return 'handler';
127
+ }
128
+
129
+ return 'render';
130
+ }
131
+
132
+ // ─── Terminal Formatting ────────────────────────────────────────────────────
133
+
134
+ // ANSI codes
135
+ const RED = '\x1b[31m';
136
+ const DIM = '\x1b[2m';
137
+ const RESET = '\x1b[0m';
138
+ const BOLD = '\x1b[1m';
139
+
140
+ /**
141
+ * Format an error for terminal output.
142
+ *
143
+ * - Red for the error message and phase label
144
+ * - Dim for framework-internal frames
145
+ * - Normal for application frames
146
+ * - Separate section for component stack (if present)
147
+ */
148
+ export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot: string): string {
149
+ const lines: string[] = [];
150
+
151
+ // Phase header + error message
152
+ lines.push(`${RED}${BOLD}[timber] ${PHASE_LABELS[phase]} Error${RESET}`);
153
+ lines.push(`${RED}${error.message}${RESET}`);
154
+ lines.push('');
155
+
156
+ // Component stack (if present)
157
+ const componentStack = extractComponentStack(error);
158
+ if (componentStack) {
159
+ lines.push(`${BOLD}Component Stack:${RESET}`);
160
+ for (const csLine of componentStack.trim().split('\n')) {
161
+ lines.push(` ${csLine.trim()}`);
162
+ }
163
+ lines.push('');
164
+ }
165
+
166
+ // Stack trace with frame dimming
167
+ if (error.stack) {
168
+ lines.push(`${BOLD}Stack Trace:${RESET}`);
169
+ const stackLines = error.stack.split('\n').slice(1); // Skip the first line (message)
170
+ for (const stackLine of stackLines) {
171
+ const frameType = classifyFrame(stackLine, projectRoot);
172
+ if (frameType === 'app') {
173
+ lines.push(stackLine);
174
+ } else {
175
+ lines.push(`${DIM}${stackLine}${RESET}`);
176
+ }
177
+ }
178
+ }
179
+
180
+ return lines.join('\n');
181
+ }
182
+
183
+ // ─── Overlay Integration ────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Send an error to Vite's browser overlay and log it to stderr.
187
+ *
188
+ * Uses `server.ssrFixStacktrace()` to map stack traces back to source,
189
+ * then sends the error via `server.hot.send()` for the browser overlay.
190
+ *
191
+ * The dev server remains running — errors are handled, not fatal.
192
+ */
193
+ export function sendErrorToOverlay(
194
+ server: ViteDevServer,
195
+ error: Error,
196
+ phase: ErrorPhase,
197
+ projectRoot: string
198
+ ): void {
199
+ // Fix stack trace to use source-mapped positions
200
+ server.ssrFixStacktrace(error);
201
+
202
+ // Log to stderr with frame dimming
203
+ const formatted = formatTerminalError(error, phase, projectRoot);
204
+ process.stderr.write(`${formatted}\n`);
205
+
206
+ // Build overlay payload
207
+ const loc = parseFirstAppFrame(error.stack ?? '', projectRoot);
208
+ const componentStack = extractComponentStack(error);
209
+
210
+ let message = error.message;
211
+ if (componentStack) {
212
+ message = `${error.message}\n\nComponent Stack:\n${componentStack.trim()}`;
213
+ }
214
+
215
+ // Send to browser via Vite's error overlay protocol
216
+ try {
217
+ server.hot.send({
218
+ type: 'error',
219
+ err: {
220
+ message,
221
+ stack: error.stack ?? '',
222
+ id: loc?.file,
223
+ plugin: `timber (${PHASE_LABELS[phase]})`,
224
+ loc: loc ? { file: loc.file, line: loc.line, column: loc.column } : undefined,
225
+ },
226
+ });
227
+ } catch {
228
+ // Overlay send must never crash the dev server
229
+ }
230
+ }