@zerotal/client 1.8.1 → 1.10.0

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/CHANGELOG.md CHANGED
@@ -8,6 +8,16 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.9.0] — 2026-08-29
12
+
13
+ ### Documented
14
+
15
+ - **Every promised export is documented.** The `docs-coverage` gate reads `maturity: stable` as a
16
+ promise about a package's exports, and measures how much of that promise is written down. It
17
+ was 798 gaps across the suite; it is now zero. This package's share is covered on its own
18
+ pages — types named, options shapes described, and the decisions behind them recorded where
19
+ somebody looking for them will find them.
20
+
11
21
  ## [1.7.2] — 2026-08-18
12
22
 
13
23
  ### Fixed
package/api-surface.md CHANGED
@@ -127,64 +127,64 @@ function ClientConfig = (options?: Partial<ClientConfigShape>) => Partial<Client
127
127
  function createApiClient = <Routes extends ApiRouteMap>(config?: ApiClientConfig) => ApiClient<Routes>
128
128
 
129
129
  interface ApiClientConfig = {
130
- baseUrl?: string
131
- circuitBreaker?: CircuitBreaker | CircuitBreakerOptions
132
- csrf?: boolean | { cookie?: string; header?: string;}
133
- headers?: Record<string, string>
134
- onError?: (error: ApiClientError) => void | Promise<void>
135
- onForbidden?: (error: ApiClientError) => void | Promise<void>
136
- onRequest?: RequestInterceptor | RequestInterceptor[]
137
- onResponse?: ResponseInterceptor | ResponseInterceptor[]
138
- onUnauthorized?: (error: ApiClientError, retry: (headerOverrides?: Record<string, string>) => Promise<unknown>) => Promise<unknown> | void
139
- retry?: number | RetryOptions
140
- timeout?: number
141
- token?: TokenSource
142
- withCredentials?: boolean
130
+ baseUrl?: string | undefined
131
+ circuitBreaker?: CircuitBreaker | CircuitBreakerOptions | undefined
132
+ csrf?: boolean | { cookie?: string; header?: string;} | undefined
133
+ headers?: Record<string, string> | undefined
134
+ onError?: ((error: ApiClientError) => void | Promise<void>) | undefined
135
+ onForbidden?: ((error: ApiClientError) => void | Promise<void>) | undefined
136
+ onRequest?: RequestInterceptor | RequestInterceptor[] | undefined
137
+ onResponse?: ResponseInterceptor | ResponseInterceptor[] | undefined
138
+ onUnauthorized?: ((error: ApiClientError, retry: (headerOverrides?: Record<string, string>) => Promise<unknown>) => Promise<unknown> | void) | undefined
139
+ retry?: number | RetryOptions | undefined
140
+ timeout?: number | undefined
141
+ token?: TokenSource | undefined
142
+ withCredentials?: boolean | undefined
143
143
  }
144
144
 
145
145
  interface CircuitBreakerOptions = {
146
- isFailure?: (error: unknown) => boolean
147
- resetTimeout?: number
148
- threshold?: number
146
+ isFailure?: ((error: unknown) => boolean) | undefined
147
+ resetTimeout?: number | undefined
148
+ threshold?: number | undefined
149
149
  }
150
150
 
151
151
  interface ClientConfigShape = {
152
- baseUrl?: string
153
- circuitBreaker?: CircuitBreaker | CircuitBreakerOptions
154
- csrf?: boolean | { cookie?: string; header?: string;}
155
- headers?: Record<string, string>
156
- onError?: (error: ApiClientError) => void | Promise<void>
157
- onForbidden?: (error: ApiClientError) => void | Promise<void>
158
- onRequest?: RequestInterceptor | RequestInterceptor[]
159
- onResponse?: ResponseInterceptor | ResponseInterceptor[]
160
- onUnauthorized?: (error: ApiClientError, retry: (headerOverrides?: Record<string, string>) => Promise<unknown>) => Promise<unknown> | void
161
- retry?: number | RetryOptions
162
- timeout?: number
163
- token?: TokenSource
164
- withCredentials?: boolean
152
+ baseUrl?: string | undefined
153
+ circuitBreaker?: CircuitBreaker | CircuitBreakerOptions | undefined
154
+ csrf?: boolean | { cookie?: string; header?: string;} | undefined
155
+ headers?: Record<string, string> | undefined
156
+ onError?: ((error: ApiClientError) => void | Promise<void>) | undefined
157
+ onForbidden?: ((error: ApiClientError) => void | Promise<void>) | undefined
158
+ onRequest?: RequestInterceptor | RequestInterceptor[] | undefined
159
+ onResponse?: ResponseInterceptor | ResponseInterceptor[] | undefined
160
+ onUnauthorized?: ((error: ApiClientError, retry: (headerOverrides?: Record<string, string>) => Promise<unknown>) => Promise<unknown> | void) | undefined
161
+ retry?: number | RetryOptions | undefined
162
+ timeout?: number | undefined
163
+ token?: TokenSource | undefined
164
+ withCredentials?: boolean | undefined
165
165
  }
