@timber-js/app 0.2.0-alpha.71 → 0.2.0-alpha.73

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 (248) hide show
  1. package/dist/_chunks/actions-Dg-ANYHb.js +421 -0
  2. package/dist/_chunks/actions-Dg-ANYHb.js.map +1 -0
  3. package/dist/_chunks/{als-registry-BJARkOcu.js → als-registry-HS0LGUl2.js} +1 -1
  4. package/dist/_chunks/als-registry-HS0LGUl2.js.map +1 -0
  5. package/dist/_chunks/{define-Dz1bqwaS.js → define-C77ScO0m.js} +14 -14
  6. package/dist/_chunks/define-C77ScO0m.js.map +1 -0
  7. package/dist/_chunks/{define-CGuYoRHU.js → define-CZqDwhSu.js} +15 -15
  8. package/dist/_chunks/define-CZqDwhSu.js.map +1 -0
  9. package/dist/_chunks/{define-cookie-B5mewxwM.js → define-cookie-C2IkoFGN.js} +9 -8
  10. package/dist/_chunks/{define-cookie-B5mewxwM.js.map → define-cookie-C2IkoFGN.js.map} +1 -1
  11. package/dist/_chunks/{format-Rn922VH2.js → dev-warnings-DpGRGoDi.js} +4 -26
  12. package/dist/_chunks/dev-warnings-DpGRGoDi.js.map +1 -0
  13. package/dist/_chunks/format-CYBGxKtc.js +14 -0
  14. package/dist/_chunks/format-CYBGxKtc.js.map +1 -0
  15. package/dist/_chunks/{interception-CEdHHviP.js → interception-Dpn_UfAD.js} +2 -2
  16. package/dist/_chunks/{interception-CEdHHviP.js.map → interception-Dpn_UfAD.js.map} +1 -1
  17. package/dist/_chunks/{segment-context-hzuJ048X.js → merge-search-params-Cm_KIWDX.js} +2 -33
  18. package/dist/_chunks/merge-search-params-Cm_KIWDX.js.map +1 -0
  19. package/dist/_chunks/{request-context-CywiO4jV.js → request-context-qMsWgy9C.js} +72 -36
  20. package/dist/_chunks/request-context-qMsWgy9C.js.map +1 -0
  21. package/dist/_chunks/{schema-bridge-C4SwjCQD.js → schema-bridge-C3xl_vfb.js} +1 -1
  22. package/dist/_chunks/{schema-bridge-C4SwjCQD.js.map → schema-bridge-C3xl_vfb.js.map} +1 -1
  23. package/dist/_chunks/segment-context-fHFLF1PE.js +34 -0
  24. package/dist/_chunks/segment-context-fHFLF1PE.js.map +1 -0
  25. package/dist/_chunks/ssr-data-DzuI0bIV.js +88 -0
  26. package/dist/_chunks/ssr-data-DzuI0bIV.js.map +1 -0
  27. package/dist/_chunks/{stale-reload-BLUC_Pl_.js → stale-reload-C2plcNtG.js} +1 -1
  28. package/dist/_chunks/{stale-reload-BLUC_Pl_.js.map → stale-reload-C2plcNtG.js.map} +1 -1
  29. package/dist/_chunks/{handler-store-BVePM7hp.js → tracing-CCYbKn5n.js} +60 -60
  30. package/dist/_chunks/tracing-CCYbKn5n.js.map +1 -0
  31. package/dist/_chunks/use-params-B1AuhI1p.js +307 -0
  32. package/dist/_chunks/use-params-B1AuhI1p.js.map +1 -0
  33. package/dist/_chunks/{use-query-states-DAhgj8Gx.js → use-query-states-Lo_s_pw2.js} +4 -4
  34. package/dist/_chunks/use-query-states-Lo_s_pw2.js.map +1 -0
  35. package/dist/_chunks/{wrappers-LZbghvn0.js → wrappers-_DTmImGt.js} +1 -1
  36. package/dist/_chunks/{wrappers-LZbghvn0.js.map → wrappers-_DTmImGt.js.map} +1 -1
  37. package/dist/adapters/cloudflare-kv-cache.d.ts +64 -0
  38. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -0
  39. package/dist/adapters/cloudflare-kv-cache.js +95 -0
  40. package/dist/adapters/cloudflare-kv-cache.js.map +1 -0
  41. package/dist/cache/index.d.ts +18 -4
  42. package/dist/cache/index.d.ts.map +1 -1
  43. package/dist/cache/index.js +78 -12
  44. package/dist/cache/index.js.map +1 -1
  45. package/dist/cache/sizeof.d.ts +22 -0
  46. package/dist/cache/sizeof.d.ts.map +1 -0
  47. package/dist/cli.d.ts +6 -1
  48. package/dist/cli.d.ts.map +1 -1
  49. package/dist/cli.js +6 -1
  50. package/dist/cli.js.map +1 -1
  51. package/dist/client/browser-dev.d.ts +27 -1
  52. package/dist/client/browser-dev.d.ts.map +1 -1
  53. package/dist/client/browser-entry/action-dispatch.d.ts +17 -0
  54. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -0
  55. package/dist/client/browser-entry/hmr.d.ts +21 -0
  56. package/dist/client/browser-entry/hmr.d.ts.map +1 -0
  57. package/dist/client/browser-entry/hydrate.d.ts +46 -0
  58. package/dist/client/browser-entry/hydrate.d.ts.map +1 -0
  59. package/dist/client/browser-entry/index.d.ts +30 -0
  60. package/dist/client/browser-entry/index.d.ts.map +1 -0
  61. package/dist/client/browser-entry/post-hydration.d.ts +26 -0
  62. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -0
  63. package/dist/client/browser-entry/router-init.d.ts +23 -0
  64. package/dist/client/browser-entry/router-init.d.ts.map +1 -0
  65. package/dist/client/browser-entry/rsc-stream.d.ts +24 -0
  66. package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -0
  67. package/dist/client/browser-entry/scroll.d.ts +19 -0
  68. package/dist/client/browser-entry/scroll.d.ts.map +1 -0
  69. package/dist/client/error-boundary.js +131 -1
  70. package/dist/client/error-boundary.js.map +1 -0
  71. package/dist/client/index.d.ts +4 -19
  72. package/dist/client/index.d.ts.map +1 -1
  73. package/dist/client/index.js +14 -1191
  74. package/dist/client/index.js.map +1 -1
  75. package/dist/client/internal.d.ts +18 -0
  76. package/dist/client/internal.d.ts.map +1 -0
  77. package/dist/client/internal.js +890 -0
  78. package/dist/client/internal.js.map +1 -0
  79. package/dist/client/navigation-context.d.ts.map +1 -1
  80. package/dist/client/router-ref.d.ts +1 -1
  81. package/dist/client/top-loader.d.ts +2 -2
  82. package/dist/client/use-link-status.d.ts +1 -1
  83. package/dist/client/{use-navigation-pending.d.ts → use-pending-navigation.d.ts} +4 -4
  84. package/dist/client/use-pending-navigation.d.ts.map +1 -0
  85. package/dist/client/use-router.d.ts +1 -1
  86. package/dist/codec.d.ts +10 -0
  87. package/dist/codec.d.ts.map +1 -1
  88. package/dist/codec.js +1 -1
  89. package/dist/config-types.d.ts +210 -0
  90. package/dist/config-types.d.ts.map +1 -0
  91. package/dist/content/index.d.ts +1 -10
  92. package/dist/content/index.d.ts.map +1 -1
  93. package/dist/content/index.js +0 -2
  94. package/dist/cookies/define-cookie.d.ts.map +1 -1
  95. package/dist/cookies/index.d.ts +0 -2
  96. package/dist/cookies/index.d.ts.map +1 -1
  97. package/dist/cookies/index.js +2 -3
  98. package/dist/index.d.ts +25 -288
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +261 -43
  101. package/dist/index.js.map +1 -1
  102. package/dist/plugin-context.d.ts +84 -0
  103. package/dist/plugin-context.d.ts.map +1 -0
  104. package/dist/plugins/adapter-build.d.ts +1 -1
  105. package/dist/plugins/adapter-build.d.ts.map +1 -1
  106. package/dist/plugins/build-manifest.d.ts +1 -1
  107. package/dist/plugins/build-manifest.d.ts.map +1 -1
  108. package/dist/plugins/build-report.d.ts +1 -1
  109. package/dist/plugins/build-report.d.ts.map +1 -1
  110. package/dist/plugins/content.d.ts +1 -1
  111. package/dist/plugins/content.d.ts.map +1 -1
  112. package/dist/plugins/dev-browser-logs.d.ts +1 -1
  113. package/dist/plugins/dev-browser-logs.d.ts.map +1 -1
  114. package/dist/plugins/dev-logs.d.ts +1 -1
  115. package/dist/plugins/dev-logs.d.ts.map +1 -1
  116. package/dist/plugins/dev-server.d.ts +1 -1
  117. package/dist/plugins/dev-server.d.ts.map +1 -1
  118. package/dist/plugins/entries.d.ts +1 -1
  119. package/dist/plugins/entries.d.ts.map +1 -1
  120. package/dist/plugins/fonts.d.ts +1 -1
  121. package/dist/plugins/fonts.d.ts.map +1 -1
  122. package/dist/plugins/mdx.d.ts +1 -1
  123. package/dist/plugins/mdx.d.ts.map +1 -1
  124. package/dist/plugins/routing.d.ts +1 -1
  125. package/dist/plugins/routing.d.ts.map +1 -1
  126. package/dist/plugins/shims.d.ts +1 -1
  127. package/dist/plugins/shims.d.ts.map +1 -1
  128. package/dist/plugins/static-build.d.ts +4 -4
  129. package/dist/plugins/static-build.d.ts.map +1 -1
  130. package/dist/routing/index.js +1 -1
  131. package/dist/search-params/define.d.ts +6 -6
  132. package/dist/search-params/define.d.ts.map +1 -1
  133. package/dist/search-params/index.d.ts +1 -2
  134. package/dist/search-params/index.d.ts.map +1 -1
  135. package/dist/search-params/index.js +4 -4
  136. package/dist/search-params/registry.d.ts +1 -1
  137. package/dist/search-params/registry.d.ts.map +1 -1
  138. package/dist/segment-params/define.d.ts +6 -6
  139. package/dist/segment-params/define.d.ts.map +1 -1
  140. package/dist/segment-params/index.d.ts +0 -1
  141. package/dist/segment-params/index.d.ts.map +1 -1
  142. package/dist/segment-params/index.js +3 -3
  143. package/dist/server/als-registry.d.ts +1 -1
  144. package/dist/server/dev-holding-server.d.ts +52 -0
  145. package/dist/server/dev-holding-server.d.ts.map +1 -0
  146. package/dist/server/dev-warnings.d.ts +1 -7
  147. package/dist/server/dev-warnings.d.ts.map +1 -1
  148. package/dist/server/index.d.ts +6 -45
  149. package/dist/server/index.d.ts.map +1 -1
  150. package/dist/server/index.js +7 -3272
  151. package/dist/server/index.js.map +1 -1
  152. package/dist/server/internal.d.ts +46 -0
  153. package/dist/server/internal.d.ts.map +1 -0
  154. package/dist/server/internal.js +2865 -0
  155. package/dist/server/internal.js.map +1 -0
  156. package/dist/server/pipeline.d.ts.map +1 -1
  157. package/dist/server/primitives.d.ts +41 -17
  158. package/dist/server/primitives.d.ts.map +1 -1
  159. package/dist/server/request-context.d.ts +45 -15
  160. package/dist/server/request-context.d.ts.map +1 -1
  161. package/dist/server/tracing.d.ts +4 -4
  162. package/dist/server/tracing.d.ts.map +1 -1
  163. package/dist/shims/headers.d.ts +2 -1
  164. package/dist/shims/headers.d.ts.map +1 -1
  165. package/dist/shims/navigation.d.ts +2 -1
  166. package/dist/shims/navigation.d.ts.map +1 -1
  167. package/package.json +19 -13
  168. package/src/adapters/cloudflare-kv-cache.ts +142 -0
  169. package/src/cache/handler-store.ts +2 -2
  170. package/src/cache/index.ts +74 -15
  171. package/src/cache/sizeof.ts +31 -0
  172. package/src/cli.ts +6 -1
  173. package/src/client/browser-dev.ts +128 -1
  174. package/src/client/browser-entry/action-dispatch.ts +116 -0
  175. package/src/client/browser-entry/hmr.ts +81 -0
  176. package/src/client/browser-entry/hydrate.ts +145 -0
  177. package/src/client/browser-entry/index.ts +138 -0
  178. package/src/client/browser-entry/post-hydration.ts +119 -0
  179. package/src/client/browser-entry/router-init.ts +184 -0
  180. package/src/client/browser-entry/rsc-stream.ts +157 -0
  181. package/src/client/browser-entry/scroll.ts +27 -0
  182. package/src/client/index.ts +10 -38
  183. package/src/client/internal.ts +57 -0
  184. package/src/client/navigation-context.ts +6 -2
  185. package/src/client/navigation-root.tsx +1 -1
  186. package/src/client/router-ref.ts +1 -1
  187. package/src/client/top-loader.tsx +2 -2
  188. package/src/client/use-link-status.ts +1 -1
  189. package/src/client/{use-navigation-pending.ts → use-pending-navigation.ts} +5 -5
  190. package/src/client/use-query-states.ts +2 -2
  191. package/src/client/use-router.ts +1 -1
  192. package/src/codec.ts +15 -0
  193. package/src/config-types.ts +208 -0
  194. package/src/content/index.ts +5 -13
  195. package/src/cookies/define-cookie.ts +9 -7
  196. package/src/cookies/index.ts +6 -5
  197. package/src/index.ts +84 -416
  198. package/src/plugin-context.ts +200 -0
  199. package/src/plugins/adapter-build.ts +1 -1
  200. package/src/plugins/build-manifest.ts +1 -1
  201. package/src/plugins/build-report.ts +1 -1
  202. package/src/plugins/content.ts +1 -1
  203. package/src/plugins/dev-browser-logs.ts +1 -1
  204. package/src/plugins/dev-logs.ts +1 -1
  205. package/src/plugins/dev-server.ts +16 -1
  206. package/src/plugins/entries.ts +2 -2
  207. package/src/plugins/fonts.ts +4 -3
  208. package/src/plugins/mdx.ts +1 -1
  209. package/src/plugins/routing.ts +1 -1
  210. package/src/plugins/shims.ts +53 -5
  211. package/src/plugins/static-build.ts +8 -6
  212. package/src/search-params/define.ts +22 -22
  213. package/src/search-params/index.ts +3 -3
  214. package/src/search-params/registry.ts +1 -1
  215. package/src/segment-params/define.ts +18 -18
  216. package/src/segment-params/index.ts +2 -1
  217. package/src/server/action-handler.ts +1 -1
  218. package/src/server/als-registry.ts +3 -3
  219. package/src/server/dev-holding-server.ts +185 -0
  220. package/src/server/dev-warnings.ts +2 -21
  221. package/src/server/html-injectors.ts +3 -3
  222. package/src/server/index.ts +25 -180
  223. package/src/server/internal.ts +169 -0
  224. package/src/server/pipeline.ts +12 -7
  225. package/src/server/primitives.ts +71 -30
  226. package/src/server/request-context.ts +77 -39
  227. package/src/server/route-element-builder.ts +1 -1
  228. package/src/server/rsc-entry/index.ts +2 -2
  229. package/src/server/rsc-entry/ssr-renderer.ts +1 -1
  230. package/src/server/slot-resolver.ts +1 -1
  231. package/src/server/tracing.ts +6 -6
  232. package/src/server/tree-builder.ts +1 -1
  233. package/src/shims/headers.ts +5 -1
  234. package/src/shims/navigation.ts +5 -1
  235. package/dist/_chunks/als-registry-BJARkOcu.js.map +0 -1
  236. package/dist/_chunks/define-CGuYoRHU.js.map +0 -1
  237. package/dist/_chunks/define-Dz1bqwaS.js.map +0 -1
  238. package/dist/_chunks/error-boundary-D9hzsveV.js +0 -216
  239. package/dist/_chunks/error-boundary-D9hzsveV.js.map +0 -1
  240. package/dist/_chunks/format-Rn922VH2.js.map +0 -1
  241. package/dist/_chunks/handler-store-BVePM7hp.js.map +0 -1
  242. package/dist/_chunks/request-context-CywiO4jV.js.map +0 -1
  243. package/dist/_chunks/segment-context-hzuJ048X.js.map +0 -1
  244. package/dist/_chunks/use-query-states-DAhgj8Gx.js.map +0 -1
  245. package/dist/client/browser-entry.d.ts +0 -21
  246. package/dist/client/browser-entry.d.ts.map +0 -1
  247. package/dist/client/use-navigation-pending.d.ts.map +0 -1
  248. package/src/client/browser-entry.ts +0 -846
