@timber-js/app 0.2.0-alpha.165 → 0.2.0-alpha.167

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 (113) hide show
  1. package/dist/_chunks/{actions-CSDD6x7U.js → actions-TSxpXLHJ.js} +3 -3
  2. package/dist/_chunks/{actions-CSDD6x7U.js.map → actions-TSxpXLHJ.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-eb1gydM7.js → cache-api-DzpQQOEx.js} +53 -49
  4. package/dist/_chunks/{cache-api-eb1gydM7.js.map → cache-api-DzpQQOEx.js.map} +1 -1
  5. package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js → cli-schema-sync-wX-i90Og.js} +6 -4
  6. package/dist/_chunks/cli-schema-sync-wX-i90Og.js.map +1 -0
  7. package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
  8. package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
  9. package/dist/_chunks/{define-CFmvb4Bt.js → define-COtkxMRT.js} +16 -28
  10. package/dist/_chunks/define-COtkxMRT.js.map +1 -0
  11. package/dist/_chunks/{define-Bssfp6ot.js → define-c4au4I9R.js} +2 -2
  12. package/dist/_chunks/{define-Bssfp6ot.js.map → define-c4au4I9R.js.map} +1 -1
  13. package/dist/_chunks/{logger-B_O6-mdJ.js → logger-t3uxAmbX.js} +3 -3
  14. package/dist/_chunks/logger-t3uxAmbX.js.map +1 -0
  15. package/dist/_chunks/{plugin-context-BnaiU_cF.js → plugin-context---kTF5v8.js} +2 -2
  16. package/dist/_chunks/{plugin-context-BnaiU_cF.js.map → plugin-context---kTF5v8.js.map} +1 -1
  17. package/dist/_chunks/{resolve-schema-3iUvBV5T.js → resolve-schema-Dz3fcFUo.js} +2 -2
  18. package/dist/_chunks/{resolve-schema-3iUvBV5T.js.map → resolve-schema-Dz3fcFUo.js.map} +1 -1
  19. package/dist/_chunks/{schema-bridge-BY3QLBL7.js → schema-bridge-DT_Tn0Xf.js} +2 -2
  20. package/dist/_chunks/{schema-bridge-BY3QLBL7.js.map → schema-bridge-DT_Tn0Xf.js.map} +1 -1
  21. package/dist/_chunks/{use-query-states-CbeQmext.js → use-query-states-DFvWd-EA.js} +65 -7
  22. package/dist/_chunks/use-query-states-DFvWd-EA.js.map +1 -0
  23. package/dist/_chunks/{walkers-BL3MCMgO.js → walkers-Cfwvl-UC.js} +3 -3
  24. package/dist/_chunks/{walkers-BL3MCMgO.js.map → walkers-Cfwvl-UC.js.map} +1 -1
  25. package/dist/adapters/cloudflare-dev.js +1 -1
  26. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  27. package/dist/adapters/cloudflare.d.ts +12 -1
  28. package/dist/adapters/cloudflare.d.ts.map +1 -1
  29. package/dist/adapters/cloudflare.js +2 -461
  30. package/dist/adapters/nitro.js +1 -1
  31. package/dist/adapters/types.d.ts +2 -0
  32. package/dist/adapters/types.d.ts.map +1 -1
  33. package/dist/cache/cache-api.d.ts +33 -11
  34. package/dist/cache/cache-api.d.ts.map +1 -1
  35. package/dist/cache/index.js +1 -1
  36. package/dist/cli.js +2 -2
  37. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  38. package/dist/client/history.d.ts +10 -0
  39. package/dist/client/history.d.ts.map +1 -1
  40. package/dist/client/internal.js +139 -115
  41. package/dist/client/internal.js.map +1 -1
  42. package/dist/client/router.d.ts.map +1 -1
  43. package/dist/client/use-query-states.d.ts.map +1 -1
  44. package/dist/codec.js +1 -1
  45. package/dist/cookies/index.js +1 -1
  46. package/dist/dev-tools/logs.d.ts +15 -1
  47. package/dist/dev-tools/logs.d.ts.map +1 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +138 -45
  50. package/dist/index.js.map +1 -1
  51. package/dist/params/index.js +1 -1
  52. package/dist/plugins/adapter-build.d.ts.map +1 -1
  53. package/dist/plugins/cache.d.ts +8 -8
  54. package/dist/plugins/cache.d.ts.map +1 -1
  55. package/dist/routing/index.js +2 -2
  56. package/dist/schema-bridge.d.ts.map +1 -1
  57. package/dist/search-params/define.d.ts +35 -4
  58. package/dist/search-params/define.d.ts.map +1 -1
  59. package/dist/search-params/index.js +3 -7
  60. package/dist/search-params/index.js.map +1 -1
  61. package/dist/search-params/wrappers.d.ts +2 -2
  62. package/dist/search-params/wrappers.d.ts.map +1 -1
  63. package/dist/segment-params/index.js +1 -1
  64. package/dist/server/als-registry.d.ts +7 -0
  65. package/dist/server/als-registry.d.ts.map +1 -1
  66. package/dist/server/deny-boundary.d.ts +3 -1
  67. package/dist/server/deny-boundary.d.ts.map +1 -1
  68. package/dist/server/index.js +2 -2
  69. package/dist/server/internal.js +12 -7
  70. package/dist/server/internal.js.map +1 -1
  71. package/dist/server/route-element-builder.d.ts +9 -0
  72. package/dist/server/route-element-builder.d.ts.map +1 -1
  73. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  74. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  75. package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
  76. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  77. package/dist/server/stream-utils.d.ts.map +1 -1
  78. package/docs/api/32-api-cache.mdx +4 -4
  79. package/docs/api/33-api-search-params.mdx +13 -0
  80. package/docs/learn/00-introduction.mdx +1 -2
  81. package/docs/learn/09-caching.mdx +5 -5
  82. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  83. package/docs/more/50-ai-agent-instructions.mdx +5 -5
  84. package/package.json +10 -8
  85. package/src/adapters/cloudflare.ts +63 -25
  86. package/src/adapters/types.ts +2 -0
  87. package/src/cache/cache-api.ts +84 -84
  88. package/src/cli.ts +0 -0
  89. package/src/client/browser-entry/post-hydration.ts +16 -9
  90. package/src/client/history.ts +11 -0
  91. package/src/client/router.ts +212 -206
  92. package/src/client/use-query-states.ts +99 -7
  93. package/src/dev-tools/logs.ts +119 -34
  94. package/src/index.ts +14 -1
  95. package/src/plugins/adapter-build.ts +1 -0
  96. package/src/plugins/cache.ts +45 -30
  97. package/src/routing/scanner.ts +7 -0
  98. package/src/schema-bridge.ts +4 -3
  99. package/src/search-params/define.ts +57 -51
  100. package/src/search-params/wrappers.ts +17 -26
  101. package/src/server/als-registry.ts +7 -0
  102. package/src/server/deny-boundary.ts +6 -3
  103. package/src/server/route-element-builder.ts +49 -8
  104. package/src/server/rsc-entry/render-route.ts +3 -1
  105. package/src/server/rsc-entry/rsc-payload.ts +21 -4
  106. package/src/server/rsc-entry/rsc-stream.ts +8 -0
  107. package/src/server/stream-utils.ts +8 -6
  108. package/LICENSE +0 -8
  109. package/dist/_chunks/cli-schema-sync-mGfRbjh2.js.map +0 -1
  110. package/dist/_chunks/define-CFmvb4Bt.js.map +0 -1
  111. package/dist/_chunks/logger-B_O6-mdJ.js.map +0 -1
  112. package/dist/_chunks/use-query-states-CbeQmext.js.map +0 -1
  113. package/dist/adapters/cloudflare.js.map +0 -1
