@stacksjs/realtime 0.70.88 → 0.70.91
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/dist/broadcast.d.ts +62 -0
- package/dist/broadcast.js +124 -0
- package/dist/channel.d.ts +24 -0
- package/dist/channel.js +46 -0
- package/dist/emit.d.ts +37 -0
- package/dist/emit.js +25 -0
- package/dist/heartbeat.d.ts +32 -0
- package/dist/heartbeat.js +84 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +10 -0
- package/dist/replay-buffer.d.ts +83 -0
- package/dist/replay-buffer.js +81 -0
- package/dist/server-instance.d.ts +17 -0
- package/dist/server-instance.js +19 -0
- package/dist/ws.d.ts +44 -0
- package/dist/ws.js +32 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/channel.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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