@timber-js/app 0.2.0-alpha.166 → 0.2.0-alpha.168

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 (188) hide show
  1. package/dist/_chunks/{actions-CDPfMp_I.js → actions-O_LsyCE4.js} +3 -3
  2. package/dist/_chunks/{actions-CDPfMp_I.js.map → actions-O_LsyCE4.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-DygSeKCB.js → cache-api-B-lhk9p4.js} +107 -58
  4. package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-EXGYPhI2.js → cli-schema-sync-B73L6pMq.js} +5 -3
  6. package/dist/_chunks/cli-schema-sync-B73L6pMq.js.map +1 -0
  7. package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
  8. package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
  9. package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
  10. package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
  11. package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
  12. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
  13. package/dist/_chunks/{walkers-DBVzXuWc.js → walkers-CoOC8Hga.js} +2 -2
  14. package/dist/_chunks/{walkers-DBVzXuWc.js.map → walkers-CoOC8Hga.js.map} +1 -1
  15. package/dist/adapters/cloudflare-dev.js +1 -1
  16. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  17. package/dist/adapters/cloudflare-kv-cache.js +10 -3
  18. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  19. package/dist/adapters/cloudflare.d.ts +12 -1
  20. package/dist/adapters/cloudflare.d.ts.map +1 -1
  21. package/dist/adapters/cloudflare.js +2 -461
  22. package/dist/adapters/types.d.ts +2 -0
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/cache/cache-api.d.ts +33 -11
  25. package/dist/cache/cache-api.d.ts.map +1 -1
  26. package/dist/cache/index.d.ts +1 -1
  27. package/dist/cache/index.d.ts.map +1 -1
  28. package/dist/cache/index.js +1 -1
  29. package/dist/cache/json-lossy-check.d.ts +11 -0
  30. package/dist/cache/json-lossy-check.d.ts.map +1 -0
  31. package/dist/cache/redis-handler.d.ts +42 -0
  32. package/dist/cache/redis-handler.d.ts.map +1 -1
  33. package/dist/cache/singleflight.d.ts.map +1 -1
  34. package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
  35. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
  36. package/dist/cache/stores/memory.d.ts +1 -1
  37. package/dist/cache/stores/memory.d.ts.map +1 -1
  38. package/dist/cache/stores/redis.d.ts +1 -1
  39. package/dist/cache/stores/redis.d.ts.map +1 -1
  40. package/dist/cache/stores/vercel.d.ts +1 -1
  41. package/dist/cache/stores/vercel.d.ts.map +1 -1
  42. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  43. package/dist/cache/timber-cache.d.ts.map +1 -1
  44. package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
  45. package/dist/cdn/fastly-purge.d.ts.map +1 -1
  46. package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
  47. package/dist/cli.d.ts +1 -1
  48. package/dist/cli.d.ts.map +1 -1
  49. package/dist/cli.js +3 -2
  50. package/dist/cli.js.map +1 -1
  51. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  52. package/dist/client/browser-entry/router-init.d.ts +1 -0
  53. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  54. package/dist/client/child-segment-context.d.ts +2 -2
  55. package/dist/client/child-segment-context.d.ts.map +1 -1
  56. package/dist/client/error-boundary.d.ts.map +1 -1
  57. package/dist/client/history.d.ts +10 -0
  58. package/dist/client/history.d.ts.map +1 -1
  59. package/dist/client/internal.js +141 -116
  60. package/dist/client/internal.js.map +1 -1
  61. package/dist/client/router.d.ts +6 -0
  62. package/dist/client/router.d.ts.map +1 -1
  63. package/dist/client/rsc-fetch.d.ts.map +1 -1
  64. package/dist/client/segment-cache.d.ts.map +1 -1
  65. package/dist/client/segment-update-context.d.ts +3 -3
  66. package/dist/client/segment-update-context.d.ts.map +1 -1
  67. package/dist/client/slot-outlet.d.ts +1 -1
  68. package/dist/codec.d.ts.map +1 -1
  69. package/dist/codec.js +2 -1
  70. package/dist/codec.js.map +1 -1
  71. package/dist/config-types.d.ts +16 -37
  72. package/dist/config-types.d.ts.map +1 -1
  73. package/dist/config-validation.d.ts.map +1 -1
  74. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  75. package/dist/fonts/pipeline.d.ts +19 -0
  76. package/dist/fonts/pipeline.d.ts.map +1 -1
  77. package/dist/fonts/transform.d.ts.map +1 -1
  78. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +378 -109
  81. package/dist/index.js.map +1 -1
  82. package/dist/plugin-context.d.ts.map +1 -1
  83. package/dist/plugins/adapter-build.d.ts +1 -0
  84. package/dist/plugins/adapter-build.d.ts.map +1 -1
  85. package/dist/plugins/cache.d.ts +8 -8
  86. package/dist/plugins/cache.d.ts.map +1 -1
  87. package/dist/plugins/entries.d.ts +27 -3
  88. package/dist/plugins/entries.d.ts.map +1 -1
  89. package/dist/plugins/fonts.d.ts.map +1 -1
  90. package/dist/plugins/server-bundle.d.ts.map +1 -1
  91. package/dist/routing/index.js +2 -2
  92. package/dist/server/action-client.d.ts +1 -1
  93. package/dist/server/action-client.d.ts.map +1 -1
  94. package/dist/server/als-registry.d.ts +7 -0
  95. package/dist/server/als-registry.d.ts.map +1 -1
  96. package/dist/server/body-limits.d.ts.map +1 -1
  97. package/dist/server/deny-boundary.d.ts +3 -1
  98. package/dist/server/deny-boundary.d.ts.map +1 -1
  99. package/dist/server/form-data.d.ts.map +1 -1
  100. package/dist/server/html-injector-core.d.ts.map +1 -1
  101. package/dist/server/index.js +20 -3
  102. package/dist/server/index.js.map +1 -1
  103. package/dist/server/internal.js +28 -41
  104. package/dist/server/internal.js.map +1 -1
  105. package/dist/server/middleware-runner.d.ts +0 -17
  106. package/dist/server/middleware-runner.d.ts.map +1 -1
  107. package/dist/server/param-coercion.d.ts.map +1 -1
  108. package/dist/server/pipeline-phases.d.ts +6 -3
  109. package/dist/server/pipeline-phases.d.ts.map +1 -1
  110. package/dist/server/pipeline.d.ts +18 -0
  111. package/dist/server/pipeline.d.ts.map +1 -1
  112. package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
  113. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  114. package/dist/server/primitives.d.ts.map +1 -1
  115. package/dist/server/render-timeout.d.ts.map +1 -1
  116. package/dist/server/route-element-builder.d.ts +9 -0
  117. package/dist/server/route-element-builder.d.ts.map +1 -1
  118. package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
  119. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  120. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  121. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  122. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  123. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  124. package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
  125. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  126. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
  127. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
  128. package/dist/server/safe-load.d.ts.map +1 -1
  129. package/dist/server/ssr-entry.d.ts.map +1 -1
  130. package/dist/shared/redirect-type.d.ts +2 -2
  131. package/dist/shared/redirect-type.d.ts.map +1 -1
  132. package/dist/shims/font-google.d.ts.map +1 -1
  133. package/dist/shims/image.d.ts +120 -120
  134. package/dist/shims/image.d.ts.map +1 -1
  135. package/docs/api/32-api-cache.mdx +116 -4
  136. package/docs/api/34-api-config.mdx +0 -26
  137. package/docs/learn/09-caching.mdx +126 -10
  138. package/docs/learn/12-client-navigation.mdx +9 -1
  139. package/docs/learn/13-configuration.mdx +6 -8
  140. package/docs/learn/14-deploying.mdx +18 -18
  141. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  142. package/docs/more/04-metadata-and-fonts.mdx +1 -1
  143. package/docs/more/50-ai-agent-instructions.mdx +5 -5
  144. package/package.json +6 -5
  145. package/src/adapters/cloudflare-kv-cache.ts +27 -6
  146. package/src/adapters/cloudflare.ts +63 -25
  147. package/src/adapters/types.ts +2 -0
  148. package/src/cache/cache-api.ts +84 -84
  149. package/src/cache/index.ts +1 -1
  150. package/src/cache/json-lossy-check.ts +75 -0
  151. package/src/cache/redis-handler.ts +65 -14
  152. package/src/cache/timber-cache.ts +10 -2
  153. package/src/client/browser-entry/index.ts +2 -0
  154. package/src/client/browser-entry/post-hydration.ts +16 -9
  155. package/src/client/browser-entry/router-init.ts +2 -0
  156. package/src/client/history.ts +11 -0
  157. package/src/client/router.ts +224 -208
  158. package/src/codec.ts +3 -1
  159. package/src/config-types.ts +16 -37
  160. package/src/config-validation.ts +7 -5
  161. package/src/fonts/pipeline.ts +32 -0
  162. package/src/fonts/transform.ts +30 -24
  163. package/src/plugins/adapter-build.ts +132 -13
  164. package/src/plugins/cache.ts +45 -30
  165. package/src/plugins/entries.ts +40 -43
  166. package/src/plugins/fonts.ts +30 -0
  167. package/src/plugins/server-bundle.ts +7 -15
  168. package/src/routing/scanner.ts +7 -0
  169. package/src/server/action-client.ts +1 -1
  170. package/src/server/als-registry.ts +7 -0
  171. package/src/server/deny-boundary.ts +6 -3
  172. package/src/server/middleware-runner.ts +0 -44
  173. package/src/server/param-coercion.ts +1 -0
  174. package/src/server/pipeline-phases.ts +11 -13
  175. package/src/server/pipeline.ts +51 -3
  176. package/src/server/route-element-builder.ts +59 -9
  177. package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
  178. package/src/server/rsc-entry/index.ts +30 -40
  179. package/src/server/rsc-entry/render-route.ts +6 -2
  180. package/src/server/rsc-entry/rsc-payload.ts +21 -4
  181. package/src/server/rsc-entry/rsc-stream.ts +8 -0
  182. package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
  183. package/dist/_chunks/cache-api-DygSeKCB.js.map +0 -1
  184. package/dist/_chunks/cli-schema-sync-EXGYPhI2.js.map +0 -1
  185. package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
  186. package/dist/_chunks/tree-match-D2l830j2.js +0 -102
  187. package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
  188. package/dist/adapters/cloudflare.js.map +0 -1
