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
|
@@ -0,0 +1,2401 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { ClientSafeError, createRoomClient, defineRoomType, serveRoomType } from "../src";
|
|
5
|
+
import type { ClientSocketLike, JoinedRoom, RoomDefinition, RoomServerAdapter, RoomServerHandlers, ServerSocketLike } from "../src";
|
|
6
|
+
import type { PresencePolicy } from "../src";
|
|
7
|
+
|
|
8
|
+
type Listener = (...args: any[]) => void;
|
|
9
|
+
|
|
10
|
+
class MockNamespace {
|
|
11
|
+
private readonly sockets = new Map<string, MockClientSocket>();
|
|
12
|
+
|
|
13
|
+
register(socket: MockClientSocket): void {
|
|
14
|
+
this.sockets.set(socket.id, socket);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
unregister(socket: MockClientSocket): void {
|
|
18
|
+
const current = this.sockets.get(socket.id);
|
|
19
|
+
if (current === socket) {
|
|
20
|
+
this.sockets.delete(socket.id);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
to(roomOrSocketId: string): { emit(eventName: string, payload: unknown): void } {
|
|
25
|
+
return {
|
|
26
|
+
emit: (eventName: string, payload: unknown) => {
|
|
27
|
+
this.sockets.get(roomOrSocketId)?.receive(eventName, payload);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class MockServerSocket implements ServerSocketLike {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly nsp: MockNamespace;
|
|
36
|
+
|
|
37
|
+
private readonly listeners = new Map<string, Set<Listener>>();
|
|
38
|
+
readonly joinedRooms: string[] = [];
|
|
39
|
+
readonly leftRooms: string[] = [];
|
|
40
|
+
|
|
41
|
+
constructor(id: string, nsp: MockNamespace) {
|
|
42
|
+
this.id = id;
|
|
43
|
+
this.nsp = nsp;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
emit(eventName: string, ...args: any[]): void {
|
|
47
|
+
this.nsp.to(this.id).emit(eventName, args[0]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
on(eventName: string, handler: Listener): void {
|
|
51
|
+
const handlers = this.listeners.get(eventName) ?? new Set<Listener>();
|
|
52
|
+
handlers.add(handler);
|
|
53
|
+
this.listeners.set(eventName, handlers);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
off(eventName: string, handler: Listener): void {
|
|
57
|
+
this.listeners.get(eventName)?.delete(handler);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
join(room: string): void {
|
|
61
|
+
this.joinedRooms.push(room);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
leave(room: string): void {
|
|
65
|
+
this.leftRooms.push(room);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
receive(eventName: string, payload: unknown, ack?: Listener): void {
|
|
69
|
+
for (const handler of this.listeners.get(eventName) ?? []) {
|
|
70
|
+
handler(payload, ack);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class MockClientSocket implements ClientSocketLike {
|
|
76
|
+
readonly id: string;
|
|
77
|
+
|
|
78
|
+
private readonly listeners = new Map<string, Set<Listener>>();
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
id: string,
|
|
82
|
+
private readonly serverSocket: MockServerSocket,
|
|
83
|
+
private readonly namespace: MockNamespace,
|
|
84
|
+
) {
|
|
85
|
+
this.id = id;
|
|
86
|
+
this.namespace.register(this);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
emit(eventName: string, ...args: any[]): void {
|
|
90
|
+
const maybeAck = args.at(-1);
|
|
91
|
+
const ack = typeof maybeAck === "function" ? (maybeAck as Listener) : undefined;
|
|
92
|
+
this.serverSocket.receive(eventName, args[0], ack);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
on(eventName: string, handler: Listener): void {
|
|
96
|
+
const handlers = this.listeners.get(eventName) ?? new Set<Listener>();
|
|
97
|
+
handlers.add(handler);
|
|
98
|
+
this.listeners.set(eventName, handlers);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
off(eventName: string, handler: Listener): void {
|
|
102
|
+
this.listeners.get(eventName)?.delete(handler);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
receive(eventName: string, payload: unknown): void {
|
|
106
|
+
for (const handler of this.listeners.get(eventName) ?? []) {
|
|
107
|
+
handler(payload);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
close(): void {
|
|
112
|
+
this.namespace.unregister(this);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
class MockConnection {
|
|
117
|
+
readonly serverSocket: MockServerSocket;
|
|
118
|
+
readonly clientSocket: MockClientSocket;
|
|
119
|
+
|
|
120
|
+
constructor(namespace: MockNamespace, id: string) {
|
|
121
|
+
this.serverSocket = new MockServerSocket(id, namespace);
|
|
122
|
+
this.clientSocket = new MockClientSocket(id, this.serverSocket, namespace);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
close(): void {
|
|
126
|
+
this.clientSocket.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type ChatMessage = {
|
|
131
|
+
id: string;
|
|
132
|
+
name: string;
|
|
133
|
+
text: string;
|
|
134
|
+
sentAt: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type ChatRoomSchema = {
|
|
138
|
+
joinRequest: {
|
|
139
|
+
roomId: string;
|
|
140
|
+
roomKey: string;
|
|
141
|
+
userId: string;
|
|
142
|
+
userName: string;
|
|
143
|
+
};
|
|
144
|
+
memberProfile: {
|
|
145
|
+
userId: string;
|
|
146
|
+
userName: string;
|
|
147
|
+
};
|
|
148
|
+
roomProfile: {
|
|
149
|
+
roomId: string;
|
|
150
|
+
created: string;
|
|
151
|
+
};
|
|
152
|
+
serverState: {
|
|
153
|
+
roomKey: string;
|
|
154
|
+
created: string;
|
|
155
|
+
history: ChatMessage[];
|
|
156
|
+
};
|
|
157
|
+
events: {
|
|
158
|
+
broadcastNotice: { text: string };
|
|
159
|
+
roomNotice: { text: string };
|
|
160
|
+
relay: { text: string };
|
|
161
|
+
privateNotice: { text: string };
|
|
162
|
+
message: { text: string };
|
|
163
|
+
};
|
|
164
|
+
rpc: {
|
|
165
|
+
announce: (input: { text: string }) => Promise<void>;
|
|
166
|
+
announceRoom: (input: { roomId: string; text: string }) => Promise<void>;
|
|
167
|
+
sendMessage: (input: { text: string }) => Promise<{ id: string; historySize: number }>;
|
|
168
|
+
whisper: (input: { targetMemberId: string; text: string }) => Promise<void>;
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
type ChatRoomType<TPresence extends PresencePolicy = "list"> = RoomDefinition<ChatRoomSchema, TPresence>;
|
|
173
|
+
|
|
174
|
+
function createRoomType<TPresence extends PresencePolicy = "list">(name: string, presence: TPresence = "list" as TPresence): ChatRoomType<TPresence> {
|
|
175
|
+
return defineRoomType<ChatRoomSchema, TPresence>({ name: name, presence });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function createClientPair<TRoom extends RoomDefinition<any, PresencePolicy>, TAuth = unknown>(
|
|
179
|
+
namespace: MockNamespace,
|
|
180
|
+
id: string,
|
|
181
|
+
roomType: TRoom,
|
|
182
|
+
handlers: RoomServerHandlers<any, TAuth>,
|
|
183
|
+
adapter?: RoomServerAdapter,
|
|
184
|
+
) {
|
|
185
|
+
const connection = new MockConnection(namespace, id);
|
|
186
|
+
const stop = serveRoomType(connection.serverSocket, roomType, handlers, adapter);
|
|
187
|
+
return {
|
|
188
|
+
connection,
|
|
189
|
+
stop,
|
|
190
|
+
client: createRoomClient(connection.clientSocket, roomType),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function createBaseHandlers(options: {
|
|
195
|
+
initState?: (joinRequest: { roomId: string; roomKey: string; userId: string; userName: string }) => {
|
|
196
|
+
roomKey: string;
|
|
197
|
+
created: string;
|
|
198
|
+
history: ChatMessage[];
|
|
199
|
+
} | Promise<{
|
|
200
|
+
roomKey: string;
|
|
201
|
+
created: string;
|
|
202
|
+
history: ChatMessage[];
|
|
203
|
+
}>;
|
|
204
|
+
admit?: (joinRequest: { roomId: string; roomKey: string; userId: string; userName: string }, ctx: any) => Promise<any> | any;
|
|
205
|
+
onJoin?: (memberProfile: { userId: string; userName: string }, ctx: any) => Promise<void> | void;
|
|
206
|
+
onLeave?: (memberProfile: { userId: string; userName: string }, ctx: any) => Promise<void> | void;
|
|
207
|
+
rpc?: {
|
|
208
|
+
sendMessage?: (input: { text: string }, ctx: any) => Promise<{ id: string; historySize: number }> | { id: string; historySize: number };
|
|
209
|
+
whisper?: (input: { targetMemberId: string; text: string }, ctx: any) => Promise<void> | void;
|
|
210
|
+
};
|
|
211
|
+
} = {}) {
|
|
212
|
+
return {
|
|
213
|
+
initState: options.initState ?? (() => ({
|
|
214
|
+
roomKey: "shared-key",
|
|
215
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
216
|
+
history: [],
|
|
217
|
+
})),
|
|
218
|
+
admit: options.admit ?? ((join: any) => ({
|
|
219
|
+
roomId: join.roomId,
|
|
220
|
+
memberId: join.userId,
|
|
221
|
+
memberProfile: {
|
|
222
|
+
userId: join.userId,
|
|
223
|
+
userName: join.userName,
|
|
224
|
+
},
|
|
225
|
+
roomProfile: {
|
|
226
|
+
roomId: join.roomId,
|
|
227
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
228
|
+
},
|
|
229
|
+
})),
|
|
230
|
+
onJoin: options.onJoin,
|
|
231
|
+
onLeave: options.onLeave,
|
|
232
|
+
events: {
|
|
233
|
+
relay: async () => undefined,
|
|
234
|
+
},
|
|
235
|
+
rpc: {
|
|
236
|
+
announce: async ({ text }, ctx: any) => {
|
|
237
|
+
await ctx.broadcast.emit.broadcastNotice({ text });
|
|
238
|
+
},
|
|
239
|
+
announceRoom: async ({ roomId, text }, ctx: any) => {
|
|
240
|
+
await ctx.broadcast.toRoom(roomId).emit.roomNotice({ text });
|
|
241
|
+
},
|
|
242
|
+
sendMessage: options.rpc?.sendMessage ?? (async ({ text }, ctx: any) => {
|
|
243
|
+
const message = {
|
|
244
|
+
id: `message-${ctx.serverState.history.length + 1}`,
|
|
245
|
+
name: ctx.memberProfile.userName,
|
|
246
|
+
text,
|
|
247
|
+
sentAt: "2026-03-23T00:00:00.000Z",
|
|
248
|
+
};
|
|
249
|
+
ctx.serverState.history.push(message);
|
|
250
|
+
await ctx.emit.message({ text });
|
|
251
|
+
return {
|
|
252
|
+
id: message.id,
|
|
253
|
+
historySize: ctx.serverState.history.length,
|
|
254
|
+
};
|
|
255
|
+
}),
|
|
256
|
+
whisper: options.rpc?.whisper ?? (async ({ targetMemberId, text }, ctx: any) => {
|
|
257
|
+
await ctx.broadcast.toMembers([targetMemberId]).emit.privateNotice({ text });
|
|
258
|
+
}),
|
|
259
|
+
},
|
|
260
|
+
} satisfies RoomServerHandlers<any>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
describe("room kit", () => {
|
|
264
|
+
it("rejects admission failures", async () => {
|
|
265
|
+
const namespace = new MockNamespace();
|
|
266
|
+
const roomType = createRoomType("reject-admission");
|
|
267
|
+
const handlers = createBaseHandlers({
|
|
268
|
+
admit: async () => {
|
|
269
|
+
throw new ClientSafeError("forbidden");
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
273
|
+
|
|
274
|
+
await expect(
|
|
275
|
+
client.join({
|
|
276
|
+
roomId: "room-1",
|
|
277
|
+
roomKey: "shared-key",
|
|
278
|
+
userId: "alice",
|
|
279
|
+
userName: "Ada",
|
|
280
|
+
}),
|
|
281
|
+
).rejects.toThrow("forbidden");
|
|
282
|
+
|
|
283
|
+
expect(connection.serverSocket.joinedRooms).toEqual([]);
|
|
284
|
+
|
|
285
|
+
stop();
|
|
286
|
+
connection.close();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("keeps namespaces isolated", async () => {
|
|
290
|
+
const namespace = new MockNamespace();
|
|
291
|
+
const roomTypeA = createRoomType("namespace-a");
|
|
292
|
+
const roomTypeB = createRoomType("namespace-b");
|
|
293
|
+
const handlersA = createBaseHandlers();
|
|
294
|
+
const handlersB = createBaseHandlers();
|
|
295
|
+
const { client: clientA, connection: connectionA, stop: stopA } = createClientPair(namespace, "a-socket", roomTypeA, handlersA);
|
|
296
|
+
const { client: clientB, connection: connectionB, stop: stopB } = createClientPair(namespace, "b-socket", roomTypeB, handlersB);
|
|
297
|
+
|
|
298
|
+
const roomA = await clientA.join({
|
|
299
|
+
roomId: "room-1",
|
|
300
|
+
roomKey: "shared-key",
|
|
301
|
+
userId: "alice",
|
|
302
|
+
userName: "Ada",
|
|
303
|
+
});
|
|
304
|
+
const roomB = await clientB.join({
|
|
305
|
+
roomId: "room-1",
|
|
306
|
+
roomKey: "shared-key",
|
|
307
|
+
userId: "bob",
|
|
308
|
+
userName: "Ben",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const seenA: string[] = [];
|
|
312
|
+
const seenB: string[] = [];
|
|
313
|
+
roomA.on.message((payload) => seenA.push(payload.text));
|
|
314
|
+
roomB.on.message((payload) => seenB.push(payload.text));
|
|
315
|
+
|
|
316
|
+
await roomA.rpc.sendMessage({ text: "only-a" });
|
|
317
|
+
expect(seenA).toEqual(["only-a"]);
|
|
318
|
+
expect(seenB).toEqual([]);
|
|
319
|
+
|
|
320
|
+
stopA();
|
|
321
|
+
stopB();
|
|
322
|
+
connectionA.close();
|
|
323
|
+
connectionB.close();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("reuses room state and initState only once", async () => {
|
|
327
|
+
const namespace = new MockNamespace();
|
|
328
|
+
const roomType = createRoomType("state-reuse");
|
|
329
|
+
let initCount = 0;
|
|
330
|
+
const handlers = createBaseHandlers({
|
|
331
|
+
initState: () => {
|
|
332
|
+
initCount += 1;
|
|
333
|
+
return {
|
|
334
|
+
roomKey: "shared-key",
|
|
335
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
336
|
+
history: [],
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
341
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
342
|
+
|
|
343
|
+
const aliceRoom = await first.client.join({
|
|
344
|
+
roomId: "room-1",
|
|
345
|
+
roomKey: "shared-key",
|
|
346
|
+
userId: "alice",
|
|
347
|
+
userName: "Ada",
|
|
348
|
+
});
|
|
349
|
+
const bobRoom = await second.client.join({
|
|
350
|
+
roomId: "room-1",
|
|
351
|
+
roomKey: "shared-key",
|
|
352
|
+
userId: "bob",
|
|
353
|
+
userName: "Ben",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
expect(initCount).toBe(1);
|
|
357
|
+
expect(aliceRoom.roomProfile.created).toBe("2026-03-23T00:00:00.000Z");
|
|
358
|
+
expect(bobRoom.roomProfile.created).toBe("2026-03-23T00:00:00.000Z");
|
|
359
|
+
|
|
360
|
+
first.stop();
|
|
361
|
+
second.stop();
|
|
362
|
+
first.connection.close();
|
|
363
|
+
second.connection.close();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("supports targeted delivery to specific members", async () => {
|
|
367
|
+
const namespace = new MockNamespace();
|
|
368
|
+
const roomType = createRoomType("targeted-delivery");
|
|
369
|
+
const handlers = createBaseHandlers();
|
|
370
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
371
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
372
|
+
|
|
373
|
+
const aliceRoom = await first.client.join({
|
|
374
|
+
roomId: "room-1",
|
|
375
|
+
roomKey: "shared-key",
|
|
376
|
+
userId: "alice",
|
|
377
|
+
userName: "Ada",
|
|
378
|
+
});
|
|
379
|
+
const bobRoom = await second.client.join({
|
|
380
|
+
roomId: "room-1",
|
|
381
|
+
roomKey: "shared-key",
|
|
382
|
+
userId: "bob",
|
|
383
|
+
userName: "Ben",
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const aliceNotices: string[] = [];
|
|
387
|
+
const bobNotices: string[] = [];
|
|
388
|
+
aliceRoom.on.privateNotice((payload) => aliceNotices.push(payload.text));
|
|
389
|
+
bobRoom.on.privateNotice((payload) => bobNotices.push(payload.text));
|
|
390
|
+
|
|
391
|
+
await bobRoom.rpc.whisper({
|
|
392
|
+
targetMemberId: "alice",
|
|
393
|
+
text: "private hello",
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(aliceNotices).toEqual(["private hello"]);
|
|
397
|
+
expect(bobNotices).toEqual([]);
|
|
398
|
+
|
|
399
|
+
first.stop();
|
|
400
|
+
second.stop();
|
|
401
|
+
first.connection.close();
|
|
402
|
+
second.connection.close();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("supports broadcast operators and custom adapter delivery", async () => {
|
|
406
|
+
const namespace = new MockNamespace();
|
|
407
|
+
const roomType = createRoomType("broadcast-operators");
|
|
408
|
+
const adapterCalls: Array<{ socketIds: string[]; eventName: string; payload: unknown }> = [];
|
|
409
|
+
const adapter: RoomServerAdapter = {
|
|
410
|
+
emitToSocketIds(socketIds, eventName, payload) {
|
|
411
|
+
adapterCalls.push({
|
|
412
|
+
socketIds: [...socketIds],
|
|
413
|
+
eventName,
|
|
414
|
+
payload,
|
|
415
|
+
});
|
|
416
|
+
for (const socketId of socketIds) {
|
|
417
|
+
namespace.to(socketId).emit(eventName, payload);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
const handlers = createBaseHandlers();
|
|
422
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers, adapter);
|
|
423
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers, adapter);
|
|
424
|
+
|
|
425
|
+
const aliceRoom = await first.client.join({
|
|
426
|
+
roomId: "room-1",
|
|
427
|
+
roomKey: "shared-key",
|
|
428
|
+
userId: "alice",
|
|
429
|
+
userName: "Ada",
|
|
430
|
+
});
|
|
431
|
+
const bobRoom = await second.client.join({
|
|
432
|
+
roomId: "room-1",
|
|
433
|
+
roomKey: "shared-key",
|
|
434
|
+
userId: "bob",
|
|
435
|
+
userName: "Ben",
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const aliceBroadcasts: string[] = [];
|
|
439
|
+
const bobBroadcasts: string[] = [];
|
|
440
|
+
const aliceRoomNotices: string[] = [];
|
|
441
|
+
const bobRoomNotices: string[] = [];
|
|
442
|
+
|
|
443
|
+
aliceRoom.on.broadcastNotice((payload) => aliceBroadcasts.push(payload.text));
|
|
444
|
+
bobRoom.on.broadcastNotice((payload) => bobBroadcasts.push(payload.text));
|
|
445
|
+
aliceRoom.on.roomNotice((payload) => aliceRoomNotices.push(payload.text));
|
|
446
|
+
bobRoom.on.roomNotice((payload) => bobRoomNotices.push(payload.text));
|
|
447
|
+
|
|
448
|
+
await aliceRoom.rpc.announce({ text: "namespace wide" });
|
|
449
|
+
expect(aliceBroadcasts).toEqual([]);
|
|
450
|
+
expect(bobBroadcasts).toEqual(["namespace wide"]);
|
|
451
|
+
expect(adapterCalls.at(-1)?.socketIds).toEqual(["bob-socket"]);
|
|
452
|
+
|
|
453
|
+
await bobRoom.rpc.announceRoom({ roomId: "room-1", text: "room scoped" });
|
|
454
|
+
expect(aliceRoomNotices).toEqual(["room scoped"]);
|
|
455
|
+
expect(bobRoomNotices).toEqual([]);
|
|
456
|
+
|
|
457
|
+
first.stop();
|
|
458
|
+
second.stop();
|
|
459
|
+
first.connection.close();
|
|
460
|
+
second.connection.close();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("reports presence counts and paginated member lists", async () => {
|
|
464
|
+
const namespace = new MockNamespace();
|
|
465
|
+
const roomType = createRoomType("presence-pages", "list");
|
|
466
|
+
const handlers = createBaseHandlers();
|
|
467
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
468
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
469
|
+
const third = createClientPair(namespace, "carol-socket", roomType, handlers);
|
|
470
|
+
|
|
471
|
+
const aliceRoom = await first.client.join({
|
|
472
|
+
roomId: "room-1",
|
|
473
|
+
roomKey: "shared-key",
|
|
474
|
+
userId: "alice",
|
|
475
|
+
userName: "Ada",
|
|
476
|
+
});
|
|
477
|
+
await second.client.join({
|
|
478
|
+
roomId: "room-1",
|
|
479
|
+
roomKey: "shared-key",
|
|
480
|
+
userId: "bob",
|
|
481
|
+
userName: "Ben",
|
|
482
|
+
});
|
|
483
|
+
await third.client.join({
|
|
484
|
+
roomId: "room-1",
|
|
485
|
+
roomKey: "shared-key",
|
|
486
|
+
userId: "carol",
|
|
487
|
+
userName: "Cid",
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
expect(await aliceRoom.presence.count()).toBe(3);
|
|
491
|
+
expect(await aliceRoom.presence.list({ offset: 0, limit: 2 })).toMatchObject({
|
|
492
|
+
count: 3,
|
|
493
|
+
offset: 0,
|
|
494
|
+
limit: 2,
|
|
495
|
+
members: [
|
|
496
|
+
{ memberId: "alice" },
|
|
497
|
+
{ memberId: "bob" },
|
|
498
|
+
],
|
|
499
|
+
});
|
|
500
|
+
expect(await aliceRoom.presence.list({ offset: 2, limit: 2 })).toMatchObject({
|
|
501
|
+
count: 3,
|
|
502
|
+
offset: 2,
|
|
503
|
+
limit: 2,
|
|
504
|
+
members: [
|
|
505
|
+
{ memberId: "carol" },
|
|
506
|
+
],
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
first.stop();
|
|
510
|
+
second.stop();
|
|
511
|
+
third.stop();
|
|
512
|
+
first.connection.close();
|
|
513
|
+
second.connection.close();
|
|
514
|
+
third.connection.close();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("dedupes presence counts for repeated member ids", async () => {
|
|
518
|
+
const namespace = new MockNamespace();
|
|
519
|
+
const roomType = createRoomType("presence-dedupe", "list");
|
|
520
|
+
const handlers = createBaseHandlers();
|
|
521
|
+
const first = createClientPair(namespace, "alice-socket-1", roomType, handlers);
|
|
522
|
+
const second = createClientPair(namespace, "alice-socket-2", roomType, handlers);
|
|
523
|
+
const third = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
524
|
+
|
|
525
|
+
const primaryRoom = await first.client.join({
|
|
526
|
+
roomId: "room-1",
|
|
527
|
+
roomKey: "shared-key",
|
|
528
|
+
userId: "alice",
|
|
529
|
+
userName: "Ada",
|
|
530
|
+
});
|
|
531
|
+
await second.client.join({
|
|
532
|
+
roomId: "room-1",
|
|
533
|
+
roomKey: "shared-key",
|
|
534
|
+
userId: "alice",
|
|
535
|
+
userName: "Ada",
|
|
536
|
+
});
|
|
537
|
+
await third.client.join({
|
|
538
|
+
roomId: "room-1",
|
|
539
|
+
roomKey: "shared-key",
|
|
540
|
+
userId: "bob",
|
|
541
|
+
userName: "Ben",
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
expect(await primaryRoom.presence.count()).toBe(2);
|
|
545
|
+
expect(await primaryRoom.presence.list()).toMatchObject({
|
|
546
|
+
count: 2,
|
|
547
|
+
members: [
|
|
548
|
+
{ memberId: "alice" },
|
|
549
|
+
{ memberId: "bob" },
|
|
550
|
+
],
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
first.stop();
|
|
554
|
+
second.stop();
|
|
555
|
+
third.stop();
|
|
556
|
+
first.connection.close();
|
|
557
|
+
second.connection.close();
|
|
558
|
+
third.connection.close();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("cleans up on leave and disconnect", async () => {
|
|
562
|
+
const namespace = new MockNamespace();
|
|
563
|
+
const roomType = createRoomType("cleanup");
|
|
564
|
+
const handlers = createBaseHandlers();
|
|
565
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
566
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
567
|
+
|
|
568
|
+
const aliceRoom = await first.client.join({
|
|
569
|
+
roomId: "room-1",
|
|
570
|
+
roomKey: "shared-key",
|
|
571
|
+
userId: "alice",
|
|
572
|
+
userName: "Ada",
|
|
573
|
+
});
|
|
574
|
+
const bobRoom = await second.client.join({
|
|
575
|
+
roomId: "room-1",
|
|
576
|
+
roomKey: "shared-key",
|
|
577
|
+
userId: "bob",
|
|
578
|
+
userName: "Ben",
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const seen: string[] = [];
|
|
582
|
+
aliceRoom.on.message((payload) => seen.push(payload.text));
|
|
583
|
+
|
|
584
|
+
await bobRoom.rpc.sendMessage({ text: "first" });
|
|
585
|
+
expect(seen).toEqual(["first"]);
|
|
586
|
+
|
|
587
|
+
await aliceRoom.leave();
|
|
588
|
+
await bobRoom.rpc.sendMessage({ text: "after-leave" });
|
|
589
|
+
expect(seen).toEqual(["first"]);
|
|
590
|
+
expect(first.connection.serverSocket.leftRooms).toEqual(["room-1"]);
|
|
591
|
+
|
|
592
|
+
const rejoinedAliceRoom = await first.client.join({
|
|
593
|
+
roomId: "room-1",
|
|
594
|
+
roomKey: "shared-key",
|
|
595
|
+
userId: "alice",
|
|
596
|
+
userName: "Ada",
|
|
597
|
+
});
|
|
598
|
+
const rejoinedSeen: string[] = [];
|
|
599
|
+
rejoinedAliceRoom.on.message((payload) => rejoinedSeen.push(payload.text));
|
|
600
|
+
first.connection.serverSocket.receive("disconnect", undefined);
|
|
601
|
+
|
|
602
|
+
await bobRoom.rpc.sendMessage({ text: "after-disconnect" });
|
|
603
|
+
expect(rejoinedSeen).toEqual([]);
|
|
604
|
+
|
|
605
|
+
first.stop();
|
|
606
|
+
second.stop();
|
|
607
|
+
first.connection.close();
|
|
608
|
+
second.connection.close();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("replays joined rooms after reconnect", async () => {
|
|
612
|
+
const namespace = new MockNamespace();
|
|
613
|
+
const roomType = createRoomType("reconnect");
|
|
614
|
+
const handlers = createBaseHandlers();
|
|
615
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
616
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
617
|
+
|
|
618
|
+
const aliceRoom = await first.client.join({
|
|
619
|
+
roomId: "room-1",
|
|
620
|
+
roomKey: "shared-key",
|
|
621
|
+
userId: "alice",
|
|
622
|
+
userName: "Ada",
|
|
623
|
+
});
|
|
624
|
+
const bobRoom = await second.client.join({
|
|
625
|
+
roomId: "room-1",
|
|
626
|
+
roomKey: "shared-key",
|
|
627
|
+
userId: "bob",
|
|
628
|
+
userName: "Ben",
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const seen: string[] = [];
|
|
632
|
+
aliceRoom.on.message((payload) => seen.push(payload.text));
|
|
633
|
+
|
|
634
|
+
await bobRoom.rpc.sendMessage({ text: "before reconnect" });
|
|
635
|
+
expect(seen).toEqual(["before reconnect"]);
|
|
636
|
+
|
|
637
|
+
first.connection.serverSocket.receive("disconnect", undefined);
|
|
638
|
+
first.connection.clientSocket.receive("connect", undefined);
|
|
639
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
640
|
+
|
|
641
|
+
await bobRoom.rpc.sendMessage({ text: "after reconnect" });
|
|
642
|
+
expect(seen).toEqual(["before reconnect", "after reconnect"]);
|
|
643
|
+
expect(first.connection.serverSocket.joinedRooms).toEqual(["room-1", "room-1"]);
|
|
644
|
+
|
|
645
|
+
first.stop();
|
|
646
|
+
second.stop();
|
|
647
|
+
first.connection.close();
|
|
648
|
+
second.connection.close();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("exposes server room snapshots and member pages", async () => {
|
|
652
|
+
const namespace = new MockNamespace();
|
|
653
|
+
const roomType = createRoomType("introspection", "list");
|
|
654
|
+
const handlers = createBaseHandlers();
|
|
655
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
656
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
657
|
+
|
|
658
|
+
const serverHandle = first.stop;
|
|
659
|
+
await first.client.join({
|
|
660
|
+
roomId: "room-1",
|
|
661
|
+
roomKey: "shared-key",
|
|
662
|
+
userId: "alice",
|
|
663
|
+
userName: "Ada",
|
|
664
|
+
});
|
|
665
|
+
await second.client.join({
|
|
666
|
+
roomId: "room-1",
|
|
667
|
+
roomKey: "shared-key",
|
|
668
|
+
userId: "bob",
|
|
669
|
+
userName: "Ben",
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
expect(serverHandle.count("room-1")).toBe(2);
|
|
673
|
+
expect(serverHandle.rooms()).toHaveLength(1);
|
|
674
|
+
expect(serverHandle.room("room-1")).toMatchObject({
|
|
675
|
+
roomId: "room-1",
|
|
676
|
+
memberCount: 2,
|
|
677
|
+
presence: {
|
|
678
|
+
count: 2,
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
expect(serverHandle.members("room-1", { offset: 0, limit: 1 })).toMatchObject({
|
|
682
|
+
count: 2,
|
|
683
|
+
offset: 0,
|
|
684
|
+
limit: 1,
|
|
685
|
+
members: [
|
|
686
|
+
{ memberId: "alice" },
|
|
687
|
+
],
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
first.stop();
|
|
691
|
+
second.stop();
|
|
692
|
+
first.connection.close();
|
|
693
|
+
second.connection.close();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("includes source metadata for server and member events", async () => {
|
|
697
|
+
const namespace = new MockNamespace();
|
|
698
|
+
const roomType = createRoomType("source-meta", "list");
|
|
699
|
+
const handlers = createBaseHandlers();
|
|
700
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
701
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
702
|
+
|
|
703
|
+
const aliceRoom = await first.client.join({
|
|
704
|
+
roomId: "room-1",
|
|
705
|
+
roomKey: "shared-key",
|
|
706
|
+
userId: "alice",
|
|
707
|
+
userName: "Ada",
|
|
708
|
+
});
|
|
709
|
+
const bobRoom = await second.client.join({
|
|
710
|
+
roomId: "room-1",
|
|
711
|
+
roomKey: "shared-key",
|
|
712
|
+
userId: "bob",
|
|
713
|
+
userName: "Ben",
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const sources: Array<{ text: string; sourceKind: string; memberId?: string }> = [];
|
|
717
|
+
aliceRoom.on.relay((payload, meta) => {
|
|
718
|
+
sources.push({
|
|
719
|
+
text: payload.text,
|
|
720
|
+
sourceKind: meta.source.kind,
|
|
721
|
+
memberId: meta.source.kind === "member" ? meta.source.memberId : undefined,
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
aliceRoom.on.message((payload, meta) => {
|
|
725
|
+
sources.push({
|
|
726
|
+
text: payload.text,
|
|
727
|
+
sourceKind: meta.source.kind,
|
|
728
|
+
memberId: meta.source.kind === "member" ? meta.source.memberId : undefined,
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
await aliceRoom.emit.relay({ text: "from-alice" });
|
|
733
|
+
await bobRoom.rpc.sendMessage({ text: "from-server" });
|
|
734
|
+
|
|
735
|
+
expect(sources).toEqual([
|
|
736
|
+
{
|
|
737
|
+
text: "from-alice",
|
|
738
|
+
sourceKind: "member",
|
|
739
|
+
memberId: "alice",
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
text: "from-server",
|
|
743
|
+
sourceKind: "server",
|
|
744
|
+
},
|
|
745
|
+
]);
|
|
746
|
+
|
|
747
|
+
first.stop();
|
|
748
|
+
second.stop();
|
|
749
|
+
first.connection.close();
|
|
750
|
+
second.connection.close();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("rejects mismatched room ids", async () => {
|
|
754
|
+
const namespace = new MockNamespace();
|
|
755
|
+
const roomType = createRoomType("mismatched-room-id");
|
|
756
|
+
const handlers = createBaseHandlers({
|
|
757
|
+
admit: async (join) => ({
|
|
758
|
+
roomId: `${join.roomId}-other`,
|
|
759
|
+
memberId: join.userId,
|
|
760
|
+
memberProfile: {
|
|
761
|
+
userId: join.userId,
|
|
762
|
+
userName: join.userName,
|
|
763
|
+
},
|
|
764
|
+
roomProfile: {
|
|
765
|
+
roomId: `${join.roomId}-other`,
|
|
766
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
767
|
+
},
|
|
768
|
+
}),
|
|
769
|
+
});
|
|
770
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
771
|
+
|
|
772
|
+
await expect(
|
|
773
|
+
client.join({
|
|
774
|
+
roomId: "room-1",
|
|
775
|
+
roomKey: "shared-key",
|
|
776
|
+
userId: "alice",
|
|
777
|
+
userName: "Ada",
|
|
778
|
+
}),
|
|
779
|
+
).rejects.toThrow("Admission roomId must match join request roomId");
|
|
780
|
+
|
|
781
|
+
stop();
|
|
782
|
+
connection.close();
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("rejects when onAuth fails", async () => {
|
|
786
|
+
const namespace = new MockNamespace();
|
|
787
|
+
const roomType = createRoomType("reject-auth", "list");
|
|
788
|
+
const handlers: RoomServerHandlers<typeof roomType, { userId: string }> = {
|
|
789
|
+
onAuth: async () => {
|
|
790
|
+
throw new ClientSafeError("unauthorized");
|
|
791
|
+
},
|
|
792
|
+
initState: async () => ({
|
|
793
|
+
roomKey: "shared-key",
|
|
794
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
795
|
+
history: [],
|
|
796
|
+
}),
|
|
797
|
+
admit: async () => {
|
|
798
|
+
throw new ClientSafeError("should not admit");
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
802
|
+
|
|
803
|
+
await expect(
|
|
804
|
+
client.join({
|
|
805
|
+
roomId: "room-1",
|
|
806
|
+
roomKey: "shared-key",
|
|
807
|
+
userId: "alice",
|
|
808
|
+
userName: "Ada",
|
|
809
|
+
}),
|
|
810
|
+
).rejects.toThrow("unauthorized");
|
|
811
|
+
|
|
812
|
+
stop();
|
|
813
|
+
connection.close();
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("calls onAuth once and exposes auth in handlers", async () => {
|
|
817
|
+
const namespace = new MockNamespace();
|
|
818
|
+
type Auth = {
|
|
819
|
+
userId: string;
|
|
820
|
+
token: string;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
type AuthRoomSchema = {
|
|
824
|
+
joinRequest: {
|
|
825
|
+
roomId: string;
|
|
826
|
+
roomKey: string;
|
|
827
|
+
userName: string;
|
|
828
|
+
};
|
|
829
|
+
memberProfile: {
|
|
830
|
+
userId: string;
|
|
831
|
+
userName: string;
|
|
832
|
+
};
|
|
833
|
+
roomProfile: {
|
|
834
|
+
roomId: string;
|
|
835
|
+
created: string;
|
|
836
|
+
};
|
|
837
|
+
serverState: {
|
|
838
|
+
roomKey: string;
|
|
839
|
+
created: string;
|
|
840
|
+
};
|
|
841
|
+
events: {
|
|
842
|
+
message: { text: string };
|
|
843
|
+
};
|
|
844
|
+
rpc: {
|
|
845
|
+
sendMessage: (input: { text: string }) => Promise<void>;
|
|
846
|
+
};
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const roomType = defineRoomType<AuthRoomSchema, "count">({ name: "auth-hooks", presence: "count" });
|
|
850
|
+
let authCalls = 0;
|
|
851
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
852
|
+
onAuth: async () => {
|
|
853
|
+
authCalls += 1;
|
|
854
|
+
return {
|
|
855
|
+
userId: "alice",
|
|
856
|
+
token: "trusted-token",
|
|
857
|
+
};
|
|
858
|
+
},
|
|
859
|
+
initState: async () => ({
|
|
860
|
+
roomKey: "shared-key",
|
|
861
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
862
|
+
}),
|
|
863
|
+
admit: async (join, ctx) => {
|
|
864
|
+
expect(ctx.auth).toEqual({
|
|
865
|
+
userId: "alice",
|
|
866
|
+
token: "trusted-token",
|
|
867
|
+
});
|
|
868
|
+
expect(join.userName).toBe("Ada");
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
roomId: join.roomId,
|
|
872
|
+
memberId: ctx.auth.userId,
|
|
873
|
+
memberProfile: {
|
|
874
|
+
userId: ctx.auth.userId,
|
|
875
|
+
userName: join.userName,
|
|
876
|
+
},
|
|
877
|
+
roomProfile: {
|
|
878
|
+
roomId: join.roomId,
|
|
879
|
+
created: ctx.serverState.created,
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
},
|
|
883
|
+
rpc: {
|
|
884
|
+
sendMessage: async ({ text }, ctx) => {
|
|
885
|
+
expect(ctx.auth).toEqual({
|
|
886
|
+
userId: "alice",
|
|
887
|
+
token: "trusted-token",
|
|
888
|
+
});
|
|
889
|
+
expect(text).toBe("hello");
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
895
|
+
|
|
896
|
+
const firstRoom = await client.join({
|
|
897
|
+
roomId: "room-1",
|
|
898
|
+
roomKey: "shared-key",
|
|
899
|
+
userName: "Ada",
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
await firstRoom.rpc.sendMessage({ text: "hello" });
|
|
903
|
+
|
|
904
|
+
await client.join({
|
|
905
|
+
roomId: "room-2",
|
|
906
|
+
roomKey: "shared-key",
|
|
907
|
+
userName: "Ada",
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
expect(authCalls).toBe(1);
|
|
911
|
+
|
|
912
|
+
stop();
|
|
913
|
+
connection.close();
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("calls onConnect once with resolved auth", async () => {
|
|
917
|
+
const namespace = new MockNamespace();
|
|
918
|
+
type Auth = {
|
|
919
|
+
userId: string;
|
|
920
|
+
};
|
|
921
|
+
const roomType = createRoomType("on-connect", "count");
|
|
922
|
+
const connected: Array<{ socketId: string; auth: Auth }> = [];
|
|
923
|
+
let authCalls = 0;
|
|
924
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
925
|
+
onAuth: async () => {
|
|
926
|
+
authCalls += 1;
|
|
927
|
+
return { userId: "alice" };
|
|
928
|
+
},
|
|
929
|
+
onConnect: (socket, auth) => {
|
|
930
|
+
connected.push({
|
|
931
|
+
socketId: socket.id,
|
|
932
|
+
auth,
|
|
933
|
+
});
|
|
934
|
+
},
|
|
935
|
+
admit: async (join, ctx) => ({
|
|
936
|
+
roomId: join.roomId,
|
|
937
|
+
memberId: ctx.auth.userId,
|
|
938
|
+
memberProfile: {
|
|
939
|
+
userId: ctx.auth.userId,
|
|
940
|
+
userName: join.userName,
|
|
941
|
+
},
|
|
942
|
+
roomProfile: {
|
|
943
|
+
roomId: join.roomId,
|
|
944
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
945
|
+
},
|
|
946
|
+
}),
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
const { connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
950
|
+
|
|
951
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
952
|
+
|
|
953
|
+
expect(connected).toEqual([
|
|
954
|
+
{
|
|
955
|
+
socketId: "alice-socket",
|
|
956
|
+
auth: { userId: "alice" },
|
|
957
|
+
},
|
|
958
|
+
]);
|
|
959
|
+
expect(authCalls).toBe(1);
|
|
960
|
+
|
|
961
|
+
stop();
|
|
962
|
+
connection.close();
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it("calls onDisconnect once with resolved auth", async () => {
|
|
966
|
+
const namespace = new MockNamespace();
|
|
967
|
+
type Auth = {
|
|
968
|
+
userId: string;
|
|
969
|
+
};
|
|
970
|
+
const roomType = createRoomType("on-disconnect", "count");
|
|
971
|
+
const disconnected: Array<{ socketId: string; auth: Auth }> = [];
|
|
972
|
+
let authCalls = 0;
|
|
973
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
974
|
+
onAuth: async () => {
|
|
975
|
+
authCalls += 1;
|
|
976
|
+
return { userId: "alice" };
|
|
977
|
+
},
|
|
978
|
+
onDisconnect: (socket, auth) => {
|
|
979
|
+
disconnected.push({
|
|
980
|
+
socketId: socket.id,
|
|
981
|
+
auth,
|
|
982
|
+
});
|
|
983
|
+
},
|
|
984
|
+
admit: async (join, ctx) => ({
|
|
985
|
+
roomId: join.roomId,
|
|
986
|
+
memberId: ctx.auth.userId,
|
|
987
|
+
memberProfile: {
|
|
988
|
+
userId: ctx.auth.userId,
|
|
989
|
+
userName: join.userName,
|
|
990
|
+
},
|
|
991
|
+
roomProfile: {
|
|
992
|
+
roomId: join.roomId,
|
|
993
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
994
|
+
},
|
|
995
|
+
}),
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
999
|
+
await client.join({
|
|
1000
|
+
roomId: "room-1",
|
|
1001
|
+
roomKey: "shared-key",
|
|
1002
|
+
userId: "alice",
|
|
1003
|
+
userName: "Ada",
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
connection.serverSocket.receive("disconnect", undefined);
|
|
1007
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1008
|
+
|
|
1009
|
+
expect(disconnected).toEqual([
|
|
1010
|
+
{
|
|
1011
|
+
socketId: "alice-socket",
|
|
1012
|
+
auth: { userId: "alice" },
|
|
1013
|
+
},
|
|
1014
|
+
]);
|
|
1015
|
+
expect(authCalls).toBe(1);
|
|
1016
|
+
|
|
1017
|
+
stop();
|
|
1018
|
+
connection.close();
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it("supports per-request auth revalidation", async () => {
|
|
1022
|
+
const namespace = new MockNamespace();
|
|
1023
|
+
type Auth = {
|
|
1024
|
+
userId: string;
|
|
1025
|
+
sessionVersion: number;
|
|
1026
|
+
};
|
|
1027
|
+
const roomType = createRoomType("auth-revalidate", "count");
|
|
1028
|
+
let authCalls = 0;
|
|
1029
|
+
let revalidateCalls = 0;
|
|
1030
|
+
let revoked = false;
|
|
1031
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1032
|
+
onAuth: async () => {
|
|
1033
|
+
authCalls += 1;
|
|
1034
|
+
return {
|
|
1035
|
+
userId: "alice",
|
|
1036
|
+
sessionVersion: 1,
|
|
1037
|
+
};
|
|
1038
|
+
},
|
|
1039
|
+
revalidateAuth: async () => {
|
|
1040
|
+
revalidateCalls += 1;
|
|
1041
|
+
if (revoked) {
|
|
1042
|
+
return {
|
|
1043
|
+
kind: "reject",
|
|
1044
|
+
message: "session expired",
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
kind: "ok",
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
1052
|
+
admit: async (join, ctx) => ({
|
|
1053
|
+
roomId: join.roomId,
|
|
1054
|
+
memberId: ctx.auth.userId,
|
|
1055
|
+
memberProfile: {
|
|
1056
|
+
userId: ctx.auth.userId,
|
|
1057
|
+
userName: join.userName,
|
|
1058
|
+
},
|
|
1059
|
+
roomProfile: {
|
|
1060
|
+
roomId: join.roomId,
|
|
1061
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
1062
|
+
},
|
|
1063
|
+
}),
|
|
1064
|
+
rpc: {
|
|
1065
|
+
sendMessage: async () => {
|
|
1066
|
+
return {
|
|
1067
|
+
id: "message-1",
|
|
1068
|
+
historySize: 1,
|
|
1069
|
+
};
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1072
|
+
};
|
|
1073
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1074
|
+
|
|
1075
|
+
const joined = await client.join({
|
|
1076
|
+
roomId: "room-1",
|
|
1077
|
+
roomKey: "shared-key",
|
|
1078
|
+
userId: "alice",
|
|
1079
|
+
userName: "Ada",
|
|
1080
|
+
});
|
|
1081
|
+
expect(authCalls).toBe(1);
|
|
1082
|
+
expect(revalidateCalls).toBe(1);
|
|
1083
|
+
|
|
1084
|
+
revoked = true;
|
|
1085
|
+
await expect(joined.rpc.sendMessage({ text: "hello" })).rejects.toThrow("session expired");
|
|
1086
|
+
expect(revalidateCalls).toBe(2);
|
|
1087
|
+
|
|
1088
|
+
stop();
|
|
1089
|
+
connection.close();
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it("supports auth rotation via revalidateAuth", async () => {
|
|
1093
|
+
const namespace = new MockNamespace();
|
|
1094
|
+
type Auth = {
|
|
1095
|
+
userId: string;
|
|
1096
|
+
version: number;
|
|
1097
|
+
};
|
|
1098
|
+
const roomType = createRoomType("auth-revalidate-rotation", "count");
|
|
1099
|
+
let authCalls = 0;
|
|
1100
|
+
let revalidateCalls = 0;
|
|
1101
|
+
let observedRpcAuthVersion = 0;
|
|
1102
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1103
|
+
onAuth: async () => {
|
|
1104
|
+
authCalls += 1;
|
|
1105
|
+
return {
|
|
1106
|
+
userId: "alice",
|
|
1107
|
+
version: 1,
|
|
1108
|
+
};
|
|
1109
|
+
},
|
|
1110
|
+
revalidateAuth: async (_socket, auth) => {
|
|
1111
|
+
revalidateCalls += 1;
|
|
1112
|
+
return {
|
|
1113
|
+
kind: "ok",
|
|
1114
|
+
auth: {
|
|
1115
|
+
...auth,
|
|
1116
|
+
version: auth.version + 1,
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
},
|
|
1120
|
+
admit: async (join, ctx) => ({
|
|
1121
|
+
roomId: join.roomId,
|
|
1122
|
+
memberId: ctx.auth.userId,
|
|
1123
|
+
memberProfile: {
|
|
1124
|
+
userId: ctx.auth.userId,
|
|
1125
|
+
userName: join.userName,
|
|
1126
|
+
},
|
|
1127
|
+
roomProfile: {
|
|
1128
|
+
roomId: join.roomId,
|
|
1129
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
1130
|
+
},
|
|
1131
|
+
}),
|
|
1132
|
+
rpc: {
|
|
1133
|
+
sendMessage: async (_input, ctx) => {
|
|
1134
|
+
observedRpcAuthVersion = ctx.auth.version;
|
|
1135
|
+
return {
|
|
1136
|
+
id: "message-1",
|
|
1137
|
+
historySize: 1,
|
|
1138
|
+
};
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
};
|
|
1142
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1143
|
+
|
|
1144
|
+
const joined = await client.join({
|
|
1145
|
+
roomId: "room-1",
|
|
1146
|
+
roomKey: "shared-key",
|
|
1147
|
+
userId: "alice",
|
|
1148
|
+
userName: "Ada",
|
|
1149
|
+
});
|
|
1150
|
+
await joined.rpc.sendMessage({ text: "hello" });
|
|
1151
|
+
|
|
1152
|
+
expect(authCalls).toBe(1);
|
|
1153
|
+
expect(revalidateCalls).toBe(2);
|
|
1154
|
+
expect(observedRpcAuthVersion).toBe(3);
|
|
1155
|
+
|
|
1156
|
+
stop();
|
|
1157
|
+
connection.close();
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
it("runs revalidateAuth on each presence query", async () => {
|
|
1161
|
+
const namespace = new MockNamespace();
|
|
1162
|
+
type Auth = {
|
|
1163
|
+
userId: string;
|
|
1164
|
+
};
|
|
1165
|
+
const roomType = createRoomType("auth-revalidate-presence", "list");
|
|
1166
|
+
let revalidateCalls = 0;
|
|
1167
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1168
|
+
...createBaseHandlers(),
|
|
1169
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1170
|
+
revalidateAuth: async () => {
|
|
1171
|
+
revalidateCalls += 1;
|
|
1172
|
+
return { kind: "ok" };
|
|
1173
|
+
},
|
|
1174
|
+
};
|
|
1175
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1176
|
+
const room = await pair.client.join({
|
|
1177
|
+
roomId: "room-1",
|
|
1178
|
+
roomKey: "shared-key",
|
|
1179
|
+
userId: "alice",
|
|
1180
|
+
userName: "Ada",
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
await room.presence.count();
|
|
1184
|
+
await room.presence.list({ offset: 0, limit: 10 });
|
|
1185
|
+
|
|
1186
|
+
expect(revalidateCalls).toBe(3);
|
|
1187
|
+
|
|
1188
|
+
pair.stop();
|
|
1189
|
+
pair.connection.close();
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it("blocks client events when revalidateAuth rejects", async () => {
|
|
1193
|
+
const namespace = new MockNamespace();
|
|
1194
|
+
type Auth = {
|
|
1195
|
+
userId: string;
|
|
1196
|
+
};
|
|
1197
|
+
const roomType = createRoomType("auth-revalidate-client-event", "count");
|
|
1198
|
+
let revoked = false;
|
|
1199
|
+
let relayCalls = 0;
|
|
1200
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1201
|
+
...createBaseHandlers(),
|
|
1202
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1203
|
+
revalidateAuth: async () => {
|
|
1204
|
+
if (revoked) {
|
|
1205
|
+
return {
|
|
1206
|
+
kind: "reject",
|
|
1207
|
+
message: "session expired",
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
kind: "ok",
|
|
1212
|
+
};
|
|
1213
|
+
},
|
|
1214
|
+
events: {
|
|
1215
|
+
relay: async () => {
|
|
1216
|
+
relayCalls += 1;
|
|
1217
|
+
},
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1221
|
+
const room = await pair.client.join({
|
|
1222
|
+
roomId: "room-1",
|
|
1223
|
+
roomKey: "shared-key",
|
|
1224
|
+
userId: "alice",
|
|
1225
|
+
userName: "Ada",
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
revoked = true;
|
|
1229
|
+
await expect(room.emit.relay({ text: "blocked" })).rejects.toThrow("session expired");
|
|
1230
|
+
expect(relayCalls).toBe(0);
|
|
1231
|
+
|
|
1232
|
+
pair.stop();
|
|
1233
|
+
pair.connection.close();
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
it("runs onDisconnect before per-room onLeave callbacks", async () => {
|
|
1237
|
+
const namespace = new MockNamespace();
|
|
1238
|
+
type Auth = {
|
|
1239
|
+
userId: string;
|
|
1240
|
+
};
|
|
1241
|
+
const roomType = createRoomType("disconnect-ordering", "count");
|
|
1242
|
+
const sequence: string[] = [];
|
|
1243
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1244
|
+
...createBaseHandlers(),
|
|
1245
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1246
|
+
onDisconnect: async () => {
|
|
1247
|
+
sequence.push("onDisconnect");
|
|
1248
|
+
},
|
|
1249
|
+
onLeave: async (member) => {
|
|
1250
|
+
sequence.push(`onLeave:${member.userId}`);
|
|
1251
|
+
},
|
|
1252
|
+
};
|
|
1253
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1254
|
+
await pair.client.join({
|
|
1255
|
+
roomId: "room-1",
|
|
1256
|
+
roomKey: "shared-key",
|
|
1257
|
+
userId: "alice",
|
|
1258
|
+
userName: "Ada",
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
pair.connection.serverSocket.receive("disconnect", undefined);
|
|
1262
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1263
|
+
|
|
1264
|
+
expect(sequence).toEqual(["onDisconnect", "onLeave:alice"]);
|
|
1265
|
+
|
|
1266
|
+
pair.stop();
|
|
1267
|
+
pair.connection.close();
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
it("keeps disconnect cleanup active when onDisconnect throws", async () => {
|
|
1271
|
+
const namespace = new MockNamespace();
|
|
1272
|
+
type Auth = {
|
|
1273
|
+
userId: string;
|
|
1274
|
+
};
|
|
1275
|
+
const roomType = createRoomType("disconnect-error-isolation", "list");
|
|
1276
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1277
|
+
...createBaseHandlers(),
|
|
1278
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1279
|
+
onDisconnect: async () => {
|
|
1280
|
+
throw new Error("disconnect observer failed");
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1283
|
+
const alice = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1284
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
1285
|
+
|
|
1286
|
+
await alice.client.join({
|
|
1287
|
+
roomId: "room-1",
|
|
1288
|
+
roomKey: "shared-key",
|
|
1289
|
+
userId: "alice",
|
|
1290
|
+
userName: "Ada",
|
|
1291
|
+
});
|
|
1292
|
+
const bobRoom = await bob.client.join({
|
|
1293
|
+
roomId: "room-1",
|
|
1294
|
+
roomKey: "shared-key",
|
|
1295
|
+
userId: "bob",
|
|
1296
|
+
userName: "Ben",
|
|
1297
|
+
});
|
|
1298
|
+
expect(await bobRoom.presence.count()).toBe(2);
|
|
1299
|
+
|
|
1300
|
+
alice.connection.serverSocket.receive("disconnect", undefined);
|
|
1301
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1302
|
+
|
|
1303
|
+
expect(await bobRoom.presence.count()).toBe(1);
|
|
1304
|
+
expect(bob.stop.count("room-1")).toBe(1);
|
|
1305
|
+
|
|
1306
|
+
alice.stop();
|
|
1307
|
+
bob.stop();
|
|
1308
|
+
alice.connection.close();
|
|
1309
|
+
bob.connection.close();
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it("does not block room operations when onConnect throws", async () => {
|
|
1313
|
+
const namespace = new MockNamespace();
|
|
1314
|
+
type Auth = {
|
|
1315
|
+
userId: string;
|
|
1316
|
+
};
|
|
1317
|
+
const roomType = createRoomType("on-connect-error", "count");
|
|
1318
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1319
|
+
...createBaseHandlers(),
|
|
1320
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1321
|
+
onConnect: async () => {
|
|
1322
|
+
throw new Error("connect hook failed");
|
|
1323
|
+
},
|
|
1324
|
+
};
|
|
1325
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1326
|
+
|
|
1327
|
+
const room = await pair.client.join({
|
|
1328
|
+
roomId: "room-1",
|
|
1329
|
+
roomKey: "shared-key",
|
|
1330
|
+
userId: "alice",
|
|
1331
|
+
userName: "Ada",
|
|
1332
|
+
});
|
|
1333
|
+
await expect(room.rpc.sendMessage({ text: "still-works" })).resolves.toMatchObject({
|
|
1334
|
+
id: "message-1",
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
pair.stop();
|
|
1338
|
+
pair.connection.close();
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it("clears auth cache after revalidateAuth rejection and resolves auth again", async () => {
|
|
1342
|
+
const namespace = new MockNamespace();
|
|
1343
|
+
type Auth = {
|
|
1344
|
+
userId: string;
|
|
1345
|
+
token: string;
|
|
1346
|
+
};
|
|
1347
|
+
const roomType = createRoomType("auth-cache-reset-after-reject", "count");
|
|
1348
|
+
let authCalls = 0;
|
|
1349
|
+
let rejectNext = false;
|
|
1350
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1351
|
+
onAuth: async () => {
|
|
1352
|
+
authCalls += 1;
|
|
1353
|
+
return {
|
|
1354
|
+
userId: "alice",
|
|
1355
|
+
token: `token-${authCalls}`,
|
|
1356
|
+
};
|
|
1357
|
+
},
|
|
1358
|
+
revalidateAuth: async () => {
|
|
1359
|
+
if (rejectNext) {
|
|
1360
|
+
rejectNext = false;
|
|
1361
|
+
return {
|
|
1362
|
+
kind: "reject",
|
|
1363
|
+
message: "session expired",
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
return {
|
|
1367
|
+
kind: "ok",
|
|
1368
|
+
};
|
|
1369
|
+
},
|
|
1370
|
+
admit: async (join, ctx) => ({
|
|
1371
|
+
roomId: join.roomId,
|
|
1372
|
+
memberId: ctx.auth.userId,
|
|
1373
|
+
memberProfile: {
|
|
1374
|
+
userId: ctx.auth.userId,
|
|
1375
|
+
userName: join.userName,
|
|
1376
|
+
},
|
|
1377
|
+
roomProfile: {
|
|
1378
|
+
roomId: join.roomId,
|
|
1379
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
1380
|
+
},
|
|
1381
|
+
}),
|
|
1382
|
+
rpc: {
|
|
1383
|
+
sendMessage: async () => ({
|
|
1384
|
+
id: "message-1",
|
|
1385
|
+
historySize: 1,
|
|
1386
|
+
}),
|
|
1387
|
+
},
|
|
1388
|
+
};
|
|
1389
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1390
|
+
const room = await pair.client.join({
|
|
1391
|
+
roomId: "room-1",
|
|
1392
|
+
roomKey: "shared-key",
|
|
1393
|
+
userId: "alice",
|
|
1394
|
+
userName: "Ada",
|
|
1395
|
+
});
|
|
1396
|
+
expect(authCalls).toBe(1);
|
|
1397
|
+
|
|
1398
|
+
rejectNext = true;
|
|
1399
|
+
await expect(room.rpc.sendMessage({ text: "blocked" })).rejects.toThrow("session expired");
|
|
1400
|
+
await expect(room.rpc.sendMessage({ text: "after-refresh" })).resolves.toMatchObject({
|
|
1401
|
+
id: "message-1",
|
|
1402
|
+
});
|
|
1403
|
+
expect(authCalls).toBe(2);
|
|
1404
|
+
|
|
1405
|
+
pair.stop();
|
|
1406
|
+
pair.connection.close();
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it("calls onDisconnect once and onLeave per room for multi-room sockets", async () => {
|
|
1410
|
+
const namespace = new MockNamespace();
|
|
1411
|
+
type Auth = {
|
|
1412
|
+
userId: string;
|
|
1413
|
+
};
|
|
1414
|
+
const roomType = createRoomType("multi-room-disconnect-hooks", "count");
|
|
1415
|
+
let disconnectCalls = 0;
|
|
1416
|
+
const leftRoomIds: string[] = [];
|
|
1417
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1418
|
+
...createBaseHandlers(),
|
|
1419
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1420
|
+
onDisconnect: async () => {
|
|
1421
|
+
disconnectCalls += 1;
|
|
1422
|
+
},
|
|
1423
|
+
onLeave: async (_member, ctx) => {
|
|
1424
|
+
leftRoomIds.push(ctx.roomId);
|
|
1425
|
+
},
|
|
1426
|
+
};
|
|
1427
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1428
|
+
await pair.client.join({
|
|
1429
|
+
roomId: "room-1",
|
|
1430
|
+
roomKey: "shared-key",
|
|
1431
|
+
userId: "alice",
|
|
1432
|
+
userName: "Ada",
|
|
1433
|
+
});
|
|
1434
|
+
await pair.client.join({
|
|
1435
|
+
roomId: "room-2",
|
|
1436
|
+
roomKey: "shared-key",
|
|
1437
|
+
userId: "alice",
|
|
1438
|
+
userName: "Ada",
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
pair.connection.serverSocket.receive("disconnect", undefined);
|
|
1442
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1443
|
+
|
|
1444
|
+
expect(disconnectCalls).toBe(1);
|
|
1445
|
+
expect(leftRoomIds.slice().sort()).toEqual(["room-1", "room-2"]);
|
|
1446
|
+
|
|
1447
|
+
pair.stop();
|
|
1448
|
+
pair.connection.close();
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it("passes rotated auth to onDisconnect", async () => {
|
|
1452
|
+
const namespace = new MockNamespace();
|
|
1453
|
+
type Auth = {
|
|
1454
|
+
userId: string;
|
|
1455
|
+
version: number;
|
|
1456
|
+
};
|
|
1457
|
+
const roomType = createRoomType("disconnect-rotated-auth", "count");
|
|
1458
|
+
let disconnectVersion = 0;
|
|
1459
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1460
|
+
...createBaseHandlers(),
|
|
1461
|
+
onAuth: async () => ({
|
|
1462
|
+
userId: "alice",
|
|
1463
|
+
version: 1,
|
|
1464
|
+
}),
|
|
1465
|
+
revalidateAuth: async (_socket, auth) => ({
|
|
1466
|
+
kind: "ok",
|
|
1467
|
+
auth: {
|
|
1468
|
+
...auth,
|
|
1469
|
+
version: auth.version + 1,
|
|
1470
|
+
},
|
|
1471
|
+
}),
|
|
1472
|
+
onDisconnect: async (_socket, auth) => {
|
|
1473
|
+
disconnectVersion = auth.version;
|
|
1474
|
+
},
|
|
1475
|
+
};
|
|
1476
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1477
|
+
const room = await pair.client.join({
|
|
1478
|
+
roomId: "room-1",
|
|
1479
|
+
roomKey: "shared-key",
|
|
1480
|
+
userId: "alice",
|
|
1481
|
+
userName: "Ada",
|
|
1482
|
+
});
|
|
1483
|
+
await room.rpc.sendMessage({ text: "before-disconnect" });
|
|
1484
|
+
|
|
1485
|
+
pair.connection.serverSocket.receive("disconnect", undefined);
|
|
1486
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1487
|
+
|
|
1488
|
+
expect(disconnectVersion).toBe(4);
|
|
1489
|
+
|
|
1490
|
+
pair.stop();
|
|
1491
|
+
pair.connection.close();
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
it("rejects presence.list when revalidateAuth rejects", async () => {
|
|
1495
|
+
const namespace = new MockNamespace();
|
|
1496
|
+
type Auth = {
|
|
1497
|
+
userId: string;
|
|
1498
|
+
};
|
|
1499
|
+
const roomType = createRoomType("presence-list-revalidate-reject", "list");
|
|
1500
|
+
let revoked = false;
|
|
1501
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1502
|
+
...createBaseHandlers(),
|
|
1503
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1504
|
+
revalidateAuth: async () => {
|
|
1505
|
+
if (revoked) {
|
|
1506
|
+
return {
|
|
1507
|
+
kind: "reject",
|
|
1508
|
+
message: "presence list blocked",
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
return {
|
|
1512
|
+
kind: "ok",
|
|
1513
|
+
};
|
|
1514
|
+
},
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1518
|
+
const room = await pair.client.join({
|
|
1519
|
+
roomId: "room-1",
|
|
1520
|
+
roomKey: "shared-key",
|
|
1521
|
+
userId: "alice",
|
|
1522
|
+
userName: "Ada",
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
revoked = true;
|
|
1526
|
+
await expect(room.presence.list({ offset: 0, limit: 10 })).rejects.toThrow("presence list blocked");
|
|
1527
|
+
|
|
1528
|
+
pair.stop();
|
|
1529
|
+
pair.connection.close();
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it("still removes membership when auth resolution fails during disconnect", async () => {
|
|
1533
|
+
const namespace = new MockNamespace();
|
|
1534
|
+
type Auth = {
|
|
1535
|
+
userId: string;
|
|
1536
|
+
};
|
|
1537
|
+
const roomType = createRoomType("disconnect-auth-failure-cleanup", "list");
|
|
1538
|
+
let rejectNext = false;
|
|
1539
|
+
let failAliceAuth = false;
|
|
1540
|
+
let onLeaveCalls = 0;
|
|
1541
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1542
|
+
...createBaseHandlers(),
|
|
1543
|
+
onAuth: async (socket) => {
|
|
1544
|
+
if (failAliceAuth && socket.id === "alice-socket") {
|
|
1545
|
+
throw new ClientSafeError("auth lookup failed");
|
|
1546
|
+
}
|
|
1547
|
+
return {
|
|
1548
|
+
userId: socket.id === "alice-socket" ? "alice" : "bob",
|
|
1549
|
+
};
|
|
1550
|
+
},
|
|
1551
|
+
revalidateAuth: async () => {
|
|
1552
|
+
if (rejectNext) {
|
|
1553
|
+
rejectNext = false;
|
|
1554
|
+
return {
|
|
1555
|
+
kind: "reject",
|
|
1556
|
+
message: "session expired",
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
return {
|
|
1560
|
+
kind: "ok",
|
|
1561
|
+
};
|
|
1562
|
+
},
|
|
1563
|
+
onLeave: async () => {
|
|
1564
|
+
onLeaveCalls += 1;
|
|
1565
|
+
},
|
|
1566
|
+
};
|
|
1567
|
+
|
|
1568
|
+
const alice = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1569
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
1570
|
+
|
|
1571
|
+
const aliceRoom = await alice.client.join({
|
|
1572
|
+
roomId: "room-1",
|
|
1573
|
+
roomKey: "shared-key",
|
|
1574
|
+
userId: "alice",
|
|
1575
|
+
userName: "Ada",
|
|
1576
|
+
});
|
|
1577
|
+
const bobRoom = await bob.client.join({
|
|
1578
|
+
roomId: "room-1",
|
|
1579
|
+
roomKey: "shared-key",
|
|
1580
|
+
userId: "bob",
|
|
1581
|
+
userName: "Ben",
|
|
1582
|
+
});
|
|
1583
|
+
expect(await bobRoom.presence.count()).toBe(2);
|
|
1584
|
+
|
|
1585
|
+
rejectNext = true;
|
|
1586
|
+
await expect(aliceRoom.rpc.sendMessage({ text: "forces-clear" })).rejects.toThrow("session expired");
|
|
1587
|
+
failAliceAuth = true;
|
|
1588
|
+
|
|
1589
|
+
alice.connection.serverSocket.receive("disconnect", undefined);
|
|
1590
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1591
|
+
|
|
1592
|
+
expect(await bobRoom.presence.count()).toBe(1);
|
|
1593
|
+
expect(onLeaveCalls).toBe(0);
|
|
1594
|
+
|
|
1595
|
+
alice.stop();
|
|
1596
|
+
bob.stop();
|
|
1597
|
+
alice.connection.close();
|
|
1598
|
+
bob.connection.close();
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it("surfaces ClientSafeError and sanitizes generic errors from revalidateAuth throws", async () => {
|
|
1602
|
+
const namespace = new MockNamespace();
|
|
1603
|
+
type Auth = {
|
|
1604
|
+
userId: string;
|
|
1605
|
+
};
|
|
1606
|
+
const roomType = createRoomType("revalidate-throw-errors", "count");
|
|
1607
|
+
let mode: "none" | "safe" | "generic" = "none";
|
|
1608
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1609
|
+
...createBaseHandlers(),
|
|
1610
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1611
|
+
revalidateAuth: async () => {
|
|
1612
|
+
if (mode === "safe") {
|
|
1613
|
+
throw new ClientSafeError("safe denial");
|
|
1614
|
+
}
|
|
1615
|
+
if (mode === "generic") {
|
|
1616
|
+
throw new Error("unexpected backend failure");
|
|
1617
|
+
}
|
|
1618
|
+
return {
|
|
1619
|
+
kind: "ok",
|
|
1620
|
+
};
|
|
1621
|
+
},
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1625
|
+
const room = await pair.client.join({
|
|
1626
|
+
roomId: "room-1",
|
|
1627
|
+
roomKey: "shared-key",
|
|
1628
|
+
userId: "alice",
|
|
1629
|
+
userName: "Ada",
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
mode = "safe";
|
|
1633
|
+
await expect(room.rpc.sendMessage({ text: "safe" })).rejects.toThrow("safe denial");
|
|
1634
|
+
mode = "generic";
|
|
1635
|
+
await expect(room.rpc.sendMessage({ text: "generic" })).rejects.toThrow("An internal server error occurred.");
|
|
1636
|
+
|
|
1637
|
+
pair.stop();
|
|
1638
|
+
pair.connection.close();
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it("reuses a single onAuth refresh for concurrent RPCs after cache clear", async () => {
|
|
1642
|
+
const namespace = new MockNamespace();
|
|
1643
|
+
type Auth = {
|
|
1644
|
+
userId: string;
|
|
1645
|
+
token: string;
|
|
1646
|
+
};
|
|
1647
|
+
const roomType = createRoomType("concurrent-auth-refresh", "count");
|
|
1648
|
+
let onAuthCalls = 0;
|
|
1649
|
+
let rejectNext = false;
|
|
1650
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1651
|
+
onAuth: async () => {
|
|
1652
|
+
onAuthCalls += 1;
|
|
1653
|
+
return {
|
|
1654
|
+
userId: "alice",
|
|
1655
|
+
token: `token-${onAuthCalls}`,
|
|
1656
|
+
};
|
|
1657
|
+
},
|
|
1658
|
+
revalidateAuth: async () => {
|
|
1659
|
+
if (rejectNext) {
|
|
1660
|
+
rejectNext = false;
|
|
1661
|
+
return {
|
|
1662
|
+
kind: "reject",
|
|
1663
|
+
message: "session expired",
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
return {
|
|
1667
|
+
kind: "ok",
|
|
1668
|
+
};
|
|
1669
|
+
},
|
|
1670
|
+
admit: async (join, ctx) => ({
|
|
1671
|
+
roomId: join.roomId,
|
|
1672
|
+
memberId: ctx.auth.userId,
|
|
1673
|
+
memberProfile: {
|
|
1674
|
+
userId: ctx.auth.userId,
|
|
1675
|
+
userName: join.userName,
|
|
1676
|
+
},
|
|
1677
|
+
roomProfile: {
|
|
1678
|
+
roomId: join.roomId,
|
|
1679
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
1680
|
+
},
|
|
1681
|
+
}),
|
|
1682
|
+
rpc: {
|
|
1683
|
+
sendMessage: async () => ({
|
|
1684
|
+
id: "message-1",
|
|
1685
|
+
historySize: 1,
|
|
1686
|
+
}),
|
|
1687
|
+
},
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1691
|
+
const room = await pair.client.join({
|
|
1692
|
+
roomId: "room-1",
|
|
1693
|
+
roomKey: "shared-key",
|
|
1694
|
+
userId: "alice",
|
|
1695
|
+
userName: "Ada",
|
|
1696
|
+
});
|
|
1697
|
+
expect(onAuthCalls).toBe(1);
|
|
1698
|
+
|
|
1699
|
+
rejectNext = true;
|
|
1700
|
+
await expect(room.rpc.sendMessage({ text: "reject-once" })).rejects.toThrow("session expired");
|
|
1701
|
+
|
|
1702
|
+
const [first, second] = await Promise.all([
|
|
1703
|
+
room.rpc.sendMessage({ text: "a" }),
|
|
1704
|
+
room.rpc.sendMessage({ text: "b" }),
|
|
1705
|
+
]);
|
|
1706
|
+
expect(first.id).toBe("message-1");
|
|
1707
|
+
expect(second.id).toBe("message-1");
|
|
1708
|
+
expect(onAuthCalls).toBe(2);
|
|
1709
|
+
|
|
1710
|
+
pair.stop();
|
|
1711
|
+
pair.connection.close();
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
it("does not invoke lifecycle hooks after stop()", async () => {
|
|
1715
|
+
const namespace = new MockNamespace();
|
|
1716
|
+
type Auth = {
|
|
1717
|
+
userId: string;
|
|
1718
|
+
};
|
|
1719
|
+
const roomType = createRoomType("stop-detaches-hooks", "count");
|
|
1720
|
+
let onDisconnectCalls = 0;
|
|
1721
|
+
let onLeaveCalls = 0;
|
|
1722
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1723
|
+
...createBaseHandlers(),
|
|
1724
|
+
onAuth: async () => ({ userId: "alice" }),
|
|
1725
|
+
onDisconnect: async () => {
|
|
1726
|
+
onDisconnectCalls += 1;
|
|
1727
|
+
},
|
|
1728
|
+
onLeave: async () => {
|
|
1729
|
+
onLeaveCalls += 1;
|
|
1730
|
+
},
|
|
1731
|
+
};
|
|
1732
|
+
|
|
1733
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1734
|
+
await pair.client.join({
|
|
1735
|
+
roomId: "room-1",
|
|
1736
|
+
roomKey: "shared-key",
|
|
1737
|
+
userId: "alice",
|
|
1738
|
+
userName: "Ada",
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
pair.stop();
|
|
1742
|
+
pair.connection.serverSocket.receive("disconnect", undefined);
|
|
1743
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1744
|
+
|
|
1745
|
+
expect(onDisconnectCalls).toBe(0);
|
|
1746
|
+
expect(onLeaveCalls).toBe(0);
|
|
1747
|
+
|
|
1748
|
+
pair.connection.close();
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
it("keeps same-member presence and delivery when one socket disconnects", async () => {
|
|
1752
|
+
const namespace = new MockNamespace();
|
|
1753
|
+
const roomType = createRoomType("same-member-multi-socket", "list");
|
|
1754
|
+
const handlers = createBaseHandlers();
|
|
1755
|
+
const alicePrimary = createClientPair(namespace, "alice-socket-1", roomType, handlers);
|
|
1756
|
+
const aliceSecondary = createClientPair(namespace, "alice-socket-2", roomType, handlers);
|
|
1757
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
1758
|
+
|
|
1759
|
+
await alicePrimary.client.join({
|
|
1760
|
+
roomId: "room-1",
|
|
1761
|
+
roomKey: "shared-key",
|
|
1762
|
+
userId: "alice",
|
|
1763
|
+
userName: "Ada",
|
|
1764
|
+
});
|
|
1765
|
+
const aliceSecondaryRoom = await aliceSecondary.client.join({
|
|
1766
|
+
roomId: "room-1",
|
|
1767
|
+
roomKey: "shared-key",
|
|
1768
|
+
userId: "alice",
|
|
1769
|
+
userName: "Ada",
|
|
1770
|
+
});
|
|
1771
|
+
const bobRoom = await bob.client.join({
|
|
1772
|
+
roomId: "room-1",
|
|
1773
|
+
roomKey: "shared-key",
|
|
1774
|
+
userId: "bob",
|
|
1775
|
+
userName: "Ben",
|
|
1776
|
+
});
|
|
1777
|
+
expect(await bobRoom.presence.count()).toBe(2);
|
|
1778
|
+
|
|
1779
|
+
alicePrimary.connection.serverSocket.receive("disconnect", undefined);
|
|
1780
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
1781
|
+
expect(await bobRoom.presence.count()).toBe(2);
|
|
1782
|
+
|
|
1783
|
+
const seen: string[] = [];
|
|
1784
|
+
aliceSecondaryRoom.on.message((payload) => {
|
|
1785
|
+
seen.push(payload.text);
|
|
1786
|
+
});
|
|
1787
|
+
await bobRoom.rpc.sendMessage({ text: "still-here" });
|
|
1788
|
+
expect(seen).toEqual(["still-here"]);
|
|
1789
|
+
|
|
1790
|
+
alicePrimary.stop();
|
|
1791
|
+
aliceSecondary.stop();
|
|
1792
|
+
bob.stop();
|
|
1793
|
+
alicePrimary.connection.close();
|
|
1794
|
+
aliceSecondary.connection.close();
|
|
1795
|
+
bob.connection.close();
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("keeps auth rotation monotonic across multiple operations", async () => {
|
|
1799
|
+
const namespace = new MockNamespace();
|
|
1800
|
+
type Auth = {
|
|
1801
|
+
userId: string;
|
|
1802
|
+
version: number;
|
|
1803
|
+
};
|
|
1804
|
+
const roomType = createRoomType("auth-rotation-monotonic", "list");
|
|
1805
|
+
const seen = {
|
|
1806
|
+
admit: 0,
|
|
1807
|
+
presence: [] as number[],
|
|
1808
|
+
rpc: [] as number[],
|
|
1809
|
+
};
|
|
1810
|
+
const handlers: RoomServerHandlers<typeof roomType, Auth> = {
|
|
1811
|
+
...createBaseHandlers(),
|
|
1812
|
+
onAuth: async () => ({
|
|
1813
|
+
userId: "alice",
|
|
1814
|
+
version: 0,
|
|
1815
|
+
}),
|
|
1816
|
+
revalidateAuth: async (_socket, auth) => ({
|
|
1817
|
+
kind: "ok",
|
|
1818
|
+
auth: {
|
|
1819
|
+
...auth,
|
|
1820
|
+
version: auth.version + 1,
|
|
1821
|
+
},
|
|
1822
|
+
}),
|
|
1823
|
+
admit: async (join, ctx) => {
|
|
1824
|
+
seen.admit = ctx.auth.version;
|
|
1825
|
+
return {
|
|
1826
|
+
roomId: join.roomId,
|
|
1827
|
+
memberId: ctx.auth.userId,
|
|
1828
|
+
memberProfile: {
|
|
1829
|
+
userId: ctx.auth.userId,
|
|
1830
|
+
userName: join.userName,
|
|
1831
|
+
},
|
|
1832
|
+
roomProfile: {
|
|
1833
|
+
roomId: join.roomId,
|
|
1834
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
1835
|
+
},
|
|
1836
|
+
};
|
|
1837
|
+
},
|
|
1838
|
+
presencePolicy: (ctx) => {
|
|
1839
|
+
seen.presence.push(ctx.auth.version);
|
|
1840
|
+
return "list";
|
|
1841
|
+
},
|
|
1842
|
+
rpc: {
|
|
1843
|
+
sendMessage: async (_input, ctx) => {
|
|
1844
|
+
seen.rpc.push(ctx.auth.version);
|
|
1845
|
+
return {
|
|
1846
|
+
id: "message-1",
|
|
1847
|
+
historySize: 1,
|
|
1848
|
+
};
|
|
1849
|
+
},
|
|
1850
|
+
},
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1854
|
+
const room = await pair.client.join({
|
|
1855
|
+
roomId: "room-1",
|
|
1856
|
+
roomKey: "shared-key",
|
|
1857
|
+
userId: "alice",
|
|
1858
|
+
userName: "Ada",
|
|
1859
|
+
});
|
|
1860
|
+
await room.presence.count();
|
|
1861
|
+
await room.rpc.sendMessage({ text: "one" });
|
|
1862
|
+
await room.presence.list({ offset: 0, limit: 10 });
|
|
1863
|
+
await room.rpc.sendMessage({ text: "two" });
|
|
1864
|
+
|
|
1865
|
+
expect(seen.admit).toBe(1);
|
|
1866
|
+
expect(seen.presence).toEqual([2, 4]);
|
|
1867
|
+
expect(seen.rpc).toEqual([3, 5]);
|
|
1868
|
+
|
|
1869
|
+
pair.stop();
|
|
1870
|
+
pair.connection.close();
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
it("keeps presence APIs out of the type surface when disabled", () => {
|
|
1874
|
+
const noneRoom = defineRoomType<{
|
|
1875
|
+
joinRequest: { roomId: string };
|
|
1876
|
+
roomProfile: { roomId: string };
|
|
1877
|
+
}, "none">({ name: "no-presence", presence: "none" });
|
|
1878
|
+
const countRoom = defineRoomType<{
|
|
1879
|
+
joinRequest: { roomId: string };
|
|
1880
|
+
roomProfile: { roomId: string };
|
|
1881
|
+
}, "count">({ name: "count-presence", presence: "count" });
|
|
1882
|
+
|
|
1883
|
+
type NoneHasPresence = JoinedRoom<typeof noneRoom> extends { presence: unknown } ? true : false;
|
|
1884
|
+
type CountHasPresence = JoinedRoom<typeof countRoom> extends { presence: unknown } ? true : false;
|
|
1885
|
+
expectTypeOf<NoneHasPresence>().toEqualTypeOf<false>();
|
|
1886
|
+
expectTypeOf<CountHasPresence>().toEqualTypeOf<true>();
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
it("rejects presence queries when presence is disabled", async () => {
|
|
1890
|
+
const namespace = new MockNamespace();
|
|
1891
|
+
const roomType = createRoomType("presence-disabled", "none");
|
|
1892
|
+
const handlers = createBaseHandlers();
|
|
1893
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1894
|
+
|
|
1895
|
+
const room = await client.join({
|
|
1896
|
+
roomId: "room-1",
|
|
1897
|
+
roomKey: "shared-key",
|
|
1898
|
+
userId: "alice",
|
|
1899
|
+
userName: "Ada",
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
await expect((room as any).presence.count()).rejects.toThrow("Presence is disabled for this room");
|
|
1903
|
+
await expect((room.presence as any).list()).rejects.toThrow("Presence is disabled for this room");
|
|
1904
|
+
expect(() => stop.count("room-1")).toThrow("Presence is disabled for this room");
|
|
1905
|
+
|
|
1906
|
+
stop();
|
|
1907
|
+
connection.close();
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it("rejects member lists when only count presence is enabled", async () => {
|
|
1911
|
+
const namespace = new MockNamespace();
|
|
1912
|
+
const roomType = createRoomType("presence-count-only", "count");
|
|
1913
|
+
const handlers = createBaseHandlers();
|
|
1914
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1915
|
+
|
|
1916
|
+
const room = await client.join({
|
|
1917
|
+
roomId: "room-1",
|
|
1918
|
+
roomKey: "shared-key",
|
|
1919
|
+
userId: "alice",
|
|
1920
|
+
userName: "Ada",
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
await expect(room.presence.count()).resolves.toBe(1);
|
|
1924
|
+
await expect((room.presence as any).list()).rejects.toThrow("Member lists are disabled for this room");
|
|
1925
|
+
expect(() => stop.members("room-1")).toThrow("Member lists are disabled for this room");
|
|
1926
|
+
|
|
1927
|
+
stop();
|
|
1928
|
+
connection.close();
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
it("rejects undeclared client events", async () => {
|
|
1932
|
+
const namespace = new MockNamespace();
|
|
1933
|
+
const roomType = createRoomType("unknown-client-event", "list");
|
|
1934
|
+
const handlers = createBaseHandlers();
|
|
1935
|
+
const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1936
|
+
|
|
1937
|
+
const room = await client.join({
|
|
1938
|
+
roomId: "room-1",
|
|
1939
|
+
roomKey: "shared-key",
|
|
1940
|
+
userId: "alice",
|
|
1941
|
+
userName: "Ada",
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
await expect((room.emit as any).doesNotExist({ text: "nope" })).rejects.toThrow("Unknown event 'doesNotExist'");
|
|
1945
|
+
|
|
1946
|
+
stop();
|
|
1947
|
+
connection.close();
|
|
1948
|
+
});
|
|
1949
|
+
|
|
1950
|
+
it("supports per-request presence policy overrides", async () => {
|
|
1951
|
+
const namespace = new MockNamespace();
|
|
1952
|
+
const roomType = createRoomType("presence-policy-override", "list");
|
|
1953
|
+
const handlers: RoomServerHandlers<typeof roomType> = {
|
|
1954
|
+
...createBaseHandlers(),
|
|
1955
|
+
presencePolicy: (ctx) => (ctx.memberId === "alice" ? "list" : "count"),
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
const alicePair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
1959
|
+
const bobPair = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
1960
|
+
|
|
1961
|
+
const aliceRoom = await alicePair.client.join({
|
|
1962
|
+
roomId: "room-1",
|
|
1963
|
+
roomKey: "shared-key",
|
|
1964
|
+
userId: "alice",
|
|
1965
|
+
userName: "Ada",
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
const bobRoom = await bobPair.client.join({
|
|
1969
|
+
roomId: "room-1",
|
|
1970
|
+
roomKey: "shared-key",
|
|
1971
|
+
userId: "bob",
|
|
1972
|
+
userName: "Ben",
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
await expect(aliceRoom.presence.count()).resolves.toBe(2);
|
|
1976
|
+
await expect(aliceRoom.presence.list({ offset: 0, limit: 10 })).resolves.toMatchObject({
|
|
1977
|
+
count: 2,
|
|
1978
|
+
members: [
|
|
1979
|
+
{ memberId: "alice" },
|
|
1980
|
+
{ memberId: "bob" },
|
|
1981
|
+
],
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
await expect(bobRoom.presence.count()).resolves.toBe(2);
|
|
1985
|
+
await expect((bobRoom.presence as any).list()).rejects.toThrow("Member lists are disabled for this room");
|
|
1986
|
+
|
|
1987
|
+
alicePair.stop();
|
|
1988
|
+
bobPair.stop();
|
|
1989
|
+
alicePair.connection.close();
|
|
1990
|
+
bobPair.connection.close();
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
it("does not allow presencePolicy to escalate beyond room default", async () => {
|
|
1994
|
+
const namespace = new MockNamespace();
|
|
1995
|
+
const roomType = createRoomType("presence-no-escalation", "count");
|
|
1996
|
+
const handlers: RoomServerHandlers<typeof roomType> = {
|
|
1997
|
+
...createBaseHandlers(),
|
|
1998
|
+
presencePolicy: () => "list",
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
const alice = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2002
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
2003
|
+
|
|
2004
|
+
const aliceRoom = await alice.client.join({
|
|
2005
|
+
roomId: "room-1",
|
|
2006
|
+
roomKey: "shared-key",
|
|
2007
|
+
userId: "alice",
|
|
2008
|
+
userName: "Ada",
|
|
2009
|
+
});
|
|
2010
|
+
await bob.client.join({
|
|
2011
|
+
roomId: "room-1",
|
|
2012
|
+
roomKey: "shared-key",
|
|
2013
|
+
userId: "bob",
|
|
2014
|
+
userName: "Ben",
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
await expect(aliceRoom.presence.count()).resolves.toBe(2);
|
|
2018
|
+
await expect((aliceRoom.presence as any).list()).rejects.toThrow("Member lists are disabled for this room");
|
|
2019
|
+
|
|
2020
|
+
alice.stop();
|
|
2021
|
+
bob.stop();
|
|
2022
|
+
alice.connection.close();
|
|
2023
|
+
bob.connection.close();
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
it("calls presencePolicy for each presence query request", async () => {
|
|
2027
|
+
const namespace = new MockNamespace();
|
|
2028
|
+
const roomType = createRoomType("presence-policy-called-per-request", "list");
|
|
2029
|
+
let calls = 0;
|
|
2030
|
+
const handlers: RoomServerHandlers<typeof roomType> = {
|
|
2031
|
+
...createBaseHandlers(),
|
|
2032
|
+
presencePolicy: () => {
|
|
2033
|
+
calls += 1;
|
|
2034
|
+
return "list";
|
|
2035
|
+
},
|
|
2036
|
+
};
|
|
2037
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2038
|
+
const room = await pair.client.join({
|
|
2039
|
+
roomId: "room-1",
|
|
2040
|
+
roomKey: "shared-key",
|
|
2041
|
+
userId: "alice",
|
|
2042
|
+
userName: "Ada",
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
await room.presence.count();
|
|
2046
|
+
await room.presence.list({ offset: 0, limit: 10 });
|
|
2047
|
+
await room.presence.count();
|
|
2048
|
+
expect(calls).toBe(3);
|
|
2049
|
+
|
|
2050
|
+
pair.stop();
|
|
2051
|
+
pair.connection.close();
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
it("returns ClientSafeError messages from presencePolicy", async () => {
|
|
2055
|
+
const namespace = new MockNamespace();
|
|
2056
|
+
const roomType = createRoomType("presence-policy-client-safe-error", "list");
|
|
2057
|
+
const handlers: RoomServerHandlers<typeof roomType> = {
|
|
2058
|
+
...createBaseHandlers(),
|
|
2059
|
+
presencePolicy: () => {
|
|
2060
|
+
throw new ClientSafeError("presence blocked");
|
|
2061
|
+
},
|
|
2062
|
+
};
|
|
2063
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2064
|
+
const room = await pair.client.join({
|
|
2065
|
+
roomId: "room-1",
|
|
2066
|
+
roomKey: "shared-key",
|
|
2067
|
+
userId: "alice",
|
|
2068
|
+
userName: "Ada",
|
|
2069
|
+
});
|
|
2070
|
+
|
|
2071
|
+
await expect(room.presence.count()).rejects.toThrow("presence blocked");
|
|
2072
|
+
|
|
2073
|
+
pair.stop();
|
|
2074
|
+
pair.connection.close();
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
it("sanitizes non-ClientSafeError presencePolicy failures", async () => {
|
|
2078
|
+
const namespace = new MockNamespace();
|
|
2079
|
+
const roomType = createRoomType("presence-policy-sanitized-error", "list");
|
|
2080
|
+
const handlers: RoomServerHandlers<typeof roomType> = {
|
|
2081
|
+
...createBaseHandlers(),
|
|
2082
|
+
presencePolicy: () => {
|
|
2083
|
+
throw new Error("db down");
|
|
2084
|
+
},
|
|
2085
|
+
};
|
|
2086
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2087
|
+
const room = await pair.client.join({
|
|
2088
|
+
roomId: "room-1",
|
|
2089
|
+
roomKey: "shared-key",
|
|
2090
|
+
userId: "alice",
|
|
2091
|
+
userName: "Ada",
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
await expect(room.presence.count()).rejects.toThrow("An internal server error occurred.");
|
|
2095
|
+
|
|
2096
|
+
pair.stop();
|
|
2097
|
+
pair.connection.close();
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
it("supports auth-aware presencePolicy", async () => {
|
|
2101
|
+
const namespace = new MockNamespace();
|
|
2102
|
+
const roomType = createRoomType("presence-policy-auth-aware", "list");
|
|
2103
|
+
const handlers: RoomServerHandlers<typeof roomType, { role: "admin" | "member" }> = {
|
|
2104
|
+
...createBaseHandlers(),
|
|
2105
|
+
onAuth: (socket) => ({
|
|
2106
|
+
role: socket.id === "alice-socket" ? "admin" : "member",
|
|
2107
|
+
}),
|
|
2108
|
+
presencePolicy: (ctx) => (ctx.auth.role === "admin" ? "list" : "count"),
|
|
2109
|
+
};
|
|
2110
|
+
|
|
2111
|
+
const alice = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2112
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
2113
|
+
|
|
2114
|
+
const aliceRoom = await alice.client.join({
|
|
2115
|
+
roomId: "room-1",
|
|
2116
|
+
roomKey: "shared-key",
|
|
2117
|
+
userId: "alice",
|
|
2118
|
+
userName: "Ada",
|
|
2119
|
+
});
|
|
2120
|
+
const bobRoom = await bob.client.join({
|
|
2121
|
+
roomId: "room-1",
|
|
2122
|
+
roomKey: "shared-key",
|
|
2123
|
+
userId: "bob",
|
|
2124
|
+
userName: "Ben",
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
await expect(aliceRoom.presence.list()).resolves.toMatchObject({ count: 2 });
|
|
2128
|
+
await expect((bobRoom.presence as any).list()).rejects.toThrow("Member lists are disabled for this room");
|
|
2129
|
+
|
|
2130
|
+
alice.stop();
|
|
2131
|
+
bob.stop();
|
|
2132
|
+
alice.connection.close();
|
|
2133
|
+
bob.connection.close();
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
it("rejects prototype-like client event keys", async () => {
|
|
2137
|
+
const namespace = new MockNamespace();
|
|
2138
|
+
const roomType = createRoomType("event-prototype-keys", "list");
|
|
2139
|
+
const handlers = createBaseHandlers();
|
|
2140
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2141
|
+
const room = await pair.client.join({
|
|
2142
|
+
roomId: "room-1",
|
|
2143
|
+
roomKey: "shared-key",
|
|
2144
|
+
userId: "alice",
|
|
2145
|
+
userName: "Ada",
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
await expect((room.emit as any)["__proto__"]({ text: "x" })).rejects.toThrow("Unknown event '__proto__'");
|
|
2149
|
+
await expect((room.emit as any)["toString"]({ text: "x" })).rejects.toThrow("Unknown event 'toString'");
|
|
2150
|
+
|
|
2151
|
+
pair.stop();
|
|
2152
|
+
pair.connection.close();
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
it("rejects prototype-like RPC keys", async () => {
|
|
2156
|
+
const namespace = new MockNamespace();
|
|
2157
|
+
const roomType = createRoomType("rpc-prototype-keys", "list");
|
|
2158
|
+
const handlers = createBaseHandlers();
|
|
2159
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2160
|
+
const room = await pair.client.join({
|
|
2161
|
+
roomId: "room-1",
|
|
2162
|
+
roomKey: "shared-key",
|
|
2163
|
+
userId: "alice",
|
|
2164
|
+
userName: "Ada",
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
await expect((room.rpc as any)["__proto__"]()).rejects.toThrow("Unknown RPC '__proto__'");
|
|
2168
|
+
await expect((room.rpc as any)["toString"]()).rejects.toThrow("Unknown RPC 'toString'");
|
|
2169
|
+
|
|
2170
|
+
pair.stop();
|
|
2171
|
+
pair.connection.close();
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
it("delivers presence frames through the custom adapter path", async () => {
|
|
2175
|
+
const namespace = new MockNamespace();
|
|
2176
|
+
const roomType = createRoomType("presence-adapter-delivery", "list");
|
|
2177
|
+
const adapterEvents: string[] = [];
|
|
2178
|
+
const adapter: RoomServerAdapter = {
|
|
2179
|
+
emitToSocketIds(socketIds, eventName, payload) {
|
|
2180
|
+
adapterEvents.push(eventName);
|
|
2181
|
+
for (const socketId of socketIds) {
|
|
2182
|
+
namespace.to(socketId).emit(eventName, payload);
|
|
2183
|
+
}
|
|
2184
|
+
},
|
|
2185
|
+
};
|
|
2186
|
+
const handlers = createBaseHandlers();
|
|
2187
|
+
const alice = createClientPair(namespace, "alice-socket", roomType, handlers, adapter);
|
|
2188
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers, adapter);
|
|
2189
|
+
|
|
2190
|
+
await alice.client.join({
|
|
2191
|
+
roomId: "room-1",
|
|
2192
|
+
roomKey: "shared-key",
|
|
2193
|
+
userId: "alice",
|
|
2194
|
+
userName: "Ada",
|
|
2195
|
+
});
|
|
2196
|
+
await bob.client.join({
|
|
2197
|
+
roomId: "room-1",
|
|
2198
|
+
roomKey: "shared-key",
|
|
2199
|
+
userId: "bob",
|
|
2200
|
+
userName: "Ben",
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
expect(adapterEvents).toContain("room-kit:presence");
|
|
2204
|
+
|
|
2205
|
+
alice.stop();
|
|
2206
|
+
bob.stop();
|
|
2207
|
+
alice.connection.close();
|
|
2208
|
+
bob.connection.close();
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
it("keeps introspection member listing independent from per-request presencePolicy", async () => {
|
|
2212
|
+
const namespace = new MockNamespace();
|
|
2213
|
+
const roomType = createRoomType("introspection-vs-policy", "list");
|
|
2214
|
+
const handlers: RoomServerHandlers<typeof roomType> = {
|
|
2215
|
+
...createBaseHandlers(),
|
|
2216
|
+
presencePolicy: () => "count",
|
|
2217
|
+
};
|
|
2218
|
+
const first = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2219
|
+
const second = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
2220
|
+
|
|
2221
|
+
const aliceRoom = await first.client.join({
|
|
2222
|
+
roomId: "room-1",
|
|
2223
|
+
roomKey: "shared-key",
|
|
2224
|
+
userId: "alice",
|
|
2225
|
+
userName: "Ada",
|
|
2226
|
+
});
|
|
2227
|
+
await second.client.join({
|
|
2228
|
+
roomId: "room-1",
|
|
2229
|
+
roomKey: "shared-key",
|
|
2230
|
+
userId: "bob",
|
|
2231
|
+
userName: "Ben",
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
await expect((aliceRoom.presence as any).list()).rejects.toThrow("Member lists are disabled for this room");
|
|
2235
|
+
expect(first.stop.members("room-1")).toMatchObject({
|
|
2236
|
+
count: 2,
|
|
2237
|
+
members: [
|
|
2238
|
+
{ memberId: "alice" },
|
|
2239
|
+
{ memberId: "bob" },
|
|
2240
|
+
],
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
first.stop();
|
|
2244
|
+
second.stop();
|
|
2245
|
+
first.connection.close();
|
|
2246
|
+
second.connection.close();
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
it("routes broadcast.toRoom to the target room only", async () => {
|
|
2250
|
+
const namespace = new MockNamespace();
|
|
2251
|
+
const roomType = createRoomType("broadcast-to-other-room", "list");
|
|
2252
|
+
const handlers = createBaseHandlers();
|
|
2253
|
+
const alice = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2254
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
2255
|
+
const carol = createClientPair(namespace, "carol-socket", roomType, handlers);
|
|
2256
|
+
|
|
2257
|
+
const aliceRoom = await alice.client.join({
|
|
2258
|
+
roomId: "room-1",
|
|
2259
|
+
roomKey: "shared-key",
|
|
2260
|
+
userId: "alice",
|
|
2261
|
+
userName: "Ada",
|
|
2262
|
+
});
|
|
2263
|
+
const bobRoom = await bob.client.join({
|
|
2264
|
+
roomId: "room-2",
|
|
2265
|
+
roomKey: "shared-key",
|
|
2266
|
+
userId: "bob",
|
|
2267
|
+
userName: "Ben",
|
|
2268
|
+
});
|
|
2269
|
+
const carolRoom = await carol.client.join({
|
|
2270
|
+
roomId: "room-1",
|
|
2271
|
+
roomKey: "shared-key",
|
|
2272
|
+
userId: "carol",
|
|
2273
|
+
userName: "Cid",
|
|
2274
|
+
});
|
|
2275
|
+
|
|
2276
|
+
const seenRoom1: string[] = [];
|
|
2277
|
+
const seenRoom2: string[] = [];
|
|
2278
|
+
aliceRoom.on.roomNotice((payload) => seenRoom1.push(payload.text));
|
|
2279
|
+
bobRoom.on.roomNotice((payload) => seenRoom2.push(payload.text));
|
|
2280
|
+
|
|
2281
|
+
await carolRoom.rpc.announceRoom({ roomId: "room-2", text: "hello-room-2" });
|
|
2282
|
+
|
|
2283
|
+
expect(seenRoom1).toEqual([]);
|
|
2284
|
+
expect(seenRoom2).toEqual(["hello-room-2"]);
|
|
2285
|
+
|
|
2286
|
+
alice.stop();
|
|
2287
|
+
bob.stop();
|
|
2288
|
+
carol.stop();
|
|
2289
|
+
alice.connection.close();
|
|
2290
|
+
bob.connection.close();
|
|
2291
|
+
carol.connection.close();
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
it("drops replay state after reconnect admission roomId mismatch", async () => {
|
|
2295
|
+
const namespace = new MockNamespace();
|
|
2296
|
+
const roomType = createRoomType("reconnect-mismatch-cleanup", "list");
|
|
2297
|
+
const joinCalls = new Map<string, number>();
|
|
2298
|
+
const handlers = createBaseHandlers({
|
|
2299
|
+
admit: (join) => {
|
|
2300
|
+
const count = (joinCalls.get(join.userId) ?? 0) + 1;
|
|
2301
|
+
joinCalls.set(join.userId, count);
|
|
2302
|
+
if (count === 1) {
|
|
2303
|
+
return {
|
|
2304
|
+
roomId: join.roomId,
|
|
2305
|
+
memberId: join.userId,
|
|
2306
|
+
memberProfile: {
|
|
2307
|
+
userId: join.userId,
|
|
2308
|
+
userName: join.userName,
|
|
2309
|
+
},
|
|
2310
|
+
roomProfile: {
|
|
2311
|
+
roomId: join.roomId,
|
|
2312
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
2313
|
+
},
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
return {
|
|
2318
|
+
roomId: `${join.roomId}-other`,
|
|
2319
|
+
memberId: join.userId,
|
|
2320
|
+
memberProfile: {
|
|
2321
|
+
userId: join.userId,
|
|
2322
|
+
userName: join.userName,
|
|
2323
|
+
},
|
|
2324
|
+
roomProfile: {
|
|
2325
|
+
roomId: `${join.roomId}-other`,
|
|
2326
|
+
created: "2026-03-23T00:00:00.000Z",
|
|
2327
|
+
},
|
|
2328
|
+
};
|
|
2329
|
+
},
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
const alice = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2333
|
+
const bob = createClientPair(namespace, "bob-socket", roomType, handlers);
|
|
2334
|
+
|
|
2335
|
+
const aliceRoom = await alice.client.join({
|
|
2336
|
+
roomId: "room-1",
|
|
2337
|
+
roomKey: "shared-key",
|
|
2338
|
+
userId: "alice",
|
|
2339
|
+
userName: "Ada",
|
|
2340
|
+
});
|
|
2341
|
+
const bobRoom = await bob.client.join({
|
|
2342
|
+
roomId: "room-1",
|
|
2343
|
+
roomKey: "shared-key",
|
|
2344
|
+
userId: "bob",
|
|
2345
|
+
userName: "Ben",
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
const seen: string[] = [];
|
|
2349
|
+
aliceRoom.on.message((payload) => seen.push(payload.text));
|
|
2350
|
+
|
|
2351
|
+
await bobRoom.rpc.sendMessage({ text: "before" });
|
|
2352
|
+
expect(seen).toEqual(["before"]);
|
|
2353
|
+
|
|
2354
|
+
alice.connection.serverSocket.receive("disconnect", undefined);
|
|
2355
|
+
alice.connection.clientSocket.receive("connect", undefined);
|
|
2356
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
2357
|
+
alice.connection.clientSocket.receive("connect", undefined);
|
|
2358
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
2359
|
+
|
|
2360
|
+
await bobRoom.rpc.sendMessage({ text: "after" });
|
|
2361
|
+
expect(seen).toEqual(["before"]);
|
|
2362
|
+
expect(joinCalls.get("alice")).toBe(2);
|
|
2363
|
+
|
|
2364
|
+
alice.stop();
|
|
2365
|
+
bob.stop();
|
|
2366
|
+
alice.connection.close();
|
|
2367
|
+
bob.connection.close();
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
it("exposes client transport state changes", () => {
|
|
2371
|
+
const namespace = new MockNamespace();
|
|
2372
|
+
const roomType = createRoomType("connection-state", "list");
|
|
2373
|
+
const handlers = createBaseHandlers();
|
|
2374
|
+
const pair = createClientPair(namespace, "alice-socket", roomType, handlers);
|
|
2375
|
+
const states: string[] = [];
|
|
2376
|
+
|
|
2377
|
+
expect(pair.client.connection.current).toBe("connecting");
|
|
2378
|
+
const unsubscribe = pair.client.connection.onChange((state) => {
|
|
2379
|
+
states.push(state);
|
|
2380
|
+
});
|
|
2381
|
+
|
|
2382
|
+
pair.connection.clientSocket.receive("connect_error", undefined);
|
|
2383
|
+
expect(pair.client.connection.current).toBe("connecting");
|
|
2384
|
+
|
|
2385
|
+
pair.connection.clientSocket.receive("connect", undefined);
|
|
2386
|
+
expect(pair.client.connection.current).toBe("connected");
|
|
2387
|
+
|
|
2388
|
+
pair.connection.clientSocket.receive("reconnect_attempt", undefined);
|
|
2389
|
+
expect(pair.client.connection.current).toBe("reconnecting");
|
|
2390
|
+
pair.connection.clientSocket.receive("reconnect_error", undefined);
|
|
2391
|
+
expect(pair.client.connection.current).toBe("reconnecting");
|
|
2392
|
+
|
|
2393
|
+
pair.connection.clientSocket.receive("disconnect", undefined);
|
|
2394
|
+
expect(pair.client.connection.current).toBe("disconnected");
|
|
2395
|
+
expect(states).toEqual(["connected", "reconnecting", "disconnected"]);
|
|
2396
|
+
|
|
2397
|
+
unsubscribe();
|
|
2398
|
+
pair.stop();
|
|
2399
|
+
pair.connection.close();
|
|
2400
|
+
});
|
|
2401
|
+
});
|