@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.
Files changed (78) hide show
  1. package/dist/contract/builder.d.ts +9 -4
  2. package/dist/contract/builder.js +2 -0
  3. package/dist/contract/index.d.ts +3 -1
  4. package/dist/contract/index.js +1 -0
  5. package/dist/contract/types.d.ts +9 -1
  6. package/dist/contract/ws.d.ts +178 -0
  7. package/dist/contract/ws.js +164 -0
  8. package/dist/core/compose.js +5 -0
  9. package/dist/core/error.d.ts +21 -0
  10. package/dist/core/error.js +50 -7
  11. package/dist/core/index.d.ts +7 -3
  12. package/dist/core/index.js +5 -1
  13. package/dist/core/matcher.js +2 -1
  14. package/dist/core/route-builder.d.ts +4 -4
  15. package/dist/core/router.d.ts +16 -2
  16. package/dist/core/router.js +45 -6
  17. package/dist/core/transport.d.ts +39 -0
  18. package/dist/core/transport.js +68 -0
  19. package/dist/core/types.d.ts +22 -3
  20. package/dist/core/upgrade.d.ts +85 -0
  21. package/dist/core/upgrade.js +110 -0
  22. package/dist/core/utils/url.d.ts +4 -1
  23. package/dist/core/utils/url.js +14 -3
  24. package/dist/sse/hub.js +15 -33
  25. package/dist/sse/types.d.ts +3 -5
  26. package/dist/static/index.d.ts +27 -0
  27. package/dist/static/index.js +25 -0
  28. package/dist/static/negotiate.d.ts +23 -0
  29. package/dist/static/negotiate.js +85 -0
  30. package/dist/static/options.d.ts +44 -0
  31. package/dist/static/options.js +190 -0
  32. package/dist/static/resolve.d.ts +56 -0
  33. package/dist/static/resolve.js +113 -0
  34. package/dist/static/response.d.ts +53 -0
  35. package/dist/static/response.js +146 -0
  36. package/dist/static/router.d.ts +59 -0
  37. package/dist/static/router.js +217 -0
  38. package/dist/static/rules.d.ts +21 -0
  39. package/dist/static/rules.js +49 -0
  40. package/dist/static/types.d.ts +76 -0
  41. package/dist/static/types.js +0 -0
  42. package/dist/ws/channels/channel.d.ts +175 -0
  43. package/dist/ws/channels/channel.js +125 -0
  44. package/dist/ws/channels/handler.d.ts +44 -0
  45. package/dist/ws/channels/handler.js +517 -0
  46. package/dist/ws/channels/protocol.d.ts +96 -0
  47. package/dist/ws/channels/protocol.js +94 -0
  48. package/dist/ws/channels/target.d.ts +36 -0
  49. package/dist/ws/channels/target.js +56 -0
  50. package/dist/ws/connection.d.ts +56 -0
  51. package/dist/ws/connection.js +172 -0
  52. package/dist/ws/helpers.d.ts +17 -0
  53. package/dist/ws/helpers.js +48 -0
  54. package/dist/ws/hub.d.ts +190 -0
  55. package/dist/ws/hub.js +562 -0
  56. package/dist/ws/index.d.ts +65 -0
  57. package/dist/ws/index.js +66 -0
  58. package/dist/ws/lifecycle.d.ts +21 -0
  59. package/dist/ws/lifecycle.js +97 -0
  60. package/dist/ws/limits.d.ts +32 -0
  61. package/dist/ws/limits.js +67 -0
  62. package/dist/ws/message.d.ts +19 -0
  63. package/dist/ws/message.js +58 -0
  64. package/dist/ws/origin.d.ts +40 -0
  65. package/dist/ws/origin.js +152 -0
  66. package/dist/ws/presence.d.ts +96 -0
  67. package/dist/ws/presence.js +420 -0
  68. package/dist/ws/route-builder.d.ts +136 -0
  69. package/dist/ws/route-builder.js +265 -0
  70. package/dist/ws/router.d.ts +21 -0
  71. package/dist/ws/router.js +36 -0
  72. package/dist/ws/tickets.d.ts +82 -0
  73. package/dist/ws/tickets.js +161 -0
  74. package/dist/ws/typed.d.ts +54 -0
  75. package/dist/ws/typed.js +104 -0
  76. package/dist/ws/types.d.ts +69 -0
  77. package/dist/ws/types.js +70 -0
  78. 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";
@@ -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";