@znight/websocket 0.0.0-stage → 0.6.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -2
  3. package/dist/bun.d.mts +4 -0
  4. package/dist/bun.mjs +112 -0
  5. package/dist/bun.mjs.map +1 -0
  6. package/dist/compiler.d.mts +90 -0
  7. package/dist/compiler.mjs +864 -0
  8. package/dist/compiler.mjs.map +1 -0
  9. package/dist/index.d.mts +501 -0
  10. package/dist/index.mjs +2088 -0
  11. package/dist/index.mjs.map +1 -0
  12. package/dist/server-CI_Bh9o1.d.mts +331 -0
  13. package/dist/server-D40_bC88.mjs +72 -0
  14. package/dist/server-D40_bC88.mjs.map +1 -0
  15. package/dist/testing.d.mts +60 -0
  16. package/dist/testing.mjs +189 -0
  17. package/dist/testing.mjs.map +1 -0
  18. package/dist/transport-xnJqvB93.d.mts +63 -0
  19. package/dist/ws.d.mts +9 -0
  20. package/dist/ws.mjs +143 -0
  21. package/dist/ws.mjs.map +1 -0
  22. package/package.json +100 -4
  23. package/src/client.ts +246 -0
  24. package/src/compiler/analyze.ts +850 -0
  25. package/src/compiler/constants.ts +102 -0
  26. package/src/compiler/data.ts +78 -0
  27. package/src/compiler/generate.ts +262 -0
  28. package/src/compiler/index.ts +53 -0
  29. package/src/compiler/validate.ts +81 -0
  30. package/src/connection.ts +455 -0
  31. package/src/context.ts +43 -0
  32. package/src/decorators.ts +83 -0
  33. package/src/dispatcher.ts +343 -0
  34. package/src/envelope.ts +136 -0
  35. package/src/exceptions.ts +91 -0
  36. package/src/index.ts +89 -0
  37. package/src/metadata.ts +74 -0
  38. package/src/options.ts +343 -0
  39. package/src/plugin.ts +820 -0
  40. package/src/rate-limit.ts +27 -0
  41. package/src/rooms.ts +185 -0
  42. package/src/server.ts +125 -0
  43. package/src/testing/index.ts +279 -0
  44. package/src/tokens.ts +23 -0
  45. package/src/transport.ts +72 -0
  46. package/src/transports/bun.ts +163 -0
  47. package/src/transports/memory.ts +174 -0
  48. package/src/transports/ws.ts +237 -0
  49. package/src/validate.ts +31 -0
