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/example/server.ts CHANGED
@@ -2,7 +2,7 @@ import http from "node:http";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { randomUUID } from "node:crypto";
5
+ import { randomUUID, timingSafeEqual as cryptoTimingSafeEqual } from "node:crypto";
6
6
 
7
7
  import { Server } from "socket.io";
8
8
 
@@ -10,191 +10,218 @@ import { ClientSafeError, serveRoomType } from "../src/index";
10
10
  import { chatRoomType, type ChatMessage } from "./common";
11
11
 
12
12
  type ChatAuth = {
13
- userId: string;
13
+ userId: string;
14
14
  };
15
15
 
16
16
  const __filename = fileURLToPath(import.meta.url);
17
17
  const __dirname = path.dirname(__filename);
18
18
  const port = Number(process.env.PORT || 3000);
19
- const publicDir = path.join(__dirname, "public");
19
+ const publicDir = path.resolve(path.join(__dirname, "public"));
20
20
 
21
21
  function normalizeRoomId(value: unknown): string {
22
- if (typeof value !== "string" || !value.trim()) {
23
- throw new ClientSafeError("Room id is required.");
24
- }
22
+ if (typeof value !== "string" || !value.trim()) {
23
+ throw new ClientSafeError("Room id is required.");
24
+ }
25
25
 
26
- return value.trim().toLowerCase();
26
+ return value.trim().toLowerCase();
27
27
  }
28
28
 
29
29
  function normalizeRoomKey(value: unknown): string {
30
- if (typeof value !== "string" || !value.trim()) {
31
- throw new ClientSafeError("Room key is required.");
32
- }
30
+ if (typeof value !== "string" || !value.trim()) {
31
+ throw new ClientSafeError("Room key is required.");
32
+ }
33
33
 
34
- return value.trim();
34
+ const key = value.trim();
35
+ if (key.length > 64) {
36
+ throw new ClientSafeError("Room key must be 64 characters or fewer.");
37
+ }
38
+
39
+ return key;
35
40
  }
36
41
 
37
42
  function normalizeUserId(value: unknown): string {
38
- if (typeof value !== "string" || !value.trim()) {
39
- throw new ClientSafeError("User id is required.");
40
- }
43
+ if (typeof value !== "string" || !value.trim()) {
44
+ throw new ClientSafeError("User id is required.");
45
+ }
41
46
 
42
- return value.trim();
47
+ return value.trim();
43
48
  }
44
49
 
45
50
  function normalizeDisplayName(value: unknown): string {
46
- if (typeof value !== "string" || !value.trim()) {
47
- throw new ClientSafeError("Display name is required.");
48
- }
51
+ if (typeof value !== "string" || !value.trim()) {
52
+ throw new ClientSafeError("Display name is required.");
53
+ }
49
54
 
50
- const name = value.trim();
51
- if (name.length > 32) {
52
- throw new ClientSafeError("Display name must be 32 characters or fewer.");
53
- }
55
+ const name = value.trim();
56
+ if (name.length > 32) {
57
+ throw new ClientSafeError("Display name must be 32 characters or fewer.");
58
+ }
54
59
 
55
- return name;
60
+ return name;
56
61
  }
57
62
 
58
63
  function normalizeMessageText(value: unknown): string {
59
- if (typeof value !== "string" || !value.trim()) {
60
- throw new ClientSafeError("Message text is required.");
61
- }
64
+ if (typeof value !== "string" || !value.trim()) {
65
+ throw new ClientSafeError("Message text is required.");
66
+ }
62
67
 
63
- const text = value.trim();
64
- if (text.length > 500) {
65
- throw new ClientSafeError("Message text must be 500 characters or fewer.");
66
- }
68
+ const text = value.trim();
69
+ if (text.length > 500) {
70
+ throw new ClientSafeError("Message text must be 500 characters or fewer.");
71
+ }
67
72
 
68
- return text;
73
+ return text;
69
74
  }
70
75
 
