@timber-js/app 0.2.0-alpha.157 → 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 (290) hide show
  1. package/dist/_chunks/{actions-CqE7SW7E.js → actions-CBbP1hHO.js} +22 -6
  2. package/dist/_chunks/actions-CBbP1hHO.js.map +1 -0
  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/form-data.d.ts +12 -0
  139. package/dist/server/form-data.d.ts.map +1 -1
  140. package/dist/server/html-injectors.d.ts +1 -0
  141. package/dist/server/html-injectors.d.ts.map +1 -1
  142. package/dist/server/index.js +2 -2
  143. package/dist/server/internal.d.ts +6 -1
  144. package/dist/server/internal.d.ts.map +1 -1
  145. package/dist/server/internal.js +563 -74
  146. package/dist/server/internal.js.map +1 -1
  147. package/dist/server/prebuilt/cache-key.d.ts +58 -0
  148. package/dist/server/prebuilt/cache-key.d.ts.map +1 -0
  149. package/dist/server/prebuilt/capture-state.d.ts +57 -0
  150. package/dist/server/prebuilt/capture-state.d.ts.map +1 -0
  151. package/dist/server/prebuilt/manifest-tag-index.d.ts +27 -0
  152. package/dist/server/prebuilt/manifest-tag-index.d.ts.map +1 -0
  153. package/dist/server/prebuilt/overlay.d.ts +100 -0
  154. package/dist/server/prebuilt/overlay.d.ts.map +1 -0
  155. package/dist/server/prebuilt/payload-source.d.ts +75 -0
  156. package/dist/server/prebuilt/payload-source.d.ts.map +1 -0
  157. package/dist/server/prebuilt/synthetic-store.d.ts +64 -0
  158. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -0
  159. package/dist/server/prebuilt-builder.d.ts +110 -0
  160. package/dist/server/prebuilt-builder.d.ts.map +1 -0
  161. package/dist/server/prebuilt-runtime.d.ts +101 -0
  162. package/dist/server/prebuilt-runtime.d.ts.map +1 -0
  163. package/dist/server/route-element-builder.d.ts +10 -8
  164. package/dist/server/route-element-builder.d.ts.map +1 -1
  165. package/dist/server/rsc-entry/helpers.d.ts +0 -5
  166. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  167. package/dist/server/rsc-entry/index.d.ts +10 -0
  168. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  169. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  170. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  171. package/dist/server/rsc-entry/rsc-payload.d.ts +3 -4
  172. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  173. package/dist/server/sitemap-generator.d.ts +6 -6
  174. package/dist/server/sitemap-generator.d.ts.map +1 -1
  175. package/dist/server/tracing.d.ts +37 -3
  176. package/dist/server/tracing.d.ts.map +1 -1
  177. package/docs/api/32-api-cache.mdx +10 -4
  178. package/docs/learn/00-introduction.mdx +8 -13
  179. package/docs/learn/02-pages-and-layouts.mdx +9 -0
  180. package/docs/learn/06-forms-and-actions.mdx +0 -4
  181. package/docs/learn/07-typed-routes.mdx +127 -0
  182. package/docs/learn/09-caching.mdx +12 -1
  183. package/docs/learn/10-middleware.mdx +1 -0
  184. package/docs/learn/11-error-handling.mdx +9 -0
  185. package/docs/learn/13-configuration.mdx +28 -0
  186. package/docs/learn/14-deploying.mdx +52 -0
  187. package/docs/more/06-instrumentation.mdx +27 -1
  188. package/docs/more/07-security.mdx +8 -2
  189. package/docs/more/08-developer-experience.mdx +134 -0
  190. package/package.json +31 -2
  191. package/src/adapters/build-output-helper.ts +10 -0
  192. package/src/adapters/cloudflare.ts +178 -4
  193. package/src/cache/cache-api.ts +60 -0
  194. package/src/cache/handler-store.ts +8 -0
  195. package/src/cache/index.ts +4 -0
  196. package/src/cache/redis-handler.ts +25 -13
  197. package/src/cache/singleflight.ts +11 -11
  198. package/src/cache/store.ts +71 -0
  199. package/src/cache/stores/cloudflare-kv.ts +52 -0
  200. package/src/cache/stores/memory.ts +125 -0
  201. package/src/cache/stores/redis.ts +97 -0
  202. package/src/cache/stores/vercel.ts +59 -0
  203. package/src/cache/tag-aware-handler.ts +233 -0
  204. package/src/cache/timber-cache.ts +17 -7
  205. package/src/cdn/cloudflare-purge.ts +50 -0
  206. package/src/cdn/fastly-purge.ts +63 -0
  207. package/src/cdn/headers.ts +127 -0
  208. package/src/cdn/index.ts +16 -0
  209. package/src/cdn/purge-store.ts +34 -0
  210. package/src/client/browser-entry/action-dispatch.ts +1 -10
  211. package/src/client/browser-entry/post-hydration.ts +0 -1
  212. package/src/client/browser-entry/router-init.ts +0 -5
  213. package/src/client/history.ts +0 -4
  214. package/src/client/navigation-api.ts +5 -11
  215. package/src/client/router.ts +9 -43
  216. package/src/client/rsc-fetch.ts +5 -27
  217. package/src/client/segment-cache.ts +1 -4
  218. package/src/client/use-cookie.ts +18 -12
  219. package/src/config-types.ts +3 -1
  220. package/src/config-validation.ts +1 -0
  221. package/src/dev-tools/browser-logs.ts +37 -273
  222. package/src/dev-tools/index.ts +2 -2
  223. package/src/dev-tools/instrumentation.ts +1 -1
  224. package/src/dev-tools/logger.ts +33 -51
  225. package/src/dev-tools/terminal.ts +21 -22
  226. package/src/fonts/transform.ts +4 -43
  227. package/src/index.ts +16 -3
  228. package/src/plugin-context.ts +36 -0
  229. package/src/plugins/build-report.ts +133 -1
  230. package/src/plugins/cache.ts +32 -131
  231. package/src/plugins/callsite-ast.ts +167 -0
  232. package/src/plugins/callsite-jsx.ts +243 -0
  233. package/src/plugins/entries.ts +23 -4
  234. package/src/plugins/fonts.ts +16 -2
  235. package/src/plugins/prebuilt-capture.ts +208 -0
  236. package/src/plugins/prebuilt.ts +673 -0
  237. package/src/plugins/prerender-sugar.ts +329 -0
  238. package/src/plugins/server-bundle.ts +12 -0
  239. package/src/plugins/shims.ts +9 -0
  240. package/src/plugins/static-build.ts +0 -1
  241. package/src/routing/codegen.ts +1 -21
  242. package/src/routing/convention-lint.ts +85 -49
  243. package/src/routing/export-detect.ts +304 -0
  244. package/src/routing/manifest-codegen.ts +2 -11
  245. package/src/rsc-runtime/rsc.ts +7 -0
  246. package/src/server/action-handler.ts +11 -27
  247. package/src/server/actions.ts +3 -4
  248. package/src/server/als-registry.ts +7 -0
  249. package/src/server/cookie-parsing.ts +75 -59
  250. package/src/server/deny-renderer.ts +7 -15
  251. package/src/server/form-data.ts +23 -7
  252. package/src/server/html-injectors.ts +11 -3
  253. package/src/server/internal.ts +17 -1
  254. package/src/server/prebuilt/cache-key.ts +224 -0
  255. package/src/server/prebuilt/capture-state.ts +89 -0
  256. package/src/server/prebuilt/manifest-tag-index.ts +46 -0
  257. package/src/server/prebuilt/overlay.ts +256 -0
  258. package/src/server/prebuilt/payload-source.ts +192 -0
  259. package/src/server/prebuilt/synthetic-store.ts +108 -0
  260. package/src/server/prebuilt-builder.ts +640 -0
  261. package/src/server/prebuilt-runtime.ts +706 -0
  262. package/src/server/route-element-builder.ts +40 -12
  263. package/src/server/rsc-entry/helpers.ts +0 -17
  264. package/src/server/rsc-entry/index.ts +83 -0
  265. package/src/server/rsc-entry/render-route.ts +6 -33
  266. package/src/server/rsc-entry/revalidate-renderer.ts +16 -11
  267. package/src/server/rsc-entry/rsc-payload.ts +5 -11
  268. package/src/server/sitemap-generator.ts +16 -10
  269. package/src/server/tracing.ts +88 -3
  270. package/dist/_chunks/actions-CqE7SW7E.js.map +0 -1
  271. package/dist/_chunks/cache-api-Cbyh3RW4.js +0 -401
  272. package/dist/_chunks/cache-api-Cbyh3RW4.js.map +0 -1
  273. package/dist/_chunks/logger-C5v1IMuh.js.map +0 -1
  274. package/dist/_chunks/plugin-context-DUl3dnCF.js.map +0 -1
  275. package/dist/_chunks/validation-CnKhGJsp.js +0 -113
  276. package/dist/_chunks/validation-CnKhGJsp.js.map +0 -1
  277. package/dist/_chunks/walkers-D5jCGPum.js.map +0 -1
  278. package/dist/cache/index.js.map +0 -1
  279. package/dist/client/head.d.ts +0 -16
  280. package/dist/client/head.d.ts.map +0 -1
  281. package/dist/client/navigation-api-types.d.ts +0 -90
  282. package/dist/client/navigation-api-types.d.ts.map +0 -1
  283. package/dist/server/client-module-map.d.ts +0 -47
  284. package/dist/server/client-module-map.d.ts.map +0 -1
  285. package/dist/shared/head-header.d.ts +0 -54
  286. package/dist/shared/head-header.d.ts.map +0 -1
  287. package/src/client/head.ts +0 -61
  288. package/src/client/navigation-api-types.ts +0 -112
  289. package/src/server/client-module-map.ts +0 -58
  290. package/src/shared/head-header.ts +0 -69