@@ -30,12 +30,9 @@ const KNOWN_CONFIG_KEYS = {
30
30
  debug: true,
31
31
  clientJavascript: true,
32
32
  adapter: true,
33
- cacheHandler: true,
34
- cdnPurge: true,
35
33
  allowedOrigins: true,
36
34
  csrf: true,
37
35
  limits: true,
38
- actions: true,
39
36
  forms: true,
40
37
  pageExtensions: true,
41
38
  slowRequestMs: true,
@@ -49,6 +46,7 @@ const KNOWN_CONFIG_KEYS = {
49
46
  reactCompiler: true,
50
47
  sitemap: true,
51
48
  buildDir: true,
49
+ clientSegmentCache: true,
52
50
  topLoader: true,
53
51
  budget: true,
54
52
  } satisfies Record<keyof TimberUserConfig, true>;
@@ -197,12 +195,16 @@ export function validateConfig(config: TimberUserConfig): ConfigError[] {
197
195
  }
198
196
  }
199
197
 
198
+ const MOVED_TO_CACHE_FILE = new Set(['cacheHandler', 'cdnPurge']);
199
+
200
200
  for (const key of Object.keys(config)) {
201
201
  if (!knownKeys.has(key)) {
202
202
  errors.push({
203
203
  field: key,
204
204
  message: `Unknown config option: "${key}".`,
205
- suggestion: `Check for typos. Known options: ${[...knownKeys].sort().join(', ')}.`,
205
+ suggestion: MOVED_TO_CACHE_FILE.has(key)
206
+ ? `"${key}" has moved to timber.cache.ts. Export cacheHandler as default, cdnPurge as a named export.`
207
+ : `Check for typos. Known options: ${[...knownKeys].sort().join(', ')}.`,
206
208
  });
207
209
  }
208
210
  }