71
76
  function serveFile(filePath: string, res: http.ServerResponse): void {
72
- fs.readFile(filePath, (error, contents) => {
73
- if (error) {
74
- res.statusCode = 404;
75
- res.end("Not found");
76
- return;
77
- }
78
-
79
- const ext = path.extname(filePath);
80
- const contentType =
81
- ext === ".html"
82
- ? "text/html; charset=utf-8"
83
- : ext === ".css"
84
- ? "text/css; charset=utf-8"
85
- : ext === ".js"
86
- ? "text/javascript; charset=utf-8"
87
- : "application/octet-stream";
88
-
89
- res.writeHead(200, {
90
- "content-type": contentType,
91
- "cache-control": "no-store",
92
- });
93
- res.end(contents);
94
- });
77
+ const resolved = path.resolve(filePath);
78
+ if (!resolved.startsWith(publicDir + path.sep) && resolved !== publicDir) {
79
+ res.statusCode = 403;
80
+ res.end("Forbidden");
81
+ return;
82
+ }
83
+
84
+ fs.readFile(resolved, (error, contents) => {
85
+ if (error) {
86
+ res.statusCode = 404;
87
+ res.end("Not found");
88
+ return;
89
+ }
90
+
91
+ const ext = path.extname(resolved);
92
+ const contentType =
93
+ ext === ".html"
94
+ ? "text/html; charset=utf-8"
95
+ : ext === ".css"
96
+ ? "text/css; charset=utf-8"
97
+ : ext === ".js"
98
+ ? "text/javascript; charset=utf-8"
99
+ : "application/octet-stream";
100
+
101
+ res.writeHead(200, {
102
+ "content-type": contentType,
103
+ "cache-control": "no-store",
104
+ });
105
+ res.end(contents);
106
+ });
95
107
  }
96
108
 
