@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.
- package/dist/_chunks/{actions-CSDD6x7U.js → actions-TSxpXLHJ.js} +3 -3
- package/dist/_chunks/{actions-CSDD6x7U.js.map → actions-TSxpXLHJ.js.map} +1 -1
- package/dist/_chunks/{cache-api-eb1gydM7.js → cache-api-DzpQQOEx.js} +53 -49
- package/dist/_chunks/{cache-api-eb1gydM7.js.map → cache-api-DzpQQOEx.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js → cli-schema-sync-wX-i90Og.js} +6 -4
- package/dist/_chunks/cli-schema-sync-wX-i90Og.js.map +1 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
- package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
- package/dist/_chunks/{define-CFmvb4Bt.js → define-COtkxMRT.js} +16 -28
- package/dist/_chunks/define-COtkxMRT.js.map +1 -0
- package/dist/_chunks/{define-Bssfp6ot.js → define-c4au4I9R.js} +2 -2
- package/dist/_chunks/{define-Bssfp6ot.js.map → define-c4au4I9R.js.map} +1 -1
- package/dist/_chunks/{logger-B_O6-mdJ.js → logger-t3uxAmbX.js} +3 -3
- package/dist/_chunks/logger-t3uxAmbX.js.map +1 -0
- package/dist/_chunks/{plugin-context-BnaiU_cF.js → plugin-context---kTF5v8.js} +2 -2
- package/dist/_chunks/{plugin-context-BnaiU_cF.js.map → plugin-context---kTF5v8.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-3iUvBV5T.js → resolve-schema-Dz3fcFUo.js} +2 -2
- package/dist/_chunks/{resolve-schema-3iUvBV5T.js.map → resolve-schema-Dz3fcFUo.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-BY3QLBL7.js → schema-bridge-DT_Tn0Xf.js} +2 -2
- package/dist/_chunks/{schema-bridge-BY3QLBL7.js.map → schema-bridge-DT_Tn0Xf.js.map} +1 -1
- package/dist/_chunks/{use-query-states-CbeQmext.js → use-query-states-DFvWd-EA.js} +65 -7
- package/dist/_chunks/use-query-states-DFvWd-EA.js.map +1 -0
- package/dist/_chunks/{walkers-BL3MCMgO.js → walkers-Cfwvl-UC.js} +3 -3
- package/dist/_chunks/{walkers-BL3MCMgO.js.map → walkers-Cfwvl-UC.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.d.ts +12 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +2 -461
- package/dist/adapters/nitro.js +1 -1
- package/dist/adapters/types.d.ts +2 -0
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cache/cache-api.d.ts +33 -11
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/history.d.ts +10 -0
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/internal.js +139 -115
- package/dist/client/internal.js.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/dev-tools/logs.d.ts +15 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +138 -45
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +8 -8
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +35 -4
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -7
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/wrappers.d.ts +2 -2
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/als-registry.d.ts +7 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +3 -1
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +12 -7
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts +9 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/stream-utils.d.ts.map +1 -1
- package/docs/api/32-api-cache.mdx +4 -4
- package/docs/api/33-api-search-params.mdx +13 -0
- package/docs/learn/00-introduction.mdx +1 -2
- package/docs/learn/09-caching.mdx +5 -5
- package/docs/more/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/50-ai-agent-instructions.mdx +5 -5
- package/package.json +10 -8
- package/src/adapters/cloudflare.ts +63 -25
- package/src/adapters/types.ts +2 -0
- package/src/cache/cache-api.ts +84 -84
- package/src/cli.ts +0 -0
- package/src/client/browser-entry/post-hydration.ts +16 -9
- package/src/client/history.ts +11 -0
- package/src/client/router.ts +212 -206
- package/src/client/use-query-states.ts +99 -7
- package/src/dev-tools/logs.ts +119 -34
- package/src/index.ts +14 -1
- package/src/plugins/adapter-build.ts +1 -0
- package/src/plugins/cache.ts +45 -30
- package/src/routing/scanner.ts +7 -0
- package/src/schema-bridge.ts +4 -3
- package/src/search-params/define.ts +57 -51
- package/src/search-params/wrappers.ts +17 -26
- package/src/server/als-registry.ts +7 -0
- package/src/server/deny-boundary.ts +6 -3
- package/src/server/route-element-builder.ts +49 -8
- package/src/server/rsc-entry/render-route.ts +3 -1
- package/src/server/rsc-entry/rsc-payload.ts +21 -4
- package/src/server/rsc-entry/rsc-stream.ts +8 -0
- package/src/server/stream-utils.ts +8 -6
- package/LICENSE +0 -8
- package/dist/_chunks/cli-schema-sync-mGfRbjh2.js.map +0 -1
- package/dist/_chunks/define-CFmvb4Bt.js.map +0 -1
- package/dist/_chunks/logger-B_O6-mdJ.js.map +0 -1
- package/dist/_chunks/use-query-states-CbeQmext.js.map +0 -1
- 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:
|
|
35
|
-
|
|
36
|
-
|
|
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(
|
|
228
|
+
void setValues(forwarded as any, nuqsSetOptions);
|
|
137
229
|
};
|
|
138
230
|
|
|
139
|
-
return [
|
|
231
|
+
return [normalized as T, setParams];
|
|
140
232
|
}
|
|
141
233
|
|
|
142
234
|
// ─── Definition binding ───────────────────────────────────────────
|
package/src/dev-tools/logs.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
301
|
+
prevConsole.set(level, console[level].bind(console));
|
|
222
302
|
|
|
223
303
|
console[level] = (...args: unknown[]) => {
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
311
|
+
patchingLevel.add(level);
|
|
239
312
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
343
|
+
// Return a cleanup function to restore the previous console methods
|
|
259
344
|
return () => {
|
|
260
|
-
for (const [level,
|
|
261
|
-
console[level] =
|
|
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
|
};
|
package/src/plugins/cache.ts
CHANGED
|
@@ -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
|
-
* (`
|
|
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)
|
|
19
|
-
* ns.cache(fn, opts)
|
|
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
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
84
|
-
if (callee.type
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
!
|
|
91
|
-
|
|
92
|
-
bindings.namespaces.has((
|
|
93
|
-
|
|
94
|
-
(
|
|
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
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
200
|
+
isCacheDataCallee(call.callee, cacheBindings) &&
|
|
186
201
|
!isCalleeShadowed(call),
|
|
187
202
|
calls
|
|
188
203
|
);
|
package/src/routing/scanner.ts
CHANGED
|
@@ -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
|
}
|
package/src/schema-bridge.ts
CHANGED
|
@@ -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 —
|
|
188
|
-
//
|
|
189
|
-
// via
|
|
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
|
|