@timber-js/app 0.2.0-alpha.158 → 0.2.0-alpha.159

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 (286) hide show
  1. package/dist/_chunks/{actions-BA5tIpVu.js → actions-CBbP1hHO.js} +3 -3
  2. package/dist/_chunks/{actions-BA5tIpVu.js.map → actions-CBbP1hHO.js.map} +1 -1
  3. package/dist/_chunks/{build-output-helper-C8q4Zpf5.js → build-output-helper-DF5uScqS.js} +4 -1
  4. package/dist/_chunks/{build-output-helper-C8q4Zpf5.js.map → build-output-helper-DF5uScqS.js.map} +1 -1
  5. package/dist/_chunks/cache-api-DsWEYwVZ.js +1120 -0
  6. package/dist/_chunks/cache-api-DsWEYwVZ.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js → cli-schema-sync-Bivx4Rn7.js} +4 -3
  8. package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js.map → cli-schema-sync-Bivx4Rn7.js.map} +1 -1
  9. package/dist/_chunks/dist-BA3u1z3W.js +423 -0
  10. package/dist/_chunks/dist-BA3u1z3W.js.map +1 -0
  11. package/dist/_chunks/{logger-C5v1IMuh.js → logger-CrRmZMlo.js} +81 -56
  12. package/dist/_chunks/logger-CrRmZMlo.js.map +1 -0
  13. package/dist/_chunks/{plugin-context-DUl3dnCF.js → plugin-context-DeAxFRMq.js} +3 -36
  14. package/dist/_chunks/plugin-context-DeAxFRMq.js.map +1 -0
  15. package/dist/_chunks/sizeof-lXffQ1Vr.js +35 -0
  16. package/dist/_chunks/sizeof-lXffQ1Vr.js.map +1 -0
  17. package/dist/_chunks/{walkers-D5jCGPum.js → walkers-DXbJkeCY.js} +217 -19
  18. package/dist/_chunks/walkers-DXbJkeCY.js.map +1 -0
  19. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  20. package/dist/adapters/cloudflare.d.ts +28 -0
  21. package/dist/adapters/cloudflare.d.ts.map +1 -1
  22. package/dist/adapters/cloudflare.js +143 -9
  23. package/dist/adapters/cloudflare.js.map +1 -1
  24. package/dist/adapters/nitro.js +1 -1
  25. package/dist/cache/cache-api.d.ts +2 -0
  26. package/dist/cache/cache-api.d.ts.map +1 -1
  27. package/dist/cache/handler-store.d.ts +2 -0
  28. package/dist/cache/handler-store.d.ts.map +1 -1
  29. package/dist/cache/index.d.ts +2 -0
  30. package/dist/cache/index.d.ts.map +1 -1
  31. package/dist/cache/index.js +3 -186
  32. package/dist/cache/redis-handler.d.ts +17 -5
  33. package/dist/cache/redis-handler.d.ts.map +1 -1
  34. package/dist/cache/singleflight.d.ts +1 -1
  35. package/dist/cache/singleflight.d.ts.map +1 -1
  36. package/dist/cache/store.d.ts +53 -0
  37. package/dist/cache/store.d.ts.map +1 -0
  38. package/dist/cache/stores/cloudflare-kv.d.ts +38 -0
  39. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -0
  40. package/dist/cache/stores/cloudflare-kv.js +29 -0
  41. package/dist/cache/stores/cloudflare-kv.js.map +1 -0
  42. package/dist/cache/stores/memory.d.ts +42 -0
  43. package/dist/cache/stores/memory.d.ts.map +1 -0
  44. package/dist/cache/stores/memory.js +90 -0
  45. package/dist/cache/stores/memory.js.map +1 -0
  46. package/dist/cache/stores/redis.d.ts +69 -0
  47. package/dist/cache/stores/redis.d.ts.map +1 -0
  48. package/dist/cache/stores/redis.js +35 -0
  49. package/dist/cache/stores/redis.js.map +1 -0
  50. package/dist/cache/stores/vercel.d.ts +36 -0
  51. package/dist/cache/stores/vercel.d.ts.map +1 -0
  52. package/dist/cache/stores/vercel.js +33 -0
  53. package/dist/cache/stores/vercel.js.map +1 -0
  54. package/dist/cache/tag-aware-handler.d.ts +51 -0
  55. package/dist/cache/tag-aware-handler.d.ts.map +1 -0
  56. package/dist/cache/timber-cache.d.ts.map +1 -1
  57. package/dist/cdn/cloudflare-purge.d.ts +23 -0
  58. package/dist/cdn/cloudflare-purge.d.ts.map +1 -0
  59. package/dist/cdn/fastly-purge.d.ts +25 -0
  60. package/dist/cdn/fastly-purge.d.ts.map +1 -0
  61. package/dist/cdn/headers.d.ts +82 -0
  62. package/dist/cdn/headers.d.ts.map +1 -0
  63. package/dist/cdn/index.d.ts +5 -0
  64. package/dist/cdn/index.d.ts.map +1 -0
  65. package/dist/cdn/purge-store.d.ts +23 -0
  66. package/dist/cdn/purge-store.d.ts.map +1 -0
  67. package/dist/cli.js +2 -2
  68. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  69. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  70. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  71. package/dist/client/history.d.ts +0 -3
  72. package/dist/client/history.d.ts.map +1 -1
  73. package/dist/client/index.js +0 -1
  74. package/dist/client/index.js.map +1 -1
  75. package/dist/client/internal.js +11 -86
  76. package/dist/client/internal.js.map +1 -1
  77. package/dist/client/navigation-api.d.ts +1 -3
  78. package/dist/client/navigation-api.d.ts.map +1 -1
  79. package/dist/client/router.d.ts +1 -4
  80. package/dist/client/router.d.ts.map +1 -1
  81. package/dist/client/rsc-fetch.d.ts +1 -11
  82. package/dist/client/rsc-fetch.d.ts.map +1 -1
  83. package/dist/client/segment-cache.d.ts +1 -3
  84. package/dist/client/segment-cache.d.ts.map +1 -1
  85. package/dist/client/use-cookie.d.ts.map +1 -1
  86. package/dist/config-types.d.ts +3 -1
  87. package/dist/config-types.d.ts.map +1 -1
  88. package/dist/config-validation.d.ts.map +1 -1
  89. package/dist/cookies/index.js +15 -11
  90. package/dist/cookies/index.js.map +1 -1
  91. package/dist/dev-tools/browser-logs.d.ts +28 -72
  92. package/dist/dev-tools/browser-logs.d.ts.map +1 -1
  93. package/dist/dev-tools/index.d.ts +1 -1
  94. package/dist/dev-tools/index.d.ts.map +1 -1
  95. package/dist/dev-tools/logger.d.ts.map +1 -1
  96. package/dist/dev-tools/terminal.d.ts.map +1 -1
  97. package/dist/fonts/transform.d.ts +4 -0
  98. package/dist/fonts/transform.d.ts.map +1 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +1269 -359
  101. package/dist/index.js.map +1 -1
  102. package/dist/plugin-context.d.ts +36 -0
  103. package/dist/plugin-context.d.ts.map +1 -1
  104. package/dist/plugins/build-report.d.ts +21 -0
  105. package/dist/plugins/build-report.d.ts.map +1 -1
  106. package/dist/plugins/cache.d.ts +5 -14
  107. package/dist/plugins/cache.d.ts.map +1 -1
  108. package/dist/plugins/callsite-ast.d.ts +95 -0
  109. package/dist/plugins/callsite-ast.d.ts.map +1 -0
  110. package/dist/plugins/callsite-jsx.d.ts +35 -0
  111. package/dist/plugins/callsite-jsx.d.ts.map +1 -0
  112. package/dist/plugins/entries.d.ts.map +1 -1
  113. package/dist/plugins/fonts.d.ts.map +1 -1
  114. package/dist/plugins/prebuilt-capture.d.ts +60 -0
  115. package/dist/plugins/prebuilt-capture.d.ts.map +1 -0
  116. package/dist/plugins/prebuilt.d.ts +82 -0
  117. package/dist/plugins/prebuilt.d.ts.map +1 -0
  118. package/dist/plugins/prerender-sugar.d.ts +42 -0
  119. package/dist/plugins/prerender-sugar.d.ts.map +1 -0
  120. package/dist/plugins/server-bundle.d.ts.map +1 -1
  121. package/dist/plugins/shims.d.ts.map +1 -1
  122. package/dist/plugins/static-build.d.ts.map +1 -1
  123. package/dist/routing/convention-lint.d.ts.map +1 -1
  124. package/dist/routing/export-detect.d.ts +56 -0
  125. package/dist/routing/export-detect.d.ts.map +1 -0
  126. package/dist/routing/index.js +2 -2
  127. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  128. package/dist/rsc-runtime/rsc.d.ts +1 -1
  129. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  130. package/dist/server/action-handler.d.ts.map +1 -1
  131. package/dist/server/actions.d.ts +0 -3
  132. package/dist/server/actions.d.ts.map +1 -1
  133. package/dist/server/als-registry.d.ts +7 -0
  134. package/dist/server/als-registry.d.ts.map +1 -1
  135. package/dist/server/cookie-parsing.d.ts +15 -1
  136. package/dist/server/cookie-parsing.d.ts.map +1 -1
  137. package/dist/server/deny-renderer.d.ts.map +1 -1
  138. package/dist/server/html-injectors.d.ts +1 -0
  139. package/dist/server/html-injectors.d.ts.map +1 -1
  140. package/dist/server/index.js +2 -2
  141. package/dist/server/internal.d.ts +6 -1
  142. package/dist/server/internal.d.ts.map +1 -1
  143. package/dist/server/internal.js +563 -74
  144. package/dist/server/internal.js.map +1 -1
  145. package/dist/server/prebuilt/cache-key.d.ts +58 -0
  146. package/dist/server/prebuilt/cache-key.d.ts.map +1 -0
  147. package/dist/server/prebuilt/capture-state.d.ts +57 -0
  148. package/dist/server/prebuilt/capture-state.d.ts.map +1 -0
  149. package/dist/server/prebuilt/manifest-tag-index.d.ts +27 -0
  150. package/dist/server/prebuilt/manifest-tag-index.d.ts.map +1 -0
  151. package/dist/server/prebuilt/overlay.d.ts +100 -0
  152. package/dist/server/prebuilt/overlay.d.ts.map +1 -0
  153. package/dist/server/prebuilt/payload-source.d.ts +75 -0
  154. package/dist/server/prebuilt/payload-source.d.ts.map +1 -0
  155. package/dist/server/prebuilt/synthetic-store.d.ts +64 -0
  156. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -0
  157. package/dist/server/prebuilt-builder.d.ts +110 -0
  158. package/dist/server/prebuilt-builder.d.ts.map +1 -0
  159. package/dist/server/prebuilt-runtime.d.ts +101 -0
  160. package/dist/server/prebuilt-runtime.d.ts.map +1 -0
  161. package/dist/server/route-element-builder.d.ts +10 -8
  162. package/dist/server/route-element-builder.d.ts.map +1 -1
  163. package/dist/server/rsc-entry/helpers.d.ts +0 -5
  164. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  165. package/dist/server/rsc-entry/index.d.ts +10 -0
  166. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  167. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  168. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  169. package/dist/server/rsc-entry/rsc-payload.d.ts +3 -4
  170. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  171. package/dist/server/sitemap-generator.d.ts +6 -6
  172. package/dist/server/sitemap-generator.d.ts.map +1 -1
  173. package/dist/server/tracing.d.ts +37 -3
  174. package/dist/server/tracing.d.ts.map +1 -1
  175. package/docs/api/32-api-cache.mdx +10 -4
  176. package/docs/learn/00-introduction.mdx +8 -13
  177. package/docs/learn/02-pages-and-layouts.mdx +9 -0
  178. package/docs/learn/06-forms-and-actions.mdx +0 -4
  179. package/docs/learn/07-typed-routes.mdx +127 -0
  180. package/docs/learn/09-caching.mdx +12 -1
  181. package/docs/learn/10-middleware.mdx +1 -0
  182. package/docs/learn/11-error-handling.mdx +9 -0
  183. package/docs/learn/13-configuration.mdx +28 -0
  184. package/docs/learn/14-deploying.mdx +52 -0
  185. package/docs/more/06-instrumentation.mdx +27 -1
  186. package/docs/more/07-security.mdx +8 -2
  187. package/docs/more/08-developer-experience.mdx +134 -0
  188. package/package.json +31 -2
  189. package/src/adapters/build-output-helper.ts +10 -0
  190. package/src/adapters/cloudflare.ts +178 -4
  191. package/src/cache/cache-api.ts +60 -0
  192. package/src/cache/handler-store.ts +8 -0
  193. package/src/cache/index.ts +4 -0
  194. package/src/cache/redis-handler.ts +25 -13
  195. package/src/cache/singleflight.ts +11 -11
  196. package/src/cache/store.ts +71 -0
  197. package/src/cache/stores/cloudflare-kv.ts +52 -0
  198. package/src/cache/stores/memory.ts +125 -0
  199. package/src/cache/stores/redis.ts +97 -0
  200. package/src/cache/stores/vercel.ts +59 -0
  201. package/src/cache/tag-aware-handler.ts +233 -0
  202. package/src/cache/timber-cache.ts +17 -7
  203. package/src/cdn/cloudflare-purge.ts +50 -0
  204. package/src/cdn/fastly-purge.ts +63 -0
  205. package/src/cdn/headers.ts +127 -0
  206. package/src/cdn/index.ts +16 -0
  207. package/src/cdn/purge-store.ts +34 -0
  208. package/src/client/browser-entry/action-dispatch.ts +1 -10
  209. package/src/client/browser-entry/post-hydration.ts +0 -1
  210. package/src/client/browser-entry/router-init.ts +0 -5
  211. package/src/client/history.ts +0 -4
  212. package/src/client/navigation-api.ts +5 -11
  213. package/src/client/router.ts +9 -43
  214. package/src/client/rsc-fetch.ts +5 -27
  215. package/src/client/segment-cache.ts +1 -4
  216. package/src/client/use-cookie.ts +18 -12
  217. package/src/config-types.ts +3 -1
  218. package/src/config-validation.ts +1 -0
  219. package/src/dev-tools/browser-logs.ts +37 -273
  220. package/src/dev-tools/index.ts +2 -2
  221. package/src/dev-tools/instrumentation.ts +1 -1
  222. package/src/dev-tools/logger.ts +33 -51
  223. package/src/dev-tools/terminal.ts +21 -22
  224. package/src/fonts/transform.ts +4 -43
  225. package/src/index.ts +16 -3
  226. package/src/plugin-context.ts +36 -0
  227. package/src/plugins/build-report.ts +133 -1
  228. package/src/plugins/cache.ts +32 -131
  229. package/src/plugins/callsite-ast.ts +167 -0
  230. package/src/plugins/callsite-jsx.ts +243 -0
  231. package/src/plugins/entries.ts +23 -4
  232. package/src/plugins/fonts.ts +16 -2
  233. package/src/plugins/prebuilt-capture.ts +208 -0
  234. package/src/plugins/prebuilt.ts +673 -0
  235. package/src/plugins/prerender-sugar.ts +329 -0
  236. package/src/plugins/server-bundle.ts +12 -0
  237. package/src/plugins/shims.ts +9 -0
  238. package/src/plugins/static-build.ts +0 -1
  239. package/src/routing/codegen.ts +1 -21
  240. package/src/routing/convention-lint.ts +85 -49
  241. package/src/routing/export-detect.ts +304 -0
  242. package/src/routing/manifest-codegen.ts +2 -11
  243. package/src/rsc-runtime/rsc.ts +7 -0
  244. package/src/server/action-handler.ts +11 -27
  245. package/src/server/actions.ts +3 -4
  246. package/src/server/als-registry.ts +7 -0
  247. package/src/server/cookie-parsing.ts +75 -59
  248. package/src/server/deny-renderer.ts +7 -15
  249. package/src/server/html-injectors.ts +11 -3
  250. package/src/server/internal.ts +17 -1
  251. package/src/server/prebuilt/cache-key.ts +224 -0
  252. package/src/server/prebuilt/capture-state.ts +89 -0
  253. package/src/server/prebuilt/manifest-tag-index.ts +46 -0
  254. package/src/server/prebuilt/overlay.ts +256 -0
  255. package/src/server/prebuilt/payload-source.ts +192 -0
  256. package/src/server/prebuilt/synthetic-store.ts +108 -0
  257. package/src/server/prebuilt-builder.ts +640 -0
  258. package/src/server/prebuilt-runtime.ts +706 -0
  259. package/src/server/route-element-builder.ts +40 -12
  260. package/src/server/rsc-entry/helpers.ts +0 -17
  261. package/src/server/rsc-entry/index.ts +83 -0
  262. package/src/server/rsc-entry/render-route.ts +6 -33
  263. package/src/server/rsc-entry/revalidate-renderer.ts +16 -11
  264. package/src/server/rsc-entry/rsc-payload.ts +5 -11
  265. package/src/server/sitemap-generator.ts +16 -10
  266. package/src/server/tracing.ts +88 -3
  267. package/dist/_chunks/cache-api-Cbyh3RW4.js +0 -401
  268. package/dist/_chunks/cache-api-Cbyh3RW4.js.map +0 -1
  269. package/dist/_chunks/logger-C5v1IMuh.js.map +0 -1
  270. package/dist/_chunks/plugin-context-DUl3dnCF.js.map +0 -1
  271. package/dist/_chunks/validation-CnKhGJsp.js +0 -113
  272. package/dist/_chunks/validation-CnKhGJsp.js.map +0 -1
  273. package/dist/_chunks/walkers-D5jCGPum.js.map +0 -1
  274. package/dist/cache/index.js.map +0 -1
  275. package/dist/client/head.d.ts +0 -16
  276. package/dist/client/head.d.ts.map +0 -1
  277. package/dist/client/navigation-api-types.d.ts +0 -90
  278. package/dist/client/navigation-api-types.d.ts.map +0 -1
  279. package/dist/server/client-module-map.d.ts +0 -47
  280. package/dist/server/client-module-map.d.ts.map +0 -1
  281. package/dist/shared/head-header.d.ts +0 -54
  282. package/dist/shared/head-header.d.ts.map +0 -1
  283. package/src/client/head.ts +0 -61
  284. package/src/client/navigation-api-types.ts +0 -112
  285. package/src/server/client-module-map.ts +0 -58
  286. package/src/shared/head-header.ts +0 -69