@@ -0,0 +1,331 @@
1
+ import { a as TransportSocket, n as SendStatus, r as SocketEvents } from "./transport-xnJqvB93.mjs";
2
+ import { HttpContext } from "@znight/http";
3
+ /** The token bucket of `@Gateway({ rateLimit })` (RFC 0003 §8.6), one per connection. */
4
+ declare class TokenBucket {
5
+ #private;
6
+ /** `messages` per `intervalMs`, refilled continuously; full at first. */
7
+ constructor(messages: number, intervalMs: number, now?: number);
8
+ /** Takes one token: `false` when the bucket is empty. */
9
+ take(now?: number): boolean;
10
+ }
11
+ /** Event names: `^[A-Za-z][\w.:/-]{0,127}$`; names starting with `$` are reserved. */
12
+ declare const EVENT_NAME: RegExp;
13
+ /** A message id: a string of at most 64 characters or a safe integer, chosen by the client. */
14
+ type MessageId = string | number;
15
+ /** A decoded inbound message. */
16
+ interface InboundMessage {
17
+ readonly event: string;
18
+ readonly data: unknown;
19
+ /** Present when the client expects exactly one reply. */
20
+ readonly id: MessageId | undefined;
21
+ }
22
+ /** A frame that could not be decoded: answered with `$error` (or a reply when the id was readable). */
23
+ interface InboundFailure {
24
+ readonly error: string;
25
+ readonly message: string;
26
+ readonly id: MessageId | undefined;
27
+ }
28
+ /** The wire error of a reply or of an `$error` event. */
29
+ interface WireError {
30
+ readonly code: string;
31
+ readonly message: string;
32
+ readonly details?: unknown;
33
+ }
34
+ /** Whether `value` is a valid message id. */
35
+ declare function isMessageId(value: unknown): value is MessageId;
36
+ /**
37
+ * Decodes an inbound frame of an `events` gateway with the JSON codec: a binary frame is
38
+ * `UNSUPPORTED_FRAME`, anything but a JSON object with a string `event` is `BAD_FRAME`. Unknown
39
+ * fields are ignored.
40
+ */
41
+ declare function decodeFrame(frame: string | Uint8Array): InboundMessage | InboundFailure;
42
+ /** Whether a decoded frame is a failure. */
43
+ declare function isFailure(decoded: InboundMessage | InboundFailure): decoded is InboundFailure;
44
+ /** A success reply: `{ id, data }`, `data` omitted when `undefined`. */
45
+ declare function encodeReply(id: MessageId, data: unknown): string;
46
+ /** A failure reply: `{ id, error }`. */
47
+ declare function encodeErrorReply(id: MessageId, error: WireError): string;
48
+ /** A server event: `{ event, data }`. */
49
+ declare function encodeEvent(event: string, data: unknown): string;
50
+ /** The failure of a message without id: `{ event: '$error', data: { event, code, message, details? } }`. */
51
+ declare function encodeErrorEvent(event: string | undefined, error: WireError): string;
52
+ /** An outbound frame of an `events` gateway, before encoding. */
53
+ type OutboundMessage = {
54
+ readonly kind: "event";
55
+ readonly event: string;
56
+ readonly data: unknown;
57
+ } | {
58
+ readonly kind: "reply";
59
+ readonly id: MessageId;
60
+ readonly data: unknown;
61
+ } | {
62
+ readonly kind: "error";
63
+ readonly id: MessageId | undefined;
64
+ readonly event: string | undefined;
65
+ readonly error: WireError;
66
+ };
67
+ /**
68
+ * The codec of `events` gateways (`WebSocketPlugin` option `codec`): how frames are decoded into
69
+ * `{ event, data, id }` and how outbound messages are encoded. The default is {@link jsonCodec};
70
+ * a custom codec may carry MessagePack over binary frames, for instance.
71
+ */
72
+ interface WsCodec {
73
+ /** Decodes an inbound frame; a failure is answered with its code (`BAD_FRAME`...). */
74
+ decode(frame: string | Uint8Array, binary: boolean): InboundMessage | InboundFailure;
75
+ /** Encodes an outbound message: a string is a text frame, bytes a binary frame. */
76
+ encode(message: OutboundMessage): string | Uint8Array;
77
+ }
78
+ /** The default codec: UTF-8 JSON text frames (RFC 0003 §5.3); binary frames are `UNSUPPORTED_FRAME`. */
79
+ declare const jsonCodec: WsCodec;
80
+ /** What a room holds: a connection, seen through the operations rooms need. */
81
+ interface ChannelMember {
82
+ readonly id: string;
83
+ /** The rooms of the member, kept in sync by the channel. */
84
+ readonly roomSet: Set<string>;
85
+ /** Sends an already encoded frame (`string`: text, `Uint8Array`: binary). */
86
+ sendFrame(frame: string | Uint8Array): unknown;
87
+ close(code?: number, reason?: string): void;
88
+ }
89
+ /** A target of `except()`: a member or its id. */
90
+ type MemberRef<M extends ChannelMember = ChannelMember> = M | string;
91
+ /** A selection of members of a gateway (RFC 0003 §5.4). */
92
+ interface Broadcast<E extends object = Record<string, unknown>, M extends ChannelMember = ChannelMember> {
93
+ to(room: string | readonly string[]): Broadcast<E, M>;
94
+ except(target: MemberRef<M> | readonly MemberRef<M>[]): Broadcast<E, M>;
95
+ exceptRoom(room: string | readonly string[]): Broadcast<E, M>;
96
+ emit<K extends keyof E & string>(event: K, data: E[K]): void;
97
+ send(frame: string | Uint8Array): void;
98
+ join(room: string): void;
99
+ leave(room: string): void;
100
+ close(code?: number, reason?: string): void;
101
+ }
102
+ interface Selection {
103
+ /** `undefined`: every member of the gateway. */
104
+ readonly rooms: readonly string[] | undefined;
105
+ readonly except: ReadonlySet<string>;
106
+ readonly exceptRooms: readonly string[];
107
+ }
108
+ /** The members and rooms of one gateway. */
109
+ declare class RoomRegistry<M extends ChannelMember = ChannelMember> {
110
+ #private;
111
+ /** Encodes the events of broadcasts (once per broadcast). */
112
+ readonly codec: WsCodec;
113
+ constructor(codec?: WsCodec);
114
+ /** Open members. */
115
+ get size(): number;
116
+ add(member: M): void;
117
+ /** Removes the member and leaves its rooms. */
118
+ remove(member: M): void;
119
+ get(id: string): M | undefined;
120
+ members(): IterableIterator<M>;
121
+ join(member: M, room: string): void;
122
+ leave(member: M, room: string): void;
123
+ /** The members of `room` (a read-only view). */
124
+ room(room: string): ReadonlySet<M>;
125
+ /** Room → size. */
126
+ rooms(): ReadonlyMap<string, number>;
127
+ /** A broadcast over every member, or over the members of `rooms`. */
128
+ select<E extends object>(rooms?: string | readonly string[], except?: readonly string[]): Broadcast<E, M>;
129
+ /** @internal The members a selection designates, each once. */
130
+ resolve(selection: Selection): M[];
131
+ }
132
+ /** Who closed a connection. */
133
+ type WsCloseInitiator = "client" | "server" | "shutdown" | "heartbeat" | "backpressure" | "error";
134
+ /** How a connection closed. */
135
+ interface WsCloseInfo {
136
+ readonly code: number;
137
+ readonly reason: string;
138
+ readonly initiator: WsCloseInitiator;
139
+ }
140
+ /** The result of a send: written, buffered by the transport, or dropped. */
141
+ type WsSendResult = SendStatus;
142
+ /** `conn.drained()` rejects with it once the connection is closed. */
143
+ declare class WsClosedError extends Error {
144
+ constructor(message?: string);
145
+ }
146
+ /** A WebSocket connection, as handlers and services see it. */
147
+ interface WsConnection<TData extends object = Record<string, unknown>, E extends object = Record<string, unknown>> {
148
+ readonly id: string;
149
+ /** Path pattern of the gateway, prefix included (`/api/chat`). */
150
+ readonly gateway: string;
151
+ /** The request id of the handshake. */
152
+ readonly requestId: string;
153
+ /** The handshake, detached from its socket. */
154
+ readonly handshake: HttpContext;
155
+ readonly params: Readonly<Record<string, string>>;
156
+ /** The negotiated subprotocol, `''` when none. */
157
+ readonly protocol: string;
158
+ /** Per-connection state, an empty object at first. */
159
+ readonly data: TData;
160
+ readonly rooms: ReadonlySet<string>;
161
+ readonly readyState: "open" | "closing" | "closed";
162
+ readonly bufferedAmount: number;
163
+ /** Aborted when the connection starts closing. */
164
+ readonly signal: AbortSignal;
165
+ /** How the connection closed: set once the socket closed, before `@OnDisconnect`. */
166
+ readonly closeInfo: WsCloseInfo | undefined;
167
+ emit<K extends keyof E & string>(event: K, data: E[K]): WsSendResult;
168
+ send(frame: string | Uint8Array): WsSendResult;
169
+ join(room: string | readonly string[]): void;
170
+ leave(room: string): void;
171
+ /** The other members of `room`. */
172
+ to(room: string | readonly string[]): Broadcast<E>;
173
+ /** Resolves once the outbound buffer is below `limit / 4`; rejects with {@link WsClosedError} once closed. */
174
+ drained(): Promise<void>;
175
+ close(code?: number, reason?: string): void;
176
+ terminate(): void;
177
+ }
178
+ /** Limits of one connection, resolved from the gateway and the plugin options. */
179
+ interface ConnectionLimits {
180
+ readonly maxPayload: number;
181
+ readonly maxPendingMessages: number;
182
+ readonly concurrency: number;
183
+ readonly backpressureLimit: number;
184
+ readonly onBackpressure: "close" | "drop";
185
+ readonly closeTimeoutMs: number;
186
+ readonly rateLimit?: {
187
+ readonly bucket: TokenBucket;
188
+ readonly onLimit: "reject" | "close";
189
+ };
190
+ }
191
+ /** What the plugin does with a connection (the gateway semantics). */
192
+ interface ConnectionDriver {
193
+ /** Runs `@OnConnect`; a rejection closes the connection with the code it returns. */
194
+ connect(conn: WsConnectionImpl): Promise<void>;
195
+ /** Handles one inbound message (decode, guards, handler, reply). Never rejects. */
196
+ dispatch(conn: WsConnectionImpl, frame: string | Uint8Array, binary: boolean): Promise<void>;
197
+ /** A message refused by the rate limit with `onLimit: 'reject'`. */
198
+ rateLimited(conn: WsConnectionImpl, frame: string | Uint8Array, binary: boolean): void;
199
+ /** The close code of an `@OnConnect` failure. */
200
+ connectFailure(error: unknown): {
201
+ readonly code: number;
202
+ readonly reason: string;
203
+ };
204
+ /** Runs `@OnDisconnect` (errors handled by the driver). */
205
+ disconnect(conn: WsConnectionImpl): Promise<void>;
206
+ /** Once fully closed (rooms left): hooks, stats, scope release. */
207
+ closed(conn: WsConnectionImpl, info: WsCloseInfo): void;
208
+ }
209
+ /** Totals of the plugin (`WebSocketPlugin.stats()`), updated by the connections. */
210
+ interface WsStats {
211
+ /** Open connections. */
212
+ readonly connections: number;
213
+ readonly opened: number;
214
+ readonly closed: number;
215
+ readonly messagesIn: number;
216
+ readonly messagesOut: number;
217
+ readonly bytesIn: number;
218
+ readonly bytesOut: number;
219
+ /** Outbound frames dropped by a `drop` backpressure policy or refused by the transport. */
220
+ readonly dropped: number;
221
+ /** Connections closed with `1013` (slow consumer). */
222
+ readonly slowConsumerCloses: number;
223
+ }
224
+ /** The mutable counters behind {@link WsStats} (internal). */
225
+ declare class StatsCounter {
226
+ opened: number;
227
+ closed: number;
228
+ messagesIn: number;
229
+ messagesOut: number;
230
+ bytesIn: number;
231
+ bytesOut: number;
232
+ dropped: number;
233
+ slowConsumerCloses: number;
234
+ /** A frozen snapshot, with the number of open connections. */
235
+ snapshot(connections: number): WsStats;
236
+ }
237
+ /** What a connection is created with. */
238
+ interface ConnectionInit {
239
+ readonly id: string;
240
+ readonly gateway: string;
241
+ readonly handshake: HttpContext;
242
+ readonly protocol: string;
243
+ readonly limits: ConnectionLimits;
244
+ readonly rooms: RoomRegistry<WsConnectionImpl>;
245
+ readonly driver: ConnectionDriver;
246
+ /** Counters shared by the connections of a plugin. */
247
+ readonly stats?: StatsCounter;
248
+ }
249
+ /** The implementation of {@link WsConnection} (internal to the package). */
250
+ declare class WsConnectionImpl implements WsConnection, ChannelMember {
251
+ #private;
252
+ readonly id: string;
253
+ readonly gateway: string;
254
+ readonly handshake: HttpContext;
255
+ readonly protocol: string;
256
+ readonly data: Record<string, unknown>;
257
+ readonly roomSet: Set<string>;
258
+ constructor(init: ConnectionInit);
259
+ get requestId(): string;
260
+ get params(): Readonly<Record<string, string>>;
261
+ get rooms(): ReadonlySet<string>;
262
+ get readyState(): "open" | "closing" | "closed";
263
+ get bufferedAmount(): number;
264
+ get signal(): AbortSignal;
265
+ get closeInfo(): WsCloseInfo | undefined;
266
+ /** Resolves once the connection is fully closed. */
267
+ get finished(): Promise<WsCloseInfo>;
268
+ /** The socket events to give to the transport. */
269
+ socketEvents(): SocketEvents;
270
+ /** Binds the accepted socket, joins the gateway and runs `@OnConnect` as the first task. */
271
+ start(socket: TransportSocket): void;
272
+ emit(event: string, data: unknown): WsSendResult;
273
+ send(frame: string | Uint8Array): WsSendResult;
274
+ /** Sends an encoded frame through the backpressure policy. */
275
+ sendFrame(frame: string | Uint8Array): WsSendResult;
276
+ join(room: string | readonly string[]): void;
277
+ leave(room: string): void;
278
+ to(room: string | readonly string[]): Broadcast;
279
+ drained(): Promise<void>;
280
+ close(code?: number, reason?: string): void;
281
+ /** Closes with an initiator (shutdown, heartbeat...). */
282
+ closeWith(code: number, reason: string, initiator: WsCloseInitiator): void;
283
+ terminate(): void;
284
+ /** Terminates without close frame (heartbeat timeout, shutdown deadline...): local code `1006`. */
285
+ terminateWith(initiator: WsCloseInitiator): void;
286
+ /**
287
+ * One heartbeat tick (RFC 0003 §8.7): a connection not seen alive since the previous tick is
288
+ * terminated, the others are marked and pinged.
289
+ */
290
+ heartbeat(): void;
291
+ }
292
+ /** A gateway class or its declared path (`'/chat'`, without the HTTP prefix). */
293
+ type GatewayRef = (abstract new (...args: never[]) => object) | string;
294
+ /** The connections and rooms of one gateway (this process). */
295
+ interface GatewayChannel<E extends object = Record<string, unknown>> {
296
+ /** The gateway path, prefix included. */
297
+ readonly path: string;
298
+ readonly size: number;
299
+ connection(id: string): WsConnection | undefined;
300
+ connections(): IterableIterator<WsConnection>;
301
+ members(room: string): ReadonlySet<WsConnection>;
302
+ rooms(): ReadonlyMap<string, number>;
303
+ to(room: string | readonly string[]): Broadcast<E>;
304
+ except(target: MemberRef<WsConnectionImpl> | readonly MemberRef<WsConnectionImpl>[]): Broadcast<E>;
305
+ emit<K extends keyof E & string>(event: K, data: E[K]): void;
306
+ send(frame: string | Uint8Array): void;
307
+ }
308
+ /** One served gateway, as registered by the plugin. */
309
+ interface ServedGateway {
310
+ /** The gateway class. */
311
+ readonly type: abstract new (...args: never[]) => object;
312
+ /** The declared path, without the prefix. */
313
+ readonly declaredPath: string;
314
+ /** The path, prefix included. */
315
+ readonly path: string;
316
+ readonly rooms: RoomRegistry<WsConnectionImpl>;
317
+ }
318
+ /** The gateways of the application: injectable singleton provided by `WebSocketPlugin`. */
319
+ declare class WsServer {
320
+ #private;
321
+ /** @internal Called by the plugin at bootstrap. */
322
+ register(gateway: ServedGateway): void;
323
+ /** @internal Called by the plugin when it serves a new application. */
324
+ reset(): void;
325
+ /** Open connections of every gateway of this process. */
326
+ get connections(): number;
327
+ /** A lazy handle on a gateway: its class or its declared path. */
328
+ of<E extends object = Record<string, unknown>>(gateway: GatewayRef): GatewayChannel<E>;
329
+ }
330
+ export { TokenBucket as A, encodeErrorEvent as C, isFailure as D, encodeReply as E, isMessageId as O, decodeFrame as S, encodeEvent as T, InboundMessage as _, WsCloseInfo as a, WireError as b, WsConnection as c, Broadcast as d, ChannelMember as f, InboundFailure as g, EVENT_NAME as h, WsServer as i, jsonCodec as k, WsSendResult as l, RoomRegistry as m, GatewayRef as n, WsCloseInitiator as o, MemberRef as p, ServedGateway as r, WsClosedError as s, GatewayChannel as t, WsStats as u, MessageId as v, encodeErrorReply as w, WsCodec as x, OutboundMessage as y };
331
+ //# sourceMappingURL=server-CI_Bh9o1.d.mts.map
@@ -0,0 +1,72 @@
1
+ // src/server.ts
2
+ /** The gateways of the application: injectable singleton provided by `WebSocketPlugin`. */
3
+ var WsServer = class {
4
+ #byType = /* @__PURE__ */ new Map();
5
+ #byPath = /* @__PURE__ */ new Map();
6
+ /** @internal Called by the plugin at bootstrap. */
7
+ register(gateway) {
8
+ this.#byType.set(gateway.type, gateway);
9
+ this.#byPath.set(gateway.declaredPath, gateway);
10
+ }
11
+ /** @internal Called by the plugin when it serves a new application. */
12
+ reset() {
13
+ this.#byType.clear();
14
+ this.#byPath.clear();
15
+ }
16
+ /** Open connections of every gateway of this process. */
17
+ get connections() {
18
+ let total = 0;
19
+ for (const gateway of this.#byType.values()) total += gateway.rooms.size;
20
+ return total;
21
+ }
22
+ /** A lazy handle on a gateway: its class or its declared path. */
23
+ of(gateway) {
24
+ return new Channel(() => {
25
+ const served = typeof gateway === "string" ? this.#byPath.get(gateway) : this.#byType.get(gateway);
26
+ if (served === void 0) {
27
+ const name = typeof gateway === "string" ? `"${gateway}"` : gateway.name;
28
+ throw new TypeError(`WsServer.of(${name}): no gateway is served for it (or WebSocketPlugin is not bootstrapped)`);
29
+ }
30
+ return served;
31
+ });
32
+ }
33
+ };
34
+ var Channel = class {
35
+ #resolve;
36
+ constructor(resolve) {
37
+ this.#resolve = resolve;
38
+ }
39
+ get path() {
40
+ return this.#resolve().path;
41
+ }
42
+ get size() {
43
+ return this.#resolve().rooms.size;
44
+ }
45
+ connection(id) {
46
+ return this.#resolve().rooms.get(id);
47
+ }
48
+ connections() {
49
+ return this.#resolve().rooms.members();
50
+ }
51
+ members(room) {
52
+ return this.#resolve().rooms.room(room);
53
+ }
54
+ rooms() {
55
+ return this.#resolve().rooms.rooms();
56
+ }
57
+ to(room) {
58
+ return this.#resolve().rooms.select(room);
59
+ }
60
+ except(target) {
61
+ return this.#resolve().rooms.select().except(target);
62
+ }
63
+ emit(event, data) {
64
+ this.#resolve().rooms.select().emit(event, data);
65
+ }
66
+ send(frame) {
67
+ this.#resolve().rooms.select().send(frame);
68
+ }
69
+ };
70
+ export { WsServer as t };
71
+
72
+ //# sourceMappingURL=server-D40_bC88.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-D40_bC88.mjs","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * `WsServer` and `GatewayChannel` (RFC 0003 §5.4): the injectable view of the gateways, their\n * connections and rooms. `of()` is lazy: it never throws, a handle of an unknown gateway throws a\n * `TypeError` when used.\n */\nimport type { WsConnection, WsConnectionImpl } from './connection.ts'\nimport type { Broadcast, MemberRef, RoomRegistry } from './rooms.ts'\n\n/** A gateway class or its declared path (`'/chat'`, without the HTTP prefix). */\nexport type GatewayRef = (abstract new (...args: never[]) => object) | string\n\n/** The connections and rooms of one gateway (this process). */\nexport interface GatewayChannel<E extends object = Record<string, unknown>> {\n /** The gateway path, prefix included. */\n readonly path: string\n readonly size: number\n connection(id: string): WsConnection | undefined\n connections(): IterableIterator<WsConnection>\n members(room: string): ReadonlySet<WsConnection>\n rooms(): ReadonlyMap<string, number>\n to(room: string | readonly string[]): Broadcast<E>\n except(target: MemberRef<WsConnectionImpl> | readonly MemberRef<WsConnectionImpl>[]): Broadcast<E>\n emit<K extends keyof E & string>(event: K, data: E[K]): void\n send(frame: string | Uint8Array): void\n}\n\n/** One served gateway, as registered by the plugin. */\nexport interface ServedGateway {\n /** The gateway class. */\n readonly type: abstract new (\n ...args: never[]\n ) => object\n /** The declared path, without the prefix. */\n readonly declaredPath: string\n /** The path, prefix included. */\n readonly path: string\n readonly rooms: RoomRegistry<WsConnectionImpl>\n}\n\n/** The gateways of the application: injectable singleton provided by `WebSocketPlugin`. */\nexport class WsServer {\n readonly #byType = new Map<unknown, ServedGateway>()\n readonly #byPath = new Map<string, ServedGateway>()\n\n /** @internal Called by the plugin at bootstrap. */\n register(gateway: ServedGateway): void {\n this.#byType.set(gateway.type, gateway)\n this.#byPath.set(gateway.declaredPath, gateway)\n }\n\n /** @internal Called by the plugin when it serves a new application. */\n reset(): void {\n this.#byType.clear()\n this.#byPath.clear()\n }\n\n /** Open connections of every gateway of this process. */\n get connections(): number {\n let total = 0\n for (const gateway of this.#byType.values()) total += gateway.rooms.size\n return total\n }\n\n /** A lazy handle on a gateway: its class or its declared path. */\n of<E extends object = Record<string, unknown>>(gateway: GatewayRef): GatewayChannel<E> {\n return new Channel<E>(() => {\n const served = typeof gateway === 'string' ? this.#byPath.get(gateway) : this.#byType.get(gateway)\n if (served === undefined) {\n const name = typeof gateway === 'string' ? `\"${gateway}\"` : gateway.name\n throw new TypeError(\n `WsServer.of(${name}): no gateway is served for it (or WebSocketPlugin is not bootstrapped)`,\n )\n }\n return served\n })\n }\n}\n\nclass Channel<E extends object> implements GatewayChannel<E> {\n readonly #resolve: () => ServedGateway\n\n constructor(resolve: () => ServedGateway) {\n this.#resolve = resolve\n }\n\n get path(): string {\n return this.#resolve().path\n }\n\n get size(): number {\n return this.#resolve().rooms.size\n }\n\n connection(id: string): WsConnection | undefined {\n return this.#resolve().rooms.get(id)\n }\n\n connections(): IterableIterator<WsConnection> {\n return this.#resolve().rooms.members()\n }\n\n members(room: string): ReadonlySet<WsConnection> {\n return this.#resolve().rooms.room(room)\n }\n\n rooms(): ReadonlyMap<string, number> {\n return this.#resolve().rooms.rooms()\n }\n\n to(room: string | readonly string[]): Broadcast<E> {\n return this.#resolve().rooms.select<E>(room)\n }\n\n except(target: MemberRef<WsConnectionImpl> | readonly MemberRef<WsConnectionImpl>[]): Broadcast<E> {\n return this.#resolve().rooms.select<E>().except(target)\n }\n\n emit<K extends keyof E & string>(event: K, data: E[K]): void {\n this.#resolve().rooms.select<E>().emit(event, data)\n }\n\n send(frame: string | Uint8Array): void {\n this.#resolve().rooms.select<E>().send(frame)\n }\n}\n"],"mappings":";;AAwCA,IAAa,WAAb,MAAsB;CACpB,0BAAmB,IAAI,IAA4B;CACnD,0BAAmB,IAAI,IAA2B;;CAGlD,SAAS,SAA8B;EACrC,KAAK,QAAQ,IAAI,QAAQ,MAAM,OAAO;EACtC,KAAK,QAAQ,IAAI,QAAQ,cAAc,OAAO;CAChD;;CAGA,QAAc;EACZ,KAAK,QAAQ,MAAM;EACnB,KAAK,QAAQ,MAAM;CACrB;;CAGA,IAAI,cAAsB;EACxB,IAAI,QAAQ;EACZ,KAAK,MAAM,WAAW,KAAK,QAAQ,OAAO,GAAG,SAAS,QAAQ,MAAM;EACpE,OAAO;CACT;;CAGA,GAA+C,SAAwC;EACrF,OAAO,IAAI,cAAiB;GAC1B,MAAM,SAAS,OAAO,YAAY,WAAW,KAAK,QAAQ,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,OAAO;GACjG,IAAI,WAAW,KAAA,GAAW;IACxB,MAAM,OAAO,OAAO,YAAY,WAAW,IAAI,QAAQ,KAAK,QAAQ;IACpE,MAAM,IAAI,UACR,eAAe,KAAK,wEACtB;GACF;GACA,OAAO;EACT,CAAC;CACH;AACF;AAEA,IAAM,UAAN,MAA6D;CAC3D;CAEA,YAAY,SAA8B;EACxC,KAAK,WAAW;CAClB;CAEA,IAAI,OAAe;EACjB,OAAO,KAAK,SAAS,CAAC,CAAC;CACzB;CAEA,IAAI,OAAe;EACjB,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM;CAC/B;CAEA,WAAW,IAAsC;EAC/C,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,IAAI,EAAE;CACrC;CAEA,cAA8C;EAC5C,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,QAAQ;CACvC;CAEA,QAAQ,MAAyC;EAC/C,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,KAAK,IAAI;CACxC;CAEA,QAAqC;EACnC,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,MAAM;CACrC;CAEA,GAAG,MAAgD;EACjD,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,OAAU,IAAI;CAC7C;CAEA,OAAO,QAA4F;EACjG,OAAO,KAAK,SAAS,CAAC,CAAC,MAAM,OAAU,CAAC,CAAC,OAAO,MAAM;CACxD;CAEA,KAAiC,OAAU,MAAkB;EAC3D,KAAK,SAAS,CAAC,CAAC,MAAM,OAAU,CAAC,CAAC,KAAK,OAAO,IAAI;CACpD;CAEA,KAAK,OAAkC;EACrC,KAAK,SAAS,CAAC,CAAC,MAAM,OAAU,CAAC,CAAC,KAAK,KAAK;CAC9C;AACF"}
@@ -0,0 +1,60 @@
1
+ import { c as WsConnection, i as WsServer } from "./server-CI_Bh9o1.mjs";
2
+ import { HttpContext } from "@znight/http";
3
+ /** One recorded broadcast (or a `close`, `join` or `leave` of a selection). */
4
+ export interface RecordedBroadcast {
5
+ /** The gateway path, or the class name when no path was given for it. */
6
+ readonly gateway: string;
7
+ /** Selected rooms, `[]` for every connection of the gateway. */
8
+ readonly rooms: readonly string[];
9
+ /** Excluded connection ids. */
10
+ readonly except: readonly string[];
11
+ /** Excluded rooms (omitted when none). */
12
+ readonly exceptRooms?: readonly string[];
13
+ /** The emitted event, `undefined` for `send()` and the selection operations. */
14
+ readonly event?: string;
15
+ readonly data?: unknown;
16
+ /** A raw frame (`send()`). */
17
+ readonly frame?: string | Uint8Array;
18
+ /** `join`, `leave` or `close` of the selection. */
19
+ readonly action?: "join" | "leave" | "close";
20
+ readonly room?: string;
21
+ }
22
+ /** Options of {@link createTestConnection}. */
23
+ export interface TestConnectionOptions<TData extends object> {
24
+ readonly id?: string;
25
+ /** Gateway path. Default: `'/'`. */
26
+ readonly gateway?: string;
27
+ readonly data?: TData;
28
+ readonly rooms?: readonly string[];
29
+ readonly params?: Readonly<Record<string, string>>;
30
+ readonly protocol?: string;
31
+ readonly requestId?: string;
32
+ /** The handshake context. Default: a minimal object with `params`, `query`, `headers` and `state`. */
33
+ readonly handshake?: Partial<HttpContext>;
34
+ }
35
+ /** A fake connection: a `WsConnection` with the record of what it was sent. */
36
+ export interface TestConnection<TData extends object = Record<string, unknown>, E extends object = Record<string, unknown>> extends WsConnection<TData, E> {
37
+ /** Events emitted to this connection only (`conn.emit`), in order. */
38
+ readonly emitted: {
39
+ readonly event: string;
40
+ readonly data: unknown;
41
+ }[];
42
+ /** Raw frames sent to this connection (`conn.send`). */
43
+ readonly sent: (string | Uint8Array)[];
44
+ /** Broadcasts made through `conn.to()` (the connection itself is in `except`). */
45
+ readonly broadcasts: RecordedBroadcast[];
46
+ }
47
+ /** A fake server: a `WsServer` recording broadcasts. */
48
+ export interface TestServer extends WsServer {
49
+ readonly broadcasts: RecordedBroadcast[];
50
+ }
51
+ /** Creates a fake connection for unit tests. */
52
+ export declare function createTestConnection<TData extends object = Record<string, unknown>, E extends object = Record<string, unknown>>(options?: TestConnectionOptions<TData>): TestConnection<TData, E>;
53
+ /** Options of {@link createTestServer}. */
54
+ export interface TestServerOptions {
55
+ /** Paths of gateway classes, for the `gateway` of recorded broadcasts (`[[ChatGateway, '/chat']]`). */
56
+ readonly paths?: readonly (readonly [abstract new (...args: never[]) => object, string])[];
57
+ }
58
+ /** Creates a fake `WsServer` recording broadcasts (no connection, every room empty). */
59
+ export declare function createTestServer(options?: TestServerOptions): TestServer;
60
+ //# sourceMappingURL=testing.d.mts.map
@@ -0,0 +1,189 @@
1
+ import { t as WsServer } from "./server-D40_bC88.mjs";
2
+ // src/testing/index.ts
3
+ /** A selection recording what is done with it. */
4
+ var RecordingBroadcast = class RecordingBroadcast {
5
+ #gateway;
6
+ #rooms;
7
+ #except;
8
+ #exceptRooms;
9
+ #record;
10
+ constructor(gateway, rooms, except, exceptRooms, record) {
11
+ this.#gateway = gateway;
12
+ this.#rooms = rooms;
13
+ this.#except = except;
14
+ this.#exceptRooms = exceptRooms;
15
+ this.#record = record;
16
+ }
17
+ to(room) {
18
+ return new RecordingBroadcast(this.#gateway, [...this.#rooms, ...list(room)], this.#except, this.#exceptRooms, this.#record);
19
+ }
20
+ except(target) {
21
+ const ids = (Array.isArray(target) ? target : [target]).map((ref) => typeof ref === "string" ? ref : ref.id);
22
+ return new RecordingBroadcast(this.#gateway, this.#rooms, [...this.#except, ...ids], this.#exceptRooms, this.#record);
23
+ }
24
+ exceptRoom(room) {
25
+ return new RecordingBroadcast(this.#gateway, this.#rooms, this.#except, [...this.#exceptRooms, ...list(room)], this.#record);
26
+ }
27
+ emit(event, data) {
28
+ this.#record({
29
+ ...this.#base(),
30
+ event,
31
+ data
32
+ });
33
+ }
34
+ send(frame) {
35
+ this.#record({
36
+ ...this.#base(),
37
+ frame
38
+ });
39
+ }
40
+ join(room) {
41
+ this.#record({
42
+ ...this.#base(),
43
+ action: "join",
44
+ room
45
+ });
46
+ }
47
+ leave(room) {
48
+ this.#record({
49
+ ...this.#base(),
50
+ action: "leave",
51
+ room
52
+ });
53
+ }
54
+ close() {
55
+ this.#record({
56
+ ...this.#base(),
57
+ action: "close"
58
+ });
59
+ }
60
+ #base() {
61
+ return {
62
+ gateway: this.#gateway,
63
+ rooms: [...this.#rooms],
64
+ except: [...this.#except],
65
+ ...this.#exceptRooms.length === 0 ? {} : { exceptRooms: [...this.#exceptRooms] }
66
+ };
67
+ }
68
+ };
69
+ function list(value) {
70
+ return typeof value === "string" ? [value] : value;
71
+ }
72
+ /** Creates a fake connection for unit tests. */
73
+ function createTestConnection(options = {}) {
74
+ const id = options.id ?? crypto.randomUUID();
75
+ const gateway = options.gateway ?? "/";
76
+ const rooms = new Set(options.rooms ?? []);
77
+ const abort = new AbortController();
78
+ const params = options.params ?? {};
79
+ const handshake = {
80
+ params,
81
+ query: {},
82
+ headers: {},
83
+ state: /* @__PURE__ */ new Map(),
84
+ requestId: options.requestId ?? "test-request",
85
+ ...options.handshake
86
+ };
87
+ let state = "open";
88
+ let closeInfo;
89
+ const emitted = [];
90
+ const sent = [];
91
+ const broadcasts = [];
92
+ const result = () => state === "open" ? "sent" : "dropped";
93
+ return {
94
+ id,
95
+ gateway,
96
+ requestId: options.requestId ?? "test-request",
97
+ handshake,
98
+ params,
99
+ protocol: options.protocol ?? "",
100
+ data: options.data ?? {},
101
+ rooms,
102
+ get readyState() {
103
+ return state;
104
+ },
105
+ bufferedAmount: 0,
106
+ signal: abort.signal,
107
+ get closeInfo() {
108
+ return closeInfo;
109
+ },
110
+ emitted,
111
+ sent,
112
+ broadcasts,
113
+ emit(event, data) {
114
+ if (state === "open") emitted.push({
115
+ event,
116
+ data
117
+ });
118
+ return result();
119
+ },
120
+ send(frame) {
121
+ if (state === "open") sent.push(frame);
122
+ return result();
123
+ },
124
+ join(room) {
125
+ for (const name of list(room)) rooms.add(name);
126
+ },
127
+ leave(room) {
128
+ rooms.delete(room);
129
+ },
130
+ to(room) {
131
+ return new RecordingBroadcast(gateway, list(room), [id], [], (entry) => broadcasts.push(entry));
132
+ },
133
+ drained() {
134
+ return state === "open" ? Promise.resolve() : Promise.reject(/* @__PURE__ */ new Error("The WebSocket connection is closed"));
135
+ },
136
+ close(code = 1e3, reason = "") {
137
+ if (state !== "open") return;
138
+ state = "closed";
139
+ closeInfo = {
140
+ code,
141
+ reason,
142
+ initiator: "server"
143
+ };
144
+ abort.abort();
145
+ },
146
+ terminate() {
147
+ if (state !== "open") return;
148
+ state = "closed";
149
+ closeInfo = {
150
+ code: 1006,
151
+ reason: "",
152
+ initiator: "server"
153
+ };
154
+ abort.abort();
155
+ }
156
+ };
157
+ }
158
+ /** Creates a fake `WsServer` recording broadcasts (no connection, every room empty). */
159
+ function createTestServer(options = {}) {
160
+ const paths = new Map(options.paths ?? []);
161
+ const broadcasts = [];
162
+ const record = (entry) => broadcasts.push(entry);
163
+ class FakeServer extends WsServer {
164
+ broadcasts = broadcasts;
165
+ get connections() {
166
+ return 0;
167
+ }
168
+ of(gateway) {
169
+ const path = typeof gateway === "string" ? gateway : paths.get(gateway) ?? gateway.name;
170
+ const all = () => new RecordingBroadcast(path, [], [], [], record);
171
+ return {
172
+ path,
173
+ size: 0,
174
+ connection: () => void 0,
175
+ connections: () => [][Symbol.iterator](),
176
+ members: () => /* @__PURE__ */ new Set(),
177
+ rooms: () => /* @__PURE__ */ new Map(),
178
+ to: (room) => all().to(room),
179
+ except: (target) => all().except(target),
180
+ emit: (event, data) => all().emit(event, data),
181
+ send: (frame) => all().send(frame)
182
+ };
183
+ }
184
+ }
185
+ return new FakeServer();
186
+ }
187
+ export { createTestConnection, createTestServer };
188
+
189
+ //# sourceMappingURL=testing.mjs.map