@@ -15,7 +15,7 @@
15
15
  * See design/02-rendering-pipeline.md, design/04-authorization.md
16
16
  */
17
17
 
18
- import { createElement } from 'react';
18
+ import { createElement, Fragment } from 'react';
19
19
  import { randomUUID } from 'node:crypto';
20
20
 
21
21
  import { withSpan } from './tracing.js';
@@ -48,6 +48,28 @@ import { SegmentOutlet } from '../client/segment-outlet.js';
48
48
  import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
49
49
  import type { InterceptionContext } from './pipeline.js';
50
50
  import { shouldSkipSegment } from './state-tree-diff.js';
51
+ import type { HeadElement } from './metadata.js';
52
+
53
+ /**
54
+ * Convert HeadElement descriptors to React elements for Float hoisting.
55
+ *
56
+ * React 19 Float hoists <title>, <meta>, and <link> rendered anywhere in
57
+ * the tree into <head> during SSR, and manages them in document.head
58
+ * during client-side rendering. This replaces the X-Timber-Head header
59
+ * side-channel and the imperative client DOM applier.
60
+ */
61
+ export function headElementsToReact(elements: HeadElement[]): React.ReactElement {
62
+ const children: React.ReactElement[] = [];
63
+ for (let i = 0; i < elements.length; i++) {
64
+ const el = elements[i];
65
+ if (el.tag === 'title' && el.content !== undefined) {
66
+ children.push(createElement('title', { key: `t${i}` }, el.content));
67
+ } else if (el.attrs) {
68
+ children.push(createElement(el.tag, { key: `h${i}`, ...el.attrs }));
69
+ }
70
+ }
71
+ return createElement(Fragment, null, ...children);
72
+ }
51
73
  import { loadModule } from './safe-load.js';
