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.
@@ -0,0 +1,200 @@
1
+ import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ import { Server } from "socket.io";
8
+
9
+ import { ClientSafeError, serveRoomType } from "../src/index";
10
+ import { chatRoomType, type ChatMessage } from "./common";
11
+
12
+ type ChatAuth = {
13
+ userId: string;
14
+ };
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+ const port = Number(process.env.PORT || 3000);
19
+ const publicDir = path.join(__dirname, "public");
20
+
21
+ function normalizeRoomId(value: unknown): string {
22
+ if (typeof value !== "string" || !value.trim()) {
23
+ throw new ClientSafeError("Room id is required.");
24
+ }
25
+
26
+ return value.trim().toLowerCase();
27
+ }
28
+
29
+ function normalizeRoomKey(value: unknown): string {
30
+ if (typeof value !== "string" || !value.trim()) {
31
+ throw new ClientSafeError("Room key is required.");
32
+ }
33
+
34
+ return value.trim();
35
+ }
36
+
37
+ function normalizeUserId(value: unknown): string {
38
+ if (typeof value !== "string" || !value.trim()) {
39
+ throw new ClientSafeError("User id is required.");
40
+ }
41
+
42
+ return value.trim();
43
+ }
44
+
45
+ function normalizeDisplayName(value: unknown): string {
46
+ if (typeof value !== "string" || !value.trim()) {
47
+ throw new ClientSafeError("Display name is required.");
48
+ }
49
+
50
+ const name = value.trim();
51
+ if (name.length > 32) {
52
+ throw new ClientSafeError("Display name must be 32 characters or fewer.");
53
+ }
54
+
55
+ return name;
56
+ }
57
+
58
+ function normalizeMessageText(value: unknown): string {
59
+ if (typeof value !== "string" || !value.trim()) {
60
+ throw new ClientSafeError("Message text is required.");
61
+ }
62
+
63
+ const text = value.trim();
64
+ if (text.length > 500) {
65
+ throw new ClientSafeError("Message text must be 500 characters or fewer.");
66
+ }
67
+
68
+ return text;
69
+ }
70
+
71
+ 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
+ });
95
+ }
96
+
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
+ }
104
+
105
+ if (requestUrl.pathname === "/styles.css") {
106
+ serveFile(path.join(publicDir, "styles.css"), res);
107
+ return;
108
+ }
109
+
110
+ if (requestUrl.pathname === "/app.js") {
111
+ serveFile(path.join(publicDir, "app.js"), res);
112
+ return;
113
+ }
114
+
115
+ res.statusCode = 404;
116
+ res.end("Not found");
117
+ });
118
+
119
+ const io = new Server(server);
120
+
121
+ 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
+ });
196
+ });
197
+
198
+ server.listen(port, "127.0.0.1", () => {
199
+ console.log(`Chat example listening on http://127.0.0.1:${port}`);
200
+ });
package/jsr.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@onlycliches/room-kit",
3
+ "version": "1.0.0",
4
+ "description": "Type-safe channel primitives for Socket.IO events, requests, streams, and room membership.",
5
+ "exports": "./src/index.ts",
6
+ "publish": {
7
+ "include": [
8
+ "LICENSE",
9
+ "README.md",
10
+ "src/**/*.ts"
11
+ ]
12
+ }
13
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "room-kit",
3
+ "version": "1.0.0",
4
+ "description": "Type-safe room membership, presence, and realtime messaging for Socket.IO.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "module": "dist/index.js",
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.json",
11
+ "test": "vitest run"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/only-cliches/room-kit.git"
16
+ },
17
+ "keywords": [
18
+ "socket.io",
19
+ "rooms",
20
+ "presence",
21
+ "membership",
22
+ "typescript",
23
+ "realtime",
24
+ "private rooms",
25
+ "rpc",
26
+ "events",
27
+ "pubsub"
28
+ ],
29
+ "author": "Scott Lott <me@scottlott.com>",
30
+ "license": "MIT",
31
+ "bugs": {
32
+ "url": "https://github.com/only-cliches/room-kit/issues"
33
+ },
34
+ "homepage": "https://github.com/only-cliches/room-kit#readme",
35
+ "dependencies": {},
36
+ "devDependencies": {
37
+ "socket.io": "^4.8.3",
38
+ "socket.io-client": "^4.8.3",
39
+ "typescript": "^6.0.2",
40
+ "vitest": "^4.1.1"
41
+ }
42
+ }
package/src/client.ts ADDED
@@ -0,0 +1,433 @@
1
+ import type {
2
+ ClientConnectionState,
3
+ ClientSocketLike,
4
+ EventEmitApi,
5
+ EventListenApi,
6
+ EventMetaFor,
7
+ JoinedRoom,
8
+ JoinRequest,
9
+ PresenceFor,
10
+ PresenceListQuery,
11
+ PresencePageFor,
12
+ PresenceValueFor,
13
+ RoomClient,
14
+ RoomDefinition,
15
+ RoomEvents,
16
+ RoomProfileFor,
17
+ RoomRpc,
18
+ RpcClientApi,
19
+ } from "./types";
20
+
21
+ const JOIN_EVENT = "room-kit:join";
22
+ const LEAVE_EVENT = "room-kit:leave";
23
+ const RPC_EVENT = "room-kit:rpc";
24
+ const CLIENT_EVENT = "room-kit:client-event";
25
+ const SERVER_EVENT = "room-kit:server-event";
26
+ const PRESENCE_EVENT = "room-kit:presence";
27
+ const PRESENCE_QUERY_EVENT = "room-kit:presence-query";
28
+
29
+ type ClientRegistry = {
30
+ serverHandlerInstalled: boolean;
31
+ presenceHandlerInstalled: boolean;
32
+ reconnectHandlerInstalled: boolean;
33
+ disconnectHandlerInstalled: boolean;
34
+ connectErrorHandlerInstalled: boolean;
35
+ reconnectAttemptHandlerInstalled: boolean;
36
+ reconnectErrorHandlerInstalled: boolean;
37
+ reconnectFailedHandlerInstalled: boolean;
38
+ hasConnectedOnce: boolean;
39
+ connectionState: ClientConnectionState;
40
+ connectionListeners: Set<(state: ClientConnectionState) => void>;
41
+ joinedRooms: Map<string, JoinedRoomState<any>>;
42
+ };
43
+
44
+ type JoinedRoomState<TRoom extends RoomDefinition<any>> = {
45
+ name: string;
46
+ roomId: string;
47
+ memberId: string;
48
+ roomProfile: RoomProfileFor<TRoom>;
49
+ presenceCurrent: PresenceValueFor<TRoom>;
50
+ joinRequest: JoinRequest<TRoom>;
51
+ eventListeners: Map<string, Set<(payload: unknown, meta: EventMetaFor<TRoom>) => void>>;
52
+ presenceListeners: Set<(presence: PresenceFor<TRoom>) => void>;
53
+ };
54
+
55
+ const clientRegistries = new WeakMap<ClientSocketLike, ClientRegistry>();
56
+
57
+ /**
58
+ * Binds a client socket to a room type and returns a typed room client.
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * import { io } from "socket.io-client";
63
+ * import { createRoomClient } from "room-kit";
64
+ *
65
+ * const socket = io("http://127.0.0.1:3000");
66
+ * const client = createRoomClient(socket, chatRoomType);
67
+ * const joined = await client.join({ roomId: "team", roomKey: "secret", userName: "Ada" });
68
+ * ```
69
+ */
70
+ export function createRoomClient<TRoom extends RoomDefinition<any>>(
71
+ socket: ClientSocketLike,
72
+ room: TRoom,
73
+ ): RoomClient<TRoom> {
74
+ return {
75
+ name: room.name,
76
+ get connection() {
77
+ const registry = getClientRegistry(socket);
78
+ return {
79
+ get current(): ClientConnectionState {
80
+ return registry.connectionState;
81
+ },
82
+ onChange(handler: (state: ClientConnectionState) => void): () => void {
83
+ registry.connectionListeners.add(handler);
84
+ return () => {
85
+ registry.connectionListeners.delete(handler);
86
+ };
87
+ },
88
+ };
89
+ },
90
+ join(payload: JoinRequest<TRoom>): Promise<JoinedRoom<TRoom>> {
91
+ const registry = getClientRegistry(socket);
92
+
93
+ return emitAck<{
94
+ roomId: string;
95
+ memberId: string;
96
+ roomProfile: RoomProfileFor<TRoom>;
97
+ presence: PresenceValueFor<TRoom>;
98
+ }>(socket, JOIN_EVENT, {
99
+ roomType: room.name,
100
+ payload,
101
+ }).then((value) => {
102
+ const state: JoinedRoomState<TRoom> = {
103
+ name: room.name,
104
+ roomId: value.roomId,
105
+ memberId: value.memberId,
106
+ roomProfile: value.roomProfile,
107
+ presenceCurrent: value.presence,
108
+ joinRequest: payload,
109
+ eventListeners: new Map(),
110
+ presenceListeners: new Set(),
111
+ };
112
+
113
+ registry.joinedRooms.set(makeJoinedRoomKey(room.name, value.roomId), state);
114
+ return createJoinedRoom(socket, state);
115
+ });
116
+ },
117
+ };
118
+ }
119
+
120
+ function createJoinedRoom<TRoom extends RoomDefinition<any>>(
121
+ socket: ClientSocketLike,
122
+ state: JoinedRoomState<TRoom>,
123
+ ): JoinedRoom<TRoom> {
124
+ const rpc = new Proxy({} as RpcClientApi<TRoom>, {
125
+ get(_target, key) {
126
+ if (typeof key !== "string") {
127
+ return undefined;
128
+ }
129
+
130
+ return (...args: unknown[]) => {
131
+ return emitAck(socket, RPC_EVENT, {
132
+ roomType: state.name,
133
+ roomId: state.roomId,
134
+ name: key,
135
+ args,
136
+ });
137
+ };
138
+ },
139
+ });
140
+
141
+ const emit = new Proxy({} as EventEmitApi<TRoom>, {
142
+ get(_target, key) {
143
+ if (typeof key !== "string") {
144
+ return undefined;
145
+ }
146
+
147
+ return (payload: unknown) => {
148
+ return emitAck<void>(socket, CLIENT_EVENT, {
149
+ roomType: state.name,
150
+ roomId: state.roomId,
151
+ name: key,
152
+ payload,
153
+ });
154
+ };
155
+ },
156
+ });
157
+
158
+ const on = new Proxy({} as EventListenApi<TRoom>, {
159
+ get(_target, key) {
160
+ if (typeof key !== "string") {
161
+ return undefined;
162
+ }
163
+
164
+ 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
+ };
174
+ };
175
+ },
176
+ });
177
+
178
+ const base = {
179
+ name: state.name,
180
+ roomId: state.roomId,
181
+ memberId: state.memberId,
182
+ roomProfile: state.roomProfile,
183
+ rpc,
184
+ emit,
185
+ on,
186
+ async leave(): Promise<void> {
187
+ await emitAck<void>(socket, LEAVE_EVENT, {
188
+ roomType: state.name,
189
+ roomId: state.roomId,
190
+ });
191
+ const registry = getClientRegistry(socket);
192
+ registry.joinedRooms.delete(makeJoinedRoomKey(state.name, state.roomId));
193
+ },
194
+ };
195
+
196
+ return new Proxy(base, {
197
+ get(target, key, receiver) {
198
+ if (key === "presence") {
199
+ return {
200
+ get current(): PresenceFor<TRoom> {
201
+ return state.presenceCurrent as PresenceFor<TRoom>;
202
+ },
203
+ onChange(handler: (presence: PresenceFor<TRoom>) => void): () => void {
204
+ state.presenceListeners.add(handler);
205
+ return () => {
206
+ state.presenceListeners.delete(handler);
207
+ };
208
+ },
209
+ count(): Promise<number> {
210
+ return emitAck<number>(socket, PRESENCE_QUERY_EVENT, {
211
+ roomType: state.name,
212
+ roomId: state.roomId,
213
+ kind: "count",
214
+ });
215
+ },
216
+ list(query: PresenceListQuery = {}): Promise<PresencePageFor<TRoom>> {
217
+ return emitAck<PresencePageFor<TRoom>>(socket, PRESENCE_QUERY_EVENT, {
218
+ roomType: state.name,
219
+ roomId: state.roomId,
220
+ kind: "list",
221
+ ...query,
222
+ });
223
+ },
224
+ };
225
+ }
226
+
227
+ return Reflect.get(target, key, receiver);
228
+ },
229
+ }) as JoinedRoom<TRoom>;
230
+ }
231
+
232
+ function getClientRegistry(socket: ClientSocketLike): ClientRegistry {
233
+ const existing = clientRegistries.get(socket);
234
+ if (existing) {
235
+ return existing;
236
+ }
237
+
238
+ const created: ClientRegistry = {
239
+ serverHandlerInstalled: false,
240
+ presenceHandlerInstalled: false,
241
+ reconnectHandlerInstalled: false,
242
+ disconnectHandlerInstalled: false,
243
+ connectErrorHandlerInstalled: false,
244
+ reconnectAttemptHandlerInstalled: false,
245
+ reconnectErrorHandlerInstalled: false,
246
+ reconnectFailedHandlerInstalled: false,
247
+ hasConnectedOnce: false,
248
+ connectionState: "connecting",
249
+ connectionListeners: new Set(),
250
+ joinedRooms: new Map(),
251
+ };
252
+
253
+ installClientHandlers(socket, created);
254
+ clientRegistries.set(socket, created);
255
+ return created;
256
+ }
257
+
258
+ function installClientHandlers(socket: ClientSocketLike, registry: ClientRegistry): void {
259
+ if (!registry.serverHandlerInstalled) {
260
+ const onServerEvent = (frame: {
261
+ roomType: string;
262
+ roomId: string;
263
+ name: string;
264
+ payload: unknown;
265
+ meta: {
266
+ roomId: string;
267
+ sentAt: string;
268
+ source: unknown;
269
+ };
270
+ }) => {
271
+ const state = registry.joinedRooms.get(makeJoinedRoomKey(frame.roomType, frame.roomId));
272
+ if (!state) {
273
+ return;
274
+ }
275
+
276
+ const handlers = state.eventListeners.get(frame.name);
277
+ if (!handlers || handlers.size === 0) {
278
+ return;
279
+ }
280
+
281
+ const meta = {
282
+ ...frame.meta,
283
+ sentAt: new Date(frame.meta.sentAt),
284
+ } as EventMetaFor<any>;
285
+
286
+ for (const handler of handlers) {
287
+ handler(frame.payload, meta);
288
+ }
289
+ };
290
+
291
+ socket.on(SERVER_EVENT, onServerEvent);
292
+ registry.serverHandlerInstalled = true;
293
+ }
294
+
295
+ if (!registry.presenceHandlerInstalled) {
296
+ const onPresence = (frame: { roomType: string; roomId: string; presence: unknown }) => {
297
+ const state = registry.joinedRooms.get(makeJoinedRoomKey(frame.roomType, frame.roomId));
298
+ if (!state) {
299
+ return;
300
+ }
301
+
302
+ state.presenceCurrent = frame.presence as PresenceFor<any>;
303
+ if (state.presenceCurrent === undefined) {
304
+ return;
305
+ }
306
+
307
+ for (const handler of state.presenceListeners) {
308
+ handler(state.presenceCurrent);
309
+ }
310
+ };
311
+
312
+ socket.on(PRESENCE_EVENT, onPresence);
313
+ registry.presenceHandlerInstalled = true;
314
+ }
315
+
316
+ if (!registry.reconnectHandlerInstalled) {
317
+ const onConnect = () => {
318
+ registry.hasConnectedOnce = true;
319
+ setConnectionState(registry, "connected");
320
+ void replayJoinedRooms(socket, registry);
321
+ };
322
+
323
+ socket.on("connect", onConnect);
324
+ registry.reconnectHandlerInstalled = true;
325
+ }
326
+
327
+ if (!registry.disconnectHandlerInstalled) {
328
+ const onDisconnect = () => {
329
+ setConnectionState(registry, "disconnected");
330
+ };
331
+
332
+ socket.on("disconnect", onDisconnect);
333
+ registry.disconnectHandlerInstalled = true;
334
+ }
335
+
336
+ if (!registry.connectErrorHandlerInstalled) {
337
+ const onConnectError = () => {
338
+ setConnectionState(registry, registry.hasConnectedOnce ? "reconnecting" : "connecting");
339
+ };
340
+
341
+ socket.on("connect_error", onConnectError);
342
+ registry.connectErrorHandlerInstalled = true;
343
+ }
344
+
345
+ if (!registry.reconnectAttemptHandlerInstalled) {
346
+ const onReconnectAttempt = () => {
347
+ setConnectionState(registry, "reconnecting");
348
+ };
349
+
350
+ socket.on("reconnect_attempt", onReconnectAttempt);
351
+ registry.reconnectAttemptHandlerInstalled = true;
352
+ }
353
+
354
+ if (!registry.reconnectFailedHandlerInstalled) {
355
+ const onReconnectFailed = () => {
356
+ setConnectionState(registry, "disconnected");
357
+ };
358
+
359
+ socket.on("reconnect_failed", onReconnectFailed);
360
+ registry.reconnectFailedHandlerInstalled = true;
361
+ }
362
+
363
+ if (!registry.reconnectErrorHandlerInstalled) {
364
+ const onReconnectError = () => {
365
+ setConnectionState(registry, "reconnecting");
366
+ };
367
+
368
+ socket.on("reconnect_error", onReconnectError);
369
+ registry.reconnectErrorHandlerInstalled = true;
370
+ }
371
+ }
372
+
373
+ async function replayJoinedRooms(socket: ClientSocketLike, registry: ClientRegistry): Promise<void> {
374
+ for (const [key, state] of Array.from(registry.joinedRooms.entries())) {
375
+ try {
376
+ const value = await emitAck<{
377
+ roomId: string;
378
+ memberId: string;
379
+ roomProfile: RoomProfileFor<any>;
380
+ presence: PresenceFor<any>;
381
+ }>(socket, JOIN_EVENT, {
382
+ roomType: state.name,
383
+ payload: state.joinRequest,
384
+ });
385
+
386
+ state.roomId = value.roomId;
387
+ state.memberId = value.memberId;
388
+ state.roomProfile = value.roomProfile;
389
+ state.presenceCurrent = value.presence;
390
+
391
+ const newKey = makeJoinedRoomKey(state.name, value.roomId);
392
+ if (newKey !== key) {
393
+ registry.joinedRooms.delete(key);
394
+ }
395
+ registry.joinedRooms.set(newKey, state);
396
+ } catch {
397
+ registry.joinedRooms.delete(key);
398
+ }
399
+ }
400
+ }
401
+
402
+ function makeJoinedRoomKey(name: string, roomId: string): string {
403
+ return `${name}:${roomId}`;
404
+ }
405
+
406
+ function emitAck<TValue>(socket: ClientSocketLike, eventName: string, payload: unknown): Promise<TValue> {
407
+ return new Promise<TValue>((resolve, reject) => {
408
+ socket.emit(eventName, payload, (result: { ok: true; value: TValue } | { ok: false; error: string }) => {
409
+ if (!result || typeof result !== "object") {
410
+ reject(new Error("Invalid acknowledgement payload"));
411
+ return;
412
+ }
413
+
414
+ if (result.ok) {
415
+ resolve(result.value);
416
+ return;
417
+ }
418
+
419
+ reject(new Error(result.error));
420
+ });
421
+ });
422
+ }
423
+
424
+ function setConnectionState(registry: ClientRegistry, next: ClientConnectionState): void {
425
+ if (registry.connectionState === next) {
426
+ return;
427
+ }
428
+
429
+ registry.connectionState = next;
430
+ for (const listener of registry.connectionListeners) {
431
+ listener(next);
432
+ }
433
+ }