@@ -409,12 +409,20 @@ export function buildClientScripts(runtimeConfig: {
409
409
  output: string;
410
410
  clientJavascript: { disabled: boolean; enableHMRInDev: boolean };
411
411
  dev: boolean;
412
+ devBrowserLogs?: string;
412
413
  buildManifest?: import('./build-manifest.js').BuildManifest;
413
414
  }): ClientBootstrapConfig {
414
415
  if (runtimeConfig.clientJavascript.disabled) {
415
- // When client JS is disabled but enableHMRInDev is true in dev mode,
416
- // inject only the Vite HMR client for hot reloading CSS, etc.
417
- if (runtimeConfig.dev && runtimeConfig.clientJavascript.enableHMRInDev) {
416
+ // When client JS is disabled, import /@vite/client in dev mode if
417
+ // any dev feature needs it: HMR for CSS (enableHMRInDev) or
418
+ // forwardConsole for browser→terminal log forwarding (devBrowserLogs
419
+ // not 'none'). Without /@vite/client, server.forwardConsole's
420
+ // client-side console patching never activates. When both features
421
+ // are off, honor the zero-JS opt-out. See TIM-1152.
422
+ const needsViteClient =
423
+ runtimeConfig.clientJavascript.enableHMRInDev ||
424
+ (runtimeConfig.devBrowserLogs ?? 'warn') !== 'none';
425
+ if (runtimeConfig.dev && needsViteClient) {
418
426
  return {
419
427
  bootstrapScriptContent: 'import("/@vite/client")',
420
428
  preloadLinks: '',
@@ -101,7 +101,7 @@ export {
101
101
  resolveMetadataUrls,
102
102
  renderMetadataToElements,
103
103
  } from './metadata.js';
104
- export type { SegmentMetadataEntry, ResolveMetadataOptions, HeadElement } from './metadata.js';
104
+ export type { SegmentMetadataEntry, ResolveMetadataOptions } from './metadata.js';
105
105
 
106
106
  // ── Metadata Routes ──────────────────────────────────────────────────────
107
107
  export {
@@ -163,3 +163,19 @@ export type { RouteModule, RouteHandler, HttpMethod } from './route-handler.js';
163
163
  // ── Render Timeout ───────────────────────────────────────────────────────
164
164
  export { RenderTimeoutError } from './render-timeout.js';
165
165
  export type { RenderTimeout } from './render-timeout.js';
166
+
167
+ // ── Prebuilt Components (cache.component transform targets) ─────────────
168
+ export {
169
+ __prebuilt,
170
+ __prebuiltDev,
171
+ getPrebuiltRegistration,
172
+ getPrebuiltRegistry,
173
+ } from './prebuilt-runtime.js';
174
+ export type { PrebuiltComponentOptions, PrebuiltRegistration } from './prebuilt-runtime.js';
175
+ export { setPrebuiltPayloadSource, hasPrebuiltPayloadSource } from './prebuilt/payload-source.js';
176
+ export type {
177
+ PrebuiltManifest,
178
+ PrebuiltManifestEntry,
179
+ PrebuiltPayloadSource,
180
+ } from './prebuilt/payload-source.js';
181
+ export { computeComponentCacheKey } from './prebuilt/cache-key.js';
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Component cache-key hashing for prebuilt (cache.component) entries.
3
+ *
4
+ * Key: `sha256(canonicalJson({ props, params })).slice(0, 16)` — see
5
+ * design/44-render-at-build-time.md §Cache Key. Both the request-time
6
+ * lookup (prebuilt-runtime.ts) and the post-build capture pass (TIM-1119)
7
+ * MUST use this exact function, or build artifacts become unaddressable.
8
+ *
9
+ * THE IDENTITY CONTRACT (design/44 §Cache Key): cache identity is **JSON
10
+ * value semantics** — two inputs are the same entry iff they are the same
11
+ * JSON value. This is an ALLOWLIST, not a denylist: a value participates
12
+ * in caching iff it is plain JSON data — null, boolean, finite number,
13
+ * string, dense array, or a plain `Object.prototype` object with only
14
+ * enumerable string-keyed data properties — plus `undefined` and `-0`,
15
+ * which hash as distinct sentinels because flight preserves them. Every
16
+ * value outside that domain (functions, symbols, bigints, accessors,
17
+ * hidden or symbol-keyed properties, non-`Object.prototype` prototypes
18
+ * including null, aliased references, sparse arrays, cycles, non-finite
19
+ * numbers) is UNKEYABLE by definition: `computeComponentCacheKey` returns
20
+ * null and the caller renders live — slower, never wrong.
21
+ *
22
+ * The line between rejected and declared-out-of-domain: values are
23
+ * REJECTED when the JSON projection itself would be ambiguous or lossy
24
+ * (hidden/symbol/non-enumerable properties, accessors, exotic
25
+ * prototypes). Observable state that leaves the projection intact — key
26
+ * order, reference identity, descriptor metadata (writable/configurable,
27
+ * frozen/sealed/extensible), proxy behavior — is DECLARED outside
28
+ * cache.component's supported input domain rather than rejected:
29
+ * key-order insensitivity is required for determinism, and rejecting
30
+ * frozen objects would unkey ubiquitous legitimate inputs
31
+ * (Object.freeze'd config props) to defend against components that
32
+ * render differently based on property attributes.
33
+ *
34
+ * Two hard rules for this walk itself: it must be TOTAL (never throw
35
+ * anything but UnkeyableValueError) and SIDE-EFFECT-FREE (descriptor
36
+ * reads only; never invoke user code such as getters).
37
+ *
38
+ * Object keys sort recursively so `{a, b}` and `{b, a}` hash identically
39
+ * (design/13-security.md checklist #14 — cache key determinism; key order
40
+ * is deliberately NOT part of identity).
41
+ */
42
+
43
+ import { createHash } from 'node:crypto';
44
+
45
+ class UnkeyableValueError extends Error {}
46
+
47
+ function canonicalize(value: unknown, seen: Set<object>): string {
48
+ if (value === null) return 'null';
49
+ switch (typeof value) {
50
+ case 'string':
51
+ return JSON.stringify(value);
52
+ case 'boolean':
53
+ return value ? 'true' : 'false';
54
+ case 'number':
55
+ if (!Number.isFinite(value)) throw new UnkeyableValueError('non-finite number');
56
+ // -0 is distinct: flight preserves it ("$-0"), and String(-0) would
57
+ // collapse it to "0" (codex review round 4 on PR #835).
58
+ return Object.is(value, -0) ? '-0' : String(value);
59
+ case 'object':
60
+ break;
61
+ case 'undefined':
62
+ // Distinct sentinel, NOT collapsed to null or omitted: flight
63
+ // serialization preserves `undefined` ("$undefined"), so a component
64
+ // can render [undefined] differently from [null] and {a: undefined}
65
+ // differently from {}. Bare `undefined` cannot collide with any
66
+ // other canonical output (strings are always quoted). Codex review
67
+ // round 3 on PR #835.
68
+ return 'undefined';
69
+ default:
70
+ // function, symbol, bigint
71
+ throw new UnkeyableValueError(`unsupported type: ${typeof value}`);
72
+ }
73
+ const obj = value as object;
74
+ // Any REPEATED reference — cycle or alias — is unkeyable. Flight
75
+ // preserves reference identity (the second occurrence serializes as a
76
+ // row reference), so a component can observe `props.a === props.b`;
77
+ // hashing an aliased shape identically to a structurally-equal
78
+ // non-aliased one could splice the wrong subtree (codex review round 5
79
+ // on PR #835). Objects are never removed from `seen`: revisiting one
80
+ // anywhere in the walk throws.
81
+ if (seen.has(obj)) throw new UnkeyableValueError('repeated object reference (cycle or alias)');
82
+ seen.add(obj);
83
+ // Hidden own properties — symbol keys and non-enumerable props — are
84
+ // readable by a live-rendered component but invisible to this walk, so
85
+ // two observably different inputs would collide on one key (codex
86
+ // review on PR #835). Unkeyable.
87
+ if (Object.getOwnPropertySymbols(obj).length > 0) {
88
+ throw new UnkeyableValueError('symbol-keyed property');
89
+ }
90
+ {
91
+ if (Array.isArray(obj)) {
92
+ // Dense-array own names are exactly ['0'..'n-1', 'length'] — anything
93
+ // else is a custom expando property this walk would silently drop.
94
+ if (Object.getOwnPropertyNames(obj).length !== obj.length + 1) {
95
+ throw new UnkeyableValueError('non-index array property');
96
+ }
97
+ const parts: string[] = [];
98
+ for (let i = 0; i < obj.length; i++) {
99
+ // Sparse holes are unkeyable: a server component receiving the
100
+ // original array can observe `i in arr`, which no canonical form
101
+ // survives faithfully. (An explicit undefined element is fine —
102
+ // handled by the sentinel above.)
103
+ const desc = Object.getOwnPropertyDescriptor(obj, i);
104
+ if (desc === undefined) throw new UnkeyableValueError('sparse array hole');
105
+ // Accessors are rejected WITHOUT invoking them — a getter may
106
+ // throw or side-effect, and the key walk must never alter a
107
+ // render (codex review on PR #835).
108
+ if (desc.get !== undefined || desc.set !== undefined) {
109
+ throw new UnkeyableValueError('accessor property');
110
+ }
111
+ // Non-enumerable elements are invisible to Object.keys/spread/
112
+ // JSON.stringify — the JSON projection would be ambiguous, same
113
+ // rule as the object branch's keys-vs-names check.
114
+ if (!desc.enumerable) throw new UnkeyableValueError('non-enumerable array element');
115
+ parts.push(canonicalize(desc.value, seen));
116
+ }
117
+ return '[' + parts.join(',') + ']';
118
+ }
119
+ // Allowlist: exactly Object.prototype. Dates, Maps, class instances —
120
+ // and null-prototype objects, which a component can distinguish from
121
+ // literals via Object.getPrototypeOf — are all outside the JSON
122
+ // domain.
123
+ if (Object.getPrototypeOf(obj) !== Object.prototype) {
124
+ throw new UnkeyableValueError('non-plain object');
125
+ }
126
+ const keys = Object.keys(obj).sort();
127
+ if (keys.length !== Object.getOwnPropertyNames(obj).length) {
128
+ throw new UnkeyableValueError('non-enumerable property');
129
+ }
130
+ const parts: string[] = [];
131
+ for (const key of keys) {
132
+ // Descriptor read, not property read: accessors are rejected WITHOUT
133
+ // invoking them — a getter may throw or side-effect, and the key
134
+ // walk must never alter a render (codex review on PR #835).
135
+ const desc = Object.getOwnPropertyDescriptor(obj, key)!;
136
+ if (desc.get !== undefined || desc.set !== undefined) {
137
+ throw new UnkeyableValueError('accessor property');
138
+ }
139
+ parts.push(JSON.stringify(key) + ':' + canonicalize(desc.value, seen));
140
+ }
141
+ return '{' + parts.join(',') + '}';
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Canonicalize a single value, or null if it is unkeyable. The ONE
147
+ * definition of "faithfully comparable" — every prebuilt code path that
148
+ * needs value equality (cache keys, slot-param divergence) goes through
149
+ * this, so the unkeyable rules (accessors, hidden props, aliases,
150
+ * bigints, …) live in exactly one place.
151
+ */
152
+ export function tryCanonicalize(value: unknown): string | null {
153
+ try {
154
+ return canonicalize(value, new Set());
155
+ } catch (error) {
156
+ return toUnkeyable(error);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Totality by construction: ANY throw from the walk means the input is
162
+ * outside the keyable domain. The walk touches only user-controlled
163
+ * values through reflection ops — and a Proxy trap can throw arbitrary
164
+ * errors from any of them (codex review on PR #835) — so converting only
165
+ * UnkeyableValueError would let hostile/exotic inputs abort a render
166
+ * that plain live rendering would have served fine. Unexpected error
167
+ * types are logged for visibility (they would indicate either a proxy
168
+ * trap or a canonicalizer bug) but still degrade to unkeyable.
169
+ */
170
+ function toUnkeyable(error: unknown): null {
171
+ if (!(error instanceof UnkeyableValueError)) {
172
+ console.error(
173
+ '[timber] cache key canonicalization threw — treating input as unkeyable:',
174
+ error
175
+ );
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Compute the cache-entry hash for a component render:
182
+ * `sha256({props, params}).slice(0, 16)` over canonical JSON.
183
+ *
184
+ * Returns null when props (or params) contain unkeyable values — the
185
+ * caller must fall back to dynamic rendering.
186
+ */
187
+ export function computeComponentCacheKey(
188
+ props: Record<string, unknown>,
189
+ params: Record<string, string | string[]>
190
+ ): string | null {
191
+ let canonical: string;
192
+ try {
193
+ // One shared `seen` set: an object aliased between params and props is
194
+ // just as observable as one aliased within either.
195
+ const seen = new Set<object>();
196
+ // The params RECORD is framework-produced: coerceSegmentParams builds
197
+ // it with Object.create(null) as a prototype-pollution defense
198
+ // (design/13 checklist #36b), uniformly on every request — its
199
+ // prototype is an implementation detail, not user-observable input
200
+ // variation, so the record is normalized to a plain object before the
201
+ // walk (codex P1 on PR #835: rejecting it made every real request
202
+ // unkeyable). Nested VALUES still face the full allowlist, and user
203
+ // props stay strict — a null-prototype prop object remains unkeyable.
204
+ canonical =
205
+ '{"params":' +
206
+ canonicalize({ ...params }, seen) +
207
+ ',"props":' +
208
+ canonicalize(props, seen) +
209
+ '}';
210
+ } catch (error) {
211
+ return toUnkeyable(error);
212
+ }
213
+ // NOTE(hash choice — DECIDED, TIM-1119): 64 bits of truncated sha256 is
214
+ // the SHIPPED artifact format; the capture pass writes .flight files and
215
+ // manifest entries named by this hash, so changing the width is now a
216
+ // build-artifact format break. The width is adequate: entries are
217
+ // namespaced per component AND per deploy, so birthday odds need ~2^32
218
+ // entries under ONE component to matter, and design/13 #24 already
219
+ // accepts 64-bit non-crypto FNV-1a for data-cache keys on the same
220
+ // namespacing argument. Only revisit (with a format migration) if the
221
+ // phase-3 runtime tier ever stores attacker-influenced keys more
222
+ // aggressively.
223
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
224
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Build-time capture state — the channel between the post-build capture
3
+ * pass (server/prebuilt-builder.ts) and the `__prebuilt` wrapper
4
+ * (server/prebuilt-runtime.ts).
5
+ *
6
+ * During capture, a wrapped component's subtree may render OTHER wrapped
7
+ * components. With no payload source registered (the capture pass runs
8
+ * before artifacts exist), those inner wrappers render live and their
9
+ * output is baked flat into the outer entry — the nesting clamp of
10
+ * design/45-cache-lifetimes.md §Nesting Semantics (TIM-1136). The capture
11
+ * pass is the designated detection point for that clamp, so the wrapper
12
+ * reports every render that happens while a capture is active:
13
+ *
14
+ * - **Cycle**: the inner wrapper IS the component being captured (directly
15
+ * or transitively — the transitive case still renders the outer's own
16
+ * wrapper, so the identity check catches both). Left alone this recurses
17
+ * until the render times out; instead the wrapper throws
18
+ * `PrebuiltCaptureCycleError`, failing that entry with an error naming
19
+ * the cycle while the build continues (doc 44 skip-and-report policy).
20
+ * - **Nesting**: any other wrapper render is recorded as an (outer, inner)
21
+ * pair. The builder warns after the entry completes when the inner
22
+ * declares a refresh channel the outer cannot honor (doc 45 clamp rule).
23
+ *
24
+ * Captures run strictly sequentially (one entry at a time), so a single
25
+ * module-level slot is sufficient — there is never a second active capture
26
+ * to confuse renders between. At request time `active` is null and
27
+ * `noteWrapperRender` is a single null-check.
28
+ */
29
+
30
+ import type { PrebuiltComponentOptions } from '../prebuilt-runtime.js';
31
+
32
+ /** A wrapped component that rendered inside an active capture. */
33
+ export interface NestedWrapperRender {
34
+ innerId: string;
35
+ options: PrebuiltComponentOptions;
36
+ }
37
+
38
+ /** Thrown by the wrapper when a capture renders its own component. */
39
+ export class PrebuiltCaptureCycleError extends Error {
40
+ constructor(componentId: string) {
41
+ super(
42
+ `[timber] cache.component "${componentId}" renders itself (directly or through ` +
43
+ `other components) — prerendering it would recurse forever. Remove the cycle ` +
44
+ `or drop { prerender: true } on this component.`
45
+ );
46
+ this.name = 'PrebuiltCaptureCycleError';
47
+ }
48
+ }
49
+
50
+ let active: { outerId: string; nested: Map<string, PrebuiltComponentOptions> } | null = null;
51
+
52
+ /**
53
+ * Mark a capture as active. The builder calls this immediately before
54
+ * rendering an entry and MUST pair it with `endCapture()` in a finally.
55
+ */
56
+ export function beginCapture(outerId: string): void {
57
+ active = { outerId, nested: new Map() };
58
+ }
59
+
60
+ /**
61
+ * End the active capture and return the wrapped components that rendered
62
+ * inside it (first options object wins per inner ID).
63
+ */
64
+ export function endCapture(): NestedWrapperRender[] {
65
+ const nested = active?.nested;
66
+ active = null;
67
+ if (!nested) return [];
68
+ return Array.from(nested, ([innerId, options]) => ({ innerId, options }));
69
+ }
70
+
71
+ /** Whether a build-time capture is currently rendering. */
72
+ export function isCaptureActive(): boolean {
73
+ return active !== null;
74
+ }
75
+
76
+ /**
77
+ * Called by the `__prebuilt` wrapper on every render. No-op unless a
78
+ * capture is active. Throws `PrebuiltCaptureCycleError` when the wrapper
79
+ * belongs to the component currently being captured.
80
+ */
81
+ export function noteWrapperRender(innerId: string, options: PrebuiltComponentOptions): void {
82
+ if (active === null) return;
83
+ if (innerId === active.outerId) {
84
+ throw new PrebuiltCaptureCycleError(innerId);
85
+ }
86
+ if (!active.nested.has(innerId)) {
87
+ active.nested.set(innerId, options);
88
+ }
89
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Manifest tag index — maps tags to seed cache keys for tombstone writes.
3
+ *
4
+ * When `revalidateTag(tag)` fires, the framework must write tombstones for
5
+ * every build-seed entry carrying that tag. The manifest records tags per
6
+ * entry; this module inverts the index at startup so the lookup is O(1) per
7
+ * tag instead of a full manifest scan per invalidation.
8
+ *
9
+ * The index is built lazily on first access and cached for the process
10
+ * lifetime — the manifest is immutable within a deploy.
11
+ *
12
+ * See design/45-cache-lifetimes.md §ISR mechanics.
13
+ */
14
+
15
+ import type { PrebuiltManifest } from './payload-source.js';
16
+
17
+ export interface SeedKeyInfo {
18
+ componentId: string;
19
+ cacheKey: string;
20
+ }
21
+
22
+ /**
23
+ * Build the inverted tag → seed key index from the manifest.
24
+ *
25
+ * Returns a Map where each tag maps to the set of (componentId, cacheKey)
26
+ * pairs whose manifest entries carry that tag. Only entries with non-empty
27
+ * `tags` arrays participate.
28
+ */
29
+ export function buildTagIndex(manifest: PrebuiltManifest): Map<string, SeedKeyInfo[]> {
30
+ const index = new Map<string, SeedKeyInfo[]>();
31
+ for (const [componentId, component] of Object.entries(manifest)) {
32
+ if (!component || typeof component !== 'object' || !('entries' in component)) continue;
33
+ for (const [cacheKey, entry] of Object.entries(component.entries)) {
34
+ if (!entry.tags || entry.tags.length === 0) continue;
35
+ for (const tag of entry.tags) {
36
+ let list = index.get(tag);
37
+ if (!list) {
38
+ list = [];
39
+ index.set(tag, list);
40
+ }
41
+ list.push({ componentId, cacheKey });
42
+ }
43
+ }
44
+ }
45
+ return index;
46
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Component cache overlay — runtime CacheHandler layer for ISR.
3
+ *
4
+ * Sits in front of the immutable build artifact (`.flight` files) and
5
+ * stores replacement payloads and tombstones via the configured
6
+ * CacheHandler. Overlay entries are per-deploy namespaced:
7
+ * `timber:c:{deploymentId}:{componentId}:{hash}`
8
+ *
9
+ * The CacheHandler contract is untouched — tombstones and the tag index
10
+ * live above the interface as value conventions. Redis, KV, and custom
11
+ * handlers work unmodified.
12
+ *
13
+ * See design/45-cache-lifetimes.md §ISR mechanics.
14
+ */
15
+
16
+ import { getCacheHandler, isCacheHandlerConfigured } from '../../cache/handler-store.js';
17
+ import { MemoryCacheHandler } from '../../cache/index.js';
18
+ import { getDeploymentId } from '../version-skew.js';
19
+ import { buildTagIndex, type SeedKeyInfo } from './manifest-tag-index.js';
20
+ import type { PrebuiltManifest } from './payload-source.js';
21
+ import { ensureManifestLoaded } from './payload-source.js';
22
+
23
+ /** Default ceiling TTL for tag-only entries (24 hours), in seconds. */
24
+ const CEILING_TTL_SECONDS = 24 * 60 * 60;
25
+
26
+ /** Deploy-long physical TTL for seed-backed overlay records (90 days). */
27
+ const DEPLOY_LONG_TTL_SECONDS = 90 * 24 * 60 * 60;
28
+
29
+ // ─── Overlay record shapes (value conventions above CacheHandler) ────────
30
+
31
+ /**
32
+ * A cached component flight payload stored in the overlay.
33
+ * The `_t` discriminant distinguishes this from a tombstone.
34
+ */
35
+ export interface ComponentOverlayRecord {
36
+ _t: 'co';
37
+ /** Base64-encoded flight payload bytes. */
38
+ p: string;
39
+ /** Logical freshness deadline (Date.now() + ttl * 1000). */
40
+ fu: number;
41
+ /** Whether a build-artifact seed exists for this key. */
42
+ s: boolean;
43
+ }
44
+
45
+ /**
46
+ * A tombstone marking a build seed as invalidated.
47
+ * Written by revalidateTag for seeds carrying the invalidated tag.
48
+ */
49
+ export interface ComponentTombstoneRecord {
50
+ _t: 'ct';
51
+ }
52
+
53
+ type OverlayValue = ComponentOverlayRecord | ComponentTombstoneRecord;
54
+
55
+ export function isOverlayRecord(v: unknown): v is ComponentOverlayRecord {
56
+ return v !== null && typeof v === 'object' && (v as Record<string, unknown>)._t === 'co';
57
+ }
58
+
59
+ export function isTombstone(v: unknown): v is ComponentTombstoneRecord {
60
+ return v !== null && typeof v === 'object' && (v as Record<string, unknown>)._t === 'ct';
61
+ }
62
+
63
+ // ─── Overlay key generation ──────────────────────────────────────────────
64
+
65
+ export function overlayKey(componentId: string, cacheKey: string): string {
66
+ const deploymentId = getDeploymentId() ?? 'dev';
67
+ return `timber:c:${deploymentId}:${componentId}:${cacheKey}`;
68
+ }
69
+
70
+ // ─── Overlay read ────────────────────────────────────────────────────────
71
+
72
+ export interface OverlayLookupResult {
73
+ kind: 'fresh' | 'stale' | 'tombstone';
74
+ /** The overlay value (null for tombstones). */
75
+ record: ComponentOverlayRecord | null;
76
+ /** Raw flight payload bytes (decoded from base64). Null for tombstones. */
77
+ payload: Uint8Array | null;
78
+ }
79
+
80
+ /**
81
+ * Look up an overlay entry in the cache handler.
82
+ * Returns null on a clean miss (no overlay entry at all).
83
+ */
84
+ export async function lookupOverlay(
85
+ componentId: string,
86
+ cacheKey: string
87
+ ): Promise<OverlayLookupResult | null> {
88
+ const handler = getCacheHandler();
89
+ const key = overlayKey(componentId, cacheKey);
90
+ const result = await handler.get(key);
91
+ if (!result) return null;
92
+
93
+ const value = result.value as OverlayValue;
94
+ if (isTombstone(value)) {
95
+ return { kind: 'tombstone', record: null, payload: null };
96
+ }
97
+ if (isOverlayRecord(value)) {
98
+ const logicallyStale = Date.now() > value.fu;
99
+ return {
100
+ kind: logicallyStale ? 'stale' : 'fresh',
101
+ record: value,
102
+ payload: base64ToBytes(value.p),
103
+ };
104
+ }
105
+ // Unknown value shape — treat as miss (forward compatibility).
106
+ return null;
107
+ }
108
+
109
+ // ─── Overlay write ───────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Resolve the effective TTL for a component's runtime lifetime.
113
+ * Tag-only entries (no explicit ttl) get the ceiling TTL (design/45
114
+ * §Tag-only entries carry a ceiling TTL).
115
+ */
116
+ export function effectiveTtl(ttl: number | undefined): number {
117
+ return ttl ?? CEILING_TTL_SECONDS;
118
+ }
119
+
120
+ /**
121
+ * Store a component flight payload in the overlay.
122
+ */
123
+ export async function storeOverlayEntry(
124
+ componentId: string,
125
+ cacheKey: string,
126
+ payloadBytes: Uint8Array,
127
+ opts: {
128
+ ttl?: number;
129
+ tags: string[];
130
+ hasSeed: boolean;
131
+ }
132
+ ): Promise<void> {
133
+ const handler = getCacheHandler();
134
+ const key = overlayKey(componentId, cacheKey);
135
+ const logicalTtl = effectiveTtl(opts.ttl);
136
+ const physicalTtl = opts.hasSeed ? DEPLOY_LONG_TTL_SECONDS : logicalTtl;
137
+
138
+ const record: ComponentOverlayRecord = {
139
+ _t: 'co',
140
+ p: bytesToBase64(payloadBytes),
141
+ fu: Date.now() + logicalTtl * 1000,
142
+ s: opts.hasSeed,
143
+ };
144
+
145
+ await handler.set(key, record, { ttl: physicalTtl, tags: opts.tags });
146
+ }
147
+
148
+ // ─── Tombstone writes ────────────────────────────────────────────────────
149
+
150
+ let tagIndex: Map<string, SeedKeyInfo[]> | null = null;
151
+
152
+ /**
153
+ * Initialize the tag index from the prebuilt manifest. Called once when the
154
+ * manifest is loaded. Without a manifest (no prebuilt components), this is
155
+ * never called and revalidateTag skips the tombstone path entirely.
156
+ */
157
+ export function initTagIndex(manifest: PrebuiltManifest): void {
158
+ tagIndex = buildTagIndex(manifest);
159
+ emitIsrDiagnosticIfNeeded(manifest);
160
+ }
161
+
162
+ /**
163
+ * Get the tag index, if initialized.
164
+ * @internal — exported for testing.
165
+ */
166
+ export function getTagIndex(): Map<string, SeedKeyInfo[]> | null {
167
+ return tagIndex;
168
+ }
169
+
170
+ /**
171
+ * Reset the tag index (for tests).
172
+ * @internal
173
+ */
174
+ export function resetTagIndex(): void {
175
+ tagIndex = null;
176
+ }
177
+
178
+ /**
179
+ * Write tombstones for all seed entries carrying the given tag.
180
+ * Called from cache.invalidate (cache-api.ts) AFTER the handler's own
181
+ * invalidation clears overlay entries.
182
+ *
183
+ * The tombstone prevents the immutable seed from being served after
184
+ * invalidation — without it, a handler.invalidate that clears the overlay
185
+ * would expose the (now-stale) build artifact on the next request.
186
+ *
187
+ * Ensures the manifest is loaded first so cold-process actions that
188
+ * invalidate before any page render still write tombstones.
189
+ */
190
+ export async function writeTombstonesForTag(tag: string): Promise<void> {
191
+ if (!tagIndex) {
192
+ await ensureManifestLoaded();
193
+ }
194
+ if (!tagIndex) return;
195
+ const seeds = tagIndex.get(tag);
196
+ if (!seeds || seeds.length === 0) return;
197
+
198
+ const handler = getCacheHandler();
199
+ const tombstone: ComponentTombstoneRecord = { _t: 'ct' };
200
+
201
+ await Promise.all(
202
+ seeds.map((seed) => {
203
+ const key = overlayKey(seed.componentId, seed.cacheKey);
204
+ return handler.set(key, tombstone, {
205
+ ttl: DEPLOY_LONG_TTL_SECONDS,
206
+ tags: [tag],
207
+ });
208
+ })
209
+ );
210
+ }
211
+
212
+ // ─── ISR build diagnostic ────────────────────────────────────────────────
213
+
214
+ let isrDiagnosticEmitted = false;
215
+
216
+ function emitIsrDiagnosticIfNeeded(manifest: PrebuiltManifest): void {
217
+ if (isrDiagnosticEmitted) return;
218
+
219
+ const handler = getCacheHandler();
220
+ const isMemoryBased = handler instanceof MemoryCacheHandler || !isCacheHandlerConfigured();
221
+ if (!isMemoryBased) return;
222
+
223
+ for (const [, component] of Object.entries(manifest)) {
224
+ if (!component || typeof component !== 'object' || !('entries' in component)) continue;
225
+ for (const [, entry] of Object.entries(component.entries)) {
226
+ if (entry.tags && entry.tags.length > 0) {
227
+ isrDiagnosticEmitted = true;
228
+ console.warn(
229
+ '[timber] tag revalidation of prerendered components requires a shared cache ' +
230
+ 'handler in multi-instance deployments. The current MemoryCacheHandler is ' +
231
+ 'per-process — revalidateTag on one instance will not purge the build seed ' +
232
+ 'on others. Configure a shared handler (Redis, KV) for production ISR.'
233
+ );
234
+ return;
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Reset the diagnostic flag (for tests).
242
+ * @internal
243
+ */
244
+ export function resetIsrDiagnostic(): void {
245
+ isrDiagnosticEmitted = false;
246
+ }
247
+
248
+ // ─── Encoding helpers ────────────────────────────────────────────────────
249
+
250
+ function bytesToBase64(bytes: Uint8Array): string {
251
+ return Buffer.from(bytes).toString('base64');
252
+ }
253
+
254
+ function base64ToBytes(b64: string): Uint8Array {
255
+ return new Uint8Array(Buffer.from(b64, 'base64'));
256
+ }