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.
Files changed (112) hide show
  1. package/README.md +114 -3
  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-mq2qgc20.js +116 -0
  23. package/dist/index-najbyzpr.js +99 -0
  24. package/dist/index-nfvq3zax.js +475 -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 +14 -4
  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/zod/index.d.ts +10 -0
  65. package/dist/zod/introspect.d.ts +2 -1
  66. package/package.json +96 -2
  67. package/src/api-wrapper/README.md +52 -0
  68. package/src/api-wrapper/connection-auth.ts +142 -0
  69. package/src/cli/README.md +220 -0
  70. package/src/cli/fields.ts +213 -0
  71. package/src/cli/index.ts +533 -0
  72. package/src/format/README.md +82 -2
  73. package/src/format/index.ts +148 -0
  74. package/src/format/phone.ts +43 -0
  75. package/src/http/README.md +25 -0
  76. package/src/http/index.ts +94 -0
  77. package/src/i18n/README.md +206 -3
  78. package/src/i18n/catalog.ts +139 -0
  79. package/src/i18n/check.ts +376 -0
  80. package/src/i18n/cli.ts +90 -0
  81. package/src/i18n/content-rules.ts +179 -0
  82. package/src/i18n/i18next.ts +89 -0
  83. package/src/i18n/index.ts +34 -0
  84. package/src/i18n/locales.ts +135 -0
  85. package/src/i18n/vite.ts +40 -0
  86. package/src/i18n/vue.ts +82 -0
  87. package/src/index.ts +1 -1
  88. package/src/iso/README.md +44 -0
  89. package/src/iso/countries.ts +272 -0
  90. package/src/iso/currencies.ts +172 -0
  91. package/src/iso/index.ts +8 -0
  92. package/src/iso/languages.ts +208 -0
  93. package/src/iso/phone-language.ts +46 -0
  94. package/src/promise/index.ts +100 -0
  95. package/src/redis/README.md +112 -0
  96. package/src/redis/client.ts +239 -0
  97. package/src/redis/concurrency-limit.ts +591 -0
  98. package/src/redis/index.ts +15 -0
  99. package/src/redis/lua-script.ts +43 -0
  100. package/src/secrets/README.md +5 -1
  101. package/src/secrets/infisical-identity-file.ts +69 -0
  102. package/src/secrets/infisical.ts +18 -3
  103. package/src/string/index.ts +59 -0
  104. package/src/testing/README.md +119 -0
  105. package/src/testing/connection-auth-contract.ts +303 -0
  106. package/src/testing/helpers.ts +41 -0
  107. package/src/testing/index.ts +12 -0
  108. package/src/testing/module-mocker.ts +50 -0
  109. package/src/testing/prisma.ts +78 -0
  110. package/src/testing/window.ts +119 -0
  111. package/src/zod/index.ts +20 -0
  112. package/src/zod/introspect.ts +1 -1
@@ -253,6 +253,106 @@ export async function pMap<T, R>(
253
253
  return results
254
254
  }
255
255
 
