@stacksjs/realtime 0.75.72 → 0.75.73

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.
@@ -1,12 +1,4 @@
1
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
2
  /**
11
3
  * Run a broadcast from a broadcast file
12
4
  *
@@ -21,24 +13,6 @@ export declare function runBroadcast(name: string, payload?: any): Promise<void>
21
13
  * await broadcast('OrderCreated', { orderId: 123 })
22
14
  */
23
15
  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
16
  export declare interface BroadcastInstance {
43
17
  channel?: () => string | string[]
44
18
  broadcastOn?: () => string | string[]
package/dist/broadcast.js CHANGED
@@ -1 +1 @@
1
- import{log}from"@stacksjs/logging";import{recordBroadcast}from"./replay-buffer";import{getServer}from"./server-instance";let backpressureConfig=null;export function setBackpressureGuard(cfg){if(!cfg){backpressureConfig=null;return}backpressureConfig={maxPerSocketBytes:cfg.maxPerSocketBytes??1048576,onSlow:cfg.onSlow??((info)=>{log.warn(`[realtime] slow consumer on '${info.channelName}': ${info.backpressure} bytes buffered`)})}}export function getBackpressureGuard(){return backpressureConfig}function checkBackpressure(server,channelName){if(!backpressureConfig)return;try{const channels=server.channels??server.clients,set=channels&&typeof channels.get==="function"?channels.get(channelName):null;if(!set||typeof set[Symbol.iterator]!=="function")return;const{maxPerSocketBytes,onSlow}=backpressureConfig;for(const entry of set){const ws=entry&&typeof entry==="object"&&"ws"in entry?entry.ws:entry,bp=ws&&typeof ws==="object"&&"backpressure"in ws?ws.backpressure:null;if(typeof bp==="number"&&bp>maxPerSocketBytes)onSlow({channelName,backpressure:bp,socket:ws})}}catch{}}function hasSubscribers(server,channelName){try{if(typeof server.hasSubscribers==="function")return Boolean(server.hasSubscribers(channelName));if(typeof server.subscriberCount==="function")return server.subscriberCount(channelName)>0;const channels=server.channels??server.clients;if(channels&&typeof channels.get==="function"){const set=channels.get(channelName),size=(set&&(set.size??set.length))??null;if(typeof size==="number")return size>0}}catch{}return!0}export class Broadcast{async connect(){}async disconnect(){}subscribe(channel,callback){log.warn("Broadcast.subscribe() is a client-side operation. Use BroadcastClient instead.")}unsubscribe(channel){log.warn("Broadcast.unsubscribe() is a client-side operation. Use BroadcastClient instead.")}broadcast(channel,event,data,type="public"){const server=getServer();if(!server){log.warn("Broadcast server not initialized");return}let channelName=channel;if(type==="private"&&!channel.startsWith("private-"))channelName=`private-${channel}`;else if(type==="presence"&&!channel.startsWith("presence-"))channelName=`presence-${channel}`;if(!hasSubscribers(server,channelName)){log.debug(`[Broadcast] Skipping '${event}' on '${channelName}' - no subscribers`);return}checkBackpressure(server,channelName);recordBroadcast(channelName,event,data);try{server.broadcast(channelName,event,data)}catch(err){log.error(`[Broadcast] Failed to broadcast event '${event}' to channel '${channelName}':`,err)}}isConnected(){return getServer()!==null}}export async function runBroadcast(name,payload){const{appPath}=await import("@stacksjs/path"),bun=await import("bun");let broadcastFiles;try{broadcastFiles=[...new bun.Glob("Broadcasts/**/*.ts").scanSync({cwd:appPath(),absolute:!0})]}catch(error){throw Error(`Failed to scan broadcast files: ${error instanceof Error?error.message:String(error)}`)}const broadcastFile=broadcastFiles.find((file)=>file.endsWith(`${name}.ts`));if(!broadcastFile)throw Error(`Broadcast ${name} not found`);let broadcastModule;try{broadcastModule=await import(broadcastFile)}catch(error){throw Error(`Failed to import broadcast '${name}': ${error instanceof Error?error.message:String(error)}`)}const instance=broadcastModule.default;if(instance.handle){await instance.handle(payload);return}const server=getServer();if(!server)throw Error("Broadcast server not initialized");const channels=instance.broadcastOn?.()||instance.channel?.()||[],eventName=instance.broadcastAs?.()||instance.event?.()||name,data=instance.broadcastWith?.()||instance.data?.()||payload,event={shouldBroadcast:()=>!0,broadcastOn:()=>channels,broadcastAs:()=>eventName,broadcastWith:()=>data};await server.broadcaster.broadcast(event)}export async function broadcast(name,payload){if(typeof name!=="string"||name.trim().length===0)throw Error("[realtime] broadcast() requires a non-empty event name");await runBroadcast(name,payload)}
1
+ import{log}from"@stacksjs/logging";import{getServer}from"./server-instance";export class Broadcast{async connect(){}async disconnect(){}subscribe(channel,callback){log.warn("Broadcast.subscribe() is a client-side operation. Use BroadcastClient instead.")}unsubscribe(channel){log.warn("Broadcast.unsubscribe() is a client-side operation. Use BroadcastClient instead.")}broadcast(channel,event,data,type="public"){const server=getServer();if(!server){log.warn("Broadcast server not initialized");return}let channelName=channel;if(type==="private"&&!channel.startsWith("private-"))channelName=`private-${channel}`;else if(type==="presence"&&!channel.startsWith("presence-"))channelName=`presence-${channel}`;try{server.broadcast(channelName,event,data)}catch(err){log.error(`[Broadcast] Failed to broadcast event '${event}' to channel '${channelName}':`,err)}}isConnected(){return getServer()!==null}}export async function runBroadcast(name,payload){const{appPath}=await import("@stacksjs/path"),bun=await import("bun");let broadcastFiles;try{broadcastFiles=[...new bun.Glob("Broadcasts/**/*.ts").scanSync({cwd:appPath(),absolute:!0})]}catch(error){throw Error(`Failed to scan broadcast files: ${error instanceof Error?error.message:String(error)}`)}const root=appPath("Broadcasts"),nameOf=(file)=>file.slice(root.length+1,-3).split("\\").join("/"),exact=broadcastFiles.filter((file)=>nameOf(file)===name),byBasename=name.includes("/")?[]:broadcastFiles.filter((file)=>nameOf(file).split("/").pop()===name),candidates=exact.length>0?exact:byBasename;if(candidates.length>1)throw Error(`Broadcast ${name} is ambiguous: ${candidates.map(nameOf).join(", ")}. Name it by its path under app/Broadcasts.`);const broadcastFile=candidates[0];if(!broadcastFile)throw Error(`Broadcast ${name} not found`);let broadcastModule;try{broadcastModule=await import(broadcastFile)}catch(error){throw Error(`Failed to import broadcast '${name}': ${error instanceof Error?error.message:String(error)}`)}const instance=broadcastModule.default;if(instance.handle){await instance.handle(payload);return}const server=getServer();if(!server)throw Error("Broadcast server not initialized");const channels=instance.broadcastOn?.()||instance.channel?.()||[],eventName=instance.broadcastAs?.()||instance.event?.()||name,data=instance.broadcastWith?.()||instance.data?.()||payload,event={shouldBroadcast:()=>!0,broadcastOn:()=>channels,broadcastAs:()=>eventName,broadcastWith:()=>data};await server.broadcaster.broadcast(event)}export async function broadcast(name,payload){if(typeof name!=="string"||name.trim().length===0)throw Error("[realtime] broadcast() requires a non-empty event name");await runBroadcast(name,payload)}
package/dist/emit.d.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  * // Emit to presence channel
12
12
  * emit('chat.room.1', 'message', { text: 'Hello' }, { presence: true })
13
13
  *
14
- * // Exclude specific users
15
- * emit('chat.room.1', 'message', { text: 'Hello' }, { exclude: 'user-123' })
14
+ * // Leave out the sender's socket (its socket ID, from the request)
15
+ * emit('chat.room.1', 'message', { text: 'Hello' }, { exclude: request.header('X-Socket-ID') })
16
16
  */
17
17
  export declare function emit<T = unknown>(channel: string, event: string, data?: T, options?: EmitOptions): void;
18
18
  /**
package/dist/emit.js CHANGED
@@ -1 +1 @@
1
- import{getServer}from"./server-instance";export function emit(channel,event,data,options){const server=getServer();if(!server){console.warn("[realtime] Server not initialized, cannot emit event");return}let channelName=channel;if(options?.presence){if(!channel.startsWith("presence-"))channelName=`presence-${channel}`}else if(options?.private){if(!channel.startsWith("private-"))channelName=`private-${channel}`}const excludeSocketId=options?.exclude?Array.isArray(options.exclude)?options.exclude[0]:options.exclude:void 0;server.broadcast(channelName,event,data,excludeSocketId)}export function emitToUser(userId,event,data,options){emit(`private-user.${userId}`,event,data,{...options,private:!0})}export function emitToUsers(userIds,event,data,options){for(const userId of userIds)emitToUser(userId,event,data,options)}
1
+ import{getServer}from"./server-instance";export function emit(channel,event,data,options){const server=getServer();if(!server){console.warn("[realtime] Server not initialized, cannot emit event");return}let channelName=channel;if(options?.presence){if(!channel.startsWith("presence-"))channelName=`presence-${channel}`}else if(options?.private){if(!channel.startsWith("private-"))channelName=`private-${channel}`}server.broadcast(channelName,event,data,options?.exclude)}export function emitToUser(userId,event,data,options){emit(`private-user.${userId}`,event,data,{...options,private:!0})}export function emitToUsers(userIds,event,data,options){for(const userId of userIds)emitToUser(userId,event,data,options)}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  export type { EmitOptions } from './emit';
2
2
  export type { BroadcastInstance } from './broadcast';
3
- export type { BackpressureGuardConfig } from './broadcast';
4
- export type { HeartbeatConfig } from './heartbeat';
5
3
  export type { BufferedMessage, ReplayBufferConfig } from './replay-buffer';
6
4
  export type { WsAuthenticator, WsAuthResult } from './ws';
7
5
  /**
@@ -20,18 +18,16 @@ export { getServer, setServer, createServer, stopServer } from './server-instanc
20
18
  export { emit, emitToUser, emitToUsers } from './emit';
21
19
  export { channel, channel as createChannel, Channel as StacksChannel } from './channel';
22
20
  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';
21
+ // Dead-socket detection and slow-consumer handling are the broadcast
22
+ // server's own: pass `websocket: { idleTimeout, sendPings,
23
+ // backpressureLimit, closeOnBackpressureLimit }` to `createServer()`.
29
24
  // 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.
25
+ // Opt-in via setReplayBuffer. Every broadcast on a buffered channel is
26
+ // recorded and carries a top-level `seq`; apps wire `replaySince(channel,
27
+ // seq)` into their reconnect handling to re-send missed messages.
32
28
  export { debugSnapshot, getReplayBuffer, pruneExpired, recordBroadcast, replaySince, setReplayBuffer } from './replay-buffer';
33
- export { 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.
29
+ export { storeWebSocketEvent } from './ws';
30
+ // WebSocket authenticator (stacksjs/stacks#1877 R-1). Gates every upgrade
31
+ // on the server `createServer()` starts; without one, upgrades proceed
32
+ // unauthed.
37
33
  export { setWsAuthenticator, getWsAuthenticator } from './ws';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"ts-broadcasting";export{getServer,setServer,createServer,stopServer}from"./server-instance";export{emit,emitToUser,emitToUsers}from"./emit";export{channel,channel as createChannel,Channel as StacksChannel}from"./channel";export{broadcast as dispatchBroadcast,runBroadcast,Broadcast as LegacyBroadcast}from"./broadcast";export{setBackpressureGuard,getBackpressureGuard}from"./broadcast";export{getHeartbeatConfig,markPong,runOneTick,setHeartbeatConfig}from"./heartbeat";export{debugSnapshot,getReplayBuffer,pruneExpired,recordBroadcast,replaySince,setReplayBuffer}from"./replay-buffer";export{handleWebSocketRequest,storeWebSocketEvent}from"./ws";export{setWsAuthenticator,getWsAuthenticator}from"./ws";
1
+ export*from"ts-broadcasting";export{getServer,setServer,createServer,stopServer}from"./server-instance";export{emit,emitToUser,emitToUsers}from"./emit";export{channel,channel as createChannel,Channel as StacksChannel}from"./channel";export{broadcast as dispatchBroadcast,runBroadcast,Broadcast as LegacyBroadcast}from"./broadcast";export{debugSnapshot,getReplayBuffer,pruneExpired,recordBroadcast,replaySince,setReplayBuffer}from"./replay-buffer";export{storeWebSocketEvent}from"./ws";export{setWsAuthenticator,getWsAuthenticator}from"./ws";
@@ -6,26 +6,26 @@ export declare function setReplayBuffer(cfg: ReplayBufferConfig | null): void;
6
6
  /** Read the current config — useful for tests. */
7
7
  export declare function getReplayBuffer(): Readonly<BufferRegistry> | null;
8
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`.
9
+ * Called for every outbound broadcast by the hook `setServer()`
10
+ * installs. Records the message under its full channel name (the one
11
+ * clients subscribe to) and assigns a monotonic seq, which the hook
12
+ * stamps onto the outbound frame as `seq`. Returns null for a channel
13
+ * no pattern covers.
14
14
  */
15
15
  export declare function recordBroadcast(channel: string, event: string, data: unknown): number | null;
16
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.
17
+ * Replay every buffered message on `channel` (the full name, e.g.
18
+ * `private-orders.1`) with `seq > sinceSeq`. Stale entries (older than
19
+ * `ttlMs`) are evicted on the way through so callers don't see them.
20
+ * Returns the array of messages the caller should re-send to the
21
+ * reconnecting client.
21
22
  *
22
23
  * @example
23
24
  * ```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
- * }
25
+ * // The client kept `message.seq` from the last frame it received on
26
+ * // the channel and sends it back after reconnecting, e.g. to a route:
27
+ * const missed = replaySince('private-orders.1', lastSeenSeq)
28
+ * return missed.map(msg => ({ event: msg.event, channel: 'private-orders.1', data: msg.data, seq: msg.seq }))
29
29
  * ```
30
30
  */
31
31
  export declare function replaySince(channel: string, sinceSeq: number): BufferedMessage[];
@@ -49,11 +49,17 @@ export declare function debugSnapshot(): Record<string, { count: number, firstSe
49
49
  * updates), reconnect-after-network-blip becomes a silent data loss.
50
50
  *
51
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.).
52
+ * messages with monotonic sequence IDs. Every broadcast on a buffered
53
+ * channel - whichever API sent it - is recorded through the hook
54
+ * `setServer()` installs on the server, and reaches clients with its
55
+ * sequence number as a top-level field: `{ event, channel, data, seq }`.
56
+ * A client keeps the last `seq` it saw per channel; on reconnect it
57
+ * sends that back (through a route or action of the app's), and the app
58
+ * re-sends `replaySince(channel, seq)`. Apps install via
59
+ * `setReplayBuffer({ channels, maxPerChannel, ttlMs })`. Buffer is
60
+ * in-process, and so are the sequence numbers - with several instances
61
+ * behind Redis each numbers its own copy, so route replay through a
62
+ * shared store (Redis Streams, Postgres LISTEN/NOTIFY, etc.) there.
57
63
  *
58
64
  * Memory shape: `Map<channel, RingBuffer<BufferedMessage>>`. Bounded by
59
65
  * `maxPerChannel` (default 100) so a chatty channel can't OOM the
@@ -1 +1 @@
1
- let registry=null;export function setReplayBuffer(cfg){if(!cfg){registry=null;return}registry={channels:cfg.channels??[],maxPerChannel:cfg.maxPerChannel??100,ttlMs:cfg.ttlMs??300000,state:new Map}}export function getReplayBuffer(){return registry}function shouldBuffer(channel){if(!registry||registry.channels.length===0)return!1;for(const pattern of registry.channels){if(pattern==="*")return!0;if(pattern===channel)return!0;if(pattern.endsWith(".*")&&channel.startsWith(pattern.slice(0,-1)))return!0}return!1}export function recordBroadcast(channel,event,data){if(!registry||!shouldBuffer(channel))return null;let state=registry.state.get(channel);if(!state){state={messages:[],nextSeq:1};registry.state.set(channel,state)}const msg={seq:state.nextSeq++,ts:Date.now(),event,data};state.messages.push(msg);if(state.messages.length>registry.maxPerChannel)state.messages.splice(0,state.messages.length-registry.maxPerChannel);return msg.seq}export function replaySince(channel,sinceSeq){if(!registry)return[];const state=registry.state.get(channel);if(!state)return[];const now=Date.now(),ttl=registry.ttlMs;while(state.messages.length>0&&now-state.messages[0].ts>ttl)state.messages.shift();if(state.messages.length===0)return[];return state.messages.filter((m)=>m.seq>sinceSeq)}export function pruneExpired(){if(!registry)return;const now=Date.now(),ttl=registry.ttlMs;for(const state of registry.state.values())while(state.messages.length>0&&now-state.messages[0].ts>ttl)state.messages.shift()}export function debugSnapshot(){const out={};if(!registry)return out;for(const[ch,state]of registry.state)out[ch]={count:state.messages.length,firstSeq:state.messages[0]?.seq??null,lastSeq:state.messages[state.messages.length-1]?.seq??null};return out}
1
+ let registry=null;export function setReplayBuffer(cfg){if(!cfg){registry=null;return}registry={channels:cfg.channels??[],maxPerChannel:cfg.maxPerChannel??100,ttlMs:cfg.ttlMs??300000,state:new Map}}export function getReplayBuffer(){return registry}function matches(pattern,channel){if(pattern==="*"||pattern===channel)return!0;return pattern.endsWith(".*")&&channel.startsWith(pattern.slice(0,-1))}function shouldBuffer(channel){if(!registry||registry.channels.length===0)return!1;const bare=channel.replace(/^(?:private|presence)-/,"");return registry.channels.some((pattern)=>matches(pattern,channel)||matches(pattern,bare))}export function recordBroadcast(channel,event,data){if(!registry||!shouldBuffer(channel))return null;let state=registry.state.get(channel);if(!state){state={messages:[],nextSeq:1};registry.state.set(channel,state)}const msg={seq:state.nextSeq++,ts:Date.now(),event,data};state.messages.push(msg);if(state.messages.length>registry.maxPerChannel)state.messages.splice(0,state.messages.length-registry.maxPerChannel);return msg.seq}export function replaySince(channel,sinceSeq){if(!registry)return[];const state=registry.state.get(channel);if(!state)return[];const now=Date.now(),ttl=registry.ttlMs;while(state.messages.length>0&&now-state.messages[0].ts>ttl)state.messages.shift();if(state.messages.length===0)return[];return state.messages.filter((m)=>m.seq>sinceSeq)}export function pruneExpired(){if(!registry)return;const now=Date.now(),ttl=registry.ttlMs;for(const state of registry.state.values())while(state.messages.length>0&&now-state.messages[0].ts>ttl)state.messages.shift()}export function debugSnapshot(){const out={};if(!registry)return out;for(const[ch,state]of registry.state)out[ch]={count:state.messages.length,firstSeq:state.messages[0]?.seq??null,lastSeq:state.messages[state.messages.length-1]?.seq??null};return out}
@@ -1,6 +1,20 @@
1
1
  import type { BroadcastServer, ServerConfig } from 'ts-broadcasting';
2
2
  /**
3
- * Set the global broadcast server instance
3
+ * Set the global broadcast server instance.
4
+ *
5
+ * ts-broadcasting's own facade is pointed at it too. `@stacksjs/realtime`
6
+ * re-exports ts-broadcasting, so `broadcast()`, `broadcastToUser()` and
7
+ * `Broadcast.send()` from this package are ts-broadcasting's, and they read
8
+ * the server from `Broadcast.setServer()`, which nothing called. Every one of
9
+ * them threw "Broadcast server not initialized" against a running server -
10
+ * including the ORM, which broadcasts model events through `broadcast()` and
11
+ * logs that error, so no model event ever reached a socket.
12
+ *
13
+ * The replay buffer is installed on the server as a broadcast hook, so
14
+ * every broadcast - `emit()`, `channel()`, `runBroadcast()`, the facade, the
15
+ * legacy `Broadcast` class - is recorded and carries its sequence number to
16
+ * clients as a top-level `seq` field. Recording used to happen only in the
17
+ * legacy class, and the number never left the server.
4
18
  */
5
19
  export declare function setServer(server: BroadcastServer | null): void;
6
20
  /**
@@ -8,7 +22,13 @@ export declare function setServer(server: BroadcastServer | null): void;
8
22
  */
9
23
  export declare function getServer(): BroadcastServer | null;
10
24
  /**
11
- * Create and start a new broadcast server
25
+ * Create and start a new broadcast server.
26
+ *
27
+ * This is the WebSocket endpoint Stacks apps serve (`/app` and `/ws` on the
28
+ * configured host/port), and the authenticator installed with
29
+ * `setWsAuthenticator()` now gates it: it used to be consulted only by
30
+ * `handleWebSocketRequest()`, which nothing called, while this server
31
+ * upgraded every request it got.
12
32
  */
13
33
  export declare function createServer(config: ServerConfig): Promise<BroadcastServer>;
14
34
  /**
@@ -1 +1 @@
1
- let serverInstance=null;export function setServer(server){serverInstance=server}export function getServer(){return serverInstance}export async function createServer(config){const server=new(await import("ts-broadcasting")).BroadcastServer(config);await server.start();setServer(server);return server}export async function stopServer(){if(serverInstance){await serverInstance.stop();serverInstance=null}}
1
+ import{Broadcast}from"ts-broadcasting";import{recordBroadcast}from"./replay-buffer";import{getWsAuthenticator}from"./ws";let serverInstance=null,removeReplayHook=null;export function setServer(server){removeReplayHook?.();removeReplayHook=null;serverInstance=server;Broadcast.setServer(server);if(server)removeReplayHook=server.addBroadcastHook(({channel,event,data})=>{const seq=recordBroadcast(channel,event,data);return seq===null?void 0:{seq}})}export function getServer(){return serverInstance}function stacksConnectionAuthorizer(config){return async(req,user)=>{let accepted={};const authenticate=getWsAuthenticator();if(authenticate){const result=await authenticate(req);if(!result.ok)return{ok:!1,status:result.status,message:result.message};accepted={user:result.user,data:result.data}}const resolvedUser=accepted.user!==void 0?accepted.user:user;if(config.authorizeConnection){const result=await config.authorizeConnection(req,resolvedUser);if(result===!0)return{ok:!0,user:resolvedUser,data:accepted.data};return result}return{ok:!0,user:resolvedUser,data:accepted.data}}}export async function createServer(config){const server=new(await import("ts-broadcasting")).BroadcastServer({...config,authorizeConnection:stacksConnectionAuthorizer(config)});await server.start();setServer(server);return server}export async function stopServer(){if(serverInstance){await serverInstance.stop();setServer(null)}}
package/dist/ws.d.ts CHANGED
@@ -1,38 +1,38 @@
1
- import type { Server } from 'bun';
1
+ import type { User } from 'ts-broadcasting';
2
2
  /**
3
3
  * Store WebSocket event in the database
4
4
  * Note: This function is now a no-op. WebSocket events are tracked internally by ts-broadcasting.
5
5
  */
6
6
  export declare function storeWebSocketEvent(_type: 'disconnection' | 'error' | 'success', _socket: string, _details: string): Promise<void>;
7
7
  /**
8
- * Install (or clear) the global WebSocket authenticator. Called once
9
- * at server boot; pass `null` to disable auth (the unauthed default).
8
+ * Install (or clear) the global WebSocket authenticator. Pass `null`
9
+ * to disable auth (the unauthed default). A refusal answers the
10
+ * upgrade with `status` (default 401) and `message` (default
11
+ * 'Unauthorized'); an authenticator that throws refuses it with a 500
12
+ * and the error is logged, never sent to the client.
10
13
  */
11
14
  export declare function setWsAuthenticator(fn: WsAuthenticator | null): void;
12
15
  /** Read the currently-installed authenticator. Useful for tests. */
13
16
  export declare function getWsAuthenticator(): WsAuthenticator | null;
14
- /**
15
- * Handle WebSocket request upgrade. If an authenticator is installed
16
- * (see `setWsAuthenticator`), it runs FIRST and a 401 is returned on
17
- * failure (stacksjs/stacks#1877 R-1). Without an authenticator the
18
- * upgrade proceeds for backwards-compat — the function still works
19
- * the same way it did before.
20
- */
21
- export declare function handleWebSocketRequest(req: Request, server: Server<any>): Promise<Response | undefined>;
22
17
  /**
23
18
  * Optional authenticator invoked at WebSocket handshake time.
24
19
  *
25
20
  * Apps install one via `setWsAuthenticator(fn)` to require a valid
26
21
  * token / cookie / signed query param BEFORE the upgrade goes through
27
- * (stacksjs/stacks#1877 R-1). Without an authenticator, the upgrade
28
- * proceeds as before — useful for local-dev / public-broadcast apps,
29
- * but production apps should always install one.
22
+ * (stacksjs/stacks#1877 R-1). It gates the broadcast server
23
+ * `createServer()` starts, and is read on every upgrade, so it applies
24
+ * whether it is installed before or after the server starts. Without
25
+ * one, every upgrade proceeds - fine for local dev or public-only
26
+ * broadcasting, but production apps should install one.
30
27
  *
31
- * The returned `data` is attached to the upgraded socket as `ws.data`
32
- * so per-message authorization can read it back without re-parsing
33
- * the auth token on every frame.
28
+ * On success, `user` becomes `socket.data.user` and `data` becomes
29
+ * `socket.data.data` on the upgraded socket, which is what channel
30
+ * authorization callbacks (`Broadcast.channel('private-orders.{id}',
31
+ * (socket, params) => ...)`) read. Channel authorization still decides
32
+ * who may join each private/presence channel; this decides who may
33
+ * connect at all.
34
34
  */
35
35
  export type WsAuthenticator = (req: Request) => Promise<WsAuthResult> | WsAuthResult;
36
36
  /** Result returned from a `WsAuthenticator`. */
37
- export type WsAuthResult = | { ok: true, data?: Record<string, unknown> }
37
+ export type WsAuthResult = | { ok: true, user?: User, data?: Record<string, unknown> }
38
38
  | { ok: false, status?: number, message?: string }
package/dist/ws.js CHANGED
@@ -1 +1 @@
1
- import{getServer}from"./server-instance";export async function storeWebSocketEvent(_type,_socket,_details){}let wsAuthenticator=null;export function setWsAuthenticator(fn){wsAuthenticator=fn}export function getWsAuthenticator(){return wsAuthenticator}export async function handleWebSocketRequest(req,server){if(!getServer())return new Response("WebSocket server not initialized",{status:500});if(wsAuthenticator)try{const result=await wsAuthenticator(req);if(!result.ok)return new Response(result.message??"Unauthorized",{status:result.status??401});if(server.upgrade(req,result.data?{data:result.data}:void 0))return;return new Response("WebSocket upgrade failed",{status:400})}catch(err){console.error("[realtime] WebSocket authenticator threw:",err);return new Response("WebSocket auth error",{status:500})}if(server.upgrade(req))return;return new Response("WebSocket upgrade failed",{status:400})}
1
+ export async function storeWebSocketEvent(_type,_socket,_details){}let wsAuthenticator=null;export function setWsAuthenticator(fn){wsAuthenticator=fn}export function getWsAuthenticator(){return wsAuthenticator}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/realtime",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.72",
5
+ "version": "0.75.73",
6
6
  "description": "The Stacks realtime integration. Built on top of ts-broadcasting.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -58,9 +58,9 @@
58
58
  "prepublishOnly": "bun run build"
59
59
  },
60
60
  "dependencies": {
61
- "@stacksjs/logging": "0.75.72",
62
- "@stacksjs/path": "0.75.72",
63
- "ts-broadcasting": "^0.0.10"
61
+ "@stacksjs/logging": "0.75.73",
62
+ "@stacksjs/path": "0.75.73",
63
+ "ts-broadcasting": "^0.0.11"
64
64
  },
65
65
  "devDependencies": {
66
66
  "better-dx": "^0.2.24"
@@ -1,32 +0,0 @@
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
- }
package/dist/heartbeat.js DELETED
@@ -1 +0,0 @@
1
- import{log}from"@stacksjs/logging";import{getServer}from"./server-instance";let state=null;export function setHeartbeatConfig(cfg){if(state?.timer){clearInterval(state.timer);state.timer=null}if(!cfg){state=null;return}const intervalMs=cfg.intervalMs??30000,maxMissedPongs=cfg.maxMissedPongs??2,onDead=cfg.onDead??defaultOnDead;state={intervalMs,maxMissedPongs,onDead,missed:new WeakMap,timer:null};state.timer=setInterval(()=>{if(!state)return;runOneTick()},intervalMs);state.timer.unref?.()}export function getHeartbeatConfig(){return state}export function runOneTick(){if(!state)return;const server=getServer();if(!server)return;const allSockets=collectSockets(server);for(const socket of allSockets){const ws=socket,missed=state.missed.get(socket)??0;if(missed>=state.maxMissedPongs){log.warn(`[realtime] socket missed ${missed} pongs - declaring dead`);try{state.onDead(socket)}catch(err){log.warn(`[realtime] heartbeat onDead handler threw: ${err instanceof Error?err.message:String(err)}`)}state.missed.delete(socket);continue}state.missed.set(socket,missed+1);try{if(typeof ws.ping==="function")ws.ping();else if(typeof ws.send==="function")ws.send("__stacks_ping__")}catch{}}}export function markPong(socket){if(!state)return;state.missed.delete(socket)}function collectSockets(server){const out=new Set;try{const channels=server.channels??server.clients;if(channels&&typeof channels.values==="function"){for(const set of channels.values())if(set&&typeof set[Symbol.iterator]==="function")for(const entry of set){const ws=entry&&typeof entry==="object"&&"ws"in entry?entry.ws:entry;if(ws&&typeof ws==="object")out.add(ws)}}}catch{}return[...out]}function defaultOnDead(socket){const ws=socket;if(typeof ws.close==="function")ws.close(1011,"heartbeat timeout")}