hanzio 1.1.0 → 2.0.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.
Files changed (172) hide show
  1. package/README.md +832 -219
  2. package/dist/api-wrapper/builders.d.ts +15 -3
  3. package/dist/api-wrapper/create-client.d.ts +1 -1
  4. package/dist/api-wrapper/describe.d.ts +16 -0
  5. package/dist/api-wrapper/endpoint.d.ts +18 -0
  6. package/dist/api-wrapper/errors.d.ts +40 -2
  7. package/dist/api-wrapper/headers.d.ts +2 -1
  8. package/dist/api-wrapper/index.d.ts +5 -0
  9. package/dist/api-wrapper/otel.d.ts +44 -0
  10. package/dist/api-wrapper/paginate.d.ts +12 -0
  11. package/dist/api-wrapper/redaction.d.ts +2 -0
  12. package/dist/api-wrapper/shared.d.ts +16 -2
  13. package/dist/api-wrapper/testing.d.ts +23 -0
  14. package/dist/api-wrapper/transport.d.ts +12 -10
  15. package/dist/api-wrapper/types.d.ts +104 -18
  16. package/dist/array/index.d.ts +50 -4
  17. package/dist/cache/createCache.d.ts +31 -0
  18. package/dist/cache/index.d.ts +45 -2
  19. package/dist/color/contrast.d.ts +40 -0
  20. package/dist/color/index.d.ts +2 -0
  21. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  22. package/dist/cool-console-log/index.d.ts +34 -5
  23. package/dist/crypto/encoding.d.ts +18 -0
  24. package/dist/crypto/hash.d.ts +33 -0
  25. package/dist/crypto/index.d.ts +4 -0
  26. package/dist/crypto/sealer.d.ts +25 -0
  27. package/dist/crypto/webhook.d.ts +20 -0
  28. package/dist/date/index.d.ts +46 -2
  29. package/dist/emitter/index.d.ts +21 -0
  30. package/dist/error/index.d.ts +12 -0
  31. package/dist/format/index.d.ts +102 -0
  32. package/dist/function/index.d.ts +60 -0
  33. package/dist/guard/index.d.ts +20 -0
  34. package/dist/i18n/index.d.ts +37 -0
  35. package/dist/index-0zvtcbzy.js +224 -0
  36. package/dist/index-181e6vfw.js +29 -0
  37. package/dist/index-8r2vdzjg.js +87 -0
  38. package/dist/index-hy7kbb99.js +157 -0
  39. package/dist/{src/secrets/loaders.js → index-j1ybktbr.js} +608 -405
  40. package/dist/index-k9yx6bne.js +89 -0
  41. package/dist/index-kj1phgrx.js +169 -0
  42. package/dist/index-mnkcfyx8.js +78 -0
  43. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  44. package/dist/index-rp5pqghz.js +157 -0
  45. package/dist/index-srt06xd2.js +15 -0
  46. package/dist/index-wjyb9bsd.js +263 -0
  47. package/dist/index.d.ts +7 -7
  48. package/dist/jwt/index.d.ts +50 -2
  49. package/dist/math/index.d.ts +2 -2
  50. package/dist/math/linearRegressionTrend.d.ts +14 -1
  51. package/dist/math/scale.d.ts +8 -0
  52. package/dist/object/index.d.ts +19 -0
  53. package/dist/p-queue/index.d.ts +1 -1
  54. package/dist/promise/index.d.ts +97 -2
  55. package/dist/secrets/core.d.ts +47 -0
  56. package/dist/secrets/deadline.d.ts +6 -0
  57. package/dist/secrets/environment.d.ts +9 -0
  58. package/dist/secrets/errors.d.ts +11 -0
  59. package/dist/secrets/index.d.ts +13 -25
  60. package/dist/secrets/infisical-cli.d.ts +6 -0
  61. package/dist/secrets/infisical-config.d.ts +17 -0
  62. package/dist/secrets/infisical-session.d.ts +4 -0
  63. package/dist/secrets/infisical.d.ts +8 -13
  64. package/dist/secrets/loaders.d.ts +5 -12
  65. package/dist/secrets/vite.d.ts +7 -3
  66. package/dist/secrets/worker-loader.d.ts +9 -0
  67. package/dist/secrets/worker.d.ts +3 -0
  68. package/dist/sitemap/index.d.ts +48 -6
  69. package/dist/src/api-wrapper/index.js +60 -1100
  70. package/dist/src/api-wrapper/testing.js +20 -0
  71. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  72. package/dist/src/crypto/index.js +195 -0
  73. package/dist/src/format/index.js +211 -0
  74. package/dist/src/i18n/index.js +36 -0
  75. package/dist/src/index.js +863 -2809
  76. package/dist/src/jwt/index.js +138 -92
  77. package/dist/src/p-queue/index.js +9 -437
  78. package/dist/src/secrets/index.js +261 -1377
  79. package/dist/src/secrets/vite.js +6 -17
  80. package/dist/src/secrets/worker.js +12 -0
  81. package/dist/src/sitemap/index.js +183 -511
  82. package/dist/src/storage/index.js +119 -0
  83. package/dist/src/zod/index.js +347 -174
  84. package/dist/state/index.d.ts +21 -0
  85. package/dist/storage/index.d.ts +58 -0
  86. package/dist/string/index.d.ts +75 -2
  87. package/dist/typedswitch/index.d.ts +37 -26
  88. package/dist/types/index.d.ts +11 -0
  89. package/dist/url/index.d.ts +50 -1
  90. package/dist/zod/index.d.ts +32 -3
  91. package/dist/zod/introspect.d.ts +19 -0
  92. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  93. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  94. package/dist/zod/zodToTypeString.d.ts +8 -3
  95. package/package.json +87 -7
  96. package/src/api-wrapper/actions-impl.ts +11 -72
  97. package/src/api-wrapper/builders.ts +84 -52
  98. package/src/api-wrapper/create-client.ts +246 -96
  99. package/src/api-wrapper/describe.ts +157 -0
  100. package/src/api-wrapper/endpoint.ts +26 -0
  101. package/src/api-wrapper/error-reporting.ts +7 -1
  102. package/src/api-wrapper/errors.ts +117 -2
  103. package/src/api-wrapper/headers.ts +19 -1
  104. package/src/api-wrapper/index.ts +5 -0
  105. package/src/api-wrapper/otel.ts +139 -0
  106. package/src/api-wrapper/paginate.ts +28 -0
  107. package/src/api-wrapper/redaction.ts +43 -0
  108. package/src/api-wrapper/shared.ts +21 -2
  109. package/src/api-wrapper/testing.ts +48 -0
  110. package/src/api-wrapper/transport.ts +68 -74
  111. package/src/api-wrapper/types.ts +186 -18
  112. package/src/array/index.ts +195 -42
  113. package/src/cache/createCache.ts +157 -0
  114. package/src/cache/index.ts +235 -83
  115. package/src/color/contrast.ts +163 -0
  116. package/src/color/index.ts +22 -0
  117. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  118. package/src/cool-console-log/index.ts +128 -41
  119. package/src/crypto/encoding.ts +83 -0
  120. package/src/crypto/hash.ts +170 -0
  121. package/src/crypto/index.ts +26 -0
  122. package/src/crypto/sealer.ts +168 -0
  123. package/src/crypto/webhook.ts +86 -0
  124. package/src/date/index.ts +109 -34
  125. package/src/emitter/index.ts +82 -0
  126. package/src/error/index.ts +63 -0
  127. package/src/format/index.ts +389 -0
  128. package/src/function/index.ts +201 -0
  129. package/src/guard/index.ts +80 -0
  130. package/src/i18n/index.ts +125 -0
  131. package/src/index.ts +11 -7
  132. package/src/jwt/index.ts +199 -125
  133. package/src/math/index.ts +5 -10
  134. package/src/math/linearRegressionTrend.ts +33 -8
  135. package/src/math/scale.ts +17 -0
  136. package/src/object/index.ts +99 -0
  137. package/src/p-queue/index.ts +2 -0
  138. package/src/promise/index.ts +304 -9
  139. package/src/secrets/core.ts +348 -0
  140. package/src/secrets/deadline.ts +69 -0
  141. package/src/secrets/environment.ts +69 -0
  142. package/src/secrets/errors.ts +29 -0
  143. package/src/secrets/index.ts +79 -210
  144. package/src/secrets/infisical-cli.ts +99 -0
  145. package/src/secrets/infisical-config.ts +70 -0
  146. package/src/secrets/infisical-session.ts +42 -0
  147. package/src/secrets/infisical.ts +214 -66
  148. package/src/secrets/loaders.ts +24 -48
  149. package/src/secrets/vite.ts +44 -15
  150. package/src/secrets/worker-loader.ts +56 -0
  151. package/src/secrets/worker.ts +4 -0
  152. package/src/sitemap/index.ts +293 -130
  153. package/src/state/index.ts +133 -81
  154. package/src/storage/index.ts +187 -0
  155. package/src/string/index.ts +261 -23
  156. package/src/typedswitch/index.ts +134 -103
  157. package/src/types/index.ts +14 -0
  158. package/src/url/index.ts +146 -1
  159. package/src/zod/index.ts +105 -9
  160. package/src/zod/introspect.ts +103 -0
  161. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  162. package/src/zod/zodMinMaxFilter.ts +37 -13
  163. package/src/zod/zodToTypeString.ts +184 -181
  164. package/dist/api-wrapper/index.js +0 -250
  165. package/dist/gzip/index.d.ts +0 -2
  166. package/dist/gzip/index.js +0 -16
  167. package/dist/index.js +0 -1823
  168. package/dist/jwt/index.js +0 -19
  169. package/dist/sitemap/index.js +0 -552
  170. package/dist/src/gzip/index.js +0 -16
  171. package/dist/src/secrets/infisical.js +0 -1194
  172. package/dist/zod/index.js +0 -210
