@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -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 +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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
|
+
}
|