@@ -248,7 +250,7 @@ const VIRTUAL_MODULE_NAMES: Record<string, string> = {
248
250
  'virtual:timber-config': 'Runtime config (timber.config.ts)',
249
251
  'virtual:timber-route-manifest': 'Route manifest (app/ file tree)',
250
252
  'virtual:timber-instrumentation': 'Instrumentation (instrumentation.ts)',
251
- 'virtual:timber-cache-handler': 'Cache handler (cacheHandler config)',
253
+ 'virtual:timber-cache-handler': 'Cache handler (timber.cache.ts)',
252
254
  'virtual:timber-build-manifest': 'Build manifest (asset mapping)',
253
255
  };
254
256
 
@@ -208,6 +208,38 @@ export class FontPipeline {
208
208
  }
209
209
  }
210
210
 
211
+ /** True if at least one entry is registered under `importer`. */
212
+ hasImporter(importer: string): boolean {
213
+ for (const entry of this._entries.values()) {
214
+ if (entry.importer === importer) return true;
215
+ }
216
+ return false;
217
+ }
218
+
219
+ /**
220
+ * Drop **every** registry entry that originated from `importer`,
221
+ * regardless of family.
222
+ *
223
+ * Called at the start of the transform hook before re-registering the
224
+ * current extraction set. This handles the cases `pruneFor` misses:
225
+ *
226
+ * - **Family rename** (Inter → Roboto): the old family's entries are
227
+ * cleared because we prune by importer, not by (importer, family).
228
+ * - **Font call deleted**: the old entries are cleared even though no
229
+ * new call provides a family to prune against.
230
+ *
231
+ * Other importers' fonts are untouched.
232
+ *
233
+ * See TIM-1087.
234
+ */
235
+ pruneImporter(importer: string): void {
236
+ for (const [id, entry] of this._entries) {
237
+ if (entry.importer === importer) {
238
+ this._entries.delete(id);
239
+ }
240
+ }
241
+ }
242
+
211
243
  /** Drop every registered font. Used by tests and rebuild flows. */
