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
@@ -0,0 +1,591 @@
1
+ import { createLuaScript, type RedisSend } from './lua-script'
2
+
3
+ const POLL_MS = 100
4
+ const FAILURE_LOG_THROTTLE_MS = 10_000
5
+ const DEFAULT_FAIL_OPEN_AFTER_MS = 15_000
6
+ const DEFAULT_COMMAND_TIMEOUT_MS = 3_000
7
+ const START_WINDOW_MS = 1_000
8
+ const MAX_COOLDOWN_MS = 5 * 60 * 1_000
9
+ const BREAKER_PROBE_INTERVAL_MS = 5_000
10
+ const MAX_RENEW_BATCH = 200
11
+
12
+ /**
13
+ * Redis owns the clock. Pod clocks drift, and a client whose clock runs fast
14
+ * would evict slots that are still held - breaching the cap it exists to guard.
15
+ */
16
+ const NOW_MS_LUA = `
17
+ local time = redis.call('TIME')
18
+ local now = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)
19
+ `
20
+
21
+ /**
22
+ * Grants slots to as many of `ARGV[3..]` as fit under the cap, in order, and
23
+ * reports the shared cooldown in the same round trip. Callers hand the whole
24
+ * local waiting queue to one call, so Redis traffic tracks the cap rather than
25
+ * the number of waiters.
26
+ *
27
+ * Returns `{grantedCount, waitMs}` - `waitMs > 0` means a cooldown is active.
28
+ */
29
+ const ACQUIRE_SCRIPT = `
30
+ local inflightKey = KEYS[1]
31
+ local cooldownKey = KEYS[2]
32
+ local limit = tonumber(ARGV[1])
33
+ local ttlMs = tonumber(ARGV[2])
34
+ local cooldownMs = redis.call('PTTL', cooldownKey)
35
+ if cooldownMs > 0 then
36
+ return {0, cooldownMs}
37
+ end
38
+ ${NOW_MS_LUA}
39
+ redis.call('ZREMRANGEBYSCORE', inflightKey, '-inf', now)
40
+ local free = limit - redis.call('ZCARD', inflightKey)
41
+ local granted = 0
42
+ while granted < free and (granted + 3) <= #ARGV do
43
+ redis.call('ZADD', inflightKey, now + ttlMs, ARGV[granted + 3])
44
+ granted = granted + 1
45
+ end
46
+ if granted > 0 then
47
+ redis.call('PEXPIRE', inflightKey, ttlMs)
48
+ end
49
+ return {granted, 0}
50
+ `.trim()
51
+
52
+ /**
53
+ * Sliding window of *starts*. Unlike the slot cap, the window drains on a
54
+ * known schedule, so an ungranted caller is told exactly when the oldest start
55
+ * ages out instead of being left to poll.
56
+ */
57
+ const START_SCRIPT = `
58
+ local startsKey = KEYS[1]
59
+ local cooldownKey = KEYS[2]
60
+ local limit = tonumber(ARGV[1])
61
+ local windowMs = tonumber(ARGV[2])
62
+ local cooldownMs = redis.call('PTTL', cooldownKey)
63
+ if cooldownMs > 0 then
64
+ return {0, cooldownMs}
65
+ end
66
+ ${NOW_MS_LUA}
67
+ redis.call('ZREMRANGEBYSCORE', startsKey, '-inf', now - windowMs)
68
+ local free = limit - redis.call('ZCARD', startsKey)
69
+ local granted = 0
70
+ while granted < free and (granted + 3) <= #ARGV do
71
+ redis.call('ZADD', startsKey, now, ARGV[granted + 3])
72
+ granted = granted + 1
73
+ end
74
+ if granted > 0 then
75
+ redis.call('PEXPIRE', startsKey, windowMs)
76
+ end
77
+ local waitMs = 0
78
+ if granted < (#ARGV - 2) then
79
+ local oldest = redis.call('ZRANGE', startsKey, 0, 0, 'WITHSCORES')
80
+ if oldest[2] then
81
+ waitMs = math.ceil(tonumber(oldest[2]) + windowMs - now)
82
+ if waitMs < 0 then
83
+ waitMs = 0
84
+ end
85
+ end
86
+ end
87
+ return {granted, waitMs}
88
+ `.trim()
89
+
90
+ /**
91
+ * Renews every slot this process still holds in one call. A member that has
92
+ * already been evicted is left alone: re-adding it would breach the cap that
93
+ * eviction just restored.
94
+ */
95
+ const RENEW_SCRIPT = `
96
+ local key = KEYS[1]
97
+ local ttlMs = tonumber(ARGV[1])
98
+ ${NOW_MS_LUA}
99
+ local renewed = 0
100
+ for i = 2, #ARGV do
101
+ if redis.call('ZSCORE', key, ARGV[i]) then
102
+ redis.call('ZADD', key, now + ttlMs, ARGV[i])
103
+ renewed = renewed + 1
104
+ end
105
+ end
106
+ if renewed > 0 then
107
+ redis.call('PEXPIRE', key, ttlMs)
108
+ end
109
+ return renewed
110
+ `.trim()
111
+
112
+ const TRIP_COOLDOWN_SCRIPT = `
113
+ local key = KEYS[1]
114
+ local waitMs = tonumber(ARGV[1])
115
+ local ttl = redis.call('PTTL', key)
116
+ if ttl < waitMs then
117
+ redis.call('SET', key, '1', 'PX', ARGV[1])
118
+ end
119
+ return 1
120
+ `.trim()
121
+
122
+ function withCommandTimeout(send: RedisSend, timeoutMs: number): RedisSend {
123
+ return async (command, args) => {
124
+ let timer: ReturnType<typeof setTimeout> | undefined
125
+ try {
126
+ return await Promise.race([
127
+ send(command, args),
128
+ new Promise<never>((_, reject) => {
129
+ timer = setTimeout(
130
+ () =>
131
+ reject(new Error(`Redis command timed out after ${timeoutMs}ms`)),
132
+ timeoutMs
133
+ )
134
+ })
135
+ ])
136
+ } finally {
137
+ if (timer) clearTimeout(timer)
138
+ }
139
+ }
140
+ }
141
+
142
+ // The pid tells a human which process holds a slot; it is absent outside Node and Bun.
143
+ function createToken() {
144
+ const pid = globalThis.process?.pid
145
+ return pid === undefined
146
+ ? crypto.randomUUID()
147
+ : `${pid}:${crypto.randomUUID()}`
148
+ }
149
+
150
+ function jitterMs() {
151
+ return Math.floor(Math.random() * POLL_MS)
152
+ }
153
+
154
+ // Background timers must not keep a process alive; `unref` is Node/Bun only.
155
+ function unref(timer: ReturnType<typeof setTimeout>) {
156
+ ;(timer as { unref?: () => void }).unref?.()
157
+ }
158
+
159
+ type GrantReply = { granted: number; waitMs: number }
160
+
161
+ function parseGrantReply(reply: unknown): GrantReply {
162
+ const [granted, waitMs] = Array.isArray(reply) ? reply : []
163
+ return {
164
+ granted: Math.max(0, Number(granted) || 0),
165
+ waitMs: Math.min(MAX_COOLDOWN_MS, Math.max(0, Number(waitMs) || 0))
166
+ }
167
+ }
168
+
169
+ export type ConcurrencyLimitStage = 'slot' | 'start'
170
+
171
+ export type RedisConcurrencyLimitOptions = {
172
+ key: string
173
+ max: number
174
+ slotTtlMs: number
175
+ send: RedisSend
176
+ maxStartsPerSecond?: number
177
+ failOpenAfterMs?: number
178
+ commandTimeoutMs?: number
179
+ /** Receives throttled warnings. Defaults to `console`. */
180
+ logger?: Pick<Console, 'warn'>
181
+ /** Called when a caller starts queueing; the returned function runs once it stops. */
182
+ onWait?: (stage: ConcurrencyLimitStage) => (() => void) | void
183
+ }
184
+
185
+ export type RedisConcurrencyLimit = {
186
+ <T>(run: () => Promise<T>, signal?: AbortSignal): Promise<T>
187
+ tripCooldown: (retryAfterMs: number) => Promise<void>
188
+ /** Callers in this process waiting for a slot or a start, e.g. for a gauge. */
189
+ queueDepth: () => number
190
+ /** Drop this limiter's timers. See `createRedisConcurrencyLimit`. */
191
+ dispose: () => void
192
+ }
193
+
194
+ /**
195
+ * Redis semaphore shared by every process. Returns `withLimit(run)` - at most
196
+ * `max` callbacks in flight for `key`.
197
+ *
198
+ * A held slot is heartbeated for as long as `run` lasts, so `slotTtlMs` is a
199
+ * crash-detection window rather than a run budget: slots leaked by a killed pod
200
+ * free themselves within one TTL, while slow-but-alive work keeps its slot.
201
+ *
202
+ * `maxStartsPerSecond` is a separate sliding 1s window of *starts*. Use it when
203
+ * the provider quota is requests-per-second (Cloudflare Quick Actions) so
204
+ * in-flight concurrency can sit higher than the start rate.
205
+ *
206
+ * `tripCooldown` writes a shared pause from an upstream `Retry-After` so every
207
+ * process backs off together instead of stampeding into 429s.
208
+ *
209
+ * Waiters inside one process queue locally and are handed to Redis in a single
210
+ * batched call, so a pod with hundreds of callers costs the same Redis traffic
211
+ * as one with a handful.
212
+ *
213
+ * When Redis itself is unreachable for `failOpenAfterMs` the limiter gives up
214
+ * and runs uncapped. Callers guard a third-party quota; degrading to the
215
+ * provider's own rate limit beats hanging every caller until Redis returns.
216
+ * That verdict is shared per limiter: once tripped, later calls degrade
217
+ * immediately while a background probe watches for Redis to come back.
218
+ *
219
+ * `onWait` and `queueDepth` feed metrics. A limiter minted per tenant or per
220
+ * customer host should report under a shared pool label rather than its key,
221
+ * or every key ever touched leaves a permanent series. Call `dispose()` when
222
+ * such a limiter is discarded, and unregister any gauge reading `queueDepth`.
223
+ *
224
+ * @example
225
+ * const withLimit = createRedisConcurrencyLimit({
226
+ * key: 'openai',
227
+ * max: 20,
228
+ * slotTtlMs: 30_000,
229
+ * send: (command, args) => redis.getClient().send(command, args)
230
+ * })
231
+ * const reply = await withLimit(() => openai.chat(request), signal)
232
+ */
233
+ export function createRedisConcurrencyLimit(
234
+ input: RedisConcurrencyLimitOptions
235
+ ): RedisConcurrencyLimit {
236
+ if (!Number.isInteger(input.max) || input.max < 1)
237
+ throw new Error(`max must be a positive integer, got ${input.max}`)
238
+ const logger = input.logger ?? console
239
+ const commandTimeoutMs = input.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
240
+ const send = withCommandTimeout(input.send, commandTimeoutMs)
241
+ const ttlMs = String(input.slotTtlMs)
242
+ const renewEveryMs = Math.max(100, Math.floor(input.slotTtlMs / 3))
243
+ const failOpenAfterMs = input.failOpenAfterMs ?? DEFAULT_FAIL_OPEN_AFTER_MS
244
+ const maxStartsPerSecond = input.maxStartsPerSecond
245
+
246
+ // Hash tags keep the multi-key scripts on one slot under Redis Cluster.
247
+ // Renaming the inflight key means a rolling deploy briefly enforces the cap
248
+ // twice over; that resolves within one `slotTtlMs`.
249
+ const inflightKey = `{${input.key}}:inflight`
250
+ const startsKey = `{${input.key}}:starts`
251
+ const cooldownKey = `{${input.key}}:cooldown`
252
+
253
+ const acquireScript = createLuaScript(send, ACQUIRE_SCRIPT)
254
+ const startScript = createLuaScript(send, START_SCRIPT)
255
+ const renewScript = createLuaScript(send, RENEW_SCRIPT)
256
+ const tripScript = createLuaScript(send, TRIP_COOLDOWN_SCRIPT)
257
+
258
+ const lastFailureLogAtMs = new Map<string, number>()
259
+
260
+ function logFailure(message: string, error: unknown) {
261
+ const nowMs = Date.now()
262
+ if (
263
+ nowMs - (lastFailureLogAtMs.get(message) ?? 0) <
264
+ FAILURE_LOG_THROTTLE_MS
265
+ )
266
+ return
267
+ lastFailureLogAtMs.set(message, nowMs)
268
+ logger.warn(message, { key: input.key, error })
269
+ }
270
+
271
+ // ---------------------------------------------------------------- breaker
272
+
273
+ let firstFailureAtMs: number | null = null
274
+ let degraded = false
275
+ let probeTimer: ReturnType<typeof setTimeout> | null = null
276
+
277
+ function scheduleProbe() {
278
+ if (probeTimer) return
279
+ probeTimer = setTimeout(() => {
280
+ probeTimer = null
281
+ void probeRedis()
282
+ }, BREAKER_PROBE_INTERVAL_MS)
283
+ unref(probeTimer)
284
+ }
285
+
286
+ /** A zero-token acquire reads the cap without taking a slot. */
287
+ async function probeRedis() {
288
+ try {
289
+ await acquireScript(
290
+ [inflightKey, cooldownKey],
291
+ [String(input.max), ttlMs]
292
+ )
293
+ noteRedisSuccess()
294
+ } catch (error) {
295
+ logFailure('Redis concurrency probe failed', error)
296
+ scheduleProbe()
297
+ }
298
+ }
299
+
300
+ function noteRedisSuccess() {
301
+ firstFailureAtMs = null
302
+ if (!degraded) return
303
+ degraded = false
304
+ if (probeTimer) {
305
+ clearTimeout(probeTimer)
306
+ probeTimer = null
307
+ }
308
+ logger.warn('Redis reachable again, concurrency limiting re-enabled', {
309
+ key: input.key
310
+ })
311
+ }
312
+
313
+ /** True once Redis has been down long enough that callers should run uncapped. */
314
+ function noteRedisFailure(error: unknown) {
315
+ logFailure('Redis concurrency command failed, retrying', error)
316
+ if (degraded) return true
317
+ firstFailureAtMs ??= Date.now()
318
+ if (Date.now() - firstFailureAtMs < failOpenAfterMs) return false
319
+ degraded = true
320
+ scheduleProbe()
321
+ logger.warn('Redis unreachable, running without a concurrency slot', {
322
+ key: input.key
323
+ })
324
+ return true
325
+ }
326
+
327
+ // ------------------------------------------------------------------- pump
328
+
329
+ type Waiter = { token: string; settle: (grantedSlot: boolean) => void }
330
+
331
+ /**
332
+ * One loop per limiter drains a local FIFO: waiters are batched into a single
333
+ * script call, granted in order, and the remainder waits out the hint the
334
+ * script returned. Jitter keeps the fleet from retrying in lockstep after a
335
+ * shared cooldown expires.
336
+ */
337
+ function createPump(
338
+ stage: ConcurrencyLimitStage,
339
+ maxBatch: number,
340
+ grant: (tokens: string[]) => Promise<GrantReply>
341
+ ) {
342
+ const queue: Waiter[] = []
343
+ let running = false
344
+ let wakeSleeper: (() => void) | null = null
345
+
346
+ // Deliberately not unref'd: a caller is awaiting this sleep, so it has to
347
+ // hold the event loop open the way `Bun.sleep` would.
348
+ function sleep(ms: number) {
349
+ return new Promise<void>((resolve) => {
350
+ const timer = setTimeout(() => {
351
+ wakeSleeper = null
352
+ resolve()
353
+ }, ms)
354
+ wakeSleeper = () => {
355
+ wakeSleeper = null
356
+ clearTimeout(timer)
357
+ resolve()
358
+ }
359
+ })
360
+ }
361
+
362
+ function settleAll(grantedSlot: boolean) {
363
+ for (const waiter of queue.splice(0)) waiter.settle(grantedSlot)
364
+ }
365
+
366
+ async function loop() {
367
+ running = true
368
+ try {
369
+ // Callers usually arrive as a synchronous burst (one `map` over a page
370
+ // list). Yielding once lets the whole burst land in the first batch.
371
+ await Promise.resolve()
372
+ while (queue.length > 0) {
373
+ let reply: GrantReply
374
+ try {
375
+ reply = await grant(
376
+ queue.slice(0, maxBatch).map((waiter) => waiter.token)
377
+ )
378
+ noteRedisSuccess()
379
+ } catch (error) {
380
+ if (noteRedisFailure(error)) {
381
+ settleAll(false)
382
+ continue
383
+ }
384
+ await sleep(POLL_MS + jitterMs())
385
+ continue
386
+ }
387
+
388
+ for (const waiter of queue.splice(0, reply.granted))
389
+ waiter.settle(true)
390
+ if (queue.length === 0) break
391
+ await sleep((reply.waitMs || POLL_MS) + jitterMs())
392
+ }
393
+ } finally {
394
+ running = false
395
+ }
396
+ }
397
+
398
+ return {
399
+ depth: () => queue.length,
400
+ /** Resolves true once the token holds a slot, false when failing open. */
401
+ acquire(token: string) {
402
+ const stopWait = input.onWait?.(stage)
403
+ const granted = new Promise<boolean>((resolve) => {
404
+ queue.push({ token, settle: resolve })
405
+ })
406
+ // Deliberately no wake here: a burst of arrivals must not turn the
407
+ // poll interval into a spin. Only a freed slot cuts the wait short.
408
+ if (!running) void loop()
409
+ return stopWait ? granted.finally(stopWait) : granted
410
+ },
411
+ /** Retry now - a slot this process held just came free. */
412
+ wake() {
413
+ wakeSleeper?.()
414
+ }
415
+ }
416
+ }
417
+
418
+ const slotPump = createPump('slot', input.max, async (tokens) =>
419
+ parseGrantReply(
420
+ await acquireScript(
421
+ [inflightKey, cooldownKey],
422
+ [String(input.max), ttlMs, ...tokens]
423
+ )
424
+ )
425
+ )
426
+
427
+ const startPump =
428
+ maxStartsPerSecond === undefined
429
+ ? null
430
+ : createPump('start', maxStartsPerSecond, async (tokens) =>
431
+ parseGrantReply(
432
+ await startScript(
433
+ [startsKey, cooldownKey],
434
+ [String(maxStartsPerSecond), String(START_WINDOW_MS), ...tokens]
435
+ )
436
+ )
437
+ )
438
+
439
+ const queueDepth = () => slotPump.depth() + (startPump?.depth() ?? 0)
440
+
441
+ // -------------------------------------------------------------- heartbeat
442
+
443
+ const heldTokens = new Set<string>()
444
+ let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
445
+
446
+ function scheduleHeartbeat() {
447
+ if (heartbeatTimer || heldTokens.size === 0) return
448
+ heartbeatTimer = setTimeout(() => {
449
+ heartbeatTimer = null
450
+ void renewHeldSlots()
451
+ }, renewEveryMs)
452
+ unref(heartbeatTimer)
453
+ }
454
+
455
+ /** One call covers every slot this process holds, however many are in flight. */
456
+ async function renewHeldSlots() {
457
+ const tokens = [...heldTokens]
458
+ for (let index = 0; index < tokens.length; index += MAX_RENEW_BATCH) {
459
+ try {
460
+ await renewScript(
461
+ [inflightKey],
462
+ [ttlMs, ...tokens.slice(index, index + MAX_RENEW_BATCH)]
463
+ )
464
+ noteRedisSuccess()
465
+ } catch (error) {
466
+ logFailure('Failed to renew Redis concurrency slots', error)
467
+ }
468
+ }
469
+ scheduleHeartbeat()
470
+ }
471
+
472
+ // ---------------------------------------------------------------- release
473
+
474
+ let pendingReleases: string[] = []
475
+ let releaseFlush: Promise<void> | null = null
476
+
477
+ /** Slots freed in the same tick leave in one `ZREM`. */
478
+ function release(token: string) {
479
+ pendingReleases.push(token)
480
+ releaseFlush ??= (async () => {
481
+ await Promise.resolve()
482
+ const tokens = pendingReleases
483
+ pendingReleases = []
484
+ releaseFlush = null
485
+ try {
486
+ // Redis drops the sorted set once the last member leaves, so no
487
+ // explicit key cleanup is needed here.
488
+ await send('ZREM', [inflightKey, ...tokens])
489
+ noteRedisSuccess()
490
+ } catch (error) {
491
+ logFailure('Failed to release Redis concurrency slot', error)
492
+ }
493
+ slotPump.wake()
494
+ })()
495
+ return releaseFlush
496
+ }
497
+
498
+ async function tripCooldown(retryAfterMs: number) {
499
+ const waitMs = Math.min(
500
+ MAX_COOLDOWN_MS,
501
+ Math.max(0, Math.ceil(retryAfterMs))
502
+ )
503
+ if (waitMs === 0) return
504
+ try {
505
+ await tripScript([cooldownKey], [String(waitMs)])
506
+ } catch (error) {
507
+ logFailure('Failed to trip Redis rate-limit cooldown', error)
508
+ }
509
+ }
510
+
511
+ /** Abort promptly; a Redis grant already in flight must still be released. */
512
+ async function waitForGrant(
513
+ pending: Promise<boolean>,
514
+ signal: AbortSignal | undefined,
515
+ lateGrant: () => void
516
+ ) {
517
+ if (!signal) return pending
518
+ let aborted = false
519
+ let abort: (() => void) | undefined
520
+ try {
521
+ return await Promise.race([
522
+ pending.then((granted) => {
523
+ if (aborted && granted) lateGrant()
524
+ return granted
525
+ }),
526
+ new Promise<never>((_, reject) => {
527
+ abort = () => {
528
+ aborted = true
529
+ reject(signal.reason)
530
+ }
531
+ signal.addEventListener('abort', abort, { once: true })
532
+ if (signal.aborted) abort()
533
+ })
534
+ ])
535
+ } finally {
536
+ if (abort) signal.removeEventListener('abort', abort)
537
+ }
538
+ }
539
+
540
+ async function withRedisConcurrencyLimit<T>(
541
+ run: () => Promise<T>,
542
+ signal?: AbortSignal
543
+ ) {
544
+ signal?.throwIfAborted()
545
+ if (degraded) return run()
546
+
547
+ const token = createToken()
548
+ const granted = await waitForGrant(slotPump.acquire(token), signal, () => {
549
+ void release(token)
550
+ })
551
+ if (!granted) {
552
+ signal?.throwIfAborted()
553
+ return run()
554
+ }
555
+
556
+ heldTokens.add(token)
557
+ scheduleHeartbeat()
558
+ try {
559
+ // A start token is never released - it ages out of the sliding window.
560
+ if (startPump)
561
+ await waitForGrant(startPump.acquire(createToken()), signal, () => {})
562
+ signal?.throwIfAborted()
563
+ return await run()
564
+ } finally {
565
+ heldTokens.delete(token)
566
+ await release(token)
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Only for limiters that are created on demand. Callers hold no slot by the
572
+ * time they discard one - `withRedisConcurrencyLimit` awaits its release
573
+ * before it resolves - so there is nothing left to heartbeat.
574
+ */
575
+ function dispose() {
576
+ if (probeTimer) {
577
+ clearTimeout(probeTimer)
578
+ probeTimer = null
579
+ }
580
+ if (heartbeatTimer) {
581
+ clearTimeout(heartbeatTimer)
582
+ heartbeatTimer = null
583
+ }
584
+ }
585
+
586
+ return Object.assign(withRedisConcurrencyLimit, {
587
+ tripCooldown,
588
+ queueDepth,
589
+ dispose
590
+ })
591
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ createManagedRedis,
3
+ type ManagedRedis,
4
+ type ManagedRedisOptions,
5
+ type RedisErrorContext,
6
+ type RedisMessageHandler,
7
+ type RedisRole
8
+ } from './client'
9
+ export {
10
+ type ConcurrencyLimitStage,
11
+ createRedisConcurrencyLimit,
12
+ type RedisConcurrencyLimit,
13
+ type RedisConcurrencyLimitOptions
14
+ } from './concurrency-limit'
15
+ export { createLuaScript, type RedisSend } from './lua-script'
@@ -0,0 +1,43 @@
1
+ /** One raw Redis command, e.g. `(command, args) => redis.getClient().send(command, args)`. */
2
+ export type RedisSend = (command: string, args: string[]) => Promise<unknown>
3
+
4
+ function isNoScriptError(error: unknown) {
5
+ const message = error instanceof Error ? error.message : String(error)
6
+ return message.includes('NOSCRIPT')
7
+ }
8
+
9
+ /**
10
+ * Ship a Lua script by SHA instead of resending its source on every call.
11
+ *
12
+ * Redis forgets its script cache on restart and on `SCRIPT FLUSH`, so a
13
+ * `NOSCRIPT` reply falls back to an inline `EVAL` (which repopulates the cache)
14
+ * and drops the stale digest.
15
+ *
16
+ * The digest is cached per instance rather than per module so each limiter -
17
+ * and each test fake - owns its own cache.
18
+ */
19
+ export function createLuaScript(send: RedisSend, source: string) {
20
+ let sha: string | null = null
21
+ let loading: Promise<string> | null = null
22
+
23
+ function load() {
24
+ loading ??= send('SCRIPT', ['LOAD', source])
25
+ .then(String)
26
+ .finally(() => {
27
+ loading = null
28
+ })
29
+ return loading
30
+ }
31
+
32
+ return async function evalScript(keys: string[], args: string[]) {
33
+ const tail = [String(keys.length), ...keys, ...args]
34
+ sha ??= await load()
35
+ try {
36
+ return await send('EVALSHA', [sha, ...tail])
37
+ } catch (error) {
38
+ if (!isNoScriptError(error)) throw error
39
+ sha = null
40
+ return await send('EVAL', [source, ...tail])
41
+ }
42
+ }
43
+ }
@@ -87,7 +87,11 @@ tokens belong in server or authenticated upload steps, outside this key list.
87
87
  | `cli` | Uses the signed-in CLI session even when client credentials exist. |
88
88
 
89
89
  Credentials come from `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET`.
90
- Override those names with `clientIdEnvKey` and `clientSecretEnvKey`. HTTP
90
+ Override those names with `clientIdEnvKey` and `clientSecretEnvKey`. When neither
91
+ is set, `auto` and `http` read a machine identity from
92
+ `~/.infisical/universal-auth.json` (`INFISICAL_UNIVERSAL_AUTH_FILE` overrides the
93
+ path): `{ "clientId", "clientSecret", "siteUrl" }`, used only for that `siteUrl`'s
94
+ instance. agentgate writes this file on every machine of a user. HTTP
91
95
  failures never switch to another authentication mode. If all required keys
92
96
  are already supplied by genuine environment overrides, authentication is skipped.
93
97