@edge-base/server 0.2.6 → 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.
- package/admin-build/_app/immutable/chunks/{BN_-k-Ck.js → BDYewzou.js} +1 -1
- package/admin-build/_app/immutable/chunks/{qBm6xof8.js → BEM1BeVF.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Ff90owjx.js → BYyykAbh.js} +1 -1
- package/admin-build/_app/immutable/chunks/{SQVAC3Cv.js → BaUG2TJ-.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BvoGcDFV.js → BfpUQYr3.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CLHN9MVr.js → BhCO1Fpt.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DemDWbs-.js → CIOC1v_q.js} +3 -3
- package/admin-build/_app/immutable/chunks/{CrwlCAM0.js → CvczjTXx.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DQVP4KC-.js → D1u3u7xu.js} +1 -1
- package/admin-build/_app/immutable/chunks/{LL3ulaxa.js → DaXO-sFP.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CbfX3ELZ.js → DnpbvAPi.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DdvsFblq.js → Dz9cUCuv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DmDTovpg.js → Tea2dBJ8.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CR37B8DX.js → ejoEf2I5.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CCUxCptE.js → iEyeblJR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Q3vAxeY-.js → qKdzaeX3.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.CP83Ni80.js → app.DoUaxnew.js} +2 -2
- package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.DiRq7puO.js → 0.Dsxi8s7i.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.BFeyKLGT.js → 1.Cp2l-hol.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.zcee7hJx.js → 10.4oY6m8Nz.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.BW7wLs2Y.js → 11.DfcozD4J.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.CxJRlYSd.js → 12.uJgZdCIA.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.pp0F_5hn.js → 13.CaN1kRev.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.t3AfGiGo.js → 14.DQ5xIi3s.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.B3agc7NX.js → 15.B_EkebTJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.C4uG2-i8.js → 16.Tko1ZX8-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.CwGxi1Bn.js → 17.BCmWMJX9.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.CrQyN_gU.js → 18.hmGhl1O2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.NEPUOXl7.js → 19.D-1infOo.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.DGHO8ipr.js → 20.CY4KKcBL.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.Dri5It7a.js → 22.14Vd7bnt.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.BPQP_Zte.js → 23.Be6jK77o.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.D580FdSS.js → 24.CSTFkr6R.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.BMNPOZwF.js → 25.DRTg8fHc.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.XcpEcbiz.js → 26.DKt-9lwQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.C1zHHcYv.js → 27.D5caPu0F.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.CuKzzrY8.js → 28.hJhlnlyY.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.nLpBMXnM.js → 29.CDYBzFyT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.5G_aseoL.js → 3.DMyKwkGn.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.CQC4nLoU.js → 30.BaHNeEmc.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.Bet8kxOK.js → 31.C6PV5L-2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.nmJDYJpC.js → 4.9E118Ftm.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.CnbYLG4E.js → 5.D8guAl3v.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.KA01b-3y.js → 6.D1u__DtT.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.CP9fkn1L.js → 7.DWXHnRFf.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.BTzDb---.js → 8.Dojd8krc.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.DkNJg_J6.js → 9.CLtrr0K_.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +6 -1941
- package/package.json +3 -3
- package/src/__tests__/openapi-coverage.test.ts +0 -6
- package/src/__tests__/room-auth-state-loss.test.ts +6 -0
- package/src/__tests__/room-handler-context.test.ts +0 -31
- package/src/__tests__/room-rate-limit-scopes.test.ts +1 -5
- package/src/__tests__/room-runtime-routing.test.ts +1 -111
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/room-runtime-base.ts +241 -17
- package/src/durable-objects/rooms-do.ts +190 -1345
- package/src/lib/openapi.ts +1 -4
- package/src/routes/room.ts +0 -285
- package/src/types.ts +1 -14
- package/admin-build/_app/immutable/entry/start.DY6YakU0.js +0 -1
- package/admin-build/_app/immutable/nodes/21.UVKBDvp4.js +0 -1
- package/src/__tests__/cloudflare-realtime.test.ts +0 -113
- 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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
196
|
-
return this.handleRealtimeTracksClose(request, url);
|
|
197
|
-
}
|
|
135
|
+
const member = this.members.get(meta.userId);
|
|
198
136
|
|
|
199
|
-
return
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
const metadata = await this.getRoomMetadataSnapshot();
|
|
145
|
+
protected override async recoverRuntimeStateFromSockets(): Promise<void> {
|
|
146
|
+
await super.recoverRuntimeStateFromSockets();
|
|
205
147
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
512
|
-
const
|
|
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
|
-
|
|
538
|
-
|
|
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
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
606
|
+
private async handleSignal(
|
|
1182
607
|
ws: WebSocket,
|
|
1183
608
|
meta: RoomWSMeta,
|
|
1184
|
-
msg:
|
|
609
|
+
msg: SignalMessage,
|
|
1185
610
|
): Promise<void> {
|
|
1186
|
-
const
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
if (!
|
|
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
|
|
1622
|
-
|
|
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: '
|
|
2172
|
-
|
|
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
|
|
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:
|
|
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
|
}
|