@@ -1,5 +1,7 @@
1
1
  // @timber-js/app/cache — Caching primitives
2
2
 
3
+ import { estimateByteSize } from './sizeof.js';
4
+
3
5
  export interface CacheHandler {
4
6
  get(key: string): Promise<{ value: unknown; stale: boolean } | null>;
5
7
  set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;
@@ -19,15 +21,33 @@ export interface CacheOptions<Fn extends (...args: any[]) => any> {
19
21
 
20
22
  export interface MemoryCacheHandlerOptions {
21
23
  /** Maximum number of entries. Oldest accessed entries are evicted first. Default: 1000. */
24
+ maxEntries?: number;
25
+ /**
26
+ * @deprecated Use `maxEntries` instead. Will be removed in a future release.
27
+ * Alias for `maxEntries` — maximum number of entries (not bytes).
28
+ */
22
29
  maxSize?: number;
30
+ /** Maximum total byte budget for all cached values. Oldest entries are evicted when exceeded. Default: no limit. */
31
+ maxBytes?: number;
32
+ /** Maximum byte size for a single cache entry. Entries exceeding this are silently dropped. Default: no limit. */
33
+ maxEntryBytes?: number;
23
34
  }
24
35
 
25
36
  export class MemoryCacheHandler implements CacheHandler {
26
- private store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();
27
- private maxSize: number;
37
+ private store = new Map<
38
+ string,
39
+ { value: unknown; expiresAt: number; tags: string[]; byteSize: number }
40
+ >();
41
+ private maxEntries: number;
42
+ private maxBytes: number | undefined;
43
+ private maxEntryBytes: number | undefined;
44
+ private currentBytes = 0;
28
45
 
29
46
  constructor(opts?: MemoryCacheHandlerOptions) {
30
- this.maxSize = opts?.maxSize ?? 1000;
47
+ // maxEntries takes precedence over deprecated maxSize
48
+ this.maxEntries = opts?.maxEntries ?? opts?.maxSize ?? 1000;
49
+ this.maxBytes = opts?.maxBytes;
50
+ this.maxEntryBytes = opts?.maxEntryBytes;
31
51
  }
32
52
 
33
53
  async get(key: string) {
@@ -43,18 +63,33 @@ export class MemoryCacheHandler implements CacheHandler {
43
63
  }
44
64
 
45
65
  async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {
46
- // If key already exists, delete first to refresh insertion order
66
+ const byteSize = estimateByteSize(value);
67
+
68
+ // Reject entries exceeding per-entry byte limit
69
+ if (this.maxEntryBytes !== undefined && byteSize > this.maxEntryBytes) {
70
+ return;
71
+ }
72
+
73
+ // If key already exists, delete first to refresh insertion order and reclaim bytes
47
74
  if (this.store.has(key)) {
75
+ const existing = this.store.get(key)!;
76
+ this.currentBytes -= existing.byteSize;
48
77
  this.store.delete(key);
49
78
  }
50
79
 
51
- // Evict oldest entries (front of Map) if at capacity
52
- while (this.store.size >= this.maxSize) {
53
- const oldest = this.store.keys().next().value;
54
- if (oldest !== undefined) {
55
- this.store.delete(oldest);
56
- } else {
57
- break;
80
+ // Evict oldest entries (front of Map) if at entry count capacity
81
+ while (this.store.size >= this.maxEntries) {
82
+ this.evictOldest();
83
+ }
84
+
85
+ // Evict oldest entries if byte budget would be exceeded
86
+ if (this.maxBytes !== undefined) {
87
+ while (this.currentBytes + byteSize > this.maxBytes && this.store.size > 0) {
88
+ this.evictOldest();
89
+ }
90
+ // If the single entry exceeds the total byte budget, don't store it
91
+ if (this.currentBytes + byteSize > this.maxBytes) {
92
+ return;
58
93
  }
59
94
  }
60
95
 
@@ -62,16 +97,23 @@ export class MemoryCacheHandler implements CacheHandler {
62
97
  value,
63
98
  expiresAt: Date.now() + opts.ttl * 1000,
64
99
  tags: opts.tags,
100
+ byteSize,
65
101
  });
102
+ this.currentBytes += byteSize;
66
103
  }
67
104
 
68
105
  async invalidate(opts: { key?: string; tag?: string }) {
69
106
  if (opts.key) {
70
- this.store.delete(opts.key);
107
+ const entry = this.store.get(opts.key);
108
+ if (entry) {
109
+ this.currentBytes -= entry.byteSize;
110
+ this.store.delete(opts.key);
111
+ }
71
112
  }
72
113
  if (opts.tag) {
73
114
  for (const [key, entry] of this.store) {
74
115
  if (entry.tags.includes(opts.tag)) {
116
+ this.currentBytes -= entry.byteSize;
75
117
  this.store.delete(key);
76
118
  }
77
119
  }
@@ -82,6 +124,21 @@ export class MemoryCacheHandler implements CacheHandler {
82
124
  get size(): number {
83
125
  return this.store.size;
84
126
  }
127
+
128
+ /** Estimated total byte size of all cached values. */
129
+ get bytes(): number {
130
+ return this.currentBytes;
131
+ }
132
+
133
+ /** Evict the oldest entry (front of Map). */
134
+ private evictOldest(): void {
135
+ const oldest = this.store.keys().next().value;
136
+ if (oldest !== undefined) {
137
+ const entry = this.store.get(oldest)!;
138
+ this.currentBytes -= entry.byteSize;
139
+ this.store.delete(oldest);
140
+ }
141
+ }
85
142
  }
86
143
 
87
144
  export { RedisCacheHandler } from './redis-handler';
@@ -90,6 +147,8 @@ export { cache } from './cache-api';
90
147
  export { setCacheHandler, getCacheHandler } from './handler-store';
91
148
  // NOTE: registerCachedFunction (runtime for 'use cache' directive) removed.
92
149
  // Future feature pending design doc. See design/06-caching.md.
93
- export { stableStringify } from './stable-stringify';
94
- export { createSingleflight, SingleflightTimeoutError } from './singleflight';
95
- export type { Singleflight, SingleflightOptions } from './singleflight';
150
+ export { estimateByteSize } from './sizeof';
151
+ // stableStringify, createSingleflight, and SingleflightTimeoutError are
152
+ // internal utilities used by the cache implementation. They are not
153
+ // re-exported here — import directly from the source files if needed
154
+ // within the package. See TIM-720.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Lightweight byte-size estimation for cache entries.
3
+ *
4
+ * Estimates the in-memory byte cost of a JavaScript value using
5
+ * JSON.stringify().length * 2 (UTF-16 char width). This is a rough
6
+ * approximation — it doesn't account for V8 object overhead, Map
7
+ * metadata, or non-serializable values — but it's fast and good
8
+ * enough for cache budget enforcement.
9
+ *
10
+ * Values that fail JSON serialization (circular references, BigInt,
11
+ * etc.) return 0, allowing the entry to be cached without counting
12
+ * toward the byte budget. This is a conservative choice: it's better
13
+ * to cache and undercount than to reject the entry.
14
+ */
15
+
16
+ /**
17
+ * Estimate the byte size of a value.
18
+ *
19
+ * Uses `JSON.stringify(value).length * 2` to approximate UTF-16
20
+ * in-memory size. Returns 0 if the value is not serializable.
21
+ */
22
+ export function estimateByteSize(value: unknown): number {
23
+ try {
24
+ const json = JSON.stringify(value);
25
+ if (json === undefined) return 0;
26
+ // Each JS string character is 2 bytes (UTF-16)
27
+ return json.length * 2;
28
+ } catch {
29
+ return 0;
30
+ }
31
+ }
package/src/cli.ts CHANGED
@@ -63,7 +63,12 @@ export interface ViteDeps {
63
63
 
64
64
  /**
65
65
  * Start the Vite dev server.
66
- * Middleware re-runs on file change via HMR wiring in timber-routing.
66
+ *
67
+ * The timber plugin binds the port immediately with a holding page
68
+ * (in rootSync's config hook) so browsers see a "starting..." page
69
+ * instead of ERR_CONNECTION_REFUSED during initialization.
70
+ *
71
+ * See design/21-dev-server.md, TIM-665.
67
72
  */
68
73
  export async function runDev(options: CommandOptions, _deps?: ViteDeps): Promise<void> {
69
74
  const createServer = _deps?.createServer ?? (await import('vite')).createServer;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Dev-only browser helpers — server log replay and client error forwarding.
2
+ * Dev-only browser helpers — server log replay, client error forwarding,
3
+ * and compiling overlay.
3
4
  *
4
5
  * These are only active when import.meta.hot is available (Vite dev mode).
5
6
  * Extracted from browser-entry.ts to keep files under 500 lines.
@@ -100,6 +101,132 @@ export function setupServerLogReplay(hot: Pick<HotInterface, 'on'>): void {
100
101
  });
101
102
  }
102
103
 
104
+ // ─── Compiling Overlay ──────────────────────────────────────────
105
+
106
+ /**
107
+ * Compiling overlay state.
108
+ *
109
+ * A small non-blocking indicator in the bottom-left corner that shows
110
+ * "Compiling..." during HMR/module updates. Uses a 200ms debounce so
111
+ * near-instant updates never flash the indicator.
112
+ *
113
+ * The overlay is lazily created on first show and reused thereafter.
114
+ * All styles are inline — no external CSS dependency.
115
+ */
116
+
117
+ let overlayEl: HTMLDivElement | null = null;
118
+ let showTimeoutId: ReturnType<typeof setTimeout> | null = null;
119
+ let pendingUpdates = 0;
120
+
121
+ /** Debounce delay before showing the overlay (ms). */
122
+ const SHOW_DELAY_MS = 200;
123
+
124
+ /**
125
+ * Get the effective debounce delay.
126
+ * Checks for a test override on `window.__timber_compiling_debounce`
127
+ * (number) to allow E2E tests to bypass the debounce.
128
+ */
129
+ function getShowDelay(): number {
130
+ const win = window as unknown as { __timber_compiling_debounce?: number };
131
+ return typeof win.__timber_compiling_debounce === 'number'
132
+ ? win.__timber_compiling_debounce
133
+ : SHOW_DELAY_MS;
134
+ }
135
+
136
+ /**
137
+ * Lazily create the overlay DOM element.
138
+ * Appended to document.body with fixed positioning in the bottom-left.
139
+ */
140
+ function getOrCreateOverlay(): HTMLDivElement {
141
+ if (overlayEl) return overlayEl;
142
+
143
+ const el = document.createElement('div');
144
+ el.setAttribute('data-timber-compiling-overlay', '');
145
+ Object.assign(el.style, {
146
+ position: 'fixed',
147
+ bottom: '16px',
148
+ left: '16px',
149
+ padding: '6px 12px',
150
+ background: 'rgba(0, 0, 0, 0.85)',
151
+ color: '#fff',
152
+ fontSize: '13px',
153
+ fontFamily:
154
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
155
+ fontWeight: '500',
156
+ borderRadius: '6px',
157
+ zIndex: '2147483647',
158
+ pointerEvents: 'none',
159
+ opacity: '0',
160
+ transition: 'opacity 150ms ease',
161
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
162
+ lineHeight: '1',
163
+ });
164
+ el.textContent = 'Compiling…';
165
+ document.body.appendChild(el);
166
+ overlayEl = el;
167
+ return el;
168
+ }
169
+
170
+ /**
171
+ * Signal that an HMR/module update has started.
172
+ *
173
+ * If an update is already tracked, this increments the counter.
174
+ * The overlay is shown after SHOW_DELAY_MS to avoid flashing
175
+ * for fast updates.
176
+ *
177
+ * When the delay is 0 (E2E test override), the overlay is shown
178
+ * synchronously to eliminate the race where hideCompilingOverlay()
179
+ * clears the show timeout before it fires.
180
+ */
181
+ export function showCompilingOverlay(): void {
182
+ pendingUpdates++;
183
+ if (pendingUpdates === 1) {
184
+ const delay = getShowDelay();
185
+ if (delay <= 0) {
186
+ // Synchronous show — no setTimeout race with hideCompilingOverlay().
187
+ // Used by E2E tests to guarantee the overlay is visible before
188
+ // the async HMR refresh begins its network roundtrip.
189
+ const el = getOrCreateOverlay();
190
+ el.style.opacity = '1';
191
+ } else {
192
+ // Production path — debounce to avoid flashing for fast updates
193
+ showTimeoutId = setTimeout(() => {
194
+ showTimeoutId = null;
195
+ const el = getOrCreateOverlay();
196
+ el.style.opacity = '1';
197
+ }, delay);
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Signal that an HMR/module update has completed.
204
+ *
205
+ * When all pending updates are resolved, the overlay is hidden.
206
+ * If the overlay was never shown (update completed within the
207
+ * debounce window), the timeout is cleared — no visual flash.
208
+ *
209
+ * Test hook: if `window.__timber_hold_compiling_overlay` is true,
210
+ * the overlay stays visible. E2E tests use this to avoid a race
211
+ * where the RSC refresh completes between polling intervals,
212
+ * making the overlay impossible to observe.
213
+ */
214
+ export function hideCompilingOverlay(): void {
215
+ const win = window as unknown as { __timber_hold_compiling_overlay?: boolean };
216
+ if (win.__timber_hold_compiling_overlay) return;
217
+
218
+ pendingUpdates = Math.max(0, pendingUpdates - 1);
219
+ if (pendingUpdates === 0) {
220
+ if (showTimeoutId !== null) {
221
+ clearTimeout(showTimeoutId);
222
+ showTimeoutId = null;
223
+ }
224
+ if (overlayEl) {
225
+ overlayEl.style.opacity = '0';
226
+ }
227
+ }
228
+ }
229
+
103
230
  // ─── Client Error Forwarding ────────────────────────────────────────
104
231
 
105
232
  /**
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Server Action Dispatch — registers the callServer callback.
3
+ *
4
+ * When React encounters a server reference (from `'use server'` modules),
5
+ * it calls `callServer(id, args)` to dispatch the action to the server.
6
+ * The RSC plugin delegates to `globalThis.__viteRscCallServer` which is
7
+ * set by `setServerCallback`.
8
+ *
9
+ * The callback:
10
+ * 1. Serializes args via `encodeReply` (RSC wire format)
11
+ * 2. POSTs to the current URL with `Accept: text/x-component`
12
+ * 3. Decodes the RSC response stream
13
+ *
14
+ * See design/08-forms-and-actions.md §"Client-Side Form Mechanics"
15
+ */
16
+
17
+ import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runtime/browser.js';
18
+ import { getRouter } from '#client-internal';
19
+ import { setHardNavigating } from '../navigation-root.js';
20
+ import { isStaleClientReference, triggerStaleReload } from '../stale-reload.js';
21
+ import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
22
+
23
+ export function setupServerActions(): void {
24
+ setServerCallback(async (id: string, args: unknown[]) => {
25
+ const body = await encodeReply(args);
26
+
27
+ // Track the X-Timber-Revalidation header from the response.
28
+ // We intercept the fetch promise to read headers before createFromFetch
29
+ // consumes the body stream.
30
+ let hasRevalidation = false;
31
+ let hasRedirect = false;
32
+ let headElementsJson: string | null = null;
33
+
34
+ // Build action request headers. Include deployment ID for version
35
+ // skew detection (TIM-446) — the server rejects stale actions gracefully.
36
+ const actionHeaders: Record<string, string> = {
37
+ 'Accept': 'text/x-component',
38
+ 'x-rsc-action': id,
39
+ };
40
+ const actionDeploymentId = getClientDeploymentId();
41
+ if (actionDeploymentId) {
42
+ actionHeaders[DEPLOYMENT_ID_HEADER] = actionDeploymentId;
43
+ }
44
+
45
+ const response = fetch(window.location.href, {
46
+ method: 'POST',
47
+ headers: actionHeaders,
48
+ body,
49
+ }).then((res) => {
50
+ // Version skew detection (TIM-446): if the server signals a reload,
51
+ // trigger a full page load to pick up the new deployment.
52
+ if (res.headers.get(RELOAD_HEADER) === '1') {
53
+ window.location.reload();
54
+ throw new Error('Version skew detected — reloading page');
55
+ }
56
+ hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
57
+ hasRedirect = res.headers.get('X-Timber-Redirect') != null;
58
+ headElementsJson = res.headers.get('X-Timber-Head');
59
+ return res;
60
+ });
61
+
62
+ let decoded: unknown;
63
+ try {
64
+ decoded = await createFromFetch(response);
65
+ } catch (error) {
66
+ if (isStaleClientReference(error)) {
67
+ triggerStaleReload();
68
+ // Return a never-resolving promise to prevent further processing
69
+ return new Promise(() => {});
70
+ }
71
+ throw error;
72
+ }
73
+
74
+ // Handle redirect — server encoded the redirect location in the RSC stream
75
+ // instead of returning HTTP 302. Perform a client-side SPA navigation.
76
+ if (hasRedirect) {
77
+ const wrapper = decoded as { _redirect: string; _status: number };
78
+ try {
79
+ const router = getRouter();
80
+ void router.navigate(wrapper._redirect);
81
+ } catch {
82
+ // Router not yet initialized — fall back to full navigation.
83
+ // Set hard-navigating flag to prevent Navigation API interception
84
+ // and React from rendering during page teardown. See TIM-626.
85
+ setHardNavigating(true);
86
+ window.location.href = wrapper._redirect;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ if (hasRevalidation) {
92
+ // Piggybacked response: wrapper object { _action, _tree }
93
+ // Apply the revalidated tree directly — no separate router.refresh() needed.
94
+ const wrapper = decoded as { _action: unknown; _tree: unknown };
95
+ try {
96
+ const router = getRouter();
97
+ const headElements = headElementsJson ? JSON.parse(headElementsJson) : null;
98
+ router.applyRevalidation(wrapper._tree, headElements);
99
+ } catch {
100
+ // Router not yet initialized — fall through
101
+ }
102
+ return wrapper._action;
103
+ }
104
+
105
+ // No piggybacked revalidation — refresh to pick up any mutations.
106
+ // This covers actions that don't call revalidatePath().
107
+ try {
108
+ const router = getRouter();
109
+ void router.refresh();
110
+ } catch {
111
+ // Router not yet initialized (rare edge case during bootstrap)
112
+ }
113
+
114
+ return decoded;
115
+ });
116
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * HMR & Dev Tooling — dev-only wiring for hot module replacement.
3
+ *
4
+ * Handles:
5
+ * - RSC module invalidation (rsc:update → router.refresh())
6
+ * - Dev warnings forwarded from server via WebSocket
7
+ * - Server console log replay in browser
8
+ * - Client error forwarding to Vite error overlay
9
+ *
10
+ * See design/21-dev-server.md §"HMR Wiring"
11
+ */
12
+
13
+ import type { RouterInstance } from '#client-internal';
14
+ import {
15
+ setupServerLogReplay,
16
+ setupClientErrorForwarding,
17
+ showCompilingOverlay,
18
+ hideCompilingOverlay,
19
+ } from '../browser-dev.js';
20
+
21
+ type HotApi = {
22
+ on(event: string, cb: (...args: unknown[]) => void): void;
23
+ send(event: string, data: unknown): void;
24
+ };
25
+
26
+ /**
27
+ * Set up HMR and dev tool integration.
28
+ *
29
+ * Reads `import.meta.hot` to register dev-only event listeners.
30
+ * In production builds, `import.meta.hot` is undefined and this
31
+ * function is a no-op.
32
+ */
33
+ export function setupHmr(router: RouterInstance): void {
34
+ // Vite injects import.meta.hot in dev mode. Cast to access it without
35
+ // requiring vite/client types in the package tsconfig.
36
+ const hot = (import.meta as unknown as { hot?: HotApi }).hot;
37
+ if (!hot) return;
38
+
39
+ // ─── Compiling overlay: Vite client HMR updates ──────────────
40
+ // vite:beforeUpdate fires when Vite begins applying client-side
41
+ // module updates (React Fast Refresh, CSS hot update, etc.).
42
+ // vite:afterUpdate fires once all updates are applied.
43
+ hot.on('vite:beforeUpdate', () => {
44
+ showCompilingOverlay();
45
+ });
46
+ hot.on('vite:afterUpdate', () => {
47
+ hideCompilingOverlay();
48
+ });
49
+
50
+ // ─── RSC module invalidation ─────────────────────────────────
51
+ // rsc:update fires when a server component is edited.
52
+ // Show the compiling overlay while the RSC refresh is in flight.
53
+ hot.on('rsc:update', () => {
54
+ showCompilingOverlay();
55
+ void router.refresh().finally(() => {
56
+ hideCompilingOverlay();
57
+ });
58
+ });
59
+
60
+ // Listen for dev warnings forwarded from the server via WebSocket.
61
+ // See dev-warnings.ts — emitOnce() sends these via server.hot.send().
62
+ hot.on('timber:dev-warning', (data: unknown) => {
63
+ const warning = data as { level: string; message: string };
64
+ if (warning.level === 'error') {
65
+ console.error(warning.message);
66
+ } else {
67
+ console.warn(warning.message);
68
+ }
69
+ });
70
+
71
+ // Listen for server console logs forwarded via WebSocket.
72
+ // Replays them in the browser console with a [SERVER] prefix
73
+ // so developers can see server output without switching to the terminal.
74
+ // See plugins/dev-logs.ts.
75
+ setupServerLogReplay(hot);
76
+
77
+ // Forward uncaught client errors to the server for the dev overlay.
78
+ // The server source-maps the stack and sends it back via Vite's
79
+ // error overlay protocol. See dev-server.ts §client error listener.
80
+ setupClientErrorForwarding(hot);
81
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Hydration — pre-hydration sequence and React root creation.
3
+ *
4
+ * Handles two paths:
5
+ * 1. RSC payload available: hydrateRoot with NavigationProvider wrapping
6
+ * 2. No RSC payload: deferred root creation via installDeferredNavigation
7
+ *
8
+ * Pre-hydration ordering contract (MUST execute in this order):
9
+ * 1. initRouter() — creates the global router so useRouter()
10
+ * works during render
11
+ * 2. setCurrentParams() — populates params snapshot so
12
+ * + setNavigationState() useSegmentParams() and usePathname()
13
+ * return correct values during hydration
14
+ * 3. hydrateRoot() — synchronously executes component render
15
+ * functions that depend on steps 1-2
16
+ *
17
+ * See design/19-client-navigation.md §"NavigationContext"
18
+ */
19
+
20
+ import { createElement } from 'react';
21
+ import { hydrateRoot, createRoot } from 'react-dom/client';
22
+ import { getRouterOrNull, setCurrentParams } from '#client-internal';
23
+ import {
24
+ NavigationProvider,
25
+ getNavigationState,
26
+ setNavigationState,
27
+ } from '../navigation-context.js';
28
+ import { TimberNuqsAdapter } from '../nuqs-adapter.js';
29
+ import { isPageUnloading } from '../unload-guard.js';
30
+ import { NavigationRoot, installDeferredNavigation } from '../navigation-root.js';
31
+ import type { TopLoaderConfig } from '../top-loader.js';
32
+
33
+ import type { RscStreamResult } from './rsc-stream.js';
34
+
35
+ interface HydrateOptions {
36
+ /** RSC stream result (null if no inlined payload) */
37
+ rscResult: RscStreamResult | null;
38
+ /** Runtime config from virtual:timber-config */
39
+ config: { topLoader?: TopLoaderConfig };
40
+ }
41
+
42
+ /**
43
+ * Run the pre-hydration sequence: read server-embedded params and
44
+ * set navigation state. Must be called AFTER initRouter().
45
+ */
46
+ export function runPreHydration(): void {
47
+ const earlyParams = (self as unknown as Record<string, unknown>).__timber_params;
48
+ if (earlyParams && typeof earlyParams === 'object') {
49
+ setCurrentParams(earlyParams as Record<string, string | string[]>);
50
+ setNavigationState({
51
+ params: earlyParams as Record<string, string | string[]>,
52
+ pathname: window.location.pathname,
53
+ });
54
+ delete (self as unknown as Record<string, unknown>).__timber_params;
55
+ } else {
56
+ setNavigationState({
57
+ params: {},
58
+ pathname: window.location.pathname,
59
+ });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Hydrate the React tree or set up deferred root creation.
65
+ *
66
+ * When an RSC payload is available, wraps it with NavigationProvider +
67
+ * TimberNuqsAdapter + NavigationRoot and calls hydrateRoot on the
68
+ * document.
69
+ *
70
+ * When no RSC payload is available (JS-only client), sets up deferred
71
+ * navigation so the first client navigation creates the React root.
72
+ */
73
+ export function hydrateApp({ rscResult, config }: HydrateOptions): void {
74
+ if (rscResult) {
75
+ const element = rscResult.element;
76
+
77
+ // Wrap with NavigationProvider (for atomic useParams/usePathname),
78
+ // TimberNuqsAdapter (for nuqs context), and NavigationRoot (for
79
+ // transition-based rendering during client navigation).
80
+ //
81
+ // NavigationRoot holds the element in React state and updates via
82
+ // startTransition, so React keeps old UI visible while new Suspense
83
+ // boundaries resolve during navigation. See design/05-streaming.md.
84
+ const navState = getNavigationState();
85
+ const withNav = createElement(
86
+ NavigationProvider,
87
+ { value: navState },
88
+ element as React.ReactNode
89
+ );
90
+ const wrapped = createElement(TimberNuqsAdapter, null, withNav);
91
+ const rootElement = createElement(NavigationRoot, {
92
+ initial: wrapped,
93
+ topLoaderConfig: config.topLoader,
94
+ });
95
+
96
+ if (process.env.NODE_ENV !== 'production') {
97
+ if (!getRouterOrNull()) {
98
+ throw new Error(
99
+ '[timber] hydrateRoot called before initRouter() — bootstrap order violated'
100
+ );
101
+ }
102
+ }
103
+
104
+ hydrateRoot(document, rootElement, {
105
+ // Suppress recoverable hydration errors from deny/error signals
106
+ // inside Suspense boundaries. The server already handled these
107
+ // (wrapStreamWithErrorHandling closes the stream cleanly after
108
+ // the shell is flushed). React replays the error during hydration
109
+ // but the server HTML is already correct — no recovery needed.
110
+ onRecoverableError(error: unknown) {
111
+ // Suppress errors during page unload (refresh/navigate away).
112
+ // The aborted stream causes incomplete HTML which React flags
113
+ // as a recoverable error — but the page is being replaced.
114
+ if (isPageUnloading()) return;
115
+ // Only log in dev — in production these are expected for
116
+ // deny() inside Suspense and streaming error boundaries.
117
+ if (process.env.NODE_ENV === 'development') {
118
+ console.debug('[timber] Hydration recoverable error:', error);
119
+ }
120
+ },
121
+ });
122
+ } else {
123
+ // No RSC payload available — defer React root creation until the
124
+ // first client navigation (TIM-600).
125
+ //
126
+ // We must NOT call createRoot(document).render() here — that would
127
+ // take React ownership of the entire document and blank the SSR HTML.
128
+ // Instead, installDeferredNavigation sets up one-shot callbacks so
129
+ // the first navigateTransition/transitionRender call creates the root
130
+ // on `document` with the navigated content. After that initial render,
131
+ // NavigationRoot's real startTransition-based callbacks take over.
132
+ //
133
+ // This also fixes TIM-580 (navigation from SSR-only pages) because
134
+ // the deferred callbacks ensure NavigationRoot is mounted before the
135
+ // first navigation completes.
136
+ installDeferredNavigation((initial) => {
137
+ const rootElement = createElement(NavigationRoot, {
138
+ initial,
139
+ topLoaderConfig: config.topLoader,
140
+ });
141
+ const root = createRoot(document);
142
+ root.render(rootElement);
143
+ });
144
+ }
145
+ }