@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,179 @@
1
+ /**
2
+ * timber-mdx — Vite sub-plugin for MDX page rendering.
3
+ *
4
+ * Wires @mdx-js/rollup into the Vite pipeline when MDX is activated.
5
+ * MDX is activated when pageExtensions includes 'mdx' or 'md', or
6
+ * when a content/ directory exists at the project root.
7
+ *
8
+ * Design doc: 20-content-collections.md §"The timber-mdx Plugin"
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 MDX_EXTENSIONS = ['mdx', 'md'];
17
+
18
+ /**
19
+ * Check if mdx-components.tsx (or .ts, .jsx, .js) exists at the project root.
20
+ * Returns the absolute path if found, otherwise undefined.
21
+ */
22
+ function findMdxComponents(root: string): string | undefined {
23
+ const candidates = [
24
+ 'mdx-components.tsx',
25
+ 'mdx-components.ts',
26
+ 'mdx-components.jsx',
27
+ 'mdx-components.js',
28
+ ];
29
+ for (const name of candidates) {
30
+ const p = join(root, name);
31
+ if (existsSync(p)) return p;
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ /**
37
+ * Determine if MDX should be activated based on config and project structure.
38
+ */
39
+ function shouldActivate(ctx: PluginContext): boolean {
40
+ const exts = ctx.config.pageExtensions;
41
+ if (exts && exts.some((ext) => MDX_EXTENSIONS.includes(ext))) {
42
+ return true;
43
+ }
44
+
45
+ if (existsSync(join(ctx.root, 'content'))) {
46
+ return true;
47
+ }
48
+
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Try to dynamically import a module by name. Returns the default export
54
+ * or the module itself, or undefined if the module is not installed.
55
+ */
56
+ async function tryImport(name: string): Promise<unknown | undefined> {
57
+ try {
58
+ const mod = await import(name);
59
+ return mod.default ?? mod;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Create the timber-mdx Vite plugin.
67
+ *
68
+ * Uses the transform and resolveId hooks to delegate MDX compilation
69
+ * to @mdx-js/rollup. The inner plugin is loaded lazily on first activation.
70
+ *
71
+ * Hooks: buildStart (loads @mdx-js/rollup), resolveId, load, transform
72
+ */
73
+ export function timberMdx(ctx: PluginContext): Plugin {
74
+ let innerPlugin: Plugin | null = null;
75
+
76
+ async function activate(): Promise<void> {
77
+ if (innerPlugin !== null || !shouldActivate(ctx)) return;
78
+
79
+ const createMdxPlugin = (await tryImport('@mdx-js/rollup')) as
80
+ | ((options?: Record<string, unknown>) => Plugin)
81
+ | undefined;
82
+
83
+ if (!createMdxPlugin) {
84
+ throw new Error(
85
+ [
86
+ '[timber] MDX is enabled but @mdx-js/rollup is not installed.',
87
+ '',
88
+ 'Install it:',
89
+ ' pnpm add -D @mdx-js/rollup remark-frontmatter remark-mdx-frontmatter',
90
+ '',
91
+ 'MDX is activated because pageExtensions includes "mdx"/"md" or a content/ directory exists.',
92
+ ].join('\n')
93
+ );
94
+ }
95
+
96
+ const mdxConfig = ctx.config.mdx ?? {};
97
+
98
+ // Auto-register frontmatter plugins
99
+ const remarkPlugins: unknown[] = [];
100
+ const remarkFrontmatter = await tryImport('remark-frontmatter');
101
+ const remarkMdxFrontmatter = await tryImport('remark-mdx-frontmatter');
102
+ if (remarkFrontmatter) remarkPlugins.push(remarkFrontmatter);
103
+ if (remarkMdxFrontmatter) remarkPlugins.push(remarkMdxFrontmatter);
104
+
105
+ if (mdxConfig.remarkPlugins) {
106
+ remarkPlugins.push(...mdxConfig.remarkPlugins);
107
+ }
108
+
109
+ const mdxOptions: Record<string, unknown> = {
110
+ remarkPlugins,
111
+ rehypePlugins: mdxConfig.rehypePlugins ?? [],
112
+ recmaPlugins: mdxConfig.recmaPlugins ?? [],
113
+ remarkRehypeOptions: mdxConfig.remarkRehypeOptions,
114
+ };
115
+
116
+ const mdxComponentsPath = findMdxComponents(ctx.root);
117
+ if (mdxComponentsPath) {
118
+ mdxOptions.providerImportSource = mdxComponentsPath;
119
+ }
120
+
121
+ innerPlugin = createMdxPlugin(mdxOptions);
122
+ }
123
+
124
+ return {
125
+ name: 'timber-mdx',
126
+ // Must run before @vitejs/plugin-rsc (rsc:use-client) which tries to parse
127
+ // all files as JS. MDX files must be compiled to JS first.
128
+ enforce: 'pre',
129
+
130
+ async buildStart(options) {
131
+ ctx.timer.start('mdx-activate');
132
+ await activate();
133
+ ctx.timer.end('mdx-activate');
134
+ if (!innerPlugin) return;
135
+ if (typeof innerPlugin.buildStart === 'function') {
136
+ await (innerPlugin.buildStart as (options: unknown) => void | Promise<void>).call(
137
+ this,
138
+ options
139
+ );
140
+ }
141
+ },
142
+
143
+ async resolveId(source, importer, options) {
144
+ if (!innerPlugin) return null;
145
+ // MDX pages are server components — only compile in the RSC environment.
146
+ // This prevents server-only deps (e.g. bright via mdx-components) from
147
+ // being pulled into SSR or client bundles.
148
+ const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
149
+ if (envName && envName !== 'rsc') return null;
150
+ if (typeof innerPlugin.resolveId === 'function') {
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
+ return (innerPlugin.resolveId as any).call(this, source, importer, options);
153
+ }
154
+ return null;
155
+ },
156
+
157
+ async load(id) {
158
+ if (!innerPlugin) return null;
159
+ const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
160
+ if (envName && envName !== 'rsc') return null;
161
+ if (typeof innerPlugin.load === 'function') {
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ return (innerPlugin.load as any).call(this, id);
164
+ }
165
+ return null;
166
+ },
167
+
168
+ async transform(code, id) {
169
+ if (!innerPlugin) return null;
170
+ const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
171
+ if (envName && envName !== 'rsc') return null;
172
+ if (typeof innerPlugin.transform === 'function') {
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ return (innerPlugin.transform as any).call(this, code, id);
175
+ }
176
+ return null;
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,56 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ /**
4
+ * Redirect React's CJS development bundles to production bundles.
5
+ *
6
+ * React packages use a runtime `process.env.NODE_ENV` check in their CJS
7
+ * entry points to conditionally require either `*.development.js` or
8
+ * `*.production.js`. Rollup's CJS plugin resolves both branches statically
9
+ * before the `define` replacement can eliminate the dead branch, causing
10
+ * development React code to be included in production builds.
11
+ *
12
+ * This plugin intercepts `resolveId` and rewrites any `*.development.js`
13
+ * import under `react/cjs/`, `react-dom/cjs/`, or `scheduler/cjs/` to
14
+ * its `*.production.js` counterpart.
15
+ *
16
+ * Only active in production builds. Has no effect in dev mode.
17
+ */
18
+ export function timberReactProd(): Plugin {
19
+ let isProd = false;
20
+
21
+ return {
22
+ name: 'timber-react-prod',
23
+ enforce: 'pre',
24
+ configResolved(config) {
25
+ isProd = config.command === 'build' && config.mode === 'production';
26
+ },
27
+ resolveId: {
28
+ order: 'pre',
29
+ async handler(source, importer, options) {
30
+ if (!isProd) return;
31
+
32
+ // Match: react/cjs/*.development.js, react-dom/cjs/*.development.js, scheduler/cjs/*.development.js
33
+ if (!source.includes('.development.')) return;
34
+
35
+ const resolved = await this.resolve(source, importer, {
36
+ ...options,
37
+ skipSelf: true,
38
+ });
39
+ if (!resolved) return;
40
+
41
+ // Only rewrite paths inside react/react-dom/scheduler/react-server-dom CJS directories
42
+ if (
43
+ !resolved.id.includes('/react/cjs/') &&
44
+ !resolved.id.includes('/react-dom/cjs/') &&
45
+ !resolved.id.includes('/scheduler/cjs/') &&
46
+ !resolved.id.includes('/react-server-dom/cjs/')
47
+ ) {
48
+ return;
49
+ }
50
+
51
+ const prodId = resolved.id.replace('.development.', '.production.');
52
+ return { ...resolved, id: prodId };
53
+ },
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,419 @@
1
+ /**
2
+ * timber-routing — Vite sub-plugin for route manifest generation.
3
+ *
4
+ * Bridges the route scanner with Vite's module graph. Scans app/ at startup,
5
+ * watches for file changes in dev, and serves the virtual:timber-route-manifest
6
+ * module that entry files consume.
7
+ *
8
+ * Design docs: 18-build-system.md §"Virtual Modules", 07-routing.md
9
+ */
10
+
11
+ import type { Plugin, ViteDevServer } from 'vite';
12
+ import { writeFile, mkdir } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { scanRoutes } from '#/routing/scanner.js';
15
+ import { generateRouteMap } from '#/routing/codegen.js';
16
+ import { collectInterceptionRewrites } from '#/routing/interception.js';
17
+ import { lintStatusFileDirectives, formatStatusFileLintWarnings } from '#/routing/status-file-lint.js';
18
+ import type { RouteTree, SegmentNode, RouteFile } from '#/routing/types.js';
19
+ import type { PluginContext } from '#/index.js';
20
+
21
+ const VIRTUAL_MODULE_ID = 'virtual:timber-route-manifest';
22
+ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
23
+
24
+ /**
25
+ * File convention names we track for changes that require manifest regeneration.
26
+ */
27
+ const ROUTE_FILE_PATTERNS =
28
+ /\/(page|layout|middleware|access|route|error|default|denied|search-params|\d{3}|[45]xx|not-found|forbidden|unauthorized|sitemap|robots|manifest|favicon|icon|opengraph-image|twitter-image|apple-icon)\./;
29
+
30
+ /**
31
+ * Create the timber-routing Vite plugin.
32
+ *
33
+ * Hooks: resolveId, load, buildStart, configureServer
34
+ */
35
+ /** Absolute path to the generated route map declaration file. */
36
+ const CODEGEN_OUTPUT = '.timber/timber-routes.d.ts';
37
+
38
+ /**
39
+ * Content of the timber-env.d.ts file written to the project root.
40
+ *
41
+ * Uses a triple-slash reference so TypeScript auto-discovers the generated
42
+ * route map without the user needing to edit tsconfig.json — same pattern
43
+ * as Next.js's next-env.d.ts.
44
+ */
45
+ const TIMBER_ENV_DTS = [
46
+ '// This file is auto-generated by timber.js. Do not edit.',
47
+ '// It ensures TypeScript picks up the generated route types.',
48
+ '// eslint-disable-next-line @typescript-eslint/triple-slash-reference',
49
+ '/// <reference path=".timber/timber-routes.d.ts" />',
50
+ '',
51
+ ].join('\n');
52
+
53
+ /**
54
+ * Write the generated route map and the timber-env.d.ts reference file.
55
+ *
56
+ * Fire-and-forget — never awaited to avoid blocking the hot path.
57
+ * Errors are swallowed; a missing .d.ts is a developer UX issue, not a runtime failure.
58
+ */
59
+ function writeCodegen(ctx: PluginContext): void {
60
+ if (!ctx.routeTree) return;
61
+ const timberDir = join(ctx.root, '.timber');
62
+ const content = generateRouteMap(ctx.routeTree, { appDir: ctx.appDir, outputDir: timberDir });
63
+ const routesPath = join(ctx.root, CODEGEN_OUTPUT);
64
+ const envPath = join(ctx.root, 'timber-env.d.ts');
65
+ mkdir(timberDir, { recursive: true })
66
+ .then(() =>
67
+ Promise.all([
68
+ writeFile(routesPath, content, 'utf-8'),
69
+ writeFile(envPath, TIMBER_ENV_DTS, 'utf-8'),
70
+ ])
71
+ )
72
+ .catch((err) => {
73
+ // Non-fatal — types are a dev convenience, but log so issues are visible
74
+ console.warn('[timber] Failed to write codegen output:', err);
75
+ });
76
+ }
77
+
78
+ export function timberRouting(ctx: PluginContext): Plugin {
79
+ /** Track warned files to avoid repeating warnings on rescan. */
80
+ const warnedFiles = new Set<string>();
81
+
82
+ function rescan(): void {
83
+ ctx.timer.start('route-scan');
84
+ ctx.routeTree = scanRoutes(ctx.appDir, {
85
+ pageExtensions: ctx.config.pageExtensions,
86
+ });
87
+ ctx.timer.end('route-scan');
88
+ writeCodegen(ctx);
89
+
90
+ // Lint status files for missing 'use client' directive
91
+ const warnings = lintStatusFileDirectives(ctx.routeTree);
92
+ const newWarnings = warnings.filter((w) => !warnedFiles.has(w.filePath));
93
+ if (newWarnings.length > 0) {
94
+ for (const w of newWarnings) warnedFiles.add(w.filePath);
95
+ console.warn(formatStatusFileLintWarnings(newWarnings));
96
+ }
97
+ }
98
+
99
+ return {
100
+ name: 'timber-routing',
101
+
102
+ /**
103
+ * Resolve the virtual module ID.
104
+ *
105
+ * Handles:
106
+ * - virtual:timber-route-manifest
107
+ * - <root>/virtual:timber-route-manifest (Vite SSR build root prefix)
108
+ * - \0virtual:timber-route-manifest (RSC plugin re-imports)
109
+ */
110
+ resolveId(id: string) {
111
+ // Strip \0 prefix if present (RSC plugin generates these)
112
+ const cleanId = id.startsWith('\0') ? id.slice(1) : id;
113
+
114
+ if (cleanId === VIRTUAL_MODULE_ID) {
115
+ return RESOLVED_VIRTUAL_ID;
116
+ }
117
+
118
+ // Handle root-prefixed resolution in SSR build
119
+ if (cleanId.endsWith(`/${VIRTUAL_MODULE_ID}`)) {
120
+ return RESOLVED_VIRTUAL_ID;
121
+ }
122
+
123
+ return null;
124
+ },
125
+
126
+ /**
127
+ * Generate the route manifest module.
128
+ *
129
+ * The manifest exports the route tree as a serialized data structure
130
+ * with absolute import paths for all file references.
131
+ */
132
+ load(id: string) {
133
+ if (id !== RESOLVED_VIRTUAL_ID) return null;
134
+
135
+ // If routeTree hasn't been built yet (shouldn't happen), scan now
136
+ if (!ctx.routeTree) {
137
+ rescan();
138
+ }
139
+
140
+ return generateManifestModule(ctx.routeTree!);
141
+ },
142
+
143
+ /**
144
+ * Scan routes at build start.
145
+ *
146
+ * In dev mode, skip — configureServer runs after buildStart and will
147
+ * do its own scan. Avoids a redundant FS traversal on cold start.
148
+ */
149
+ buildStart() {
150
+ if (ctx.dev) return;
151
+ rescan();
152
+ },
153
+
154
+ /**
155
+ * Set up file watcher in dev mode.
156
+ *
157
+ * Watches app/ for file additions/deletions/renames that affect
158
+ * the route tree, regenerates the manifest, and invalidates
159
+ * dependent modules via HMR.
160
+ */
161
+ configureServer(devServer: ViteDevServer) {
162
+ // Initial scan (only scan point in dev — buildStart is skipped)
163
+ rescan();
164
+
165
+ // Watch the app directory
166
+ devServer.watcher.add(ctx.appDir);
167
+
168
+ const handleFileChange = (filePath: string) => {
169
+ // Only react to route-significant files in the app directory
170
+ if (!filePath.startsWith(ctx.appDir)) return;
171
+ if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
172
+
173
+ // Rescan the route tree
174
+ rescan();
175
+
176
+ // Invalidate the virtual module in all environments
177
+ invalidateManifest(devServer);
178
+ };
179
+
180
+ devServer.watcher.on('add', handleFileChange);
181
+ devServer.watcher.on('unlink', handleFileChange);
182
+ // Also watch renames (which are add+unlink) — handled by the above
183
+ },
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Invalidate the virtual route manifest module across all Vite environments.
189
+ *
190
+ * When the route tree changes, any environment that has imported the manifest
191
+ * needs to be invalidated so the next request/transform picks up the new tree.
192
+ */
193
+ function invalidateManifest(server: ViteDevServer): void {
194
+ for (const envName of Object.keys(server.environments)) {
195
+ const env = server.environments[envName];
196
+ if (!env?.moduleGraph) continue;
197
+
198
+ const mod = env.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
199
+ if (mod) {
200
+ env.moduleGraph.invalidateModule(mod);
201
+ }
202
+ }
203
+
204
+ // Trigger HMR full reload — route changes are structural
205
+ server.hot.send({ type: 'full-reload' });
206
+ }
207
+
208
+ /**
209
+ * Generate the virtual module source code for the route manifest.
210
+ *
211
+ * The output is a default-exported object containing the serialized route tree.
212
+ * All file references use absolute paths (required for virtual modules).
213
+ */
214
+ function generateManifestModule(tree: RouteTree): string {
215
+ const imports: string[] = [];
216
+ let importIndex = 0;
217
+
218
+ /**
219
+ * Create a lazy import expression for a route file.
220
+ * Returns the import variable name.
221
+ */
222
+ function addImport(file: RouteFile): string {
223
+ const varName = `_route${importIndex++}`;
224
+ imports.push(`const ${varName} = () => import("${file.filePath}");`);
225
+ return varName;
226
+ }
227
+
228
+ /**
229
+ * Serialize a segment node to a JS object literal.
230
+ */
231
+ function serializeNode(node: SegmentNode, indent: string): string {
232
+ const nextIndent = indent + ' ';
233
+ const parts: string[] = [];
234
+
235
+ parts.push(`${nextIndent}segmentName: ${JSON.stringify(node.segmentName)},`);
236
+ parts.push(`${nextIndent}segmentType: ${JSON.stringify(node.segmentType)},`);
237
+ parts.push(`${nextIndent}urlPath: ${JSON.stringify(node.urlPath)},`);
238
+
239
+ if (node.paramName) {
240
+ parts.push(`${nextIndent}paramName: ${JSON.stringify(node.paramName)},`);
241
+ }
242
+
243
+ if (node.interceptionMarker) {
244
+ parts.push(`${nextIndent}interceptionMarker: ${JSON.stringify(node.interceptionMarker)},`);
245
+ }
246
+ if (node.interceptedSegmentName) {
247
+ parts.push(
248
+ `${nextIndent}interceptedSegmentName: ${JSON.stringify(node.interceptedSegmentName)},`
249
+ );
250
+ }
251
+
252
+ // File conventions — absolute paths as lazy imports
253
+ if (node.page) {
254
+ const v = addImport(node.page);
255
+ parts.push(
256
+ `${nextIndent}page: { load: ${v}, filePath: ${JSON.stringify(node.page.filePath)} },`
257
+ );
258
+ }
259
+ if (node.layout) {
260
+ const v = addImport(node.layout);
261
+ parts.push(
262
+ `${nextIndent}layout: { load: ${v}, filePath: ${JSON.stringify(node.layout.filePath)} },`
263
+ );
264
+ }
265
+ if (node.middleware) {
266
+ const v = addImport(node.middleware);
267
+ parts.push(
268
+ `${nextIndent}middleware: { load: ${v}, filePath: ${JSON.stringify(node.middleware.filePath)} },`
269
+ );
270
+ }
271
+ if (node.access) {
272
+ const v = addImport(node.access);
273
+ parts.push(
274
+ `${nextIndent}access: { load: ${v}, filePath: ${JSON.stringify(node.access.filePath)} },`
275
+ );
276
+ }
277
+ if (node.route) {
278
+ const v = addImport(node.route);
279
+ parts.push(
280
+ `${nextIndent}route: { load: ${v}, filePath: ${JSON.stringify(node.route.filePath)} },`
281
+ );
282
+ }
283
+ if (node.error) {
284
+ const v = addImport(node.error);
285
+ parts.push(
286
+ `${nextIndent}error: { load: ${v}, filePath: ${JSON.stringify(node.error.filePath)} },`
287
+ );
288
+ }
289
+ if (node.default) {
290
+ const v = addImport(node.default);
291
+ parts.push(
292
+ `${nextIndent}default: { load: ${v}, filePath: ${JSON.stringify(node.default.filePath)} },`
293
+ );
294
+ }
295
+ if (node.denied) {
296
+ const v = addImport(node.denied);
297
+ parts.push(
298
+ `${nextIndent}denied: { load: ${v}, filePath: ${JSON.stringify(node.denied.filePath)} },`
299
+ );
300
+ }
301
+ if (node.searchParams) {
302
+ const v = addImport(node.searchParams);
303
+ parts.push(
304
+ `${nextIndent}searchParams: { load: ${v}, filePath: ${JSON.stringify(node.searchParams.filePath)} },`
305
+ );
306
+ }
307
+
308
+ // Status-code files
309
+ if (node.statusFiles && node.statusFiles.size > 0) {
310
+ const statusEntries: string[] = [];
311
+ for (const [code, file] of node.statusFiles) {
312
+ const v = addImport(file);
313
+ statusEntries.push(
314
+ `${nextIndent} ${JSON.stringify(code)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
315
+ );
316
+ }
317
+ parts.push(`${nextIndent}statusFiles: {\n${statusEntries.join(',\n')}\n${nextIndent}},`);
318
+ }
319
+
320
+ // JSON status-code files
321
+ if (node.jsonStatusFiles && node.jsonStatusFiles.size > 0) {
322
+ const jsonEntries: string[] = [];
323
+ for (const [code, file] of node.jsonStatusFiles) {
324
+ const v = addImport(file);
325
+ jsonEntries.push(
326
+ `${nextIndent} ${JSON.stringify(code)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
327
+ );
328
+ }
329
+ parts.push(`${nextIndent}jsonStatusFiles: {\n${jsonEntries.join(',\n')}\n${nextIndent}},`);
330
+ }
331
+
332
+ // Legacy status files
333
+ if (node.legacyStatusFiles && node.legacyStatusFiles.size > 0) {
334
+ const legacyEntries: string[] = [];
335
+ for (const [name, file] of node.legacyStatusFiles) {
336
+ const v = addImport(file);
337
+ legacyEntries.push(
338
+ `${nextIndent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
339
+ );
340
+ }
341
+ parts.push(
342
+ `${nextIndent}legacyStatusFiles: {\n${legacyEntries.join(',\n')}\n${nextIndent}},`
343
+ );
344
+ }
345
+
346
+ // Metadata route files (sitemap.ts, robots.ts, icon.tsx, etc.)
347
+ if (node.metadataRoutes && node.metadataRoutes.size > 0) {
348
+ const metaEntries: string[] = [];
349
+ for (const [name, file] of node.metadataRoutes) {
350
+ const v = addImport(file);
351
+ metaEntries.push(
352
+ `${nextIndent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`
353
+ );
354
+ }
355
+ parts.push(
356
+ `${nextIndent}metadataRoutes: {\n${metaEntries.join(',\n')}\n${nextIndent}},`
357
+ );
358
+ }
359
+
360
+ // Children
361
+ if (node.children.length > 0) {
362
+ const childNodes = node.children.map((c) => serializeNode(c, nextIndent));
363
+ parts.push(`${nextIndent}children: [\n${childNodes.join(',\n')}\n${nextIndent}],`);
364
+ } else {
365
+ parts.push(`${nextIndent}children: [],`);
366
+ }
367
+
368
+ // Parallel slots
369
+ if (node.slots.size > 0) {
370
+ const slotEntries: string[] = [];
371
+ for (const [slotName, slotNode] of node.slots) {
372
+ slotEntries.push(
373
+ `${nextIndent} ${JSON.stringify(slotName)}: ${serializeNode(slotNode, nextIndent + ' ')}`
374
+ );
375
+ }
376
+ parts.push(`${nextIndent}slots: {\n${slotEntries.join(',\n')}\n${nextIndent}},`);
377
+ } else {
378
+ parts.push(`${nextIndent}slots: {},`);
379
+ }
380
+
381
+ return `${indent}{\n${parts.join('\n')}\n${indent}}`;
382
+ }
383
+
384
+ const rootSerialized = serializeNode(tree.root, ' ');
385
+
386
+ // Proxy file
387
+ let proxyLine = '';
388
+ if (tree.proxy) {
389
+ const v = addImport(tree.proxy);
390
+ proxyLine = ` proxy: { load: ${v}, filePath: ${JSON.stringify(tree.proxy.filePath)} },`;
391
+ }
392
+
393
+ // Interception rewrites — computed at build time from the route tree.
394
+ // Only interceptedPattern and interceptingPrefix are needed at runtime.
395
+ const rewrites = collectInterceptionRewrites(tree.root);
396
+ const rewritesLine =
397
+ rewrites.length > 0
398
+ ? ` interceptionRewrites: ${JSON.stringify(rewrites.map((r) => ({ interceptedPattern: r.interceptedPattern, interceptingPrefix: r.interceptingPrefix })))},`
399
+ : '';
400
+
401
+ const code = [
402
+ '// Auto-generated route manifest — do not edit.',
403
+ '// Generated by timber-routing plugin.',
404
+ '',
405
+ ...imports,
406
+ '',
407
+ 'const manifest = {',
408
+ proxyLine,
409
+ rewritesLine,
410
+ ` root: ${rootSerialized},`,
411
+ '};',
412
+ '',
413
+ 'export default manifest;',
414
+ ]
415
+ .filter((line) => line !== undefined && line !== '')
416
+ .join('\n');
417
+
418
+ return code;
419
+ }