@timber-js/app 0.2.0-alpha.167 → 0.2.0-alpha.169

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 (152) hide show
  1. package/dist/_chunks/{actions-TSxpXLHJ.js → actions-O_LsyCE4.js} +3 -3
  2. package/dist/_chunks/{actions-TSxpXLHJ.js.map → actions-O_LsyCE4.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-DzpQQOEx.js → cache-api-B-lhk9p4.js} +56 -11
  4. package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-wX-i90Og.js → cli-schema-sync-B73L6pMq.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-wX-i90Og.js.map → cli-schema-sync-B73L6pMq.js.map} +1 -1
  7. package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
  8. package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
  9. package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
  10. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
  11. package/dist/_chunks/{walkers-Cfwvl-UC.js → walkers-CoOC8Hga.js} +2 -2
  12. package/dist/_chunks/{walkers-Cfwvl-UC.js.map → walkers-CoOC8Hga.js.map} +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  14. package/dist/adapters/cloudflare-kv-cache.js +9 -2
  15. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  16. package/dist/cache/cache-api.d.ts.map +1 -1
  17. package/dist/cache/index.d.ts +1 -1
  18. package/dist/cache/index.d.ts.map +1 -1
  19. package/dist/cache/index.js +1 -1
  20. package/dist/cache/json-lossy-check.d.ts +11 -0
  21. package/dist/cache/json-lossy-check.d.ts.map +1 -0
  22. package/dist/cache/redis-handler.d.ts +42 -0
  23. package/dist/cache/redis-handler.d.ts.map +1 -1
  24. package/dist/cache/singleflight.d.ts.map +1 -1
  25. package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
  26. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
  27. package/dist/cache/stores/memory.d.ts +1 -1
  28. package/dist/cache/stores/memory.d.ts.map +1 -1
  29. package/dist/cache/stores/redis.d.ts +1 -1
  30. package/dist/cache/stores/redis.d.ts.map +1 -1
  31. package/dist/cache/stores/vercel.d.ts +1 -1
  32. package/dist/cache/stores/vercel.d.ts.map +1 -1
  33. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  34. package/dist/cache/timber-cache.d.ts.map +1 -1
  35. package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
  36. package/dist/cdn/fastly-purge.d.ts.map +1 -1
  37. package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
  38. package/dist/cli.d.ts +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +3 -2
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client/browser-entry/router-init.d.ts +1 -0
  43. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  44. package/dist/client/child-segment-context.d.ts +2 -2
  45. package/dist/client/child-segment-context.d.ts.map +1 -1
  46. package/dist/client/error-boundary.d.ts.map +1 -1
  47. package/dist/client/history.d.ts.map +1 -1
  48. package/dist/client/internal.js +4 -3
  49. package/dist/client/internal.js.map +1 -1
  50. package/dist/client/router.d.ts +6 -0
  51. package/dist/client/router.d.ts.map +1 -1
  52. package/dist/client/rsc-fetch.d.ts.map +1 -1
  53. package/dist/client/segment-cache.d.ts.map +1 -1
  54. package/dist/client/segment-update-context.d.ts +3 -3
  55. package/dist/client/segment-update-context.d.ts.map +1 -1
  56. package/dist/client/slot-outlet.d.ts +1 -1
  57. package/dist/codec.d.ts.map +1 -1
  58. package/dist/codec.js +2 -1
  59. package/dist/codec.js.map +1 -1
  60. package/dist/config-types.d.ts +16 -37
  61. package/dist/config-types.d.ts.map +1 -1
  62. package/dist/config-validation.d.ts.map +1 -1
  63. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  64. package/dist/fonts/pipeline.d.ts +19 -0
  65. package/dist/fonts/pipeline.d.ts.map +1 -1
  66. package/dist/fonts/transform.d.ts.map +1 -1
  67. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +344 -84
  70. package/dist/index.js.map +1 -1
  71. package/dist/plugin-context.d.ts.map +1 -1
  72. package/dist/plugins/adapter-build.d.ts +1 -0
  73. package/dist/plugins/adapter-build.d.ts.map +1 -1
  74. package/dist/plugins/entries.d.ts +27 -3
  75. package/dist/plugins/entries.d.ts.map +1 -1
  76. package/dist/plugins/fonts.d.ts.map +1 -1
  77. package/dist/plugins/server-bundle.d.ts.map +1 -1
  78. package/dist/routing/index.js +2 -2
  79. package/dist/server/action-client.d.ts +1 -1
  80. package/dist/server/action-client.d.ts.map +1 -1
  81. package/dist/server/body-limits.d.ts.map +1 -1
  82. package/dist/server/form-data.d.ts.map +1 -1
  83. package/dist/server/html-injector-core.d.ts.map +1 -1
  84. package/dist/server/index.js +20 -3
  85. package/dist/server/index.js.map +1 -1
  86. package/dist/server/internal.js +24 -40
  87. package/dist/server/internal.js.map +1 -1
  88. package/dist/server/middleware-runner.d.ts +0 -17
  89. package/dist/server/middleware-runner.d.ts.map +1 -1
  90. package/dist/server/param-coercion.d.ts.map +1 -1
  91. package/dist/server/pipeline-phases.d.ts +6 -3
  92. package/dist/server/pipeline-phases.d.ts.map +1 -1
  93. package/dist/server/pipeline.d.ts +18 -0
  94. package/dist/server/pipeline.d.ts.map +1 -1
  95. package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
  96. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  97. package/dist/server/primitives.d.ts.map +1 -1
  98. package/dist/server/render-timeout.d.ts.map +1 -1
  99. package/dist/server/route-element-builder.d.ts.map +1 -1
  100. package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
  101. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  102. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  103. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  104. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  105. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
  106. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
  107. package/dist/server/safe-load.d.ts.map +1 -1
  108. package/dist/server/ssr-entry.d.ts.map +1 -1
  109. package/dist/shared/redirect-type.d.ts +2 -2
  110. package/dist/shared/redirect-type.d.ts.map +1 -1
  111. package/dist/shims/font-google.d.ts.map +1 -1
  112. package/dist/shims/image.d.ts +120 -120
  113. package/dist/shims/image.d.ts.map +1 -1
  114. package/docs/api/32-api-cache.mdx +112 -0
  115. package/docs/api/34-api-config.mdx +0 -26
  116. package/docs/learn/09-caching.mdx +121 -5
  117. package/docs/learn/12-client-navigation.mdx +9 -1
  118. package/docs/learn/13-configuration.mdx +6 -8
  119. package/docs/learn/14-deploying.mdx +18 -18
  120. package/docs/more/04-metadata-and-fonts.mdx +1 -1
  121. package/package.json +6 -6
  122. package/src/adapters/cloudflare-kv-cache.ts +27 -6
  123. package/src/cache/index.ts +1 -1
  124. package/src/cache/json-lossy-check.ts +75 -0
  125. package/src/cache/redis-handler.ts +65 -14
  126. package/src/cache/timber-cache.ts +10 -2
  127. package/src/client/browser-entry/index.ts +2 -0
  128. package/src/client/browser-entry/router-init.ts +2 -0
  129. package/src/client/router.ts +13 -3
  130. package/src/codec.ts +3 -1
  131. package/src/config-types.ts +16 -37
  132. package/src/config-validation.ts +7 -5
  133. package/src/fonts/pipeline.ts +32 -0
  134. package/src/fonts/transform.ts +30 -24
  135. package/src/plugins/adapter-build.ts +131 -13
  136. package/src/plugins/entries.ts +40 -43
  137. package/src/plugins/fonts.ts +30 -0
  138. package/src/plugins/server-bundle.ts +7 -15
  139. package/src/server/action-client.ts +1 -1
  140. package/src/server/middleware-runner.ts +0 -44
  141. package/src/server/param-coercion.ts +1 -0
  142. package/src/server/pipeline-phases.ts +11 -13
  143. package/src/server/pipeline.ts +51 -3
  144. package/src/server/route-element-builder.ts +10 -1
  145. package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
  146. package/src/server/rsc-entry/index.ts +30 -40
  147. package/src/server/rsc-entry/render-route.ts +3 -1
  148. package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
  149. package/dist/_chunks/cache-api-DzpQQOEx.js.map +0 -1
  150. package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
  151. package/dist/_chunks/tree-match-D2l830j2.js +0 -102
  152. package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