97
- const server = http.createServer((req, res) => {
98
- const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
99
-
100
- if (requestUrl.pathname === "/" || requestUrl.pathname === "/index.html") {
101
- serveFile(path.join(publicDir, "index.html"), res);
102
- return;
103
- }
109
+ const STATIC_ROUTES: Record<string, string> = {
110
+ "/": "index.html",
111
+ "/index.html": "index.html",
112
+ "/styles.css": "styles.css",
113
+ "/app.js": "app.js",
114
+ };
104
115
 
105
- if (requestUrl.pathname === "/styles.css") {
106
- serveFile(path.join(publicDir, "styles.css"), res);
107
- return;
108
- }
116
+ const server = http.createServer((req, res) => {
117
+ const requestUrl = new URL(req.url || "/", "http://127.0.0.1");
118
+ const staticFile = STATIC_ROUTES[requestUrl.pathname];
109
119
 
110
- if (requestUrl.pathname === "/app.js") {
111
- serveFile(path.join(publicDir, "app.js"), res);
112
- return;
113
- }
120
+ if (staticFile) {
121
+ serveFile(path.join(publicDir, staticFile), res);
122
+ return;
123
+ }
114
124
 
115
- res.statusCode = 404;
116
- res.end("Not found");
125
+ res.statusCode = 404;
126
+ res.end("Not found");
117
127
  });
118
128
 
119
129
  const io = new Server(server);
120
130
 
121
131
  io.on("connection", (socket) => {
122
- serveRoomType<typeof chatRoomType, ChatAuth>(socket, chatRoomType, {
123
- onAuth: () => ({
124
- userId: socket.id,
125
- }),
126
-
127
- initState: async (join) => {
128
- return {
129
- roomKey: normalizeRoomKey(join.roomKey),
130
- created: new Date().toISOString(),
131
- history: [],
132
- };
133
- },
134
-
135
- admit: async (join, ctx) => {
136
- const roomId = normalizeRoomId(join.roomId);
137
- const roomKey = normalizeRoomKey(join.roomKey);
138
- const userId = normalizeUserId(ctx.auth.userId);
139
- const userName = normalizeDisplayName(join.userName);
140
-
141
- if (ctx.serverState.roomKey !== roomKey) {
142
- throw new ClientSafeError("That room key is incorrect.");
143
- }
144
-
145
- return {
146
- roomId,
147
- memberId: userId,
148
- memberProfile: {
149
- userId,
150
- userName,
151
- joinedAt: Date.now(),
152
- },
153
- roomProfile: {
154
- roomId,
155
- created: ctx.serverState.created,
156
- history: ctx.serverState.history.slice(-50),
157
- },
158
- };
159
- },
160
-
161
- onJoin: async (member, ctx) => {
162
- await ctx.emit.systemNotice({
163
- text: `${member.userName} joined the room`,
164
- sentAt: new Date().toISOString(),
165
- });
166
- },
167
-
168
- onLeave: async (member, ctx) => {
169
- await ctx.emit.systemNotice({
170
- text: `${member.userName} left the room`,
171
- sentAt: new Date().toISOString(),
172
- });
173
- },
174
-
175
- rpc: {
176
- sendMessage: async ({ text }, ctx) => {
177
- const messageText = normalizeMessageText(text);
178
-
179
- const message: ChatMessage = {
180
- id: randomUUID(),
181
- name: ctx.memberProfile.userName,
182
- text: messageText,
183
- sentAt: new Date().toISOString(),
184
- };
185
-
186
- ctx.serverState.history.push(message);
187
- if (ctx.serverState.history.length > 50) {
188
- ctx.serverState.history.shift();
189
- }
190
-
191
- await ctx.emit.message(message);
192
- return { id: message.id };
193
- },
194
- },
195
- });
132
+ const serverRooms = serveRoomType<typeof chatRoomType, ChatAuth>(socket, chatRoomType, {
133
+ onAuth: () => ({
134
+ userId: socket.id,
135
+ }),
136
+
137
+ initState: async (join) => {
138
+ return {
139
+ roomKey: normalizeRoomKey(join.roomKey),
140
+ created: new Date().toISOString(),
141
+ history: [],
142
+ };
143
+ },
144
+
145
+ admit: async (join, ctx) => {
146
+ const roomId = normalizeRoomId(join.roomId);
147
+ const roomKey = normalizeRoomKey(join.roomKey);
148
+ const userId = normalizeUserId(ctx.auth.userId);
149
+ const userName = normalizeDisplayName(join.userName);
150
+
151
+ if (!timingSafeEqual(ctx.serverState.roomKey, roomKey)) {
152
+ throw new ClientSafeError("That room key is incorrect.");
153
+ }
154
+
155
+ return {
156
+ roomId,
157
+ memberId: userId,
158
+ memberProfile: {
159
+ userId,
160
+ userName,
161
+ joinedAt: Date.now(),
162
+ },
163
+ roomProfile: {
164
+ roomId,
165
+ created: ctx.serverState.created,
166
+ history: ctx.serverState.history.slice(-50),
167
+ },
168
+ };
169
+ },
170
+
171
+ onJoin: async (member, ctx) => {
172
+ await ctx.emit.systemNotice({
173
+ text: `${member.userName} joined the room`,
174
+ sentAt: new Date().toISOString(),
175
+ });
176
+ },
177
+
178
+ onLeave: async (member, ctx) => {
179
+ await ctx.emit.systemNotice({
180
+ text: `${member.userName} left the room`,
181
+ sentAt: new Date().toISOString(),
182
+ });
183
+ },
184
+ events: {
185
+ message: () => undefined,
186
+ },
187
+ rpc: {
188
+ sendMessage: async ({ text }, ctx) => {
189
+ const messageText = normalizeMessageText(text);
190
+
191
+ const message: ChatMessage = {
192
+ id: randomUUID(),
193
+ name: ctx.memberProfile.userName,
194
+ text: messageText,
195
+ sentAt: new Date().toISOString(),
196
+ };
197
+
198
+ ctx.serverState.history.push(message);
199
+ if (ctx.serverState.history.length > 50) {
200
+ ctx.serverState.history.shift();
201
+ }
202
+
203
+ await ctx.emit.message(message);
204
+ return { id: message.id };
205
+ },
206
+ },
207
+ });
208
+
196
209
  });
197
210
 
211
+
212
+ // Uses crypto.timingSafeEqual to prevent timing side-channel attacks on
213
+ // secret comparisons like room keys.
214
+ function timingSafeEqual(a: string, b: string): boolean {
215
+ const bufA = Buffer.from(a, "utf-8");
216
+ const bufB = Buffer.from(b, "utf-8");
217
+ if (bufA.length !== bufB.length) {
218
+ // Compare against self to burn equal time, then return false
219
+ cryptoTimingSafeEqual(bufA, bufA);
220
+ return false;
221
+ }
222
+ return cryptoTimingSafeEqual(bufA, bufB);
223
+ }
224
+
198
225
  server.listen(port, "127.0.0.1", () => {
199
- console.log(`Chat example listening on http://127.0.0.1:${port}`);
226
+ console.log(`Chat example listening on http://127.0.0.1:${port}`);
200
227
  });
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlycliches/room-kit",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Type-safe channel primitives for Socket.IO events, requests, streams, and room membership.",
5
5
  "exports": "./src/index.ts",
6
6
  "publish": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "room-kit",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Type-safe room membership, presence, and realtime messaging for Socket.IO.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/client.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  RoomDefinition,
15
15
  RoomEvents,
16
16
  RoomProfileFor,
17
+ RoomListenApi,
17
18
  RoomRpc,
18
19
  RpcClientApi,
19
20
  } from "./types";
@@ -26,6 +27,8 @@ const SERVER_EVENT = "room-kit:server-event";
26
27
  const PRESENCE_EVENT = "room-kit:presence";
27
28
  const PRESENCE_QUERY_EVENT = "room-kit:presence-query";
28
29
 
30
+ const DEFAULT_ACK_TIMEOUT_MS = 30_000;
31
+
29
32
  type ClientRegistry = {
30
33
  serverHandlerInstalled: boolean;
31
34
  presenceHandlerInstalled: boolean;
@@ -162,19 +165,41 @@ function createJoinedRoom<TRoom extends RoomDefinition<any>>(
162
165
  }
163
166
 
164
167
  return (handler: (payload: unknown, meta: EventMetaFor<TRoom>) => void) => {
165
- const handlers = state.eventListeners.get(key) ?? new Set();
166
- handlers.add(handler);
167
- state.eventListeners.set(key, handlers);
168
- return () => {
169
- handlers.delete(handler);
170
- if (handlers.size === 0) {
171
- state.eventListeners.delete(key);
172
- }
173
- };
168
+ return registerEventListener(state, key, handler);
174
169
  };
175
170
  },
176
171
  });
