hanzio 1.2.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 (145) hide show
  1. package/README.md +786 -239
  2. package/dist/api-wrapper/builders.d.ts +4 -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 +39 -2
  7. package/dist/api-wrapper/index.d.ts +5 -0
  8. package/dist/api-wrapper/otel.d.ts +44 -0
  9. package/dist/api-wrapper/paginate.d.ts +12 -0
  10. package/dist/api-wrapper/redaction.d.ts +2 -0
  11. package/dist/api-wrapper/shared.d.ts +16 -2
  12. package/dist/api-wrapper/testing.d.ts +23 -0
  13. package/dist/api-wrapper/transport.d.ts +4 -5
  14. package/dist/api-wrapper/types.d.ts +103 -18
  15. package/dist/array/index.d.ts +50 -4
  16. package/dist/cache/createCache.d.ts +31 -0
  17. package/dist/cache/index.d.ts +19 -2
  18. package/dist/color/contrast.d.ts +40 -0
  19. package/dist/color/index.d.ts +2 -0
  20. package/dist/{math → color}/metricsSeriesColors.d.ts +14 -7
  21. package/dist/cool-console-log/index.d.ts +34 -5
  22. package/dist/crypto/encoding.d.ts +18 -0
  23. package/dist/crypto/hash.d.ts +33 -0
  24. package/dist/crypto/index.d.ts +4 -0
  25. package/dist/crypto/sealer.d.ts +25 -0
  26. package/dist/crypto/webhook.d.ts +20 -0
  27. package/dist/date/index.d.ts +46 -2
  28. package/dist/emitter/index.d.ts +21 -0
  29. package/dist/error/index.d.ts +12 -0
  30. package/dist/format/index.d.ts +102 -0
  31. package/dist/function/index.d.ts +60 -0
  32. package/dist/guard/index.d.ts +20 -0
  33. package/dist/i18n/index.d.ts +37 -0
  34. package/dist/index-0zvtcbzy.js +224 -0
  35. package/dist/index-181e6vfw.js +29 -0
  36. package/dist/index-8r2vdzjg.js +87 -0
  37. package/dist/index-hy7kbb99.js +157 -0
  38. package/dist/{src/secrets/infisical.js → index-j1ybktbr.js} +500 -538
  39. package/dist/index-k9yx6bne.js +89 -0
  40. package/dist/index-kj1phgrx.js +169 -0
  41. package/dist/index-mnkcfyx8.js +78 -0
  42. package/dist/{p-queue/index.js → index-pwt0dz63.js} +7 -11
  43. package/dist/index-rp5pqghz.js +157 -0
  44. package/dist/index-srt06xd2.js +15 -0
  45. package/dist/index-wjyb9bsd.js +263 -0
  46. package/dist/index.d.ts +7 -7
  47. package/dist/jwt/index.d.ts +50 -2
  48. package/dist/math/index.d.ts +2 -2
  49. package/dist/math/linearRegressionTrend.d.ts +14 -1
  50. package/dist/math/scale.d.ts +8 -0
  51. package/dist/object/index.d.ts +19 -0
  52. package/dist/p-queue/index.d.ts +1 -1
  53. package/dist/promise/index.d.ts +97 -2
  54. package/dist/sitemap/index.d.ts +48 -6
  55. package/dist/src/api-wrapper/index.js +60 -1130
  56. package/dist/src/api-wrapper/testing.js +20 -0
  57. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  58. package/dist/src/crypto/index.js +195 -0
  59. package/dist/src/format/index.js +211 -0
  60. package/dist/src/i18n/index.js +36 -0
  61. package/dist/src/index.js +739 -3145
  62. package/dist/src/jwt/index.js +138 -92
  63. package/dist/src/p-queue/index.js +9 -437
  64. package/dist/src/secrets/index.js +44 -1428
  65. package/dist/src/secrets/vite.js +6 -39
  66. package/dist/src/secrets/worker.js +10 -273
  67. package/dist/src/sitemap/index.js +183 -511
  68. package/dist/src/storage/index.js +119 -0
  69. package/dist/src/zod/index.js +340 -167
  70. package/dist/state/index.d.ts +21 -0
  71. package/dist/storage/index.d.ts +58 -0
  72. package/dist/string/index.d.ts +75 -2
  73. package/dist/typedswitch/index.d.ts +31 -24
  74. package/dist/types/index.d.ts +11 -0
  75. package/dist/url/index.d.ts +50 -1
  76. package/dist/zod/index.d.ts +32 -3
  77. package/dist/zod/introspect.d.ts +19 -0
  78. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  79. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  80. package/dist/zod/zodToTypeString.d.ts +8 -3
  81. package/package.json +78 -7
  82. package/src/api-wrapper/actions-impl.ts +11 -72
  83. package/src/api-wrapper/builders.ts +32 -25
  84. package/src/api-wrapper/create-client.ts +171 -41
  85. package/src/api-wrapper/describe.ts +157 -0
  86. package/src/api-wrapper/endpoint.ts +26 -0
  87. package/src/api-wrapper/error-reporting.ts +7 -1
  88. package/src/api-wrapper/errors.ts +107 -2
  89. package/src/api-wrapper/headers.ts +11 -6
  90. package/src/api-wrapper/index.ts +5 -0
  91. package/src/api-wrapper/otel.ts +139 -0
  92. package/src/api-wrapper/paginate.ts +28 -0
  93. package/src/api-wrapper/redaction.ts +42 -0
  94. package/src/api-wrapper/shared.ts +21 -2
  95. package/src/api-wrapper/testing.ts +48 -0
  96. package/src/api-wrapper/transport.ts +41 -43
  97. package/src/api-wrapper/types.ts +185 -18
  98. package/src/array/index.ts +195 -42
  99. package/src/cache/createCache.ts +157 -0
  100. package/src/cache/index.ts +132 -147
  101. package/src/color/contrast.ts +163 -0
  102. package/src/color/index.ts +22 -0
  103. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  104. package/src/cool-console-log/index.ts +128 -41
  105. package/src/crypto/encoding.ts +83 -0
  106. package/src/crypto/hash.ts +170 -0
  107. package/src/crypto/index.ts +26 -0
  108. package/src/crypto/sealer.ts +168 -0
  109. package/src/crypto/webhook.ts +86 -0
  110. package/src/date/index.ts +109 -34
  111. package/src/emitter/index.ts +82 -0
  112. package/src/error/index.ts +63 -0
  113. package/src/format/index.ts +389 -0
  114. package/src/function/index.ts +201 -0
  115. package/src/guard/index.ts +80 -0
  116. package/src/i18n/index.ts +125 -0
  117. package/src/index.ts +11 -7
  118. package/src/jwt/index.ts +199 -125
  119. package/src/math/index.ts +5 -10
  120. package/src/math/linearRegressionTrend.ts +33 -8
  121. package/src/math/scale.ts +17 -0
  122. package/src/object/index.ts +99 -0
  123. package/src/p-queue/index.ts +2 -0
  124. package/src/promise/index.ts +304 -9
  125. package/src/sitemap/index.ts +293 -130
  126. package/src/state/index.ts +133 -81
  127. package/src/storage/index.ts +187 -0
  128. package/src/string/index.ts +261 -23
  129. package/src/typedswitch/index.ts +58 -38
  130. package/src/types/index.ts +14 -0
  131. package/src/url/index.ts +146 -1
  132. package/src/zod/index.ts +105 -9
  133. package/src/zod/introspect.ts +103 -0
  134. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  135. package/src/zod/zodMinMaxFilter.ts +37 -13
  136. package/src/zod/zodToTypeString.ts +184 -181
  137. package/dist/api-wrapper/index.js +0 -250
  138. package/dist/gzip/index.d.ts +0 -2
  139. package/dist/gzip/index.js +0 -16
  140. package/dist/index.js +0 -1823
  141. package/dist/jwt/index.js +0 -19
  142. package/dist/sitemap/index.js +0 -552
  143. package/dist/src/gzip/index.js +0 -16
  144. package/dist/src/secrets/loaders.js +0 -1529
  145. package/dist/zod/index.js +0 -210
