room-kit 1.0.0 → 1.0.1

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/src/server.ts CHANGED
@@ -31,6 +31,132 @@ const SERVER_EVENT = "room-kit:server-event";
31
31
  const PRESENCE_EVENT = "room-kit:presence";
32
32
  const PRESENCE_QUERY_EVENT = "room-kit:presence-query";
33
33
 
34
+ function assertNonEmptyString(value: unknown, label: string): asserts value is string {
35
+ if (typeof value !== "string" || value.length === 0) {
36
+ throw new ClientSafeError(`${label} must be a non-empty string`);
37
+ }
38
+ }
39
+
40
+ function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
41
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
42
+ throw new ClientSafeError(`${label} must be an object`);
43
+ }
44
+ }
45
+
46
+ function assertArray(value: unknown, label: string): asserts value is unknown[] {
47
+ if (!Array.isArray(value)) {
48
+ throw new ClientSafeError(`${label} must be an array`);
49
+ }
50
+ }
51
+
52
+ /** Validates and narrows a join frame from the wire. */
53
+ function validateJoinFrame(raw: unknown): {
54
+ roomType: string;
55
+ payload: Record<string, unknown>;
56
+ } {
57
+ assertObject(raw, "Join frame");
58
+ assertNonEmptyString(raw.roomType, "roomType");
59
+ assertObject(raw.payload, "payload");
60
+ return { roomType: raw.roomType, payload: raw.payload as Record<string, unknown> };
61
+ }
62
+
63
+ /** Validates and narrows a leave frame from the wire. */
64
+ function validateLeaveFrame(raw: unknown): { roomType: string; roomId: string } {
65
+ assertObject(raw, "Leave frame");
66
+ assertNonEmptyString(raw.roomType, "roomType");
67
+ assertNonEmptyString(raw.roomId, "roomId");
68
+ return { roomType: raw.roomType, roomId: raw.roomId };
69
+ }
70
+
71
+ /** Validates and narrows an RPC frame from the wire. */
72
+ function validateRpcFrame(raw: unknown): {
73
+ roomType: string;
74
+ roomId: string;
75
+ name: string;
76
+ args: unknown[];
77
+ } {
78
+ assertObject(raw, "RPC frame");
79
+ assertNonEmptyString(raw.roomType, "roomType");
80
+ assertNonEmptyString(raw.roomId, "roomId");
81
+ assertNonEmptyString(raw.name, "name");
82
+ assertArray(raw.args, "args");
83
+ return { roomType: raw.roomType, roomId: raw.roomId, name: raw.name, args: raw.args };
84
+ }
85
+
86
+ /** Validates and narrows a client-event frame from the wire. */
87
+ function validateClientEventFrame(raw: unknown): {
88
+ roomType: string;
89
+ roomId: string;
90
+ name: string;
91
+ payload: unknown;
92
+ } {
93
+ assertObject(raw, "Client-event frame");
94
+ assertNonEmptyString(raw.roomType, "roomType");
95
+ assertNonEmptyString(raw.roomId, "roomId");
96
+ assertNonEmptyString(raw.name, "name");
97
+ return { roomType: raw.roomType, roomId: raw.roomId, name: raw.name, payload: raw.payload };
98
+ }
99
+
100
+ /** Validates and narrows a presence-query frame from the wire. */
101
+ function validatePresenceQueryFrame(raw: unknown): {
102
+ roomType: string;
103
+ roomId: string;
104
+ kind: "count" | "list";
105
+ offset?: number;
106
+ limit?: number;
107
+ } {
108
+ assertObject(raw, "Presence-query frame");
109
+ assertNonEmptyString(raw.roomType, "roomType");
110
+ assertNonEmptyString(raw.roomId, "roomId");
111
+ if (raw.kind !== "count" && raw.kind !== "list") {
112
+ throw new ClientSafeError("Presence query kind must be 'count' or 'list'");
113
+ }
114
+ const result: {
115
+ roomType: string;
116
+ roomId: string;
117
+ kind: "count" | "list";
118
+ offset?: number;
119
+ limit?: number;
120
+ } = { roomType: raw.roomType, roomId: raw.roomId, kind: raw.kind };
121
+ if (raw.offset !== undefined) {
122
+ if (typeof raw.offset !== "number" || !Number.isFinite(raw.offset)) {
123
+ throw new ClientSafeError("offset must be a finite number");
124
+ }
125
+ result.offset = raw.offset;
126
+ }
127
+ if (raw.limit !== undefined) {
128
+ if (typeof raw.limit !== "number" || !Number.isFinite(raw.limit)) {
129
+ throw new ClientSafeError("limit must be a finite number");
130
+ }
131
+ result.limit = raw.limit;
132
+ }
133
+ return result;
134
+ }
135
+
136
+ const DANGEROUS_PROPERTY_NAMES = new Set([
137
+ "__proto__",
138
+ "constructor",
139
+ "prototype",
140
+ "toString",
141
+ "valueOf",
142
+ "hasOwnProperty",
143
+ "isPrototypeOf",
144
+ "propertyIsEnumerable",
145
+ "toLocaleString",
146
+ ]);
147
+
148
+ function assertSafeHandlerName(name: string): void {
149
+ if (DANGEROUS_PROPERTY_NAMES.has(name)) {
150
+ throw new ClientSafeError(`Disallowed handler name '${name}'`);
151
+ }
152
+ }
153
+
154
+ /** Maximum number of rooms that can exist within a single namespace. */
155
+ const MAX_ROOMS_PER_NAMESPACE = 10_000;
156
+
157
+ /** Maximum number of socket connections tracked per room. */
158
+ const MAX_MEMBERS_PER_ROOM = 10_000;
159
+
34
160
  type StoredMember<TRoom extends RoomDefinition<any>> = {
35
161
  socketId: string;
36
162
  memberId: string;
@@ -59,13 +185,13 @@ const namespaceStates = new WeakMap<object, NamespaceState>();
59
185
  /**
60
186
  * Attaches room runtime handlers to a connected server socket.
61
187
  *
62
- * Returns a callable handle that unregisters all listeners for the bound
63
- * socket, and exposes room introspection helpers.
188
+ * Returns a handle that unregisters all listeners for the bound socket via
189
+ * `cleanup()`, and exposes room introspection helpers.
64
190
  *
65
191
  * @example
66
192
  * ```ts
67
193
  * io.on("connection", (socket) => {
68
- * const stop = serveRoomType(socket, chatRoomType, {
194
+ * const handle = serveRoomType(socket, chatRoomType, {
69
195
  * onAuth: async () => ({ userId: socket.id }),
70
196
  * admit: async (join, ctx) => ({
71
197
  * roomId: join.roomId,
@@ -76,7 +202,7 @@ const namespaceStates = new WeakMap<object, NamespaceState>();
76
202
  * });
77
203
  *
78
204
  * // Later if needed:
79
- * // stop();
205
+ * // handle.cleanup();
80
206
  * });
81
207
  * ```
82
208
  */
@@ -90,20 +216,27 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
90
216
  const authCache = new WeakMap<ServerSocketLike, AuthCacheEntry<TAuth>>();
91
217
 
92
218
  const onJoin = async (
93
- frame: {
94
- roomType: string;
95
- payload: JoinRequest<TRoom>;
96
- },
219
+ raw: unknown,
97
220
  ack?: (result: { ok: true; value: { roomId: string; memberId: string; roomProfile: any; presence: PresenceValueFor<TRoom> } } | { ok: false; error: string }) => void,
98
221
  ) => {
99
222
  try {
223
+
224
+ if (ack !== undefined && typeof ack !== "function") {
225
+ return;
226
+ }
227
+ const frame = validateJoinFrame(raw);
100
228
  assertMatchingRoomName(_room, frame.roomType);
101
229
  const requestedRoomId = extractRoomId(frame.payload);
102
230
  const roomCollection = getOrCreateRoomCollection(namespaceState, frame.roomType);
231
+
232
+ if (!roomCollection.has(requestedRoomId) && roomCollection.size >= MAX_ROOMS_PER_NAMESPACE) {
233
+ throw new ClientSafeError("Maximum room limit reached for this namespace");
234
+ }
235
+
103
236
  const existingRoomState = roomCollection.get(requestedRoomId);
104
- const initialState = (existingRoomState?.serverState ??
105
- await Promise.resolve(handlers.initState?.(frame.payload) ?? {})) as ServerStateFor<TRoom>;
106
237
  const auth = await resolveSocketAuth(socket, handlers, authCache, true);
238
+ const initialState = (existingRoomState?.serverState ??
239
+ await Promise.resolve(handlers.initState?.(frame.payload as JoinRequest<TRoom>) ?? {})) as ServerStateFor<TRoom>;
107
240
  const provisional = createContext<TRoom, TAuth>(socket, namespaceState, {
108
241
  adapter,
109
242
  name: frame.roomType,
@@ -114,7 +247,7 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
114
247
  roomProfile: undefined,
115
248
  serverState: initialState,
116
249
  });
117
- const admission = await handlers.admit(frame.payload, provisional);
250
+ const admission = await handlers.admit(frame.payload as JoinRequest<TRoom>, provisional);
118
251
  assertMatchingRoomIds(
119
252
  requestedRoomId,
120
253
  admission.roomId,
@@ -128,6 +261,11 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
128
261
  membersBySocketId: new Map(),
129
262
  socketIdsByMemberId: new Map(),
130
263
  };
264
+
265
+ if (!roomState.membersBySocketId.has(socket.id) && roomState.membersBySocketId.size >= MAX_MEMBERS_PER_ROOM) {
266
+ throw new ClientSafeError("Room is full");
267
+ }
268
+
131
269
  roomState.roomProfile = roomState.roomProfile ?? admission.roomProfile;
132
270
  roomState.serverState = roomState.serverState ?? provisional.serverState;
133
271
  roomState.membersBySocketId.set(socket.id, {
@@ -168,10 +306,14 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
168
306
  };
169
307
 
170
308
  const onLeave = async (
171
- payload: { roomType: string; roomId: string },
309
+ raw: unknown,
172
310
  ack?: (result: { ok: true; value: void } | { ok: false; error: string }) => void,
173
311
  ) => {
174
312
  try {
313
+ if (ack !== undefined && typeof ack !== "function") {
314
+ return;
315
+ }
316
+ const payload = validateLeaveFrame(raw);
175
317
  assertMatchingRoomName(_room, payload.roomType);
176
318
  const stored = getStoredMembership(namespaceState, payload.roomType, payload.roomId, socket.id);
177
319
  if (!stored) {
@@ -202,11 +344,16 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
202
344
  };
203
345
 
204
346
  const onRpc = async (
205
- frame: { roomType: string; roomId: string; name: string; args: unknown[] },
347
+ raw: unknown,
206
348
  ack?: (result: { ok: true; value: unknown } | { ok: false; error: string }) => void,
207
349
  ) => {
208
350
  try {
351
+ if (ack !== undefined && typeof ack !== "function") {
352
+ return;
353
+ }
354
+ const frame = validateRpcFrame(raw);
209
355
  assertMatchingRoomName(_room, frame.roomType);
356
+ assertSafeHandlerName(frame.name);
210
357
  if (!handlers.rpc || !Object.hasOwn(handlers.rpc, frame.name)) {
211
358
  throw new ClientSafeError(`Unknown RPC '${frame.name}'`);
212
359
  }
@@ -232,6 +379,9 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
232
379
  serverState: getRoomState(namespaceState, frame.roomType, frame.roomId).serverState as ServerStateFor<TRoom>,
233
380
  });
234
381
 
382
+ if (frame.args.length > 64) {
383
+ throw new ClientSafeError("Too many RPC arguments");
384
+ }
235
385
  const result = await handler(...frame.args, ctx);
236
386
  ack?.({ ok: true, value: result });
237
387
  } catch (error) {
@@ -240,11 +390,16 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
240
390
  };
241
391
 
242
392
  const onClientEvent = async (
243
- frame: { roomType: string; roomId: string; name: string; payload: unknown },
393
+ raw: unknown,
244
394
  ack?: (result: { ok: true; value: void } | { ok: false; error: string }) => void,
245
395
  ) => {
246
396
  try {
397
+ if (ack !== undefined && typeof ack !== "function") {
398
+ return;
399
+ }
400
+ const frame = validateClientEventFrame(raw);
247
401
  assertMatchingRoomName(_room, frame.roomType);
402
+ assertSafeHandlerName(frame.name);
248
403
  const stored = getStoredMembership(namespaceState, frame.roomType, frame.roomId, socket.id);
249
404
  if (!stored) {
250
405
  throw new ClientSafeError("Socket is not joined to that room");
@@ -284,16 +439,14 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
284
439
  };
285
440
 
286
441
  const onPresenceQuery = async (
287
- frame: {
288
- roomType: string;
289
- roomId: string;
290
- kind: "count" | "list";
291
- offset?: number;
292
- limit?: number;
293
- },
442
+ raw: unknown,
294
443
  ack?: (result: { ok: true; value: number | PresencePageFor<TRoom> } | { ok: false; error: string }) => void,
295
444
  ) => {
296
445
  try {
446
+ if (ack !== undefined && typeof ack !== "function") {
447
+ return;
448
+ }
449
+ const frame = validatePresenceQueryFrame(raw);
297
450
  assertMatchingRoomName(_room, frame.roomType);
298
451
  const stored = getStoredMembership(namespaceState, frame.roomType, frame.roomId, socket.id);
299
452
  if (!stored) {
@@ -414,7 +567,7 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
414
567
  .catch(() => undefined);
415
568
  }
416
569
 
417
- const stop = () => {
570
+ const cleanup = () => {
418
571
  socket.off(JOIN_EVENT, onJoin);
419
572
  socket.off(LEAVE_EVENT, onLeave);
420
573
  socket.off(RPC_EVENT, onRpc);
@@ -423,13 +576,14 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
423
576
  socket.off("disconnect", onDisconnect);
424
577
  };
425
578
 
426
- return Object.assign(stop, {
579
+ return {
580
+ cleanup,
427
581
  rooms: () => listRoomSnapshots<TRoom>(namespaceState, _room.name),
428
582
  room: (roomId: string) => getRoomSnapshot<TRoom>(namespaceState, _room.name, roomId),
429
583
  members: (roomId: string, query?: PresenceListQuery) =>
430
584
  getPresenceMembersPage<TRoom>(namespaceState, _room.name, roomId, query),
431
585
  count: (roomId: string) => getPresenceCount(namespaceState, _room.name, roomId),
432
- });
586
+ };
433
587
  }
434
588
 
435
589
  function createContext<TRoom extends RoomDefinition<any>, TAuth = unknown>(
@@ -499,6 +653,11 @@ async function resolveSocketAuth<TAuth>(
499
653
  if (entry.value === undefined) {
500
654
  const pending = Promise.resolve(handlers.onAuth?.(socket) as TAuth)
501
655
  .then((auth) => {
656
+ if (auth === false) {
657
+ authCache.delete(socket);
658
+ throw new ClientSafeError("Unauthorized");
659
+ }
660
+
502
661
  const current = authCache.get(socket) ?? {};
503
662
  current.value = auth;
504
663
  current.pending = undefined;
@@ -1026,7 +1185,12 @@ function extractRoomId(payload: unknown): string {
1026
1185
  throw new ClientSafeError("Join request must include a string roomId");
1027
1186
  }
1028
1187
 
1029
- return (payload as { roomId: string }).roomId;
1188
+ const roomId = (payload as { roomId: string }).roomId;
1189
+ if (roomId.length === 0 || roomId.length > 256) {
1190
+ throw new ClientSafeError("roomId must be between 1 and 256 characters");
1191
+ }
1192
+
1193
+ return roomId;
1030
1194
  }
1031
1195
 
1032
1196
  function toErrorMessage(error: unknown): string {
package/src/types.ts CHANGED
@@ -218,6 +218,13 @@ export type EventListener<TRoom extends RoomDefinition<any>, TName extends keyof
218
218
  meta: EventMetaFor<TRoom>,
219
219
  ) => void;
220
220
 
221
+ /**
222
+ * Event listener map accepted by `JoinedRoom.listen`.
223
+ */
224
+ export type RoomEventListenerMap<TRoom extends RoomDefinition<any>> = Partial<{
225
+ [K in keyof RoomEvents<TRoom>]: EventListener<TRoom, K>;
226
+ }>;
227
+
221
228
  /**
222
229
  * Server-side snapshot entry for a connected socket membership.
223
230
  */
@@ -271,16 +278,31 @@ export type JoinedRoom<TRoom extends RoomDefinition<any>> = {
271
278
  readonly rpc: RpcClientApi<TRoom>;
272
279
  readonly emit: EventEmitApi<TRoom>;
273
280
  readonly on: EventListenApi<TRoom>;
281
+ listen(options: RoomListenApi<TRoom>): () => void;
274
282
  leave(): Promise<void>;
275
283
  } & PresenceClientApi<TRoom>;
276
284
 
285
+ /**
286
+ * Batched listener registration accepted by `JoinedRoom.listen`.
287
+ */
288
+ export type RoomListenApi<TRoom extends RoomDefinition<any>> = {
289
+ readonly events?: RoomEventListenerMap<TRoom>;
290
+ } & ([PresenceFor<TRoom>] extends [never]
291
+ ? {}
292
+ : {
293
+ readonly presence?: {
294
+ onChange: (presence: PresenceFor<TRoom>) => void;
295
+ };
296
+ });
297
+
277
298
  /**
278
299
  * Handle returned by `serveRoomType`.
279
300
  *
280
- * The handle is callable and unregisters listeners for the bound socket.
301
+ * The handle unregisters listeners for the bound socket via `cleanup()`.
281
302
  * It also exposes read-only introspection helpers for tests and diagnostics.
282
303
  */
283
- export type RoomServerHandle<TRoom extends RoomDefinition<any>> = (() => void) & {
304
+ export type RoomServerHandle<TRoom extends RoomDefinition<any>> = {
305
+ cleanup(): void;
284
306
  rooms(): Array<RoomSnapshot<TRoom>>;
285
307
  room(roomId: string): RoomSnapshot<TRoom> | undefined;
286
308
  members(roomId: string, query?: PresenceListQuery): PresencePageFor<TRoom> | undefined;
@@ -439,14 +461,16 @@ type RoomServerHandlersCommon<TRoom extends RoomDefinition<any>, TAuth> = {
439
461
  *
440
462
  * `onAuth` is required when `TAuth` is explicitly typed to a non-`unknown`
441
463
  * shape, and optional otherwise.
464
+ *
465
+ * Returning `false` rejects the socket before any room state is initialized.
442
466
  */
443
467
  export type RoomServerHandlers<TRoom extends RoomDefinition<any>, TAuth = unknown> =
444
468
  IsUnknown<TAuth> extends true
445
469
  ? RoomServerHandlersCommon<TRoom, TAuth> & {
446
- onAuth?(socket: ServerSocketLike): Promise<TAuth> | TAuth;
470
+ onAuth?(socket: ServerSocketLike): Promise<TAuth | false> | TAuth | false;
447
471
  }
448
472
  : RoomServerHandlersCommon<TRoom, TAuth> & {
449
- onAuth(socket: ServerSocketLike): Promise<TAuth> | TAuth;
473
+ onAuth(socket: ServerSocketLike): Promise<TAuth | false> | TAuth | false;
450
474
  };
451
475
 
452
476
  /**