hanzio 1.2.0 → 2.1.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 (155) 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-8r2vdzjg.js +87 -0
  36. package/dist/index-hy7kbb99.js +157 -0
  37. package/dist/index-hyt6c56h.js +290 -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-ktynw3vs.js +37 -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-w882zry3.js +29 -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/secrets/core.d.ts +7 -0
  55. package/dist/secrets/index.d.ts +3 -1
  56. package/dist/secrets/public-config.d.ts +7 -0
  57. package/dist/secrets/vite.d.ts +1 -0
  58. package/dist/secrets/worker.d.ts +1 -0
  59. package/dist/sitemap/index.d.ts +48 -6
  60. package/dist/src/api-wrapper/index.js +60 -1130
  61. package/dist/src/api-wrapper/testing.js +20 -0
  62. package/dist/{cool-console-log → src/cool-console-log}/index.js +80 -33
  63. package/dist/src/crypto/index.js +195 -0
  64. package/dist/src/format/index.js +211 -0
  65. package/dist/src/i18n/index.js +36 -0
  66. package/dist/src/index.js +739 -3145
  67. package/dist/src/jwt/index.js +138 -92
  68. package/dist/src/p-queue/index.js +9 -437
  69. package/dist/src/secrets/index.js +50 -1428
  70. package/dist/src/secrets/vite.js +10 -39
  71. package/dist/src/secrets/worker.js +14 -273
  72. package/dist/src/sitemap/index.js +183 -511
  73. package/dist/src/storage/index.js +119 -0
  74. package/dist/src/zod/index.js +340 -167
  75. package/dist/state/index.d.ts +21 -0
  76. package/dist/storage/index.d.ts +58 -0
  77. package/dist/string/index.d.ts +75 -2
  78. package/dist/typedswitch/index.d.ts +31 -24
  79. package/dist/types/index.d.ts +11 -0
  80. package/dist/url/index.d.ts +50 -1
  81. package/dist/zod/index.d.ts +32 -3
  82. package/dist/zod/introspect.d.ts +19 -0
  83. package/dist/zod/jsonToZodSchemaMapper.d.ts +3 -1
  84. package/dist/zod/zodMinMaxFilter.d.ts +22 -3
  85. package/dist/zod/zodToTypeString.d.ts +8 -3
  86. package/package.json +78 -7
  87. package/src/api-wrapper/actions-impl.ts +11 -72
  88. package/src/api-wrapper/builders.ts +32 -25
  89. package/src/api-wrapper/create-client.ts +171 -41
  90. package/src/api-wrapper/describe.ts +157 -0
  91. package/src/api-wrapper/endpoint.ts +26 -0
  92. package/src/api-wrapper/error-reporting.ts +7 -1
  93. package/src/api-wrapper/errors.ts +107 -2
  94. package/src/api-wrapper/headers.ts +11 -6
  95. package/src/api-wrapper/index.ts +5 -0
  96. package/src/api-wrapper/otel.ts +139 -0
  97. package/src/api-wrapper/paginate.ts +28 -0
  98. package/src/api-wrapper/redaction.ts +42 -0
  99. package/src/api-wrapper/shared.ts +21 -2
  100. package/src/api-wrapper/testing.ts +48 -0
  101. package/src/api-wrapper/transport.ts +41 -43
  102. package/src/api-wrapper/types.ts +185 -18
  103. package/src/array/index.ts +195 -42
  104. package/src/cache/createCache.ts +157 -0
  105. package/src/cache/index.ts +132 -147
  106. package/src/color/contrast.ts +163 -0
  107. package/src/color/index.ts +22 -0
  108. package/src/{math → color}/metricsSeriesColors.ts +47 -49
  109. package/src/cool-console-log/index.ts +128 -41
  110. package/src/crypto/encoding.ts +83 -0
  111. package/src/crypto/hash.ts +170 -0
  112. package/src/crypto/index.ts +26 -0
  113. package/src/crypto/sealer.ts +168 -0
  114. package/src/crypto/webhook.ts +86 -0
  115. package/src/date/index.ts +109 -34
  116. package/src/emitter/index.ts +82 -0
  117. package/src/error/index.ts +63 -0
  118. package/src/format/index.ts +389 -0
  119. package/src/function/index.ts +201 -0
  120. package/src/guard/index.ts +80 -0
  121. package/src/i18n/index.ts +125 -0
  122. package/src/index.ts +11 -7
  123. package/src/jwt/index.ts +199 -125
  124. package/src/math/index.ts +5 -10
  125. package/src/math/linearRegressionTrend.ts +33 -8
  126. package/src/math/scale.ts +17 -0
  127. package/src/object/index.ts +99 -0
  128. package/src/p-queue/index.ts +2 -0
  129. package/src/promise/index.ts +304 -9
  130. package/src/secrets/core.ts +72 -16
  131. package/src/secrets/index.ts +16 -4
  132. package/src/secrets/public-config.ts +47 -0
  133. package/src/secrets/vite.ts +2 -0
  134. package/src/secrets/worker.ts +2 -0
  135. package/src/sitemap/index.ts +293 -130
  136. package/src/state/index.ts +133 -81
  137. package/src/storage/index.ts +187 -0
  138. package/src/string/index.ts +261 -23
  139. package/src/typedswitch/index.ts +58 -38
  140. package/src/types/index.ts +14 -0
  141. package/src/url/index.ts +146 -1
  142. package/src/zod/index.ts +105 -9
  143. package/src/zod/introspect.ts +103 -0
  144. package/src/zod/jsonToZodSchemaMapper.ts +44 -21
  145. package/src/zod/zodMinMaxFilter.ts +37 -13
  146. package/src/zod/zodToTypeString.ts +184 -181
  147. package/dist/api-wrapper/index.js +0 -250
  148. package/dist/gzip/index.d.ts +0 -2
  149. package/dist/gzip/index.js +0 -16
  150. package/dist/index.js +0 -1823
  151. package/dist/jwt/index.js +0 -19
  152. package/dist/sitemap/index.js +0 -552
  153. package/dist/src/gzip/index.js +0 -16
  154. package/dist/src/secrets/loaders.js +0 -1529
  155. package/dist/zod/index.js +0 -210