@@ -1,12 +1,16 @@
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,
12
16
  isNonRetryableApiError,
@@ -20,6 +24,7 @@ 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 = {
@@ -28,7 +33,7 @@ export type RequestConfig = {
28
33
  body: unknown
29
34
  headers: Record<string, string>
30
35
  timeoutMs?: number
31
- fetchFn: typeof fetch
36
+ fetchFn: FetchLike
32
37
  userSignal?: AbortSignal
33
38
  }
34
39
 
@@ -57,7 +62,7 @@ export async function httpRequest(
57
62
  ? undefined
58
63
  : setTimeout(() => timeoutController.abort(), config.timeoutMs)
59
64
 
60
- const signal = mergeSignals(timeoutController.signal, config.userSignal)
65
+ const signal = anySignal(timeoutController.signal, config.userSignal)
61
66
 
62
67
  try {
63
68
  return await config.fetchFn(config.fullUrl, {
@@ -100,27 +105,6 @@ function isDomAbortError(error: unknown): boolean {
100
105
  )
101
106
  }
102
107
 
103
- export function mergeSignals(
104
- a: AbortSignal,
105
- b: AbortSignal | undefined
106
- ): AbortSignal {
107
- if (!b) return a
108
- const anyFn = (
109
- AbortSignal as unknown as {
110
- any?: (signals: AbortSignal[]) => AbortSignal
111
- }
112
- ).any
113
- if (typeof anyFn === 'function') return anyFn([a, b])
114
-
115
- const controller = new AbortController()
116
- const onAbort = () => controller.abort()
117
- if (a.aborted) controller.abort()
118
- else a.addEventListener('abort', onAbort, { once: true })
119
- if (b.aborted) controller.abort()
120
- else b.addEventListener('abort', onAbort, { once: true })
121
- return controller.signal
122
- }
123
-
124
108
  export async function readErrorResponse(response: Response): Promise<{
125
109
  body: string
126
110
  headers: Record<string, string>
@@ -143,17 +127,8 @@ export function getRetryDelay(params: {
143
127
  const maxDelay = params.maxRetryDelayMs ?? 30_000
144
128
  const { retryDelay, attempt, response } = params
145
129
  if (response && (response.status === 429 || response.status === 503)) {
146
- const retryAfter = response.headers.get('retry-after')
147
- if (retryAfter) {
148
- const seconds = Number(retryAfter)
149
- if (Number.isFinite(seconds)) {
150
- return Math.min(Math.max(0, seconds * 1000), maxDelay)
151
- }
152
- const dateMs = Date.parse(retryAfter)
153
- if (!Number.isNaN(dateMs)) {
154
- return Math.min(Math.max(0, dateMs - Date.now()), maxDelay)
155
- }
156
- }
130
+ const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'))
131
+ if (retryAfterMs !== undefined) return Math.min(retryAfterMs, maxDelay)
157
132
  }
158
133
  const delayMs =
159
134
  typeof retryDelay === 'function' ? retryDelay(attempt) : retryDelay
@@ -182,7 +157,7 @@ type MakeRequestArgs<TValidated> = {
182
157
  endpointName: string
183
158
  method: HttpMethod
184
159
  fullUrl: string
185
- meta?: Record<string, unknown>
160
+ meta?: RequestMeta
186
161
  onResponse?: (context: OnResponseContext) => void | Promise<void>
187
162
  onRetry?: (context: OnRetryContext) => void | Promise<void>
188
163
  }
@@ -215,7 +190,13 @@ export async function makeRequestWithRetry<TValidated>(
215
190
  while (true) {
216
191
  let response: Response | undefined
217
192
  try {
218
- 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
+ })
219
200
  response = await httpRequest(config, (over) =>
220
201
  ctx({ attempt: retryCount, ...over })
221
202
  )
@@ -250,8 +231,10 @@ export async function makeRequestWithRetry<TValidated>(
250
231
  delayMs,
251
232
  nextAttempt: retryCount + 1
252
233
  })
234
+ await sleepBeforeRetry(delayMs, config.userSignal, () =>
235
+ ctx({ attempt: retryCount })
236
+ )
253
237
  retryCount++
254
- await delay(delayMs)
255
238
  continue
256
239
  }
257
240
 
@@ -271,7 +254,11 @@ export async function makeRequestWithRetry<TValidated>(
271
254
  httpStatus: response.status
272
255
  }
273
256
  } catch (error) {
274
- if (isNonRetryableApiError(error)) {
257
+ // ApiResponseError is not retried by default, but a custom shouldRetry may opt in.
258
+ if (
259
+ isNonRetryableApiError(error) &&
260
+ !(error instanceof ApiResponseError)
261
+ ) {
275
262
  throw error
276
263
  }
277
264
 
@@ -298,8 +285,10 @@ export async function makeRequestWithRetry<TValidated>(
298
285
  delayMs,
299
286
  nextAttempt: retryCount + 1
300
287
  })
288
+ await sleepBeforeRetry(delayMs, config.userSignal, () =>
289
+ ctx({ attempt: retryCount })
290
+ )
301
291
  retryCount++
302
- await delay(delayMs)
303
292
  }
304
293
  }
305
294
  }
@@ -326,7 +315,8 @@ export async function validateAndTransformResponse<T extends z.ZodType>(
326
315
  throw new ResponseValidationError(
327
316
  makeJsonParseZodError(error, text),
328
317
  text,
329
- ctx()
318
+ ctx(),
319
+ { invalidJson: true }
330
320
  )
331
321
  }
332
322
  } else {
@@ -345,6 +335,14 @@ export function defaultShouldRetry(ctx: RetryContext): boolean {
345
335
  return shouldAttemptRetryOnHttp(ctx)
346
336
  }
347
337
 
348
- function delay(ms: number): Promise<void> {
349
- 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
+ }
350
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,14 +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)
228
382
  maxRetryDelayMs?: number
229
383
  shouldRetry?: (context: RetryContext) => boolean
230
384
  logger?: Pick<Console, 'debug' | 'error'>
231
- 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>
232
397
  onRequest?: (
233
398
  context: OnRequestContext
234
399
  ) => RequestInterceptorResult | Promise<RequestInterceptorResult>
@@ -250,6 +415,8 @@ export type ApiClientConfigWithActions<
250
415
  }
251
416
 
252
417
  export type {
418
+ ApiRequestMeta,
419
+ RequestMeta,
253
420
  BaseApiUrl,
254
421
  HttpMethod,
255
422
  PathParams,