@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,304 @@
1
+ /**
2
+ * AST-based export detection for route files.
3
+ *
4
+ * Uses Vite's `parseAst` (backed by oxc) to precisely detect named and
5
+ * default exports, replacing the regex-based approach that was fragile
6
+ * with TypeScript syntax (type exports, `as` aliases, comments, etc.).
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { parseAst } from 'vite';
11
+ import { readFileCached } from './file-cache.js';
12
+
13
+ interface AstNode {
14
+ type: string;
15
+ exportKind?: string;
16
+ declaration?: AstNode;
17
+ specifiers?: Array<{
18
+ exported?: { name: string };
19
+ local?: { name: string };
20
+ exportKind?: string;
21
+ }>;
22
+ id?: { name: string };
23
+ declarations?: Array<{ id?: { name: string } }>;
24
+ }
25
+
26
+ interface ProgramNode {
27
+ body: AstNode[];
28
+ }
29
+
30
+ function tryParse(source: string): ProgramNode | null {
31
+ try {
32
+ return parseAst(source, { lang: 'tsx' }) as unknown as ProgramNode;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Collect all named export identifiers from a parsed program.
40
+ * Handles: `export function X`, `export const X`, `export { X }`,
41
+ * `export { X } from '...'`, `export { Y as X }`.
42
+ */
43
+ function collectNamedExports(program: ProgramNode): Set<string> {
44
+ const names = new Set<string>();
45
+ for (const stmt of program.body) {
46
+ if (stmt.type !== 'ExportNamedDeclaration') continue;
47
+ // Skip `export type ...` declarations — they're erased at runtime
48
+ if (stmt.exportKind === 'type') continue;
49
+
50
+ if (stmt.declaration) {
51
+ if (stmt.declaration.id?.name) {
52
+ names.add(stmt.declaration.id.name);
53
+ }
54
+ if (stmt.declaration.declarations) {
55
+ for (const decl of stmt.declaration.declarations) {
56
+ if (decl.id?.name) names.add(decl.id.name);
57
+ }
58
+ }
59
+ }
60
+
61
+ if (stmt.specifiers) {
62
+ for (const spec of stmt.specifiers) {
63
+ // Skip `export { type X }` — per-specifier type exports
64
+ if (spec.exportKind === 'type') continue;
65
+ if (spec.exported?.name && spec.exported.name !== 'default') {
66
+ names.add(spec.exported.name);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return names;
72
+ }
73
+
74
+ /**
75
+ * Check whether a program has a default export.
76
+ * Handles: `export default ...`, `export { X as default }`,
77
+ * `export { default } from '...'`.
78
+ */
79
+ function hasDefaultExport(program: ProgramNode): boolean {
80
+ for (const stmt of program.body) {
81
+ if (stmt.type === 'ExportDefaultDeclaration') return true;
82
+ if (stmt.type === 'ExportNamedDeclaration' && stmt.specifiers) {
83
+ for (const spec of stmt.specifiers) {
84
+ if (spec.exported?.name === 'default') return true;
85
+ }
86
+ }
87
+ }
88
+ return false;
89
+ }
90
+
91
+ /**
92
+ * Check whether a program has any bare `export * from '...'` declarations.
93
+ * Excludes `export * as X from '...'` (namespace re-exports) since those
94
+ * create a namespace object, not individual top-level exports.
95
+ */
96
+ function hasStarExport(program: ProgramNode): boolean {
97
+ return program.body.some(
98
+ (stmt) =>
99
+ stmt.type === 'ExportAllDeclaration' && !(stmt as any).exported && stmt.exportKind !== 'type'
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Check if a file exports a specific named export.
105
+ * Returns false if the file doesn't exist or can't be parsed.
106
+ */
107
+ export function fileHasExport(filePath: string, exportName: string): boolean {
108
+ if (!existsSync(filePath)) return false;
109
+ try {
110
+ const source = readFileCached(filePath);
111
+ const program = tryParse(source);
112
+ if (!program) return false;
113
+ return collectNamedExports(program).has(exportName);
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Check if a file has any of the given named exports.
121
+ * Returns the set of matching export names.
122
+ */
123
+ export function fileHasAnyExport(filePath: string, exportNames: readonly string[]): Set<string> {
124
+ const matches = new Set<string>();
125
+ try {
126
+ const source = readFileCached(filePath);
127
+ const program = tryParse(source);
128
+ if (!program) return matches;
129
+ const exports = collectNamedExports(program);
130
+ for (const name of exportNames) {
131
+ if (exports.has(name)) matches.add(name);
132
+ }
133
+ } catch {
134
+ // Graceful fallback: return empty set
135
+ }
136
+ return matches;
137
+ }
138
+
139
+ /**
140
+ * Check if a file has a default export.
141
+ * Returns false if the file doesn't exist or can't be parsed.
142
+ */
143
+ export function fileHasDefaultExport(filePath: string): boolean {
144
+ if (!existsSync(filePath)) return false;
145
+ try {
146
+ const source = readFileCached(filePath);
147
+ const program = tryParse(source);
148
+ if (!program) return false;
149
+ return hasDefaultExport(program);
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Check if a file has any `export * from '...'` declarations.
157
+ * Returns false if the file doesn't exist or can't be parsed.
158
+ */
159
+ export function fileHasStarExport(filePath: string): boolean {
160
+ if (!existsSync(filePath)) return false;
161
+ try {
162
+ const source = readFileCached(filePath);
163
+ const program = tryParse(source);
164
+ if (!program) return false;
165
+ return hasStarExport(program);
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Parsed value of `export const prerender` from a route file.
173
+ *
174
+ * - `true` / `false` — literal boolean
175
+ * - `{ ttl?, tags? }` — ISR options object (prerender: true is implied)
176
+ * - `undefined` — no `export const prerender` found
177
+ *
178
+ * See design/45-cache-lifetimes.md §"Route-Level Static".
179
+ */
180
+ export type PrerenderExportValue = boolean | { ttl?: number; tags?: string[] };
181
+
182
+ function extractLiteralValue(node: AstNode): unknown {
183
+ if (!node) return undefined;
184
+ const n = node as any;
185
+ if (n.type === 'Literal') return n.value;
186
+ if (n.type === 'UnaryExpression' && n.operator === '-' && n.argument?.type === 'Literal') {
187
+ return -(n.argument.value as number);
188
+ }
189
+ if (n.type === 'ArrayExpression') {
190
+ const elements = n.elements as AstNode[] | undefined;
191
+ if (!elements) return undefined;
192
+ const result: unknown[] = [];
193
+ for (const el of elements) {
194
+ const v = extractLiteralValue(el);
195
+ if (v === undefined) return undefined;
196
+ result.push(v);
197
+ }
198
+ return result;
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ function extractObjectLiteral(node: AstNode): Record<string, unknown> | undefined {
204
+ if ((node as any).type !== 'ObjectExpression') return undefined;
205
+ const props = (node as any).properties as AstNode[] | undefined;
206
+ if (!props) return undefined;
207
+ const result: Record<string, unknown> = {};
208
+ for (const prop of props) {
209
+ const p = prop as any;
210
+ if (p.type === 'SpreadElement') return undefined;
211
+ if (p.type !== 'Property' || p.computed) return undefined;
212
+ const key =
213
+ p.key?.type === 'Identifier'
214
+ ? p.key.name
215
+ : p.key?.type === 'Literal'
216
+ ? String(p.key.value)
217
+ : undefined;
218
+ if (!key) return undefined;
219
+ const value = extractLiteralValue(p.value);
220
+ if (value === undefined) return undefined;
221
+ result[key] = value;
222
+ }
223
+ return result;
224
+ }
225
+
226
+ /**
227
+ * Extract the value of `export const prerender` from a file.
228
+ * Returns undefined if the file doesn't export `prerender` or can't be parsed.
229
+ *
230
+ * Supports:
231
+ * export const prerender = true;
232
+ * export const prerender = false;
233
+ * export const prerender = { ttl: 3600, tags: ['docs'] };
234
+ */
235
+ export function getPrerenderExport(filePath: string): PrerenderExportValue | undefined {
236
+ if (!existsSync(filePath)) return undefined;
237
+ try {
238
+ const source = readFileCached(filePath);
239
+ const program = tryParse(source);
240
+ if (!program) return undefined;
241
+
242
+ for (const stmt of program.body) {
243
+ if (stmt.type !== 'ExportNamedDeclaration') continue;
244
+ if (stmt.exportKind === 'type') continue;
245
+ if (!stmt.declaration) continue;
246
+ const decl = stmt.declaration;
247
+ if (decl.type !== 'VariableDeclaration') continue;
248
+ const declarations = decl.declarations as
249
+ | Array<{
250
+ id?: { name?: string };
251
+ init?: AstNode;
252
+ }>
253
+ | undefined;
254
+ if (!declarations) continue;
255
+ for (const d of declarations) {
256
+ if (d.id?.name !== 'prerender' || !d.init) continue;
257
+ const init = d.init as any;
258
+ if (init.type === 'Literal' && typeof init.value === 'boolean') {
259
+ return init.value;
260
+ }
261
+ if (init.type === 'ObjectExpression') {
262
+ const obj = extractObjectLiteral(init);
263
+ if (!obj) return undefined;
264
+ const result: { ttl?: number; tags?: string[] } = {};
265
+ if ('ttl' in obj && typeof obj.ttl === 'number') result.ttl = obj.ttl;
266
+ if (
267
+ 'tags' in obj &&
268
+ Array.isArray(obj.tags) &&
269
+ obj.tags.every((t: unknown) => typeof t === 'string')
270
+ ) {
271
+ result.tags = obj.tags as string[];
272
+ }
273
+ return result;
274
+ }
275
+ return undefined;
276
+ }
277
+ }
278
+ return undefined;
279
+ } catch {
280
+ return undefined;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Check if a file starts with a specific directive (e.g. "use client").
286
+ * Directives are string literal expression statements at the top of the file.
287
+ */
288
+ export function fileHasDirective(filePath: string, directive: string): boolean {
289
+ if (!existsSync(filePath)) return false;
290
+ try {
291
+ const source = readFileCached(filePath);
292
+ const program = tryParse(source);
293
+ if (!program) return false;
294
+ for (const stmt of program.body) {
295
+ if (stmt.type !== 'ExpressionStatement') break;
296
+ const expr = (stmt as any).expression;
297
+ if (expr?.type !== 'Literal' || typeof expr.value !== 'string') break;
298
+ if (expr.value === directive) return true;
299
+ }
300
+ return false;
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
@@ -11,8 +11,8 @@
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { collectInterceptionRewrites } from './interception.js';
14
+ import { fileHasExport } from './export-detect.js';
14
15
  import type { RouteTree, SegmentNode, RouteFile } from './types.js';
15
- import { readFileCached } from './file-cache.js';
16
16
 
17
17
  // ── Single-file conventions ─────────────────────────────────────────────
18
18
  // Each entry maps to a node property that holds a single RouteFile.
@@ -236,16 +236,7 @@ export function generateSearchParamsRegistryModule(tree: RouteTree): string {
236
236
  }
237
237
 
238
238
  function fileHasSearchParamsExport(filePath: string): boolean {
239
- try {
240
- if (!existsSync(filePath)) return false;
241
- const content = readFileCached(filePath);
242
- return (
243
- /export\s+(const|let|var)\s+searchParams\b/.test(content) ||
244
- /export\s*\{[^}]*\bsearchParams\b[^}]*\}/.test(content)
245
- );
246
- } catch {
247
- return false;
248
- }
239
+ return fileHasExport(filePath, 'searchParams');
249
240
  }
250
241
 
251
242
  // ── Schema module ───────────────────────────────────────────────────────
@@ -18,4 +18,11 @@ export {
18
18
  decodeAction,
19
19
  encryptActionBoundArgs,
20
20
  decryptActionBoundArgs,
21
+ // Flight client running INSIDE the RSC environment — decodes a cached
22
+ // flight payload back into React elements. Client references revive as
23
+ // re-serializable reference proxies via plugin-rsc's server-decode
24
+ // manifest (registerClientReference under the hood). Used by the
25
+ // prebuilt component runtime (TIM-1140); same mechanism as Next.js
26
+ // "use cache" (use-cache-wrapper.ts).
27
+ createFromReadableStream,
21
28
  } from '@vitejs/plugin-rsc/rsc';
@@ -35,7 +35,6 @@ import {
35
35
  } from './sensitive-fields.js';
36
36
  import type { FormFlashData } from './form-flash.js';
37
37
  import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
38
- import { encodeHeadHeader, HEAD_HEADER } from '../shared/head-header.js';
39
38
  import { logActionError } from './logger.js';
40
39
 
41
40
  // ─── Types ────────────────────────────────────────────────────────────────
@@ -300,17 +299,9 @@ async function handleRscAction(
300
299
  _tree: result.revalidation.element,
301
300
  };
302
301
  headers['X-Timber-Revalidation'] = '1';
303
- // Forward head elements (percent-encoded JSON) so the client can update
304
- // <head>. Raw JSON.stringify is NOT safe here: header values must be
305
- // ByteStrings, so a title with an em dash or emoji would make
306
- // `new Response(...)` throw — a 500 after the action's mutation already
307
- // committed, dropping Set-Cookie too. See TIM-1058.
308
- if (result.revalidation.headElements.length > 0) {
309
- const encodedHead = encodeHeadHeader(result.revalidation.headElements);
310
- if (encodedHead !== null) {
311
- headers[HEAD_HEADER] = encodedHead;
312
- }
313
- }
302
+ // Metadata (<title>/<meta>/<link>) now rides the revalidation element
303
+ // tree as React elements — React 19 Float manages them in document.head
304
+ // during client rendering. No X-Timber-Head header needed. See TIM-1151.
314
305
  } else {
315
306
  payload = result.actionResult;
316
307
  }
@@ -347,19 +338,6 @@ async function handleFormAction(
347
338
  return new Response(null, { status: fieldResult.status });
348
339
  }
349
340
 
350
- // Check if this is actually a React server action form.
351
- // If there's no $ACTION_REF_* or $ACTION_KEY, it's a regular form POST
352
- // that should be handled by the route's route handler, not here.
353
- // React uses `$ACTION_REF_` + identifierPrefix — since we don't set one,
354
- // the suffix is empty. Check for any key starting with $ACTION_REF_ or $ACTION_ID_.
355
- const allKeys = [...formData.keys()];
356
- const hasActionField = allKeys.some(
357
- (k) => k.startsWith('$ACTION_REF_') || k.startsWith('$ACTION_ID_')
358
- );
359
- if (!hasActionField && !formData.has('$ACTION_KEY')) {
360
- return null;
361
- }
362
-
363
341
  // Capture submitted values for re-render on validation failure.
364
342
  // Parse before decodeAction consumes the FormData, then strip sensitive
365
343
  // fields (passwords, tokens, CVV, etc.) so they are never rendered back
@@ -371,8 +349,14 @@ async function handleFormAction(
371
349
  const submittedValues = stripSensitiveFields(parseFormData(formData), sensitivePredicate);
372
350
 
373
351
  // decodeAction resolves the action function from the form data's hidden fields.
374
- // It returns a bound function with the form data already applied.
375
- const actionFn = (await decodeAction(formData)) as (...args: unknown[]) => Promise<unknown>;
352
+ // Returns null when no $ACTION_REF_/$ACTION_ID_ fields are present — meaning
353
+ // this is a regular form POST, not a React server action.
354
+ const actionFn = (await decodeAction(formData)) as
355
+ | ((...args: unknown[]) => Promise<unknown>)
356
+ | null;
357
+ if (!actionFn) {
358
+ return null;
359
+ }
376
360
 
377
361
  // Execute the action — no additional args needed (form data is already bound).
378
362
  // Errors are caught to prevent stack traces from leaking in the response.
@@ -18,16 +18,12 @@ import { cache } from '../cache/cache-api';
18
18
  import { DenySignal, RedirectSignal } from './primitives';
19
19
  import { withSpan } from './tracing';
20
20
  import { revalidationAls, type RevalidationState } from './als-registry.js';
21
- import type { HeadElementPayload } from '../shared/head-header.js';
22
-
23
21
  // ─── Types ───────────────────────────────────────────────────────────────
24
22
 
25
23
  /** Result of rendering a revalidation — element tree before RSC serialization. */
26
24
  export interface RevalidationResult {
27
25
  /** React element tree (pre-serialization — passed to renderToReadableStream). */
28
26
  element: unknown;
29
- /** Resolved head elements for metadata (forwarded via X-Timber-Head). */
30
- headElements: HeadElementPayload[];
31
27
  }
32
28
 
33
29
  /** Renderer function that builds a React element tree for a given path. */
@@ -168,6 +164,9 @@ export async function executeAction(
168
164
  // rsc-entry when timber.config.ts provides a cacheHandler; otherwise falls
169
165
  // back to in-memory LRU (TIM-599).
170
166
  if (state.tags.length > 0) {
167
+ // cache.invalidate handles both data-cache clearing AND component-seed
168
+ // tombstone writes (design/45 §ISR mechanics) — no separate tombstone
169
+ // step needed here.
171
170
  await Promise.all(state.tags.map((tag) => cache.invalidate({ tag })));
172
171
  }
173
172
 
@@ -150,6 +150,13 @@ export interface TraceStore {
150
150
  traceId: string;
151
151
  /** OTEL span ID if available, undefined otherwise. */
152
152
  spanId?: string;
153
+ /**
154
+ * Innermost active native platform span (e.g. Cloudflare's `Span` from
155
+ * tracing.enterSpan()). Tracked so setSpanAttribute() can set attributes
156
+ * after span creation — the platform API has no getActiveSpan() equivalent.
157
+ * Saved/restored by withSpan() around each callback. See TIM-1135.
158
+ */
159
+ platformSpan?: import('./tracing.js').PlatformSpan;
153
160
  }
154
161
 
155
162
  /** @internal — import via tracing.ts public API */
@@ -9,8 +9,14 @@
9
9
  * live in the API surface — `cookie-context.ts` invokes them at every
10
10
  * jar entry point so the smuggling-primitive invariant from TIM-868
11
11
  * is enforced regardless of which path produced the bytes).
12
+ *
13
+ * Delegates to the `cookie` package (RFC 6265, dependency-free,
14
+ * browser-safe) for wire codecs. Adapts at the boundary to preserve
15
+ * timber's Map-based API and never-throw contract.
12
16
  */
13
17
 
18
+ import { parseCookie, parseSetCookie as upstreamParseSetCookie, stringifySetCookie } from 'cookie';
19
+
14
20
  import type { CookieEntry } from './als-registry.js';
15
21
  import type { CookieOptions } from './cookie-context.js';
16
22
 
@@ -28,14 +34,13 @@ export function parseCookieHeader(header: string): Map<string, string> {
28
34
  const map = new Map<string, string>();
29
35
  if (!header) return map;
30
36
 
31
- for (const pair of header.split(';')) {
32
- const eqIndex = pair.indexOf('=');
33
- if (eqIndex === -1) continue;
34
- const name = pair.slice(0, eqIndex).trim();
35
- const value = pair.slice(eqIndex + 1).trim();
36
- if (name && !map.has(name)) {
37
- map.set(name, safeDecodeCookieValue(value));
38
- }
37
+ // cookie.parseCookie returns a plain object with first-wins semantics
38
+ // and uses safeDecodeURIComponent by default (try/catch around
39
+ // decodeURIComponent, falls back to raw). This matches our contract.
40
+ const parsed = parseCookie(header);
41
+ for (const name in parsed) {
42
+ const value = parsed[name];
43
+ if (value !== undefined) map.set(name, value);
39
44
  }
40
45
 
41
46
  return map;
@@ -48,6 +53,9 @@ export function parseCookieHeader(header: string): Map<string, string> {
48
53
  * Used by both `parseCookieHeader` (incoming Cookie: header) and the
49
54
  * `setRaw` forwarding path (outgoing Set-Cookie from upstream services).
50
55
  * Total — never throws.
56
+ *
57
+ * cookie's internal decode has identical semantics but is not exported,
58
+ * so we keep this standalone helper.
51
59
  */
52
60
  export function safeDecodeCookieValue(raw: string): string {
53
61
  try {
@@ -57,8 +65,43 @@ export function safeDecodeCookieValue(raw: string): string {
57
65
  }
58
66
  }
59
67
 
60
- /** Serialize a CookieEntry into a Set-Cookie header value. */
68
+ /**
69
+ * Serialize a CookieEntry into a Set-Cookie header value.
70
+ *
71
+ * Total — never throws. If the cookie package's serializer rejects
72
+ * the input (e.g. non-integer maxAge, forbidden chars in name/value),
73
+ * falls back to a minimal hand-rolled serialization so the response
74
+ * path is never interrupted by a codec error.
75
+ */
61
76
  export function serializeCookieEntry(entry: CookieEntry): string {
77
+ try {
78
+ return stringifySetCookie(
79
+ {
80
+ name: entry.name,
81
+ value: entry.value,
82
+ domain: entry.options.domain,
83
+ path: entry.options.path,
84
+ expires: entry.options.expires,
85
+ maxAge: entry.options.maxAge,
86
+ httpOnly: entry.options.httpOnly,
87
+ secure: entry.options.secure,
88
+ sameSite: entry.options.sameSite,
89
+ partitioned: entry.options.partitioned,
90
+ },
91
+ // timber pre-encodes values at the API surface (set() calls
92
+ // encodeURIComponent), so pass identity to avoid double-encoding.
93
+ { encode: (v: string) => v }
94
+ );
95
+ } catch {
96
+ // Fallback: hand-roll a minimal Set-Cookie so we never drop the
97
+ // header entirely. The entry-point validators in cookie-context.ts
98
+ // already reject truly dangerous input — this path only fires for
99
+ // edge-case formatting the upstream serializer is strict about.
100
+ return serializeCookieEntryFallback(entry);
101
+ }
102
+ }
103
+
104
+ function serializeCookieEntryFallback(entry: CookieEntry): string {
62
105
  const parts = [`${entry.name}=${entry.value}`];
63
106
  const opts = entry.options;
64
107
 
@@ -88,60 +131,33 @@ export function serializeCookieEntry(entry: CookieEntry): string {
88
131
  export function parseSetCookie(
89
132
  header: string
90
133
  ): { name: string; value: string; options: CookieOptions } | null {
91
- const segments = header.split(';');
92
- const nameValue = segments[0];
93
- const eqIdx = nameValue.indexOf('=');
94
- if (eqIdx <= 0) return null;
134
+ const parsed = upstreamParseSetCookie(header, {
135
+ // Don't decode — setRaw expects the wire-form value so it can
136
+ // re-emit verbatim and validate against cookie-octet.
137
+ decode: (v: string) => v,
138
+ });
139
+
140
+ // cookie.parseSetCookie always returns a SetCookie object, but
141
+ // with name/value as empty strings when the header has no `=`.
142
+ if (!parsed.name) return null;
95
143
 
96
- const name = nameValue.slice(0, eqIdx).trim();
97
- const value = nameValue.slice(eqIdx + 1).trim();
98
144
  const options: CookieOptions = {};
99
145
 
100
- for (let i = 1; i < segments.length; i++) {
101
- const seg = segments[i].trim();
102
- if (!seg) continue;
103
- const [attrName, ...rest] = seg.split('=');
104
- const key = attrName.trim().toLowerCase();
105
- const val = rest.join('=').trim();
106
- switch (key) {
107
- case 'path':
108
- options.path = val || '/';
109
- break;
110
- case 'domain':
111
- options.domain = val;
112
- break;
113
- case 'max-age': {
114
- // RFC 6265 §5.2.2: a non-numeric Max-Age is ignored, not fatal.
115
- // Dropping NaN/Infinity here keeps this parser total and keeps
116
- // `Max-Age=NaN` out of the serialized Set-Cookie header.
117
- const maxAge = Number(val);
118
- if (Number.isFinite(maxAge)) options.maxAge = maxAge;
119
- break;
120
- }
121
- case 'expires':
122
- options.expires = new Date(val);
123
- break;
124
- case 'samesite': {
125
- // Unknown SameSite values are ignored (browsers treat them as
126
- // unspecified) so forwarding a quirky upstream header via
127
- // setFromHeaders never trips the entry-point validator.
128
- const sameSite = val.toLowerCase();
129
- if (sameSite === 'strict' || sameSite === 'lax' || sameSite === 'none') {
130
- options.sameSite = sameSite;
131
- }
132
- break;
133
- }
134
- case 'secure':
135
- options.secure = true;
136
- break;
137
- case 'httponly':
138
- options.httpOnly = true;
139
- break;
140
- case 'partitioned':
141
- options.partitioned = true;
142
- break;
146
+ if (parsed.path !== undefined) options.path = parsed.path || '/';
147
+ if (parsed.domain !== undefined) options.domain = parsed.domain;
148
+ if (parsed.maxAge !== undefined && Number.isFinite(parsed.maxAge)) {
149
+ options.maxAge = parsed.maxAge;
150
+ }
151
+ if (parsed.expires !== undefined) options.expires = parsed.expires;
152
+ if (parsed.sameSite !== undefined && parsed.sameSite !== true) {
153
+ const sameSite = (parsed.sameSite as string).toLowerCase();
154
+ if (sameSite === 'strict' || sameSite === 'lax' || sameSite === 'none') {
155
+ options.sameSite = sameSite;
143
156
  }
144
157
  }
158
+ if (parsed.secure) options.secure = true;
159
+ if (parsed.httpOnly) options.httpOnly = true;
160
+ if (parsed.partitioned) options.partitioned = true;
145
161
 
146
- return { name, value, options };
162
+ return { name: parsed.name, value: parsed.value ?? '', options };
147
163
  }
@@ -15,7 +15,7 @@
15
15
  * See design/10-error-handling.md §"Status-Code Files"
16
16
  */
17
17
 
18
- import { createElement } from 'react';
18
+ import { createElement, Fragment } from 'react';
19
19
  import { renderToReadableStream } from '../rsc-runtime/rsc.js';
20
20
 
21
21
  import { DenySignal } from './primitives.js';
@@ -25,8 +25,8 @@ import { logRenderError } from './logger.js';
25
25
  import { loadModule } from './safe-load.js';
26
26
  import { isMdxFilePath } from './utils/mdx-file.js';
27
27
  import { isDebug } from './debug.js';
28
- import { escapeHtml } from './utils/escape-html.js';
29
28
  import { resolveMetadata, renderMetadataToElements } from './metadata.js';
29
+ import { headElementsToReact } from './route-element-builder.js';
30
30
  import { resolveStatusFile } from './status-code-resolver.js';
31
31
  import type { ManifestSegmentNode } from './route-matcher.js';
32
32
  import type { RouteMatch } from './pipeline.js';
@@ -166,21 +166,13 @@ export async function renderDenyPage(
166
166
  element = h(component, null, element);
167
167
  }
168
168
 
169
- // Build head HTML from error page metadata (if any)
170
- let headHtml = '';
169
+ // Embed metadata as React elements in the tree — React 19 Float hoists
170
+ // them into <head> during SSR. See TIM-1151.
171
171
  if (mod.metadata) {
172
172
  const resolvedMeta = resolveMetadata([{ metadata: mod.metadata as Metadata, isPage: true }]);
173
173
  const headElements = renderMetadataToElements(resolvedMeta);
174
- for (const el of headElements) {
175
- if (el.tag === 'title' && el.content) {
176
- headHtml += `<title>${escapeHtml(el.content)}</title>`;
177
- } else if (el.attrs) {
178
- const attrs = Object.entries(el.attrs)
179
- .map(([k, v]) => `${k}="${escapeHtml(v)}"`)
180
- .join(' ');
181
- headHtml += `<${el.tag} ${attrs}>`;
182
- }
183
- }
174
+ const metaEl = headElementsToReact(headElements);
175
+ element = h(Fragment, null, metaEl, element);
184
176
  }
185
177
 
186
178
  // Render the error page to a fresh RSC Flight stream.
@@ -200,7 +192,7 @@ export async function renderDenyPage(
200
192
  searchParams: Object.fromEntries(new URL(req.url).searchParams),
201
193
  statusCode: deny.status,
202
194
  responseHeaders,
203
- headHtml: headHtml + flightInitScript(),
195
+ headHtml: flightInitScript(),
204
196
  bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
205
197
  rscStream: inlineStream,
206
198
  signal: req.signal,