@@ -43,73 +43,116 @@ const WILDCARD = "*";
4343 * - "<collection>" → all events for the collection
4444 * - "<collection>/<id>" → events for one specific record
4545 * - "*" → every event everywhere
46+ *
47+ * Storage is keyed by **connection id** (string), not by `WSLike` object
48+ * identity. Bun/Elysia can hand you a different wrapper per handler call
49+ * (one for `open`, another for `message`); using `===` for membership
50+ * misbehaves — subscribe stored wrapper A, unsubscribe looked up wrapper B,
51+ * cross-call mutation silently dropped. The id is minted at connect time
52+ * and stashed in Bun's persistent `ws.data` slot.
53+ *
54+ * The inner Map maps connId → adapter so broadcast can still call .send()
55+ * via the wrapper that's currently live. Whichever wrapper subscribed last
56+ * "wins" — the most recent send target is what fires.
4657 */
47- const subs = new Map < string , Set < WSLike > > ( ) ;
48- const wsAuth = new WeakMap < WSLike , WSAuth > ( ) ;
58+ const subs = new Map < string , Map < string , WSLike > > ( ) ;
59+ const wsAuth = new Map < string , WSAuth > ( ) ;
60+
61+ /** Pull the persistent connection id off `ws.data` (set by the WS open handler). */
62+ function connId ( ws : WSLike ) : string {
63+ const id = ( ws as unknown as { data ?: { connId ?: string } } ) . data ?. connId ;
64+ if ( typeof id !== "string" ) throw new Error ( "realtime: ws.data.connId missing — open handler must mint one" ) ;
65+ return id ;
66+ }
4967
5068export function setWSAuth ( ws : WSLike , auth : WSAuth | null ) : void {
51- if ( auth ) wsAuth . set ( ws , auth ) ;
52- else wsAuth . delete ( ws ) ;
69+ const id = connId ( ws ) ;
70+ if ( auth ) wsAuth . set ( id , auth ) ;
71+ else wsAuth . delete ( id ) ;
5372}
5473
5574export function getWSAuth ( ws : WSLike ) : WSAuth | undefined {
56- return wsAuth . get ( ws ) ;
75+ return wsAuth . get ( connId ( ws ) ) ;
5776}
5877
5978/**
6079 * Canonicalise a topic string. The internal store keys are:
6180 *
62- * <collection> collection-level
63- * <collection>/<id> single-record
64- * * global wildcard
81+ * <collection> every event for the collection
82+ * <collection>/<id> events for one specific record
83+ * <collection>.<event-type> only that event-type (create / update / delete)
84+ * * every event everywhere
85+ * *.<event-type> that event-type globally
6586 *
66- * For ergonomic + PB-compat reasons we accept these synonyms :
87+ * Ergonomic synonyms we collapse :
6788 *
68- * <collection>.* → <collection> (dotted-wildcard)
69- * <collection>/* → <collection> (slashed-wildcard)
70- * <collection> → <collection> (no-op)
89+ * <collection>.* → <collection> (dotted-wildcard)
90+ * <collection>/* → <collection> (slashed-wildcard)
7191 *
72- * Returns the canonical form, or `null` if the topic is empty/malformed.
73- * Symmetric — used by both subscribe + unsubscribe so the two halves
74- * always agree on the storage key.
92+ * Symmetric — applied by both subscribe + unsubscribe so the two halves
93+ * always agree on the storage key. Returns `null` on empty input.
7594 */
95+ const EVENT_KINDS = new Set ( [ "create" , "update" , "delete" ] ) ;
96+
7697export function normalizeTopic ( raw : string ) : string | null {
7798 if ( typeof raw !== "string" ) return null ;
7899 const t = raw . trim ( ) ;
79100 if ( ! t ) return null ;
80101 if ( t === "*" ) return "*" ;
81102 if ( t . endsWith ( ".*" ) ) return t . slice ( 0 , - 2 ) || null ;
82103 if ( t . endsWith ( "/*" ) ) return t . slice ( 0 , - 2 ) || null ;
104+ // `<base>.<event-type>` — keep verbatim only when the suffix is a
105+ // known event kind. Anything else stays as-is for legacy callers.
106+ const dot = t . lastIndexOf ( "." ) ;
107+ if ( dot > 0 ) {
108+ const suffix = t . slice ( dot + 1 ) ;
109+ if ( EVENT_KINDS . has ( suffix ) ) return t ; // canonical event-typed form
110+ }
83111 return t ;
84112}
85113
86114export function subscribe ( ws : WSLike , topics : string [ ] ) : string [ ] {
115+ const id = connId ( ws ) ;
87116 const accepted : string [ ] = [ ] ;
88117 for ( const raw of topics ) {
89118 const t = normalizeTopic ( raw ) ;
90119 if ( ! t ) continue ;
91- if ( ! subs . has ( t ) ) subs . set ( t , new Set ( ) ) ;
92- subs . get ( t ) ! . add ( ws ) ;
120+ let inner = subs . get ( t ) ;
121+ if ( ! inner ) { inner = new Map ( ) ; subs . set ( t , inner ) ; }
122+ inner . set ( id , ws ) ;
93123 accepted . push ( t ) ;
94124 }
95125 return accepted ;
96126}
97127
98128export function unsubscribe ( ws : WSLike , topics : string [ ] ) : string [ ] {
129+ const id = connId ( ws ) ;
99130 const removed : string [ ] = [ ] ;
100131 for ( const raw of topics ) {
101132 const t = normalizeTopic ( raw ) ;
102133 if ( ! t ) continue ;
103- if ( subs . get ( t ) ?. delete ( ws ) ) removed . push ( t ) ;
134+ if ( subs . get ( t ) ?. delete ( id ) ) removed . push ( t ) ;
104135 }
105136 return removed ;
106137}
107138
139+ /** Every topic this WS is currently subscribed to. Cheap introspection for debugging. */
140+ export function listSubsFor ( ws : WSLike ) : string [ ] {
141+ const id = connId ( ws ) ;
142+ const out : string [ ] = [ ] ;
143+ for ( const [ topic , inner ] of subs . entries ( ) ) {
144+ if ( inner . has ( id ) ) out . push ( topic ) ;
145+ }
146+ out . sort ( ) ;
147+ return out ;
148+ }
149+
108150export function disconnectAll ( ws : WSLike ) : void {
109- for ( const set of subs . values ( ) ) {
110- set . delete ( ws ) ;
151+ const id = connId ( ws ) ;
152+ for ( const inner of subs . values ( ) ) {
153+ inner . delete ( id ) ;
111154 }
112- wsAuth . delete ( ws ) ;
155+ wsAuth . delete ( id ) ;
113156}
114157
115158/**
@@ -118,9 +161,9 @@ export function disconnectAll(ws: WSLike): void {
118161 * supplied, everyone passes (back-compat). When supplied, behavior matches
119162 * the records HTTP `view_rule` semantics.
120163 */
121- function shouldSendTo ( ws : WSLike , opts ?: BroadcastOpts ) : boolean {
164+ function shouldSendTo ( id : string , opts ?: BroadcastOpts ) : boolean {
122165 if ( ! opts || opts . viewRule === undefined ) return true ;
123- const auth = wsAuth . get ( ws ) ;
166+ const auth = wsAuth . get ( id ) ;
124167 if ( auth ?. type === "admin" ) return true ;
125168 const rule = opts . viewRule ;
126169 if ( rule === null ) return true ; // public
@@ -133,34 +176,39 @@ function shouldSendTo(ws: WSLike, opts?: BroadcastOpts): boolean {
133176
134177/**
135178 * Send to subscribers of `<collection>`, `<collection>/<id>` (when the event has
136- * a record id), and the wildcard `*` topic — fans out with per-ws dedup. When
179+ * a record id), and the wildcard `*` topic — fans out with per-id dedup. When
137180 * the caller passes `opts.viewRule` (and `opts.record` for the eval target),
138181 * each subscriber's auth is checked against the rule and non-matching connections
139182 * are skipped silently.
140183 */
141184export function broadcast ( collection : string , event : RealtimeEvent , opts ?: BroadcastOpts ) : void {
142- const targets : ( string | undefined ) [ ] = [ collection , WILDCARD ] ;
185+ const targets : ( string | undefined ) [ ] = [
186+ collection , // collection-level
187+ WILDCARD , // global
188+ `${ collection } .${ event . type } ` , // event-typed per collection
189+ `${ WILDCARD } .${ event . type } ` , // event-typed global
190+ ] ;
143191 if ( event . type === "create" || event . type === "update" ) {
144192 targets . push ( `${ collection } /${ event . record . id } ` ) ;
145193 } else if ( event . type === "delete" ) {
146194 targets . push ( `${ collection } /${ event . id } ` ) ;
147195 }
148196 const payload = JSON . stringify ( event ) ;
149- // Dedup: a ws subscribed to both "posts" and "*" should still receive the
150- // event once. WeakSet doesn't support iteration, so use a regular Set .
151- const sent = new Set < WSLike > ( ) ;
197+ // Dedup: a connection subscribed to both "posts" and "*" should still receive
198+ // the event once.
199+ const sent = new Set < string > ( ) ;
152200 for ( const topic of targets ) {
153201 if ( ! topic ) continue ;
154- const set = subs . get ( topic ) ;
155- if ( ! set ) continue ;
156- for ( const ws of set ) {
157- if ( sent . has ( ws ) ) continue ;
158- sent . add ( ws ) ;
159- if ( ! shouldSendTo ( ws , opts ) ) continue ;
202+ const inner = subs . get ( topic ) ;
203+ if ( ! inner ) continue ;
204+ for ( const [ id , ws ] of inner ) {
205+ if ( sent . has ( id ) ) continue ;
206+ sent . add ( id ) ;
207+ if ( ! shouldSendTo ( id , opts ) ) continue ;
160208 try {
161209 ws . send ( payload ) ;
162210 } catch {
163- set . delete ( ws ) ;
211+ inner . delete ( id ) ;
164212 }
165213 }
166214 }
@@ -174,12 +222,12 @@ export function broadcast(collection: string, event: RealtimeEvent, opts?: Broad
174222 * collide with a user-defined collection.
175223 */
176224export function broadcastSystem ( topic : string , message : object ) : void {
177- const set = subs . get ( topic ) ;
178- if ( ! set ) return ;
225+ const inner = subs . get ( topic ) ;
226+ if ( ! inner ) return ;
179227 const payload = JSON . stringify ( message ) ;
180- for ( const ws of set ) {
228+ for ( const [ id , ws ] of inner ) {
181229 try { ws . send ( payload ) ; }
182- catch { set . delete ( ws ) ; }
230+ catch { inner . delete ( id ) ; }
183231 }
184232}
185233
@@ -193,6 +241,12 @@ export function broadcastSystem(topic: string, message: object): void {
193241const sseClients = new Map < string , WSLike > ( ) ;
194242
195243export function registerSSEClient ( clientId : string , adapter : WSLike ) : void {
244+ // Mirror the WS contract: every adapter must carry a stable `data.connId`
245+ // so subscribe / unsubscribe / disconnectAll have a real key. SSE adapters
246+ // typically don't carry `data`, so we attach it here.
247+ const a = adapter as unknown as { data ?: { connId ?: string } } ;
248+ if ( ! a . data || typeof a . data !== "object" ) a . data = { connId : clientId } ;
249+ else if ( typeof a . data . connId !== "string" ) a . data . connId = clientId ;
196250 sseClients . set ( clientId , adapter ) ;
197251}
198252
@@ -212,8 +266,9 @@ export function unregisterSSEClient(clientId: string): void {
212266export function setSSESubscriptions ( clientId : string , topics : string [ ] ) : boolean {
213267 const adapter = sseClients . get ( clientId ) ;
214268 if ( ! adapter ) return false ;
269+ const id = connId ( adapter ) ;
215270 // Remove from every topic, then re-add the new set.
216- for ( const set of subs . values ( ) ) set . delete ( adapter ) ;
271+ for ( const inner of subs . values ( ) ) inner . delete ( id ) ;
217272 subscribe ( adapter , topics ) ;
218273 return true ;
219274}
0 commit comments