@@ -22,19 +22,71 @@ import { getSearchParamsDefinition } from '../search-params/registry.js';
22
22
 
23
23
  // ─── Codec Bridge ─────────────────────────────────────────────────
24
24
 
25
+ // nuqs's parser contract conflates values timber codecs distinguish:
26
+ // parse() returning null means "unparseable, substitute defaultValue",
27
+ // and undefined entries are skipped entirely. Timber codecs can
28
+ // legitimately produce both — bare z.string() yields undefined for absent
29
+ // params (implicit optionality), and a codec may map a present value to
30
+ // null. Wrap those two values in sentinels across the nuqs boundary and
31
+ // unwrap them before handing values back to the caller, so the client
32
+ // hook returns exactly what server-side parse() returns.
33
+ // Unique object references compared by identity — a codec can never
34
+ // produce these from URL input, so user-controlled strings cannot collide
35
+ // with them (unlike string sentinels), and unlike Symbols they survive
36
+ // nuqs's internal string coercion without throwing.
37
+ const NULL_SENTINEL: object = { timberSentinel: 'null' };
38
+ const UNDEFINED_SENTINEL: object = { timberSentinel: 'undefined' };
39
+
40
+ function wrapNuqsValue(value: unknown): unknown {
41
+ if (value === null) return NULL_SENTINEL;
42
+ if (value === undefined) return UNDEFINED_SENTINEL;
43
+ return value;
44
+ }
45
+
46
+ function unwrapNuqsValue(value: unknown): unknown {
47
+ if (value === NULL_SENTINEL) return null;
48
+ if (value === UNDEFINED_SENTINEL) return undefined;
49
+ return value;
50
+ }
51
+
25
52
  /**
26
53
  * Bridge a timber SearchParamCodec to a nuqs-compatible SingleParser.
27
54
  *
28
55
  * nuqs parsers: { parse(string) → T|null, serialize?(T) → string, eq?, defaultValue? }
29
56
  * timber codecs: { parse(string|string[]|undefined) → T, serialize(T) → string|null }
57
+ *
58
+ * The defaultValue is computed eagerly. Codecs are documented to return a
59
+ * default rather than throw, but a throwing codec must not crash every
60
+ * component that mounts the hook — treat its default as undefined and let
61
+ * its error surface from server-side parse() instead.
30
62
  */
