@timber-js/app 0.2.0-alpha.165 → 0.2.0-alpha.166
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-CDPfMp_I.js} +3 -3
- package/dist/_chunks/{actions-CSDD6x7U.js.map → actions-CDPfMp_I.js.map} +1 -1
- package/dist/_chunks/{cache-api-eb1gydM7.js → cache-api-DygSeKCB.js} +2 -2
- package/dist/_chunks/{cache-api-eb1gydM7.js.map → cache-api-DygSeKCB.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js → cli-schema-sync-EXGYPhI2.js} +3 -3
- package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js.map → cli-schema-sync-EXGYPhI2.js.map} +1 -1
- 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-B_O6-mdJ.js.map → logger-t3uxAmbX.js.map} +1 -1
- 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-DBVzXuWc.js} +3 -3
- package/dist/_chunks/{walkers-BL3MCMgO.js.map → walkers-DBVzXuWc.js.map} +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/internal.js +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 +104 -20
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +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/index.js +2 -2
- package/dist/server/internal.js +8 -6
- package/dist/server/internal.js.map +1 -1
- package/dist/server/stream-utils.d.ts.map +1 -1
- package/docs/api/33-api-search-params.mdx +13 -0
- package/docs/learn/00-introduction.mdx +1 -2
- package/package.json +8 -7
- package/src/cli.ts +0 -0
- 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/schema-bridge.ts +4 -3
- package/src/search-params/define.ts +57 -51
- package/src/search-params/wrappers.ts +17 -26
- package/src/server/stream-utils.ts +8 -6
- package/LICENSE +0 -8
- package/dist/_chunks/define-CFmvb4Bt.js.map +0 -1
- package/dist/_chunks/use-query-states-CbeQmext.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stream-utils.d.ts","sourceRoot":"","sources":["../../src/server/stream-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,OAAO,CAAC,EAAE,UAAU,GACnB,CAAC,cAAc,CAAC,UAAU,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC,CAmK1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,SAAS,EAAE,MAAM,GAChB,cAAc,CAAC,UAAU,CAAC,
|
|
1
|
+
{"version":3,"file":"stream-utils.d.ts","sourceRoot":"","sources":["../../src/server/stream-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,OAAO,CAAC,EAAE,UAAU,GACnB,CAAC,cAAc,CAAC,UAAU,CAAC,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC,CAmK1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,SAAS,EAAE,MAAM,GAChB,cAAc,CAAC,UAAU,CAAC,CA4D5B"}
|
|
@@ -23,6 +23,19 @@ export const searchParams = defineSearchParams({
|
|
|
23
23
|
});
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
### Implicit optionality
|
|
27
|
+
|
|
28
|
+
Search params are optional by nature — the URL might not contain them. A schema without `.default()` or `.optional()` (e.g. bare `z.string()`) is treated as implicitly optional: absent or invalid input parses to `undefined`, and the field's inferred type widens to `T | undefined`. Definitions never throw at module-eval time, and schema-backed parsing never throws at request time.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const def = defineSearchParams({ name: z.string() });
|
|
32
|
+
def.parse(new URLSearchParams('')); // { name: undefined }
|
|
33
|
+
def.parse(new URLSearchParams('name=x')); // { name: 'x' }
|
|
34
|
+
// Inferred type: { name: string | undefined }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Note: `z.coerce.*` schemas declare input `unknown`, so a coerce schema without `.default()` keeps its narrow type even though absent input yields `undefined` at runtime — add `.default()` to coerce schemas for accurate types.
|
|
38
|
+
|
|
26
39
|
### Returns: `SearchParamsDefinition<T>`
|
|
27
40
|
|
|
28
41
|
| Method / Property | Description |
|
|
@@ -61,8 +61,7 @@ export const searchParams = defineSearchParams({
|
|
|
61
61
|
const sp = searchParams.get();
|
|
62
62
|
|
|
63
63
|
// or on the client
|
|
64
|
-
|
|
65
|
-
const [sp, setSearchParams] = searchParams.useQueryState();
|
|
64
|
+
const [sp, setSearchParams] = searchParams.useQueryStates();
|
|
66
65
|
```
|
|
67
66
|
|
|
68
67
|
### Typed Routes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.166",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -150,6 +150,12 @@
|
|
|
150
150
|
"publishConfig": {
|
|
151
151
|
"access": "public"
|
|
152
152
|
},
|
|
153
|
+
"scripts": {
|
|
154
|
+
"copy-docs": "node scripts/copy-docs.js",
|
|
155
|
+
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist && pnpm run copy-docs",
|
|
156
|
+
"typecheck": "tsgo --noEmit",
|
|
157
|
+
"prepublishOnly": "pnpm run build"
|
|
158
|
+
},
|
|
153
159
|
"dependencies": {
|
|
154
160
|
"@opentelemetry/api": "^1.9.1",
|
|
155
161
|
"@opentelemetry/context-async-hooks": "^2.8.0",
|
|
@@ -195,10 +201,5 @@
|
|
|
195
201
|
},
|
|
196
202
|
"engines": {
|
|
197
203
|
"node": ">=22.18.0"
|
|
198
|
-
},
|
|
199
|
-
"scripts": {
|
|
200
|
-
"copy-docs": "node scripts/copy-docs.js",
|
|
201
|
-
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist && pnpm run copy-docs",
|
|
202
|
-
"typecheck": "tsgo --noEmit"
|
|
203
204
|
}
|
|
204
|
-
}
|
|
205
|
+
}
|
package/src/cli.ts
CHANGED
|
File without changes
|
|
@@ -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/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
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { useQueryStates as clientUseQueryStates } from '../client/use-query-states.js';
|
|
14
|
-
import { fromSchema, isStandardSchema, isCodec
|
|
14
|
+
import { fromSchema, isStandardSchema, isCodec } from '../schema-bridge.js';
|
|
15
15
|
import type { StandardSchemaV1 } from '../schema-bridge.js';
|
|
16
16
|
import type { Codec } from '../codec.js';
|
|
17
17
|
|
|
@@ -165,9 +165,43 @@ export type { StandardSchemaV1 } from '../schema-bridge.js';
|
|
|
165
165
|
// Type-level helpers
|
|
166
166
|
// ---------------------------------------------------------------------------
|
|
167
167
|
|
|
168
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Extract a Standard Schema's declared *input* type from its optional
|
|
170
|
+
* `~standard.types` property (part of the Standard Schema spec; Zod, Valibot,
|
|
171
|
+
* and ArkType all declare it at the type level). Falls back to `never` when
|
|
172
|
+
* the schema doesn't declare types (e.g. hand-written schemas): with no
|
|
173
|
+
* metadata we can't know whether the schema handles `undefined`, so InferField
|
|
174
|
+
* widens conservatively rather than risk a type lie.
|
|
175
|
+
*/
|
|
176
|
+
type InferSchemaInput<V> = V extends { '~standard': { types?: infer TS } }
|
|
177
|
+
? [NonNullable<TS>] extends [{ input: infer I }]
|
|
178
|
+
? I
|
|
179
|
+
: never
|
|
180
|
+
: never;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Infer the output type from either a SearchParamCodec or a StandardSchemaV1.
|
|
184
|
+
*
|
|
185
|
+
* Schemas whose input type rejects `undefined` (e.g. bare `z.string()`, whose
|
|
186
|
+
* input is `string`) are implicitly optional: the URL might not contain the
|
|
187
|
+
* param, and fromSchema returns `undefined` when the schema rejects absent
|
|
188
|
+
* input and has no default. The output type widens to `T | undefined` so the
|
|
189
|
+
* type doesn't lie. Schemas that accept `undefined` input (`.optional()`,
|
|
190
|
+
* `.default()`) keep their declared output type.
|
|
191
|
+
*
|
|
192
|
+
* Limitation: `z.coerce.*` schemas declare input `unknown`, which accepts
|
|
193
|
+
* `undefined` at the type level — so a coerce schema without `.default()`
|
|
194
|
+
* keeps its narrow output type even though absent input yields `undefined`
|
|
195
|
+
* at runtime. Add `.default()` to coerce schemas for accurate types.
|
|
196
|
+
*/
|
|
169
197
|
export type InferField<V> =
|
|
170
|
-
V extends SearchParamCodec<infer T>
|
|
198
|
+
V extends SearchParamCodec<infer T>
|
|
199
|
+
? T
|
|
200
|
+
: V extends StandardSchemaV1<infer T>
|
|
201
|
+
? undefined extends InferSchemaInput<V>
|
|
202
|
+
? T
|
|
203
|
+
: T | undefined
|
|
204
|
+
: never;
|
|
171
205
|
|
|
172
206
|
/** Acceptable field value for defineSearchParams: a codec or a Standard Schema. */
|
|
173
207
|
export type SearchParamField<T = unknown> = SearchParamCodec<T> | StandardSchemaV1<T>;
|
|
@@ -198,9 +232,17 @@ function normalizeRaw(
|
|
|
198
232
|
* Compute the serialized default value for a codec. Used for
|
|
199
233
|
* default-omission: when serialize(value) === serialize(parse(undefined)),
|
|
200
234
|
* the field is omitted from the URL.
|
|
235
|
+
*
|
|
236
|
+
* Codecs are documented to return a default rather than throw, but a
|
|
237
|
+
* hand-written codec that throws on absent input must not turn definition
|
|
238
|
+
* into a crash — treat its default as null (nothing to omit).
|
|
201
239
|
*/
|
|
202
240
|
function getDefaultSerialized<T>(codec: SearchParamCodec<T>): string | null {
|
|
203
|
-
|
|
241
|
+
try {
|
|
242
|
+
return codec.serialize(codec.parse(undefined));
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
204
246
|
}
|
|
205
247
|
|
|
206
248
|
// isStandardSchema and isCodec are imported from schema-bridge.ts.
|
|
@@ -218,22 +260,11 @@ function resolveField(
|
|
|
218
260
|
return { codec: value, urlKey: value.urlKey };
|
|
219
261
|
}
|
|
220
262
|
|
|
221
|
-
// Auto-detect Standard Schema
|
|
263
|
+
// Auto-detect Standard Schema. Schemas that reject undefined input and
|
|
264
|
+
// have no default are implicitly optional: fromSchema returns undefined
|
|
265
|
+
// for absent params, and InferField widens the output type to
|
|
266
|
+
// T | undefined. design/23-search-params.md §"Implicit Optionality"
|
|
222
267
|
if (isStandardSchema(value)) {
|
|
223
|
-
// Validate that the schema handles absent params (undefined input).
|
|
224
|
-
// Search params are optional — the URL might not contain the param.
|
|
225
|
-
// If the schema rejects undefined and has no default, the codec would
|
|
226
|
-
// return `undefined as T`, making the type lie. Catch this at definition
|
|
227
|
-
// time instead. design/23-search-params.md §"Default Validation"
|
|
228
|
-
const absentCheck = validateSync(value, undefined);
|
|
229
|
-
if (absentCheck.issues) {
|
|
230
|
-
throw new Error(
|
|
231
|
-
`[timber] defineSearchParams: field '${fieldName}' has no default value and rejects undefined input.\n` +
|
|
232
|
-
` Search params are optional — the URL might not contain ?${fieldName}=anything.\n` +
|
|
233
|
-
` Add .default() to your schema: z.coerce.number().int().min(1).default(1)\n` +
|
|
234
|
-
` Or use .optional() if the field is truly optional.`
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
268
|
return { codec: fromSchema(value) };
|
|
238
269
|
}
|
|
239
270
|
|
|
@@ -244,25 +275,6 @@ function resolveField(
|
|
|
244
275
|
);
|
|
245
276
|
}
|
|
246
277
|
|
|
247
|
-
/**
|
|
248
|
-
* Validate that all codecs handle absent params (parse(undefined) doesn't throw).
|
|
249
|
-
* Catches schemas that throw on missing input. `undefined` and `null` are both
|
|
250
|
-
* valid defaults — `undefined` is correct for optional fields (e.g., `z.string().optional()`).
|
|
251
|
-
*/
|
|
252
|
-
function validateDefaults(codecMap: Record<string, SearchParamCodec<unknown>>): void {
|
|
253
|
-
for (const [key, codec] of Object.entries(codecMap)) {
|
|
254
|
-
try {
|
|
255
|
-
codec.parse(undefined);
|
|
256
|
-
} catch {
|
|
257
|
-
throw new Error(
|
|
258
|
-
`[timber] defineSearchParams: field '${key}' throws when the param is absent.\n` +
|
|
259
|
-
` Search params are optional — the URL might not contain ?${key}=anything.\n` +
|
|
260
|
-
` Add .default() or .optional() to your schema, or wrap with withDefault().`
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
278
|
// ---------------------------------------------------------------------------
|
|
267
279
|
// Factory
|
|
268
280
|
// ---------------------------------------------------------------------------
|
|
@@ -297,9 +309,13 @@ function validateDefaults(codecMap: Record<string, SearchParamCodec<unknown>>):
|
|
|
297
309
|
* )
|
|
298
310
|
* ```
|
|
299
311
|
*/
|
|
300
|
-
export function defineSearchParams<
|
|
301
|
-
|
|
302
|
-
|
|
312
|
+
export function defineSearchParams<
|
|
313
|
+
S extends StandardSchemaV1<Record<string, unknown>> & {
|
|
314
|
+
shape: Record<string, StandardSchemaV1<unknown>>;
|
|
315
|
+
},
|
|
316
|
+
>(
|
|
317
|
+
schema: S
|
|
318
|
+
): SearchParamsDefinition<{ [K in keyof S['shape'] & string]: InferField<S['shape'][K]> }>;
|
|
303
319
|
|
|
304
320
|
/**
|
|
305
321
|
* Overload: accept a map of codecs and/or Standard Schema objects.
|
|
@@ -357,9 +373,6 @@ function defineSearchParamsFromMap(
|
|
|
357
373
|
}
|
|
358
374
|
}
|
|
359
375
|
|
|
360
|
-
// Validate that all codecs handle absent params
|
|
361
|
-
validateDefaults(resolvedCodecs);
|
|
362
|
-
|
|
363
376
|
return buildDefinition(resolvedCodecs as unknown as CodecMap<Record<string, unknown>>, urlKeys);
|
|
364
377
|
}
|
|
365
378
|
|
|
@@ -484,11 +497,6 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
484
497
|
// Merge URL keys: base keys + new codec urlKeys from withUrlKey
|
|
485
498
|
const combinedUrlKeys: Record<string, string> = { ...urlKeys, ...newUrlKeys };
|
|
486
499
|
|
|
487
|
-
// Same definition-time guard as defineSearchParams — a throw-on-absent
|
|
488
|
-
// codec added via extend() gets the friendly guidance error, not a
|
|
489
|
-
// crash on first parse (TIM-1066).
|
|
490
|
-
validateDefaults(combinedCodecs as Record<string, SearchParamCodec<unknown>>);
|
|
491
|
-
|
|
492
500
|
return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);
|
|
493
501
|
}
|
|
494
502
|
|
|
@@ -515,8 +523,6 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
515
523
|
}
|
|
516
524
|
}
|
|
517
525
|
|
|
518
|
-
validateDefaults(pickedCodecs);
|
|
519
|
-
|
|
520
526
|
return buildDefinition<Pick<T, K>>(
|
|
521
527
|
pickedCodecs as unknown as CodecMap<Pick<T, K>>,
|
|
522
528
|
pickedUrlKeys
|