@strav/signal 0.1.0

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 ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@strav/signal",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Communication layer for the Strav framework — mail, notifications, and broadcasting",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "bun",
9
+ "framework",
10
+ "typescript",
11
+ "strav",
12
+ "mail",
13
+ "notification"
14
+ ],
15
+ "files": [
16
+ "src/",
17
+ "package.json",
18
+ "tsconfig.json",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./mail": "./src/mail/index.ts",
24
+ "./mail/*": "./src/mail/*.ts",
25
+ "./notification": "./src/notification/index.ts",
26
+ "./notification/*": "./src/notification/*.ts",
27
+ "./broadcast": "./src/broadcast/index.ts",
28
+ "./broadcast/*": "./src/broadcast/*.ts",
29
+ "./providers": "./src/providers/index.ts",
30
+ "./providers/*": "./src/providers/*.ts"
31
+ },
32
+ "peerDependencies": {
33
+ "@strav/kernel": "0.1.0",
34
+ "@strav/http": "0.1.0",
35
+ "@strav/view": "0.1.0",
36
+ "@strav/database": "0.1.0",
37
+ "@strav/queue": "0.1.0"
38
+ },
39
+ "dependencies": {
40
+ "nodemailer": "^6.10.0",
41
+ "@types/nodemailer": "^6.4.0",
42
+ "juice": "^11.0.0"
43
+ },
44
+ "scripts": {
45
+ "test": "bun test tests/",
46
+ "typecheck": "tsc --noEmit"
47
+ }
48
+ }
@@ -0,0 +1,424 @@
1
+ import type { ServerWebSocket } from 'bun'
2
+ import Context from '@stravigor/http/http/context'
3
+ import { compose } from '@stravigor/http/http/middleware'
4
+ import type { Middleware } from '@stravigor/http/http/middleware'
5
+ import type Router from '@stravigor/http/http/router'
6
+ import type { WebSocketData } from '@stravigor/http/http/router'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** Authorization callback — return true to allow subscription. */
13
+ export type AuthorizeCallback = (
14
+ ctx: Context,
15
+ params: Record<string, string>
16
+ ) => boolean | Promise<boolean>
17
+
18
+ /** Handler for client messages on a channel. */
19
+ export type MessageHandler = (
20
+ ctx: Context,
21
+ params: Record<string, string>,
22
+ data: unknown
23
+ ) => void | Promise<void>
24
+
25
+ /** Full channel configuration with authorization and message handlers. */
26
+ export interface ChannelConfig {
27
+ authorize?: AuthorizeCallback
28
+ messages?: Record<string, MessageHandler>
29
+ }
30
+
31
+ export interface BootOptions {
32
+ /** WebSocket endpoint path. Default: `/_broadcast` */
33
+ path?: string
34
+ /** Middleware to run on each WebSocket connection (e.g. session). */
35
+ middleware?: Middleware[]
36
+ /** Keepalive ping interval in ms. 0 to disable. Default: 30000 */
37
+ pingInterval?: number
38
+ }
39
+
40
+ interface ChannelDefinition {
41
+ pattern: string
42
+ regex: RegExp
43
+ paramNames: string[]
44
+ authorize?: AuthorizeCallback
45
+ messages?: Record<string, MessageHandler>
46
+ }
47
+
48
+ interface ClientConnection {
49
+ ws: ServerWebSocket<WebSocketData>
50
+ clientId: string
51
+ channels: Set<string>
52
+ ctxReady: Promise<Context>
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function parsePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
60
+ const paramNames: string[] = []
61
+ const regexStr = pattern
62
+ .replace(/\/\*(\w+)/, (_, name) => {
63
+ paramNames.push(name)
64
+ return '/(.+)'
65
+ })
66
+ .replace(/:(\w+)/g, (_, name) => {
67
+ paramNames.push(name)
68
+ return '([^/]+)'
69
+ })
70
+ return { regex: new RegExp(`^${regexStr}$`), paramNames }
71
+ }
72
+
73
+ function extractParams(names: string[], match: RegExpExecArray): Record<string, string> {
74
+ const params: Record<string, string> = {}
75
+ for (let i = 0; i < names.length; i++) {
76
+ params[names[i]!] = match[i + 1]!
77
+ }
78
+ return params
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // PendingBroadcast — fluent builder for .to().except().send()
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export interface PendingBroadcast {
86
+ /** Exclude a specific client from receiving the broadcast. */
87
+ except(clientId: string): PendingBroadcast
88
+ /** Send an event with optional data to the channel. */
89
+ send(event: string, data?: unknown): void
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // BroadcastManager
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Channel-based WebSocket broadcasting.
98
+ *
99
+ * Manages channel definitions, client connections, subscriptions,
100
+ * and message routing over a single multiplexed WebSocket endpoint.
101
+ *
102
+ * @example
103
+ * // Bootstrap
104
+ * BroadcastManager.boot(router, { middleware: [session()] })
105
+ *
106
+ * // Define channels
107
+ * BroadcastManager.channel('notifications')
108
+ * BroadcastManager.channel('chats/:id', async (ctx, { id }) => !!ctx.get('user'))
109
+ *
110
+ * // Broadcast
111
+ * BroadcastManager.to('notifications').send('alert', { text: 'Hello' })
112
+ */
113
+ export default class BroadcastManager {
114
+ private static _channels: ChannelDefinition[] = []
115
+ private static _clients = new Map<string, ClientConnection>()
116
+ private static _subscribers = new Map<string, Set<string>>()
117
+ private static _wsToClient = new WeakMap<object, string>()
118
+ private static _middleware: Middleware[] = []
119
+ private static _pingTimer: ReturnType<typeof setInterval> | null = null
120
+
121
+ /**
122
+ * Register the broadcast WebSocket endpoint on the router.
123
+ *
124
+ * @example
125
+ * BroadcastManager.boot(router, {
126
+ * middleware: [session()],
127
+ * pingInterval: 30_000,
128
+ * })
129
+ */
130
+ static boot(router: Router, options?: BootOptions): void {
131
+ const path = options?.path ?? '/_broadcast'
132
+ const pingInterval = options?.pingInterval ?? 30_000
133
+
134
+ if (options?.middleware) {
135
+ BroadcastManager._middleware = options.middleware
136
+ }
137
+
138
+ router.ws(path, {
139
+ open(ws) {
140
+ const clientId = crypto.randomUUID()
141
+ BroadcastManager._wsToClient.set(ws, clientId)
142
+
143
+ const ctxReady = BroadcastManager.buildContext(ws)
144
+ BroadcastManager._clients.set(clientId, {
145
+ ws,
146
+ clientId,
147
+ channels: new Set(),
148
+ ctxReady,
149
+ })
150
+
151
+ ws.send(JSON.stringify({ t: 'welcome', id: clientId }))
152
+ },
153
+
154
+ async message(ws, raw) {
155
+ try {
156
+ const msg = JSON.parse(raw as string)
157
+ const clientId = BroadcastManager._wsToClient.get(ws)
158
+ if (!clientId) return
159
+
160
+ const client = BroadcastManager._clients.get(clientId)
161
+ if (!client) return
162
+
163
+ switch (msg.t) {
164
+ case 'sub':
165
+ await BroadcastManager.handleSubscribe(client, msg.c)
166
+ break
167
+ case 'unsub':
168
+ BroadcastManager.handleUnsubscribe(client, msg.c)
169
+ break
170
+ case 'msg':
171
+ await BroadcastManager.handleMessage(client, msg.c, msg.e, msg.d)
172
+ break
173
+ case 'pong':
174
+ break
175
+ }
176
+ } catch {
177
+ // Malformed message — silently ignore
178
+ }
179
+ },
180
+
181
+ close(ws) {
182
+ const clientId = BroadcastManager._wsToClient.get(ws)
183
+ if (clientId) BroadcastManager.removeClient(clientId)
184
+ },
185
+ })
186
+
187
+ // Keepalive pings
188
+ if (pingInterval > 0) {
189
+ BroadcastManager._pingTimer = setInterval(() => {
190
+ const ping = JSON.stringify({ t: 'ping' })
191
+ for (const client of BroadcastManager._clients.values()) {
192
+ try {
193
+ client.ws.send(ping)
194
+ } catch {}
195
+ }
196
+ }, pingInterval)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Register a channel.
202
+ *
203
+ * Accepts either an authorization callback or a full config with
204
+ * message handlers for bidirectional communication.
205
+ *
206
+ * @example
207
+ * // Public channel
208
+ * BroadcastManager.channel('announcements')
209
+ *
210
+ * // Authorized channel
211
+ * BroadcastManager.channel('chats/:id', async (ctx, { id }) => {
212
+ * return !!ctx.get('user')
213
+ * })
214
+ *
215
+ * // Channel with message handlers
216
+ * BroadcastManager.channel('chat/:id', {
217
+ * authorize: async (ctx, { id }) => !!ctx.get('user'),
218
+ * messages: {
219
+ * async send(ctx, { id }, data) {
220
+ * BroadcastManager.to(`chat/${id}`).send('new_message', data)
221
+ * }
222
+ * }
223
+ * })
224
+ */
225
+ static channel(pattern: string, config?: AuthorizeCallback | ChannelConfig): void {
226
+ const { regex, paramNames } = parsePattern(pattern)
227
+
228
+ let authorize: AuthorizeCallback | undefined
229
+ let messages: Record<string, MessageHandler> | undefined
230
+
231
+ if (typeof config === 'function') {
232
+ authorize = config
233
+ } else if (config) {
234
+ authorize = config.authorize
235
+ messages = config.messages
236
+ }
237
+
238
+ BroadcastManager._channels.push({ pattern, regex, paramNames, authorize, messages })
239
+ }
240
+
241
+ /**
242
+ * Begin a broadcast to a channel.
243
+ *
244
+ * @example
245
+ * BroadcastManager.to('chat/1').send('message', { text: 'Hello' })
246
+ * BroadcastManager.to('chat/1').except(senderId).send('message', data)
247
+ */
248
+ static to(channel: string): PendingBroadcast & { except(clientId: string): PendingBroadcast } {
249
+ let excluded: string | null = null
250
+
251
+ const pending: PendingBroadcast & { except(clientId: string): PendingBroadcast } = {
252
+ except(clientId: string) {
253
+ excluded = clientId
254
+ return pending
255
+ },
256
+ send(event: string, data?: unknown) {
257
+ BroadcastManager.broadcastToChannel(channel, event, data, excluded)
258
+ },
259
+ }
260
+
261
+ return pending
262
+ }
263
+
264
+ /** Number of active WebSocket connections. */
265
+ static get clientCount(): number {
266
+ return BroadcastManager._clients.size
267
+ }
268
+
269
+ /** Number of subscribers on a specific channel. */
270
+ static subscriberCount(channel: string): number {
271
+ return BroadcastManager._subscribers.get(channel)?.size ?? 0
272
+ }
273
+
274
+ /** Clear all state. Intended for test teardown. */
275
+ static reset(): void {
276
+ if (BroadcastManager._pingTimer) {
277
+ clearInterval(BroadcastManager._pingTimer)
278
+ BroadcastManager._pingTimer = null
279
+ }
280
+ BroadcastManager._channels = []
281
+ BroadcastManager._clients.clear()
282
+ BroadcastManager._subscribers.clear()
283
+ BroadcastManager._middleware = []
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Internal
288
+ // ---------------------------------------------------------------------------
289
+
290
+ private static async buildContext(ws: ServerWebSocket<WebSocketData>): Promise<Context> {
291
+ const request = ws.data?.request
292
+ if (!request) return new Context(new Request('http://localhost'), {})
293
+
294
+ const ctx = new Context(request, {})
295
+
296
+ if (BroadcastManager._middleware.length > 0) {
297
+ const noop = () => new Response(null)
298
+ await compose(BroadcastManager._middleware, noop)(ctx)
299
+ }
300
+
301
+ return ctx
302
+ }
303
+
304
+ private static async handleSubscribe(
305
+ client: ClientConnection,
306
+ channelName: string
307
+ ): Promise<void> {
308
+ if (!channelName) return
309
+
310
+ // Already subscribed
311
+ if (client.channels.has(channelName)) {
312
+ client.ws.send(JSON.stringify({ t: 'ok', c: channelName }))
313
+ return
314
+ }
315
+
316
+ const match = BroadcastManager.matchChannel(channelName)
317
+ if (!match) {
318
+ client.ws.send(JSON.stringify({ t: 'err', c: channelName, r: 'unknown channel' }))
319
+ return
320
+ }
321
+
322
+ const { definition, params } = match
323
+
324
+ if (definition.authorize) {
325
+ try {
326
+ const ctx = await client.ctxReady
327
+ const allowed = await definition.authorize(ctx, params)
328
+ if (!allowed) {
329
+ client.ws.send(JSON.stringify({ t: 'err', c: channelName, r: 'unauthorized' }))
330
+ return
331
+ }
332
+ } catch {
333
+ client.ws.send(JSON.stringify({ t: 'err', c: channelName, r: 'authorization failed' }))
334
+ return
335
+ }
336
+ }
337
+
338
+ // Add to subscribers
339
+ client.channels.add(channelName)
340
+ let subs = BroadcastManager._subscribers.get(channelName)
341
+ if (!subs) {
342
+ subs = new Set()
343
+ BroadcastManager._subscribers.set(channelName, subs)
344
+ }
345
+ subs.add(client.clientId)
346
+
347
+ client.ws.send(JSON.stringify({ t: 'ok', c: channelName }))
348
+ }
349
+
350
+ private static handleUnsubscribe(client: ClientConnection, channelName: string): void {
351
+ if (!channelName) return
352
+ client.channels.delete(channelName)
353
+ const subs = BroadcastManager._subscribers.get(channelName)
354
+ if (subs) {
355
+ subs.delete(client.clientId)
356
+ if (subs.size === 0) BroadcastManager._subscribers.delete(channelName)
357
+ }
358
+ }
359
+
360
+ private static async handleMessage(
361
+ client: ClientConnection,
362
+ channelName: string,
363
+ event: string,
364
+ data: unknown
365
+ ): Promise<void> {
366
+ if (!channelName || !event) return
367
+ if (!client.channels.has(channelName)) return
368
+
369
+ const match = BroadcastManager.matchChannel(channelName)
370
+ if (!match?.definition.messages?.[event]) return
371
+
372
+ const ctx = await client.ctxReady
373
+ ;(ctx as any).clientId = client.clientId
374
+
375
+ await match.definition.messages[event]!(ctx, match.params, data)
376
+ }
377
+
378
+ private static matchChannel(
379
+ channelName: string
380
+ ): { definition: ChannelDefinition; params: Record<string, string> } | null {
381
+ for (const def of BroadcastManager._channels) {
382
+ const m = def.regex.exec(channelName)
383
+ if (m) return { definition: def, params: extractParams(def.paramNames, m) }
384
+ }
385
+ return null
386
+ }
387
+
388
+ private static broadcastToChannel(
389
+ channel: string,
390
+ event: string,
391
+ data: unknown,
392
+ excludeClientId: string | null
393
+ ): void {
394
+ const subs = BroadcastManager._subscribers.get(channel)
395
+ if (!subs || subs.size === 0) return
396
+
397
+ const msg = JSON.stringify({ t: 'msg', c: channel, e: event, d: data })
398
+
399
+ for (const clientId of subs) {
400
+ if (clientId === excludeClientId) continue
401
+ const client = BroadcastManager._clients.get(clientId)
402
+ if (client) {
403
+ try {
404
+ client.ws.send(msg)
405
+ } catch {}
406
+ }
407
+ }
408
+ }
409
+
410
+ private static removeClient(clientId: string): void {
411
+ const client = BroadcastManager._clients.get(clientId)
412
+ if (!client) return
413
+
414
+ for (const channel of client.channels) {
415
+ const subs = BroadcastManager._subscribers.get(channel)
416
+ if (subs) {
417
+ subs.delete(clientId)
418
+ if (subs.size === 0) BroadcastManager._subscribers.delete(channel)
419
+ }
420
+ }
421
+
422
+ BroadcastManager._clients.delete(clientId)
423
+ }
424
+ }