31
63
  function bridgeCodec<T>(codec: SearchParamCodec<T>): SingleParser<T> & { defaultValue: T } {
64
+ let defaultValue: unknown;
65
+ try {
66
+ defaultValue = codec.parse(undefined);
67
+ } catch {
68
+ defaultValue = undefined;
69
+ }
32
70
  return {
33
- parse: (v: string) => codec.parse(v),
34
- serialize: (v: T) => codec.serialize(v) ?? '',
35
- defaultValue: codec.parse(undefined) as T,
36
- eq: (a: T, b: T) => codec.serialize(a) === codec.serialize(b),
37
- };
71
+ parse: (v: string) => wrapNuqsValue(codec.parse(v)),
72
+ serialize: (v: unknown) => {
73
+ const value = unwrapNuqsValue(v);
74
+ // Delegate null to the codec — some codecs encode null as a real
75
+ // query value. undefined has no encoding; nuqs requires a string.
76
+ return value === undefined ? '' : (codec.serialize(value as T) ?? '');
77
+ },
78
+ defaultValue: wrapNuqsValue(defaultValue),
79
+ eq: (a: unknown, b: unknown) => {
80
+ if (a === b) return true;
81
+ try {
82
+ return (
83
+ codec.serialize(unwrapNuqsValue(a) as T) === codec.serialize(unwrapNuqsValue(b) as T)
84
+ );
85
+ } catch {
86
+ return false;
87
+ }
88
+ },
89
+ } as SingleParser<T> & { defaultValue: T };
38
90
  }
39
91
 
