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/README.md +25 -10
- package/example/public/app.ts +184 -187
- package/example/server.ts +173 -146
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/client.ts +105 -16
- package/src/server.ts +189 -25
- package/src/types.ts +28 -4
- package/test/room.spec.ts +241 -78
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
23
|
+
throw new ClientSafeError("Room id is required.");
|
|
24
|
+
}
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
return value.trim().toLowerCase();
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function normalizeRoomKey(value: unknown): string {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
31
|
+
throw new ClientSafeError("Room key is required.");
|
|
32
|
+
}
|
|
33
33
|
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
44
|
+
throw new ClientSafeError("User id is required.");
|
|
45
|
+
}
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
return value.trim();
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
function normalizeDisplayName(value: unknown): string {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
52
|
+
throw new ClientSafeError("Display name is required.");
|
|
53
|
+
}
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
60
|
+
return name;
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
function normalizeMessageText(value: unknown): string {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
65
|
+
throw new ClientSafeError("Message text is required.");
|
|
66
|
+
}
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
73
|
+
return text;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
function serveFile(filePath: string, res: http.ServerResponse): void {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
if (staticFile) {
|
|
121
|
+
serveFile(path.join(publicDir, staticFile), res);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
114
124
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
226
|
+
console.log(`Chat example listening on http://127.0.0.1:${port}`);
|
|
200
227
|
});
|
package/jsr.json
CHANGED
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
+
try {
|
|
517
|
+
listener(next);
|
|
518
|
+
} catch {
|
|
519
|
+
// Swallow per-listener errors
|
|
520
|
+
}
|
|
432
521
|
}
|
|
433
522
|
}
|