166
166
 
167
167
  interface GetOptions = {
168
- headers?: Record<string, string>
169
- init?: Omit<RequestInit, 'method' | 'headers' | 'body'>
170
- meta?: (meta: ResponseMeta) => void
171
- query?: Q
172
- responseType?: 'text' | 'json' | 'auto' | 'blob' | 'arrayBuffer'
173
- retry?: number | false | RetryOptions
174
- signal?: AbortSignal
175
- timeout?: number
168
+ headers?: Record<string, string> | undefined
169
+ init?: Omit<RequestInit, 'method' | 'headers' | 'body'> | undefined
170
+ meta?: ((meta: ResponseMeta) => void) | undefined
171
+ query?: Q | undefined
172
+ responseType?: 'text' | 'json' | 'auto' | 'blob' | 'arrayBuffer' | undefined
173
+ retry?: number | false | RetryOptions | undefined
174
+ signal?: AbortSignal | undefined
175
+ timeout?: number | undefined
176
176
  }
177
177
 
178
178
  interface MutationOptions = {
179
- headers?: Record<string, string>
180
- init?: Omit<RequestInit, 'method' | 'headers' | 'body'>
181
- meta?: (meta: ResponseMeta) => void
182
- params?: Record<string, string | number>
183
- query?: Q
184
- responseType?: 'text' | 'json' | 'auto' | 'blob' | 'arrayBuffer'
185
- retry?: number | false | RetryOptions
186
- signal?: AbortSignal
187
- timeout?: number
179
+ headers?: Record<string, string> | undefined
180
+ init?: Omit<RequestInit, 'method' | 'headers' | 'body'> | undefined
181
+ meta?: ((meta: ResponseMeta) => void) | undefined
182
+ params?: Record<string, string | number> | undefined
183
+ query?: Q | undefined
184
+ responseType?: 'text' | 'json' | 'auto' | 'blob' | 'arrayBuffer' | undefined
185
+ retry?: number | false | RetryOptions | undefined
186
+ signal?: AbortSignal | undefined
187
+ timeout?: number | undefined
188
188
  }
189
189
 
