@stravigor/core 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.
Files changed (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,411 @@
1
+ import type { ServerWebSocket } from 'bun'
2
+ import Context from '../http/context.ts'
3
+ import { compose } from '../http/middleware.ts'
4
+ import type { Middleware } from '../http/middleware.ts'
5
+ import type Router from '../http/router.ts'
6
+ import type { WebSocketData } from '../http/router.ts'
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) => { paramNames.push(name); return '/(.+)' })
63
+ .replace(/:(\w+)/g, (_, name) => { paramNames.push(name); return '([^/]+)' })
64
+ return { regex: new RegExp(`^${regexStr}$`), paramNames }
65
+ }
66
+
67
+ function extractParams(names: string[], match: RegExpExecArray): Record<string, string> {
68
+ const params: Record<string, string> = {}
69
+ for (let i = 0; i < names.length; i++) {
70
+ params[names[i]!] = match[i + 1]!
71
+ }
72
+ return params
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // PendingBroadcast — fluent builder for .to().except().send()
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export interface PendingBroadcast {
80
+ /** Exclude a specific client from receiving the broadcast. */
81
+ except(clientId: string): PendingBroadcast
82
+ /** Send an event with optional data to the channel. */
83
+ send(event: string, data?: unknown): void
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // BroadcastManager
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Channel-based WebSocket broadcasting.
92
+ *
93
+ * Manages channel definitions, client connections, subscriptions,
94
+ * and message routing over a single multiplexed WebSocket endpoint.
95
+ *
96
+ * @example
97
+ * // Bootstrap
98
+ * BroadcastManager.boot(router, { middleware: [session()] })
99
+ *
100
+ * // Define channels
101
+ * BroadcastManager.channel('notifications')
102
+ * BroadcastManager.channel('chats/:id', async (ctx, { id }) => !!ctx.get('user'))
103
+ *
104
+ * // Broadcast
105
+ * BroadcastManager.to('notifications').send('alert', { text: 'Hello' })
106
+ */
107
+ export default class BroadcastManager {
108
+ private static _channels: ChannelDefinition[] = []
109
+ private static _clients = new Map<string, ClientConnection>()
110
+ private static _subscribers = new Map<string, Set<string>>()
111
+ private static _wsToClient = new WeakMap<object, string>()
112
+ private static _middleware: Middleware[] = []
113
+ private static _pingTimer: ReturnType<typeof setInterval> | null = null
114
+
115
+ /**
116
+ * Register the broadcast WebSocket endpoint on the router.
117
+ *
118
+ * @example
119
+ * BroadcastManager.boot(router, {
120
+ * middleware: [session()],
121
+ * pingInterval: 30_000,
122
+ * })
123
+ */
124
+ static boot(router: Router, options?: BootOptions): void {
125
+ const path = options?.path ?? '/_broadcast'
126
+ const pingInterval = options?.pingInterval ?? 30_000
127
+
128
+ if (options?.middleware) {
129
+ BroadcastManager._middleware = options.middleware
130
+ }
131
+
132
+ router.ws(path, {
133
+ open(ws) {
134
+ const clientId = crypto.randomUUID()
135
+ BroadcastManager._wsToClient.set(ws, clientId)
136
+
137
+ const ctxReady = BroadcastManager.buildContext(ws)
138
+ BroadcastManager._clients.set(clientId, {
139
+ ws,
140
+ clientId,
141
+ channels: new Set(),
142
+ ctxReady,
143
+ })
144
+
145
+ ws.send(JSON.stringify({ t: 'welcome', id: clientId }))
146
+ },
147
+
148
+ async message(ws, raw) {
149
+ try {
150
+ const msg = JSON.parse(raw as string)
151
+ const clientId = BroadcastManager._wsToClient.get(ws)
152
+ if (!clientId) return
153
+
154
+ const client = BroadcastManager._clients.get(clientId)
155
+ if (!client) return
156
+
157
+ switch (msg.t) {
158
+ case 'sub':
159
+ await BroadcastManager.handleSubscribe(client, msg.c)
160
+ break
161
+ case 'unsub':
162
+ BroadcastManager.handleUnsubscribe(client, msg.c)
163
+ break
164
+ case 'msg':
165
+ await BroadcastManager.handleMessage(client, msg.c, msg.e, msg.d)
166
+ break
167
+ case 'pong':
168
+ break
169
+ }
170
+ } catch {
171
+ // Malformed message — silently ignore
172
+ }
173
+ },
174
+
175
+ close(ws) {
176
+ const clientId = BroadcastManager._wsToClient.get(ws)
177
+ if (clientId) BroadcastManager.removeClient(clientId)
178
+ },
179
+ })
180
+
181
+ // Keepalive pings
182
+ if (pingInterval > 0) {
183
+ BroadcastManager._pingTimer = setInterval(() => {
184
+ const ping = JSON.stringify({ t: 'ping' })
185
+ for (const client of BroadcastManager._clients.values()) {
186
+ try { client.ws.send(ping) } catch {}
187
+ }
188
+ }, pingInterval)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Register a channel.
194
+ *
195
+ * Accepts either an authorization callback or a full config with
196
+ * message handlers for bidirectional communication.
197
+ *
198
+ * @example
199
+ * // Public channel
200
+ * BroadcastManager.channel('announcements')
201
+ *
202
+ * // Authorized channel
203
+ * BroadcastManager.channel('chats/:id', async (ctx, { id }) => {
204
+ * return !!ctx.get('user')
205
+ * })
206
+ *
207
+ * // Channel with message handlers
208
+ * BroadcastManager.channel('chat/:id', {
209
+ * authorize: async (ctx, { id }) => !!ctx.get('user'),
210
+ * messages: {
211
+ * async send(ctx, { id }, data) {
212
+ * BroadcastManager.to(`chat/${id}`).send('new_message', data)
213
+ * }
214
+ * }
215
+ * })
216
+ */
217
+ static channel(pattern: string, config?: AuthorizeCallback | ChannelConfig): void {
218
+ const { regex, paramNames } = parsePattern(pattern)
219
+
220
+ let authorize: AuthorizeCallback | undefined
221
+ let messages: Record<string, MessageHandler> | undefined
222
+
223
+ if (typeof config === 'function') {
224
+ authorize = config
225
+ } else if (config) {
226
+ authorize = config.authorize
227
+ messages = config.messages
228
+ }
229
+
230
+ BroadcastManager._channels.push({ pattern, regex, paramNames, authorize, messages })
231
+ }
232
+
233
+ /**
234
+ * Begin a broadcast to a channel.
235
+ *
236
+ * @example
237
+ * BroadcastManager.to('chat/1').send('message', { text: 'Hello' })
238
+ * BroadcastManager.to('chat/1').except(senderId).send('message', data)
239
+ */
240
+ static to(channel: string): PendingBroadcast & { except(clientId: string): PendingBroadcast } {
241
+ let excluded: string | null = null
242
+
243
+ const pending: PendingBroadcast & { except(clientId: string): PendingBroadcast } = {
244
+ except(clientId: string) {
245
+ excluded = clientId
246
+ return pending
247
+ },
248
+ send(event: string, data?: unknown) {
249
+ BroadcastManager.broadcastToChannel(channel, event, data, excluded)
250
+ },
251
+ }
252
+
253
+ return pending
254
+ }
255
+
256
+ /** Number of active WebSocket connections. */
257
+ static get clientCount(): number {
258
+ return BroadcastManager._clients.size
259
+ }
260
+
261
+ /** Number of subscribers on a specific channel. */
262
+ static subscriberCount(channel: string): number {
263
+ return BroadcastManager._subscribers.get(channel)?.size ?? 0
264
+ }
265
+
266
+ /** Clear all state. Intended for test teardown. */
267
+ static reset(): void {
268
+ if (BroadcastManager._pingTimer) {
269
+ clearInterval(BroadcastManager._pingTimer)
270
+ BroadcastManager._pingTimer = null
271
+ }
272
+ BroadcastManager._channels = []
273
+ BroadcastManager._clients.clear()
274
+ BroadcastManager._subscribers.clear()
275
+ BroadcastManager._middleware = []
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Internal
280
+ // ---------------------------------------------------------------------------
281
+
282
+ private static async buildContext(ws: ServerWebSocket<WebSocketData>): Promise<Context> {
283
+ const request = ws.data?.request
284
+ if (!request) return new Context(new Request('http://localhost'), {})
285
+
286
+ const ctx = new Context(request, {})
287
+
288
+ if (BroadcastManager._middleware.length > 0) {
289
+ const noop = () => new Response(null)
290
+ await compose(BroadcastManager._middleware, noop)(ctx)
291
+ }
292
+
293
+ return ctx
294
+ }
295
+
296
+ private static async handleSubscribe(client: ClientConnection, channelName: string): Promise<void> {
297
+ if (!channelName) return
298
+
299
+ // Already subscribed
300
+ if (client.channels.has(channelName)) {
301
+ client.ws.send(JSON.stringify({ t: 'ok', c: channelName }))
302
+ return
303
+ }
304
+
305
+ const match = BroadcastManager.matchChannel(channelName)
306
+ if (!match) {
307
+ client.ws.send(JSON.stringify({ t: 'err', c: channelName, r: 'unknown channel' }))
308
+ return
309
+ }
310
+
311
+ const { definition, params } = match
312
+
313
+ if (definition.authorize) {
314
+ try {
315
+ const ctx = await client.ctxReady
316
+ const allowed = await definition.authorize(ctx, params)
317
+ if (!allowed) {
318
+ client.ws.send(JSON.stringify({ t: 'err', c: channelName, r: 'unauthorized' }))
319
+ return
320
+ }
321
+ } catch {
322
+ client.ws.send(JSON.stringify({ t: 'err', c: channelName, r: 'authorization failed' }))
323
+ return
324
+ }
325
+ }
326
+
327
+ // Add to subscribers
328
+ client.channels.add(channelName)
329
+ let subs = BroadcastManager._subscribers.get(channelName)
330
+ if (!subs) {
331
+ subs = new Set()
332
+ BroadcastManager._subscribers.set(channelName, subs)
333
+ }
334
+ subs.add(client.clientId)
335
+
336
+ client.ws.send(JSON.stringify({ t: 'ok', c: channelName }))
337
+ }
338
+
339
+ private static handleUnsubscribe(client: ClientConnection, channelName: string): void {
340
+ if (!channelName) return
341
+ client.channels.delete(channelName)
342
+ const subs = BroadcastManager._subscribers.get(channelName)
343
+ if (subs) {
344
+ subs.delete(client.clientId)
345
+ if (subs.size === 0) BroadcastManager._subscribers.delete(channelName)
346
+ }
347
+ }
348
+
349
+ private static async handleMessage(
350
+ client: ClientConnection,
351
+ channelName: string,
352
+ event: string,
353
+ data: unknown
354
+ ): Promise<void> {
355
+ if (!channelName || !event) return
356
+ if (!client.channels.has(channelName)) return
357
+
358
+ const match = BroadcastManager.matchChannel(channelName)
359
+ if (!match?.definition.messages?.[event]) return
360
+
361
+ const ctx = await client.ctxReady
362
+ ;(ctx as any).clientId = client.clientId
363
+
364
+ await match.definition.messages[event]!(ctx, match.params, data)
365
+ }
366
+
367
+ private static matchChannel(
368
+ channelName: string
369
+ ): { definition: ChannelDefinition; params: Record<string, string> } | null {
370
+ for (const def of BroadcastManager._channels) {
371
+ const m = def.regex.exec(channelName)
372
+ if (m) return { definition: def, params: extractParams(def.paramNames, m) }
373
+ }
374
+ return null
375
+ }
376
+
377
+ private static broadcastToChannel(
378
+ channel: string,
379
+ event: string,
380
+ data: unknown,
381
+ excludeClientId: string | null
382
+ ): void {
383
+ const subs = BroadcastManager._subscribers.get(channel)
384
+ if (!subs || subs.size === 0) return
385
+
386
+ const msg = JSON.stringify({ t: 'msg', c: channel, e: event, d: data })
387
+
388
+ for (const clientId of subs) {
389
+ if (clientId === excludeClientId) continue
390
+ const client = BroadcastManager._clients.get(clientId)
391
+ if (client) {
392
+ try { client.ws.send(msg) } catch {}
393
+ }
394
+ }
395
+ }
396
+
397
+ private static removeClient(clientId: string): void {
398
+ const client = BroadcastManager._clients.get(clientId)
399
+ if (!client) return
400
+
401
+ for (const channel of client.channels) {
402
+ const subs = BroadcastManager._subscribers.get(channel)
403
+ if (subs) {
404
+ subs.delete(clientId)
405
+ if (subs.size === 0) BroadcastManager._subscribers.delete(channel)
406
+ }
407
+ }
408
+
409
+ BroadcastManager._clients.delete(clientId)
410
+ }
411
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Browser-side Broadcast client.
3
+ *
4
+ * Connects to the server's broadcast WebSocket endpoint,
5
+ * manages channel subscriptions, auto-reconnects, and routes
6
+ * events to subscription listeners.
7
+ *
8
+ * Zero dependencies — works in any browser.
9
+ *
10
+ * @example
11
+ * import { Broadcast } from '@stravigor/core/broadcast/client'
12
+ *
13
+ * const bc = new Broadcast()
14
+ *
15
+ * const chat = bc.subscribe('chat/1')
16
+ * chat.on('new_message', (data) => console.log(data))
17
+ * chat.send('send', { text: 'Hello!' })
18
+ *
19
+ * bc.on('connected', () => console.log('Online'))
20
+ * bc.on('disconnected', () => console.log('Offline'))
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface BroadcastOptions {
28
+ /** WebSocket URL. Default: auto-detected from current host + `/_broadcast`. */
29
+ url?: string
30
+ /** Maximum reconnection attempts. Default: Infinity */
31
+ maxReconnectAttempts?: number
32
+ }
33
+
34
+ type Callback = (...args: any[]) => void
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Subscription
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * A subscription to a single broadcast channel.
42
+ *
43
+ * Listen for server events, send messages to the channel,
44
+ * or leave the channel entirely.
45
+ *
46
+ * @example
47
+ * const sub = bc.subscribe('chat/1')
48
+ * sub.on('message', (data) => { ... })
49
+ * sub.send('typing', { active: true })
50
+ * sub.leave()
51
+ */
52
+ export class Subscription {
53
+ private listeners = new Map<string, Set<Callback>>()
54
+
55
+ constructor(
56
+ /** The channel name this subscription is bound to. */
57
+ readonly channel: string,
58
+ private sendFn: (msg: object) => void,
59
+ private leaveFn: () => void
60
+ ) {}
61
+
62
+ /**
63
+ * Listen for a specific event on this channel.
64
+ * Returns a function that removes the listener when called.
65
+ *
66
+ * @example
67
+ * const stop = sub.on('message', (data) => console.log(data))
68
+ * stop() // remove listener
69
+ */
70
+ on(event: string, callback: Callback): () => void {
71
+ let set = this.listeners.get(event)
72
+ if (!set) {
73
+ set = new Set()
74
+ this.listeners.set(event, set)
75
+ }
76
+ set.add(callback)
77
+ return () => { set!.delete(callback) }
78
+ }
79
+
80
+ /**
81
+ * Send a message to the server on this channel.
82
+ *
83
+ * @example
84
+ * sub.send('typing', { active: true })
85
+ */
86
+ send(event: string, data?: unknown): void {
87
+ this.sendFn({ t: 'msg', c: this.channel, e: event, d: data })
88
+ }
89
+
90
+ /** Unsubscribe from this channel. */
91
+ leave(): void {
92
+ this.sendFn({ t: 'unsub', c: this.channel })
93
+ this.listeners.clear()
94
+ this.leaveFn()
95
+ }
96
+
97
+ /** @internal Dispatch an incoming event to registered listeners. */
98
+ _dispatch(event: string, data: unknown): void {
99
+ const set = this.listeners.get(event)
100
+ if (set) for (const cb of set) cb(data)
101
+ }
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Broadcast
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * Broadcast client — manages a single WebSocket connection
110
+ * with multiplexed channel subscriptions.
111
+ *
112
+ * @example
113
+ * const bc = new Broadcast()
114
+ * const chat = bc.subscribe('chat/1')
115
+ * chat.on('message', (data) => console.log(data))
116
+ */
117
+ export class Broadcast {
118
+ private ws: WebSocket | null = null
119
+ private url: string
120
+ private maxReconnectAttempts: number
121
+ private reconnectAttempt = 0
122
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
123
+ private subscriptions = new Map<string, Subscription>()
124
+ private listeners = new Map<string, Set<Callback>>()
125
+ private queue: string[] = []
126
+ private _connected = false
127
+ private _clientId: string | null = null
128
+
129
+ constructor(options?: BroadcastOptions) {
130
+ this.url = options?.url ?? this.autoUrl()
131
+ this.maxReconnectAttempts = options?.maxReconnectAttempts ?? Infinity
132
+ this.connect()
133
+ }
134
+
135
+ /** Whether the WebSocket connection is currently open. */
136
+ get connected(): boolean {
137
+ return this._connected
138
+ }
139
+
140
+ /** The unique client ID assigned by the server, or null if not yet connected. */
141
+ get clientId(): string | null {
142
+ return this._clientId
143
+ }
144
+
145
+ /**
146
+ * Subscribe to a broadcast channel.
147
+ *
148
+ * Returns an existing subscription if already subscribed.
149
+ * On reconnect, all active subscriptions are automatically re-established.
150
+ *
151
+ * @example
152
+ * const notifications = bc.subscribe('notifications')
153
+ * notifications.on('alert', (data) => showToast(data.text))
154
+ */
155
+ subscribe(channel: string): Subscription {
156
+ const existing = this.subscriptions.get(channel)
157
+ if (existing) return existing
158
+
159
+ const sub = new Subscription(
160
+ channel,
161
+ (msg) => this.send(msg),
162
+ () => this.subscriptions.delete(channel)
163
+ )
164
+
165
+ this.subscriptions.set(channel, sub)
166
+
167
+ if (this._connected) {
168
+ this.rawSend({ t: 'sub', c: channel })
169
+ }
170
+
171
+ return sub
172
+ }
173
+
174
+ /**
175
+ * Listen for connection lifecycle events.
176
+ *
177
+ * Events:
178
+ * - `connected` — WebSocket connection established
179
+ * - `disconnected` — WebSocket connection lost
180
+ * - `reconnecting` — reconnection attempt (callback receives attempt number)
181
+ * - `subscribed` — channel subscription confirmed (callback receives channel name)
182
+ * - `error` — subscription error (callback receives `{ channel, reason }`)
183
+ *
184
+ * Returns a function that removes the listener.
185
+ */
186
+ on(event: string, callback: Callback): () => void {
187
+ let set = this.listeners.get(event)
188
+ if (!set) {
189
+ set = new Set()
190
+ this.listeners.set(event, set)
191
+ }
192
+ set.add(callback)
193
+ return () => { set!.delete(callback) }
194
+ }
195
+
196
+ /** Close the connection permanently (no reconnection). */
197
+ close(): void {
198
+ this.reconnectAttempt = Infinity
199
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
200
+ this.ws?.close()
201
+ this.ws = null
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Internal
206
+ // ---------------------------------------------------------------------------
207
+
208
+ private autoUrl(): string {
209
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
210
+ return `${proto}//${location.host}/_broadcast`
211
+ }
212
+
213
+ private connect(): void {
214
+ this.ws = new WebSocket(this.url)
215
+
216
+ this.ws.onopen = () => {
217
+ this._connected = true
218
+ this.reconnectAttempt = 0
219
+ this.emit('connected')
220
+ }
221
+
222
+ this.ws.onmessage = (event) => {
223
+ try {
224
+ const msg = JSON.parse(event.data)
225
+ this.handleMessage(msg)
226
+ } catch {}
227
+ }
228
+
229
+ this.ws.onclose = () => {
230
+ const wasConnected = this._connected
231
+ this._connected = false
232
+ if (wasConnected) this.emit('disconnected')
233
+ this.scheduleReconnect()
234
+ }
235
+
236
+ this.ws.onerror = () => {
237
+ this.ws?.close()
238
+ }
239
+ }
240
+
241
+ private handleMessage(msg: any): void {
242
+ switch (msg.t) {
243
+ case 'welcome':
244
+ this._clientId = msg.id
245
+ // Re-subscribe to all active channels
246
+ for (const channel of this.subscriptions.keys()) {
247
+ this.rawSend({ t: 'sub', c: channel })
248
+ }
249
+ // Flush queued messages
250
+ for (const raw of this.queue) {
251
+ this.ws!.send(raw)
252
+ }
253
+ this.queue = []
254
+ break
255
+
256
+ case 'ok':
257
+ this.emit('subscribed', msg.c)
258
+ break
259
+
260
+ case 'err':
261
+ this.emit('error', { channel: msg.c, reason: msg.r })
262
+ break
263
+
264
+ case 'msg':
265
+ this.subscriptions.get(msg.c)?._dispatch(msg.e, msg.d)
266
+ break
267
+
268
+ case 'ping':
269
+ this.rawSend({ t: 'pong' })
270
+ break
271
+ }
272
+ }
273
+
274
+ private send(msg: object): void {
275
+ const raw = JSON.stringify(msg)
276
+ if (this._connected && this.ws?.readyState === WebSocket.OPEN) {
277
+ this.ws.send(raw)
278
+ } else {
279
+ this.queue.push(raw)
280
+ }
281
+ }
282
+
283
+ private rawSend(msg: object): void {
284
+ if (this.ws?.readyState === WebSocket.OPEN) {
285
+ this.ws.send(JSON.stringify(msg))
286
+ }
287
+ }
288
+
289
+ private scheduleReconnect(): void {
290
+ if (this.reconnectAttempt >= this.maxReconnectAttempts) return
291
+
292
+ this.reconnectAttempt++
293
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt - 1), 30_000)
294
+ this.emit('reconnecting', this.reconnectAttempt)
295
+ this.reconnectTimer = setTimeout(() => this.connect(), delay)
296
+ }
297
+
298
+ private emit(event: string, data?: unknown): void {
299
+ const set = this.listeners.get(event)
300
+ if (set) for (const cb of set) cb(data)
301
+ }
302
+ }