@timber-js/app 0.2.0-alpha.164 → 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.
Files changed (66) hide show
  1. package/dist/_chunks/{actions-CSDD6x7U.js → actions-CDPfMp_I.js} +3 -3
  2. package/dist/_chunks/{actions-CSDD6x7U.js.map → actions-CDPfMp_I.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-eb1gydM7.js → cache-api-DygSeKCB.js} +2 -2
  4. package/dist/_chunks/{cache-api-eb1gydM7.js.map → cache-api-DygSeKCB.js.map} +1 -1
  5. package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js → cli-schema-sync-EXGYPhI2.js} +3 -3
  6. package/dist/_chunks/{cli-schema-sync-mGfRbjh2.js.map → cli-schema-sync-EXGYPhI2.js.map} +1 -1
  7. package/dist/_chunks/{define-CFmvb4Bt.js → define-COtkxMRT.js} +16 -28
  8. package/dist/_chunks/define-COtkxMRT.js.map +1 -0
  9. package/dist/_chunks/{define-Bssfp6ot.js → define-c4au4I9R.js} +2 -2
  10. package/dist/_chunks/{define-Bssfp6ot.js.map → define-c4au4I9R.js.map} +1 -1
  11. package/dist/_chunks/{logger-B_O6-mdJ.js → logger-t3uxAmbX.js} +3 -3
  12. package/dist/_chunks/{logger-B_O6-mdJ.js.map → logger-t3uxAmbX.js.map} +1 -1
  13. package/dist/_chunks/{plugin-context-BnaiU_cF.js → plugin-context---kTF5v8.js} +2 -2
  14. package/dist/_chunks/{plugin-context-BnaiU_cF.js.map → plugin-context---kTF5v8.js.map} +1 -1
  15. package/dist/_chunks/{resolve-schema-3iUvBV5T.js → resolve-schema-Dz3fcFUo.js} +2 -2
  16. package/dist/_chunks/{resolve-schema-3iUvBV5T.js.map → resolve-schema-Dz3fcFUo.js.map} +1 -1
  17. package/dist/_chunks/{schema-bridge-BY3QLBL7.js → schema-bridge-DT_Tn0Xf.js} +2 -2
  18. package/dist/_chunks/{schema-bridge-BY3QLBL7.js.map → schema-bridge-DT_Tn0Xf.js.map} +1 -1
  19. package/dist/_chunks/{use-query-states-CbeQmext.js → use-query-states-DFvWd-EA.js} +65 -7
  20. package/dist/_chunks/use-query-states-DFvWd-EA.js.map +1 -0
  21. package/dist/_chunks/{walkers-BL3MCMgO.js → walkers-DBVzXuWc.js} +3 -3
  22. package/dist/_chunks/{walkers-BL3MCMgO.js.map → walkers-DBVzXuWc.js.map} +1 -1
  23. package/dist/adapters/nitro.d.ts +29 -0
  24. package/dist/adapters/nitro.d.ts.map +1 -1
  25. package/dist/adapters/nitro.js +57 -10
  26. package/dist/adapters/nitro.js.map +1 -1
  27. package/dist/cache/index.js +1 -1
  28. package/dist/cli.js +2 -2
  29. package/dist/client/internal.js +1 -1
  30. package/dist/client/use-query-states.d.ts.map +1 -1
  31. package/dist/codec.js +1 -1
  32. package/dist/cookies/index.js +1 -1
  33. package/dist/dev-tools/logs.d.ts +15 -1
  34. package/dist/dev-tools/logs.d.ts.map +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +104 -20
  37. package/dist/index.js.map +1 -1
  38. package/dist/params/index.js +1 -1
  39. package/dist/routing/index.js +2 -2
  40. package/dist/schema-bridge.d.ts.map +1 -1
  41. package/dist/search-params/define.d.ts +35 -4
  42. package/dist/search-params/define.d.ts.map +1 -1
  43. package/dist/search-params/index.js +3 -7
  44. package/dist/search-params/index.js.map +1 -1
  45. package/dist/search-params/wrappers.d.ts +2 -2
  46. package/dist/search-params/wrappers.d.ts.map +1 -1
  47. package/dist/segment-params/index.js +1 -1
  48. package/dist/server/index.js +2 -2
  49. package/dist/server/internal.js +8 -6
  50. package/dist/server/internal.js.map +1 -1
  51. package/dist/server/stream-utils.d.ts.map +1 -1
  52. package/docs/api/33-api-search-params.mdx +13 -0
  53. package/docs/learn/00-introduction.mdx +1 -2
  54. package/package.json +8 -7
  55. package/src/adapters/nitro.ts +106 -4
  56. package/src/cli.ts +0 -0
  57. package/src/client/use-query-states.ts +99 -7
  58. package/src/dev-tools/logs.ts +119 -34
  59. package/src/index.ts +14 -1
  60. package/src/schema-bridge.ts +4 -3
  61. package/src/search-params/define.ts +57 -51
  62. package/src/search-params/wrappers.ts +17 -26
  63. package/src/server/stream-utils.ts +8 -6
  64. package/LICENSE +0 -8
  65. package/dist/_chunks/define-CFmvb4Bt.js.map +0 -1
  66. 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,CA0D5B"}
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.164",
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
+ }
@@ -5,9 +5,9 @@
5
5
  // compression, graceful shutdown, static file serving, and platform quirks.
