@stacksjs/realtime 0.70.88 → 0.70.90

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,62 @@
1
+ import type { ChannelType } from 'ts-broadcasting';
2
+ /**
3
+ * Install (or clear) the backpressure guard. Pass `null` to disable.
4
+ */
5
+ export declare function setBackpressureGuard(cfg: BackpressureGuardConfig | null): void;
6
+ /**
7
+ * Read the currently-installed guard config (useful for tests).
8
+ */
9
+ export declare function getBackpressureGuard(): Required<BackpressureGuardConfig> | null;
10
+ /**
11
+ * Run a broadcast from a broadcast file
12
+ *
13
+ * @example
14
+ * await runBroadcast('OrderCreated', { orderId: 123 })
15
+ */
16
+ export declare function runBroadcast(name: string, payload?: any): Promise<void>;
17
+ /**
18
+ * Alias for runBroadcast.
19
+ *
20
+ * @example
21
+ * await broadcast('OrderCreated', { orderId: 123 })
22
+ */
23
+ export declare function broadcast(name: string, payload?: any): Promise<void>;
24
+ /**
25
+ * Backpressure guard config (stacksjs/stacks#1877 R-2). The default
26
+ * threshold is 1 MiB of buffered-bytes per socket — above this, the
27
+ * configured `onSlow` callback fires once per offending socket per
28
+ * broadcast. Apps install via `setBackpressureGuard({...})`; the
29
+ * default is "no guard" for backwards-compat, so existing callers
30
+ * see no behavior change until they opt in.
31
+ *
32
+ * Why opt-in: the underlying ts-broadcasting `server.broadcast()` is
33
+ * synchronous and we can't inject between message-serialize and
34
+ * socket-write. The best we can do at the Stacks layer is detect
35
+ * slow consumers AROUND the broadcast call and let the app decide
36
+ * what to do (close socket, drop client from channel, scale up).
37
+ */
38
+ export declare interface BackpressureGuardConfig {
39
+ maxPerSocketBytes?: number
40
+ onSlow?: (info: { channelName: string, backpressure: number, socket: unknown }) => void
41
+ }
42
+ export declare interface BroadcastInstance {
43
+ channel?: () => string | string[]
44
+ broadcastOn?: () => string | string[]
45
+ event?: () => string
46
+ broadcastAs?: () => string
47
+ data?: () => any
48
+ broadcastWith?: () => any
49
+ handle?: (payload?: any) => Promise<void> | void
50
+ }
51
+ /**
52
+ * Stacks Broadcast class for backward compatibility
53
+ * Wraps ts-broadcasting's BroadcastServer
54
+ */
55
+ export declare class Broadcast {
56
+ connect(): Promise<void>;
57
+ disconnect(): Promise<void>;
58
+ subscribe(channel: string, callback: (data: any) => void): void;
59
+ unsubscribe(channel: string): void;
60
+ broadcast(channel: string, event: string, data?: any, type?: ChannelType): void;
61
+ isConnected(): boolean;
62
+ }
@@ -0,0 +1,124 @@
1
+ import { log } from "@stacksjs/logging";
2
+ import { recordBroadcast } from "./replay-buffer";
3
+ import { getServer } from "./server-instance";
4
+ let backpressureConfig = null;
5
+ export function setBackpressureGuard(cfg) {
6
+ if (!cfg) {
7
+ backpressureConfig = null;
8
+ return;
9
+ }
10
+ backpressureConfig = {
11
+ maxPerSocketBytes: cfg.maxPerSocketBytes ?? 1048576,
12
+ onSlow: cfg.onSlow ?? ((info) => {
13
+ log.warn(`[realtime] slow consumer on '${info.channelName}': ${info.backpressure} bytes buffered`);
14
+ })
15
+ };
16
+ }
17
+ export function getBackpressureGuard() {
18
+ return backpressureConfig;
19
+ }
20
+ function checkBackpressure(server, channelName) {
21
+ if (!backpressureConfig)
22
+ return;
23
+ try {
24
+ const channels = server.channels ?? server.clients, set = channels && typeof channels.get === "function" ? channels.get(channelName) : null;
25
+ if (!set || typeof set[Symbol.iterator] !== "function")
26
+ return;
27
+ const { maxPerSocketBytes, onSlow } = backpressureConfig;
28
+ for (const entry of set) {
29
+ const ws = entry && typeof entry === "object" && "ws" in entry ? entry.ws : entry, bp = ws && typeof ws === "object" && "backpressure" in ws ? ws.backpressure : null;
30
+ if (typeof bp === "number" && bp > maxPerSocketBytes)
31
+ onSlow({ channelName, backpressure: bp, socket: ws });
32
+ }
33
+ } catch {}
34
+ }
35
+ function hasSubscribers(server, channelName) {
36
+ try {
37
+ if (typeof server.hasSubscribers === "function")
38
+ return Boolean(server.hasSubscribers(channelName));
39
+ if (typeof server.subscriberCount === "function")
40
+ return server.subscriberCount(channelName) > 0;
41
+ const channels = server.channels ?? server.clients;
42
+ if (channels && typeof channels.get === "function") {
43
+ const set = channels.get(channelName), size = (set && (set.size ?? set.length)) ?? null;
44
+ if (typeof size === "number")
45
+ return size > 0;
46
+ }
47
+ } catch {}
48
+ return !0;
49
+ }
50
+
51
+ export class Broadcast {
52
+ async connect() {}
53
+ async disconnect() {}
54
+ subscribe(channel, callback) {
55
+ log.warn("Broadcast.subscribe() is a client-side operation. Use BroadcastClient instead.");
56
+ }
57
+ unsubscribe(channel) {
58
+ log.warn("Broadcast.unsubscribe() is a client-side operation. Use BroadcastClient instead.");
59
+ }
60
+ broadcast(channel, event, data, type = "public") {
61
+ const server = getServer();
62
+ if (!server) {
63
+ log.warn("Broadcast server not initialized");
64
+ return;
65
+ }
66
+ let channelName = channel;
67
+ if (type === "private" && !channel.startsWith("private-"))
68
+ channelName = `private-${channel}`;
69
+ else if (type === "presence" && !channel.startsWith("presence-"))
70
+ channelName = `presence-${channel}`;
71
+ if (!hasSubscribers(server, channelName)) {
72
+ log.debug(`[Broadcast] Skipping '${event}' on '${channelName}' \u2014 no subscribers`);
73
+ return;
74
+ }
75
+ checkBackpressure(server, channelName);
76
+ recordBroadcast(channelName, event, data);
77
+ try {
78
+ server.broadcast(channelName, event, data);
79
+ } catch (err) {
80
+ log.error(`[Broadcast] Failed to broadcast event '${event}' to channel '${channelName}':`, err);
81
+ }
82
+ }
83
+ isConnected() {
84
+ return getServer() !== null;
85
+ }
86
+ }
87
+ export async function runBroadcast(name, payload) {
88
+ const { appPath } = await import("@stacksjs/path"), bun = await import("bun");
89
+ let broadcastFiles;
90
+ try {
91
+ broadcastFiles = bun.globSync([appPath("Broadcasts/**/*.ts")], { absolute: !0 });
92
+ } catch (error) {
93
+ throw Error(`Failed to scan broadcast files: ${error instanceof Error ? error.message : String(error)}`);
94
+ }
95
+ const broadcastFile = broadcastFiles.find((file) => file.endsWith(`${name}.ts`));
96
+ if (!broadcastFile)
97
+ throw Error(`Broadcast ${name} not found`);
98
+ let broadcastModule;
99
+ try {
100
+ broadcastModule = await import(broadcastFile);
101
+ } catch (error) {
102
+ throw Error(`Failed to import broadcast '${name}': ${error instanceof Error ? error.message : String(error)}`);
103
+ }
104
+ const instance = broadcastModule.default;
105
+ if (instance.handle) {
106
+ await instance.handle(payload);
107
+ return;
108
+ }
109
+ const server = getServer();
110
+ if (!server)
111
+ throw Error("Broadcast server not initialized");
112
+ const channels = instance.broadcastOn?.() || instance.channel?.() || [], eventName = instance.broadcastAs?.() || instance.event?.() || name, data = instance.broadcastWith?.() || instance.data?.() || payload, event = {
113
+ shouldBroadcast: () => !0,
114
+ broadcastOn: () => channels,
115
+ broadcastAs: () => eventName,
116
+ broadcastWith: () => data
117
+ };
118
+ await server.broadcaster.broadcast(event);
119
+ }
120
+ export async function broadcast(name, payload) {
121
+ if (typeof name !== "string" || name.trim().length === 0)
122
+ throw Error("[realtime] broadcast() requires a non-empty event name");
123
+ await runBroadcast(name, payload);
124
+ }
@@ -0,0 +1,24 @@
1
+ import type { ChannelType } from 'ts-broadcasting';
2
+ /**
3
+ * Create a new channel instance
4
+ *
5
+ * @example
6
+ * // Broadcast to a public channel
7
+ * await channel('orders').public('created', { id: 1 })
8
+ *
9
+ * // Broadcast to a private channel
10
+ * await channel('orders.123').private('updated', { status: 'shipped' })
11
+ *
12
+ * // Broadcast to a presence channel
13
+ * await channel('chat.room.1').presence('message', { text: 'Hello' })
14
+ */
15
+ export declare function channel(name: string): Channel;
16
+ /**
17
+ * Stacks Channel class for backward compatibility
18
+ * Provides a fluent API for broadcasting to channels
19
+ */
20
+ export declare class Channel {
21
+ constructor(channel: string);
22
+ presence(event: string, data?: any): Promise<void>;
23
+ broadcast(event: string, data?: any, type?: ChannelType): Promise<void>;
24
+ }
@@ -0,0 +1,46 @@
1
+ import { getServer } from "./server-instance";
2
+ const KNOWN_CHANNEL_PREFIXES = ["private-", "presence-"];
3
+ function stripPrefix(name) {
4
+ for (const p of KNOWN_CHANNEL_PREFIXES)
5
+ if (name.startsWith(p))
6
+ return name.slice(p.length);
7
+ return name;
8
+ }
9
+
10
+ export class Channel {
11
+ channelName;
12
+ constructor(channel) {
13
+ this.channelName = channel;
14
+ }
15
+ async private(event, data) {
16
+ const server = getServer();
17
+ if (!server)
18
+ throw Error("Broadcast server not initialized");
19
+ await server.broadcast(`private-${stripPrefix(this.channelName)}`, event, data);
20
+ }
21
+ async public(event, data) {
22
+ const server = getServer();
23
+ if (!server)
24
+ throw Error("Broadcast server not initialized");
25
+ await server.broadcast(stripPrefix(this.channelName), event, data);
26
+ }
27
+ async presence(event, data) {
28
+ const server = getServer();
29
+ if (!server)
30
+ throw Error("Broadcast server not initialized");
31
+ await server.broadcast(`presence-${stripPrefix(this.channelName)}`, event, data);
32
+ }
33
+ async broadcast(event, data, type = "public") {
34
+ switch (type) {
35
+ case "private":
36
+ return this.private(event, data);
37
+ case "presence":
38
+ return this.presence(event, data);
39
+ default:
40
+ return this.public(event, data);
41
+ }
42
+ }
43
+ }
44
+ export function channel(name) {
45
+ return new Channel(name);
46
+ }
package/dist/emit.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Emit an event to a channel
3
+ *
4
+ * @example
5
+ * // Simple emit to public channel
6
+ * emit('orders', 'created', { id: 1, total: 99.99 })
7
+ *
8
+ * // Emit to private channel
9
+ * emit('orders.123', 'updated', { status: 'shipped' }, { private: true })
10
+ *
11
+ * // Emit to presence channel
12
+ * emit('chat.room.1', 'message', { text: 'Hello' }, { presence: true })
13
+ *
14
+ * // Exclude specific users
15
+ * emit('chat.room.1', 'message', { text: 'Hello' }, { exclude: 'user-123' })
16
+ */
17
+ export declare function emit<T = unknown>(channel: string, event: string, data?: T, options?: EmitOptions): void;
18
+ /**
19
+ * Emit an event to a specific user
20
+ *
21
+ * @example
22
+ * emitToUser('user-123', 'notification', { message: 'You have a new order!' })
23
+ */
24
+ export declare function emitToUser<T = unknown>(userId: string | number, event: string, data?: T, options?: Omit<EmitOptions, 'private'>): void;
25
+ /**
26
+ * Emit an event to multiple users
27
+ *
28
+ * @example
29
+ * emitToUsers(['user-1', 'user-2'], 'announcement', { message: 'Server maintenance!' })
30
+ */
31
+ export declare function emitToUsers<T = unknown>(userIds: (string | number)[], event: string, data?: T, options?: Omit<EmitOptions, 'private'>): void;
32
+ export declare interface EmitOptions {
33
+ private?: boolean
34
+ presence?: boolean
35
+ exclude?: string | string[]
36
+ driver?: string
37
+ }
package/dist/emit.js ADDED
@@ -0,0 +1,25 @@
1
+ import { getServer } from "./server-instance";
2
+ export function emit(channel, event, data, options) {
3
+ const server = getServer();
4
+ if (!server) {
5
+ console.warn("[realtime] Server not initialized, cannot emit event");
6
+ return;
7
+ }
8
+ let channelName = channel;
9
+ if (options?.presence) {
10
+ if (!channel.startsWith("presence-"))
11
+ channelName = `presence-${channel}`;
12
+ } else if (options?.private) {
13
+ if (!channel.startsWith("private-"))
14
+ channelName = `private-${channel}`;
15
+ }
16
+ const excludeSocketId = options?.exclude ? Array.isArray(options.exclude) ? options.exclude[0] : options.exclude : void 0;
17
+ server.broadcast(channelName, event, data, excludeSocketId);
18
+ }
19
+ export function emitToUser(userId, event, data, options) {
20
+ emit(`private-user.${userId}`, event, data, { ...options, private: !0 });
21
+ }
22
+ export function emitToUsers(userIds, event, data, options) {
23
+ for (const userId of userIds)
24
+ emitToUser(userId, event, data, options);
25
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Install (or replace) the heartbeat config. Pass `null` to stop
3
+ * the heartbeat loop. Safe to call multiple times — the previous
4
+ * timer is cleared before the new one starts.
5
+ */
6
+ export declare function setHeartbeatConfig(cfg: HeartbeatConfig | null): void;
7
+ /** Read the current config — useful for tests. */
8
+ export declare function getHeartbeatConfig(): Readonly<HeartbeatState> | null;
9
+ /**
10
+ * Manually fire a single heartbeat tick. Exposed for tests; in
11
+ * production it's invoked by the internal interval.
12
+ */
13
+ export declare function runOneTick(): void;
14
+ /**
15
+ * Called from the server's pong handler (or message handler when
16
+ * fallback `__stacks_ping__` text frames are in use). Resets the
17
+ * missed-pong counter for the given socket so it doesn't get
18
+ * declared dead.
19
+ */
20
+ export declare function markPong(socket: object): void;
21
+ export declare interface HeartbeatConfig {
22
+ intervalMs?: number
23
+ maxMissedPongs?: number
24
+ onDead?: (socket: unknown) => void
25
+ }
26
+ declare interface HeartbeatState {
27
+ intervalMs: number
28
+ maxMissedPongs: number
29
+ onDead: (socket: unknown) => void
30
+ missed: WeakMap<object, number>
31
+ timer: ReturnType<typeof setInterval> | null
32
+ }
@@ -0,0 +1,84 @@
1
+ import { log } from "@stacksjs/logging";
2
+ import { getServer } from "./server-instance";
3
+ let state = null;
4
+ export function setHeartbeatConfig(cfg) {
5
+ if (state?.timer) {
6
+ clearInterval(state.timer);
7
+ state.timer = null;
8
+ }
9
+ if (!cfg) {
10
+ state = null;
11
+ return;
12
+ }
13
+ const intervalMs = cfg.intervalMs ?? 30000, maxMissedPongs = cfg.maxMissedPongs ?? 2, onDead = cfg.onDead ?? defaultOnDead;
14
+ state = {
15
+ intervalMs,
16
+ maxMissedPongs,
17
+ onDead,
18
+ missed: new WeakMap,
19
+ timer: null
20
+ };
21
+ state.timer = setInterval(() => {
22
+ if (!state)
23
+ return;
24
+ runOneTick();
25
+ }, intervalMs);
26
+ state.timer.unref?.();
27
+ }
28
+ export function getHeartbeatConfig() {
29
+ return state;
30
+ }
31
+ export function runOneTick() {
32
+ if (!state)
33
+ return;
34
+ const server = getServer();
35
+ if (!server)
36
+ return;
37
+ const allSockets = collectSockets(server);
38
+ for (const socket of allSockets) {
39
+ const ws = socket, missed = state.missed.get(socket) ?? 0;
40
+ if (missed >= state.maxMissedPongs) {
41
+ log.warn(`[realtime] socket missed ${missed} pongs \u2014 declaring dead`);
42
+ try {
43
+ state.onDead(socket);
44
+ } catch (err) {
45
+ log.warn(`[realtime] heartbeat onDead handler threw: ${err instanceof Error ? err.message : String(err)}`);
46
+ }
47
+ state.missed.delete(socket);
48
+ continue;
49
+ }
50
+ state.missed.set(socket, missed + 1);
51
+ try {
52
+ if (typeof ws.ping === "function")
53
+ ws.ping();
54
+ else if (typeof ws.send === "function")
55
+ ws.send("__stacks_ping__");
56
+ } catch {}
57
+ }
58
+ }
59
+ export function markPong(socket) {
60
+ if (!state)
61
+ return;
62
+ state.missed.delete(socket);
63
+ }
64
+ function collectSockets(server) {
65
+ const out = new Set;
66
+ try {
67
+ const channels = server.channels ?? server.clients;
68
+ if (channels && typeof channels.values === "function") {
69
+ for (const set of channels.values())
70
+ if (set && typeof set[Symbol.iterator] === "function")
71
+ for (const entry of set) {
72
+ const ws = entry && typeof entry === "object" && "ws" in entry ? entry.ws : entry;
73
+ if (ws && typeof ws === "object")
74
+ out.add(ws);
75
+ }
76
+ }
77
+ } catch {}
78
+ return [...out];
79
+ }
80
+ function defaultOnDead(socket) {
81
+ const ws = socket;
82
+ if (typeof ws.close === "function")
83
+ ws.close(1011, "heartbeat timeout");
84
+ }
@@ -0,0 +1,37 @@
1
+ export type { EmitOptions } from './emit';
2
+ export type { BroadcastInstance } from './broadcast';
3
+ export type { BackpressureGuardConfig } from './broadcast';
4
+ export type { HeartbeatConfig } from './heartbeat';
5
+ export type { BufferedMessage, ReplayBufferConfig } from './replay-buffer';
6
+ export type { WsAuthenticator, WsAuthResult } from './ws';
7
+ /**
8
+ * Stacks Realtime Module
9
+ *
10
+ * This module provides real-time broadcasting capabilities for Stacks applications.
11
+ * It's built on top of ts-broadcasting and provides a familiar Laravel-like API.
12
+ */
13
+ // Re-export everything from ts-broadcasting
14
+ export * from 'ts-broadcasting';
15
+ // Note: all exports are already provided by `export * from 'ts-broadcasting'` above.
16
+ // Aliases are provided below for convenience.
17
+ // Server instance management
18
+ export { getServer, setServer, createServer, stopServer } from './server-instance';
19
+ // Stacks-specific exports
20
+ export { emit, emitToUser, emitToUsers } from './emit';
21
+ export { channel, channel as createChannel, Channel as StacksChannel } from './channel';
22
+ export { broadcast as dispatchBroadcast, runBroadcast, Broadcast as LegacyBroadcast } 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
+ // Heartbeat ping/pong for detecting half-closed sockets
27
+ // (stacksjs/stacks#1877 R-5). Opt-in via setHeartbeatConfig.
28
+ export { getHeartbeatConfig, markPong, runOneTick, setHeartbeatConfig } from './heartbeat';
29
+ // At-least-once replay buffer for reconnect (stacksjs/stacks#1877 R-3).
30
+ // Opt-in via setReplayBuffer. Apps wire `replaySince(channel, seq)`
31
+ // into their reconnect handler to re-send missed messages.
32
+ export { debugSnapshot, getReplayBuffer, pruneExpired, recordBroadcast, replaySince, setReplayBuffer } from './replay-buffer';
33
+ export { setBunSocket, handleWebSocketRequest, storeWebSocketEvent } from './ws';
34
+ // WebSocket authenticator wiring (stacksjs/stacks#1877 R-1). Install
35
+ // once at server boot to require a valid token / cookie at the
36
+ // handshake boundary — without it, the upgrade proceeds unauthed.
37
+ export { setWsAuthenticator, getWsAuthenticator } from './ws';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from "ts-broadcasting";
2
+ export { getServer, setServer, createServer, stopServer } from "./server-instance";
3
+ export { emit, emitToUser, emitToUsers } from "./emit";
4
+ export { channel, channel as createChannel, Channel as StacksChannel } from "./channel";
5
+ export { broadcast as dispatchBroadcast, runBroadcast, Broadcast as LegacyBroadcast } from "./broadcast";
6
+ export { setBackpressureGuard, getBackpressureGuard } from "./broadcast";
7
+ export { getHeartbeatConfig, markPong, runOneTick, setHeartbeatConfig } from "./heartbeat";
8
+ export { debugSnapshot, getReplayBuffer, pruneExpired, recordBroadcast, replaySince, setReplayBuffer } from "./replay-buffer";
9
+ export { setBunSocket, handleWebSocketRequest, storeWebSocketEvent } from "./ws";
10
+ export { setWsAuthenticator, getWsAuthenticator } from "./ws";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Install (or replace) the replay-buffer config. Pass `null` to disable
3
+ * and drop all buffered state. Safe to call multiple times.
4
+ */
5
+ export declare function setReplayBuffer(cfg: ReplayBufferConfig | null): void;
6
+ /** Read the current config — useful for tests. */
7
+ export declare function getReplayBuffer(): Readonly<BufferRegistry> | null;
8
+ /**
9
+ * Called by the broadcast wrapper for every outbound message on a
10
+ * matched channel. Records the message and assigns a monotonic seq.
11
+ * Returns the seq for the caller to optionally include in the
12
+ * outbound payload — clients store the latest seq locally and send
13
+ * it back on reconnect via `replaySince`.
14
+ */
15
+ export declare function recordBroadcast(channel: string, event: string, data: unknown): number | null;
16
+ /**
17
+ * Replay every buffered message on `channel` with `seq > sinceSeq`.
18
+ * Stale entries (older than `ttlMs`) are evicted on the way through
19
+ * so callers don't see them. Returns the array of messages the
20
+ * caller should re-send to the reconnecting client.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * // Inside the reconnect handler:
25
+ * const missed = replaySince('orders', lastSeenSeq)
26
+ * for (const msg of missed) {
27
+ * socket.send(JSON.stringify({ event: msg.event, data: msg.data, seq: msg.seq }))
28
+ * }
29
+ * ```
30
+ */
31
+ export declare function replaySince(channel: string, sinceSeq: number): BufferedMessage[];
32
+ /**
33
+ * Drop expired entries across every tracked channel. Called by apps
34
+ * that want eager memory reclaim — the default lazy-on-read path is
35
+ * adequate for most workloads.
36
+ */
37
+ export declare function pruneExpired(): void;
38
+ /**
39
+ * Snapshot the buffer state — debugging only. Don't depend on this
40
+ * shape in production code; the internals may change.
41
+ */
42
+ export declare function debugSnapshot(): Record<string, { count: number, firstSeq: number | null, lastSeq: number | null }>;
43
+ /**
44
+ * Per-channel message replay buffer (stacksjs/stacks#1877 R-3).
45
+ *
46
+ * Background: ts-broadcasting delivers messages at-most-once — a client
47
+ * that drops between two broadcasts loses everything in flight. For
48
+ * channels where the app needs every message (chat, presence, order
49
+ * updates), reconnect-after-network-blip becomes a silent data loss.
50
+ *
51
+ * Fix: opt-in per-channel ring buffer that retains the most-recent N
52
+ * messages with monotonic sequence IDs. On reconnect, the client sends
53
+ * its last-seen seq; the server replays everything stored after that
54
+ * point. Apps install via `setReplayBuffer({ channels, maxPerChannel,
55
+ * ttlMs })`. Buffer is in-process — for cross-instance replay, route
56
+ * through a shared store (Redis Streams, Postgres LISTEN/NOTIFY, etc.).
57
+ *
58
+ * Memory shape: `Map<channel, RingBuffer<BufferedMessage>>`. Bounded by
59
+ * `maxPerChannel` (default 100) so a chatty channel can't OOM the
60
+ * server. Entries past `ttlMs` are evicted lazily on read — apps that
61
+ * want eager eviction can call `pruneExpired()` from their own timer.
62
+ */
63
+ export declare interface ReplayBufferConfig {
64
+ channels?: string[]
65
+ maxPerChannel?: number
66
+ ttlMs?: number
67
+ }
68
+ export declare interface BufferedMessage {
69
+ seq: number
70
+ ts: number
71
+ event: string
72
+ data: unknown
73
+ }
74
+ declare interface ChannelState {
75
+ messages: BufferedMessage[]
76
+ nextSeq: number
77
+ }
78
+ declare interface BufferRegistry {
79
+ channels: string[]
80
+ maxPerChannel: number
81
+ ttlMs: number
82
+ state: Map<string, ChannelState>
83
+ }
@@ -0,0 +1,81 @@
1
+ let registry = null;
2
+ export function setReplayBuffer(cfg) {
3
+ if (!cfg) {
4
+ registry = null;
5
+ return;
6
+ }
7
+ registry = {
8
+ channels: cfg.channels ?? [],
9
+ maxPerChannel: cfg.maxPerChannel ?? 100,
10
+ ttlMs: cfg.ttlMs ?? 300000,
11
+ state: new Map
12
+ };
13
+ }
14
+ export function getReplayBuffer() {
15
+ return registry;
16
+ }
17
+ function shouldBuffer(channel) {
18
+ if (!registry || registry.channels.length === 0)
19
+ return !1;
20
+ for (const pattern of registry.channels) {
21
+ if (pattern === "*")
22
+ return !0;
23
+ if (pattern === channel)
24
+ return !0;
25
+ if (pattern.endsWith(".*") && channel.startsWith(pattern.slice(0, -1)))
26
+ return !0;
27
+ }
28
+ return !1;
29
+ }
30
+ export function recordBroadcast(channel, event, data) {
31
+ if (!registry || !shouldBuffer(channel))
32
+ return null;
33
+ let state = registry.state.get(channel);
34
+ if (!state) {
35
+ state = { messages: [], nextSeq: 1 };
36
+ registry.state.set(channel, state);
37
+ }
38
+ const msg = {
39
+ seq: state.nextSeq++,
40
+ ts: Date.now(),
41
+ event,
42
+ data
43
+ };
44
+ state.messages.push(msg);
45
+ if (state.messages.length > registry.maxPerChannel)
46
+ state.messages.splice(0, state.messages.length - registry.maxPerChannel);
47
+ return msg.seq;
48
+ }
49
+ export function replaySince(channel, sinceSeq) {
50
+ if (!registry)
51
+ return [];
52
+ const state = registry.state.get(channel);
53
+ if (!state)
54
+ return [];
55
+ const now = Date.now(), ttl = registry.ttlMs;
56
+ while (state.messages.length > 0 && now - state.messages[0].ts > ttl)
57
+ state.messages.shift();
58
+ if (state.messages.length === 0)
59
+ return [];
60
+ return state.messages.filter((m) => m.seq > sinceSeq);
61
+ }
62
+ export function pruneExpired() {
63
+ if (!registry)
64
+ return;
65
+ const now = Date.now(), ttl = registry.ttlMs;
66
+ for (const state of registry.state.values())
67
+ while (state.messages.length > 0 && now - state.messages[0].ts > ttl)
68
+ state.messages.shift();
69
+ }
70
+ export function debugSnapshot() {
71
+ const out = {};
72
+ if (!registry)
73
+ return out;
74
+ for (const [ch, state] of registry.state)
75
+ out[ch] = {
76
+ count: state.messages.length,
77
+ firstSeq: state.messages[0]?.seq ?? null,
78
+ lastSeq: state.messages[state.messages.length - 1]?.seq ?? null
79
+ };
80
+ return out;
81
+ }
@@ -0,0 +1,17 @@
1
+ import type { BroadcastServer, ServerConfig } from 'ts-broadcasting';
2
+ /**
3
+ * Set the global broadcast server instance
4
+ */
5
+ export declare function setServer(server: BroadcastServer): void;
6
+ /**
7
+ * Get the global broadcast server instance
8
+ */
9
+ export declare function getServer(): BroadcastServer | null;
10
+ /**
11
+ * Create and start a new broadcast server
12
+ */
13
+ export declare function createServer(config: ServerConfig): Promise<BroadcastServer>;
14
+ /**
15
+ * Stop the current broadcast server
16
+ */
17
+ export declare function stopServer(): Promise<void>;
@@ -0,0 +1,19 @@
1
+ let serverInstance = null;
2
+ export function setServer(server) {
3
+ serverInstance = server;
4
+ }
5
+ export function getServer() {
6
+ return serverInstance;
7
+ }
8
+ export async function createServer(config) {
9
+ const server = new (await import("ts-broadcasting")).BroadcastServer(config);
10
+ await server.start();
11
+ setServer(server);
12
+ return server;
13
+ }
14
+ export async function stopServer() {
15
+ if (serverInstance) {
16
+ await serverInstance.stop();
17
+ serverInstance = null;
18
+ }
19
+ }
package/dist/ws.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { Server } from 'bun';
2
+ import type { BroadcastServer } from 'ts-broadcasting';
3
+ /**
4
+ * Set the broadcast server instance
5
+ * @deprecated Use setServer from './server-instance' instead
6
+ */
7
+ export declare function setBunSocket(server: BroadcastServer | null): void;
8
+ /**
9
+ * Store WebSocket event in the database
10
+ * Note: This function is now a no-op. WebSocket events are tracked internally by ts-broadcasting.
11
+ */
12
+ export declare function storeWebSocketEvent(_type: 'disconnection' | 'error' | 'success', _socket: string, _details: string): Promise<void>;
13
+ /**
14
+ * Install (or clear) the global WebSocket authenticator. Called once
15
+ * at server boot; pass `null` to disable auth (the unauthed default).
16
+ */
17
+ export declare function setWsAuthenticator(fn: WsAuthenticator | null): void;
18
+ /** Read the currently-installed authenticator. Useful for tests. */
19
+ export declare function getWsAuthenticator(): WsAuthenticator | null;
20
+ /**
21
+ * Handle WebSocket request upgrade. If an authenticator is installed
22
+ * (see `setWsAuthenticator`), it runs FIRST and a 401 is returned on
23
+ * failure (stacksjs/stacks#1877 R-1). Without an authenticator the
24
+ * upgrade proceeds for backwards-compat — the function still works
25
+ * the same way it did before.
26
+ */
27
+ export declare function handleWebSocketRequest(req: Request, server: Server<any>): Promise<Response | undefined>;
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
+ /** Result returned from a `WsAuthenticator`. */
43
+ export type WsAuthResult = | { ok: true, data?: Record<string, unknown> }
44
+ | { ok: false, status?: number, message?: string }
package/dist/ws.js ADDED
@@ -0,0 +1,32 @@
1
+ import { getServer, setServer } from "./server-instance";
2
+ export function setBunSocket(server) {
3
+ if (server)
4
+ setServer(server);
5
+ }
6
+ export async function storeWebSocketEvent(_type, _socket, _details) {}
7
+ let wsAuthenticator = null;
8
+ export function setWsAuthenticator(fn) {
9
+ wsAuthenticator = fn;
10
+ }
11
+ export function getWsAuthenticator() {
12
+ return wsAuthenticator;
13
+ }
14
+ export async function handleWebSocketRequest(req, server) {
15
+ if (!getServer())
16
+ return new Response("WebSocket server not initialized", { status: 500 });
17
+ if (wsAuthenticator)
18
+ try {
19
+ const result = await wsAuthenticator(req);
20
+ if (!result.ok)
21
+ return new Response(result.message ?? "Unauthorized", { status: result.status ?? 401 });
22
+ if (server.upgrade(req, result.data ? { data: result.data } : void 0))
23
+ return;
24
+ return new Response("WebSocket upgrade failed", { status: 400 });
25
+ } catch (err) {
26
+ console.error("[realtime] WebSocket authenticator threw:", err);
27
+ return new Response("WebSocket auth error", { status: 500 });
28
+ }
29
+ if (server.upgrade(req))
30
+ return;
31
+ return new Response("WebSocket upgrade failed", { status: 400 });
32
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/realtime",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.88",
5
+ "version": "0.70.90",
6
6
  "description": "The Stacks realtime integration. Built on top of ts-broadcasting.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [