@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
@@ -0,0 +1,640 @@
1
+ /**
2
+ * Prebuilt payload capture — the post-build half of cache.component
3
+ * (TIM-1119, design/44-render-at-build-time.md §Build Phase).
4
+ *
5
+ * Runs AFTER Vite's 5-step build sequence, INSIDE the built RSC bundle:
6
+ * the component registry, the ALS instances, and the flight renderer are
7
+ * module instances in the bundle's graph, so the capture pass must share
8
+ * that graph — `server/rsc-entry/index.ts` re-exports this module's
9
+ * entry point as `__timber_capturePrebuilt`, and the timber-prebuilt
10
+ * plugin imports the built `rsc/index.js` and calls it.
11
+ *
12
+ * Per candidate route (a page with prebuilt components attributed by the
13
+ * build-time module-graph walk):
14
+ *
15
+ * 1. Load the page module (+ layout-chain modules that declare prebuilt
16
+ * components) — module evaluation registers `__prebuilt` components.
17
+ * 2. Enumerate param combos: `generateStaticSegmentParams()` for dynamic
18
+ * routes, the single empty combo for static routes.
19
+ * 3. Per combo, reconstruct the URL and run the REAL request-time pair —
20
+ * `matchRoute` + `coerceSegmentParams` — so codec-coerced params hash
21
+ * byte-identically to a live request. `computeComponentCacheKey` is
22
+ * the single shared implementation (design/44 §Cache Key).
23
+ * 4. Render each registered component in isolation under a synthetic ALS
24
+ * store (segment params only; cookies/headers/searchParams are hard
25
+ * errors — synthetic-store.ts), fully drain the flight stream, and
26
+ * smoke-revive the captured bytes once before accepting them.
27
+ * 5. Hand each payload to the caller's sink. Persistence (atomic file
28
+ * writes, manifest.json) is the PLUGIN's job on the Node side —
29
+ * keeping node:fs out of the server bundle, which must also run on
30
+ * Cloudflare Workers.
31
+ *
32
+ * No payload source is registered while this runs, so wrappers rendered
33
+ * inside a captured subtree always miss and render live — inner output is
34
+ * baked flat into the outer entry, which is exactly the nesting clamp of
35
+ * design/45 §Nesting Semantics. The capture pass detects and reports the
36
+ * clamp (and fails cyclic entries) via capture-state.ts.
37
+ *
38
+ * Error policy (design/44 §Build-Phase Error Handling):
39
+ * - `BuildTimeContextError` (cookies/headers/searchParams at build time)
40
+ * → HARD error; the caller must fail the build.
41
+ * - Anything else (bad data, deny(), cycles, timeouts, revival failures)
42
+ * → skip that entry, warn, keep building.
43
+ */
44
+
45
+ import { createElement } from 'react';
46
+
47
+ import { requestContextAls } from './als-registry.js';
48
+ import { ParamCoercionError } from './route-element-builder.js';
49
+ import { coerceSegmentParams } from './param-coercion.js';
50
+ import { DenySignal, RedirectSignal } from './primitives.js';
51
+ import {
52
+ beginCapture,
53
+ endCapture,
54
+ PrebuiltCaptureCycleError,
55
+ type NestedWrapperRender,
56
+ } from './prebuilt/capture-state.js';
57
+ import { computeComponentCacheKey } from './prebuilt/cache-key.js';
58
+ import { BuildTimeContextError, createBuildTimeStore } from './prebuilt/synthetic-store.js';
59
+ import {
60
+ getPrebuiltRegistration,
61
+ type PrebuiltComponentOptions,
62
+ type PrebuiltRegistration,
63
+ } from './prebuilt-runtime.js';
64
+ import { createRouteMatcher } from './route-matcher.js';
65
+ import type { ManifestFile, ManifestRoot, ManifestSegmentNode } from './route-matcher.js';
66
+ import { createFromReadableStream, renderToReadableStream } from '../rsc-runtime/rsc.js';
67
+ import { loadModule } from './safe-load.js';
68
+ import { hasDynamicSegments, resolvePathWithParams } from './sitemap-generator.js';
69
+
70
+ // ─── Types ────────────────────────────────────────────────────────────────
71
+
72
+ /** One captured flight payload, ready for the caller's persistence sink. */
73
+ export interface CapturedEntry {
74
+ componentId: string;
75
+ /** `computeComponentCacheKey(callSiteProps, coercedParams)` — names the .flight file. */
76
+ cacheKey: string;
77
+ /** The raw param combo (JSON-safe) — manifest metadata, not the hash input. */
78
+ params: Record<string, string | string[]>;
79
+ /** Call-site props used for this entry (empty object for zero-prop usages). */
80
+ props?: Record<string, unknown>;
81
+ bytes: Uint8Array;
82
+ /** Resolved tags from callsite options — static or function-form, evaluated at capture time. */
83
+ tags?: string[];
84
+ }
85
+
86
+ /** A per-entry problem — either a skip (build continues) or a hard error. */
87
+ export interface CaptureIssue {
88
+ componentId: string;
89
+ routeUrlPath: string;
90
+ params: Record<string, string | string[]>;
91
+ message: string;
92
+ }
93
+
94
+ export interface CaptureSummary {
95
+ /** Entries captured, smoke-revived, and accepted by the sink. */
96
+ captured: number;
97
+ /** Skipped entries — reported, build continues (design/44 policy). */
98
+ skipped: CaptureIssue[];
99
+ /** Dynamic-context violations — the caller MUST fail the build. */
100
+ hardErrors: CaptureIssue[];
101
+ /** Nesting-clamp warnings emitted (design/45 §Nesting Semantics). */
102
+ nestingWarnings: number;
103
+ }
104
+
105
+ export interface CaptureOptions {
106
+ /**
107
+ * Route file path (page or layout, absolute) → prebuilt component IDs
108
+ * transitively reachable from it. Computed by the timber-prebuilt
109
+ * plugin's RSC module-graph walk at generateBundle time.
110
+ */
111
+ fileComponentMap: Record<string, string[]>;
112
+ /**
113
+ * Component ID → array of distinct static call-site prop objects captured
114
+ * from JSX usage sites (design/44 §Props Derivation). Each entry renders
115
+ * one payload per (props, params) combination. Components with no captured
116
+ * props (zero-prop usages) render with `{}` — the pre-TIM-1142 behavior.
117
+ */
118
+ callSiteProps?: Record<string, Record<string, unknown>[]>;
119
+ /**
120
+ * Persistence sink, called once per accepted payload. Throws propagate
121
+ * and abort the capture — a failed artifact write is an infrastructure
122
+ * failure, not a per-entry data problem.
123
+ */
124
+ onEntry: (entry: CapturedEntry) => Promise<void>;
125
+ /**
126
+ * Per-entry ceiling on render + drain. A component that hangs (stalled
127
+ * fetch, unbounded recursion the cycle guard can't see) fails its entry
128
+ * instead of hanging the whole build — the A2 stream-read rule from
129
+ * design/13-security.md applied to the build process.
130
+ */
131
+ renderTimeoutMs?: number;
132
+ /** Progress/warning logger. Defaults to console.warn. */
133
+ log?: (message: string) => void;
134
+ }
135
+
136
+ const DEFAULT_RENDER_TIMEOUT_MS = 30_000;
137
+
138
+ // ─── Route collection ─────────────────────────────────────────────────────
139
+
140
+ interface CandidateRoute {
141
+ urlPath: string;
142
+ page: ManifestFile;
143
+ /** Layout files along the chain that declare prebuilt components. */
144
+ layoutsToLoad: ManifestFile[];
145
+ /** Component IDs attributed to this route's page + layout chain. */
146
+ componentIds: string[];
147
+ }
148
+
149
+ /**
150
+ * Walk the manifest tree and collect pages whose file chain (page +
151
+ * layouts above it) has prebuilt components attributed to it. Slots and
152
+ * intercepting/private segments are not walked — slot-scoped prebuilt is
153
+ * already unkeyable at request time when slot params diverge (see
154
+ * resolveKeyParams in prebuilt-runtime.ts), so the build doesn't model
155
+ * slots either.
156
+ */
157
+ function collectCandidateRoutes(
158
+ root: ManifestSegmentNode,
159
+ fileComponentMap: Record<string, string[]>
160
+ ): CandidateRoute[] {
161
+ const routes: CandidateRoute[] = [];
162
+
163
+ const walk = (node: ManifestSegmentNode, layoutChain: ManifestFile[]) => {
164
+ if (node.segmentType === 'intercepting' || node.segmentType === 'private') return;
165
+ const chain = node.layout ? [...layoutChain, node.layout] : layoutChain;
166
+
167
+ if (node.page) {
168
+ const componentIds = new Set<string>();
169
+ for (const file of [...chain, node.page]) {
170
+ for (const id of fileComponentMap[file.filePath] ?? []) componentIds.add(id);
171
+ }
172
+ if (componentIds.size > 0) {
173
+ routes.push({
174
+ urlPath: node.urlPath,
175
+ page: node.page,
176
+ layoutsToLoad: chain.filter((l) => (fileComponentMap[l.filePath] ?? []).length > 0),
177
+ componentIds: Array.from(componentIds),
178
+ });
179
+ }
180
+ }
181
+
182
+ for (const child of node.children) walk(child, chain);
183
+ };
184
+
185
+ walk(root, []);
186
+ return routes;
187
+ }
188
+
189
+ // ─── Param enumeration ────────────────────────────────────────────────────
190
+
191
+ type ParamCombo = Record<string, string | string[]>;
192
+
193
+ /**
194
+ * Resolve the param combos to capture for a route. Static routes have
195
+ * exactly one combo (the empty record) — `export const prerender`-style
196
+ * components on static pages need no enumerator. Dynamic routes require
197
+ * `generateStaticSegmentParams` (design/44 §generateStaticSegmentParams);
198
+ * without it every request renders live and the wrapper is a no-op.
199
+ */
200
+ async function enumerateCombos(
201
+ route: CandidateRoute,
202
+ pageModule: Record<string, unknown>,
203
+ log: (message: string) => void
204
+ ): Promise<ParamCombo[] | null> {
205
+ if (!hasDynamicSegments(route.urlPath)) return [{}];
206
+
207
+ let generateFn = pageModule.generateStaticSegmentParams;
208
+ if (typeof generateFn !== 'function' && typeof pageModule.generateStaticParams === 'function') {
209
+ generateFn = pageModule.generateStaticParams;
210
+ log(
211
+ `[timber] ${route.page.filePath}: generateStaticParams is deprecated — rename to generateStaticSegmentParams`
212
+ );
213
+ }
214
+ if (typeof generateFn !== 'function') {
215
+ log(
216
+ `[timber] cache.component: route ${route.urlPath} has prebuilt components but no ` +
217
+ `generateStaticSegmentParams export — all its entries will render dynamically ` +
218
+ `until the page declares its param combos`
219
+ );
220
+ return null;
221
+ }
222
+ const combos: unknown = await (generateFn as () => unknown)();
223
+ if (!Array.isArray(combos)) {
224
+ throw new Error(
225
+ `generateStaticSegmentParams must return an array of param records, got ${typeof combos}`
226
+ );
227
+ }
228
+ return combos as ParamCombo[];
229
+ }
230
+
231
+ // ─── Rendering ────────────────────────────────────────────────────────────
232
+
233
+ /** Concatenate drained chunks without Buffer (the bundle also runs on Workers). */
234
+ function concatChunks(chunks: Uint8Array[]): Uint8Array {
235
+ let total = 0;
236
+ for (const chunk of chunks) total += chunk.byteLength;
237
+ const out = new Uint8Array(total);
238
+ let offset = 0;
239
+ for (const chunk of chunks) {
240
+ out.set(chunk, offset);
241
+ offset += chunk.byteLength;
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /**
247
+ * Drain a flight stream to completion under a deadline. On timeout the
248
+ * reader is cancelled (never abandoned mid-read — design/13 A2/A3) and
249
+ * the entry fails.
250
+ */
251
+ async function drainWithTimeout(
252
+ stream: ReadableStream<Uint8Array>,
253
+ timeoutMs: number
254
+ ): Promise<Uint8Array> {
255
+ const reader = stream.getReader();
256
+ const chunks: Uint8Array[] = [];
257
+ let timer: ReturnType<typeof setTimeout> | undefined;
258
+ let timedOut = false;
259
+ const deadline = new Promise<never>((_, reject) => {
260
+ timer = setTimeout(() => {
261
+ timedOut = true;
262
+ reject(new Error(`render did not complete within ${timeoutMs}ms`));
263
+ }, timeoutMs);
264
+ });
265
+ try {
266
+ for (;;) {
267
+ const { done, value } = await Promise.race([reader.read(), deadline]);
268
+ if (done) break;
269
+ chunks.push(value as Uint8Array);
270
+ }
271
+ } finally {
272
+ clearTimeout(timer);
273
+ if (timedOut) await reader.cancel().catch(() => {});
274
+ }
275
+ return concatChunks(chunks);
276
+ }
277
+
278
+ /** Wrap payload bytes as a single-chunk stream for the smoke revival. */
279
+ function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
280
+ return new ReadableStream<Uint8Array>({
281
+ start(controller) {
282
+ controller.enqueue(bytes);
283
+ controller.close();
284
+ },
285
+ });
286
+ }
287
+
288
+ /**
289
+ * View of a registered component that satisfies createElement — same
290
+ * narrowing rationale as prebuilt-runtime.ts.
291
+ */
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ type RenderableComponent = (props: any) => React.ReactNode;
294
+
295
+ interface EntryOutcome {
296
+ bytes: Uint8Array | null;
297
+ failure: unknown;
298
+ nested: NestedWrapperRender[];
299
+ resolvedTags: string[] | undefined;
300
+ }
301
+
302
+ /**
303
+ * Render one (component, props, coerced-params) entry in isolation and drain
304
+ * its flight payload. Any `onError` during serialization fails the entry —
305
+ * a payload with an embedded error row would replay that error on every
306
+ * future request, so only fully clean renders are captured.
307
+ */
308
+ async function renderEntry(
309
+ registration: PrebuiltRegistration,
310
+ props: Record<string, unknown>,
311
+ coercedParams: Record<string, string | string[]>,
312
+ timeoutMs: number
313
+ ): Promise<EntryOutcome> {
314
+ const { store, violations } = createBuildTimeStore(registration.id, coercedParams);
315
+ let failure: unknown = null;
316
+
317
+ beginCapture(registration.id);
318
+ let bytes: Uint8Array | null = null;
319
+ let resolvedTags: string[] | undefined;
320
+ let nested: NestedWrapperRender[];
321
+ try {
322
+ // Render AND drain inside one ALS scope: flight executes components
323
+ // lazily as the stream is pulled, and each pull must still see the
324
+ // synthetic store (segmentParams.get() inside the component).
325
+ // Tag resolution also runs inside this scope so function-form tags
326
+ // that read segment params see the synthetic store (codex P2 on PR #858).
327
+ bytes = await requestContextAls.run(store, async () => {
328
+ const stream = renderToReadableStream(
329
+ createElement(registration.component as RenderableComponent, props),
330
+ {
331
+ onError(error: unknown) {
332
+ failure ??= error ?? new Error('unknown render error');
333
+ // Digest string keeps flight from warning about a missing
334
+ // digest; the payload is discarded because `failure` is set.
335
+ return 'timber-prebuilt-capture-error';
336
+ },
337
+ }
338
+ );
339
+ const drained = await drainWithTimeout(stream, timeoutMs);
340
+ resolvedTags = resolveCapturedTags(registration.options, props);
341
+ return drained;
342
+ });
343
+ } catch (error) {
344
+ // Sync component throw (renderToReadableStream creation), drain
345
+ // timeout, or stream error.
346
+ failure ??= error;
347
+ } finally {
348
+ nested = endCapture();
349
+ }
350
+
351
+ // Side-channel check FIRST: a component that touched per-request context
352
+ // is a hard error even when it caught the throw and rendered a fallback —
353
+ // accepting that payload would bake the request-dependent branch for
354
+ // every future request (codex P1 on PR #836).
355
+ if (violations.length > 0) return { bytes: null, failure: violations[0], nested, resolvedTags };
356
+ if (failure !== null) return { bytes: null, failure, nested, resolvedTags };
357
+ return { bytes, failure: null, nested, resolvedTags };
358
+ }
359
+
360
+ /** Human-readable classification for a failed entry. */
361
+ function describeFailure(error: unknown): string {
362
+ if (error instanceof DenySignal) return `component called deny(${error.status})`;
363
+ if (error instanceof RedirectSignal) return `component called redirect("${error.location}")`;
364
+ if (error instanceof Error) return error.message;
365
+ return String(error);
366
+ }
367
+
368
+ // ─── Nesting clamp warnings (design/45 §Nesting Semantics) ────────────────
369
+
370
+ /** Render a lifetime declaration for the clamp warning. */
371
+ function describeLifetime(options: PrebuiltComponentOptions): string {
372
+ const parts: string[] = [];
373
+ if (options.prerender) parts.push('prerender');
374
+ if (options.ttl !== undefined) parts.push(`ttl: ${options.ttl}`);
375
+ if (options.tags !== undefined) {
376
+ parts.push(
377
+ typeof options.tags === 'function' ? 'tags: fn' : `tags: ${JSON.stringify(options.tags)}`
378
+ );
379
+ }
380
+ return parts.join(', ') || 'no lifetime';
381
+ }
382
+
383
+ /**
384
+ * Warn when an inner wrapped component declares a refresh channel the
385
+ * enclosing prerendered entry cannot honor. The outer here is always
386
+ * prerender-tier (that's all the build captures), whose effective
387
+ * lifetime is deploy-long with no tags — so ANY inner ttl/tags warns.
388
+ * Once per (outer, inner) pair per build.
389
+ */
390
+ function warnNestingClamps(
391
+ outerId: string,
392
+ nested: NestedWrapperRender[],
393
+ warnedPairs: Set<string>,
394
+ log: (message: string) => void
395
+ ): number {
396
+ let count = 0;
397
+ for (const { innerId, options } of nested) {
398
+ if (options.ttl === undefined && options.tags === undefined) continue;
399
+ const pairKey = `${outerId}${innerId}`;
400
+ if (warnedPairs.has(pairKey)) continue;
401
+ warnedPairs.add(pairKey);
402
+ count++;
403
+ log(
404
+ `[timber] ${innerId} (${describeLifetime(options)}) is embedded in prerendered ` +
405
+ `${outerId} — the embedded copy refreshes only when ${outerId} does (on redeploy). ` +
406
+ `Runtime revalidation will NOT update the embedded copy; tag ${outerId} itself if ` +
407
+ `you need that. Chain: ${outerId} (prerender) → ${innerId} (${describeLifetime(options)})`
408
+ );
409
+ }
410
+ return count;
411
+ }
412
+
413
+ // ─── Capture orchestration ────────────────────────────────────────────────
414
+
415
+ /** Resolve tags from callsite options — static arrays pass through, function tags are evaluated with the capture-time props. */
416
+ function resolveCapturedTags(
417
+ options: PrebuiltComponentOptions,
418
+ props: Record<string, unknown>
419
+ ): string[] | undefined {
420
+ if (!options.tags) return undefined;
421
+ const resolved = typeof options.tags === 'function' ? options.tags(props) : options.tags;
422
+ return resolved.length > 0 ? resolved : undefined;
423
+ }
424
+
425
+ /**
426
+ * Capture flight payloads for every attributed, registered, prerender-tier
427
+ * component across the route manifest. See module docblock for the flow.
428
+ */
429
+ export async function capturePrebuiltPayloads(
430
+ manifest: ManifestRoot,
431
+ options: CaptureOptions
432
+ ): Promise<CaptureSummary> {
433
+ const log = options.log ?? ((message: string) => console.warn(message));
434
+ const timeoutMs = options.renderTimeoutMs ?? DEFAULT_RENDER_TIMEOUT_MS;
435
+ const matchRoute = createRouteMatcher(manifest);
436
+
437
+ const summary: CaptureSummary = { captured: 0, skipped: [], hardErrors: [], nestingWarnings: 0 };
438
+ const seenEntries = new Set<string>();
439
+ const warnedPairs = new Set<string>();
440
+ const reportedMissingRegistrations = new Set<string>();
441
+
442
+ const skip = (issue: CaptureIssue) => {
443
+ summary.skipped.push(issue);
444
+ log(
445
+ `[timber] cache.component skipped "${issue.componentId}" for ${issue.routeUrlPath} ` +
446
+ `${JSON.stringify(issue.params)} — ${issue.message}`
447
+ );
448
+ };
449
+
450
+ for (const route of collectCandidateRoutes(manifest.root, options.fileComponentMap)) {
451
+ // Load the page + prebuilt-declaring layout modules. Evaluation is what
452
+ // registers module-scope __prebuilt components (design/44 §Registration).
453
+ let pageModule: Record<string, unknown>;
454
+ try {
455
+ pageModule = await loadModule(route.page);
456
+ for (const layout of route.layoutsToLoad) await loadModule(layout);
457
+ } catch (error) {
458
+ for (const componentId of route.componentIds) {
459
+ skip({
460
+ componentId,
461
+ routeUrlPath: route.urlPath,
462
+ params: {},
463
+ message: describeFailure(error),
464
+ });
465
+ }
466
+ continue;
467
+ }
468
+
469
+ let combos: ParamCombo[] | null;
470
+ try {
471
+ combos = await enumerateCombos(route, pageModule, log);
472
+ } catch (error) {
473
+ for (const componentId of route.componentIds) {
474
+ skip({
475
+ componentId,
476
+ routeUrlPath: route.urlPath,
477
+ params: {},
478
+ message: describeFailure(error),
479
+ });
480
+ }
481
+ continue;
482
+ }
483
+ if (combos === null) continue;
484
+
485
+ for (const combo of combos) {
486
+ if (combo === null || typeof combo !== 'object' || Array.isArray(combo)) {
487
+ skip({
488
+ componentId: route.componentIds[0],
489
+ routeUrlPath: route.urlPath,
490
+ params: {},
491
+ message: `generateStaticSegmentParams returned a non-object combo: ${JSON.stringify(combo)}`,
492
+ });
493
+ continue;
494
+ }
495
+ // Reconstruct the URL and run the request-time matcher + coercion so
496
+ // the params record — and therefore the cache key — is byte-identical
497
+ // to what a live request for this combo produces.
498
+ const path = resolvePathWithParams(route.urlPath, combo);
499
+ if (path === null) {
500
+ skip({
501
+ componentId: route.componentIds[0],
502
+ routeUrlPath: route.urlPath,
503
+ params: combo,
504
+ message: 'param combo is missing a required dynamic segment value',
505
+ });
506
+ continue;
507
+ }
508
+ const match = matchRoute(path);
509
+ if (
510
+ !match ||
511
+ match.segments[match.segments.length - 1]?.page?.filePath !== route.page.filePath
512
+ ) {
513
+ skip({
514
+ componentId: route.componentIds[0],
515
+ routeUrlPath: route.urlPath,
516
+ params: combo,
517
+ message: `resolved URL "${path}" does not match this route (another route shadows it)`,
518
+ });
519
+ continue;
520
+ }
521
+ try {
522
+ await coerceSegmentParams(match);
523
+ } catch (error) {
524
+ skip({
525
+ componentId: route.componentIds[0],
526
+ routeUrlPath: route.urlPath,
527
+ params: combo,
528
+ message:
529
+ error instanceof ParamCoercionError
530
+ ? `param coercion rejected the combo: ${error.message}`
531
+ : describeFailure(error),
532
+ });
533
+ continue;
534
+ }
535
+ const coercedParams = match.segmentParams;
536
+
537
+ for (const componentId of route.componentIds) {
538
+ const registration = getPrebuiltRegistration(componentId);
539
+ if (!registration) {
540
+ if (!reportedMissingRegistrations.has(componentId)) {
541
+ reportedMissingRegistrations.add(componentId);
542
+ skip({
543
+ componentId,
544
+ routeUrlPath: route.urlPath,
545
+ params: combo,
546
+ message:
547
+ 'attributed to this route but never registered at module load — the ' +
548
+ 'declaring module is probably loaded via dynamic import; it will render live',
549
+ });
550
+ }
551
+ continue;
552
+ }
553
+ // Runtime tier (ttl/tags without prerender) does no build work.
554
+ if (registration.options.prerender !== true) continue;
555
+
556
+ // One entry per distinct call-site props object (design/44 §Props
557
+ // Derivation, TIM-1142). When no call-site props were captured,
558
+ // render with empty props (the pre-TIM-1142 behavior). When
559
+ // captured props exist, render ONLY those — a spurious {} render
560
+ // on a component that always receives props could hit a throw
561
+ // path (e.g., required prop access) and fail the build for a
562
+ // shape no JSX call site actually uses.
563
+ const capturedProps = options.callSiteProps?.[componentId];
564
+ const propsSets: Record<string, unknown>[] =
565
+ capturedProps && capturedProps.length > 0 ? [...capturedProps] : [{}];
566
+
567
+ for (const callSiteProps of propsSets) {
568
+ const cacheKey = computeComponentCacheKey(callSiteProps, coercedParams);
569
+ if (cacheKey === null) {
570
+ skip({
571
+ componentId,
572
+ routeUrlPath: route.urlPath,
573
+ params: combo,
574
+ message:
575
+ 'coerced segment params are unkeyable (see design/44 §Cache Key) — ' +
576
+ 'requests will always render live',
577
+ });
578
+ continue;
579
+ }
580
+ const entryKey = `${componentId}${cacheKey}`;
581
+ if (seenEntries.has(entryKey)) continue;
582
+ seenEntries.add(entryKey);
583
+
584
+ const outcome = await renderEntry(registration, callSiteProps, coercedParams, timeoutMs);
585
+ summary.nestingWarnings += warnNestingClamps(
586
+ componentId,
587
+ outcome.nested,
588
+ warnedPairs,
589
+ log
590
+ );
591
+
592
+ if (outcome.failure !== null) {
593
+ const issue: CaptureIssue = {
594
+ componentId,
595
+ routeUrlPath: route.urlPath,
596
+ params: combo,
597
+ message: describeFailure(outcome.failure),
598
+ };
599
+ if (outcome.failure instanceof BuildTimeContextError) {
600
+ summary.hardErrors.push(issue);
601
+ log(`[timber] BUILD ERROR: ${issue.message}`);
602
+ } else {
603
+ skip(issue);
604
+ }
605
+ continue;
606
+ }
607
+
608
+ // Write-time integrity: revive the captured bytes once, exactly the
609
+ // way the request path will, before accepting them. Corrupt or
610
+ // truncated captures fail HERE, at build time — the request path
611
+ // trusts artifacts the way it trusts JS chunks in the same deploy.
612
+ try {
613
+ await createFromReadableStream(bytesToStream(outcome.bytes!));
614
+ } catch (error) {
615
+ skip({
616
+ componentId,
617
+ routeUrlPath: route.urlPath,
618
+ params: combo,
619
+ message: `captured payload failed smoke revival: ${describeFailure(error)}`,
620
+ });
621
+ continue;
622
+ }
623
+
624
+ const hasProps = Object.keys(callSiteProps).length > 0;
625
+ await options.onEntry({
626
+ componentId,
627
+ cacheKey,
628
+ params: combo,
629
+ ...(hasProps ? { props: callSiteProps } : {}),
630
+ bytes: outcome.bytes!,
631
+ tags: outcome.resolvedTags,
632
+ });
633
+ summary.captured++;
634
+ }
635
+ }
636
+ }
637
+ }
638
+
639
+ return summary;
640
+ }