@snakechat/commskit-node 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/adapters-8ztjEYtz.d.ts +83 -0
- package/dist/call.d.ts +34 -0
- package/dist/call.js +51 -0
- package/dist/call.js.map +1 -0
- package/dist/chat.d.ts +64 -0
- package/dist/chat.js +30 -0
- package/dist/chat.js.map +1 -0
- package/dist/chunk-5BJ2SNM5.js +26 -0
- package/dist/chunk-5BJ2SNM5.js.map +1 -0
- package/dist/chunk-6I27CI32.js +586 -0
- package/dist/chunk-6I27CI32.js.map +1 -0
- package/dist/chunk-BBFVG7QU.js +52 -0
- package/dist/chunk-BBFVG7QU.js.map +1 -0
- package/dist/chunk-GR4PMGCS.js +82 -0
- package/dist/chunk-GR4PMGCS.js.map +1 -0
- package/dist/chunk-KL4WRZKZ.js +799 -0
- package/dist/chunk-KL4WRZKZ.js.map +1 -0
- package/dist/chunk-KLKRK6OG.js +107 -0
- package/dist/chunk-KLKRK6OG.js.map +1 -0
- package/dist/chunk-LY2GJWN7.js +708 -0
- package/dist/chunk-LY2GJWN7.js.map +1 -0
- package/dist/chunk-SEDRF62Q.js +39 -0
- package/dist/chunk-SEDRF62Q.js.map +1 -0
- package/dist/chunk-U4Z5INIW.js +1192 -0
- package/dist/chunk-U4Z5INIW.js.map +1 -0
- package/dist/chunk-XOXZZYSW.js +247 -0
- package/dist/chunk-XOXZZYSW.js.map +1 -0
- package/dist/chunk-XQ6FLRX7.js +179 -0
- package/dist/chunk-XQ6FLRX7.js.map +1 -0
- package/dist/chunk-XQFQN73U.js +38 -0
- package/dist/chunk-XQFQN73U.js.map +1 -0
- package/dist/connections-CGEPiZPF.d.ts +128 -0
- package/dist/core.d.ts +26 -0
- package/dist/core.js +89 -0
- package/dist/core.js.map +1 -0
- package/dist/devices-oLIB_M19.d.ts +97 -0
- package/dist/express.d.ts +19 -0
- package/dist/express.js +34 -0
- package/dist/express.js.map +1 -0
- package/dist/fastify.d.ts +22 -0
- package/dist/fastify.js +42 -0
- package/dist/fastify.js.map +1 -0
- package/dist/feature-B2bEXLZG.d.ts +233 -0
- package/dist/feature-DQUbnYE-.d.ts +284 -0
- package/dist/feature-DwKkuOBh.d.ts +195 -0
- package/dist/hub-CPrWmIvB.d.ts +174 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.d.ts +97 -0
- package/dist/integration.js +151 -0
- package/dist/integration.js.map +1 -0
- package/dist/models-D1lehXIu.d.ts +31 -0
- package/dist/models-DTXEX1Q8.d.ts +34 -0
- package/dist/moderation.d.ts +9 -0
- package/dist/moderation.js +48 -0
- package/dist/moderation.js.map +1 -0
- package/dist/push.d.ts +198 -0
- package/dist/push.js +475 -0
- package/dist/push.js.map +1 -0
- package/dist/redis.d.ts +103 -0
- package/dist/redis.js +296 -0
- package/dist/redis.js.map +1 -0
- package/dist/s3.d.ts +75 -0
- package/dist/s3.js +315 -0
- package/dist/s3.js.map +1 -0
- package/dist/util-B32rIBVA.d.ts +22 -0
- package/package.json +148 -6
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NoopPush,
|
|
3
|
+
NullUserDirectory,
|
|
4
|
+
resolveUserId
|
|
5
|
+
} from "./chunk-XQ6FLRX7.js";
|
|
6
|
+
import {
|
|
7
|
+
HttpError,
|
|
8
|
+
detectDialect,
|
|
9
|
+
matchPath
|
|
10
|
+
} from "./chunk-GR4PMGCS.js";
|
|
11
|
+
import {
|
|
12
|
+
RateLimiter
|
|
13
|
+
} from "./chunk-BBFVG7QU.js";
|
|
14
|
+
import {
|
|
15
|
+
LRU,
|
|
16
|
+
TaskSet,
|
|
17
|
+
formatIso,
|
|
18
|
+
nowIso,
|
|
19
|
+
utcnow
|
|
20
|
+
} from "./chunk-KLKRK6OG.js";
|
|
21
|
+
import {
|
|
22
|
+
logger,
|
|
23
|
+
setLogger
|
|
24
|
+
} from "./chunk-5BJ2SNM5.js";
|
|
25
|
+
|
|
26
|
+
// src/core/compat.ts
|
|
27
|
+
var PONG_TYPE = "PONG";
|
|
28
|
+
|
|
29
|
+
// src/core/connections.ts
|
|
30
|
+
import { randomBytes } from "crypto";
|
|
31
|
+
var USER_ROLE = "user";
|
|
32
|
+
var OPEN = 1;
|
|
33
|
+
function sendText(ws, text) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
if (ws.readyState !== OPEN) return reject(new Error("socket not open"));
|
|
36
|
+
try {
|
|
37
|
+
ws.send(text, (err) => err ? reject(err) : resolve());
|
|
38
|
+
} catch (err) {
|
|
39
|
+
reject(err);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
var ConnectionManager = class {
|
|
44
|
+
bridge;
|
|
45
|
+
serverId;
|
|
46
|
+
/** A user may hold several sockets (devices / screens); all get every frame. */
|
|
47
|
+
userSockets = /* @__PURE__ */ new Map();
|
|
48
|
+
activeConnections = /* @__PURE__ */ new Map();
|
|
49
|
+
roles = /* @__PURE__ */ new Map();
|
|
50
|
+
rooms = /* @__PURE__ */ new Map();
|
|
51
|
+
userRooms = /* @__PURE__ */ new Map();
|
|
52
|
+
/** user.came_online: 0 = only dedupe concurrent connects; > 0 = at most once per cooldown (seconds). */
|
|
53
|
+
cameOnlineCooldown;
|
|
54
|
+
cameOnlineDedupeSeconds;
|
|
55
|
+
cameOnlineAt = new LRU(1e5);
|
|
56
|
+
offlineHooks = [];
|
|
57
|
+
partnersLookup = null;
|
|
58
|
+
tasks = new TaskSet();
|
|
59
|
+
constructor(opts = {}) {
|
|
60
|
+
this.bridge = opts.bridge ?? null;
|
|
61
|
+
this.serverId = opts.serverId ?? `${process.env.HOSTNAME ?? "server"}-${process.pid}-${randomBytes(3).toString("hex")}`;
|
|
62
|
+
this.cameOnlineCooldown = opts.cameOnlineCooldown ?? 0;
|
|
63
|
+
this.cameOnlineDedupeSeconds = opts.cameOnlineDedupeSeconds ?? 10;
|
|
64
|
+
}
|
|
65
|
+
// ----------------------------------------------------------- lifecycle
|
|
66
|
+
/** Start the bridge consumer (call at app start). */
|
|
67
|
+
async start() {
|
|
68
|
+
if (this.bridge) {
|
|
69
|
+
this.bridge.setRoomDeliver?.((room, env) => this.deliverRoomFromBridge(room, env));
|
|
70
|
+
await this.bridge.start((uid, env) => this.deliverFromBridge(uid, env), this.serverId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async stop() {
|
|
74
|
+
await this.tasks.settle();
|
|
75
|
+
if (this.bridge) await this.bridge.stop();
|
|
76
|
+
}
|
|
77
|
+
/** `hook(userId)` runs when the user's last socket on this worker closes (users only unless `allRoles`). */
|
|
78
|
+
addOfflineHook(hook, allRoles = false) {
|
|
79
|
+
this.offlineHooks.push([hook, allRoles]);
|
|
80
|
+
}
|
|
81
|
+
/** Who should receive this user's `user_status` (chat installs this). */
|
|
82
|
+
setPartnersLookup(lookup) {
|
|
83
|
+
this.partnersLookup = lookup;
|
|
84
|
+
}
|
|
85
|
+
roleOf(userId) {
|
|
86
|
+
return this.roles.get(userId) ?? USER_ROLE;
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------- connections
|
|
89
|
+
/**
|
|
90
|
+
* Register an already-accepted socket. Returns true when this connect is the user's
|
|
91
|
+
* offline -> online transition across all workers; always false for non-user roles.
|
|
92
|
+
*/
|
|
93
|
+
async register(userId, ws, role = USER_ROLE) {
|
|
94
|
+
const firstSocket = !this.userSockets.get(userId)?.size;
|
|
95
|
+
let set = this.userSockets.get(userId);
|
|
96
|
+
if (!set) this.userSockets.set(userId, set = /* @__PURE__ */ new Set());
|
|
97
|
+
set.add(ws);
|
|
98
|
+
this.roles.set(userId, role);
|
|
99
|
+
this.activeConnections.set(userId, { userId, status: "online", lastSeen: utcnow() });
|
|
100
|
+
let cameOnline = false;
|
|
101
|
+
if (firstSocket && role === USER_ROLE) cameOnline = await this.claimCameOnline(userId);
|
|
102
|
+
if (this.bridge) {
|
|
103
|
+
await this.bridge.setOnline(userId, this.serverId);
|
|
104
|
+
await this.bridge.setAway(userId, false);
|
|
105
|
+
}
|
|
106
|
+
if (firstSocket) await this.broadcastUserStatus(userId, "online");
|
|
107
|
+
return cameOnline;
|
|
108
|
+
}
|
|
109
|
+
async claimCameOnline(userId) {
|
|
110
|
+
if (this.bridge) {
|
|
111
|
+
let others = [];
|
|
112
|
+
try {
|
|
113
|
+
others = (await this.bridge.getUserServers(userId)).filter((s) => s !== this.serverId);
|
|
114
|
+
} catch {
|
|
115
|
+
others = [];
|
|
116
|
+
}
|
|
117
|
+
if (others.length) return false;
|
|
118
|
+
if (this.bridge.claimCameOnline) {
|
|
119
|
+
const ttl = this.cameOnlineCooldown || this.cameOnlineDedupeSeconds;
|
|
120
|
+
try {
|
|
121
|
+
return Boolean(await this.bridge.claimCameOnline(userId, ttl));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
logger.error(`claimCameOnline(${userId}) failed`, err);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (this.cameOnlineCooldown > 0) {
|
|
128
|
+
const now = performance.now() / 1e3;
|
|
129
|
+
const last = this.cameOnlineAt.get(userId);
|
|
130
|
+
if (last !== void 0 && now - last < this.cameOnlineCooldown) return false;
|
|
131
|
+
this.cameOnlineAt.put(userId, now);
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
async heartbeat(userId) {
|
|
136
|
+
if (this.bridge && this.userSockets.get(userId)?.size) await this.bridge.setOnline(userId, this.serverId);
|
|
137
|
+
}
|
|
138
|
+
/** Remove one socket (or all). The user stays online while any socket remains. Idempotent. */
|
|
139
|
+
disconnect(userId, ws) {
|
|
140
|
+
const sockets = this.userSockets.get(userId);
|
|
141
|
+
if (sockets && ws) {
|
|
142
|
+
sockets.delete(ws);
|
|
143
|
+
if (sockets.size) return;
|
|
144
|
+
}
|
|
145
|
+
this.userSockets.delete(userId);
|
|
146
|
+
const role = this.roles.get(userId) ?? USER_ROLE;
|
|
147
|
+
this.roles.delete(userId);
|
|
148
|
+
for (const room of this.userRooms.get(userId) ?? []) {
|
|
149
|
+
const members = this.rooms.get(room);
|
|
150
|
+
if (members) {
|
|
151
|
+
members.delete(userId);
|
|
152
|
+
if (!members.size) this.rooms.delete(room);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
this.userRooms.delete(userId);
|
|
156
|
+
if (!this.activeConnections.delete(userId)) return;
|
|
157
|
+
this.tasks.spawn(() => this.wentOffline(userId, role));
|
|
158
|
+
for (const [hook, allRoles] of this.offlineHooks) {
|
|
159
|
+
if (role === USER_ROLE || allRoles) this.tasks.spawn(async () => hook(userId));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async wentOffline(userId, role) {
|
|
163
|
+
if (this.userSockets.get(userId)?.size) return;
|
|
164
|
+
if (this.bridge) await this.bridge.setOffline(userId, this.serverId);
|
|
165
|
+
if (await this.isConnected(userId)) return;
|
|
166
|
+
if (role !== USER_ROLE) return;
|
|
167
|
+
if (this.bridge?.releaseCameOnline && !this.cameOnlineCooldown) {
|
|
168
|
+
try {
|
|
169
|
+
await this.bridge.releaseCameOnline(userId);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
logger.error(`releaseCameOnline(${userId}) failed`, err);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await this.broadcastStatus(userId, "offline");
|
|
175
|
+
}
|
|
176
|
+
/** Client-reported foreground ("online") / background (anything else). */
|
|
177
|
+
async setStatus(userId, status) {
|
|
178
|
+
const now = utcnow();
|
|
179
|
+
const conn = this.activeConnections.get(userId);
|
|
180
|
+
if (conn) {
|
|
181
|
+
conn.status = status;
|
|
182
|
+
conn.lastSeen = now;
|
|
183
|
+
}
|
|
184
|
+
if (this.bridge) await this.bridge.setAway(userId, status !== "online");
|
|
185
|
+
await this.broadcastUserStatus(userId, status, now);
|
|
186
|
+
}
|
|
187
|
+
// ------------------------------------------------------------- presence
|
|
188
|
+
/** Local only: foreground socket on this worker. */
|
|
189
|
+
isUserOnline(userId) {
|
|
190
|
+
return this.activeConnections.get(userId)?.status === "online";
|
|
191
|
+
}
|
|
192
|
+
/** Cluster-wide: connected somewhere with the app in the foreground. */
|
|
193
|
+
async isOnline(userId) {
|
|
194
|
+
if (this.isUserOnline(userId)) return true;
|
|
195
|
+
return this.bridge ? this.bridge.isOnline(userId) : false;
|
|
196
|
+
}
|
|
197
|
+
/** Cluster-wide: any open socket (foreground or background). */
|
|
198
|
+
async isConnected(userId) {
|
|
199
|
+
if (this.userSockets.get(userId)?.size) return true;
|
|
200
|
+
return this.bridge ? (await this.bridge.getUserServers(userId)).length > 0 : false;
|
|
201
|
+
}
|
|
202
|
+
async getOnlineUsers(userIds) {
|
|
203
|
+
const local = new Set(userIds.filter((u) => this.isUserOnline(u)));
|
|
204
|
+
if (!this.bridge) return local;
|
|
205
|
+
const remote = await this.bridge.getOnlineUsers(userIds.filter((u) => !local.has(u)));
|
|
206
|
+
return /* @__PURE__ */ new Set([...local, ...remote]);
|
|
207
|
+
}
|
|
208
|
+
// ------------------------------------------------------------- delivery
|
|
209
|
+
async sendLocal(userId, text) {
|
|
210
|
+
let delivered = false;
|
|
211
|
+
const dead = [];
|
|
212
|
+
for (const ws of [...this.userSockets.get(userId) ?? []]) {
|
|
213
|
+
try {
|
|
214
|
+
await sendText(ws, text);
|
|
215
|
+
delivered = true;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logger.warn(`Send to ${userId} failed: ${err.message}`);
|
|
218
|
+
dead.push(ws);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const ws of dead) this.disconnect(userId, ws);
|
|
222
|
+
return delivered;
|
|
223
|
+
}
|
|
224
|
+
/** Deliver to every socket of the user on any worker. True if at least one got it. */
|
|
225
|
+
async send(userId, payload) {
|
|
226
|
+
let delivered = await this.sendLocal(userId, JSON.stringify(payload));
|
|
227
|
+
if (this.bridge) {
|
|
228
|
+
const remote = (await this.bridge.getUserServers(userId)).filter((s) => s !== this.serverId);
|
|
229
|
+
if (remote.length) {
|
|
230
|
+
await this.bridge.publish(userId, payload, this.serverId);
|
|
231
|
+
delivered = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return delivered;
|
|
235
|
+
}
|
|
236
|
+
async sendToSocket(ws, payload) {
|
|
237
|
+
await sendText(ws, JSON.stringify(payload));
|
|
238
|
+
}
|
|
239
|
+
/** Every socket on this worker (e.g. host community events). */
|
|
240
|
+
async broadcast(payload) {
|
|
241
|
+
const text = JSON.stringify(payload);
|
|
242
|
+
for (const userId of [...this.userSockets.keys()]) await this.sendLocal(userId, text);
|
|
243
|
+
}
|
|
244
|
+
async deliverFromBridge(userId, envelope) {
|
|
245
|
+
if (envelope && "origin" in envelope && "data" in envelope) {
|
|
246
|
+
if (envelope.origin === this.serverId) return;
|
|
247
|
+
envelope = envelope.data;
|
|
248
|
+
}
|
|
249
|
+
await this.sendLocal(userId, JSON.stringify(envelope));
|
|
250
|
+
}
|
|
251
|
+
// ---------------------------------------------------------------- rooms
|
|
252
|
+
/** Add the user's sockets on this worker to `room` (left automatically on disconnect). False when none. */
|
|
253
|
+
joinRoom(userId, room) {
|
|
254
|
+
if (!this.userSockets.get(userId)?.size) return false;
|
|
255
|
+
if (!this.rooms.has(room)) this.rooms.set(room, /* @__PURE__ */ new Set());
|
|
256
|
+
this.rooms.get(room).add(userId);
|
|
257
|
+
if (!this.userRooms.has(userId)) this.userRooms.set(userId, /* @__PURE__ */ new Set());
|
|
258
|
+
this.userRooms.get(userId).add(room);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
leaveRoom(userId, room) {
|
|
262
|
+
const members = this.rooms.get(room);
|
|
263
|
+
if (members) {
|
|
264
|
+
members.delete(userId);
|
|
265
|
+
if (!members.size) this.rooms.delete(room);
|
|
266
|
+
}
|
|
267
|
+
const rooms = this.userRooms.get(userId);
|
|
268
|
+
if (rooms) {
|
|
269
|
+
rooms.delete(room);
|
|
270
|
+
if (!rooms.size) this.userRooms.delete(userId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Members on this worker. */
|
|
274
|
+
roomMembers(room) {
|
|
275
|
+
return new Set(this.rooms.get(room) ?? []);
|
|
276
|
+
}
|
|
277
|
+
async sendRoomLocal(room, payload) {
|
|
278
|
+
const text = JSON.stringify(payload);
|
|
279
|
+
let reached = 0;
|
|
280
|
+
for (const userId of [...this.rooms.get(room) ?? []]) if (await this.sendLocal(userId, text)) reached += 1;
|
|
281
|
+
return reached;
|
|
282
|
+
}
|
|
283
|
+
/** Send `payload` unchanged to every member on every worker. Returns how many local members got it. */
|
|
284
|
+
async broadcastRoom(room, payload) {
|
|
285
|
+
const reached = await this.sendRoomLocal(room, payload);
|
|
286
|
+
if (this.bridge?.publishRoom) await this.bridge.publishRoom(room, payload, this.serverId);
|
|
287
|
+
return reached;
|
|
288
|
+
}
|
|
289
|
+
async deliverRoomFromBridge(room, envelope) {
|
|
290
|
+
if (envelope && "origin" in envelope && "data" in envelope) {
|
|
291
|
+
if (envelope.origin === this.serverId) return;
|
|
292
|
+
envelope = envelope.data;
|
|
293
|
+
}
|
|
294
|
+
await this.sendRoomLocal(room, envelope);
|
|
295
|
+
}
|
|
296
|
+
// ------------------------------------------------------------- presence
|
|
297
|
+
/** Presence goes only to the user's conversation partners (never for non-user roles). */
|
|
298
|
+
async broadcastUserStatus(userId, status, timestamp) {
|
|
299
|
+
if (this.roleOf(userId) !== USER_ROLE) return;
|
|
300
|
+
await this.broadcastStatus(userId, status, timestamp);
|
|
301
|
+
}
|
|
302
|
+
async broadcastStatus(userId, status, timestamp) {
|
|
303
|
+
if (!this.partnersLookup) return;
|
|
304
|
+
const payload = { type: "user_status", user_id: userId, status, timestamp: formatIso(timestamp ?? utcnow()) };
|
|
305
|
+
let partners;
|
|
306
|
+
try {
|
|
307
|
+
partners = [...await this.partnersLookup(userId)];
|
|
308
|
+
} catch (err) {
|
|
309
|
+
logger.error(`Partner lookup for ${userId} failed`, err);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
for (const partnerId of partners) await this.send(partnerId, payload);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// src/core/events.ts
|
|
317
|
+
var EventBus = class {
|
|
318
|
+
listeners = /* @__PURE__ */ new Map();
|
|
319
|
+
on(name, listener) {
|
|
320
|
+
const list = this.listeners.get(name) ?? [];
|
|
321
|
+
list.push(listener);
|
|
322
|
+
this.listeners.set(name, list);
|
|
323
|
+
return listener;
|
|
324
|
+
}
|
|
325
|
+
off(name, listener) {
|
|
326
|
+
const list = this.listeners.get(name);
|
|
327
|
+
if (!list) return;
|
|
328
|
+
const i = list.indexOf(listener);
|
|
329
|
+
if (i >= 0) list.splice(i, 1);
|
|
330
|
+
}
|
|
331
|
+
/** Run listeners in registration order; a failing listener is logged, not thrown. */
|
|
332
|
+
async emit(name, ...args) {
|
|
333
|
+
for (const listener of [...this.listeners.get(name) ?? []]) {
|
|
334
|
+
try {
|
|
335
|
+
await listener(...args);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
logger.error(`Listener for ${name} failed`, err);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// src/core/websocket.ts
|
|
344
|
+
import { WebSocketServer } from "ws";
|
|
345
|
+
var WsEndpoint = class {
|
|
346
|
+
constructor(hub) {
|
|
347
|
+
this.hub = hub;
|
|
348
|
+
}
|
|
349
|
+
hub;
|
|
350
|
+
wss = new WebSocketServer({ noServer: true });
|
|
351
|
+
/** Token from `/ws/<token>` (query string ignored), or null. */
|
|
352
|
+
match(url) {
|
|
353
|
+
if (!url) return null;
|
|
354
|
+
const pathOnly = url.split("?", 1)[0];
|
|
355
|
+
const m = /^\/ws\/([^/]+)$/.exec(pathOnly);
|
|
356
|
+
if (!m) return null;
|
|
357
|
+
try {
|
|
358
|
+
return decodeURIComponent(m[1]);
|
|
359
|
+
} catch {
|
|
360
|
+
return m[1];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
handleUpgrade(req, socket, head) {
|
|
364
|
+
const token = this.match(req.url);
|
|
365
|
+
if (token === null) {
|
|
366
|
+
socket.destroy();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws, token));
|
|
370
|
+
}
|
|
371
|
+
closeAll() {
|
|
372
|
+
for (const ws of this.wss.clients) ws.terminate();
|
|
373
|
+
}
|
|
374
|
+
onSocket(ws, token) {
|
|
375
|
+
const hub = this.hub;
|
|
376
|
+
let userId = null;
|
|
377
|
+
let heartbeat = null;
|
|
378
|
+
let chain = (async () => {
|
|
379
|
+
try {
|
|
380
|
+
userId = await resolveUserId(hub.auth, token);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
logger.info(`WS auth failed: ${err.message}`);
|
|
383
|
+
ws.close(1008, "Invalid token");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const role = await hub.roleOf(userId);
|
|
388
|
+
const cameOnline = await hub.connections.register(userId, ws, role);
|
|
389
|
+
await hub.runConnectHooks(userId, ws, role, cameOnline);
|
|
390
|
+
const bridge = hub.connections.bridge;
|
|
391
|
+
if (bridge) {
|
|
392
|
+
const uid = userId;
|
|
393
|
+
heartbeat = setInterval(() => void hub.connections.heartbeat(uid).catch(() => void 0), bridge.heartbeatSeconds * 1e3);
|
|
394
|
+
heartbeat.unref();
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logger.error(`WS setup for ${userId} crashed`, err);
|
|
398
|
+
ws.close(1011, "Internal server error");
|
|
399
|
+
}
|
|
400
|
+
})();
|
|
401
|
+
ws.on("message", (data) => {
|
|
402
|
+
chain = chain.then(async () => {
|
|
403
|
+
if (userId !== null) await this.handleFrame(userId, ws, rawToString(data));
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
ws.on("error", (err) => logger.warn(`WS error: ${err.message}`));
|
|
407
|
+
ws.on("close", () => {
|
|
408
|
+
chain = chain.then(() => {
|
|
409
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
410
|
+
if (userId !== null) hub.connections.disconnect(userId, ws);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async handleFrame(userId, ws, raw) {
|
|
415
|
+
const hub = this.hub;
|
|
416
|
+
const reply = (payload) => hub.connections.sendToSocket(ws, payload).catch(() => void 0);
|
|
417
|
+
let message;
|
|
418
|
+
try {
|
|
419
|
+
message = JSON.parse(raw);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
await hub.reportHandlerError(userId, raw, err);
|
|
422
|
+
await reply({ type: "error", error: "Invalid JSON" });
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (message === null || typeof message !== "object" || Array.isArray(message)) {
|
|
426
|
+
await hub.reportHandlerError(userId, message, new TypeError("Message must be a JSON object"));
|
|
427
|
+
await reply({ type: "error", error: "Message must be a JSON object" });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const frame = message;
|
|
431
|
+
try {
|
|
432
|
+
await hub.dispatch(userId, frame);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
logger.error(`Handler for ${JSON.stringify(frame.type)} failed`, err);
|
|
435
|
+
await hub.reportHandlerError(userId, frame, err);
|
|
436
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
437
|
+
await hub.send(userId, { type: "error", error: `Message processing failed: ${text}` }).catch(() => void 0);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
function rawToString(data) {
|
|
442
|
+
if (typeof data === "string") return data;
|
|
443
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
|
444
|
+
return Buffer.from(data).toString("utf8");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/core/hub.ts
|
|
448
|
+
var CommsHub = class {
|
|
449
|
+
auth;
|
|
450
|
+
db;
|
|
451
|
+
dialect;
|
|
452
|
+
users;
|
|
453
|
+
pushSender;
|
|
454
|
+
connections;
|
|
455
|
+
rateLimiter;
|
|
456
|
+
roleResolver;
|
|
457
|
+
onHandlerError;
|
|
458
|
+
events = new EventBus();
|
|
459
|
+
features = {};
|
|
460
|
+
handlers = /* @__PURE__ */ new Map();
|
|
461
|
+
connectHooks = [];
|
|
462
|
+
routes = [];
|
|
463
|
+
tasks = new TaskSet();
|
|
464
|
+
ws;
|
|
465
|
+
constructor(opts) {
|
|
466
|
+
this.auth = opts.auth;
|
|
467
|
+
this.db = opts.db;
|
|
468
|
+
this.dialect = opts.dialect ?? detectDialect(opts.db);
|
|
469
|
+
this.users = opts.users ?? new NullUserDirectory();
|
|
470
|
+
this.pushSender = opts.push ?? new NoopPush();
|
|
471
|
+
this.connections = opts.connections ?? new ConnectionManager();
|
|
472
|
+
if (opts.cameOnlineCooldown !== void 0) this.connections.cameOnlineCooldown = Number(opts.cameOnlineCooldown);
|
|
473
|
+
this.rateLimiter = opts.rateLimiter ?? new RateLimiter();
|
|
474
|
+
this.roleResolver = opts.roleResolver;
|
|
475
|
+
this.onHandlerError = opts.onHandlerError;
|
|
476
|
+
if (opts.logger) setLogger(opts.logger);
|
|
477
|
+
this.connections.addOfflineHook((uid) => this.events.emit("user.disconnected", uid));
|
|
478
|
+
this.on("ping", async (userId) => {
|
|
479
|
+
await this.send(userId, { type: PONG_TYPE, timestamp: nowIso() });
|
|
480
|
+
});
|
|
481
|
+
this.on(
|
|
482
|
+
"user_status",
|
|
483
|
+
(userId, message) => this.connections.setStatus(userId, String(message.status ?? "online"))
|
|
484
|
+
);
|
|
485
|
+
this.ws = new WsEndpoint(this);
|
|
486
|
+
}
|
|
487
|
+
// ------------------------------------------------------------ registry
|
|
488
|
+
/** Install a feature (`hub.install(new ChatFeature({...}))`). Returns the feature. */
|
|
489
|
+
install(feature) {
|
|
490
|
+
feature.install(this);
|
|
491
|
+
return feature;
|
|
492
|
+
}
|
|
493
|
+
registerFeature(name, feature) {
|
|
494
|
+
if (name in this.features) throw new Error(`Feature '${name}' is already installed on this hub`);
|
|
495
|
+
this.features[name] = feature;
|
|
496
|
+
}
|
|
497
|
+
/** Register a WS handler for frames of `type`. Registering again replaces the previous handler. */
|
|
498
|
+
on(type, handler) {
|
|
499
|
+
this.handlers.set(type, handler);
|
|
500
|
+
return handler;
|
|
501
|
+
}
|
|
502
|
+
handles(type) {
|
|
503
|
+
return this.handlers.has(type);
|
|
504
|
+
}
|
|
505
|
+
/** Run the handler for `message.type`. Unknown types are ignored (false). */
|
|
506
|
+
async dispatch(userId, message) {
|
|
507
|
+
const handler = this.handlers.get(message.type);
|
|
508
|
+
if (!handler) return false;
|
|
509
|
+
await handler(userId, message, this.db);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
/** `hook(userId, ws)` after a socket is registered (runs before frames). Users only unless `allRoles`. */
|
|
513
|
+
onConnect(hook, allRoles = false) {
|
|
514
|
+
this.connectHooks.push([hook, allRoles]);
|
|
515
|
+
return hook;
|
|
516
|
+
}
|
|
517
|
+
/** `hook(userId)` when the user's last socket on this worker closes (users only unless `allRoles`). */
|
|
518
|
+
onDisconnect(hook, allRoles = false) {
|
|
519
|
+
this.connections.addOfflineHook(hook, allRoles);
|
|
520
|
+
return hook;
|
|
521
|
+
}
|
|
522
|
+
/** `roleResolver(userId)`; "user" without a resolver or when it fails. */
|
|
523
|
+
async roleOf(userId) {
|
|
524
|
+
if (!this.roleResolver) return USER_ROLE;
|
|
525
|
+
try {
|
|
526
|
+
return String(await this.roleResolver(userId) || USER_ROLE);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
logger.error(`roleResolver failed for ${userId}`, err);
|
|
529
|
+
return USER_ROLE;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/** Call `onHandlerError`; never throws (hook failures are logged). */
|
|
533
|
+
async reportHandlerError(userId, frame, err) {
|
|
534
|
+
if (!this.onHandlerError) return;
|
|
535
|
+
try {
|
|
536
|
+
await this.onHandlerError(userId, frame, err);
|
|
537
|
+
} catch (e) {
|
|
538
|
+
logger.error("onHandlerError hook failed", e);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// ---------------------------------------------------------------- rooms
|
|
542
|
+
joinRoom(userId, room) {
|
|
543
|
+
return this.connections.joinRoom(userId, room);
|
|
544
|
+
}
|
|
545
|
+
leaveRoom(userId, room) {
|
|
546
|
+
this.connections.leaveRoom(userId, room);
|
|
547
|
+
}
|
|
548
|
+
/** Send `frame` as given to every member (every worker, with a room-capable bridge). */
|
|
549
|
+
broadcastRoom(room, frame) {
|
|
550
|
+
return this.connections.broadcastRoom(room, frame);
|
|
551
|
+
}
|
|
552
|
+
// ------------------------------------------------------------------ REST
|
|
553
|
+
/** Features call this from `install` to contribute routes. */
|
|
554
|
+
includeRoutes(routes) {
|
|
555
|
+
this.routes.push(...routes);
|
|
556
|
+
}
|
|
557
|
+
/** `Authorization: Bearer <token>` -> user id (401 otherwise). */
|
|
558
|
+
async authenticate(headers) {
|
|
559
|
+
const raw = headers["authorization"];
|
|
560
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
561
|
+
const m = value ? /^bearer\s+(.+)$/i.exec(value.trim()) : null;
|
|
562
|
+
if (!m || !m[1]) throw new HttpError(401, "Not authenticated");
|
|
563
|
+
try {
|
|
564
|
+
return await resolveUserId(this.auth, m[1].trim());
|
|
565
|
+
} catch {
|
|
566
|
+
throw new HttpError(401, "Invalid token");
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/** All routes, ready for an adapter: auth is resolved and HttpError / crashes become `{status, body}`. */
|
|
570
|
+
restRoutes() {
|
|
571
|
+
return this.routes.map((route) => ({
|
|
572
|
+
...route,
|
|
573
|
+
handler: async (ctx) => {
|
|
574
|
+
try {
|
|
575
|
+
const userId = route.auth === false ? null : await this.authenticate(ctx.headers);
|
|
576
|
+
return await route.handler({ ...ctx, userId });
|
|
577
|
+
} catch (err) {
|
|
578
|
+
if (err instanceof HttpError) return { status: err.status, headers: err.headers, body: { detail: err.detail } };
|
|
579
|
+
logger.error(`REST ${route.method} ${route.path} failed`, err);
|
|
580
|
+
return { status: 500, body: { detail: "Internal Server Error" } };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
// -------------------------------------------------------------------- WS
|
|
586
|
+
/** True when `url` is a `/ws/<token>` request. */
|
|
587
|
+
matchesWs(url) {
|
|
588
|
+
return this.ws.match(url) !== null;
|
|
589
|
+
}
|
|
590
|
+
/** Handle an HTTP upgrade for `/ws/:token` (accept first, then authenticate: 1008 on a bad token). */
|
|
591
|
+
handleUpgrade(req, socket, head) {
|
|
592
|
+
this.ws.handleUpgrade(req, socket, head);
|
|
593
|
+
}
|
|
594
|
+
/** Listen for `/ws/:token` upgrades on an http.Server (other upgrade paths are left to other listeners). */
|
|
595
|
+
attach(server) {
|
|
596
|
+
server.on("upgrade", (req, socket, head) => {
|
|
597
|
+
if (this.matchesWs(req.url)) this.handleUpgrade(req, socket, head);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
get wsServer() {
|
|
601
|
+
return this.ws.wss;
|
|
602
|
+
}
|
|
603
|
+
// ------------------------------------------------------------- services
|
|
604
|
+
spawn(task) {
|
|
605
|
+
this.tasks.spawn(task);
|
|
606
|
+
}
|
|
607
|
+
/** Close sockets and wait briefly for background work. */
|
|
608
|
+
async shutdown() {
|
|
609
|
+
this.ws.closeAll();
|
|
610
|
+
await this.tasks.settle();
|
|
611
|
+
await this.connections.stop();
|
|
612
|
+
}
|
|
613
|
+
async send(userId, payload) {
|
|
614
|
+
return this.connections.send(userId, payload);
|
|
615
|
+
}
|
|
616
|
+
async runConnectHooks(userId, ws, role = USER_ROLE, cameOnline = false) {
|
|
617
|
+
const isUser = role === USER_ROLE;
|
|
618
|
+
for (const [hook, allRoles] of [...this.connectHooks]) {
|
|
619
|
+
if (!(isUser || allRoles)) continue;
|
|
620
|
+
try {
|
|
621
|
+
await hook(userId, ws);
|
|
622
|
+
} catch (err) {
|
|
623
|
+
logger.error(`onConnect hook failed for ${userId}`, err);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (!isUser) return;
|
|
627
|
+
await this.events.emit("user.connected", userId);
|
|
628
|
+
if (cameOnline) await this.events.emit("user.came_online", userId);
|
|
629
|
+
}
|
|
630
|
+
async profiles(userIds) {
|
|
631
|
+
const ids = [...new Set(userIds)].filter(Boolean);
|
|
632
|
+
if (!ids.length) return {};
|
|
633
|
+
try {
|
|
634
|
+
return await this.users.getProfiles(ids);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
logger.error("UserDirectory.getProfiles failed", err);
|
|
637
|
+
return {};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async profile(userId) {
|
|
641
|
+
return (await this.profiles([userId]))[userId];
|
|
642
|
+
}
|
|
643
|
+
async displayName(userId, fallback = "Someone") {
|
|
644
|
+
const p = await this.profile(userId);
|
|
645
|
+
return p?.name ? p.name : fallback;
|
|
646
|
+
}
|
|
647
|
+
/** Never throws: push failures are logged. */
|
|
648
|
+
async push(userId, title, body, data, channel = "default", ttl = null) {
|
|
649
|
+
try {
|
|
650
|
+
await this.pushSender.send(userId, title, body, data, channel, ttl);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
logger.error(`Push to ${userId} failed`, err);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
// src/core/http.ts
|
|
658
|
+
async function readBody(req) {
|
|
659
|
+
const chunks = [];
|
|
660
|
+
for await (const c of req) chunks.push(c);
|
|
661
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
662
|
+
if (!text) return void 0;
|
|
663
|
+
try {
|
|
664
|
+
return JSON.parse(text);
|
|
665
|
+
} catch {
|
|
666
|
+
return void 0;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function restListener(hub) {
|
|
670
|
+
const routes = hub.restRoutes();
|
|
671
|
+
return async (req, res) => {
|
|
672
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
673
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
674
|
+
for (const route of routes) {
|
|
675
|
+
if (route.method !== method) continue;
|
|
676
|
+
const params = matchPath(route.path, url.pathname);
|
|
677
|
+
if (!params) continue;
|
|
678
|
+
const query = {};
|
|
679
|
+
for (const key of new Set(url.searchParams.keys())) {
|
|
680
|
+
const all = url.searchParams.getAll(key);
|
|
681
|
+
query[key] = all.length > 1 ? all : all[0];
|
|
682
|
+
}
|
|
683
|
+
const result = await route.handler({
|
|
684
|
+
method,
|
|
685
|
+
path: route.path,
|
|
686
|
+
params,
|
|
687
|
+
query,
|
|
688
|
+
headers: req.headers,
|
|
689
|
+
body: await readBody(req),
|
|
690
|
+
userId: null
|
|
691
|
+
});
|
|
692
|
+
res.writeHead(result.status, { "content-type": "application/json", ...result.headers });
|
|
693
|
+
res.end(JSON.stringify(result.body));
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
return false;
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export {
|
|
701
|
+
PONG_TYPE,
|
|
702
|
+
USER_ROLE,
|
|
703
|
+
ConnectionManager,
|
|
704
|
+
EventBus,
|
|
705
|
+
CommsHub,
|
|
706
|
+
restListener
|
|
707
|
+
};
|
|
708
|
+
//# sourceMappingURL=chunk-LY2GJWN7.js.map
|