@fishenv/http 0.0.2 → 0.0.4
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/contract/builder.d.ts +9 -4
- package/dist/contract/builder.js +2 -0
- package/dist/contract/index.d.ts +3 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/types.d.ts +9 -1
- package/dist/contract/ws.d.ts +178 -0
- package/dist/contract/ws.js +164 -0
- package/dist/core/compose.js +5 -0
- package/dist/core/error.d.ts +21 -0
- package/dist/core/error.js +50 -7
- package/dist/core/index.d.ts +7 -3
- package/dist/core/index.js +5 -1
- package/dist/core/matcher.js +2 -1
- package/dist/core/route-builder.d.ts +4 -4
- package/dist/core/router.d.ts +16 -2
- package/dist/core/router.js +45 -6
- package/dist/core/transport.d.ts +39 -0
- package/dist/core/transport.js +68 -0
- package/dist/core/types.d.ts +22 -3
- package/dist/core/upgrade.d.ts +85 -0
- package/dist/core/upgrade.js +110 -0
- package/dist/core/utils/url.d.ts +4 -1
- package/dist/core/utils/url.js +14 -3
- package/dist/sse/hub.js +15 -33
- package/dist/sse/types.d.ts +3 -5
- package/dist/static/index.d.ts +27 -0
- package/dist/static/index.js +25 -0
- package/dist/static/negotiate.d.ts +23 -0
- package/dist/static/negotiate.js +85 -0
- package/dist/static/options.d.ts +44 -0
- package/dist/static/options.js +190 -0
- package/dist/static/resolve.d.ts +56 -0
- package/dist/static/resolve.js +113 -0
- package/dist/static/response.d.ts +53 -0
- package/dist/static/response.js +146 -0
- package/dist/static/router.d.ts +59 -0
- package/dist/static/router.js +217 -0
- package/dist/static/rules.d.ts +21 -0
- package/dist/static/rules.js +49 -0
- package/dist/static/types.d.ts +76 -0
- package/dist/static/types.js +0 -0
- package/dist/ws/channels/channel.d.ts +175 -0
- package/dist/ws/channels/channel.js +125 -0
- package/dist/ws/channels/handler.d.ts +44 -0
- package/dist/ws/channels/handler.js +517 -0
- package/dist/ws/channels/protocol.d.ts +96 -0
- package/dist/ws/channels/protocol.js +94 -0
- package/dist/ws/channels/target.d.ts +36 -0
- package/dist/ws/channels/target.js +56 -0
- package/dist/ws/connection.d.ts +56 -0
- package/dist/ws/connection.js +172 -0
- package/dist/ws/helpers.d.ts +17 -0
- package/dist/ws/helpers.js +48 -0
- package/dist/ws/hub.d.ts +190 -0
- package/dist/ws/hub.js +562 -0
- package/dist/ws/index.d.ts +65 -0
- package/dist/ws/index.js +66 -0
- package/dist/ws/lifecycle.d.ts +21 -0
- package/dist/ws/lifecycle.js +97 -0
- package/dist/ws/limits.d.ts +32 -0
- package/dist/ws/limits.js +67 -0
- package/dist/ws/message.d.ts +19 -0
- package/dist/ws/message.js +58 -0
- package/dist/ws/origin.d.ts +40 -0
- package/dist/ws/origin.js +152 -0
- package/dist/ws/presence.d.ts +96 -0
- package/dist/ws/presence.js +420 -0
- package/dist/ws/route-builder.d.ts +136 -0
- package/dist/ws/route-builder.js +265 -0
- package/dist/ws/router.d.ts +21 -0
- package/dist/ws/router.js +36 -0
- package/dist/ws/tickets.d.ts +82 -0
- package/dist/ws/tickets.js +161 -0
- package/dist/ws/typed.d.ts +54 -0
- package/dist/ws/typed.js +104 -0
- package/dist/ws/types.d.ts +69 -0
- package/dist/ws/types.js +70 -0
- package/package.json +14 -2
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence — *who* is in a topic (a channel, or any app-defined topic like
|
|
3
|
+
* `"online-users"`), keyed by identity rather than by socket, replicated
|
|
4
|
+
* across nodes.
|
|
5
|
+
*
|
|
6
|
+
* Model: an entry is `(key, ref, meta)` — `key` is the identity (user id),
|
|
7
|
+
* `ref` the connection id, `meta` app data. Three tabs = one key, three refs;
|
|
8
|
+
* closing one tab is not a "leave" of the user.
|
|
9
|
+
*
|
|
10
|
+
* Replication is single-writer: every entry is owned by the node holding its
|
|
11
|
+
* socket and only that node writes it, so state is conflict-free without a
|
|
12
|
+
* merge CRDT. Each node stamps its messages with a per-node clock; a gap or
|
|
13
|
+
* an unknown node triggers a full `sync` from the owner. Nodes heartbeat;
|
|
14
|
+
* a silent node is declared dead and its entries are dropped.
|
|
15
|
+
*/
|
|
16
|
+
export class PresenceLimitError extends Error {
|
|
17
|
+
constructor(topic) {
|
|
18
|
+
super(`Presence topic "${topic}" is full`);
|
|
19
|
+
this.name = "PresenceLimitError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Per-hub presence state. Get it via `hub.presence(topic)` — you rarely
|
|
24
|
+
* construct this directly.
|
|
25
|
+
*/
|
|
26
|
+
export class PresenceRegistry {
|
|
27
|
+
#hub;
|
|
28
|
+
#opts;
|
|
29
|
+
#now;
|
|
30
|
+
#setTimer;
|
|
31
|
+
#clearTimer;
|
|
32
|
+
#setInterval;
|
|
33
|
+
#clearInterval;
|
|
34
|
+
#local = new Map();
|
|
35
|
+
#sockets = new Map();
|
|
36
|
+
#peers = new Map();
|
|
37
|
+
#listeners = new Set();
|
|
38
|
+
#pending = new Map();
|
|
39
|
+
#flushTimer;
|
|
40
|
+
#clock = 0;
|
|
41
|
+
#intervals = [];
|
|
42
|
+
#replicated;
|
|
43
|
+
constructor(hub, replicated, options = {}) {
|
|
44
|
+
this.#hub = hub;
|
|
45
|
+
this.#replicated = replicated;
|
|
46
|
+
this.#opts = {
|
|
47
|
+
batchMs: options.batchMs ?? 50,
|
|
48
|
+
heartbeatMs: options.heartbeatMs ?? 5_000,
|
|
49
|
+
peerTimeoutMs: options.peerTimeoutMs ?? 15_000,
|
|
50
|
+
maxEntriesPerTopic: options.maxEntriesPerTopic ?? 10_000,
|
|
51
|
+
};
|
|
52
|
+
this.#now = options.now ?? Date.now;
|
|
53
|
+
this.#setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
54
|
+
this.#clearTimer = options.clearTimer ?? ((h) => clearTimeout(h));
|
|
55
|
+
this.#setInterval =
|
|
56
|
+
options.setInterval ??
|
|
57
|
+
((fn, ms) => {
|
|
58
|
+
const handle = setInterval(fn, ms);
|
|
59
|
+
handle.unref?.();
|
|
60
|
+
return handle;
|
|
61
|
+
});
|
|
62
|
+
this.#clearInterval =
|
|
63
|
+
options.clearInterval ?? ((h) => clearInterval(h));
|
|
64
|
+
if (replicated) {
|
|
65
|
+
hub._registerExtension("presence", (body) => this.#onRemote(body));
|
|
66
|
+
this.#intervals.push(this.#setInterval(() => this.#heartbeat(), this.#opts.heartbeatMs), this.#setInterval(() => this.#sweep(), this.#opts.heartbeatMs));
|
|
67
|
+
// Ask everyone for their current entries.
|
|
68
|
+
this.#publish({
|
|
69
|
+
op: "sync_request",
|
|
70
|
+
node: this.#hub.nodeId,
|
|
71
|
+
target: "*",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ── Local writes ────────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Track `socket` in `topic` under `key`. Re-tracking replaces the meta.
|
|
78
|
+
* Untracked automatically when the socket closes.
|
|
79
|
+
*/
|
|
80
|
+
track(topic, socket, key, meta) {
|
|
81
|
+
if (socket.closed)
|
|
82
|
+
return;
|
|
83
|
+
let entries = this.#local.get(topic);
|
|
84
|
+
if (entries == null) {
|
|
85
|
+
entries = new Map();
|
|
86
|
+
this.#local.set(topic, entries);
|
|
87
|
+
}
|
|
88
|
+
const previous = entries.get(socket.id);
|
|
89
|
+
if (previous == null && entries.size >= this.#opts.maxEntriesPerTopic) {
|
|
90
|
+
throw new PresenceLimitError(topic);
|
|
91
|
+
}
|
|
92
|
+
const stored = { key, meta };
|
|
93
|
+
entries.set(socket.id, stored);
|
|
94
|
+
if (!this.#sockets.has(socket.id)) {
|
|
95
|
+
this.#sockets.set(socket.id, socket);
|
|
96
|
+
socket.onClose(() => this.#untrackSocket(socket));
|
|
97
|
+
}
|
|
98
|
+
const leaves = previous ? { [socket.id]: previous } : {};
|
|
99
|
+
this.#queue(topic, { [socket.id]: stored }, leaves);
|
|
100
|
+
this.#publishDiff(topic, { [socket.id]: stored }, previous ? [socket.id] : []);
|
|
101
|
+
}
|
|
102
|
+
/** Replace the meta of an already tracked socket. No-op if untracked. */
|
|
103
|
+
update(topic, socket, meta) {
|
|
104
|
+
const current = this.#local.get(topic)?.get(socket.id);
|
|
105
|
+
if (current == null)
|
|
106
|
+
return false;
|
|
107
|
+
this.track(topic, socket, current.key, meta);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
untrack(topic, socket) {
|
|
111
|
+
const entries = this.#local.get(topic);
|
|
112
|
+
const previous = entries?.get(socket.id);
|
|
113
|
+
if (entries == null || previous == null)
|
|
114
|
+
return;
|
|
115
|
+
entries.delete(socket.id);
|
|
116
|
+
if (entries.size === 0)
|
|
117
|
+
this.#local.delete(topic);
|
|
118
|
+
this.#queue(topic, {}, { [socket.id]: previous });
|
|
119
|
+
this.#publishDiff(topic, {}, [socket.id]);
|
|
120
|
+
}
|
|
121
|
+
// ── Reads (cluster-wide) ────────────────────────────────────────────
|
|
122
|
+
list(topic) {
|
|
123
|
+
const state = {};
|
|
124
|
+
const add = (entries) => {
|
|
125
|
+
for (const [ref, { key, meta }] of entries ?? []) {
|
|
126
|
+
addEntry(state, key, ref, meta);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
add(this.#local.get(topic));
|
|
130
|
+
for (const peer of this.#peers.values())
|
|
131
|
+
add(peer.topics.get(topic));
|
|
132
|
+
return state;
|
|
133
|
+
}
|
|
134
|
+
has(topic, key) {
|
|
135
|
+
return this.list(topic)[key] != null;
|
|
136
|
+
}
|
|
137
|
+
keys(topic) {
|
|
138
|
+
return Object.keys(this.list(topic));
|
|
139
|
+
}
|
|
140
|
+
/** Tracked meta of a local socket. */
|
|
141
|
+
metaOf(topic, socket) {
|
|
142
|
+
return this.#local.get(topic)?.get(socket.id)?.meta;
|
|
143
|
+
}
|
|
144
|
+
/** Topics with at least one entry anywhere. */
|
|
145
|
+
topics() {
|
|
146
|
+
const all = new Set(this.#local.keys());
|
|
147
|
+
for (const peer of this.#peers.values()) {
|
|
148
|
+
for (const topic of peer.topics.keys())
|
|
149
|
+
all.add(topic);
|
|
150
|
+
}
|
|
151
|
+
return [...all];
|
|
152
|
+
}
|
|
153
|
+
/** Coalesced diffs for every topic (local and remote changes). */
|
|
154
|
+
onDiff(fn) {
|
|
155
|
+
this.#listeners.add(fn);
|
|
156
|
+
return () => this.#listeners.delete(fn);
|
|
157
|
+
}
|
|
158
|
+
/** Stop timers and drop remote state (e.g. on shutdown). */
|
|
159
|
+
close() {
|
|
160
|
+
for (const handle of this.#intervals)
|
|
161
|
+
this.#clearInterval(handle);
|
|
162
|
+
this.#intervals = [];
|
|
163
|
+
if (this.#flushTimer != null)
|
|
164
|
+
this.#clearTimer(this.#flushTimer);
|
|
165
|
+
this.#flushTimer = undefined;
|
|
166
|
+
this.#replicated = false;
|
|
167
|
+
}
|
|
168
|
+
// ── Batching ────────────────────────────────────────────────────────
|
|
169
|
+
#queue(topic, joins, leaves) {
|
|
170
|
+
let pending = this.#pending.get(topic);
|
|
171
|
+
if (pending == null) {
|
|
172
|
+
pending = { joins: new Map(), leaves: new Map() };
|
|
173
|
+
this.#pending.set(topic, pending);
|
|
174
|
+
}
|
|
175
|
+
for (const [ref, entry] of Object.entries(leaves)) {
|
|
176
|
+
if (pending.joins.has(ref)) {
|
|
177
|
+
// Joined within this window, so clients never saw that version. If
|
|
178
|
+
// they saw an older one, its leave is already queued.
|
|
179
|
+
pending.joins.delete(ref);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!pending.leaves.has(ref))
|
|
183
|
+
pending.leaves.set(ref, entry);
|
|
184
|
+
}
|
|
185
|
+
for (const [ref, entry] of Object.entries(joins))
|
|
186
|
+
pending.joins.set(ref, entry);
|
|
187
|
+
this.#flushTimer ??= this.#setTimer(() => this.flush(), this.#opts.batchMs);
|
|
188
|
+
}
|
|
189
|
+
/** Emit pending diffs now. Called automatically after `batchMs`. */
|
|
190
|
+
flush() {
|
|
191
|
+
if (this.#flushTimer != null)
|
|
192
|
+
this.#clearTimer(this.#flushTimer);
|
|
193
|
+
this.#flushTimer = undefined;
|
|
194
|
+
const pending = [...this.#pending];
|
|
195
|
+
this.#pending.clear();
|
|
196
|
+
for (const [topic, { joins, leaves }] of pending) {
|
|
197
|
+
if (joins.size === 0 && leaves.size === 0)
|
|
198
|
+
continue;
|
|
199
|
+
const diff = { joins: group(joins), leaves: group(leaves) };
|
|
200
|
+
for (const fn of [...this.#listeners]) {
|
|
201
|
+
try {
|
|
202
|
+
fn(topic, diff);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
console.error("[fishenv.ws] presence listener failed:", err);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// ── Replication ─────────────────────────────────────────────────────
|
|
211
|
+
#publish(message) {
|
|
212
|
+
if (this.#replicated)
|
|
213
|
+
this.#hub._publishExtension("presence", message);
|
|
214
|
+
}
|
|
215
|
+
#tick() {
|
|
216
|
+
return ++this.#clock;
|
|
217
|
+
}
|
|
218
|
+
#publishDiff(topic, joins, leaves) {
|
|
219
|
+
if (!this.#replicated)
|
|
220
|
+
return;
|
|
221
|
+
this.#publish({
|
|
222
|
+
op: "diff",
|
|
223
|
+
node: this.#hub.nodeId,
|
|
224
|
+
clock: this.#tick(),
|
|
225
|
+
topic,
|
|
226
|
+
joins,
|
|
227
|
+
leaves,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
#heartbeat() {
|
|
231
|
+
this.#publish({
|
|
232
|
+
op: "heartbeat",
|
|
233
|
+
node: this.#hub.nodeId,
|
|
234
|
+
clock: this.#tick(),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Drop peers that stopped heart-beating (crashed or partitioned nodes). */
|
|
238
|
+
#sweep() {
|
|
239
|
+
const deadline = this.#now() - this.#opts.peerTimeoutMs;
|
|
240
|
+
for (const [node, peer] of [...this.#peers]) {
|
|
241
|
+
if (peer.lastSeen >= deadline)
|
|
242
|
+
continue;
|
|
243
|
+
this.#peers.delete(node);
|
|
244
|
+
for (const [topic, entries] of peer.topics) {
|
|
245
|
+
this.#queue(topic, {}, Object.fromEntries(entries));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
#onRemote(message) {
|
|
250
|
+
if (message == null || typeof message !== "object")
|
|
251
|
+
return;
|
|
252
|
+
if (message.node === this.#hub.nodeId)
|
|
253
|
+
return;
|
|
254
|
+
if (message.op === "sync_request") {
|
|
255
|
+
if (message.target === "*" || message.target === this.#hub.nodeId) {
|
|
256
|
+
this.#sendSync(message.node);
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let peer = this.#peers.get(message.node);
|
|
261
|
+
const isNew = peer == null;
|
|
262
|
+
if (peer == null) {
|
|
263
|
+
peer = {
|
|
264
|
+
clock: 0,
|
|
265
|
+
lastSeen: this.#now(),
|
|
266
|
+
syncing: false,
|
|
267
|
+
topics: new Map(),
|
|
268
|
+
};
|
|
269
|
+
this.#peers.set(message.node, peer);
|
|
270
|
+
}
|
|
271
|
+
peer.lastSeen = this.#now();
|
|
272
|
+
if (message.op === "sync") {
|
|
273
|
+
if (message.to === this.#hub.nodeId)
|
|
274
|
+
this.#applySync(peer, message);
|
|
275
|
+
else if (!peer.syncing)
|
|
276
|
+
this.#advance(message.node, peer, message.clock, isNew);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (peer.syncing)
|
|
280
|
+
return; // the snapshot will contain it
|
|
281
|
+
if (!this.#advance(message.node, peer, message.clock, isNew))
|
|
282
|
+
return;
|
|
283
|
+
if (message.op === "diff")
|
|
284
|
+
this.#applyDiff(peer, message);
|
|
285
|
+
}
|
|
286
|
+
/** Check clock continuity; on a gap request a full sync. */
|
|
287
|
+
#advance(node, peer, clock, isNew) {
|
|
288
|
+
if (clock === peer.clock + 1 || (isNew && clock === 1)) {
|
|
289
|
+
peer.clock = clock;
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
if (clock <= peer.clock)
|
|
293
|
+
return false; // duplicate / reordered — ignore
|
|
294
|
+
peer.syncing = true;
|
|
295
|
+
this.#publish({ op: "sync_request", node: this.#hub.nodeId, target: node });
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
#applyDiff(peer, message) {
|
|
299
|
+
let entries = peer.topics.get(message.topic);
|
|
300
|
+
if (entries == null) {
|
|
301
|
+
entries = new Map();
|
|
302
|
+
peer.topics.set(message.topic, entries);
|
|
303
|
+
}
|
|
304
|
+
const leaves = {};
|
|
305
|
+
for (const ref of message.leaves) {
|
|
306
|
+
const previous = entries.get(ref);
|
|
307
|
+
if (previous == null)
|
|
308
|
+
continue;
|
|
309
|
+
entries.delete(ref);
|
|
310
|
+
leaves[ref] = previous;
|
|
311
|
+
}
|
|
312
|
+
for (const [ref, entry] of Object.entries(message.joins))
|
|
313
|
+
entries.set(ref, entry);
|
|
314
|
+
if (entries.size === 0)
|
|
315
|
+
peer.topics.delete(message.topic);
|
|
316
|
+
this.#queue(message.topic, message.joins, leaves);
|
|
317
|
+
}
|
|
318
|
+
#sendSync(to) {
|
|
319
|
+
const topics = {};
|
|
320
|
+
for (const [topic, entries] of this.#local)
|
|
321
|
+
topics[topic] = Object.fromEntries(entries);
|
|
322
|
+
this.#publish({
|
|
323
|
+
op: "sync",
|
|
324
|
+
node: this.#hub.nodeId,
|
|
325
|
+
clock: this.#tick(),
|
|
326
|
+
to,
|
|
327
|
+
topics,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
/** Replace a peer's state with its snapshot and emit the difference. */
|
|
331
|
+
#applySync(peer, message) {
|
|
332
|
+
const previous = peer.topics;
|
|
333
|
+
const next = new Map();
|
|
334
|
+
for (const [topic, entries] of Object.entries(message.topics)) {
|
|
335
|
+
next.set(topic, new Map(Object.entries(entries)));
|
|
336
|
+
}
|
|
337
|
+
const allTopics = new Set([...previous.keys(), ...next.keys()]);
|
|
338
|
+
for (const topic of allTopics) {
|
|
339
|
+
const before = previous.get(topic) ?? new Map();
|
|
340
|
+
const after = next.get(topic) ?? new Map();
|
|
341
|
+
const joins = {};
|
|
342
|
+
const leaves = {};
|
|
343
|
+
for (const [ref, entry] of before) {
|
|
344
|
+
const now = after.get(ref);
|
|
345
|
+
if (now == null || !sameEntry(now, entry))
|
|
346
|
+
leaves[ref] = entry;
|
|
347
|
+
}
|
|
348
|
+
for (const [ref, entry] of after) {
|
|
349
|
+
const was = before.get(ref);
|
|
350
|
+
if (was == null || !sameEntry(was, entry))
|
|
351
|
+
joins[ref] = entry;
|
|
352
|
+
}
|
|
353
|
+
this.#queue(topic, joins, leaves);
|
|
354
|
+
}
|
|
355
|
+
peer.topics = next;
|
|
356
|
+
peer.clock = message.clock;
|
|
357
|
+
peer.syncing = false;
|
|
358
|
+
}
|
|
359
|
+
#untrackSocket(socket) {
|
|
360
|
+
this.#sockets.delete(socket.id);
|
|
361
|
+
for (const topic of [...this.#local.keys()])
|
|
362
|
+
this.untrack(topic, socket);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function addEntry(state, key, ref, meta) {
|
|
366
|
+
const list = state[key];
|
|
367
|
+
if (list == null)
|
|
368
|
+
state[key] = [{ ref, meta }];
|
|
369
|
+
else
|
|
370
|
+
list.push({ ref, meta });
|
|
371
|
+
}
|
|
372
|
+
function sameEntry(a, b) {
|
|
373
|
+
return a.key === b.key && JSON.stringify(a.meta) === JSON.stringify(b.meta);
|
|
374
|
+
}
|
|
375
|
+
function group(entries) {
|
|
376
|
+
const state = {};
|
|
377
|
+
for (const [ref, { key, meta }] of entries)
|
|
378
|
+
addEntry(state, key, ref, meta);
|
|
379
|
+
return state;
|
|
380
|
+
}
|
|
381
|
+
/** A read/write view of one topic, from `hub.presence(topic)`. */
|
|
382
|
+
export class PresenceTopic {
|
|
383
|
+
topic;
|
|
384
|
+
#registry;
|
|
385
|
+
/** @internal */
|
|
386
|
+
constructor(registry, topic) {
|
|
387
|
+
this.#registry = registry;
|
|
388
|
+
this.topic = topic;
|
|
389
|
+
}
|
|
390
|
+
track(socket, key, meta) {
|
|
391
|
+
this.#registry.track(this.topic, socket, key, meta);
|
|
392
|
+
}
|
|
393
|
+
update(socket, meta) {
|
|
394
|
+
return this.#registry.update(this.topic, socket, meta);
|
|
395
|
+
}
|
|
396
|
+
untrack(socket) {
|
|
397
|
+
this.#registry.untrack(this.topic, socket);
|
|
398
|
+
}
|
|
399
|
+
/** Cluster-wide: `{ [key]: { ref, meta }[] }`. */
|
|
400
|
+
list() {
|
|
401
|
+
return this.#registry.list(this.topic);
|
|
402
|
+
}
|
|
403
|
+
has(key) {
|
|
404
|
+
return this.#registry.has(this.topic, key);
|
|
405
|
+
}
|
|
406
|
+
keys() {
|
|
407
|
+
return this.#registry.keys(this.topic);
|
|
408
|
+
}
|
|
409
|
+
/** Number of distinct keys (users), cluster-wide. */
|
|
410
|
+
count() {
|
|
411
|
+
return this.keys().length;
|
|
412
|
+
}
|
|
413
|
+
/** Coalesced diffs of this topic. */
|
|
414
|
+
onDiff(fn) {
|
|
415
|
+
return this.#registry.onDiff((topic, diff) => {
|
|
416
|
+
if (topic === this.topic)
|
|
417
|
+
fn(diff);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** Fluent builder for WebSocket routes, layered on the core `RouteBuilder`. */
|
|
2
|
+
import type { AnyChannelContract, WsContract, WsContractDefinition } from "../contract/index.ts";
|
|
3
|
+
import { type AnySchema, type BuildEnv, type DIContainer, type FinishedRoute, type InputOptions, type MergeParam, type MiddlewareFn, type RouteBuilder, type RouteMeta } from "../core/index.ts";
|
|
4
|
+
import { type ChannelFactory, type ChannelImplDefinition } from "./channels/channel.ts";
|
|
5
|
+
import { type ChannelsOptions } from "./channels/handler.ts";
|
|
6
|
+
import type { WsConnection } from "./connection.ts";
|
|
7
|
+
import type { WsHub } from "./hub.ts";
|
|
8
|
+
import { type WsConnectionLimit } from "./limits.ts";
|
|
9
|
+
import { type OriginSource } from "./origin.ts";
|
|
10
|
+
import { type WsCleanupFn, type WsRateLimit } from "./types.ts";
|
|
11
|
+
export interface WsHandlerArgs<Ctx extends Record<string, unknown>, Params extends Record<string, unknown>, Socket = WsConnection> {
|
|
12
|
+
/** `TypedWsConnection` on `c.ws().messages()` contract routes. */
|
|
13
|
+
socket: Socket;
|
|
14
|
+
path: Params;
|
|
15
|
+
ctx: Ctx;
|
|
16
|
+
/** The upgrade request. */
|
|
17
|
+
req: Request;
|
|
18
|
+
container: DIContainer;
|
|
19
|
+
}
|
|
20
|
+
export type WsHandlerFn<Ctx extends Record<string, unknown>, Params extends Record<string, unknown>, Socket = WsConnection> = (args: WsHandlerArgs<Ctx, Params, Socket>) => void | WsCleanupFn | Promise<void | WsCleanupFn>;
|
|
21
|
+
/** What a protocol handler learns about the route it is mounted on. */
|
|
22
|
+
export interface WsRouteInfo {
|
|
23
|
+
/** Full path including router prefixes. */
|
|
24
|
+
path: string;
|
|
25
|
+
contract?: WsContractDefinition;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A reusable protocol implementation (channels, JSON-RPC, …) that plugs into
|
|
29
|
+
* `.handleWith()` exactly like the built-in `channels()` handler.
|
|
30
|
+
*/
|
|
31
|
+
export interface WsProtocolHandler<Ctx extends Record<string, unknown>, Params extends Record<string, unknown>> {
|
|
32
|
+
/** Subprotocols this handler speaks, in preference order. */
|
|
33
|
+
readonly protocols?: readonly string[];
|
|
34
|
+
readonly setup: WsHandlerFn<Ctx, Params>;
|
|
35
|
+
/** Startup validation hook (e.g. completeness checks). */
|
|
36
|
+
onBuild?(env: BuildEnv, route: WsRouteInfo): void;
|
|
37
|
+
}
|
|
38
|
+
/** Router-level defaults set via `withWs(router, options)`. */
|
|
39
|
+
export interface WsRouterOptions {
|
|
40
|
+
/** Origin sources added to every ws route on this router. */
|
|
41
|
+
origins?: OriginSource[];
|
|
42
|
+
}
|
|
43
|
+
/** Mutable per-route configuration, copied when a builder is re-created. */
|
|
44
|
+
export interface WsRouteOptions {
|
|
45
|
+
routerOrigins: OriginSource[];
|
|
46
|
+
origins: OriginSource[];
|
|
47
|
+
inheritOrigins: boolean;
|
|
48
|
+
protocols?: readonly string[];
|
|
49
|
+
maxMessageSize?: number;
|
|
50
|
+
rateLimit?: WsRateLimit;
|
|
51
|
+
maxQueuedMessages?: number;
|
|
52
|
+
maxConnections?: WsConnectionLimit<any>;
|
|
53
|
+
/** Set for routes created from a `c.ws()` contract. */
|
|
54
|
+
contract?: WsContractDefinition;
|
|
55
|
+
}
|
|
56
|
+
type Inner<RouterCtx extends Record<string, unknown>, RouteCtx extends Record<string, unknown>, Params extends Record<string, unknown>> = RouteBuilder<RouterCtx, RouteCtx, "none", InputOptions<"none">, Params, undefined>;
|
|
57
|
+
/**
|
|
58
|
+
* A channel implementation that `.channels()` accepts for a given ctx.
|
|
59
|
+
* Only the phantom members are compared, so the check is exactly: "is it
|
|
60
|
+
* complete?" and "does the route's ctx satisfy what the handlers need?".
|
|
61
|
+
*/
|
|
62
|
+
export interface CompleteChannelImpl<Ctx> {
|
|
63
|
+
readonly _ctx?: (ctx: Ctx) => void;
|
|
64
|
+
readonly _status?: "complete";
|
|
65
|
+
readonly contract: AnyChannelContract;
|
|
66
|
+
readonly _definition: ChannelImplDefinition;
|
|
67
|
+
}
|
|
68
|
+
type ImplNames<Impls extends readonly unknown[]> = Impls[number] extends infer I ? I extends {
|
|
69
|
+
readonly contract: {
|
|
70
|
+
readonly name: infer N;
|
|
71
|
+
};
|
|
72
|
+
} ? N : never : never;
|
|
73
|
+
type ContractNames<K> = K extends WsContract<any, any, "channels", infer Cs, any, any> ? Cs extends readonly AnyChannelContract[] ? Cs[number]["name"] : never : never;
|
|
74
|
+
/** `unknown` when the implementations match the socket contract; a named error otherwise. */
|
|
75
|
+
export type ChannelCoverage<Impls extends readonly unknown[], K> = [K] extends [
|
|
76
|
+
undefined
|
|
77
|
+
] ? unknown : [Exclude<ContractNames<K>, ImplNames<Impls>>] extends [never] ? [Exclude<ImplNames<Impls>, ContractNames<K>>] extends [never] ? unknown : {
|
|
78
|
+
__channelNotInContract: Exclude<ImplNames<Impls>, ContractNames<K>>;
|
|
79
|
+
} : {
|
|
80
|
+
__missingChannel: Exclude<ContractNames<K>, ImplNames<Impls>>;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Delegates to the core `RouteBuilder`, so `.param()` / `.with()` carry
|
|
84
|
+
* exactly the same generics as HTTP and SSE routes.
|
|
85
|
+
*
|
|
86
|
+
* Handshake order: origin → upgrade headers (426) → subprotocol →
|
|
87
|
+
* middleware → path params → connection limits → upgrade.
|
|
88
|
+
*/
|
|
89
|
+
export declare class WsRouteBuilder<RouterCtx extends Record<string, unknown>, RouteCtx extends Record<string, unknown>, Params extends Record<string, unknown>, Socket = WsConnection, Contract = undefined> {
|
|
90
|
+
#private;
|
|
91
|
+
constructor(inner: Inner<RouterCtx, RouteCtx, Params>, options?: Partial<WsRouteOptions>);
|
|
92
|
+
meta(data: RouteMeta): this;
|
|
93
|
+
/**
|
|
94
|
+
* Add allowed origins on top of the inherited ones (root `cors()` and
|
|
95
|
+
* `withWs()` options). Pass `{ inherit: false }` to replace them instead.
|
|
96
|
+
*/
|
|
97
|
+
origin(...args: [...OriginSource[], {
|
|
98
|
+
inherit?: boolean;
|
|
99
|
+
}] | OriginSource[]): this;
|
|
100
|
+
/** Supported subprotocols in preference order; others get `400`. */
|
|
101
|
+
protocols(list: readonly string[]): this;
|
|
102
|
+
/** Per-route inbound limit (closes with `1009`). Must be ≤ the server-wide limit. */
|
|
103
|
+
maxMessageSize(bytes: number): this;
|
|
104
|
+
/** Inbound token bucket per connection. Opt-in. */
|
|
105
|
+
rateLimit(limit: WsRateLimit): this;
|
|
106
|
+
/** Cap open sockets on this route (`503`) and/or per key (`429`). */
|
|
107
|
+
maxConnections(limit: WsConnectionLimit<Omit<WsHandlerArgs<RouterCtx & RouteCtx, Params, Socket>, "socket">>): this;
|
|
108
|
+
/** Not available on contract routes — the contract owns the params. */
|
|
109
|
+
param<Name extends string & keyof Params, S extends AnySchema>(name: Name, schema: S): WsRouteBuilder<RouterCtx, RouteCtx, MergeParam<Params, Name, S>, Socket, Contract>;
|
|
110
|
+
with<NewCtx extends Record<string, unknown>>(mw: MiddlewareFn<RouterCtx & RouteCtx, NewCtx>): WsRouteBuilder<RouterCtx, RouteCtx & NewCtx, Params, Socket, Contract>;
|
|
111
|
+
/**
|
|
112
|
+
* Terminal: serve the channel protocol. Every implementation must be
|
|
113
|
+
* complete (all client events handled, join authorized or `.public()`),
|
|
114
|
+
* and on contract routes the set must match the socket contract exactly.
|
|
115
|
+
*
|
|
116
|
+
* Pass a callback to get a `channel` factory typed with this route's ctx:
|
|
117
|
+
* `.channels((channel) => [channel(room).join(({ ctx }) => …)])`.
|
|
118
|
+
*/
|
|
119
|
+
channels<const Impls extends readonly CompleteChannelImpl<RouterCtx & RouteCtx>[]>(impls: (Impls & ChannelCoverage<Impls, Contract>) | ((channel: ChannelFactory<RouterCtx & RouteCtx>) => Impls & ChannelCoverage<Impls, Contract>), options?: ChannelsOptions): FinishedRoute;
|
|
120
|
+
/** Terminal: plug in a reusable protocol handler (see `channels()`). */
|
|
121
|
+
handleWith(handler: WsProtocolHandler<RouterCtx & RouteCtx, Params>): FinishedRoute;
|
|
122
|
+
/**
|
|
123
|
+
* Terminal shorthand: resolve a hub from the container and register the
|
|
124
|
+
* socket under the key derived from the handshake (e.g. the user id).
|
|
125
|
+
*/
|
|
126
|
+
subscribe<H extends WsHub>(token: abstract new (...args: any[]) => H, key: (args: WsHandlerArgs<RouterCtx & RouteCtx, Params, Socket>) => string): FinishedRoute;
|
|
127
|
+
/**
|
|
128
|
+
* Terminal. Runs once per socket, after the upgrade. The returned function
|
|
129
|
+
* (if any) runs when the socket closes.
|
|
130
|
+
*
|
|
131
|
+
* `.catch()` on the result only covers failures *before* the upgrade
|
|
132
|
+
* (origin, middleware, params) — after that, errors close the socket.
|
|
133
|
+
*/
|
|
134
|
+
handle(fn: WsHandlerFn<RouterCtx & RouteCtx, Params, Socket>): FinishedRoute;
|
|
135
|
+
}
|
|
136
|
+
export {};
|