212
244
  clear(): void {
213
245
  this._entries.clear();
@@ -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';
@@ -83,6 +84,7 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
83
84
  }
84
85
 
85
86
  const adapterConfig: TimberConfig = {
87
+ root: ctx.root,
86
88
  output: ctx.config.output ?? 'server',
87
89
  clientJavascriptDisabled: ctx.clientJavascript.disabled,
88
90
  manifestInit,
@@ -94,6 +96,116 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
94
96
  };
95
97
  }
96
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
+
97
209
  /**
98
210
  * Strip JS references from the RSC plugin's assets manifest files.
99
211
  *
@@ -125,13 +237,8 @@ export async function mergeRscAssetsManifest(
125
237
  return;
126
238
  }
127
239
 
128
- const jsonStr = content.replace(/^export default\s*/, '').replace(/;?\s*$/, '');
129
- let manifest: Record<string, unknown>;
130
- try {
131
- manifest = JSON.parse(jsonStr);
132
- } catch {
133
- return;
134
- }
240
+ const manifest = await parseRscManifest(manifestPath, content);
241
+ if (!manifest) return;
135
242
 
136
243
  // Merge serverResources CSS into buildManifest.css.
137
244
  // Keys in serverResources are root-relative (e.g., "app/layout.tsx" or
@@ -174,7 +281,7 @@ export async function mergeRscAssetsManifest(
174
281
  }
175
282
  }
176
283
 
177
- async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
284
+ export async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
178
285
  const manifestName = '__vite_rsc_assets_manifest.js';
179
286
  const paths = [join(buildDir, 'rsc', manifestName), join(buildDir, 'ssr', manifestName)];
180
287
 
@@ -186,17 +293,29 @@ async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
186
293
  continue;
187
294
  }
188
295
 
296
+ // Try JSON.parse to check if we can safely re-serialize
189
297
  const jsonStr = content.replace(/^export default\s*/, '').replace(/;?\s*$/, '');
190
- let manifest: Record<string, unknown>;
298
+ let isStrictJson = true;
299
+ let manifest: Record<string, unknown> | null = null;
191
300
  try {
192
301
  manifest = JSON.parse(jsonStr);
193
- } catch (err) {
194
- 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);
195
314
  continue;
