hanzio 2.2.0 → 3.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 (116) hide show
  1. package/README.md +133 -7
  2. package/dist/api-wrapper/connection-auth.d.ts +61 -0
  3. package/dist/cli/fields.d.ts +30 -0
  4. package/dist/cli/index.d.ts +122 -0
  5. package/dist/format/index.d.ts +91 -0
  6. package/dist/format/phone.d.ts +19 -0
  7. package/dist/http/index.d.ts +14 -0
  8. package/dist/i18n/catalog.d.ts +47 -0
  9. package/dist/i18n/check.d.ts +45 -0
  10. package/dist/i18n/cli.d.ts +2 -0
  11. package/dist/i18n/content-rules.d.ts +55 -0
  12. package/dist/i18n/i18next.d.ts +39 -0
  13. package/dist/i18n/index.d.ts +16 -1
  14. package/dist/i18n/locales.d.ts +62 -0
  15. package/dist/i18n/vite.d.ts +16 -0
  16. package/dist/i18n/vue.d.ts +93 -0
  17. package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
  18. package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
  19. package/dist/index-5n2e326q.js +409 -0
  20. package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
  21. package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
  22. package/dist/index-6ragggbs.js +473 -0
  23. package/dist/index-mq2qgc20.js +116 -0
  24. package/dist/index-najbyzpr.js +99 -0
  25. package/dist/index-ww09bm49.js +250 -0
  26. package/dist/iso/countries.d.ts +262 -0
  27. package/dist/iso/currencies.d.ts +165 -0
  28. package/dist/iso/index.d.ts +4 -0
  29. package/dist/iso/languages.d.ts +195 -0
  30. package/dist/iso/phone-language.d.ts +5 -0
  31. package/dist/promise/index.d.ts +31 -0
  32. package/dist/redis/client.d.ts +49 -0
  33. package/dist/redis/concurrency-limit.d.ts +63 -0
  34. package/dist/redis/index.d.ts +3 -0
  35. package/dist/redis/lua-script.d.ts +13 -0
  36. package/dist/secrets/infisical-identity-file.d.ts +12 -0
  37. package/dist/src/api-wrapper/connection-auth.js +82 -0
  38. package/dist/src/api-wrapper/index.js +1 -1
  39. package/dist/src/cli/index.js +10 -0
  40. package/dist/src/format/index.js +79 -2
  41. package/dist/src/format/phone.js +21 -0
  42. package/dist/src/http/index.js +69 -0
  43. package/dist/src/i18n/check.js +15 -0
  44. package/dist/src/i18n/cli.js +71 -0
  45. package/dist/src/i18n/i18next.js +48 -0
  46. package/dist/src/i18n/index.js +16 -34
  47. package/dist/src/i18n/vite.js +25 -0
  48. package/dist/src/i18n/vue.js +51 -0
  49. package/dist/src/index.js +22 -6
  50. package/dist/src/iso/index.js +655 -0
  51. package/dist/src/redis/index.js +577 -0
  52. package/dist/src/secrets/index.js +78 -35
  53. package/dist/src/sitemap/index.js +1 -1
  54. package/dist/src/storage/index.js +8 -117
  55. package/dist/src/testing/index.js +348 -0
  56. package/dist/src/zod/index.js +20 -253
  57. package/dist/string/index.d.ts +21 -0
  58. package/dist/testing/connection-auth-contract.d.ts +43 -0
  59. package/dist/testing/helpers.d.ts +26 -0
  60. package/dist/testing/index.d.ts +5 -0
  61. package/dist/testing/module-mocker.d.ts +30 -0
  62. package/dist/testing/prisma.d.ts +30 -0
  63. package/dist/testing/window.d.ts +18 -0
  64. package/dist/typedswitch/index.d.ts +55 -34
  65. package/dist/zod/index.d.ts +10 -0
  66. package/dist/zod/introspect.d.ts +2 -1
  67. package/package.json +97 -2
  68. package/src/api-wrapper/README.md +57 -2
  69. package/src/api-wrapper/SKILL.md +256 -0
  70. package/src/api-wrapper/connection-auth.ts +142 -0
  71. package/src/cli/README.md +220 -0
  72. package/src/cli/fields.ts +213 -0
  73. package/src/cli/index.ts +532 -0
  74. package/src/format/README.md +82 -2
  75. package/src/format/index.ts +148 -0
  76. package/src/format/phone.ts +43 -0
  77. package/src/http/README.md +25 -0
  78. package/src/http/index.ts +94 -0
  79. package/src/i18n/README.md +206 -3
  80. package/src/i18n/catalog.ts +139 -0
  81. package/src/i18n/check.ts +376 -0
  82. package/src/i18n/cli.ts +90 -0
  83. package/src/i18n/content-rules.ts +179 -0
  84. package/src/i18n/i18next.ts +89 -0
  85. package/src/i18n/index.ts +34 -0
  86. package/src/i18n/locales.ts +135 -0
  87. package/src/i18n/vite.ts +40 -0
  88. package/src/i18n/vue.ts +82 -0
  89. package/src/index.ts +1 -1
  90. package/src/iso/README.md +44 -0
  91. package/src/iso/countries.ts +272 -0
  92. package/src/iso/currencies.ts +172 -0
  93. package/src/iso/index.ts +8 -0
  94. package/src/iso/languages.ts +208 -0
  95. package/src/iso/phone-language.ts +46 -0
  96. package/src/promise/index.ts +100 -0
  97. package/src/redis/README.md +112 -0
  98. package/src/redis/client.ts +239 -0
  99. package/src/redis/concurrency-limit.ts +591 -0
  100. package/src/redis/index.ts +15 -0
  101. package/src/redis/lua-script.ts +43 -0
  102. package/src/secrets/README.md +5 -1
  103. package/src/secrets/infisical-identity-file.ts +69 -0
  104. package/src/secrets/infisical.ts +18 -3
  105. package/src/string/index.ts +59 -0
  106. package/src/testing/README.md +119 -0
  107. package/src/testing/connection-auth-contract.ts +303 -0
  108. package/src/testing/helpers.ts +41 -0
  109. package/src/testing/index.ts +12 -0
  110. package/src/testing/module-mocker.ts +50 -0
  111. package/src/testing/prisma.ts +78 -0
  112. package/src/testing/window.ts +119 -0
  113. package/src/typedswitch/README.md +55 -39
  114. package/src/typedswitch/index.ts +97 -83
  115. package/src/zod/index.ts +20 -0
  116. package/src/zod/introspect.ts +1 -1
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "hanzio",
3
- "version": "2.2.0",
3
+ "version": "3.1.0",
4
4
  "description": "A lightweight set of utility functions for TypeScript",