256
+ export type TaskPool = {
257
+ /** Queues `task`; it starts once a slot is free. */
258
+ run<T>(task: () => Promise<T>): Promise<T>
259
+ /** Resolves once every queued and active task has settled; rejects with the first failure. */
260
+ drain(): Promise<void>
261
+ /** Throws the abort reason or the first failure, if any. */
262
+ throwIfFailed(): void
263
+ }
264
+
265
+ /**
266
+ * A bounded pool for work that arrives over time, such as a crawl that queues
267
+ * pages as it discovers them (`pMap` needs the whole list up front).
268
+ *
269
+ * The first failure, or an abort, discards queued tasks and rejects later
270
+ * `run` calls; `drain` waits for active tasks to settle before rejecting with
271
+ * it. Pass the same `signal` to the tasks' IO so an abort interrupts them too.
272
+ * `stopOnError: false` keeps the pool running when a task fails, for callers
273
+ * that retry or fall back per task.
274
+ *
275
+ * A `run` promise that is never awaited does not cause an unhandled rejection;
276
+ * `drain` still reports its failure.
277
+ *
278
+ * @example
279
+ * const pool = createTaskPool({ concurrency: 4, signal })
280
+ * for await (const page of discoverPages(signal)) void pool.run(() => scrape(page, signal))
281
+ * await pool.drain()
282
+ */
283
+ export function createTaskPool(options: {
284
+ concurrency: number
285
+ signal?: AbortSignal
286
+ stopOnError?: boolean
287
+ }): TaskPool {
288
+ const { concurrency, signal, stopOnError = true } = options
289
+ if (!Number.isInteger(concurrency) || concurrency < 1)
290
+ throw new Error(
291
+ `Task concurrency must be a positive integer, got ${concurrency}`
292
+ )
293
+ const queued: { start: () => void; reject: (error: unknown) => void }[] = []
294
+ const idle: (() => void)[] = []
295
+ let active = 0
296
+ let failed = false
297
+ let failure: unknown
298
+ const throwIfFailed = () => {
299
+ signal?.throwIfAborted()
300
+ if (failed) throw failure
301
+ }
302
+ const stop = (error: unknown) => {
303
+ if (!failed) {
304
+ failed = true
305
+ failure = error
306
+ }
307
+ for (const task of queued.splice(0)) task.reject(failure)
308
+ }
309
+ const dispatch = () => {
310
+ if (signal?.aborted) stop(signal.reason)
311
+ while (!failed && active < concurrency && queued.length) {
312
+ active += 1
313
+ queued.shift()!.start()
314
+ }
315
+ if (!active && !queued.length)
316
+ for (const resolve of idle.splice(0)) resolve()
317
+ }
318
+ const run = <T>(task: () => Promise<T>): Promise<T> => {
319
+ const result = new Promise<T>((resolve, reject) => {
320
+ if (failed || signal?.aborted) {
321
+ stop(signal?.aborted ? signal.reason : failure)
322
+ reject(failure)
323
+ return
324
+ }
325
+ queued.push({
326
+ reject,
327
+ start: () => {
328
+ void Promise.resolve()
329
+ .then(() => {
330
+ throwIfFailed()
331
+ return task()
332
+ })
333
+ .then(resolve, (error: unknown) => {
334
+ if (stopOnError || signal?.aborted) stop(error)
335
+ reject(error)
336
+ })
337
+ .finally(() => {
338
+ active -= 1
339
+ dispatch()
340
+ })
341
+ }
342
+ })
343
+ dispatch()
344
+ })
345
+ void result.catch(() => {})
346
+ return result
347
+ }
348
+ const drain = async () => {
349
+ if (active || queued.length)
350
+ await new Promise<void>((resolve) => idle.push(resolve))
351
+ throwIfFailed()
352
+ }
353
+ return { run, drain, throwIfFailed }
354
+ }
355
+
256
356
  const clock = (): number => globalThis.performance?.now() ?? Date.now()
257
357
 
258
358
  /** Starts a clock; call the returned function to read elapsed ms. */
