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/src/promise/index.ts
CHANGED
|
@@ -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
|
+
}
|