5
5
  "type": "module",
6
6
  "module": "./dist/src/index.js",
7
7
  "main": "./dist/src/index.js",
8
8
  "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "hanzio-i18n": "./dist/src/i18n/cli.js"
11
+ },
9
12
  "license": "MIT",
10
13
  "repository": {
11
14
  "type": "git",
@@ -26,6 +29,7 @@
26
29
  "files": [
27
30
  "src/**/*.ts",
28
31
  "src/**/README.md",
32
+ "src/api-wrapper/SKILL.md",
29
33
  "dist",
30
34
  "!src/**/*.test.ts"
31
35
  ],
@@ -55,6 +59,12 @@
55
59
  "import": "./dist/src/api-wrapper/testing.js",
56
60
  "default": "./dist/src/api-wrapper/testing.js"
57
61
  },
62
+ "./api-wrapper/connection-auth": {
63
+ "types": "./dist/api-wrapper/connection-auth.d.ts",
64
+ "bun": "./src/api-wrapper/connection-auth.ts",
65
+ "import": "./dist/src/api-wrapper/connection-auth.js",
66
+ "default": "./dist/src/api-wrapper/connection-auth.js"
67
+ },
58
68
  "./jwt": {
59
69
  "types": "./dist/jwt/index.d.ts",
60
70
  "bun": "./src/jwt/index.ts",
@@ -85,6 +95,12 @@
85
95
  "import": "./dist/src/format/index.js",
86
96
  "default": "./dist/src/format/index.js"
87
97
  },
98
+ "./format/phone": {
99
+ "types": "./dist/format/phone.d.ts",
100
+ "bun": "./src/format/phone.ts",
101
+ "import": "./dist/src/format/phone.js",
102
+ "default": "./dist/src/format/phone.js"
103
+ },
88
104
  "./crypto": {
89
105
  "types": "./dist/crypto/index.d.ts",
90
106
  "bun": "./src/crypto/index.ts",
@@ -103,6 +119,36 @@
103
119
  "import": "./dist/src/i18n/index.js",
104
120
  "default": "./dist/src/i18n/index.js"
105
121
  },