177
172
 
173
+ const listen = (options: RoomListenApi<TRoom>): (() => void) => {
174
+ const cleanups: Array<() => void> = [];
175
+
176
+ if (options.events) {
177
+ for (const [key, handler] of Object.entries(options.events) as Array<
178
+ [string, (payload: unknown, meta: EventMetaFor<TRoom>) => void]
179
+ >) {
180
+ if (typeof handler !== "function") {
181
+ continue;
182
+ }
183
+ cleanups.push(registerEventListener(state, key, handler));
184
+ }
185
+ }
186
+
187
+ if ("presence" in options && options.presence?.onChange) {
188
+ cleanups.push(registerPresenceListener(state, options.presence.onChange));
189
+ }
190
+
191
+ let cleaned = false;
192
+ return () => {
193
+ if (cleaned) {
194
+ return;
195
+ }
196
+ cleaned = true;
197
+ for (const cleanup of cleanups) {
198
+ cleanup();
199
+ }
200
+ };
201
+ };
202
+
178
203
  const base = {
179
204
  name: state.name,
180
205
  roomId: state.roomId,
@@ -183,6 +208,7 @@ function createJoinedRoom<TRoom extends RoomDefinition<any>>(
183
208
  rpc,
184
209
  emit,
185
210
  on,
211
+ listen,
186
212
  async leave(): Promise<void> {
187
213
  await emitAck<void>(socket, LEAVE_EVENT, {
188
214
  roomType: state.name,
@@ -201,10 +227,7 @@ function createJoinedRoom<TRoom extends RoomDefinition<any>>(
201
227
  return state.presenceCurrent as PresenceFor<TRoom>;
202
228
  },
203
229
  onChange(handler: (presence: PresenceFor<TRoom>) => void): () => void {
204
- state.presenceListeners.add(handler);
205
- return () => {
206
- state.presenceListeners.delete(handler);
207
- };
230
+ return registerPresenceListener(state, handler);
208
231
  },
209
232
  count(): Promise<number> {
210
233
  return emitAck<number>(socket, PRESENCE_QUERY_EVENT, {
@@ -229,6 +252,32 @@ function createJoinedRoom<TRoom extends RoomDefinition<any>>(
229
252
  }) as JoinedRoom<TRoom>;
230
253
  }
231
254
 
255
+ function registerEventListener<TRoom extends RoomDefinition<any>>(
256
+ state: JoinedRoomState<TRoom>,
257
+ key: string,
258
+ handler: (payload: unknown, meta: EventMetaFor<TRoom>) => void,
259
+ ): () => void {
260
+ const handlers = state.eventListeners.get(key) ?? new Set();
261
+ handlers.add(handler);
262
+ state.eventListeners.set(key, handlers);
263
+ return () => {
264
+ handlers.delete(handler);
265
+ if (handlers.size === 0) {
266
+ state.eventListeners.delete(key);
267
+ }
268
+ };
269
+ }
270
+
271
+ function registerPresenceListener<TRoom extends RoomDefinition<any>>(
272
+ state: JoinedRoomState<TRoom>,
273
+ handler: (presence: PresenceFor<TRoom>) => void,
274
+ ): () => void {
275
+ state.presenceListeners.add(handler);
276
+ return () => {
277
+ state.presenceListeners.delete(handler);
278
+ };
279
+ }
280
+
232
281
  function getClientRegistry(socket: ClientSocketLike): ClientRegistry {
233
282
  const existing = clientRegistries.get(socket);
234
283
  if (existing) {
@@ -268,6 +317,19 @@ function installClientHandlers(socket: ClientSocketLike, registry: ClientRegistr
268
317
  source: unknown;
269
318
  };
270
319
  }) => {
320
+ if (
321
+ !frame ||
322
+ typeof frame !== "object" ||
323
+ typeof frame.roomType !== "string" ||
324
+ typeof frame.roomId !== "string" ||
325
+ typeof frame.name !== "string" ||
326
+ !frame.meta ||
327
+ typeof frame.meta !== "object" ||
328
+ typeof frame.meta.sentAt !== "string"
329
+ ) {
330
+ return;
331
+ }
332
+
271
333
  const state = registry.joinedRooms.get(makeJoinedRoomKey(frame.roomType, frame.roomId));
272
334
  if (!state) {
273
335
  return;
@@ -284,7 +346,11 @@ function installClientHandlers(socket: ClientSocketLike, registry: ClientRegistr
284
346
  } as EventMetaFor<any>;
285
347
 
286
348
  for (const handler of handlers) {
287
- handler(frame.payload, meta);
349
+ try {
350
+ handler(frame.payload, meta);
351
+ } catch {
352
+ // Swallow per-listener errors to avoid breaking the event loop
353
+ }
288
354
  }
289
355
  };
290
356
 
@@ -294,6 +360,15 @@ function installClientHandlers(socket: ClientSocketLike, registry: ClientRegistr
294
360
 
295
361
  if (!registry.presenceHandlerInstalled) {
296
362
  const onPresence = (frame: { roomType: string; roomId: string; presence: unknown }) => {
363
+ if (
364
+ !frame ||
365
+ typeof frame !== "object" ||
366
+ typeof frame.roomType !== "string" ||
367
+ typeof frame.roomId !== "string"
368
+ ) {
369
+ return;
370
+ }
371
+
297
372
  const state = registry.joinedRooms.get(makeJoinedRoomKey(frame.roomType, frame.roomId));
298
373
  if (!state) {
299
374
  return;
@@ -305,7 +380,11 @@ function installClientHandlers(socket: ClientSocketLike, registry: ClientRegistr
305
380
  }
306
381
 
307
382
  for (const handler of state.presenceListeners) {
308
- handler(state.presenceCurrent);
383
+ try {
384
+ handler(state.presenceCurrent);
385
+ } catch {
386
+ // Swallow per-listener errors
387
+ }
309
388
  }
310
389
  };
311
390
 
@@ -405,7 +484,13 @@ function makeJoinedRoomKey(name: string, roomId: string): string {
405
484
 
406
485
  function emitAck<TValue>(socket: ClientSocketLike, eventName: string, payload: unknown): Promise<TValue> {
407
486
  return new Promise<TValue>((resolve, reject) => {
487
+ const timer = setTimeout(() => {
488
+ reject(new Error(`Acknowledgement timeout for '${eventName}'`));
489
+ }, DEFAULT_ACK_TIMEOUT_MS);
490
+
408
491
  socket.emit(eventName, payload, (result: { ok: true; value: TValue } | { ok: false; error: string }) => {
492
+ clearTimeout(timer);
493
+
409
494
  if (!result || typeof result !== "object") {
410
495
  reject(new Error("Invalid acknowledgement payload"));
411
496
  return;
@@ -428,6 +513,10 @@ function setConnectionState(registry: ClientRegistry, next: ClientConnectionStat
428
513
 
429
514
  registry.connectionState = next;
430
515
  for (const listener of registry.connectionListeners) {
431
- listener(next);
516
+ try {
517
+ listener(next);
518
+ } catch {
519
+ // Swallow per-listener errors
520
+ }
432
521
  }
433
522
  }