196
315
  }
197
316
 
198
317
  // Clear JS from clientReferenceDeps — preserves CSS
199
- const deps = manifest.clientReferenceDeps as
318
+ const deps = manifest?.clientReferenceDeps as
200
319
  | Record<string, { js: string[]; css: string[] }>
201
320
  | undefined;
202
321
  if (deps) {
@@ -205,7 +324,7 @@ async function stripJsFromRscAssetsManifests(buildDir: string): Promise<void> {
205
324
  }
206
325
  }
207
326
 
208
- manifest.bootstrapScriptContent = '';
327
+ if (manifest) manifest.bootstrapScriptContent = '';
209
328
 
210
329
  await writeFile(path, `export default ${JSON.stringify(manifest, null, 2)};\n`);
211
330
  }
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * timber-cache-transform — Vite plugin that injects stable callsite IDs
3
- * into `cache()` calls imported from `@timber-js/app/cache`.
3
+ * into `cache.data()` calls imported from `@timber-js/app/cache`.
4
4
  *
5
5
  * Detected callsite forms (TIM-1054):
6
- * - named imports, including aliases: `import { cache as c }` + `c(fn, opts)`
7
- * - namespace imports: `import * as ns` + `ns.cache(fn, opts)`
6
+ * - named imports, including aliases: `import { cache as c }` + `c.data(fn, opts)`
7
+ * - namespace imports: `import * as ns` + `ns.cache.data(fn, opts)`
8
8
  * Forms a per-module transform cannot trace — local variable aliases
9
9
  * (`const c = cache`), user re-export wrapper modules, computed access
10
- * (`ns['cache']`) — fall back to the runtime fnId derived from
10
+ * (`cache['data']`) — fall back to the runtime fnId derived from
11
11
  * fnv1a(fn.toString()) (see cache/timber-cache.ts), which is deterministic
12
12
  * across instances and cold starts of the same build. Without either
13
13
  * mechanism, an ordinal counter would collide across instances with a shared
@@ -15,8 +15,8 @@
15
15
  * data to another.
16
16
  *
17
17
  * The plugin transforms:
18
- * cache(fn, opts) → cache(fn, opts, "stable-id")
19
- * ns.cache(fn, opts) → ns.cache(fn, opts, "stable-id")
18
+ * cache.data(fn, opts) → cache.data(fn, opts, "stable-id")
19
+ * ns.cache.data(fn, opts) → ns.cache.data(fn, opts, "stable-id")
20
20
  *
21
21
  * where stable-id =
22
22
  * fnv1a(relPath + ":cache:" + fnv1a(stmtText) + ":" + fnv1a(callSpanText) + ":" + occurrence)
@@ -34,9 +34,9 @@
34
34
  * unrelated edits, while a changed call gets a fresh ID — which is exactly
35
35
  * when invalidation is wanted.
36
36
  *
