@super-line/server 0.1.0 → 0.2.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/dist/index.cjs +308 -6
- package/dist/index.d.cts +111 -6
- package/dist/index.d.ts +111 -6
- package/dist/index.js +308 -6
- package/package.json +5 -4
package/dist/index.cjs
CHANGED
|
@@ -32,26 +32,51 @@ var import_core = require("@super-line/core");
|
|
|
32
32
|
|
|
33
33
|
// src/conn.ts
|
|
34
34
|
var Conn = class {
|
|
35
|
-
constructor(ws, role, ctx, serializer) {
|
|
35
|
+
constructor(ws, id, role, ctx, serializer, backpressure) {
|
|
36
36
|
this.ws = ws;
|
|
37
|
+
this.id = id;
|
|
37
38
|
this.role = role;
|
|
38
39
|
this.ctx = ctx;
|
|
39
40
|
this.serializer = serializer;
|
|
41
|
+
this.backpressure = backpressure;
|
|
40
42
|
}
|
|
41
43
|
ws;
|
|
44
|
+
id;
|
|
42
45
|
role;
|
|
43
46
|
ctx;
|
|
44
47
|
serializer;
|
|
48
|
+
backpressure;
|
|
45
49
|
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
46
50
|
channels = /* @__PURE__ */ new Set();
|
|
51
|
+
/** Mutable per-connection scratch state, typed per role by the contract's `data` schema. */
|
|
52
|
+
data = {};
|
|
53
|
+
/** When this connection was accepted (`Date.now()` at the upgrade). */
|
|
54
|
+
connectedAt = Date.now();
|
|
55
|
+
/** When the server last sent a heartbeat ping to this connection (managed by the server). */
|
|
56
|
+
lastPingAt;
|
|
57
|
+
/** When a heartbeat pong was last received — liveness signal (managed by the server). */
|
|
58
|
+
lastPongAt;
|
|
59
|
+
/** Pings sent since the last pong; drives reaping (managed by the server). */
|
|
60
|
+
missedPongs = 0;
|
|
61
|
+
// true => the frame was handled by the backpressure policy and must not be sent
|
|
62
|
+
overBackpressure() {
|
|
63
|
+
const bp = this.backpressure;
|
|
64
|
+
if (!bp || this.ws.bufferedAmount <= bp.maxBufferedBytes) return false;
|
|
65
|
+
if (bp.onExceed === "drop") {
|
|
66
|
+
console.warn(`[super-line] dropping frame: conn ${this.id} over backpressure limit`);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
this.ws.close(1013);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
47
72
|
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
48
73
|
send(frame) {
|
|
49
|
-
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
74
|
+
if (this.ws.readyState !== this.ws.OPEN || this.overBackpressure()) return;
|
|
50
75
|
this.ws.send(this.serializer.encode(frame));
|
|
51
76
|
}
|
|
52
77
|
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
53
78
|
sendRaw(payload) {
|
|
54
|
-
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
79
|
+
if (this.ws.readyState !== this.ws.OPEN || this.overBackpressure()) return;
|
|
55
80
|
this.ws.send(payload);
|
|
56
81
|
}
|
|
57
82
|
/** Push an event to THIS connection (node-local). Scoped to the role's events. */
|
|
@@ -67,6 +92,8 @@ var Conn = class {
|
|
|
67
92
|
// src/memory-adapter.ts
|
|
68
93
|
var MemoryBus = class {
|
|
69
94
|
channels = /* @__PURE__ */ new Map();
|
|
95
|
+
// shared presence directory; in-memory liveness = "has a descriptor" (graceful del on disconnect)
|
|
96
|
+
descriptors = /* @__PURE__ */ new Map();
|
|
70
97
|
subscribe(channel, adapter) {
|
|
71
98
|
let set = this.channels.get(channel);
|
|
72
99
|
if (!set) {
|
|
@@ -87,12 +114,68 @@ var MemoryBus = class {
|
|
|
87
114
|
for (const adapter of set) adapter.deliver(channel, payload);
|
|
88
115
|
}
|
|
89
116
|
};
|
|
117
|
+
var MemoryPresence = class {
|
|
118
|
+
constructor(bus) {
|
|
119
|
+
this.bus = bus;
|
|
120
|
+
}
|
|
121
|
+
bus;
|
|
122
|
+
set(d) {
|
|
123
|
+
this.bus.descriptors.set(d.id, d);
|
|
124
|
+
}
|
|
125
|
+
del(connId) {
|
|
126
|
+
this.bus.descriptors.delete(connId);
|
|
127
|
+
}
|
|
128
|
+
beat() {
|
|
129
|
+
}
|
|
130
|
+
clearNode(nodeId) {
|
|
131
|
+
for (const [id, d] of this.bus.descriptors) if (d.nodeId === nodeId) this.bus.descriptors.delete(id);
|
|
132
|
+
}
|
|
133
|
+
addRoom(connId, room) {
|
|
134
|
+
const d = this.bus.descriptors.get(connId);
|
|
135
|
+
if (d && !d.rooms.includes(room)) d.rooms = [...d.rooms, room];
|
|
136
|
+
}
|
|
137
|
+
removeRoom(connId, room) {
|
|
138
|
+
const d = this.bus.descriptors.get(connId);
|
|
139
|
+
if (d) d.rooms = d.rooms.filter((r) => r !== room);
|
|
140
|
+
}
|
|
141
|
+
list() {
|
|
142
|
+
return [...this.bus.descriptors.values()];
|
|
143
|
+
}
|
|
144
|
+
get(connId) {
|
|
145
|
+
return this.bus.descriptors.get(connId);
|
|
146
|
+
}
|
|
147
|
+
byUser(userId) {
|
|
148
|
+
return this.list().filter((d) => d.userId === userId);
|
|
149
|
+
}
|
|
150
|
+
roomMembers(room) {
|
|
151
|
+
return this.list().filter((d) => d.rooms.includes(room));
|
|
152
|
+
}
|
|
153
|
+
count() {
|
|
154
|
+
return this.bus.descriptors.size;
|
|
155
|
+
}
|
|
156
|
+
topology() {
|
|
157
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const d of this.list()) {
|
|
159
|
+
const set = byNode.get(d.nodeId);
|
|
160
|
+
if (set) set.push(d);
|
|
161
|
+
else byNode.set(d.nodeId, [d]);
|
|
162
|
+
}
|
|
163
|
+
return [...byNode.entries()].map(([nodeId, ds]) => ({
|
|
164
|
+
nodeId,
|
|
165
|
+
connections: ds.length,
|
|
166
|
+
rooms: new Set(ds.flatMap((d) => d.rooms)).size,
|
|
167
|
+
alive: true
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
};
|
|
90
171
|
var MemoryAdapter = class {
|
|
91
172
|
constructor(bus) {
|
|
92
173
|
this.bus = bus;
|
|
174
|
+
this.presence = new MemoryPresence(bus);
|
|
93
175
|
}
|
|
94
176
|
bus;
|
|
95
177
|
handler;
|
|
178
|
+
presence;
|
|
96
179
|
subscribe(channel) {
|
|
97
180
|
this.bus.subscribe(channel, this);
|
|
98
181
|
}
|
|
@@ -116,6 +199,9 @@ function createInMemoryAdapter(bus = new MemoryBus()) {
|
|
|
116
199
|
// src/index.ts
|
|
117
200
|
var ROOM = "r:";
|
|
118
201
|
var TOPIC = "t:";
|
|
202
|
+
var CONN = "c:";
|
|
203
|
+
var USER = "u:";
|
|
204
|
+
var REPLY = "reply:";
|
|
119
205
|
var S2S = "s2s";
|
|
120
206
|
function createSocketServer(contract, opts) {
|
|
121
207
|
const c = contract;
|
|
@@ -126,17 +212,122 @@ function createSocketServer(contract, opts) {
|
|
|
126
212
|
const members = /* @__PURE__ */ new Map();
|
|
127
213
|
const serverListeners = /* @__PURE__ */ new Map();
|
|
128
214
|
const instanceId = (0, import_node_crypto.randomUUID)();
|
|
215
|
+
const replyChannel = REPLY + instanceId;
|
|
129
216
|
let impl = {};
|
|
217
|
+
let closing = false;
|
|
218
|
+
let nextSReq = 1;
|
|
219
|
+
const originWaiters = /* @__PURE__ */ new Map();
|
|
220
|
+
const ownerRouting = /* @__PURE__ */ new Map();
|
|
221
|
+
function buildDescriptor(conn) {
|
|
222
|
+
const userId = opts.identify?.(conn);
|
|
223
|
+
const rooms = [];
|
|
224
|
+
for (const ch of conn.channels) if (ch.startsWith(ROOM)) rooms.push(ch.slice(ROOM.length));
|
|
225
|
+
return {
|
|
226
|
+
id: conn.id,
|
|
227
|
+
role: conn.role,
|
|
228
|
+
nodeId: instanceId,
|
|
229
|
+
connectedAt: conn.connectedAt,
|
|
230
|
+
...userId !== void 0 ? { userId } : {},
|
|
231
|
+
rooms,
|
|
232
|
+
...opts.describeConn?.(conn)
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function presenceOrThrow() {
|
|
236
|
+
if (!adapter.presence) throw new Error("cluster queries require an adapter with presence support");
|
|
237
|
+
return adapter.presence;
|
|
238
|
+
}
|
|
130
239
|
adapter.onMessage((channel, payload) => {
|
|
131
240
|
if (channel === S2S) {
|
|
132
241
|
handleServerMessage(payload);
|
|
133
242
|
return;
|
|
134
243
|
}
|
|
244
|
+
if (channel === replyChannel) {
|
|
245
|
+
handleReply(payload);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (channel.startsWith(CONN) || channel.startsWith(USER)) {
|
|
249
|
+
handlePersonal(channel, payload);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
135
252
|
const set = members.get(channel);
|
|
136
253
|
if (!set) return;
|
|
137
254
|
for (const conn of set) conn.sendRaw(payload);
|
|
138
255
|
});
|
|
256
|
+
void adapter.subscribe(replyChannel);
|
|
257
|
+
function handlePersonal(channel, payload) {
|
|
258
|
+
const set = members.get(channel);
|
|
259
|
+
if (!set) return;
|
|
260
|
+
let env;
|
|
261
|
+
try {
|
|
262
|
+
env = serializer.decode(payload);
|
|
263
|
+
} catch {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (env.p === "close") {
|
|
267
|
+
for (const conn of set) conn.close();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (env.p === "req") {
|
|
271
|
+
const localId = nextSReq++;
|
|
272
|
+
ownerRouting.set(localId, { origin: env.o, corrId: env.i, name: env.m });
|
|
273
|
+
for (const conn of set) conn.send({ t: "sreq", i: localId, m: env.m, d: env.d });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const frame = serializer.encode(env.f);
|
|
277
|
+
for (const conn of set) conn.sendRaw(frame);
|
|
278
|
+
}
|
|
279
|
+
function handleReply(payload) {
|
|
280
|
+
let env;
|
|
281
|
+
try {
|
|
282
|
+
env = serializer.decode(payload);
|
|
283
|
+
} catch {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const w = originWaiters.get(env.i);
|
|
287
|
+
if (!w) return;
|
|
288
|
+
originWaiters.delete(env.i);
|
|
289
|
+
if (w.timer) clearTimeout(w.timer);
|
|
290
|
+
if (env.ok) w.resolve(env.d);
|
|
291
|
+
else w.reject(new import_core.SocketError(env.code, env.m, env.d));
|
|
292
|
+
}
|
|
293
|
+
async function handleClientReply(conn, localId, result) {
|
|
294
|
+
const r = ownerRouting.get(localId);
|
|
295
|
+
if (!r) return;
|
|
296
|
+
ownerRouting.delete(localId);
|
|
297
|
+
let env;
|
|
298
|
+
if (result.ok) {
|
|
299
|
+
try {
|
|
300
|
+
const def = c.roles[conn.role]?.serverToClient?.[r.name] ?? c.shared?.serverToClient?.[r.name];
|
|
301
|
+
const out = def && "output" in def ? await (0, import_core.validate)(def.output, result.d) : result.d;
|
|
302
|
+
env = { i: r.corrId, ok: true, d: out };
|
|
303
|
+
} catch (err) {
|
|
304
|
+
const e = err instanceof import_core.SocketError ? err : new import_core.SocketError("INTERNAL", "Internal server error");
|
|
305
|
+
env = { i: r.corrId, ok: false, code: e.code, m: e.message, d: e.data };
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
env = { i: r.corrId, ok: false, code: result.code, m: result.m, d: result.d };
|
|
309
|
+
}
|
|
310
|
+
void adapter.publish(REPLY + r.origin, serializer.encode(env));
|
|
311
|
+
}
|
|
139
312
|
if (c.serverToServer) void adapter.subscribe(S2S);
|
|
313
|
+
const hb = opts.heartbeat === false ? null : opts.heartbeat ?? {};
|
|
314
|
+
let hbTimer;
|
|
315
|
+
if (hb) {
|
|
316
|
+
hbTimer = setInterval(() => {
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
void adapter.presence?.beat(instanceId);
|
|
319
|
+
for (const conn of conns) {
|
|
320
|
+
if (hb.maxMissed != null && conn.missedPongs >= hb.maxMissed) {
|
|
321
|
+
conn.ws.terminate();
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
conn.missedPongs++;
|
|
325
|
+
conn.lastPingAt = now;
|
|
326
|
+
conn.ws.ping();
|
|
327
|
+
}
|
|
328
|
+
}, hb.interval ?? 3e4);
|
|
329
|
+
hbTimer.unref?.();
|
|
330
|
+
}
|
|
140
331
|
function handleServerMessage(payload) {
|
|
141
332
|
let msg;
|
|
142
333
|
try {
|
|
@@ -150,6 +341,7 @@ function createSocketServer(contract, opts) {
|
|
|
150
341
|
}
|
|
151
342
|
function joinChannel(conn, channel) {
|
|
152
343
|
conn.channels.add(channel);
|
|
344
|
+
if (channel.startsWith(ROOM)) void adapter.presence?.addRoom(conn.id, channel.slice(ROOM.length));
|
|
153
345
|
const set = members.get(channel);
|
|
154
346
|
if (set) {
|
|
155
347
|
set.add(conn);
|
|
@@ -163,14 +355,19 @@ function createSocketServer(contract, opts) {
|
|
|
163
355
|
if (!set) return;
|
|
164
356
|
set.delete(conn);
|
|
165
357
|
conn.channels.delete(channel);
|
|
358
|
+
if (channel.startsWith(ROOM)) void adapter.presence?.removeRoom(conn.id, channel.slice(ROOM.length));
|
|
166
359
|
if (set.size === 0) {
|
|
167
360
|
members.delete(channel);
|
|
168
361
|
void adapter.unsubscribe(channel);
|
|
169
362
|
}
|
|
170
363
|
}
|
|
364
|
+
function isTopic(name, block) {
|
|
365
|
+
const def = block?.serverToClient?.[name];
|
|
366
|
+
return !!def && typeof def === "object" && "subscribe" in def && def.subscribe === true;
|
|
367
|
+
}
|
|
171
368
|
function topicNamespace(role, name) {
|
|
172
|
-
if (c.roles[role]
|
|
173
|
-
if (c.shared
|
|
369
|
+
if (isTopic(name, c.roles[role])) return role;
|
|
370
|
+
if (isTopic(name, c.shared)) return "shared";
|
|
174
371
|
return void 0;
|
|
175
372
|
}
|
|
176
373
|
if (opts.server) {
|
|
@@ -192,17 +389,26 @@ function createSocketServer(contract, opts) {
|
|
|
192
389
|
return;
|
|
193
390
|
}
|
|
194
391
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
195
|
-
const conn = new Conn(ws, auth.role, auth.ctx, serializer);
|
|
392
|
+
const conn = new Conn(ws, (0, import_node_crypto.randomUUID)(), auth.role, auth.ctx, serializer, opts.backpressure);
|
|
196
393
|
conns.add(conn);
|
|
394
|
+
ws.on("pong", () => {
|
|
395
|
+
conn.lastPongAt = Date.now();
|
|
396
|
+
conn.missedPongs = 0;
|
|
397
|
+
});
|
|
197
398
|
ws.on("message", (data, isBinary) => {
|
|
198
399
|
void onMessage(conn, data, isBinary);
|
|
199
400
|
});
|
|
200
401
|
ws.on("close", (code) => {
|
|
201
402
|
conns.delete(conn);
|
|
202
403
|
for (const channel of conn.channels) leaveChannel(conn, channel);
|
|
404
|
+
void adapter.presence?.del(conn.id);
|
|
203
405
|
opts.onDisconnect?.(conn, auth.ctx, code);
|
|
204
406
|
});
|
|
205
407
|
opts.onConnection?.(conn, auth.ctx);
|
|
408
|
+
void adapter.presence?.set(buildDescriptor(conn));
|
|
409
|
+
void joinChannel(conn, CONN + conn.id);
|
|
410
|
+
const uid = opts.identify?.(conn);
|
|
411
|
+
if (uid !== void 0) void joinChannel(conn, USER + uid);
|
|
206
412
|
});
|
|
207
413
|
}
|
|
208
414
|
async function onMessage(conn, data, isBinary) {
|
|
@@ -217,6 +423,10 @@ function createSocketServer(contract, opts) {
|
|
|
217
423
|
else if (frame.t === "unsub") {
|
|
218
424
|
const ns = topicNamespace(conn.role, frame.c);
|
|
219
425
|
if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
|
|
426
|
+
} else if (frame.t === "sres") {
|
|
427
|
+
await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
|
|
428
|
+
} else if (frame.t === "serr") {
|
|
429
|
+
await handleClientReply(conn, frame.i, { ok: false, code: frame.code, m: frame.m, d: frame.d });
|
|
220
430
|
}
|
|
221
431
|
}
|
|
222
432
|
function runMiddleware(info, terminal) {
|
|
@@ -288,13 +498,101 @@ function createSocketServer(contract, opts) {
|
|
|
288
498
|
},
|
|
289
499
|
get size() {
|
|
290
500
|
return members.get(channel)?.size ?? 0;
|
|
501
|
+
},
|
|
502
|
+
get connections() {
|
|
503
|
+
return [...members.get(channel) ?? []];
|
|
291
504
|
}
|
|
292
505
|
};
|
|
293
506
|
}
|
|
294
507
|
function publishTo(ns, name, data) {
|
|
295
508
|
void adapter.publish(TOPIC + ns + ":" + name, serializer.encode({ t: "pub", c: name, d: data }));
|
|
296
509
|
}
|
|
510
|
+
function personalTarget(channel) {
|
|
511
|
+
return {
|
|
512
|
+
emit(event, data) {
|
|
513
|
+
const env = { p: "emit", f: { t: "evt", e: event, d: data } };
|
|
514
|
+
void adapter.publish(channel, serializer.encode(env));
|
|
515
|
+
},
|
|
516
|
+
close() {
|
|
517
|
+
void adapter.publish(channel, serializer.encode({ p: "close" }));
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function requestConn(id, name, input, opts2) {
|
|
522
|
+
const reqId = nextSReq++;
|
|
523
|
+
return new Promise((resolve, reject) => {
|
|
524
|
+
const ms = opts2?.timeout ?? 3e4;
|
|
525
|
+
const timer = ms > 0 ? setTimeout(() => {
|
|
526
|
+
originWaiters.delete(reqId);
|
|
527
|
+
reject(new import_core.SocketError("TIMEOUT", `Request '${name}' timed out`));
|
|
528
|
+
}, ms) : void 0;
|
|
529
|
+
originWaiters.set(reqId, { resolve, reject, timer });
|
|
530
|
+
opts2?.signal?.addEventListener(
|
|
531
|
+
"abort",
|
|
532
|
+
() => {
|
|
533
|
+
if (originWaiters.delete(reqId)) {
|
|
534
|
+
if (timer) clearTimeout(timer);
|
|
535
|
+
reject(new import_core.SocketError("BAD_REQUEST", "Aborted"));
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
{ once: true }
|
|
539
|
+
);
|
|
540
|
+
const env = { p: "req", o: instanceId, i: reqId, m: name, d: input };
|
|
541
|
+
void adapter.publish(CONN + id, serializer.encode(env));
|
|
542
|
+
});
|
|
543
|
+
}
|
|
297
544
|
const api = {
|
|
545
|
+
nodeId: instanceId,
|
|
546
|
+
get local() {
|
|
547
|
+
return {
|
|
548
|
+
connections: [...conns],
|
|
549
|
+
get rooms() {
|
|
550
|
+
const out = [];
|
|
551
|
+
for (const channel of members.keys()) if (channel.startsWith(ROOM)) out.push(channel.slice(ROOM.length));
|
|
552
|
+
return out;
|
|
553
|
+
},
|
|
554
|
+
get topics() {
|
|
555
|
+
const out = [];
|
|
556
|
+
for (const channel of members.keys()) {
|
|
557
|
+
if (!channel.startsWith(TOPIC)) continue;
|
|
558
|
+
out.push(channel.slice(channel.indexOf(":", TOPIC.length) + 1));
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
cluster: {
|
|
565
|
+
async connections() {
|
|
566
|
+
return presenceOrThrow().list();
|
|
567
|
+
},
|
|
568
|
+
async count() {
|
|
569
|
+
return presenceOrThrow().count();
|
|
570
|
+
},
|
|
571
|
+
async byUser(userId) {
|
|
572
|
+
return presenceOrThrow().byUser(userId);
|
|
573
|
+
},
|
|
574
|
+
async room(name) {
|
|
575
|
+
return presenceOrThrow().roomMembers(name);
|
|
576
|
+
},
|
|
577
|
+
async topology() {
|
|
578
|
+
return presenceOrThrow().topology();
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
async isOnline(userId) {
|
|
582
|
+
return (await presenceOrThrow().byUser(userId)).length > 0;
|
|
583
|
+
},
|
|
584
|
+
toConn(id) {
|
|
585
|
+
const t = personalTarget(CONN + id);
|
|
586
|
+
return {
|
|
587
|
+
emit: t.emit,
|
|
588
|
+
close: t.close,
|
|
589
|
+
request: (name, input, opts2) => requestConn(id, String(name), input, opts2)
|
|
590
|
+
};
|
|
591
|
+
},
|
|
592
|
+
toUser(userId) {
|
|
593
|
+
const t = personalTarget(USER + userId);
|
|
594
|
+
return { emit: t.emit, disconnect: t.close };
|
|
595
|
+
},
|
|
298
596
|
implement(handlers) {
|
|
299
597
|
impl = handlers;
|
|
300
598
|
return api;
|
|
@@ -329,7 +627,11 @@ function createSocketServer(contract, opts) {
|
|
|
329
627
|
};
|
|
330
628
|
},
|
|
331
629
|
async close() {
|
|
630
|
+
if (closing) return;
|
|
631
|
+
closing = true;
|
|
632
|
+
if (hbTimer) clearInterval(hbTimer);
|
|
332
633
|
for (const conn of conns) conn.close();
|
|
634
|
+
await adapter.presence?.clearNode(instanceId);
|
|
333
635
|
await adapter.close?.();
|
|
334
636
|
await new Promise((resolve) => {
|
|
335
637
|
wss.close(() => resolve());
|
package/dist/index.d.cts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Server, IncomingMessage } from 'node:http';
|
|
2
|
-
import { ServerMessageDef, Serializer, ServerFrame, EmitData, Adapter, Contract, RoleOf,
|
|
2
|
+
import { ServerMessageDef, Serializer, ServerFrame, EmitData, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, SharedRequests, ServerInput, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, ServerEvents, ServerEmit, ServerData } from '@super-line/core';
|
|
3
3
|
import { WebSocket } from 'ws';
|
|
4
4
|
|
|
5
|
+
/** Backpressure policy: what to do when a connection's send buffer grows too large. */
|
|
6
|
+
interface Backpressure {
|
|
7
|
+
/** Buffer size (bytes) above which {@link Backpressure.onExceed} kicks in. */
|
|
8
|
+
maxBufferedBytes: number;
|
|
9
|
+
/** `'close'` (default) drops the connection with code 1013; `'drop'` skips the frame. */
|
|
10
|
+
onExceed?: 'close' | 'drop';
|
|
11
|
+
}
|
|
5
12
|
/**
|
|
6
13
|
* A single client connection, passed to handlers as the third argument.
|
|
7
14
|
*
|
|
@@ -10,23 +17,39 @@ import { WebSocket } from 'ws';
|
|
|
10
17
|
* (use a per-user room instead). Generic over the events it may emit (scoped by
|
|
11
18
|
* role), its `ctx`, and its `role`.
|
|
12
19
|
*/
|
|
13
|
-
declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role extends string = string> {
|
|
20
|
+
declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role extends string = string, Data = unknown> {
|
|
14
21
|
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
15
22
|
readonly ws: WebSocket;
|
|
23
|
+
/** Server-assigned unique id for this connection (stable for its lifetime). */
|
|
24
|
+
readonly id: string;
|
|
16
25
|
/** This connection's role (the literal resolved by `authenticate`). */
|
|
17
26
|
readonly role: Role;
|
|
18
27
|
/** The context `authenticate` returned for this connection. */
|
|
19
28
|
readonly ctx: Ctx;
|
|
20
29
|
private readonly serializer;
|
|
30
|
+
private readonly backpressure?;
|
|
21
31
|
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
22
32
|
readonly channels: Set<string>;
|
|
33
|
+
/** Mutable per-connection scratch state, typed per role by the contract's `data` schema. */
|
|
34
|
+
data: Data;
|
|
35
|
+
/** When this connection was accepted (`Date.now()` at the upgrade). */
|
|
36
|
+
readonly connectedAt: number;
|
|
37
|
+
/** When the server last sent a heartbeat ping to this connection (managed by the server). */
|
|
38
|
+
lastPingAt?: number;
|
|
39
|
+
/** When a heartbeat pong was last received — liveness signal (managed by the server). */
|
|
40
|
+
lastPongAt?: number;
|
|
41
|
+
/** Pings sent since the last pong; drives reaping (managed by the server). */
|
|
42
|
+
missedPongs: number;
|
|
23
43
|
constructor(
|
|
24
44
|
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
25
45
|
ws: WebSocket,
|
|
46
|
+
/** Server-assigned unique id for this connection (stable for its lifetime). */
|
|
47
|
+
id: string,
|
|
26
48
|
/** This connection's role (the literal resolved by `authenticate`). */
|
|
27
49
|
role: Role,
|
|
28
50
|
/** The context `authenticate` returned for this connection. */
|
|
29
|
-
ctx: Ctx, serializer: Serializer);
|
|
51
|
+
ctx: Ctx, serializer: Serializer, backpressure?: Backpressure | undefined);
|
|
52
|
+
private overBackpressure;
|
|
30
53
|
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
31
54
|
send(frame: ServerFrame): void;
|
|
32
55
|
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
@@ -40,9 +63,12 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
|
|
|
40
63
|
/**
|
|
41
64
|
* In-process pub/sub bus. Share one bus across multiple servers to simulate
|
|
42
65
|
* multiple nodes in a test (each server gets its own adapter bound to the bus).
|
|
66
|
+
* The presence directory also lives here, so servers sharing a bus see the whole
|
|
67
|
+
* cluster (mirroring how Redis is shared in production).
|
|
43
68
|
*/
|
|
44
69
|
declare class MemoryBus {
|
|
45
70
|
private readonly channels;
|
|
71
|
+
readonly descriptors: Map<string, ConnDescriptor>;
|
|
46
72
|
subscribe(channel: string, adapter: MemoryAdapter): void;
|
|
47
73
|
unsubscribe(channel: string, adapter: MemoryAdapter): void;
|
|
48
74
|
publish(channel: string, payload: string | Uint8Array): void;
|
|
@@ -50,6 +76,7 @@ declare class MemoryBus {
|
|
|
50
76
|
declare class MemoryAdapter implements Adapter {
|
|
51
77
|
private readonly bus;
|
|
52
78
|
private handler?;
|
|
79
|
+
readonly presence: PresenceStore;
|
|
53
80
|
constructor(bus: MemoryBus);
|
|
54
81
|
subscribe(channel: string): void;
|
|
55
82
|
unsubscribe(channel: string): void;
|
|
@@ -83,10 +110,10 @@ type CtxUnion<A> = A extends {
|
|
|
83
110
|
ctx: infer X;
|
|
84
111
|
} ? X : never;
|
|
85
112
|
type RoleHandlers<C extends Contract, A, R extends RoleOf<C>> = {
|
|
86
|
-
[K in keyof RoleRequests<C, R>]: (input: ServerInput<RoleRequests<C, R>[K]>, ctx: CtxFor<A, R>, conn: Conn<Events<C, R>, CtxFor<A, R>, R
|
|
113
|
+
[K in keyof RoleRequests<C, R>]: (input: ServerInput<RoleRequests<C, R>[K]>, ctx: CtxFor<A, R>, conn: Conn<Events<C, R>, CtxFor<A, R>, R, DataOf<C, R>>) => Awaitable<Output<RoleRequests<C, R>[K]>>;
|
|
87
114
|
};
|
|
88
115
|
type SharedHandlers<C extends Contract, A> = {
|
|
89
|
-
[K in keyof SharedRequests<C>]: (input: ServerInput<SharedRequests<C>[K]>, ctx: CtxUnion<A>, conn: Conn<SharedEvents<C>, CtxUnion<A>, RoleOf<C>>) => Awaitable<Output<SharedRequests<C>[K]>>;
|
|
116
|
+
[K in keyof SharedRequests<C>]: (input: ServerInput<SharedRequests<C>[K]>, ctx: CtxUnion<A>, conn: Conn<SharedEvents<C>, CtxUnion<A>, RoleOf<C>, AnyData<C>>) => Awaitable<Output<SharedRequests<C>[K]>>;
|
|
90
117
|
};
|
|
91
118
|
/**
|
|
92
119
|
* The handler map passed to `implement`: one block per role plus an optional
|
|
@@ -125,6 +152,56 @@ interface Room<C extends Contract> {
|
|
|
125
152
|
broadcast<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
126
153
|
/** Member count **on the current node** (membership is node-local). */
|
|
127
154
|
readonly size: number;
|
|
155
|
+
/** Snapshot of this room's members **on the current node**. */
|
|
156
|
+
readonly connections: Conn[];
|
|
157
|
+
}
|
|
158
|
+
/** Synchronous, node-local introspection of the current server process. */
|
|
159
|
+
interface LocalView {
|
|
160
|
+
/** Snapshot of all connections accepted on this node. */
|
|
161
|
+
readonly connections: Conn[];
|
|
162
|
+
/** Names of rooms with at least one member on this node. */
|
|
163
|
+
readonly rooms: string[];
|
|
164
|
+
/** Names of topics with at least one subscriber on this node. */
|
|
165
|
+
readonly topics: string[];
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Asynchronous, cluster-wide introspection backed by the adapter's presence
|
|
169
|
+
* directory. Methods reject if the configured adapter has no presence support.
|
|
170
|
+
*/
|
|
171
|
+
interface ClusterView {
|
|
172
|
+
/** Every live connection across the cluster. */
|
|
173
|
+
connections(): Promise<ConnDescriptor[]>;
|
|
174
|
+
/** Total live connection count across the cluster. */
|
|
175
|
+
count(): Promise<number>;
|
|
176
|
+
/** Connections for a given user key (the `identify` hook). */
|
|
177
|
+
byUser(userId: string): Promise<ConnDescriptor[]>;
|
|
178
|
+
/** Connections that are members of `room`, across nodes. */
|
|
179
|
+
room(name: string): Promise<ConnDescriptor[]>;
|
|
180
|
+
/** Per-node aggregates (the other nodes and their counts). */
|
|
181
|
+
topology(): Promise<NodeStat[]>;
|
|
182
|
+
}
|
|
183
|
+
/** A single targeted connection, reachable on whatever node holds it. */
|
|
184
|
+
interface ConnTarget<C extends Contract> {
|
|
185
|
+
/** Push a shared event to this connection (cross-node). */
|
|
186
|
+
emit<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
187
|
+
/**
|
|
188
|
+
* Send a shared server→client request and await the client's typed reply
|
|
189
|
+
* (cross-node). Rejects with a `TIMEOUT` `SocketError` if no live node owns
|
|
190
|
+
* the connection or the client doesn't answer in time.
|
|
191
|
+
*/
|
|
192
|
+
request<M extends keyof SharedServerRequests<C>>(name: M, input: ClientInput<SharedServerRequests<C>[M]>, opts?: {
|
|
193
|
+
timeout?: number;
|
|
194
|
+
signal?: AbortSignal;
|
|
195
|
+
}): Promise<Output<SharedServerRequests<C>[M]>>;
|
|
196
|
+
/** Close this connection (cross-node kick). */
|
|
197
|
+
close(): void;
|
|
198
|
+
}
|
|
199
|
+
/** All of a user's connections (0..N devices), reachable across nodes. */
|
|
200
|
+
interface UserTarget<C extends Contract> {
|
|
201
|
+
/** Push a shared event to every one of the user's connections (cross-node). */
|
|
202
|
+
emit<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
203
|
+
/** Disconnect every one of the user's connections (cross-node). */
|
|
204
|
+
disconnect(): void;
|
|
128
205
|
}
|
|
129
206
|
/** Lens for role-scoped server sends, returned by `srv.forRole(role)`. */
|
|
130
207
|
interface RoleLens<C extends Contract, R extends RoleOf<C>> {
|
|
@@ -143,10 +220,26 @@ interface ServerOptions<C extends Contract, A extends AuthResult<C>> {
|
|
|
143
220
|
path?: string;
|
|
144
221
|
/** Runs at the HTTP upgrade. Return { role, ctx }, or throw to reject with 401. */
|
|
145
222
|
authenticate: (req: IncomingMessage) => Awaitable<A>;
|
|
223
|
+
/** Stable user key for a connection (powers `cluster.byUser`, `isOnline`, and `toUser`). */
|
|
224
|
+
identify?: (conn: Conn) => string | undefined;
|
|
225
|
+
/** Extra fields merged into the connection's cluster descriptor (e.g. `{ plan }`). `ctx` is never auto-serialized. */
|
|
226
|
+
describeConn?: (conn: Conn) => Record<string, unknown>;
|
|
146
227
|
/** Runs on each client subscribe. Return false or throw to deny. */
|
|
147
228
|
authorizeSubscribe?: (topic: string, ctx: CtxUnion<A>, conn: Conn) => Awaitable<boolean | void>;
|
|
148
229
|
/** Middleware chain run before req/subscribe handlers (rate-limit, authz, logging, metrics). */
|
|
149
230
|
use?: Middleware<A>[];
|
|
231
|
+
/**
|
|
232
|
+
* Heartbeat: one timer pings every connection each `interval` ms (updating
|
|
233
|
+
* `conn.lastPingAt`/`lastPongAt`). Set `maxMissed` to terminate a connection
|
|
234
|
+
* that misses that many consecutive pongs. `false` disables it.
|
|
235
|
+
* Defaults to `{ interval: 30_000 }` (no reaping).
|
|
236
|
+
*/
|
|
237
|
+
heartbeat?: {
|
|
238
|
+
interval?: number;
|
|
239
|
+
maxMissed?: number;
|
|
240
|
+
} | false;
|
|
241
|
+
/** Guard against slow consumers: when a connection's send buffer exceeds the limit, close or drop. */
|
|
242
|
+
backpressure?: Backpressure;
|
|
150
243
|
/** Called once per accepted connection. */
|
|
151
244
|
onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
|
|
152
245
|
/** Called when a connection closes, with the WebSocket close `code`. */
|
|
@@ -156,6 +249,18 @@ interface ServerOptions<C extends Contract, A extends AuthResult<C>> {
|
|
|
156
249
|
}
|
|
157
250
|
/** A running super-line server, returned by {@link createSocketServer}. */
|
|
158
251
|
interface SocketServer<C extends Contract, A extends AuthResult<C>> {
|
|
252
|
+
/** This node's stable id (unique per server process). */
|
|
253
|
+
readonly nodeId: string;
|
|
254
|
+
/** Synchronous, node-local introspection (connections, rooms, topics on this process). */
|
|
255
|
+
readonly local: LocalView;
|
|
256
|
+
/** Asynchronous, cluster-wide introspection backed by the adapter's presence directory. */
|
|
257
|
+
readonly cluster: ClusterView;
|
|
258
|
+
/** Whether a user (by `identify` key) has at least one live connection anywhere. */
|
|
259
|
+
isOnline(userId: string): Promise<boolean>;
|
|
260
|
+
/** Target a single connection by id, on whatever node holds it (cross-node emit/close). */
|
|
261
|
+
toConn(id: string): ConnTarget<C>;
|
|
262
|
+
/** Target all of a user's connections (by `identify` key) across nodes (emit/disconnect). */
|
|
263
|
+
toUser(userId: string): UserTarget<C>;
|
|
159
264
|
/** Register handlers for shared + per-role requests (chainable). */
|
|
160
265
|
implement(handlers: Handlers<C, A>): SocketServer<C, A>;
|
|
161
266
|
/** Mixed-role connection group; broadcast() sends a shared contract event to members. */
|
|
@@ -194,4 +299,4 @@ interface SocketServer<C extends Contract, A extends AuthResult<C>> {
|
|
|
194
299
|
*/
|
|
195
300
|
declare function createSocketServer<C extends Contract, A extends AuthResult<C>>(contract: C, opts: ServerOptions<C, A>): SocketServer<C, A>;
|
|
196
301
|
|
|
197
|
-
export { type AuthResult, Conn, type Handlers, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerOptions, type SocketServer, createInMemoryAdapter, createSocketServer };
|
|
302
|
+
export { type AuthResult, type Backpressure, type ClusterView, Conn, type ConnTarget, type Handlers, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerOptions, type SocketServer, type UserTarget, createInMemoryAdapter, createSocketServer };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Server, IncomingMessage } from 'node:http';
|
|
2
|
-
import { ServerMessageDef, Serializer, ServerFrame, EmitData, Adapter, Contract, RoleOf,
|
|
2
|
+
import { ServerMessageDef, Serializer, ServerFrame, EmitData, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, SharedRequests, ServerInput, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, ServerEvents, ServerEmit, ServerData } from '@super-line/core';
|
|
3
3
|
import { WebSocket } from 'ws';
|
|
4
4
|
|
|
5
|
+
/** Backpressure policy: what to do when a connection's send buffer grows too large. */
|
|
6
|
+
interface Backpressure {
|
|
7
|
+
/** Buffer size (bytes) above which {@link Backpressure.onExceed} kicks in. */
|
|
8
|
+
maxBufferedBytes: number;
|
|
9
|
+
/** `'close'` (default) drops the connection with code 1013; `'drop'` skips the frame. */
|
|
10
|
+
onExceed?: 'close' | 'drop';
|
|
11
|
+
}
|
|
5
12
|
/**
|
|
6
13
|
* A single client connection, passed to handlers as the third argument.
|
|
7
14
|
*
|
|
@@ -10,23 +17,39 @@ import { WebSocket } from 'ws';
|
|
|
10
17
|
* (use a per-user room instead). Generic over the events it may emit (scoped by
|
|
11
18
|
* role), its `ctx`, and its `role`.
|
|
12
19
|
*/
|
|
13
|
-
declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role extends string = string> {
|
|
20
|
+
declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role extends string = string, Data = unknown> {
|
|
14
21
|
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
15
22
|
readonly ws: WebSocket;
|
|
23
|
+
/** Server-assigned unique id for this connection (stable for its lifetime). */
|
|
24
|
+
readonly id: string;
|
|
16
25
|
/** This connection's role (the literal resolved by `authenticate`). */
|
|
17
26
|
readonly role: Role;
|
|
18
27
|
/** The context `authenticate` returned for this connection. */
|
|
19
28
|
readonly ctx: Ctx;
|
|
20
29
|
private readonly serializer;
|
|
30
|
+
private readonly backpressure?;
|
|
21
31
|
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
22
32
|
readonly channels: Set<string>;
|
|
33
|
+
/** Mutable per-connection scratch state, typed per role by the contract's `data` schema. */
|
|
34
|
+
data: Data;
|
|
35
|
+
/** When this connection was accepted (`Date.now()` at the upgrade). */
|
|
36
|
+
readonly connectedAt: number;
|
|
37
|
+
/** When the server last sent a heartbeat ping to this connection (managed by the server). */
|
|
38
|
+
lastPingAt?: number;
|
|
39
|
+
/** When a heartbeat pong was last received — liveness signal (managed by the server). */
|
|
40
|
+
lastPongAt?: number;
|
|
41
|
+
/** Pings sent since the last pong; drives reaping (managed by the server). */
|
|
42
|
+
missedPongs: number;
|
|
23
43
|
constructor(
|
|
24
44
|
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
25
45
|
ws: WebSocket,
|
|
46
|
+
/** Server-assigned unique id for this connection (stable for its lifetime). */
|
|
47
|
+
id: string,
|
|
26
48
|
/** This connection's role (the literal resolved by `authenticate`). */
|
|
27
49
|
role: Role,
|
|
28
50
|
/** The context `authenticate` returned for this connection. */
|
|
29
|
-
ctx: Ctx, serializer: Serializer);
|
|
51
|
+
ctx: Ctx, serializer: Serializer, backpressure?: Backpressure | undefined);
|
|
52
|
+
private overBackpressure;
|
|
30
53
|
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
31
54
|
send(frame: ServerFrame): void;
|
|
32
55
|
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
@@ -40,9 +63,12 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
|
|
|
40
63
|
/**
|
|
41
64
|
* In-process pub/sub bus. Share one bus across multiple servers to simulate
|
|
42
65
|
* multiple nodes in a test (each server gets its own adapter bound to the bus).
|
|
66
|
+
* The presence directory also lives here, so servers sharing a bus see the whole
|
|
67
|
+
* cluster (mirroring how Redis is shared in production).
|
|
43
68
|
*/
|
|
44
69
|
declare class MemoryBus {
|
|
45
70
|
private readonly channels;
|
|
71
|
+
readonly descriptors: Map<string, ConnDescriptor>;
|
|
46
72
|
subscribe(channel: string, adapter: MemoryAdapter): void;
|
|
47
73
|
unsubscribe(channel: string, adapter: MemoryAdapter): void;
|
|
48
74
|
publish(channel: string, payload: string | Uint8Array): void;
|
|
@@ -50,6 +76,7 @@ declare class MemoryBus {
|
|
|
50
76
|
declare class MemoryAdapter implements Adapter {
|
|
51
77
|
private readonly bus;
|
|
52
78
|
private handler?;
|
|
79
|
+
readonly presence: PresenceStore;
|
|
53
80
|
constructor(bus: MemoryBus);
|
|
54
81
|
subscribe(channel: string): void;
|
|
55
82
|
unsubscribe(channel: string): void;
|
|
@@ -83,10 +110,10 @@ type CtxUnion<A> = A extends {
|
|
|
83
110
|
ctx: infer X;
|
|
84
111
|
} ? X : never;
|
|
85
112
|
type RoleHandlers<C extends Contract, A, R extends RoleOf<C>> = {
|
|
86
|
-
[K in keyof RoleRequests<C, R>]: (input: ServerInput<RoleRequests<C, R>[K]>, ctx: CtxFor<A, R>, conn: Conn<Events<C, R>, CtxFor<A, R>, R
|
|
113
|
+
[K in keyof RoleRequests<C, R>]: (input: ServerInput<RoleRequests<C, R>[K]>, ctx: CtxFor<A, R>, conn: Conn<Events<C, R>, CtxFor<A, R>, R, DataOf<C, R>>) => Awaitable<Output<RoleRequests<C, R>[K]>>;
|
|
87
114
|
};
|
|
88
115
|
type SharedHandlers<C extends Contract, A> = {
|
|
89
|
-
[K in keyof SharedRequests<C>]: (input: ServerInput<SharedRequests<C>[K]>, ctx: CtxUnion<A>, conn: Conn<SharedEvents<C>, CtxUnion<A>, RoleOf<C>>) => Awaitable<Output<SharedRequests<C>[K]>>;
|
|
116
|
+
[K in keyof SharedRequests<C>]: (input: ServerInput<SharedRequests<C>[K]>, ctx: CtxUnion<A>, conn: Conn<SharedEvents<C>, CtxUnion<A>, RoleOf<C>, AnyData<C>>) => Awaitable<Output<SharedRequests<C>[K]>>;
|
|
90
117
|
};
|
|
91
118
|
/**
|
|
92
119
|
* The handler map passed to `implement`: one block per role plus an optional
|
|
@@ -125,6 +152,56 @@ interface Room<C extends Contract> {
|
|
|
125
152
|
broadcast<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
126
153
|
/** Member count **on the current node** (membership is node-local). */
|
|
127
154
|
readonly size: number;
|
|
155
|
+
/** Snapshot of this room's members **on the current node**. */
|
|
156
|
+
readonly connections: Conn[];
|
|
157
|
+
}
|
|
158
|
+
/** Synchronous, node-local introspection of the current server process. */
|
|
159
|
+
interface LocalView {
|
|
160
|
+
/** Snapshot of all connections accepted on this node. */
|
|
161
|
+
readonly connections: Conn[];
|
|
162
|
+
/** Names of rooms with at least one member on this node. */
|
|
163
|
+
readonly rooms: string[];
|
|
164
|
+
/** Names of topics with at least one subscriber on this node. */
|
|
165
|
+
readonly topics: string[];
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Asynchronous, cluster-wide introspection backed by the adapter's presence
|
|
169
|
+
* directory. Methods reject if the configured adapter has no presence support.
|
|
170
|
+
*/
|
|
171
|
+
interface ClusterView {
|
|
172
|
+
/** Every live connection across the cluster. */
|
|
173
|
+
connections(): Promise<ConnDescriptor[]>;
|
|
174
|
+
/** Total live connection count across the cluster. */
|
|
175
|
+
count(): Promise<number>;
|
|
176
|
+
/** Connections for a given user key (the `identify` hook). */
|
|
177
|
+
byUser(userId: string): Promise<ConnDescriptor[]>;
|
|
178
|
+
/** Connections that are members of `room`, across nodes. */
|
|
179
|
+
room(name: string): Promise<ConnDescriptor[]>;
|
|
180
|
+
/** Per-node aggregates (the other nodes and their counts). */
|
|
181
|
+
topology(): Promise<NodeStat[]>;
|
|
182
|
+
}
|
|
183
|
+
/** A single targeted connection, reachable on whatever node holds it. */
|
|
184
|
+
interface ConnTarget<C extends Contract> {
|
|
185
|
+
/** Push a shared event to this connection (cross-node). */
|
|
186
|
+
emit<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
187
|
+
/**
|
|
188
|
+
* Send a shared server→client request and await the client's typed reply
|
|
189
|
+
* (cross-node). Rejects with a `TIMEOUT` `SocketError` if no live node owns
|
|
190
|
+
* the connection or the client doesn't answer in time.
|
|
191
|
+
*/
|
|
192
|
+
request<M extends keyof SharedServerRequests<C>>(name: M, input: ClientInput<SharedServerRequests<C>[M]>, opts?: {
|
|
193
|
+
timeout?: number;
|
|
194
|
+
signal?: AbortSignal;
|
|
195
|
+
}): Promise<Output<SharedServerRequests<C>[M]>>;
|
|
196
|
+
/** Close this connection (cross-node kick). */
|
|
197
|
+
close(): void;
|
|
198
|
+
}
|
|
199
|
+
/** All of a user's connections (0..N devices), reachable across nodes. */
|
|
200
|
+
interface UserTarget<C extends Contract> {
|
|
201
|
+
/** Push a shared event to every one of the user's connections (cross-node). */
|
|
202
|
+
emit<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
203
|
+
/** Disconnect every one of the user's connections (cross-node). */
|
|
204
|
+
disconnect(): void;
|
|
128
205
|
}
|
|
129
206
|
/** Lens for role-scoped server sends, returned by `srv.forRole(role)`. */
|
|
130
207
|
interface RoleLens<C extends Contract, R extends RoleOf<C>> {
|
|
@@ -143,10 +220,26 @@ interface ServerOptions<C extends Contract, A extends AuthResult<C>> {
|
|
|
143
220
|
path?: string;
|
|
144
221
|
/** Runs at the HTTP upgrade. Return { role, ctx }, or throw to reject with 401. */
|
|
145
222
|
authenticate: (req: IncomingMessage) => Awaitable<A>;
|
|
223
|
+
/** Stable user key for a connection (powers `cluster.byUser`, `isOnline`, and `toUser`). */
|
|
224
|
+
identify?: (conn: Conn) => string | undefined;
|
|
225
|
+
/** Extra fields merged into the connection's cluster descriptor (e.g. `{ plan }`). `ctx` is never auto-serialized. */
|
|
226
|
+
describeConn?: (conn: Conn) => Record<string, unknown>;
|
|
146
227
|
/** Runs on each client subscribe. Return false or throw to deny. */
|
|
147
228
|
authorizeSubscribe?: (topic: string, ctx: CtxUnion<A>, conn: Conn) => Awaitable<boolean | void>;
|
|
148
229
|
/** Middleware chain run before req/subscribe handlers (rate-limit, authz, logging, metrics). */
|
|
149
230
|
use?: Middleware<A>[];
|
|
231
|
+
/**
|
|
232
|
+
* Heartbeat: one timer pings every connection each `interval` ms (updating
|
|
233
|
+
* `conn.lastPingAt`/`lastPongAt`). Set `maxMissed` to terminate a connection
|
|
234
|
+
* that misses that many consecutive pongs. `false` disables it.
|
|
235
|
+
* Defaults to `{ interval: 30_000 }` (no reaping).
|
|
236
|
+
*/
|
|
237
|
+
heartbeat?: {
|
|
238
|
+
interval?: number;
|
|
239
|
+
maxMissed?: number;
|
|
240
|
+
} | false;
|
|
241
|
+
/** Guard against slow consumers: when a connection's send buffer exceeds the limit, close or drop. */
|
|
242
|
+
backpressure?: Backpressure;
|
|
150
243
|
/** Called once per accepted connection. */
|
|
151
244
|
onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
|
|
152
245
|
/** Called when a connection closes, with the WebSocket close `code`. */
|
|
@@ -156,6 +249,18 @@ interface ServerOptions<C extends Contract, A extends AuthResult<C>> {
|
|
|
156
249
|
}
|
|
157
250
|
/** A running super-line server, returned by {@link createSocketServer}. */
|
|
158
251
|
interface SocketServer<C extends Contract, A extends AuthResult<C>> {
|
|
252
|
+
/** This node's stable id (unique per server process). */
|
|
253
|
+
readonly nodeId: string;
|
|
254
|
+
/** Synchronous, node-local introspection (connections, rooms, topics on this process). */
|
|
255
|
+
readonly local: LocalView;
|
|
256
|
+
/** Asynchronous, cluster-wide introspection backed by the adapter's presence directory. */
|
|
257
|
+
readonly cluster: ClusterView;
|
|
258
|
+
/** Whether a user (by `identify` key) has at least one live connection anywhere. */
|
|
259
|
+
isOnline(userId: string): Promise<boolean>;
|
|
260
|
+
/** Target a single connection by id, on whatever node holds it (cross-node emit/close). */
|
|
261
|
+
toConn(id: string): ConnTarget<C>;
|
|
262
|
+
/** Target all of a user's connections (by `identify` key) across nodes (emit/disconnect). */
|
|
263
|
+
toUser(userId: string): UserTarget<C>;
|
|
159
264
|
/** Register handlers for shared + per-role requests (chainable). */
|
|
160
265
|
implement(handlers: Handlers<C, A>): SocketServer<C, A>;
|
|
161
266
|
/** Mixed-role connection group; broadcast() sends a shared contract event to members. */
|
|
@@ -194,4 +299,4 @@ interface SocketServer<C extends Contract, A extends AuthResult<C>> {
|
|
|
194
299
|
*/
|
|
195
300
|
declare function createSocketServer<C extends Contract, A extends AuthResult<C>>(contract: C, opts: ServerOptions<C, A>): SocketServer<C, A>;
|
|
196
301
|
|
|
197
|
-
export { type AuthResult, Conn, type Handlers, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerOptions, type SocketServer, createInMemoryAdapter, createSocketServer };
|
|
302
|
+
export { type AuthResult, type Backpressure, type ClusterView, Conn, type ConnTarget, type Handlers, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerOptions, type SocketServer, type UserTarget, createInMemoryAdapter, createSocketServer };
|
package/dist/index.js
CHANGED
|
@@ -9,26 +9,51 @@ import {
|
|
|
9
9
|
|
|
10
10
|
// src/conn.ts
|
|
11
11
|
var Conn = class {
|
|
12
|
-
constructor(ws, role, ctx, serializer) {
|
|
12
|
+
constructor(ws, id, role, ctx, serializer, backpressure) {
|
|
13
13
|
this.ws = ws;
|
|
14
|
+
this.id = id;
|
|
14
15
|
this.role = role;
|
|
15
16
|
this.ctx = ctx;
|
|
16
17
|
this.serializer = serializer;
|
|
18
|
+
this.backpressure = backpressure;
|
|
17
19
|
}
|
|
18
20
|
ws;
|
|
21
|
+
id;
|
|
19
22
|
role;
|
|
20
23
|
ctx;
|
|
21
24
|
serializer;
|
|
25
|
+
backpressure;
|
|
22
26
|
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
23
27
|
channels = /* @__PURE__ */ new Set();
|
|
28
|
+
/** Mutable per-connection scratch state, typed per role by the contract's `data` schema. */
|
|
29
|
+
data = {};
|
|
30
|
+
/** When this connection was accepted (`Date.now()` at the upgrade). */
|
|
31
|
+
connectedAt = Date.now();
|
|
32
|
+
/** When the server last sent a heartbeat ping to this connection (managed by the server). */
|
|
33
|
+
lastPingAt;
|
|
34
|
+
/** When a heartbeat pong was last received — liveness signal (managed by the server). */
|
|
35
|
+
lastPongAt;
|
|
36
|
+
/** Pings sent since the last pong; drives reaping (managed by the server). */
|
|
37
|
+
missedPongs = 0;
|
|
38
|
+
// true => the frame was handled by the backpressure policy and must not be sent
|
|
39
|
+
overBackpressure() {
|
|
40
|
+
const bp = this.backpressure;
|
|
41
|
+
if (!bp || this.ws.bufferedAmount <= bp.maxBufferedBytes) return false;
|
|
42
|
+
if (bp.onExceed === "drop") {
|
|
43
|
+
console.warn(`[super-line] dropping frame: conn ${this.id} over backpressure limit`);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
this.ws.close(1013);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
24
49
|
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
25
50
|
send(frame) {
|
|
26
|
-
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
51
|
+
if (this.ws.readyState !== this.ws.OPEN || this.overBackpressure()) return;
|
|
27
52
|
this.ws.send(this.serializer.encode(frame));
|
|
28
53
|
}
|
|
29
54
|
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
30
55
|
sendRaw(payload) {
|
|
31
|
-
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
56
|
+
if (this.ws.readyState !== this.ws.OPEN || this.overBackpressure()) return;
|
|
32
57
|
this.ws.send(payload);
|
|
33
58
|
}
|
|
34
59
|
/** Push an event to THIS connection (node-local). Scoped to the role's events. */
|
|
@@ -44,6 +69,8 @@ var Conn = class {
|
|
|
44
69
|
// src/memory-adapter.ts
|
|
45
70
|
var MemoryBus = class {
|
|
46
71
|
channels = /* @__PURE__ */ new Map();
|
|
72
|
+
// shared presence directory; in-memory liveness = "has a descriptor" (graceful del on disconnect)
|
|
73
|
+
descriptors = /* @__PURE__ */ new Map();
|
|
47
74
|
subscribe(channel, adapter) {
|
|
48
75
|
let set = this.channels.get(channel);
|
|
49
76
|
if (!set) {
|
|
@@ -64,12 +91,68 @@ var MemoryBus = class {
|
|
|
64
91
|
for (const adapter of set) adapter.deliver(channel, payload);
|
|
65
92
|
}
|
|
66
93
|
};
|
|
94
|
+
var MemoryPresence = class {
|
|
95
|
+
constructor(bus) {
|
|
96
|
+
this.bus = bus;
|
|
97
|
+
}
|
|
98
|
+
bus;
|
|
99
|
+
set(d) {
|
|
100
|
+
this.bus.descriptors.set(d.id, d);
|
|
101
|
+
}
|
|
102
|
+
del(connId) {
|
|
103
|
+
this.bus.descriptors.delete(connId);
|
|
104
|
+
}
|
|
105
|
+
beat() {
|
|
106
|
+
}
|
|
107
|
+
clearNode(nodeId) {
|
|
108
|
+
for (const [id, d] of this.bus.descriptors) if (d.nodeId === nodeId) this.bus.descriptors.delete(id);
|
|
109
|
+
}
|
|
110
|
+
addRoom(connId, room) {
|
|
111
|
+
const d = this.bus.descriptors.get(connId);
|
|
112
|
+
if (d && !d.rooms.includes(room)) d.rooms = [...d.rooms, room];
|
|
113
|
+
}
|
|
114
|
+
removeRoom(connId, room) {
|
|
115
|
+
const d = this.bus.descriptors.get(connId);
|
|
116
|
+
if (d) d.rooms = d.rooms.filter((r) => r !== room);
|
|
117
|
+
}
|
|
118
|
+
list() {
|
|
119
|
+
return [...this.bus.descriptors.values()];
|
|
120
|
+
}
|
|
121
|
+
get(connId) {
|
|
122
|
+
return this.bus.descriptors.get(connId);
|
|
123
|
+
}
|
|
124
|
+
byUser(userId) {
|
|
125
|
+
return this.list().filter((d) => d.userId === userId);
|
|
126
|
+
}
|
|
127
|
+
roomMembers(room) {
|
|
128
|
+
return this.list().filter((d) => d.rooms.includes(room));
|
|
129
|
+
}
|
|
130
|
+
count() {
|
|
131
|
+
return this.bus.descriptors.size;
|
|
132
|
+
}
|
|
133
|
+
topology() {
|
|
134
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
135
|
+
for (const d of this.list()) {
|
|
136
|
+
const set = byNode.get(d.nodeId);
|
|
137
|
+
if (set) set.push(d);
|
|
138
|
+
else byNode.set(d.nodeId, [d]);
|
|
139
|
+
}
|
|
140
|
+
return [...byNode.entries()].map(([nodeId, ds]) => ({
|
|
141
|
+
nodeId,
|
|
142
|
+
connections: ds.length,
|
|
143
|
+
rooms: new Set(ds.flatMap((d) => d.rooms)).size,
|
|
144
|
+
alive: true
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
};
|
|
67
148
|
var MemoryAdapter = class {
|
|
68
149
|
constructor(bus) {
|
|
69
150
|
this.bus = bus;
|
|
151
|
+
this.presence = new MemoryPresence(bus);
|
|
70
152
|
}
|
|
71
153
|
bus;
|
|
72
154
|
handler;
|
|
155
|
+
presence;
|
|
73
156
|
subscribe(channel) {
|
|
74
157
|
this.bus.subscribe(channel, this);
|
|
75
158
|
}
|
|
@@ -93,6 +176,9 @@ function createInMemoryAdapter(bus = new MemoryBus()) {
|
|
|
93
176
|
// src/index.ts
|
|
94
177
|
var ROOM = "r:";
|
|
95
178
|
var TOPIC = "t:";
|
|
179
|
+
var CONN = "c:";
|
|
180
|
+
var USER = "u:";
|
|
181
|
+
var REPLY = "reply:";
|
|
96
182
|
var S2S = "s2s";
|
|
97
183
|
function createSocketServer(contract, opts) {
|
|
98
184
|
const c = contract;
|
|
@@ -103,17 +189,122 @@ function createSocketServer(contract, opts) {
|
|
|
103
189
|
const members = /* @__PURE__ */ new Map();
|
|
104
190
|
const serverListeners = /* @__PURE__ */ new Map();
|
|
105
191
|
const instanceId = randomUUID();
|
|
192
|
+
const replyChannel = REPLY + instanceId;
|
|
106
193
|
let impl = {};
|
|
194
|
+
let closing = false;
|
|
195
|
+
let nextSReq = 1;
|
|
196
|
+
const originWaiters = /* @__PURE__ */ new Map();
|
|
197
|
+
const ownerRouting = /* @__PURE__ */ new Map();
|
|
198
|
+
function buildDescriptor(conn) {
|
|
199
|
+
const userId = opts.identify?.(conn);
|
|
200
|
+
const rooms = [];
|
|
201
|
+
for (const ch of conn.channels) if (ch.startsWith(ROOM)) rooms.push(ch.slice(ROOM.length));
|
|
202
|
+
return {
|
|
203
|
+
id: conn.id,
|
|
204
|
+
role: conn.role,
|
|
205
|
+
nodeId: instanceId,
|
|
206
|
+
connectedAt: conn.connectedAt,
|
|
207
|
+
...userId !== void 0 ? { userId } : {},
|
|
208
|
+
rooms,
|
|
209
|
+
...opts.describeConn?.(conn)
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function presenceOrThrow() {
|
|
213
|
+
if (!adapter.presence) throw new Error("cluster queries require an adapter with presence support");
|
|
214
|
+
return adapter.presence;
|
|
215
|
+
}
|
|
107
216
|
adapter.onMessage((channel, payload) => {
|
|
108
217
|
if (channel === S2S) {
|
|
109
218
|
handleServerMessage(payload);
|
|
110
219
|
return;
|
|
111
220
|
}
|
|
221
|
+
if (channel === replyChannel) {
|
|
222
|
+
handleReply(payload);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (channel.startsWith(CONN) || channel.startsWith(USER)) {
|
|
226
|
+
handlePersonal(channel, payload);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
112
229
|
const set = members.get(channel);
|
|
113
230
|
if (!set) return;
|
|
114
231
|
for (const conn of set) conn.sendRaw(payload);
|
|
115
232
|
});
|
|
233
|
+
void adapter.subscribe(replyChannel);
|
|
234
|
+
function handlePersonal(channel, payload) {
|
|
235
|
+
const set = members.get(channel);
|
|
236
|
+
if (!set) return;
|
|
237
|
+
let env;
|
|
238
|
+
try {
|
|
239
|
+
env = serializer.decode(payload);
|
|
240
|
+
} catch {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (env.p === "close") {
|
|
244
|
+
for (const conn of set) conn.close();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (env.p === "req") {
|
|
248
|
+
const localId = nextSReq++;
|
|
249
|
+
ownerRouting.set(localId, { origin: env.o, corrId: env.i, name: env.m });
|
|
250
|
+
for (const conn of set) conn.send({ t: "sreq", i: localId, m: env.m, d: env.d });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const frame = serializer.encode(env.f);
|
|
254
|
+
for (const conn of set) conn.sendRaw(frame);
|
|
255
|
+
}
|
|
256
|
+
function handleReply(payload) {
|
|
257
|
+
let env;
|
|
258
|
+
try {
|
|
259
|
+
env = serializer.decode(payload);
|
|
260
|
+
} catch {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const w = originWaiters.get(env.i);
|
|
264
|
+
if (!w) return;
|
|
265
|
+
originWaiters.delete(env.i);
|
|
266
|
+
if (w.timer) clearTimeout(w.timer);
|
|
267
|
+
if (env.ok) w.resolve(env.d);
|
|
268
|
+
else w.reject(new SocketError(env.code, env.m, env.d));
|
|
269
|
+
}
|
|
270
|
+
async function handleClientReply(conn, localId, result) {
|
|
271
|
+
const r = ownerRouting.get(localId);
|
|
272
|
+
if (!r) return;
|
|
273
|
+
ownerRouting.delete(localId);
|
|
274
|
+
let env;
|
|
275
|
+
if (result.ok) {
|
|
276
|
+
try {
|
|
277
|
+
const def = c.roles[conn.role]?.serverToClient?.[r.name] ?? c.shared?.serverToClient?.[r.name];
|
|
278
|
+
const out = def && "output" in def ? await validate(def.output, result.d) : result.d;
|
|
279
|
+
env = { i: r.corrId, ok: true, d: out };
|
|
280
|
+
} catch (err) {
|
|
281
|
+
const e = err instanceof SocketError ? err : new SocketError("INTERNAL", "Internal server error");
|
|
282
|
+
env = { i: r.corrId, ok: false, code: e.code, m: e.message, d: e.data };
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
env = { i: r.corrId, ok: false, code: result.code, m: result.m, d: result.d };
|
|
286
|
+
}
|
|
287
|
+
void adapter.publish(REPLY + r.origin, serializer.encode(env));
|
|
288
|
+
}
|
|
116
289
|
if (c.serverToServer) void adapter.subscribe(S2S);
|
|
290
|
+
const hb = opts.heartbeat === false ? null : opts.heartbeat ?? {};
|
|
291
|
+
let hbTimer;
|
|
292
|
+
if (hb) {
|
|
293
|
+
hbTimer = setInterval(() => {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
void adapter.presence?.beat(instanceId);
|
|
296
|
+
for (const conn of conns) {
|
|
297
|
+
if (hb.maxMissed != null && conn.missedPongs >= hb.maxMissed) {
|
|
298
|
+
conn.ws.terminate();
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
conn.missedPongs++;
|
|
302
|
+
conn.lastPingAt = now;
|
|
303
|
+
conn.ws.ping();
|
|
304
|
+
}
|
|
305
|
+
}, hb.interval ?? 3e4);
|
|
306
|
+
hbTimer.unref?.();
|
|
307
|
+
}
|
|
117
308
|
function handleServerMessage(payload) {
|
|
118
309
|
let msg;
|
|
119
310
|
try {
|
|
@@ -127,6 +318,7 @@ function createSocketServer(contract, opts) {
|
|
|
127
318
|
}
|
|
128
319
|
function joinChannel(conn, channel) {
|
|
129
320
|
conn.channels.add(channel);
|
|
321
|
+
if (channel.startsWith(ROOM)) void adapter.presence?.addRoom(conn.id, channel.slice(ROOM.length));
|
|
130
322
|
const set = members.get(channel);
|
|
131
323
|
if (set) {
|
|
132
324
|
set.add(conn);
|
|
@@ -140,14 +332,19 @@ function createSocketServer(contract, opts) {
|
|
|
140
332
|
if (!set) return;
|
|
141
333
|
set.delete(conn);
|
|
142
334
|
conn.channels.delete(channel);
|
|
335
|
+
if (channel.startsWith(ROOM)) void adapter.presence?.removeRoom(conn.id, channel.slice(ROOM.length));
|
|
143
336
|
if (set.size === 0) {
|
|
144
337
|
members.delete(channel);
|
|
145
338
|
void adapter.unsubscribe(channel);
|
|
146
339
|
}
|
|
147
340
|
}
|
|
341
|
+
function isTopic(name, block) {
|
|
342
|
+
const def = block?.serverToClient?.[name];
|
|
343
|
+
return !!def && typeof def === "object" && "subscribe" in def && def.subscribe === true;
|
|
344
|
+
}
|
|
148
345
|
function topicNamespace(role, name) {
|
|
149
|
-
if (c.roles[role]
|
|
150
|
-
if (c.shared
|
|
346
|
+
if (isTopic(name, c.roles[role])) return role;
|
|
347
|
+
if (isTopic(name, c.shared)) return "shared";
|
|
151
348
|
return void 0;
|
|
152
349
|
}
|
|
153
350
|
if (opts.server) {
|
|
@@ -169,17 +366,26 @@ function createSocketServer(contract, opts) {
|
|
|
169
366
|
return;
|
|
170
367
|
}
|
|
171
368
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
172
|
-
const conn = new Conn(ws, auth.role, auth.ctx, serializer);
|
|
369
|
+
const conn = new Conn(ws, randomUUID(), auth.role, auth.ctx, serializer, opts.backpressure);
|
|
173
370
|
conns.add(conn);
|
|
371
|
+
ws.on("pong", () => {
|
|
372
|
+
conn.lastPongAt = Date.now();
|
|
373
|
+
conn.missedPongs = 0;
|
|
374
|
+
});
|
|
174
375
|
ws.on("message", (data, isBinary) => {
|
|
175
376
|
void onMessage(conn, data, isBinary);
|
|
176
377
|
});
|
|
177
378
|
ws.on("close", (code) => {
|
|
178
379
|
conns.delete(conn);
|
|
179
380
|
for (const channel of conn.channels) leaveChannel(conn, channel);
|
|
381
|
+
void adapter.presence?.del(conn.id);
|
|
180
382
|
opts.onDisconnect?.(conn, auth.ctx, code);
|
|
181
383
|
});
|
|
182
384
|
opts.onConnection?.(conn, auth.ctx);
|
|
385
|
+
void adapter.presence?.set(buildDescriptor(conn));
|
|
386
|
+
void joinChannel(conn, CONN + conn.id);
|
|
387
|
+
const uid = opts.identify?.(conn);
|
|
388
|
+
if (uid !== void 0) void joinChannel(conn, USER + uid);
|
|
183
389
|
});
|
|
184
390
|
}
|
|
185
391
|
async function onMessage(conn, data, isBinary) {
|
|
@@ -194,6 +400,10 @@ function createSocketServer(contract, opts) {
|
|
|
194
400
|
else if (frame.t === "unsub") {
|
|
195
401
|
const ns = topicNamespace(conn.role, frame.c);
|
|
196
402
|
if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
|
|
403
|
+
} else if (frame.t === "sres") {
|
|
404
|
+
await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
|
|
405
|
+
} else if (frame.t === "serr") {
|
|
406
|
+
await handleClientReply(conn, frame.i, { ok: false, code: frame.code, m: frame.m, d: frame.d });
|
|
197
407
|
}
|
|
198
408
|
}
|
|
199
409
|
function runMiddleware(info, terminal) {
|
|
@@ -265,13 +475,101 @@ function createSocketServer(contract, opts) {
|
|
|
265
475
|
},
|
|
266
476
|
get size() {
|
|
267
477
|
return members.get(channel)?.size ?? 0;
|
|
478
|
+
},
|
|
479
|
+
get connections() {
|
|
480
|
+
return [...members.get(channel) ?? []];
|
|
268
481
|
}
|
|
269
482
|
};
|
|
270
483
|
}
|
|
271
484
|
function publishTo(ns, name, data) {
|
|
272
485
|
void adapter.publish(TOPIC + ns + ":" + name, serializer.encode({ t: "pub", c: name, d: data }));
|
|
273
486
|
}
|
|
487
|
+
function personalTarget(channel) {
|
|
488
|
+
return {
|
|
489
|
+
emit(event, data) {
|
|
490
|
+
const env = { p: "emit", f: { t: "evt", e: event, d: data } };
|
|
491
|
+
void adapter.publish(channel, serializer.encode(env));
|
|
492
|
+
},
|
|
493
|
+
close() {
|
|
494
|
+
void adapter.publish(channel, serializer.encode({ p: "close" }));
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function requestConn(id, name, input, opts2) {
|
|
499
|
+
const reqId = nextSReq++;
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
const ms = opts2?.timeout ?? 3e4;
|
|
502
|
+
const timer = ms > 0 ? setTimeout(() => {
|
|
503
|
+
originWaiters.delete(reqId);
|
|
504
|
+
reject(new SocketError("TIMEOUT", `Request '${name}' timed out`));
|
|
505
|
+
}, ms) : void 0;
|
|
506
|
+
originWaiters.set(reqId, { resolve, reject, timer });
|
|
507
|
+
opts2?.signal?.addEventListener(
|
|
508
|
+
"abort",
|
|
509
|
+
() => {
|
|
510
|
+
if (originWaiters.delete(reqId)) {
|
|
511
|
+
if (timer) clearTimeout(timer);
|
|
512
|
+
reject(new SocketError("BAD_REQUEST", "Aborted"));
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
{ once: true }
|
|
516
|
+
);
|
|
517
|
+
const env = { p: "req", o: instanceId, i: reqId, m: name, d: input };
|
|
518
|
+
void adapter.publish(CONN + id, serializer.encode(env));
|
|
519
|
+
});
|
|
520
|
+
}
|
|
274
521
|
const api = {
|
|
522
|
+
nodeId: instanceId,
|
|
523
|
+
get local() {
|
|
524
|
+
return {
|
|
525
|
+
connections: [...conns],
|
|
526
|
+
get rooms() {
|
|
527
|
+
const out = [];
|
|
528
|
+
for (const channel of members.keys()) if (channel.startsWith(ROOM)) out.push(channel.slice(ROOM.length));
|
|
529
|
+
return out;
|
|
530
|
+
},
|
|
531
|
+
get topics() {
|
|
532
|
+
const out = [];
|
|
533
|
+
for (const channel of members.keys()) {
|
|
534
|
+
if (!channel.startsWith(TOPIC)) continue;
|
|
535
|
+
out.push(channel.slice(channel.indexOf(":", TOPIC.length) + 1));
|
|
536
|
+
}
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
cluster: {
|
|
542
|
+
async connections() {
|
|
543
|
+
return presenceOrThrow().list();
|
|
544
|
+
},
|
|
545
|
+
async count() {
|
|
546
|
+
return presenceOrThrow().count();
|
|
547
|
+
},
|
|
548
|
+
async byUser(userId) {
|
|
549
|
+
return presenceOrThrow().byUser(userId);
|
|
550
|
+
},
|
|
551
|
+
async room(name) {
|
|
552
|
+
return presenceOrThrow().roomMembers(name);
|
|
553
|
+
},
|
|
554
|
+
async topology() {
|
|
555
|
+
return presenceOrThrow().topology();
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
async isOnline(userId) {
|
|
559
|
+
return (await presenceOrThrow().byUser(userId)).length > 0;
|
|
560
|
+
},
|
|
561
|
+
toConn(id) {
|
|
562
|
+
const t = personalTarget(CONN + id);
|
|
563
|
+
return {
|
|
564
|
+
emit: t.emit,
|
|
565
|
+
close: t.close,
|
|
566
|
+
request: (name, input, opts2) => requestConn(id, String(name), input, opts2)
|
|
567
|
+
};
|
|
568
|
+
},
|
|
569
|
+
toUser(userId) {
|
|
570
|
+
const t = personalTarget(USER + userId);
|
|
571
|
+
return { emit: t.emit, disconnect: t.close };
|
|
572
|
+
},
|
|
275
573
|
implement(handlers) {
|
|
276
574
|
impl = handlers;
|
|
277
575
|
return api;
|
|
@@ -306,7 +604,11 @@ function createSocketServer(contract, opts) {
|
|
|
306
604
|
};
|
|
307
605
|
},
|
|
308
606
|
async close() {
|
|
607
|
+
if (closing) return;
|
|
608
|
+
closing = true;
|
|
609
|
+
if (hbTimer) clearInterval(hbTimer);
|
|
309
610
|
for (const conn of conns) conn.close();
|
|
611
|
+
await adapter.presence?.clearNode(instanceId);
|
|
310
612
|
await adapter.close?.();
|
|
311
613
|
await new Promise((resolve) => {
|
|
312
614
|
wss.close(() => resolve());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@super-line/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typesafe WebSocket server for super-line — rooms, topics, middleware, pluggable adapters.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,14 +48,15 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"ws": "^8.18.0",
|
|
51
|
-
"@super-line/core": "^0.
|
|
51
|
+
"@super-line/core": "^0.2.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/ws": "^8.5.13",
|
|
55
|
+
"ioredis": "^5.4.2",
|
|
55
56
|
"testcontainers": "^10.13.2",
|
|
56
57
|
"zod": "^3.24.1",
|
|
57
|
-
"@super-line/adapter-redis": "0.
|
|
58
|
-
"@super-line/client": "0.
|
|
58
|
+
"@super-line/adapter-redis": "0.2.0",
|
|
59
|
+
"@super-line/client": "0.2.0"
|
|
59
60
|
},
|
|
60
61
|
"scripts": {
|
|
61
62
|
"build": "tsup"
|