@stacksjs/realtime 0.70.55 → 0.70.56

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/realtime",
3
3
  "type": "module",
4
- "version": "0.70.55",
4
+ "version": "0.70.56",
5
5
  "description": "The Stacks realtime integration. Built on top of ts-broadcasting.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -28,20 +28,23 @@
28
28
  "exports": {
29
29
  ".": {
30
30
  "types": "./dist/index.d.ts",
31
- "bun": "./src/index.ts",
32
- "import": "./dist/index.js"
31
+ "development": "./src/index.ts",
32
+ "bun": "./dist/index.js",
33
+ "import": "./dist/index.js",
34
+ "default": "./dist/index.js"
33
35
  },
34
36
  "./*": {
35
- "bun": "./src/*",
36
- "import": "./dist/*"
37
+ "development": "./src/*",
38
+ "bun": "./dist/*",
39
+ "import": "./dist/*",
40
+ "default": "./dist/*"
37
41
  }
38
42
  },
39
43
  "module": "dist/index.js",
40
44
  "types": "dist/index.d.ts",
41
45
  "files": [
42
46
  "README.md",
43
- "dist",
44
- "src"
47
+ "dist"
45
48
  ],
46
49
  "scripts": {
47
50
  "build": "bun build.ts",
package/src/broadcast.ts DELETED
@@ -1,303 +0,0 @@
1
- import type { BroadcastEvent, ChannelType } from 'ts-broadcasting'
2
- import { log } from '@stacksjs/logging'
3
- import { recordBroadcast } from './replay-buffer'
4
- import { getServer } from './server-instance'
5
-
6
- /**
7
- * Backpressure guard config (stacksjs/stacks#1877 R-2). The default
8
- * threshold is 1 MiB of buffered-bytes per socket — above this, the
9
- * configured `onSlow` callback fires once per offending socket per
10
- * broadcast. Apps install via `setBackpressureGuard({...})`; the
11
- * default is "no guard" for backwards-compat, so existing callers
12
- * see no behavior change until they opt in.
13
- *
14
- * Why opt-in: the underlying ts-broadcasting `server.broadcast()` is
15
- * synchronous and we can't inject between message-serialize and
16
- * socket-write. The best we can do at the Stacks layer is detect
17
- * slow consumers AROUND the broadcast call and let the app decide
18
- * what to do (close socket, drop client from channel, scale up).
19
- */
20
- export interface BackpressureGuardConfig {
21
- /**
22
- * Per-socket buffered-bytes threshold. Sockets with `backpressure`
23
- * (Bun.ServerWebSocket property) above this value trigger `onSlow`.
24
- * Default: 1 MiB (1024 * 1024).
25
- */
26
- maxPerSocketBytes?: number
27
- /**
28
- * Called once per slow socket per broadcast tick. Default action
29
- * is to log a warning; install a custom handler to close the
30
- * socket, drop the client from the channel, etc.
31
- */
32
- onSlow?: (info: { channelName: string, backpressure: number, socket: unknown }) => void
33
- }
34
-
35
- let backpressureConfig: Required<BackpressureGuardConfig> | null = null
36
-
37
- /**
38
- * Install (or clear) the backpressure guard. Pass `null` to disable.
39
- */
40
- export function setBackpressureGuard(cfg: BackpressureGuardConfig | null): void {
41
- if (!cfg) {
42
- backpressureConfig = null
43
- return
44
- }
45
- backpressureConfig = {
46
- maxPerSocketBytes: cfg.maxPerSocketBytes ?? 1024 * 1024,
47
- onSlow: cfg.onSlow ?? ((info) => {
48
- log.warn(`[realtime] slow consumer on '${info.channelName}': ${info.backpressure} bytes buffered`)
49
- }),
50
- }
51
- }
52
-
53
- /**
54
- * Read the currently-installed guard config (useful for tests).
55
- */
56
- export function getBackpressureGuard(): Required<BackpressureGuardConfig> | null {
57
- return backpressureConfig
58
- }
59
-
60
- /**
61
- * Walk the server's per-channel socket set and check `backpressure`
62
- * against the configured threshold. Best-effort: ts-broadcasting may
63
- * expose the socket set under a few different property names and
64
- * sockets without a `.backpressure` field are silently skipped.
65
- */
66
- function checkBackpressure(server: any, channelName: string): void {
67
- if (!backpressureConfig) return
68
- try {
69
- const channels = server.channels ?? server.clients
70
- const set = channels && typeof channels.get === 'function' ? channels.get(channelName) : null
71
- if (!set || typeof set[Symbol.iterator] !== 'function') return
72
-
73
- const { maxPerSocketBytes, onSlow } = backpressureConfig
74
- for (const entry of set) {
75
- // ts-broadcasting may store either the bare socket or a
76
- // wrapper — probe both shapes.
77
- const ws = (entry && typeof entry === 'object' && 'ws' in entry) ? (entry as { ws: unknown }).ws : entry
78
- const bp = ws && typeof ws === 'object' && 'backpressure' in ws ? (ws as { backpressure: unknown }).backpressure : null
79
- if (typeof bp === 'number' && bp > maxPerSocketBytes) {
80
- onSlow({ channelName, backpressure: bp, socket: ws })
81
- }
82
- }
83
- }
84
- catch {
85
- // Introspection failed — same fallthrough policy as hasSubscribers.
86
- }
87
- }
88
-
89
- /**
90
- * Best-effort check for whether anyone is subscribed to `channelName`.
91
- *
92
- * The `BroadcastServer` interface is stable but the subscriber-count
93
- * accessor isn't — different ts-broadcasting versions expose it as
94
- * `hasSubscribers`, `channelCount`, or via `clients.get(name)?.size`.
95
- * We try the public surface first and fall back to a permissive
96
- * `true` so we never *block* a legitimate broadcast just because we
97
- * couldn't introspect the subscriber set. This stays a perf hint, not
98
- * a correctness gate.
99
- */
100
- function hasSubscribers(server: any, channelName: string): boolean {
101
- try {
102
- if (typeof server.hasSubscribers === 'function') {
103
- return Boolean(server.hasSubscribers(channelName))
104
- }
105
- if (typeof server.subscriberCount === 'function') {
106
- return server.subscriberCount(channelName) > 0
107
- }
108
- const channels = server.channels ?? server.clients
109
- if (channels && typeof channels.get === 'function') {
110
- const set = channels.get(channelName)
111
- const size = (set && (set.size ?? set.length)) ?? null
112
- if (typeof size === 'number') return size > 0
113
- }
114
- }
115
- catch {
116
- // Fall through — if introspection threw, treat as "subscribers might exist".
117
- }
118
- return true
119
- }
120
-
121
- export interface BroadcastInstance {
122
- channel?: () => string | string[]
123
- broadcastOn?: () => string | string[]
124
- event?: () => string
125
- broadcastAs?: () => string
126
- data?: () => any
127
- broadcastWith?: () => any
128
- handle?: (payload?: any) => Promise<void> | void
129
- }
130
-
131
- /**
132
- * Stacks Broadcast class for backward compatibility
133
- * Wraps ts-broadcasting's BroadcastServer
134
- */
135
- export class Broadcast {
136
- /**
137
- * Connect to the realtime service
138
- */
139
- async connect(): Promise<void> {
140
- // No-op - connection is managed by BroadcastServer
141
- }
142
-
143
- /**
144
- * Disconnect from the realtime service
145
- */
146
- async disconnect(): Promise<void> {
147
- // No-op - disconnection is managed by BroadcastServer
148
- }
149
-
150
- /**
151
- * Subscribe to a channel
152
- */
153
- subscribe(channel: string, callback: (data: any) => void): void {
154
- // Subscription is client-side, not server-side
155
- // This is handled by BroadcastClient
156
- log.warn('Broadcast.subscribe() is a client-side operation. Use BroadcastClient instead.')
157
- }
158
-
159
- /**
160
- * Unsubscribe from a channel
161
- */
162
- unsubscribe(channel: string): void {
163
- // Unsubscription is client-side, not server-side
164
- log.warn('Broadcast.unsubscribe() is a client-side operation. Use BroadcastClient instead.')
165
- }
166
-
167
- /**
168
- * Broadcast an event to a channel
169
- *
170
- * Skips the wire-level broadcast when no subscribers are listening on
171
- * the resolved channel. Without this, every emit walked the channel
172
- * multiplexer, serialized the payload, and looped through an empty
173
- * subscriber set — which is wasted work that compounds when chatty
174
- * model-event broadcasts run on cold sockets.
175
- */
176
- broadcast(channel: string, event: string, data?: any, type: ChannelType = 'public'): void {
177
- const server = getServer()
178
-
179
- if (!server) {
180
- log.warn('Broadcast server not initialized')
181
- return
182
- }
183
-
184
- let channelName = channel
185
- if (type === 'private' && !channel.startsWith('private-')) {
186
- channelName = `private-${channel}`
187
- }
188
- else if (type === 'presence' && !channel.startsWith('presence-')) {
189
- channelName = `presence-${channel}`
190
- }
191
-
192
- if (!hasSubscribers(server, channelName)) {
193
- log.debug(`[Broadcast] Skipping '${event}' on '${channelName}' — no subscribers`)
194
- return
195
- }
196
-
197
- // Backpressure guard (stacksjs/stacks#1877 R-2). Opt-in via
198
- // `setBackpressureGuard({...})`. Default is no-op so existing
199
- // callers see no behavior change. Runs before the broadcast so
200
- // a slow consumer detected on the previous tick fires its
201
- // `onSlow` handler before more bytes are queued onto its socket.
202
- checkBackpressure(server, channelName)
203
-
204
- // Record the message in the replay buffer BEFORE the wire-level
205
- // broadcast (stacksjs/stacks#1877 R-3). Opt-in via
206
- // `setReplayBuffer({...})`; default is no-op so non-buffered
207
- // channels see zero overhead. Recording first means a transient
208
- // broadcast failure (next try block) doesn't leave a buffered
209
- // ghost on a channel where nobody received the event.
210
- recordBroadcast(channelName, event, data)
211
-
212
- try {
213
- server.broadcast(channelName, event, data)
214
- }
215
- catch (err) {
216
- log.error(`[Broadcast] Failed to broadcast event '${event}' to channel '${channelName}':`, err)
217
- }
218
- }
219
-
220
- /**
221
- * Check if connected to the realtime service
222
- */
223
- isConnected(): boolean {
224
- return getServer() !== null
225
- }
226
- }
227
-
228
- /**
229
- * Run a broadcast from a broadcast file
230
- *
231
- * @example
232
- * await runBroadcast('OrderCreated', { orderId: 123 })
233
- */
234
- export async function runBroadcast(name: string, payload?: any): Promise<void> {
235
- // Dynamically import path utilities to avoid build-time issues
236
- const { appPath } = await import('@stacksjs/path')
237
- const bun = await import('bun')
238
-
239
- let broadcastFiles: string[]
240
- try {
241
- broadcastFiles = (bun as any).globSync([appPath('Broadcasts/**/*.ts')], { absolute: true })
242
- }
243
- catch (error) {
244
- throw new Error(`Failed to scan broadcast files: ${error instanceof Error ? error.message : String(error)}`)
245
- }
246
-
247
- const broadcastFile = broadcastFiles.find((file: string) => file.endsWith(`${name}.ts`))
248
-
249
- if (!broadcastFile)
250
- throw new Error(`Broadcast ${name} not found`)
251
-
252
- let broadcastModule: any
253
- try {
254
- broadcastModule = await import(broadcastFile)
255
- }
256
- catch (error) {
257
- throw new Error(`Failed to import broadcast '${name}': ${error instanceof Error ? error.message : String(error)}`)
258
- }
259
-
260
- const instance = broadcastModule.default as BroadcastInstance
261
-
262
- // Handle using handle() method
263
- if (instance.handle) {
264
- await instance.handle(payload)
265
- return
266
- }
267
-
268
- // Handle using BroadcastEvent-like interface
269
- const server = getServer()
270
- if (!server) {
271
- throw new Error('Broadcast server not initialized')
272
- }
273
-
274
- const channels = instance.broadcastOn?.() || instance.channel?.() || []
275
- const eventName = instance.broadcastAs?.() || instance.event?.() || name
276
- const data = instance.broadcastWith?.() || instance.data?.() || payload
277
-
278
- // Convert to BroadcastEvent and broadcast
279
- const event: BroadcastEvent = {
280
- shouldBroadcast: () => true,
281
- broadcastOn: () => channels,
282
- broadcastAs: () => eventName,
283
- broadcastWith: () => data,
284
- }
285
-
286
- await server.broadcaster.broadcast(event)
287
- }
288
-
289
- /**
290
- * Alias for runBroadcast.
291
- *
292
- * @example
293
- * await broadcast('OrderCreated', { orderId: 123 })
294
- */
295
- export async function broadcast(name: string, payload?: any): Promise<void> {
296
- // Validate the event name eagerly — empty / non-string names go through
297
- // ts-broadcasting and surface as confusing wire-format errors deep
298
- // inside the channel multiplexer instead of where the bug originated.
299
- if (typeof name !== 'string' || name.trim().length === 0) {
300
- throw new Error('[realtime] broadcast() requires a non-empty event name')
301
- }
302
- await runBroadcast(name, payload)
303
- }
package/src/channel.ts DELETED
@@ -1,103 +0,0 @@
1
- import type { ChannelType } from 'ts-broadcasting'
2
- import { getServer } from './server-instance'
3
-
4
- /**
5
- * Strip any well-known channel-type prefix from `name` so the
6
- * type-specific methods can apply their own prefix without the user
7
- * accidentally producing `private-presence-foo` etc. when the name
8
- * was passed in already prefixed (or with the wrong prefix).
9
- *
10
- * The previous implementation only guarded against the *matching*
11
- * prefix being doubled — `channel('presence-x').private(...)` would
12
- * incorrectly emit on `private-presence-x` instead of `private-x`.
13
- */
14
- const KNOWN_CHANNEL_PREFIXES = ['private-', 'presence-'] as const
15
-
16
- function stripPrefix(name: string): string {
17
- for (const p of KNOWN_CHANNEL_PREFIXES) {
18
- if (name.startsWith(p)) return name.slice(p.length)
19
- }
20
- return name
21
- }
22
-
23
- /**
24
- * Stacks Channel class for backward compatibility
25
- * Provides a fluent API for broadcasting to channels
26
- */
27
- export class Channel {
28
- private channelName: string
29
-
30
- constructor(channel: string) {
31
- this.channelName = channel
32
- }
33
-
34
- /**
35
- * Broadcast to a private channel
36
- */
37
- async private(event: string, data?: any): Promise<void> {
38
- const server = getServer()
39
- if (!server) {
40
- throw new Error('Broadcast server not initialized')
41
- }
42
-
43
- await server.broadcast(`private-${stripPrefix(this.channelName)}`, event, data)
44
- }
45
-
46
- /**
47
- * Broadcast to a public channel
48
- */
49
- async public(event: string, data?: any): Promise<void> {
50
- const server = getServer()
51
- if (!server) {
52
- throw new Error('Broadcast server not initialized')
53
- }
54
-
55
- // Public channels never use a prefix. If the caller passed in a
56
- // type-prefixed name by mistake, strip it so the broadcast actually
57
- // lands on the public bus rather than colliding with a private one.
58
- await server.broadcast(stripPrefix(this.channelName), event, data)
59
- }
60
-
61
- /**
62
- * Broadcast to a presence channel
63
- */
64
- async presence(event: string, data?: any): Promise<void> {
65
- const server = getServer()
66
- if (!server) {
67
- throw new Error('Broadcast server not initialized')
68
- }
69
-
70
- await server.broadcast(`presence-${stripPrefix(this.channelName)}`, event, data)
71
- }
72
-
73
- /**
74
- * Broadcast to a channel with explicit type
75
- */
76
- async broadcast(event: string, data?: any, type: ChannelType = 'public'): Promise<void> {
77
- switch (type) {
78
- case 'private':
79
- return this.private(event, data)
80
- case 'presence':
81
- return this.presence(event, data)
82
- default:
83
- return this.public(event, data)
84
- }
85
- }
86
- }
87
-
88
- /**
89
- * Create a new channel instance
90
- *
91
- * @example
92
- * // Broadcast to a public channel
93
- * await channel('orders').public('created', { id: 1 })
94
- *
95
- * // Broadcast to a private channel
96
- * await channel('orders.123').private('updated', { status: 'shipped' })
97
- *
98
- * // Broadcast to a presence channel
99
- * await channel('chat.room.1').presence('message', { text: 'Hello' })
100
- */
101
- export function channel(name: string): Channel {
102
- return new Channel(name)
103
- }
package/src/emit.ts DELETED
@@ -1,95 +0,0 @@
1
- import type { ChannelType } from 'ts-broadcasting'
2
- import { getServer } from './server-instance'
3
-
4
- export interface EmitOptions {
5
- private?: boolean
6
- presence?: boolean
7
- exclude?: string | string[]
8
- driver?: string
9
- }
10
-
11
- /**
12
- * Emit an event to a channel
13
- *
14
- * @example
15
- * // Simple emit to public channel
16
- * emit('orders', 'created', { id: 1, total: 99.99 })
17
- *
18
- * // Emit to private channel
19
- * emit('orders.123', 'updated', { status: 'shipped' }, { private: true })
20
- *
21
- * // Emit to presence channel
22
- * emit('chat.room.1', 'message', { text: 'Hello' }, { presence: true })
23
- *
24
- * // Exclude specific users
25
- * emit('chat.room.1', 'message', { text: 'Hello' }, { exclude: 'user-123' })
26
- */
27
- export function emit<T = unknown>(
28
- channel: string,
29
- event: string,
30
- data?: T,
31
- options?: EmitOptions,
32
- ): void {
33
- const server = getServer()
34
-
35
- if (!server) {
36
- console.warn('[realtime] Server not initialized, cannot emit event')
37
- return
38
- }
39
-
40
- // Determine channel type and prefix
41
- let channelName = channel
42
-
43
- if (options?.presence) {
44
- if (!channel.startsWith('presence-')) {
45
- channelName = `presence-${channel}`
46
- }
47
- }
48
- else if (options?.private) {
49
- if (!channel.startsWith('private-')) {
50
- channelName = `private-${channel}`
51
- }
52
- }
53
-
54
- // Get exclude socket ID
55
- const excludeSocketId = options?.exclude
56
- ? Array.isArray(options.exclude)
57
- ? options.exclude[0] // BroadcastServer only supports single socket exclusion
58
- : options.exclude
59
- : undefined
60
-
61
- // Broadcast the event
62
- server.broadcast(channelName, event, data, excludeSocketId)
63
- }
64
-
65
- /**
66
- * Emit an event to a specific user
67
- *
68
- * @example
69
- * emitToUser('user-123', 'notification', { message: 'You have a new order!' })
70
- */
71
- export function emitToUser<T = unknown>(
72
- userId: string | number,
73
- event: string,
74
- data?: T,
75
- options?: Omit<EmitOptions, 'private'>,
76
- ): void {
77
- emit(`private-user.${userId}`, event, data, { ...options, private: true })
78
- }
79
-
80
- /**
81
- * Emit an event to multiple users
82
- *
83
- * @example
84
- * emitToUsers(['user-1', 'user-2'], 'announcement', { message: 'Server maintenance!' })
85
- */
86
- export function emitToUsers<T = unknown>(
87
- userIds: (string | number)[],
88
- event: string,
89
- data?: T,
90
- options?: Omit<EmitOptions, 'private'>,
91
- ): void {
92
- for (const userId of userIds) {
93
- emitToUser(userId, event, data, options)
94
- }
95
- }
package/src/heartbeat.ts DELETED
@@ -1,184 +0,0 @@
1
- /**
2
- * Server-side WebSocket heartbeat (stacksjs/stacks#1877 R-5).
3
- *
4
- * Background: a half-closed socket (network cable pulled, idle NAT
5
- * timeout, mobile client lost connection without sending FIN) lingers
6
- * in the server's memory until the OS surfaces the close. On long-running
7
- * servers behind NAT — exactly the production deployment shape — these
8
- * accumulate as "ghost" subscribers that the broadcast loop still tries
9
- * to write to.
10
- *
11
- * Fix: opt-in heartbeat that pings every `intervalMs`, tracks the last
12
- * pong timestamp per socket, and closes any socket that misses
13
- * `maxMissedPongs` consecutive cycles. Defaults: 30s interval, 2 missed
14
- * pongs = 90s effective deadline. Apps install via
15
- * `setHeartbeatConfig({...})` once at boot.
16
- *
17
- * Limitation: ts-broadcasting owns the socket lifecycle. We can detect
18
- * dead sockets but the underlying server has to honor a close call.
19
- * The default `onDead` handler tries `socket.close()` if available;
20
- * apps can override to do harder cleanup.
21
- */
22
-
23
- import { log } from '@stacksjs/logging'
24
- import { getServer } from './server-instance'
25
-
26
- export interface HeartbeatConfig {
27
- /** Ping interval in milliseconds. Default: 30s. */
28
- intervalMs?: number
29
- /**
30
- * Maximum consecutive missed pongs before declaring the socket
31
- * dead and calling `onDead`. Default: 2.
32
- */
33
- maxMissedPongs?: number
34
- /**
35
- * Called when a socket misses `maxMissedPongs` pings in a row.
36
- * Default action: try `socket.close()`. Install a custom handler
37
- * to drop the socket from per-channel sets, emit a metric, etc.
38
- */
39
- onDead?: (socket: unknown) => void
40
- }
41
-
42
- interface HeartbeatState {
43
- intervalMs: number
44
- maxMissedPongs: number
45
- onDead: (socket: unknown) => void
46
- /** Map<socket, missed-pong-count> */
47
- missed: WeakMap<object, number>
48
- timer: ReturnType<typeof setInterval> | null
49
- }
50
-
51
- let state: HeartbeatState | null = null
52
-
53
- /**
54
- * Install (or replace) the heartbeat config. Pass `null` to stop
55
- * the heartbeat loop. Safe to call multiple times — the previous
56
- * timer is cleared before the new one starts.
57
- */
58
- export function setHeartbeatConfig(cfg: HeartbeatConfig | null): void {
59
- if (state?.timer) {
60
- clearInterval(state.timer)
61
- state.timer = null
62
- }
63
- if (!cfg) {
64
- state = null
65
- return
66
- }
67
-
68
- const intervalMs = cfg.intervalMs ?? 30_000
69
- const maxMissedPongs = cfg.maxMissedPongs ?? 2
70
- const onDead = cfg.onDead ?? defaultOnDead
71
-
72
- state = {
73
- intervalMs,
74
- maxMissedPongs,
75
- onDead,
76
- missed: new WeakMap(),
77
- timer: null,
78
- }
79
-
80
- state.timer = setInterval(() => {
81
- if (!state) return
82
- runOneTick()
83
- }, intervalMs)
84
- ;(state.timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.()
85
- }
86
-
87
- /** Read the current config — useful for tests. */
88
- export function getHeartbeatConfig(): Readonly<HeartbeatState> | null {
89
- return state
90
- }
91
-
92
- /**
93
- * Manually fire a single heartbeat tick. Exposed for tests; in
94
- * production it's invoked by the internal interval.
95
- */
96
- export function runOneTick(): void {
97
- if (!state) return
98
- const server = getServer()
99
- if (!server) return
100
-
101
- // Collect every active socket across all channels. ts-broadcasting
102
- // exposes them under different property names by version; probe
103
- // both `channels` and `clients` (already done by hasSubscribers and
104
- // checkBackpressure — same iteration pattern).
105
- const allSockets = collectSockets(server)
106
-
107
- for (const socket of allSockets) {
108
- const ws = socket as { send?: (data: string) => void, ping?: () => void, close?: (code?: number, reason?: string) => void }
109
- const missed = state.missed.get(socket) ?? 0
110
-
111
- if (missed >= state.maxMissedPongs) {
112
- log.warn(`[realtime] socket missed ${missed} pongs — declaring dead`)
113
- try {
114
- state.onDead(socket)
115
- }
116
- catch (err) {
117
- log.warn(`[realtime] heartbeat onDead handler threw: ${err instanceof Error ? err.message : String(err)}`)
118
- }
119
- state.missed.delete(socket as object)
120
- continue
121
- }
122
-
123
- // Bump the missed counter BEFORE sending the ping — when the
124
- // client pongs back, `markPong` resets it to 0. If the client
125
- // never replies, the counter grows tick-by-tick until we
126
- // declare it dead above.
127
- state.missed.set(socket as object, missed + 1)
128
-
129
- try {
130
- if (typeof ws.ping === 'function') {
131
- ws.ping()
132
- }
133
- else if (typeof ws.send === 'function') {
134
- // Fallback for sockets without a native ping helper —
135
- // send an application-level heartbeat frame the client
136
- // can pong via the same mechanism.
137
- ws.send('__stacks_ping__')
138
- }
139
- }
140
- catch {
141
- // Send threw — socket is likely already broken. Mark dead next tick.
142
- }
143
- }
144
- }
145
-
146
- /**
147
- * Called from the server's pong handler (or message handler when
148
- * fallback `__stacks_ping__` text frames are in use). Resets the
149
- * missed-pong counter for the given socket so it doesn't get
150
- * declared dead.
151
- */
152
- export function markPong(socket: object): void {
153
- if (!state) return
154
- state.missed.delete(socket)
155
- }
156
-
157
- function collectSockets(server: unknown): object[] {
158
- const out = new Set<object>()
159
- try {
160
- const channels = (server as { channels?: unknown, clients?: unknown }).channels
161
- ?? (server as { channels?: unknown, clients?: unknown }).clients
162
- if (channels && typeof (channels as { values?: () => Iterable<unknown> }).values === 'function') {
163
- for (const set of (channels as { values: () => Iterable<unknown> }).values()) {
164
- if (set && typeof (set as { [Symbol.iterator]: unknown })[Symbol.iterator] === 'function') {
165
- for (const entry of set as Iterable<unknown>) {
166
- const ws = (entry && typeof entry === 'object' && 'ws' in entry) ? (entry as { ws: object }).ws : entry
167
- if (ws && typeof ws === 'object') out.add(ws as object)
168
- }
169
- }
170
- }
171
- }
172
- }
173
- catch {
174
- // Introspection failed — same fallthrough policy as backpressure
175
- // guard / hasSubscribers. Heartbeat is best-effort.
176
- }
177
- return [...out]
178
- }
179
-
180
- function defaultOnDead(socket: unknown): void {
181
- const ws = socket as { close?: (code?: number, reason?: string) => void }
182
- if (typeof ws.close === 'function')
183
- ws.close(1011, 'heartbeat timeout')
184
- }
package/src/index.ts DELETED
@@ -1,43 +0,0 @@
1
- /**
2
- * Stacks Realtime Module
3
- *
4
- * This module provides real-time broadcasting capabilities for Stacks applications.
5
- * It's built on top of ts-broadcasting and provides a familiar Laravel-like API.
6
- */
7
-
8
- // Re-export everything from ts-broadcasting
9
- export * from 'ts-broadcasting'
10
-
11
- // Note: all exports are already provided by `export * from 'ts-broadcasting'` above.
12
- // Aliases are provided below for convenience.
13
-
14
- // Server instance management
15
- export { getServer, setServer, createServer, stopServer } from './server-instance'
16
-
17
- // Stacks-specific exports
18
- export { emit, emitToUser, emitToUsers } from './emit'
19
- export type { EmitOptions } from './emit'
20
- export { channel as createChannel, Channel as StacksChannel } from './channel'
21
- export { broadcast as dispatchBroadcast, runBroadcast, Broadcast as LegacyBroadcast } from './broadcast'
22
- export type { BroadcastInstance } from './broadcast'
23
- // Backpressure guard for slow consumers (stacksjs/stacks#1877 R-2).
24
- // Opt-in via setBackpressureGuard; default is no-op.
25
- export { setBackpressureGuard, getBackpressureGuard } from './broadcast'
26
- export type { BackpressureGuardConfig } from './broadcast'
27
-
28
- // Heartbeat ping/pong for detecting half-closed sockets
29
- // (stacksjs/stacks#1877 R-5). Opt-in via setHeartbeatConfig.
30
- export { getHeartbeatConfig, markPong, runOneTick, setHeartbeatConfig } from './heartbeat'
31
- export type { HeartbeatConfig } from './heartbeat'
32
-
33
- // At-least-once replay buffer for reconnect (stacksjs/stacks#1877 R-3).
34
- // Opt-in via setReplayBuffer. Apps wire `replaySince(channel, seq)`
35
- // into their reconnect handler to re-send missed messages.
36
- export { debugSnapshot, getReplayBuffer, pruneExpired, recordBroadcast, replaySince, setReplayBuffer } from './replay-buffer'
37
- export type { BufferedMessage, ReplayBufferConfig } from './replay-buffer'
38
- export { setBunSocket, handleWebSocketRequest, storeWebSocketEvent } from './ws'
39
- // WebSocket authenticator wiring (stacksjs/stacks#1877 R-1). Install
40
- // once at server boot to require a valid token / cookie at the
41
- // handshake boundary — without it, the upgrade proceeds unauthed.
42
- export { setWsAuthenticator, getWsAuthenticator } from './ws'
43
- export type { WsAuthenticator, WsAuthResult } from './ws'
@@ -1,201 +0,0 @@
1
- /**
2
- * Per-channel message replay buffer (stacksjs/stacks#1877 R-3).
3
- *
4
- * Background: ts-broadcasting delivers messages at-most-once — a client
5
- * that drops between two broadcasts loses everything in flight. For
6
- * channels where the app needs every message (chat, presence, order
7
- * updates), reconnect-after-network-blip becomes a silent data loss.
8
- *
9
- * Fix: opt-in per-channel ring buffer that retains the most-recent N
10
- * messages with monotonic sequence IDs. On reconnect, the client sends
11
- * its last-seen seq; the server replays everything stored after that
12
- * point. Apps install via `setReplayBuffer({ channels, maxPerChannel,
13
- * ttlMs })`. Buffer is in-process — for cross-instance replay, route
14
- * through a shared store (Redis Streams, Postgres LISTEN/NOTIFY, etc.).
15
- *
16
- * Memory shape: `Map<channel, RingBuffer<BufferedMessage>>`. Bounded by
17
- * `maxPerChannel` (default 100) so a chatty channel can't OOM the
18
- * server. Entries past `ttlMs` are evicted lazily on read — apps that
19
- * want eager eviction can call `pruneExpired()` from their own timer.
20
- */
21
-
22
- export interface ReplayBufferConfig {
23
- /**
24
- * Glob-ish channel-name patterns to buffer. `'*'` buffers every
25
- * channel; `'orders.*'` buffers channels matching that prefix. The
26
- * empty array (default) disables buffering for all channels.
27
- */
28
- channels?: string[]
29
- /**
30
- * Maximum messages retained per channel. Older entries are evicted
31
- * FIFO. Default: 100.
32
- */
33
- maxPerChannel?: number
34
- /**
35
- * Max age (milliseconds) of any buffered message. Entries older
36
- * than this are evicted lazily on read. Default: 5 minutes.
37
- */
38
- ttlMs?: number
39
- }
40
-
41
- export interface BufferedMessage {
42
- /** Monotonic per-channel sequence id. Starts at 1. */
43
- seq: number
44
- /** Wall-clock timestamp when the message was recorded. */
45
- ts: number
46
- /** Event name from `server.broadcast(channel, event, data)`. */
47
- event: string
48
- /** Payload from the broadcast — opaque to the buffer. */
49
- data: unknown
50
- }
51
-
52
- interface ChannelState {
53
- /** Ring buffer of recent messages (head = oldest). */
54
- messages: BufferedMessage[]
55
- /** Next sequence id to assign. */
56
- nextSeq: number
57
- }
58
-
59
- interface BufferRegistry {
60
- channels: string[]
61
- maxPerChannel: number
62
- ttlMs: number
63
- state: Map<string, ChannelState>
64
- }
65
-
66
- let registry: BufferRegistry | null = null
67
-
68
- /**
69
- * Install (or replace) the replay-buffer config. Pass `null` to disable
70
- * and drop all buffered state. Safe to call multiple times.
71
- */
72
- export function setReplayBuffer(cfg: ReplayBufferConfig | null): void {
73
- if (!cfg) {
74
- registry = null
75
- return
76
- }
77
- registry = {
78
- channels: cfg.channels ?? [],
79
- maxPerChannel: cfg.maxPerChannel ?? 100,
80
- ttlMs: cfg.ttlMs ?? 5 * 60_000,
81
- state: new Map(),
82
- }
83
- }
84
-
85
- /** Read the current config — useful for tests. */
86
- export function getReplayBuffer(): Readonly<BufferRegistry> | null {
87
- return registry
88
- }
89
-
90
- /**
91
- * Returns true if the configured patterns cover `channel`. Pattern
92
- * matching is glob-ish: `*` matches any channel; otherwise a literal
93
- * prefix ending in `.*` (e.g. `orders.*`) matches any channel
94
- * starting with that prefix.
95
- */
96
- function shouldBuffer(channel: string): boolean {
97
- if (!registry || registry.channels.length === 0) return false
98
- for (const pattern of registry.channels) {
99
- if (pattern === '*') return true
100
- if (pattern === channel) return true
101
- if (pattern.endsWith('.*') && channel.startsWith(pattern.slice(0, -1))) return true
102
- }
103
- return false
104
- }
105
-
106
- /**
107
- * Called by the broadcast wrapper for every outbound message on a
108
- * matched channel. Records the message and assigns a monotonic seq.
109
- * Returns the seq for the caller to optionally include in the
110
- * outbound payload — clients store the latest seq locally and send
111
- * it back on reconnect via `replaySince`.
112
- */
113
- export function recordBroadcast(channel: string, event: string, data: unknown): number | null {
114
- if (!registry || !shouldBuffer(channel)) return null
115
-
116
- let state = registry.state.get(channel)
117
- if (!state) {
118
- state = { messages: [], nextSeq: 1 }
119
- registry.state.set(channel, state)
120
- }
121
-
122
- const msg: BufferedMessage = {
123
- seq: state.nextSeq++,
124
- ts: Date.now(),
125
- event,
126
- data,
127
- }
128
- state.messages.push(msg)
129
-
130
- // FIFO eviction past the size cap. Splicing from the front is O(n)
131
- // but maxPerChannel is bounded (default 100) so this stays cheap.
132
- if (state.messages.length > registry.maxPerChannel)
133
- state.messages.splice(0, state.messages.length - registry.maxPerChannel)
134
-
135
- return msg.seq
136
- }
137
-
138
- /**
139
- * Replay every buffered message on `channel` with `seq > sinceSeq`.
140
- * Stale entries (older than `ttlMs`) are evicted on the way through
141
- * so callers don't see them. Returns the array of messages the
142
- * caller should re-send to the reconnecting client.
143
- *
144
- * @example
145
- * ```ts
146
- * // Inside the reconnect handler:
147
- * const missed = replaySince('orders', lastSeenSeq)
148
- * for (const msg of missed) {
149
- * socket.send(JSON.stringify({ event: msg.event, data: msg.data, seq: msg.seq }))
150
- * }
151
- * ```
152
- */
153
- export function replaySince(channel: string, sinceSeq: number): BufferedMessage[] {
154
- if (!registry) return []
155
- const state = registry.state.get(channel)
156
- if (!state) return []
157
-
158
- const now = Date.now()
159
- const ttl = registry.ttlMs
160
- // Lazy TTL eviction — drop expired messages from the head.
161
- while (state.messages.length > 0 && now - state.messages[0]!.ts > ttl)
162
- state.messages.shift()
163
-
164
- if (state.messages.length === 0) return []
165
- // Binary-search would be marginally faster; linear is fine at
166
- // maxPerChannel=100 and clearer for the buffer's volume.
167
- return state.messages.filter(m => m.seq > sinceSeq)
168
- }
169
-
170
- /**
171
- * Drop expired entries across every tracked channel. Called by apps
172
- * that want eager memory reclaim — the default lazy-on-read path is
173
- * adequate for most workloads.
174
- */
175
- export function pruneExpired(): void {
176
- if (!registry) return
177
- const now = Date.now()
178
- const ttl = registry.ttlMs
179
- for (const state of registry.state.values()) {
180
- while (state.messages.length > 0 && now - state.messages[0]!.ts > ttl)
181
- state.messages.shift()
182
- }
183
- }
184
-
185
- /**
186
- * Snapshot the buffer state — debugging only. Don't depend on this
187
- * shape in production code; the internals may change.
188
- */
189
- export function debugSnapshot(): Record<string, { count: number, firstSeq: number | null, lastSeq: number | null }> {
190
- const out: Record<string, { count: number, firstSeq: number | null, lastSeq: number | null }> = {}
191
- if (!registry) return out
192
- for (const [ch, state] of registry.state) {
193
- out[ch] = {
194
- count: state.messages.length,
195
- firstSeq: state.messages[0]?.seq ?? null,
196
- lastSeq: state.messages[state.messages.length - 1]?.seq ?? null,
197
- }
198
- }
199
- return out
200
- }
201
-
@@ -1,39 +0,0 @@
1
- import type { BroadcastServer, ServerConfig } from 'ts-broadcasting'
2
-
3
- let serverInstance: BroadcastServer | null = null
4
-
5
- /**
6
- * Set the global broadcast server instance
7
- */
8
- export function setServer(server: BroadcastServer): void {
9
- serverInstance = server
10
- }
11
-
12
- /**
13
- * Get the global broadcast server instance
14
- */
15
- export function getServer(): BroadcastServer | null {
16
- return serverInstance
17
- }
18
-
19
- /**
20
- * Create and start a new broadcast server
21
- */
22
- export async function createServer(config: ServerConfig): Promise<BroadcastServer> {
23
- const broadcasting = await import('ts-broadcasting')
24
- const Server = (broadcasting as any).BroadcastServer
25
- const server = new Server(config)
26
- await server.start()
27
- setServer(server)
28
- return server
29
- }
30
-
31
- /**
32
- * Stop the current broadcast server
33
- */
34
- export async function stopServer(): Promise<void> {
35
- if (serverInstance) {
36
- await serverInstance.stop()
37
- serverInstance = null
38
- }
39
- }
package/src/ws.ts DELETED
@@ -1,110 +0,0 @@
1
- import type { Server } from 'bun'
2
- import type { BroadcastServer } from 'ts-broadcasting'
3
- import { getServer, setServer } from './server-instance'
4
-
5
- /**
6
- * Set the broadcast server instance
7
- * @deprecated Use setServer from './server-instance' instead
8
- */
9
- export function setBunSocket(server: BroadcastServer | null): void {
10
- if (server) {
11
- setServer(server)
12
- }
13
- }
14
-
15
- /**
16
- * Store WebSocket event in the database
17
- * Note: This function is now a no-op. WebSocket events are tracked internally by ts-broadcasting.
18
- */
19
- export async function storeWebSocketEvent(
20
- _type: 'disconnection' | 'error' | 'success',
21
- _socket: string,
22
- _details: string,
23
- ): Promise<void> {
24
- // WebSocket events are tracked internally by ts-broadcasting's monitoring system
25
- // This function is kept for backward compatibility
26
- }
27
-
28
- /**
29
- * Optional authenticator invoked at WebSocket handshake time.
30
- *
31
- * Apps install one via `setWsAuthenticator(fn)` to require a valid
32
- * token / cookie / signed query param BEFORE the upgrade goes through
33
- * (stacksjs/stacks#1877 R-1). Without an authenticator, the upgrade
34
- * proceeds as before — useful for local-dev / public-broadcast apps,
35
- * but production apps should always install one.
36
- *
37
- * The returned `data` is attached to the upgraded socket as `ws.data`
38
- * so per-message authorization can read it back without re-parsing
39
- * the auth token on every frame.
40
- */
41
- export type WsAuthenticator = (req: Request) => Promise<WsAuthResult> | WsAuthResult
42
-
43
- /** Result returned from a `WsAuthenticator`. */
44
- export type WsAuthResult =
45
- | { ok: true, data?: Record<string, unknown> }
46
- | { ok: false, status?: number, message?: string }
47
-
48
- let wsAuthenticator: WsAuthenticator | null = null
49
-
50
- /**
51
- * Install (or clear) the global WebSocket authenticator. Called once
52
- * at server boot; pass `null` to disable auth (the unauthed default).
53
- */
54
- export function setWsAuthenticator(fn: WsAuthenticator | null): void {
55
- wsAuthenticator = fn
56
- }
57
-
58
- /** Read the currently-installed authenticator. Useful for tests. */
59
- export function getWsAuthenticator(): WsAuthenticator | null {
60
- return wsAuthenticator
61
- }
62
-
63
- /**
64
- * Handle WebSocket request upgrade. If an authenticator is installed
65
- * (see `setWsAuthenticator`), it runs FIRST and a 401 is returned on
66
- * failure (stacksjs/stacks#1877 R-1). Without an authenticator the
67
- * upgrade proceeds for backwards-compat — the function still works
68
- * the same way it did before.
69
- */
70
- export async function handleWebSocketRequest(req: Request, server: Server<any>): Promise<Response | undefined> {
71
- const broadcastServer = getServer()
72
-
73
- if (!broadcastServer) {
74
- return new Response('WebSocket server not initialized', { status: 500 })
75
- }
76
-
77
- // Authentication at the upgrade boundary, BEFORE the socket is
78
- // established. Without this, ts-broadcasting's per-channel auth
79
- // ran only after the connection was open — meaning an attacker
80
- // could establish a socket, subscribe to public channels, and
81
- // burn server resources without ever presenting a credential.
82
- if (wsAuthenticator) {
83
- try {
84
- const result = await wsAuthenticator(req)
85
- if (!result.ok) {
86
- return new Response(
87
- result.message ?? 'Unauthorized',
88
- { status: result.status ?? 401 },
89
- )
90
- }
91
- // Pass the auth data through to the upgraded socket so
92
- // downstream handlers (channel auth callbacks, presence
93
- // tracking) can read it via `ws.data` without re-parsing.
94
- const upgraded = server.upgrade(req, result.data ? { data: result.data } : undefined)
95
- if (upgraded) return undefined
96
- return new Response('WebSocket upgrade failed', { status: 400 })
97
- }
98
- catch (err) {
99
- // Don't expose internals to the client — log and 500.
100
- // eslint-disable-next-line no-console
101
- console.error('[realtime] WebSocket authenticator threw:', err)
102
- return new Response('WebSocket auth error', { status: 500 })
103
- }
104
- }
105
-
106
- // No authenticator installed — preserve the original behavior.
107
- const success = server.upgrade(req)
108
- if (success) return undefined
109
- return new Response('WebSocket upgrade failed', { status: 400 })
110
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes