@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 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]?.serverToClient?.[name]?.subscribe) return role;
173
- if (c.shared?.serverToClient?.[name]?.subscribe) return "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, SharedRequests, ServerInput, SharedEvents, Output, RoleRequests, Events, RoleTopics, SharedTopics, ServerEvents, ServerEmit, ServerData } from '@super-line/core';
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>) => Awaitable<Output<RoleRequests<C, R>[K]>>;
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, SharedRequests, ServerInput, SharedEvents, Output, RoleRequests, Events, RoleTopics, SharedTopics, ServerEvents, ServerEmit, ServerData } from '@super-line/core';
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>) => Awaitable<Output<RoleRequests<C, R>[K]>>;
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]?.serverToClient?.[name]?.subscribe) return role;
150
- if (c.shared?.serverToClient?.[name]?.subscribe) return "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.1.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.1.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.1.0",
58
- "@super-line/client": "0.1.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"