37
- * The enclosing-statement text is included so that byte-identical cache()
37
+ * The enclosing-statement text is included so that byte-identical cache.data()
38
38
  * calls in different scopes — e.g. two factory helpers that each `return
39
- * cache(fn, { ttl: 60 })` — get distinct, insertion-stable IDs: their
39
+ * cache.data(fn, { ttl: 60 })` — get distinct, insertion-stable IDs: their
40
40
  * enclosing declarations differ by name/body even when the call spans are
41
41
  * identical. Without it, a source-order occurrence counter alone would shift
42
42
  * when an identical span is inserted above, reassigning IDs across deploys.
@@ -75,41 +75,56 @@ import type {
75
75
  } from './callsite-ast.js';
76
76
 
77
77
  /**
78
- * Is this callee a reference to the imported `cache` function — either a bare
79
- * identifier bound by a named import, or `ns.cache` on a namespace import?
80
- * Computed access (`ns['cache']`) is deliberately not matched; it falls back
78
+ * Is this callee a `cache.data(...)` call? Matches two forms:
79
+ * - Named import: `cache.data(fn, opts)` where `cache` is the imported binding
80
+ * - Namespace import: `ns.cache.data(fn, opts)` where `ns` is the namespace
81
+ * Computed access (`cache['data']`) is deliberately not matched; it falls back
81
82
  * to the runtime fnId derivation.
82
83
  */
83
- function isCacheCallee(callee: PositionedNode, bindings: ImportBindings): boolean {
84
- if (callee.type === 'Identifier') {
85
- return bindings.named.has((callee as IdentifierNode).name);
84
+ function isCacheDataCallee(callee: PositionedNode, bindings: ImportBindings): boolean {
85
+ if (callee.type !== 'MemberExpression') return false;
86
+ const member = callee as unknown as MemberExpressionNode;
87
+ if (member.computed) return false;
88
+ if (member.property.type !== 'Identifier' || (member.property as IdentifierNode).name !== 'data')
89
+ return false;
90
+
91
+ const obj = member.object;
92
+ // Named import: `cache.data(...)` — obj is the imported `cache` identifier
93
+ if (obj.type === 'Identifier') {
94
+ return bindings.named.has((obj as IdentifierNode).name);
86
95
  }
87
- if (callee.type === 'MemberExpression') {
88
- const member = callee as unknown as MemberExpressionNode;
96
+ // Namespace import: `ns.cache.data(...)` — obj is `ns.cache`
97
+ if (obj.type === 'MemberExpression') {
98
+ const outer = obj as unknown as MemberExpressionNode;
89
99
  return (
90
- !member.computed &&
91
- member.object.type === 'Identifier' &&
92
- bindings.namespaces.has((member.object as IdentifierNode).name) &&
93
- member.property.type === 'Identifier' &&
94
- (member.property as IdentifierNode).name === 'cache'
100
+ !outer.computed &&
101
+ outer.object.type === 'Identifier' &&
102
+ bindings.namespaces.has((outer.object as IdentifierNode).name) &&
103
+ outer.property.type === 'Identifier' &&
104
+ (outer.property as IdentifierNode).name === 'cache'
95
105
  );
96
106
  }
97
107
  return false;
98
108
  }
99
109
 
100
110
  /**
101
- * Extract the local binding name that a cache callee resolves to — the
102
- * identifier for named imports, or the namespace object for `ns.cache()`.
111
+ * Extract the local binding name that a cache.data callee resolves to — the
112
+ * `cache` identifier for named imports, or the namespace object for `ns.cache.data()`.
103
113
  */
104
114
  function calleeBindingName(callee: PositionedNode, bindings: ImportBindings): string | null {
105
- if (callee.type === 'Identifier') {
106
- const name = (callee as IdentifierNode).name;
115
+ if (callee.type !== 'MemberExpression') return null;
116
+ const member = callee as unknown as MemberExpressionNode;
117
+ const obj = member.object;
118
+ // Named import: `cache.data(...)` — binding is `cache`
119
+ if (obj.type === 'Identifier') {
120
+ const name = (obj as IdentifierNode).name;
107
121
  return bindings.named.has(name) ? name : null;
108
122
  }
109
- if (callee.type === 'MemberExpression') {
110
- const member = callee as unknown as MemberExpressionNode;
111
- if (member.object.type === 'Identifier') {
112
- const name = (member.object as IdentifierNode).name;
123
+ // Namespace import: `ns.cache.data(...)` — binding is `ns`
124
+ if (obj.type === 'MemberExpression') {
125
+ const outer = obj as unknown as MemberExpressionNode;
126
+ if (outer.object.type === 'Identifier') {
127
+ const name = (outer.object as IdentifierNode).name;
113
128
  return bindings.namespaces.has(name) ? name : null;
114
129
  }
115
130
  }
@@ -182,7 +197,7 @@ export function timberCacheTransform(ctx: PluginContext): Plugin {
182
197
  stmt,
183
198
  (call) =>
184
199
  call.arguments.length === 2 &&
185
- isCacheCallee(call.callee, cacheBindings) &&
200
+ isCacheDataCallee(call.callee, cacheBindings) &&
186
201
  !isCalleeShadowed(call),
187
202
  calls
188
203
  );
@@ -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
  },