122
+ "./i18n/vue": {
123
+ "types": "./dist/i18n/vue.d.ts",
124
+ "bun": "./src/i18n/vue.ts",
125
+ "import": "./dist/src/i18n/vue.js",
126
+ "default": "./dist/src/i18n/vue.js"
127
+ },
128
+ "./i18n/i18next": {
129
+ "types": "./dist/i18n/i18next.d.ts",
130
+ "bun": "./src/i18n/i18next.ts",
131
+ "import": "./dist/src/i18n/i18next.js",
132
+ "default": "./dist/src/i18n/i18next.js"
133
+ },
134
+ "./i18n/vite": {
135
+ "types": "./dist/i18n/vite.d.ts",
136
+ "bun": "./src/i18n/vite.ts",
137
+ "import": "./dist/src/i18n/vite.js",
138
+ "default": "./dist/src/i18n/vite.js"
139
+ },
140
+ "./i18n/check": {
141
+ "types": "./dist/i18n/check.d.ts",
142
+ "bun": "./src/i18n/check.ts",
143
+ "import": "./dist/src/i18n/check.js",
144
+ "default": "./dist/src/i18n/check.js"
145
+ },
146
+ "./iso": {
147
+ "types": "./dist/iso/index.d.ts",
148
+ "bun": "./src/iso/index.ts",
149
+ "import": "./dist/src/iso/index.js",
150
+ "default": "./dist/src/iso/index.js"
151
+ },
106
152
  "./secrets": {
107
153
  "types": "./dist/secrets/index.d.ts",
108
154
  "bun": "./src/secrets/index.ts",
@@ -120,11 +166,35 @@
120
166
  "bun": "./src/secrets/worker.ts",
121
167
  "import": "./dist/src/secrets/worker.js",
122
168
  "default": "./dist/src/secrets/worker.js"
169
+ },
170
+ "./redis": {
171
+ "types": "./dist/redis/index.d.ts",
172
+ "bun": "./src/redis/index.ts",
173
+ "import": "./dist/src/redis/index.js",
174
+ "default": "./dist/src/redis/index.js"
175
+ },
176
+ "./http": {
177
+ "types": "./dist/http/index.d.ts",
178
+ "bun": "./src/http/index.ts",
179
+ "import": "./dist/src/http/index.js",
180
+ "default": "./dist/src/http/index.js"
181
+ },
182
+ "./cli": {
183
+ "types": "./dist/cli/index.d.ts",
184
+ "bun": "./src/cli/index.ts",
185
+ "import": "./dist/src/cli/index.js",
186
+ "default": "./dist/src/cli/index.js"
187
+ },
188
+ "./testing": {
189
+ "types": "./dist/testing/index.d.ts",
190
+ "bun": "./src/testing/index.ts",
191
+ "import": "./dist/src/testing/index.js",
192
+ "default": "./dist/src/testing/index.js"
123
193
  }
124
194
  },
125
195
  "scripts": {
126
196
  "build": "rm -rf dist && bun run build:js && bun run build:types",
127
- "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/api-wrapper/testing.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/cool-console-log/index.ts ./src/format/index.ts ./src/crypto/index.ts ./src/storage/index.ts ./src/i18n/index.ts ./src/secrets/index.ts ./src/secrets/vite.ts ./src/secrets/worker.ts --outdir ./dist --target node --packages external --splitting",
197
+ "build:js": "bun build ./src/index.ts ./src/zod/index.ts ./src/api-wrapper/index.ts ./src/api-wrapper/testing.ts ./src/api-wrapper/connection-auth.ts ./src/jwt/index.ts ./src/sitemap/index.ts ./src/p-queue/index.ts ./src/cool-console-log/index.ts ./src/format/index.ts ./src/format/phone.ts ./src/crypto/index.ts ./src/storage/index.ts ./src/i18n/index.ts ./src/i18n/vue.ts ./src/i18n/i18next.ts ./src/i18n/vite.ts ./src/i18n/check.ts ./src/i18n/cli.ts ./src/iso/index.ts ./src/secrets/index.ts ./src/secrets/vite.ts ./src/secrets/worker.ts ./src/redis/index.ts ./src/http/index.ts ./src/cli/index.ts ./src/testing/index.ts --outdir ./dist --target node --packages external --splitting",
128
198
  "build:types": "tsgo -p tsconfig.build.json",
129
199
  "prepublishOnly": "bun run build",
130
200
  "test": "bun test",
@@ -138,19 +208,44 @@
138
208
  },