190
190
  interface PresenceMember = {
@@ -200,13 +200,13 @@ interface RequestConfig = {
200
200
  }
201
201
 
202
202
  interface RequestOptions = {
203
- headers?: Record<string, string>
204
- init?: Omit<RequestInit, 'method' | 'headers' | 'body'>
205
- meta?: (meta: ResponseMeta) => void
206
- responseType?: 'text' | 'json' | 'auto' | 'blob' | 'arrayBuffer'
207
- retry?: number | false | RetryOptions
208
- signal?: AbortSignal
209
- timeout?: number
203
+ headers?: Record<string, string> | undefined
204
+ init?: Omit<RequestInit, 'method' | 'headers' | 'body'> | undefined
205
+ meta?: ((meta: ResponseMeta) => void) | undefined
206
+ responseType?: 'text' | 'json' | 'auto' | 'blob' | 'arrayBuffer' | undefined
207
+ retry?: number | false | RetryOptions | undefined
208
+ signal?: AbortSignal | undefined
209
+ timeout?: number | undefined
210
210
  }
211
211
 
212
212
  interface ResponseContext = {
@@ -222,11 +222,11 @@ interface ResponseMeta = {
222
222
  }
223
223
 
224
224
  interface RetryOptions = {
225
- attempts?: number
226
- backoff?: (attempt: number, retryAfterMs: number | null) => number
227
- methods?: string[]
228
- respectRetryAfter?: boolean
229
- statuses?: number[]
225
+ attempts?: number | undefined
226
+ backoff?: ((attempt: number, retryAfterMs: number | null) => number) | undefined
227
+ methods?: string[] | undefined
228
+ respectRetryAfter?: boolean | undefined
229
+ statuses?: number[] | undefined
230
230
  }
231
231
 
232
232
  interface RouteShape = {
@@ -247,20 +247,20 @@ interface SocketLike = {
247
247
  }
248
248
 
249
249
  interface SocketOptions = {
250
- WebSocket?: new (url: string) => SocketLike
251
- auth?: { params?: Record<string, string>; headers?: Record<string, string>;}
252
- authEndpoint?: string | false
253
- autoConnect?: boolean
254
- fetch?: typeof fetch
255
- host?: string
256
- maxReconnectDelay?: number
257
- path?: string
258
- pingInterval?: number
259
- port?: string | number
260
- reconnect?: boolean
261
- reconnectDelay?: number
262
- scheme?: 'ws' | 'wss'
263
- url?: string
250
+ WebSocket?: (new (url: string) => SocketLike) | undefined
251
+ auth?: { params?: Record<string, string>; headers?: Record<string, string>;} | undefined
252
+ authEndpoint?: string | false | undefined
253
+ autoConnect?: boolean | undefined
254
+ fetch?: typeof fetch | undefined
255
+ host?: string | undefined
256
+ maxReconnectDelay?: number | undefined
257
+ path?: string | undefined
258
+ pingInterval?: number | undefined
259
+ port?: string | number | undefined
260
+ reconnect?: boolean | undefined
261
+ reconnectDelay?: number | undefined
262
+ scheme?: 'ws' | 'wss' | undefined
263
+ url?: string | undefined
264
264
  }
265
265
 
266
266
  type ApiRouteMap = { [x: `GET ${string}`]: RouteShape | undefined; [x: `POST ${string}`]: RouteShape | undefined; [x: `PUT ${string}`]: RouteShape | undefined; [x: `PATCH ${string}`]: RouteShape | undefined; [x: `DELETE ${string}`]: RouteShape | undefined;}
@@ -373,20 +373,20 @@ interface SocketLike = {
373
373
  }
374
374
 
375
375
  interface SocketOptions = {
376
- WebSocket?: new (url: string) => SocketLike
377
- auth?: { params?: Record<string, string>; headers?: Record<string, string>;}
378
- authEndpoint?: string | false
379
- autoConnect?: boolean
380
- fetch?: typeof fetch
381
- host?: string
382
- maxReconnectDelay?: number
383
- path?: string
384
- pingInterval?: number
385
- port?: string | number
386
- reconnect?: boolean
387
- reconnectDelay?: number
388
- scheme?: 'ws' | 'wss'
389
- url?: string
376
+ WebSocket?: (new (url: string) => SocketLike) | undefined
377
+ auth?: { params?: Record<string, string>; headers?: Record<string, string>;} | undefined
378
+ authEndpoint?: string | false | undefined
379
+ autoConnect?: boolean | undefined
380
+ fetch?: typeof fetch | undefined
381
+ host?: string | undefined
382
+ maxReconnectDelay?: number | undefined
383
+ path?: string | undefined
384
+ pingInterval?: number | undefined
385
+ port?: string | number | undefined
386
+ reconnect?: boolean | undefined
387
+ reconnectDelay?: number | undefined
388
+ scheme?: 'ws' | 'wss' | undefined
389
+ url?: string | undefined
390
390
  }
391
391
 
392
392
  type SocketState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'error'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/client",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -35,7 +35,7 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.8.1"
38
+ "@zerotal/core": "1.10.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0"
package/src/ApiClient.ts CHANGED
@@ -73,31 +73,31 @@ export interface ResponseMeta {
73
73
 
74
74
  export interface RequestOptions {
75
75
  /** Extra headers merged on top of the client's default headers. */
76
- headers?: Record<string, string>;
76
+ headers?: Record<string, string> | undefined;
77
77
  /** Additional `RequestInit` fields (e.g. `credentials`, `mode`, `cache`). */
78
- init?: Omit<RequestInit, "method" | "headers" | "body">;
78
+ init?: Omit<RequestInit, "method" | "headers" | "body"> | undefined;
79
79
  /** Abort signal for cancellation (combined with any `timeout`). */
80
- signal?: AbortSignal;
80
+ signal?: AbortSignal | undefined;
81
81
  /** Per-request timeout in ms (overrides the client default; `0` disables). */
82
- timeout?: number;
82
+ timeout?: number | undefined;
83
83
  /** Per-request retry policy (overrides the client default; `false` disables). */
84
- retry?: number | RetryOptions | false;
84
+ retry?: number | RetryOptions | false | undefined;
85
85
  /** How to read the response body. Default `"auto"` (JSON with text fallback). */
86
- responseType?: "auto" | "json" | "text" | "blob" | "arrayBuffer";
86
+ responseType?: "auto" | "json" | "text" | "blob" | "arrayBuffer" | undefined;
87
87
  /** Read response metadata (status, headers) without a global interceptor. */
88
- meta?: (meta: ResponseMeta) => void;
88
+ meta?: ((meta: ResponseMeta) => void) | undefined;
89
89
  }
90
90
 
91
91
  export interface GetOptions<Q> extends RequestOptions {
92
92
  /** Query-string parameters (nested arrays/objects are serialized with bracket notation). */
93
- query?: Q;
93
+ query?: Q | undefined;
94
94
  }
95
95
 
96
96
  export interface MutationOptions<Q> extends RequestOptions {
97
97
  /** URL path parameters for routes like `/api/users/{id}/action`. */
98
- params?: Record<string, string | number>;
98
+ params?: Record<string, string | number> | undefined;
99
99
  /** Query-string parameters. */
100
- query?: Q;
100
+ query?: Q | undefined;
101
101
  }
102
102
 
103
103
  // ── Client config ─────────────────────────────────────────────────────────────
@@ -108,39 +108,39 @@ export type TokenSource =
108
108
 
109
109
  export interface ApiClientConfig {
110
110
  /** Base URL prepended to every request path, e.g. `'https://api.example.com'`. */
111
- baseUrl?: string;
111
+ baseUrl?: string | undefined;
112
112
  /** Default headers sent with every request (e.g. `Authorization`). */
113
- headers?: Record<string, string>;
113
+ headers?: Record<string, string> | undefined;
114
114
  /**
115
115
  * Bearer token attached as `Authorization: Bearer <token>` when no `Authorization` header is
116
116
  * already present. May be a string or a (possibly async) resolver. Update at runtime with
117
117
  * {@link ApiClient.setToken}.
118
118
  */
119
- token?: TokenSource;
119
+ token?: TokenSource | undefined;
120
120
  /** Send credentials (cookies) with every request — sets `credentials: 'include'`. */
121
- withCredentials?: boolean;
121
+ withCredentials?: boolean | undefined;
122
122
  /**
123
123
  * Attach the CSRF/XSRF token to mutating requests: reads the `XSRF-TOKEN` cookie and sends it as
124
124
  * `X-XSRF-TOKEN` (the SPA/session flow). Defaults to `true` when `withCredentials` is set.
125
125
  * Customize the cookie/header names with an object.
126
126
  */
127
- csrf?: boolean | { cookie?: string; header?: string };
127
+ csrf?: boolean | { cookie?: string; header?: string } | undefined;
128
128
  /** Default per-request timeout in ms (`0`/omitted = no timeout). */
129
- timeout?: number;
129
+ timeout?: number | undefined;
130
130
  /**
131
131
  * Default retry policy. `2` retries idempotent requests on network errors / 5xx / 429 with
132
132
  * exponential backoff (honoring `Retry-After`); pass {@link RetryOptions} to tune, or omit to
133
133
  * disable. Per-request `retry` overrides this.
134
134
  */
135
- retry?: number | RetryOptions;
135
+ retry?: number | RetryOptions | undefined;
136
136
  /** Called for every non-2xx response before the error is thrown. */
137
- onError?: (error: ApiClientError) => void | Promise<void>;
137
+ onError?: ((error: ApiClientError) => void | Promise<void>) | undefined;
138
138
  /** Called when a request receives `403 Forbidden`. */
139
- onForbidden?: (error: ApiClientError) => void | Promise<void>;
139
+ onForbidden?: ((error: ApiClientError) => void | Promise<void>) | undefined;
140
140
  /** One or more request interceptors executed in order before every fetch. */
141
- onRequest?: RequestInterceptor | RequestInterceptor[];
141
+ onRequest?: RequestInterceptor | RequestInterceptor[] | undefined;
142
142
  /** One or more response interceptors executed in order after every successful (2xx) response. */
143
- onResponse?: ResponseInterceptor | ResponseInterceptor[];
143
+ onResponse?: ResponseInterceptor | ResponseInterceptor[] | undefined;
144
144
  /**
145
145
  * Called when a request receives `401 Unauthorized`. Receives the error and a `retry` function
146
146
  * (optionally with header overrides) to re-execute the request once.
@@ -148,12 +148,14 @@ export interface ApiClientConfig {
148
148
  * @example
149
149
  * onUnauthorized: async (err, retry) => retry({ Authorization: `Bearer ${await refresh()}` }),
150
150
  */
151
- onUnauthorized?: (
152
- error: ApiClientError,
153
- retry: (headerOverrides?: Record<string, string>) => Promise<unknown>,
154
- ) => Promise<unknown> | void;
151
+ onUnauthorized?:
152
+ | ((
153
+ error: ApiClientError,
154
+ retry: (headerOverrides?: Record<string, string>) => Promise<unknown>,
155
+ ) => Promise<unknown> | void)
156
+ | undefined;
155
157
  /** Attach a circuit breaker (instance to share, or options to create a dedicated one). */
156
- circuitBreaker?: CircuitBreaker | CircuitBreakerOptions;
158
+ circuitBreaker?: CircuitBreaker | CircuitBreakerOptions | undefined;
157
159
  }
158
160
 
159
161
  // ── ApiClient<Routes> ───────────────────────────────────────────────────────────
@@ -10,19 +10,19 @@ export interface CircuitBreakerOptions {
10
10
  * Number of consecutive failures required to open the circuit.
11
11
  * Default: `5`.
12
12
  */
13
- threshold?: number;
13
+ threshold?: number | undefined;
14
14
  /**
15
15
  * Milliseconds to wait in the open state before allowing a single probe
16
16
  * request through (transition to half-open). Default: `30_000` (30 s).
17
17
  */
18
- resetTimeout?: number;
18
+ resetTimeout?: number | undefined;
19
19
  /**
20
20
  * Predicate that decides whether a thrown error counts as a failure.
21
21
  * By default, network errors and `ApiClientError` responses with status ≥ 500
22
22
  * are failures; 4xx errors are not (they indicate client mistakes, not
23
23
  * upstream outages).
24
24
  */
25
- isFailure?: (error: unknown) => boolean;
25
+ isFailure?: ((error: unknown) => boolean) | undefined;
26
26
  }
27
27
 
28
28
  // ── Error thrown when the circuit is open ─────────────────────────────────────
package/src/Socket.ts CHANGED
@@ -50,42 +50,42 @@ export interface SocketOptions {
50
50
  * Full WebSocket URL. When omitted it's derived from the page location (browser) as
51
51
  * `ws(s)://<host>:<port><path>`. Required in non-browser environments.
52
52
  */
53
- url?: string;
53
+ url?: string | undefined;
54
54
  /** WebSocket upgrade path. Defaults to `/app/ws` (matches the broadcasting config default). */
55
- path?: string;
55
+ path?: string | undefined;
56
56
  /** Host override (defaults to `location.hostname`). */
57
- host?: string;
57
+ host?: string | undefined;
58
58
  /** Port override (defaults to `location.port`). */
59
- port?: string | number;
59
+ port?: string | number | undefined;
60
60
  /** Force a scheme. Defaults to `wss` on https pages, `ws` otherwise. */
61
- scheme?: "ws" | "wss";
61
+ scheme?: "ws" | "wss" | undefined;
62
62
  /**
63
63
  * Authentication for the connection and for private/presence channels:
64
64
  * - `params` — query params appended to the WS URL (browsers can't set WS headers), read
65
65
  * by the server on upgrade.
66
66
  * - `headers` — sent on the `POST /broadcasting/auth` request (e.g. a CSRF token).
67
67
  */
68
- auth?: { params?: Record<string, string>; headers?: Record<string, string> };
68
+ auth?: { params?: Record<string, string>; headers?: Record<string, string> } | undefined;
69
69
  /**
70
70
  * Endpoint that signs a private/presence subscription (Pusher-style). Defaults to
71
71
  * `/broadcasting/auth`. Set to `false` to skip the signature fetch and rely on
72
72
  * connection-level authorization instead.
73
73
  */
74
- authEndpoint?: string | false;
74
+ authEndpoint?: string | false | undefined;
75
75
  /** Inject a `fetch` implementation for the auth request (tests / non-DOM runtimes). */
76
- fetch?: typeof fetch;
76
+ fetch?: typeof fetch | undefined;
77
77
  /** Connect immediately on construction. Default: true. */
78
- autoConnect?: boolean;
78
+ autoConnect?: boolean | undefined;
79
79
  /** Reconnect automatically after an unexpected close. Default: true. */
80
- reconnect?: boolean;
80
+ reconnect?: boolean | undefined;
81
81
  /** Initial reconnect delay in ms (doubles each attempt up to `maxReconnectDelay`). Default: 1000. */
82
- reconnectDelay?: number;
82
+ reconnectDelay?: number | undefined;
83
83
  /** Cap on the reconnect backoff in ms. Default: 30000. */
84
- maxReconnectDelay?: number;
84
+ maxReconnectDelay?: number | undefined;
85
85
  /** Heartbeat ping interval in ms (0 disables). Default: 30000. */
86
- pingInterval?: number;
86
+ pingInterval?: number | undefined;
87
87
  /** Inject a WebSocket constructor (tests / non-DOM runtimes). Defaults to the global. */
88
- WebSocket?: new (url: string) => SocketLike;
88
+ WebSocket?: (new (url: string) => SocketLike) | undefined;
89
89
  }
90
90
 
91
91
  type Listener = (payload: unknown) => void;
@@ -217,6 +217,16 @@ export class PresenceChannel extends Channel {
217
217
  }
218
218
  }
219
219
 
220
+ /** The options {@link Socket}'s constructor always fills in. */
221
+ type GuaranteedOption =
222
+ | "path"
223
+ | "autoConnect"
224
+ | "reconnect"
225
+ | "reconnectDelay"
226
+ | "maxReconnectDelay"
227
+ | "pingInterval"
228
+ | "authEndpoint";
229
+
220
230
  /**
221
231
  * Realtime client for Zerotal broadcasting (native `ws` / `redis` drivers). Exposes a
222
232
  * familiar realtime-client API over the native protocol — see the module header for usage.
@@ -231,19 +241,17 @@ export class Socket {
231
241
  private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
232
242
  private _pingTimer: ReturnType<typeof setInterval> | null = null;
233
243
  private _closedByUser = false;
234
- private readonly _opts: Required<
235
- Pick<
236
- SocketOptions,
237
- | "path"
238
- | "autoConnect"
239
- | "reconnect"
240
- | "reconnectDelay"
241
- | "maxReconnectDelay"
242
- | "pingInterval"
243
- | "authEndpoint"
244
- >
245
- > &
246
- SocketOptions;
244
+ /**
245
+ * The seven fields the constructor guarantees, plus whatever else was passed.
246
+ *
247
+ * `Omit` rather than `& SocketOptions`: intersecting with the whole shape puts the
248
+ * optional declaration of each guaranteed field back alongside the required one, so
249
+ * `pingInterval` read as possibly `undefined` in the very code that had just given
250
+ * it a default.
251
+ */
252
+ private readonly _opts: {
253
+ [K in GuaranteedOption]-?: Exclude<SocketOptions[K], undefined>;
254
+ } & Omit<SocketOptions, GuaranteedOption>;
247
255
 
248
256
  constructor(options: SocketOptions = {}) {
249
257
  // The caller's options go FIRST, then the defaults fill the gaps.
package/src/config.ts CHANGED
@@ -2,7 +2,7 @@ import { deepMerge } from "@zerotal/core/helpers";
2
2
  import type { ApiClientConfig } from "./ApiClient.ts";
3
3
 
4
4
  export interface ClientConfigShape extends ApiClientConfig {
5
- baseUrl?: string;
5
+ baseUrl?: string | undefined;
6
6
  }
7
7
 
8
8
  // The client carries no framework-level defaults of its own — `ApiClient` applies
package/src/http.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { definedOnly, type Resolved } from "@zerotal/core/helpers";
1
2
  import { ApiClientError } from "./errors.ts";
2
3
  import { CircuitBreakerOpenError } from "./CircuitBreaker.ts";
3
4
 
@@ -109,18 +110,18 @@ export function readCookie(name: string): string | undefined {
109
110
 
110
111
  export interface RetryOptions {
111
112
  /** Max retry attempts after the first try. Default: 2. */
112
- attempts?: number;
113
+ attempts?: number | undefined;
113
114
  /** HTTP methods eligible for retry. Default: idempotent verbs (GET/PUT/DELETE/HEAD/OPTIONS). */
114
- methods?: string[];
115
+ methods?: string[] | undefined;
115
116
  /** HTTP statuses that should be retried. Default: 408, 425, 429, 500, 502, 503, 504. */
116
- statuses?: number[];
117
+ statuses?: number[] | undefined;
117
118
  /** Honor a `Retry-After` header when present. Default: true. */
118
- respectRetryAfter?: boolean;
119
+ respectRetryAfter?: boolean | undefined;
119
120
  /** Compute the delay (ms) before attempt N. Default: exponential backoff + jitter. */
120
- backoff?: (attempt: number, retryAfterMs: number | null) => number;
121
+ backoff?: ((attempt: number, retryAfterMs: number | null) => number) | undefined;
121
122
  }
122
123
 
123
- const DEFAULT_RETRY: Required<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff"> = {
124
+ const DEFAULT_RETRY: Resolved<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff"> = {
124
125
  attempts: 2,
125
126
  methods: ["GET", "PUT", "DELETE", "HEAD", "OPTIONS"],
126
127
  statuses: [408, 425, 429, 500, 502, 503, 504],
@@ -131,12 +132,15 @@ const DEFAULT_RETRY: Required<Omit<RetryOptions, "backoff">> & Pick<RetryOptions
131
132
  export function resolveRetry(
132
133
  retry: number | RetryOptions | false | undefined,
133
134
  method: string,
134
- ): Required<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff"> {
135
+ ): Resolved<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff"> {
135
136
  if (retry === undefined || retry === false || retry === 0) {
136
137
  return { ...DEFAULT_RETRY, attempts: 0 };
137
138
  }
138
139
  const opts = typeof retry === "number" ? { attempts: retry } : retry;
139
- const merged = { ...DEFAULT_RETRY, ...opts };
140
+ // `definedOnly`, not a bare spread: these options accept an explicit `undefined`
141
+ // so that `{ attempts: cfg.attempts }` compiles, and a bare spread would then let
142
+ // that `undefined` overwrite the default rather than leave it standing.
143
+ const merged = { ...DEFAULT_RETRY, ...definedOnly(opts) };
140
144
  // Methods are matched case-insensitively.
141
145
  if (!merged.methods.some((m) => m.toUpperCase() === method.toUpperCase())) {
142
146
  return { ...merged, attempts: 0 };
@@ -173,7 +177,7 @@ function _isRetryable(
173
177
  /** Run `fn`, retrying transient failures per `policy`. */
174
178
  export async function withRetry<T>(
175
179
  fn: () => Promise<T>,
176
- policy: Required<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff">,
180
+ policy: Resolved<Omit<RetryOptions, "backoff">> & Pick<RetryOptions, "backoff">,
177
181
  ): Promise<T> {
178
182
  const backoff = policy.backoff ?? _defaultBackoff;
179
183
  let attempt = 0;