room-kit 1.0.0
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/.github/workflows/publish.yml +16 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/changelog.md +13 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +317 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/room.d.ts +24 -0
- package/dist/room.d.ts.map +1 -0
- package/dist/room.js +26 -0
- package/dist/room.js.map +1 -0
- package/dist/server.d.ts +27 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +770 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +413 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -0
- package/example/README.md +43 -0
- package/example/common.ts +41 -0
- package/example/package.json +17 -0
- package/example/public/app.ts +298 -0
- package/example/public/index.html +86 -0
- package/example/public/styles.css +317 -0
- package/example/server.ts +200 -0
- package/jsr.json +13 -0
- package/package.json +42 -0
- package/src/client.ts +433 -0
- package/src/index.ts +34 -0
- package/src/room.ts +32 -0
- package/src/server.ts +1064 -0
- package/src/types.ts +503 -0
- package/test/room.spec.ts +2401 -0
- package/tsconfig.json +20 -0
- package/tsconfig.test.json +9 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthRevalidationDecision,
|
|
3
|
+
EventEmitApi,
|
|
4
|
+
EventMetaFor,
|
|
5
|
+
JoinRequest,
|
|
6
|
+
PresenceFor,
|
|
7
|
+
PresenceListQuery,
|
|
8
|
+
PresencePageFor,
|
|
9
|
+
PresencePolicy,
|
|
10
|
+
RoomMemberSnapshot,
|
|
11
|
+
RoomDefinition,
|
|
12
|
+
RoomEvents,
|
|
13
|
+
RoomServerAdapter,
|
|
14
|
+
RoomServerBroadcastApi,
|
|
15
|
+
RoomServerHandle,
|
|
16
|
+
RoomServerContext,
|
|
17
|
+
RoomServerHandlers,
|
|
18
|
+
RoomSnapshot,
|
|
19
|
+
ServerSocketLike,
|
|
20
|
+
ServerStateFor,
|
|
21
|
+
PresenceValueFor,
|
|
22
|
+
VisibleMemberFor,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import { ClientSafeError } from "./types";
|
|
25
|
+
|
|
26
|
+
const JOIN_EVENT = "room-kit:join";
|
|
27
|
+
const LEAVE_EVENT = "room-kit:leave";
|
|
28
|
+
const RPC_EVENT = "room-kit:rpc";
|
|
29
|
+
const CLIENT_EVENT = "room-kit:client-event";
|
|
30
|
+
const SERVER_EVENT = "room-kit:server-event";
|
|
31
|
+
const PRESENCE_EVENT = "room-kit:presence";
|
|
32
|
+
const PRESENCE_QUERY_EVENT = "room-kit:presence-query";
|
|
33
|
+
|
|
34
|
+
type StoredMember<TRoom extends RoomDefinition<any>> = {
|
|
35
|
+
socketId: string;
|
|
36
|
+
memberId: string;
|
|
37
|
+
memberProfile: any;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type RoomState<TRoom extends RoomDefinition<any>> = {
|
|
41
|
+
presence: PresencePolicy;
|
|
42
|
+
roomProfile: any;
|
|
43
|
+
serverState: ServerStateFor<TRoom>;
|
|
44
|
+
membersBySocketId: Map<string, StoredMember<TRoom>>;
|
|
45
|
+
socketIdsByMemberId: Map<string, Set<string>>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type NamespaceState = {
|
|
49
|
+
roomsByNameSpace: Map<string, Map<string, RoomState<any>>>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type AuthCacheEntry<TAuth> = {
|
|
53
|
+
pending?: Promise<TAuth>;
|
|
54
|
+
value?: TAuth;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const namespaceStates = new WeakMap<object, NamespaceState>();
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Attaches room runtime handlers to a connected server socket.
|
|
61
|
+
*
|
|
62
|
+
* Returns a callable handle that unregisters all listeners for the bound
|
|
63
|
+
* socket, and exposes room introspection helpers.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* io.on("connection", (socket) => {
|
|
68
|
+
* const stop = serveRoomType(socket, chatRoomType, {
|
|
69
|
+
* onAuth: async () => ({ userId: socket.id }),
|
|
70
|
+
* admit: async (join, ctx) => ({
|
|
71
|
+
* roomId: join.roomId,
|
|
72
|
+
* memberId: ctx.auth.userId,
|
|
73
|
+
* memberProfile: { userId: ctx.auth.userId, userName: join.userName },
|
|
74
|
+
* roomProfile: { roomId: join.roomId, created: new Date().toISOString() },
|
|
75
|
+
* }),
|
|
76
|
+
* });
|
|
77
|
+
*
|
|
78
|
+
* // Later if needed:
|
|
79
|
+
* // stop();
|
|
80
|
+
* });
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown>(
|
|
84
|
+
socket: ServerSocketLike,
|
|
85
|
+
_room: TRoom,
|
|
86
|
+
handlers: RoomServerHandlers<TRoom, TAuth>,
|
|
87
|
+
adapter?: RoomServerAdapter,
|
|
88
|
+
): RoomServerHandle<TRoom> {
|
|
89
|
+
const namespaceState = getNamespaceState(socket);
|
|
90
|
+
const authCache = new WeakMap<ServerSocketLike, AuthCacheEntry<TAuth>>();
|
|
91
|
+
|
|
92
|
+
const onJoin = async (
|
|
93
|
+
frame: {
|
|
94
|
+
roomType: string;
|
|
95
|
+
payload: JoinRequest<TRoom>;
|
|
96
|
+
},
|
|
97
|
+
ack?: (result: { ok: true; value: { roomId: string; memberId: string; roomProfile: any; presence: PresenceValueFor<TRoom> } } | { ok: false; error: string }) => void,
|
|
98
|
+
) => {
|
|
99
|
+
try {
|
|
100
|
+
assertMatchingRoomName(_room, frame.roomType);
|
|
101
|
+
const requestedRoomId = extractRoomId(frame.payload);
|
|
102
|
+
const roomCollection = getOrCreateRoomCollection(namespaceState, frame.roomType);
|
|
103
|
+
const existingRoomState = roomCollection.get(requestedRoomId);
|
|
104
|
+
const initialState = (existingRoomState?.serverState ??
|
|
105
|
+
await Promise.resolve(handlers.initState?.(frame.payload) ?? {})) as ServerStateFor<TRoom>;
|
|
106
|
+
const auth = await resolveSocketAuth(socket, handlers, authCache, true);
|
|
107
|
+
const provisional = createContext<TRoom, TAuth>(socket, namespaceState, {
|
|
108
|
+
adapter,
|
|
109
|
+
name: frame.roomType,
|
|
110
|
+
roomId: requestedRoomId,
|
|
111
|
+
auth: auth as TAuth,
|
|
112
|
+
memberId: socket.id,
|
|
113
|
+
memberProfile: undefined,
|
|
114
|
+
roomProfile: undefined,
|
|
115
|
+
serverState: initialState,
|
|
116
|
+
});
|
|
117
|
+
const admission = await handlers.admit(frame.payload, provisional);
|
|
118
|
+
assertMatchingRoomIds(
|
|
119
|
+
requestedRoomId,
|
|
120
|
+
admission.roomId,
|
|
121
|
+
(admission.roomProfile as { roomId: string }).roomId,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const roomState: RoomState<TRoom> = roomCollection.get(admission.roomId) ?? {
|
|
125
|
+
presence: _room.presence,
|
|
126
|
+
roomProfile: admission.roomProfile,
|
|
127
|
+
serverState: provisional.serverState as ServerStateFor<TRoom>,
|
|
128
|
+
membersBySocketId: new Map(),
|
|
129
|
+
socketIdsByMemberId: new Map(),
|
|
130
|
+
};
|
|
131
|
+
roomState.roomProfile = roomState.roomProfile ?? admission.roomProfile;
|
|
132
|
+
roomState.serverState = roomState.serverState ?? provisional.serverState;
|
|
133
|
+
roomState.membersBySocketId.set(socket.id, {
|
|
134
|
+
socketId: socket.id,
|
|
135
|
+
memberId: admission.memberId,
|
|
136
|
+
memberProfile: admission.memberProfile,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const memberSockets = roomState.socketIdsByMemberId.get(admission.memberId) ?? new Set();
|
|
140
|
+
memberSockets.add(socket.id);
|
|
141
|
+
roomState.socketIdsByMemberId.set(admission.memberId, memberSockets);
|
|
142
|
+
roomCollection.set(admission.roomId, roomState);
|
|
143
|
+
|
|
144
|
+
await Promise.resolve(socket.join(admission.roomId));
|
|
145
|
+
|
|
146
|
+
const ctx = createContext<TRoom, TAuth>(socket, namespaceState, {
|
|
147
|
+
adapter,
|
|
148
|
+
name: frame.roomType,
|
|
149
|
+
auth: auth as TAuth,
|
|
150
|
+
...admission,
|
|
151
|
+
serverState: roomState.serverState,
|
|
152
|
+
});
|
|
153
|
+
await handlers.onJoin?.(admission.memberProfile, ctx);
|
|
154
|
+
broadcastPresence(socket, namespaceState, frame.roomType, admission.roomId, adapter);
|
|
155
|
+
|
|
156
|
+
ack?.({
|
|
157
|
+
ok: true,
|
|
158
|
+
value: {
|
|
159
|
+
roomId: admission.roomId,
|
|
160
|
+
memberId: admission.memberId,
|
|
161
|
+
roomProfile: roomState.roomProfile,
|
|
162
|
+
presence: getPresenceSnapshot(namespaceState, frame.roomType, admission.roomId),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
ack?.({ ok: false, error: toErrorMessage(error) });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const onLeave = async (
|
|
171
|
+
payload: { roomType: string; roomId: string },
|
|
172
|
+
ack?: (result: { ok: true; value: void } | { ok: false; error: string }) => void,
|
|
173
|
+
) => {
|
|
174
|
+
try {
|
|
175
|
+
assertMatchingRoomName(_room, payload.roomType);
|
|
176
|
+
const stored = getStoredMembership(namespaceState, payload.roomType, payload.roomId, socket.id);
|
|
177
|
+
if (!stored) {
|
|
178
|
+
throw new ClientSafeError("Socket is not joined to that room");
|
|
179
|
+
}
|
|
180
|
+
const auth = await resolveSocketAuth(socket, handlers, authCache, true);
|
|
181
|
+
|
|
182
|
+
const ctx = createContext<TRoom, TAuth>(socket, namespaceState, {
|
|
183
|
+
adapter,
|
|
184
|
+
name: payload.roomType,
|
|
185
|
+
roomId: payload.roomId,
|
|
186
|
+
auth: auth as TAuth,
|
|
187
|
+
memberId: stored.memberId,
|
|
188
|
+
memberProfile: stored.memberProfile,
|
|
189
|
+
roomProfile: getRoomState(namespaceState, payload.roomType, payload.roomId).roomProfile,
|
|
190
|
+
serverState: getRoomState(namespaceState, payload.roomType, payload.roomId).serverState as ServerStateFor<TRoom>,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
removeMembership(namespaceState, payload.roomType, payload.roomId, socket.id);
|
|
194
|
+
await Promise.resolve(socket.leave(payload.roomId));
|
|
195
|
+
await handlers.onLeave?.(stored.memberProfile, ctx);
|
|
196
|
+
broadcastPresence(socket, namespaceState, payload.roomType, payload.roomId, adapter);
|
|
197
|
+
|
|
198
|
+
ack?.({ ok: true, value: undefined });
|
|
199
|
+
} catch (error) {
|
|
200
|
+
ack?.({ ok: false, error: toErrorMessage(error) });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const onRpc = async (
|
|
205
|
+
frame: { roomType: string; roomId: string; name: string; args: unknown[] },
|
|
206
|
+
ack?: (result: { ok: true; value: unknown } | { ok: false; error: string }) => void,
|
|
207
|
+
) => {
|
|
208
|
+
try {
|
|
209
|
+
assertMatchingRoomName(_room, frame.roomType);
|
|
210
|
+
if (!handlers.rpc || !Object.hasOwn(handlers.rpc, frame.name)) {
|
|
211
|
+
throw new ClientSafeError(`Unknown RPC '${frame.name}'`);
|
|
212
|
+
}
|
|
213
|
+
const handler = handlers.rpc[frame.name as keyof typeof handlers.rpc];
|
|
214
|
+
if (typeof handler !== "function") {
|
|
215
|
+
throw new ClientSafeError(`Invalid RPC handler for '${frame.name}'`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const stored = getStoredMembership(namespaceState, frame.roomType, frame.roomId, socket.id);
|
|
219
|
+
if (!stored) {
|
|
220
|
+
throw new ClientSafeError("Socket is not joined to that room");
|
|
221
|
+
}
|
|
222
|
+
const auth = await resolveSocketAuth(socket, handlers, authCache, true);
|
|
223
|
+
|
|
224
|
+
const ctx = createContext<TRoom, TAuth>(socket, namespaceState, {
|
|
225
|
+
adapter,
|
|
226
|
+
name: frame.roomType,
|
|
227
|
+
roomId: frame.roomId,
|
|
228
|
+
auth: auth as TAuth,
|
|
229
|
+
memberId: stored.memberId,
|
|
230
|
+
memberProfile: stored.memberProfile,
|
|
231
|
+
roomProfile: getRoomState(namespaceState, frame.roomType, frame.roomId).roomProfile,
|
|
232
|
+
serverState: getRoomState(namespaceState, frame.roomType, frame.roomId).serverState as ServerStateFor<TRoom>,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const result = await handler(...frame.args, ctx);
|
|
236
|
+
ack?.({ ok: true, value: result });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
ack?.({ ok: false, error: toErrorMessage(error) });
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const onClientEvent = async (
|
|
243
|
+
frame: { roomType: string; roomId: string; name: string; payload: unknown },
|
|
244
|
+
ack?: (result: { ok: true; value: void } | { ok: false; error: string }) => void,
|
|
245
|
+
) => {
|
|
246
|
+
try {
|
|
247
|
+
assertMatchingRoomName(_room, frame.roomType);
|
|
248
|
+
const stored = getStoredMembership(namespaceState, frame.roomType, frame.roomId, socket.id);
|
|
249
|
+
if (!stored) {
|
|
250
|
+
throw new ClientSafeError("Socket is not joined to that room");
|
|
251
|
+
}
|
|
252
|
+
const auth = await resolveSocketAuth(socket, handlers, authCache, true);
|
|
253
|
+
|
|
254
|
+
const ctx = createContext<TRoom, TAuth>(socket, namespaceState, {
|
|
255
|
+
adapter,
|
|
256
|
+
name: frame.roomType,
|
|
257
|
+
roomId: frame.roomId,
|
|
258
|
+
auth: auth as TAuth,
|
|
259
|
+
memberId: stored.memberId,
|
|
260
|
+
memberProfile: stored.memberProfile,
|
|
261
|
+
roomProfile: getRoomState(namespaceState, frame.roomType, frame.roomId).roomProfile,
|
|
262
|
+
serverState: getRoomState(namespaceState, frame.roomType, frame.roomId).serverState as ServerStateFor<TRoom>,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (!handlers.events || !Object.hasOwn(handlers.events, frame.name)) {
|
|
266
|
+
throw new ClientSafeError(`Unknown event '${frame.name}'`);
|
|
267
|
+
}
|
|
268
|
+
const handler = handlers.events[frame.name as keyof typeof handlers.events];
|
|
269
|
+
if (typeof handler !== "function") {
|
|
270
|
+
throw new ClientSafeError(`Invalid event handler for '${frame.name}'`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await handler(frame.payload as never, ctx);
|
|
274
|
+
emitToMembers(socket, namespaceState, frame.roomType, frame.roomId, allMemberIds(namespaceState, frame.roomType, frame.roomId), frame.name, frame.payload, {
|
|
275
|
+
roomId: frame.roomId,
|
|
276
|
+
sentAt: new Date(),
|
|
277
|
+
source: makeMemberSource(ctx),
|
|
278
|
+
} as EventMetaFor<TRoom>, undefined, adapter);
|
|
279
|
+
|
|
280
|
+
ack?.({ ok: true, value: undefined });
|
|
281
|
+
} catch (error) {
|
|
282
|
+
ack?.({ ok: false, error: toErrorMessage(error) });
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const onPresenceQuery = async (
|
|
287
|
+
frame: {
|
|
288
|
+
roomType: string;
|
|
289
|
+
roomId: string;
|
|
290
|
+
kind: "count" | "list";
|
|
291
|
+
offset?: number;
|
|
292
|
+
limit?: number;
|
|
293
|
+
},
|
|
294
|
+
ack?: (result: { ok: true; value: number | PresencePageFor<TRoom> } | { ok: false; error: string }) => void,
|
|
295
|
+
) => {
|
|
296
|
+
try {
|
|
297
|
+
assertMatchingRoomName(_room, frame.roomType);
|
|
298
|
+
const stored = getStoredMembership(namespaceState, frame.roomType, frame.roomId, socket.id);
|
|
299
|
+
if (!stored) {
|
|
300
|
+
throw new ClientSafeError("Socket is not joined to that room");
|
|
301
|
+
}
|
|
302
|
+
const auth = await resolveSocketAuth(socket, handlers, authCache, true);
|
|
303
|
+
const ctx = createContext<TRoom, TAuth>(socket, namespaceState, {
|
|
304
|
+
adapter,
|
|
305
|
+
name: frame.roomType,
|
|
306
|
+
roomId: frame.roomId,
|
|
307
|
+
auth: auth as TAuth,
|
|
308
|
+
memberId: stored.memberId,
|
|
309
|
+
memberProfile: stored.memberProfile,
|
|
310
|
+
roomProfile: getRoomState(namespaceState, frame.roomType, frame.roomId).roomProfile,
|
|
311
|
+
serverState: getRoomState(namespaceState, frame.roomType, frame.roomId).serverState as ServerStateFor<TRoom>,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const roomState = getRoomState(namespaceState, frame.roomType, frame.roomId);
|
|
315
|
+
const requestedPolicy = await Promise.resolve(handlers.presencePolicy?.(ctx) ?? roomState.presence);
|
|
316
|
+
const effectivePolicy = clampPresencePolicy(roomState.presence, requestedPolicy);
|
|
317
|
+
|
|
318
|
+
if (effectivePolicy === "none") {
|
|
319
|
+
throw new ClientSafeError("Presence is disabled for this room");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (frame.kind === "count") {
|
|
323
|
+
ack?.({ ok: true, value: getPresenceCount(namespaceState, frame.roomType, frame.roomId) });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (effectivePolicy !== "list") {
|
|
328
|
+
throw new ClientSafeError("Member lists are disabled for this room");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
ack?.({
|
|
332
|
+
ok: true,
|
|
333
|
+
value: getPresenceMembersPage(namespaceState, frame.roomType, frame.roomId, {
|
|
334
|
+
offset: frame.offset,
|
|
335
|
+
limit: frame.limit,
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
} catch (error) {
|
|
339
|
+
ack?.({ ok: false, error: toErrorMessage(error) });
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const onDisconnect = async () => {
|
|
344
|
+
const joinedRooms = joinedRoomsForSocket(namespaceState, socket.id);
|
|
345
|
+
const disconnectedMemberships: Array<{
|
|
346
|
+
roomType: string;
|
|
347
|
+
roomId: string;
|
|
348
|
+
memberId: string;
|
|
349
|
+
memberProfile: any;
|
|
350
|
+
roomProfile: any;
|
|
351
|
+
serverState: ServerStateFor<TRoom>;
|
|
352
|
+
}> = [];
|
|
353
|
+
|
|
354
|
+
for (const joined of joinedRooms) {
|
|
355
|
+
const stored = getStoredMembership(namespaceState, joined.roomType, joined.roomId, socket.id);
|
|
356
|
+
if (!stored) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const roomState = getRoomState(namespaceState, joined.roomType, joined.roomId);
|
|
361
|
+
disconnectedMemberships.push({
|
|
362
|
+
roomType: joined.roomType,
|
|
363
|
+
roomId: joined.roomId,
|
|
364
|
+
memberId: stored.memberId,
|
|
365
|
+
memberProfile: stored.memberProfile,
|
|
366
|
+
roomProfile: roomState.roomProfile,
|
|
367
|
+
serverState: roomState.serverState as ServerStateFor<TRoom>,
|
|
368
|
+
});
|
|
369
|
+
removeMembership(namespaceState, joined.roomType, joined.roomId, socket.id);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let auth: TAuth | undefined;
|
|
373
|
+
let hasAuth = false;
|
|
374
|
+
try {
|
|
375
|
+
auth = await resolveSocketAuth(socket, handlers, authCache, true);
|
|
376
|
+
hasAuth = true;
|
|
377
|
+
await handlers.onDisconnect?.(socket, auth as TAuth);
|
|
378
|
+
} catch {
|
|
379
|
+
hasAuth = false;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const disconnected of disconnectedMemberships) {
|
|
383
|
+
const ctx = createContext<TRoom, TAuth>(socket, namespaceState, {
|
|
384
|
+
adapter,
|
|
385
|
+
name: disconnected.roomType,
|
|
386
|
+
roomId: disconnected.roomId,
|
|
387
|
+
auth: auth as TAuth,
|
|
388
|
+
memberId: disconnected.memberId,
|
|
389
|
+
memberProfile: disconnected.memberProfile,
|
|
390
|
+
roomProfile: disconnected.roomProfile,
|
|
391
|
+
serverState: disconnected.serverState,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
if (hasAuth) {
|
|
395
|
+
await handlers.onLeave?.(disconnected.memberProfile, ctx);
|
|
396
|
+
}
|
|
397
|
+
broadcastPresence(socket, namespaceState, disconnected.roomType, disconnected.roomId, adapter);
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
socket.on(JOIN_EVENT, onJoin);
|
|
402
|
+
socket.on(LEAVE_EVENT, onLeave);
|
|
403
|
+
socket.on(RPC_EVENT, onRpc);
|
|
404
|
+
socket.on(CLIENT_EVENT, onClientEvent);
|
|
405
|
+
socket.on(PRESENCE_QUERY_EVENT, onPresenceQuery);
|
|
406
|
+
socket.on("disconnect", onDisconnect);
|
|
407
|
+
|
|
408
|
+
if (handlers.onConnect) {
|
|
409
|
+
void Promise.resolve()
|
|
410
|
+
.then(async () => {
|
|
411
|
+
const auth = await resolveSocketAuth(socket, handlers, authCache, false);
|
|
412
|
+
await handlers.onConnect?.(socket, auth as TAuth);
|
|
413
|
+
})
|
|
414
|
+
.catch(() => undefined);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const stop = () => {
|
|
418
|
+
socket.off(JOIN_EVENT, onJoin);
|
|
419
|
+
socket.off(LEAVE_EVENT, onLeave);
|
|
420
|
+
socket.off(RPC_EVENT, onRpc);
|
|
421
|
+
socket.off(CLIENT_EVENT, onClientEvent);
|
|
422
|
+
socket.off(PRESENCE_QUERY_EVENT, onPresenceQuery);
|
|
423
|
+
socket.off("disconnect", onDisconnect);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
return Object.assign(stop, {
|
|
427
|
+
rooms: () => listRoomSnapshots<TRoom>(namespaceState, _room.name),
|
|
428
|
+
room: (roomId: string) => getRoomSnapshot<TRoom>(namespaceState, _room.name, roomId),
|
|
429
|
+
members: (roomId: string, query?: PresenceListQuery) =>
|
|
430
|
+
getPresenceMembersPage<TRoom>(namespaceState, _room.name, roomId, query),
|
|
431
|
+
count: (roomId: string) => getPresenceCount(namespaceState, _room.name, roomId),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function createContext<TRoom extends RoomDefinition<any>, TAuth = unknown>(
|
|
436
|
+
socket: ServerSocketLike,
|
|
437
|
+
namespaceState: NamespaceState,
|
|
438
|
+
ctxState: {
|
|
439
|
+
adapter?: RoomServerAdapter;
|
|
440
|
+
name: string;
|
|
441
|
+
roomId: string;
|
|
442
|
+
auth: TAuth;
|
|
443
|
+
memberId: string;
|
|
444
|
+
memberProfile: any;
|
|
445
|
+
roomProfile: any;
|
|
446
|
+
serverState: ServerStateFor<TRoom>;
|
|
447
|
+
},
|
|
448
|
+
): RoomServerContext<TRoom, TAuth> {
|
|
449
|
+
const emit = createEventEmitApi<TRoom>((eventName, payload) => {
|
|
450
|
+
emitToMembers(socket, namespaceState, ctxState.name, ctxState.roomId, allMemberIds(namespaceState, ctxState.name, ctxState.roomId), eventName, payload, {
|
|
451
|
+
roomId: ctxState.roomId,
|
|
452
|
+
sentAt: new Date(),
|
|
453
|
+
source: {
|
|
454
|
+
kind: "server",
|
|
455
|
+
},
|
|
456
|
+
} as EventMetaFor<TRoom>, undefined, ctxState.adapter);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const broadcast = createBroadcastApi<TRoom>(socket, namespaceState, ctxState.name, ctxState.roomId, socket.id, ctxState.adapter);
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
name: ctxState.name,
|
|
463
|
+
roomId: ctxState.roomId,
|
|
464
|
+
auth: ctxState.auth,
|
|
465
|
+
memberId: ctxState.memberId,
|
|
466
|
+
memberProfile: ctxState.memberProfile,
|
|
467
|
+
roomProfile: ctxState.roomProfile,
|
|
468
|
+
serverState: ctxState.serverState,
|
|
469
|
+
emit,
|
|
470
|
+
broadcast,
|
|
471
|
+
getPresence() {
|
|
472
|
+
return getPresenceSnapshot(namespaceState, ctxState.name, ctxState.roomId);
|
|
473
|
+
},
|
|
474
|
+
getPresenceCount() {
|
|
475
|
+
return getPresenceCount(namespaceState, ctxState.name, ctxState.roomId);
|
|
476
|
+
},
|
|
477
|
+
listPresenceMembers(query: PresenceListQuery = {}) {
|
|
478
|
+
return getPresenceMembersPage(namespaceState, ctxState.name, ctxState.roomId, query);
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function resolveSocketAuth<TAuth>(
|
|
484
|
+
socket: ServerSocketLike,
|
|
485
|
+
handlers: RoomServerHandlers<any, TAuth>,
|
|
486
|
+
authCache: WeakMap<ServerSocketLike, AuthCacheEntry<TAuth>>,
|
|
487
|
+
revalidate: boolean,
|
|
488
|
+
): Promise<TAuth> {
|
|
489
|
+
let entry = authCache.get(socket);
|
|
490
|
+
if (!entry) {
|
|
491
|
+
entry = {};
|
|
492
|
+
authCache.set(socket, entry);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (entry.pending) {
|
|
496
|
+
return entry.pending;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (entry.value === undefined) {
|
|
500
|
+
const pending = Promise.resolve(handlers.onAuth?.(socket) as TAuth)
|
|
501
|
+
.then((auth) => {
|
|
502
|
+
const current = authCache.get(socket) ?? {};
|
|
503
|
+
current.value = auth;
|
|
504
|
+
current.pending = undefined;
|
|
505
|
+
authCache.set(socket, current);
|
|
506
|
+
return auth;
|
|
507
|
+
})
|
|
508
|
+
.catch((error) => {
|
|
509
|
+
authCache.delete(socket);
|
|
510
|
+
throw error;
|
|
511
|
+
});
|
|
512
|
+
entry.pending = pending;
|
|
513
|
+
authCache.set(socket, entry);
|
|
514
|
+
await pending;
|
|
515
|
+
entry = authCache.get(socket) ?? entry;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
let auth = entry.value as TAuth;
|
|
519
|
+
if (!revalidate || !handlers.revalidateAuth) {
|
|
520
|
+
return auth;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const decision = await Promise.resolve(handlers.revalidateAuth(socket, auth));
|
|
524
|
+
if (!decision || decision.kind === "ok") {
|
|
525
|
+
if (decision?.auth !== undefined) {
|
|
526
|
+
auth = decision.auth;
|
|
527
|
+
entry.value = auth;
|
|
528
|
+
authCache.set(socket, entry);
|
|
529
|
+
}
|
|
530
|
+
return auth;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
handleRejectedAuth(authCache, socket, decision);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function handleRejectedAuth<TAuth>(
|
|
537
|
+
authCache: WeakMap<ServerSocketLike, AuthCacheEntry<TAuth>>,
|
|
538
|
+
socket: ServerSocketLike,
|
|
539
|
+
decision: Extract<AuthRevalidationDecision<TAuth>, { kind: "reject" }>,
|
|
540
|
+
): never {
|
|
541
|
+
authCache.delete(socket);
|
|
542
|
+
throw new ClientSafeError(decision.message ?? "Unauthorized");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function createEventEmitApi<TRoom extends RoomDefinition<any>>(
|
|
546
|
+
send: (eventName: string, payload: unknown) => void,
|
|
547
|
+
): EventEmitApi<TRoom> {
|
|
548
|
+
return new Proxy({} as EventEmitApi<TRoom>, {
|
|
549
|
+
get(_target, key) {
|
|
550
|
+
if (typeof key !== "string") {
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return async (payload: unknown) => {
|
|
555
|
+
send(key, payload);
|
|
556
|
+
};
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function createBroadcastApi<TRoom extends RoomDefinition<any>>(
|
|
562
|
+
socket: ServerSocketLike,
|
|
563
|
+
namespaceState: NamespaceState,
|
|
564
|
+
name: string,
|
|
565
|
+
roomId: string,
|
|
566
|
+
senderSocketId: string,
|
|
567
|
+
adapter?: RoomServerAdapter,
|
|
568
|
+
): RoomServerBroadcastApi<TRoom> {
|
|
569
|
+
return {
|
|
570
|
+
emit: createEventEmitApi<TRoom>((eventName, payload) => {
|
|
571
|
+
emitToNamespace(socket, namespaceState, name, eventName, payload, senderSocketId, {
|
|
572
|
+
roomId,
|
|
573
|
+
sentAt: new Date(),
|
|
574
|
+
source: {
|
|
575
|
+
kind: "server",
|
|
576
|
+
},
|
|
577
|
+
} as EventMetaFor<TRoom>, adapter);
|
|
578
|
+
}),
|
|
579
|
+
toRoom(targetRoomId: string) {
|
|
580
|
+
return {
|
|
581
|
+
emit: createEventEmitApi<TRoom>((eventName, payload) => {
|
|
582
|
+
emitToRoom(socket, namespaceState, name, targetRoomId, eventName, payload, {
|
|
583
|
+
roomId: targetRoomId,
|
|
584
|
+
sentAt: new Date(),
|
|
585
|
+
source: {
|
|
586
|
+
kind: "server",
|
|
587
|
+
},
|
|
588
|
+
} as EventMetaFor<TRoom>, senderSocketId, adapter);
|
|
589
|
+
}),
|
|
590
|
+
};
|
|
591
|
+
},
|
|
592
|
+
toMembers(memberIds: readonly string[]) {
|
|
593
|
+
return {
|
|
594
|
+
emit: createEventEmitApi<TRoom>((eventName, payload) => {
|
|
595
|
+
emitToMembers(socket, namespaceState, name, roomId, memberIds, eventName, payload, {
|
|
596
|
+
roomId,
|
|
597
|
+
sentAt: new Date(),
|
|
598
|
+
source: {
|
|
599
|
+
kind: "server",
|
|
600
|
+
},
|
|
601
|
+
} as EventMetaFor<TRoom>, senderSocketId, adapter);
|
|
602
|
+
}),
|
|
603
|
+
};
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function emitToMembers(
|
|
609
|
+
socket: ServerSocketLike,
|
|
610
|
+
namespaceState: NamespaceState,
|
|
611
|
+
name: string,
|
|
612
|
+
roomId: string,
|
|
613
|
+
memberIds: readonly string[],
|
|
614
|
+
eventName: string,
|
|
615
|
+
payload: unknown,
|
|
616
|
+
meta: { roomId: string; sentAt: Date; source: unknown },
|
|
617
|
+
excludeSocketId?: string,
|
|
618
|
+
adapter?: RoomServerAdapter,
|
|
619
|
+
): void {
|
|
620
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
621
|
+
if (!roomState) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const socketIds = new Set<string>();
|
|
626
|
+
for (const memberId of memberIds) {
|
|
627
|
+
const ids = roomState.socketIdsByMemberId.get(memberId);
|
|
628
|
+
if (!ids) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for (const socketId of ids) {
|
|
633
|
+
if (excludeSocketId && socketId === excludeSocketId) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
socketIds.add(socketId);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
emitToSocketIds(socket, socketIds, eventName, payload, name, roomId, meta, adapter);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function emitToRoom(
|
|
644
|
+
socket: ServerSocketLike,
|
|
645
|
+
namespaceState: NamespaceState,
|
|
646
|
+
name: string,
|
|
647
|
+
roomId: string,
|
|
648
|
+
eventName: string,
|
|
649
|
+
payload: unknown,
|
|
650
|
+
meta: { roomId: string; sentAt: Date; source: unknown },
|
|
651
|
+
excludeSocketId?: string,
|
|
652
|
+
adapter?: RoomServerAdapter,
|
|
653
|
+
): void {
|
|
654
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
655
|
+
if (!roomState) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const socketIds = new Set<string>();
|
|
660
|
+
for (const stored of roomState.membersBySocketId.values()) {
|
|
661
|
+
if (excludeSocketId && stored.socketId === excludeSocketId) {
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
socketIds.add(stored.socketId);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
emitToSocketIds(socket, socketIds, eventName, payload, name, roomId, meta, adapter);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function emitToNamespace(
|
|
671
|
+
socket: ServerSocketLike,
|
|
672
|
+
namespaceState: NamespaceState,
|
|
673
|
+
name: string,
|
|
674
|
+
eventName: string,
|
|
675
|
+
payload: unknown,
|
|
676
|
+
excludeSocketId: string | undefined,
|
|
677
|
+
meta: { roomId: string; sentAt: Date; source: unknown },
|
|
678
|
+
adapter?: RoomServerAdapter,
|
|
679
|
+
): void {
|
|
680
|
+
const socketIds = allSocketIdsForNamespace(namespaceState, name, excludeSocketId);
|
|
681
|
+
emitToSocketIds(socket, socketIds, eventName, payload, name, meta.roomId, meta, adapter);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function emitToSocketIds(
|
|
685
|
+
socket: ServerSocketLike,
|
|
686
|
+
socketIds: Iterable<string>,
|
|
687
|
+
eventName: string,
|
|
688
|
+
payload: unknown,
|
|
689
|
+
name: string,
|
|
690
|
+
roomId: string,
|
|
691
|
+
meta: { roomId: string; sentAt: Date; source: unknown },
|
|
692
|
+
adapter?: RoomServerAdapter,
|
|
693
|
+
): void {
|
|
694
|
+
const emitted = {
|
|
695
|
+
roomType: name,
|
|
696
|
+
roomId,
|
|
697
|
+
name: eventName,
|
|
698
|
+
payload,
|
|
699
|
+
meta: {
|
|
700
|
+
...meta,
|
|
701
|
+
sentAt: meta.sentAt.toISOString(),
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
if (adapter) {
|
|
706
|
+
adapter.emitToSocketIds(Array.from(socketIds), SERVER_EVENT, emitted);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
for (const socketId of socketIds) {
|
|
711
|
+
socket.nsp.to(socketId).emit(SERVER_EVENT, emitted);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function broadcastPresence(
|
|
716
|
+
socket: ServerSocketLike,
|
|
717
|
+
namespaceState: NamespaceState,
|
|
718
|
+
name: string,
|
|
719
|
+
roomId: string,
|
|
720
|
+
adapter?: RoomServerAdapter,
|
|
721
|
+
): void {
|
|
722
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
723
|
+
if (!roomState) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const presence = getPresenceSnapshot(namespaceState, name, roomId);
|
|
728
|
+
if (presence === undefined) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
for (const stored of roomState.membersBySocketId.values()) {
|
|
733
|
+
const frame = {
|
|
734
|
+
roomType: name,
|
|
735
|
+
roomId,
|
|
736
|
+
presence,
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
if (adapter) {
|
|
740
|
+
adapter.emitToSocketIds([stored.socketId], PRESENCE_EVENT, frame);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
socket.nsp.to(stored.socketId).emit(PRESENCE_EVENT, frame);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function getPresenceSnapshot<TRoom extends RoomDefinition<any>>(
|
|
749
|
+
namespaceState: NamespaceState,
|
|
750
|
+
name: string,
|
|
751
|
+
roomId: string,
|
|
752
|
+
): PresenceValueFor<TRoom> {
|
|
753
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
754
|
+
if (!roomState) {
|
|
755
|
+
return undefined as PresenceValueFor<TRoom>;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (roomState.presence === "none") {
|
|
759
|
+
return undefined as PresenceValueFor<TRoom>;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const members = dedupeMembers(roomState);
|
|
763
|
+
if (roomState.presence === "list") {
|
|
764
|
+
return {
|
|
765
|
+
count: members.length,
|
|
766
|
+
members: members.map((entry) => ({
|
|
767
|
+
memberId: entry.memberId,
|
|
768
|
+
memberProfile: entry.memberProfile,
|
|
769
|
+
})),
|
|
770
|
+
} as PresenceValueFor<TRoom>;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
count: members.length,
|
|
775
|
+
} as PresenceValueFor<TRoom>;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function getRoomSnapshot<TRoom extends RoomDefinition<any>>(
|
|
779
|
+
namespaceState: NamespaceState,
|
|
780
|
+
name: string,
|
|
781
|
+
roomId: string,
|
|
782
|
+
): RoomSnapshot<TRoom> | undefined {
|
|
783
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
784
|
+
if (!roomState) {
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const members = dedupeMembers(roomState);
|
|
789
|
+
return {
|
|
790
|
+
roomId,
|
|
791
|
+
roomProfile: roomState.roomProfile,
|
|
792
|
+
serverState: roomState.serverState,
|
|
793
|
+
presence: getPresenceSnapshot(namespaceState, name, roomId),
|
|
794
|
+
memberCount: members.length,
|
|
795
|
+
members: members.map((entry) => ({
|
|
796
|
+
socketId: entry.socketId,
|
|
797
|
+
memberId: entry.memberId,
|
|
798
|
+
memberProfile: entry.memberProfile,
|
|
799
|
+
})),
|
|
800
|
+
} as RoomSnapshot<TRoom>;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function listRoomSnapshots<TRoom extends RoomDefinition<any>>(
|
|
804
|
+
namespaceState: NamespaceState,
|
|
805
|
+
name: string,
|
|
806
|
+
): Array<RoomSnapshot<TRoom>> {
|
|
807
|
+
const roomCollection = getRoomCollection(namespaceState, name);
|
|
808
|
+
if (!roomCollection) {
|
|
809
|
+
return [];
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const snapshots: Array<RoomSnapshot<TRoom>> = [];
|
|
813
|
+
for (const roomId of roomCollection.keys()) {
|
|
814
|
+
const snapshot = getRoomSnapshot<TRoom>(namespaceState, name, roomId);
|
|
815
|
+
if (snapshot) {
|
|
816
|
+
snapshots.push(snapshot);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return snapshots;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function getPresenceCount(namespaceState: NamespaceState, name: string, roomId: string): number {
|
|
824
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
825
|
+
if (!roomState) {
|
|
826
|
+
return 0;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (roomState.presence === "none") {
|
|
830
|
+
throw new ClientSafeError("Presence is disabled for this room");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return dedupeMembers(roomState).length;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function getPresenceMembersPage<TRoom extends RoomDefinition<any>>(
|
|
837
|
+
namespaceState: NamespaceState,
|
|
838
|
+
name: string,
|
|
839
|
+
roomId: string,
|
|
840
|
+
query: PresenceListQuery = {},
|
|
841
|
+
): PresencePageFor<TRoom> {
|
|
842
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
843
|
+
if (!roomState) {
|
|
844
|
+
return {
|
|
845
|
+
count: 0,
|
|
846
|
+
offset: 0,
|
|
847
|
+
limit: 0,
|
|
848
|
+
members: [],
|
|
849
|
+
} as unknown as PresencePageFor<TRoom>;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (roomState.presence === "none") {
|
|
853
|
+
throw new ClientSafeError("Presence is disabled for this room");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (roomState.presence !== "list") {
|
|
857
|
+
throw new ClientSafeError("Member lists are disabled for this room");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const members = dedupeMembers(roomState).map((entry) => ({
|
|
861
|
+
memberId: entry.memberId,
|
|
862
|
+
memberProfile: entry.memberProfile,
|
|
863
|
+
})) as Array<VisibleMemberFor<TRoom>>;
|
|
864
|
+
const count = members.length;
|
|
865
|
+
const offset = normalizePageOffset(query.offset, count);
|
|
866
|
+
const limit = normalizePageLimit(query.limit, count);
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
count,
|
|
870
|
+
offset,
|
|
871
|
+
limit,
|
|
872
|
+
members: members.slice(offset, offset + limit),
|
|
873
|
+
} as PresencePageFor<TRoom>;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function normalizePageOffset(value: number | undefined, count: number): number {
|
|
877
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
878
|
+
return 0;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return Math.min(Math.floor(value), count);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function normalizePageLimit(value: number | undefined, count: number): number {
|
|
885
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
886
|
+
return Math.max(count, 0);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return Math.floor(value);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function dedupeMembers(roomState: RoomState<any>): Array<{ socketId: string; memberId: string; memberProfile: unknown }> {
|
|
893
|
+
const seen = new Set<string>();
|
|
894
|
+
const members: Array<{ socketId: string; memberId: string; memberProfile: unknown }> = [];
|
|
895
|
+
|
|
896
|
+
for (const stored of roomState.membersBySocketId.values()) {
|
|
897
|
+
if (seen.has(stored.memberId)) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
seen.add(stored.memberId);
|
|
902
|
+
members.push({
|
|
903
|
+
socketId: stored.socketId,
|
|
904
|
+
memberId: stored.memberId,
|
|
905
|
+
memberProfile: stored.memberProfile,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return members;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function allSocketIdsForNamespace(
|
|
913
|
+
namespaceState: NamespaceState,
|
|
914
|
+
name: string,
|
|
915
|
+
excludeSocketId?: string,
|
|
916
|
+
): Set<string> {
|
|
917
|
+
const socketIds = new Set<string>();
|
|
918
|
+
const roomCollection = getRoomCollection(namespaceState, name);
|
|
919
|
+
if (!roomCollection) {
|
|
920
|
+
return socketIds;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
for (const roomState of roomCollection.values()) {
|
|
924
|
+
for (const stored of roomState.membersBySocketId.values()) {
|
|
925
|
+
if (excludeSocketId && stored.socketId === excludeSocketId) {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
socketIds.add(stored.socketId);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return socketIds;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function makeMemberSource<TRoom extends RoomDefinition<any>>(ctx: RoomServerContext<TRoom>): EventMetaFor<TRoom>["source"] {
|
|
936
|
+
return {
|
|
937
|
+
kind: "member",
|
|
938
|
+
memberId: ctx.memberId,
|
|
939
|
+
memberProfile: ctx.memberProfile,
|
|
940
|
+
} as EventMetaFor<TRoom>["source"];
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function getNamespaceState(socket: ServerSocketLike): NamespaceState {
|
|
944
|
+
const existing = namespaceStates.get(socket.nsp);
|
|
945
|
+
if (existing) {
|
|
946
|
+
return existing;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const created: NamespaceState = {
|
|
950
|
+
roomsByNameSpace: new Map(),
|
|
951
|
+
};
|
|
952
|
+
namespaceStates.set(socket.nsp, created);
|
|
953
|
+
return created;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function getRoomCollection(namespaceState: NamespaceState, name: string): Map<string, RoomState<any>> | undefined {
|
|
957
|
+
return namespaceState.roomsByNameSpace.get(name);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function getOrCreateRoomCollection(namespaceState: NamespaceState, name: string): Map<string, RoomState<any>> {
|
|
961
|
+
const existing = getRoomCollection(namespaceState, name);
|
|
962
|
+
if (existing) {
|
|
963
|
+
return existing;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const created = new Map<string, RoomState<any>>();
|
|
967
|
+
namespaceState.roomsByNameSpace.set(name, created);
|
|
968
|
+
return created;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function getRoomState(namespaceState: NamespaceState, name: string, roomId: string): RoomState<any> {
|
|
972
|
+
const roomState = getRoomCollection(namespaceState, name)?.get(roomId);
|
|
973
|
+
if (!roomState) {
|
|
974
|
+
throw new ClientSafeError("Unknown room");
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return roomState;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function getStoredMembership(namespaceState: NamespaceState, name: string, roomId: string, socketId: string): StoredMember<any> | undefined {
|
|
981
|
+
return getRoomCollection(namespaceState, name)?.get(roomId)?.membersBySocketId.get(socketId);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function removeMembership(namespaceState: NamespaceState, name: string, roomId: string, socketId: string): void {
|
|
985
|
+
const roomCollection = getRoomCollection(namespaceState, name);
|
|
986
|
+
const roomState = roomCollection?.get(roomId);
|
|
987
|
+
const stored = roomState?.membersBySocketId.get(socketId);
|
|
988
|
+
if (!roomState || !stored) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
roomState.membersBySocketId.delete(socketId);
|
|
993
|
+
const socketIds = roomState.socketIdsByMemberId.get(stored.memberId);
|
|
994
|
+
socketIds?.delete(socketId);
|
|
995
|
+
if (socketIds && socketIds.size === 0) {
|
|
996
|
+
roomState.socketIdsByMemberId.delete(stored.memberId);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (roomState.membersBySocketId.size === 0) {
|
|
1000
|
+
roomCollection?.delete(roomId);
|
|
1001
|
+
if (roomCollection && roomCollection.size === 0) {
|
|
1002
|
+
namespaceState.roomsByNameSpace.delete(name);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function joinedRoomsForSocket(namespaceState: NamespaceState, socketId: string): Array<{ roomType: string; roomId: string }> {
|
|
1008
|
+
const rooms: Array<{ roomType: string; roomId: string }> = [];
|
|
1009
|
+
for (const [name, roomCollection] of namespaceState.roomsByNameSpace) {
|
|
1010
|
+
for (const [roomId, roomState] of roomCollection) {
|
|
1011
|
+
if (roomState.membersBySocketId.has(socketId)) {
|
|
1012
|
+
rooms.push({ roomType: name, roomId });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return rooms;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function allMemberIds(namespaceState: NamespaceState, name: string, roomId: string): string[] {
|
|
1021
|
+
return Array.from(getRoomCollection(namespaceState, name)?.get(roomId)?.socketIdsByMemberId.keys() ?? []);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function extractRoomId(payload: unknown): string {
|
|
1025
|
+
if (!payload || typeof payload !== "object" || typeof (payload as { roomId?: unknown }).roomId !== "string") {
|
|
1026
|
+
throw new ClientSafeError("Join request must include a string roomId");
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return (payload as { roomId: string }).roomId;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function toErrorMessage(error: unknown): string {
|
|
1033
|
+
return error instanceof ClientSafeError ? error.message : "An internal server error occurred.";
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function clampPresencePolicy(base: PresencePolicy, requested: PresencePolicy): PresencePolicy {
|
|
1037
|
+
const rank = (policy: PresencePolicy): number => {
|
|
1038
|
+
if (policy === "none") {
|
|
1039
|
+
return 0;
|
|
1040
|
+
}
|
|
1041
|
+
if (policy === "count") {
|
|
1042
|
+
return 1;
|
|
1043
|
+
}
|
|
1044
|
+
return 2;
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
return rank(requested) <= rank(base) ? requested : base;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function assertMatchingRoomName(room: RoomDefinition<any>, name: string): void {
|
|
1051
|
+
if (room.name !== name) {
|
|
1052
|
+
throw new ClientSafeError(`Expected namespace '${room.name}' but received '${name}'`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function assertMatchingRoomIds(joinRoomId: string, admissionRoomId: string, roomProfileRoomId: string): void {
|
|
1057
|
+
if (joinRoomId !== admissionRoomId) {
|
|
1058
|
+
throw new ClientSafeError("Admission roomId must match join request roomId");
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (roomProfileRoomId !== admissionRoomId) {
|
|
1062
|
+
throw new ClientSafeError("Admission roomProfile.roomId must match admission roomId");
|
|
1063
|
+
}
|
|
1064
|
+
}
|