@@ -0,0 +1,112 @@
1
+ ---
2
+ description: Bun Redis client that reconnects and shares subscriptions, plus a fleet-wide concurrency limiter built on Lua scripts.
3
+ ---
4
+
5
+ # hanzio/redis
6
+
7
+ For Bun servers. The client wraps `Bun.RedisClient`; the limiter takes any
8
+ `send(command, args)` function, so it also works with other clients.
9
+
10
+ ```ts
11
+ import {
12
+ createLuaScript,
13
+ createManagedRedis,
14
+ createRedisConcurrencyLimit
15
+ } from 'hanzio/redis'
16
+ ```
17
+
18
+ ## Managed client
19
+
20
+ ```ts
21
+ export const redis = createManagedRedis({
22
+ url: () => getSecret('REDIS_URL'),
23
+ onError: (error, { role, stage, channel }) =>
24
+ captureException(error, 'redis', { role, stage, channel })
25
+ })
26
+
27
+ await redis.getClient().set('key', 'value')
28
+
29
+ const unsubscribe = await redis.subscribe('orders', (message, channel) => {
30
+ handle(JSON.parse(message))
31
+ })
32
+ await unsubscribe()
33
+
34
+ redis.getStats() // { channelCount, channels, isConnected, isSubscriberConnected }
35
+ redis.close() // on shutdown
36
+ ```
37
+
38
+ - `getClient()` returns one shared command connection. Both connections retry
39
+ forever (Bun gives up after about 20 seconds by default) and queue commands
40
+ while offline. If Bun closes one permanently, the next call builds a new one.
41
+ - `subscribe` shares one Redis `SUBSCRIBE` per channel, however many handlers
42
+ listen. The channel is unsubscribed when its last handler leaves. After a
43
+ reconnect, or a replaced subscriber connection, every channel is subscribed
44
+ again. A failed `SUBSCRIBE` rejects and leaves nothing behind.
45
+ - `onError` receives every failure: a connection that closed unexpectedly
46
+ (`stage: 'close'`), a failed subscribe, resubscribe or unsubscribe, and a
47
+ handler that threw. It defaults to `console.error`. It is not throttled, so
48
+ throttle error tracking yourself if a Redis outage would flood it.
49
+ - `close()` closes both connections. Errors caused by the close itself, such as
50
+ an in-flight unsubscribe rejected with "Connection closed", are not reported.
51
+ - `createClient(url, options)` replaces `new Bun.RedisClient(url, options)`,
52
+ for example in tests.
53
+
54
+ ## Concurrency limiter
55
+
56
+ `createRedisConcurrencyLimit` is a semaphore shared by every process: at most
57
+ `max` callbacks run at once for `key`.
58
+
59
+ ```ts
60
+ const withOpenAiSlot = createRedisConcurrencyLimit({
61
+ key: 'openai',
62
+ max: 20,
63
+ slotTtlMs: 30_000,
64
+ maxStartsPerSecond: 10, // optional: also cap starts in a sliding 1s window
65
+ send: (command, args) => redis.getClient().send(command, args)
66
+ })
67
+
68
+ const reply = await withOpenAiSlot(() => openai.chat(request), signal)
69
+
70
+ // After a 429 with Retry-After, pause every process together:
71
+ await withOpenAiSlot.tripCooldown(retryAfterMs)
72
+ ```
73
+
74
+ - Redis owns the clock (`TIME`), so drifting pod clocks cannot evict a slot
75
+ that is still held.
76
+ - A held slot is renewed for as long as the callback runs. `slotTtlMs` is how
77
+ quickly a crashed process's slots free themselves, not a time limit.
78
+ - Waiters in one process queue locally and are granted in one batched script
79
+ call, so Redis traffic follows the cap, not the number of waiters.
80
+ - If Redis has been unreachable for `failOpenAfterMs` (15s) the limiter runs
81
+ callbacks uncapped, and later calls skip Redis until a background probe sees
82
+ it again. Commands time out after `commandTimeoutMs` (3s).
83
+ - Aborting `signal` stops waiting; a slot granted at the same moment is
84
+ released.
85
+ - Keys use a hash tag (`{key}:inflight`), so the scripts work on Redis Cluster.
86
+
87
+ Metrics and logs are hooks:
88
+
89
+ ```ts
90
+ const withLimit = createRedisConcurrencyLimit({
91
+ ...options,
92
+ logger, // throttled warnings; defaults to console
93
+ onWait: (stage) => {
94
+ const timer = waitHistogram.startTimer({ pool: 'openai', stage })
95
+ return () => timer.stop()
96
+ }
97
+ })
98
+ queueDepthGauge.observe(() => withLimit.queueDepth())
99
+ ```
100
+
101
+ A limiter created per tenant or per host should report under a shared pool
102
+ label, not its key, and must be `dispose()`d when discarded.
103
+
104
+ ## Lua scripts
105
+
106
+ `createLuaScript(send, source)` sends a script by SHA (`EVALSHA`) and falls back
107
+ to `EVAL` when Redis has forgotten it after a restart or `SCRIPT FLUSH`.
108
+
109
+ ```ts
110
+ const increment = createLuaScript(send, `return redis.call('INCRBY', KEYS[1], ARGV[1])`)
111
+ await increment(['counter'], ['2'])
112
+ ```
@@ -0,0 +1,239 @@
1
+ import type { RedisClient, RedisOptions } from 'bun'
2
+
3
+ export type RedisRole = 'client' | 'subscriber'
4
+
5
+ export type RedisErrorContext = {
6
+ role: RedisRole
7
+ stage: 'close' | 'subscribe' | 'resubscribe' | 'unsubscribe' | 'handler'
8
+ channel?: string
9
+ }
10
+
11
+ export type ManagedRedisOptions = {
12
+ /** Read when a connection is created, so a secret store can resolve it lazily. */
13
+ url: string | (() => string)
14
+ /** Every failure, for logging, metrics and error tracking. Defaults to `console.error`. */
15
+ onError?: (error: unknown, context: RedisErrorContext) => void
16
+ /** Builds each connection. Defaults to `new Bun.RedisClient(url, options)`. */
17
+ createClient?: (url: string, options: RedisOptions) => RedisClient
18
+ }
19
+
20
+ export type RedisMessageHandler = (message: string, channel: string) => void
21
+
22
+ export type ManagedRedis = {
23
+ /** The shared command connection, created on first use. */
24
+ getClient(): RedisClient
25
+ /**
26
+ * Subscribes `handler` to `channel` on the shared subscriber connection.
27
+ * Channels are ref-counted: Redis sees one `SUBSCRIBE` however many handlers
28
+ * share it, and the returned function unsubscribes when the last one leaves.
29
+ */
30
+ subscribe(
31
+ channel: string,
32
+ handler: RedisMessageHandler
33
+ ): Promise<() => Promise<void>>
34
+ isConnected(): boolean
35
+ getStats(): {
36
+ channelCount: number
37
+ channels: string[]
38
+ isConnected: boolean
39
+ isSubscriberConnected: boolean
40
+ }
41
+ /** Closes both connections. Errors from the shutdown itself are not reported. */
42
+ close(): void
43
+ }
44
+
45
+ /**
46
+ * Reconnect effectively forever. Bun caps its backoff at about 2s, so a large
47
+ * budget keeps a long-lived server reconnecting through extended outages
48
+ * (Bun's default of 10 gives up after about 20s). Transient drops re-fire
49
+ * `onconnect`, which resubscribes channels; `onclose` only fires on a
50
+ * permanent close or an explicit `close()`.
51
+ */
52
+ const CONNECTION_OPTIONS: RedisOptions = {
53
+ autoReconnect: true,
54
+ maxRetries: 1_000_000_000,
55
+ enableOfflineQueue: true
56
+ }
57
+
58
+ function defaultCreateClient(url: string, options: RedisOptions) {
59
+ return new Bun.RedisClient(url, options)
60
+ }
61
+
62
+ function defaultOnError(error: unknown, context: RedisErrorContext) {
63
+ const channel = context.channel ? ` (${context.channel})` : ''
64
+ console.error(
65
+ `Redis ${context.role} ${context.stage} failed${channel}`,
66
+ error
67
+ )
68
+ }
69
+
70
+ /**
71
+ * A command connection plus one shared subscriber connection for a Bun
72
+ * server. Both reconnect on their own, and subscriptions come back after a
73
+ * reconnect.
74
+ *
75
+ * @example
76
+ * export const redis = createManagedRedis({
77
+ * url: () => getSecret('REDIS_URL'),
78
+ * onError: (error, context) => captureException(error, 'redis', context)
79
+ * })
80
+ * await redis.getClient().set('key', 'value')
81
+ * const unsubscribe = await redis.subscribe('events', (message) => handle(message))
82
+ */
83
+ export function createManagedRedis(options: ManagedRedisOptions): ManagedRedis {
84
+ const createClient = options.createClient ?? defaultCreateClient
85
+ const report = options.onError ?? defaultOnError
86
+ const url = () =>
87
+ typeof options.url === 'function' ? options.url() : options.url
88
+
89
+ let client: RedisClient | null = null
90
+ let subscriber: RedisClient | null = null
91
+ const handlers = new Map<string, Set<{ handler: RedisMessageHandler }>>()
92
+ const listeners = new Map<string, RedisMessageHandler>()
93
+ const subscribing = new Map<string, Promise<unknown>>()
94
+
95
+ function listenerFor(channel: string) {
96
+ let listener = listeners.get(channel)
97
+ if (!listener) {
98
+ listener = (message, messageChannel) => {
99
+ if (messageChannel !== channel) return
100
+ for (const entry of [...(handlers.get(channel) ?? [])]) {
101
+ try {
102
+ entry.handler(message, channel)
103
+ } catch (error) {
104
+ report(error, { role: 'subscriber', stage: 'handler', channel })
105
+ }
106
+ }
107
+ }
108
+ listeners.set(channel, listener)
109
+ }
110
+ return listener
111
+ }
112
+
113
+ async function resubscribe(connection: RedisClient) {
114
+ for (const channel of handlers.keys()) {
115
+ try {
116
+ await connection.subscribe(channel, listenerFor(channel))
117
+ } catch (error) {
118
+ report(error, { role: 'subscriber', stage: 'resubscribe', channel })
119
+ }
120
+ }
121
+ }
122
+
123
+ function getSubscriber() {
124
+ if (subscriber) return subscriber
125
+ const connection = createClient(url(), CONNECTION_OPTIONS)
126
+ connection.onconnect = () => {
127
+ void resubscribe(connection)
128
+ }
129
+ // close() detaches the connection first, so a deliberate close (which Bun
130
+ // still reports as an error) returns here without being reported.
131
+ connection.onclose = (error) => {
132
+ if (subscriber !== connection) return
133
+ subscriber = null
134
+ listeners.clear()
135
+ report(error, { role: 'subscriber', stage: 'close' })
136
+ // A fresh connection resubscribes the remaining channels on connect.
137
+ if (handlers.size > 0) getSubscriber()
138
+ }
139
+ subscriber = connection
140
+ return connection
141
+ }
142
+
143
+ function getClient() {
144
+ if (client) return client
145
+ const connection = createClient(url(), CONNECTION_OPTIONS)
146
+ connection.onclose = (error) => {
147
+ if (client !== connection) return
148
+ // Drop the dead reference so the next call builds a fresh client.
149
+ client = null
150
+ if (error) report(error, { role: 'client', stage: 'close' })
151
+ }
152
+ client = connection
153
+ return connection
154
+ }
155
+
156
+ async function subscribe(channel: string, handler: RedisMessageHandler) {
157
+ const entry = { handler }
158
+ let entries = handlers.get(channel)
159
+ if (!entries) {
160
+ entries = new Set()
161
+ handlers.set(channel, entries)
162
+ }
163
+ entries.add(entry)
164
+
165
+ let pending = subscribing.get(channel)
166
+ const first = !pending && entries.size === 1
167
+ if (first) {
168
+ pending = getSubscriber().subscribe(channel, listenerFor(channel))
169
+ subscribing.set(channel, pending)
170
+ void pending
171
+ .catch(() => {})
172
+ .finally(() => {
173
+ if (subscribing.get(channel) === pending) subscribing.delete(channel)
174
+ })
175
+ }
176
+
177
+ try {
178
+ await pending
179
+ } catch (error) {
180
+ entries.delete(entry)
181
+ if (entries.size === 0 && handlers.get(channel) === entries) {
182
+ handlers.delete(channel)
183
+ listeners.delete(channel)
184
+ }
185
+ if (first)
186
+ report(error, { role: 'subscriber', stage: 'subscribe', channel })
187
+ throw error
188
+ }
189
+
190
+ let left = false
191
+ return async () => {
192
+ if (left) return
193
+ left = true
194
+ const current = handlers.get(channel)
195
+ if (!current?.delete(entry) || current.size > 0) return
196
+ handlers.delete(channel)
197
+ const listener = listeners.get(channel)
198
+ listeners.delete(channel)
199
+ const connection = subscriber
200
+ try {
201
+ if (listener) await connection?.unsubscribe(channel, listener)
202
+ } catch (error) {
203
+ // close() rejects in-flight commands; once this connection is no
204
+ // longer current the channel is already gone.
205
+ if (subscriber !== connection) return
206
+ report(error, { role: 'subscriber', stage: 'unsubscribe', channel })
207
+ }
208
+ }
209
+ }
210
+
211
+ function close() {
212
+ const connections = [client, subscriber]
213
+ client = null
214
+ subscriber = null
215
+ handlers.clear()
216
+ listeners.clear()
217
+ subscribing.clear()
218
+ for (const connection of connections) {
219
+ try {
220
+ connection?.close()
221
+ } catch {
222
+ // Already closed.
223
+ }
224
+ }
225
+ }
226
+
227
+ return {
228
+ getClient,
229
+ subscribe,
230
+ isConnected: () => client?.connected === true,
231
+ getStats: () => ({
232
+ channelCount: handlers.size,
233
+ channels: [...handlers.keys()],
234
+ isConnected: client?.connected === true,
235
+ isSubscriberConnected: subscriber?.connected === true
236
+ }),
237
+ close
238
+ }
239
+ }