@strav/broadcast 1.0.0-alpha.29 → 1.0.0-alpha.30
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/broadcast",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.30",
|
|
4
4
|
"description": "Strav broadcast / pub-sub primitive — Broadcaster interface + MemoryBroadcaster (in-process) + PostgresBroadcaster (polling-ledger backplane) + per-channel authorization. Pairs with @strav/http's router.sse and @strav/notification/broadcast.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"exports": {
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./memory": "./src/drivers/memory/index.ts",
|
|
11
|
-
"./postgres": "./src/drivers/postgres/index.ts"
|
|
11
|
+
"./postgres": "./src/drivers/postgres/index.ts",
|
|
12
|
+
"./redis": "./src/drivers/redis/index.ts"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src",
|
|
@@ -21,10 +22,10 @@
|
|
|
21
22
|
"access": "public"
|
|
22
23
|
},
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
25
|
+
"@strav/kernel": "1.0.0-alpha.30"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
|
-
"@strav/database": "1.0.0-alpha.
|
|
28
|
+
"@strav/database": "1.0.0-alpha.30",
|
|
28
29
|
"@types/bun": ">=1.3.14"
|
|
29
30
|
},
|
|
30
31
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RedisBroadcastProvider` — wires `RedisBroadcaster` under the
|
|
3
|
+
* `Broadcaster` token. Apps register this INSTEAD OF `BroadcastProvider`
|
|
4
|
+
* (or `PostgresBroadcastProvider`) to use Redis Pub/Sub as the
|
|
5
|
+
* multi-node backplane.
|
|
6
|
+
*
|
|
7
|
+
* Reads `config.broadcast` for the connection URL + buffer knobs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
|
|
11
|
+
import { BroadcastConfigError } from '../../broadcast_error.ts'
|
|
12
|
+
import { Broadcaster } from '../../broadcaster.ts'
|
|
13
|
+
import { RedisBroadcaster, type RedisBroadcasterOptions } from './redis_broadcaster.ts'
|
|
14
|
+
|
|
15
|
+
export interface RedisBroadcastConfig extends Omit<RedisBroadcasterOptions, 'pub' | 'sub'> {
|
|
16
|
+
driver: 'redis'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class RedisBroadcastProvider extends ServiceProvider {
|
|
20
|
+
override readonly name = 'broadcast'
|
|
21
|
+
override readonly dependencies = ['config']
|
|
22
|
+
|
|
23
|
+
override register(app: Application): void {
|
|
24
|
+
app.singleton(Broadcaster, (c) => {
|
|
25
|
+
const cfg = c.resolve(ConfigRepository).get('broadcast') as RedisBroadcastConfig | undefined
|
|
26
|
+
if (cfg === undefined || cfg.url === undefined || cfg.url === '') {
|
|
27
|
+
throw new BroadcastConfigError(
|
|
28
|
+
'RedisBroadcastProvider: `config.broadcast.url` is required (e.g. `redis://127.0.0.1:6379`).',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return new RedisBroadcaster({
|
|
32
|
+
url: cfg.url,
|
|
33
|
+
...(cfg.maxBufferSize !== undefined ? { maxBufferSize: cfg.maxBufferSize } : {}),
|
|
34
|
+
...(cfg.onOverflow !== undefined ? { onOverflow: cfg.onOverflow } : {}),
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override async boot(app: Application): Promise<void> {
|
|
40
|
+
app.resolve(Broadcaster)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override async shutdown(app: Application): Promise<void> {
|
|
44
|
+
await app.resolve(Broadcaster).close()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RedisBroadcaster` — multi-node broadcast backplane via Redis Pub/Sub.
|
|
3
|
+
*
|
|
4
|
+
* Why Redis (vs the Postgres ledger):
|
|
5
|
+
*
|
|
6
|
+
* - End-to-end latency is one network hop — no polling floor.
|
|
7
|
+
* - The Postgres driver is fine up to a few hundred messages/sec on
|
|
8
|
+
* small clusters; once subscribers fan out wider or publish rates
|
|
9
|
+
* climb, Redis is the obvious next step. Apps deploying behind a
|
|
10
|
+
* reverse proxy with sticky sessions and a small node count can
|
|
11
|
+
* stay on Postgres; everyone else picks this.
|
|
12
|
+
*
|
|
13
|
+
* How it works:
|
|
14
|
+
*
|
|
15
|
+
* - Two Bun `RedisClient` instances — one for publish, one for
|
|
16
|
+
* subscribe. Bun's client enters a sticky pub/sub mode after
|
|
17
|
+
* `subscribe(...)`, blocking most other commands until
|
|
18
|
+
* `unsubscribe()`. The split keeps publishes from being gated on
|
|
19
|
+
* the subscribe-mode lock.
|
|
20
|
+
* - `publish(channel, event)` JSON-encodes the event and calls
|
|
21
|
+
* `pub.publish(channel, payload)`.
|
|
22
|
+
* - `subscribe(channel)` opens a local subscription via an embedded
|
|
23
|
+
* `MemoryBroadcaster` and, on the first subscriber for a channel,
|
|
24
|
+
* issues a single `sub.subscribe(channel, listener)` upstream. The
|
|
25
|
+
* listener decodes the JSON payload and fans it out locally. When
|
|
26
|
+
* the last subscriber for a channel goes away, the upstream
|
|
27
|
+
* subscription is released via `sub.unsubscribe(channel)`.
|
|
28
|
+
* - Subscribers always start from "events published from now on" —
|
|
29
|
+
* same contract as `PostgresBroadcaster`. Apps that need replay
|
|
30
|
+
* wire a separate ledger (the Postgres driver, or a custom
|
|
31
|
+
* stream-backed one).
|
|
32
|
+
*
|
|
33
|
+
* Both `RedisBroadcastProvider` and `BroadcastProvider` register under
|
|
34
|
+
* the same `Broadcaster` token, so app code injecting `Broadcaster`
|
|
35
|
+
* doesn't change between drivers.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { BroadcastPublishError } from '../../broadcast_error.ts'
|
|
39
|
+
import { Broadcaster } from '../../broadcaster.ts'
|
|
40
|
+
import type { BroadcastEvent, BroadcastSubscription } from '../../types.ts'
|
|
41
|
+
import { MemoryBroadcaster } from '../memory/memory_broadcaster.ts'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Minimal slice of `Bun.RedisClient` the broadcaster actually uses.
|
|
45
|
+
* Declared inline so a custom client can be injected for tests without
|
|
46
|
+
* standing up a real Redis.
|
|
47
|
+
*/
|
|
48
|
+
export interface RedisBroadcasterClient {
|
|
49
|
+
publish(channel: string, message: string): Promise<number>
|
|
50
|
+
subscribe(channel: string, listener: (message: string, channel: string) => void): Promise<number>
|
|
51
|
+
unsubscribe(channel: string): Promise<void>
|
|
52
|
+
close(): void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RedisBroadcasterOptions {
|
|
56
|
+
/**
|
|
57
|
+
* Redis connection URL — `redis://host:port` or `rediss://…` for TLS.
|
|
58
|
+
* Required unless `pub` and `sub` are both injected directly.
|
|
59
|
+
*/
|
|
60
|
+
url?: string
|
|
61
|
+
/** Custom publisher client — usually only set in tests. */
|
|
62
|
+
pub?: RedisBroadcasterClient
|
|
63
|
+
/** Custom subscriber client — usually only set in tests. */
|
|
64
|
+
sub?: RedisBroadcasterClient
|
|
65
|
+
/**
|
|
66
|
+
* Per-subscription buffer cap forwarded to the in-process
|
|
67
|
+
* `MemoryBroadcaster`. Default `1000`.
|
|
68
|
+
*/
|
|
69
|
+
maxBufferSize?: number
|
|
70
|
+
/** Forwarded to the in-process `MemoryBroadcaster`'s onOverflow hook. */
|
|
71
|
+
onOverflow?: (channel: string, droppedEvent: BroadcastEvent) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class RedisBroadcaster extends Broadcaster {
|
|
75
|
+
private readonly pub: RedisBroadcasterClient
|
|
76
|
+
private readonly sub: RedisBroadcasterClient
|
|
77
|
+
private readonly ownsClients: boolean
|
|
78
|
+
private readonly local: MemoryBroadcaster
|
|
79
|
+
private readonly upstream = new Map<string, Promise<void>>()
|
|
80
|
+
private readonly subscribed = new Set<string>()
|
|
81
|
+
private closed = false
|
|
82
|
+
|
|
83
|
+
constructor(options: RedisBroadcasterOptions) {
|
|
84
|
+
super()
|
|
85
|
+
if (options.pub !== undefined || options.sub !== undefined) {
|
|
86
|
+
if (options.pub === undefined || options.sub === undefined) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'RedisBroadcaster: when injecting clients, both `pub` and `sub` must be provided.',
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
this.pub = options.pub
|
|
92
|
+
this.sub = options.sub
|
|
93
|
+
this.ownsClients = false
|
|
94
|
+
} else {
|
|
95
|
+
if (options.url === undefined || options.url === '') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
'RedisBroadcaster: `url` is required (e.g. `redis://127.0.0.1:6379`) unless `pub`/`sub` are injected.',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
// Deferred import — keeps `bun` off the lookup path when callers
|
|
101
|
+
// inject their own clients (e.g. tests). The runtime import
|
|
102
|
+
// resolves to Bun's built-in `RedisClient`.
|
|
103
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun global types vary by version
|
|
104
|
+
const { RedisClient } = require('bun') as { RedisClient: new (url: string) => any }
|
|
105
|
+
this.pub = new RedisClient(options.url) as RedisBroadcasterClient
|
|
106
|
+
this.sub = new RedisClient(options.url) as RedisBroadcasterClient
|
|
107
|
+
this.ownsClients = true
|
|
108
|
+
}
|
|
109
|
+
this.local = new MemoryBroadcaster({
|
|
110
|
+
...(options.maxBufferSize !== undefined ? { maxBufferSize: options.maxBufferSize } : {}),
|
|
111
|
+
...(options.onOverflow !== undefined ? { onOverflow: options.onOverflow } : {}),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override async publish(channel: string, event: BroadcastEvent): Promise<void> {
|
|
116
|
+
let payload: string
|
|
117
|
+
try {
|
|
118
|
+
payload = JSON.stringify(event)
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
throw new BroadcastPublishError('RedisBroadcaster: event is not JSON-serialisable.', {
|
|
121
|
+
context: { channel, event: event.event },
|
|
122
|
+
cause,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await this.pub.publish(channel, payload)
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
throw new BroadcastPublishError('RedisBroadcaster: PUBLISH failed.', {
|
|
129
|
+
context: { channel, event: event.event },
|
|
130
|
+
cause,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
override subscribe(channel: string): BroadcastSubscription {
|
|
136
|
+
const localSub = this.local.subscribe(channel)
|
|
137
|
+
void this.ensureUpstream(channel)
|
|
138
|
+
|
|
139
|
+
const dropIfLast = async (): Promise<void> => {
|
|
140
|
+
if (this.closed) return
|
|
141
|
+
if (this.local.subscriberCount(channel) > 0) return
|
|
142
|
+
if (!this.subscribed.has(channel)) return
|
|
143
|
+
this.subscribed.delete(channel)
|
|
144
|
+
this.upstream.delete(channel)
|
|
145
|
+
try {
|
|
146
|
+
await this.sub.unsubscribe(channel)
|
|
147
|
+
} catch {
|
|
148
|
+
// Best-effort — a transient client error here shouldn't break
|
|
149
|
+
// consumers. The next subscribe() re-issues the upstream call.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const wrapped: BroadcastSubscription = {
|
|
154
|
+
[Symbol.asyncIterator](): AsyncIterableIterator<BroadcastEvent> {
|
|
155
|
+
return wrapped
|
|
156
|
+
},
|
|
157
|
+
next: () => localSub.next(),
|
|
158
|
+
async return(): Promise<IteratorResult<BroadcastEvent>> {
|
|
159
|
+
const result = await (localSub.return?.() ??
|
|
160
|
+
Promise.resolve({ value: undefined, done: true as const }))
|
|
161
|
+
await dropIfLast()
|
|
162
|
+
return result
|
|
163
|
+
},
|
|
164
|
+
async unsubscribe(): Promise<void> {
|
|
165
|
+
await localSub.unsubscribe()
|
|
166
|
+
await dropIfLast()
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
return wrapped
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
override async close(): Promise<void> {
|
|
173
|
+
if (this.closed) return
|
|
174
|
+
this.closed = true
|
|
175
|
+
for (const channel of this.subscribed) {
|
|
176
|
+
try {
|
|
177
|
+
await this.sub.unsubscribe(channel)
|
|
178
|
+
} catch {
|
|
179
|
+
// Same rationale as dropIfLast — best-effort cleanup.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.subscribed.clear()
|
|
183
|
+
this.upstream.clear()
|
|
184
|
+
await this.local.close()
|
|
185
|
+
if (this.ownsClients) {
|
|
186
|
+
try {
|
|
187
|
+
this.pub.close()
|
|
188
|
+
} catch {
|
|
189
|
+
// Already closed or never connected.
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
this.sub.close()
|
|
193
|
+
} catch {
|
|
194
|
+
// Same.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @internal — diagnostics for tests. */
|
|
200
|
+
upstreamSubscribed(channel: string): boolean {
|
|
201
|
+
return this.subscribed.has(channel)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async ensureUpstream(channel: string): Promise<void> {
|
|
205
|
+
if (this.closed) return
|
|
206
|
+
const existing = this.upstream.get(channel)
|
|
207
|
+
if (existing !== undefined) return existing
|
|
208
|
+
const promise = (async (): Promise<void> => {
|
|
209
|
+
await this.sub.subscribe(channel, (message: string, ch: string) => {
|
|
210
|
+
if (this.closed) return
|
|
211
|
+
let event: BroadcastEvent
|
|
212
|
+
try {
|
|
213
|
+
event = JSON.parse(message) as BroadcastEvent
|
|
214
|
+
} catch {
|
|
215
|
+
// Non-JSON traffic on a Strav channel is most likely a third
|
|
216
|
+
// party sharing the Redis instance. Drop silently rather
|
|
217
|
+
// than dying — apps that care wire a Redis client themselves.
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
void this.local.publish(ch, event)
|
|
221
|
+
})
|
|
222
|
+
this.subscribed.add(channel)
|
|
223
|
+
})()
|
|
224
|
+
this.upstream.set(channel, promise)
|
|
225
|
+
try {
|
|
226
|
+
await promise
|
|
227
|
+
} catch {
|
|
228
|
+
// Subscribe failure: clear so the next subscribe() retries. The
|
|
229
|
+
// local subscription is still live; it just won't receive events
|
|
230
|
+
// until upstream is healthy.
|
|
231
|
+
this.upstream.delete(channel)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|