@edge-base/server 0.2.6 → 0.2.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.
Files changed (78) hide show
  1. package/admin-build/_app/immutable/chunks/{CbfX3ELZ.js → B9efkx2V.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/{CLHN9MVr.js → BMXWUTG-.js} +1 -1
  3. package/admin-build/_app/immutable/chunks/{DemDWbs-.js → Bt4AyT3o.js} +3 -3
  4. package/admin-build/_app/immutable/chunks/CKVjMXZi.js +1 -0
  5. package/admin-build/_app/immutable/chunks/{BvoGcDFV.js → CMYgGhZR.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/{LL3ulaxa.js → CTRjWhGs.js} +1 -1
  7. package/admin-build/_app/immutable/chunks/{BN_-k-Ck.js → CwyE59Yt.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{DQVP4KC-.js → D8aeTKry.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/{Ff90owjx.js → DGAHkap7.js} +1 -1
  10. package/admin-build/_app/immutable/chunks/{CR37B8DX.js → DPgR4-0v.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/{DdvsFblq.js → DYtrHeVQ.js} +1 -1
  12. package/admin-build/_app/immutable/chunks/{CrwlCAM0.js → DcVb45Ds.js} +1 -1
  13. package/admin-build/_app/immutable/chunks/Djnkhy-S.js +1 -0
  14. package/admin-build/_app/immutable/chunks/{DmDTovpg.js → fPy6xmgG.js} +1 -1
  15. package/admin-build/_app/immutable/chunks/{CCUxCptE.js → j4jxnAKj.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{qBm6xof8.js → zl2AUKMP.js} +1 -1
  17. package/admin-build/_app/immutable/entry/{app.CP83Ni80.js → app.Cmz0WjMl.js} +2 -2
  18. package/admin-build/_app/immutable/entry/start.JE7dcbK1.js +1 -0
  19. package/admin-build/_app/immutable/nodes/{0.DiRq7puO.js → 0.y6D_QyUb.js} +1 -1
  20. package/admin-build/_app/immutable/nodes/{1.BFeyKLGT.js → 1.CndRxhbH.js} +1 -1
  21. package/admin-build/_app/immutable/nodes/{10.zcee7hJx.js → 10.CdA5FmXy.js} +1 -1
  22. package/admin-build/_app/immutable/nodes/{11.BW7wLs2Y.js → 11.DG8SzMp_.js} +1 -1
  23. package/admin-build/_app/immutable/nodes/{12.CxJRlYSd.js → 12.CvmQqpFa.js} +1 -1
  24. package/admin-build/_app/immutable/nodes/{13.pp0F_5hn.js → 13.BbGNdswT.js} +1 -1
  25. package/admin-build/_app/immutable/nodes/{14.t3AfGiGo.js → 14.CZKsN7-O.js} +1 -1
  26. package/admin-build/_app/immutable/nodes/{15.B3agc7NX.js → 15.A7-CYgkG.js} +1 -1
  27. package/admin-build/_app/immutable/nodes/{16.C4uG2-i8.js → 16.hgJT9H-x.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/{17.CwGxi1Bn.js → 17.DkWZbcN2.js} +1 -1
  29. package/admin-build/_app/immutable/nodes/{18.CrQyN_gU.js → 18.sX3Fb5gh.js} +1 -1
  30. package/admin-build/_app/immutable/nodes/{19.NEPUOXl7.js → 19.VAZUW-1K.js} +1 -1
  31. package/admin-build/_app/immutable/nodes/{20.DGHO8ipr.js → 20.DkIKxacG.js} +1 -1
  32. package/admin-build/_app/immutable/nodes/21.DOjJlQKc.js +1 -0
  33. package/admin-build/_app/immutable/nodes/{22.Dri5It7a.js → 22.BDaHvtaw.js} +1 -1
  34. package/admin-build/_app/immutable/nodes/{23.BPQP_Zte.js → 23.BVRzw_pD.js} +1 -1
  35. package/admin-build/_app/immutable/nodes/{24.D580FdSS.js → 24.CVhSJyG0.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{25.BMNPOZwF.js → 25.Bme-9bZn.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/{26.XcpEcbiz.js → 26.Dsx7RIIs.js} +1 -1
  38. package/admin-build/_app/immutable/nodes/{27.C1zHHcYv.js → 27.DMGQnzFM.js} +1 -1
  39. package/admin-build/_app/immutable/nodes/{28.CuKzzrY8.js → 28.GGwFmEhZ.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/{29.nLpBMXnM.js → 29.Dnghr0nk.js} +1 -1
  41. package/admin-build/_app/immutable/nodes/{3.5G_aseoL.js → 3.Cg7zZJP1.js} +1 -1
  42. package/admin-build/_app/immutable/nodes/{30.CQC4nLoU.js → 30.C0J24z3I.js} +1 -1
  43. package/admin-build/_app/immutable/nodes/{31.Bet8kxOK.js → 31.MdxFI8v6.js} +1 -1
  44. package/admin-build/_app/immutable/nodes/{4.nmJDYJpC.js → 4.DCAOVzGE.js} +1 -1
  45. package/admin-build/_app/immutable/nodes/{5.CnbYLG4E.js → 5.DzUQ-cTc.js} +1 -1
  46. package/admin-build/_app/immutable/nodes/{6.KA01b-3y.js → 6.CptBYTVj.js} +1 -1
  47. package/admin-build/_app/immutable/nodes/{7.CP9fkn1L.js → 7.DfeeQ0Rg.js} +1 -1
  48. package/admin-build/_app/immutable/nodes/{8.BTzDb---.js → 8.CIcvctW7.js} +1 -1
  49. package/admin-build/_app/immutable/nodes/{9.DkNJg_J6.js → 9.QKrvq4RA.js} +1 -1
  50. package/admin-build/_app/version.json +1 -1
  51. package/admin-build/index.html +7 -7
  52. package/openapi.json +6 -1941
  53. package/package.json +3 -3
  54. package/src/__tests__/admin-assets.test.ts +7 -7
  55. package/src/__tests__/frontend-assets.test.ts +75 -0
  56. package/src/__tests__/frontend-config.test.ts +16 -0
  57. package/src/__tests__/frontend-routing.test.ts +200 -0
  58. package/src/__tests__/openapi-coverage.test.ts +0 -6
  59. package/src/__tests__/room-auth-state-loss.test.ts +6 -0
  60. package/src/__tests__/room-handler-context.test.ts +0 -31
  61. package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
  62. package/src/__tests__/room-runtime-routing.test.ts +1 -111
  63. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  64. package/src/durable-objects/room-runtime-base.ts +243 -17
  65. package/src/durable-objects/rooms-do.ts +190 -1345
  66. package/src/index.ts +97 -3
  67. package/src/lib/admin-assets.ts +5 -5
  68. package/src/lib/frontend-assets.ts +129 -0
  69. package/src/lib/frontend-config.ts +11 -0
  70. package/src/lib/openapi.ts +1 -4
  71. package/src/routes/room.ts +0 -285
  72. package/src/types.ts +1 -14
  73. package/admin-build/_app/immutable/chunks/Q3vAxeY-.js +0 -1
  74. package/admin-build/_app/immutable/chunks/SQVAC3Cv.js +0 -1
  75. package/admin-build/_app/immutable/entry/start.DY6YakU0.js +0 -1
  76. package/admin-build/_app/immutable/nodes/21.UVKBDvp4.js +0 -1
  77. package/src/__tests__/cloudflare-realtime.test.ts +0 -113
  78. package/src/lib/cloudflare-realtime.ts +0 -251
@@ -5,15 +5,6 @@ import {
5
5
  type RoomSender,
6
6
  type RoomServerAPI,
7
7
  } from '@edge-base/shared';
8
- import {
9
- type CloudflareRealtimeCloseTracksRequest,
10
- type CloudflareRealtimeNewSessionRequest,
11
- type CloudflareRealtimeNewSessionResponse,
12
- type CloudflareRealtimeRenegotiateRequest,
13
- type CloudflareRealtimeTracksRequest,
14
- type CloudflareRealtimeTracksResponse,
15
- } from '../lib/cloudflare-realtime.js';
16
- import type { Env } from '../types.js';
17
8
  import { RoomRuntimeBaseDO, type RoomWSMeta } from './room-runtime-base.js';
18
9
 
19
10
  /**
@@ -52,16 +43,6 @@ interface AdminMessage {
52
43
  requestId?: string;
53
44
  }
54
45
 
55
- type MediaKind = 'audio' | 'video' | 'screen';
56
-
57
- interface MediaMessage {
58
- type: 'media';
59
- operation: 'publish' | 'unpublish' | 'mute' | 'device';
60
- kind?: MediaKind;
61
- payload?: Record<string, unknown>;
62
- requestId?: string;
63
- }
64
-
65
46
  interface SignalFrameMeta {
66
47
  memberId: string | null;
67
48
  userId: string | null;
@@ -70,27 +51,12 @@ interface SignalFrameMeta {
70
51
  serverSent: boolean;
71
52
  }
72
53
 
73
- interface RoomMemberMediaKindState {
74
- published: boolean;
75
- muted: boolean;
76
- trackId?: string;
77
- deviceId?: string;
78
- publishedAt?: number;
79
- adminDisabled?: boolean;
80
- providerSessionId?: string;
81
- }
82
-
83
- interface RoomMemberMediaState {
84
- audio?: RoomMemberMediaKindState;
85
- video?: RoomMemberMediaKindState;
86
- screen?: RoomMemberMediaKindState;
87
- }
88
-
89
54
  interface RoomMemberPresence {
90
55
  memberId: string;
91
56
  userId: string;
92
57
  joinedAt: number;
93
58
  connectionIds: Set<string>;
59
+ reconnectUntil?: number;
94
60
  state: Record<string, unknown>;
95
61
  }
96
62
 
@@ -105,11 +71,11 @@ interface RoomSummaryResponse {
105
71
  updatedAt: string;
106
72
  }
107
73
 
108
- interface RoomMemberRealtimeSession {
109
- sessionId: string;
110
- connectionId?: string;
111
- createdAt: number;
112
- updatedAt: number;
74
+ interface RoomsWSAttachmentExtra {
75
+ joined?: boolean;
76
+ joinedAt?: number;
77
+ role?: string;
78
+ state?: Record<string, unknown>;
113
79
  }
114
80
 
115
81
  type RoomMemberSnapshot = RoomMemberInfo & { state: Record<string, unknown> };
@@ -122,9 +88,7 @@ const SYSTEM_SIGNAL_SENDER: RoomSender = {
122
88
 
123
89
  const DEFAULT_MEMBER_RECONNECT_TIMEOUT_MS = 30000;
124
90
  const SIGNAL_DENIED = Symbol('rooms.signal.denied');
125
- const MEDIA_DENIED = Symbol('rooms.media.denied');
126
91
  const WEBSOCKET_OPEN = 1;
127
- const CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY = 'cloudflareRealtimeKitMeetingId';
128
92
 
129
93
  function getRoomHooks(namespaceConfig?: RoomNamespaceConfig | null) {
130
94
  return namespaceConfig?.hooks;
@@ -153,623 +117,102 @@ function computeStateDelta(
153
117
  return hasChanges ? delta : null;
154
118
  }
155
119
 
120
+ function isRecord(value: unknown): value is Record<string, unknown> {
121
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
122
+ }
123
+
156
124
  export class RoomsDO extends RoomRuntimeBaseDO {
157
125
  private readonly joinedConnectionIds = new Set<string>();
158
126
  private readonly members = new Map<string, RoomMemberPresence>();
159
127
  private readonly blockedMembers = new Set<string>();
160
128
  private readonly memberRoles = new Map<string, string>();
161
- private readonly memberMediaStates = new Map<string, RoomMemberMediaState>();
162
- private readonly memberRealtimeSessions = new Map<string, RoomMemberRealtimeSession>();
163
- private cloudflareRealtimeKitMeetingId: string | null = null;
164
- private cloudflareRealtimeKitMeetingIdPromise: Promise<string> | null = null;
165
-
166
- override async fetch(request: Request): Promise<Response> {
167
- const url = new URL(request.url);
168
129
 
169
- if (url.pathname === '/summary' && request.method === 'GET') {
170
- return this.handleSummaryGet(url);
171
- }
172
-
173
- if (url.pathname === '/media/cloudflare_realtimekit/session' && request.method === 'POST') {
174
- return this.handleCloudflareRealtimeKitSessionCreate(request, url);
175
- }
176
-
177
- if (url.pathname === '/media/realtime/session') {
178
- if (request.method === 'POST') return this.handleRealtimeSessionCreate(request, url);
179
- if (request.method === 'GET') return this.handleRealtimeSessionGet(request, url);
180
- return this.jsonResponse(405, { code: 405, message: 'Method not allowed' });
181
- }
182
-
183
- if (url.pathname === '/media/realtime/turn' && request.method === 'POST') {
184
- return this.handleRealtimeTurn(request, url);
185
- }
186
-
187
- if (url.pathname === '/media/realtime/tracks/new' && request.method === 'POST') {
188
- return this.handleRealtimeTracksNew(request, url);
189
- }
190
-
191
- if (url.pathname === '/media/realtime/renegotiate' && request.method === 'PUT') {
192
- return this.handleRealtimeRenegotiate(request, url);
130
+ protected override buildWSAttachmentExtra(_ws: WebSocket, meta: RoomWSMeta): unknown {
131
+ if (!meta.userId) {
132
+ return undefined;
193
133
  }
194
134
 
195
- if (url.pathname === '/media/realtime/tracks/close' && request.method === 'PUT') {
196
- return this.handleRealtimeTracksClose(request, url);
197
- }
135
+ const member = this.members.get(meta.userId);
198
136
 
199
- return super.fetch(request);
137
+ return {
138
+ joined: this.joinedConnectionIds.has(meta.connectionId),
139
+ joinedAt: member?.joinedAt,
140
+ role: this.memberRoles.get(meta.userId) ?? meta.role,
141
+ state: member ? { ...member.state } : undefined,
142
+ } satisfies RoomsWSAttachmentExtra;
200
143
  }
201
144
 
202
- private async handleSummaryGet(url: URL): Promise<Response> {
203
- this.hydrateRoomIdentityFromUrl(url);
204
- const metadata = await this.getRoomMetadataSnapshot();
145
+ protected override async recoverRuntimeStateFromSockets(): Promise<void> {
146
+ await super.recoverRuntimeStateFromSockets();
205
147
 
206
- return this.jsonResponse<RoomSummaryResponse>(200, {
207
- namespace: this.namespace ?? '',
208
- roomId: this.roomId ?? '',
209
- metadata,
210
- occupancy: {
211
- activeMembers: this.members.size,
212
- activeConnections: this.joinedConnectionIds.size,
213
- },
214
- updatedAt: new Date().toISOString(),
215
- });
216
- }
148
+ this.joinedConnectionIds.clear();
149
+ this.members.clear();
150
+ this.blockedMembers.clear();
151
+ this.memberRoles.clear();
217
152
 
218
- private async handleCloudflareRealtimeKitSessionCreate(request: Request, url: URL): Promise<Response> {
219
- try {
220
- const body = await this.readJsonBody<{
221
- connectionId?: string;
222
- customParticipantId?: string;
223
- name?: string;
224
- picture?: string;
225
- }>(request);
226
- const { memberId, connectionId, meta } = await this.authenticateRealtimeRequest(
227
- request,
228
- url,
229
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
230
- );
231
- if (this.hasPublishedTracks(memberId)) {
232
- return this.jsonResponse(409, {
233
- code: 409,
234
- message: 'Unpublish existing room media before creating a new Cloudflare RealtimeKit session',
235
- });
153
+ for (const ws of this.ctx.getWebSockets()) {
154
+ const meta = this.getWSMeta(ws);
155
+ if (!meta?.authenticated || !meta.userId) {
156
+ continue;
236
157
  }
237
158
 
238
- const config = this.getCloudflareRealtimeKitConfig();
239
- const meetingId = await this.ensureCloudflareRealtimeKitMeetingId(config);
240
- const participant = await this.createCloudflareRealtimeKitParticipant(config, meetingId, {
241
- customParticipantId: this.buildCloudflareRealtimeKitParticipantId(memberId, body.customParticipantId),
242
- name: typeof body.name === 'string' && body.name.trim()
243
- ? body.name.trim()
244
- : meta.auth?.email ?? meta.userId ?? memberId,
245
- picture: typeof body.picture === 'string' && body.picture.trim() ? body.picture.trim() : undefined,
246
- });
247
-
248
- return this.jsonResponse(200, {
249
- sessionId: participant.id,
250
- meetingId,
251
- participantId: participant.id,
252
- authToken: participant.token,
253
- presetName: participant.presetName ?? config.presetName,
254
- connectionId,
255
- reused: false,
256
- });
257
- } catch (err) {
258
- return this.jsonResponse(400, {
259
- code: 400,
260
- message: err instanceof Error ? err.message : 'Failed to create Cloudflare RealtimeKit session',
261
- });
262
- }
263
- }
264
-
265
- private async handleRealtimeSessionCreate(request: Request, url: URL): Promise<Response> {
266
- try {
267
- const body = await this.readJsonBody<{
268
- connectionId?: string;
269
- correlationId?: string;
270
- thirdparty?: boolean;
271
- sessionDescription?: CloudflareRealtimeNewSessionRequest['sessionDescription'];
272
- }>(request);
273
- const { memberId, connectionId } = await this.authenticateRealtimeRequest(
274
- request,
275
- url,
276
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
277
- );
278
-
279
- if (this.hasPublishedTracks(memberId)) {
280
- return this.jsonResponse(409, {
281
- code: 409,
282
- message: 'Unpublish existing room media before replacing the active realtime session.',
283
- });
159
+ const extra = this.getWSAttachmentExtra<RoomsWSAttachmentExtra>(ws);
160
+ if (!extra?.joined) {
161
+ continue;
284
162
  }
285
163
 
286
- const client = await this.buildRealtimeClient();
287
- const response = await client.createSession(
288
- {
289
- sessionDescription: body.sessionDescription,
290
- },
291
- {
292
- thirdparty: body.thirdparty === true,
293
- correlationId:
294
- typeof body.correlationId === 'string' && body.correlationId.trim()
295
- ? body.correlationId.trim()
296
- : `${this.namespace ?? 'room'}::${this.roomId ?? 'unknown'}::${memberId}`,
297
- },
298
- );
299
-
300
- this.memberRealtimeSessions.set(memberId, {
301
- sessionId: response.sessionId,
302
- connectionId,
303
- createdAt: Date.now(),
304
- updatedAt: Date.now(),
305
- });
306
-
307
- return this.jsonResponse<CloudflareRealtimeNewSessionResponse & {
308
- connectionId: string;
309
- reused: false;
310
- }>(200, {
311
- ...response,
312
- connectionId,
313
- reused: false,
314
- });
315
- } catch (err) {
316
- return this.jsonResponse(400, {
317
- code: 400,
318
- message: err instanceof Error ? err.message : 'Failed to create realtime session',
319
- });
320
- }
321
- }
322
-
323
- private async handleRealtimeSessionGet(request: Request, url: URL): Promise<Response> {
324
- try {
325
- const requestedConnectionId = url.searchParams.get('connectionId') ?? undefined;
326
- const { memberId } = await this.authenticateRealtimeRequest(request, url, requestedConnectionId);
327
- const session = this.memberRealtimeSessions.get(memberId);
328
- if (!session) {
329
- return this.jsonResponse(404, {
330
- code: 404,
331
- message: 'No active realtime session for this room member.',
332
- });
164
+ this.joinedConnectionIds.add(meta.connectionId);
165
+ const member = this.ensureMember(meta.userId);
166
+ member.connectionIds.add(meta.connectionId);
167
+ if (typeof extra.joinedAt === 'number' && Number.isFinite(extra.joinedAt)) {
168
+ member.joinedAt = Math.min(member.joinedAt, extra.joinedAt);
333
169
  }
334
- return this.jsonResponse(200, session);
335
- } catch (err) {
336
- return this.jsonResponse(400, {
337
- code: 400,
338
- message: err instanceof Error ? err.message : 'Failed to read realtime session',
339
- });
340
- }
341
- }
342
-
343
- private async handleRealtimeTurn(request: Request, url: URL): Promise<Response> {
344
- try {
345
- const body = await this.readJsonBody<{ ttl?: number }>(request);
346
- await this.authenticateRealtimeRequest(request, url);
347
- const client = await this.buildRealtimeClient();
348
- const ttl = typeof body.ttl === 'number' && Number.isFinite(body.ttl) && body.ttl > 0
349
- ? Math.floor(body.ttl)
350
- : 3600;
351
- const response = await client.generateIceServers(ttl);
352
- return this.jsonResponse(200, response);
353
- } catch (err) {
354
- return this.jsonResponse(400, {
355
- code: 400,
356
- message: err instanceof Error ? err.message : 'Failed to generate ICE servers',
357
- });
358
- }
359
- }
360
-
361
- private async handleRealtimeTracksNew(request: Request, url: URL): Promise<Response> {
362
- try {
363
- const body = await this.readJsonBody<CloudflareRealtimeTracksRequest & {
364
- sessionId?: string;
365
- connectionId?: string;
366
- publish?: {
367
- kind?: MediaKind;
368
- trackId?: string;
369
- deviceId?: string;
370
- muted?: boolean;
170
+ if (isRecord(extra.state)) {
171
+ member.state = {
172
+ ...member.state,
173
+ ...extra.state,
371
174
  };
372
- }>(request);
373
-
374
- const { memberId, meta } = await this.authenticateRealtimeRequest(
375
- request,
376
- url,
377
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
378
- );
379
-
380
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
381
- if (!sessionId) {
382
- throw new Error('sessionId is required');
383
- }
384
- this.assertRealtimeSessionOwnership(memberId, sessionId);
385
-
386
- if (!Array.isArray(body.tracks) || body.tracks.length === 0) {
387
- throw new Error('tracks is required');
388
- }
389
-
390
- const client = await this.buildRealtimeClient();
391
- const response = await client.addTracks(sessionId, {
392
- sessionDescription: body.sessionDescription,
393
- tracks: body.tracks,
394
- autoDiscover: body.autoDiscover === true,
395
- });
396
- this.assertRealtimeTracksResponseSuccess(response);
397
-
398
- const publishPayload = body.publish;
399
- const publishKind = publishPayload?.kind;
400
- if (publishKind) {
401
- if (!(await this.canPublishMedia(meta, publishKind, publishPayload ?? {}))) {
402
- throw new Error('Denied by room media publish access rule');
403
- }
404
- const localTrackName = publishPayload.trackId?.trim()
405
- || body.tracks.find((track) => track.location === 'local')?.trackName?.trim()
406
- || response.tracks?.find((track) => track.location === 'local')?.trackName?.trim();
407
- await this.publishMedia(meta, publishKind, {
408
- trackId: localTrackName,
409
- deviceId: publishPayload.deviceId,
410
- muted: publishPayload.muted,
411
- providerSessionId: sessionId,
412
- });
413
- }
414
-
415
- const session = this.memberRealtimeSessions.get(memberId);
416
- if (session) {
417
- session.updatedAt = Date.now();
418
- }
419
-
420
- return this.jsonResponse(200, response);
421
- } catch (err) {
422
- return this.jsonResponse(400, {
423
- code: 400,
424
- message: err instanceof Error ? err.message : 'Failed to add realtime tracks',
425
- });
426
- }
427
- }
428
-
429
- private async handleRealtimeRenegotiate(request: Request, url: URL): Promise<Response> {
430
- try {
431
- const body = await this.readJsonBody<CloudflareRealtimeRenegotiateRequest & {
432
- sessionId?: string;
433
- connectionId?: string;
434
- }>(request);
435
- const { memberId } = await this.authenticateRealtimeRequest(
436
- request,
437
- url,
438
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
439
- );
440
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
441
- if (!sessionId) throw new Error('sessionId is required');
442
- this.assertRealtimeSessionOwnership(memberId, sessionId);
443
- if (!body.sessionDescription) {
444
- throw new Error('sessionDescription is required');
445
- }
446
-
447
- const client = await this.buildRealtimeClient();
448
- const response = await client.renegotiate(sessionId, {
449
- sessionDescription: body.sessionDescription,
450
- });
451
- this.assertRealtimeTracksResponseSuccess(response);
452
-
453
- const session = this.memberRealtimeSessions.get(memberId);
454
- if (session) {
455
- session.updatedAt = Date.now();
456
- }
457
- return this.jsonResponse(200, response);
458
- } catch (err) {
459
- return this.jsonResponse(400, {
460
- code: 400,
461
- message: err instanceof Error ? err.message : 'Failed to renegotiate realtime session',
462
- });
463
- }
464
- }
465
-
466
- private async handleRealtimeTracksClose(request: Request, url: URL): Promise<Response> {
467
- try {
468
- const body = await this.readJsonBody<CloudflareRealtimeCloseTracksRequest & {
469
- sessionId?: string;
470
- connectionId?: string;
471
- unpublish?: { kind?: MediaKind };
472
- }>(request);
473
- const { memberId } = await this.authenticateRealtimeRequest(
474
- request,
475
- url,
476
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
477
- );
478
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
479
- if (!sessionId) throw new Error('sessionId is required');
480
- this.assertRealtimeSessionOwnership(memberId, sessionId);
481
- if (!Array.isArray(body.tracks) || body.tracks.length === 0) {
482
- throw new Error('tracks is required');
483
- }
484
-
485
- const client = await this.buildRealtimeClient();
486
- const response = await client.closeTracks(sessionId, {
487
- sessionDescription: body.sessionDescription,
488
- tracks: body.tracks,
489
- force: body.force === true,
490
- });
491
- this.assertRealtimeTracksResponseSuccess(response);
492
-
493
- const unpublishKind = body.unpublish?.kind;
494
- if (unpublishKind) {
495
- await this.unpublishMedia(memberId, unpublishKind);
496
175
  }
497
176
 
498
- const session = this.memberRealtimeSessions.get(memberId);
499
- if (session) {
500
- session.updatedAt = Date.now();
177
+ const role = typeof extra.role === 'string' && extra.role.trim()
178
+ ? extra.role.trim()
179
+ : meta.role;
180
+ if (role) {
181
+ this.memberRoles.set(meta.userId, role);
501
182
  }
502
- return this.jsonResponse(200, response);
503
- } catch (err) {
504
- return this.jsonResponse(400, {
505
- code: 400,
506
- message: err instanceof Error ? err.message : 'Failed to close realtime tracks',
507
- });
508
183
  }
509
184
  }
510
185
 
511
- private async buildRealtimeClient() {
512
- const { createCloudflareRealtimeClient } = await import('../lib/cloudflare-realtime.js');
513
- return createCloudflareRealtimeClient(this.env as unknown as Env);
514
- }
515
-
516
- private getCloudflareRealtimeKitConfig(): {
517
- accountId: string;
518
- apiToken: string;
519
- appId: string;
520
- presetName: string;
521
- } {
522
- const env = this.env as unknown as Env;
523
- const accountId = env.CF_ACCOUNT_ID?.trim();
524
- const apiToken = env.CF_API_TOKEN?.trim();
525
- const appId = env.CF_REALTIME_APP_ID?.trim();
526
- const presetName = env.CF_REALTIME_PRESET_NAME?.trim() || 'group_call_participant';
527
-
528
- if (!accountId || !apiToken || !appId) {
529
- throw new Error(
530
- 'Cloudflare Realtime is not configured. Set CF_ACCOUNT_ID, CF_API_TOKEN, and CF_REALTIME_APP_ID.',
531
- );
532
- }
533
-
534
- return { accountId, apiToken, appId, presetName };
535
- }
186
+ override async fetch(request: Request): Promise<Response> {
187
+ const url = new URL(request.url);
536
188
 
537
- private buildCloudflareRealtimeKitParticipantId(memberId: string, provided?: string): string {
538
- const trimmed = typeof provided === 'string' ? provided.trim() : '';
539
- if (trimmed) {
540
- return trimmed;
189
+ if (url.pathname === '/summary' && request.method === 'GET') {
190
+ return this.handleSummaryGet(url);
541
191
  }
542
192
 
543
- return [
544
- this.namespace ?? 'room',
545
- this.roomId ?? 'unknown',
546
- memberId,
547
- Date.now().toString(36),
548
- ].join(':');
193
+ return super.fetch(request);
549
194
  }
550
195
 
551
- private async ensureCloudflareRealtimeKitMeetingId(config: {
552
- accountId: string;
553
- apiToken: string;
554
- appId: string;
555
- }): Promise<string> {
556
- if (this.cloudflareRealtimeKitMeetingId) {
557
- return this.cloudflareRealtimeKitMeetingId;
558
- }
559
-
560
- if (this.cloudflareRealtimeKitMeetingIdPromise) {
561
- return this.cloudflareRealtimeKitMeetingIdPromise;
562
- }
563
-
564
- this.cloudflareRealtimeKitMeetingIdPromise = (async () => {
565
- const storedMeetingId = await this.ctx.storage.get<string>(CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY);
566
- if (storedMeetingId) {
567
- this.cloudflareRealtimeKitMeetingId = storedMeetingId;
568
- return storedMeetingId;
569
- }
570
-
571
- const response = await fetch(
572
- `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(config.accountId)}`
573
- + `/realtime/kit/${encodeURIComponent(config.appId)}/meetings`,
574
- {
575
- method: 'POST',
576
- headers: {
577
- Authorization: `Bearer ${config.apiToken}`,
578
- 'Content-Type': 'application/json',
579
- },
580
- body: JSON.stringify({
581
- title: `${this.namespace ?? 'room'}::${this.roomId ?? 'unknown'}`,
582
- }),
583
- },
584
- );
585
- const data = await this.parseCloudflareApiEnvelope<{ id: string }>(response);
586
- this.cloudflareRealtimeKitMeetingId = data.id;
587
- await this.ctx.storage.put(CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY, data.id);
588
- return data.id;
589
- })();
590
-
591
- try {
592
- return await this.cloudflareRealtimeKitMeetingIdPromise;
593
- } finally {
594
- this.cloudflareRealtimeKitMeetingIdPromise = null;
595
- }
596
- }
196
+ private async handleSummaryGet(url: URL): Promise<Response> {
197
+ this.hydrateRoomIdentityFromUrl(url);
198
+ const metadata = await this.getRoomMetadataSnapshot();
199
+ const activeMembers = Array.from(this.members.values()).filter(
200
+ (member) => this.getVisibleMemberConnectionIds(member).length > 0
201
+ ).length;
597
202
 
598
- private async createCloudflareRealtimeKitParticipant(
599
- config: {
600
- accountId: string;
601
- apiToken: string;
602
- appId: string;
603
- presetName: string;
604
- },
605
- meetingId: string,
606
- payload: {
607
- customParticipantId: string;
608
- name?: string;
609
- picture?: string;
610
- },
611
- ): Promise<{ id: string; token: string; presetName?: string }> {
612
- const response = await fetch(
613
- `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(config.accountId)}`
614
- + `/realtime/kit/${encodeURIComponent(config.appId)}`
615
- + `/meetings/${encodeURIComponent(meetingId)}/participants`,
616
- {
617
- method: 'POST',
618
- headers: {
619
- Authorization: `Bearer ${config.apiToken}`,
620
- 'Content-Type': 'application/json',
621
- },
622
- body: JSON.stringify({
623
- custom_participant_id: payload.customParticipantId,
624
- preset_name: config.presetName,
625
- name: payload.name,
626
- picture: payload.picture,
627
- }),
203
+ return this.jsonResponse<RoomSummaryResponse>(200, {
204
+ namespace: this.namespace ?? '',
205
+ roomId: this.roomId ?? '',
206
+ metadata,
207
+ occupancy: {
208
+ activeMembers,
209
+ activeConnections: Array.from(this.members.values()).reduce(
210
+ (count, member) => count + this.getVisibleMemberConnectionIds(member).length,
211
+ 0,
212
+ ),
628
213
  },
629
- );
630
-
631
- const data = await this.parseCloudflareApiEnvelope<{
632
- id: string;
633
- token: string;
634
- preset_name?: string;
635
- }>(response);
636
-
637
- return {
638
- id: data.id,
639
- token: data.token,
640
- presetName: data.preset_name,
641
- };
642
- }
643
-
644
- private async parseCloudflareApiEnvelope<T>(response: Response): Promise<T> {
645
- const payload = (await response.json().catch(() => ({}))) as {
646
- success?: boolean;
647
- errors?: Array<{ message?: string }>;
648
- messages?: Array<{ message?: string }>;
649
- result?: T;
650
- data?: T;
651
- };
652
-
653
- if (!response.ok || payload.success === false) {
654
- const message =
655
- payload.errors?.find((entry) => typeof entry.message === 'string')?.message
656
- ?? payload.messages?.find((entry) => typeof entry.message === 'string')?.message
657
- ?? `Cloudflare API request failed (${response.status})`;
658
- throw new Error(message);
659
- }
660
-
661
- return (payload.data ?? payload.result ?? {}) as T;
662
- }
663
-
664
- private async authenticateRealtimeRequest(
665
- request: Request,
666
- url: URL,
667
- requestedConnectionId?: string,
668
- ): Promise<{ memberId: string; connectionId: string; meta: RoomWSMeta }> {
669
- this.hydrateRoomFromUrl(url);
670
-
671
- const token = this.extractBearerToken(request);
672
- if (!token) {
673
- throw new Error('Authentication required before the room connection could be established.');
674
- }
675
-
676
- const { resolveAuthContextFromToken } = await import('../middleware/auth.js');
677
- const auth = await resolveAuthContextFromToken(this.env, token, request);
678
- const memberId = auth.id;
679
- const member = this.members.get(memberId);
680
- if (!member || member.connectionIds.size === 0) {
681
- throw new Error('Join the room WebSocket before using realtime media');
682
- }
683
-
684
- const connectionId = requestedConnectionId?.trim()
685
- || (member.connectionIds.values().next().value as string | undefined);
686
- if (!connectionId) {
687
- throw new Error('No active room connection for this member');
688
- }
689
- if (!member.connectionIds.has(connectionId)) {
690
- throw new Error('connectionId does not belong to the authenticated room member');
691
- }
692
-
693
- const existingMeta = this.findConnectionMeta(connectionId);
694
- const meta: RoomWSMeta = existingMeta
695
- ? {
696
- ...existingMeta,
697
- authenticated: true,
698
- userId: memberId,
699
- role: auth.role,
700
- auth,
701
- }
702
- : {
703
- authenticated: true,
704
- userId: memberId,
705
- role: auth.role,
706
- auth,
707
- connectionId,
708
- ip: request.headers.get('CF-Connecting-IP')
709
- || request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
710
- || undefined,
711
- userAgent: request.headers.get('User-Agent') || undefined,
712
- };
713
-
714
- return { memberId, connectionId, meta };
715
- }
716
-
717
- private assertRealtimeSessionOwnership(memberId: string, sessionId: string): RoomMemberRealtimeSession {
718
- const session = this.memberRealtimeSessions.get(memberId);
719
- if (!session || session.sessionId !== sessionId) {
720
- throw new Error('Realtime session is not owned by the authenticated room member');
721
- }
722
- return session;
723
- }
724
-
725
- private assertRealtimeTracksResponseSuccess(response: CloudflareRealtimeTracksResponse): void {
726
- if (response.errorCode) {
727
- throw new Error(response.errorDescription || response.errorCode);
728
- }
729
- const trackFailure = response.tracks?.find((track) => track.errorCode);
730
- if (trackFailure?.errorCode) {
731
- throw new Error(trackFailure.errorDescription || trackFailure.errorCode);
732
- }
733
- }
734
-
735
- private hasPublishedTracks(memberId: string): boolean {
736
- const state = this.memberMediaStates.get(memberId);
737
- return !!state?.audio?.published || !!state?.video?.published || !!state?.screen?.published;
738
- }
739
-
740
- private hydrateRoomFromUrl(url: URL): void {
741
- const roomFullName = url.searchParams.get('room');
742
- if (!roomFullName || this.namespace) {
743
- return;
744
- }
745
-
746
- const separatorIdx = roomFullName.indexOf('::');
747
- if (separatorIdx >= 0) {
748
- this.namespace = roomFullName.substring(0, separatorIdx);
749
- this.roomId = roomFullName.substring(separatorIdx + 2);
750
- } else {
751
- this.namespace = roomFullName;
752
- this.roomId = roomFullName;
753
- }
754
- this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
755
- }
756
-
757
- private extractBearerToken(request: Request): string | null {
758
- const header = request.headers.get('Authorization');
759
- if (!header) return null;
760
- const match = header.match(/^Bearer\s+(.+)$/i);
761
- return match?.[1]?.trim() ?? null;
762
- }
763
-
764
- private async readJsonBody<T>(request: Request): Promise<T> {
765
- if (request.method === 'GET' || request.method === 'HEAD') {
766
- return {} as T;
767
- }
768
- try {
769
- return await request.json() as T;
770
- } catch {
771
- return {} as T;
772
- }
214
+ updatedAt: new Date().toISOString(),
215
+ });
773
216
  }
774
217
 
775
218
  private jsonResponse<T>(status: number, body: T): Response {
@@ -780,6 +223,14 @@ export class RoomsDO extends RoomRuntimeBaseDO {
780
223
  }
781
224
 
782
225
  override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
226
+ await this.ensureRuntimeReady();
227
+ await this.recoverStateIfNeeded();
228
+
229
+ const activityMeta = this.getWSMeta(ws);
230
+ if (activityMeta) {
231
+ activityMeta.lastSeenAt = Date.now();
232
+ }
233
+
783
234
  if (typeof message !== 'string') return;
784
235
 
785
236
  let msg: Record<string, unknown>;
@@ -864,34 +315,6 @@ export class RoomsDO extends RoomRuntimeBaseDO {
864
315
  return;
865
316
  }
866
317
 
867
- if (msg.type === 'media') {
868
- const meta = this.requireAuthenticatedMeta(ws);
869
- if (!meta) return;
870
-
871
- const operation = this.normalizeMediaOperation(msg.operation);
872
- const kind = this.normalizeMediaKind(msg.kind);
873
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
874
- if (!this.checkRateLimit(meta.connectionId, 'media')) {
875
- this.safeSend(ws, {
876
- type: 'media_error',
877
- operation: operation ?? '',
878
- kind: kind ?? null,
879
- message: 'Rate limited',
880
- requestId,
881
- });
882
- return;
883
- }
884
-
885
- await this.handleMedia(ws, meta, {
886
- type: 'media',
887
- operation: operation ?? 'publish',
888
- kind: kind ?? undefined,
889
- payload: this.asRecord(msg.payload),
890
- requestId,
891
- });
892
- return;
893
- }
894
-
895
318
  await super.webSocketMessage(ws, message);
896
319
  }
897
320
 
@@ -922,8 +345,18 @@ export class RoomsDO extends RoomRuntimeBaseDO {
922
345
  }
923
346
 
924
347
  const member = this.ensureMember(userId);
348
+ const restoredMemberState = wasReconnecting
349
+ ? this.normalizeRecoveredMemberState(msg.lastMemberState)
350
+ : null;
351
+ if (restoredMemberState) {
352
+ member.state = {
353
+ ...member.state,
354
+ ...restoredMemberState,
355
+ };
356
+ }
925
357
  this.joinedConnectionIds.add(meta.connectionId);
926
358
  member.connectionIds.add(meta.connectionId);
359
+ member.reconnectUntil = undefined;
927
360
 
928
361
  if (!hadMember) {
929
362
  const snapshot = this.buildMemberSnapshot(member);
@@ -932,11 +365,13 @@ export class RoomsDO extends RoomRuntimeBaseDO {
932
365
  }
933
366
 
934
367
  this.broadcastMembersSync();
935
- await this.sendMediaSyncToConnection(ws, meta);
368
+ this.sendMembersSyncToConnection(ws);
936
369
 
937
370
  if (wasReconnecting) {
938
371
  await this.runSessionReconnectHook(this.buildSender(meta));
939
372
  }
373
+
374
+ this.refreshMemberSocketAttachments(userId);
940
375
  }
941
376
 
942
377
  protected override async handleExplicitLeave(ws: WebSocket, meta: RoomWSMeta): Promise<void> {
@@ -951,9 +386,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
951
386
 
952
387
  const member = this.removeMemberConnection(userId, meta.connectionId);
953
388
  if (member) {
954
- if (member.connectionIds.size === 0) {
955
- await this.clearPublishedMedia(userId);
956
- }
389
+ this.refreshMemberSocketAttachments(userId);
957
390
  this.broadcastMembersSync();
958
391
  }
959
392
  }
@@ -978,15 +411,18 @@ export class RoomsDO extends RoomRuntimeBaseDO {
978
411
  }
979
412
 
980
413
  if (member.connectionIds.size > 0) {
414
+ member.reconnectUntil = undefined;
415
+ this.refreshMemberSocketAttachments(userId);
981
416
  this.broadcastMembersSync();
982
417
  return;
983
418
  }
984
419
 
985
- await this.clearPublishedMedia(userId);
986
-
987
420
  const reconnectTimeout = this.namespaceConfig?.reconnectTimeout ?? DEFAULT_MEMBER_RECONNECT_TIMEOUT_MS;
988
421
  if (!kicked && reconnectTimeout > 0) {
422
+ member.reconnectUntil = Date.now() + reconnectTimeout;
423
+ this.refreshMemberSocketAttachments(userId);
989
424
  this.broadcastMembersSync();
425
+ return;
990
426
  }
991
427
  }
992
428
 
@@ -1013,8 +449,6 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1013
449
  }
1014
450
 
1015
451
  this.deleteMember(userId);
1016
- this.memberMediaStates.delete(userId);
1017
- this.memberRealtimeSessions.delete(userId);
1018
452
 
1019
453
  const leaveReason: RoomMemberLeaveReason = reason === 'disconnect' ? 'timeout' : reason;
1020
454
  await this.runMemberLeaveHook(snapshot, leaveReason);
@@ -1147,15 +581,6 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1147
581
  this.broadcastMembersSync();
1148
582
  break;
1149
583
  }
1150
- case 'mute':
1151
- await this.applyMuteChange(memberId, 'audio', true);
1152
- break;
1153
- case 'disableVideo':
1154
- await this.applyAdminUnpublish(memberId, 'video');
1155
- break;
1156
- case 'stopScreenShare':
1157
- await this.applyAdminUnpublish(memberId, 'screen');
1158
- break;
1159
584
  default:
1160
585
  throw new Error(`Unsupported admin operation '${operation}'`);
1161
586
  }
@@ -1178,103 +603,15 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1178
603
  }
1179
604
  }
1180
605
 
1181
- private async handleMedia(
606
+ private async handleSignal(
1182
607
  ws: WebSocket,
1183
608
  meta: RoomWSMeta,
1184
- msg: MediaMessage,
609
+ msg: SignalMessage,
1185
610
  ): Promise<void> {
1186
- const operation = msg.operation;
1187
- const kind = msg.kind;
1188
- const requestId = msg.requestId;
1189
- if (!operation || !kind) {
1190
- this.safeSend(ws, {
1191
- type: 'media_error',
1192
- operation: operation ?? '',
1193
- kind: kind ?? null,
1194
- message: 'operation and kind are required',
1195
- requestId,
1196
- });
1197
- return;
1198
- }
1199
-
1200
- if (!meta.userId || !this.roomId) {
1201
- this.safeSend(ws, {
1202
- type: 'media_error',
1203
- operation,
1204
- kind,
1205
- message: 'User not authenticated',
1206
- requestId,
1207
- });
1208
- return;
1209
- }
1210
-
1211
- if (!this.isJoinedConnection(meta.connectionId)) {
1212
- this.safeSend(ws, {
1213
- type: 'media_error',
1214
- operation,
1215
- kind,
1216
- message: 'Join the room before using media controls',
1217
- requestId,
1218
- });
1219
- return;
1220
- }
1221
-
1222
- try {
1223
- const payload = msg.payload ?? {};
1224
- if (operation === 'publish') {
1225
- if (!(await this.canPublishMedia(meta, kind, payload))) {
1226
- throw new Error('Denied by room media publish access rule');
1227
- }
1228
- } else if (!(await this.canControlMedia(meta, operation, { kind, ...payload }))) {
1229
- throw new Error('Denied by room media control access rule');
1230
- }
1231
-
1232
- switch (operation) {
1233
- case 'publish':
1234
- await this.publishMedia(meta, kind, payload);
1235
- break;
1236
- case 'unpublish':
1237
- await this.unpublishMedia(meta.userId, kind);
1238
- break;
1239
- case 'mute': {
1240
- const muted = payload.muted === true;
1241
- await this.applyMuteChange(meta.userId, kind, muted);
1242
- break;
1243
- }
1244
- case 'device':
1245
- await this.applyDeviceChange(meta.userId, kind, payload);
1246
- break;
1247
- default:
1248
- throw new Error(`Unsupported media operation '${operation}'`);
1249
- }
1250
-
1251
- this.safeSend(ws, {
1252
- type: 'media_result',
1253
- operation,
1254
- kind,
1255
- requestId,
1256
- result: { ok: true },
1257
- });
1258
- } catch (err) {
1259
- this.safeSend(ws, {
1260
- type: 'media_error',
1261
- operation,
1262
- kind,
1263
- requestId,
1264
- message: err instanceof Error ? err.message : 'Media operation failed',
1265
- });
1266
- }
1267
- }
1268
-
1269
- private async handleSignal(
1270
- ws: WebSocket,
1271
- meta: RoomWSMeta,
1272
- msg: SignalMessage,
1273
- ): Promise<void> {
1274
- const event = typeof msg.event === 'string' ? msg.event.trim() : '';
1275
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1276
-
1277
- if (!event) {
611
+ const event = typeof msg.event === 'string' ? msg.event.trim() : '';
612
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
613
+
614
+ if (!event) {
1278
615
  this.safeSend(ws, {
1279
616
  type: 'signal_error',
1280
617
  event: '',
@@ -1404,6 +741,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1404
741
 
1405
742
  const snapshot = this.buildMemberSnapshot(member);
1406
743
  const state = { ...member.state };
744
+ this.refreshMemberSocketAttachments(meta.userId);
1407
745
  this.broadcastToJoined({
1408
746
  type: 'member_state',
1409
747
  member: snapshot,
@@ -1618,590 +956,18 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1618
956
  }
1619
957
  }
1620
958
 
1621
- private async canPublishMedia(
1622
- meta: RoomWSMeta,
1623
- kind: MediaKind,
1624
- payload: Record<string, unknown>,
1625
- ): Promise<boolean> {
1626
- const publishAccess = this.namespaceConfig?.access?.media?.publish;
1627
- if (!publishAccess || !this.roomId) {
1628
- return !this.config.release;
1629
- }
1630
-
1631
- try {
1632
- return await Promise.resolve(
1633
- publishAccess(this.buildAuthFromMeta(meta), this.roomId, kind, payload),
1634
- );
1635
- } catch {
1636
- return false;
1637
- }
1638
- }
1639
-
1640
- private async canControlMedia(
1641
- meta: RoomWSMeta,
1642
- operation: MediaMessage['operation'],
1643
- payload: Record<string, unknown>,
1644
- ): Promise<boolean> {
1645
- const controlAccess = this.namespaceConfig?.access?.media?.control;
1646
- if (!controlAccess || !this.roomId) {
1647
- return !this.config.release;
1648
- }
1649
-
1650
- try {
1651
- return await Promise.resolve(
1652
- controlAccess(this.buildAuthFromMeta(meta), this.roomId, operation, payload),
1653
- );
1654
- } catch {
1655
- return false;
1656
- }
1657
- }
1658
-
1659
- private async canSubscribeToMedia(
1660
- meta: RoomWSMeta,
1661
- payload: Record<string, unknown>,
1662
- ): Promise<boolean> {
1663
- if (meta.userId && payload.memberId === meta.userId) {
1664
- return true;
1665
- }
1666
-
1667
- const subscribeAccess = this.namespaceConfig?.access?.media?.subscribe;
1668
- if (!subscribeAccess || !this.roomId) {
1669
- return !this.config.release;
1670
- }
1671
-
1672
- try {
1673
- return await Promise.resolve(
1674
- subscribeAccess(this.buildAuthFromMeta(meta), this.roomId, payload),
1675
- );
1676
- } catch {
1677
- return false;
1678
- }
1679
- }
1680
-
1681
- private ensureMemberMediaState(memberId: string): RoomMemberMediaState {
1682
- let mediaState = this.memberMediaStates.get(memberId);
1683
- if (!mediaState) {
1684
- mediaState = {};
1685
- this.memberMediaStates.set(memberId, mediaState);
1686
- }
1687
- return mediaState;
1688
- }
1689
-
1690
- private getKindState(memberId: string, kind: MediaKind): RoomMemberMediaKindState {
1691
- const mediaState = this.ensureMemberMediaState(memberId);
1692
- mediaState[kind] ??= {
1693
- published: false,
1694
- muted: false,
1695
- };
1696
- return mediaState[kind]!;
1697
- }
1698
-
1699
- private pruneMediaKindState(memberId: string, kind: MediaKind): void {
1700
- const mediaState = this.memberMediaStates.get(memberId);
1701
- const kindState = mediaState?.[kind];
1702
- if (!mediaState || !kindState) {
959
+ private sendMembersSyncToConnection(ws: WebSocket): void {
960
+ if (!this.isSocketOpen(ws)) {
1703
961
  return;
1704
962
  }
1705
963
 
1706
- if (
1707
- !kindState.published &&
1708
- !kindState.muted &&
1709
- !kindState.trackId &&
1710
- !kindState.deviceId &&
1711
- !kindState.publishedAt &&
1712
- !kindState.adminDisabled &&
1713
- !kindState.providerSessionId
1714
- ) {
1715
- delete mediaState[kind];
1716
- }
1717
-
1718
- if (!mediaState.audio && !mediaState.video && !mediaState.screen) {
1719
- this.memberMediaStates.delete(memberId);
1720
- }
1721
- }
1722
-
1723
- private buildMediaStateSnapshot(memberId: string): RoomMemberMediaState {
1724
- const mediaState = this.memberMediaStates.get(memberId);
1725
- if (!mediaState) {
1726
- return {};
1727
- }
1728
-
1729
- const snapshot: RoomMemberMediaState = {};
1730
- for (const kind of ['audio', 'video', 'screen'] as const) {
1731
- const kindState = mediaState[kind];
1732
- if (kindState) {
1733
- snapshot[kind] = { ...kindState };
1734
- }
1735
- }
1736
- return snapshot;
1737
- }
1738
-
1739
- private buildMediaTrackFrame(memberId: string, kind: MediaKind): {
1740
- kind: MediaKind;
1741
- trackId?: string;
1742
- deviceId?: string;
1743
- muted: boolean;
1744
- publishedAt?: number;
1745
- adminDisabled?: boolean;
1746
- providerSessionId?: string;
1747
- } | null {
1748
- const kindState = this.memberMediaStates.get(memberId)?.[kind];
1749
- if (!kindState?.published) {
1750
- return null;
1751
- }
1752
-
1753
- return {
1754
- kind,
1755
- trackId: kindState.trackId,
1756
- deviceId: kindState.deviceId,
1757
- muted: kindState.muted,
1758
- publishedAt: kindState.publishedAt,
1759
- adminDisabled: kindState.adminDisabled,
1760
- providerSessionId: kindState.providerSessionId,
1761
- };
1762
- }
1763
-
1764
- private listPublishedTracks(memberId: string): Array<{
1765
- kind: MediaKind;
1766
- trackId?: string;
1767
- deviceId?: string;
1768
- muted: boolean;
1769
- publishedAt?: number;
1770
- adminDisabled?: boolean;
1771
- providerSessionId?: string;
1772
- }> {
1773
- const tracks: Array<{
1774
- kind: MediaKind;
1775
- trackId?: string;
1776
- deviceId?: string;
1777
- muted: boolean;
1778
- publishedAt?: number;
1779
- adminDisabled?: boolean;
1780
- providerSessionId?: string;
1781
- }> = [];
1782
- for (const kind of ['audio', 'video', 'screen'] as const) {
1783
- const track = this.buildMediaTrackFrame(memberId, kind);
1784
- if (track) {
1785
- tracks.push(track);
1786
- }
1787
- }
1788
- return tracks;
1789
- }
1790
-
1791
- private buildMemberSender(memberId: string): RoomSender {
1792
- const member = this.members.get(memberId);
1793
- const info = member
1794
- ? this.buildMemberInfo(member)
1795
- : { memberId, userId: memberId, connectionId: undefined, connectionCount: 0, role: this.memberRoles.get(memberId) };
1796
-
1797
- return {
1798
- userId: info.userId,
1799
- connectionId: info.connectionId ?? 'server',
1800
- role: info.role,
1801
- };
1802
- }
1803
-
1804
- private async publishMedia(
1805
- meta: RoomWSMeta,
1806
- kind: MediaKind,
1807
- payload: Record<string, unknown>,
1808
- ): Promise<void> {
1809
- if (!meta.userId) {
1810
- throw new Error('User not authenticated');
1811
- }
1812
-
1813
- const member = this.members.get(meta.userId);
1814
- if (!member) {
1815
- throw new Error('Member is not joined');
1816
- }
1817
-
1818
- const sender = this.buildSender(meta);
1819
- const roomApi = this.buildRoomServerAPI();
1820
- const beforePublish = await this.applyMediaBeforePublish(kind, sender, roomApi);
1821
- if (beforePublish === MEDIA_DENIED) {
1822
- throw new Error('Rejected by room media hook');
1823
- }
1824
-
1825
- const nextPayload = beforePublish && typeof beforePublish === 'object' && !Array.isArray(beforePublish)
1826
- ? { ...payload, ...(beforePublish as Record<string, unknown>) }
1827
- : payload;
1828
- const previousTrack = this.buildMediaTrackFrame(meta.userId, kind);
1829
- const kindState = this.getKindState(meta.userId, kind);
1830
- const trackId = typeof nextPayload.trackId === 'string' && nextPayload.trackId.trim()
1831
- ? nextPayload.trackId.trim()
1832
- : kindState.trackId ?? `${kind}-${crypto.randomUUID()}`;
1833
- const deviceId = typeof nextPayload.deviceId === 'string' && nextPayload.deviceId.trim()
1834
- ? nextPayload.deviceId.trim()
1835
- : kindState.deviceId;
1836
- const providerSessionId =
1837
- typeof nextPayload.providerSessionId === 'string' && nextPayload.providerSessionId.trim()
1838
- ? nextPayload.providerSessionId.trim()
1839
- : kindState.providerSessionId;
1840
-
1841
- kindState.published = true;
1842
- kindState.muted = nextPayload.muted === true ? true : kindState.muted;
1843
- kindState.trackId = trackId;
1844
- kindState.deviceId = deviceId;
1845
- kindState.publishedAt = Date.now();
1846
- kindState.adminDisabled = false;
1847
- kindState.providerSessionId = providerSessionId;
1848
-
1849
- if (previousTrack && previousTrack.trackId !== trackId) {
1850
- await this.broadcastMediaTrackRemoved(meta.userId, kind, previousTrack);
1851
- }
1852
-
1853
- await this.broadcastMediaTrack(meta.userId, kind);
1854
- await this.broadcastMediaState(meta.userId);
1855
- await this.runMediaPublishedHook(kind, sender, roomApi);
1856
- }
1857
-
1858
- private async unpublishMedia(memberId: string, kind: MediaKind): Promise<void> {
1859
- const mediaState = this.memberMediaStates.get(memberId);
1860
- const kindState = mediaState?.[kind];
1861
- if (!kindState) {
1862
- return;
1863
- }
1864
-
1865
- const previousTrack = this.buildMediaTrackFrame(memberId, kind);
1866
- if (!kindState.published && !previousTrack) {
1867
- kindState.trackId = undefined;
1868
- kindState.publishedAt = undefined;
1869
- kindState.adminDisabled = false;
1870
- kindState.providerSessionId = undefined;
1871
- this.pruneMediaKindState(memberId, kind);
1872
- return;
1873
- }
1874
-
1875
- kindState.published = false;
1876
- kindState.trackId = undefined;
1877
- kindState.publishedAt = undefined;
1878
- kindState.adminDisabled = false;
1879
- kindState.providerSessionId = undefined;
1880
- this.pruneMediaKindState(memberId, kind);
1881
-
1882
- if (previousTrack) {
1883
- await this.broadcastMediaTrackRemoved(memberId, kind, previousTrack);
1884
- await this.broadcastMediaState(memberId);
1885
- await this.runMediaUnpublishedHook(kind, this.buildMemberSender(memberId), this.buildRoomServerAPI());
1886
- }
1887
- }
1888
-
1889
- private async applyMuteChange(
1890
- memberId: string,
1891
- kind: MediaKind,
1892
- muted: boolean,
1893
- ): Promise<void> {
1894
- if (!this.members.has(memberId) && !this.memberMediaStates.has(memberId)) {
1895
- throw new Error('Unknown member');
1896
- }
1897
-
1898
- const kindState = this.getKindState(memberId, kind);
1899
- if (kindState.muted === muted) {
1900
- return;
1901
- }
1902
-
1903
- kindState.muted = muted;
1904
- this.pruneMediaKindState(memberId, kind);
1905
- await this.broadcastMediaState(memberId);
1906
- await this.runMediaMuteChangeHook(
1907
- kind,
1908
- this.buildMemberSender(memberId),
1909
- muted,
1910
- this.buildRoomServerAPI(),
1911
- );
1912
- }
1913
-
1914
- private async applyDeviceChange(
1915
- memberId: string,
1916
- kind: MediaKind,
1917
- payload: Record<string, unknown>,
1918
- ): Promise<void> {
1919
- if (!this.members.has(memberId) && !this.memberMediaStates.has(memberId)) {
1920
- throw new Error('Unknown member');
1921
- }
1922
-
1923
- const deviceId = typeof payload.deviceId === 'string' ? payload.deviceId.trim() : '';
1924
- if (!deviceId) {
1925
- throw new Error('deviceId is required');
1926
- }
1927
-
1928
- const kindState = this.getKindState(memberId, kind);
1929
- if (kindState.deviceId === deviceId) {
1930
- return;
1931
- }
1932
-
1933
- kindState.deviceId = deviceId;
1934
- await this.broadcastMediaState(memberId);
1935
- await this.broadcastMediaDevice(memberId, kind, deviceId);
1936
- }
1937
-
1938
- private async applyAdminUnpublish(memberId: string, kind: MediaKind): Promise<void> {
1939
- const kindState = this.getKindState(memberId, kind);
1940
- kindState.adminDisabled = true;
1941
- await this.unpublishMedia(memberId, kind);
1942
- }
1943
-
1944
- private async clearPublishedMedia(memberId: string): Promise<void> {
1945
- for (const kind of ['audio', 'video', 'screen'] as const) {
1946
- await this.unpublishMedia(memberId, kind);
1947
- }
1948
- }
1949
-
1950
- private async applyMediaBeforePublish(
1951
- kind: MediaKind,
1952
- sender: RoomSender,
1953
- roomApi: RoomServerAPI,
1954
- ): Promise<unknown | typeof MEDIA_DENIED> {
1955
- const beforePublish = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.beforePublish;
1956
- if (!beforePublish) {
1957
- return undefined;
1958
- }
1959
-
1960
- const ctx = await this.buildHandlerContext();
1961
- const result = await Promise.resolve(beforePublish(kind, sender, roomApi, ctx));
1962
- if (result === false) {
1963
- return MEDIA_DENIED;
1964
- }
1965
- return result;
1966
- }
1967
-
1968
- private async runMediaPublishedHook(
1969
- kind: MediaKind,
1970
- sender: RoomSender,
1971
- roomApi: RoomServerAPI,
1972
- ): Promise<void> {
1973
- const onPublished = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onPublished;
1974
- if (!onPublished) return;
1975
-
1976
- try {
1977
- const ctx = await this.buildHandlerContext();
1978
- await Promise.resolve(onPublished(kind, sender, roomApi, ctx));
1979
- } catch (err) {
1980
- console.error(`[Rooms] media.onPublished error: ${err instanceof Error ? err.message : String(err)}`);
1981
- }
1982
- }
1983
-
1984
- private async runMediaUnpublishedHook(
1985
- kind: MediaKind,
1986
- sender: RoomSender,
1987
- roomApi: RoomServerAPI,
1988
- ): Promise<void> {
1989
- const onUnpublished = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onUnpublished;
1990
- if (!onUnpublished) return;
1991
-
1992
- try {
1993
- const ctx = await this.buildHandlerContext();
1994
- await Promise.resolve(onUnpublished(kind, sender, roomApi, ctx));
1995
- } catch (err) {
1996
- console.error(`[Rooms] media.onUnpublished error: ${err instanceof Error ? err.message : String(err)}`);
1997
- }
1998
- }
1999
-
2000
- private async runMediaMuteChangeHook(
2001
- kind: MediaKind,
2002
- sender: RoomSender,
2003
- muted: boolean,
2004
- roomApi: RoomServerAPI,
2005
- ): Promise<void> {
2006
- const onMuteChange = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onMuteChange;
2007
- if (!onMuteChange) return;
2008
-
2009
- try {
2010
- const ctx = await this.buildHandlerContext();
2011
- await Promise.resolve(onMuteChange(kind, sender, muted, roomApi, ctx));
2012
- } catch (err) {
2013
- console.error(`[Rooms] media.onMuteChange error: ${err instanceof Error ? err.message : String(err)}`);
2014
- }
2015
- }
2016
-
2017
- private async broadcastMediaTrack(memberId: string, kind: MediaKind): Promise<void> {
2018
- const member = this.members.get(memberId);
2019
- const track = this.buildMediaTrackFrame(memberId, kind);
2020
- if (!member || !track) {
2021
- return;
2022
- }
2023
-
2024
- await this.broadcastMediaFrame(
2025
- {
2026
- type: 'media_track',
2027
- member: this.buildMemberInfo(member),
2028
- track,
2029
- },
2030
- {
2031
- event: 'track',
2032
- memberId,
2033
- kind,
2034
- track,
2035
- },
2036
- );
2037
- }
2038
-
2039
- private async broadcastMediaTrackRemoved(
2040
- memberId: string,
2041
- kind: MediaKind,
2042
- track?: {
2043
- kind: MediaKind;
2044
- trackId?: string;
2045
- deviceId?: string;
2046
- muted: boolean;
2047
- publishedAt?: number;
2048
- adminDisabled?: boolean;
2049
- } | null,
2050
- ): Promise<void> {
2051
- const member = this.members.get(memberId);
2052
- if (!member) {
2053
- return;
2054
- }
2055
-
2056
- await this.broadcastMediaFrame(
2057
- {
2058
- type: 'media_track_removed',
2059
- member: this.buildMemberInfo(member),
2060
- track: track ?? { kind },
2061
- },
2062
- {
2063
- event: 'track_removed',
2064
- memberId,
2065
- kind,
2066
- track: track ?? { kind },
2067
- },
2068
- );
2069
- }
2070
-
2071
- private async broadcastMediaState(memberId: string): Promise<void> {
2072
- const member = this.members.get(memberId);
2073
- if (!member) {
2074
- return;
2075
- }
2076
-
2077
- const state = this.buildMediaStateSnapshot(memberId);
2078
- await this.broadcastMediaFrame(
2079
- {
2080
- type: 'media_state',
2081
- member: this.buildMemberInfo(member),
2082
- state,
2083
- },
2084
- {
2085
- event: 'state',
2086
- memberId,
2087
- state,
2088
- },
2089
- );
2090
- }
2091
-
2092
- private async broadcastMediaDevice(
2093
- memberId: string,
2094
- kind: MediaKind,
2095
- deviceId: string,
2096
- ): Promise<void> {
2097
- const member = this.members.get(memberId);
2098
- if (!member) {
2099
- return;
2100
- }
2101
-
2102
- await this.broadcastMediaFrame(
2103
- {
2104
- type: 'media_device',
2105
- member: this.buildMemberInfo(member),
2106
- kind,
2107
- deviceId,
2108
- },
2109
- {
2110
- event: 'device',
2111
- memberId,
2112
- kind,
2113
- deviceId,
2114
- },
2115
- );
2116
- }
2117
-
2118
- private async broadcastMediaFrame(
2119
- frame: Record<string, unknown>,
2120
- payload: Record<string, unknown>,
2121
- ): Promise<void> {
2122
- const json = JSON.stringify(frame);
2123
- for (const ws of this.ctx.getWebSockets()) {
2124
- const meta = this.getWSMeta(ws);
2125
- if (!meta?.authenticated || !this.joinedConnectionIds.has(meta.connectionId)) {
2126
- continue;
2127
- }
2128
- if (!(await this.canSubscribeToMedia(meta, payload))) {
2129
- continue;
2130
- }
2131
- this.safeSendRaw(ws, json);
2132
- }
2133
- }
2134
-
2135
- private async sendMediaSyncToConnection(ws: WebSocket, meta: RoomWSMeta): Promise<void> {
2136
- if (!this.isSocketOpen(ws) || !meta.userId) {
2137
- return;
2138
- }
2139
-
2140
- const members: Array<{
2141
- member: RoomMemberInfo;
2142
- state: RoomMemberMediaState;
2143
- tracks: Array<{
2144
- kind: MediaKind;
2145
- trackId?: string;
2146
- deviceId?: string;
2147
- muted: boolean;
2148
- publishedAt?: number;
2149
- adminDisabled?: boolean;
2150
- }>;
2151
- }> = [];
2152
-
2153
- for (const member of this.listMembers()) {
2154
- const state = this.buildMediaStateSnapshot(member.memberId);
2155
- const tracks = this.listPublishedTracks(member.memberId);
2156
- if (Object.keys(state).length === 0 && tracks.length === 0) {
2157
- continue;
2158
- }
2159
- if (!(await this.canSubscribeToMedia(meta, {
2160
- event: 'sync',
2161
- memberId: member.memberId,
2162
- state,
2163
- tracks,
2164
- }))) {
2165
- continue;
2166
- }
2167
- members.push({ member, state, tracks });
2168
- }
2169
-
2170
964
  this.safeSend(ws, {
2171
- type: 'media_sync',
2172
- members,
965
+ type: 'members_sync',
966
+ occupancy: this.buildAuthoritativeOccupancy(),
967
+ members: this.listMembers(),
2173
968
  });
2174
969
  }
2175
970
 
2176
- private normalizeMediaOperation(operation: unknown): MediaMessage['operation'] | null {
2177
- if (typeof operation !== 'string') {
2178
- return null;
2179
- }
2180
- switch (operation.trim()) {
2181
- case 'publish':
2182
- case 'unpublish':
2183
- case 'mute':
2184
- case 'device':
2185
- return operation.trim() as MediaMessage['operation'];
2186
- default:
2187
- return null;
2188
- }
2189
- }
2190
-
2191
- private normalizeMediaKind(kind: unknown): MediaKind | null {
2192
- if (typeof kind !== 'string') {
2193
- return null;
2194
- }
2195
- switch (kind.trim()) {
2196
- case 'audio':
2197
- case 'video':
2198
- case 'screen':
2199
- return kind.trim() as MediaKind;
2200
- default:
2201
- return null;
2202
- }
2203
- }
2204
-
2205
971
  private deliverSignal(
2206
972
  frame: {
2207
973
  type: 'signal';
@@ -2254,6 +1020,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2254
1020
  private broadcastMembersSync(): void {
2255
1021
  this.broadcastToJoined({
2256
1022
  type: 'members_sync',
1023
+ occupancy: this.buildAuthoritativeOccupancy(),
2257
1024
  members: this.listMembers(),
2258
1025
  });
2259
1026
  }
@@ -2299,6 +1066,14 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2299
1066
  return member;
2300
1067
  }
2301
1068
 
1069
+ private normalizeRecoveredMemberState(value: unknown): Record<string, unknown> | null {
1070
+ if (!isRecord(value)) {
1071
+ return null;
1072
+ }
1073
+
1074
+ return { ...value };
1075
+ }
1076
+
2302
1077
  private removeMemberConnection(userId: string, connectionId: string): RoomMemberPresence | null {
2303
1078
  this.joinedConnectionIds.delete(connectionId);
2304
1079
  const member = this.members.get(userId);
@@ -2322,12 +1097,70 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2322
1097
  this.members.delete(userId);
2323
1098
  }
2324
1099
 
1100
+ private getEffectiveSocketStaleTimeoutMs(): number {
1101
+ const configured = (this.namespaceConfig as { socketStaleTimeout?: unknown } | null)?.socketStaleTimeout;
1102
+ if (typeof configured !== 'number' || !Number.isFinite(configured)) {
1103
+ return 20000;
1104
+ }
1105
+ const normalized = Math.floor(configured);
1106
+ return normalized >= 3000 ? normalized : 20000;
1107
+ }
1108
+
1109
+ private getVisibleMemberConnectionIds(member: RoomMemberPresence): string[] {
1110
+ if (member.connectionIds.size === 0) {
1111
+ return [];
1112
+ }
1113
+
1114
+ const staleBefore = Date.now() - this.getEffectiveSocketStaleTimeoutMs();
1115
+ const visibleConnectionIds: string[] = [];
1116
+
1117
+ for (const connectionId of member.connectionIds) {
1118
+ const meta = this.findConnectionMeta(connectionId);
1119
+ if (!meta?.authenticated) {
1120
+ continue;
1121
+ }
1122
+ if ((meta.lastSeenAt ?? 0) <= staleBefore) {
1123
+ continue;
1124
+ }
1125
+ visibleConnectionIds.push(connectionId);
1126
+ }
1127
+
1128
+ return visibleConnectionIds;
1129
+ }
1130
+
2325
1131
  private listMembers(): RoomMemberSnapshot[] {
1132
+ const now = Date.now();
2326
1133
  return Array.from(this.members.values())
1134
+ .filter((member) => {
1135
+ const visibleConnectionIds = this.getVisibleMemberConnectionIds(member);
1136
+ if (visibleConnectionIds.length > 0) {
1137
+ return true;
1138
+ }
1139
+ return typeof member.reconnectUntil === 'number' && member.reconnectUntil > now;
1140
+ })
2327
1141
  .sort((left, right) => left.joinedAt - right.joinedAt || left.memberId.localeCompare(right.memberId))
2328
1142
  .map((member) => this.buildMemberSnapshot(member));
2329
1143
  }
2330
1144
 
1145
+ private buildAuthoritativeOccupancy(): { activeMembers: number; activeConnections: number } {
1146
+ let activeMembers = 0;
1147
+ let activeConnections = 0;
1148
+
1149
+ for (const member of this.members.values()) {
1150
+ const connectionCount = this.getVisibleMemberConnectionIds(member).length;
1151
+ if (connectionCount <= 0) {
1152
+ continue;
1153
+ }
1154
+ activeMembers += 1;
1155
+ activeConnections += connectionCount;
1156
+ }
1157
+
1158
+ return {
1159
+ activeMembers,
1160
+ activeConnections,
1161
+ };
1162
+ }
1163
+
2331
1164
  private buildMemberSnapshot(
2332
1165
  member: RoomMemberPresence,
2333
1166
  fallbackConnectionId?: string,
@@ -2342,7 +1175,9 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2342
1175
  member: RoomMemberPresence,
2343
1176
  fallbackConnectionId?: string,
2344
1177
  ): RoomMemberInfo {
2345
- const activeConnectionId = member.connectionIds.values().next().value as string | undefined;
1178
+ const visibleConnectionIds = this.getVisibleMemberConnectionIds(member);
1179
+ const activeConnectionId = visibleConnectionIds[0]
1180
+ ?? member.connectionIds.values().next().value as string | undefined;
2346
1181
  const connectionId = activeConnectionId ?? fallbackConnectionId;
2347
1182
  const meta = connectionId ? this.findConnectionMeta(connectionId) : null;
2348
1183
  const role = this.memberRoles.get(member.memberId) ?? meta?.role;
@@ -2351,7 +1186,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2351
1186
  memberId: member.memberId,
2352
1187
  userId: member.userId,
2353
1188
  connectionId,
2354
- connectionCount: member.connectionIds.size,
1189
+ connectionCount: visibleConnectionIds.length,
2355
1190
  role,
2356
1191
  };
2357
1192
  }
@@ -2428,4 +1263,14 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2428
1263
  this.setWSMeta(ws, meta);
2429
1264
  }
2430
1265
  }
1266
+
1267
+ private refreshMemberSocketAttachments(memberId: string): void {
1268
+ for (const ws of this.ctx.getWebSockets()) {
1269
+ const meta = this.getWSMeta(ws);
1270
+ if (!meta || meta.userId !== memberId) {
1271
+ continue;
1272
+ }
1273
+ this.setWSMeta(ws, meta);
1274
+ }
1275
+ }
2431
1276
  }