@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
package/dist/ws/hub.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed registry of live sockets plus server-side rooms — the seam for
|
|
3
|
+
* targeted (non-broadcast) sends from anywhere in the application.
|
|
4
|
+
*/
|
|
5
|
+
import { createTransportBridge, } from "../core/index.js";
|
|
6
|
+
import { WsChannelTarget } from "./channels/target.js";
|
|
7
|
+
import { PresenceRegistry, PresenceTopic, } from "./presence.js";
|
|
8
|
+
import { WsCloseCode } from "./types.js";
|
|
9
|
+
const NO_FILTER = {
|
|
10
|
+
exceptIds: new Set(),
|
|
11
|
+
exceptKeys: new Set(),
|
|
12
|
+
onlyKeys: null,
|
|
13
|
+
};
|
|
14
|
+
function isUnfiltered(filter) {
|
|
15
|
+
return (filter.exceptIds.size === 0 &&
|
|
16
|
+
filter.exceptKeys.size === 0 &&
|
|
17
|
+
filter.onlyKeys == null);
|
|
18
|
+
}
|
|
19
|
+
function toWireFilter(filter) {
|
|
20
|
+
if (isUnfiltered(filter))
|
|
21
|
+
return undefined;
|
|
22
|
+
return {
|
|
23
|
+
exceptIds: filter.exceptIds.size ? [...filter.exceptIds] : undefined,
|
|
24
|
+
exceptKeys: filter.exceptKeys.size ? [...filter.exceptKeys] : undefined,
|
|
25
|
+
onlyKeys: filter.onlyKeys ? [...filter.onlyKeys] : undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function fromWireFilter(wire) {
|
|
29
|
+
if (wire == null)
|
|
30
|
+
return NO_FILTER;
|
|
31
|
+
return {
|
|
32
|
+
exceptIds: new Set(wire.exceptIds ?? []),
|
|
33
|
+
exceptKeys: new Set(wire.exceptKeys ?? []),
|
|
34
|
+
onlyKeys: wire.onlyKeys ? new Set(wire.onlyKeys) : null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** A fan-out target: a room, optionally narrowed to/away from sockets or keys. */
|
|
38
|
+
export class WsRoomTarget {
|
|
39
|
+
#hub;
|
|
40
|
+
#room;
|
|
41
|
+
#filter;
|
|
42
|
+
/** @internal Use `hub.to(room)`. */
|
|
43
|
+
constructor(hub, room, filter = NO_FILTER) {
|
|
44
|
+
this.#hub = hub;
|
|
45
|
+
this.#room = room;
|
|
46
|
+
this.#filter = filter;
|
|
47
|
+
}
|
|
48
|
+
/** Exclude a socket (e.g. the sender) or every socket of a key. */
|
|
49
|
+
except(...targets) {
|
|
50
|
+
const exceptIds = new Set(this.#filter.exceptIds);
|
|
51
|
+
const exceptKeys = new Set(this.#filter.exceptKeys);
|
|
52
|
+
for (const t of targets) {
|
|
53
|
+
if (typeof t === "string")
|
|
54
|
+
exceptKeys.add(t);
|
|
55
|
+
else
|
|
56
|
+
exceptIds.add(t.id);
|
|
57
|
+
}
|
|
58
|
+
return new WsRoomTarget(this.#hub, this.#room, {
|
|
59
|
+
...this.#filter,
|
|
60
|
+
exceptIds,
|
|
61
|
+
exceptKeys,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Only members registered under one of `keys`. */
|
|
65
|
+
only(...keys) {
|
|
66
|
+
const onlyKeys = new Set(keys);
|
|
67
|
+
if (this.#filter.onlyKeys != null) {
|
|
68
|
+
for (const k of [...onlyKeys]) {
|
|
69
|
+
if (!this.#filter.onlyKeys.has(k))
|
|
70
|
+
onlyKeys.delete(k);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return new WsRoomTarget(this.#hub, this.#room, {
|
|
74
|
+
...this.#filter,
|
|
75
|
+
onlyKeys,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Deliver to matching members on every node. Returns the local delivery count. */
|
|
79
|
+
send(data) {
|
|
80
|
+
return this.#hub._sendRoom(this.#room, encodeFrame(data), this.#filter, true);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Remove matching members from the room on every node, running any leave
|
|
84
|
+
* hook registered for them (channels use this to notify the client).
|
|
85
|
+
*/
|
|
86
|
+
kick(reason = "") {
|
|
87
|
+
return this.#hub._kick(this.#room, this.#filter, reason, true);
|
|
88
|
+
}
|
|
89
|
+
/** Local matching member count. */
|
|
90
|
+
count() {
|
|
91
|
+
return this.sockets().length;
|
|
92
|
+
}
|
|
93
|
+
/** Local matching members. */
|
|
94
|
+
sockets() {
|
|
95
|
+
return this.#hub._members(this.#room, this.#filter);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Sockets keyed by an application identity (user id, tenant id, device…),
|
|
100
|
+
* plus server-side **rooms** — groupings the server assigns; clients can't
|
|
101
|
+
* ask to join one. One key maps to many sockets (tabs); one socket has at
|
|
102
|
+
* most one key and any number of rooms.
|
|
103
|
+
*
|
|
104
|
+
* Subclass it to get a DI token:
|
|
105
|
+
* ```ts
|
|
106
|
+
* class ChatSockets extends WsHub {}
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export class WsHub {
|
|
110
|
+
name;
|
|
111
|
+
#byKey = new Map();
|
|
112
|
+
#keyOf = new Map();
|
|
113
|
+
#rooms = new Map();
|
|
114
|
+
#roomsOf = new Map();
|
|
115
|
+
#tracked = new Set();
|
|
116
|
+
#leaveHooks = new Map();
|
|
117
|
+
/** Members whose room frames are queued until `_release` (pending joins). */
|
|
118
|
+
#held = new Map();
|
|
119
|
+
#extensions = new Map();
|
|
120
|
+
#bridge;
|
|
121
|
+
#replicated;
|
|
122
|
+
#presenceOptions;
|
|
123
|
+
#presence;
|
|
124
|
+
constructor(options = {}) {
|
|
125
|
+
this.name = options.name ?? this.constructor.name;
|
|
126
|
+
this.#replicated = options.transport != null;
|
|
127
|
+
this.#presenceOptions = options.presence;
|
|
128
|
+
this.#bridge = createTransportBridge(options.transport, (message) => {
|
|
129
|
+
if (message.hub === this.name)
|
|
130
|
+
this.#onRemote(message);
|
|
131
|
+
}, { label: "[fishenv.ws]" });
|
|
132
|
+
}
|
|
133
|
+
/** Random id of this hub instance on the transport. */
|
|
134
|
+
get nodeId() {
|
|
135
|
+
return this.#bridge.nodeId;
|
|
136
|
+
}
|
|
137
|
+
// ── Registration ────────────────────────────────────────────────────
|
|
138
|
+
/**
|
|
139
|
+
* Register a socket under `key`. Returns an unregister fn; also
|
|
140
|
+
* auto-removes (keys and rooms) on close. Re-adding moves the socket.
|
|
141
|
+
*/
|
|
142
|
+
add(key, socket) {
|
|
143
|
+
if (socket.closed)
|
|
144
|
+
return () => { };
|
|
145
|
+
this.#removeKey(socket);
|
|
146
|
+
let set = this.#byKey.get(key);
|
|
147
|
+
if (set == null) {
|
|
148
|
+
set = new Set();
|
|
149
|
+
this.#byKey.set(key, set);
|
|
150
|
+
}
|
|
151
|
+
set.add(socket);
|
|
152
|
+
this.#keyOf.set(socket, key);
|
|
153
|
+
this.#track(socket);
|
|
154
|
+
return () => {
|
|
155
|
+
if (this.#keyOf.get(socket) === key)
|
|
156
|
+
this.#removeKey(socket);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** The key a socket was registered under, if any. */
|
|
160
|
+
keyOf(socket) {
|
|
161
|
+
return this.#keyOf.get(socket);
|
|
162
|
+
}
|
|
163
|
+
// ── Rooms ───────────────────────────────────────────────────────────
|
|
164
|
+
/** Put a socket in a room (idempotent). Server-side only. */
|
|
165
|
+
join(socket, room) {
|
|
166
|
+
if (this.#addMember(socket, room) && serverOf(socket) != null) {
|
|
167
|
+
socket.raw.subscribe(this.#topic(room));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Remove a socket from a room. Does *not* run leave hooks. */
|
|
171
|
+
leave(socket, room) {
|
|
172
|
+
this.#leaveHooks.get(socket)?.delete(room);
|
|
173
|
+
this.#unhold(socket, room);
|
|
174
|
+
const members = this.#rooms.get(room);
|
|
175
|
+
if (members == null || !members.delete(socket))
|
|
176
|
+
return;
|
|
177
|
+
if (members.size === 0)
|
|
178
|
+
this.#rooms.delete(room);
|
|
179
|
+
const rooms = this.#roomsOf.get(socket);
|
|
180
|
+
rooms?.delete(room);
|
|
181
|
+
if (rooms?.size === 0)
|
|
182
|
+
this.#roomsOf.delete(socket);
|
|
183
|
+
if (!socket.closed && serverOf(socket) != null) {
|
|
184
|
+
socket.raw.unsubscribe(this.#topic(room));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* @internal Make a socket a member of `room` whose frames for the room are
|
|
189
|
+
* queued instead of sent, until {@link _release}. Channels use this while a
|
|
190
|
+
* join handler runs, so nothing broadcast meanwhile is missed and nothing
|
|
191
|
+
* arrives before the join reply. Past `limit` queued frames the hold is
|
|
192
|
+
* marked overflowed (further frames are dropped). `false` when nothing is
|
|
193
|
+
* held: the socket is closed or already a (normal) member.
|
|
194
|
+
*/
|
|
195
|
+
_hold(socket, room, limit) {
|
|
196
|
+
if (!this.#addMember(socket, room))
|
|
197
|
+
return false;
|
|
198
|
+
let held = this.#held.get(socket);
|
|
199
|
+
if (held == null) {
|
|
200
|
+
held = new Map();
|
|
201
|
+
this.#held.set(socket, held);
|
|
202
|
+
}
|
|
203
|
+
held.set(room, { frames: [], limit, overflowed: false });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* @internal End a hold: subscribe the socket normally and return the queued
|
|
208
|
+
* frames, oldest first, for the caller to send. `null` when the socket is no
|
|
209
|
+
* longer held (it left, was kicked or closed); `"overflow"` when the queue
|
|
210
|
+
* overflowed, in which case the socket also leaves the room.
|
|
211
|
+
*/
|
|
212
|
+
_release(socket, room) {
|
|
213
|
+
const held = this.#held.get(socket)?.get(room);
|
|
214
|
+
if (held == null)
|
|
215
|
+
return null;
|
|
216
|
+
this.#unhold(socket, room);
|
|
217
|
+
if (held.overflowed) {
|
|
218
|
+
this.leave(socket, room);
|
|
219
|
+
return "overflow";
|
|
220
|
+
}
|
|
221
|
+
if (serverOf(socket) != null)
|
|
222
|
+
socket.raw.subscribe(this.#topic(room));
|
|
223
|
+
return held.frames;
|
|
224
|
+
}
|
|
225
|
+
/** @internal Queue `frame` if `socket` is held in `room`; `false` otherwise. */
|
|
226
|
+
_holdFrame(socket, room, frame) {
|
|
227
|
+
const held = this.#held.get(socket)?.get(room);
|
|
228
|
+
if (held == null)
|
|
229
|
+
return false;
|
|
230
|
+
if (held.overflowed)
|
|
231
|
+
return true;
|
|
232
|
+
if (held.frames.length >= held.limit) {
|
|
233
|
+
held.overflowed = true;
|
|
234
|
+
held.frames = [];
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
held.frames.push(frame);
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
/** `true` when the socket was added (not closed, not already a member). */
|
|
242
|
+
#addMember(socket, room) {
|
|
243
|
+
if (socket.closed)
|
|
244
|
+
return false;
|
|
245
|
+
let members = this.#rooms.get(room);
|
|
246
|
+
if (members == null) {
|
|
247
|
+
members = new Set();
|
|
248
|
+
this.#rooms.set(room, members);
|
|
249
|
+
}
|
|
250
|
+
if (members.has(socket))
|
|
251
|
+
return false;
|
|
252
|
+
members.add(socket);
|
|
253
|
+
let rooms = this.#roomsOf.get(socket);
|
|
254
|
+
if (rooms == null) {
|
|
255
|
+
rooms = new Set();
|
|
256
|
+
this.#roomsOf.set(socket, rooms);
|
|
257
|
+
}
|
|
258
|
+
rooms.add(room);
|
|
259
|
+
this.#track(socket);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
#unhold(socket, room) {
|
|
263
|
+
const held = this.#held.get(socket);
|
|
264
|
+
if (held?.delete(room) && held.size === 0)
|
|
265
|
+
this.#held.delete(socket);
|
|
266
|
+
}
|
|
267
|
+
/** Rooms a socket is in. */
|
|
268
|
+
rooms(socket) {
|
|
269
|
+
return [...(this.#roomsOf.get(socket) ?? [])];
|
|
270
|
+
}
|
|
271
|
+
/** Every room with at least one local member. */
|
|
272
|
+
roomNames() {
|
|
273
|
+
return [...this.#rooms.keys()];
|
|
274
|
+
}
|
|
275
|
+
to(room) {
|
|
276
|
+
return new WsRoomTarget(this, room);
|
|
277
|
+
}
|
|
278
|
+
channel(a, b) {
|
|
279
|
+
return new WsChannelTarget(this, typeof a === "string" ? a : (b ?? ""));
|
|
280
|
+
}
|
|
281
|
+
// ── Sending ─────────────────────────────────────────────────────────
|
|
282
|
+
/** Send to every socket of `key` on every node. Returns local delivery count. */
|
|
283
|
+
send(key, data) {
|
|
284
|
+
const frame = encodeFrame(data);
|
|
285
|
+
this.#bridge.publish({
|
|
286
|
+
hub: this.name,
|
|
287
|
+
kind: "send",
|
|
288
|
+
key,
|
|
289
|
+
payload: toWire(frame),
|
|
290
|
+
});
|
|
291
|
+
return this.#deliverKey(key, frame);
|
|
292
|
+
}
|
|
293
|
+
sendMany(keys, data) {
|
|
294
|
+
let delivered = 0;
|
|
295
|
+
for (const key of keys)
|
|
296
|
+
delivered += this.send(key, data);
|
|
297
|
+
return delivered;
|
|
298
|
+
}
|
|
299
|
+
/** Send to every keyed socket in this hub, on every node. */
|
|
300
|
+
broadcast(data) {
|
|
301
|
+
const frame = encodeFrame(data);
|
|
302
|
+
this.#bridge.publish({
|
|
303
|
+
hub: this.name,
|
|
304
|
+
kind: "send",
|
|
305
|
+
key: null,
|
|
306
|
+
payload: toWire(frame),
|
|
307
|
+
});
|
|
308
|
+
return this.#deliverAll(frame);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Close every socket of `key` on every node — e.g. on logout or when
|
|
312
|
+
* permissions are revoked. Returns the local count.
|
|
313
|
+
*/
|
|
314
|
+
close(key, code = WsCloseCode.Normal, reason = "") {
|
|
315
|
+
this.#bridge.publish({ hub: this.name, kind: "close", key, code, reason });
|
|
316
|
+
return this.#closeKey(key, code, reason);
|
|
317
|
+
}
|
|
318
|
+
/** Close every local socket and detach from the transport. */
|
|
319
|
+
closeAll(code = WsCloseCode.GoingAway, reason = "") {
|
|
320
|
+
for (const socket of [...this.#tracked])
|
|
321
|
+
socket.close(code, reason);
|
|
322
|
+
this.#presence?.close();
|
|
323
|
+
this.#bridge.close();
|
|
324
|
+
}
|
|
325
|
+
// ── Presence ────────────────────────────────────────────────────────
|
|
326
|
+
/**
|
|
327
|
+
* Presence for a topic — who is "in" it, by key, across every node.
|
|
328
|
+
* Channels with contract presence use `hub.channel(name).presence`.
|
|
329
|
+
*/
|
|
330
|
+
presence(topic) {
|
|
331
|
+
return new PresenceTopic(this._presenceRegistry(), topic);
|
|
332
|
+
}
|
|
333
|
+
/** @internal The hub's presence registry (created on first use). */
|
|
334
|
+
_presenceRegistry() {
|
|
335
|
+
this.#presence ??= new PresenceRegistry(this, this.#replicated, this.#presenceOptions);
|
|
336
|
+
return this.#presence;
|
|
337
|
+
}
|
|
338
|
+
// ── Introspection ───────────────────────────────────────────────────
|
|
339
|
+
has(key) {
|
|
340
|
+
return (this.#byKey.get(key)?.size ?? 0) > 0;
|
|
341
|
+
}
|
|
342
|
+
/** Socket count for `key`, or every keyed socket when omitted. */
|
|
343
|
+
count(key) {
|
|
344
|
+
if (key != null)
|
|
345
|
+
return this.#byKey.get(key)?.size ?? 0;
|
|
346
|
+
return this.#keyOf.size;
|
|
347
|
+
}
|
|
348
|
+
keys() {
|
|
349
|
+
return [...this.#byKey.keys()];
|
|
350
|
+
}
|
|
351
|
+
connections(key) {
|
|
352
|
+
return [...(this.#byKey.get(key) ?? [])];
|
|
353
|
+
}
|
|
354
|
+
// ── Extension points (channels, presence) ───────────────────────────
|
|
355
|
+
/**
|
|
356
|
+
* @internal Run `hook` when `socket` is kicked from `room` (locally or by
|
|
357
|
+
* another node). The hook is responsible for calling `leave()`.
|
|
358
|
+
*/
|
|
359
|
+
_onLeave(socket, room, hook) {
|
|
360
|
+
let hooks = this.#leaveHooks.get(socket);
|
|
361
|
+
if (hooks == null) {
|
|
362
|
+
hooks = new Map();
|
|
363
|
+
this.#leaveHooks.set(socket, hooks);
|
|
364
|
+
}
|
|
365
|
+
hooks.set(room, hook);
|
|
366
|
+
}
|
|
367
|
+
/** @internal Receive transport messages of an extension (e.g. presence). */
|
|
368
|
+
_registerExtension(kind, onRemote) {
|
|
369
|
+
this.#extensions.set(kind, onRemote);
|
|
370
|
+
}
|
|
371
|
+
/** @internal Publish an extension message to other nodes. */
|
|
372
|
+
_publishExtension(kind, body) {
|
|
373
|
+
this.#bridge.publish({ hub: this.name, kind, body });
|
|
374
|
+
}
|
|
375
|
+
/** @internal */
|
|
376
|
+
_members(room, filter = NO_FILTER) {
|
|
377
|
+
return [...(this.#rooms.get(room) ?? [])].filter((s) => this.#matches(s, filter));
|
|
378
|
+
}
|
|
379
|
+
/** @internal */
|
|
380
|
+
_sendRoom(room, frame, filter, publish) {
|
|
381
|
+
if (publish) {
|
|
382
|
+
this.#bridge.publish({
|
|
383
|
+
hub: this.name,
|
|
384
|
+
kind: "room",
|
|
385
|
+
room,
|
|
386
|
+
payload: toWire(frame),
|
|
387
|
+
filter: toWireFilter(filter),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const members = this.#rooms.get(room);
|
|
391
|
+
if (members == null)
|
|
392
|
+
return 0;
|
|
393
|
+
// Fast path: no filter → one native publish per server.
|
|
394
|
+
if (isUnfiltered(filter)) {
|
|
395
|
+
const servers = new Set();
|
|
396
|
+
let delivered = 0;
|
|
397
|
+
for (const socket of members) {
|
|
398
|
+
if (socket.closed)
|
|
399
|
+
continue;
|
|
400
|
+
if (this._holdFrame(socket, room, frame)) {
|
|
401
|
+
delivered++;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const server = serverOf(socket);
|
|
405
|
+
if (server == null) {
|
|
406
|
+
if (isDelivered(socket.send(frame)))
|
|
407
|
+
delivered++;
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
servers.add(server);
|
|
411
|
+
delivered++;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const server of servers)
|
|
415
|
+
server.publish(this.#topic(room), frame);
|
|
416
|
+
return delivered;
|
|
417
|
+
}
|
|
418
|
+
let delivered = 0;
|
|
419
|
+
for (const socket of [...members]) {
|
|
420
|
+
if (!this.#matches(socket, filter))
|
|
421
|
+
continue;
|
|
422
|
+
if (this._holdFrame(socket, room, frame))
|
|
423
|
+
delivered++;
|
|
424
|
+
else if (isDelivered(socket.send(frame)))
|
|
425
|
+
delivered++;
|
|
426
|
+
}
|
|
427
|
+
return delivered;
|
|
428
|
+
}
|
|
429
|
+
/** @internal */
|
|
430
|
+
_kick(room, filter, reason, publish) {
|
|
431
|
+
if (publish) {
|
|
432
|
+
this.#bridge.publish({
|
|
433
|
+
hub: this.name,
|
|
434
|
+
kind: "kick",
|
|
435
|
+
room,
|
|
436
|
+
filter: toWireFilter(filter),
|
|
437
|
+
reason,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const targets = this._members(room, filter);
|
|
441
|
+
for (const socket of targets) {
|
|
442
|
+
const hook = this.#leaveHooks.get(socket)?.get(room);
|
|
443
|
+
if (hook != null)
|
|
444
|
+
hook(reason);
|
|
445
|
+
else
|
|
446
|
+
this.leave(socket, room);
|
|
447
|
+
}
|
|
448
|
+
return targets.length;
|
|
449
|
+
}
|
|
450
|
+
// ── Internals ───────────────────────────────────────────────────────
|
|
451
|
+
#matches(socket, filter) {
|
|
452
|
+
if (filter.exceptIds.has(socket.id))
|
|
453
|
+
return false;
|
|
454
|
+
const key = this.#keyOf.get(socket);
|
|
455
|
+
if (key != null && filter.exceptKeys.has(key))
|
|
456
|
+
return false;
|
|
457
|
+
if (filter.onlyKeys != null && (key == null || !filter.onlyKeys.has(key))) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
#topic(room) {
|
|
463
|
+
return `fx:${this.name}:r:${room}`;
|
|
464
|
+
}
|
|
465
|
+
#deliverKey(key, frame) {
|
|
466
|
+
let delivered = 0;
|
|
467
|
+
for (const socket of [...(this.#byKey.get(key) ?? [])]) {
|
|
468
|
+
if (isDelivered(socket.send(frame)))
|
|
469
|
+
delivered++;
|
|
470
|
+
}
|
|
471
|
+
return delivered;
|
|
472
|
+
}
|
|
473
|
+
#deliverAll(frame) {
|
|
474
|
+
let delivered = 0;
|
|
475
|
+
for (const socket of [...this.#keyOf.keys()]) {
|
|
476
|
+
if (isDelivered(socket.send(frame)))
|
|
477
|
+
delivered++;
|
|
478
|
+
}
|
|
479
|
+
return delivered;
|
|
480
|
+
}
|
|
481
|
+
#closeKey(key, code, reason) {
|
|
482
|
+
const sockets = [...(this.#byKey.get(key) ?? [])];
|
|
483
|
+
for (const socket of sockets)
|
|
484
|
+
socket.close(code, reason);
|
|
485
|
+
return sockets.length;
|
|
486
|
+
}
|
|
487
|
+
#onRemote(message) {
|
|
488
|
+
switch (message.kind) {
|
|
489
|
+
case "send": {
|
|
490
|
+
const frame = fromWire(message.payload);
|
|
491
|
+
if (message.key == null)
|
|
492
|
+
this.#deliverAll(frame);
|
|
493
|
+
else
|
|
494
|
+
this.#deliverKey(message.key, frame);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
case "room":
|
|
498
|
+
this._sendRoom(message.room, fromWire(message.payload), fromWireFilter(message.filter), false);
|
|
499
|
+
return;
|
|
500
|
+
case "close":
|
|
501
|
+
this.#closeKey(message.key, message.code, message.reason);
|
|
502
|
+
return;
|
|
503
|
+
case "kick":
|
|
504
|
+
this._kick(message.room, fromWireFilter(message.filter), message.reason, false);
|
|
505
|
+
return;
|
|
506
|
+
case "presence":
|
|
507
|
+
this.#extensions.get("presence")?.(message.body);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
#track(socket) {
|
|
512
|
+
if (this.#tracked.has(socket))
|
|
513
|
+
return;
|
|
514
|
+
this.#tracked.add(socket);
|
|
515
|
+
socket.onClose(() => {
|
|
516
|
+
this.#tracked.delete(socket);
|
|
517
|
+
this.#removeKey(socket);
|
|
518
|
+
for (const room of this.rooms(socket))
|
|
519
|
+
this.leave(socket, room);
|
|
520
|
+
this.#leaveHooks.delete(socket);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
#removeKey(socket) {
|
|
524
|
+
const key = this.#keyOf.get(socket);
|
|
525
|
+
if (key == null)
|
|
526
|
+
return;
|
|
527
|
+
this.#keyOf.delete(socket);
|
|
528
|
+
const set = this.#byKey.get(key);
|
|
529
|
+
set?.delete(socket);
|
|
530
|
+
if (set?.size === 0)
|
|
531
|
+
this.#byKey.delete(key);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
function serverOf(socket) {
|
|
535
|
+
return socket.raw.data?.server;
|
|
536
|
+
}
|
|
537
|
+
function isDelivered(result) {
|
|
538
|
+
return result === "sent" || result === "backpressure";
|
|
539
|
+
}
|
|
540
|
+
/** Text and binary frames go out as-is; everything else as JSON. */
|
|
541
|
+
export function encodeFrame(data) {
|
|
542
|
+
if (typeof data === "string")
|
|
543
|
+
return data;
|
|
544
|
+
if (data instanceof Uint8Array)
|
|
545
|
+
return data;
|
|
546
|
+
if (data instanceof ArrayBuffer)
|
|
547
|
+
return new Uint8Array(data);
|
|
548
|
+
if (ArrayBuffer.isView(data)) {
|
|
549
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
550
|
+
}
|
|
551
|
+
return JSON.stringify(data);
|
|
552
|
+
}
|
|
553
|
+
function toWire(frame) {
|
|
554
|
+
return typeof frame === "string"
|
|
555
|
+
? { text: frame }
|
|
556
|
+
: { base64: Buffer.from(frame).toString("base64") };
|
|
557
|
+
}
|
|
558
|
+
function fromWire(payload) {
|
|
559
|
+
if (payload.text != null)
|
|
560
|
+
return payload.text;
|
|
561
|
+
return new Uint8Array(Buffer.from(payload.base64 ?? "", "base64"));
|
|
562
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSockets for `@fishenv/http`.
|
|
3
|
+
*
|
|
4
|
+
* A ws route is an ordinary `GET` route: origin checks, middleware (auth),
|
|
5
|
+
* path-param validation and connection limits all run *before* the upgrade,
|
|
6
|
+
* so a rejected handshake is a normal HTTP error and no socket is opened.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const api = withWs(r({ prefix: "/api" }).use(auth));
|
|
10
|
+
*
|
|
11
|
+
* api.ws("/echo/:room")
|
|
12
|
+
* .maxMessageSize(16 * 1024)
|
|
13
|
+
* .rateLimit({ messages: 20, perMs: 1000 })
|
|
14
|
+
* .handle(({ socket, path }) => {
|
|
15
|
+
* socket.onMessage((msg) => socket.send(`[${path.room}] ${msg.text()}`));
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Module layout:
|
|
20
|
+
* - `types.ts` — shared types, close codes, error → close mapping
|
|
21
|
+
* - `message.ts` — `WsMessage`, a lazily decoded inbound message
|
|
22
|
+
* - `connection.ts` — `WsConnection`, a single live socket
|
|
23
|
+
* - `lifecycle.ts` — per-socket buffering, limits and error handling
|
|
24
|
+
* - `origin.ts` — CSWSH origin policy
|
|
25
|
+
* - `limits.ts` — rate limiting and connection counting
|
|
26
|
+
* - `helpers.ts` — handshake helpers
|
|
27
|
+
* - `hub.ts` — `WsHub`, keyed registry + server-side rooms
|
|
28
|
+
* - `presence.ts` — replicated presence (`hub.presence(topic)`)
|
|
29
|
+
* - `tickets.ts` — `WsTickets`, single-use handshake tickets
|
|
30
|
+
* - `typed.ts` — `TypedWsConnection` for `c.ws().messages()` contracts
|
|
31
|
+
* - `route-builder.ts` — `WsRouteBuilder`
|
|
32
|
+
* - `channels/` — the opt-in channel protocol: `channel()`, `channels()`,
|
|
33
|
+
* wire format, `hub.channel(name)` targets
|
|
34
|
+
* - `router.ts` — `withWs()` mixin
|
|
35
|
+
*/
|
|
36
|
+
export type { AnyChannelImpl, AnyEventFn, ChannelContext, ChannelFactory, ChannelHandlerArgs, ChannelImplDefinition, ChannelPresence, ClientEventName, EventFn, JoinFn, LeaveFn, LeaveReason, PresenceFn, ServerEventName, ServerEventPayload, } from "./channels/channel.ts";
|
|
37
|
+
export { ChannelImpl, channel } from "./channels/channel.ts";
|
|
38
|
+
export type { ChannelsOptions } from "./channels/handler.ts";
|
|
39
|
+
export { channels } from "./channels/handler.ts";
|
|
40
|
+
export type { ClientFrame, PresenceEntries, ServerFrame, WireError, } from "./channels/protocol.ts";
|
|
41
|
+
export { CHANNELS_PROTOCOL, FrameError, parseClientFrame, } from "./channels/protocol.ts";
|
|
42
|
+
export { channelRoom, WsChannelTarget } from "./channels/target.ts";
|
|
43
|
+
export type { WsCloseListener, WsMessageListener } from "./connection.ts";
|
|
44
|
+
export { WsConnection } from "./connection.ts";
|
|
45
|
+
export { isWebSocketUpgrade, negotiateProtocol, requestedProtocols, UpgradeRequiredError, } from "./helpers.ts";
|
|
46
|
+
export type { RoomFilter, WireFilter, WirePayload, WsHubOptions, WsHubPayload, WsTransportMessage, } from "./hub.ts";
|
|
47
|
+
export { encodeFrame, WsHub, WsRoomTarget } from "./hub.ts";
|
|
48
|
+
export type { LifecycleOptions, WsSetupFn } from "./lifecycle.ts";
|
|
49
|
+
export { createLifecycle } from "./lifecycle.ts";
|
|
50
|
+
export type { WsConnectionLimit } from "./limits.ts";
|
|
51
|
+
export { ConnectionCounter, TokenBucket } from "./limits.ts";
|
|
52
|
+
export { WsMessage } from "./message.ts";
|
|
53
|
+
export type { OriginPolicy, OriginPredicate, OriginSource } from "./origin.ts";
|
|
54
|
+
export { createOriginPolicy, originSourcesFromCors } from "./origin.ts";
|
|
55
|
+
export type { PresenceDiff, PresenceEntry, PresenceOptions, PresenceState, } from "./presence.ts";
|
|
56
|
+
export { PresenceLimitError, PresenceRegistry, PresenceTopic, } from "./presence.ts";
|
|
57
|
+
export type { ChannelCoverage, CompleteChannelImpl, WsHandlerArgs, WsHandlerFn, WsProtocolHandler, WsRouteInfo, WsRouteOptions, WsRouterOptions, } from "./route-builder.ts";
|
|
58
|
+
export { WsRouteBuilder } from "./route-builder.ts";
|
|
59
|
+
export type { SocketOf, WsRouter } from "./router.ts";
|
|
60
|
+
export { withWs } from "./router.ts";
|
|
61
|
+
export type { TicketStore, WsTicketsOptions } from "./tickets.ts";
|
|
62
|
+
export { bearerFromProtocol, MemoryTicketStore, WsTickets } from "./tickets.ts";
|
|
63
|
+
export { ContractViolationError, checkOutbound, parseInbound, TypedWsConnection, } from "./typed.ts";
|
|
64
|
+
export type { WsCleanupFn, WsConnectionOptions, WsRateLimit, WsSendable, WsSendResult, } from "./types.ts";
|
|
65
|
+
export { closeFrameFor, MAX_CLOSE_REASON_BYTES, truncateReason, WsCloseCode, WsCloseError, WsProtocolError, } from "./types.ts";
|
package/dist/ws/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSockets for `@fishenv/http`.
|
|
3
|
+
*
|
|
4
|
+
* A ws route is an ordinary `GET` route: origin checks, middleware (auth),
|
|
5
|
+
* path-param validation and connection limits all run *before* the upgrade,
|
|
6
|
+
* so a rejected handshake is a normal HTTP error and no socket is opened.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const api = withWs(r({ prefix: "/api" }).use(auth));
|
|
10
|
+
*
|
|
11
|
+
* api.ws("/echo/:room")
|
|
12
|
+
* .maxMessageSize(16 * 1024)
|
|
13
|
+
* .rateLimit({ messages: 20, perMs: 1000 })
|
|
14
|
+
* .handle(({ socket, path }) => {
|
|
15
|
+
* socket.onMessage((msg) => socket.send(`[${path.room}] ${msg.text()}`));
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Module layout:
|
|
20
|
+
* - `types.ts` — shared types, close codes, error → close mapping
|
|
21
|
+
* - `message.ts` — `WsMessage`, a lazily decoded inbound message
|
|
22
|
+
* - `connection.ts` — `WsConnection`, a single live socket
|
|
23
|
+
* - `lifecycle.ts` — per-socket buffering, limits and error handling
|
|
24
|
+
* - `origin.ts` — CSWSH origin policy
|
|
25
|
+
* - `limits.ts` — rate limiting and connection counting
|
|
26
|
+
* - `helpers.ts` — handshake helpers
|
|
27
|
+
* - `hub.ts` — `WsHub`, keyed registry + server-side rooms
|
|
28
|
+
* - `presence.ts` — replicated presence (`hub.presence(topic)`)
|
|
29
|
+
* - `tickets.ts` — `WsTickets`, single-use handshake tickets
|
|
30
|
+
* - `typed.ts` — `TypedWsConnection` for `c.ws().messages()` contracts
|
|
31
|
+
* - `route-builder.ts` — `WsRouteBuilder`
|
|
32
|
+
* - `channels/` — the opt-in channel protocol: `channel()`, `channels()`,
|
|
33
|
+
* wire format, `hub.channel(name)` targets
|
|
34
|
+
* - `router.ts` — `withWs()` mixin
|
|
35
|
+
*/
|
|
36
|
+
// Channels (Layer 3)
|
|
37
|
+
export { ChannelImpl, channel } from "./channels/channel.js";
|
|
38
|
+
export { channels } from "./channels/handler.js";
|
|
39
|
+
export { CHANNELS_PROTOCOL, FrameError, parseClientFrame, } from "./channels/protocol.js";
|
|
40
|
+
export { channelRoom, WsChannelTarget } from "./channels/target.js";
|
|
41
|
+
// Connection
|
|
42
|
+
export { WsConnection } from "./connection.js";
|
|
43
|
+
// Handshake helpers
|
|
44
|
+
export { isWebSocketUpgrade, negotiateProtocol, requestedProtocols, UpgradeRequiredError, } from "./helpers.js";
|
|
45
|
+
// Hub
|
|
46
|
+
export { encodeFrame, WsHub, WsRoomTarget } from "./hub.js";
|
|
47
|
+
// Lifecycle (for custom routes built on core's requestUpgrade)
|
|
48
|
+
export { createLifecycle } from "./lifecycle.js";
|
|
49
|
+
// Limits
|
|
50
|
+
export { ConnectionCounter, TokenBucket } from "./limits.js";
|
|
51
|
+
// Messages
|
|
52
|
+
export { WsMessage } from "./message.js";
|
|
53
|
+
// Origin policy
|
|
54
|
+
export { createOriginPolicy, originSourcesFromCors } from "./origin.js";
|
|
55
|
+
// Presence
|
|
56
|
+
export { PresenceLimitError, PresenceRegistry, PresenceTopic, } from "./presence.js";
|
|
57
|
+
// Route builder
|
|
58
|
+
export { WsRouteBuilder } from "./route-builder.js";
|
|
59
|
+
// Router mixin
|
|
60
|
+
export { withWs } from "./router.js";
|
|
61
|
+
// Tickets
|
|
62
|
+
export { bearerFromProtocol, MemoryTicketStore, WsTickets } from "./tickets.js";
|
|
63
|
+
// Contract-typed sockets
|
|
64
|
+
export { ContractViolationError, checkOutbound, parseInbound, TypedWsConnection, } from "./typed.js";
|
|
65
|
+
// Types & close codes
|
|
66
|
+
export { closeFrameFor, MAX_CLOSE_REASON_BYTES, truncateReason, WsCloseCode, WsCloseError, WsProtocolError, } from "./types.js";
|