@@ -213,7 +213,11 @@ export function runFontsTransform(
213
213
 
214
214
  const s = new MagicString(code);
215
215
 
216
- // Process each call site.
216
+ // Extract and validate all call sites before mutating the pipeline.
217
+ // If any extraction fails (emitError throws), the pipeline is untouched
218
+ // so the previous valid CSS stays intact (TIM-1087).
219
+ const pending: { extracted: ExtractedFont; site: (typeof analysis.callSites)[0] }[] = [];
220
+
217
221
  for (const site of analysis.callSites) {
218
222
  if (site.binding.provider === 'google') {
219
223
  const family = site.binding.importedName.replace(/_/g, ' ');
@@ -232,24 +236,22 @@ export function runFontsTransform(
232
236
  const fontStack = buildFontStack(family);
233
237
  const display = config!.display ?? 'swap';
234
238
 
235
- const extracted: ExtractedFont = {
236
- id: fontId,
237
- family,
238
- provider: 'google',
239
- weights: normalizeWeightArray(config!.weight),
240
- styles: normalizeStyleArray(config!.style),
241
- subsets: config!.subsets ?? ['latin'],
242
- display,
243
- variable: config!.variable,
244
- className,
245
- fontFamily: fontStack,
246
- importer: id,
247
- };
248
-
249
- pipeline.pruneFor(id, family);
250
- pipeline.register(extracted);
251
-
252
- s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
239
+ pending.push({
240
+ site,
241
+ extracted: {
242
+ id: fontId,
243
+ family,
244
+ provider: 'google',
245
+ weights: normalizeWeightArray(config!.weight),
246
+ styles: normalizeStyleArray(config!.style),
247
+ subsets: config!.subsets ?? ['latin'],
248
+ display,
249
+ variable: config!.variable,
250
+ className,
251
+ fontFamily: fontStack,
252
+ importer: id,
253
+ },
254
+ });
253
255
  } else {
254
256
  // local font
255
257
  const config = extractLocalFontConfigFromNode(site.configNode);
@@ -261,14 +263,18 @@ export function runFontsTransform(
261
263
  );
262
264
  }
263
265
 
264
- const extracted = processLocalFont(config!, id);
265
- pipeline.pruneFor(id, extracted.family);
266
- pipeline.register(extracted);
267
-
268
- s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
266
+ pending.push({ site, extracted: processLocalFont(config!, id) });
269
267
  }
270
268
  }
271
269
 
270
+ // All extractions succeeded — now prune stale entries and register.
271
+ // Handles family renames and deleted font calls (TIM-1087).
272
+ pipeline.pruneImporter(id);
273
+ for (const { extracted, site } of pending) {
274
+ pipeline.register(extracted);
275
+ s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
276
+ }
277
+
272
278
  // Remove font import declarations. Use a Set to avoid double-removing
273
279
  // when multiple bindings come from the same ImportDeclaration.
274
280
  const removedRanges = new Set<string>();
@@ -15,6 +15,7 @@
15
15
 
16
16
  import type { Plugin } from 'vite';
17
17
  import { join } from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
18
19
  import { readFile, writeFile } from 'node:fs/promises';
19
20
  import type { PluginContext } from '../plugin-context.js';
20
21
  import type { TimberPlatformAdapter, TimberConfig } from '../adapters/types.js';
@@ -95,6 +96,116 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
95
96
  };
