@principal-ai/control-tower-core 0.1.7 → 0.1.8

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/index.mjs CHANGED
@@ -3170,6 +3170,255 @@ class DefaultLockManager extends LockManager {
3170
3170
  }
3171
3171
  }
3172
3172
  }
3173
+ // src/abstractions/PresenceManager.ts
3174
+ class PresenceManager {
3175
+ config;
3176
+ constructor(config) {
3177
+ this.config = {
3178
+ enabled: config?.enabled ?? false,
3179
+ heartbeatInterval: config?.heartbeatInterval ?? 30000,
3180
+ awayThreshold: config?.awayThreshold ?? 2,
3181
+ disconnectThreshold: config?.disconnectThreshold ?? 3,
3182
+ gracePeriod: config?.gracePeriod ?? 30000,
3183
+ trackDeviceActivity: config?.trackDeviceActivity ?? true,
3184
+ broadcastPresenceUpdates: config?.broadcastPresenceUpdates ?? true
3185
+ };
3186
+ }
3187
+ getConfig() {
3188
+ return { ...this.config };
3189
+ }
3190
+ isEnabled() {
3191
+ return this.config.enabled;
3192
+ }
3193
+ calculatePresenceStatus(devices, now = Date.now()) {
3194
+ if (devices.size === 0) {
3195
+ return "offline";
3196
+ }
3197
+ const heartbeatTimeout = this.config.heartbeatInterval * this.config.awayThreshold;
3198
+ for (const device of devices.values()) {
3199
+ const timeSinceActivity = now - device.lastActivity;
3200
+ if (timeSinceActivity < heartbeatTimeout) {
3201
+ return "online";
3202
+ }
3203
+ }
3204
+ return "away";
3205
+ }
3206
+ shouldDisconnectDevice(device, now = Date.now()) {
3207
+ const timeout = this.config.heartbeatInterval * this.config.disconnectThreshold;
3208
+ return now - device.lastActivity > timeout;
3209
+ }
3210
+ }
3211
+ // src/abstractions/DefaultPresenceManager.ts
3212
+ class DefaultPresenceManager extends PresenceManager {
3213
+ userPresences = new Map;
3214
+ deviceToUser = new Map;
3215
+ gracePeriodEntries = new Map;
3216
+ constructor(config) {
3217
+ super(config);
3218
+ }
3219
+ async connectDevice(userId, deviceId, deviceInfo) {
3220
+ const now = Date.now();
3221
+ const gracePeriodEntry = this.gracePeriodEntries.get(userId);
3222
+ if (gracePeriodEntry) {
3223
+ this.gracePeriodEntries.delete(userId);
3224
+ const presence2 = gracePeriodEntry.previousPresence;
3225
+ const device2 = {
3226
+ deviceId,
3227
+ type: deviceInfo?.type,
3228
+ connectedAt: now,
3229
+ lastActivity: now,
3230
+ metadata: deviceInfo?.metadata
3231
+ };
3232
+ presence2.devices.set(deviceId, device2);
3233
+ presence2.status = "online";
3234
+ presence2.lastActivity = now;
3235
+ this.userPresences.set(userId, presence2);
3236
+ this.deviceToUser.set(deviceId, userId);
3237
+ return presence2;
3238
+ }
3239
+ let presence = this.userPresences.get(userId);
3240
+ if (!presence) {
3241
+ presence = {
3242
+ userId,
3243
+ status: "online",
3244
+ devices: new Map,
3245
+ firstConnectedAt: now,
3246
+ lastActivity: now,
3247
+ metadata: {}
3248
+ };
3249
+ this.userPresences.set(userId, presence);
3250
+ }
3251
+ const device = {
3252
+ deviceId,
3253
+ type: deviceInfo?.type,
3254
+ connectedAt: now,
3255
+ lastActivity: now,
3256
+ metadata: deviceInfo?.metadata
3257
+ };
3258
+ presence.devices.set(deviceId, device);
3259
+ presence.status = this.calculatePresenceStatus(presence.devices, now);
3260
+ presence.lastActivity = now;
3261
+ this.deviceToUser.set(deviceId, userId);
3262
+ return presence;
3263
+ }
3264
+ async disconnectDevice(userId, deviceId) {
3265
+ const presence = this.userPresences.get(userId);
3266
+ if (!presence) {
3267
+ return null;
3268
+ }
3269
+ presence.devices.delete(deviceId);
3270
+ this.deviceToUser.delete(deviceId);
3271
+ const now = Date.now();
3272
+ if (presence.devices.size === 0) {
3273
+ if (this.config.gracePeriod > 0) {
3274
+ this.gracePeriodEntries.set(userId, {
3275
+ userId,
3276
+ disconnectedAt: now,
3277
+ previousPresence: presence
3278
+ });
3279
+ this.userPresences.delete(userId);
3280
+ return null;
3281
+ } else {
3282
+ this.userPresences.delete(userId);
3283
+ return null;
3284
+ }
3285
+ }
3286
+ presence.status = this.calculatePresenceStatus(presence.devices, now);
3287
+ let mostRecentActivity = 0;
3288
+ for (const device of presence.devices.values()) {
3289
+ if (device.lastActivity > mostRecentActivity) {
3290
+ mostRecentActivity = device.lastActivity;
3291
+ }
3292
+ }
3293
+ presence.lastActivity = mostRecentActivity;
3294
+ return presence;
3295
+ }
3296
+ async updateActivity(update) {
3297
+ const { userId, deviceId, timestamp } = update;
3298
+ const presence = this.userPresences.get(userId);
3299
+ if (!presence) {
3300
+ throw new Error(`User ${userId} not found in presence system`);
3301
+ }
3302
+ const device = presence.devices.get(deviceId);
3303
+ if (!device) {
3304
+ throw new Error(`Device ${deviceId} not found for user ${userId}`);
3305
+ }
3306
+ device.lastActivity = timestamp;
3307
+ if (timestamp > presence.lastActivity) {
3308
+ presence.lastActivity = timestamp;
3309
+ }
3310
+ const previousStatus = presence.status;
3311
+ presence.status = this.calculatePresenceStatus(presence.devices, timestamp);
3312
+ return presence;
3313
+ }
3314
+ async getUserPresence(userId) {
3315
+ return this.userPresences.get(userId) || null;
3316
+ }
3317
+ async getUsersPresence(userIds) {
3318
+ const result = new Map;
3319
+ for (const userId of userIds) {
3320
+ const presence = this.userPresences.get(userId);
3321
+ if (presence) {
3322
+ result.set(userId, presence);
3323
+ }
3324
+ }
3325
+ return result;
3326
+ }
3327
+ async getOnlineUsers() {
3328
+ const online = [];
3329
+ for (const presence of this.userPresences.values()) {
3330
+ if (presence.status === "online") {
3331
+ online.push(presence);
3332
+ }
3333
+ }
3334
+ return online;
3335
+ }
3336
+ async getUserDevices(userId) {
3337
+ const presence = this.userPresences.get(userId);
3338
+ if (!presence) {
3339
+ return [];
3340
+ }
3341
+ return Array.from(presence.devices.values());
3342
+ }
3343
+ async setUserStatus(userId, status) {
3344
+ const presence = this.userPresences.get(userId);
3345
+ if (!presence) {
3346
+ throw new Error(`User ${userId} not found in presence system`);
3347
+ }
3348
+ presence.status = status;
3349
+ return presence;
3350
+ }
3351
+ async processHeartbeats() {
3352
+ const now = Date.now();
3353
+ const changes = [];
3354
+ for (const [userId, presence] of this.userPresences.entries()) {
3355
+ const devicesToRemove = [];
3356
+ for (const [deviceId, device] of presence.devices.entries()) {
3357
+ if (this.shouldDisconnectDevice(device, now)) {
3358
+ devicesToRemove.push(deviceId);
3359
+ }
3360
+ }
3361
+ for (const deviceId of devicesToRemove) {
3362
+ presence.devices.delete(deviceId);
3363
+ this.deviceToUser.delete(deviceId);
3364
+ }
3365
+ const previousStatus = presence.status;
3366
+ const newStatus = this.calculatePresenceStatus(presence.devices, now);
3367
+ if (newStatus !== previousStatus) {
3368
+ presence.status = newStatus;
3369
+ changes.push({
3370
+ userId,
3371
+ previousStatus,
3372
+ status: newStatus,
3373
+ timestamp: now,
3374
+ reason: "heartbeat_timeout"
3375
+ });
3376
+ }
3377
+ if (presence.devices.size === 0) {
3378
+ if (this.config.gracePeriod > 0) {
3379
+ this.gracePeriodEntries.set(userId, {
3380
+ userId,
3381
+ disconnectedAt: now,
3382
+ previousPresence: presence
3383
+ });
3384
+ }
3385
+ this.userPresences.delete(userId);
3386
+ }
3387
+ }
3388
+ const expiredGracePeriods = [];
3389
+ for (const [userId, entry] of this.gracePeriodEntries.entries()) {
3390
+ const gracePeriodElapsed = now - entry.disconnectedAt;
3391
+ if (gracePeriodElapsed > this.config.gracePeriod) {
3392
+ expiredGracePeriods.push(userId);
3393
+ changes.push({
3394
+ userId,
3395
+ previousStatus: entry.previousPresence.status,
3396
+ status: "offline",
3397
+ timestamp: now,
3398
+ reason: "grace_period_expired"
3399
+ });
3400
+ }
3401
+ }
3402
+ for (const userId of expiredGracePeriods) {
3403
+ this.gracePeriodEntries.delete(userId);
3404
+ }
3405
+ return changes;
3406
+ }
3407
+ async clear() {
3408
+ this.userPresences.clear();
3409
+ this.deviceToUser.clear();
3410
+ this.gracePeriodEntries.clear();
3411
+ }
3412
+ getGracePeriodCount() {
3413
+ return this.gracePeriodEntries.size;
3414
+ }
3415
+ getConnectedDeviceCount() {
3416
+ return this.deviceToUser.size;
3417
+ }
3418
+ getUniqueUserCount() {
3419
+ return this.userPresences.size + this.gracePeriodEntries.size;
3420
+ }
3421
+ }
3173
3422
  // src/adapters/mock/MockTransportAdapter.ts
