@edge-base/server 0.2.5 → 0.2.7

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 (135) hide show
  1. package/admin-build/_app/immutable/chunks/{DILS_-VJ.js → B3CvhH3c.js} +1 -1
  2. package/admin-build/_app/immutable/chunks/BDYewzou.js +1 -0
  3. package/admin-build/_app/immutable/chunks/{Cdm5zBRA.js → BEM1BeVF.js} +1 -1
  4. package/admin-build/_app/immutable/chunks/{Dt4vL4Df.js → BYL_uBga.js} +1 -1
  5. package/admin-build/_app/immutable/chunks/{B94PilAN.js → BYyykAbh.js} +1 -1
  6. package/admin-build/_app/immutable/chunks/BaUG2TJ-.js +1 -0
  7. package/admin-build/_app/immutable/chunks/{C72lTcG0.js → Bcs4KYNp.js} +1 -1
  8. package/admin-build/_app/immutable/chunks/{D2j3I1VQ.js → BfpUQYr3.js} +1 -1
  9. package/admin-build/_app/immutable/chunks/BhCO1Fpt.js +1 -0
  10. package/admin-build/_app/immutable/chunks/{B8s_s9QY.js → BkZCgsc3.js} +1 -1
  11. package/admin-build/_app/immutable/chunks/CIOC1v_q.js +128 -0
  12. package/admin-build/_app/immutable/chunks/CjcrXziO.js +2 -0
  13. package/admin-build/_app/immutable/chunks/CvczjTXx.js +1 -0
  14. package/admin-build/_app/immutable/chunks/D1u3u7xu.js +1 -0
  15. package/admin-build/_app/immutable/chunks/{B0HRJ657.js → DOOPbWwG.js} +1 -1
  16. package/admin-build/_app/immutable/chunks/{BqTb6Mxk.js → DaXO-sFP.js} +1 -1
  17. package/admin-build/_app/immutable/chunks/DnpbvAPi.js +1 -0
  18. package/admin-build/_app/immutable/chunks/{B6MschND.js → Dz9cUCuv.js} +1 -1
  19. package/admin-build/_app/immutable/chunks/{CaVKAiCe.js → Tea2dBJ8.js} +1 -1
  20. package/admin-build/_app/immutable/chunks/{Z41NK6i6.js → bguI1TeA.js} +1 -1
  21. package/admin-build/_app/immutable/chunks/{J2Gw0SMu.js → ejoEf2I5.js} +1 -1
  22. package/admin-build/_app/immutable/chunks/{B2TnDKF7.js → iEyeblJR.js} +1 -1
  23. package/admin-build/_app/immutable/chunks/{_teD5ji5.js → nlAMTi52.js} +1 -1
  24. package/admin-build/_app/immutable/chunks/qKdzaeX3.js +1 -0
  25. package/admin-build/_app/immutable/entry/{app.D3flihMw.js → app.DoUaxnew.js} +2 -2
  26. package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
  27. package/admin-build/_app/immutable/nodes/{0.CdczqZLK.js → 0.Dsxi8s7i.js} +1 -1
  28. package/admin-build/_app/immutable/nodes/1.Cp2l-hol.js +1 -0
  29. package/admin-build/_app/immutable/nodes/10.4oY6m8Nz.js +1 -0
  30. package/admin-build/_app/immutable/nodes/11.DfcozD4J.js +1 -0
  31. package/admin-build/_app/immutable/nodes/12.uJgZdCIA.js +1 -0
  32. package/admin-build/_app/immutable/nodes/13.CaN1kRev.js +110 -0
  33. package/admin-build/_app/immutable/nodes/14.DQ5xIi3s.js +3 -0
  34. package/admin-build/_app/immutable/nodes/15.B_EkebTJ.js +1 -0
  35. package/admin-build/_app/immutable/nodes/{16.BR7WwQrS.js → 16.Tko1ZX8-.js} +1 -1
  36. package/admin-build/_app/immutable/nodes/{17.Cm57KKXV.js → 17.BCmWMJX9.js} +1 -1
  37. package/admin-build/_app/immutable/nodes/18.hmGhl1O2.js +1 -0
  38. package/admin-build/_app/immutable/nodes/19.D-1infOo.js +2 -0
  39. package/admin-build/_app/immutable/nodes/{20.DnHeFlTv.js → 20.CY4KKcBL.js} +1 -1
  40. package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
  41. package/admin-build/_app/immutable/nodes/22.14Vd7bnt.js +1 -0
  42. package/admin-build/_app/immutable/nodes/{23.CWSGMcKJ.js → 23.Be6jK77o.js} +2 -2
  43. package/admin-build/_app/immutable/nodes/24.CSTFkr6R.js +2 -0
  44. package/admin-build/_app/immutable/nodes/25.DRTg8fHc.js +2 -0
  45. package/admin-build/_app/immutable/nodes/26.DKt-9lwQ.js +1 -0
  46. package/admin-build/_app/immutable/nodes/27.D5caPu0F.js +1 -0
  47. package/admin-build/_app/immutable/nodes/28.hJhlnlyY.js +1 -0
  48. package/admin-build/_app/immutable/nodes/29.CDYBzFyT.js +1 -0
  49. package/admin-build/_app/immutable/nodes/{3.B6q-7qr8.js → 3.DMyKwkGn.js} +1 -1
  50. package/admin-build/_app/immutable/nodes/30.BaHNeEmc.js +1 -0
  51. package/admin-build/_app/immutable/nodes/31.C6PV5L-2.js +1 -0
  52. package/admin-build/_app/immutable/nodes/4.9E118Ftm.js +1 -0
  53. package/admin-build/_app/immutable/nodes/5.D8guAl3v.js +1 -0
  54. package/admin-build/_app/immutable/nodes/6.D1u__DtT.js +1 -0
  55. package/admin-build/_app/immutable/nodes/7.DWXHnRFf.js +1 -0
  56. package/admin-build/_app/immutable/nodes/8.Dojd8krc.js +1 -0
  57. package/admin-build/_app/immutable/nodes/9.CLtrr0K_.js +1 -0
  58. package/admin-build/_app/version.json +1 -1
  59. package/admin-build/index.html +7 -7
  60. package/openapi.json +6 -1941
  61. package/package.json +3 -3
  62. package/src/__tests__/openapi-coverage.test.ts +0 -6
  63. package/src/__tests__/push-handlers.test.ts +1 -1
  64. package/src/__tests__/room-auth-state-loss.test.ts +6 -0
  65. package/src/__tests__/room-handler-context.test.ts +0 -31
  66. package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
  67. package/src/__tests__/room-runtime-routing.test.ts +24 -111
  68. package/src/__tests__/route-parser.test.ts +6 -0
  69. package/src/__tests__/schema.test.ts +15 -6
  70. package/src/__tests__/smoke-skip-report.test.ts +1 -1
  71. package/src/durable-objects/database-do.ts +7 -1
  72. package/src/durable-objects/room-runtime-base.ts +290 -57
  73. package/src/durable-objects/rooms-do.ts +212 -1336
  74. package/src/index.ts +23 -9
  75. package/src/lib/d1-handler.ts +32 -17
  76. package/src/lib/openapi.ts +1 -4
  77. package/src/lib/postgres-handler.ts +24 -12
  78. package/src/lib/route-parser.ts +3 -0
  79. package/src/lib/schemas.ts +12 -2
  80. package/src/middleware/captcha-verify.ts +16 -3
  81. package/src/middleware/error-handler.ts +1 -1
  82. package/src/middleware/rules.ts +28 -9
  83. package/src/routes/admin-auth.ts +3 -3
  84. package/src/routes/admin.ts +13 -8
  85. package/src/routes/analytics-api.ts +3 -3
  86. package/src/routes/auth.ts +1 -1
  87. package/src/routes/backup.ts +1 -1
  88. package/src/routes/d1.ts +14 -7
  89. package/src/routes/database-live.ts +13 -6
  90. package/src/routes/kv.ts +21 -10
  91. package/src/routes/oauth.ts +1 -1
  92. package/src/routes/push.ts +119 -77
  93. package/src/routes/room.ts +203 -280
  94. package/src/routes/schema-endpoint.ts +2 -2
  95. package/src/routes/sql.ts +10 -6
  96. package/src/routes/storage.ts +4 -2
  97. package/src/routes/vectorize.ts +16 -4
  98. package/src/types.ts +1 -14
  99. package/admin-build/_app/immutable/chunks/6oMK_164.js +0 -1
  100. package/admin-build/_app/immutable/chunks/BEW7Ez_g.js +0 -1
  101. package/admin-build/_app/immutable/chunks/BoOooyH6.js +0 -1
  102. package/admin-build/_app/immutable/chunks/BvHnF5tV.js +0 -1
  103. package/admin-build/_app/immutable/chunks/CoI6jjbg.js +0 -2
  104. package/admin-build/_app/immutable/chunks/CrOZMmdF.js +0 -1
  105. package/admin-build/_app/immutable/chunks/Cw6OYcq-.js +0 -1
  106. package/admin-build/_app/immutable/chunks/DPdQ7z0T.js +0 -128
  107. package/admin-build/_app/immutable/chunks/pUxw8jfq.js +0 -1
  108. package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +0 -1
  109. package/admin-build/_app/immutable/nodes/1.DxcSsEqS.js +0 -1
  110. package/admin-build/_app/immutable/nodes/10.DuAd4aIm.js +0 -1
  111. package/admin-build/_app/immutable/nodes/11.0jgHQL92.js +0 -1
  112. package/admin-build/_app/immutable/nodes/12.CKNPqmyy.js +0 -1
  113. package/admin-build/_app/immutable/nodes/13.B1p2POXS.js +0 -110
  114. package/admin-build/_app/immutable/nodes/14.Bb-REBND.js +0 -3
  115. package/admin-build/_app/immutable/nodes/15.1uBFCX0X.js +0 -1
  116. package/admin-build/_app/immutable/nodes/18.CoiwfAuQ.js +0 -1
  117. package/admin-build/_app/immutable/nodes/19.B8ZdLlXj.js +0 -2
  118. package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +0 -1
  119. package/admin-build/_app/immutable/nodes/22.CItETFzy.js +0 -1
  120. package/admin-build/_app/immutable/nodes/24.CWbEqNMB.js +0 -2
  121. package/admin-build/_app/immutable/nodes/25.DRkLEhKi.js +0 -2
  122. package/admin-build/_app/immutable/nodes/26.BRxO8AYH.js +0 -1
  123. package/admin-build/_app/immutable/nodes/27.BLs-nVHz.js +0 -1
  124. package/admin-build/_app/immutable/nodes/28.G79qkdBK.js +0 -1
  125. package/admin-build/_app/immutable/nodes/29.BOcI6g0N.js +0 -1
  126. package/admin-build/_app/immutable/nodes/30.DAIC7dKd.js +0 -1
  127. package/admin-build/_app/immutable/nodes/31.pl0XXjXF.js +0 -1
  128. package/admin-build/_app/immutable/nodes/4.DOdvVlZj.js +0 -1
  129. package/admin-build/_app/immutable/nodes/5.BW_zlgye.js +0 -1
  130. package/admin-build/_app/immutable/nodes/6.Dxy1CAI2.js +0 -1
  131. package/admin-build/_app/immutable/nodes/7.BG98w_o7.js +0 -1
  132. package/admin-build/_app/immutable/nodes/8.DoG5R2rG.js +0 -1
  133. package/admin-build/_app/immutable/nodes/9.Dmxf6zAC.js +0 -1
  134. package/src/__tests__/cloudflare-realtime.test.ts +0 -113
  135. 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,35 +51,31 @@ 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
 