139
209
  "devDependencies": {
140
210
  "@biomejs/biome": "^2.4.6",
211
+ "@intlify/message-compiler": "^11.4.16",
141
212
  "@opentelemetry/api": "^1.9.1",
142
213
  "@types/bun": "latest",
143
214
  "@typescript/native-preview": "^7.0.0-dev.20260306.1",
144
215
  "husky": "^9.1.7",
216
+ "i18next": "^25.10.10",
217
+ "libphonenumber-js": "^1.13.15",
145
218
  "miniflare": "^4.20260730.0",
146
219
  "typescript": "^5.7.0",
147
220
  "vite": "^8.2.2",
221
+ "vue": "^3.5.44",
222
+ "vue-i18n": "^11.4.16",
148
223
  "zod": "^4.3.6"
149
224
  },
150
225
  "peerDependencies": {
226
+ "@intlify/message-compiler": ">=10.0.0",
227
+ "i18next": ">=23.0.0",
228
+ "libphonenumber-js": "^1.10.0",
229
+ "vite": ">=6.0.0",
230
+ "vue-i18n": ">=10.0.0",
151
231
  "zod": "^4.3.6"
152
232
  },
153
233
  "peerDependenciesMeta": {
234
+ "@intlify/message-compiler": {
235
+ "optional": true
236
+ },
237
+ "i18next": {
238
+ "optional": true
239
+ },
240
+ "libphonenumber-js": {
241
+ "optional": true
242
+ },
243
+ "vite": {
244
+ "optional": true
245
+ },
246
+ "vue-i18n": {
247
+ "optional": true
248
+ },
154
249
  "zod": {
155
250
  "optional": true
156
251
  }
@@ -3,8 +3,11 @@
3
3
  Typed HTTP clients from Zod schemas: `createApiClient` validates requests and
4
4
  responses, retries with exponential backoff, and throws structured errors.
5
5
 
6
- Building a new wrapper with an AI agent? See [AGENTS.md](./AGENTS.md) for the
7
- step-by-step workflow and folder conventions.
6
+ Building a new wrapper with an AI agent? [SKILL.md](./SKILL.md) has the
7
+ step-by-step workflow and folder conventions. It ships with the package: copy
8
+ `node_modules/hanzio/src/api-wrapper/SKILL.md` to
9
+ `.claude/skills/hanzio-api-wrapper/SKILL.md` (or `.agents/skills/…`), or point
10
+ your agent at it.
8
11
 
9
12
  ## Quick Start
10
13
 
@@ -645,6 +648,58 @@ Cache API:
645
648
  The cache is also exposed on the client itself as `api.cache` for use outside
646
649
  of action handlers (e.g. clearing auth on logout).
647
650
 
651
+ ### Sharing a login across clients
652
+
653
+ `ctx.cache` lives for one client. When every request builds its own client
654
+ (one per tenant or per connection, from stored credentials), use
655
+ `ConnectionAuthCache` from `hanzio/api-wrapper/connection-auth` instead: one
656
+ module-level cache shares a single token exchange per connection across all
657
+ of them. It needs `node:crypto` (Bun, Node, Deno, or Workers with
658
+ `nodejs_compat`), so it is not part of `hanzio/api-wrapper`.
659
+
660
+ ```ts
661
+ import { ConnectionAuthCache } from 'hanzio/api-wrapper/connection-auth'
662
+
663
+ const authCache = new ConnectionAuthCache() // keeps at most 1,000 connections
664
+
665
+ export function createShopClient(connection: { id: string; apiKey: string; secret: string }) {
666
+ const auth = authCache.bind({
667
+ connectionId: connection.id,
668
+ authInputs: [connection.apiKey, connection.secret],
669
+ refreshMarginMs: 60_000,
670
+ exchange: () => login(connection) // resolves { token, expiresAt }
671
+ })
672
+
673
+ return {
674
+ async get(path: string, signal?: AbortSignal) {
675
+ for (let attempt = 0; ; attempt++) {
676
+ const current = await auth.get({ signal })
677
+ const response = await fetch(`https://shop.example.com${path}`, {
678
+ headers: { authorization: `Bearer ${current.token}` },
679
+ signal
680
+ })
681
+ // One retry with a new login when the token was refused.
682
+ if (response.status !== 401 || attempt > 0) return response
683
+ auth.reject(current)
684
+ }
685
+ }
686
+ }
687
+ }
688
+ ```
689
+
690
+ - Concurrent callers share one exchange, and aborting one caller's wait leaves
691
+ it running for the others.
692
+ - Changing any `authInputs` value (a rotated secret) retires the old token: a
693
+ client still using the old credentials can finish its request but cannot
694
+ store its token. The inputs are kept only as an HMAC under a random key.
695
+ - `reject(token)` drops a token the API refused, unless a newer login has
696
+ already replaced it. `get({ fresh: true })` always exchanges, for an explicit
697
+ credential check, and never falls back to the old token when it fails.
698
+ - `clear()` forgets everything, including exchanges still in flight.
699
+
700
+ `connectionAuthContract` in [`hanzio/testing`](../testing/README.md#connection-auth-contract)
701
+ runs these races against your client.
702
+
648
703
  ## Endpoint Configuration Reference
649
704
 
650
705
  | Option | Required | Description |
@@ -0,0 +1,256 @@
1
+ ---
2
+ name: hanzio-api-wrapper
3
+ description: Building typed HTTP API wrappers with createApiClient and Zod in hanzio/api-wrapper. Covers folder layout, index factory, endpoint files with defineEndpoint, shared types, pagination with paginate, and common REST API integration patterns.
4
+ ---
5
+
6
+ # AI Agent Guide: Building API Wrappers
7
+
8
+ This guide is for AI assistants to follow when building API wrappers using the
9
+ `createApiClient` utility from `hanzio/api-wrapper`. The API reference
10
+ (configuration, errors, actions, retries, pagination) lives in
11
+ [README.md](./README.md).
12
+
13
+ ## Folder Structure
14
+
15
+ When creating a new API wrapper, follow this structure:
16
+
17
+ ```txt
18
+ src/api-wrapper/{api-name}/
19
+ ├── index.ts
20
+ ├── types.ts
21
+ ├── {endpointName}.ts
22
+ ├── {anotherEndpoint}.ts
23
+ └── utils/
24
+ ├── {utilName}.ts
25
+ └── ...
26
+ ```
27
+
28
+ ### Naming Conventions
29
+
30
+ - Folder name: lowercase, kebab-case, for example `github-api`, `stripe`, `template-persona`.
31
+ - Endpoint files: camelCase and action-oriented, for example `topicsGet.ts`, `usersCreate.ts`, `ordersUpdate.ts`.
32
+ - Utility files: camelCase and descriptive, for example `paginateResults.ts`, `templateGetAll.ts`.
33
+
34
+ ## Step-By-Step Process
35
+
36
+ ### Step 1: Gather Information
37
+
38
+ The user will typically provide one or more of:
39
+
40
+ 1. cURL command: extract method, URL, headers, and body.
41
+ 2. Request/response examples: use them to build Zod schemas.
42
+ 3. Chrome DevTools Network logs: extract request and response details.
43
+ 4. API documentation: reference schemas, auth, pagination, and endpoints.
44
+
45
+ ### Step 2: Create The Folder
46
+
47
+ ```bash
48
+ mkdir -p src/api-wrapper/{api-name}/utils
49
+ ```
50
+
51
+ ### Step 3: Create The Index File
52
+
53
+ The index file creates and exports the API client. When the token comes from
54
+ a secret store, create the client at module level and resolve the header
55
+ lazily with a `defaultHeaders` function; take the token as a parameter only
56
+ when callers supply it per instance.
57
+
58
+ ```ts
59
+ import { createApiClient } from 'hanzio/api-wrapper'
60
+ import { endpointOne } from './endpointOne'
61
+ import { endpointTwo } from './endpointTwo'
62
+
63
+ export const exampleApi = createApiClient({
64
+ name: 'exampleApi',
65
+ baseApiUrls: {
66
+ default: 'https://api.example.com'
67
+ },
68
+ defaultHeaders: () => ({
69
+ Authorization: `Bearer ${getSecret('EXAMPLE_API_TOKEN')}`
70
+ }),
71
+ endpoints: {
72
+ endpointOne,
73
+ endpointTwo
74
+ }
75
+ })
76
+ ```
77
+
78
+ If the project uses OpenTelemetry, add
79
+ `use: [otelMiddleware(trace.getTracer('app'))]`; for other shared middleware
80
+ (metrics), pass it with `use: [...]` instead of wrapping `fetch`. Pass a
81
+ `logger` so failures are logged with their category (server, input or output
82
+ validation). If the API returns HTTP 200 with an
83
+ error envelope (e.g. `{ success: false, errors }`), add a `checkResponse` that
84
+ throws on failure rather than checking after every call. Endpoints without the
85
+ envelope set `checkResponse: false` (or their own check). When the result is in
86
+ response headers (e.g. a `Location`), read them in `resFormatter(body, headers)`.
87
+
88
+ ### Step 4: Create Shared Types
89
+
90
+ If multiple endpoints share schemas, create a `types.ts` file.
91
+
92
+ ```ts
93
+ import { z } from 'zod'
94
+
95
+ export const ListQueryInput = z.object({
96
+ page: z.number().optional(),
97
+ page_size: z.number().optional()
98
+ })
99
+ export type ListQueryInput = z.infer<typeof ListQueryInput>
100
+
101
+ export const ListQueryOutput = <T extends z.ZodType>(itemSchema: T) =>
102
+ z.object({
103
+ data: z.array(itemSchema),
104
+ current_page: z.number(),
105
+ last_page: z.number(),
106
+ per_page: z.number(),
107
+ total: z.number()
108
+ })
109
+
110
+ export type ListQueryOutput<T> = {
111
+ data: T[]
112
+ current_page: number
113
+ last_page: number
114
+ per_page: number
115
+ total: number
116
+ }
117
+ ```
118
+
119
+ ### Step 5: Create Endpoint Files
120
+
121
+ Each endpoint gets its own file with request/response schemas and endpoint
122
+ configuration.
123
+
124
+ ```ts
125
+ import { defineEndpoint } from 'hanzio/api-wrapper'
126
+ import { z } from 'zod'
127
+
128
+ export const UserResponse = z.object({
129
+ id: z.number(),
130
+ name: z.string(),
131
+ email: z.string()
132
+ })
133
+
134
+ export const usersGet = defineEndpoint({
135
+ method: 'GET',
136
+ path: '/users/:userId',
137
+ reqQuerySchema: z.object({ includeInactive: z.boolean().optional() }),
138
+ resSchema: UserResponse
139
+ })
140
+ ```
141
+
142
+ `defineEndpoint` types `reqParams` from the `:params` in `path`, so add a
143
+ `reqParamsSchema` only when params need validation or coercion.
144
+
145
+ ### Step 6: Create Utility Functions
146
+
147
+ Place reusable helpers in `utils/`, e.g. response normalization or rate-limit
148
+ handling. For pagination, use the built-in `paginate` instead of a hand-written
149
+ loop:
150
+
151
+ ```ts
152
+ import { paginate } from 'hanzio/api-wrapper'
153
+ import type { GetExampleApi } from '../index'
154
+
155
+ export const usersGetAll = (api: GetExampleApi) =>
156
+ Array.fromAsync(
157
+ paginate(
158
+ (page: number = 1) => api.usersList({ reqQuery: { page } }),
159
+ (res) =>
160
+ res.data.current_page < res.data.last_page
161
+ ? res.data.current_page + 1
162
+ : null
163
+ )
164
+ )
165
+ ```
166
+
167
+ ## Extracting Information From User Input
168
+
169
+ ### From cURL Commands
170
+
171
+ ```bash
172
+ curl -X POST 'https://api.example.com/users' \
173
+ -H 'Authorization: Bearer token123' \
174
+ -H 'Content-Type: application/json' \
175
+ -d '{"name": "John", "email": "john@example.com"}'
176
+ ```
177
+
178
+ Extract:
179
+
180
+ - Method: `POST`
181
+ - Base URL: `https://api.example.com`
182
+ - Path: `/users`
183
+ - Headers: `Authorization`, `Content-Type`
184
+ - Body schema: `{ name: string, email: string }`
185
+
186
+ ### From Chrome DevTools
187
+
188
+ Look for:
189
+
190
+ 1. Request URL: base URL plus path.
191
+ 2. Request method: HTTP method.
192
+ 3. Request headers: auth and custom headers.
193
+ 4. Request payload: body schema.
194
+ 5. Response body: response schema.
195
+ 6. Query string parameters: query schema.
196
+
197
+ ### Building Zod Schemas From JSON
198
+
199
+ Given this response:
200
+
201
+ ```json
202
+ {
203
+ "id": 123,
204
+ "name": "John Doe",
205
+ "email": "john@example.com",
206
+ "created_at": "2024-01-15T10:30:00Z",
207
+ "roles": ["admin", "user"],
208
+ "profile": {
209
+ "avatar_url": "https://example.com/avatar.png",
210
+ "bio": "Developer"
211
+ },
212
+ "is_active": true
213
+ }
214
+ ```
215
+
216
+ Create this schema:
217
+
218
+ ```ts
219
+ const UserResponse = z.object({
220
+ id: z.number(),
221
+ name: z.string(),
222
+ email: z.string(),
223
+ created_at: z.string(),
224
+ roles: z.array(z.string()),
225
+ profile: z.object({
226
+ avatar_url: z.string(),
227
+ bio: z.string()
228
+ }),
229
+ is_active: z.boolean()
230
+ })
231
+ ```
232
+
233
+ ## Checklist
234
+
235
+ - [ ] Create folder with API name in kebab-case.
236
+ - [ ] Create `index.ts` with a `createApiClient` call.
237
+ - [ ] Set `baseApiUrls` and `defaultHeaders` (a function when the token comes from a secret store).
238
+ - [ ] Add `checkResponse` if the API returns errors inside 200 responses.
239
+ - [ ] Create `types.ts` for shared schemas when needed.
240
+ - [ ] Create one file per endpoint.
241
+ - [ ] Define Zod schemas for all request and response data.
242
+ - [ ] Use `defineEndpoint` for endpoint definitions.
243
+ - [ ] Create helpers in `utils/` when needed.
244
+ - [ ] Export the API type, for example `export type ExampleApi = typeof exampleApi`.
245
+ - [ ] Test with `createMockFetch` from `hanzio/api-wrapper/testing` passed as `fetch`; don't replace `globalThis.fetch`.
246
+
247
+ ## Tips
248
+
249
+ 1. Always use `defineEndpoint` for endpoint definitions (it checks `resFormatter` and types path params).
250
+ 2. Import Zod from `zod`.
251
+ 3. Be strict with schemas and mark unstable fields as `.optional()` or `.nullish()`.
252
+ 4. Use descriptive endpoint names like `usersGet`, `usersCreate`, and `ordersListByStatus`.
253
+ 5. Keep files small by using one endpoint per file.
254
+ 6. Extract schemas to `types.ts` when used by two or more endpoints.
255
+ 7. Use `paginate` when APIs use pagination.
256
+ 8. Ask for sample responses if the user only provides cURL commands.
@@ -0,0 +1,142 @@
1
+ import { createHmac, randomBytes } from 'node:crypto'
2
+ import { raceAbort } from '../promise'
3
+
4
+ export type ConnectionAuthToken = { token: string; expiresAt: number }
5
+
6
+ type Entry = {
7
+ identity: string
8
+ token?: ConnectionAuthToken
9
+ pending?: Promise<ConnectionAuthToken>
10
+ }
11
+
12
+ /**
13
+ * Shares one login per connection across every client instance in the process,
14
+ * for APIs that exchange credentials for a short-lived token.
15
+ *
16
+ * - Concurrent callers share one exchange; aborting one caller's wait leaves the
17
+ * exchange running for the others.
18
+ * - Changing any of `authInputs` (rotated password, new client id) retires the
19
+ * old token: a retired client may finish its request but cannot store its
20
+ * token. Inputs are kept only as an HMAC under a random per-cache key, so
21
+ * the cache never holds, and cannot be used to guess, the credentials.
22
+ * - On a 401, `reject(token)` drops the refused token, unless a newer one has
23
+ * already replaced it, and the next `get()` exchanges again.
24
+ * - `get({ fresh: true })` always exchanges, for an explicit credential check;
25
+ * its failure never falls back to the previous token.
26
+ * - At most `maxEntries` connections are kept, least recently used first;
27
+ * connections with an exchange in flight are never evicted.
28
+ *
29
+ * @example
30
+ * const auth = new ConnectionAuthCache()
31
+ *
32
+ * class ShopApi {
33
+ * private readonly token
34
+ * constructor(credentials: { connectionId: string; apiKey: string; secret: string }) {
35
+ * this.token = auth.bind({
36
+ * connectionId: credentials.connectionId,
37
+ * authInputs: [credentials.apiKey, credentials.secret],
38
+ * refreshMarginMs: 60_000,
39
+ * exchange: () => login(credentials) // resolves { token, expiresAt }
40
+ * })
41
+ * }
42
+ * async read(signal?: AbortSignal) {
43
+ * const current = await this.token.get({ signal })
44
+ * // On a 401: this.token.reject(current), then retry once with this.token.get().
45
+ * }
46
+ * }
47
+ */
48
+ export class ConnectionAuthCache {
49
+ private readonly key = randomBytes(32)
50
+ private readonly entries = new Map<string, Entry>()
51
+
52
+ constructor(private readonly maxEntries = 1_000) {}
53
+
54
+ get size() {
55
+ return this.entries.size
56
+ }
57
+
58
+ clear() {
59
+ for (const entry of this.entries.values()) entry.token = undefined
60
+ this.entries.clear()
61
+ }
62
+
63
+ private prune(protectedEntry?: Entry) {
64
+ for (const [connectionId, entry] of this.entries) {
65
+ if (this.entries.size <= this.maxEntries) break
66
+ // In-flight work must remain discoverable by identical callers. Its
67
+ // ownership ends on settlement, when pruning runs again.
68
+ if (entry.pending || entry === protectedEntry) continue
69
+ entry.token = undefined
70
+ this.entries.delete(connectionId)
71
+ }
72
+ }
73
+
74
+ bind(input: {
75
+ connectionId: string
76
+ authInputs: readonly (string | number)[]
77
+ refreshMarginMs: number
78
+ exchange: () => Promise<ConnectionAuthToken>
79
+ }) {
80
+ const identity = createHmac('sha256', this.key)
81
+ .update(JSON.stringify(input.authInputs))
82
+ .digest('hex')
83
+ let entry = this.entries.get(input.connectionId)
84
+
85
+ if (!entry || entry.identity !== identity) {
86
+ if (entry) entry.token = undefined
87
+ entry = { identity }
88
+ }
89
+
90
+ // Map order is LRU. Eviction never cancels an exchange already in use.
91
+ this.entries.delete(input.connectionId)
92
+ this.entries.set(input.connectionId, entry)
93
+ this.prune(entry)
94
+
95
+ const state = entry
96
+ const get = (options?: { fresh?: boolean; signal?: AbortSignal }) => {
97
+ if (options?.signal?.aborted) {
98
+ return Promise.reject<ConnectionAuthToken>(options.signal.reason)
99
+ }
100
+
101
+ if (this.entries.get(input.connectionId) === state) {
102
+ this.entries.delete(input.connectionId)
103
+ this.entries.set(input.connectionId, state)
104
+ }
105
+
106
+ // Aborting cancels only this wait; the exchange belongs to every waiter.
107
+ if (state.pending) return raceAbort(state.pending, options?.signal)
108
+ if (
109
+ !options?.fresh &&
110
+ state.token &&
111
+ state.token.expiresAt - Date.now() > input.refreshMarginMs
112
+ ) {
113
+ return Promise.resolve(state.token)
114
+ }
115
+
116
+ // A fresh check cannot leave the previous token usable after failure.
117
+ state.token = undefined
118
+ const pending = input
119
+ .exchange()
120
+ .then((token) => {
121
+ if (this.entries.get(input.connectionId) === state) {
122
+ state.token = token
123
+ }
124
+ return token
125
+ })
126
+ .finally(() => {
127
+ if (state.pending === pending) state.pending = undefined
128
+ this.prune()
129
+ })
130
+ state.pending = pending
131
+
132
+ return raceAbort(pending, options?.signal)
133
+ }
134
+
135
+ return {
136
+ get,
137
+ reject(token: ConnectionAuthToken) {
138
+ if (state.token === token) state.token = undefined
139
+ }
140
+ }
141
+ }
142
+ }