@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 +48 -0
- package/src/broadcast/broadcast_manager.ts +424 -0
- package/src/broadcast/client.ts +308 -0
- package/src/broadcast/index.ts +58 -0
- package/src/index.ts +4 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +23 -0
- package/src/mail/mail_manager.ts +111 -0
- package/src/mail/transports/alibaba_transport.ts +88 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/mailgun_transport.ts +74 -0
- package/src/mail/transports/resend_transport.ts +58 -0
- package/src/mail/transports/sendgrid_transport.ts +80 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +98 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +48 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +50 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +127 -0
- package/src/notification/types.ts +122 -0
- package/src/providers/broadcast_provider.ts +22 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/mail_provider.ts +16 -0
- package/src/providers/notification_provider.ts +29 -0
- package/tsconfig.json +5 -0
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
|
+
}
|