@@ -1,18 +1,22 @@
1
1
  import { ZodError, type z } from 'zod'
2
+ import { anySignal, promiseTimeout } from '../promise'
3
+ import { parseRetryAfter } from '../url'
2
4
  import type {
3
5
  ApiEndpoint,
6
+ FetchLike,
4
7
  OnResponseContext,
5
8
  OnRetryContext,
6
9
  RetryContext
7
10
  } from './types'
8
- import type { ApiErrorContext, HttpMethod } from './shared'
11
+ import type { ApiErrorContext, HttpMethod, RequestMeta } from './shared'
9
12
  import {
13
+ ApiResponseError,
10
14
  ConfigError,
11
15
  HttpResponseError,
16
+ isNonRetryableApiError,
12
17
  NetworkError,
13
18
  RequestAbortedError,
14
19
  RequestTimeoutError,
15
- RequestValidationError,
16
20
  ResponseValidationError
17
21
  } from './errors'
18
22
  import {
@@ -20,17 +24,16 @@ import {
20
24
  getHeadersAsObject,
21
25
  hasJsonContentType
22
26
  } from './headers'
27
+ import { redactUrl } from './redaction'
23
28
  import { makeJsonParseZodError } from './zod-issues'
24
29
 
25
30
  export type RequestConfig = {
26
31
  method: HttpMethod
27
- url: string
28
- queryParams: Record<string, string>
32
+ fullUrl: string
29
33
  body: unknown
30
34
  headers: Record<string, string>
31
- doNotEncodeQueryParams?: boolean
32
35
  timeoutMs?: number
33
- fetchFn: typeof fetch
36
+ fetchFn: FetchLike
34
37
  userSignal?: AbortSignal
35
38
  }
36
39
 
@@ -53,26 +56,23 @@ export async function httpRequest(
53
56
  config: RequestConfig,
54
57
  ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
55
58
  ): Promise<Response> {
56
- const queryString = config.doNotEncodeQueryParams
57
- ? Object.entries(config.queryParams)
58
- .map(([key, value]) => `${key}=${value}`)
59
- .join('&')
60
- : new URLSearchParams(config.queryParams).toString()
61
- const fullUrl = queryString ? `${config.url}?${queryString}` : config.url
62
-
63
59
  const timeoutController = new AbortController()
64
60
  const timeout =
65
61
  config.timeoutMs === undefined
66
62
  ? undefined
67
63
  : setTimeout(() => timeoutController.abort(), config.timeoutMs)
68
64
 
69
- const signal = mergeSignals(timeoutController.signal, config.userSignal)
65
+ const signal = anySignal(timeoutController.signal, config.userSignal)
70
66
 
71
67
  try {
72
- return await config.fetchFn(fullUrl, {
68
+ return await config.fetchFn(config.fullUrl, {
73
69
  method: config.method,
74
70
  headers: config.headers,
75
- body: buildFetchBody(config.body, config.headers),
71
+ body: buildFetchBody(
72
+ config.body,
73
+ config.headers,
74
+ (msg) => new ConfigError(msg, ctx())
75
+ ),
76
76
  signal
77
77
  })
78
78
  } catch (error) {
@@ -85,6 +85,7 @@ export async function httpRequest(
85
85
  }
86
86
  throw new RequestAbortedError(ctx(), error)
87
87
  }
88
+ if (error instanceof ConfigError) throw error
88
89
  throw new NetworkError(
89
90
  error instanceof Error ? error.message : String(error),
90
91
  ctx(),
@@ -104,27 +105,6 @@ function isDomAbortError(error: unknown): boolean {
104
105
  )
105
106
  }
106
107
 
107
- export function mergeSignals(
108
- a: AbortSignal,
109
- b: AbortSignal | undefined
110
- ): AbortSignal {
111
- if (!b) return a
112
- const anyFn = (
113
- AbortSignal as unknown as {
114
- any?: (signals: AbortSignal[]) => AbortSignal
115
- }
116
- ).any
117
- if (typeof anyFn === 'function') return anyFn([a, b])
118
-
119
- const controller = new AbortController()
120
- const onAbort = () => controller.abort()
121
- if (a.aborted) controller.abort()
122
- else a.addEventListener('abort', onAbort, { once: true })
123
- if (b.aborted) controller.abort()
124
- else b.addEventListener('abort', onAbort, { once: true })
125
- return controller.signal
126
- }
127
-
128
108
  export async function readErrorResponse(response: Response): Promise<{
129
109
  body: string
130
110
  headers: Record<string, string>
@@ -142,30 +122,21 @@ export function getRetryDelay(params: {
142
122
  retryDelay: number | ((attempt: number) => number)
143
123
  attempt: number
144
124
  response?: Response
125
+ maxRetryDelayMs?: number
145
126
  }): number {
127
+ const maxDelay = params.maxRetryDelayMs ?? 30_000
146
128
  const { retryDelay, attempt, response } = params
147
129
  if (response && (response.status === 429 || response.status === 503)) {
148
- const retryAfter = response.headers.get('retry-after')
149
- if (retryAfter) {
150
- const seconds = Number(retryAfter)
151
- if (Number.isFinite(seconds)) return seconds * 1000
152
- const dateMs = Date.parse(retryAfter)
153
- if (!Number.isNaN(dateMs)) {
154
- return Math.max(0, dateMs - Date.now())
155
- }
156
- }
130
+ const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
131
+ if (retryAfterMs !== undefined) return Math.min(retryAfterMs, maxDelay)
157
132
  }
158
- if (typeof retryDelay === 'function') return retryDelay(attempt)
159
- return retryDelay
133
+ const delayMs =
134
+ typeof retryDelay === 'function' ? retryDelay(attempt) : retryDelay
135
+ return Math.min(delayMs, maxDelay)
160
136
  }
161
137
 
162
138
  export function shouldAttemptRetryOnError(error: unknown): boolean {
163
- if (error instanceof HttpResponseError) return false
164
- if (error instanceof ResponseValidationError) return false
165
- if (error instanceof RequestValidationError) return false
166
- if (error instanceof ConfigError) return false
167
- if (error instanceof RequestAbortedError) return false
168
- return true
139
+ return !isNonRetryableApiError(error)
169
140
  }
170
141
 
171
142
  export function shouldAttemptRetryOnHttp(retryCtx: RetryContext): boolean {
@@ -178,14 +149,15 @@ type MakeRequestArgs<TValidated> = {
178
149
  config: RequestConfig
179
150
  maxRetries: number
180
151
  retryDelay: number | ((attempt: number) => number)
181
- validateFn: (response: Response) => Promise<TValidated>
152
+ maxRetryDelayMs?: number
153
+ validateFn: (response: Response, attempt: number) => Promise<TValidated>
182
154
  shouldRetry: (context: RetryContext) => boolean
183
155
  logger?: Pick<Console, 'debug'>
184
156
  ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
185
157
  endpointName: string
186
158
  method: HttpMethod
187
159
  fullUrl: string
188
- meta?: Record<string, unknown>
160
+ meta?: RequestMeta
189
161
  onResponse?: (context: OnResponseContext) => void | Promise<void>
190
162
  onRetry?: (context: OnRetryContext) => void | Promise<void>
191
163
  }
@@ -201,6 +173,7 @@ export async function makeRequestWithRetry<TValidated>(
201
173
  config,
202
174
  maxRetries,
203
175
  retryDelay,
176
+ maxRetryDelayMs,
204
177
  validateFn,
205
178
  shouldRetry,
206
179
  logger,
@@ -217,7 +190,13 @@ export async function makeRequestWithRetry<TValidated>(
217
190
  while (true) {
218
191
  let response: Response | undefined
219
192
  try {
220
- logger?.debug?.('API request', config)
193
+ // Headers and body stay out of debug logs; they may carry credentials.
194
+ logger?.debug?.('API request', {
195
+ endpoint: endpointName,
196
+ method,
197
+ url: redactUrl(fullUrl),
198
+ attempt: retryCount
199
+ })
221
200
  response = await httpRequest(config, (over) =>
222
201
  ctx({ attempt: retryCount, ...over })
223
202
  )
@@ -244,15 +223,18 @@ export async function makeRequestWithRetry<TValidated>(
244
223
  const delayMs = getRetryDelay({
245
224
  retryDelay,
246
225
  attempt: retryCount,
247
- response
226
+ response,
227
+ maxRetryDelayMs
248
228
  })
249
229
  await onRetry?.({
250
230
  ...retryCtx,
251
231
  delayMs,
252
232
  nextAttempt: retryCount + 1
253
233
  })
234
+ await sleepBeforeRetry(delayMs, config.userSignal, () =>
235
+ ctx({ attempt: retryCount })
236
+ )
254
237
  retryCount++
255
- await delay(delayMs)
256
238
  continue
257
239
  }
258
240
 
@@ -267,17 +249,15 @@ export async function makeRequestWithRetry<TValidated>(
267
249
  }
268
250
 
269
251
  return {
270
- validatedData: await validateFn(response),
252
+ validatedData: await validateFn(response, retryCount),
271
253
  retryCount,
272
254
  httpStatus: response.status
273
255
  }
274
256
  } catch (error) {
257
+ // ApiResponseError is not retried by default, but a custom shouldRetry may opt in.
275
258
  if (
276
- error instanceof HttpResponseError ||
277
- error instanceof ResponseValidationError ||
278
- error instanceof RequestValidationError ||
279
- error instanceof ConfigError ||
280
- error instanceof RequestAbortedError
259
+ isNonRetryableApiError(error) &&
260
+ !(error instanceof ApiResponseError)
281
261
  ) {
282
262
  throw error
283
263
  }
@@ -297,15 +277,18 @@ export async function makeRequestWithRetry<TValidated>(
297
277
  const delayMs = getRetryDelay({
298
278
  retryDelay,
299
279
  attempt: retryCount,
300
- response: undefined
280
+ response: undefined,
281
+ maxRetryDelayMs
301
282
  })
302
283
  await onRetry?.({
303
284
  ...retryCtx,
304
285
  delayMs,
305
286
  nextAttempt: retryCount + 1
306
287
  })
288
+ await sleepBeforeRetry(delayMs, config.userSignal, () =>
289
+ ctx({ attempt: retryCount })
290
+ )
307
291
  retryCount++
308
- await delay(delayMs)
309
292
  }
310
293
  }
311
294
  }
@@ -320,28 +303,31 @@ export async function validateAndTransformResponse<T extends z.ZodType>(
320
303
  >,
321
304
  response: Response,
322
305
  ctx: (over?: Partial<ApiErrorContext>) => ApiErrorContext
323
- ): Promise<z.infer<T>> {
306
+ ): Promise<{ data: z.infer<T>; responseSizeBytes: number }> {
324
307
  const responseHeaders = getHeadersAsObject(response.headers)
308
+ const text = await response.text()
309
+ const responseSizeBytes = new TextEncoder().encode(text).length
325
310
  let responseData: unknown
326
311
  if (hasJsonContentType(responseHeaders)) {
327
- const text = await response.text()
328
312
  try {
329
313
  responseData = text === '' ? undefined : JSON.parse(text)
330
314
  } catch (error) {
331
315
  throw new ResponseValidationError(
332
316
  makeJsonParseZodError(error, text),
333
317
  text,
334
- ctx()
318
+ ctx(),
319
+ { invalidJson: true }
335
320
  )
336
321
  }
337
322
  } else {
338
- responseData = await response.text()
323
+ responseData = text
339
324
  }
340
325
  const transformedData = endpoint.resFormatter
341
326
  ? endpoint.resFormatter(responseData, responseHeaders)
342
327
  : responseData
343
328
 
344
- return validateResponse(endpoint.resSchema, transformedData, ctx)
329
+ const data = await validateResponse(endpoint.resSchema, transformedData, ctx)
330
+ return { data, responseSizeBytes }
345
331
  }
346
332
 
347
333
  export function defaultShouldRetry(ctx: RetryContext): boolean {
@@ -349,6 +335,14 @@ export function defaultShouldRetry(ctx: RetryContext): boolean {
349
335
  return shouldAttemptRetryOnHttp(ctx)
350
336
  }
351
337
 
352
- function delay(ms: number): Promise<void> {
353
- return new Promise((resolve) => setTimeout(resolve, ms))
338
+ async function sleepBeforeRetry(
339
+ ms: number,
340
+ signal: AbortSignal | undefined,
341
+ ctx: () => ApiErrorContext
342
+ ): Promise<void> {
343
+ try {
344
+ await promiseTimeout(ms, { signal })
345
+ } catch (error) {
346
+ throw new RequestAbortedError(ctx(), error)
347
+ }
354
348
  }
@@ -1,4 +1,5 @@
1
1
  import type { z } from 'zod'
2
+ import type { Result } from '../promise'
2
3
  import type { ApiError } from './errors'
3
4
  import type {
4
5
  ApiErrorContext,
@@ -6,7 +7,8 @@ import type {
6
7
  HttpMethod,
7
8
  PathParams,
8
9
  QueryParams,
9
- RequestBodyFormat
10
+ RequestBodyFormat,
11
+ RequestMeta
10
12
  } from './shared'
11
13
 
12
14
  export interface ApiEndpoint<
@@ -26,10 +28,11 @@ export interface ApiEndpoint<
26
28
  baseApiUrl?: string
27
29
  reqBodyFormat?: RequestBodyFormat
28
30
  defaultHeaders?: Record<string, string>
31
+ /** Reshapes the raw body before `resSchema` parses it, so it returns the schema's input type. */
29
32
  resFormatter?: (
30
33
  data: unknown,
31
34
  headers: Record<string, string>
32
- ) => z.infer<TResponse>
35
+ ) => z.input<TResponse>
33
36
  reqDefaultQueryParams?: QueryParams
34
37
  doNotEncodeQueryParams?: boolean
35
38
  }
@@ -72,7 +75,7 @@ export type OnRequestContext = {
72
75
  url: string
73
76
  headers: Record<string, string>
74
77
  body: unknown
75
- meta?: Record<string, unknown>
78
+ meta?: RequestMeta
76
79
  }
77
80
 
78
81
  export type OnResponseContext = {
@@ -81,7 +84,7 @@ export type OnResponseContext = {
81
84
  url: string
82
85
  response: Response
83
86
  attempt: number
84
- meta?: Record<string, unknown>
87
+ meta?: RequestMeta
85
88
  }
86
89
 
87
90
  export type ActionCache = {
@@ -98,7 +101,7 @@ export type ActionCache = {
98
101
 
99
102
  export type ActionInvokeOptions = {
100
103
  signal?: AbortSignal
101
- meta?: Record<string, unknown>
104
+ meta?: RequestMeta
102
105
  }
103
106
 
104
107
  // biome-ignore lint/suspicious/noExplicitAny: TApi defaults to any so plain ApiAction stays open
@@ -106,7 +109,7 @@ export type ActionContext<TInput, TApi = any> = {
106
109
  input: TInput
107
110
  api: TApi
108
111
  signal?: AbortSignal
109
- meta?: Record<string, unknown>
112
+ meta?: RequestMeta
110
113
  logger?: Pick<Console, 'debug' | 'error'>
111
114
  cache: ActionCache
112
115
  }
@@ -114,6 +117,8 @@ export type ActionContext<TInput, TApi = any> = {
114
117
  // biome-ignore lint/suspicious/noExplicitAny: variance-friendly bounds
115
118
  export interface ApiAction<TInput = any, TOutput = any> {
116
119
  handler: (ctx: ActionContext<TInput>) => Promise<TOutput> | TOutput
120
+ /** Validates the caller's input at runtime; `handler` receives the parsed output. */
121
+ readonly input?: z.ZodType
117
122
  /** When true, the action accepts only optional `{ signal, meta }`; input is always `undefined`. */
118
123
  readonly noRuntimeInput?: boolean
119
124
  }
@@ -122,7 +127,18 @@ type ActionDefinition<TInput, TOutput, TApi> = {
122
127
  handler: (ctx: ActionContext<TInput, TApi>) => Promise<TOutput> | TOutput
123
128
  }
124
129
 
130
+ type SchemaActionDefinition<TSchema extends z.ZodType, TOutput, TApi> = {
131
+ input: TSchema
132
+ handler: (
133
+ ctx: ActionContext<z.output<TSchema>, TApi>
134
+ ) => Promise<TOutput> | TOutput
135
+ }
136
+
125
137
  export type DefineAction<TApi> = {
138
+ /** Input validated by a Zod schema: callers pass `z.input`, the handler gets `z.output`. */
139
+ <TSchema extends z.ZodType, TOutput>(
140
+ def: SchemaActionDefinition<TSchema, TOutput, TApi>
141
+ ): ApiAction<z.input<TSchema>, TOutput>
126
142
  <TOutput>(
127
143
  def: ActionDefinition<undefined, TOutput, TApi>
128
144
  ): ApiAction<undefined, TOutput>
@@ -140,24 +156,68 @@ export type ActionArgs<A> = [ActionInput<A>] extends [undefined]
140
156
  ? [options?: ActionInvokeOptions]
141
157
  : [input: ActionInput<A>, options?: ActionInvokeOptions]
142
158
 
159
+ // Tail-recursive (accumulator) to stay under TypeScript's recursion limit.
160
+ type CharsOf<
161
+ S extends string,
162
+ Acc extends string = never
163
+ > = S extends `${infer C}${infer Rest}` ? CharsOf<Rest, Acc | C> : Acc
164
+ type IdentStart =
165
+ CharsOf<'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'>
166
+ type WordChar = IdentStart | CharsOf<'0123456789'>
167
+ type TakeWord<
168
+ S extends string,
169
+ Acc extends string = ''
170
+ > = S extends `${infer C}${infer Rest}`
171
+ ? C extends WordChar
172
+ ? TakeWord<Rest, `${Acc}${C}`>
173
+ : Acc
174
+ : Acc
175
+
176
+ /**
177
+ * `:name` placeholders in a literal path, matching the runtime rule (a letter
178
+ * or `_`, then word characters, so ports like `:8080` don't count).
179
+ * `never` for non-literal paths.
180
+ */
181
+ export type PathParamNames<TPath> = TPath extends string
182
+ ? string extends TPath
183
+ ? never
184
+ : TPath extends `${string}:${infer Rest}`
185
+ ?
186
+ | (Rest extends `${IdentStart}${string}` ? TakeWord<Rest> : never)
187
+ | PathParamNames<Rest>
188
+ : never
189
+ : never
190
+
191
+ type EndpointPathParamNames<TEndpoint extends ApiEndpoint> = PathParamNames<
192
+ TEndpoint['path']
193
+ >
194
+
143
195
  type SchemaInputKeys<TEndpoint extends ApiEndpoint> =
144
196
  | (TEndpoint['reqBodySchema'] extends z.ZodType ? 'reqBody' : never)
145
- | (TEndpoint['reqParamsSchema'] extends z.ZodType ? 'reqParams' : never)
146
197
  | (TEndpoint['reqQuerySchema'] extends z.ZodType ? 'reqQuery' : never)
147
198
  | (TEndpoint['reqHeadersSchema'] extends z.ZodType ? 'reqHeaders' : never)
199
+ | (TEndpoint['reqParamsSchema'] extends z.ZodType
200
+ ? 'reqParams'
201
+ : [EndpointPathParamNames<TEndpoint>] extends [never]
202
+ ? never
203
+ : 'reqParams')
148
204
 
205
+ // Callers pass what the schemas accept (`z.input`): fields with `.default()`
206
+ // are optional and transforms take their pre-transform type.
149
207
  type SchemaInputs<TEndpoint extends ApiEndpoint> = {
150
208
  reqBody: TEndpoint['reqBodySchema'] extends z.ZodType
151
- ? z.infer<TEndpoint['reqBodySchema']>
209
+ ? z.input<TEndpoint['reqBodySchema']>
152
210
  : unknown
153
211
  reqParams: TEndpoint['reqParamsSchema'] extends z.ZodType
154
- ? z.infer<TEndpoint['reqParamsSchema']>
155
- : PathParams
212
+ ? z.input<TEndpoint['reqParamsSchema']>
213
+ : [EndpointPathParamNames<TEndpoint>] extends [never]
214
+ ? PathParams
215
+ : Record<EndpointPathParamNames<TEndpoint>, string | number>
156
216
  reqQuery: TEndpoint['reqQuerySchema'] extends z.ZodType
157
- ? z.infer<TEndpoint['reqQuerySchema']>
217
+ ? z.input<TEndpoint['reqQuerySchema']>
158
218
  : QueryParams
159
219
  reqHeaders: TEndpoint['reqHeadersSchema'] extends z.ZodType
160
- ? z.infer<TEndpoint['reqHeadersSchema']>
220
+ ? z.input<TEndpoint['reqHeadersSchema']>
161
221
  : Record<string, string>
162
222
  }
163
223
 
@@ -166,8 +226,8 @@ type CommonRequestOptions = {
166
226
  signal?: AbortSignal
167
227
  timeoutMs?: number
168
228
  retries?: number
169
- fetch?: typeof fetch
170
- meta?: Record<string, unknown>
229
+ fetch?: FetchLike
230
+ meta?: RequestMeta
171
231
  }
172
232
 
173
233
  export type RequestInput<TEndpoint extends ApiEndpoint> = Pick<
@@ -190,21 +250,72 @@ export type ApiClient<
190
250
  <K extends keyof T>(
191
251
  endpointKey: K,
192
252
  ...args: RequestArgs<T[K]>
193
- ): Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>>
253
+ ): Promise<ApiWrapperResponse<EndpointResponse<T[K]>>>
194
254
  <K extends keyof A>(
195
255
  actionKey: K,
196
256
  ...args: ActionArgs<A[K]>
197
257
  ): Promise<ActionOutput<A[K]>>
198
258
  }
199
259
  cache: ActionCache
260
+ /**
261
+ * The same endpoints and actions, resolving to `Result` instead of throwing
262
+ * `ApiError`s. Other errors (bugs in hooks or middleware) still throw.
263
+ */
264
+ safe: {
265
+ [K in keyof T]: (
266
+ ...args: RequestArgs<T[K]>
267
+ ) => Promise<Result<ApiWrapperResponse<EndpointResponse<T[K]>>, ApiError>>
268
+ } & {
269
+ [K in keyof A]: (
270
+ ...args: ActionArgs<A[K]>
271
+ ) => Promise<Result<ActionOutput<A[K]>, ApiError>>
272
+ }
200
273
  } & {
201
274
  [K in keyof T]: (
202
275
  ...args: RequestArgs<T[K]>
203
- ) => Promise<ApiWrapperResponse<z.infer<T[K]['resSchema']>>>
276
+ ) => Promise<ApiWrapperResponse<EndpointResponse<T[K]>>>
204
277
  } & {
205
278
  [K in keyof A]: (...args: ActionArgs<A[K]>) => Promise<ActionOutput<A[K]>>
206
279
  }
207
280
 
281
+ /**
282
+ * Any client, for helpers that accept whichever client they're given. Call
283
+ * endpoints through `request` or `safe`; results are `unknown`.
284
+ */
285
+ export type AnyApiClient = {
286
+ // Method syntax keeps the key parameter bivariant, so any client fits.
287
+ // biome-ignore lint/suspicious/noExplicitAny: accepts every client's argument shapes
288
+ request(key: string, ...args: any[]): Promise<unknown>
289
+ cache: ActionCache
290
+ // biome-ignore lint/suspicious/noExplicitAny: accepts every client's argument shapes
291
+ safe: Record<string, (...args: any[]) => Promise<Result<unknown, ApiError>>>
292
+ }
293
+
294
+ /** Parsed response data of an endpoint definition (`z.output` of `resSchema`). */
295
+ export type EndpointResponse<TEndpoint extends ApiEndpoint> = z.output<
296
+ TEndpoint['resSchema']
297
+ >
298
+
299
+ /** Request input for an endpoint definition (`reqBody`, `reqParams`, …). */
300
+ export type EndpointRequest<TEndpoint extends ApiEndpoint> =
301
+ RequestInput<TEndpoint>
302
+
303
+ /** Response data of a client method: `InferResponse<typeof api, 'usersGet'>`. */
304
+ export type InferResponse<
305
+ TClient,
306
+ K extends keyof TClient
307
+ > = TClient[K] extends (...args: never[]) => Promise<infer R>
308
+ ? R extends ApiWrapperResponse<infer D>
309
+ ? D
310
+ : R
311
+ : never
312
+
313
+ /** First argument of a client method: `InferRequest<typeof api, 'usersGet'>`. */
314
+ export type InferRequest<
315
+ TClient,
316
+ K extends keyof TClient
317
+ > = TClient[K] extends (...args: infer P) => unknown ? NonNullable<P[0]> : never
318
+
208
319
  export type ActionsFactoryHelpers<
209
320
  T extends Record<string, ApiEndpoint>,
210
321
  A extends Record<string, ApiAction> = Record<string, never>
@@ -213,6 +324,48 @@ export type ActionsFactoryHelpers<
213
324
  defineAction: DefineAction<ApiClient<T, A>>
214
325
  }
215
326
 
327
+ /** Any fetch-compatible function; avoids Bun's `typeof fetch` extras like `preconnect`. */
328
+ export type FetchLike = (
329
+ input: string | URL | Request,
330
+ init?: RequestInit
331
+ ) => Promise<Response>
332
+
333
+ export type ApiMiddlewareContext = {
334
+ /** The client's `name`, when set. */
335
+ client?: string
336
+ /** The endpoint key, e.g. `usersGet`. */
337
+ endpoint: string
338
+ method: HttpMethod
339
+ /** Full URL including the query string. */
340
+ url: string
341
+ /** Outgoing headers. Mutate before `next()` to add headers (e.g. `traceparent`). */
342
+ headers: Record<string, string>
343
+ meta?: RequestMeta
344
+ }
345
+
346
+ /**
347
+ * Wraps one logical request: `onRequest`, every retry attempt, validation and
348
+ * `checkResponse`. Call `next()` to continue; errors it throws are `ApiError`s
349
+ * that were already redacted and passed to `onError`.
350
+ */
351
+ export type ApiMiddleware = (
352
+ context: ApiMiddlewareContext,
353
+ next: () => Promise<ApiWrapperResponse<unknown>>
354
+ ) => Promise<ApiWrapperResponse<unknown>>
355
+
356
+ export type CheckResponseContext = {
357
+ endpoint: string
358
+ method: HttpMethod
359
+ url: string
360
+ httpStatus: number
361
+ attempt: number
362
+ meta?: RequestMeta
363
+ }
364
+
365
+ export type DefaultHeaders =
366
+ | Record<string, string>
367
+ | (() => Record<string, string> | Promise<Record<string, string>>)
368
+
216
369
  export interface ApiClientConfig<
217
370
  T extends Record<string, ApiEndpoint>,
218
371
  A extends Record<string, ApiAction> = Record<string, never>
@@ -221,13 +374,26 @@ export interface ApiClientConfig<
221
374
  baseApiUrls: Record<string, BaseApiUrl>
222
375
  defaultBaseApiUrl?: string
223
376
  endpoints: T
224
- defaultHeaders?: Record<string, string>
377
+ /** Static headers, or a function resolved on every request (e.g. auth from a secret store). */
378
+ defaultHeaders?: DefaultHeaders
225
379
  timeoutMs?: number
226
380
  retries?: number
227
381
  retryDelayMs?: number | ((attempt: number) => number)
382
+ maxRetryDelayMs?: number
228
383
  shouldRetry?: (context: RetryContext) => boolean
229
384
  logger?: Pick<Console, 'debug' | 'error'>
230
- fetch?: typeof fetch
385
+ fetch?: FetchLike
386
+ /** Middleware around each whole request, in array order (first is outermost). */
387
+ use?: ApiMiddleware[]
388
+ /**
389
+ * Runs after `resSchema` validation on every attempt. Throw to reject the
390
+ * response; non-`ApiError` throws become `ApiResponseError`, which the
391
+ * default retry policy does not retry.
392
+ */
393
+ checkResponse?: (
394
+ data: unknown,
395
+ context: CheckResponseContext
396
+ ) => void | Promise<void>
231
397
  onRequest?: (
232
398
  context: OnRequestContext
233
399
  ) => RequestInterceptorResult | Promise<RequestInterceptorResult>
@@ -249,6 +415,8 @@ export type ApiClientConfigWithActions<
249
415
  }
250
416
 
251
417
  export type {
418
+ ApiRequestMeta,
419
+ RequestMeta,
252
420
  BaseApiUrl,
253
421
  HttpMethod,
254
422
  PathParams,