@@ -3087,21 +3087,24 @@ export class RoomDO extends DurableObject<Env> {
30873087 }
30883088 const participantId = deviceRecord . participantId ;
30893089
3090- // Peer cap: count distinct deviceIds currently connected.
3090+ // Peer cap: count distinct deviceIds currently represented by an
3091+ // announced socket. Multiple tabs/connections for one registered device
3092+ // are one presence peer and consume one peer-cap slot.
30913093 const policy = await this . ctx . storage . get < RoomPolicy > ( META . policy ) ;
30923094 if ( policy === undefined ) {
30933095 return errorResponse ( 500 , "ATTN_ROOM_CORRUPT" , `room ${ roomId } missing policy` ) ;
30943096 }
30953097 const connectedDevices = new Set < string > ( ) ;
30963098 for ( const existing of this . ctx . getWebSockets ( ) ) {
30973099 const attached = readAttachment ( existing ) ;
3098- if ( attached !== undefined ) connectedDevices . add ( attached . deviceId ) ;
3100+ if ( attached ?. presenceAnnounced === true ) connectedDevices . add ( attached . deviceId ) ;
30993101 }
3100- // If THIS deviceId is already connected we let it in (the prior socket
3101- // will be replaced — the spec doesn't speak to multi-tab per device, so we
3102- // treat it as the same peer). New deviceId beyond cap → close 4004.
3102+ // If THIS deviceId is already connected we let another socket in while
3103+ // continuing to represent it as one online peer. New deviceId beyond cap
3104+ // → close 4004.
3105+ const deviceAlreadyOnline = connectedDevices . has ( deviceId ) ;
31033106 const wouldBeOver =
3104- ! connectedDevices . has ( deviceId ) && connectedDevices . size >= policy . maxPeers ;
3107+ ! deviceAlreadyOnline && connectedDevices . size >= policy . maxPeers ;
31053108
31063109 // Build the upgrade response.
31073110 const pair = new WebSocketPair ( ) ;
@@ -3119,6 +3122,7 @@ export class RoomDO extends DurableObject<Env> {
31193122 participantId,
31203123 subscribed : false ,
31213124 lastPongTs : 0 ,
3125+ presenceAnnounced : ! wouldBeOver ,
31223126 } ) ;
31233127
31243128 if ( wouldBeOver ) {
@@ -3135,8 +3139,11 @@ export class RoomDO extends DurableObject<Env> {
31353139 } ) ;
31363140 }
31373141
3138- // Broadcast presence:join to every OTHER connected peer.
3139- this . broadcastPresence ( { event : "join" , deviceId, participantId } , server ) ;
3142+ // Presence is per registered device, not per socket. A second tab/socket
3143+ // for an already-online device must not produce another join.
3144+ if ( ! deviceAlreadyOnline ) {
3145+ this . broadcastPresence ( { event : "join" , deviceId, participantId } , server ) ;
3146+ }
31403147
31413148 return new Response ( null , {
31423149 status : 101 ,
@@ -3151,6 +3158,27 @@ export class RoomDO extends DurableObject<Env> {
31513158 * eviction between frames.
31523159 */
31533160 override async webSocketMessage ( ws : WebSocket , msg : string | ArrayBuffer ) : Promise < void > {
3161+ // A peer-cap rejection uses an accepted-then-closed upgrade so the client
3162+ // can observe close code 4004. Frames can race that close. Gate on the
3163+ // attachment before decoding or replying so the rejected socket cannot
3164+ // subscribe, receive hello/replay/ping, or elicit validation errors.
3165+ const att = readAttachment ( ws ) ;
3166+ if ( att === undefined ) {
3167+ try {
3168+ ws . close ( CLOSE_NORMAL , "missing attachment" ) ;
3169+ } catch {
3170+ // ignore
3171+ }
3172+ return ;
3173+ }
3174+ if ( ! att . presenceAnnounced ) {
3175+ try {
3176+ ws . close ( CLOSE_PEER_CAP , "peer cap reached" ) ;
3177+ } catch {
3178+ // close is already in progress
3179+ }
3180+ return ;
3181+ }
31543182 if ( typeof msg !== "string" ) {
31553183 sendError ( ws , "ATTN_FRAME_INVALID" , "binary frames are reserved" ) ;
31563184 return ;
@@ -3167,16 +3195,6 @@ export class RoomDO extends DurableObject<Env> {
31673195 sendError ( ws , "ATTN_FRAME_INVALID" , formatZodError ( frame . error ) ) ;
31683196 return ;
31693197 }
3170- const att = readAttachment ( ws ) ;
3171- if ( att === undefined ) {
3172- // Attachment shouldn't be missing — defensively close.
3173- try {
3174- ws . close ( CLOSE_NORMAL , "missing attachment" ) ;
3175- } catch {
3176- // ignore
3177- }
3178- return ;
3179- }
31803198 const body = frame . data ;
31813199 if ( body . type === "subscribe" ) {
31823200 await this . handleSubscribe ( ws , att , body . after ) ;
@@ -3188,19 +3206,20 @@ export class RoomDO extends DurableObject<Env> {
31883206 }
31893207 }
31903208
3191- /** Hibernation entry-point on close. Broadcast presence:leave . */
3209+ /** Hibernation entry-point on close. Leave only after the final device socket . */
31923210 override async webSocketClose (
31933211 ws : WebSocket ,
31943212 _code : number ,
31953213 _reason : string ,
31963214 _wasClean : boolean ,
31973215 ) : Promise < void > {
31983216 const att = readAttachment ( ws ) ;
3199- if ( att !== undefined ) {
3200- this . broadcastPresence (
3201- { event : "leave" , deviceId : att . deviceId , participantId : att . participantId } ,
3202- ws ,
3203- ) ;
3217+ if ( att ?. presenceAnnounced === true && ! this . hasOtherDeviceSocket ( ws , att . deviceId ) ) {
3218+ this . broadcastPresence ( {
3219+ event : "leave" ,
3220+ deviceId : att . deviceId ,
3221+ participantId : att . participantId ,
3222+ } , ws ) ;
32043223 }
32053224 // No need to call ws.close — the runtime already did. We just clean up
32063225 // any state we owned (the attachment lives on the socket itself, which
@@ -3210,14 +3229,29 @@ export class RoomDO extends DurableObject<Env> {
32103229 /** Hibernation entry-point on socket-level error. Same handling as close. */
32113230 override async webSocketError ( ws : WebSocket , _err : unknown ) : Promise < void > {
32123231 const att = readAttachment ( ws ) ;
3213- if ( att !== undefined ) {
3214- this . broadcastPresence (
3215- { event : "leave" , deviceId : att . deviceId , participantId : att . participantId } ,
3216- ws ,
3217- ) ;
3232+ if ( att ?. presenceAnnounced === true ) {
3233+ // Error may be followed by webSocketClose. Mark this socket first so
3234+ // that the later callback cannot emit a duplicate leave.
3235+ writeAttachment ( ws , { ...att , presenceAnnounced : false } ) ;
3236+ if ( ! this . hasOtherDeviceSocket ( ws , att . deviceId ) ) {
3237+ this . broadcastPresence ( {
3238+ event : "leave" ,
3239+ deviceId : att . deviceId ,
3240+ participantId : att . participantId ,
3241+ } , ws ) ;
3242+ }
32183243 }
32193244 }
32203245
3246+ /** True when another announced socket still represents `deviceId` online. */
3247+ private hasOtherDeviceSocket ( originator : WebSocket , deviceId : string ) : boolean {
3248+ return this . ctx . getWebSockets ( ) . some ( ( socket ) => {
3249+ if ( socket === originator ) return false ;
3250+ const attachment = readAttachment ( socket ) ;
3251+ return attachment ?. presenceAnnounced === true && attachment . deviceId === deviceId ;
3252+ } ) ;
3253+ }
3254+
32213255 /**
32223256 * Handle the client's `subscribe { after }` frame. Sends `hello` plus a
32233257 * replay of every envelope with serverSeq > after, in order. If `after` is
@@ -3276,7 +3310,12 @@ export class RoomDO extends DurableObject<Env> {
32763310 }
32773311 const onlineDeviceIds = [ ...new Set (
32783312 this . ctx . getWebSockets ( )
3279- . map ( ( socket ) => readAttachment ( socket ) ?. deviceId )
3313+ . map ( ( socket ) => {
3314+ const attachment = readAttachment ( socket ) ;
3315+ return attachment ?. presenceAnnounced === true
3316+ ? attachment . deviceId
3317+ : undefined ;
3318+ } )
32803319 . filter ( ( deviceId ) : deviceId is string => deviceId !== undefined ) ,
32813320 ) ] . sort ( ) ;
32823321 const hello : ServerFrame = {
@@ -3336,7 +3375,7 @@ export class RoomDO extends DurableObject<Env> {
33363375 const json = JSON . stringify ( frame ) ;
33373376 for ( const sock of sockets ) {
33383377 const att = readAttachment ( sock ) ;
3339- if ( att === undefined ) continue ;
3378+ if ( att ?. presenceAnnounced !== true ) continue ;
33403379 if ( ! att . subscribed ) continue ;
33413380 if ( ! deliverableTo ( record , att . deviceId ) ) continue ;
33423381 sendRaw ( sock , json ) ;
@@ -3362,7 +3401,7 @@ export class RoomDO extends DurableObject<Env> {
33623401 for ( const sock of this . ctx . getWebSockets ( ) ) {
33633402 if ( sock === originator ) continue ;
33643403 const att = readAttachment ( sock ) ;
3365- if ( att === undefined ) continue ;
3404+ if ( att ?. presenceAnnounced !== true ) continue ;
33663405 sendRaw ( sock , json ) ;
33673406 }
33683407 }
@@ -4458,6 +4497,8 @@ export const WS_CLOSE_CODES = {
44584497interface WSAttachment {
44594498 deviceId : string ;
44604499 participantId : string ;
4500+ /** This socket participates in the device's aggregate online presence. */
4501+ presenceAnnounced : boolean ;
44614502 /** True once the socket has received a successful `subscribe` reply. */
44624503 subscribed : boolean ;
44634504 /** Last seen pong timestamp (ms). 0 until the first pong. */
@@ -4587,6 +4628,9 @@ function readAttachment(ws: WebSocket): WSAttachment | undefined {
45874628 return {
45884629 deviceId : r . deviceId ,
45894630 participantId : r . participantId ,
4631+ // Attachments written before aggregate same-device presence shipped did
4632+ // announce presence, so missing legacy state must restore as true.
4633+ presenceAnnounced : r . presenceAnnounced !== false ,
45904634 subscribed : r . subscribed === true ,
45914635 lastPongTs : typeof r . lastPongTs === "number" ? r . lastPongTs : 0 ,
45924636 } ;
0 commit comments