40
92
  /**
@@ -124,6 +176,19 @@ export function useQueryStates<T extends Record<string, unknown>>(
124
176
  throw err;
125
177
  }
126
178
 
179
+ // Unwrap the null/undefined sentinels the bridge injected (see Codec
180
+ // Bridge above) so callers see exactly what server-side parse() returns.
181
+ // Copy-on-write preserves the identity of nuqs's memoized values object
182
+ // when nothing needs unwrapping.
183
+ let normalized = values;
184
+ for (const key of Object.keys(bridged)) {
185
+ const value = normalized[key];
186
+ if (value === NULL_SENTINEL || value === UNDEFINED_SENTINEL) {
187
+ if (normalized === values) normalized = { ...values };
188
+ normalized[key] = unwrapNuqsValue(value);
189
+ }
190
+ }
191
+
127
192
  // Wrap the nuqs setter to match timber's SetParams<T> signature.
128
193
  // nuqs's setter accepts Partial<Nullable<Values>> | UpdaterFn | null.
129
194
  // timber's setter accepts Partial<T> with optional SetParamsOptions.
@@ -132,11 +197,38 @@ export function useQueryStates<T extends Record<string, unknown>>(
132
197
  if (setOptions?.shallow !== undefined) nuqsSetOptions.shallow = setOptions.shallow;
133
198
  if (setOptions?.scroll !== undefined) nuqsSetOptions.scroll = setOptions.scroll;
134
199
  if (setOptions?.history !== undefined) nuqsSetOptions.history = setOptions.history;
200
+ // nuqs's update loop skips undefined entries and treats null as a
201
+ // key deletion before serialize runs. Timber semantics:
202
+ // - setParams({ q: undefined }) must clear ?q= (absent = undefined),
203
+ // so explicit undefined maps to a null deletion.
204
+ // - setParams({ q: null }) clears the key only when the codec encodes
205
+ // null as "omit" (serialize(null) === null). If the codec encodes
206
+ // null as a real query value, forward the sentinel so the bridged
207
+ // serialize writes it — matching definition.serialize({ q: null }).
208
+ let forwarded: Record<string, unknown> = partial;
209
+ for (const key of Object.keys(partial)) {
210
+ const value = partial[key as keyof T];
211
+ if (value === undefined) {
212
+ if (forwarded === partial) forwarded = { ...partial };
213
+ forwarded[key] = null;
214
+ } else if (value === null) {
215
+ let encoded: string | null = null;
216
+ try {
217
+ encoded = codecs[key as keyof T]?.serialize(null as T[keyof T]) ?? null;
218
+ } catch {
219
+ // Codec can't serialize null — treat as a deletion.
220
+ }
221
+ if (encoded !== null) {
222
+ if (forwarded === partial) forwarded = { ...partial };
223
+ forwarded[key] = NULL_SENTINEL;
224
+ }
225
+ }
226
+ }
135
227
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
- void setValues(partial as any, nuqsSetOptions);
228
+ void setValues(forwarded as any, nuqsSetOptions);
137
229
  };
138
230
 
139
- return [values as T, setParams];
231
+ return [normalized as T, setParams];
140
232
  }
141
233
 
142
234
  // ─── Definition binding ───────────────────────────────────────────
@@ -12,9 +12,86 @@
12
12
  * Design docs: 18-build-system.md §"Dev Server", 02-rendering-pipeline.md
13
13
  */
14
14
 
15
- import type { Plugin, ViteDevServer } from 'vite';
15
+ import type { Plugin, ViteDevServer, Logger } from 'vite';
16
16
  import type { PluginContext } from '../plugin-context.js';
17
17
 
