@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.
@@ -0,0 +1,308 @@
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/signal/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 () => {
78
+ set!.delete(callback)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Send a message to the server on this channel.
84
+ *
85
+ * @example
86
+ * sub.send('typing', { active: true })
87
+ */
88
+ send(event: string, data?: unknown): void {
89
+ this.sendFn({ t: 'msg', c: this.channel, e: event, d: data })
90
+ }
91
+
92
+ /** Unsubscribe from this channel. */
93
+ leave(): void {
94
+ this.sendFn({ t: 'unsub', c: this.channel })
95
+ this.listeners.clear()
96
+ this.leaveFn()
97
+ }
98
+
99
+ /** @internal Dispatch an incoming event to registered listeners. */
100
+ _dispatch(event: string, data: unknown): void {
101
+ const set = this.listeners.get(event)
102
+ if (set) for (const cb of set) cb(data)
103
+ }
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Broadcast
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Broadcast client — manages a single WebSocket connection
112
+ * with multiplexed channel subscriptions.
113
+ *
114
+ * @example
115
+ * const bc = new Broadcast()
116
+ * const chat = bc.subscribe('chat/1')
117
+ * chat.on('message', (data) => console.log(data))
118
+ */
119
+ export class Broadcast {
120
+ private ws: WebSocket | null = null
121
+ private url: string
122
+ private maxReconnectAttempts: number
123
+ private reconnectAttempt = 0
124
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
125
+ private subscriptions = new Map<string, Subscription>()
126
+ private listeners = new Map<string, Set<Callback>>()
127
+ private queue: string[] = []
128
+ private _connected = false
129
+ private _clientId: string | null = null
130
+
131
+ constructor(options?: BroadcastOptions) {
132
+ this.url = options?.url ?? this.autoUrl()
133
+ this.maxReconnectAttempts = options?.maxReconnectAttempts ?? Infinity
134
+ this.connect()
135
+ }
136
+
137
+ /** Whether the WebSocket connection is currently open. */
138
+ get connected(): boolean {
139
+ return this._connected
140
+ }
141
+
142
+ /** The unique client ID assigned by the server, or null if not yet connected. */
143
+ get clientId(): string | null {
144
+ return this._clientId
145
+ }
146
+
147
+ /**
148
+ * Subscribe to a broadcast channel.
149
+ *
150
+ * Returns an existing subscription if already subscribed.
151
+ * On reconnect, all active subscriptions are automatically re-established.
152
+ *
153
+ * @example
154
+ * const notifications = bc.subscribe('notifications')
155
+ * notifications.on('alert', (data) => showToast(data.text))
156
+ */
157
+ subscribe(channel: string): Subscription {
158
+ const existing = this.subscriptions.get(channel)
159
+ if (existing) return existing
160
+
161
+ const sub = new Subscription(
162
+ channel,
163
+ msg => this.send(msg),
164
+ () => this.subscriptions.delete(channel)
165
+ )
166
+
167
+ this.subscriptions.set(channel, sub)
168
+
169
+ if (this._connected) {
170
+ this.rawSend({ t: 'sub', c: channel })
171
+ }
172
+
173
+ return sub
174
+ }
175
+
176
+ /**
177
+ * Listen for connection lifecycle events.
178
+ *
179
+ * Events:
180
+ * - `connected` — WebSocket connection established
181
+ * - `disconnected` — WebSocket connection lost
182
+ * - `reconnecting` — reconnection attempt (callback receives attempt number)
183
+ * - `subscribed` — channel subscription confirmed (callback receives channel name)
184
+ * - `error` — subscription error (callback receives `{ channel, reason }`)
185
+ *
186
+ * Returns a function that removes the listener.
187
+ */
188
+ on(event: string, callback: Callback): () => void {
189
+ let set = this.listeners.get(event)
190
+ if (!set) {
191
+ set = new Set()
192
+ this.listeners.set(event, set)
193
+ }
194
+ set.add(callback)
195
+ return () => {
196
+ set!.delete(callback)
197
+ }
198
+ }
199
+
200
+ /** Close the connection permanently (no reconnection). */
201
+ close(): void {
202
+ this.reconnectAttempt = Infinity
203
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
204
+ this.ws?.close()
205
+ this.ws = null
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Internal
210
+ // ---------------------------------------------------------------------------
211
+
212
+ private autoUrl(): string {
213
+ // @ts-ignore browser-only API
214
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
215
+ // @ts-ignore browser-only API
216
+ return `${proto}//${location.host}/_broadcast`
217
+ }
218
+
219
+ private connect(): void {
220
+ this.ws = new WebSocket(this.url)
221
+
222
+ this.ws.onopen = () => {
223
+ this._connected = true
224
+ this.reconnectAttempt = 0
225
+ this.emit('connected')
226
+ }
227
+
228
+ this.ws.onmessage = event => {
229
+ try {
230
+ const msg = JSON.parse(event.data)
231
+ this.handleMessage(msg)
232
+ } catch {}
233
+ }
234
+
235
+ this.ws.onclose = () => {
236
+ const wasConnected = this._connected
237
+ this._connected = false
238
+ if (wasConnected) this.emit('disconnected')
239
+ this.scheduleReconnect()
240
+ }
241
+
242
+ this.ws.onerror = () => {
243
+ this.ws?.close()
244
+ }
245
+ }
246
+
247
+ private handleMessage(msg: any): void {
248
+ switch (msg.t) {
249
+ case 'welcome':
250
+ this._clientId = msg.id
251
+ // Re-subscribe to all active channels
252
+ for (const channel of this.subscriptions.keys()) {
253
+ this.rawSend({ t: 'sub', c: channel })
254
+ }
255
+ // Flush queued messages
256
+ for (const raw of this.queue) {
257
+ this.ws!.send(raw)
258
+ }
259
+ this.queue = []
260
+ break
261
+
262
+ case 'ok':
263
+ this.emit('subscribed', msg.c)
264
+ break
265
+
266
+ case 'err':
267
+ this.emit('error', { channel: msg.c, reason: msg.r })
268
+ break
269
+
270
+ case 'msg':
271
+ this.subscriptions.get(msg.c)?._dispatch(msg.e, msg.d)
272
+ break
273
+
274
+ case 'ping':
275
+ this.rawSend({ t: 'pong' })
276
+ break
277
+ }
278
+ }
279
+
280
+ private send(msg: object): void {
281
+ const raw = JSON.stringify(msg)
282
+ if (this._connected && this.ws?.readyState === WebSocket.OPEN) {
283
+ this.ws.send(raw)
284
+ } else {
285
+ this.queue.push(raw)
286
+ }
287
+ }
288
+
289
+ private rawSend(msg: object): void {
290
+ if (this.ws?.readyState === WebSocket.OPEN) {
291
+ this.ws.send(JSON.stringify(msg))
292
+ }
293
+ }
294
+
295
+ private scheduleReconnect(): void {
296
+ if (this.reconnectAttempt >= this.maxReconnectAttempts) return
297
+
298
+ this.reconnectAttempt++
299
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt - 1), 30_000)
300
+ this.emit('reconnecting', this.reconnectAttempt)
301
+ this.reconnectTimer = setTimeout(() => this.connect(), delay)
302
+ }
303
+
304
+ private emit(event: string, data?: unknown): void {
305
+ const set = this.listeners.get(event)
306
+ if (set) for (const cb of set) cb(data)
307
+ }
308
+ }
@@ -0,0 +1,58 @@
1
+ export { default, default as BroadcastManager } from './broadcast_manager.ts'
2
+ export type {
3
+ AuthorizeCallback,
4
+ MessageHandler,
5
+ ChannelConfig,
6
+ BootOptions,
7
+ PendingBroadcast,
8
+ } from './broadcast_manager.ts'
9
+ export { Broadcast, Subscription } from './client.ts'
10
+ export type { BroadcastOptions } from './client.ts'
11
+
12
+ import BroadcastManager from './broadcast_manager.ts'
13
+ import type { AuthorizeCallback, ChannelConfig, BootOptions } from './broadcast_manager.ts'
14
+ import type Router from '@stravigor/http/http/router'
15
+
16
+ /**
17
+ * Broadcast helper — convenience object that delegates to `BroadcastManager`.
18
+ *
19
+ * @example
20
+ * import { broadcast } from '@stravigor/signal/broadcast'
21
+ *
22
+ * // Bootstrap
23
+ * broadcast.boot(router, { middleware: [session()] })
24
+ *
25
+ * // Define channels
26
+ * broadcast.channel('notifications')
27
+ * broadcast.channel('chats/:id', async (ctx, { id }) => !!ctx.get('user'))
28
+ *
29
+ * // Broadcast from anywhere
30
+ * broadcast.to('notifications').send('alert', { text: 'Hello' })
31
+ * broadcast.to(`chats/${chatId}`).except(senderId).send('message', data)
32
+ */
33
+ export const broadcast = {
34
+ /** Register the broadcast WebSocket endpoint on the router. */
35
+ boot(router: Router, options?: BootOptions): void {
36
+ BroadcastManager.boot(router, options)
37
+ },
38
+
39
+ /** Register a channel with optional authorization and message handlers. */
40
+ channel(pattern: string, config?: AuthorizeCallback | ChannelConfig): void {
41
+ BroadcastManager.channel(pattern, config)
42
+ },
43
+
44
+ /** Begin a broadcast to a channel. */
45
+ to(channel: string) {
46
+ return BroadcastManager.to(channel)
47
+ },
48
+
49
+ /** Number of active WebSocket connections. */
50
+ get clientCount() {
51
+ return BroadcastManager.clientCount
52
+ },
53
+
54
+ /** Number of subscribers on a specific channel. */
55
+ subscriberCount(channel: string) {
56
+ return BroadcastManager.subscriberCount(channel)
57
+ },
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './mail/index.ts'
2
+ export * from './notification/index.ts'
3
+ export * from './broadcast/index.ts'
4
+ export * from './providers/index.ts'
@@ -0,0 +1,79 @@
1
+ import juice from 'juice'
2
+
3
+ export interface InlinerOptions {
4
+ /** Enable CSS inlining (default: true). */
5
+ enabled: boolean
6
+ /** Enable Tailwind CSS compilation before inlining (default: false). */
7
+ tailwind: boolean
8
+ }
9
+
10
+ /**
11
+ * Process rendered HTML for email delivery:
12
+ * 1. Optionally compile Tailwind classes to CSS
13
+ * 2. Inline all <style> blocks into style="" attributes via juice
14
+ */
15
+ export async function inlineCss(html: string, options: InlinerOptions): Promise<string> {
16
+ if (!options.enabled) return html
17
+
18
+ let processed = html
19
+
20
+ if (options.tailwind) {
21
+ processed = await compileTailwind(processed)
22
+ }
23
+
24
+ return juice(processed, {
25
+ removeStyleTags: true,
26
+ preserveMediaQueries: true,
27
+ preserveFontFaces: true,
28
+ preserveKeyFrames: true,
29
+ applyWidthAttributes: true,
30
+ applyHeightAttributes: true,
31
+ applyAttributesTableElements: true,
32
+ })
33
+ }
34
+
35
+ /**
36
+ * Extract Tailwind utility classes from HTML and compile them to CSS,
37
+ * then inject a <style> block for juice to inline.
38
+ *
39
+ * Uses dynamic import — silently skips if tailwindcss is not installed.
40
+ */
41
+ async function compileTailwind(html: string): Promise<string> {
42
+ try {
43
+ // @ts-ignore: Tailwind is optional
44
+ const { compile } = await import('tailwindcss')
45
+
46
+ const compiler = await compile('@tailwind utilities;')
47
+ const classes = extractClasses(html)
48
+
49
+ if (classes.length === 0) return html
50
+
51
+ const css = compiler.build(classes)
52
+ if (!css) return html
53
+
54
+ const insertPoint = html.indexOf('</head>')
55
+ if (insertPoint !== -1) {
56
+ return html.slice(0, insertPoint) + `<style>${css}</style>` + html.slice(insertPoint)
57
+ }
58
+ return `<style>${css}</style>` + html
59
+ } catch {
60
+ // tailwindcss not installed or API mismatch — skip silently
61
+ return html
62
+ }
63
+ }
64
+
65
+ /** Extract class names from HTML class="..." attributes. */
66
+ function extractClasses(html: string): string[] {
67
+ const classRegex = /class\s*=\s*["']([^"']*)["']/gi
68
+ const classes = new Set<string>()
69
+
70
+ let match
71
+ while ((match = classRegex.exec(html)) !== null) {
72
+ for (const cls of match[1]!.split(/\s+/)) {
73
+ const trimmed = cls.trim()
74
+ if (trimmed) classes.add(trimmed)
75
+ }
76
+ }
77
+
78
+ return [...classes]
79
+ }
@@ -0,0 +1,212 @@
1
+ import MailManager from './mail_manager.ts'
2
+ import { ViewEngine } from '@stravigor/view'
3
+ import { inlineCss } from './css_inliner.ts'
4
+ import Queue from '@stravigor/queue/queue/queue'
5
+ import type { MailMessage, MailResult, MailAttachment } from './types.ts'
6
+
7
+ /**
8
+ * Fluent email builder. Returned by `mail.to()`.
9
+ *
10
+ * @example
11
+ * await mail.to('user@example.com')
12
+ * .subject('Welcome!')
13
+ * .template('welcome', { name: 'Alice' })
14
+ * .send()
15
+ *
16
+ * await mail.to(user.email)
17
+ * .from('support@app.com')
18
+ * .subject('Reset Password')
19
+ * .template('reset', { token })
20
+ * .queue()
21
+ */
22
+ export class PendingMail {
23
+ private _from?: string
24
+ private _to: string | string[]
25
+ private _cc?: string | string[]
26
+ private _bcc?: string | string[]
27
+ private _replyTo?: string
28
+ private _subject = ''
29
+ private _html?: string
30
+ private _text?: string
31
+ private _attachments: MailAttachment[] = []
32
+ private _template?: string
33
+ private _templateData?: Record<string, unknown>
34
+
35
+ constructor(to: string | string[]) {
36
+ this._to = to
37
+ }
38
+
39
+ from(address: string): this {
40
+ this._from = address
41
+ return this
42
+ }
43
+
44
+ cc(address: string | string[]): this {
45
+ this._cc = address
46
+ return this
47
+ }
48
+
49
+ bcc(address: string | string[]): this {
50
+ this._bcc = address
51
+ return this
52
+ }
53
+
54
+ replyTo(address: string): this {
55
+ this._replyTo = address
56
+ return this
57
+ }
58
+
59
+ subject(value: string): this {
60
+ this._subject = value
61
+ return this
62
+ }
63
+
64
+ /** Set raw HTML content (bypasses template rendering). */
65
+ html(value: string): this {
66
+ this._html = value
67
+ return this
68
+ }
69
+
70
+ /** Set plain text content. */
71
+ text(value: string): this {
72
+ this._text = value
73
+ return this
74
+ }
75
+
76
+ /** Use a .strav template. Name is relative to the mail template prefix. */
77
+ template(name: string, data: Record<string, unknown> = {}): this {
78
+ this._template = name
79
+ this._templateData = data
80
+ return this
81
+ }
82
+
83
+ attach(attachment: MailAttachment): this {
84
+ this._attachments.push(attachment)
85
+ return this
86
+ }
87
+
88
+ /** Build the MailMessage, rendering template + inlining CSS if needed. */
89
+ async build(): Promise<MailMessage> {
90
+ const config = MailManager.config
91
+ let html = this._html
92
+ const text = this._text
93
+
94
+ if (this._template) {
95
+ const templateName = `${config.templatePrefix}.${this._template}`
96
+ html = await ViewEngine.instance.render(templateName, this._templateData ?? {})
97
+ }
98
+
99
+ if (html) {
100
+ html = await inlineCss(html, {
101
+ enabled: config.inlineCss,
102
+ tailwind: config.tailwind,
103
+ })
104
+ }
105
+
106
+ return {
107
+ from: this._from ?? config.from,
108
+ to: this._to,
109
+ cc: this._cc,
110
+ bcc: this._bcc,
111
+ replyTo: this._replyTo,
112
+ subject: this._subject,
113
+ html,
114
+ text,
115
+ attachments: this._attachments.length > 0 ? this._attachments : undefined,
116
+ }
117
+ }
118
+
119
+ /** Send the email immediately via the configured transport. */
120
+ async send(): Promise<MailResult> {
121
+ const message = await this.build()
122
+ return MailManager.transport.send(message)
123
+ }
124
+
125
+ /** Push the email onto the job queue for async sending. */
126
+ async queue(options?: { queue?: string; delay?: number }): Promise<number> {
127
+ const message = await this.build()
128
+ return Queue.push('strav:send-mail', message, options)
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Mail helper object — the primary API for sending emails.
134
+ *
135
+ * @example
136
+ * import { mail } from '@stravigor/signal/mail'
137
+ *
138
+ * // Fluent builder
139
+ * await mail.to('user@example.com').subject('Hello').template('welcome', { name }).send()
140
+ *
141
+ * // Convenience send
142
+ * await mail.send({ to: 'user@example.com', subject: 'Hello', template: 'welcome', data: { name } })
143
+ *
144
+ * // Raw HTML send
145
+ * await mail.raw({ to: 'user@example.com', subject: 'Hello', html: '<h1>Hi</h1>' })
146
+ */
147
+ export const mail = {
148
+ /** Start building an email to the given recipient(s). Returns a fluent PendingMail. */
149
+ to(address: string | string[]): PendingMail {
150
+ return new PendingMail(address)
151
+ },
152
+
153
+ /** Send an email using a template. Convenience wrapper for the fluent API. */
154
+ async send(options: {
155
+ to: string | string[]
156
+ from?: string
157
+ cc?: string | string[]
158
+ bcc?: string | string[]
159
+ replyTo?: string
160
+ subject: string
161
+ template: string
162
+ data?: Record<string, unknown>
163
+ attachments?: MailAttachment[]
164
+ }): Promise<MailResult> {
165
+ const pending = new PendingMail(options.to)
166
+ .subject(options.subject)
167
+ .template(options.template, options.data)
168
+ if (options.from) pending.from(options.from)
169
+ if (options.cc) pending.cc(options.cc)
170
+ if (options.bcc) pending.bcc(options.bcc)
171
+ if (options.replyTo) pending.replyTo(options.replyTo)
172
+ if (options.attachments) {
173
+ for (const a of options.attachments) pending.attach(a)
174
+ }
175
+ return pending.send()
176
+ },
177
+
178
+ /** Send a raw email without template rendering. */
179
+ async raw(options: {
180
+ to: string | string[]
181
+ from?: string
182
+ cc?: string | string[]
183
+ bcc?: string | string[]
184
+ replyTo?: string
185
+ subject: string
186
+ html?: string
187
+ text?: string
188
+ attachments?: MailAttachment[]
189
+ }): Promise<MailResult> {
190
+ const pending = new PendingMail(options.to).subject(options.subject)
191
+ if (options.from) pending.from(options.from)
192
+ if (options.html) pending.html(options.html)
193
+ if (options.text) pending.text(options.text)
194
+ if (options.cc) pending.cc(options.cc)
195
+ if (options.bcc) pending.bcc(options.bcc)
196
+ if (options.replyTo) pending.replyTo(options.replyTo)
197
+ if (options.attachments) {
198
+ for (const a of options.attachments) pending.attach(a)
199
+ }
200
+ return pending.send()
201
+ },
202
+
203
+ /**
204
+ * Register the built-in queue handler for async mail sending.
205
+ * Call this in your app bootstrap after Queue is configured.
206
+ */
207
+ registerQueueHandler(): void {
208
+ Queue.handle<MailMessage>('strav:send-mail', async message => {
209
+ await MailManager.transport.send(message)
210
+ })
211
+ },
212
+ }
@@ -0,0 +1,23 @@
1
+ export { default, default as MailManager } from './mail_manager.ts'
2
+ export { mail, PendingMail } from './helpers.ts'
3
+ export { SmtpTransport } from './transports/smtp_transport.ts'
4
+ export { ResendTransport } from './transports/resend_transport.ts'
5
+ export { SendGridTransport } from './transports/sendgrid_transport.ts'
6
+ export { MailgunTransport } from './transports/mailgun_transport.ts'
7
+ export { AlibabaTransport } from './transports/alibaba_transport.ts'
8
+ export { LogTransport } from './transports/log_transport.ts'
9
+ export { inlineCss } from './css_inliner.ts'
10
+ export type {
11
+ MailTransport,
12
+ MailMessage,
13
+ MailResult,
14
+ MailAttachment,
15
+ MailConfig,
16
+ SmtpConfig,
17
+ ResendConfig,
18
+ SendGridConfig,
19
+ MailgunConfig,
20
+ AlibabaConfig,
21
+ LogConfig,
22
+ } from './types.ts'
23
+ export type { InlinerOptions } from './css_inliner.ts'