97
- interface RoomMemberRealtimeSession {
98
- sessionId: string;
99
- connectionId?: string;
100
- createdAt: number;
101
- updatedAt: number;
63
+ interface RoomSummaryResponse {
64
+ namespace: string;
65
+ roomId: string;
66
+ metadata: Record<string, unknown>;
67
+ occupancy: {
68
+ activeMembers: number;
69
+ activeConnections: number;
70
+ };
71
+ updatedAt: string;
72
+ }
73
+
74
+ interface RoomsWSAttachmentExtra {
75
+ joined?: boolean;
76
+ joinedAt?: number;
77
+ role?: string;
78
+ state?: Record<string, unknown>;
102
79
  }
103
80
 
104
81
  type RoomMemberSnapshot = RoomMemberInfo & { state: Record<string, unknown> };
@@ -111,9 +88,7 @@ const SYSTEM_SIGNAL_SENDER: RoomSender = {
111
88
 
112
89
  const DEFAULT_MEMBER_RECONNECT_TIMEOUT_MS = 30000;
113
90
  const SIGNAL_DENIED = Symbol('rooms.signal.denied');
114
- const MEDIA_DENIED = Symbol('rooms.media.denied');
115
91
  const WEBSOCKET_OPEN = 1;
116
- const CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY = 'cloudflareRealtimeKitMeetingId';
117
92
 
118
93
  function getRoomHooks(namespaceConfig?: RoomNamespaceConfig | null) {
119
94
  return namespaceConfig?.hooks;
@@ -142,603 +117,102 @@ function computeStateDelta(
142
117
  return hasChanges ? delta : null;
143
118
  }
144
119
 
120
+ function isRecord(value: unknown): value is Record<string, unknown> {
121
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
122
+ }
123
+
145
124
  export class RoomsDO extends RoomRuntimeBaseDO {
146
125
  private readonly joinedConnectionIds = new Set<string>();
147
126
  private readonly members = new Map<string, RoomMemberPresence>();
148
127
  private readonly blockedMembers = new Set<string>();
149
128
  private readonly memberRoles = new Map<string, string>();
150
- private readonly memberMediaStates = new Map<string, RoomMemberMediaState>();
151
- private readonly memberRealtimeSessions = new Map<string, RoomMemberRealtimeSession>();
152
- private cloudflareRealtimeKitMeetingId: string | null = null;
153
- private cloudflareRealtimeKitMeetingIdPromise: Promise<string> | null = null;
154
-
155
- override async fetch(request: Request): Promise<Response> {
156
- const url = new URL(request.url);
157
-
158
- if (url.pathname === '/media/cloudflare_realtimekit/session' && request.method === 'POST') {
159
- return this.handleCloudflareRealtimeKitSessionCreate(request, url);
160
- }
161
-
162
- if (url.pathname === '/media/realtime/session') {
163
- if (request.method === 'POST') return this.handleRealtimeSessionCreate(request, url);
164
- if (request.method === 'GET') return this.handleRealtimeSessionGet(request, url);
165
- return this.jsonResponse(405, { code: 405, message: 'Method not allowed' });
166
- }
167
-
168
- if (url.pathname === '/media/realtime/turn' && request.method === 'POST') {
169
- return this.handleRealtimeTurn(request, url);
170
- }
171
-
172
- if (url.pathname === '/media/realtime/tracks/new' && request.method === 'POST') {
173
- return this.handleRealtimeTracksNew(request, url);
174
- }
175
-
176
- if (url.pathname === '/media/realtime/renegotiate' && request.method === 'PUT') {
177
- return this.handleRealtimeRenegotiate(request, url);
178
- }
179
-
180
- if (url.pathname === '/media/realtime/tracks/close' && request.method === 'PUT') {
181
- return this.handleRealtimeTracksClose(request, url);
182
- }
183
-
184
- return super.fetch(request);
185
- }
186
-
187
- private async handleCloudflareRealtimeKitSessionCreate(request: Request, url: URL): Promise<Response> {
188
- try {
189
- const body = await this.readJsonBody<{
190
- connectionId?: string;
191
- customParticipantId?: string;
192
- name?: string;
193
- picture?: string;
194
- }>(request);
195
- const { memberId, connectionId, meta } = await this.authenticateRealtimeRequest(
196
- request,
197
- url,
198
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
199
- );
200
- if (this.hasPublishedTracks(memberId)) {
201
- return this.jsonResponse(409, {
202
- code: 409,
203
- message: 'Unpublish existing room media before creating a new Cloudflare RealtimeKit session',
204
- });
205
- }
206
-
207
- const config = this.getCloudflareRealtimeKitConfig();
208
- const meetingId = await this.ensureCloudflareRealtimeKitMeetingId(config);
209
- const participant = await this.createCloudflareRealtimeKitParticipant(config, meetingId, {
210
- customParticipantId: this.buildCloudflareRealtimeKitParticipantId(memberId, body.customParticipantId),
211
- name: typeof body.name === 'string' && body.name.trim()
212
- ? body.name.trim()
213
- : meta.auth?.email ?? meta.userId ?? memberId,
214
- picture: typeof body.picture === 'string' && body.picture.trim() ? body.picture.trim() : undefined,
215
- });
216
-
217
- return this.jsonResponse(200, {
218
- sessionId: participant.id,
219
- meetingId,
220
- participantId: participant.id,
221
- authToken: participant.token,
222
- presetName: participant.presetName ?? config.presetName,
223
- connectionId,
224
- reused: false,
225
- });
226
- } catch (err) {
227
- return this.jsonResponse(400, {
228
- code: 400,
229
- message: err instanceof Error ? err.message : 'Failed to create Cloudflare RealtimeKit session',
230
- });
231
- }
232
- }
233
-
234
- private async handleRealtimeSessionCreate(request: Request, url: URL): Promise<Response> {
235
- try {
236
- const body = await this.readJsonBody<{
237
- connectionId?: string;
238
- correlationId?: string;
239
- thirdparty?: boolean;
240
- sessionDescription?: CloudflareRealtimeNewSessionRequest['sessionDescription'];
241
- }>(request);
242
- const { memberId, connectionId } = await this.authenticateRealtimeRequest(
243
- request,
244
- url,
245
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
246
- );
247
-
248
- if (this.hasPublishedTracks(memberId)) {
249
- return this.jsonResponse(409, {
250
- code: 409,
251
- message: 'Unpublish existing room media before replacing the active realtime session.',
252
- });
253
- }
254
-
255
- const client = await this.buildRealtimeClient();
256
- const response = await client.createSession(
257
- {
258
- sessionDescription: body.sessionDescription,
259
- },
260
- {
261
- thirdparty: body.thirdparty === true,
262
- correlationId:
263
- typeof body.correlationId === 'string' && body.correlationId.trim()
264
- ? body.correlationId.trim()
265
- : `${this.namespace ?? 'room'}::${this.roomId ?? 'unknown'}::${memberId}`,
266
- },
267
- );
268
-
269
- this.memberRealtimeSessions.set(memberId, {
270
- sessionId: response.sessionId,
271
- connectionId,
272
- createdAt: Date.now(),
273
- updatedAt: Date.now(),
274
- });
275
129
 
276
- return this.jsonResponse<CloudflareRealtimeNewSessionResponse & {
277
- connectionId: string;
278
- reused: false;
279
- }>(200, {
280
- ...response,
281
- connectionId,
282
- reused: false,
283
- });
284
- } catch (err) {
285
- return this.jsonResponse(400, {
286
- code: 400,
287
- message: err instanceof Error ? err.message : 'Failed to create realtime session',
288
- });
130
+ protected override buildWSAttachmentExtra(_ws: WebSocket, meta: RoomWSMeta): unknown {
131
+ if (!meta.userId) {
132
+ return undefined;
289
133
  }
290
- }
291
134
 
292
- private async handleRealtimeSessionGet(request: Request, url: URL): Promise<Response> {
293
- try {
294
- const requestedConnectionId = url.searchParams.get('connectionId') ?? undefined;
295
- const { memberId } = await this.authenticateRealtimeRequest(request, url, requestedConnectionId);
296
- const session = this.memberRealtimeSessions.get(memberId);
297
- if (!session) {
298
- return this.jsonResponse(404, {
299
- code: 404,
300
- message: 'No active realtime session for this room member.',
301
- });
302
- }
303
- return this.jsonResponse(200, session);
304
- } catch (err) {
305
- return this.jsonResponse(400, {
306
- code: 400,
307
- message: err instanceof Error ? err.message : 'Failed to read realtime session',
308
- });
309
- }
310
- }
135
+ const member = this.members.get(meta.userId);
311
136
 
312
- private async handleRealtimeTurn(request: Request, url: URL): Promise<Response> {
313
- try {
314
- const body = await this.readJsonBody<{ ttl?: number }>(request);
315
- await this.authenticateRealtimeRequest(request, url);
316
- const client = await this.buildRealtimeClient();
317
- const ttl = typeof body.ttl === 'number' && Number.isFinite(body.ttl) && body.ttl > 0
318
- ? Math.floor(body.ttl)
319
- : 3600;
320
- const response = await client.generateIceServers(ttl);
321
- return this.jsonResponse(200, response);
322
- } catch (err) {
323
- return this.jsonResponse(400, {
324
- code: 400,
325
- message: err instanceof Error ? err.message : 'Failed to generate ICE servers',
326
- });
327
- }
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;
328
143
  }
329
144
 
330
- private async handleRealtimeTracksNew(request: Request, url: URL): Promise<Response> {
331
- try {
332
- const body = await this.readJsonBody<CloudflareRealtimeTracksRequest & {
333
- sessionId?: string;
334
- connectionId?: string;
335
- publish?: {
336
- kind?: MediaKind;
337
- trackId?: string;
338
- deviceId?: string;
339
- muted?: boolean;
340
- };
341
- }>(request);
342
-
343
- const { memberId, meta } = await this.authenticateRealtimeRequest(
344
- request,
345
- url,
346
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
347
- );
348
-
349
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
350
- if (!sessionId) {
351
- throw new Error('sessionId is required');
352
- }
353
- this.assertRealtimeSessionOwnership(memberId, sessionId);
354
-
355
- if (!Array.isArray(body.tracks) || body.tracks.length === 0) {
356
- throw new Error('tracks is required');
357
- }
358
-
359
- const client = await this.buildRealtimeClient();
360
- const response = await client.addTracks(sessionId, {
361
- sessionDescription: body.sessionDescription,
362
- tracks: body.tracks,
363
- autoDiscover: body.autoDiscover === true,
364
- });
365
- this.assertRealtimeTracksResponseSuccess(response);
366
-
367
- const publishPayload = body.publish;
368
- const publishKind = publishPayload?.kind;
369
- if (publishKind) {
370
- if (!(await this.canPublishMedia(meta, publishKind, publishPayload ?? {}))) {
371
- throw new Error('Denied by room media publish access rule');
372
- }
373
- const localTrackName = publishPayload.trackId?.trim()
374
- || body.tracks.find((track) => track.location === 'local')?.trackName?.trim()
375
- || response.tracks?.find((track) => track.location === 'local')?.trackName?.trim();
376
- await this.publishMedia(meta, publishKind, {
377
- trackId: localTrackName,
378
- deviceId: publishPayload.deviceId,
379
- muted: publishPayload.muted,
380
- providerSessionId: sessionId,
381
- });
382
- }
383
-
384
- const session = this.memberRealtimeSessions.get(memberId);
385
- if (session) {
386
- session.updatedAt = Date.now();
387
- }
145
+ protected override async recoverRuntimeStateFromSockets(): Promise<void> {
146
+ await super.recoverRuntimeStateFromSockets();
388
147
 
389
- return this.jsonResponse(200, response);
390
- } catch (err) {
391
- return this.jsonResponse(400, {
392
- code: 400,
393
- message: err instanceof Error ? err.message : 'Failed to add realtime tracks',
394
- });
395
- }
396
- }
148
+ this.joinedConnectionIds.clear();
149
+ this.members.clear();
150
+ this.blockedMembers.clear();
151
+ this.memberRoles.clear();
397
152
 
398
- private async handleRealtimeRenegotiate(request: Request, url: URL): Promise<Response> {
399
- try {
400
- const body = await this.readJsonBody<CloudflareRealtimeRenegotiateRequest & {
401
- sessionId?: string;
402
- connectionId?: string;
403
- }>(request);
404
- const { memberId } = await this.authenticateRealtimeRequest(
405
- request,
406
- url,
407
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
408
- );
409
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
410
- if (!sessionId) throw new Error('sessionId is required');
411
- this.assertRealtimeSessionOwnership(memberId, sessionId);
412
- if (!body.sessionDescription) {
413
- throw new Error('sessionDescription is required');
153
+ for (const ws of this.ctx.getWebSockets()) {
154
+ const meta = this.getWSMeta(ws);
155
+ if (!meta?.authenticated || !meta.userId) {
156
+ continue;
414
157
  }
415
158
 
416
- const client = await this.buildRealtimeClient();
417
- const response = await client.renegotiate(sessionId, {
418
- sessionDescription: body.sessionDescription,
419
- });
420
- this.assertRealtimeTracksResponseSuccess(response);
421
-
422
- const session = this.memberRealtimeSessions.get(memberId);
423
- if (session) {
424
- session.updatedAt = Date.now();
159
+ const extra = this.getWSAttachmentExtra<RoomsWSAttachmentExtra>(ws);
160
+ if (!extra?.joined) {
161
+ continue;
425
162
  }
426
- return this.jsonResponse(200, response);
427
- } catch (err) {
428
- return this.jsonResponse(400, {
429
- code: 400,
430
- message: err instanceof Error ? err.message : 'Failed to renegotiate realtime session',
431
- });
432
- }
433
- }
434
163
 
435
- private async handleRealtimeTracksClose(request: Request, url: URL): Promise<Response> {
436
- try {
437
- const body = await this.readJsonBody<CloudflareRealtimeCloseTracksRequest & {
438
- sessionId?: string;
439
- connectionId?: string;
440
- unpublish?: { kind?: MediaKind };
441
- }>(request);
442
- const { memberId } = await this.authenticateRealtimeRequest(
443
- request,
444
- url,
445
- typeof body.connectionId === 'string' ? body.connectionId : undefined,
446
- );
447
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
448
- if (!sessionId) throw new Error('sessionId is required');
449
- this.assertRealtimeSessionOwnership(memberId, sessionId);
450
- if (!Array.isArray(body.tracks) || body.tracks.length === 0) {
451
- throw new Error('tracks is required');
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);
452
169
  }
453
-
454
- const client = await this.buildRealtimeClient();
455
- const response = await client.closeTracks(sessionId, {
456
- sessionDescription: body.sessionDescription,
457
- tracks: body.tracks,
458
- force: body.force === true,
459
- });
460
- this.assertRealtimeTracksResponseSuccess(response);
461
-
462
- const unpublishKind = body.unpublish?.kind;
463
- if (unpublishKind) {
464
- await this.unpublishMedia(memberId, unpublishKind);
170
+ if (isRecord(extra.state)) {
171
+ member.state = {
172
+ ...member.state,
173
+ ...extra.state,
174
+ };
465
175
  }
466
176
 
467
- const session = this.memberRealtimeSessions.get(memberId);
468
- if (session) {
469
- 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);
470
182
  }
471
- return this.jsonResponse(200, response);
472
- } catch (err) {
473
- return this.jsonResponse(400, {
474
- code: 400,
475
- message: err instanceof Error ? err.message : 'Failed to close realtime tracks',
476
- });
477
- }
478
- }
479
-
480
- private async buildRealtimeClient() {
481
- const { createCloudflareRealtimeClient } = await import('../lib/cloudflare-realtime.js');
482
- return createCloudflareRealtimeClient(this.env as unknown as Env);
483
- }
484
-
485
- private getCloudflareRealtimeKitConfig(): {
486
- accountId: string;
487
- apiToken: string;
488
- appId: string;
489
- presetName: string;
490
- } {
491
- const env = this.env as unknown as Env;
492
- const accountId = env.CF_ACCOUNT_ID?.trim();
493
- const apiToken = env.CF_API_TOKEN?.trim();
494
- const appId = env.CF_REALTIME_APP_ID?.trim();
495
- const presetName = env.CF_REALTIME_PRESET_NAME?.trim() || 'group_call_participant';
496
-
497
- if (!accountId || !apiToken || !appId) {
498
- throw new Error(
499
- 'Cloudflare Realtime is not configured. Set CF_ACCOUNT_ID, CF_API_TOKEN, and CF_REALTIME_APP_ID.',
500
- );
501
- }
502
-
503
- return { accountId, apiToken, appId, presetName };
504
- }
505
-
506
- private buildCloudflareRealtimeKitParticipantId(memberId: string, provided?: string): string {
507
- const trimmed = typeof provided === 'string' ? provided.trim() : '';
508
- if (trimmed) {
509
- return trimmed;
510
183
  }
511
-
512
- return [
513
- this.namespace ?? 'room',
514
- this.roomId ?? 'unknown',
515
- memberId,
516
- Date.now().toString(36),
517
- ].join(':');
518
184
  }
519
185
 
520
- private async ensureCloudflareRealtimeKitMeetingId(config: {
521
- accountId: string;
522
- apiToken: string;
523
- appId: string;
524
- }): Promise<string> {
525
- if (this.cloudflareRealtimeKitMeetingId) {
526
- return this.cloudflareRealtimeKitMeetingId;
527
- }
186
+ override async fetch(request: Request): Promise<Response> {
187
+ const url = new URL(request.url);
528
188
 
529
- if (this.cloudflareRealtimeKitMeetingIdPromise) {
530
- return this.cloudflareRealtimeKitMeetingIdPromise;
189
+ if (url.pathname === '/summary' && request.method === 'GET') {
190
+ return this.handleSummaryGet(url);
531
191
  }
532
192
 
533
- this.cloudflareRealtimeKitMeetingIdPromise = (async () => {
534
- const storedMeetingId = await this.ctx.storage.get<string>(CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY);
535
- if (storedMeetingId) {
536
- this.cloudflareRealtimeKitMeetingId = storedMeetingId;
537
- return storedMeetingId;
538
- }
539
-
540
- const response = await fetch(
541
- `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(config.accountId)}`
542
- + `/realtime/kit/${encodeURIComponent(config.appId)}/meetings`,
543
- {
544
- method: 'POST',
545
- headers: {
546
- Authorization: `Bearer ${config.apiToken}`,
547
- 'Content-Type': 'application/json',
548
- },
549
- body: JSON.stringify({
550
- title: `${this.namespace ?? 'room'}::${this.roomId ?? 'unknown'}`,
551
- }),
552
- },
553
- );
554
- const data = await this.parseCloudflareApiEnvelope<{ id: string }>(response);
555
- this.cloudflareRealtimeKitMeetingId = data.id;
556
- await this.ctx.storage.put(CLOUDFLARE_REALTIME_KIT_MEETING_STORAGE_KEY, data.id);
557
- return data.id;
558
- })();
559
-
560
- try {
561
- return await this.cloudflareRealtimeKitMeetingIdPromise;
562
- } finally {
563
- this.cloudflareRealtimeKitMeetingIdPromise = null;
564
- }
193
+ return super.fetch(request);
565
194
  }
566
195
 
567
- private async createCloudflareRealtimeKitParticipant(
568
- config: {
569
- accountId: string;
570
- apiToken: string;
571
- appId: string;
572
- presetName: string;
573
- },
574
- meetingId: string,
575
- payload: {
576
- customParticipantId: string;
577
- name?: string;
578
- picture?: string;
579
- },
580
- ): Promise<{ id: string; token: string; presetName?: string }> {
581
- const response = await fetch(
582
- `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(config.accountId)}`
583
- + `/realtime/kit/${encodeURIComponent(config.appId)}`
584
- + `/meetings/${encodeURIComponent(meetingId)}/participants`,
585
- {
586
- method: 'POST',
587
- headers: {
588
- Authorization: `Bearer ${config.apiToken}`,
589
- 'Content-Type': 'application/json',
590
- },
591
- body: JSON.stringify({
592
- custom_participant_id: payload.customParticipantId,
593
- preset_name: config.presetName,
594
- name: payload.name,
595
- picture: payload.picture,
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;
202
+
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
+ ),
597
213
  },
598
- );
599
-
600
- const data = await this.parseCloudflareApiEnvelope<{
601
- id: string;
602
- token: string;
603
- preset_name?: string;
604
- }>(response);
605
-
606
- return {
607
- id: data.id,
608
- token: data.token,
609
- presetName: data.preset_name,
610
- };
611
- }
612
-
613
- private async parseCloudflareApiEnvelope<T>(response: Response): Promise<T> {
614
- const payload = (await response.json().catch(() => ({}))) as {
615
- success?: boolean;
616
- errors?: Array<{ message?: string }>;
617
- messages?: Array<{ message?: string }>;
618
- result?: T;
619
- data?: T;
620
- };
621
-
622
- if (!response.ok || payload.success === false) {
623
- const message =
624
- payload.errors?.find((entry) => typeof entry.message === 'string')?.message
625
- ?? payload.messages?.find((entry) => typeof entry.message === 'string')?.message
626
- ?? `Cloudflare API request failed (${response.status})`;
627
- throw new Error(message);
628
- }
629
-
630
- return (payload.data ?? payload.result ?? {}) as T;
631
- }
632
-
633
- private async authenticateRealtimeRequest(
634
- request: Request,
635
- url: URL,
636
- requestedConnectionId?: string,
637
- ): Promise<{ memberId: string; connectionId: string; meta: RoomWSMeta }> {
638
- this.hydrateRoomFromUrl(url);
639
-
640
- const token = this.extractBearerToken(request);
641
- if (!token) {
642
- throw new Error('Authentication required');
643
- }
644
-
645
- const { resolveAuthContextFromToken } = await import('../middleware/auth.js');
646
- const auth = await resolveAuthContextFromToken(this.env, token, request);
647
- const memberId = auth.id;
648
- const member = this.members.get(memberId);
649
- if (!member || member.connectionIds.size === 0) {
650
- throw new Error('Join the room WebSocket before using realtime media');
651
- }
652
-
653
- const connectionId = requestedConnectionId?.trim()
654
- || (member.connectionIds.values().next().value as string | undefined);
655
- if (!connectionId) {
656
- throw new Error('No active room connection for this member');
657
- }
658
- if (!member.connectionIds.has(connectionId)) {
659
- throw new Error('connectionId does not belong to the authenticated room member');
660
- }
661
-
662
- const existingMeta = this.findConnectionMeta(connectionId);
663
- const meta: RoomWSMeta = existingMeta
664
- ? {
665
- ...existingMeta,
666
- authenticated: true,
667
- userId: memberId,
668
- role: auth.role,
669
- auth,
670
- }
671
- : {
672
- authenticated: true,
673
- userId: memberId,
674
- role: auth.role,
675
- auth,
676
- connectionId,
677
- ip: request.headers.get('CF-Connecting-IP')
678
- || request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim()
679
- || undefined,
680
- userAgent: request.headers.get('User-Agent') || undefined,
681
- };
682
-
683
- return { memberId, connectionId, meta };
684
- }
685
-
686
- private assertRealtimeSessionOwnership(memberId: string, sessionId: string): RoomMemberRealtimeSession {
687
- const session = this.memberRealtimeSessions.get(memberId);
688
- if (!session || session.sessionId !== sessionId) {
689
- throw new Error('Realtime session is not owned by the authenticated room member');
690
- }
691
- return session;
692
- }
693
-
694
- private assertRealtimeTracksResponseSuccess(response: CloudflareRealtimeTracksResponse): void {
695
- if (response.errorCode) {
696
- throw new Error(response.errorDescription || response.errorCode);
697
- }
698
- const trackFailure = response.tracks?.find((track) => track.errorCode);
699
- if (trackFailure?.errorCode) {
700
- throw new Error(trackFailure.errorDescription || trackFailure.errorCode);
701
- }
702
- }
703
-
704
- private hasPublishedTracks(memberId: string): boolean {
705
- const state = this.memberMediaStates.get(memberId);
706
- return !!state?.audio?.published || !!state?.video?.published || !!state?.screen?.published;
707
- }
708
-
709
- private hydrateRoomFromUrl(url: URL): void {
710
- const roomFullName = url.searchParams.get('room');
711
- if (!roomFullName || this.namespace) {
712
- return;
713
- }
714
-
715
- const separatorIdx = roomFullName.indexOf('::');
716
- if (separatorIdx >= 0) {
717
- this.namespace = roomFullName.substring(0, separatorIdx);
718
- this.roomId = roomFullName.substring(separatorIdx + 2);
719
- } else {
720
- this.namespace = roomFullName;
721
- this.roomId = roomFullName;
722
- }
723
- this.namespaceConfig = this.config.rooms?.[this.namespace] ?? null;
724
- }
725
-
726
- private extractBearerToken(request: Request): string | null {
727
- const header = request.headers.get('Authorization');
728
- if (!header) return null;
729
- const match = header.match(/^Bearer\s+(.+)$/i);
730
- return match?.[1]?.trim() ?? null;
731
- }
732
-
733
- private async readJsonBody<T>(request: Request): Promise<T> {
734
- if (request.method === 'GET' || request.method === 'HEAD') {
735
- return {} as T;
736
- }
737
- try {
738
- return await request.json() as T;
739
- } catch {
740
- return {} as T;
741
- }
214
+ updatedAt: new Date().toISOString(),
215
+ });
742
216
  }
743
217
 
744
218
  private jsonResponse<T>(status: number, body: T): Response {
@@ -749,6 +223,14 @@ export class RoomsDO extends RoomRuntimeBaseDO {
749
223
  }
750
224
 
751
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
+
752
234
  if (typeof message !== 'string') return;
753
235
 
754
236
  let msg: Record<string, unknown>;
@@ -833,34 +315,6 @@ export class RoomsDO extends RoomRuntimeBaseDO {
833
315
  return;
834
316
  }
835
317
 
836
- if (msg.type === 'media') {
837
- const meta = this.requireAuthenticatedMeta(ws);
838
- if (!meta) return;
839
-
840
- const operation = this.normalizeMediaOperation(msg.operation);
841
- const kind = this.normalizeMediaKind(msg.kind);
842
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
843
- if (!this.checkRateLimit(meta.connectionId, 'media')) {
844
- this.safeSend(ws, {
845
- type: 'media_error',
846
- operation: operation ?? '',
847
- kind: kind ?? null,
848
- message: 'Rate limited',
849
- requestId,
850
- });
851
- return;
852
- }
853
-
854
- await this.handleMedia(ws, meta, {
855
- type: 'media',
856
- operation: operation ?? 'publish',
857
- kind: kind ?? undefined,
858
- payload: this.asRecord(msg.payload),
859
- requestId,
860
- });
861
- return;
862
- }
863
-
864
318
  await super.webSocketMessage(ws, message);
865
319
  }
866
320
 
@@ -891,8 +345,18 @@ export class RoomsDO extends RoomRuntimeBaseDO {
891
345
  }
892
346
 
893
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
+ }
894
357
  this.joinedConnectionIds.add(meta.connectionId);
895
358
  member.connectionIds.add(meta.connectionId);
359
+ member.reconnectUntil = undefined;
896
360
 
897
361
  if (!hadMember) {
898
362
  const snapshot = this.buildMemberSnapshot(member);
@@ -901,11 +365,13 @@ export class RoomsDO extends RoomRuntimeBaseDO {
901
365
  }
902
366
 
903
367
  this.broadcastMembersSync();
904
- await this.sendMediaSyncToConnection(ws, meta);
368
+ this.sendMembersSyncToConnection(ws);
905
369
 
906
370
  if (wasReconnecting) {
907
371
  await this.runSessionReconnectHook(this.buildSender(meta));
908
372
  }
373
+
374
+ this.refreshMemberSocketAttachments(userId);
909
375
  }
910
376
 
911
377
  protected override async handleExplicitLeave(ws: WebSocket, meta: RoomWSMeta): Promise<void> {
@@ -920,9 +386,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
920
386
 
921
387
  const member = this.removeMemberConnection(userId, meta.connectionId);
922
388
  if (member) {
923
- if (member.connectionIds.size === 0) {
924
- await this.clearPublishedMedia(userId);
925
- }
389
+ this.refreshMemberSocketAttachments(userId);
926
390
  this.broadcastMembersSync();
927
391
  }
928
392
  }
@@ -947,15 +411,18 @@ export class RoomsDO extends RoomRuntimeBaseDO {
947
411
  }
948
412
 
949
413
  if (member.connectionIds.size > 0) {
414
+ member.reconnectUntil = undefined;
415
+ this.refreshMemberSocketAttachments(userId);
950
416
  this.broadcastMembersSync();
951
417
  return;
952
418
  }
953
419
 
954
- await this.clearPublishedMedia(userId);
955
-
956
420
  const reconnectTimeout = this.namespaceConfig?.reconnectTimeout ?? DEFAULT_MEMBER_RECONNECT_TIMEOUT_MS;
957
421
  if (!kicked && reconnectTimeout > 0) {
422
+ member.reconnectUntil = Date.now() + reconnectTimeout;
423
+ this.refreshMemberSocketAttachments(userId);
958
424
  this.broadcastMembersSync();
425
+ return;
959
426
  }
960
427
  }
961
428
 
@@ -982,8 +449,6 @@ export class RoomsDO extends RoomRuntimeBaseDO {
982
449
  }
983
450
 
984
451
  this.deleteMember(userId);
985
- this.memberMediaStates.delete(userId);
986
- this.memberRealtimeSessions.delete(userId);
987
452
 
988
453
  const leaveReason: RoomMemberLeaveReason = reason === 'disconnect' ? 'timeout' : reason;
989
454
  await this.runMemberLeaveHook(snapshot, leaveReason);
@@ -1116,15 +581,6 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1116
581
  this.broadcastMembersSync();
1117
582
  break;
1118
583
  }
1119
- case 'mute':
1120
- await this.applyMuteChange(memberId, 'audio', true);
1121
- break;
1122
- case 'disableVideo':
1123
- await this.applyAdminUnpublish(memberId, 'video');
1124
- break;
1125
- case 'stopScreenShare':
1126
- await this.applyAdminUnpublish(memberId, 'screen');
1127
- break;
1128
584
  default:
1129
585
  throw new Error(`Unsupported admin operation '${operation}'`);
1130
586
  }
@@ -1147,20 +603,19 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1147
603
  }
1148
604
  }
1149
605
 
1150
- private async handleMedia(
606
+ private async handleSignal(
1151
607
  ws: WebSocket,
1152
608
  meta: RoomWSMeta,
1153
- msg: MediaMessage,
609
+ msg: SignalMessage,
1154
610
  ): Promise<void> {
1155
- const operation = msg.operation;
1156
- const kind = msg.kind;
1157
- const requestId = msg.requestId;
1158
- if (!operation || !kind) {
611
+ const event = typeof msg.event === 'string' ? msg.event.trim() : '';
612
+ const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
613
+
614
+ if (!event) {
1159
615
  this.safeSend(ws, {
1160
- type: 'media_error',
1161
- operation: operation ?? '',
1162
- kind: kind ?? null,
1163
- message: 'operation and kind are required',
616
+ type: 'signal_error',
617
+ event: '',
618
+ message: 'event is required',
1164
619
  requestId,
1165
620
  });
1166
621
  return;
@@ -1168,9 +623,8 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1168
623
 
1169
624
  if (!meta.userId || !this.roomId) {
1170
625
  this.safeSend(ws, {
1171
- type: 'media_error',
1172
- operation,
1173
- kind,
626
+ type: 'signal_error',
627
+ event,
1174
628
  message: 'User not authenticated',
1175
629
  requestId,
1176
630
  });
@@ -1179,101 +633,15 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1179
633
 
1180
634
  if (!this.isJoinedConnection(meta.connectionId)) {
1181
635
  this.safeSend(ws, {
1182
- type: 'media_error',
1183
- operation,
1184
- kind,
1185
- message: 'Join the room before using media controls',
636
+ type: 'signal_error',
637
+ event,
638
+ message: 'Join the room before sending signals',
1186
639
  requestId,
1187
640
  });
1188
641
  return;
1189
642
  }
1190
643
 
1191
- try {
1192
- const payload = msg.payload ?? {};
1193
- if (operation === 'publish') {
1194
- if (!(await this.canPublishMedia(meta, kind, payload))) {
1195
- throw new Error('Denied by room media publish access rule');
1196
- }
1197
- } else if (!(await this.canControlMedia(meta, operation, { kind, ...payload }))) {
1198
- throw new Error('Denied by room media control access rule');
1199
- }
1200
-
1201
- switch (operation) {
1202
- case 'publish':
1203
- await this.publishMedia(meta, kind, payload);
1204
- break;
1205
- case 'unpublish':
1206
- await this.unpublishMedia(meta.userId, kind);
1207
- break;
1208
- case 'mute': {
1209
- const muted = payload.muted === true;
1210
- await this.applyMuteChange(meta.userId, kind, muted);
1211
- break;
1212
- }
1213
- case 'device':
1214
- await this.applyDeviceChange(meta.userId, kind, payload);
1215
- break;
1216
- default:
1217
- throw new Error(`Unsupported media operation '${operation}'`);
1218
- }
1219
-
1220
- this.safeSend(ws, {
1221
- type: 'media_result',
1222
- operation,
1223
- kind,
1224
- requestId,
1225
- result: { ok: true },
1226
- });
1227
- } catch (err) {
1228
- this.safeSend(ws, {
1229
- type: 'media_error',
1230
- operation,
1231
- kind,
1232
- requestId,
1233
- message: err instanceof Error ? err.message : 'Media operation failed',
1234
- });
1235
- }
1236
- }
1237
-
1238
- private async handleSignal(
1239
- ws: WebSocket,
1240
- meta: RoomWSMeta,
1241
- msg: SignalMessage,
1242
- ): Promise<void> {
1243
- const event = typeof msg.event === 'string' ? msg.event.trim() : '';
1244
- const requestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1245
-
1246
- if (!event) {
1247
- this.safeSend(ws, {
1248
- type: 'signal_error',
1249
- event: '',
1250
- message: 'event is required',
1251
- requestId,
1252
- });
1253
- return;
1254
- }
1255
-
1256
- if (!meta.userId || !this.roomId) {
1257
- this.safeSend(ws, {
1258
- type: 'signal_error',
1259
- event,
1260
- message: 'User not authenticated',
1261
- requestId,
1262
- });
1263
- return;
1264
- }
1265
-
1266
- if (!this.isJoinedConnection(meta.connectionId)) {
1267
- this.safeSend(ws, {
1268
- type: 'signal_error',
1269
- event,
1270
- message: 'Join the room before sending signals',
1271
- requestId,
1272
- });
1273
- return;
1274
- }
1275
-
1276
- if (!(await this.canSendSignal(meta, event, msg.payload))) {
644
+ if (!(await this.canSendSignal(meta, event, msg.payload))) {
1277
645
  this.safeSend(ws, {
1278
646
  type: 'signal_error',
1279
647
  event,
@@ -1373,6 +741,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1373
741
 
1374
742
  const snapshot = this.buildMemberSnapshot(member);
1375
743
  const state = { ...member.state };
744
+ this.refreshMemberSocketAttachments(meta.userId);
1376
745
  this.broadcastToJoined({
1377
746
  type: 'member_state',
1378
747
  member: snapshot,
@@ -1587,590 +956,18 @@ export class RoomsDO extends RoomRuntimeBaseDO {
1587
956
  }
1588
957
  }
1589
958
 
1590
- private async canPublishMedia(
1591
- meta: RoomWSMeta,
1592
- kind: MediaKind,
1593
- payload: Record<string, unknown>,
1594
- ): Promise<boolean> {
1595
- const publishAccess = this.namespaceConfig?.access?.media?.publish;
1596
- if (!publishAccess || !this.roomId) {
1597
- return !this.config.release;
1598
- }
1599
-
1600
- try {
1601
- return await Promise.resolve(
1602
- publishAccess(this.buildAuthFromMeta(meta), this.roomId, kind, payload),
1603
- );
1604
- } catch {
1605
- return false;
1606
- }
1607
- }
1608
-
1609
- private async canControlMedia(
1610
- meta: RoomWSMeta,
1611
- operation: MediaMessage['operation'],
1612
- payload: Record<string, unknown>,
1613
- ): Promise<boolean> {
1614
- const controlAccess = this.namespaceConfig?.access?.media?.control;
1615
- if (!controlAccess || !this.roomId) {
1616
- return !this.config.release;
1617
- }
1618
-
1619
- try {
1620
- return await Promise.resolve(
1621
- controlAccess(this.buildAuthFromMeta(meta), this.roomId, operation, payload),
1622
- );
1623
- } catch {
1624
- return false;
1625
- }
1626
- }
1627
-
1628
- private async canSubscribeToMedia(
1629
- meta: RoomWSMeta,
1630
- payload: Record<string, unknown>,
1631
- ): Promise<boolean> {
1632
- if (meta.userId && payload.memberId === meta.userId) {
1633
- return true;
1634
- }
1635
-
1636
- const subscribeAccess = this.namespaceConfig?.access?.media?.subscribe;
1637
- if (!subscribeAccess || !this.roomId) {
1638
- return !this.config.release;
1639
- }
1640
-
1641
- try {
1642
- return await Promise.resolve(
1643
- subscribeAccess(this.buildAuthFromMeta(meta), this.roomId, payload),
1644
- );
1645
- } catch {
1646
- return false;
1647
- }
1648
- }
1649
-
1650
- private ensureMemberMediaState(memberId: string): RoomMemberMediaState {
1651
- let mediaState = this.memberMediaStates.get(memberId);
1652
- if (!mediaState) {
1653
- mediaState = {};
1654
- this.memberMediaStates.set(memberId, mediaState);
1655
- }
1656
- return mediaState;
1657
- }
1658
-
1659
- private getKindState(memberId: string, kind: MediaKind): RoomMemberMediaKindState {
1660
- const mediaState = this.ensureMemberMediaState(memberId);
1661
- mediaState[kind] ??= {
1662
- published: false,
1663
- muted: false,
1664
- };
1665
- return mediaState[kind]!;
1666
- }
1667
-
1668
- private pruneMediaKindState(memberId: string, kind: MediaKind): void {
1669
- const mediaState = this.memberMediaStates.get(memberId);
1670
- const kindState = mediaState?.[kind];
1671
- if (!mediaState || !kindState) {
959
+ private sendMembersSyncToConnection(ws: WebSocket): void {
960
+ if (!this.isSocketOpen(ws)) {
1672
961
  return;
1673
962
  }
1674
963
 
1675
- if (
1676
- !kindState.published &&
1677
- !kindState.muted &&
1678
- !kindState.trackId &&
1679
- !kindState.deviceId &&
1680
- !kindState.publishedAt &&
1681
- !kindState.adminDisabled &&
1682
- !kindState.providerSessionId
1683
- ) {
1684
- delete mediaState[kind];
1685
- }
1686
-
1687
- if (!mediaState.audio && !mediaState.video && !mediaState.screen) {
1688
- this.memberMediaStates.delete(memberId);
1689
- }
1690
- }
1691
-
1692
- private buildMediaStateSnapshot(memberId: string): RoomMemberMediaState {
1693
- const mediaState = this.memberMediaStates.get(memberId);
1694
- if (!mediaState) {
1695
- return {};
1696
- }
1697
-
1698
- const snapshot: RoomMemberMediaState = {};
1699
- for (const kind of ['audio', 'video', 'screen'] as const) {
1700
- const kindState = mediaState[kind];
1701
- if (kindState) {
1702
- snapshot[kind] = { ...kindState };
1703
- }
1704
- }
1705
- return snapshot;
1706
- }
1707
-
1708
- private buildMediaTrackFrame(memberId: string, kind: MediaKind): {
1709
- kind: MediaKind;
1710
- trackId?: string;
1711
- deviceId?: string;
1712
- muted: boolean;
1713
- publishedAt?: number;
1714
- adminDisabled?: boolean;
1715
- providerSessionId?: string;
1716
- } | null {
1717
- const kindState = this.memberMediaStates.get(memberId)?.[kind];
1718
- if (!kindState?.published) {
1719
- return null;
1720
- }
1721
-
1722
- return {
1723
- kind,
1724
- trackId: kindState.trackId,
1725
- deviceId: kindState.deviceId,
1726
- muted: kindState.muted,
1727
- publishedAt: kindState.publishedAt,
1728
- adminDisabled: kindState.adminDisabled,
1729
- providerSessionId: kindState.providerSessionId,
1730
- };
1731
- }
1732
-
1733
- private listPublishedTracks(memberId: string): Array<{
1734
- kind: MediaKind;
1735
- trackId?: string;
1736
- deviceId?: string;
1737
- muted: boolean;
1738
- publishedAt?: number;
1739
- adminDisabled?: boolean;
1740
- providerSessionId?: string;
1741
- }> {
1742
- const tracks: Array<{
1743
- kind: MediaKind;
1744
- trackId?: string;
1745
- deviceId?: string;
1746
- muted: boolean;
1747
- publishedAt?: number;
1748
- adminDisabled?: boolean;
1749
- providerSessionId?: string;
1750
- }> = [];
1751
- for (const kind of ['audio', 'video', 'screen'] as const) {
1752
- const track = this.buildMediaTrackFrame(memberId, kind);
1753
- if (track) {
1754
- tracks.push(track);
1755
- }
1756
- }
1757
- return tracks;
1758
- }
1759
-
1760
- private buildMemberSender(memberId: string): RoomSender {
1761
- const member = this.members.get(memberId);
1762
- const info = member
1763
- ? this.buildMemberInfo(member)
1764
- : { memberId, userId: memberId, connectionId: undefined, connectionCount: 0, role: this.memberRoles.get(memberId) };
1765
-
1766
- return {
1767
- userId: info.userId,
1768
- connectionId: info.connectionId ?? 'server',
1769
- role: info.role,
1770
- };
1771
- }
1772
-
1773
- private async publishMedia(
1774
- meta: RoomWSMeta,
1775
- kind: MediaKind,
1776
- payload: Record<string, unknown>,
1777
- ): Promise<void> {
1778
- if (!meta.userId) {
1779
- throw new Error('User not authenticated');
1780
- }
1781
-
1782
- const member = this.members.get(meta.userId);
1783
- if (!member) {
1784
- throw new Error('Member is not joined');
1785
- }
1786
-
1787
- const sender = this.buildSender(meta);
1788
- const roomApi = this.buildRoomServerAPI();
1789
- const beforePublish = await this.applyMediaBeforePublish(kind, sender, roomApi);
1790
- if (beforePublish === MEDIA_DENIED) {
1791
- throw new Error('Rejected by room media hook');
1792
- }
1793
-
1794
- const nextPayload = beforePublish && typeof beforePublish === 'object' && !Array.isArray(beforePublish)
1795
- ? { ...payload, ...(beforePublish as Record<string, unknown>) }
1796
- : payload;
1797
- const previousTrack = this.buildMediaTrackFrame(meta.userId, kind);
1798
- const kindState = this.getKindState(meta.userId, kind);
1799
- const trackId = typeof nextPayload.trackId === 'string' && nextPayload.trackId.trim()
1800
- ? nextPayload.trackId.trim()
1801
- : kindState.trackId ?? `${kind}-${crypto.randomUUID()}`;
1802
- const deviceId = typeof nextPayload.deviceId === 'string' && nextPayload.deviceId.trim()
1803
- ? nextPayload.deviceId.trim()
1804
- : kindState.deviceId;
1805
- const providerSessionId =
1806
- typeof nextPayload.providerSessionId === 'string' && nextPayload.providerSessionId.trim()
1807
- ? nextPayload.providerSessionId.trim()
1808
- : kindState.providerSessionId;
1809
-
1810
- kindState.published = true;
1811
- kindState.muted = nextPayload.muted === true ? true : kindState.muted;
1812
- kindState.trackId = trackId;
1813
- kindState.deviceId = deviceId;
1814
- kindState.publishedAt = Date.now();
1815
- kindState.adminDisabled = false;
1816
- kindState.providerSessionId = providerSessionId;
1817
-
1818
- if (previousTrack && previousTrack.trackId !== trackId) {
1819
- await this.broadcastMediaTrackRemoved(meta.userId, kind, previousTrack);
1820
- }
1821
-
1822
- await this.broadcastMediaTrack(meta.userId, kind);
1823
- await this.broadcastMediaState(meta.userId);
1824
- await this.runMediaPublishedHook(kind, sender, roomApi);
1825
- }
1826
-
1827
- private async unpublishMedia(memberId: string, kind: MediaKind): Promise<void> {
1828
- const mediaState = this.memberMediaStates.get(memberId);
1829
- const kindState = mediaState?.[kind];
1830
- if (!kindState) {
1831
- return;
1832
- }
1833
-
1834
- const previousTrack = this.buildMediaTrackFrame(memberId, kind);
1835
- if (!kindState.published && !previousTrack) {
1836
- kindState.trackId = undefined;
1837
- kindState.publishedAt = undefined;
1838
- kindState.adminDisabled = false;
1839
- kindState.providerSessionId = undefined;
1840
- this.pruneMediaKindState(memberId, kind);
1841
- return;
1842
- }
1843
-
1844
- kindState.published = false;
1845
- kindState.trackId = undefined;
1846
- kindState.publishedAt = undefined;
1847
- kindState.adminDisabled = false;
1848
- kindState.providerSessionId = undefined;
1849
- this.pruneMediaKindState(memberId, kind);
1850
-
1851
- if (previousTrack) {
1852
- await this.broadcastMediaTrackRemoved(memberId, kind, previousTrack);
1853
- await this.broadcastMediaState(memberId);
1854
- await this.runMediaUnpublishedHook(kind, this.buildMemberSender(memberId), this.buildRoomServerAPI());
1855
- }
1856
- }
1857
-
1858
- private async applyMuteChange(
1859
- memberId: string,
1860
- kind: MediaKind,
1861
- muted: boolean,
1862
- ): Promise<void> {
1863
- if (!this.members.has(memberId) && !this.memberMediaStates.has(memberId)) {
1864
- throw new Error('Unknown member');
1865
- }
1866
-
1867
- const kindState = this.getKindState(memberId, kind);
1868
- if (kindState.muted === muted) {
1869
- return;
1870
- }
1871
-
1872
- kindState.muted = muted;
1873
- this.pruneMediaKindState(memberId, kind);
1874
- await this.broadcastMediaState(memberId);
1875
- await this.runMediaMuteChangeHook(
1876
- kind,
1877
- this.buildMemberSender(memberId),
1878
- muted,
1879
- this.buildRoomServerAPI(),
1880
- );
1881
- }
1882
-
1883
- private async applyDeviceChange(
1884
- memberId: string,
1885
- kind: MediaKind,
1886
- payload: Record<string, unknown>,
1887
- ): Promise<void> {
1888
- if (!this.members.has(memberId) && !this.memberMediaStates.has(memberId)) {
1889
- throw new Error('Unknown member');
1890
- }
1891
-
1892
- const deviceId = typeof payload.deviceId === 'string' ? payload.deviceId.trim() : '';
1893
- if (!deviceId) {
1894
- throw new Error('deviceId is required');
1895
- }
1896
-
1897
- const kindState = this.getKindState(memberId, kind);
1898
- if (kindState.deviceId === deviceId) {
1899
- return;
1900
- }
1901
-
1902
- kindState.deviceId = deviceId;
1903
- await this.broadcastMediaState(memberId);
1904
- await this.broadcastMediaDevice(memberId, kind, deviceId);
1905
- }
1906
-
1907
- private async applyAdminUnpublish(memberId: string, kind: MediaKind): Promise<void> {
1908
- const kindState = this.getKindState(memberId, kind);
1909
- kindState.adminDisabled = true;
1910
- await this.unpublishMedia(memberId, kind);
1911
- }
1912
-
1913
- private async clearPublishedMedia(memberId: string): Promise<void> {
1914
- for (const kind of ['audio', 'video', 'screen'] as const) {
1915
- await this.unpublishMedia(memberId, kind);
1916
- }
1917
- }
1918
-
1919
- private async applyMediaBeforePublish(
1920
- kind: MediaKind,
1921
- sender: RoomSender,
1922
- roomApi: RoomServerAPI,
1923
- ): Promise<unknown | typeof MEDIA_DENIED> {
1924
- const beforePublish = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.beforePublish;
1925
- if (!beforePublish) {
1926
- return undefined;
1927
- }
1928
-
1929
- const ctx = await this.buildHandlerContext();
1930
- const result = await Promise.resolve(beforePublish(kind, sender, roomApi, ctx));
1931
- if (result === false) {
1932
- return MEDIA_DENIED;
1933
- }
1934
- return result;
1935
- }
1936
-
1937
- private async runMediaPublishedHook(
1938
- kind: MediaKind,
1939
- sender: RoomSender,
1940
- roomApi: RoomServerAPI,
1941
- ): Promise<void> {
1942
- const onPublished = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onPublished;
1943
- if (!onPublished) return;
1944
-
1945
- try {
1946
- const ctx = await this.buildHandlerContext();
1947
- await Promise.resolve(onPublished(kind, sender, roomApi, ctx));
1948
- } catch (err) {
1949
- console.error(`[Rooms] media.onPublished error: ${err instanceof Error ? err.message : String(err)}`);
1950
- }
1951
- }
1952
-
1953
- private async runMediaUnpublishedHook(
1954
- kind: MediaKind,
1955
- sender: RoomSender,
1956
- roomApi: RoomServerAPI,
1957
- ): Promise<void> {
1958
- const onUnpublished = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onUnpublished;
1959
- if (!onUnpublished) return;
1960
-
1961
- try {
1962
- const ctx = await this.buildHandlerContext();
1963
- await Promise.resolve(onUnpublished(kind, sender, roomApi, ctx));
1964
- } catch (err) {
1965
- console.error(`[Rooms] media.onUnpublished error: ${err instanceof Error ? err.message : String(err)}`);
1966
- }
1967
- }
1968
-
1969
- private async runMediaMuteChangeHook(
1970
- kind: MediaKind,
1971
- sender: RoomSender,
1972
- muted: boolean,
1973
- roomApi: RoomServerAPI,
1974
- ): Promise<void> {
1975
- const onMuteChange = getRoomHooks(this.namespaceConfig ?? undefined)?.media?.onMuteChange;
1976
- if (!onMuteChange) return;
1977
-
1978
- try {
1979
- const ctx = await this.buildHandlerContext();
1980
- await Promise.resolve(onMuteChange(kind, sender, muted, roomApi, ctx));
1981
- } catch (err) {
1982
- console.error(`[Rooms] media.onMuteChange error: ${err instanceof Error ? err.message : String(err)}`);
1983
- }
1984
- }
1985
-
1986
- private async broadcastMediaTrack(memberId: string, kind: MediaKind): Promise<void> {
1987
- const member = this.members.get(memberId);
1988
- const track = this.buildMediaTrackFrame(memberId, kind);
1989
- if (!member || !track) {
1990
- return;
1991
- }
1992
-
1993
- await this.broadcastMediaFrame(
1994
- {
1995
- type: 'media_track',
1996
- member: this.buildMemberInfo(member),
1997
- track,
1998
- },
1999
- {
2000
- event: 'track',
2001
- memberId,
2002
- kind,
2003
- track,
2004
- },
2005
- );
2006
- }
2007
-
2008
- private async broadcastMediaTrackRemoved(
2009
- memberId: string,
2010
- kind: MediaKind,
2011
- track?: {
2012
- kind: MediaKind;
2013
- trackId?: string;
2014
- deviceId?: string;
2015
- muted: boolean;
2016
- publishedAt?: number;
2017
- adminDisabled?: boolean;
2018
- } | null,
2019
- ): Promise<void> {
2020
- const member = this.members.get(memberId);
2021
- if (!member) {
2022
- return;
2023
- }
2024
-
2025
- await this.broadcastMediaFrame(
2026
- {
2027
- type: 'media_track_removed',
2028
- member: this.buildMemberInfo(member),
2029
- track: track ?? { kind },
2030
- },
2031
- {
2032
- event: 'track_removed',
2033
- memberId,
2034
- kind,
2035
- track: track ?? { kind },
2036
- },
2037
- );
2038
- }
2039
-
2040
- private async broadcastMediaState(memberId: string): Promise<void> {
2041
- const member = this.members.get(memberId);
2042
- if (!member) {
2043
- return;
2044
- }
2045
-
2046
- const state = this.buildMediaStateSnapshot(memberId);
2047
- await this.broadcastMediaFrame(
2048
- {
2049
- type: 'media_state',
2050
- member: this.buildMemberInfo(member),
2051
- state,
2052
- },
2053
- {
2054
- event: 'state',
2055
- memberId,
2056
- state,
2057
- },
2058
- );
2059
- }
2060
-
2061
- private async broadcastMediaDevice(
2062
- memberId: string,
2063
- kind: MediaKind,
2064
- deviceId: string,
2065
- ): Promise<void> {
2066
- const member = this.members.get(memberId);
2067
- if (!member) {
2068
- return;
2069
- }
2070
-
2071
- await this.broadcastMediaFrame(
2072
- {
2073
- type: 'media_device',
2074
- member: this.buildMemberInfo(member),
2075
- kind,
2076
- deviceId,
2077
- },
2078
- {
2079
- event: 'device',
2080
- memberId,
2081
- kind,
2082
- deviceId,
2083
- },
2084
- );
2085
- }
2086
-
2087
- private async broadcastMediaFrame(
2088
- frame: Record<string, unknown>,
2089
- payload: Record<string, unknown>,
2090
- ): Promise<void> {
2091
- const json = JSON.stringify(frame);
2092
- for (const ws of this.ctx.getWebSockets()) {
2093
- const meta = this.getWSMeta(ws);
2094
- if (!meta?.authenticated || !this.joinedConnectionIds.has(meta.connectionId)) {
2095
- continue;
2096
- }
2097
- if (!(await this.canSubscribeToMedia(meta, payload))) {
2098
- continue;
2099
- }
2100
- this.safeSendRaw(ws, json);
2101
- }
2102
- }
2103
-
2104
- private async sendMediaSyncToConnection(ws: WebSocket, meta: RoomWSMeta): Promise<void> {
2105
- if (!this.isSocketOpen(ws) || !meta.userId) {
2106
- return;
2107
- }
2108
-
2109
- const members: Array<{
2110
- member: RoomMemberInfo;
2111
- state: RoomMemberMediaState;
2112
- tracks: Array<{
2113
- kind: MediaKind;
2114
- trackId?: string;
2115
- deviceId?: string;
2116
- muted: boolean;
2117
- publishedAt?: number;
2118
- adminDisabled?: boolean;
2119
- }>;
2120
- }> = [];
2121
-
2122
- for (const member of this.listMembers()) {
2123
- const state = this.buildMediaStateSnapshot(member.memberId);
2124
- const tracks = this.listPublishedTracks(member.memberId);
2125
- if (Object.keys(state).length === 0 && tracks.length === 0) {
2126
- continue;
2127
- }
2128
- if (!(await this.canSubscribeToMedia(meta, {
2129
- event: 'sync',
2130
- memberId: member.memberId,
2131
- state,
2132
- tracks,
2133
- }))) {
2134
- continue;
2135
- }
2136
- members.push({ member, state, tracks });
2137
- }
2138
-
2139
964
  this.safeSend(ws, {
2140
- type: 'media_sync',
2141
- members,
965
+ type: 'members_sync',
966
+ occupancy: this.buildAuthoritativeOccupancy(),
967
+ members: this.listMembers(),
2142
968
  });
2143
969
  }
2144
970
 
2145
- private normalizeMediaOperation(operation: unknown): MediaMessage['operation'] | null {
2146
- if (typeof operation !== 'string') {
2147
- return null;
2148
- }
2149
- switch (operation.trim()) {
2150
- case 'publish':
2151
- case 'unpublish':
2152
- case 'mute':
2153
- case 'device':
2154
- return operation.trim() as MediaMessage['operation'];
2155
- default:
2156
- return null;
2157
- }
2158
- }
2159
-
2160
- private normalizeMediaKind(kind: unknown): MediaKind | null {
2161
- if (typeof kind !== 'string') {
2162
- return null;
2163
- }
2164
- switch (kind.trim()) {
2165
- case 'audio':
2166
- case 'video':
2167
- case 'screen':
2168
- return kind.trim() as MediaKind;
2169
- default:
2170
- return null;
2171
- }
2172
- }
2173
-
2174
971
  private deliverSignal(
2175
972
  frame: {
2176
973
  type: 'signal';
@@ -2223,6 +1020,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2223
1020
  private broadcastMembersSync(): void {
2224
1021
  this.broadcastToJoined({
2225
1022
  type: 'members_sync',
1023
+ occupancy: this.buildAuthoritativeOccupancy(),
2226
1024
  members: this.listMembers(),
2227
1025
  });
2228
1026
  }
@@ -2268,6 +1066,14 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2268
1066
  return member;
2269
1067
  }
2270
1068
 
1069
+ private normalizeRecoveredMemberState(value: unknown): Record<string, unknown> | null {
1070
+ if (!isRecord(value)) {
1071
+ return null;
1072
+ }
1073
+
1074
+ return { ...value };
1075
+ }
1076
+
2271
1077
  private removeMemberConnection(userId: string, connectionId: string): RoomMemberPresence | null {
2272
1078
  this.joinedConnectionIds.delete(connectionId);
2273
1079
  const member = this.members.get(userId);
@@ -2291,12 +1097,70 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2291
1097
  this.members.delete(userId);
2292
1098
  }
2293
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
+
2294
1131
  private listMembers(): RoomMemberSnapshot[] {
1132
+ const now = Date.now();
2295
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
+ })
2296
1141
  .sort((left, right) => left.joinedAt - right.joinedAt || left.memberId.localeCompare(right.memberId))
2297
1142
  .map((member) => this.buildMemberSnapshot(member));
2298
1143
  }
2299
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
+
2300
1164
  private buildMemberSnapshot(
2301
1165
  member: RoomMemberPresence,
2302
1166
  fallbackConnectionId?: string,
@@ -2311,7 +1175,9 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2311
1175
  member: RoomMemberPresence,
2312
1176
  fallbackConnectionId?: string,
2313
1177
  ): RoomMemberInfo {
2314
- 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;
2315
1181
  const connectionId = activeConnectionId ?? fallbackConnectionId;
2316
1182
  const meta = connectionId ? this.findConnectionMeta(connectionId) : null;
2317
1183
  const role = this.memberRoles.get(member.memberId) ?? meta?.role;
@@ -2320,7 +1186,7 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2320
1186
  memberId: member.memberId,
2321
1187
  userId: member.userId,
2322
1188
  connectionId,
2323
- connectionCount: member.connectionIds.size,
1189
+ connectionCount: visibleConnectionIds.length,
2324
1190
  role,
2325
1191
  };
2326
1192
  }
@@ -2397,4 +1263,14 @@ export class RoomsDO extends RoomRuntimeBaseDO {
2397
1263
  this.setWSMeta(ws, meta);
2398
1264
  }
2399
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
+ }
2400
1276
  }