@@ -11,10 +11,38 @@ import {
11
11
 
12
12
  const MAX_BODY_PREVIEW = 500
13
13
 
14
+ /**
15
+ * Where a request failed, for logs and alerts:
16
+ * - `server_error`: the API returned 5xx
17
+ * - `client_error`: the API rejected the request (4xx: auth, params, body)
18
+ * - `input_validation`: the caller's data failed a request schema; nothing was sent
19
+ * - `output_validation`: the response wasn't JSON or didn't match `resSchema`
20
+ * - `response_rejected`: `checkResponse` rejected a valid response
21
+ * - `network` / `timeout` / `aborted`: the request didn't complete
22
+ * - `config`: the client or call is misconfigured
23
+ * - `action`: a composite action handler threw
24
+ */
25
+ export type ApiErrorCategory =
26
+ | 'server_error'
27
+ | 'client_error'
28
+ | 'input_validation'
29
+ | 'output_validation'
30
+ | 'response_rejected'
31
+ | 'network'
32
+ | 'timeout'
33
+ | 'aborted'
34
+ | 'config'
35
+ | 'action'
36
+ | 'unknown'
37
+
14
38
  export class ApiError extends Error {
15
39
  public context: ApiErrorContext
16
40
  public override cause?: unknown
17
41
 
42
+ get category(): ApiErrorCategory {
43
+ return 'unknown'
44
+ }
45
+
18
46
  constructor(
19
47
  message: string,
20
48
  context: ApiErrorContext,
@@ -29,6 +57,7 @@ export class ApiError extends Error {
29
57
  toJSON(): Record<string, unknown> {
30
58
  return {
31
59
  name: this.name,
60
+ category: this.category,
32
61
  message: this.message,
33
62
  context: this.context
34
63
  }
@@ -66,6 +95,10 @@ export class HttpResponseError extends ApiError {
66
95
  this.bodyJson = tryParseJson(body)
67
96
  }
68
97
 
98
+ override get category(): ApiErrorCategory {
99
+ return this.status >= 500 ? 'server_error' : 'client_error'
100
+ }
101
+
69
102
  override toJSON(): Record<string, unknown> {
70
103
  return {
71
104
  ...super.toJSON(),
@@ -83,11 +116,14 @@ export class ResponseValidationError extends ApiError {
83
116
  public readonly rawResponse: unknown
84
117
  public readonly issues: FormattedZodIssue[]
85
118
  public readonly validationIssues: string
119
+ /** True when the body wasn't valid JSON (rather than failing `resSchema`). */
120
+ public readonly invalidJson: boolean
86
121
 
87
122
  constructor(
88
123
  zodError: ZodError,
89
124
  rawResponse: unknown,
90
- context: ApiErrorContext
125
+ context: ApiErrorContext,
126
+ options: { invalidJson?: boolean } = {}
91
127
  ) {
92
128
  const issues = formatZodIssues(zodError, rawResponse)
93
129
  const summary = renderIssueSummary(issues)
@@ -97,6 +133,7 @@ export class ResponseValidationError extends ApiError {
97
133
  { cause: zodError }
98
134
  )
99
135
  this.name = 'ResponseValidationError'
136
+ this.invalidJson = options.invalidJson ?? false
100
137
  this.zodError = zodError
101
138
  this.rawResponse = rawResponse
102
139
  this.issues = issues
@@ -105,9 +142,14 @@ export class ResponseValidationError extends ApiError {
105
142
  .join('; ')
106
143
  }
107
144
 
145
+ override get category(): ApiErrorCategory {
146
+ return 'output_validation'
147
+ }
148
+
108
149
  override toJSON(): Record<string, unknown> {
109
150
  return {
110
151
  ...super.toJSON(),
152
+ invalidJson: this.invalidJson,
111
153
  issues: this.issues,
112
154
  validationIssues: this.validationIssues,
113
155
  rawResponsePreview: previewValue(this.rawResponse, 1000)
@@ -115,7 +157,13 @@ export class ResponseValidationError extends ApiError {
115
157
  }
116
158
  }
117
159
 
118
- export type RequestValidationTarget = 'body' | 'query' | 'params' | 'headers'
160
+ /** Which request schema failed; `input` is an action's input schema. */
161
+ export type RequestValidationTarget =
162
+ | 'body'
163
+ | 'query'
164
+ | 'params'
165
+ | 'headers'
166
+ | 'input'
119
167
 
120
168
  export class RequestValidationError extends ApiError {
121
169
  public readonly zodError: ZodError
@@ -147,6 +195,10 @@ export class RequestValidationError extends ApiError {
147
195
  .join('; ')
148
196
  }
149
197
 
198
+ override get category(): ApiErrorCategory {
199
+ return 'input_validation'
200
+ }
201
+
150
202
  override toJSON(): Record<string, unknown> {
151
203
  return {
152
204
  ...super.toJSON(),
@@ -168,6 +220,10 @@ export class RequestTimeoutError extends ApiError {
168
220
  this.name = 'RequestTimeoutError'
169
221
  this.timeoutMs = timeoutMs
170
222
  }
223
+
224
+ override get category(): ApiErrorCategory {
225
+ return 'timeout'
226
+ }
171
227
  }
172
228
 
173
229
  export class NetworkError extends ApiError {
@@ -175,6 +231,10 @@ export class NetworkError extends ApiError {
175
231
  super(message, context, { cause })
176
232
  this.name = 'NetworkError'
177
233
  }
234
+
235
+ override get category(): ApiErrorCategory {
236
+ return 'network'
237
+ }
178
238
  }
179
239
 
180
240
  export class RequestAbortedError extends ApiError {
@@ -184,6 +244,10 @@ export class RequestAbortedError extends ApiError {
184
244
  })
185
245
  this.name = 'RequestAbortedError'
186
246
  }
247
+
248
+ override get category(): ApiErrorCategory {
249
+ return 'aborted'
250
+ }
187
251
  }
188
252
 
189
253
  export class ConfigError extends ApiError {
@@ -191,6 +255,40 @@ export class ConfigError extends ApiError {
191
255
  super(message, context)
192
256
  this.name = 'ConfigError'
193
257
  }
258
+
259
+ override get category(): ApiErrorCategory {
260
+ return 'config'
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Thrown when the client's `checkResponse` rejects a response that passed
266
+ * schema validation (e.g. HTTP 200 with `{ success: false }`).
267
+ */
268
+ export class ApiResponseError extends ApiError {
269
+ public readonly data: unknown
270
+
271
+ constructor(
272
+ message: string,
273
+ data: unknown,
274
+ context: ApiErrorContext,
275
+ cause?: unknown
276
+ ) {
277
+ super(message, context, { cause })
278
+ this.name = 'ApiResponseError'
279
+ this.data = data
280
+ }
281
+
282
+ override get category(): ApiErrorCategory {
283
+ return 'response_rejected'
284
+ }
285
+
286
+ override toJSON(): Record<string, unknown> {
287
+ return {
288
+ ...super.toJSON(),
289
+ dataPreview: previewValue(this.data, 1000)
290
+ }
291
+ }
194
292
  }
195
293
 
196
294
  export class ActionError extends ApiError {
@@ -198,6 +296,10 @@ export class ActionError extends ApiError {
198
296
  super(message, context, { cause })
199
297
  this.name = 'ActionError'
200
298
  }
299
+
300
+ override get category(): ApiErrorCategory {
301
+ return 'action'
302
+ }
201
303
  }
202
304
 
203
305
  export const isApiError = (e: unknown): e is ApiError => e instanceof ApiError
@@ -219,11 +321,14 @@ export const isConfigError = (e: unknown): e is ConfigError =>
219
321
  e instanceof ConfigError
220
322
  export const isActionError = (e: unknown): e is ActionError =>
221
323
  e instanceof ActionError
324
+ export const isApiResponseError = (e: unknown): e is ApiResponseError =>
325
+ e instanceof ApiResponseError
222
326
 
223
327
  export function isNonRetryableApiError(error: unknown): boolean {
224
328
  return (
225
329
  error instanceof HttpResponseError ||
226
330
  error instanceof ResponseValidationError ||
331
+ error instanceof ApiResponseError ||
227
332
  error instanceof RequestValidationError ||
228
333
  error instanceof ConfigError ||
229
334
  error instanceof RequestAbortedError
@@ -57,12 +57,17 @@ export function buildFetchBody(
57
57
  if (body === undefined || body === null) return undefined
58
58
  if (body instanceof FormData || body instanceof Blob) return body
59
59
  if (hasJsonContentType(headers)) return JSON.stringify(body)
60
- if (
61
- typeof body === 'object' &&
62
- (Object.getPrototypeOf(body) === Object.prototype ||
63
- Object.getPrototypeOf(body) === null) &&
64
- configError
65
- ) {
60
+ const isJsonLike =
61
+ Array.isArray(body) ||
62
+ (typeof body === 'object' &&
63
+ (Object.getPrototypeOf(body) === Object.prototype ||
64
+ Object.getPrototypeOf(body) === null))
65
+ // Objects and arrays default to JSON when no Content-Type was chosen.
66
+ if (isJsonLike && getHeader(headers, 'content-type') === undefined) {
67
+ headers['Content-Type'] = 'application/json'
68
+ return JSON.stringify(body)
69
+ }
70
+ if (isJsonLike && configError) {
66
71
  throw configError(
67
72
  'Request body is a plain object but Content-Type is not application/json'
68
73
  )
@@ -3,3 +3,8 @@ export * from './types'
3
3
  export * from './errors'
4
4
  export { createApiClient } from './create-client'
5
5
  export { defineAction } from './actions-impl'
6
+ export { type PaginateOptions, paginate } from './paginate'
7
+ export { defaultShouldRetry } from './transport'
8
+ export { apiErrorLogAttributes, describeApiError } from './describe'
9
+ export * from './otel'
10
+ export { defineEndpoint } from './endpoint'
@@ -0,0 +1,139 @@
1
+ import { ApiError, HttpResponseError } from './errors'
2
+ import { redactUrl } from './redaction'
3
+ import type { ApiMiddleware, ApiMiddlewareContext } from './types'
4
+
5
+ // Structural subsets of @opentelemetry/api, so hanzio never imports it. The
6
+ // app passes its own tracer (`trace.getTracer('app')`); a type test keeps
7
+ // these compatible with the real `Tracer`.
8
+ export type OtelAttributes = Record<
9
+ string,
10
+ string | number | boolean | undefined
11
+ >
12
+
13
+ export type OtelSpanLike = {
14
+ setAttributes(attributes: OtelAttributes): unknown
15
+ setStatus(status: { code: number; message?: string }): unknown
16
+ recordException(exception: Error | string): unknown
17
+ end(): void
18
+ }
19
+
20
+ export type OtelTracerLike = {
21
+ startActiveSpan<F extends (span: OtelSpanLike) => unknown>(
22
+ name: string,
23
+ options: { kind?: number; attributes?: OtelAttributes },
24
+ fn: F
25
+ ): ReturnType<F>
26
+ }
27
+
28
+ export type OtelMiddlewareOptions = {
29
+ /** Default: `client.endpoint`, or the endpoint key when the client has no `name`. */
30
+ spanName?: (context: ApiMiddlewareContext) => string
31
+ /** Extra attributes set when the span starts. */
32
+ attributes?: (context: ApiMiddlewareContext) => OtelAttributes
33
+ /**
34
+ * `url.full` keeps the query string with sensitive values redacted
35
+ * (default). Set `false` to drop the query string entirely.
36
+ */
37
+ includeQuery?: boolean
38
+ /**
39
+ * Adds trace-context headers to the outgoing request, e.g.
40
+ * `(headers) => propagation.inject(context.active(), headers)`. Runs inside
41
+ * the active span. Not needed when fetch is already auto-instrumented.
42
+ */
43
+ inject?: (headers: Record<string, string>) => void
44
+ }
45
+
46
+ // Frozen numeric values from the OpenTelemetry API.
47
+ const SPAN_KIND_CLIENT = 2
48
+ const SPAN_STATUS_ERROR = 2
49
+
50
+ /**
51
+ * One CLIENT span per logical request (all retries, validation and
52
+ * `checkResponse`), with OpenTelemetry HTTP semantic-convention attributes.
53
+ * It's the active span, so auto-instrumented fetch attempts become children.
54
+ *
55
+ * @example
56
+ * import { trace } from '@opentelemetry/api'
57
+ * createApiClient({ ...config, use: [otelMiddleware(trace.getTracer('app'))] })
58
+ */
59
+ export function otelMiddleware(
60
+ tracer: OtelTracerLike,
61
+ options: OtelMiddlewareOptions = {}
62
+ ): ApiMiddleware {
63
+ const { spanName, attributes, includeQuery = true, inject } = options
64
+
65
+ return (context, next) => {
66
+ const url = includeQuery
67
+ ? redactUrl(context.url)
68
+ : context.url.split(/[?#]/, 1)[0]!
69
+ const name =
70
+ spanName?.(context) ??
71
+ (context.client
72
+ ? `${context.client}.${context.endpoint}`
73
+ : context.endpoint)
74
+
75
+ return tracer.startActiveSpan(
76
+ name,
77
+ {
78
+ kind: SPAN_KIND_CLIENT,
79
+ attributes: {
80
+ 'http.request.method': context.method,
81
+ 'url.full': url,
82
+ 'server.address': hostOf(context.url),
83
+ 'api.client': context.client,
84
+ 'api.endpoint': context.endpoint,
85
+ ...attributes?.(context)
86
+ }
87
+ },
88
+ async (span) => {
89
+ inject?.(context.headers)
90
+ try {
91
+ const result = await next()
92
+ span.setAttributes({
93
+ 'http.response.status_code': result.httpStatus,
94
+ 'http.request.resend_count': result.retryCount || undefined,
95
+ 'http.response.body.size': Math.round(
96
+ result.responseSizeMb * 1024 * 1024
97
+ )
98
+ })
99
+ return result
100
+ } catch (error) {
101
+ recordFailure(span, error)
102
+ throw error
103
+ } finally {
104
+ span.end()
105
+ }
106
+ }
107
+ )
108
+ }
109
+ }
110
+
111
+ function recordFailure(span: OtelSpanLike, error: unknown): void {
112
+ if (error instanceof ApiError) {
113
+ const isHttp = error instanceof HttpResponseError
114
+ span.setAttributes({
115
+ 'error.type': isHttp ? String(error.status) : error.name,
116
+ 'api.error.category': error.category,
117
+ 'http.response.status_code': isHttp ? error.status : undefined,
118
+ 'http.request.resend_count': error.context.attempt || undefined
119
+ })
120
+ } else {
121
+ span.setAttributes({
122
+ 'error.type': error instanceof Error ? error.name : 'unknown'
123
+ })
124
+ }
125
+ span.recordException(error instanceof Error ? error : String(error))
126
+ span.setStatus({
127
+ code: SPAN_STATUS_ERROR,
128
+ message:
129
+ error instanceof Error ? error.message.split('\n', 1)[0] : String(error)
130
+ })
131
+ }
132
+
133
+ function hostOf(url: string): string | undefined {
134
+ try {
135
+ return new URL(url).hostname
136
+ } catch {
137
+ return undefined
138
+ }
139
+ }
@@ -0,0 +1,28 @@
1
+ export type PaginateOptions = {
2
+ /** Stop after this many pages (default: no limit). */
3
+ maxPages?: number
4
+ /** Checked before each page fetch; throws `signal.reason` when aborted. */
5
+ signal?: AbortSignal
6
+ }
7
+
8
+ /**
9
+ * Yields pages until `getNext` returns `null`/`undefined`. The first call to
10
+ * `fetchPage` gets `undefined` as cursor. Pages are fetched lazily, so
11
+ * breaking out of `for await` stops further requests.
12
+ */
13
+ export async function* paginate<TPage, TCursor>(
14
+ fetchPage: (cursor: TCursor | undefined) => Promise<TPage>,
15
+ getNext: (page: TPage) => TCursor | null | undefined,
16
+ options: PaginateOptions = {}
17
+ ): AsyncGenerator<TPage, void, undefined> {
18
+ const { maxPages = Number.POSITIVE_INFINITY, signal } = options
19
+ let cursor: TCursor | undefined
20
+ for (let count = 0; count < maxPages; count++) {
21
+ signal?.throwIfAborted()
22
+ const page = await fetchPage(cursor)
23
+ yield page
24
+ const next = getNext(page)
25
+ if (next == null) return
26
+ cursor = next
27
+ }
28
+ }
@@ -22,6 +22,18 @@ const SENSITIVE_BODY_KEYS = new Set([
22
22
  'api_key'
23
23
  ])
24
24
 
25
+ // Query strings also carry API keys and presigned-URL signatures.
26
+ const SENSITIVE_QUERY_KEYS = new Set([
27
+ ...SENSITIVE_BODY_KEYS,
28
+ 'key',
29
+ 'code',
30
+ 'sig',
31
+ 'signature',
32
+ 'x-amz-signature',
33
+ 'x-amz-credential',
34
+ 'x-amz-security-token'
35
+ ])
36
+
25
37
  function redactHeaders(
26
38
  headers: Record<string, string> | undefined
27
39
  ): Record<string, string> | undefined {
@@ -52,9 +64,39 @@ function redactBody(body: unknown): unknown {
52
64
  return out
53
65
  }
54
66
 
67
+ /** Replaces values of sensitive query parameters (`?api_key=…`) in a URL. */
68
+ export function redactUrl(url: string): string {
69
+ const queryStart = url.indexOf('?')
70
+ if (queryStart === -1) return url
71
+ const hashStart = url.indexOf('#', queryStart)
72
+ const query = url.slice(
73
+ queryStart + 1,
74
+ hashStart === -1 ? undefined : hashStart
75
+ )
76
+ const redacted = query
77
+ .split('&')
78
+ .map((pair) => {
79
+ const [key = ''] = pair.split('=', 1)
80
+ return SENSITIVE_QUERY_KEYS.has(decodeKey(key).toLowerCase())
81
+ ? `${key}=[REDACTED]`
82
+ : pair
83
+ })
84
+ .join('&')
85
+ return `${url.slice(0, queryStart + 1)}${redacted}${hashStart === -1 ? '' : url.slice(hashStart)}`
86
+ }
87
+
88
+ function decodeKey(key: string): string {
89
+ try {
90
+ return decodeURIComponent(key.replace(/\+/g, ' '))
91
+ } catch {
92
+ return key
93
+ }
94
+ }
95
+
55
96
  export function defaultRedactContext(ctx: ApiErrorContext): ApiErrorContext {
56
97
  return {
57
98
  ...ctx,
99
+ url: redactUrl(ctx.url),
58
100
  requestHeaders: redactHeaders(ctx.requestHeaders),
59
101
  requestBody: redactBody(ctx.requestBody),
60
102
  meta: ctx.meta
@@ -1,3 +1,5 @@
1
+ import type { UrlQueryParams } from '../url'
2
+
1
3
  export type HttpMethod =
2
4
  | 'GET'
3
5
  | 'POST'
@@ -10,7 +12,24 @@ export type HttpMethod =
10
12
  export type RequestBodyFormat = 'json' | 'form-data'
11
13
  export type BaseApiUrl = string | (() => string)
12
14
  export type PathParams = Record<string, string | number>
13
- export type QueryParams = Record<string, string | number | boolean | undefined>
15
+ /** Array values repeat the key (`a=1&a=2`); `null`/`undefined` are skipped. */
16
+ export type QueryParams = UrlQueryParams
17
+
18
+ /**
19
+ * Augment to type `meta` on requests, actions, hooks, middleware and errors:
20
+ *
21
+ * @example
22
+ * declare module 'hanzio/api-wrapper' {
23
+ * interface ApiRequestMeta { traceId: string; tenantId?: string }
24
+ * }
25
+ */
26
+ // biome-ignore lint/suspicious/noEmptyInterface: augmentation target
27
+ export interface ApiRequestMeta {}
28
+
29
+ /** `ApiRequestMeta` once augmented, otherwise any record. */
30
+ export type RequestMeta = keyof ApiRequestMeta extends never
31
+ ? Record<string, unknown>
32
+ : ApiRequestMeta
14
33
 
15
34
  export type ApiErrorContext = {
16
35
  endpoint: string
@@ -21,5 +40,5 @@ export type ApiErrorContext = {
21
40
  elapsedMs: number
22
41
  requestHeaders?: Record<string, string>
23
42
  requestBody?: unknown
24
- meta?: Record<string, unknown>
43
+ meta?: RequestMeta
25
44
  }
@@ -0,0 +1,48 @@
1
+ import type { FetchLike } from './types'
2
+
3
+ export type MockFetch = FetchLike & {
4
+ /** Every request received, in order. Bodies are still readable. */
5
+ readonly calls: Request[]
6
+ }
7
+
8
+ /**
9
+ * A fetch stand-in for tests: pass it as the client's `fetch` option instead
10
+ * of replacing `globalThis.fetch`. Test-runner agnostic.
11
+ *
12
+ * @example
13
+ * const fetch = createMockFetch((request) =>
14
+ * request.url.endsWith('/users/1')
15
+ * ? jsonResponse({ id: 1 })
16
+ * : jsonResponse({ error: 'not found' }, { status: 404 })
17
+ * )
18
+ * const api = createApiClient({ ...config, fetch })
19
+ */
20
+ export function createMockFetch(
21
+ handler: (request: Request, callIndex: number) => Response | Promise<Response>
22
+ ): MockFetch {
23
+ const calls: Request[] = []
24
+ const mockFetch = async (
25
+ input: string | URL | Request,
26
+ init?: RequestInit
27
+ ): Promise<Response> => {
28
+ const request: Request =
29
+ input instanceof Request
30
+ ? new Request(input, init)
31
+ : new Request(input.toString(), init)
32
+ // Bun types clone() as the undici Request; it is the same runtime object.
33
+ calls.push(request.clone() as Request)
34
+ return handler(request, calls.length - 1)
35
+ }
36
+ return Object.assign(mockFetch, { calls })
37
+ }
38
+
39
+ /** A JSON `Response` with `content-type: application/json`. */
40
+ export function jsonResponse(
41
+ body: unknown,
42
+ init: { status?: number; headers?: Record<string, string> } = {}
43
+ ): Response {
44
+ return new Response(JSON.stringify(body), {
45
+ status: init.status ?? 200,
46
+ headers: { 'content-type': 'application/json', ...init.headers }
47
+ })
48
+ }