@super-line/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/index.cjs +352 -0
- package/dist/index.d.cts +197 -0
- package/dist/index.d.ts +197 -0
- package/dist/index.js +328 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @super-line/server
|
|
2
|
+
|
|
3
|
+
The server for [**super-line**](https://mertdogar.github.io/super-line/) — end-to-end typesafe WebSockets for TypeScript. Implements a shared contract over [`ws`](https://www.npmjs.com/package/ws): role-keyed handlers, rooms, topics, middleware, lifecycle hooks, and node-to-node messaging.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pnpm add @super-line/core @super-line/server zod
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import http from 'node:http'
|
|
11
|
+
import { createSocketServer } from '@super-line/server'
|
|
12
|
+
import { api } from './contract'
|
|
13
|
+
|
|
14
|
+
const server = http.createServer()
|
|
15
|
+
const srv = createSocketServer(api, {
|
|
16
|
+
server,
|
|
17
|
+
authenticate: (req) => ({ role: 'user' as const, ctx: { id: '1' } }), // throw -> 401
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
srv.implement({
|
|
21
|
+
user: {
|
|
22
|
+
send: async ({ text }, ctx, conn) => {
|
|
23
|
+
conn.emit('message', { text })
|
|
24
|
+
return { id: crypto.randomUUID() }
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
server.listen(3000)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Authenticate returns `{ role, ctx }`; cross-role calls are rejected with `NOT_FOUND`. Scale across processes with [`@super-line/adapter-redis`](https://www.npmjs.com/package/@super-line/adapter-redis).
|
|
33
|
+
|
|
34
|
+
- 📖 Docs: <https://mertdogar.github.io/super-line/>
|
|
35
|
+
- 📚 Guides: [roles & auth](https://mertdogar.github.io/super-line/guide/roles-auth), [events & rooms](https://mertdogar.github.io/super-line/guide/events-rooms)
|
|
36
|
+
- 📕 API reference: <https://mertdogar.github.io/super-line/reference/>
|
|
37
|
+
- 🧩 Source: <https://github.com/mertdogar/super-line>
|
|
38
|
+
|
|
39
|
+
MIT © Mert
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Conn: () => Conn,
|
|
24
|
+
MemoryBus: () => MemoryBus,
|
|
25
|
+
createInMemoryAdapter: () => createInMemoryAdapter,
|
|
26
|
+
createSocketServer: () => createSocketServer
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
var import_node_crypto = require("crypto");
|
|
30
|
+
var import_ws = require("ws");
|
|
31
|
+
var import_core = require("@super-line/core");
|
|
32
|
+
|
|
33
|
+
// src/conn.ts
|
|
34
|
+
var Conn = class {
|
|
35
|
+
constructor(ws, role, ctx, serializer) {
|
|
36
|
+
this.ws = ws;
|
|
37
|
+
this.role = role;
|
|
38
|
+
this.ctx = ctx;
|
|
39
|
+
this.serializer = serializer;
|
|
40
|
+
}
|
|
41
|
+
ws;
|
|
42
|
+
role;
|
|
43
|
+
ctx;
|
|
44
|
+
serializer;
|
|
45
|
+
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
46
|
+
channels = /* @__PURE__ */ new Set();
|
|
47
|
+
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
48
|
+
send(frame) {
|
|
49
|
+
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
50
|
+
this.ws.send(this.serializer.encode(frame));
|
|
51
|
+
}
|
|
52
|
+
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
53
|
+
sendRaw(payload) {
|
|
54
|
+
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
55
|
+
this.ws.send(payload);
|
|
56
|
+
}
|
|
57
|
+
/** Push an event to THIS connection (node-local). Scoped to the role's events. */
|
|
58
|
+
emit(event, data) {
|
|
59
|
+
this.send({ t: "evt", e: String(event), d: data });
|
|
60
|
+
}
|
|
61
|
+
/** Close the underlying socket. */
|
|
62
|
+
close() {
|
|
63
|
+
this.ws.close();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// src/memory-adapter.ts
|
|
68
|
+
var MemoryBus = class {
|
|
69
|
+
channels = /* @__PURE__ */ new Map();
|
|
70
|
+
subscribe(channel, adapter) {
|
|
71
|
+
let set = this.channels.get(channel);
|
|
72
|
+
if (!set) {
|
|
73
|
+
set = /* @__PURE__ */ new Set();
|
|
74
|
+
this.channels.set(channel, set);
|
|
75
|
+
}
|
|
76
|
+
set.add(adapter);
|
|
77
|
+
}
|
|
78
|
+
unsubscribe(channel, adapter) {
|
|
79
|
+
const set = this.channels.get(channel);
|
|
80
|
+
if (!set) return;
|
|
81
|
+
set.delete(adapter);
|
|
82
|
+
if (set.size === 0) this.channels.delete(channel);
|
|
83
|
+
}
|
|
84
|
+
publish(channel, payload) {
|
|
85
|
+
const set = this.channels.get(channel);
|
|
86
|
+
if (!set) return;
|
|
87
|
+
for (const adapter of set) adapter.deliver(channel, payload);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var MemoryAdapter = class {
|
|
91
|
+
constructor(bus) {
|
|
92
|
+
this.bus = bus;
|
|
93
|
+
}
|
|
94
|
+
bus;
|
|
95
|
+
handler;
|
|
96
|
+
subscribe(channel) {
|
|
97
|
+
this.bus.subscribe(channel, this);
|
|
98
|
+
}
|
|
99
|
+
unsubscribe(channel) {
|
|
100
|
+
this.bus.unsubscribe(channel, this);
|
|
101
|
+
}
|
|
102
|
+
publish(channel, payload) {
|
|
103
|
+
this.bus.publish(channel, payload);
|
|
104
|
+
}
|
|
105
|
+
onMessage(handler) {
|
|
106
|
+
this.handler = handler;
|
|
107
|
+
}
|
|
108
|
+
deliver(channel, payload) {
|
|
109
|
+
this.handler?.(channel, payload);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
function createInMemoryAdapter(bus = new MemoryBus()) {
|
|
113
|
+
return new MemoryAdapter(bus);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/index.ts
|
|
117
|
+
var ROOM = "r:";
|
|
118
|
+
var TOPIC = "t:";
|
|
119
|
+
var S2S = "s2s";
|
|
120
|
+
function createSocketServer(contract, opts) {
|
|
121
|
+
const c = contract;
|
|
122
|
+
const serializer = opts.serializer ?? import_core.jsonSerializer;
|
|
123
|
+
const adapter = opts.adapter ?? createInMemoryAdapter();
|
|
124
|
+
const wss = new import_ws.WebSocketServer({ noServer: true });
|
|
125
|
+
const conns = /* @__PURE__ */ new Set();
|
|
126
|
+
const members = /* @__PURE__ */ new Map();
|
|
127
|
+
const serverListeners = /* @__PURE__ */ new Map();
|
|
128
|
+
const instanceId = (0, import_node_crypto.randomUUID)();
|
|
129
|
+
let impl = {};
|
|
130
|
+
adapter.onMessage((channel, payload) => {
|
|
131
|
+
if (channel === S2S) {
|
|
132
|
+
handleServerMessage(payload);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const set = members.get(channel);
|
|
136
|
+
if (!set) return;
|
|
137
|
+
for (const conn of set) conn.sendRaw(payload);
|
|
138
|
+
});
|
|
139
|
+
if (c.serverToServer) void adapter.subscribe(S2S);
|
|
140
|
+
function handleServerMessage(payload) {
|
|
141
|
+
let msg;
|
|
142
|
+
try {
|
|
143
|
+
msg = serializer.decode(payload);
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (msg.from === instanceId) return;
|
|
148
|
+
const set = serverListeners.get(msg.e);
|
|
149
|
+
if (set) for (const cb of set) cb(msg.d);
|
|
150
|
+
}
|
|
151
|
+
function joinChannel(conn, channel) {
|
|
152
|
+
conn.channels.add(channel);
|
|
153
|
+
const set = members.get(channel);
|
|
154
|
+
if (set) {
|
|
155
|
+
set.add(conn);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
members.set(channel, /* @__PURE__ */ new Set([conn]));
|
|
159
|
+
return adapter.subscribe(channel);
|
|
160
|
+
}
|
|
161
|
+
function leaveChannel(conn, channel) {
|
|
162
|
+
const set = members.get(channel);
|
|
163
|
+
if (!set) return;
|
|
164
|
+
set.delete(conn);
|
|
165
|
+
conn.channels.delete(channel);
|
|
166
|
+
if (set.size === 0) {
|
|
167
|
+
members.delete(channel);
|
|
168
|
+
void adapter.unsubscribe(channel);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function topicNamespace(role, name) {
|
|
172
|
+
if (c.roles[role]?.serverToClient?.[name]?.subscribe) return role;
|
|
173
|
+
if (c.shared?.serverToClient?.[name]?.subscribe) return "shared";
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
if (opts.server) {
|
|
177
|
+
opts.server.on("upgrade", (req, socket, head) => {
|
|
178
|
+
void handleUpgrade(req, socket, head);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async function handleUpgrade(req, socket, head) {
|
|
182
|
+
if (opts.path) {
|
|
183
|
+
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
|
184
|
+
if (pathname !== opts.path) return;
|
|
185
|
+
}
|
|
186
|
+
let auth;
|
|
187
|
+
try {
|
|
188
|
+
auth = await opts.authenticate(req);
|
|
189
|
+
} catch {
|
|
190
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
191
|
+
socket.destroy();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
195
|
+
const conn = new Conn(ws, auth.role, auth.ctx, serializer);
|
|
196
|
+
conns.add(conn);
|
|
197
|
+
ws.on("message", (data, isBinary) => {
|
|
198
|
+
void onMessage(conn, data, isBinary);
|
|
199
|
+
});
|
|
200
|
+
ws.on("close", (code) => {
|
|
201
|
+
conns.delete(conn);
|
|
202
|
+
for (const channel of conn.channels) leaveChannel(conn, channel);
|
|
203
|
+
opts.onDisconnect?.(conn, auth.ctx, code);
|
|
204
|
+
});
|
|
205
|
+
opts.onConnection?.(conn, auth.ctx);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
async function onMessage(conn, data, isBinary) {
|
|
209
|
+
let frame;
|
|
210
|
+
try {
|
|
211
|
+
frame = serializer.decode(toWire(data, isBinary));
|
|
212
|
+
} catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (frame.t === "req") await handleReq(conn, frame);
|
|
216
|
+
else if (frame.t === "sub") await handleSub(conn, frame);
|
|
217
|
+
else if (frame.t === "unsub") {
|
|
218
|
+
const ns = topicNamespace(conn.role, frame.c);
|
|
219
|
+
if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function runMiddleware(info, terminal) {
|
|
223
|
+
const chain = opts.use ?? [];
|
|
224
|
+
let last = -1;
|
|
225
|
+
const dispatch = (idx) => {
|
|
226
|
+
if (idx <= last) return Promise.reject(new Error("next() called multiple times"));
|
|
227
|
+
last = idx;
|
|
228
|
+
const mw = chain[idx];
|
|
229
|
+
if (!mw) return terminal();
|
|
230
|
+
return Promise.resolve(mw(info.conn.ctx, info, () => dispatch(idx + 1)));
|
|
231
|
+
};
|
|
232
|
+
return dispatch(0);
|
|
233
|
+
}
|
|
234
|
+
async function dispatchOp(conn, id, info, terminal) {
|
|
235
|
+
let responded = false;
|
|
236
|
+
try {
|
|
237
|
+
await runMiddleware(info, async () => {
|
|
238
|
+
await terminal();
|
|
239
|
+
responded = true;
|
|
240
|
+
});
|
|
241
|
+
} catch (err) {
|
|
242
|
+
opts.onError?.(err, info);
|
|
243
|
+
if (!responded) {
|
|
244
|
+
const e = err instanceof import_core.SocketError ? err : new import_core.SocketError("INTERNAL", "Internal server error");
|
|
245
|
+
conn.send({ t: "err", i: id, code: e.code, m: e.message, d: e.data });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function handleReq(conn, frame) {
|
|
250
|
+
const def = c.roles[conn.role]?.clientToServer?.[frame.m] ?? c.shared?.clientToServer?.[frame.m];
|
|
251
|
+
const handler = impl[conn.role]?.[frame.m] ?? impl.shared?.[frame.m];
|
|
252
|
+
if (!def || !handler) {
|
|
253
|
+
conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: `Unknown message: ${frame.m}` });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
await dispatchOp(conn, frame.i, { kind: "request", name: frame.m, conn }, async () => {
|
|
257
|
+
const input = await (0, import_core.validate)(def.input, frame.d);
|
|
258
|
+
const output = await handler(input, conn.ctx, conn);
|
|
259
|
+
conn.send({ t: "res", i: frame.i, d: output });
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async function handleSub(conn, frame) {
|
|
263
|
+
const ns = topicNamespace(conn.role, frame.c);
|
|
264
|
+
if (!ns) {
|
|
265
|
+
conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: `Unknown topic: ${frame.c}` });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await dispatchOp(conn, frame.i, { kind: "subscribe", name: frame.c, conn }, async () => {
|
|
269
|
+
if (opts.authorizeSubscribe) {
|
|
270
|
+
const ok = await opts.authorizeSubscribe(frame.c, conn.ctx, conn);
|
|
271
|
+
if (ok === false) throw new import_core.SocketError("FORBIDDEN", `Subscribe denied: ${frame.c}`);
|
|
272
|
+
}
|
|
273
|
+
await joinChannel(conn, TOPIC + ns + ":" + frame.c);
|
|
274
|
+
conn.send({ t: "res", i: frame.i, d: null });
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
function room(name) {
|
|
278
|
+
const channel = ROOM + name;
|
|
279
|
+
return {
|
|
280
|
+
add(conn) {
|
|
281
|
+
void joinChannel(conn, channel);
|
|
282
|
+
},
|
|
283
|
+
remove(conn) {
|
|
284
|
+
leaveChannel(conn, channel);
|
|
285
|
+
},
|
|
286
|
+
broadcast(event, data) {
|
|
287
|
+
void adapter.publish(channel, serializer.encode({ t: "evt", e: String(event), d: data }));
|
|
288
|
+
},
|
|
289
|
+
get size() {
|
|
290
|
+
return members.get(channel)?.size ?? 0;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function publishTo(ns, name, data) {
|
|
295
|
+
void adapter.publish(TOPIC + ns + ":" + name, serializer.encode({ t: "pub", c: name, d: data }));
|
|
296
|
+
}
|
|
297
|
+
const api = {
|
|
298
|
+
implement(handlers) {
|
|
299
|
+
impl = handlers;
|
|
300
|
+
return api;
|
|
301
|
+
},
|
|
302
|
+
room,
|
|
303
|
+
publish(topic, data) {
|
|
304
|
+
publishTo("shared", String(topic), data);
|
|
305
|
+
},
|
|
306
|
+
forRole(role) {
|
|
307
|
+
return {
|
|
308
|
+
publish(topic, data) {
|
|
309
|
+
publishTo(role, String(topic), data);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
},
|
|
313
|
+
emitServer(event, data) {
|
|
314
|
+
void adapter.publish(S2S, serializer.encode({ from: instanceId, e: String(event), d: data }));
|
|
315
|
+
},
|
|
316
|
+
onServer(event, handler) {
|
|
317
|
+
const name = String(event);
|
|
318
|
+
let set = serverListeners.get(name);
|
|
319
|
+
if (!set) {
|
|
320
|
+
set = /* @__PURE__ */ new Set();
|
|
321
|
+
serverListeners.set(name, set);
|
|
322
|
+
}
|
|
323
|
+
set.add(handler);
|
|
324
|
+
return () => {
|
|
325
|
+
const current = serverListeners.get(name);
|
|
326
|
+
if (!current) return;
|
|
327
|
+
current.delete(handler);
|
|
328
|
+
if (current.size === 0) serverListeners.delete(name);
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
async close() {
|
|
332
|
+
for (const conn of conns) conn.close();
|
|
333
|
+
await adapter.close?.();
|
|
334
|
+
await new Promise((resolve) => {
|
|
335
|
+
wss.close(() => resolve());
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
return api;
|
|
340
|
+
}
|
|
341
|
+
function toWire(data, _isBinary) {
|
|
342
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
343
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
344
|
+
return data;
|
|
345
|
+
}
|
|
346
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
347
|
+
0 && (module.exports = {
|
|
348
|
+
Conn,
|
|
349
|
+
MemoryBus,
|
|
350
|
+
createInMemoryAdapter,
|
|
351
|
+
createSocketServer
|
|
352
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
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';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single client connection, passed to handlers as the third argument.
|
|
7
|
+
*
|
|
8
|
+
* Node-local: `conn` objects live on the node that accepted the upgrade, so don't
|
|
9
|
+
* stash one to reach a user later — cross-node delivery goes through the Adapter
|
|
10
|
+
* (use a per-user room instead). Generic over the events it may emit (scoped by
|
|
11
|
+
* role), its `ctx`, and its `role`.
|
|
12
|
+
*/
|
|
13
|
+
declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role extends string = string> {
|
|
14
|
+
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
15
|
+
readonly ws: WebSocket;
|
|
16
|
+
/** This connection's role (the literal resolved by `authenticate`). */
|
|
17
|
+
readonly role: Role;
|
|
18
|
+
/** The context `authenticate` returned for this connection. */
|
|
19
|
+
readonly ctx: Ctx;
|
|
20
|
+
private readonly serializer;
|
|
21
|
+
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
22
|
+
readonly channels: Set<string>;
|
|
23
|
+
constructor(
|
|
24
|
+
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
25
|
+
ws: WebSocket,
|
|
26
|
+
/** This connection's role (the literal resolved by `authenticate`). */
|
|
27
|
+
role: Role,
|
|
28
|
+
/** The context `authenticate` returned for this connection. */
|
|
29
|
+
ctx: Ctx, serializer: Serializer);
|
|
30
|
+
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
31
|
+
send(frame: ServerFrame): void;
|
|
32
|
+
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
33
|
+
sendRaw(payload: string | Uint8Array): void;
|
|
34
|
+
/** Push an event to THIS connection (node-local). Scoped to the role's events. */
|
|
35
|
+
emit<E extends keyof Ev>(event: E, data: EmitData<Ev[E]>): void;
|
|
36
|
+
/** Close the underlying socket. */
|
|
37
|
+
close(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* In-process pub/sub bus. Share one bus across multiple servers to simulate
|
|
42
|
+
* multiple nodes in a test (each server gets its own adapter bound to the bus).
|
|
43
|
+
*/
|
|
44
|
+
declare class MemoryBus {
|
|
45
|
+
private readonly channels;
|
|
46
|
+
subscribe(channel: string, adapter: MemoryAdapter): void;
|
|
47
|
+
unsubscribe(channel: string, adapter: MemoryAdapter): void;
|
|
48
|
+
publish(channel: string, payload: string | Uint8Array): void;
|
|
49
|
+
}
|
|
50
|
+
declare class MemoryAdapter implements Adapter {
|
|
51
|
+
private readonly bus;
|
|
52
|
+
private handler?;
|
|
53
|
+
constructor(bus: MemoryBus);
|
|
54
|
+
subscribe(channel: string): void;
|
|
55
|
+
unsubscribe(channel: string): void;
|
|
56
|
+
publish(channel: string, payload: string | Uint8Array): void;
|
|
57
|
+
onMessage(handler: (channel: string, payload: string | Uint8Array) => void): void;
|
|
58
|
+
deliver(channel: string, payload: string | Uint8Array): void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Create an in-memory {@link Adapter}. The default for a single-node server.
|
|
62
|
+
* Pass a shared {@link MemoryBus} to two servers to simulate cross-node fan-out.
|
|
63
|
+
*/
|
|
64
|
+
declare function createInMemoryAdapter(bus?: MemoryBus): Adapter;
|
|
65
|
+
|
|
66
|
+
type Awaitable<T> = T | Promise<T>;
|
|
67
|
+
/**
|
|
68
|
+
* The discriminated value `authenticate` returns: a `role` (one of the contract's
|
|
69
|
+
* roles) plus its `ctx`. Returning different ctx shapes per role narrows both the
|
|
70
|
+
* handler surface and `ctx` together.
|
|
71
|
+
*/
|
|
72
|
+
type AuthResult<C extends Contract> = {
|
|
73
|
+
[R in RoleOf<C>]: {
|
|
74
|
+
role: R;
|
|
75
|
+
ctx: unknown;
|
|
76
|
+
};
|
|
77
|
+
}[RoleOf<C>];
|
|
78
|
+
type CtxFor<A, R> = A extends {
|
|
79
|
+
role: R;
|
|
80
|
+
ctx: infer X;
|
|
81
|
+
} ? X : never;
|
|
82
|
+
type CtxUnion<A> = A extends {
|
|
83
|
+
ctx: infer X;
|
|
84
|
+
} ? X : never;
|
|
85
|
+
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]>>;
|
|
87
|
+
};
|
|
88
|
+
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]>>;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The handler map passed to `implement`: one block per role plus an optional
|
|
93
|
+
* `shared` block. Each handler's `input`/`ctx`/`conn` are narrowed to its role.
|
|
94
|
+
* The `shared` key is required only when the contract has shared requests.
|
|
95
|
+
*/
|
|
96
|
+
type Handlers<C extends Contract, A> = ([keyof SharedRequests<C>] extends [never] ? {} : {
|
|
97
|
+
shared: SharedHandlers<C, A>;
|
|
98
|
+
}) & {
|
|
99
|
+
[R in RoleOf<C>]: RoleHandlers<C, A, R>;
|
|
100
|
+
};
|
|
101
|
+
/** Context passed to middleware and lifecycle hooks about the current operation. */
|
|
102
|
+
interface MiddlewareInfo {
|
|
103
|
+
/** Whether this is a request or a topic subscribe. */
|
|
104
|
+
kind: 'request' | 'subscribe';
|
|
105
|
+
/** The request/topic name. */
|
|
106
|
+
name: string;
|
|
107
|
+
/** The connection the operation is on (`conn.role` available). */
|
|
108
|
+
conn: Conn;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Flat middleware run before request/subscribe handlers. Call `next()` to proceed,
|
|
112
|
+
* or `throw` to short-circuit (rejecting the operation). Does not change `ctx`'s type.
|
|
113
|
+
*/
|
|
114
|
+
type Middleware<A> = (ctx: CtxUnion<A>, info: MiddlewareInfo, next: () => Promise<void>) => Awaitable<void>;
|
|
115
|
+
/**
|
|
116
|
+
* A mixed-role, server-controlled connection group. `broadcast` delivers a
|
|
117
|
+
* **shared** event to every member, regardless of their role.
|
|
118
|
+
*/
|
|
119
|
+
interface Room<C extends Contract> {
|
|
120
|
+
/** Add a connection to the room (server-controlled membership). */
|
|
121
|
+
add(conn: Conn): void;
|
|
122
|
+
/** Remove a connection from the room. */
|
|
123
|
+
remove(conn: Conn): void;
|
|
124
|
+
/** Broadcast a shared event to all members. */
|
|
125
|
+
broadcast<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
126
|
+
/** Member count **on the current node** (membership is node-local). */
|
|
127
|
+
readonly size: number;
|
|
128
|
+
}
|
|
129
|
+
/** Lens for role-scoped server sends, returned by `srv.forRole(role)`. */
|
|
130
|
+
interface RoleLens<C extends Contract, R extends RoleOf<C>> {
|
|
131
|
+
/** Publish to a topic in role `R`'s surface (reaches that role's subscribers). */
|
|
132
|
+
publish<T extends keyof RoleTopics<C, R>>(topic: T, data: EmitData<RoleTopics<C, R>[T]>): void;
|
|
133
|
+
}
|
|
134
|
+
/** Options for {@link createSocketServer}. */
|
|
135
|
+
interface ServerOptions<C extends Contract, A extends AuthResult<C>> {
|
|
136
|
+
/** The `http.Server` to attach to (compose with Express/Fastify/Hono). */
|
|
137
|
+
server?: Server;
|
|
138
|
+
/** Wire serializer; MUST match the client. Defaults to `jsonSerializer`. */
|
|
139
|
+
serializer?: Serializer;
|
|
140
|
+
/** Cross-node fan-out adapter. Defaults to a per-server in-memory adapter. */
|
|
141
|
+
adapter?: Adapter;
|
|
142
|
+
/** Only handle upgrades for this pathname; others are left untouched. */
|
|
143
|
+
path?: string;
|
|
144
|
+
/** Runs at the HTTP upgrade. Return { role, ctx }, or throw to reject with 401. */
|
|
145
|
+
authenticate: (req: IncomingMessage) => Awaitable<A>;
|
|
146
|
+
/** Runs on each client subscribe. Return false or throw to deny. */
|
|
147
|
+
authorizeSubscribe?: (topic: string, ctx: CtxUnion<A>, conn: Conn) => Awaitable<boolean | void>;
|
|
148
|
+
/** Middleware chain run before req/subscribe handlers (rate-limit, authz, logging, metrics). */
|
|
149
|
+
use?: Middleware<A>[];
|
|
150
|
+
/** Called once per accepted connection. */
|
|
151
|
+
onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
|
|
152
|
+
/** Called when a connection closes, with the WebSocket close `code`. */
|
|
153
|
+
onDisconnect?: (conn: Conn, ctx: CtxUnion<A>, code: number) => void;
|
|
154
|
+
/** Called for any error thrown in middleware/handlers (after the client is replied to). */
|
|
155
|
+
onError?: (error: unknown, info: MiddlewareInfo) => void;
|
|
156
|
+
}
|
|
157
|
+
/** A running super-line server, returned by {@link createSocketServer}. */
|
|
158
|
+
interface SocketServer<C extends Contract, A extends AuthResult<C>> {
|
|
159
|
+
/** Register handlers for shared + per-role requests (chainable). */
|
|
160
|
+
implement(handlers: Handlers<C, A>): SocketServer<C, A>;
|
|
161
|
+
/** Mixed-role connection group; broadcast() sends a shared contract event to members. */
|
|
162
|
+
room(name: string): Room<C>;
|
|
163
|
+
/** Publish a SHARED topic to all subscribers (server-only publish). */
|
|
164
|
+
publish<T extends keyof SharedTopics<C>>(topic: T, data: EmitData<SharedTopics<C>[T]>): void;
|
|
165
|
+
/** Lens for role-scoped sends, e.g. forRole('user').publish('feed', data). */
|
|
166
|
+
forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
|
|
167
|
+
/** Broadcast a typed event to all OTHER server nodes (at-most-once, excludes self). */
|
|
168
|
+
emitServer<E extends keyof ServerEvents<C>>(event: E, data: ServerEmit<ServerEvents<C>[E]>): void;
|
|
169
|
+
/** Listen for inter-server events from other nodes. Returns an unsubscribe fn. */
|
|
170
|
+
onServer<E extends keyof ServerEvents<C>>(event: E, handler: (data: ServerData<ServerEvents<C>[E]>) => void): () => void;
|
|
171
|
+
close(): Promise<void>;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Create a server bound to a contract. Attach it to an `http.Server`, then call
|
|
175
|
+
* {@link SocketServer.implement} with your handlers. `authenticate` resolves each
|
|
176
|
+
* connection's `{ role, ctx }` at the upgrade.
|
|
177
|
+
*
|
|
178
|
+
* @param contract - the shared contract.
|
|
179
|
+
* @param opts - server options; `authenticate` is required.
|
|
180
|
+
* @returns the {@link SocketServer}.
|
|
181
|
+
* @throws nothing directly; handler throws become a typed `SocketError` to the client.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* const srv = createSocketServer(api, {
|
|
186
|
+
* server,
|
|
187
|
+
* authenticate: (req) => ({ role: 'user' as const, ctx: { id: '1' } }),
|
|
188
|
+
* })
|
|
189
|
+
* srv.implement({
|
|
190
|
+
* shared: { join: async ({ room }, _ctx, conn) => { srv.room(room).add(conn); return { ok: true } } },
|
|
191
|
+
* user: { say: async ({ text }, ctx) => ({ id: '...' }) },
|
|
192
|
+
* })
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
declare function createSocketServer<C extends Contract, A extends AuthResult<C>>(contract: C, opts: ServerOptions<C, A>): SocketServer<C, A>;
|
|
196
|
+
|
|
197
|
+
export { type AuthResult, Conn, type Handlers, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerOptions, type SocketServer, createInMemoryAdapter, createSocketServer };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
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';
|
|
3
|
+
import { WebSocket } from 'ws';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single client connection, passed to handlers as the third argument.
|
|
7
|
+
*
|
|
8
|
+
* Node-local: `conn` objects live on the node that accepted the upgrade, so don't
|
|
9
|
+
* stash one to reach a user later — cross-node delivery goes through the Adapter
|
|
10
|
+
* (use a per-user room instead). Generic over the events it may emit (scoped by
|
|
11
|
+
* role), its `ctx`, and its `role`.
|
|
12
|
+
*/
|
|
13
|
+
declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role extends string = string> {
|
|
14
|
+
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
15
|
+
readonly ws: WebSocket;
|
|
16
|
+
/** This connection's role (the literal resolved by `authenticate`). */
|
|
17
|
+
readonly role: Role;
|
|
18
|
+
/** The context `authenticate` returned for this connection. */
|
|
19
|
+
readonly ctx: Ctx;
|
|
20
|
+
private readonly serializer;
|
|
21
|
+
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
22
|
+
readonly channels: Set<string>;
|
|
23
|
+
constructor(
|
|
24
|
+
/** The underlying `ws` socket. `conn.ws.terminate()` simulates a drop in tests. */
|
|
25
|
+
ws: WebSocket,
|
|
26
|
+
/** This connection's role (the literal resolved by `authenticate`). */
|
|
27
|
+
role: Role,
|
|
28
|
+
/** The context `authenticate` returned for this connection. */
|
|
29
|
+
ctx: Ctx, serializer: Serializer);
|
|
30
|
+
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
31
|
+
send(frame: ServerFrame): void;
|
|
32
|
+
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
33
|
+
sendRaw(payload: string | Uint8Array): void;
|
|
34
|
+
/** Push an event to THIS connection (node-local). Scoped to the role's events. */
|
|
35
|
+
emit<E extends keyof Ev>(event: E, data: EmitData<Ev[E]>): void;
|
|
36
|
+
/** Close the underlying socket. */
|
|
37
|
+
close(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* In-process pub/sub bus. Share one bus across multiple servers to simulate
|
|
42
|
+
* multiple nodes in a test (each server gets its own adapter bound to the bus).
|
|
43
|
+
*/
|
|
44
|
+
declare class MemoryBus {
|
|
45
|
+
private readonly channels;
|
|
46
|
+
subscribe(channel: string, adapter: MemoryAdapter): void;
|
|
47
|
+
unsubscribe(channel: string, adapter: MemoryAdapter): void;
|
|
48
|
+
publish(channel: string, payload: string | Uint8Array): void;
|
|
49
|
+
}
|
|
50
|
+
declare class MemoryAdapter implements Adapter {
|
|
51
|
+
private readonly bus;
|
|
52
|
+
private handler?;
|
|
53
|
+
constructor(bus: MemoryBus);
|
|
54
|
+
subscribe(channel: string): void;
|
|
55
|
+
unsubscribe(channel: string): void;
|
|
56
|
+
publish(channel: string, payload: string | Uint8Array): void;
|
|
57
|
+
onMessage(handler: (channel: string, payload: string | Uint8Array) => void): void;
|
|
58
|
+
deliver(channel: string, payload: string | Uint8Array): void;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Create an in-memory {@link Adapter}. The default for a single-node server.
|
|
62
|
+
* Pass a shared {@link MemoryBus} to two servers to simulate cross-node fan-out.
|
|
63
|
+
*/
|
|
64
|
+
declare function createInMemoryAdapter(bus?: MemoryBus): Adapter;
|
|
65
|
+
|
|
66
|
+
type Awaitable<T> = T | Promise<T>;
|
|
67
|
+
/**
|
|
68
|
+
* The discriminated value `authenticate` returns: a `role` (one of the contract's
|
|
69
|
+
* roles) plus its `ctx`. Returning different ctx shapes per role narrows both the
|
|
70
|
+
* handler surface and `ctx` together.
|
|
71
|
+
*/
|
|
72
|
+
type AuthResult<C extends Contract> = {
|
|
73
|
+
[R in RoleOf<C>]: {
|
|
74
|
+
role: R;
|
|
75
|
+
ctx: unknown;
|
|
76
|
+
};
|
|
77
|
+
}[RoleOf<C>];
|
|
78
|
+
type CtxFor<A, R> = A extends {
|
|
79
|
+
role: R;
|
|
80
|
+
ctx: infer X;
|
|
81
|
+
} ? X : never;
|
|
82
|
+
type CtxUnion<A> = A extends {
|
|
83
|
+
ctx: infer X;
|
|
84
|
+
} ? X : never;
|
|
85
|
+
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]>>;
|
|
87
|
+
};
|
|
88
|
+
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]>>;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The handler map passed to `implement`: one block per role plus an optional
|
|
93
|
+
* `shared` block. Each handler's `input`/`ctx`/`conn` are narrowed to its role.
|
|
94
|
+
* The `shared` key is required only when the contract has shared requests.
|
|
95
|
+
*/
|
|
96
|
+
type Handlers<C extends Contract, A> = ([keyof SharedRequests<C>] extends [never] ? {} : {
|
|
97
|
+
shared: SharedHandlers<C, A>;
|
|
98
|
+
}) & {
|
|
99
|
+
[R in RoleOf<C>]: RoleHandlers<C, A, R>;
|
|
100
|
+
};
|
|
101
|
+
/** Context passed to middleware and lifecycle hooks about the current operation. */
|
|
102
|
+
interface MiddlewareInfo {
|
|
103
|
+
/** Whether this is a request or a topic subscribe. */
|
|
104
|
+
kind: 'request' | 'subscribe';
|
|
105
|
+
/** The request/topic name. */
|
|
106
|
+
name: string;
|
|
107
|
+
/** The connection the operation is on (`conn.role` available). */
|
|
108
|
+
conn: Conn;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Flat middleware run before request/subscribe handlers. Call `next()` to proceed,
|
|
112
|
+
* or `throw` to short-circuit (rejecting the operation). Does not change `ctx`'s type.
|
|
113
|
+
*/
|
|
114
|
+
type Middleware<A> = (ctx: CtxUnion<A>, info: MiddlewareInfo, next: () => Promise<void>) => Awaitable<void>;
|
|
115
|
+
/**
|
|
116
|
+
* A mixed-role, server-controlled connection group. `broadcast` delivers a
|
|
117
|
+
* **shared** event to every member, regardless of their role.
|
|
118
|
+
*/
|
|
119
|
+
interface Room<C extends Contract> {
|
|
120
|
+
/** Add a connection to the room (server-controlled membership). */
|
|
121
|
+
add(conn: Conn): void;
|
|
122
|
+
/** Remove a connection from the room. */
|
|
123
|
+
remove(conn: Conn): void;
|
|
124
|
+
/** Broadcast a shared event to all members. */
|
|
125
|
+
broadcast<E extends keyof SharedEvents<C>>(event: E, data: EmitData<SharedEvents<C>[E]>): void;
|
|
126
|
+
/** Member count **on the current node** (membership is node-local). */
|
|
127
|
+
readonly size: number;
|
|
128
|
+
}
|
|
129
|
+
/** Lens for role-scoped server sends, returned by `srv.forRole(role)`. */
|
|
130
|
+
interface RoleLens<C extends Contract, R extends RoleOf<C>> {
|
|
131
|
+
/** Publish to a topic in role `R`'s surface (reaches that role's subscribers). */
|
|
132
|
+
publish<T extends keyof RoleTopics<C, R>>(topic: T, data: EmitData<RoleTopics<C, R>[T]>): void;
|
|
133
|
+
}
|
|
134
|
+
/** Options for {@link createSocketServer}. */
|
|
135
|
+
interface ServerOptions<C extends Contract, A extends AuthResult<C>> {
|
|
136
|
+
/** The `http.Server` to attach to (compose with Express/Fastify/Hono). */
|
|
137
|
+
server?: Server;
|
|
138
|
+
/** Wire serializer; MUST match the client. Defaults to `jsonSerializer`. */
|
|
139
|
+
serializer?: Serializer;
|
|
140
|
+
/** Cross-node fan-out adapter. Defaults to a per-server in-memory adapter. */
|
|
141
|
+
adapter?: Adapter;
|
|
142
|
+
/** Only handle upgrades for this pathname; others are left untouched. */
|
|
143
|
+
path?: string;
|
|
144
|
+
/** Runs at the HTTP upgrade. Return { role, ctx }, or throw to reject with 401. */
|
|
145
|
+
authenticate: (req: IncomingMessage) => Awaitable<A>;
|
|
146
|
+
/** Runs on each client subscribe. Return false or throw to deny. */
|
|
147
|
+
authorizeSubscribe?: (topic: string, ctx: CtxUnion<A>, conn: Conn) => Awaitable<boolean | void>;
|
|
148
|
+
/** Middleware chain run before req/subscribe handlers (rate-limit, authz, logging, metrics). */
|
|
149
|
+
use?: Middleware<A>[];
|
|
150
|
+
/** Called once per accepted connection. */
|
|
151
|
+
onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
|
|
152
|
+
/** Called when a connection closes, with the WebSocket close `code`. */
|
|
153
|
+
onDisconnect?: (conn: Conn, ctx: CtxUnion<A>, code: number) => void;
|
|
154
|
+
/** Called for any error thrown in middleware/handlers (after the client is replied to). */
|
|
155
|
+
onError?: (error: unknown, info: MiddlewareInfo) => void;
|
|
156
|
+
}
|
|
157
|
+
/** A running super-line server, returned by {@link createSocketServer}. */
|
|
158
|
+
interface SocketServer<C extends Contract, A extends AuthResult<C>> {
|
|
159
|
+
/** Register handlers for shared + per-role requests (chainable). */
|
|
160
|
+
implement(handlers: Handlers<C, A>): SocketServer<C, A>;
|
|
161
|
+
/** Mixed-role connection group; broadcast() sends a shared contract event to members. */
|
|
162
|
+
room(name: string): Room<C>;
|
|
163
|
+
/** Publish a SHARED topic to all subscribers (server-only publish). */
|
|
164
|
+
publish<T extends keyof SharedTopics<C>>(topic: T, data: EmitData<SharedTopics<C>[T]>): void;
|
|
165
|
+
/** Lens for role-scoped sends, e.g. forRole('user').publish('feed', data). */
|
|
166
|
+
forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
|
|
167
|
+
/** Broadcast a typed event to all OTHER server nodes (at-most-once, excludes self). */
|
|
168
|
+
emitServer<E extends keyof ServerEvents<C>>(event: E, data: ServerEmit<ServerEvents<C>[E]>): void;
|
|
169
|
+
/** Listen for inter-server events from other nodes. Returns an unsubscribe fn. */
|
|
170
|
+
onServer<E extends keyof ServerEvents<C>>(event: E, handler: (data: ServerData<ServerEvents<C>[E]>) => void): () => void;
|
|
171
|
+
close(): Promise<void>;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Create a server bound to a contract. Attach it to an `http.Server`, then call
|
|
175
|
+
* {@link SocketServer.implement} with your handlers. `authenticate` resolves each
|
|
176
|
+
* connection's `{ role, ctx }` at the upgrade.
|
|
177
|
+
*
|
|
178
|
+
* @param contract - the shared contract.
|
|
179
|
+
* @param opts - server options; `authenticate` is required.
|
|
180
|
+
* @returns the {@link SocketServer}.
|
|
181
|
+
* @throws nothing directly; handler throws become a typed `SocketError` to the client.
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* const srv = createSocketServer(api, {
|
|
186
|
+
* server,
|
|
187
|
+
* authenticate: (req) => ({ role: 'user' as const, ctx: { id: '1' } }),
|
|
188
|
+
* })
|
|
189
|
+
* srv.implement({
|
|
190
|
+
* shared: { join: async ({ room }, _ctx, conn) => { srv.room(room).add(conn); return { ok: true } } },
|
|
191
|
+
* user: { say: async ({ text }, ctx) => ({ id: '...' }) },
|
|
192
|
+
* })
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
declare function createSocketServer<C extends Contract, A extends AuthResult<C>>(contract: C, opts: ServerOptions<C, A>): SocketServer<C, A>;
|
|
196
|
+
|
|
197
|
+
export { type AuthResult, Conn, type Handlers, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerOptions, type SocketServer, createInMemoryAdapter, createSocketServer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
4
|
+
import {
|
|
5
|
+
jsonSerializer,
|
|
6
|
+
validate,
|
|
7
|
+
SocketError
|
|
8
|
+
} from "@super-line/core";
|
|
9
|
+
|
|
10
|
+
// src/conn.ts
|
|
11
|
+
var Conn = class {
|
|
12
|
+
constructor(ws, role, ctx, serializer) {
|
|
13
|
+
this.ws = ws;
|
|
14
|
+
this.role = role;
|
|
15
|
+
this.ctx = ctx;
|
|
16
|
+
this.serializer = serializer;
|
|
17
|
+
}
|
|
18
|
+
ws;
|
|
19
|
+
role;
|
|
20
|
+
ctx;
|
|
21
|
+
serializer;
|
|
22
|
+
/** Namespaced channels (rooms + topics) this connection belongs to. */
|
|
23
|
+
channels = /* @__PURE__ */ new Set();
|
|
24
|
+
/** Encode and send a frame (unicast, e.g. req/res). */
|
|
25
|
+
send(frame) {
|
|
26
|
+
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
27
|
+
this.ws.send(this.serializer.encode(frame));
|
|
28
|
+
}
|
|
29
|
+
/** Forward an already-encoded frame (fan-out path; encoded once at the source). */
|
|
30
|
+
sendRaw(payload) {
|
|
31
|
+
if (this.ws.readyState !== this.ws.OPEN) return;
|
|
32
|
+
this.ws.send(payload);
|
|
33
|
+
}
|
|
34
|
+
/** Push an event to THIS connection (node-local). Scoped to the role's events. */
|
|
35
|
+
emit(event, data) {
|
|
36
|
+
this.send({ t: "evt", e: String(event), d: data });
|
|
37
|
+
}
|
|
38
|
+
/** Close the underlying socket. */
|
|
39
|
+
close() {
|
|
40
|
+
this.ws.close();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/memory-adapter.ts
|
|
45
|
+
var MemoryBus = class {
|
|
46
|
+
channels = /* @__PURE__ */ new Map();
|
|
47
|
+
subscribe(channel, adapter) {
|
|
48
|
+
let set = this.channels.get(channel);
|
|
49
|
+
if (!set) {
|
|
50
|
+
set = /* @__PURE__ */ new Set();
|
|
51
|
+
this.channels.set(channel, set);
|
|
52
|
+
}
|
|
53
|
+
set.add(adapter);
|
|
54
|
+
}
|
|
55
|
+
unsubscribe(channel, adapter) {
|
|
56
|
+
const set = this.channels.get(channel);
|
|
57
|
+
if (!set) return;
|
|
58
|
+
set.delete(adapter);
|
|
59
|
+
if (set.size === 0) this.channels.delete(channel);
|
|
60
|
+
}
|
|
61
|
+
publish(channel, payload) {
|
|
62
|
+
const set = this.channels.get(channel);
|
|
63
|
+
if (!set) return;
|
|
64
|
+
for (const adapter of set) adapter.deliver(channel, payload);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
var MemoryAdapter = class {
|
|
68
|
+
constructor(bus) {
|
|
69
|
+
this.bus = bus;
|
|
70
|
+
}
|
|
71
|
+
bus;
|
|
72
|
+
handler;
|
|
73
|
+
subscribe(channel) {
|
|
74
|
+
this.bus.subscribe(channel, this);
|
|
75
|
+
}
|
|
76
|
+
unsubscribe(channel) {
|
|
77
|
+
this.bus.unsubscribe(channel, this);
|
|
78
|
+
}
|
|
79
|
+
publish(channel, payload) {
|
|
80
|
+
this.bus.publish(channel, payload);
|
|
81
|
+
}
|
|
82
|
+
onMessage(handler) {
|
|
83
|
+
this.handler = handler;
|
|
84
|
+
}
|
|
85
|
+
deliver(channel, payload) {
|
|
86
|
+
this.handler?.(channel, payload);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
function createInMemoryAdapter(bus = new MemoryBus()) {
|
|
90
|
+
return new MemoryAdapter(bus);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/index.ts
|
|
94
|
+
var ROOM = "r:";
|
|
95
|
+
var TOPIC = "t:";
|
|
96
|
+
var S2S = "s2s";
|
|
97
|
+
function createSocketServer(contract, opts) {
|
|
98
|
+
const c = contract;
|
|
99
|
+
const serializer = opts.serializer ?? jsonSerializer;
|
|
100
|
+
const adapter = opts.adapter ?? createInMemoryAdapter();
|
|
101
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
102
|
+
const conns = /* @__PURE__ */ new Set();
|
|
103
|
+
const members = /* @__PURE__ */ new Map();
|
|
104
|
+
const serverListeners = /* @__PURE__ */ new Map();
|
|
105
|
+
const instanceId = randomUUID();
|
|
106
|
+
let impl = {};
|
|
107
|
+
adapter.onMessage((channel, payload) => {
|
|
108
|
+
if (channel === S2S) {
|
|
109
|
+
handleServerMessage(payload);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const set = members.get(channel);
|
|
113
|
+
if (!set) return;
|
|
114
|
+
for (const conn of set) conn.sendRaw(payload);
|
|
115
|
+
});
|
|
116
|
+
if (c.serverToServer) void adapter.subscribe(S2S);
|
|
117
|
+
function handleServerMessage(payload) {
|
|
118
|
+
let msg;
|
|
119
|
+
try {
|
|
120
|
+
msg = serializer.decode(payload);
|
|
121
|
+
} catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (msg.from === instanceId) return;
|
|
125
|
+
const set = serverListeners.get(msg.e);
|
|
126
|
+
if (set) for (const cb of set) cb(msg.d);
|
|
127
|
+
}
|
|
128
|
+
function joinChannel(conn, channel) {
|
|
129
|
+
conn.channels.add(channel);
|
|
130
|
+
const set = members.get(channel);
|
|
131
|
+
if (set) {
|
|
132
|
+
set.add(conn);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
members.set(channel, /* @__PURE__ */ new Set([conn]));
|
|
136
|
+
return adapter.subscribe(channel);
|
|
137
|
+
}
|
|
138
|
+
function leaveChannel(conn, channel) {
|
|
139
|
+
const set = members.get(channel);
|
|
140
|
+
if (!set) return;
|
|
141
|
+
set.delete(conn);
|
|
142
|
+
conn.channels.delete(channel);
|
|
143
|
+
if (set.size === 0) {
|
|
144
|
+
members.delete(channel);
|
|
145
|
+
void adapter.unsubscribe(channel);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function topicNamespace(role, name) {
|
|
149
|
+
if (c.roles[role]?.serverToClient?.[name]?.subscribe) return role;
|
|
150
|
+
if (c.shared?.serverToClient?.[name]?.subscribe) return "shared";
|
|
151
|
+
return void 0;
|
|
152
|
+
}
|
|
153
|
+
if (opts.server) {
|
|
154
|
+
opts.server.on("upgrade", (req, socket, head) => {
|
|
155
|
+
void handleUpgrade(req, socket, head);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function handleUpgrade(req, socket, head) {
|
|
159
|
+
if (opts.path) {
|
|
160
|
+
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
|
161
|
+
if (pathname !== opts.path) return;
|
|
162
|
+
}
|
|
163
|
+
let auth;
|
|
164
|
+
try {
|
|
165
|
+
auth = await opts.authenticate(req);
|
|
166
|
+
} catch {
|
|
167
|
+
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
168
|
+
socket.destroy();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
172
|
+
const conn = new Conn(ws, auth.role, auth.ctx, serializer);
|
|
173
|
+
conns.add(conn);
|
|
174
|
+
ws.on("message", (data, isBinary) => {
|
|
175
|
+
void onMessage(conn, data, isBinary);
|
|
176
|
+
});
|
|
177
|
+
ws.on("close", (code) => {
|
|
178
|
+
conns.delete(conn);
|
|
179
|
+
for (const channel of conn.channels) leaveChannel(conn, channel);
|
|
180
|
+
opts.onDisconnect?.(conn, auth.ctx, code);
|
|
181
|
+
});
|
|
182
|
+
opts.onConnection?.(conn, auth.ctx);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async function onMessage(conn, data, isBinary) {
|
|
186
|
+
let frame;
|
|
187
|
+
try {
|
|
188
|
+
frame = serializer.decode(toWire(data, isBinary));
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (frame.t === "req") await handleReq(conn, frame);
|
|
193
|
+
else if (frame.t === "sub") await handleSub(conn, frame);
|
|
194
|
+
else if (frame.t === "unsub") {
|
|
195
|
+
const ns = topicNamespace(conn.role, frame.c);
|
|
196
|
+
if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function runMiddleware(info, terminal) {
|
|
200
|
+
const chain = opts.use ?? [];
|
|
201
|
+
let last = -1;
|
|
202
|
+
const dispatch = (idx) => {
|
|
203
|
+
if (idx <= last) return Promise.reject(new Error("next() called multiple times"));
|
|
204
|
+
last = idx;
|
|
205
|
+
const mw = chain[idx];
|
|
206
|
+
if (!mw) return terminal();
|
|
207
|
+
return Promise.resolve(mw(info.conn.ctx, info, () => dispatch(idx + 1)));
|
|
208
|
+
};
|
|
209
|
+
return dispatch(0);
|
|
210
|
+
}
|
|
211
|
+
async function dispatchOp(conn, id, info, terminal) {
|
|
212
|
+
let responded = false;
|
|
213
|
+
try {
|
|
214
|
+
await runMiddleware(info, async () => {
|
|
215
|
+
await terminal();
|
|
216
|
+
responded = true;
|
|
217
|
+
});
|
|
218
|
+
} catch (err) {
|
|
219
|
+
opts.onError?.(err, info);
|
|
220
|
+
if (!responded) {
|
|
221
|
+
const e = err instanceof SocketError ? err : new SocketError("INTERNAL", "Internal server error");
|
|
222
|
+
conn.send({ t: "err", i: id, code: e.code, m: e.message, d: e.data });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function handleReq(conn, frame) {
|
|
227
|
+
const def = c.roles[conn.role]?.clientToServer?.[frame.m] ?? c.shared?.clientToServer?.[frame.m];
|
|
228
|
+
const handler = impl[conn.role]?.[frame.m] ?? impl.shared?.[frame.m];
|
|
229
|
+
if (!def || !handler) {
|
|
230
|
+
conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: `Unknown message: ${frame.m}` });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
await dispatchOp(conn, frame.i, { kind: "request", name: frame.m, conn }, async () => {
|
|
234
|
+
const input = await validate(def.input, frame.d);
|
|
235
|
+
const output = await handler(input, conn.ctx, conn);
|
|
236
|
+
conn.send({ t: "res", i: frame.i, d: output });
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async function handleSub(conn, frame) {
|
|
240
|
+
const ns = topicNamespace(conn.role, frame.c);
|
|
241
|
+
if (!ns) {
|
|
242
|
+
conn.send({ t: "err", i: frame.i, code: "NOT_FOUND", m: `Unknown topic: ${frame.c}` });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
await dispatchOp(conn, frame.i, { kind: "subscribe", name: frame.c, conn }, async () => {
|
|
246
|
+
if (opts.authorizeSubscribe) {
|
|
247
|
+
const ok = await opts.authorizeSubscribe(frame.c, conn.ctx, conn);
|
|
248
|
+
if (ok === false) throw new SocketError("FORBIDDEN", `Subscribe denied: ${frame.c}`);
|
|
249
|
+
}
|
|
250
|
+
await joinChannel(conn, TOPIC + ns + ":" + frame.c);
|
|
251
|
+
conn.send({ t: "res", i: frame.i, d: null });
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function room(name) {
|
|
255
|
+
const channel = ROOM + name;
|
|
256
|
+
return {
|
|
257
|
+
add(conn) {
|
|
258
|
+
void joinChannel(conn, channel);
|
|
259
|
+
},
|
|
260
|
+
remove(conn) {
|
|
261
|
+
leaveChannel(conn, channel);
|
|
262
|
+
},
|
|
263
|
+
broadcast(event, data) {
|
|
264
|
+
void adapter.publish(channel, serializer.encode({ t: "evt", e: String(event), d: data }));
|
|
265
|
+
},
|
|
266
|
+
get size() {
|
|
267
|
+
return members.get(channel)?.size ?? 0;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function publishTo(ns, name, data) {
|
|
272
|
+
void adapter.publish(TOPIC + ns + ":" + name, serializer.encode({ t: "pub", c: name, d: data }));
|
|
273
|
+
}
|
|
274
|
+
const api = {
|
|
275
|
+
implement(handlers) {
|
|
276
|
+
impl = handlers;
|
|
277
|
+
return api;
|
|
278
|
+
},
|
|
279
|
+
room,
|
|
280
|
+
publish(topic, data) {
|
|
281
|
+
publishTo("shared", String(topic), data);
|
|
282
|
+
},
|
|
283
|
+
forRole(role) {
|
|
284
|
+
return {
|
|
285
|
+
publish(topic, data) {
|
|
286
|
+
publishTo(role, String(topic), data);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
emitServer(event, data) {
|
|
291
|
+
void adapter.publish(S2S, serializer.encode({ from: instanceId, e: String(event), d: data }));
|
|
292
|
+
},
|
|
293
|
+
onServer(event, handler) {
|
|
294
|
+
const name = String(event);
|
|
295
|
+
let set = serverListeners.get(name);
|
|
296
|
+
if (!set) {
|
|
297
|
+
set = /* @__PURE__ */ new Set();
|
|
298
|
+
serverListeners.set(name, set);
|
|
299
|
+
}
|
|
300
|
+
set.add(handler);
|
|
301
|
+
return () => {
|
|
302
|
+
const current = serverListeners.get(name);
|
|
303
|
+
if (!current) return;
|
|
304
|
+
current.delete(handler);
|
|
305
|
+
if (current.size === 0) serverListeners.delete(name);
|
|
306
|
+
};
|
|
307
|
+
},
|
|
308
|
+
async close() {
|
|
309
|
+
for (const conn of conns) conn.close();
|
|
310
|
+
await adapter.close?.();
|
|
311
|
+
await new Promise((resolve) => {
|
|
312
|
+
wss.close(() => resolve());
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
return api;
|
|
317
|
+
}
|
|
318
|
+
function toWire(data, _isBinary) {
|
|
319
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
320
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
321
|
+
return data;
|
|
322
|
+
}
|
|
323
|
+
export {
|
|
324
|
+
Conn,
|
|
325
|
+
MemoryBus,
|
|
326
|
+
createInMemoryAdapter,
|
|
327
|
+
createSocketServer
|
|
328
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@super-line/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Typesafe WebSocket server for super-line — rooms, topics, middleware, pluggable adapters.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mert",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"websocket",
|
|
10
|
+
"server",
|
|
11
|
+
"typesafe",
|
|
12
|
+
"realtime",
|
|
13
|
+
"rooms",
|
|
14
|
+
"pubsub",
|
|
15
|
+
"ws"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mertdogar/super-line.git",
|
|
20
|
+
"directory": "packages/server"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://mertdogar.github.io/super-line/",
|
|
23
|
+
"bugs": "https://github.com/mertdogar/super-line/issues",
|
|
24
|
+
"main": "./dist/index.cjs",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"require": {
|
|
34
|
+
"types": "./dist/index.d.cts",
|
|
35
|
+
"default": "./dist/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist"
|
|
41
|
+
],
|
|
42
|
+
"sideEffects": false,
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"ws": "^8.18.0",
|
|
51
|
+
"@super-line/core": "^0.1.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/ws": "^8.5.13",
|
|
55
|
+
"testcontainers": "^10.13.2",
|
|
56
|
+
"zod": "^3.24.1",
|
|
57
|
+
"@super-line/adapter-redis": "0.1.0",
|
|
58
|
+
"@super-line/client": "0.1.0"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsup"
|
|
62
|
+
}
|
|
63
|
+
}
|