@stacksjs/realtime 0.70.53 → 0.70.55
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 +3 -2
- package/src/broadcast.ts +303 -0
- package/src/channel.ts +103 -0
- package/src/emit.ts +95 -0
- package/src/heartbeat.ts +184 -0
- package/src/index.ts +43 -0
- package/src/replay-buffer.ts +201 -0
- package/src/server-instance.ts +39 -0
- package/src/ws.ts +110 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/realtime",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.70.
|
|
4
|
+
"version": "0.70.55",
|
|
5
5
|
"description": "The Stacks realtime integration. Built on top of ts-broadcasting.",
|
|
6
6
|
"author": "Chris Breuer",
|
|
7
7
|
"contributors": [
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"types": "dist/index.d.ts",
|
|
41
41
|
"files": [
|
|
42
42
|
"README.md",
|
|
43
|
-
"dist"
|
|
43
|
+
"dist",
|
|
44
|
+
"src"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
47
|
"build": "bun build.ts",
|
package/src/broadcast.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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'
|
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|