@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,329 @@
1
+ /**
2
+ * timber-prerender-sugar — Vite plugin that desugars `export const prerender`
3
+ * on page and layout files into `cache.component(...)` wrappers.
4
+ *
5
+ * Design doc: design/45-cache-lifetimes.md §"Route-Level Static" and
6
+ * §"Layout Propagation".
7
+ *
8
+ * `export const prerender = true` on a page is pure sugar: it desugars to
9
+ * wrapping the page's default export in `timber.cache.component(Component,
10
+ * { prerender: true })`. Layout propagation cascades a layout's
11
+ * `export const prerender` to descendant pages via most-specific-wins:
12
+ * walk the segment tree from leaf to root, first explicit value wins.
13
+ *
14
+ * A layout that declares `export const prerender` is ALSO wrapped — the
15
+ * layout's flight payload is its own static shell (`children` is a
16
+ * separate component boundary), so caching it is natural (nav, sidebar,
17
+ * footer benefit from build-time rendering). See design/45 §Layout
18
+ * Propagation.
19
+ *
20
+ * This transform runs BEFORE timber-prebuilt so the generated
21
+ * `cache.component(...)` call is picked up by the callsite rewrite.
22
+ */
23
+
24
+ import MagicString from 'magic-string';
25
+ import { parseAst } from 'vite';
26
+ import type { Plugin } from 'vite';
27
+ import type { PluginContext } from '../plugin-context.js';
28
+ import type { SegmentNode, RouteFile } from '../routing/types.js';
29
+ import { getPrerenderExport, type PrerenderExportValue } from '../routing/export-detect.js';
30
+
31
+ interface AstNode {
32
+ type: string;
33
+ start: number;
34
+ end: number;
35
+ name?: string;
36
+ declaration?: AstNode;
37
+ id?: AstNode & { name?: string };
38
+ expression?: AstNode;
39
+ }
40
+
41
+ /**
42
+ * Walk the segment tree and resolve the effective prerender value for
43
+ * every page and layout. Returns a map from file path to resolved value.
44
+ *
45
+ * **Pages:** walk from leaf toward root; first explicit `export const prerender`
46
+ * wins (page > nearest layout > ... > root layout). If none, dynamic.
47
+ *
48
+ * **Layouts:** a layout that declares `export const prerender` is wrapped
49
+ * directly (its own shell is a separate component boundary from `children`).
50
+ * Layouts without their own declaration are NOT wrapped — they don't
51
+ * inherit from ancestors, because wrapping a layout's shell without the
52
+ * author's explicit opt-in would cache content they may expect to be dynamic.
53
+ */
54
+ export function resolvePagePrerender(root: SegmentNode): Map<string, PrerenderExportValue> {
55
+ const result = new Map<string, PrerenderExportValue>();
56
+
57
+ function walk(node: SegmentNode, ancestorStack: PrerenderExportValue[]) {
58
+ if (node.segmentType === 'intercepting' || node.segmentType === 'private') return;
59
+
60
+ // Check this segment's layout for a prerender export
61
+ const layoutValue = node.layout ? getPrerenderExport(node.layout.filePath) : undefined;
62
+ const stack = layoutValue !== undefined ? [layoutValue, ...ancestorStack] : ancestorStack;
63
+
64
+ // Wrap the layout itself if it declares prerender (truthy)
65
+ if (node.layout && layoutValue !== undefined && layoutValue !== false) {
66
+ result.set(node.layout.filePath, layoutValue);
67
+ }
68
+
69
+ if (node.page) {
70
+ const pageValue = getPrerenderExport(node.page.filePath);
71
+ // Page's own value takes priority; otherwise use nearest ancestor
72
+ const resolved = pageValue !== undefined ? pageValue : stack[0];
73
+ // Only record if the resolved value is truthy (prerender: true or options object)
74
+ // prerender: false explicitly opts out
75
+ if (resolved !== undefined && resolved !== false) {
76
+ result.set(node.page.filePath, resolved);
77
+ }
78
+ }
79
+
80
+ for (const child of node.children) walk(child, stack);
81
+ for (const slot of Object.values(node.slots)) walk(slot, stack);
82
+ }
83
+
84
+ walk(root, []);
85
+ return result;
86
+ }
87
+
88
+ /**
89
+ * Build the `cache.component` options source text from a resolved value.
90
+ */
91
+ function buildOptionsSource(value: PrerenderExportValue): string {
92
+ if (value === true) return '{ prerender: true }';
93
+ if (typeof value === 'object') {
94
+ const parts = ['prerender: true'];
95
+ if (value.ttl !== undefined) parts.push(`ttl: ${value.ttl}`);
96
+ if (value.tags !== undefined) parts.push(`tags: ${JSON.stringify(value.tags)}`);
97
+ return `{ ${parts.join(', ')} }`;
98
+ }
99
+ return '{ prerender: true }';
100
+ }
101
+
102
+ /**
103
+ * Find the default export in the AST and return info needed for the rewrite.
104
+ */
105
+ interface DefaultExportInfo {
106
+ kind: 'declaration' | 'expression' | 'identifier';
107
+ node: AstNode;
108
+ name?: string;
109
+ /** For `export { X as default, Y }` — position of the default specifier. */
110
+ specifierIndex?: number;
111
+ specifierCount?: number;
112
+ specifierStart?: number;
113
+ specifierEnd?: number;
114
+ }
115
+
116
+ function findDefaultExport(program: { body: AstNode[] }, source: string): DefaultExportInfo | null {
117
+ for (const stmt of program.body) {
118
+ if (stmt.type !== 'ExportDefaultDeclaration' || !stmt.declaration) continue;
119
+ const decl = stmt.declaration;
120
+
121
+ // export default function Name(...) { ... }
122
+ // export default class Name { ... }
123
+ if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
124
+ const name = decl.id?.name;
125
+ if (name) return { kind: 'declaration', node: stmt, name };
126
+ // Anonymous function/class — treat as expression
127
+ return { kind: 'expression', node: stmt };
128
+ }
129
+
130
+ // export default SomeIdentifier
131
+ if (decl.type === 'Identifier') {
132
+ return { kind: 'identifier', node: stmt, name: decl.name };
133
+ }
134
+
135
+ // export default <expression>
136
+ return { kind: 'expression', node: stmt };
137
+ }
138
+
139
+ // Check for `export { X as default }` (not re-exports from other modules)
140
+ for (const stmt of program.body) {
141
+ if (stmt.type !== 'ExportNamedDeclaration') continue;
142
+ // Skip re-exports: `export { X as default } from './other'`
143
+ if ((stmt as any).source) continue;
144
+ const specifiers = (stmt as any).specifiers as
145
+ | Array<{
146
+ type: string;
147
+ start: number;
148
+ end: number;
149
+ local?: { name: string };
150
+ exported?: { name: string };
151
+ }>
152
+ | undefined;
153
+ if (!specifiers) continue;
154
+ for (let i = 0; i < specifiers.length; i++) {
155
+ const spec = specifiers[i];
156
+ if (spec.exported?.name === 'default' && spec.local?.name && spec.local.name !== 'default') {
157
+ return {
158
+ kind: 'identifier' as const,
159
+ node: stmt,
160
+ name: spec.local.name,
161
+ specifierIndex: i,
162
+ specifierCount: specifiers.length,
163
+ specifierStart: spec.start,
164
+ specifierEnd: spec.end,
165
+ };
166
+ }
167
+ }
168
+ }
169
+
170
+ return null;
171
+ }
172
+
173
+ export function timberPrerenderSugar(ctx: PluginContext): Plugin {
174
+ let resolvedPages: Map<string, PrerenderExportValue> | null = null;
175
+
176
+ function ensureResolved() {
177
+ if (resolvedPages) return;
178
+ if (!ctx.routeTree) {
179
+ resolvedPages = new Map();
180
+ return;
181
+ }
182
+ resolvedPages = resolvePagePrerender(ctx.routeTree.root);
183
+ }
184
+
185
+ return {
186
+ name: 'timber-prerender-sugar',
187
+
188
+ buildStart() {
189
+ // Reset on each build (watch mode rebuilds)
190
+ resolvedPages = null;
191
+ },
192
+
193
+ transform(code, id) {
194
+ // Only process in RSC environment (same gate as prebuilt.ts)
195
+ if (this?.environment?.name === 'client') return;
196
+ // Skip node_modules
197
+ if (id.includes('node_modules')) return;
198
+
199
+ ensureResolved();
200
+ if (!resolvedPages || resolvedPages.size === 0) return;
201
+
202
+ const value = resolvedPages.get(id);
203
+ if (!value) return;
204
+
205
+ // This page has a resolved prerender value — wrap its default export
206
+ let program: { body: AstNode[] };
207
+ try {
208
+ program = parseAst(code, { lang: 'tsx' }) as unknown as { body: AstNode[] };
209
+ } catch {
210
+ return;
211
+ }
212
+
213
+ const defaultExport = findDefaultExport(program, code);
214
+ if (!defaultExport) {
215
+ // Warn if the file has a prerender export but no wrappable default export
216
+ // (e.g. `export { default } from './impl'` — a re-export we can't rewrite).
217
+ if (value) {
218
+ const relPath = id.replace(ctx.root + '/', '');
219
+ console.warn(
220
+ `[timber] ${relPath}: export const prerender is declared but the default ` +
221
+ `export cannot be wrapped (re-export or missing). The prerender declaration ` +
222
+ `will be ignored for this file.`
223
+ );
224
+ }
225
+ return;
226
+ }
227
+
228
+ // Skip if the default export is already a cache.component(...) call —
229
+ // avoid double-wrapping. Only check the default export itself, not the
230
+ // whole file (a page may use cache.component for child components).
231
+ if (defaultExport.kind === 'expression' && defaultExport.node.declaration) {
232
+ const declText = code.slice(
233
+ defaultExport.node.declaration.start,
234
+ defaultExport.node.declaration.end
235
+ );
236
+ if (declText.includes('.component(')) return;
237
+ }
238
+
239
+ // Record this page as desugared for downstream attribution
240
+ ctx.prerenderSugarPages ??= new Set();
241
+ ctx.prerenderSugarPages.add(id);
242
+
243
+ const s = new MagicString(code);
244
+ const optionsSource = buildOptionsSource(value);
245
+
246
+ // Add the cache import at the top (after directives)
247
+ const importAlias = '__timber_prerender_cache__';
248
+ let insertOffset = 0;
249
+ for (const stmt of program.body) {
250
+ const isDirective =
251
+ stmt.type === 'ExpressionStatement' && typeof (stmt as any).directive === 'string';
252
+ if (!isDirective) {
253
+ insertOffset = stmt.start;
254
+ break;
255
+ }
256
+ }
257
+ s.appendLeft(
258
+ insertOffset,
259
+ `import { cache as ${importAlias} } from '@timber-js/app/cache';\n`
260
+ );
261
+
262
+ switch (defaultExport.kind) {
263
+ case 'declaration': {
264
+ // `export default function Name() { ... }`
265
+ // → `function Name() { ... }` + `export default cache.component(Name, opts)`
266
+ const declStart = defaultExport.node.declaration!.start;
267
+ // Remove `export default ` prefix
268
+ s.overwrite(defaultExport.node.start, declStart, '');
269
+ // Append the wrapped export at the end
270
+ s.append(
271
+ `\nexport default ${importAlias}.component(${defaultExport.name}, ${optionsSource});\n`
272
+ );
273
+ break;
274
+ }
275
+ case 'identifier': {
276
+ if (defaultExport.specifierCount !== undefined && defaultExport.specifierCount > 1) {
277
+ // Multi-specifier: `export { Page as default, generateStaticSegmentParams }`
278
+ // Remove only the default specifier + surrounding comma, keep others.
279
+ const specStart = defaultExport.specifierStart!;
280
+ const specEnd = defaultExport.specifierEnd!;
281
+ const idx = defaultExport.specifierIndex!;
282
+ // Remove the specifier and a comma (trailing if first, leading otherwise)
283
+ if (idx === 0) {
284
+ // First specifier: remove text up to (but not including) the next specifier start
285
+ const nextSpec = (defaultExport.node as any).specifiers?.[1];
286
+ if (nextSpec) s.remove(specStart, nextSpec.start);
287
+ } else {
288
+ // Not first: remove from end of previous specifier to this specifier's end
289
+ const prevSpec = (defaultExport.node as any).specifiers?.[idx - 1];
290
+ if (prevSpec) s.remove(prevSpec.end, specEnd);
291
+ }
292
+ // Append the wrapped default export separately
293
+ s.appendLeft(
294
+ defaultExport.node.end,
295
+ `\nexport default ${importAlias}.component(${defaultExport.name}, ${optionsSource});`
296
+ );
297
+ } else {
298
+ // `export default MyPage` or `export { MyPage as default }` (sole specifier)
299
+ // → `export default cache.component(MyPage, opts)`
300
+ s.overwrite(
301
+ defaultExport.node.start,
302
+ defaultExport.node.end,
303
+ `export default ${importAlias}.component(${defaultExport.name}, ${optionsSource});`
304
+ );
305
+ }
306
+ break;
307
+ }
308
+ case 'expression': {
309
+ // `export default () => <div/>` or `export default async function() { ... }`
310
+ // → `const __default = ...; export default cache.component(__default, opts)`
311
+ const decl = defaultExport.node.declaration!;
312
+ s.overwrite(defaultExport.node.start, decl.start, 'const __timber_default_page__ = ');
313
+ // Ensure trailing semicolon
314
+ const afterExpr = code.charAt(defaultExport.node.end - 1) === ';' ? '' : ';';
315
+ s.appendLeft(
316
+ defaultExport.node.end,
317
+ `${afterExpr}\nexport default ${importAlias}.component(__timber_default_page__, ${optionsSource});`
318
+ );
319
+ break;
320
+ }
321
+ }
322
+
323
+ return {
324
+ code: s.toString(),
325
+ map: s.generateMap({ hires: 'boundary' }),
326
+ };
327
+ },
328
+ };
329
+ }
@@ -36,8 +36,18 @@ const NODE_BUILTINS: string[] = builtinModules.flatMap((m) => [m, `node:${m}`]);
36
36
  // in a server bundle.
37
37
  //
38
38
  // Mirrored on both rsc and ssr environments. See TIM-988.
39
+ // Cloudflare runtime modules (cloudflare:workers, cloudflare:sockets, etc.)
40
+ // available at runtime on Workers but must not be bundled.
41
+ const CLOUDFLARE_BUILTINS = [
42
+ 'cloudflare:workers',
43
+ 'cloudflare:sockets',
44
+ 'cloudflare:email',
45
+ 'cloudflare:workflows',
46
+ ];
47
+
39
48
  const BUILD_ONLY_EXTERNALS: string[] = [
40
49
  ...NODE_BUILTINS,
50
+ ...CLOUDFLARE_BUILTINS,
41
51
  'vite',
42
52
  'nitro',
43
53
  'rollup',
@@ -114,10 +124,12 @@ export function timberServerBundle(): Plugin[] {
114
124
  environments: {
115
125
  rsc: {
116
126
  resolve: { noExternal: true, external: BUILD_ONLY_EXTERNALS },
127
+ build: { rolldownOptions: { external: [/^cloudflare:/] } },
117
128
  define: serverDefine,
118
129
  },
119
130
  ssr: {
120
131
  resolve: { noExternal: true, external: BUILD_ONLY_EXTERNALS },
132
+ build: { rolldownOptions: { external: [/^cloudflare:/] } },
121
133
  define: serverDefine,
122
134
  },
123
135
  },
@@ -57,6 +57,15 @@ export const SUBPATH_SRC_MAP: Record<string, string> = {
57
57
  'search-params': 'search-params/index.ts',
58
58
  'segment-params': 'segment-params/index.ts',
59
59
 
60
+ 'cache/stores/memory': 'cache/stores/memory.ts',
61
+ 'cache/stores/redis': 'cache/stores/redis.ts',
62
+ 'cache/stores/cloudflare-kv': 'cache/stores/cloudflare-kv.ts',
63
+ 'cache/stores/vercel': 'cache/stores/vercel.ts',
64
+
65
+ 'cdn': 'cdn/index.ts',
66
+ 'cdn/cloudflare': 'cdn/cloudflare-purge.ts',
67
+ 'cdn/fastly': 'cdn/fastly-purge.ts',
68
+
60
69
  'adapters/cloudflare': 'adapters/cloudflare.ts',
61
70
  'adapters/cloudflare/cache': 'adapters/cloudflare-kv-cache.ts',
62
71
  'adapters/cloudflare/dev': 'adapters/cloudflare-dev.ts',
@@ -95,7 +95,6 @@ export function timberStaticBuild(ctx: PluginContext): Plugin {
95
95
  *
96
96
  * In static mode, we check every app/ file for:
97
97
  * - Dynamic API usage (getCookies(), getHeaders()) → build error
98
- * - When client JS disabled: 'use client' / 'use server' directives → build error
99
98
  */
100
99
  transform(code: string, id: string) {
101
100
  // Read ctx.config lazily inside the hook — not at plugin construction
@@ -18,7 +18,7 @@ import {
18
18
  emitResolveSegmentFieldHelper,
19
19
  formatSearchParamsType,
20
20
  } from './codegen-shared.js';
21
- import { readFileCached } from './file-cache.js';
21
+ import { fileHasExport } from './export-detect.js';
22
22
  import { formatLinkCatchAllOverloads, formatTypedLinkOverloads } from './link-codegen.js';
23
23
 
24
24
  /** Options for route map generation. */
@@ -249,26 +249,6 @@ function paramTypeForSegment(segmentType: string): ParamEntry['type'] {
249
249
  }
250
250
  }
251
251
 
252
- /**
253
- * Check if a file exports a specific named export.
254
- *
255
- * Uses a lightweight regex check — not full AST parsing. The actual type
256
- * extraction happens via the TypeScript compiler in the generated .d.ts.
257
- */
258
- function fileHasExport(filePath: string, exportName: string): boolean {
259
- if (!existsSync(filePath)) return false;
260
- try {
261
- const content = readFileCached(filePath);
262
- const e = exportName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
263
- return (
264
- new RegExp(`export\\s+(const|let|var)\\s+${e}\\b`).test(content) ||
265
- new RegExp(`export\\s*\\{[^}]*\\b${e}\\b[^}]*\\}`).test(content)
266
- );
267
- } catch {
268
- return false;
269
- }
270
- }
271
-
272
252
  /**
273
253
  * Find a legacy `segmentParams` export on layout.tsx or page.tsx.
274
254
  *
@@ -14,7 +14,12 @@
14
14
  import { existsSync } from 'node:fs';
15
15
  import type { RouteTree, SegmentNode } from './types.js';
16
16
  import { swallow } from '../server/logger.js';
17
- import { readFileCached } from './file-cache.js';
17
+ import {
18
+ fileHasAnyExport,
19
+ fileHasDefaultExport,
20
+ fileHasDirective,
21
+ fileHasStarExport,
22
+ } from './export-detect.js';
18
23
 
19
24
  // ─── Types ──────────────────────────────────────────────────────────────────
20
25
 
@@ -56,6 +61,12 @@ export function lintConventions(tree: RouteTree, appDir: string): ConventionWarn
56
61
  // Check 5: page.tsx / layout.tsx without default export
57
62
  checkDefaultExports(tree.root, warnings);
58
63
 
64
+ // Check 6: error.tsx / global-error.tsx without 'use client' directive
65
+ checkErrorDirectives(tree.root, warnings);
66
+ if (tree.globalError && isScriptExtension(tree.globalError.extension)) {
67
+ checkFileUseClientDirective(tree.globalError.filePath, 'global-error', warnings);
68
+ }
69
+
59
70
  return warnings;
60
71
  }
61
72
 
@@ -116,37 +127,23 @@ function hasAnyPage(node: SegmentNode): boolean {
116
127
  // ─── Check: Route Exports ───────────────────────────────────────────────────
117
128
 
118
129
  /** HTTP methods that route.ts can export. */
119
- const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
120
-
121
- /**
122
- * Pattern to detect named exports that look like HTTP method handlers.
123
- * Matches: export function GET, export const GET, export async function POST, etc.
124
- * Also matches: export { GET } or re-exports like export { GET } from './handler'.
125
- *
126
- * The re-export branch uses word boundaries (\b) around method names to avoid
127
- * matching substrings (e.g. TARGET should not match GET). See TIM-795.
128
- */
129
- const EXPORT_PATTERN = new RegExp(
130
- `export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+(${HTTP_METHODS.join('|')})\\b` +
131
- `|export\\s*\\{[^}]*\\b(${HTTP_METHODS.join('|')})\\b[^}]*\\}`
132
- );
130
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const;
133
131
 
134
132
  /**
135
133
  * Warn when a route.ts file doesn't appear to export any recognized HTTP methods.
136
134
  *
137
- * Uses static analysis (regex on source text) — not module loading.
138
- * This is intentionally conservative: it may miss complex re-exports but
139
- * catches the common case of an empty route.ts or one with wrong export names.
135
+ * Uses AST-based detection via parseAst for precise matching.
136
+ * Falls back silently when the file can't be parsed (syntax errors produce
137
+ * no spurious warning — the TS compiler will catch those).
140
138
  */
141
139
  function checkRouteExports(node: SegmentNode, warnings: ConventionWarning[]): void {
142
140
  if (node.route) {
143
141
  const filePath = node.route.filePath;
144
142
  try {
145
- const source = readFileCached(filePath);
146
- if (!EXPORT_PATTERN.test(source)) {
147
- // Check if there's a default export (common mistake)
148
- const hasDefaultExport = /export\s+default\b/.test(source);
149
- const hint = hasDefaultExport
143
+ const methods = fileHasAnyExport(filePath, HTTP_METHODS);
144
+ if (methods.size === 0 && !fileHasStarExport(filePath)) {
145
+ const hasDefault = fileHasDefaultExport(filePath);
146
+ const hint = hasDefault
150
147
  ? ' It looks like you have a default export. route.ts uses named exports\n' +
151
148
  ' for HTTP methods (GET, POST, etc.), not a default export.\n'
152
149
  : '';
@@ -215,18 +212,6 @@ function checkRootLayout(root: SegmentNode, warnings: ConventionWarning[]): void
215
212
 
216
213
  // ─── Check: Default Exports ───────────────────────────────────────────
217
214
 
218
- /**
219
- * Pattern to detect a default export in source code.
220
- * Matches: export default function, export default class, export default
221
- * Also matches: export { X as default }, export { default } from './Impl'
222
- *
223
- * The third alternative catches bare `default` re-exports (TIM-790).
224
- * The negative lookahead (?!\s+as\b) prevents matching `export { default as X }`
225
- * which is a named export, not a default export (TIM-806).
226
- */
227
- const DEFAULT_EXPORT_PATTERN =
228
- /export\s+default\b|export\s*\{[^}]*\bas\s+default\b|export\s*\{[^}]*\bdefault\b(?!\s+as\b)[^}]*\}/;
229
-
230
215
  /**
231
216
  * Warn when page.tsx or layout.tsx files don't have a default export.
232
217
  *
@@ -234,7 +219,8 @@ const DEFAULT_EXPORT_PATTERN =
234
219
  * - page.tsx: the page renders nothing (empty content)
235
220
  * - layout.tsx: the layout module loads but has no component to render
236
221
  *
237
- * Uses static analysis (regex on source text) — intentionally conservative.
222
+ * Uses AST-based detection via parseAst for precise matching. Falls back
223
+ * silently when the file can't be parsed (no spurious warning on syntax errors).
238
224
  */
239
225
  function checkDefaultExports(node: SegmentNode, warnings: ConventionWarning[]): void {
240
226
  // Check page files (tsx/ts/jsx/js only — not mdx, which is default-exported by the mdx compiler)
@@ -266,8 +252,7 @@ function checkFileDefaultExport(
266
252
  warnings: ConventionWarning[]
267
253
  ): void {
268
254
  try {
269
- const source = readFileCached(filePath);
270
- if (!DEFAULT_EXPORT_PATTERN.test(source)) {
255
+ if (!fileHasDefaultExport(filePath)) {
271
256
  warnings.push({
272
257
  id: `NO_DEFAULT_EXPORT:${filePath}`,
273
258
  summary: `${fileType}.tsx has no default export: ${filePath}`,
@@ -287,6 +272,56 @@ function checkFileDefaultExport(
287
272
  }
288
273
  }
289
274
 
275
+ // ─── Check: Error Directive ─────────────────────────────────────────────────
276
+
277
+ /**
278
+ * Warn when error.tsx or global-error.tsx is missing 'use client'.
279
+ *
280
+ * React's error boundary API requires client components. Without the directive,
281
+ * the error boundary silently fails — the component renders as a server component
282
+ * with no error catching. Users discover this only via cryptic runtime errors.
283
+ */
284
+ function checkErrorDirectives(node: SegmentNode, warnings: ConventionWarning[]): void {
285
+ if (node.error && isScriptExtension(node.error.extension)) {
286
+ checkFileUseClientDirective(node.error.filePath, 'error', warnings);
287
+ }
288
+
289
+ for (const child of node.children) {
290
+ checkErrorDirectives(child, warnings);
291
+ }
292
+ for (const slot of Object.values(node.slots)) {
293
+ checkErrorDirectives(slot, warnings);
294
+ }
295
+ }
296
+
297
+ function checkFileUseClientDirective(
298
+ filePath: string,
299
+ fileType: string,
300
+ warnings: ConventionWarning[]
301
+ ): void {
302
+ try {
303
+ if (!fileHasDirective(filePath, 'use client')) {
304
+ warnings.push({
305
+ id: `ERROR_NO_USE_CLIENT:${filePath}`,
306
+ summary: `${fileType}.tsx is missing 'use client': ${filePath}`,
307
+ details:
308
+ ` File: ${filePath}\n\n` +
309
+ ` ${fileType}.tsx must start with 'use client' because React error\n` +
310
+ ' boundaries require client components. Without it, the error boundary\n' +
311
+ ' silently fails and errors are uncaught.\n\n' +
312
+ " To fix: Add 'use client' as the first line of the file:\n\n" +
313
+ " 'use client';\n\n" +
314
+ ` export default function ${fileType === 'global-error' ? 'GlobalError' : 'ErrorBoundary'}({ error, reset }) {\n` +
315
+ ' return <div><h2>Something went wrong</h2><button onClick={reset}>Try again</button></div>;\n' +
316
+ ' }\n',
317
+ level: 'warn',
318
+ });
319
+ }
320
+ } catch (err) {
321
+ swallow(err, `convention-lint: unreadable ${fileType} file ${filePath}`);
322
+ }
323
+ }
324
+
290
325
  // ─── Check: App Directory Exists ────────────────────────────────────────────
291
326
 
292
327
  /**
@@ -312,11 +347,12 @@ export function checkAppDirExists(appDir: string): ConventionWarning | null {
312
347
 
313
348
  // ─── Formatting ─────────────────────────────────────────────────────────────
314
349
 
315
- const YELLOW = '\x1b[33m';
316
- const RED = '\x1b[31m';
317
- const BOLD = '\x1b[1m';
318
- const DIM = '\x1b[2m';
319
- const RESET = '\x1b[0m';
350
+ import type { InspectColor } from 'node:util';
351
+ import { styleText } from 'node:util';
352
+
353
+ type Format = InspectColor | readonly InspectColor[];
354
+ const noValidate = { validateStream: false } as const;
355
+ const style = (fmt: Format, text: string) => styleText(fmt, text, noValidate);
320
356
 
321
357
  /**
322
358
  * Format warnings for terminal output.
@@ -333,24 +369,24 @@ export function formatConventionWarnings(warnings: ConventionWarning[]): string
333
369
 
334
370
  if (errors.length > 0) {
335
371
  lines.push(
336
- `${RED}${BOLD}[timber]${RESET} ${RED}${errors.length} configuration error${errors.length !== 1 ? 's' : ''}:${RESET}`
372
+ `${style(['red', 'bold'], '[timber]')} ${style('red', `${errors.length} configuration error${errors.length !== 1 ? 's' : ''}:`)}`
337
373
  );
338
374
  for (const e of errors) {
339
375
  lines.push('');
340
- lines.push(` ${RED}✗${RESET} ${e.summary}`);
341
- lines.push(`${DIM}${e.details}${RESET}`);
376
+ lines.push(` ${style('red', '✗')} ${e.summary}`);
377
+ lines.push(style('dim', e.details));
342
378
  }
343
379
  }
344
380
 
345
381
  if (warns.length > 0) {
346
382
  if (errors.length > 0) lines.push('');
347
383
  lines.push(
348
- `${YELLOW}${BOLD}[timber]${RESET} ${YELLOW}${warns.length} configuration warning${warns.length !== 1 ? 's' : ''}:${RESET}`
384
+ `${style(['yellow', 'bold'], '[timber]')} ${style('yellow', `${warns.length} configuration warning${warns.length !== 1 ? 's' : ''}:`)}`
349
385
  );
350
386
  for (const w of warns) {
351
387
  lines.push('');
352
- lines.push(` ${YELLOW}⚠${RESET} ${w.summary}`);
353
- lines.push(`${DIM}${w.details}${RESET}`);
388
+ lines.push(` ${style('yellow', '⚠')} ${w.summary}`);
389
+ lines.push(style('dim', w.details));
354
390
  }
355
391
  }
356
392