@strav/broadcast 1.0.0-alpha.28 → 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.28",
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.28"
25
+ "@strav/kernel": "1.0.0-alpha.30"
25
26
  },
26
27
  "peerDependencies": {
27
- "@strav/database": "1.0.0-alpha.28",
28
+ "@strav/database": "1.0.0-alpha.30",
28
29
  "@types/bun": ">=1.3.14"
29
30
  },
30
31
  "peerDependenciesMeta": {
@@ -0,0 +1,9 @@
1
+ export {
2
+ type RedisBroadcastConfig,
3
+ RedisBroadcastProvider,
4
+ } from './redis_broadcast_provider.ts'
5
+ export {
6
+ RedisBroadcaster,
7
+ type RedisBroadcasterClient,
8
+ type RedisBroadcasterOptions,
9
+ } from './redis_broadcaster.ts'
@@ -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
+ }