52
74
 
53
75
  /**
@@ -119,13 +141,6 @@ export class ParamCoercionError extends Error {
119
141
 
120
142
  // ─── Types ────────────────────────────────────────────────────────────────
121
143
 
122
- /** Head element for client-side metadata updates. */
123
- export interface HeadElement {
124
- tag: string;
125
- content?: string;
126
- attrs?: Record<string, string | null>;
127
- }
128
-
129
144
  /** Layout entry with component and segment. */
130
145
  export interface LayoutComponentEntry {
131
146
  component: (...args: unknown[]) => unknown;
@@ -136,8 +151,6 @@ export interface LayoutComponentEntry {
136
151
  export interface RouteElementResult {
137
152
  /** The React element tree (page wrapped in layouts, access gates, error boundaries). */
138
153
  element: React.ReactElement;
139
- /** Resolved head elements for metadata. */
140
- headElements: HeadElement[];
141
154
  /** Layout components loaded along the segment chain. */
142
155
  layoutComponents: LayoutComponentEntry[];
143
156
  /** Segments from the route match. */
@@ -358,7 +371,12 @@ export async function buildRouteElement(
358
371
  }
359
372
  }
360
373
 
361
- // Resolve metadata
374
+ // Resolve metadata and convert to React elements that React 19 Float
375
+ // hoists into <head> during both SSR and client rendering. This replaces
376
+ // the previous HeadElement descriptor system (string splice for SSR,
377
+ // X-Timber-Head header for RSC nav, imperative DOM updates on client).
378
+ // React manages <title>/<meta>/<link> lifecycle across navigations.
379
+ // See design/16-metadata.md, TIM-1151.
362
380
  const resolvedMetadata = resolveMetadata(metadataEntries);
363
381
  const headElements = renderMetadataToElements(resolvedMetadata);
364
382
 
@@ -423,6 +441,11 @@ export async function buildRouteElement(
423
441
  }
424
442
  }
425
443
 
444
+ // Convert HeadElement descriptors to React elements for Float hoisting.
445
+ // Placed at the page segment level so they're always re-rendered on
446
+ // SPA navigation (sync layouts may be skipped by segment tree diffing).
447
+ const metadataElement = headElementsToReact(headElements);
448
+
426
449
  // Build element tree: page wrapped in layouts (innermost to outermost)
427
450
  const h = createElement as (...args: unknown[]) => React.ReactElement;
428
451
 
@@ -464,6 +487,12 @@ export async function buildRouteElement(
464
487
  element = h(TracedPage, {});
465
488
  }
466
489
 
490
+ // Wrap page + metadata in a Fragment so Float-hoisted elements are at
491
+ // the page segment level (always re-rendered, never skipped by segment
492
+ // tree diffing). React 19 Float hoists the <title>/<meta>/<link>
493
+ // children into <head> regardless of where they appear in the tree.
494
+ element = h(Fragment, null, metadataElement, element);
495
+
467
496
  // Build a lookup of layout components by segment for O(1) access.
468
497
  const layoutBySegment = new Map(
469
498
  layoutComponents.map(({ component, segment }) => [segment, component])
@@ -715,7 +744,6 @@ export async function buildRouteElement(
715
744
 
716
745
  return {
717
746
  element,
718
- headElements: headElements as HeadElement[],
719
747
  layoutComponents,
720
748
  segments,
721
749
  deferSuspenseFor,
@@ -229,20 +229,3 @@ export { isAbortError } from '../render-utils.js';
229
229
 
230
230
  // escapeHtml is now imported from server/utils/escape-html.ts
231
231
  export { escapeHtml } from '../utils/escape-html.js';
232
-
233
- /**
234
- * Parse a Cookie header string into a name→value Map.
235
- * Used to populate NavContext.cookies for SSR hooks (useCookie).
236
- */
237
- export function parseCookiesFromHeader(header: string): Map<string, string> {
238
- const map = new Map<string, string>();
239
- if (!header) return map;
240
- for (const pair of header.split(';')) {
241
- const eqIndex = pair.indexOf('=');
242
- if (eqIndex === -1) continue;
243
- const name = pair.slice(0, eqIndex).trim();
244
- const value = pair.slice(eqIndex + 1).trim();
245
- if (name) map.set(name, value);
246
- }
247
- return map;
248
- }
@@ -30,6 +30,8 @@ import buildManifest from 'virtual:timber-build-manifest';
30
30
  import loadUserInstrumentation from 'virtual:timber-instrumentation';
31
31
  // @ts-expect-error — virtual module provided by timber-entries plugin
32
32
  import loadCacheHandler from 'virtual:timber-cache-handler';
33
+ // @ts-expect-error — virtual module provided by timber-entries plugin
34
+ import { loadCdnPurgeHandler } from 'virtual:timber-cache-handler';
33
35
  // @ts-expect-error — virtual module provided by timber-routing plugin
34
36
  import globalSchemaCodecs from 'virtual:timber-schema';
35
37
 
@@ -61,6 +63,9 @@ import { callSsr } from './ssr-bridge.js';
61
63
  import { isDebug, isDevMode, setDebugFromConfig } from '../debug.js';
62
64
  import { setSourceMapCallback } from '../dev-source-map.js';
63
65
  import { requestContextAls } from '../als-registry.js';
66
+ import { capturePrebuiltPayloads } from '../prebuilt-builder.js';
67
+ import { setPrebuiltPayloadSource } from '../prebuilt/payload-source.js';
68
+ import { setRscFunctions, setComponentRefreshTimeout } from '../prebuilt-runtime.js';
64
69
  import { createAutoSitemapHandler } from '../sitemap-handler.js';
65
70
 
66
71
  /**
@@ -130,6 +135,13 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
130
135
  setCacheHandler(userCacheHandler as Parameters<typeof setCacheHandler>[0]);
131
136
  }
132
137
 
138
+ // Wire the CDN purge handler so revalidateTag triggers CDN purge (TIM-1166).
139
+ const userCdnPurge = await loadCdnPurgeHandler();
140
+ if (userCdnPurge) {
141
+ const { setCdnPurgeHandler } = await import('../../cdn/purge-store.js');
142
+ setCdnPurgeHandler(userCdnPurge as Parameters<typeof setCdnPurgeHandler>[0]);
143
+ }
144
+
133
145
  // Propagate renderTimeoutMs into the default singleflight so cache calls
134
146
  // without a per-call timeoutMs still get deadlock protection (TIM-1032).
135
147
  const renderTimeoutMs = (runtimeConfig as Record<string, unknown>).renderTimeoutMs as
@@ -138,8 +150,63 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
138
150
  if (renderTimeoutMs) {
139
151
  const { setDefaultSingleflightTimeout } = await import('../../cache/timber-cache.js');
140
152
  setDefaultSingleflightTimeout(renderTimeoutMs);
153
+ setComponentRefreshTimeout(renderTimeoutMs);
141
154
  }
142
155
 
156
+ // Wire the prebuilt payload source so cache.component wrappers can look
157
+ // up cached flight payloads at request time (TIM-1122). The source is
158
+ // platform-aware: Node/Nitro reads from disk; Cloudflare reads from
159
+ // static assets via globalThis.__TIMBER_PREBUILT_SOURCE__ (set by the
160
+ // generated _worker.js before this module loads).
161
+ // In dev mode there are no prebuilt artifacts — source stays null and
162
+ // all cache.component wrappers render dynamically.
163
+ if (!isDevMode()) {
164
+ const platformSource = (globalThis as Record<string, unknown>).__TIMBER_PREBUILT_SOURCE__ as
165
+ | import('../prebuilt/payload-source.js').PrebuiltPayloadSource
166
+ | undefined;
167
+ if (platformSource) {
168
+ setPrebuiltPayloadSource(platformSource);
169
+ } else {
170
+ // Default: Node/fs-based source — prebuilt/ is alongside rsc/ in the
171
+ // adapter output directory, so resolve relative to this entry.
172
+ try {
173
+ const { readFile: fsReadFile } = await import('node:fs/promises');
174
+ const { join: pathJoin, dirname } = await import('node:path');
175
+ const { fileURLToPath } = await import('node:url');
176
+ const entryDir = dirname(fileURLToPath(import.meta.url));
177
+ const prebuiltDir = pathJoin(entryDir, '..', 'prebuilt');
178
+ setPrebuiltPayloadSource({
179
+ async loadManifest() {
180
+ try {
181
+ const data = await fsReadFile(pathJoin(prebuiltDir, 'manifest.json'), 'utf-8');
182
+ return JSON.parse(data);
183
+ } catch {
184
+ return null;
185
+ }
186
+ },
187
+ async readPayload(file) {
188
+ try {
189
+ return await fsReadFile(pathJoin(prebuiltDir, file));
190
+ } catch {
191
+ return null;
192
+ }
193
+ },
194
+ });
195
+ } catch {
196
+ // node:fs not available (e.g. edge runtimes without the global
197
+ // override) — source stays null, wrappers render dynamically.
198
+ }
199
+ }
200
+ }
201
+
202
+ // Wire RSC flight functions so prebuilt-runtime can revive cached payloads
203
+ // and capture re-renders without a dynamic import (TIM-1165).
204
+ const rscMod = await import('../../rsc-runtime/rsc.js');
205
+ setRscFunctions({
206
+ createFromReadableStream: rscMod.createFromReadableStream,
207
+ renderToReadableStream: rscMod.renderToReadableStream,
208
+ });
209
+
143
210
  const matchRoute = createRouteMatcher(manifest);
144
211
  const matchMetadataRoute = createMetadataRouteMatcher(manifest);
145
212
 
@@ -161,6 +228,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
161
228
  const clientBootstrap = buildClientScripts({
162
229
  ...runtimeConfig,
163
230
  clientJavascript: clientJsConfig ?? { disabled: false, enableHMRInDev: false },
231
+ devBrowserLogs: (runtimeConfig as Record<string, unknown>).devBrowserLogs as string | undefined,
164
232
  buildManifest: buildManifest as BuildManifest,
165
233
  });
166
234
 
@@ -465,6 +533,21 @@ export { runWithEarlyHintsSender } from '../early-hints-sender.js';
465
533
  // waitUntil support via ALS. See design/11-platform.md §"waitUntil()".
466
534
  export { runWithWaitUntil } from '../waituntil-bridge.js';
467
535
 
536
+ /**
537
+ * Post-build flight capture entry point (TIM-1119). The capture pass must
538
+ * run inside the built RSC bundle — the prebuilt component registry, the
539
+ * ALS instances, and the flight renderer are module instances in THIS
540
+ * bundle's graph; an outside import of the framework package would get
541
+ * fresh, empty copies. The timber-prebuilt plugin imports the built
542
+ * `rsc/index.js` after the 5-step build and calls this export.
543
+ * See design/44-render-at-build-time.md §Build Phase.
544
+ */
545
+ export function __timber_capturePrebuilt(
546
+ options: import('../prebuilt-builder.js').CaptureOptions
547
+ ): Promise<import('../prebuilt-builder.js').CaptureSummary> {
548
+ return capturePrebuiltPayloads(routeManifest, options);
549
+ }
550
+
468
551
  const { handler: _handler, pipelineConfig: _pipelineConfig } = await createRequestHandler(
469
552
  routeManifest,
470
553
  config
@@ -27,7 +27,6 @@ import {
27
27
  collectRouteFonts,
28
28
  collectRouteModulepreloads,
29
29
  } from '../build-manifest.js';
30
- import { isDevMode } from '../debug.js';
31
30
  import type { LayoutEntry } from '../deny-renderer.js';
32
31
  import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
33
32
  import type { ClientBootstrapConfig } from '../html-injectors.js';
@@ -40,12 +39,7 @@ import { parseClientStateTree } from '../state-tree-diff.js';
40
39
 
41
40
  import { handleApiRoute } from './api-handler.js';
42
41
  import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
43
- import {
44
- buildRedirectResponse,
45
- createDebugChannelSink,
46
- escapeHtml,
47
- isRscPayloadRequest,
48
- } from './helpers.js';
42
+ import { buildRedirectResponse, createDebugChannelSink, isRscPayloadRequest } from './helpers.js';
49
43
  import { buildRscPayloadResponse } from './rsc-payload.js';
50
44
  import { renderRscStream } from './rsc-stream.js';
51
45
  import { renderSsrResponse } from './ssr-renderer.js';
@@ -139,8 +133,7 @@ export async function renderRoute(
139
133
  desc: 'build element tree',
140
134
  });
141
135
 
142
- const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } =
143
- routeResult;
136
+ const { element, layoutComponents, deferSuspenseFor, skippedSegments } = routeResult;
144
137
 
145
138
  // Build head HTML for injection into the SSR output.
146
139
  // Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
@@ -186,29 +179,10 @@ export async function renderRoute(
186
179
  }
187
180
  }
188
181
 
189
- for (const el of headElements) {
190
- if (el.tag === 'title' && el.content) {
191
- headHtml += `<title>${escapeHtml(el.content)}</title>`;
192
- } else if (el.attrs) {
193
- const attrs = Object.entries(el.attrs)
194
- .filter(([, v]) => v != null)
195
- .map(([k, v]) => `${k}="${escapeHtml(v as string)}"`)
196
- .join(' ');
197
- headHtml += `<${el.tag} ${attrs}>`;
198
- }
199
- }
200
-
201
- // In dev mode, inject the browser log forwarding script so console
202
- // errors/warnings from the browser appear in the server terminal.
203
- // Set by timber-dev-browser-logs plugin via globalThis (TIM-575).
204
- if (isDevMode()) {
205
- const devLogScript = (globalThis as Record<string, unknown>).__timber_dev_browser_log_script as
206
- | string
207
- | undefined;
208
- if (devLogScript) {
209
- headHtml += devLogScript;
210
- }
211
- }
182
+ // Metadata tags (<title>, <meta>, <link>) are now rendered as React
183
+ // elements in the route element tree — React 19 Float hoists them into
184
+ // <head> during SSR and manages them during client rendering. No string
185
+ // splice needed. See TIM-1151.
212
186
 
213
187
  // Render to RSC Flight stream with signal tracking.
214
188
  const _rscStart = performance.now();
@@ -286,7 +260,6 @@ export async function renderRoute(
286
260
  signals,
287
261
  segments,
288
262
  layoutComponents,
289
- headElements,
290
263
  match,
291
264
  responseHeaders,
292
265
  skippedSegments
@@ -26,6 +26,8 @@
26
26
  * design/13-security.md §"Server actions silently bypass middleware".
27
27
  */
28
28
 
29
+ import { stringifyCookie } from 'cookie';
30
+
29
31
  import type { BuildManifest } from '../build-manifest.js';
30
32
  import { canonicalize } from '../canonicalize.js';
31
33
  import { getCookiesForSsr, seedRequestCookies } from '../cookie-context.js';
@@ -116,18 +118,22 @@ export function validateRevalidatePath(path: unknown): PathValidation {
116
118
  * Serialize a cookie map into a `Cookie:` request header value
117
119
  * (`name1=value1; name2=value2`).
118
120
  *
119
- * Cookie values are RFC 6265 cookie-octet (no `;`, no `,`, no whitespace,
120
- * no `"` / `\`) by construction — `assertValidCookieValue()` rejects
121
- * anything else at write time. Names are validated similarly. Joining
122
- * with `; ` is therefore safe and cannot be used to smuggle additional
123
- * cookies via a malformed value.
121
+ * The RYW map holds decoded logical values (decodeURIComponent has
122
+ * already run), so we use the default encode (encodeURIComponent) to
123
+ * re-encode for the wire — mirroring getCookieJar().toString().
124
+ *
125
+ * Total — never throws. Malformed inbound cookie names (e.g. spaces)
126
+ * survive parseCookie but make stringifyCookie throw, so we fall back
127
+ * to a manual join to stay total over arbitrary request headers.
124
128
  */
125
129
  function serializeCookieMap(cookies: Map<string, string>): string {
126
- const parts: string[] = [];
127
- cookies.forEach((value, name) => {
128
- parts.push(`${name}=${value}`);
129
- });
130
- return parts.join('; ');
130
+ try {
131
+ return stringifyCookie(Object.fromEntries(cookies));
132
+ } catch {
133
+ return Array.from(cookies.entries())
134
+ .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
135
+ .join('; ');
136
+ }
131
137
  }
132
138
 
133
139
  /** Dependencies for the revalidate renderer factory. */
@@ -337,7 +343,6 @@ export function createRevalidateRendererFactory(
337
343
  );
338
344
  return {
339
345
  element: routeResult.element,
340
- headElements: routeResult.headElements,
341
346
  };
342
347
  };
343
348
  };
@@ -5,16 +5,14 @@
5
5
  * returned directly without SSR HTML rendering. The client decodes it via
6
6
  * `createFromFetch` and renders into the hydrated React root.
7
7
  *
8
- * Design docs: 19-client-navigation.md §"RSC Payload Handling",
9
- * 16-metadata.md §"Head Elements"
8
+ * Design docs: 19-client-navigation.md §"RSC Payload Handling"
10
9
  */
11
10
 
12
11
  import type { LayoutEntry } from '../deny-renderer.js';
13
12
  import { renderDenyPageAsRsc } from '../deny-renderer.js';
14
13
  import type { RouteMatch } from '../pipeline.js';
15
- import { encodeHeadHeader, HEAD_HEADER } from '../../shared/head-header.js';
16
14
  import type { RedirectSignal } from '../primitives.js';
17
- import type { HeadElement, LayoutComponentEntry } from '../route-element-builder.js';
15
+ import type { LayoutComponentEntry } from '../route-element-builder.js';
18
16
  import type { ManifestSegmentNode } from '../route-matcher.js';
19
17
 
20
18
  import {
@@ -40,7 +38,6 @@ export async function buildRscPayloadResponse(
40
38
  signals: RenderSignals,
41
39
  segments: ManifestSegmentNode[],
42
40
  layoutComponents: LayoutComponentEntry[],
43
- headElements: HeadElement[],
44
41
  match: RouteMatch,
45
42
  responseHeaders: Headers,
46
43
  skippedSegments?: string[]
@@ -175,12 +172,9 @@ export async function buildRscPayloadResponse(
175
172
 
176
173
  responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
177
174
 
178
- // Send resolved head elements so the client can update document.title
179
- // and <meta> tags after SPA navigation. See design/16-metadata.md.
180
- const encodedHead = encodeHeadHeader(headElements);
181
- if (encodedHead !== null) {
182
- responseHeaders.set(HEAD_HEADER, encodedHead);
183
- }
175
+ // Metadata (<title>/<meta>/<link>) now rides the RSC Flight payload as
176
+ // React elements — React 19 Float manages them in document.head during
177
+ // client rendering. No X-Timber-Head header needed. See TIM-1151.
184
178
 
185
179
  // Send segment metadata so the client can populate its segment cache
186
180
  // for state tree diffing on subsequent navigations.
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Features:
8
8
  * - Collects all static page routes automatically
9
- * - Calls `generateStaticParams()` on dynamic routes to enumerate URLs
9
+ * - Calls `generateStaticSegmentParams()` on dynamic routes to enumerate URLs
10
10
  * - Skips API routes (route.ts), layout-only segments, slots, intercepting routes
11
11
  * - Skips auth-protected routes (access.ts in segment chain) by default
12
12
  * - Automatic pagination: sitemap index when > 50,000 URLs
@@ -42,12 +42,12 @@ export interface SitemapEntry {
42
42
 
43
43
  /**
44
44
  * A route discovered during tree walk that may need async resolution
45
- * (dynamic routes with generateStaticParams).
45
+ * (dynamic routes with generateStaticSegmentParams).
46
46
  */
47
47
  export interface SitemapRoute {
48
48
  /** The URL path pattern (e.g., '/products/[id]'). */
49
49
  urlPath: string;
50
- /** Whether this route has dynamic segments that need generateStaticParams. */
50
+ /** Whether this route has dynamic segments that need generateStaticSegmentParams. */
51
51
  isDynamic: boolean;
52
52
  /** Loader for the page module (from route manifest). */
53
53
  pageLoader?: { load: () => Promise<unknown>; filePath: string };
@@ -78,7 +78,7 @@ export const URLS_PER_SITEMAP = 50_000;
78
78
  * Walk the route tree and collect all routes eligible for the sitemap.
79
79
  *
80
80
  * Pure synchronous function — no module loading. Returns routes that may
81
- * need async resolution (dynamic routes with generateStaticParams).
81
+ * need async resolution (dynamic routes with generateStaticSegmentParams).
82
82
  *
83
83
  * @param root - The root segment node from the route tree
84
84
  * @param config - Sitemap configuration
@@ -159,7 +159,7 @@ export function hasDynamicSegments(urlPath: string): boolean {
159
159
  * Resolve sitemap routes into concrete URLs.
160
160
  *
161
161
  * For static routes, the URL is derived directly from the path.
162
- * For dynamic routes, calls `generateStaticParams()` from the page module.
162
+ * For dynamic routes, calls `generateStaticSegmentParams()` from the page module.
163
163
  *
164
164
  * @param routes - Routes collected by collectSitemapRoutes
165
165
  * @param config - Sitemap configuration
@@ -184,12 +184,18 @@ export async function resolveSitemapUrls(
184
184
  priority: config.defaultPriority,
185
185
  });
186
186
  } else if (loadModule && route.pageLoader) {
187
- // Dynamic route — try to call generateStaticParams
187
+ // Dynamic route — try to call generateStaticSegmentParams
188
188
  try {
189
189
  const mod = await loadModule(route.pageLoader);
190
- const generateFn = mod.generateStaticParams;
190
+ let generateFn = mod.generateStaticSegmentParams;
191
+ if (typeof generateFn !== 'function' && typeof mod.generateStaticParams === 'function') {
192
+ generateFn = mod.generateStaticParams;
193
+ console.warn(
194
+ `[timber] ${route.pageLoader.filePath}: generateStaticParams is deprecated — rename to generateStaticSegmentParams`
195
+ );
196
+ }
191
197
  if (typeof generateFn !== 'function') {
192
- // No generateStaticParams export — skip this dynamic route
198
+ // No generateStaticSegmentParams export — skip this dynamic route
193
199
  continue;
194
200
  }
195
201
 
@@ -208,7 +214,7 @@ export async function resolveSitemapUrls(
208
214
  // Log and skip — don't fail the entire sitemap for one bad route
209
215
  const filePath = route.pageLoader.filePath;
210
216
  console.warn(
211
- `[timber] sitemap: failed to call generateStaticParams for ${filePath}:`,
217
+ `[timber] sitemap: failed to call generateStaticSegmentParams for ${filePath}:`,
212
218
  error instanceof Error ? error.message : error
213
219
  );
214
220
  }
@@ -286,7 +292,7 @@ export function resolvePathWithParams(
286
292
  *
287
293
  * @param root - The root segment node from the route tree
288
294
  * @param config - Sitemap configuration
289
- * @param loadModule - Optional async function to load page modules (for generateStaticParams)
295
+ * @param loadModule - Optional async function to load page modules (for generateStaticSegmentParams)
290
296
  * @returns The sitemap result (single or index with pages)
291
297
  */
292
298
  export async function generateSitemap(
@@ -163,6 +163,50 @@ export async function initDevTracing(
163
163
  globals[DEV_TRACING_INIT_KEY] = true;
164
164
  }
165
165
 
166
+ // ─── Platform Tracer ─────────────────────────────────────────────────────
167
+
168
+ /**
169
+ * A native platform span. Mirrors the subset of the Cloudflare Workers
170
+ * `Span` API that timber uses. See design/17-logging.md §"Cloudflare Native
171
+ * Spans" and TIM-1135.
172
+ */
173
+ export interface PlatformSpan {
174
+ setAttribute(key: string, value: string | number | boolean): void;
175
+ }
176
+
177
+ /**
178
+ * Adapter-provided native tracer. Callback-scoped like Cloudflare's
179
+ * `tracing.enterSpan()` — the span starts when the callback is invoked and
180
+ * ends when it returns or its promise settles. Nesting follows the
181
+ * platform's async context.
182
+ *
183
+ * When registered, withSpan() wraps every framework span in a native span
184
+ * *in addition to* the OTEL emission — dual emission, so external OTEL
185
+ * collectors keep working alongside the platform's native trace view.
186
+ *
187
+ * Register via setPlatformTracer(), or by writing the
188
+ * Symbol.for('timber:platform-tracer') key on globalThis from
189
+ * adapter-generated entry code (what the Cloudflare _worker.js does).
190
+ */
191
+ export interface PlatformTracer {
192
+ enterSpan<T>(name: string, fn: (span: PlatformSpan) => T): T;
193
+ }
194
+
195
+ // globalThis + Symbol.for so a tracer registered by adapter-generated entry
196
+ // code (a separate module instance) is visible in both the RSC and SSR
197
+ // environments — same pattern as the cf-bindings ALS.
198
+ const PLATFORM_TRACER_KEY = Symbol.for('timber:platform-tracer');
199
+
200
+ /** Register (or clear) the native platform tracer for this runtime. */
201
+ export function setPlatformTracer(tracer: PlatformTracer | undefined): void {
202
+ (globalThis as Record<symbol, unknown>)[PLATFORM_TRACER_KEY] = tracer;
203
+ }
204
+
205
+ /** The registered native platform tracer, if any. */
206
+ export function getPlatformTracer(): PlatformTracer | undefined {
207
+ return (globalThis as Record<symbol, unknown>)[PLATFORM_TRACER_KEY] as PlatformTracer | undefined;
208
+ }
209
+
166
210
  // ─── OTEL Span Helpers ───────────────────────────────────────────────────
167
211
 
168
212
  /**
@@ -205,19 +249,54 @@ export async function getTracer(): Promise<import('@opentelemetry/api').Tracer |
205
249
  }
206
250
 
207
251
  /**
208
- * Run a function within an OTEL span. If OTEL is not available, runs the function
209
- * directly without any span overhead.
252
+ * Run a function within a framework span. Composes two emission channels:
253
+ *
254
+ * - **Native platform span** — when an adapter registered a PlatformTracer
255
+ * (Cloudflare Workers), the fn is wrapped in platformTracer.enterSpan()
256
+ * so it appears in the platform's native trace view.
257
+ * - **OTEL span** — when an OTEL SDK is active, the fn also runs inside an
258
+ * OTEL span (dual emission). No SDK and no platform tracer = zero overhead.
210
259
  *
211
260
  * Automatically:
212
261
  * - Creates the span as a child of the current context
213
262
  * - Updates the ALS span ID for log–trace correlation
214
263
  * - Ends the span when the function completes
215
- * - Records exceptions on error
264
+ * - Records exceptions on error (OTEL channel)
216
265
  */
217
266
  export async function withSpan<T>(
218
267
  name: string,
219
268
  attributes: Record<string, string | number | boolean>,
220
269
  fn: () => T | Promise<T>
270
+ ): Promise<T> {
271
+ const platformTracer = getPlatformTracer();
272
+ if (!platformTracer) {
273
+ return runOtelSpan(name, attributes, fn);
274
+ }
275
+
276
+ // Native platform span wraps the OTEL emission (dual emission). The
277
+ // innermost platform span is tracked on the trace store so
278
+ // setSpanAttribute() can reach it after creation — the platform API has
279
+ // no getActiveSpan() equivalent.
280
+ return platformTracer.enterSpan(name, async (span) => {
281
+ for (const key of Object.keys(attributes)) {
282
+ span.setAttribute(key, attributes[key]);
283
+ }
284
+ const store = traceAls.getStore();
285
+ const prevPlatformSpan = store?.platformSpan;
286
+ if (store) store.platformSpan = span;
287
+ try {
288
+ return await runOtelSpan(name, attributes, fn);
289
+ } finally {
290
+ if (store) store.platformSpan = prevPlatformSpan;
291
+ }
292
+ });
293
+ }
294
+
295
+ /** The OTEL half of withSpan() — no-op passthrough when no SDK is active. */
296
+ async function runOtelSpan<T>(
297
+ name: string,
298
+ attributes: Record<string, string | number | boolean>,
299
+ fn: () => T | Promise<T>
221
300
  ): Promise<T> {
222
301
  const tracer = await getTracer();
223
302
  if (!tracer) {
@@ -253,6 +332,12 @@ export async function setSpanAttribute(
253
332
  key: string,
254
333
  value: string | number | boolean
255
334
  ): Promise<void> {
335
+ // Forward to the innermost active native platform span, if any.
336
+ const platformSpan = traceAls.getStore()?.platformSpan;
337
+ if (platformSpan) {
338
+ platformSpan.setAttribute(key, value);
339
+ }
340
+
256
341
  const api = await getOtelApi();
257
342
  if (!api) return;
258
343