@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.
- package/admin-build/_app/immutable/chunks/{DILS_-VJ.js → B3CvhH3c.js} +1 -1
- package/admin-build/_app/immutable/chunks/BDYewzou.js +1 -0
- package/admin-build/_app/immutable/chunks/{Cdm5zBRA.js → BEM1BeVF.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Dt4vL4Df.js → BYL_uBga.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B94PilAN.js → BYyykAbh.js} +1 -1
- package/admin-build/_app/immutable/chunks/BaUG2TJ-.js +1 -0
- package/admin-build/_app/immutable/chunks/{C72lTcG0.js → Bcs4KYNp.js} +1 -1
- package/admin-build/_app/immutable/chunks/{D2j3I1VQ.js → BfpUQYr3.js} +1 -1
- package/admin-build/_app/immutable/chunks/BhCO1Fpt.js +1 -0
- package/admin-build/_app/immutable/chunks/{B8s_s9QY.js → BkZCgsc3.js} +1 -1
- package/admin-build/_app/immutable/chunks/CIOC1v_q.js +128 -0
- package/admin-build/_app/immutable/chunks/CjcrXziO.js +2 -0
- package/admin-build/_app/immutable/chunks/CvczjTXx.js +1 -0
- package/admin-build/_app/immutable/chunks/D1u3u7xu.js +1 -0
- package/admin-build/_app/immutable/chunks/{B0HRJ657.js → DOOPbWwG.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BqTb6Mxk.js → DaXO-sFP.js} +1 -1
- package/admin-build/_app/immutable/chunks/DnpbvAPi.js +1 -0
- package/admin-build/_app/immutable/chunks/{B6MschND.js → Dz9cUCuv.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CaVKAiCe.js → Tea2dBJ8.js} +1 -1
- package/admin-build/_app/immutable/chunks/{Z41NK6i6.js → bguI1TeA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{J2Gw0SMu.js → ejoEf2I5.js} +1 -1
- package/admin-build/_app/immutable/chunks/{B2TnDKF7.js → iEyeblJR.js} +1 -1
- package/admin-build/_app/immutable/chunks/{_teD5ji5.js → nlAMTi52.js} +1 -1
- package/admin-build/_app/immutable/chunks/qKdzaeX3.js +1 -0
- package/admin-build/_app/immutable/entry/{app.D3flihMw.js → app.DoUaxnew.js} +2 -2
- package/admin-build/_app/immutable/entry/start.MmZh8oBH.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.CdczqZLK.js → 0.Dsxi8s7i.js} +1 -1
- package/admin-build/_app/immutable/nodes/1.Cp2l-hol.js +1 -0
- package/admin-build/_app/immutable/nodes/10.4oY6m8Nz.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DfcozD4J.js +1 -0
- package/admin-build/_app/immutable/nodes/12.uJgZdCIA.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CaN1kRev.js +110 -0
- package/admin-build/_app/immutable/nodes/14.DQ5xIi3s.js +3 -0
- package/admin-build/_app/immutable/nodes/15.B_EkebTJ.js +1 -0
- package/admin-build/_app/immutable/nodes/{16.BR7WwQrS.js → 16.Tko1ZX8-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.Cm57KKXV.js → 17.BCmWMJX9.js} +1 -1
- package/admin-build/_app/immutable/nodes/18.hmGhl1O2.js +1 -0
- package/admin-build/_app/immutable/nodes/19.D-1infOo.js +2 -0
- package/admin-build/_app/immutable/nodes/{20.DnHeFlTv.js → 20.CY4KKcBL.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.B9lbNUQr.js +1 -0
- package/admin-build/_app/immutable/nodes/22.14Vd7bnt.js +1 -0
- package/admin-build/_app/immutable/nodes/{23.CWSGMcKJ.js → 23.Be6jK77o.js} +2 -2
- package/admin-build/_app/immutable/nodes/24.CSTFkr6R.js +2 -0
- package/admin-build/_app/immutable/nodes/25.DRTg8fHc.js +2 -0
- package/admin-build/_app/immutable/nodes/26.DKt-9lwQ.js +1 -0
- package/admin-build/_app/immutable/nodes/27.D5caPu0F.js +1 -0
- package/admin-build/_app/immutable/nodes/28.hJhlnlyY.js +1 -0
- package/admin-build/_app/immutable/nodes/29.CDYBzFyT.js +1 -0
- package/admin-build/_app/immutable/nodes/{3.B6q-7qr8.js → 3.DMyKwkGn.js} +1 -1
- package/admin-build/_app/immutable/nodes/30.BaHNeEmc.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C6PV5L-2.js +1 -0
- package/admin-build/_app/immutable/nodes/4.9E118Ftm.js +1 -0
- package/admin-build/_app/immutable/nodes/5.D8guAl3v.js +1 -0
- package/admin-build/_app/immutable/nodes/6.D1u__DtT.js +1 -0
- package/admin-build/_app/immutable/nodes/7.DWXHnRFf.js +1 -0
- package/admin-build/_app/immutable/nodes/8.Dojd8krc.js +1 -0
- package/admin-build/_app/immutable/nodes/9.CLtrr0K_.js +1 -0
- 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__/push-handlers.test.ts +1 -1
- 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 +24 -111
- package/src/__tests__/route-parser.test.ts +6 -0
- package/src/__tests__/schema.test.ts +15 -6
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/database-do.ts +7 -1
- package/src/durable-objects/room-runtime-base.ts +290 -57
- package/src/durable-objects/rooms-do.ts +212 -1336
- package/src/index.ts +23 -9
- package/src/lib/d1-handler.ts +32 -17
- package/src/lib/openapi.ts +1 -4
- package/src/lib/postgres-handler.ts +24 -12
- package/src/lib/route-parser.ts +3 -0
- package/src/lib/schemas.ts +12 -2
- package/src/middleware/captcha-verify.ts +16 -3
- package/src/middleware/error-handler.ts +1 -1
- package/src/middleware/rules.ts +28 -9
- package/src/routes/admin-auth.ts +3 -3
- package/src/routes/admin.ts +13 -8
- package/src/routes/analytics-api.ts +3 -3
- package/src/routes/auth.ts +1 -1
- package/src/routes/backup.ts +1 -1
- package/src/routes/d1.ts +14 -7
- package/src/routes/database-live.ts +13 -6
- package/src/routes/kv.ts +21 -10
- package/src/routes/oauth.ts +1 -1
- package/src/routes/push.ts +119 -77
- package/src/routes/room.ts +203 -280
- package/src/routes/schema-endpoint.ts +2 -2
- package/src/routes/sql.ts +10 -6
- package/src/routes/storage.ts +4 -2
- package/src/routes/vectorize.ts +16 -4
- package/src/types.ts +1 -14
- package/admin-build/_app/immutable/chunks/6oMK_164.js +0 -1
- package/admin-build/_app/immutable/chunks/BEW7Ez_g.js +0 -1
- package/admin-build/_app/immutable/chunks/BoOooyH6.js +0 -1
- package/admin-build/_app/immutable/chunks/BvHnF5tV.js +0 -1
- package/admin-build/_app/immutable/chunks/CoI6jjbg.js +0 -2
- package/admin-build/_app/immutable/chunks/CrOZMmdF.js +0 -1
- package/admin-build/_app/immutable/chunks/Cw6OYcq-.js +0 -1
- package/admin-build/_app/immutable/chunks/DPdQ7z0T.js +0 -128
- package/admin-build/_app/immutable/chunks/pUxw8jfq.js +0 -1
- package/admin-build/_app/immutable/entry/start.Cl6sLxnz.js +0 -1
- package/admin-build/_app/immutable/nodes/1.DxcSsEqS.js +0 -1
- package/admin-build/_app/immutable/nodes/10.DuAd4aIm.js +0 -1
- package/admin-build/_app/immutable/nodes/11.0jgHQL92.js +0 -1
- package/admin-build/_app/immutable/nodes/12.CKNPqmyy.js +0 -1
- package/admin-build/_app/immutable/nodes/13.B1p2POXS.js +0 -110
- package/admin-build/_app/immutable/nodes/14.Bb-REBND.js +0 -3
- package/admin-build/_app/immutable/nodes/15.1uBFCX0X.js +0 -1
- package/admin-build/_app/immutable/nodes/18.CoiwfAuQ.js +0 -1
- package/admin-build/_app/immutable/nodes/19.B8ZdLlXj.js +0 -2
- package/admin-build/_app/immutable/nodes/21.CJFaf0Ia.js +0 -1
- package/admin-build/_app/immutable/nodes/22.CItETFzy.js +0 -1
- package/admin-build/_app/immutable/nodes/24.CWbEqNMB.js +0 -2
- package/admin-build/_app/immutable/nodes/25.DRkLEhKi.js +0 -2
- package/admin-build/_app/immutable/nodes/26.BRxO8AYH.js +0 -1
- package/admin-build/_app/immutable/nodes/27.BLs-nVHz.js +0 -1
- package/admin-build/_app/immutable/nodes/28.G79qkdBK.js +0 -1
- package/admin-build/_app/immutable/nodes/29.BOcI6g0N.js +0 -1
- package/admin-build/_app/immutable/nodes/30.DAIC7dKd.js +0 -1
- package/admin-build/_app/immutable/nodes/31.pl0XXjXF.js +0 -1
- package/admin-build/_app/immutable/nodes/4.DOdvVlZj.js +0 -1
- package/admin-build/_app/immutable/nodes/5.BW_zlgye.js +0 -1
- package/admin-build/_app/immutable/nodes/6.Dxy1CAI2.js +0 -1
- package/admin-build/_app/immutable/nodes/7.BG98w_o7.js +0 -1
- package/admin-build/_app/immutable/nodes/8.DoG5R2rG.js +0 -1
- package/admin-build/_app/immutable/nodes/9.Dmxf6zAC.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,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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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 (
|
|
530
|
-
return this.
|
|
189
|
+
if (url.pathname === '/summary' && request.method === 'GET') {
|
|
190
|
+
return this.handleSummaryGet(url);
|
|
531
191
|
}
|
|
532
192
|
|
|
533
|
-
|
|
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
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
606
|
+
private async handleSignal(
|
|
1151
607
|
ws: WebSocket,
|
|
1152
608
|
meta: RoomWSMeta,
|
|
1153
|
-
msg:
|
|
609
|
+
msg: SignalMessage,
|
|
1154
610
|
): Promise<void> {
|
|
1155
|
-
const
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1158
|
-
if (!
|
|
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: '
|
|
1161
|
-
|
|
1162
|
-
|
|
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: '
|
|
1172
|
-
|
|
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: '
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
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
|
|
1591
|
-
|
|
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: '
|
|
2141
|
-
|
|
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
|
|
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:
|
|
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
|
}
|