6
6
  // See design/11-platform.md and design/25-production-deployments.md.
7
7
 
8
- import { writeFile, readFile } from 'node:fs/promises';
8
+ import { writeFile, readFile, cp, glob } from 'node:fs/promises';
9
9
  import { execFile } from 'node:child_process';
10
- import { join, relative } from 'node:path';
10
+ import { join, relative, dirname, basename } from 'node:path';
11
11
  import type { TimberPlatformAdapter, TimberConfig } from './types';
12
12
  import { generateCompressModule } from './compress-module.js';
13
13
  import { IMMUTABLE_CACHE } from './shared.js';
@@ -38,6 +38,12 @@ interface PresetConfig {
38
38
  nitroPreset: string;
39
39
  /** Output directory name within the build dir. */
40
40
  outputDir: string;
41
+ /**
42
+ * Path to the server bundle directory relative to outputDir.
43
+ * Nitro places the bundled server entry + _chunks/ here.
44
+ * Most presets use `server`; Vercel uses `functions/__server.func`.
45
+ */
46
+ serverBundleDir: string;
41
47
  /** Whether the runtime supports waitUntil. */
42
48
  supportsWaitUntil: boolean;
43
49
  /** Whether the runtime supports application-level 103 Early Hints. */
@@ -52,6 +58,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
52
58
  'vercel': {
53
59
  nitroPreset: 'vercel',
54
60
  outputDir: '.vercel/output',
61
+ serverBundleDir: 'functions/__server.func',
55
62
  supportsWaitUntil: true,
56
63
  supportsEarlyHints: false,
57
64
  runtimeName: 'vercel',
@@ -60,6 +67,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
60
67
  'vercel-edge': {
61
68
  nitroPreset: 'vercel-edge',
62
69
  outputDir: '.vercel/output',
70
+ serverBundleDir: 'functions/__server.func',
63
71
  supportsWaitUntil: true,
64
72
  supportsEarlyHints: false,
65
73
  runtimeName: 'vercel-edge',
@@ -67,6 +75,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
67
75
  'netlify': {
68
76
  nitroPreset: 'netlify',
69
77
  outputDir: '.netlify/functions-internal',
78
+ serverBundleDir: 'server',
70
79
  supportsWaitUntil: false,
71
80
  supportsEarlyHints: false,
72
81
  runtimeName: 'netlify',
@@ -74,6 +83,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
74
83
  'netlify-edge': {
75
84
  nitroPreset: 'netlify-edge',
76
85
  outputDir: '.netlify/edge-functions',
86
+ serverBundleDir: 'server',
77
87
  supportsWaitUntil: true,
78
88
  supportsEarlyHints: false,
79
89
  runtimeName: 'netlify-edge',
@@ -81,6 +91,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
81
91
  'aws-lambda': {
82
92
  nitroPreset: 'aws-lambda',
83
93
  outputDir: '.output',
94
+ serverBundleDir: 'server',
84
95
  supportsWaitUntil: false,
85
96
  supportsEarlyHints: false,
86
97
  runtimeName: 'aws-lambda',
@@ -88,6 +99,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
88
99
  'deno-deploy': {
89
100
  nitroPreset: 'deno-deploy',
90
101
  outputDir: '.output',
102
+ serverBundleDir: 'server',
91
103
  supportsWaitUntil: true,
92
104
  supportsEarlyHints: false,
93
105
  runtimeName: 'deno-deploy',
@@ -95,6 +107,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
95
107
  'azure-functions': {
96
108
  nitroPreset: 'azure-functions',
97
109
  outputDir: '.output',
110
+ serverBundleDir: 'server',
98
111
  supportsWaitUntil: false,
99
112
  supportsEarlyHints: false,
100
113
  runtimeName: 'azure-functions',
@@ -102,6 +115,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
102
115
  'node-server': {
103
116
  nitroPreset: 'node-server',
104
117
  outputDir: '.output',
118
+ serverBundleDir: 'server',
105
119
  supportsWaitUntil: true,
106
120
  // Disabled by default: most node-server deployments sit behind a
107
121
  // reverse proxy (nginx, caddy, traefik) that doesn't support 103
@@ -116,6 +130,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
116
130
  'bun': {
117
131
  nitroPreset: 'bun',
118
132
  outputDir: '.output',
133
+ serverBundleDir: 'server',
119
134
  supportsWaitUntil: true,
120
135
  // Disabled for same reason as node-server — reverse proxies choke on 103.
121
136
  // Link headers on the 200 response are converted to 103 by CDNs.
@@ -220,7 +235,29 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
220
235
  // Run the Nitro build to produce a production-ready server bundle.
221
236
  // The output goes to dist/nitro/.output/server/index.mjs (for node-server preset).
222
237
  // Config is passed programmatically — no nitro.config.ts file needed.
223
- await runNitroBuild(outDir, preset, options.nitroConfig);
238
+ const { serverDir } = await runNitroBuild(outDir, preset, options.nitroConfig);
239
+
240
+ // Copy prebuilt flight payloads from the canonical build source
241
+ // (buildDir/prebuilt, cleaned by the prebuilt plugin each build) into
242
+ // the Nitro output. The RSC entry resolves `../prebuilt/` relative to
243
+ // its bundled chunk (<serverDir>/_chunks/), so prebuilt/ must live at
244
+ // <serverDir>/prebuilt/. serverDir is the resolved path from Nitro
245
+ // (honors user overrides via nitroConfig.output.serverDir).
246
+ const prebuiltSrc = join(buildDir, 'prebuilt');
247
+ await cp(prebuiltSrc, join(serverDir, 'prebuilt'), {
248
+ recursive: true,
249
+ }).catch((e: NodeJS.ErrnoException) => {
250
+ if (e.code !== 'ENOENT') throw e;
251
+ });
252
+
253
+ // Vercel with functionRules clones __server.func to per-route .func
254
+ // directories during the Nitro build. Copy prebuilt into those clones
255
+ // so requests routed to them also resolve ../prebuilt correctly.
256
+ if (preset === 'vercel' || preset === 'vercel-edge') {
257
+ const functionsDir = dirname(serverDir);
258
+ const baseFuncName = basename(serverDir);
259
+ await copyPrebuiltToVercelFuncClones(functionsDir, prebuiltSrc, baseFuncName);
260
+ }
224
261
  },
225
262
 
226
263
  // Only presets that produce a locally-runnable server get preview().
@@ -598,11 +635,16 @@ export function generateNitroPreviewCommand(
598
635
  * Externalizes the timber RSC/SSR output — those files are pre-built
599
636
  * by timber and have internal references that nitro's bundler can't follow.
600
637
  */
638
+ interface NitroBuildResult {
639
+ /** Resolved output.serverDir — where the server bundle + _chunks/ live. */
640
+ serverDir: string;
641
+ }
642
+
601
643
  async function runNitroBuild(
602
644
  nitroDir: string,
603
645
  preset: NitroPreset,
604
646
  userConfig?: Record<string, unknown>
605
- ): Promise<void> {
647
+ ): Promise<NitroBuildResult> {
606
648
  const presetConfig = PRESET_CONFIGS[preset];
607
649
  const {
608
650
  createNitro,
@@ -633,10 +675,18 @@ async function runNitroBuild(
633
675
  ...userConfig,
634
676
  });
635
677
 
678
+ // Read the resolved serverDir AFTER config merging — userConfig may
679
+ // override output.dir or output.serverDir. Nitro appends a trailing
680
+ // slash during resolution; strip it for join() compatibility.
681
+ const raw = nitro.options.output.serverDir;
682
+ const serverDir = raw.endsWith('/') ? raw.slice(0, -1) : raw;
683
+
636
684
  await prepare(nitro);
637
685
  await copyPublicAssets(nitro);
638
686
  await nitroBuild(nitro);
639
687
  await nitro.close();
688
+
689
+ return { serverDir };
640
690
  }
641
691
 
642
692
  /** Spawn a Nitro preview process and pipe stdio. */
@@ -729,3 +779,55 @@ export function sendNodeResponse(nodeRes, webRes) {
729
779
  export function getPresetConfig(preset: NitroPreset): PresetConfig {
730
780
  return PRESET_CONFIGS[preset];
731
781
  }
782
+
783
+ /**
784
+ * Resolve the destination path for prebuilt flight payloads in the Nitro
785
+ * output directory. The RSC entry resolves `../prebuilt/` relative to its
786
+ * bundled chunk location (`<serverBundleDir>/_chunks/`), so prebuilt/ must
787
+ * live at `<serverBundleDir>/prebuilt/`.
788
+ *
789
+ * The serverBundleDir varies by preset — Nitro's Vercel preset uses
790
+ * `functions/__server.func` while most others use `server`.
791
+ *
792
+ * @internal Exported for testing.
793
+ */
794
+ export function resolvePrebuiltOutputPath(outDir: string, preset: NitroPreset): string {
795
+ const presetConfig = PRESET_CONFIGS[preset];
796
+ return join(outDir, presetConfig.outputDir, presetConfig.serverBundleDir, 'prebuilt');
797
+ }
798
+
799
+ /**
800
+ * Copy prebuilt payloads into cloned Vercel function directories.
801
+ *
802
+ * When users configure `vercel.functionRules`, Nitro's compiled hook clones
803
+ * the base __server.func to per-route .func directories. These clones are
804
+ * created during `runNitroBuild()` — before our post-build prebuilt copy.
805
+ * Each clone resolves `../prebuilt` independently, so they each need a copy.
806
+ *
807
+ * @internal Exported for testing.
808
+ */
809
+ export async function copyPrebuiltToVercelFuncClones(
810
+ functionsDir: string,
811
+ prebuiltSrc: string,
812
+ baseFuncName: string
813
+ ): Promise<void> {
814
+ const funcDirs: string[] = [];
815
+ try {
816
+ for await (const match of glob('**/*.func', { cwd: functionsDir })) {
817
+ if (!match.endsWith(baseFuncName)) {
818
+ funcDirs.push(join(functionsDir, match));
819
+ }
820
+ }
821
+ } catch {
822
+ return;
823
+ }
824
+ await Promise.all(
825
+ funcDirs.map((dir) =>
826
+ cp(prebuiltSrc, join(dir, 'prebuilt'), { recursive: true }).catch(
827
+ (e: NodeJS.ErrnoException) => {
828
+ if (e.code !== 'ENOENT') throw e;
829
+ }
830
+ )
831
+ )
832
+ );
833
+ }
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: 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
  };
@@ -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