18
+ // ─── Original Console Snapshot ──────────────────────────────────────────
19
+
20
+ /**
21
+ * Snapshot of the original console methods, captured at module load time
22
+ * (before any patching). Used as Vite's `customLogger` console so that
23
+ * forwardConsole output bypasses our patch entirely — eliminating the
24
+ * server→browser→server forwarding loop without string matching.
25
+ */
26
+ export const originalConsole: Pick<Console, 'log' | 'warn' | 'error' | 'debug' | 'info'> = {
27
+ log: console.log.bind(console),
28
+ warn: console.warn.bind(console),
29
+ error: console.error.bind(console),
30
+ debug: console.debug.bind(console),
31
+ info: console.info.bind(console),
32
+ };
33
+
34
+ /**
35
+ * Wrap a Vite Logger so its output methods route through `originalConsole`
36
+ * instead of the global (potentially patched) console. This prevents the
37
+ * server→browser→server forwarding loop even when the user supplies their
38
+ * own `customLogger` that internally calls console methods.
39
+ */
40
+ export function wrapLoggerWithOriginalConsole(logger: Logger): Logger {
41
+ // Swap all patched console methods to originals during any logger call,
42
+ // since user loggers may call any combination of console methods internally.
43
+ function withOriginals<T>(fn: () => T): T {
44
+ const saved = {
45
+ log: console.log,
46
+ warn: console.warn,
47
+ error: console.error,
48
+ debug: console.debug,
49
+ info: console.info,
50
+ };
51
+ console.log = originalConsole.log;
52
+ console.warn = originalConsole.warn;
53
+ console.error = originalConsole.error;
54
+ console.debug = originalConsole.debug;
55
+ console.info = originalConsole.info;
56
+ try {
57
+ return fn();
58
+ } finally {
59
+ console.log = saved.log;
60
+ console.warn = saved.warn;
61
+ console.error = saved.error;
62
+ console.debug = saved.debug;
63
+ console.info = saved.info;
64
+ }
65
+ }
66
+
67
+ return {
68
+ get hasWarned() {
69
+ return logger.hasWarned;
70
+ },
71
+ set hasWarned(v) {
72
+ logger.hasWarned = v;
73
+ },
74
+ info(msg, opts) {
75
+ withOriginals(() => logger.info(msg, opts));
76
+ },
77
+ warn(msg, opts) {
78
+ withOriginals(() => logger.warn(msg, opts));
79
+ },
80
+ warnOnce(msg, opts) {
81
+ withOriginals(() => logger.warnOnce(msg, opts));
82
+ },
83
+ error(msg, opts) {
84
+ withOriginals(() => logger.error(msg, opts));
85
+ },
86
+ clearScreen(type) {
87
+ logger.clearScreen(type);
88
+ },
89
+ hasErrorLogged(error) {
90
+ return logger.hasErrorLogged(error);
91
+ },
92
+ };
93
+ }
94
+
18
95
  // ─── Types ───────────────────────────────────────────────────────────────
19
96
 
20
97
  /** Log levels that are patched and forwarded. */
