hanzio 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -3
- package/dist/api-wrapper/connection-auth.d.ts +61 -0
- package/dist/cli/fields.d.ts +30 -0
- package/dist/cli/index.d.ts +122 -0
- package/dist/format/index.d.ts +91 -0
- package/dist/format/phone.d.ts +19 -0
- package/dist/http/index.d.ts +14 -0
- package/dist/i18n/catalog.d.ts +47 -0
- package/dist/i18n/check.d.ts +45 -0
- package/dist/i18n/cli.d.ts +2 -0
- package/dist/i18n/content-rules.d.ts +55 -0
- package/dist/i18n/i18next.d.ts +39 -0
- package/dist/i18n/index.d.ts +16 -1
- package/dist/i18n/locales.d.ts +62 -0
- package/dist/i18n/vite.d.ts +16 -0
- package/dist/i18n/vue.d.ts +93 -0
- package/dist/{index-kj1phgrx.js → index-29mmh343.js} +24 -1
- package/dist/{index-zbj9s3s3.js → index-2kz392vw.js} +1 -1
- package/dist/index-5n2e326q.js +409 -0
- package/dist/{index-hy7kbb99.js → index-5tzqfmnj.js} +1 -1
- package/dist/{index-0zvtcbzy.js → index-6f2w90pp.js} +69 -1
- package/dist/index-mq2qgc20.js +116 -0
- package/dist/index-najbyzpr.js +99 -0
- package/dist/index-nfvq3zax.js +475 -0
- package/dist/index-ww09bm49.js +250 -0
- package/dist/iso/countries.d.ts +262 -0
- package/dist/iso/currencies.d.ts +165 -0
- package/dist/iso/index.d.ts +4 -0
- package/dist/iso/languages.d.ts +195 -0
- package/dist/iso/phone-language.d.ts +5 -0
- package/dist/promise/index.d.ts +31 -0
- package/dist/redis/client.d.ts +49 -0
- package/dist/redis/concurrency-limit.d.ts +63 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/lua-script.d.ts +13 -0
- package/dist/secrets/infisical-identity-file.d.ts +12 -0
- package/dist/src/api-wrapper/connection-auth.js +82 -0
- package/dist/src/api-wrapper/index.js +1 -1
- package/dist/src/cli/index.js +10 -0
- package/dist/src/format/index.js +79 -2
- package/dist/src/format/phone.js +21 -0
- package/dist/src/http/index.js +69 -0
- package/dist/src/i18n/check.js +15 -0
- package/dist/src/i18n/cli.js +71 -0
- package/dist/src/i18n/i18next.js +48 -0
- package/dist/src/i18n/index.js +16 -34
- package/dist/src/i18n/vite.js +25 -0
- package/dist/src/i18n/vue.js +51 -0
- package/dist/src/index.js +14 -4
- package/dist/src/iso/index.js +655 -0
- package/dist/src/redis/index.js +577 -0
- package/dist/src/secrets/index.js +78 -35
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/storage/index.js +8 -117
- package/dist/src/testing/index.js +348 -0
- package/dist/src/zod/index.js +20 -253
- package/dist/string/index.d.ts +21 -0
- package/dist/testing/connection-auth-contract.d.ts +43 -0
- package/dist/testing/helpers.d.ts +26 -0
- package/dist/testing/index.d.ts +5 -0
- package/dist/testing/module-mocker.d.ts +30 -0
- package/dist/testing/prisma.d.ts +30 -0
- package/dist/testing/window.d.ts +18 -0
- package/dist/zod/index.d.ts +10 -0
- package/dist/zod/introspect.d.ts +2 -1
- package/package.json +96 -2
- package/src/api-wrapper/README.md +52 -0
- package/src/api-wrapper/connection-auth.ts +142 -0
- package/src/cli/README.md +220 -0
- package/src/cli/fields.ts +213 -0
- package/src/cli/index.ts +533 -0
- package/src/format/README.md +82 -2
- package/src/format/index.ts +148 -0
- package/src/format/phone.ts +43 -0
- package/src/http/README.md +25 -0
- package/src/http/index.ts +94 -0
- package/src/i18n/README.md +206 -3
- package/src/i18n/catalog.ts +139 -0
- package/src/i18n/check.ts +376 -0
- package/src/i18n/cli.ts +90 -0
- package/src/i18n/content-rules.ts +179 -0
- package/src/i18n/i18next.ts +89 -0
- package/src/i18n/index.ts +34 -0
- package/src/i18n/locales.ts +135 -0
- package/src/i18n/vite.ts +40 -0
- package/src/i18n/vue.ts +82 -0
- package/src/index.ts +1 -1
- package/src/iso/README.md +44 -0
- package/src/iso/countries.ts +272 -0
- package/src/iso/currencies.ts +172 -0
- package/src/iso/index.ts +8 -0
- package/src/iso/languages.ts +208 -0
- package/src/iso/phone-language.ts +46 -0
- package/src/promise/index.ts +100 -0
- package/src/redis/README.md +112 -0
- package/src/redis/client.ts +239 -0
- package/src/redis/concurrency-limit.ts +591 -0
- package/src/redis/index.ts +15 -0
- package/src/redis/lua-script.ts +43 -0
- package/src/secrets/README.md +5 -1
- package/src/secrets/infisical-identity-file.ts +69 -0
- package/src/secrets/infisical.ts +18 -3
- package/src/string/index.ts +59 -0
- package/src/testing/README.md +119 -0
- package/src/testing/connection-auth-contract.ts +303 -0
- package/src/testing/helpers.ts +41 -0
- package/src/testing/index.ts +12 -0
- package/src/testing/module-mocker.ts +50 -0
- package/src/testing/prisma.ts +78 -0
- package/src/testing/window.ts +119 -0
- package/src/zod/index.ts +20 -0
- package/src/zod/introspect.ts +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hanzio",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
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",
|
|
@@ -56,6 +59,12 @@
|
|
|
56
59
|
"import": "./dist/src/api-wrapper/testing.js",
|
|
57
60
|
"default": "./dist/src/api-wrapper/testing.js"
|
|
58
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
|
+
},
|
|
59
68
|
"./jwt": {
|
|
60
69
|
"types": "./dist/jwt/index.d.ts",
|
|
61
70
|
"bun": "./src/jwt/index.ts",
|
|
@@ -86,6 +95,12 @@
|
|
|
86
95
|
"import": "./dist/src/format/index.js",
|
|
87
96
|
"default": "./dist/src/format/index.js"
|
|
88
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
|
+
},
|
|
89
104
|
"./crypto": {
|
|
90
105
|
"types": "./dist/crypto/index.d.ts",
|
|
91
106
|
"bun": "./src/crypto/index.ts",
|
|
@@ -104,6 +119,36 @@
|
|
|
104
119
|
"import": "./dist/src/i18n/index.js",
|
|
105
120
|
"default": "./dist/src/i18n/index.js"
|
|
106
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
|
+
},
|
|
107
152
|
"./secrets": {
|
|
108
153
|
"types": "./dist/secrets/index.d.ts",
|
|
109
154
|
"bun": "./src/secrets/index.ts",
|
|
@@ -121,11 +166,35 @@
|
|
|
121
166
|
"bun": "./src/secrets/worker.ts",
|
|
122
167
|
"import": "./dist/src/secrets/worker.js",
|
|
123
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"
|
|
124
193
|
}
|
|
125
194
|
},
|
|
126
195
|
"scripts": {
|
|
127
196
|
"build": "rm -rf dist && bun run build:js && bun run build:types",
|
|
128
|
-
"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",
|
|
129
198
|
"build:types": "tsgo -p tsconfig.build.json",
|
|
130
199
|
"prepublishOnly": "bun run build",
|
|
131
200
|
"test": "bun test",
|
|
@@ -139,19 +208,44 @@
|
|
|
139
208
|
},
|
|
140
209
|
"devDependencies": {
|
|
141
210
|
"@biomejs/biome": "^2.4.6",
|
|
211
|
+
"@intlify/message-compiler": "^11.4.16",
|
|
142
212
|
"@opentelemetry/api": "^1.9.1",
|
|
143
213
|
"@types/bun": "latest",
|
|
144
214
|
"@typescript/native-preview": "^7.0.0-dev.20260306.1",
|
|
145
215
|
"husky": "^9.1.7",
|
|
216
|
+
"i18next": "^25.10.10",
|
|
217
|
+
"libphonenumber-js": "^1.13.15",
|
|
146
218
|
"miniflare": "^4.20260730.0",
|
|
147
219
|
"typescript": "^5.7.0",
|
|
148
220
|
"vite": "^8.2.2",
|
|
221
|
+
"vue": "^3.5.44",
|
|
222
|
+
"vue-i18n": "^11.4.16",
|
|
149
223
|
"zod": "^4.3.6"
|
|
150
224
|
},
|
|
151
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",
|
|
152
231
|
"zod": "^4.3.6"
|
|
153
232
|
},
|
|
154
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
|
+
},
|
|
155
249
|
"zod": {
|
|
156
250
|
"optional": true
|
|
157
251
|
}
|
|
@@ -648,6 +648,58 @@ Cache API:
|
|
|
648
648
|
The cache is also exposed on the client itself as `api.cache` for use outside
|
|
649
649
|
of action handlers (e.g. clearing auth on logout).
|
|
650
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
|
+
|
|
651
703
|
## Endpoint Configuration Reference
|
|
652
704
|
|
|
653
705
|
| Option | Required | Description |
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Typed CLIs from Zod object schemas, parsed with node:util parseArgs, with generated help, completion and consistent exit codes.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# hanzio/cli
|
|
6
|
+
|
|
7
|
+
Each command's `input` is a Zod object: its keys become flags, Zod validates
|
|
8
|
+
them, and `run` gets the typed result. Parsing is `node:util` `parseArgs`, so
|
|
9
|
+
there is no extra dependency. Runs in Bun and Node 22.4+; needs `zod` 4.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// src/cli.ts
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { defineCli, defineCommand } from 'hanzio/cli'
|
|
15
|
+
|
|
16
|
+
export default defineCli({
|
|
17
|
+
name: 'better-mastra-evals',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
commands: {
|
|
20
|
+
run: defineCommand({
|
|
21
|
+
description: 'Run eval suites',
|
|
22
|
+
input: z.object({
|
|
23
|
+
suite: z.string().meta({ positional: true }),
|
|
24
|
+
repeats: z.number().default(1).describe('Runs per case'),
|
|
25
|
+
json: z.boolean().optional()
|
|
26
|
+
}),
|
|
27
|
+
run: async (input, ctx) => {
|
|
28
|
+
// input: { suite: string; repeats: number; json?: boolean }
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
inspect: () => import('./commands/inspect') // loaded only when used
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
#!/usr/bin/env node
|
|
38
|
+
// bin.ts
|
|
39
|
+
import cli from './cli.js'
|
|
40
|
+
|
|
41
|
+
process.exitCode = await cli.run()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use `process.exit(await cli.run())` instead when open database or Redis
|
|
45
|
+
handles would keep the process alive.
|
|
46
|
+
|
|
47
|
+
## Flags
|
|
48
|
+
|
|
49
|
+
| Schema | Command line |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `z.string()`, `z.enum([...])` | `--name value` or `--name=value` |
|
|
52
|
+
| `z.number()` | `--repeats 3`, coerced before validation |
|
|
53
|
+
| `z.boolean()` | `--json`; `--no-json` sets `false`; missing is `false` |
|
|
54
|
+
| `z.array(z.string())` | repeatable: `--model a --model b`; missing is `[]` |
|
|
55
|
+
| `.optional()`, `.default(x)` | may be left out |
|
|
56
|
+
| `.describe('…')` | help text |
|
|
57
|
+
| `.meta({ positional: true })` | positional, in key order; an array takes the rest |
|
|
58
|
+
| `.meta({ alias: 'r' })` | `-r value` |
|
|
59
|
+
| `.meta({ env: 'APP_REGION' })` | read from the environment when the flag is missing |
|
|
60
|
+
|
|
61
|
+
Keys are camelCase in the schema and kebab-case on the command line (`dryRun`
|
|
62
|
+
→ `--dry-run`). These are the same `.meta()` keys as `trpc-cli`, plus `env`;
|
|
63
|
+
importing `hanzio/cli` types them on Zod's `GlobalMeta`.
|
|
64
|
+
Pipes read their input side, so `z.stringbool()` takes `--submit true`.
|
|
65
|
+
|
|
66
|
+
Every CLI gets `-h`/`--help` (root, groups and commands), `--version` when you
|
|
67
|
+
pass `version`, and "Did you mean …?" for mistyped commands and flags.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
`commands` is a single command for a one-command CLI, or an object of
|
|
72
|
+
commands and nested groups, so `{ check: { docs } }` runs as `tool check
|
|
73
|
+
docs`. Groups are plain objects, so an adapter can build them from data.
|
|
74
|
+
|
|
75
|
+
A command is one of:
|
|
76
|
+
|
|
77
|
+
- `defineCommand({ description, input, run, output })`.
|
|
78
|
+
- `() => import('./commands/heavy')`, a module whose default export is a
|
|
79
|
+
command. It loads only when it runs.
|
|
80
|
+
- `defineCommand({ description, load: () => import('./commands/heavy') })`,
|
|
81
|
+
the same with a description for the command list.
|
|
82
|
+
- `defineCommand({ raw: true, run })`, which skips parsing: every argument
|
|
83
|
+
after the command name is in `ctx.argv` (for `tool prisma migrate dev`).
|
|
84
|
+
|
|
85
|
+
`run(input, ctx)` gets:
|
|
86
|
+
|
|
87
|
+
| `ctx` | |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `context` | what `defineCli({ context })` returned |
|
|
90
|
+
| `globals` | the parsed global options |
|
|
91
|
+
| `path` | command names, e.g. `['deal', 'list']` |
|
|
92
|
+
| `passthrough` | arguments after `--` (`tool exec -- bun test`) |
|
|
93
|
+
| `argv` | every argument after the command names, unparsed |
|
|
94
|
+
| `json` | `--json` was passed |
|
|
95
|
+
| `stdout`, `stderr`, `env` | the CLI's streams and environment |
|
|
96
|
+
| `exitCode` | set it to fail without an error message |
|
|
97
|
+
|
|
98
|
+
Whatever `run` returns goes to `output(result, ctx)`. The default prints
|
|
99
|
+
strings as they are and anything else as indented JSON, or compact JSON with
|
|
100
|
+
`--json`; `undefined` prints nothing.
|
|
101
|
+
|
|
102
|
+
## Globals, context and guard
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const cli = defineCli({
|
|
106
|
+
name: 'app',
|
|
107
|
+
globals: z.object({
|
|
108
|
+
org: z.string().optional(),
|
|
109
|
+
allowProduction: z.boolean().optional(),
|
|
110
|
+
json: z.boolean().optional()
|
|
111
|
+
}),
|
|
112
|
+
context: async (globals) => ({ db: await connect(), org: globals.org }),
|
|
113
|
+
guard: ({ context, globals }) => {
|
|
114
|
+
if (context.db.isProduction && !globals.allowProduction)
|
|
115
|
+
throw new CliError('Refusing production; pass --allow-production')
|
|
116
|
+
},
|
|
117
|
+
commands: { deal: { list, get } }
|
|
118
|
+
})
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Globals are accepted before or after the command names. `context` runs after
|
|
122
|
+
parsing, so `--help` never connects anywhere. `guard` runs before every
|
|
123
|
+
command. A command defined in its own file types its context by annotating
|
|
124
|
+
`ctx`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
export const list = defineCommand({
|
|
128
|
+
input: z.object({ limit: z.number().default(20) }),
|
|
129
|
+
run: (input, { context }: CommandContext<AppContext>) =>
|
|
130
|
+
context.db.deal.findMany({ take: input.limit })
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`defineCli` rejects a command whose context type doesn't match `context`, and
|
|
135
|
+
requires `context` once any command needs one.
|
|
136
|
+
|
|
137
|
+
## Errors and exit codes
|
|
138
|
+
|
|
139
|
+
| Exit | When |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| 0 | the command finished |
|
|
142
|
+
| 1 | `run` threw, or set `ctx.exitCode = 1` |
|
|
143
|
+
| 2 | bad arguments: unknown command or flag, failed validation |
|
|
144
|
+
|
|
145
|
+
Throw `new CliError(message, { exitCode })` for expected failures: the
|
|
146
|
+
message is printed without a stack. Other errors print their stack. With
|
|
147
|
+
`--json`, errors print as `{"error": "…"}`. Usage errors name the flag:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
--repeats: Invalid input: expected number, received string
|
|
151
|
+
Run "better-mastra-evals run --help" for usage.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Shell completion
|
|
155
|
+
|
|
156
|
+
A CLI with commands also gets `tool completion bash` and `tool completion
|
|
157
|
+
zsh`, which print a script that completes command names and flags:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
# in ~/.zshrc (or ~/.bashrc with bash)
|
|
161
|
+
source <(tool completion zsh)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Testing
|
|
165
|
+
|
|
166
|
+
`cli.run(argv, { stdout, stderr, env })` resolves to the exit code and never
|
|
167
|
+
touches `process`, so tests don't spawn anything:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
let out = ''
|
|
171
|
+
const code = await cli.run(['run', 'smoke', '--json'], {
|
|
172
|
+
stdout: { write: (text) => (out += text) },
|
|
173
|
+
stderr: { write: () => {} },
|
|
174
|
+
env: {}
|
|
175
|
+
})
|
|
176
|
+
expect(code).toBe(0)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Migrating from `parseArgs`
|
|
180
|
+
|
|
181
|
+
1. Turn the `options` object into a `z.object`: `type: 'string'` becomes
|
|
182
|
+
`z.string()`, `multiple: true` becomes `z.array(...)`, `default` becomes
|
|
183
|
+
`.default()`, `short: 'r'` becomes `.meta({ alias: 'r' })`, and
|
|
184
|
+
positionals become keys with `.meta({ positional: true })`.
|
|
185
|
+
2. Delete the hand-written validation (`Number(values.repeats)`, enum
|
|
186
|
+
checks, "--x required" errors): `z.number()`, `z.enum()` and required keys
|
|
187
|
+
do it, with the flag in the error.
|
|
188
|
+
3. Move each `switch (positionals[0])` branch into a `defineCommand`, and its
|
|
189
|
+
help line into `description` / `.describe()`. Delete the `USAGE` string.
|
|
190
|
+
4. Replace `console.error(...); process.exit(1)` with `throw new CliError(...)`,
|
|
191
|
+
and the `main().catch(...)` wrapper with `process.exitCode = await cli.run()`.
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// before
|
|
195
|
+
const { values, positionals } = parseArgs({
|
|
196
|
+
allowPositionals: true,
|
|
197
|
+
options: { out: { type: 'string', short: 'o' }, force: { type: 'boolean' } }
|
|
198
|
+
})
|
|
199
|
+
if (positionals[0] !== 'add' || !values.out) {
|
|
200
|
+
console.error(USAGE)
|
|
201
|
+
process.exit(2)
|
|
202
|
+
}
|
|
203
|
+
await add(positionals.slice(1), values)
|
|
204
|
+
|
|
205
|
+
// after
|
|
206
|
+
defineCli({
|
|
207
|
+
name: 'tool',
|
|
208
|
+
commands: {
|
|
209
|
+
add: defineCommand({
|
|
210
|
+
description: 'Copy fragments into a schema folder',
|
|
211
|
+
input: z.object({
|
|
212
|
+
fragments: z.array(z.string()).min(1).meta({ positional: true }),
|
|
213
|
+
out: z.string().meta({ alias: 'o' }),
|
|
214
|
+
force: z.boolean()
|
|
215
|
+
}),
|
|
216
|
+
run: (input) => add(input.fragments, input)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
```
|