96
97
  }
97
98
 
99
+ /**
100
+ * Parse the RSC assets manifest at `manifestPath`. Tries JSON.parse first
101
+ * (fast path for plain-string manifests); falls back to a dynamic import()
102
+ * for manifests that contain runtime expressions from renderBuiltUrl.
103
+ * Returns null if both paths fail.
104
+ */
105
+ async function parseRscManifest(
106
+ manifestPath: string,
107
+ content: string
108
+ ): Promise<Record<string, unknown> | null> {
109
+ const jsonStr = content.replace(/^export default\s*/, '').replace(/;?\s*$/, '');
110
+ try {
111
+ return JSON.parse(jsonStr);
112
+ } catch {
113
+ // Not strict JSON — manifest likely contains renderBuiltUrl runtime
114
+ // expressions. Fall back to evaluating the module directly.
115
+ }
116
+
117
+ // When renderBuiltUrl's runtime expressions reference external helpers
118
+ // not defined in the manifest file, this import will fail. That's an
119
+ // inherent limitation: runtime-computed URLs can't be resolved at build
120
+ // time for <link> tag injection. The warning below provides observability.
121
+ try {
122
+ const url = pathToFileURL(manifestPath).href;
123
+ const mod = await import(`${url}?t=${Date.now()}`);
124
+ return mod.default ?? null;
125
+ } catch (err) {
126
+ swallow(err, `failed to evaluate RSC assets manifest: ${manifestPath}`, { level: 'warn' });
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Replace all `"js": [...]` array contents with `"js": []` using balanced
133
+ * bracket matching. Handles nested brackets from runtime expressions like
134
+ * `__rbu("path/to/[slug]/file.js")`.
135
+ */
136
+ function clearJsArrays(src: string): string {
137
+ const pattern = /"js"\s*:\s*\[/g;
138
+ let result = '';
139
+ let lastEnd = 0;
140
+ let match: RegExpExecArray | null;
141
+
142
+ while ((match = pattern.exec(src)) !== null) {
143
+ const arrayStart = match.index + match[0].length;
144
+ let depth = 1;
145
+ let i = arrayStart;
146
+ while (i < src.length && depth > 0) {
147
+ if (src[i] === '[') depth++;
148
+ else if (src[i] === ']') depth--;
149
+ i++;
150
+ }
151
+ result += src.slice(lastEnd, match.index) + '"js": []';
152
+ lastEnd = i;
153
+ }
154
+ result += src.slice(lastEnd);
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Replace `"bootstrapScriptContent": <value>` with an empty string. The
160
+ * value may be a JS expression (e.g., concatenation with runtime helpers),
161
+ * not a simple string literal. Scans forward from the colon, tracking
162
+ * balanced parens and brackets, and stops at the next unbalanced `,` or `}`.
163
+ */
164
+ function clearBootstrapScriptContent(src: string): string {
165
+ const key = '"bootstrapScriptContent"';
166
+ const idx = src.indexOf(key);
167
+ if (idx === -1) return src;
168
+
169
+ // Find the colon after the key
170
+ const colonIdx = src.indexOf(':', idx + key.length);
171
+ if (colonIdx === -1) return src;
172
+
173
+ // Skip whitespace after colon
174
+ let valStart = colonIdx + 1;
175
+ while (valStart < src.length && /\s/.test(src[valStart])) valStart++;
176
+
177
+ // Scan forward to find the end of the value expression
178
+ let i = valStart;
179
+ let depth = 0;
180
+ let inString = false;
181
+ let stringChar = '';
182
+
183
+ while (i < src.length) {
184
+ const ch = src[i];
185
+ if (inString) {
186
+ if (ch === '\\') {
187
+ i += 2;
188
+ continue;
189
+ }
190
+ if (ch === stringChar) inString = false;
191
+ } else {
192
+ if (ch === '"' || ch === "'") {
193
+ inString = true;
194
+ stringChar = ch;
195
+ } else if (ch === '(' || ch === '[') {
196
+ depth++;
197
+ } else if (ch === ')' || ch === ']') {
198
+ depth--;
199
+ } else if (depth === 0 && (ch === ',' || ch === '}' || ch === '\n')) {
200
+ break;
201
+ }
202
+ }
203
+ i++;
204
+ }
205
+
206
+ return src.slice(0, idx) + '"bootstrapScriptContent": ""' + src.slice(i);
207
+ }
208
+
98
209
  /**
99
210
  * Strip JS references from the RSC plugin's assets manifest files.
100
211
  *
@@ -126,13 +237,8 @@ export async function mergeRscAssetsManifest(
126
237
  return;
127
238
  }
128
239
 
129
- const jsonStr = content.replace(/^export default\s*/, '').replace(/;?\s*$/, '');
130
- let manifest: Record<string, unknown>;
131
- try {
132
- manifest = JSON.parse(jsonStr);
133
- } catch {
134
- return;
135
- }
240
+ const manifest = await parseRscManifest(manifestPath, content);
241
+ if (!manifest) return;
136
242
 
137
243
  // Merge serverResources CSS into buildManifest.css.
138
244
  // Keys in serverResources are root-relative (e.g., "app/layout.tsx" or
@@ -175,7 +281,7 @@ export async function mergeRscAssetsManifest(
175
281
  }
176
282
  }
177
283
 
178
- async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
284
+ export async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
179
285
  const manifestName = '__vite_rsc_assets_manifest.js';
180
286
  const paths = [join(buildDir, 'rsc', manifestName), join(buildDir, 'ssr', manifestName)];
181
287
 
@@ -187,17 +293,29 @@ async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
187
293
  continue;
188
294
  }
189
295
 
296
+ // Try JSON.parse to check if we can safely re-serialize
190
297
  const jsonStr = content.replace(/^export default\s*/, '').replace(/;?\s*$/, '');
191
- let manifest: Record<string, unknown>;
298
+ let isStrictJson = true;
299
+ let manifest: Record<string, unknown> | null = null;
192
300
  try {
193
301
  manifest = JSON.parse(jsonStr);
194
- } catch (err) {
195
- swallow(err, `corrupted RSC assets manifest: ${path}`, { level: 'warn' });
302
+ } catch {
303
+ isStrictJson = false;
304
+ }
305
+
306
+ if (!isStrictJson) {
307
+ // Manifest contains renderBuiltUrl runtime expressions — cannot
308
+ // safely re-serialize with JSON.stringify. Use targeted text
309
+ // transforms to clear JS arrays and bootstrapScriptContent.
310
+ let rewritten = content;
311
+ rewritten = clearJsArrays(rewritten);
312
+ rewritten = clearBootstrapScriptContent(rewritten);
313
+ await writeFile(path, rewritten);
196
314
  continue;
197
315
  }
198
316
 
199
317
  // Clear JS from clientReferenceDeps — preserves CSS
200
- const deps = manifest.clientReferenceDeps as
318
+ const deps = manifest?.clientReferenceDeps as
201
319
  | Record<string, { js: string[]; css: string[] }>
202
320
  | undefined;
203
321
  if (deps) {
@@ -206,7 +324,7 @@ async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
206
324
  }
207
325
  }