@@ -215,50 +292,58 @@ export function isFrameworkInternalCaller(): boolean {
215
292
  * 3. Sends via server.hot.send() — dropped if no clients connected
216
293
  */
217
294
  function patchConsole(server: ViteDevServer, projectRoot: string): () => void {
218
- const originals = new Map<ServerLogLevel, (...args: unknown[]) => void>();
295
+ const prevConsole = new Map<ServerLogLevel, (...args: unknown[]) => void>();
296
+ // Per-level guard so a console.warn inside a console.error handler isn't
297
+ // suppressed — only same-level synchronous re-entrancy is blocked.
298
+ const patchingLevel = new Set<ServerLogLevel>();
219
299
 
220
300
  for (const level of LOG_LEVELS) {
221
- originals.set(level, console[level].bind(console));
301
+ prevConsole.set(level, console[level].bind(console));
222
302
 
223
303
  console[level] = (...args: unknown[]) => {
224
- // Always call the original — server terminal output is preserved
225
- originals.get(level)!(...args);
226
-
227
- // Skip framework-internal logs (plugins/, adapters/) from browser forwarding.
228
- // Server runtime logs (render errors, action errors, etc.) are preserved.
229
- if (isFrameworkInternalCaller()) return;
230
-
231
- // Break the server→browser→server console forwarding loop. Vite's
232
- // forwardConsole re-logs browser console output on the server via
233
- // `logger.error("[console.error] ...")`. Our patch would send it back
234
- // to the browser, Vite's client patch forwards it again → exponential
235
- // log spam. Detect the `[console.<level>]` prefix Vite adds and skip.
236
- if (typeof args[0] === 'string' && args[0].includes('[console.')) return;
304
+ // Same-level re-entrancy guard: if prevConsole's wrapper calls back
305
+ // into console[level], just pass through without forwarding.
306
+ if (patchingLevel.has(level)) {
307
+ prevConsole.get(level)!(...args);
308
+ return;
309
+ }
237
310
 
238
- // Serialize and forward to browser
311
+ patchingLevel.add(level);
239
312
  try {
240
- const payload: ServerLogPayload = {
241
- level,
242
- args: args.map((arg) => serializeArg(arg)),
243
- location: extractCallerLocation(projectRoot),
244
- timestamp: Date.now(),
245
- };
246
-
247
- server.hot.send('timber:server-log', payload);
248
- } catch (e) {
249
- // Use the original console.debug to avoid re-entering the patched handler
250
- originals.get('debug')!(
251
- '[timber] server log forwarding failed:',
252
- e instanceof Error ? e.message : e
253
- );
313
+ // Call the previous console method first — guarantees terminal output
314
+ // even if serialization or forwarding fails downstream. If prevConsole
315
+ // throws, the error propagates (not swallowed) but patchingLevel is
316
+ // still cleaned up by the outer finally.
317
+ prevConsole.get(level)!(...args);
318
+
319
+ // Skip framework-internal logs (plugins/, adapters/) from browser forwarding.
320
+ if (isFrameworkInternalCaller()) return;
321
+
322
+ try {
323
+ const payload: ServerLogPayload = {
324
+ level,
325
+ args: args.map((arg) => serializeArg(arg)),
326
+ location: extractCallerLocation(projectRoot),
327
+ timestamp: Date.now(),
328
+ };
329
+
330
+ server.hot.send('timber:server-log', payload);
331
+ } catch (e) {
332
+ originalConsole.debug(
333
+ '[timber] server log forwarding failed:',
334
+ e instanceof Error ? e.message : e
335
+ );
336
+ }
337
+ } finally {
338
+ patchingLevel.delete(level);
254
339
  }
255
340
  };
256
341
  }
257
342
 
258
- // Return a cleanup function to restore originals
343
+ // Return a cleanup function to restore the previous console methods
259
344
  return () => {
260
- for (const [level, original] of originals) {
261
- console[level] = original;
345
+ for (const [level, prev] of prevConsole) {
346
+ console[level] = prev;
262
347
  }
263
348
  };
264
349
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Plugin, PluginOption } from 'vite';
14
+ import { createLogger } from 'vite';
14
15
  import { join, relative, resolve } from 'node:path';
15
16
  import { createRequire } from 'node:module';
16
17
  import react, { reactCompilerPreset } from '@vitejs/plugin-react';
@@ -23,7 +24,7 @@ import { timberShims } from './plugins/shims';
23
24
  import { timberFonts } from './plugins/fonts';
24
25
  import { timberStaticBuild } from './plugins/static-build';
25
26
  import { timberBuildManifest } from './plugins/build-manifest';
26
- import { timberDevLogs } from './dev-tools/logs';
27
+ import { timberDevLogs, originalConsole, wrapLoggerWithOriginalConsole } from './dev-tools/logs';
27
28
  import { resolveForwardConsole } from './dev-tools/browser-logs';
28
29
  import { timberReactProd } from './plugins/react-prod';
29
30
  import { timberChunks } from './plugins/chunks';
@@ -438,10 +439,22 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
438
439
  // on the devBrowserLogs config (default: 'warn'). See TIM-1152.
439
440
  serverConfig.forwardConsole = resolveForwardConsole(ctx.config.devBrowserLogs);
440
441
 
442
+ // Provide a customLogger whose output routes through the pre-patched
443
+ // console methods so Vite's forwardConsole output never re-enters our
444
+ // server→browser patch. If the user supplied their own customLogger,
445
+ // wrap it so its console calls also bypass patching.
446
+ const customLogger = userConfig.customLogger
447
+ ? wrapLoggerWithOriginalConsole(userConfig.customLogger)
448
+ : createLogger(userConfig.logLevel ?? 'info', {
449
+ allowClearScreen: userConfig.clearScreen ?? true,
450
+ console: originalConsole as Console,
451
+ });
452
+
441
453
  return {
442
454
  build: { outDir: buildOutDir },
443
455
  environments: envOutDirs,
444
456
  optimizeDeps: { exclude: timberOptimizeDepsExclude },
457
+ customLogger,
445
458
  server: serverConfig,
446
459
  preview: previewConfig,
447
460
  };
@@ -83,6 +83,7 @@ export function timberAdapterBuild(ctx: PluginContext): Plugin {
83
83
  }
84
84
 
85
85
  const adapterConfig: TimberConfig = {
86
+ root: ctx.root,
86
87
  output: ctx.config.output ?? 'server',
87
88
  clientJavascriptDisabled: ctx.clientJavascript.disabled,
88
89
  manifestInit,
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * timber-cache-transform — Vite plugin that injects stable callsite IDs
3
- * into `cache()` calls imported from `@timber-js/app/cache`.
3
+ * into `cache.data()` calls imported from `@timber-js/app/cache`.
4
4
  *
5
5
  * Detected callsite forms (TIM-1054):
6
- * - named imports, including aliases: `import { cache as c }` + `c(fn, opts)`
7
- * - namespace imports: `import * as ns` + `ns.cache(fn, opts)`
6
+ * - named imports, including aliases: `import { cache as c }` + `c.data(fn, opts)`
7
+ * - namespace imports: `import * as ns` + `ns.cache.data(fn, opts)`
8
8
  * Forms a per-module transform cannot trace — local variable aliases
9
9
  * (`const c = cache`), user re-export wrapper modules, computed access
10
- * (`ns['cache']`) — fall back to the runtime fnId derived from
10
+ * (`cache['data']`) — fall back to the runtime fnId derived from
11
11
  * fnv1a(fn.toString()) (see cache/timber-cache.ts), which is deterministic
12
12
  * across instances and cold starts of the same build. Without either
13
13
  * mechanism, an ordinal counter would collide across instances with a shared
@@ -15,8 +15,8 @@
15
15
  * data to another.
16
16
  *
17
17
  * The plugin transforms:
18
- * cache(fn, opts) → cache(fn, opts, "stable-id")
19
- * ns.cache(fn, opts) → ns.cache(fn, opts, "stable-id")
18
+ * cache.data(fn, opts) → cache.data(fn, opts, "stable-id")
19
+ * ns.cache.data(fn, opts) → ns.cache.data(fn, opts, "stable-id")
20
20
  *
21
21
  * where stable-id =
22
22
  * fnv1a(relPath + ":cache:" + fnv1a(stmtText) + ":" + fnv1a(callSpanText) + ":" + occurrence)
@@ -34,9 +34,9 @@
34
34
  * unrelated edits, while a changed call gets a fresh ID — which is exactly
35
35
  * when invalidation is wanted.
36
36
  *
37
- * The enclosing-statement text is included so that byte-identical cache()
37
+ * The enclosing-statement text is included so that byte-identical cache.data()
38
38
  * calls in different scopes — e.g. two factory helpers that each `return
39
- * cache(fn, { ttl: 60 })` — get distinct, insertion-stable IDs: their
39
+ * cache.data(fn, { ttl: 60 })` — get distinct, insertion-stable IDs: their
40
40
  * enclosing declarations differ by name/body even when the call spans are
41
41
  * identical. Without it, a source-order occurrence counter alone would shift
42
42
  * when an identical span is inserted above, reassigning IDs across deploys.
@@ -75,41 +75,56 @@ import type {
75
75
  } from './callsite-ast.js';
76
76
 
77
77
  /**
78
- * Is this callee a reference to the imported `cache` function — either a bare
79
- * identifier bound by a named import, or `ns.cache` on a namespace import?
80
- * Computed access (`ns['cache']`) is deliberately not matched; it falls back
78
+ * Is this callee a `cache.data(...)` call? Matches two forms:
79
+ * - Named import: `cache.data(fn, opts)` where `cache` is the imported binding
80
+ * - Namespace import: `ns.cache.data(fn, opts)` where `ns` is the namespace
81
+ * Computed access (`cache['data']`) is deliberately not matched; it falls back
81
82
  * to the runtime fnId derivation.
82
83
  */
83
- function isCacheCallee(callee: PositionedNode, bindings: ImportBindings): boolean {
84
- if (callee.type === 'Identifier') {
85
- return bindings.named.has((callee as IdentifierNode).name);
84
+ function isCacheDataCallee(callee: PositionedNode, bindings: ImportBindings): boolean {
85
+ if (callee.type !== 'MemberExpression') return false;
86
+ const member = callee as unknown as MemberExpressionNode;
87
+ if (member.computed) return false;
88
+ if (member.property.type !== 'Identifier' || (member.property as IdentifierNode).name !== 'data')
89
+ return false;
90
+
91
+ const obj = member.object;
92
+ // Named import: `cache.data(...)` — obj is the imported `cache` identifier
93
+ if (obj.type === 'Identifier') {
94
+ return bindings.named.has((obj as IdentifierNode).name);
86
95
  }
87
- if (callee.type === 'MemberExpression') {
88
- const member = callee as unknown as MemberExpressionNode;
96
+ // Namespace import: `ns.cache.data(...)` — obj is `ns.cache`
97
+ if (obj.type === 'MemberExpression') {
98
+ const outer = obj as unknown as MemberExpressionNode;
89
99
  return (
90
- !member.computed &&
91
- member.object.type === 'Identifier' &&
92
- bindings.namespaces.has((member.object as IdentifierNode).name) &&
93
- member.property.type === 'Identifier' &&
94
- (member.property as IdentifierNode).name === 'cache'
100
+ !outer.computed &&
101
+ outer.object.type === 'Identifier' &&
102
+ bindings.namespaces.has((outer.object as IdentifierNode).name) &&
103
+ outer.property.type === 'Identifier' &&
104
+ (outer.property as IdentifierNode).name === 'cache'
95
105
  );
96
106
  }
97
107
  return false;
98
108
  }
99
109
 
100
110
  /**
101
- * Extract the local binding name that a cache callee resolves to — the
102
- * identifier for named imports, or the namespace object for `ns.cache()`.
111
+ * Extract the local binding name that a cache.data callee resolves to — the
112
+ * `cache` identifier for named imports, or the namespace object for `ns.cache.data()`.
103
113
  */
104
114
  function calleeBindingName(callee: PositionedNode, bindings: ImportBindings): string | null {
105
- if (callee.type === 'Identifier') {
106
- const name = (callee as IdentifierNode).name;
115
+ if (callee.type !== 'MemberExpression') return null;
116
+ const member = callee as unknown as MemberExpressionNode;
117
+ const obj = member.object;
118
+ // Named import: `cache.data(...)` — binding is `cache`
119
+ if (obj.type === 'Identifier') {
120
+ const name = (obj as IdentifierNode).name;
107
121
  return bindings.named.has(name) ? name : null;
108
122
  }
109
- if (callee.type === 'MemberExpression') {
110
- const member = callee as unknown as MemberExpressionNode;
111
- if (member.object.type === 'Identifier') {
112
- const name = (member.object as IdentifierNode).name;
123
+ // Namespace import: `ns.cache.data(...)` — binding is `ns`
124
+ if (obj.type === 'MemberExpression') {
125
+ const outer = obj as unknown as MemberExpressionNode;
126
+ if (outer.object.type === 'Identifier') {
127
+ const name = (outer.object as IdentifierNode).name;
113
128
  return bindings.namespaces.has(name) ? name : null;
114
129
  }
115
130
  }
@@ -182,7 +197,7 @@ export function timberCacheTransform(ctx: PluginContext): Plugin {
182
197
  stmt,
183
198
  (call) =>
184
199
  call.arguments.length === 2 &&
185
- isCacheCallee(call.callee, cacheBindings) &&
200
+ isCacheDataCallee(call.callee, cacheBindings) &&
186
201
  !isCalleeShadowed(call),
187
202
  calls
188
203
  );
@@ -232,6 +232,13 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
232
232
  // See design/16-metadata.md §"Metadata Routes"
233
233
  const metaInfo = classifyMetadataRoute(entry);
234
234
  if (metaInfo) {
235
+ if (!metaInfo.nestable && node.segmentName !== '') {
236
+ throw new Error(
237
+ `Build error: '${name}' is a root-only metadata convention and must be in the app root directory.\n` +
238
+ ` File: ${fullPath}\n` +
239
+ ` Move this file to the app root (not a route group or nested segment).`
240
+ );
241
+ }
235
242
  if (!node.metadataRoutes) {
236
243
  node.metadataRoutes = {};
237
244
  }
@@ -184,9 +184,10 @@ export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
184
184
  return defaultResult.value;
185
185
  }
186
186
 
187
- // No default available — return undefined (codec design choice).
188
- // Callers like defineSearchParams validate this at definition time
189
- // via validateSchemaDefaults() to catch it early.
187
+ // No default available — the field is implicitly optional. Return
188
+ // undefined; defineSearchParams widens the field's inferred type to
189
+ // T | undefined via InferField so this doesn't lie.
190
+ // design/23-search-params.md §"Implicit Optionality"
190
191
  return undefined as T;
191
192
  },
192
193