3174
3423
  class MockTransportAdapter {
3175
3424
  state = "disconnected";
@@ -3180,11 +3429,13 @@ class MockTransportAdapter {
3180
3429
  simulateLatency = 0;
3181
3430
  shouldFailConnection = false;
3182
3431
  connectedUrl = null;
3432
+ connectionAttempts = 0;
3183
3433
  constructor(options) {
3184
3434
  this.simulateLatency = options?.simulateLatency ?? 0;
3185
3435
  this.shouldFailConnection = options?.shouldFailConnection ?? false;
3186
3436
  }
3187
3437
  async connect(url, _options) {
3438
+ this.connectionAttempts++;
3188
3439
  if (this.shouldFailConnection) {
3189
3440
  this.state = "disconnected";
3190
3441
  const error = new Error("Mock connection failed");
@@ -3234,8 +3485,8 @@ class MockTransportAdapter {
3234
3485
  isConnected() {
3235
3486
  return this.state === "connected";
3236
3487
  }
3237
- simulateMessage(message) {
3238
- if (this.state !== "connected") {
3488
+ simulateMessage(message, bypassConnectionCheck = false) {
3489
+ if (!bypassConnectionCheck && this.state !== "connected") {
3239
3490
  throw new Error("Cannot simulate message when not connected");
3240
3491
  }
3241
3492
  this.messageHandlers.forEach((handler) => handler(message));
@@ -3256,6 +3507,72 @@ class MockTransportAdapter {
3256
3507
  getConnectedUrl() {
3257
3508
  return this.connectedUrl;
3258
3509
  }
3510
+ getConnectionAttempts() {
3511
+ return this.connectionAttempts;
3512
+ }
3513
+ resetConnectionAttempts() {
3514
+ this.connectionAttempts = 0;
3515
+ }
3516
+ async simulateConnection(clientId, options = {}) {
3517
+ if (this.state !== "connected") {
3518
+ this.state = "connected";
3519
+ }
3520
+ const message = {
3521
+ id: `conn-${clientId}`,
3522
+ type: "connection",
3523
+ payload: {
3524
+ clientId,
3525
+ authenticated: options.authenticated ?? false,
3526
+ userId: options.userId,
3527
+ metadata: options.metadata
3528
+ },
3529
+ timestamp: Date.now()
3530
+ };
3531
+ this.simulateMessage(message, true);
3532
+ }
3533
+ async simulateDisconnect(clientId, reason = "Normal closure") {
3534
+ const message = {
3535
+ id: `disconnect-${clientId}`,
3536
+ type: "disconnect",
3537
+ payload: {
3538
+ clientId,
3539
+ reason
3540
+ },
3541
+ timestamp: Date.now()
3542
+ };
3543
+ this.simulateMessage(message, true);
3544
+ }
3545
+ async simulateClientMessage(clientId, messageType, messagePayload = {}) {
3546
+ const message = {
3547
+ id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
3548
+ type: "server_message",
3549
+ payload: {
3550
+ clientId,
3551
+ type: messageType,
3552
+ ...messagePayload
3553
+ },
3554
+ timestamp: Date.now()
3555
+ };
3556
+ this.simulateMessage(message, true);
3557
+ }
3558
+ getSentMessages(clientId) {
3559
+ return this.messageQueue.filter((msg) => {
3560
+ const payload = msg.payload;
3561
+ return payload?.clientId === clientId;
3562
+ }).map((msg) => {
3563
+ const { clientId: _, ...rest } = msg.payload;
3564
+ return rest;
3565
+ });
3566
+ }
3567
+ simulateIncomingMessage(message) {
3568
+ const fullMessage = {
3569
+ id: message.id || `msg-${Date.now()}`,
3570
+ type: message.type,
3571
+ payload: message.payload || {},
3572
+ timestamp: message.timestamp || Date.now()
3573
+ };
3574
+ this.messageHandlers.forEach((handler) => handler(fullMessage));
3575
+ }
3259
3576
  }
3260
3577
  // src/adapters/mock/MockStorageAdapter.ts
3261
3578
  class MockStorageAdapter {
@@ -3635,10 +3952,10 @@ class WebSocketTransportAdapter {
3635
3952
  }
3636
3953
  setAuthAdapter(auth) {
3637
3954
  this.authAdapter = auth;
3638
- if (auth.isAuthRequired) {
3955
+ if (auth.isAuthRequired && !this.config.requireAuth) {
3639
3956
  this.config.requireAuth = auth.isAuthRequired();
3640
3957
  }
3641
- if (auth.getAuthTimeout) {
3958
+ if (auth.getAuthTimeout && this.config.authTimeout === 5000) {
3642
3959
  this.config.authTimeout = auth.getAuthTimeout();
3643
3960
  }
3644
3961
  }
@@ -4400,12 +4717,14 @@ class BaseServer extends TypedEventEmitter {
4400
4717
  storage;
4401
4718
  roomManager;
4402
4719
  lockManager;
4720
+ presenceManager;
4403
4721
  config;
4404
4722
  clients = new Map;
4405
4723
  clientMessageHandlers = new Map;
4406
4724
  running = false;
4407
4725
  initialized = false;
4408
4726
  mode;
4727
+ heartbeatIntervalId;
4409
4728
  experimental;
4410
4729
  constructor(config) {
4411
4730
  super();
@@ -4415,6 +4734,7 @@ class BaseServer extends TypedEventEmitter {
4415
4734
  this.storage = config.storage;
4416
4735
  this.roomManager = config.roomManager;
4417
4736
  this.lockManager = config.lockManager;
4737
+ this.presenceManager = config.presenceManager;
4418
4738
  this.experimental = new ExperimentalAPI({
4419
4739
  config: config.experimental || {
4420
4740
  enableBroadcast: false,
@@ -4438,6 +4758,50 @@ class BaseServer extends TypedEventEmitter {
4438
4758
  this.transport.onMessage(this.handleTransportMessage.bind(this));
4439
4759
  this.transport.onError(this.handleTransportError.bind(this));
4440
4760
  this.transport.onClose(this.handleTransportClose.bind(this));
4761
+ if (this.presenceManager?.isEnabled()) {
4762
+ this.startHeartbeatProcessor();
4763
+ }
4764
+ }
4765
+ startHeartbeatProcessor() {
4766
+ if (!this.presenceManager) {
4767
+ return;
4768
+ }
4769
+ const config = this.presenceManager.getConfig();
4770
+ const interval = config.heartbeatInterval;
4771
+ this.heartbeatIntervalId = setInterval(async () => {
4772
+ try {
4773
+ const changes = await this.presenceManager.processHeartbeats();
4774
+ for (const change of changes) {
4775
+ await this.emit("presence_changed", {
4776
+ userId: change.userId,
4777
+ status: change.status,
4778
+ timestamp: change.timestamp
4779
+ });
4780
+ if (config.broadcastPresenceUpdates) {
4781
+ await this.experimental.broadcastAuthenticated({
4782
+ type: "presence:status_changed",
4783
+ payload: {
4784
+ userId: change.userId,
4785
+ status: change.status,
4786
+ previousStatus: change.previousStatus,
4787
+ reason: change.reason
4788
+ }
4789
+ });
4790
+ }
4791
+ }
4792
+ } catch (error) {
4793
+ await this.emit("error", {
4794
+ error,
4795
+ context: "heartbeat_processor"
4796
+ });
4797
+ }
4798
+ }, interval);
4799
+ }
4800
+ stopHeartbeatProcessor() {
4801
+ if (this.heartbeatIntervalId) {
4802
+ clearInterval(this.heartbeatIntervalId);
4803
+ this.heartbeatIntervalId = undefined;
4804
+ }
4441
4805
  }
4442
4806
  async start(port) {
4443
4807
  if (this.mode === "integration") {
@@ -4496,6 +4860,7 @@ class BaseServer extends TypedEventEmitter {
4496
4860
  return;
4497
4861
  }
4498
4862
  this.running = false;
4863
+ this.stopHeartbeatProcessor();
4499
4864
  for (const [clientId] of Array.from(this.clients)) {
4500
4865
  await this.disconnectClient(clientId, "Server shutting down");
4501
4866
  }
@@ -4506,6 +4871,9 @@ class BaseServer extends TypedEventEmitter {
4506
4871
  }
4507
4872
  this.clients.clear();
4508
4873
  this.clientMessageHandlers.clear();
4874
+ if (this.presenceManager) {
4875
+ await this.presenceManager.clear();
4876
+ }
4509
4877
  await this.emit("stopped", {});
4510
4878
  }
4511
4879
  async handleTransportMessage(message) {
@@ -4517,11 +4885,16 @@ class BaseServer extends TypedEventEmitter {
4517
4885
  await this.handleClientAuthenticatedMessage(message);
4518
4886
  return;
4519
4887
  }
4888
+ if (message.type === "disconnect") {
4889
+ const payload2 = message.payload;
4890
+ await this.disconnectClient(payload2.clientId, payload2.reason || "Client disconnected");
4891
+ return;
4892
+ }
4520
4893
  const payload = message.payload;
4521
- const { clientId, ...clientMessage } = payload;
4894
+ const { clientId, type, ...clientMessage } = payload;
4522
4895
  const clientMsg = {
4523
4896
  id: message.id,
4524
- type: message.type,
4897
+ type: type || message.type,
4525
4898
  payload: clientMessage,
4526
4899
  timestamp: message.timestamp
4527
4900
  };
@@ -4544,16 +4917,50 @@ class BaseServer extends TypedEventEmitter {
4544
4917
  }
4545
4918
  async handleConnectionMessage(message) {
4546
4919
  const payload = message.payload;
4920
+ const roomIds = new Set;
4547
4921
  const client = {
4548
4922
  id: payload.clientId,
4549
4923
  userId: payload.userId || "",
4550
- roomId: null,
4924
+ roomIds,
4925
+ get roomId() {
4926
+ return roomIds.size > 0 ? roomIds.values().next().value || null : null;
4927
+ },
4551
4928
  authenticated: payload.authenticated,
4552
4929
  connectedAt: Date.now()
4553
4930
  };
4554
4931
  this.clients.set(payload.clientId, client);
4555
4932
  this.clientMessageHandlers.set(payload.clientId, this.createClientMessageHandler(payload.clientId));
4556
4933
  await this.emit("client_connected", { client });
4934
+ if (payload.authenticated && this.presenceManager?.isEnabled() && payload.userId) {
4935
+ try {
4936
+ await this.presenceManager.connectDevice(payload.userId, payload.clientId, {
4937
+ connectedAt: Date.now(),
4938
+ lastActivity: Date.now(),
4939
+ metadata: payload.metadata
4940
+ });
4941
+ await this.emit("presence_device_connected", {
4942
+ userId: payload.userId,
4943
+ deviceId: payload.clientId,
4944
+ timestamp: Date.now()
4945
+ });
4946
+ const presenceConfig = this.presenceManager.getConfig();
4947
+ if (presenceConfig.broadcastPresenceUpdates) {
4948
+ await this.experimental.broadcastAuthenticated({
4949
+ type: "presence:user_online",
4950
+ payload: {
4951
+ userId: payload.userId,
4952
+ deviceId: payload.clientId,
4953
+ status: "online"
4954
+ }
4955
+ });
4956
+ }
4957
+ } catch (error) {
4958
+ await this.emit("error", {
4959
+ error,
4960
+ context: "presence_connect_device"
4961
+ });
4962
+ }
4963
+ }
4557
4964
  if (payload.authenticated) {
4558
4965
  await this.emit("client_authenticated", {
4559
4966
  clientId: payload.clientId,
@@ -4582,10 +4989,14 @@ class BaseServer extends TypedEventEmitter {
4582
4989
  }
4583
4990
  }
4584
4991
  async addClient(clientId) {
4992
+ const roomIds = new Set;
4585
4993
  const client = {
4586
4994
  id: clientId,
4587
4995
  userId: "",
4588
- roomId: null,
4996
+ roomIds,
4997
+ get roomId() {
4998
+ return roomIds.size > 0 ? roomIds.values().next().value || null : null;
4999
+ },
4589
5000
  authenticated: false,
4590
5001
  connectedAt: Date.now()
4591
5002
  };
@@ -4598,9 +5009,36 @@ class BaseServer extends TypedEventEmitter {
4598
5009
  if (!client) {
4599
5010
  return;
4600
5011
  }
4601
- if (client.roomId) {
4602
- await this.roomManager.leaveRoom(client.roomId, client.userId);
4603
- await this.emit("client_left_room", { clientId, roomId: client.roomId });
5012
+ if (this.presenceManager?.isEnabled() && client.userId) {
5013
+ try {
5014
+ const presence = await this.presenceManager.disconnectDevice(client.userId, clientId);
5015
+ await this.emit("presence_device_disconnected", {
5016
+ userId: client.userId,
5017
+ deviceId: clientId,
5018
+ timestamp: Date.now()
5019
+ });
5020
+ const presenceConfig = this.presenceManager.getConfig();
5021
+ if (presenceConfig.broadcastPresenceUpdates && (!presence || presence.status === "offline")) {
5022
+ await this.experimental.broadcastAuthenticated({
5023
+ type: "presence:user_offline",
5024
+ payload: {
5025
+ userId: client.userId,
5026
+ deviceId: clientId,
5027
+ status: presence?.status || "offline",
5028
+ gracePeriod: presenceConfig.gracePeriod
5029
+ }
5030
+ });
5031
+ }
5032
+ } catch (error) {
5033
+ await this.emit("error", {
5034
+ error,
5035
+ context: "presence_disconnect_device"
5036
+ });
5037
+ }
5038
+ }
5039
+ for (const roomId of client.roomIds) {
5040
+ await this.roomManager.leaveRoom(roomId, client.userId);
5041
+ await this.emit("client_left_room", { clientId, roomId });
4604
5042
  }
4605
5043
  await this.lockManager.releaseUserLocks(client.userId);
4606
5044
  this.clients.delete(clientId);
@@ -4622,7 +5060,7 @@ class BaseServer extends TypedEventEmitter {
4622
5060
  await this.handleJoinRoom(clientId, message.payload);
4623
5061
  break;
4624
5062
  case "leave_room":
4625
- await this.handleLeaveRoom(clientId);
5063
+ await this.handleLeaveRoom(clientId, message.payload);
4626
5064
  break;
4627
5065
  case "broadcast_event":
4628
5066
  await this.handleBroadcastEvent(clientId, message.payload);
@@ -4635,6 +5073,9 @@ class BaseServer extends TypedEventEmitter {
4635
5073
  break;
4636
5074
  case "ping":
4637
5075
  await this.sendToClient(clientId, { type: "pong", timestamp: Date.now() });
5076
+ if (this.presenceManager?.isEnabled() && client.userId) {
5077
+ await this.updatePresenceActivity(client.userId, clientId, "heartbeat");
5078
+ }
4638
5079
  break;
4639
5080
  default:
4640
5081
  await this.sendToClient(clientId, {
@@ -4682,9 +5123,9 @@ class BaseServer extends TypedEventEmitter {
4682
5123
  return;
4683
5124
  }
4684
5125
  try {
4685
- if (client.roomId) {
4686
- await this.roomManager.leaveRoom(client.roomId, client.userId);
4687
- await this.emit("client_left_room", { clientId, roomId: client.roomId });
5126
+ if (client.roomIds.has(payload.roomId)) {
5127
+ await this.sendToClient(clientId, { type: "error", error: "Already in this room" });
5128
+ return;
4688
5129
  }
4689
5130
  let roomState = await this.roomManager.getRoomState(payload.roomId);
4690
5131
  if (!roomState) {
@@ -4705,7 +5146,7 @@ class BaseServer extends TypedEventEmitter {
4705
5146
  permissions: roomState.room.permissions || ["read", "write"]
4706
5147
  };
4707
5148
  await this.roomManager.joinRoom(payload.roomId, user);
4708
- client.roomId = payload.roomId;
5149
+ client.roomIds.add(payload.roomId);
4709
5150
  await this.emit("client_joined_room", { clientId, roomId: payload.roomId });
4710
5151
  await this.sendToClient(clientId, {
4711
5152
  type: "room_joined",
@@ -4723,20 +5164,28 @@ class BaseServer extends TypedEventEmitter {
4723
5164
  await this.sendToClient(clientId, { type: "error", error: error.message });
4724
5165
  }
4725
5166
  }
4726
- async handleLeaveRoom(clientId) {
5167
+ async handleLeaveRoom(clientId, payload) {
4727
5168
  const client = this.clients.get(clientId);
4728
- if (!client || !client.roomId) {
5169
+ if (!client) {
4729
5170
  return;
4730
5171
  }
4731
- const roomId = client.roomId;
4732
- await this.roomManager.leaveRoom(roomId, client.userId);
4733
- client.roomId = null;
4734
- await this.emit("client_left_room", { clientId, roomId });
4735
- await this.sendToClient(clientId, { type: "room_left", roomId });
5172
+ const roomsToLeave = payload?.roomId ? [payload.roomId] : Array.from(client.roomIds);
5173
+ if (roomsToLeave.length === 0) {
5174
+ return;
5175
+ }
5176
+ for (const roomId of roomsToLeave) {
5177
+ if (!client.roomIds.has(roomId)) {
5178
+ continue;
5179
+ }
5180
+ await this.roomManager.leaveRoom(roomId, client.userId);
5181
+ client.roomIds.delete(roomId);
5182
+ await this.emit("client_left_room", { clientId, roomId });
5183
+ await this.sendToClient(clientId, { type: "room_left", roomId });
5184
+ }
4736
5185
  }
4737
5186
  async handleBroadcastEvent(clientId, payload) {
4738
5187
  const client = this.clients.get(clientId);
4739
- if (!client || client.roomId !== payload.roomId) {
5188
+ if (!client || !client.roomIds.has(payload.roomId)) {
4740
5189
  await this.sendToClient(clientId, { type: "error", error: "Not in specified room" });
4741
5190
  return;
4742
5191
  }
@@ -4759,7 +5208,7 @@ class BaseServer extends TypedEventEmitter {
4759
5208
  }
4760
5209
  async handleLockRequest(clientId, payload) {
4761
5210
  const client = this.clients.get(clientId);
4762
- if (!client || client.roomId !== payload.roomId) {
5211
+ if (!client || !client.roomIds.has(payload.roomId)) {
4763
5212
  await this.sendToClient(clientId, { type: "error", error: "Not in specified room" });
4764
5213
  return;
4765
5214
  }
@@ -4789,15 +5238,15 @@ class BaseServer extends TypedEventEmitter {
4789
5238
  await this.lockManager.releaseLock(payload.lockId);
4790
5239
  await this.emit("lock_released", { lockId: payload.lockId, clientId });
4791
5240
  await this.sendToClient(clientId, { type: "lock_released", lockId: payload.lockId });
4792
- if (client.roomId) {
4793
- await this.roomManager.broadcastToRoom(client.roomId, {
5241
+ for (const roomId of client.roomIds) {
5242
+ await this.roomManager.broadcastToRoom(roomId, {
4794
5243
  id: this.generateId(),
4795
5244
  type: "lock_status",
4796
5245
  timestamp: Date.now(),
4797
5246
  userId: client.userId,
4798
- roomId: client.roomId,
5247
+ roomId,
4799
5248
  data: { lockId: payload.lockId, action: "released" },
4800
- metadata: { userId: client.userId, timestamp: Date.now(), roomId: client.roomId }
5249
+ metadata: { userId: client.userId, timestamp: Date.now(), roomId }
4801
5250
  });
4802
5251
  }
4803
5252
  } catch (error) {
@@ -4840,6 +5289,32 @@ class BaseServer extends TypedEventEmitter {
4840
5289
  isInitialized() {
4841
5290
  return this.initialized;
4842
5291
  }
5292
+ async updatePresenceActivity(userId, deviceId, activityType) {
5293
+ if (!this.presenceManager?.isEnabled()) {
5294
+ return;
5295
+ }
5296
+ try {
5297
+ await this.presenceManager.updateActivity({
5298
+ userId,
5299
+ deviceId,
5300
+ timestamp: Date.now(),
5301
+ activityType
5302
+ });
5303
+ await this.emit("presence_activity", {
5304
+ userId,
5305
+ deviceId,
5306
+ timestamp: Date.now()
5307
+ });
5308
+ } catch (error) {
5309
+ await this.emit("error", {
5310
+ error,
5311
+ context: "presence_update_activity"
5312
+ });
5313
+ }
5314
+ }
5315
+ getPresenceManager() {
5316
+ return this.presenceManager;
5317
+ }
4843
5318
  }
4844
5319
  // src/server/ServerBuilder.ts
4845
5320
  class ServerBuilder {
@@ -4848,6 +5323,7 @@ class ServerBuilder {
4848
5323
  storage;
4849
5324
  roomManager;
4850
5325
  lockManager;
5326
+ presenceManager;
4851
5327
  defaultRoomConfig;
4852
5328
  httpServer;
4853
5329
  webSocketPath;
@@ -4873,6 +5349,10 @@ class ServerBuilder {
4873
5349
  this.lockManager = lockManager;
4874
5350
  return this;
4875
5351
  }
5352
+ withPresenceManager(presenceManager) {
5353
+ this.presenceManager = presenceManager;
5354
+ return this;
5355
+ }
4876
5356
  withDefaultRoomConfig(config) {
4877
5357
  this.defaultRoomConfig = config;
4878
5358
  return this;
@@ -4909,6 +5389,7 @@ class ServerBuilder {
4909
5389
  storage: this.storage,
4910
5390
  roomManager: this.roomManager,
4911
5391
  lockManager: this.lockManager,
5392
+ presenceManager: this.presenceManager,
4912
5393
  defaultRoomConfig: this.defaultRoomConfig || {
4913
5394
  maxUsers: 50,
4914
5395
  maxHistory: 100,
@@ -4944,4 +5425,4 @@ export {
4944
5425
  BaseClient
4945
5426
  };
4946
5427
 
4947
- //# debugId=2DFFD564DA5ECE9B64756E2164756E21
5428
+ //# debugId=5B674B605D2E752364756E2164756E21