208
326
 
209
- manifest.bootstrapScriptContent = '';
327
+ if (manifest) manifest.bootstrapScriptContent = '';
210
328
 
211
329
  await writeFile(path, `export default ${JSON.stringify(manifest, null, 2)};\n`);
212
330
  }
@@ -154,6 +154,7 @@ function generateConfigModule(ctx: PluginContext): string {
154
154
  // Per-build deployment ID for version skew detection (TIM-446).
155
155
  // Null in dev mode — HMR handles code updates without full reloads.
156
156
  deploymentId: ctx.deploymentId ?? null,
157
+ clientSegmentCache: ctx.config.clientSegmentCache ?? false,
157
158
  // Browser log forwarding threshold — used by buildClientScripts to
158
159
  // decide whether /@vite/client is needed when clientJavascript is
159
160
  // disabled. See TIM-1152.
@@ -174,23 +175,39 @@ function generateConfigModule(ctx: PluginContext): string {
174
175
  }
175
176
 
176
177
  /**
177
- * Detect the user's instrumentation.ts file at the project root.
178
+ * Detect a user convention file at both root and src/.
178
179
  *
179
- * Checks for instrumentation.ts, .js, and .mjs — matching the same
180
- * extensions as timber.config.ts detection.
180
+ * Root takes precedence over src/, extension priority: .ts > .js > .mjs.
181
+ * Used for instrumentation.ts, timber.cache.ts, and similar file conventions.
181
182
  */
182
- export function detectInstrumentationFile(root: string): string | null {
183
+ export function detectConventionFile(root: string, basename: string): string | null {
183
184
  const extensions = ['.ts', '.js', '.mjs'];
184
185
  const dirs = [root, resolve(root, 'src')];
185
186
  for (const dir of dirs) {
186
187
  for (const ext of extensions) {
187
- const candidate = resolve(dir, `instrumentation${ext}`);
188
+ const candidate = resolve(dir, `${basename}${ext}`);
188
189
  if (existsSync(candidate)) return candidate;
189
190
  }
190
191
  }
191
192
  return null;
192
193
  }
193
194
 
195
+ export function detectInstrumentationFile(root: string): string | null {
196
+ return detectConventionFile(root, 'instrumentation');
197
+ }
198
+
199
+ /**
200
+ * Detect the user's timber.cache file.
201
+ *
202
+ * This file holds runtime cache instances (cacheHandler, cdnPurge)
203
+ * that cannot be JSON-serialized. Separating them from timber.config.ts
204
+ * prevents the RSC build from pulling build-time deps (adapters, MDX,
205
+ * rollup) into the runtime bundle. See TIM-1211.
206
+ */
207
+ export function detectCacheFile(root: string): string | null {
208
+ return detectConventionFile(root, 'timber.cache');
209
+ }
210
+
194
211
  /**
195
212
  * Generate the virtual:timber-instrumentation module source.
196
213
  *
@@ -202,9 +219,6 @@ export function detectInstrumentationFile(root: string): string | null {
202
219
  */
203
220
  export function generateInstrumentationModule(instrumentationPath: string | null): string {
204
221
  if (instrumentationPath) {
205
- // Use the absolute path so the bundler can resolve and include it.
206
- // The dynamic import ensures the file is loaded lazily and errors
207
- // in the user's code don't prevent the module from being parsed.
208
222
  return [
209
223
  '// Auto-generated instrumentation loader — do not edit.',
210
224
  '// Generated by timber-entries plugin.',
@@ -226,48 +240,33 @@ export function generateInstrumentationModule(instrumentationPath: string | null
226
240
  ].join('\n');
227
241
  }
228
242
 
229
- /**
230
- * Detect the user's timber.config file at the project root.
231
- * Returns the absolute path or null if not found.
232
- */
233
- function detectConfigFile(root: string): string | null {
234
- const names = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
235
- for (const name of names) {
236
- const candidate = resolve(root, name);
237
- if (existsSync(candidate)) return candidate;
238
- }
239
- return null;
240
- }
241
-
242
243
  /**
243
244
  * Generate the virtual:timber-cache-handler module source.
244
245
  *
245
- * When the user's config has a cacheHandler, generates a module that
246
- * dynamically imports the config file and extracts the cacheHandler.
247
- * The cacheHandler is a class instance (e.g. RedisCacheHandler) that
248
- * cannot be JSON-serialized into virtual:timber-config, so it must
249
- * be loaded at runtime via dynamic import. See TIM-599.
246
+ * When the user's timber.cache.ts exists, generates a module that
247
+ * dynamically imports it and extracts cacheHandler and cdnPurge.
248
+ * These are class instances (e.g. RedisCacheHandler) that cannot be
249
+ * JSON-serialized into virtual:timber-config, so they must be loaded
250
+ * at runtime via dynamic import.
251
+ *
252
+ * timber.cache.ts exports: default cacheHandler, named cdnPurge.
253
+ *
254
+ * See TIM-1211, TIM-599.
250
255
  */
251
- function generateCacheHandlerModule(
252
- configPath: string | null,
253
- hasCacheHandler: boolean,
254
- hasCdnPurge: boolean
255
- ): string {
256
- if (configPath && (hasCacheHandler || hasCdnPurge)) {
256
+ export function generateCacheHandlerModule(cachePath: string | null): string {
257
+ if (cachePath) {
257
258
  return [
258
259
  '// Auto-generated cache handler loader — do not edit.',
259
260
  '// Generated by timber-entries plugin.',
260
261
  '',
261
262
  `export default async function loadCacheHandler() {`,
262
- ` const mod = await import(${JSON.stringify(configPath)});`,
263
- ` const config = mod.default ?? mod;`,
264
- ` return config.cacheHandler ?? null;`,
263
+ ` const mod = await import(${JSON.stringify(cachePath)});`,
264
+ ` return mod.default ?? null;`,
265
265
  `}`,
266
266
  '',
267
267
  `export async function loadCdnPurgeHandler() {`,
268
- ` const mod = await import(${JSON.stringify(configPath)});`,
269
- ` const config = mod.default ?? mod;`,
270
- ` return config.cdnPurge ?? null;`,
268
+ ` const mod = await import(${JSON.stringify(cachePath)});`,
269
+ ` return mod.cdnPurge ?? null;`,
271
270
  `}`,
272
271
  ].join('\n');
273
272
  }
@@ -275,7 +274,7 @@ function generateCacheHandlerModule(
275
274
  return [
276
275
  '// Auto-generated cache handler loader — do not edit.',
277
276
  '// Generated by timber-entries plugin.',
278
- '// No cacheHandler or cdnPurge configured in timber.config.',
277
+ '// No timber.cache.ts found.',
279
278
  '',
280
279
  `export default async function loadCacheHandler() {`,
281
280
  ` return null;`,
@@ -352,10 +351,8 @@ export function timberEntries(ctx: PluginContext): Plugin {
352
351
  return generateInstrumentationModule(instrumentationPath);
353
352
  }
354
353
  if (id === RESOLVED_CACHE_HANDLER_ID) {
355
- const configPath = detectConfigFile(ctx.root);
356
- const hasCacheHandler = ctx.config.cacheHandler != null;
357
- const hasCdnPurge = ctx.config.cdnPurge != null;
358
- return generateCacheHandlerModule(configPath, hasCacheHandler, hasCdnPurge);
354
+ const cachePath = detectCacheFile(ctx.root);
355
+ return generateCacheHandlerModule(cachePath);
359
356
  }
360
357
  return null;
361
358
  },
@@ -18,6 +18,7 @@
18
18
  * Design doc: 24-fonts.md
19
19
  */
20
20
 
21
+ import { parseAst } from 'vite';
21
22
  import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
22
23
  import type { PluginContext } from '../plugin-context.js';
23
24
  import {
@@ -224,6 +225,35 @@ export function timberFonts(ctx: PluginContext): Plugin {
224
225
  */
225
226
  buildStart() {},
226
227
 
228
+ /**
229
+ * Prune stale font entries when a module that previously imported fonts
230
+ * no longer contains any font import. The transform hook's `filter.code`
231
+ * skips such modules, so without this hook the old entries would persist
232
+ * until dev restart. See TIM-1087.
233
+ */
234
+ async hotUpdate({ type, file, read }) {
235
+ if (!pipeline.hasImporter(file)) return;
236
+ if (type === 'delete') {
237
+ pipeline.pruneImporter(file);
238
+ return;
239
+ }
240
+ const code = await read();
241
+ const hasFontImport =
242
+ code.includes('@timber/fonts/') ||
243
+ code.includes('@timber-js/app/fonts/') ||
244
+ code.includes('next/font/');
245
+ if (hasFontImport) return;
246
+ // Only prune if the new source parses successfully. A syntax error
247
+ // means the HMR update will fail and the old module stays active —
248
+ // pruning would leave it without font CSS until the next valid save.
249
+ try {
250
+ parseAst(code, { lang: 'tsx' });
251
+ } catch {
252
+ return;
253
+ }
254
+ pipeline.pruneImporter(file);
255
+ },
256
+
227
257
  /**
228
258
  * Scan source files for font function calls and extract static config.
229
259
  *
@@ -19,23 +19,15 @@ import type { Plugin } from 'vite';
19
19
  // Listing explicitly silences Vite's "Automatically externalized" warnings.
20
20
  const NODE_BUILTINS: string[] = builtinModules.flatMap((m) => [m, `node:${m}`]);
21
21
 
22
- // Build-time-only packages that must never be bundled into the runtime
23
- // server environments. With `noExternal: true` rolldown otherwise follows
24
- // the transitive graph through the user's `timber.config.ts` (loaded by the
25
- // `virtual:timber-cache-handler` dynamic import) into adapter code, dragging
26
- // build tooling into the prod server bundle. The most visible symptom is
27
- // rolldown trying to parse `fsevents.node` as JS and failing with
28
- // `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`.
22
+ // Packages that must never be bundled into the runtime server environments.
23
+ // With `noExternal: true`, rolldown follows all transitive imports. These
24
+ // are build-time-only tools that have no runtime use in a server bundle.
29
25
  //
30
- // - `nitro`, `@mdx-js/rollup` — build-time tooling reachable
31
- // transitively through adapter modules and timber's own MDX activation.
32
- // - `rollup` — pulled in by nitro and
33
- // @mdx-js/rollup; declares `fsevents` as an optional native dep.
34
- // - `fsevents`, `chokidar` — native/optional deps of
35
- // rollup/vite. They are macOS-only file watchers with no runtime use
36
- // in a server bundle.
26
+ // The primary leak path (TIM-988) — `virtual:timber-cache-handler` importing
27
+ // `timber.config.ts` and pulling in adapter/MDX deps — was eliminated by
28
+ // TIM-1211: the cache handler virtual module now imports `timber.cache.ts`
29
+ // instead. These externals are retained as defense-in-depth.
37
30
  //
38
- // Mirrored on both rsc and ssr environments. See TIM-988.
39
31
  // Cloudflare runtime modules (cloudflare:workers, cloudflare:sockets, etc.)
40
32
  // available at runtime on Workers but must not be bundled.
41
33
  const CLOUDFLARE_BUILTINS = [
@@ -8,7 +8,7 @@
8
8
  * 1. Direct call: action(input) → Promise<ActionResult>
9
9
  * 2. React useActionState: (prevState, formData) => Promise<ActionResult>
10
10
  *
11
- * See design/08-forms-and-actions.md §"Middleware for Server Actions"
11
+ * See design/08-forms-and-actions.md §"Middleware and Server Actions"
12
12
  */
13
13
 
14
14
  // ─── ActionError ─────────────────────────────────────────────────────────
@@ -57,47 +57,3 @@ export async function runMiddlewareChain(
57
57
  }
58
58
  return undefined;
59
59
  }
60
-
61
- // ─── Per-Request Middleware Bypass ─────────────────────────────────────────
62
-
63
- /**
64
- * Per-request marker for synthetic re-render requests that should NOT
65
- * re-execute `middleware.ts`. The action-dispatch wrapper runs middleware
66
- * once on the inbound action POST; when validation fails on the no-JS
67
- * path, it builds a synthetic GET that flows through the normal pipeline
68
- * to render the page with `getFormFlash()` data. Without this marker, the
69
- * pipeline would run middleware a second time on that synthetic GET.
70
- *
71
- * The set is keyed by the synthetic Request object itself, so the entry
72
- * lives exactly as long as the request and is garbage-collected with it.
73
- * Cannot be set or detected by user code — there is no header, no URL
74
- * parameter, nothing on the wire that an attacker could spoof.
75
- *
76
- * See TIM-871.
77
- *
78
- * @internal — framework use only.
79
- */
80
- const middlewareBypassRequests = new WeakSet<Request>();
81
-
82
- /**
83
- * Mark a request so the pipeline skips its middleware phase.
84
- *
85
- * Used by `wrap-action-dispatch.ts` for the no-JS form-rerender path.
86
- *
87
- * @internal
88
- */
89
- export function markRequestBypassMiddleware(req: Request): void {
90
- middlewareBypassRequests.add(req);
91
- }
92
-
93
- /**
94
- * Check whether a request was marked to bypass middleware.
95
- *
96
- * Called by `handleRequest` in pipeline-phases.ts before invoking the
97
- * middleware phase. Returns false for any request not explicitly marked.
98
- *
99
- * @internal
100
- */
101
- export function shouldBypassMiddleware(req: Request): boolean {
102
- return middlewareBypassRequests.has(req);
103
- }
@@ -132,6 +132,7 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
132
132
  if (!codec) continue; // no codec in schema → keep raw string
133
133
 
134
134
  const key = segment.paramName;
135
+ if (!(key in mergeTarget)) continue;
135
136
  try {
136
137
  mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key] as string | string[]));
137
138
  } catch (err) {
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { canonicalize } from './canonicalize.js';
16
16
  import { runProxy } from './proxy.js';
17
- import { runMiddlewareChain, shouldBypassMiddleware } from './middleware-runner.js';
17
+ import { runMiddlewareChain } from './middleware-runner.js';
18
18
  import { withTiming } from './server-timing.js';
19
19
  import {
20
20
  applyRequestHeaderOverlay,
@@ -66,8 +66,11 @@ function validateInterceptionHeader(raw: string, stripTrailingSlash: boolean): s
66
66
  // ─── Phase: Proxy ──────────────────────────────────────────────────────────
67
67
 
68
68
  /**
69
- * Run the proxy.ts phase. Calls user proxy code and uses `handleRequest` as
70
- * the inner `next()` continuation. The proxy resolver was picked at pipeline
69
+ * Run the proxy.ts phase. Calls user proxy code with a `next()` continuation.
70
+ *
71
+ * When `next` is provided, it replaces the default `handleRequest` as the
72
+ * continuation — this lets the pipeline inject action dispatch between proxy
73
+ * and route matching (TIM-1213). The proxy resolver was picked at pipeline
71
74
  * construction time so the hot path sees no per-request branching on the
72
75
  * `ProxyConfig` discriminant.
73
76
  */
@@ -76,13 +79,14 @@ export async function runProxyPhase(
76
79
  getProxy: ProxyResolver,
77
80
  req: Request,
78
81
  method: string,
79
- path: string
82
+ path: string,
83
+ next?: () => Promise<Response>
80
84
  ): Promise<PhaseOutcome> {
81
85
  const detailed = config.serverTiming === 'detailed';
82
86
  try {
83
87
  const proxyExport = await getProxy();
84
- const proxyFn = () =>
85
- runProxy(proxyExport, req, () => handleRequest(config, req, method, path, true));
88
+ const continuation = next ?? (() => handleRequest(config, req, method, path, true));
89
+ const proxyFn = () => runProxy(proxyExport, req, continuation);
86
90
  const response = await withSpan('timber.proxy', {}, () =>
87
91
  detailed ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
88
92
  );
@@ -514,14 +518,8 @@ export async function handleRequest(
514
518
  .join('/') || '/';
515
519
  setMatchedSegmentPath(segmentPath.startsWith('/') ? segmentPath : `/${segmentPath}`);
516
520
 
517
- // Bypass middleware for synthetic re-render requests built by the
518
- // action-dispatch wrapper after a no-JS form validation failure.
519
- // The wrapper has already executed middleware once on the inbound POST;
520
- // running it again on the rerender GET would double-execute auth, rate
521
- // limiting, and request-header injection. See TIM-871.
522
- const skipMiddleware = shouldBypassMiddleware(req);
523
521
  const outcome =
524
- !skipMiddleware && match.middlewareChain.length > 0
522
+ match.middlewareChain.length > 0
525
523
  ? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
526
524
  canonicalPathname,
527
525
  interception,