@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
package/dist/index.mjs ADDED
@@ -0,0 +1,2088 @@
1
+ import { t as WsServer } from "./server-D40_bC88.mjs";
2
+ import { bunTransport } from "./bun.mjs";
3
+ import { wsTransport } from "./ws.mjs";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { ErrorCode, ZnightError, displayName, resolveOptions } from "@znight/core";
6
+ import { BadRequestException, ForbiddenException, HttpException, ValidationException, parseByteSize, validate } from "@znight/http";
7
+ // src/client.ts
8
+ /** A refused handshake: the HTTP response the server answered. */
9
+ var WsHandshakeError = class extends Error {
10
+ status;
11
+ headers;
12
+ body;
13
+ #response;
14
+ constructor(response) {
15
+ super(`The WebSocket handshake was refused with ${response.status}: ${response.body}`);
16
+ this.name = "WsHandshakeError";
17
+ this.status = response.status;
18
+ this.headers = response.headers;
19
+ this.body = response.body;
20
+ this.#response = response;
21
+ }
22
+ /** The body parsed as JSON. */
23
+ json() {
24
+ return this.#response.json();
25
+ }
26
+ };
27
+ /** The failure reply of a request (`{ id, error }`). */
28
+ var WsRequestError = class extends Error {
29
+ code;
30
+ details;
31
+ constructor(event, error) {
32
+ super(typeof error.message === "string" ? error.message : `The request "${event}" failed`);
33
+ this.name = "WsRequestError";
34
+ this.code = typeof error.code === "string" ? error.code : "INTERNAL";
35
+ this.details = error.details;
36
+ }
37
+ };
38
+ function decode(frame) {
39
+ if (typeof frame !== "string") return void 0;
40
+ try {
41
+ const value = JSON.parse(frame);
42
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
43
+ } catch {
44
+ return;
45
+ }
46
+ }
47
+ /** An in-memory WebSocket client (`WebSocketPlugin.connect()`, `TestApp.connect()`). */
48
+ var WsClient = class {
49
+ #peer;
50
+ #entries = [];
51
+ #waiters = /* @__PURE__ */ new Set();
52
+ #closed = Promise.withResolvers();
53
+ #nextId = 1;
54
+ /** @internal Created by `WebSocketPlugin.connect()` once the handshake succeeded. */
55
+ constructor(peer) {
56
+ this.#peer = peer;
57
+ for (const frame of peer.received) this.#receive(frame);
58
+ peer.onmessage = (frame) => this.#receive(frame);
59
+ const ended = peer.closed;
60
+ if (ended === void 0) peer.onclose = (info) => this.#closed.resolve(info);
61
+ else this.#closed.resolve(ended);
62
+ }
63
+ /** The negotiated subprotocol, `''` when none. */
64
+ get protocol() {
65
+ return this.#peer.protocol;
66
+ }
67
+ /** Every frame received, in order: JSON objects decoded, other text frames as strings, binary frames as bytes. */
68
+ get frames() {
69
+ return this.#entries.map((entry) => entry.envelope ?? entry.frame);
70
+ }
71
+ /** Resolves once the connection closed (by either side). */
72
+ get closed() {
73
+ return this.#closed.promise;
74
+ }
75
+ /** Sends an event without id (fire and forget). */
76
+ send(event, data) {
77
+ this.#peer.send(JSON.stringify(data === void 0 ? { event } : {
78
+ event,
79
+ data
80
+ }));
81
+ }
82
+ /** Sends a request and resolves with the `data` of its reply; rejects with {@link WsRequestError}. */
83
+ request(event, data, options = {}) {
84
+ const id = this.#nextId++;
85
+ const reply = this.#wait((entry) => !entry.consumed && entry.envelope?.["id"] === id && entry.envelope["event"] === void 0, (entry, resolve, reject) => {
86
+ entry.consumed = true;
87
+ const envelope = entry.envelope;
88
+ if (envelope["error"] === void 0) resolve(envelope["data"]);
89
+ else reject(new WsRequestError(event, envelope["error"]));
90
+ }, options.timeoutMs ?? 5e3, `the reply to "${event}" (id ${id})`);
91
+ this.#peer.send(JSON.stringify(data === void 0 ? {
92
+ event,
93
+ id
94
+ } : {
95
+ event,
96
+ data,
97
+ id
98
+ }));
99
+ return reply;
100
+ }
101
+ /** The `data` of the oldest server event (matching `event`) not taken yet. */
102
+ next(event, options = {}) {
103
+ return this.#wait((entry) => !entry.consumed && typeof entry.envelope?.["event"] === "string" && (event === void 0 || entry.envelope["event"] === event), (entry, resolve) => {
104
+ entry.consumed = true;
105
+ resolve(entry.envelope["data"]);
106
+ }, options.timeoutMs ?? 1e3, event === void 0 ? "a server event" : `the event "${event}"`);
107
+ }
108
+ /** Sends a frame as is (a string is a text frame). */
109
+ sendRaw(frame) {
110
+ this.#peer.send(frame);
111
+ }
112
+ /** The oldest frame not taken by `nextRaw()` yet, as received. */
113
+ nextRaw(options = {}) {
114
+ return this.#wait((entry) => !entry.rawConsumed, (entry, resolve) => {
115
+ entry.rawConsumed = true;
116
+ resolve(entry.frame);
117
+ }, options.timeoutMs ?? 1e3, "a frame");
118
+ }
119
+ /** Closes the connection and resolves with how it closed. */
120
+ close(code = 1e3, reason = "") {
121
+ this.#peer.close(code, reason);
122
+ return this.#closed.promise;
123
+ }
124
+ async [Symbol.asyncDispose]() {
125
+ await this.close();
126
+ }
127
+ #wait(match, take, timeoutMs, what) {
128
+ const { promise, resolve, reject } = Promise.withResolvers();
129
+ const found = this.#entries.find(match);
130
+ if (found !== void 0) {
131
+ take(found, resolve, reject);
132
+ return promise;
133
+ }
134
+ if (this.#peer.closed !== void 0) {
135
+ reject(/* @__PURE__ */ new Error(`The WebSocket connection is closed: no ${what} will arrive`));
136
+ return promise;
137
+ }
138
+ const timer = setTimeout(() => {
139
+ this.#waiters.delete(waiter);
140
+ reject(/* @__PURE__ */ new Error(`Timed out after ${timeoutMs} ms waiting for ${what}`));
141
+ }, timeoutMs);
142
+ const waiter = {
143
+ match,
144
+ take: (entry) => {
145
+ clearTimeout(timer);
146
+ take(entry, resolve, reject);
147
+ }
148
+ };
149
+ this.#waiters.add(waiter);
150
+ this.#closed.promise.then(() => {
151
+ if (!this.#waiters.delete(waiter)) return;
152
+ clearTimeout(timer);
153
+ reject(/* @__PURE__ */ new Error(`The WebSocket connection closed while waiting for ${what}`));
154
+ });
155
+ return promise;
156
+ }
157
+ #receive(frame) {
158
+ const entry = {
159
+ frame,
160
+ envelope: decode(frame),
161
+ consumed: false,
162
+ rawConsumed: false
163
+ };
164
+ this.#entries.push(entry);
165
+ for (const waiter of this.#waiters) {
166
+ if (!waiter.match(entry)) continue;
167
+ this.#waiters.delete(waiter);
168
+ waiter.take(entry);
169
+ }
170
+ }
171
+ };
172
+ // src/exceptions.ts
173
+ /** Exceptions of WebSocket handlers and their wire codes (RFC 0003 §5.3, §5.8). */
174
+ /** Built-in error codes of the wire protocol. */
175
+ const WsErrorCode = {
176
+ BadFrame: "BAD_FRAME",
177
+ UnsupportedFrame: "UNSUPPORTED_FRAME",
178
+ BadRequest: "BAD_REQUEST",
179
+ Unauthorized: "UNAUTHORIZED",
180
+ Forbidden: "FORBIDDEN",
181
+ NotFound: "NOT_FOUND",
182
+ Conflict: "CONFLICT",
183
+ Unprocessable: "UNPROCESSABLE",
184
+ RateLimited: "RATE_LIMITED",
185
+ UnknownEvent: "UNKNOWN_EVENT",
186
+ Timeout: "TIMEOUT",
187
+ Internal: "INTERNAL"
188
+ };
189
+ /** Close codes used by the runtime (RFC 0003 §5.8, normative). */
190
+ const WsCloseCode = {
191
+ Normal: 1e3,
192
+ GoingAway: 1001,
193
+ PolicyViolation: 1008,
194
+ MessageTooBig: 1009,
195
+ InternalError: 1011,
196
+ TryAgainLater: 1013
197
+ };
198
+ /**
199
+ * An error a handler or a message guard throws to answer the client: a reply with an `id`, an
200
+ * `$error` event otherwise, then a close when `close` is set.
201
+ *
202
+ * ```ts
203
+ * throw new WsException('FORBIDDEN', 'Join the room first')
204
+ * ```
205
+ */
206
+ var WsException = class extends Error {
207
+ code;
208
+ details;
209
+ close;
210
+ constructor(code, message, details, options = {}) {
211
+ super(message ?? code, options.cause === void 0 ? void 0 : { cause: options.cause });
212
+ this.name = "WsException";
213
+ this.code = code;
214
+ this.details = details;
215
+ this.close = options.close;
216
+ }
217
+ };
218
+ /** A payload failed its schema: `BAD_REQUEST` with `details: [{ path, message }]`. */
219
+ var WsValidationException = class extends WsException {
220
+ issues;
221
+ constructor(issues, message = "Invalid payload") {
222
+ super(WsErrorCode.BadRequest, message, issues);
223
+ this.name = "WsValidationException";
224
+ this.issues = issues;
225
+ }
226
+ };
227
+ const BY_STATUS = {
228
+ 400: WsErrorCode.BadRequest,
229
+ 401: WsErrorCode.Unauthorized,
230
+ 403: WsErrorCode.Forbidden,
231
+ 404: WsErrorCode.NotFound,
232
+ 409: WsErrorCode.Conflict,
233
+ 422: WsErrorCode.Unprocessable,
234
+ 429: WsErrorCode.RateLimited
235
+ };
236
+ /**
237
+ * The wire code of an HTTP status thrown by a service (`HttpException`): the table of RFC 0003
238
+ * §5.8, `INTERNAL` for 5xx and unknown statuses.
239
+ */
240
+ function codeOfStatus(status) {
241
+ return BY_STATUS[status] ?? (status >= 400 && status < 500 ? WsErrorCode.BadRequest : WsErrorCode.Internal);
242
+ }
243
+ // src/connection.ts
244
+ /** How often a pending `conn.drained()` re-reads the outbound buffer (ms). */
245
+ const DRAIN_POLL_MS = 10;
246
+ /** `conn.drained()` rejects with it once the connection is closed. */
247
+ var WsClosedError = class extends Error {
248
+ constructor(message = "The WebSocket connection is closed") {
249
+ super(message);
250
+ this.name = "WsClosedError";
251
+ }
252
+ };
253
+ /** The mutable counters behind {@link WsStats} (internal). */
254
+ var StatsCounter = class {
255
+ opened = 0;
256
+ closed = 0;
257
+ messagesIn = 0;
258
+ messagesOut = 0;
259
+ bytesIn = 0;
260
+ bytesOut = 0;
261
+ dropped = 0;
262
+ slowConsumerCloses = 0;
263
+ /** A frozen snapshot, with the number of open connections. */
264
+ snapshot(connections) {
265
+ return Object.freeze({
266
+ connections,
267
+ opened: this.opened,
268
+ closed: this.closed,
269
+ messagesIn: this.messagesIn,
270
+ messagesOut: this.messagesOut,
271
+ bytesIn: this.bytesIn,
272
+ bytesOut: this.bytesOut,
273
+ dropped: this.dropped,
274
+ slowConsumerCloses: this.slowConsumerCloses
275
+ });
276
+ }
277
+ };
278
+ function sizeOf(frame) {
279
+ return typeof frame === "string" ? Buffer.byteLength(frame) : frame.byteLength;
280
+ }
281
+ /** The implementation of {@link WsConnection} (internal to the package). */
282
+ var WsConnectionImpl = class {
283
+ id;
284
+ gateway;
285
+ handshake;
286
+ protocol;
287
+ data = {};
288
+ roomSet = /* @__PURE__ */ new Set();
289
+ #limits;
290
+ #registry;
291
+ #driver;
292
+ #stats;
293
+ #abort = new AbortController();
294
+ #queue = [];
295
+ #running = /* @__PURE__ */ new Set();
296
+ #drainWaiters = [];
297
+ #finished = Promise.withResolvers();
298
+ #socket;
299
+ /** Polls the buffer while `drained()` waits: a transport may only report a drain at its own limit. */
300
+ #drainPoll;
301
+ #state = "open";
302
+ #connecting = true;
303
+ #connected = false;
304
+ #paused = false;
305
+ #closing;
306
+ #closeInfo;
307
+ #closeTimer;
308
+ /** Seen alive (a frame or a pong) since the last heartbeat tick. */
309
+ #alive = true;
310
+ constructor(init) {
311
+ this.id = init.id;
312
+ this.gateway = init.gateway;
313
+ this.handshake = init.handshake;
314
+ this.protocol = init.protocol;
315
+ this.#limits = init.limits;
316
+ this.#registry = init.rooms;
317
+ this.#driver = init.driver;
318
+ this.#stats = init.stats;
319
+ }
320
+ get requestId() {
321
+ return this.handshake.requestId;
322
+ }
323
+ get params() {
324
+ return this.handshake.params;
325
+ }
326
+ get rooms() {
327
+ return this.roomSet;
328
+ }
329
+ get readyState() {
330
+ return this.#state;
331
+ }
332
+ get bufferedAmount() {
333
+ return this.#socket?.bufferedAmount ?? 0;
334
+ }
335
+ get signal() {
336
+ return this.#abort.signal;
337
+ }
338
+ get closeInfo() {
339
+ return this.#closeInfo;
340
+ }
341
+ /** Resolves once the connection is fully closed. */
342
+ get finished() {
343
+ return this.#finished.promise;
344
+ }
345
+ /** The socket events to give to the transport. */
346
+ socketEvents() {
347
+ return {
348
+ message: (frame, binary) => this.#receive(frame, binary),
349
+ pong: () => {
350
+ this.#alive = true;
351
+ },
352
+ drain: () => this.#drain(),
353
+ close: (code, reason) => this.#socketClosed(code, reason)
354
+ };
355
+ }
356
+ /** Binds the accepted socket, joins the gateway and runs `@OnConnect` as the first task. */
357
+ start(socket) {
358
+ this.#socket = socket;
359
+ this.#registry.add(this);
360
+ const task = this.#driver.connect(this).then(() => {
361
+ this.#connected = true;
362
+ this.#connecting = false;
363
+ this.#pump();
364
+ }, (error) => {
365
+ this.#connecting = false;
366
+ const { code, reason } = this.#driver.connectFailure(error);
367
+ this.#beginClose(code, reason, "server", true);
368
+ });
369
+ this.#track(task);
370
+ }
371
+ emit(event, data) {
372
+ return this.sendFrame(this.#registry.codec.encode({
373
+ kind: "event",
374
+ event,
375
+ data
376
+ }));
377
+ }
378
+ send(frame) {
379
+ return this.sendFrame(frame);
380
+ }
381
+ /** Sends an encoded frame through the backpressure policy. */
382
+ sendFrame(frame) {
383
+ const socket = this.#socket;
384
+ if (this.#state !== "open" || socket === void 0) return "dropped";
385
+ const size = sizeOf(frame);
386
+ const stats = this.#stats;
387
+ if (socket.bufferedAmount + size > this.#limits.backpressureLimit) {
388
+ if (stats !== void 0) stats.dropped++;
389
+ if (this.#limits.onBackpressure === "drop") return "dropped";
390
+ if (stats !== void 0) stats.slowConsumerCloses++;
391
+ this.#beginClose(WsCloseCode.TryAgainLater, "Slow consumer", "backpressure", true);
392
+ return "dropped";
393
+ }
394
+ const status = socket.send(frame, typeof frame !== "string");
395
+ if (stats !== void 0) {
396
+ if (status === "dropped") stats.dropped++;
397
+ else {
398
+ stats.messagesOut++;
399
+ stats.bytesOut += size;
400
+ }
401
+ }
402
+ return status;
403
+ }
404
+ join(room) {
405
+ for (const name of typeof room === "string" ? [room] : room) this.#registry.join(this, name);
406
+ }
407
+ leave(room) {
408
+ this.#registry.leave(this, room);
409
+ }
410
+ to(room) {
411
+ return this.#registry.select(room, [this.id]);
412
+ }
413
+ drained() {
414
+ if (this.#state !== "open") return Promise.reject(new WsClosedError());
415
+ if (this.bufferedAmount <= this.#limits.backpressureLimit / 4) return Promise.resolve();
416
+ const { promise, resolve, reject } = Promise.withResolvers();
417
+ this.#drainWaiters.push({
418
+ resolve,
419
+ reject
420
+ });
421
+ if (this.#drainPoll === void 0) {
422
+ this.#drainPoll = setInterval(() => this.#drain(), DRAIN_POLL_MS);
423
+ this.#drainPoll.unref?.();
424
+ }
425
+ return promise;
426
+ }
427
+ close(code = WsCloseCode.Normal, reason = "") {
428
+ this.#beginClose(code, reason, "server", true);
429
+ }
430
+ /** Closes with an initiator (shutdown, heartbeat...). */
431
+ closeWith(code, reason, initiator) {
432
+ this.#beginClose(code, reason, initiator, true);
433
+ }
434
+ terminate() {
435
+ this.terminateWith("server");
436
+ }
437
+ /** Terminates without close frame (heartbeat timeout, shutdown deadline...): local code `1006`. */
438
+ terminateWith(initiator) {
439
+ this.#beginClose(1006, "", initiator, false);
440
+ this.#socket?.terminate();
441
+ }
442
+ /**
443
+ * One heartbeat tick (RFC 0003 §8.7): a connection not seen alive since the previous tick is
444
+ * terminated, the others are marked and pinged.
445
+ */
446
+ heartbeat() {
447
+ if (this.#state !== "open" || this.#socket === void 0) return;
448
+ if (!this.#alive) {
449
+ this.terminateWith("heartbeat");
450
+ return;
451
+ }
452
+ this.#alive = false;
453
+ this.#socket.ping();
454
+ }
455
+ #receive(frame, binary) {
456
+ if (this.#state !== "open") return;
457
+ this.#alive = true;
458
+ const size = sizeOf(frame);
459
+ const stats = this.#stats;
460
+ if (stats !== void 0) {
461
+ stats.messagesIn++;
462
+ stats.bytesIn += size;
463
+ }
464
+ if (size > this.#limits.maxPayload) {
465
+ this.#beginClose(WsCloseCode.MessageTooBig, "Message too big", "server", true);
466
+ return;
467
+ }
468
+ const rate = this.#limits.rateLimit;
469
+ if (rate !== void 0 && !rate.bucket.take()) {
470
+ if (rate.onLimit === "close") this.#beginClose(4429, "Rate limited", "server", true);
471
+ else this.#driver.rateLimited(this, frame, binary);
472
+ return;
473
+ }
474
+ this.#queue.push({
475
+ frame,
476
+ binary
477
+ });
478
+ const max = this.#limits.maxPendingMessages;
479
+ if (this.#queue.length >= max && !this.#paused) {
480
+ this.#paused = this.#socket?.pause() ?? false;
481
+ if (!this.#paused && this.#queue.length >= max * 2) {
482
+ this.#beginClose(WsCloseCode.PolicyViolation, "Too many pending messages", "server", true);
483
+ return;
484
+ }
485
+ }
486
+ this.#pump();
487
+ }
488
+ #pump() {
489
+ while (this.#state === "open" && !this.#connecting && this.#running.size < this.#limits.concurrency && this.#queue.length > 0) {
490
+ const next = this.#queue.shift();
491
+ this.#track(this.#driver.dispatch(this, next.frame, next.binary));
492
+ }
493
+ if (this.#paused && this.#queue.length <= this.#limits.maxPendingMessages / 2) {
494
+ this.#paused = false;
495
+ this.#socket?.resume();
496
+ }
497
+ }
498
+ /** Counts a running task until it settles, then dispatches the next messages. */
499
+ #track(task) {
500
+ const tracked = task.catch(() => void 0).then(() => {
501
+ this.#running.delete(tracked);
502
+ this.#pump();
503
+ });
504
+ this.#running.add(tracked);
505
+ }
506
+ #drain() {
507
+ if (this.bufferedAmount > this.#limits.backpressureLimit / 4) return;
508
+ this.#stopDrainPoll();
509
+ for (const waiter of this.#drainWaiters.splice(0)) waiter.resolve();
510
+ }
511
+ #stopDrainPoll() {
512
+ if (this.#drainPoll !== void 0) clearInterval(this.#drainPoll);
513
+ this.#drainPoll = void 0;
514
+ }
515
+ #beginClose(code, reason, initiator, frame) {
516
+ if (this.#state !== "open") return;
517
+ this.#state = "closing";
518
+ this.#closing = {
519
+ code,
520
+ reason,
521
+ initiator
522
+ };
523
+ this.#abort.abort(new WsClosedError());
524
+ this.#queue.length = 0;
525
+ if (!frame) return;
526
+ this.#socket?.close(code, reason);
527
+ this.#closeTimer = setTimeout(() => this.#socket?.terminate(), this.#limits.closeTimeoutMs);
528
+ this.#closeTimer.unref?.();
529
+ }
530
+ #socketClosed(code, reason) {
531
+ if (this.#state === "closed") return;
532
+ if (this.#state === "open") this.#beginClose(code, reason, "client", false);
533
+ if (this.#closeTimer !== void 0) clearTimeout(this.#closeTimer);
534
+ this.#finish();
535
+ }
536
+ async #finish() {
537
+ const info = this.#closing ?? {
538
+ code: 1006,
539
+ reason: "",
540
+ initiator: "error"
541
+ };
542
+ this.#closeInfo = info;
543
+ while (this.#running.size > 0) await Promise.all([...this.#running]);
544
+ if (this.#connected) await this.#driver.disconnect(this).catch(() => void 0);
545
+ this.#registry.remove(this);
546
+ this.#state = "closed";
547
+ this.#stopDrainPoll();
548
+ for (const waiter of this.#drainWaiters.splice(0)) waiter.reject(new WsClosedError());
549
+ this.#driver.closed(this, info);
550
+ this.#finished.resolve(info);
551
+ }
552
+ };
553
+ // src/decorators.ts
554
+ function marker() {}
555
+ /**
556
+ * A provider whose methods handle the WebSocket connections of `path` (default `/`), joined to the
557
+ * `HttpPlugin` prefix.
558
+ *
559
+ * ```ts
560
+ * @Gateway({ path: '/chat', protocols: ['chat.v1'] })
561
+ * export class ChatGateway {}
562
+ * ```
563
+ */
564
+ function Gateway(_pathOrOptions) {
565
+ return marker;
566
+ }
567
+ /** Runs once the handshake succeeded, before any message. Throwing closes the connection. */
568
+ function OnConnect() {
569
+ return marker;
570
+ }
571
+ /**
572
+ * Handles an event (`events` protocol). Without `event`: the default handler (unknown events, every
573
+ * message of a `raw` gateway). Repeatable.
574
+ */
575
+ function OnMessage(_event) {
576
+ return marker;
577
+ }
578
+ /** Runs once per connection whose `@OnConnect` completed, before rooms are left and the scope closes. */
579
+ function OnDisconnect() {
580
+ return marker;
581
+ }
582
+ /** The message `data` (`events`) or the raw frame (`raw`), validated by `schema` (a Standard Schema). */
583
+ function Payload(_schema) {
584
+ return marker;
585
+ }
586
+ /** The `WsConnection` of the message or of the lifecycle method. */
587
+ function Conn() {
588
+ return marker;
589
+ }
590
+ /** The `WsMessageContext` (`@OnMessage` handlers only). */
591
+ function Ctx() {
592
+ return marker;
593
+ }
594
+ /** Extra HTTP guards (`CanActivate`) of the handshake, after the `HttpPlugin` global guards. */
595
+ function UseHandshakeGuard(..._guards) {
596
+ return marker;
597
+ }
598
+ /** Message guards (`WsGuard`) run before each handler: global, then class, then method. */
599
+ function UseMessageGuard(..._guards) {
600
+ return marker;
601
+ }
602
+ // src/envelope.ts
603
+ /** The envelope of `events` gateways (RFC 0003 §5.3): decoding inbound frames, encoding outbound ones. */
604
+ /** Event names: `^[A-Za-z][\w.:/-]{0,127}$`; names starting with `$` are reserved. */
605
+ const EVENT_NAME = /^[A-Za-z][\w.:/-]{0,127}$/;
606
+ /** Whether `value` is a valid message id. */
607
+ function isMessageId(value) {
608
+ return typeof value === "string" && value.length <= 64 || Number.isSafeInteger(value);
609
+ }
610
+ /**
611
+ * Decodes an inbound frame of an `events` gateway with the JSON codec: a binary frame is
612
+ * `UNSUPPORTED_FRAME`, anything but a JSON object with a string `event` is `BAD_FRAME`. Unknown
613
+ * fields are ignored.
614
+ */
615
+ function decodeFrame(frame) {
616
+ if (typeof frame !== "string") return {
617
+ error: WsErrorCode.UnsupportedFrame,
618
+ message: "Binary frames are not supported",
619
+ id: void 0
620
+ };
621
+ let value;
622
+ try {
623
+ value = JSON.parse(frame);
624
+ } catch {
625
+ return {
626
+ error: WsErrorCode.BadFrame,
627
+ message: "The frame is not valid JSON",
628
+ id: void 0
629
+ };
630
+ }
631
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {
632
+ error: WsErrorCode.BadFrame,
633
+ message: "The frame must be a JSON object",
634
+ id: void 0
635
+ };
636
+ const record = value;
637
+ const rawId = record["id"];
638
+ const id = isMessageId(rawId) ? rawId : void 0;
639
+ if (rawId !== void 0 && id === void 0) return {
640
+ error: WsErrorCode.BadFrame,
641
+ message: "Invalid message id",
642
+ id: void 0
643
+ };
644
+ const event = record["event"];
645
+ if (typeof event !== "string") return {
646
+ error: WsErrorCode.BadFrame,
647
+ message: "Missing event name",
648
+ id
649
+ };
650
+ return {
651
+ event,
652
+ data: record["data"],
653
+ id
654
+ };
655
+ }
656
+ /** Whether a decoded frame is a failure. */
657
+ function isFailure(decoded) {
658
+ return "error" in decoded;
659
+ }
660
+ /** A success reply: `{ id, data }`, `data` omitted when `undefined`. */
661
+ function encodeReply(id, data) {
662
+ return data === void 0 ? JSON.stringify({ id }) : JSON.stringify({
663
+ id,
664
+ data
665
+ });
666
+ }
667
+ /** A failure reply: `{ id, error }`. */
668
+ function encodeErrorReply(id, error) {
669
+ return JSON.stringify({
670
+ id,
671
+ error: wireError(error)
672
+ });
673
+ }
674
+ /** A server event: `{ event, data }`. */
675
+ function encodeEvent(event, data) {
676
+ return data === void 0 ? JSON.stringify({ event }) : JSON.stringify({
677
+ event,
678
+ data
679
+ });
680
+ }
681
+ /** The failure of a message without id: `{ event: '$error', data: { event, code, message, details? } }`. */
682
+ function encodeErrorEvent(event, error) {
683
+ return JSON.stringify({
684
+ event: "$error",
685
+ data: {
686
+ ...event === void 0 ? {} : { event },
687
+ ...wireError(error)
688
+ }
689
+ });
690
+ }
691
+ function wireError(error) {
692
+ return error.details === void 0 ? {
693
+ code: error.code,
694
+ message: error.message
695
+ } : {
696
+ code: error.code,
697
+ message: error.message,
698
+ details: error.details
699
+ };
700
+ }
701
+ /** The default codec: UTF-8 JSON text frames (RFC 0003 §5.3); binary frames are `UNSUPPORTED_FRAME`. */
702
+ const jsonCodec = Object.freeze({
703
+ decode: (frame) => decodeFrame(frame),
704
+ encode(message) {
705
+ switch (message.kind) {
706
+ case "event": return encodeEvent(message.event, message.data);
707
+ case "reply": return encodeReply(message.id, message.data);
708
+ case "error": return message.id === void 0 ? encodeErrorEvent(message.event, message.error) : encodeErrorReply(message.id, message.error);
709
+ }
710
+ }
711
+ });
712
+ // src/dispatcher.ts
713
+ const MESSAGE_HOOK = "websocket:message";
714
+ const OK = Object.freeze({ outcome: "ok" });
715
+ var TimeoutError = class extends Error {};
716
+ /** Converts what a handler or a guard threw to a wire error. */
717
+ function toWireError(error, exposeErrors) {
718
+ if (error instanceof WsException) return {
719
+ code: error.code,
720
+ message: error.message,
721
+ ...error.details === void 0 ? {} : { details: error.details }
722
+ };
723
+ if (error instanceof HttpException) {
724
+ const code = codeOfStatus(error.status);
725
+ return {
726
+ code,
727
+ message: code === WsErrorCode.Internal && !exposeErrors ? "Internal error" : error.message,
728
+ ...error.details === void 0 ? {} : { details: error.details }
729
+ };
730
+ }
731
+ if (error instanceof TimeoutError) return {
732
+ code: WsErrorCode.Timeout,
733
+ message: "The handler timed out"
734
+ };
735
+ const message = exposeErrors && error instanceof Error ? error.message : "Internal error";
736
+ return {
737
+ code: WsErrorCode.Internal,
738
+ message
739
+ };
740
+ }
741
+ function isForbidden(error) {
742
+ if (error instanceof WsException) return error.code === WsErrorCode.Forbidden || error.code === WsErrorCode.Unauthorized;
743
+ return error instanceof HttpException && (error.status === 401 || error.status === 403);
744
+ }
745
+ function isExpected(error) {
746
+ if (error instanceof WsException || error instanceof TimeoutError) return true;
747
+ return error instanceof HttpException && error.status < 500;
748
+ }
749
+ /** The close code of an `@OnConnect` failure (§5.8). */
750
+ function connectFailureCode(error) {
751
+ if (error instanceof WsValidationException) return {
752
+ code: 4400,
753
+ reason: error.message
754
+ };
755
+ if (error instanceof WsException) return {
756
+ code: error.close ?? WsCloseCode.PolicyViolation,
757
+ reason: error.message
758
+ };
759
+ if (error instanceof HttpException) return {
760
+ code: 4e3 + error.status,
761
+ reason: error.message
762
+ };
763
+ return {
764
+ code: WsCloseCode.InternalError,
765
+ reason: "Internal error"
766
+ };
767
+ }
768
+ /** One gateway's message pipeline, shared by its connections. */
769
+ var GatewayDispatcher = class {
770
+ #options;
771
+ #byEvent = /* @__PURE__ */ new Map();
772
+ #fallback;
773
+ #codec;
774
+ #ignored = /* @__PURE__ */ new Set();
775
+ constructor(options) {
776
+ this.#options = options;
777
+ this.#codec = options.codec ?? jsonCodec;
778
+ let fallback;
779
+ for (const plan of options.handlers) if (plan.definition.event === void 0) fallback = plan;
780
+ else this.#byEvent.set(plan.definition.event, plan);
781
+ this.#fallback = fallback;
782
+ }
783
+ get raw() {
784
+ return this.#options.definition.protocol === "raw";
785
+ }
786
+ async connect(conn) {
787
+ const lifecycle = this.#options.definition.onConnect;
788
+ if (lifecycle === void 0) return;
789
+ const gateway = await this.#options.instance(conn);
790
+ await this.#bounded(Promise.resolve(lifecycle.invoke(gateway, conn)), void 0);
791
+ }
792
+ connectFailure(error) {
793
+ return connectFailureCode(error);
794
+ }
795
+ async disconnect(conn) {
796
+ const lifecycle = this.#options.definition.onDisconnect;
797
+ if (lifecycle === void 0) return;
798
+ try {
799
+ await lifecycle.invoke(await this.#options.instance(conn), conn);
800
+ } catch (error) {
801
+ this.#options.logError(`@OnDisconnect ${lifecycle.method} failed`, error, conn);
802
+ }
803
+ }
804
+ closed(conn, info) {
805
+ this.#options.closed(conn, info);
806
+ }
807
+ rateLimited(conn, frame, binary) {
808
+ if (this.raw) return;
809
+ const decoded = this.#codec.decode(frame, binary);
810
+ const id = decoded.id;
811
+ const event = isFailure(decoded) ? void 0 : decoded.event;
812
+ this.#fail(conn, id, event, {
813
+ code: WsErrorCode.RateLimited,
814
+ message: "Too many messages"
815
+ });
816
+ }
817
+ async dispatch(conn, frame, binary) {
818
+ const receivedAt = performance.now();
819
+ let event;
820
+ let id;
821
+ let data = frame;
822
+ let failure;
823
+ if (!this.raw) {
824
+ const decoded = this.#codec.decode(frame, binary);
825
+ if (isFailure(decoded)) {
826
+ id = decoded.id;
827
+ failure = {
828
+ code: decoded.error,
829
+ message: decoded.message
830
+ };
831
+ } else {
832
+ ({event, id, data} = decoded);
833
+ if (event === "$ping") {
834
+ if (id !== void 0) conn.sendFrame(this.#codec.encode({
835
+ kind: "reply",
836
+ id,
837
+ data: { t: Date.now() }
838
+ }));
839
+ return;
840
+ }
841
+ }
842
+ }
843
+ const plan = failure === void 0 ? (event === void 0 ? void 0 : this.#byEvent.get(event)) ?? this.#fallback : void 0;
844
+ const abort = new AbortController();
845
+ const onClose = () => abort.abort(conn.signal.reason);
846
+ conn.signal.addEventListener("abort", onClose, { once: true });
847
+ const ctx = {
848
+ connection: conn,
849
+ event,
850
+ id,
851
+ data,
852
+ binary,
853
+ bytes: typeof frame === "string" ? frame.length : frame.byteLength,
854
+ receivedAt,
855
+ signal: abort.signal,
856
+ state: /* @__PURE__ */ new Map(),
857
+ route: {
858
+ path: this.#options.path,
859
+ gateway: this.#options.definition.token,
860
+ handler: plan?.definition.method,
861
+ event,
862
+ meta: plan?.meta ?? this.#options.definition.meta
863
+ }
864
+ };
865
+ try {
866
+ const hooks = this.#options.hooks;
867
+ if (hooks === void 0 || !hooks.hasWrappers(MESSAGE_HOOK) && !hooks.hasListeners(MESSAGE_HOOK)) {
868
+ await this.#handle(plan, conn, ctx, failure, abort);
869
+ return;
870
+ }
871
+ const hookEvent = {
872
+ gateway: this.#options.name ?? String(this.#options.definition.token),
873
+ path: this.#options.path,
874
+ event,
875
+ handler: plan?.definition.method,
876
+ connectionId: conn.id,
877
+ requestId: conn.requestId,
878
+ bytes: ctx.bytes,
879
+ context: ctx,
880
+ startTime: receivedAt
881
+ };
882
+ try {
883
+ await hooks.run(MESSAGE_HOOK, hookEvent, async () => {
884
+ const settled = await this.#handle(plan, conn, ctx, failure, abort);
885
+ hookEvent.outcome = settled.outcome;
886
+ if (settled.error !== void 0) hookEvent.error = settled.error;
887
+ });
888
+ } catch (error) {
889
+ this.#options.logError("A \"websocket:message\" hook wrapper failed", error, conn);
890
+ }
891
+ if (hooks.hasListeners(MESSAGE_HOOK)) hooks.emit(MESSAGE_HOOK, hookEvent);
892
+ } finally {
893
+ conn.signal.removeEventListener("abort", onClose);
894
+ }
895
+ }
896
+ /** Guards, handler, reply and error mapping of one message. Never rejects. */
897
+ async #handle(plan, conn, ctx, failure, abort) {
898
+ if (failure !== void 0) {
899
+ this.#fail(conn, ctx.id, void 0, failure);
900
+ return {
901
+ outcome: "error",
902
+ error: new WsException(failure.code, failure.message)
903
+ };
904
+ }
905
+ if (plan === void 0) {
906
+ this.#fail(conn, ctx.id, ctx.event, {
907
+ code: WsErrorCode.UnknownEvent,
908
+ message: `Unknown event "${ctx.event}"`
909
+ });
910
+ return { outcome: "unhandled" };
911
+ }
912
+ let rejected = false;
913
+ try {
914
+ const result = await this.#bounded(this.#run(plan, conn, ctx, () => {
915
+ rejected = true;
916
+ }), abort);
917
+ if (ctx.id !== void 0 && conn.readyState === "open") conn.sendFrame(this.#codec.encode({
918
+ kind: "reply",
919
+ id: ctx.id,
920
+ data: result
921
+ }));
922
+ else if (ctx.id === void 0 && result !== void 0 && !this.raw) this.#ignoredResult(plan.definition.method, ctx.event);
923
+ return OK;
924
+ } catch (thrown) {
925
+ const error = await this.#map(thrown, ctx, conn);
926
+ if (!isExpected(error)) this.#options.logError(`Handler ${plan.definition.method} failed`, error, conn);
927
+ if (!this.raw) this.#fail(conn, ctx.id, ctx.event, toWireError(error, this.#options.exposeErrors));
928
+ if (error instanceof WsException && error.close !== void 0) conn.close(error.close, error.message);
929
+ let outcome = "error";
930
+ if (thrown instanceof TimeoutError) outcome = "timeout";
931
+ else if (rejected && isForbidden(error)) outcome = "rejected";
932
+ return {
933
+ outcome,
934
+ error: thrown
935
+ };
936
+ }
937
+ }
938
+ #ignoredResult(method, event) {
939
+ const report = this.#options.ignoredResult;
940
+ if (report === void 0 || this.#ignored.has(method)) return;
941
+ this.#ignored.add(method);
942
+ report(method, event);
943
+ }
944
+ /** Runs the plugin exception mappers, in order: the first `WsException` wins. */
945
+ async #map(error, ctx, conn) {
946
+ const mappers = this.#options.mappers;
947
+ if (mappers === void 0 || mappers.length === 0 || error instanceof TimeoutError) return error;
948
+ for (const mapper of mappers) try {
949
+ const mapped = await mapper.map(error, ctx);
950
+ if (mapped !== void 0) return mapped;
951
+ } catch (failure) {
952
+ this.#options.logError("A WebSocket exception mapper failed", failure, conn);
953
+ return failure;
954
+ }
955
+ return error;
956
+ }
957
+ async #run(plan, conn, ctx, refused) {
958
+ for (const guard of plan.guards) {
959
+ let allowed;
960
+ try {
961
+ allowed = await guard.canHandle(ctx) === true;
962
+ } catch (error) {
963
+ refused();
964
+ throw error;
965
+ }
966
+ if (!allowed) {
967
+ refused();
968
+ throw new WsException(WsErrorCode.Forbidden, "Forbidden");
969
+ }
970
+ }
971
+ const gateway = await this.#options.instance(conn);
972
+ return await plan.definition.invoke(gateway, ctx);
973
+ }
974
+ /** Races a task with the handler timeout; aborts `abort` on timeout. */
975
+ #bounded(task, abort) {
976
+ const ms = this.#options.handlerTimeoutMs;
977
+ if (!Number.isFinite(ms)) return task;
978
+ let timer;
979
+ const timeout = new Promise((_resolve, reject) => {
980
+ timer = setTimeout(() => {
981
+ const error = new TimeoutError("The handler timed out");
982
+ abort?.abort(error);
983
+ reject(error);
984
+ }, ms);
985
+ });
986
+ task.catch(() => void 0);
987
+ return Promise.race([task, timeout]).finally(() => clearTimeout(timer));
988
+ }
989
+ #fail(conn, id, event, error) {
990
+ if (this.raw || conn.readyState !== "open") return;
991
+ conn.sendFrame(this.#codec.encode({
992
+ kind: "error",
993
+ id,
994
+ event,
995
+ error
996
+ }));
997
+ }
998
+ };
999
+ // src/options.ts
1000
+ const OPTION_KEYS = /* @__PURE__ */ new Set([
1001
+ "transport",
1002
+ "origins",
1003
+ "maxPayload",
1004
+ "maxConnections",
1005
+ "maxPendingMessages",
1006
+ "backpressure",
1007
+ "heartbeat",
1008
+ "handshakeTimeoutMs",
1009
+ "handlerTimeoutMs",
1010
+ "closeTimeoutMs",
1011
+ "codec",
1012
+ "messageGuards",
1013
+ "exceptionMappers",
1014
+ "exposeErrors",
1015
+ "shutdown"
1016
+ ]);
1017
+ /** `ZNIGHT050: WebSocketPlugin: option "<name>" must be <expected> (received <value>)`. */
1018
+ function invalidOption(name, expected, value, hints) {
1019
+ return new ZnightError(ErrorCode.InvalidGateway, `WebSocketPlugin: option "${name}" must be ${expected} (received ${describe(value)})`, hints === void 0 ? {} : { hints });
1020
+ }
1021
+ function describe(value) {
1022
+ if (typeof value === "function") return "a function";
1023
+ if (value instanceof RegExp) return String(value);
1024
+ try {
1025
+ return JSON.stringify(value) ?? String(value);
1026
+ } catch {
1027
+ return typeof value;
1028
+ }
1029
+ }
1030
+ function positiveInteger(name, value, fallback, infinite = false) {
1031
+ if (value === void 0) return fallback;
1032
+ if (infinite && value === Number.POSITIVE_INFINITY) return value;
1033
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw invalidOption(name, `a positive integer${infinite ? " or Infinity" : ""}`, value);
1034
+ return value;
1035
+ }
1036
+ function byteSize(name, value, fallback) {
1037
+ if (value === void 0) return fallback;
1038
+ const bytes = parseByteSize(value);
1039
+ if (bytes === void 0 || bytes === 0 || bytes === Number.POSITIVE_INFINITY) throw invalidOption(name, "a positive byte size ('64kb', 65536)", value);
1040
+ return bytes;
1041
+ }
1042
+ /** Validates the options of `WebSocketPlugin.configure()` (ZNIGHT050). */
1043
+ function resolveWebSocketOptions(options) {
1044
+ if (options === null || typeof options !== "object" || Array.isArray(options)) throw invalidOption("options", "an object", options);
1045
+ for (const key of Object.keys(options)) if (!OPTION_KEYS.has(key)) throw new ZnightError(ErrorCode.InvalidGateway, `WebSocketPlugin: unknown option "${key}"`, { hints: [`Known options: ${[...OPTION_KEYS].join(", ")}`] });
1046
+ const o = options;
1047
+ const transport = o.transport ?? "auto";
1048
+ if (transport !== "auto" && (transport === null || typeof transport !== "object" || typeof transport.accept !== "function")) throw invalidOption("transport", "'auto', wsTransport() or bunTransport()", transport);
1049
+ const backpressure = o.backpressure ?? {};
1050
+ if (backpressure === null || typeof backpressure !== "object") throw invalidOption("backpressure", "{ limit?, onLimit?: 'close' | 'drop' }", backpressure);
1051
+ const onLimit = backpressure.onLimit ?? "close";
1052
+ if (onLimit !== "close" && onLimit !== "drop") throw invalidOption("backpressure.onLimit", "'close' or 'drop'", onLimit);
1053
+ const heartbeat = o.heartbeat ?? { intervalMs: 3e4 };
1054
+ let heartbeatMs = 0;
1055
+ if (heartbeat !== false) {
1056
+ if (heartbeat === null || typeof heartbeat !== "object") throw invalidOption("heartbeat", "{ intervalMs } or false", heartbeat);
1057
+ heartbeatMs = positiveInteger("heartbeat.intervalMs", heartbeat.intervalMs, 3e4);
1058
+ }
1059
+ const shutdown = o.shutdown ?? {};
1060
+ if (shutdown === null || typeof shutdown !== "object") throw invalidOption("shutdown", "{ code?, reason?, timeoutMs? }", shutdown);
1061
+ const code = shutdown.code ?? 1001;
1062
+ if (!isCloseCode(code)) throw invalidOption("shutdown.code", "a close code (1000, 1001, 1012, 3000-4999)", code);
1063
+ const reason = shutdown.reason ?? "Server shutting down";
1064
+ if (typeof reason !== "string" || Buffer.byteLength(reason) > 123) throw invalidOption("shutdown.reason", "a string of at most 123 bytes", reason);
1065
+ const codec = o.codec ?? jsonCodec;
1066
+ if (codec === null || typeof codec !== "object" || typeof codec.decode !== "function" || typeof codec.encode !== "function") throw invalidOption("codec", "{ decode(frame, binary), encode(message) }", codec);
1067
+ if (o.exposeErrors !== void 0 && typeof o.exposeErrors !== "boolean") throw invalidOption("exposeErrors", "a boolean", o.exposeErrors);
1068
+ return Object.freeze({
1069
+ transport,
1070
+ origins: resolveOrigins(o.origins ?? "same-origin"),
1071
+ maxPayload: byteSize("maxPayload", o.maxPayload, 1048576),
1072
+ maxConnections: positiveInteger("maxConnections", o.maxConnections, Number.POSITIVE_INFINITY, true),
1073
+ maxPendingMessages: positiveInteger("maxPendingMessages", o.maxPendingMessages, 32),
1074
+ backpressure: Object.freeze({
1075
+ limit: byteSize("backpressure.limit", backpressure.limit, 1048576),
1076
+ onLimit
1077
+ }),
1078
+ heartbeatMs,
1079
+ handshakeTimeoutMs: positiveInteger("handshakeTimeoutMs", o.handshakeTimeoutMs, 1e4, true),
1080
+ handlerTimeoutMs: positiveInteger("handlerTimeoutMs", o.handlerTimeoutMs, 3e4, true),
1081
+ closeTimeoutMs: positiveInteger("closeTimeoutMs", o.closeTimeoutMs, 2e3),
1082
+ codec,
1083
+ messageGuards: refs("messageGuards", o.messageGuards, "canHandle"),
1084
+ exceptionMappers: refs("exceptionMappers", o.exceptionMappers, "map"),
1085
+ exposeErrors: o.exposeErrors ?? false,
1086
+ shutdown: Object.freeze({
1087
+ code,
1088
+ reason,
1089
+ timeoutMs: positiveInteger("shutdown.timeoutMs", shutdown.timeoutMs, 3e3)
1090
+ })
1091
+ });
1092
+ }
1093
+ /** Whether a close code may be sent by a server (RFC 6455 §7.4). */
1094
+ function isCloseCode(code) {
1095
+ if (typeof code !== "number" || !Number.isInteger(code)) return false;
1096
+ if (code >= 3e3 && code <= 4999) return true;
1097
+ return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006;
1098
+ }
1099
+ function refs(name, value, method) {
1100
+ if (value === void 0) return [];
1101
+ if (!Array.isArray(value)) throw invalidOption(name, `an array of classes or instances with ${method}()`, value);
1102
+ for (const ref of value) if (!(typeof ref === "function" ? typeof ref.prototype?.[method] === "function" : ref !== null && typeof ref === "object" && typeof ref[method] === "function")) throw invalidOption(name, `an array of classes or instances with ${method}()`, ref);
1103
+ return Object.freeze([...value]);
1104
+ }
1105
+ /** Validates the `origins` option (RFC 0003 §5.6), with the rules of the `cors.origin` option of `HttpPlugin`. */
1106
+ function resolveOrigins(value) {
1107
+ if (value === "same-origin") return {
1108
+ sameOrigin: true,
1109
+ allows: (origin, ctx) => origin === ownOrigin(ctx)
1110
+ };
1111
+ if (typeof value === "function") return {
1112
+ sameOrigin: false,
1113
+ allows: value
1114
+ };
1115
+ const items = Array.isArray(value) ? value : [value];
1116
+ const exact = /* @__PURE__ */ new Set();
1117
+ const patterns = [];
1118
+ for (const item of items) {
1119
+ if (item === "*") return {
1120
+ sameOrigin: false,
1121
+ allows: () => true
1122
+ };
1123
+ if (typeof item === "string") exact.add(checkOrigin(item));
1124
+ else if (item instanceof RegExp) patterns.push(checkPattern(item));
1125
+ else throw invalidOption("origins", ORIGINS, value);
1126
+ }
1127
+ return {
1128
+ sameOrigin: false,
1129
+ allows: (origin) => exact.has(origin) || patterns.some((pattern) => pattern.test(origin))
1130
+ };
1131
+ }
1132
+ const ORIGINS = "'same-origin', '*', an origin, an anchored RegExp, a list of them or a function";
1133
+ /** The origin of the request itself (`trustProxy` applies to `ctx.protocol`). */
1134
+ function ownOrigin(ctx) {
1135
+ return `${ctx.protocol}://${ctx.header("host") ?? ""}`;
1136
+ }
1137
+ function checkOrigin(value) {
1138
+ if (value === "null") throw invalidOption("origins", "a real origin", value, ["The \"null\" origin is shared by sandboxed iframes, local files and redirects: never trust it"]);
1139
+ let url;
1140
+ try {
1141
+ url = new URL(value);
1142
+ } catch {
1143
+ url = void 0;
1144
+ }
1145
+ if (url === void 0 || url.protocol !== "http:" && url.protocol !== "https:") throw invalidOption("origins", "an http(s) origin such as \"https://app.example.com\"", value);
1146
+ if (url.origin !== value) throw invalidOption("origins", `an origin without path, trailing slash or default port ("${url.origin}")`, value);
1147
+ return value;
1148
+ }
1149
+ function checkPattern(pattern) {
1150
+ const source = pattern.source;
1151
+ const trailingBackslashes = /\\*\$$/.exec(source)?.[0].length ?? 0;
1152
+ const anchoredEnd = source.endsWith("$") && (trailingBackslashes - 1) % 2 === 0;
1153
+ if (!source.startsWith("^") || !anchoredEnd || /(^|[^\\])\|/.test(source.replace(/\([^)]*\)|\[[^\]]*\]/g, ""))) throw invalidOption("origins", "a RegExp anchored with ^ and $ (around every alternative)", pattern, ["Without anchors, /example\\.com/ also allows https://example.com.evil.net"]);
1154
+ if (pattern.global || pattern.sticky) throw invalidOption("origins", "a RegExp without the g or y flag (test() would be stateful)", pattern);
1155
+ return pattern;
1156
+ }
1157
+ // src/rate-limit.ts
1158
+ /** The token bucket of `@Gateway({ rateLimit })` (RFC 0003 §8.6), one per connection. */
1159
+ var TokenBucket = class {
1160
+ #capacity;
1161
+ #perMs;
1162
+ #tokens;
1163
+ #updatedAt;
1164
+ /** `messages` per `intervalMs`, refilled continuously; full at first. */
1165
+ constructor(messages, intervalMs, now = performance.now()) {
1166
+ this.#capacity = messages;
1167
+ this.#perMs = messages / intervalMs;
1168
+ this.#tokens = messages;
1169
+ this.#updatedAt = now;
1170
+ }
1171
+ /** Takes one token: `false` when the bucket is empty. */
1172
+ take(now = performance.now()) {
1173
+ const elapsed = now - this.#updatedAt;
1174
+ if (elapsed > 0) {
1175
+ this.#tokens = Math.min(this.#capacity, this.#tokens + elapsed * this.#perMs);
1176
+ this.#updatedAt = now;
1177
+ }
1178
+ if (this.#tokens < 1) return false;
1179
+ this.#tokens -= 1;
1180
+ return true;
1181
+ }
1182
+ };
1183
+ // src/rooms.ts
1184
+ function list(value) {
1185
+ return typeof value === "string" ? [value] : value;
1186
+ }
1187
+ /** The members and rooms of one gateway. */
1188
+ var RoomRegistry = class {
1189
+ #members = /* @__PURE__ */ new Map();
1190
+ #rooms = /* @__PURE__ */ new Map();
1191
+ /** Encodes the events of broadcasts (once per broadcast). */
1192
+ codec;
1193
+ constructor(codec = jsonCodec) {
1194
+ this.codec = codec;
1195
+ }
1196
+ /** Open members. */
1197
+ get size() {
1198
+ return this.#members.size;
1199
+ }
1200
+ add(member) {
1201
+ this.#members.set(member.id, member);
1202
+ }
1203
+ /** Removes the member and leaves its rooms. */
1204
+ remove(member) {
1205
+ for (const room of [...member.roomSet]) this.leave(member, room);
1206
+ this.#members.delete(member.id);
1207
+ }
1208
+ get(id) {
1209
+ return this.#members.get(id);
1210
+ }
1211
+ members() {
1212
+ return this.#members.values();
1213
+ }
1214
+ join(member, room) {
1215
+ if (!this.#members.has(member.id)) return;
1216
+ let set = this.#rooms.get(room);
1217
+ if (set === void 0) {
1218
+ set = /* @__PURE__ */ new Set();
1219
+ this.#rooms.set(room, set);
1220
+ }
1221
+ set.add(member);
1222
+ member.roomSet.add(room);
1223
+ }
1224
+ leave(member, room) {
1225
+ const set = this.#rooms.get(room);
1226
+ member.roomSet.delete(room);
1227
+ if (set === void 0) return;
1228
+ set.delete(member);
1229
+ if (set.size === 0) this.#rooms.delete(room);
1230
+ }
1231
+ /** The members of `room` (a read-only view). */
1232
+ room(room) {
1233
+ return this.#rooms.get(room) ?? /* @__PURE__ */ new Set();
1234
+ }
1235
+ /** Room → size. */
1236
+ rooms() {
1237
+ const sizes = /* @__PURE__ */ new Map();
1238
+ for (const [name, set] of this.#rooms) sizes.set(name, set.size);
1239
+ return sizes;
1240
+ }
1241
+ /** A broadcast over every member, or over the members of `rooms`. */
1242
+ select(rooms, except = []) {
1243
+ return new BroadcastImpl(this, {
1244
+ rooms: rooms === void 0 ? void 0 : list(rooms),
1245
+ except: new Set(except),
1246
+ exceptRooms: []
1247
+ });
1248
+ }
1249
+ /** @internal The members a selection designates, each once. */
1250
+ resolve(selection) {
1251
+ const excluded = new Set(selection.except);
1252
+ for (const room of selection.exceptRooms) for (const member of this.room(room)) excluded.add(member.id);
1253
+ const result = [];
1254
+ const seen = /* @__PURE__ */ new Set();
1255
+ const sources = selection.rooms === void 0 ? [this.#members.values()] : selection.rooms.map((r) => this.room(r));
1256
+ for (const source of sources) for (const member of source) {
1257
+ if (seen.has(member.id) || excluded.has(member.id)) continue;
1258
+ seen.add(member.id);
1259
+ result.push(member);
1260
+ }
1261
+ return result;
1262
+ }
1263
+ };
1264
+ var BroadcastImpl = class BroadcastImpl {
1265
+ #registry;
1266
+ #selection;
1267
+ constructor(registry, selection) {
1268
+ this.#registry = registry;
1269
+ this.#selection = selection;
1270
+ }
1271
+ to(room) {
1272
+ const rooms = [...this.#selection.rooms ?? [], ...list(room)];
1273
+ return new BroadcastImpl(this.#registry, {
1274
+ ...this.#selection,
1275
+ rooms
1276
+ });
1277
+ }
1278
+ except(target) {
1279
+ const refs = Array.isArray(target) ? target : [target];
1280
+ const except = new Set(this.#selection.except);
1281
+ for (const ref of refs) except.add(typeof ref === "string" ? ref : ref.id);
1282
+ return new BroadcastImpl(this.#registry, {
1283
+ ...this.#selection,
1284
+ except
1285
+ });
1286
+ }
1287
+ exceptRoom(room) {
1288
+ return new BroadcastImpl(this.#registry, {
1289
+ ...this.#selection,
1290
+ exceptRooms: [...this.#selection.exceptRooms, ...list(room)]
1291
+ });
1292
+ }
1293
+ emit(event, data) {
1294
+ this.send(this.#registry.codec.encode({
1295
+ kind: "event",
1296
+ event,
1297
+ data
1298
+ }));
1299
+ }
1300
+ send(frame) {
1301
+ for (const member of this.#registry.resolve(this.#selection)) member.sendFrame(frame);
1302
+ }
1303
+ join(room) {
1304
+ for (const member of this.#registry.resolve(this.#selection)) this.#registry.join(member, room);
1305
+ }
1306
+ leave(room) {
1307
+ for (const member of this.#registry.resolve(this.#selection)) this.#registry.leave(member, room);
1308
+ }
1309
+ close(code, reason) {
1310
+ for (const member of this.#registry.resolve(this.#selection)) member.close(code, reason);
1311
+ }
1312
+ };
1313
+ // src/tokens.ts
1314
+ /**
1315
+ * Tokens of the values `@znight/websocket` supplies to the container. `WsConnection` is a
1316
+ * request-scoped external: the request scope of a WebSocket handshake lasts as long as its
1317
+ * connection. Inject it in request-scoped providers (`@Gateway({ scope: 'request' })`) or through
1318
+ * `Lazy<WsConnection>`.
1319
+ */
1320
+ const WsTokens = Object.freeze({
1321
+ Server: "@znight/websocket#WsServer",
1322
+ Plugin: "@znight/websocket#WebSocketPlugin",
1323
+ Connection: "@znight/websocket#WsConnection"
1324
+ });
1325
+ // src/transports/memory.ts
1326
+ /**
1327
+ * The client side of an in-memory connection: pass it as the `peer` of a memory upgrade
1328
+ * (`{ kind: 'memory', peer: client }`), then read `received` and send frames.
1329
+ */
1330
+ var MemoryClient = class {
1331
+ /** Frames received from the server, in order. */
1332
+ received = [];
1333
+ /** Called for each frame received from the server. */
1334
+ onmessage;
1335
+ /** Called once when the connection closes. */
1336
+ onclose;
1337
+ #protocol = "";
1338
+ #closed;
1339
+ #server;
1340
+ /** The subprotocol the server selected, `''` when none. */
1341
+ get protocol() {
1342
+ return this.#protocol;
1343
+ }
1344
+ /** How the connection closed, `undefined` while open. */
1345
+ get closed() {
1346
+ return this.#closed;
1347
+ }
1348
+ /** Whether the server accepted the connection. */
1349
+ get open() {
1350
+ return this.#server !== void 0 && this.#closed === void 0;
1351
+ }
1352
+ /** Sends a frame to the server (a string is a text frame). */
1353
+ send(frame) {
1354
+ if (this.#server === void 0 || this.#closed !== void 0) throw new Error("The connection is not open");
1355
+ this.#server.receive(frame);
1356
+ }
1357
+ /** Closes the connection from the client side. */
1358
+ close(code = 1e3, reason = "") {
1359
+ if (this.#server === void 0 || this.#closed !== void 0) return;
1360
+ this.#server.closeFromPeer(code, reason);
1361
+ }
1362
+ /** @internal Bound by the memory transport. */
1363
+ attach(server, protocol) {
1364
+ if (this.#server !== void 0) throw new Error("This MemoryClient is already connected");
1365
+ this.#server = server;
1366
+ this.#protocol = protocol;
1367
+ }
1368
+ /** @internal */
1369
+ deliver(frame) {
1370
+ this.received.push(frame);
1371
+ this.onmessage?.(frame);
1372
+ }
1373
+ /** @internal */
1374
+ closedWith(info) {
1375
+ if (this.#closed !== void 0) return;
1376
+ this.#closed = info;
1377
+ this.onclose?.(info);
1378
+ }
1379
+ };
1380
+ /** The server side of an in-memory connection. */
1381
+ var MemorySocket = class {
1382
+ bufferedAmount = 0;
1383
+ extensions = "";
1384
+ #client;
1385
+ #events;
1386
+ #queue = [];
1387
+ #paused = false;
1388
+ #closed = false;
1389
+ constructor(client, events) {
1390
+ this.#client = client;
1391
+ this.#events = events;
1392
+ }
1393
+ send(frame, _binary) {
1394
+ if (this.#closed) return "dropped";
1395
+ this.#client.deliver(frame);
1396
+ return "sent";
1397
+ }
1398
+ ping() {
1399
+ if (!this.#closed) this.#events.pong();
1400
+ }
1401
+ pause() {
1402
+ this.#paused = true;
1403
+ return true;
1404
+ }
1405
+ resume() {
1406
+ this.#paused = false;
1407
+ while (!this.#paused && !this.#closed && this.#queue.length > 0) this.#dispatch(this.#queue.shift());
1408
+ }
1409
+ close(code, reason) {
1410
+ this.#finish(code, reason);
1411
+ }
1412
+ terminate() {
1413
+ this.#finish(1006, "");
1414
+ }
1415
+ receive(frame) {
1416
+ if (this.#closed) return;
1417
+ if (this.#paused) this.#queue.push(frame);
1418
+ else this.#dispatch(frame);
1419
+ }
1420
+ closeFromPeer(code, reason) {
1421
+ this.#finish(code, reason);
1422
+ }
1423
+ #dispatch(frame) {
1424
+ this.#events.message(frame, typeof frame !== "string");
1425
+ }
1426
+ #finish(code, reason) {
1427
+ if (this.#closed) return;
1428
+ this.#closed = true;
1429
+ this.#queue.length = 0;
1430
+ this.#client.closedWith({
1431
+ code,
1432
+ reason
1433
+ });
1434
+ this.#events.close(code, reason);
1435
+ }
1436
+ };
1437
+ /** The in-memory transport: completes `{ kind: 'memory', peer }` upgrades whose peer is a {@link MemoryClient}. */
1438
+ function memoryTransport() {
1439
+ const sockets = /* @__PURE__ */ new Set();
1440
+ return {
1441
+ name: "memory",
1442
+ accepts: "memory",
1443
+ configure: () => void 0,
1444
+ accept(native, init, events) {
1445
+ if (native.kind !== "memory" || !(native.peer instanceof MemoryClient)) throw new Error("The memory transport only completes memory upgrades whose peer is a MemoryClient");
1446
+ const socket = new MemorySocket(native.peer, {
1447
+ message: (frame, binary) => events.message(frame, binary),
1448
+ pong: () => events.pong(),
1449
+ drain: () => events.drain(),
1450
+ close: (code, reason) => {
1451
+ sockets.delete(socket);
1452
+ events.close(code, reason);
1453
+ }
1454
+ });
1455
+ sockets.add(socket);
1456
+ native.peer.attach(socket, init.protocol);
1457
+ return socket;
1458
+ },
1459
+ close() {
1460
+ for (const socket of [...sockets]) socket.terminate();
1461
+ return Promise.resolve();
1462
+ }
1463
+ };
1464
+ }
1465
+ // src/plugin.ts
1466
+ /** The current async context (the handshake's request scope), to run the socket events in it. */
1467
+ function captureContext() {
1468
+ const snapshot = AsyncLocalStorage.snapshot;
1469
+ return typeof snapshot === "function" ? snapshot() : (run) => run();
1470
+ }
1471
+ const CONNECTION_HOOK = "websocket:connection";
1472
+ /**
1473
+ * The WebSocket plugin: `WebSocketPlugin.configure(options)` (name `websocket`). Requires
1474
+ * `HttpPlugin`: gateways are served on its server and port, whatever the plugin order.
1475
+ *
1476
+ * ```ts
1477
+ * await Application.create({
1478
+ * metadata: __ZNIGHT_METADATA__,
1479
+ * plugins: [HttpPlugin.configure({ port: 3000 }), WebSocketPlugin.configure({ maxPayload: '64kb' })],
1480
+ * })
1481
+ * ```
1482
+ */
1483
+ var WebSocketPlugin = class WebSocketPlugin {
1484
+ name = "websocket";
1485
+ /** The `WsServer` of the application (also injectable). */
1486
+ server = new WsServer();
1487
+ #options;
1488
+ #connections = /* @__PURE__ */ new Map();
1489
+ /** Connections whose heartbeat the plugin drives (the `ws` transport). */
1490
+ #beating = /* @__PURE__ */ new Set();
1491
+ #stats = new StatsCounter();
1492
+ #ctx;
1493
+ #resolved;
1494
+ #plans;
1495
+ #transport;
1496
+ #memory;
1497
+ #heartbeat;
1498
+ #draining = false;
1499
+ /** Handshakes past their guards and not yet registered: they count against `maxConnections`. */
1500
+ #accepting = 0;
1501
+ #drain;
1502
+ #active = false;
1503
+ constructor(options) {
1504
+ this.#options = options;
1505
+ }
1506
+ /**
1507
+ * Creates the plugin. `options` may be a function of the container, evaluated once the container
1508
+ * is initialised: `WebSocketPlugin.configure(c => ({ origins: c.get(ServerConfig).webOrigins }))`.
1509
+ */
1510
+ static configure(options = {}) {
1511
+ return new WebSocketPlugin(options);
1512
+ }
1513
+ /** Name of the transport (`ws`, `bun`), `undefined` before `HttpPlugin` collected the upgrade routes. */
1514
+ get transport() {
1515
+ return this.#transport?.name;
1516
+ }
1517
+ /** The served gateways (after bootstrap). */
1518
+ get gateways() {
1519
+ return (this.#plans ?? []).map((plan) => Object.freeze({
1520
+ path: plan.path,
1521
+ gateway: plan.name,
1522
+ protocol: plan.definition.protocol,
1523
+ events: Object.freeze(plan.definition.handlers.flatMap((handler) => handler.event ?? []))
1524
+ }));
1525
+ }
1526
+ /** Open connections of every gateway. */
1527
+ get connections() {
1528
+ return this.#connections.size;
1529
+ }
1530
+ /** Totals since the application started. */
1531
+ stats() {
1532
+ return this.#stats.snapshot(this.#connections.size);
1533
+ }
1534
+ /**
1535
+ * Providers `TestApp` creates at boot: the classes of the `messageGuards` and `exceptionMappers`
1536
+ * options, and the gateways of the metadata with their guards.
1537
+ */
1538
+ initRoots(context) {
1539
+ const roots = /* @__PURE__ */ new Set();
1540
+ try {
1541
+ const options = this.#options;
1542
+ if (options !== null && typeof options === "object") for (const key of ["messageGuards", "exceptionMappers"]) {
1543
+ const refs = options[key];
1544
+ if (Array.isArray(refs)) {
1545
+ for (const ref of refs) if (typeof ref === "function") roots.add(ref);
1546
+ }
1547
+ }
1548
+ const gateways = (context?.metadata.extensions?.websocket)?.gateways;
1549
+ if (Array.isArray(gateways)) for (const gateway of gateways) {
1550
+ if (typeof gateway?.token !== "string") continue;
1551
+ roots.add(gateway.token);
1552
+ const handlers = Array.isArray(gateway.handlers) ? gateway.handlers : [];
1553
+ const tokens = [
1554
+ ...gateway.handshakeGuards ?? [],
1555
+ ...gateway.messageGuards ?? [],
1556
+ ...handlers.flatMap((handler) => handler?.guards ?? [])
1557
+ ];
1558
+ for (const token of tokens) if (typeof token === "string") roots.add(token);
1559
+ }
1560
+ } catch {}
1561
+ return [...roots];
1562
+ }
1563
+ register(ctx) {
1564
+ if (this.#active) throw new ZnightError(ErrorCode.DuplicateProvider, "This WebSocketPlugin is already used by another application", { hints: ["Call WebSocketPlugin.configure() once per application"] });
1565
+ if (ctx.getPlugin("http") === void 0) throw new ZnightError(ErrorCode.WebSocketTransportUnavailable, "WebSocketPlugin needs HttpPlugin: gateways are served by the HTTP server", { hints: ["Add HttpPlugin.configure() to the plugins (without controllers for a WebSocket-only service)"] });
1566
+ this.#active = true;
1567
+ this.#ctx = ctx;
1568
+ this.#resolved = void 0;
1569
+ this.#plans = void 0;
1570
+ this.#transport = void 0;
1571
+ this.#memory = void 0;
1572
+ this.#draining = false;
1573
+ this.#drain = void 0;
1574
+ this.#stats = new StatsCounter();
1575
+ this.server.reset();
1576
+ ctx.container.registerExternal({
1577
+ token: WsTokens.Connection,
1578
+ scope: "request",
1579
+ providedBy: "WebSocketPlugin"
1580
+ });
1581
+ ctx.container.registerValue(WsTokens.Server, this.server);
1582
+ ctx.container.registerValue(WsTokens.Plugin, this);
1583
+ }
1584
+ onBootstrap(ctx) {
1585
+ this.#ctx = ctx;
1586
+ this.#resolve();
1587
+ gatewaysOf(ctx.metadata);
1588
+ }
1589
+ /**
1590
+ * The `PluginHttpUpgrades` contract of `@znight/http`: one upgrade route per gateway, and the
1591
+ * `Bun.serve()` settings of the Bun transport. Called once by `HttpPlugin.onBootstrap`. No route
1592
+ * (and no transport) without gateways.
1593
+ */
1594
+ httpUpgrades(context) {
1595
+ const plans = this.#prepare(context);
1596
+ if (plans.length === 0) return { routes: [] };
1597
+ const extensions = this.#context().metadata.extensions;
1598
+ const groups = {
1599
+ ...(extensions?.http)?.groups,
1600
+ ...(extensions?.websocket)?.groups
1601
+ };
1602
+ const options = this.#resolve();
1603
+ const routes = plans.map((plan) => ({
1604
+ path: plan.definition.path,
1605
+ name: plan.name,
1606
+ owner: plan.definition.token,
1607
+ meta: metaEntries(plan.definition.meta, groups),
1608
+ guards: plan.definition.handshakeGuards,
1609
+ timeoutMs: options.handshakeTimeoutMs,
1610
+ precheck: (ctx) => this.#precheck(plan, ctx),
1611
+ accept: (ctx, upgrade) => this.#accept(plan, ctx, upgrade)
1612
+ }));
1613
+ const bunWebSocket = this.#transport?.bunWebSocket?.();
1614
+ return bunWebSocket === void 0 ? { routes } : {
1615
+ routes,
1616
+ bunWebSocket
1617
+ };
1618
+ }
1619
+ async onStart() {
1620
+ const transport = this.#transport;
1621
+ if (transport === void 0) return;
1622
+ await transport.ready?.();
1623
+ const intervalMs = this.#resolve().heartbeatMs;
1624
+ if (intervalMs > 0 && transport.accepts === "node") {
1625
+ this.#heartbeat = setInterval(() => {
1626
+ for (const conn of this.#beating) conn.heartbeat();
1627
+ }, intervalMs);
1628
+ this.#heartbeat.unref?.();
1629
+ }
1630
+ }
1631
+ /**
1632
+ * The `PluginHttpRoutes` contract: `HttpPlugin` starts draining, whatever the plugin order.
1633
+ * Refuses new handshakes and closes every connection gracefully (see {@link onStop}).
1634
+ */
1635
+ onHttpDrain() {
1636
+ return this.#shutdown();
1637
+ }
1638
+ /**
1639
+ * Refuses handshakes (`503`), closes every connection with `shutdown.code`, waits for close
1640
+ * frames, running handlers and `@OnDisconnect` up to `shutdown.timeoutMs`, then terminates the
1641
+ * rest. Idempotent: the same drain as {@link onHttpDrain}.
1642
+ */
1643
+ onStop() {
1644
+ return this.#shutdown();
1645
+ }
1646
+ async onDispose() {
1647
+ this.#stopHeartbeat();
1648
+ try {
1649
+ await this.#shutdown();
1650
+ await Promise.all([this.#transport?.close(), this.#memory?.close()]);
1651
+ } finally {
1652
+ this.#active = false;
1653
+ this.#beating.clear();
1654
+ this.#connections.clear();
1655
+ this.#plans = void 0;
1656
+ }
1657
+ }
1658
+ /**
1659
+ * Opens an in-memory connection (tests): the real handshake through the HTTP pipeline (guards,
1660
+ * hooks, origin and subprotocol checks) with an in-memory request, then the in-memory transport.
1661
+ * `path` is the URL path, HTTP prefix included. Rejects with {@link WsHandshakeError} when the
1662
+ * handshake is refused.
1663
+ *
1664
+ * ```ts
1665
+ * await using alice = await plugin.connect('/chat', { protocols: ['chat.v1'] })
1666
+ * expect(await alice.request('room.join', { room: 'general' })).toEqual({ members: 1 })
1667
+ * ```
1668
+ */
1669
+ async connect(path, options = {}) {
1670
+ const http = this.#ctx?.getPlugin("http");
1671
+ if (http === void 0 || this.#plans === void 0) throw new ZnightError(ErrorCode.LifecycleFailed, "WebSocketPlugin is not bootstrapped yet", { hints: ["connect() is available once Application.create() resolved"] });
1672
+ const headers = { ...options.headers };
1673
+ if (options.protocols !== void 0 && options.protocols.length > 0) headers["sec-websocket-protocol"] = options.protocols.join(", ");
1674
+ if (options.origin !== void 0) headers["origin"] = options.origin;
1675
+ const peer = new MemoryClient();
1676
+ const refused = await http.upgradeInMemory({
1677
+ method: "GET",
1678
+ url: path,
1679
+ headers,
1680
+ ...options.query === void 0 ? {} : { query: options.query },
1681
+ ...options.cookies === void 0 ? {} : { cookies: options.cookies }
1682
+ }, {
1683
+ kind: "memory",
1684
+ peer
1685
+ });
1686
+ if (refused !== void 0) throw new WsHandshakeError(refused);
1687
+ return new WsClient(peer);
1688
+ }
1689
+ #context() {
1690
+ const ctx = this.#ctx;
1691
+ if (ctx === void 0) throw new ZnightError(ErrorCode.LifecycleFailed, "WebSocketPlugin is not registered in an application");
1692
+ return ctx;
1693
+ }
1694
+ #resolve() {
1695
+ if (this.#resolved !== void 0) return this.#resolved;
1696
+ const container = this.#context().container;
1697
+ this.#resolved = resolveWebSocketOptions(resolveOptions(this.#options, container));
1698
+ return this.#resolved;
1699
+ }
1700
+ /** Builds the gateway plans and selects the transport, once. */
1701
+ #prepare(context) {
1702
+ if (this.#plans !== void 0) return this.#plans;
1703
+ const ctx = this.#context();
1704
+ const options = this.#resolve();
1705
+ const definitions = gatewaysOf(ctx.metadata);
1706
+ if (definitions.length === 0) {
1707
+ this.#plans = [];
1708
+ return this.#plans;
1709
+ }
1710
+ const transport = selectTransport(options, context);
1711
+ let maxPayload = options.maxPayload;
1712
+ let backpressureLimit = options.backpressure.limit;
1713
+ for (const definition of definitions) {
1714
+ maxPayload = Math.max(maxPayload, definition.maxPayload ?? 0);
1715
+ backpressureLimit = Math.max(backpressureLimit, definition.backpressure?.limit ?? 0);
1716
+ }
1717
+ transport.configure({
1718
+ maxPayload,
1719
+ backpressureLimit,
1720
+ heartbeatMs: options.heartbeatMs
1721
+ });
1722
+ this.#transport = transport;
1723
+ const refs = new Refs(ctx.container, ctx.metadata);
1724
+ const globalGuards = options.messageGuards.map((ref) => refs.guard(ref, "global message guard"));
1725
+ const mappers = options.exceptionMappers.map((ref) => refs.mapper(ref));
1726
+ const plans = definitions.map((definition) => {
1727
+ const name = displayName(definition.token);
1728
+ const path = joinPath(context.prefix, definition.path);
1729
+ const classGuards = definition.messageGuards.map((token) => refs.guard(token, `message guard of ${name}`));
1730
+ const handlers = definition.handlers.map((handler) => ({
1731
+ definition: handler,
1732
+ guards: [
1733
+ ...globalGuards,
1734
+ ...classGuards,
1735
+ ...handler.guards.map((token) => refs.guard(token, `message guard of ${name}.${handler.method}`))
1736
+ ],
1737
+ meta: Object.freeze({
1738
+ ...definition.meta,
1739
+ ...handler.meta
1740
+ })
1741
+ }));
1742
+ const requestScoped = ctx.metadata.providers[definition.token]?.scope === "request";
1743
+ const instance = gatewayInstance(ctx.container, definition.token, requestScoped);
1744
+ const rooms = new RoomRegistry(options.codec);
1745
+ const backpressure = definition.backpressure;
1746
+ const gatewayMaxPayload = definition.maxPayload ?? options.maxPayload;
1747
+ const plan = {
1748
+ definition,
1749
+ name,
1750
+ path,
1751
+ rooms,
1752
+ maxPayload: gatewayMaxPayload,
1753
+ limits: {
1754
+ maxPayload: gatewayMaxPayload,
1755
+ maxPendingMessages: options.maxPendingMessages,
1756
+ concurrency: definition.concurrency ?? 1,
1757
+ backpressureLimit: backpressure?.limit ?? options.backpressure.limit,
1758
+ onBackpressure: backpressure?.onLimit ?? options.backpressure.onLimit,
1759
+ closeTimeoutMs: options.closeTimeoutMs
1760
+ },
1761
+ rateLimit: definition.rateLimit,
1762
+ instance,
1763
+ requestScoped,
1764
+ dispatcher: new GatewayDispatcher({
1765
+ definition,
1766
+ path,
1767
+ handlers,
1768
+ instance,
1769
+ handlerTimeoutMs: options.handlerTimeoutMs,
1770
+ exposeErrors: options.exposeErrors,
1771
+ hooks: ctx.hooks,
1772
+ name,
1773
+ mappers,
1774
+ codec: options.codec,
1775
+ ...process.env["NODE_ENV"] === "production" ? {} : { ignoredResult: (handler, event) => ctx.logger.debug(`WebSocketPlugin: the result of ${name}.${handler} is ignored: "${event}" was sent without id (fire and forget)`, {
1776
+ gateway: name,
1777
+ handler,
1778
+ event
1779
+ }) },
1780
+ logError: (message, error, conn) => {
1781
+ ctx.logger.error(`WebSocketPlugin: ${message}`, {
1782
+ err: error,
1783
+ gateway: name,
1784
+ path,
1785
+ connectionId: conn.id,
1786
+ requestId: conn.requestId
1787
+ });
1788
+ },
1789
+ closed: (conn, info) => this.#closed(plan, conn, info)
1790
+ })
1791
+ };
1792
+ this.server.register({
1793
+ type: classOf(ctx.metadata, definition.token),
1794
+ declaredPath: definition.path,
1795
+ path,
1796
+ rooms
1797
+ });
1798
+ return plan;
1799
+ });
1800
+ this.#plans = plans;
1801
+ ctx.logger.debug("WebSocket gateways ready", {
1802
+ gateways: plans.length,
1803
+ transport: transport.name
1804
+ });
1805
+ return plans;
1806
+ }
1807
+ /** Origin, subprotocol and capacity checks, before the guards. */
1808
+ async #precheck(plan, ctx) {
1809
+ if (this.#draining) throw unavailable("The server is shutting down");
1810
+ const options = this.#resolve();
1811
+ const origin = ctx.header("origin");
1812
+ if (origin !== void 0) {
1813
+ let allowed = false;
1814
+ try {
1815
+ allowed = await options.origins.allows(origin, ctx) === true;
1816
+ } catch {
1817
+ allowed = false;
1818
+ }
1819
+ if (!allowed) throw new ForbiddenException("Origin not allowed");
1820
+ }
1821
+ selectProtocol(plan, ctx);
1822
+ if (this.#full(options)) throw unavailable("Too many WebSocket connections");
1823
+ }
1824
+ #full(options) {
1825
+ return this.#connections.size + this.#accepting >= options.maxConnections;
1826
+ }
1827
+ /** After the guards: completes the upgrade and starts the connection in the handshake's scope. */
1828
+ async #accept(plan, ctx, upgrade) {
1829
+ if (this.#draining) throw unavailable("The server is shutting down");
1830
+ const options = this.#resolve();
1831
+ if (this.#full(options)) throw unavailable("Too many WebSocket connections");
1832
+ this.#accepting++;
1833
+ let opened;
1834
+ try {
1835
+ opened = await this.#open(plan, ctx, upgrade);
1836
+ } finally {
1837
+ this.#accepting--;
1838
+ }
1839
+ const { conn, socket } = opened;
1840
+ const ws = this.#context();
1841
+ const openedAt = performance.now();
1842
+ this.#connections.set(conn, openedAt);
1843
+ if (upgrade.native.kind !== "memory" && this.#transport?.accepts === "node") this.#beating.add(conn);
1844
+ this.#stats.opened++;
1845
+ if (ws.hooks.hasListeners(CONNECTION_HOOK)) ws.hooks.emit(CONNECTION_HOOK, connectionEvent("open", plan, conn, openedAt, void 0));
1846
+ conn.start(socket);
1847
+ upgrade.hold(conn.finished);
1848
+ if (this.#draining) conn.closeWith(options.shutdown.code, options.shutdown.reason, "shutdown");
1849
+ }
1850
+ /** Creates the connection (and a request-scoped gateway) and completes the handshake. */
1851
+ async #open(plan, ctx, upgrade) {
1852
+ const ws = this.#context();
1853
+ const protocol = selectProtocol(plan, ctx);
1854
+ const rate = plan.rateLimit;
1855
+ const conn = new WsConnectionImpl({
1856
+ id: crypto.randomUUID(),
1857
+ gateway: plan.path,
1858
+ handshake: ctx,
1859
+ protocol,
1860
+ limits: rate === void 0 ? plan.limits : {
1861
+ ...plan.limits,
1862
+ rateLimit: {
1863
+ bucket: new TokenBucket(rate.messages, rate.intervalMs),
1864
+ onLimit: rate.onLimit
1865
+ }
1866
+ },
1867
+ rooms: plan.rooms,
1868
+ driver: plan.dispatcher,
1869
+ stats: this.#stats
1870
+ });
1871
+ ws.container.currentScope()?.set(WsTokens.Connection, conn);
1872
+ if (plan.requestScoped) await plan.instance(conn);
1873
+ const memory = upgrade.native.kind === "memory";
1874
+ let transport = this.#transport;
1875
+ if (memory) {
1876
+ this.#memory ??= memoryTransport();
1877
+ transport = this.#memory;
1878
+ }
1879
+ const run = captureContext();
1880
+ const events = conn.socketEvents();
1881
+ const scoped = {
1882
+ message: (frame, binary) => run(() => events.message(frame, binary)),
1883
+ pong: () => events.pong(),
1884
+ drain: () => events.drain(),
1885
+ close: (code, reason) => run(() => events.close(code, reason))
1886
+ };
1887
+ if (upgrade.expired) throw new HttpException(408, "WebSocket handshake timed out");
1888
+ if (this.#draining) throw unavailable("The server is shutting down");
1889
+ return {
1890
+ conn,
1891
+ socket: await transport.accept(upgrade.native, {
1892
+ protocol,
1893
+ headers: upgrade.headers,
1894
+ maxPayload: plan.maxPayload
1895
+ }, scoped)
1896
+ };
1897
+ }
1898
+ /** A connection fully closed (after `@OnDisconnect`, rooms left). */
1899
+ #closed(plan, conn, info) {
1900
+ const openedAt = this.#connections.get(conn) ?? performance.now();
1901
+ this.#connections.delete(conn);
1902
+ this.#beating.delete(conn);
1903
+ this.#stats.closed++;
1904
+ const hooks = this.#ctx?.hooks;
1905
+ if (hooks?.hasListeners(CONNECTION_HOOK)) hooks.emit(CONNECTION_HOOK, connectionEvent("close", plan, conn, openedAt, info));
1906
+ }
1907
+ #shutdown() {
1908
+ this.#drain ??= this.#closeAll();
1909
+ return this.#drain;
1910
+ }
1911
+ async #closeAll() {
1912
+ this.#draining = true;
1913
+ this.#stopHeartbeat();
1914
+ const options = this.#resolved;
1915
+ const connections = [...this.#connections.keys()];
1916
+ if (options === void 0 || connections.length === 0) return;
1917
+ const { code, reason, timeoutMs } = options.shutdown;
1918
+ for (const conn of connections) conn.closeWith(code, reason, "shutdown");
1919
+ const all = Promise.all(connections.map((conn) => conn.finished));
1920
+ if (await within(all, timeoutMs)) return;
1921
+ for (const conn of connections) if (conn.readyState !== "closed") conn.terminateWith("shutdown");
1922
+ await within(all, options.closeTimeoutMs);
1923
+ }
1924
+ #stopHeartbeat() {
1925
+ if (this.#heartbeat !== void 0) clearInterval(this.#heartbeat);
1926
+ this.#heartbeat = void 0;
1927
+ }
1928
+ };
1929
+ /** Resolves when `task` settles (`true`) or after `ms` (`false`). */
1930
+ async function within(task, ms) {
1931
+ let timer;
1932
+ const timeout = new Promise((resolve) => {
1933
+ timer = setTimeout(() => resolve(false), ms);
1934
+ });
1935
+ try {
1936
+ return await Promise.race([task.then(() => true), timeout]);
1937
+ } finally {
1938
+ clearTimeout(timer);
1939
+ }
1940
+ }
1941
+ function unavailable(message) {
1942
+ return new HttpException(503, message, void 0, { headers: { "retry-after": "1" } });
1943
+ }
1944
+ function connectionEvent(type, plan, conn, openedAt, info) {
1945
+ return {
1946
+ type,
1947
+ gateway: plan.name,
1948
+ path: plan.path,
1949
+ connectionId: conn.id,
1950
+ requestId: conn.requestId,
1951
+ connection: conn,
1952
+ openedAt,
1953
+ ...info === void 0 ? {} : {
1954
+ code: info.code,
1955
+ reason: info.reason,
1956
+ initiator: info.initiator
1957
+ }
1958
+ };
1959
+ }
1960
+ /** The subprotocol of a handshake: the first of the gateway's `protocols` the client offers (400 otherwise). */
1961
+ function selectProtocol(plan, ctx) {
1962
+ const accepted = plan.definition.protocols;
1963
+ if (accepted === void 0 || accepted.length === 0) return "";
1964
+ const offered = (ctx.header("sec-websocket-protocol") ?? "").split(",").map((item) => item.trim()).filter((item) => item !== "");
1965
+ const selected = accepted.find((protocol) => offered.includes(protocol));
1966
+ if (selected === void 0) throw new BadRequestException("Unsupported WebSocket subprotocol");
1967
+ return selected;
1968
+ }
1969
+ /** The transport of the `transport` option for the native upgrades of the adapter (ZNIGHT051). */
1970
+ function selectTransport(options, context) {
1971
+ const kind = context.upgrades;
1972
+ if (kind === void 0) throw new ZnightError(ErrorCode.WebSocketTransportUnavailable, `WebSocketPlugin: the ${context.adapter} adapter cannot serve WebSocket upgrades`, { hints: ["Use a built-in adapter (node, fastify, express, hono) on a server Znight starts"] });
1973
+ let transport = options.transport;
1974
+ if (transport === "auto") transport = kind === "bun" ? bunTransport() : wsTransport();
1975
+ if (transport.accepts !== kind) throw new ZnightError(ErrorCode.WebSocketTransportUnavailable, `WebSocketPlugin: the ${transport.name} transport completes ${transport.accepts} upgrades, but the ${context.adapter} adapter produces ${kind} upgrades`, { hints: kind === "bun" ? ["Use bunTransport() (or transport: 'auto') with honoAdapter() on Bun"] : ["Use wsTransport() (or transport: 'auto') with the node:http adapters"] });
1976
+ return transport;
1977
+ }
1978
+ /** `prefix` (`''`, `/api`) joined with a normalized gateway path (`/chat`, `/`). */
1979
+ function joinPath(prefix, path) {
1980
+ if (prefix === "" || prefix === "/") return path;
1981
+ const base = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
1982
+ return path === "/" ? base : `${base}${path}`;
1983
+ }
1984
+ /** Route decorator metadata as upgrade route entries, with the groups of `metadata.extensions.http`. */
1985
+ function metaEntries(meta, groups) {
1986
+ return Object.keys(meta).sort().map((key) => {
1987
+ for (const [group, definition] of Object.entries(groups)) if (definition.keys.includes(key)) return {
1988
+ key,
1989
+ value: meta[key],
1990
+ group,
1991
+ required: definition.required
1992
+ };
1993
+ return {
1994
+ key,
1995
+ value: meta[key]
1996
+ };
1997
+ });
1998
+ }
1999
+ function classOf(metadata, token) {
2000
+ return metadata.providers[token]?.class;
2001
+ }
2002
+ /** The gateway instance of a connection: the singleton, or the request-scoped one of its handshake. */
2003
+ function gatewayInstance(container, token, requestScoped) {
2004
+ if (requestScoped) return () => container.get(token);
2005
+ let instance;
2006
+ return () => {
2007
+ instance ??= container.get(token);
2008
+ return instance;
2009
+ };
2010
+ }
2011
+ /** `metadata.extensions.websocket`, checked (ZNIGHT050): `[]` without gateways. */
2012
+ function gatewaysOf(metadata) {
2013
+ const section = metadata.extensions?.websocket;
2014
+ if (section === void 0) return [];
2015
+ const gateways = section?.gateways;
2016
+ if (!Array.isArray(gateways)) throw malformed("\"gateways\" must be an array");
2017
+ for (const gateway of gateways) {
2018
+ const where = typeof gateway?.token === "string" ? gateway.token : JSON.stringify(gateway);
2019
+ if (gateway === null || typeof gateway !== "object" || typeof gateway.token !== "string") throw malformed(`invalid gateway ${where}`);
2020
+ if (!Object.hasOwn(metadata.providers, gateway.token)) throw malformed(`the gateway ${where} is not a provider of the metadata`);
2021
+ if (typeof gateway.path !== "string" || !gateway.path.startsWith("/")) throw malformed(`invalid path of ${where}`);
2022
+ if (gateway.protocol !== "events" && gateway.protocol !== "raw") throw malformed(`invalid protocol of ${where}`);
2023
+ if (!Array.isArray(gateway.handlers) || !Array.isArray(gateway.handshakeGuards) || !Array.isArray(gateway.messageGuards)) throw malformed(`handlers, handshakeGuards and messageGuards of ${where} must be arrays`);
2024
+ for (const lifecycle of [gateway.onConnect, gateway.onDisconnect]) if (lifecycle !== void 0 && typeof lifecycle?.invoke !== "function") throw malformed(`invalid lifecycle handler of ${where}`);
2025
+ for (const handler of gateway.handlers) if (handler === null || typeof handler !== "object" || typeof handler.invoke !== "function" || !Array.isArray(handler.guards)) throw malformed(`invalid handler of ${where}`);
2026
+ }
2027
+ return gateways;
2028
+ }
2029
+ function malformed(reason) {
2030
+ return new ZnightError(ErrorCode.InvalidGateway, `WebSocketPlugin: malformed metadata.extensions.websocket: ${reason}`, { hints: ["Regenerate the container module (znight generate) with the same @znight/websocket version"] });
2031
+ }
2032
+ /**
2033
+ * Message guards and exception mappers resolved once at bootstrap: singletons to their instance,
2034
+ * request-scoped and transient providers per call, in the connection's scope.
2035
+ */
2036
+ var Refs = class {
2037
+ #container;
2038
+ #metadata;
2039
+ constructor(container, metadata) {
2040
+ this.#container = container;
2041
+ this.#metadata = metadata;
2042
+ }
2043
+ guard(ref, what) {
2044
+ const resolve = this.#resolve(ref, what, "canHandle");
2045
+ return typeof resolve === "function" ? { canHandle: (ctx) => resolve().canHandle(ctx) } : resolve;
2046
+ }
2047
+ mapper(ref) {
2048
+ const resolve = this.#resolve(ref, "exception mapper", "map");
2049
+ return typeof resolve === "function" ? { map: (error, ctx) => resolve().map(error, ctx) } : resolve;
2050
+ }
2051
+ /** The instance, or a function resolving it per call (request-scoped and transient providers). */
2052
+ #resolve(ref, what, method) {
2053
+ if (typeof ref !== "function" && typeof ref !== "string") return ref;
2054
+ const token = typeof ref === "string" ? ref : this.#container.tokenOf(ref);
2055
+ if (token === void 0 || !this.#container.has(token)) {
2056
+ const name = typeof ref === "string" ? displayName(ref) : ref.name || "<anonymous>";
2057
+ throw new ZnightError(ErrorCode.MissingProvider, `WebSocketPlugin: the ${what} ${name} is not a provider`, { hints: ["Add @Injectable() to the class, or pass an instance"] });
2058
+ }
2059
+ const definition = Object.hasOwn(this.#metadata.providers, token) ? this.#metadata.providers[token] : void 0;
2060
+ const cls = definition?.class;
2061
+ if (typeof cls === "function" && typeof cls.prototype?.[method] !== "function") throw new ZnightError(ErrorCode.InvalidGateway, `WebSocketPlugin: the ${what} ${displayName(token)} has no ${method}() method`);
2062
+ const scope = definition?.scope;
2063
+ if (scope === "request" || scope === "transient") return () => this.#container.get(token);
2064
+ return this.#container.get(token);
2065
+ }
2066
+ };
2067
+ // src/validate.ts
2068
+ /**
2069
+ * Validates a message payload with a Standard Schema and returns the schema output, synchronously
2070
+ * when the schema is synchronous.
2071
+ *
2072
+ * @throws WsValidationException (`BAD_REQUEST`, `details: [{ path, message }]`) when the payload is invalid.
2073
+ */
2074
+ function validatePayload(schema, value) {
2075
+ try {
2076
+ const result = validate(schema, value, "payload");
2077
+ return result instanceof Promise ? result.catch(rethrow) : result;
2078
+ } catch (error) {
2079
+ return rethrow(error);
2080
+ }
2081
+ }
2082
+ function rethrow(error) {
2083
+ if (error instanceof ValidationException) throw new WsValidationException(error.details);
2084
+ throw error;
2085
+ }
2086
+ export { Conn, Ctx, EVENT_NAME, Gateway, MemoryClient, OnConnect, OnDisconnect, OnMessage, Payload, RoomRegistry, TokenBucket, UseHandshakeGuard, UseMessageGuard, WebSocketPlugin, WsClient, WsCloseCode, WsClosedError, WsErrorCode, WsException, WsHandshakeError, WsRequestError, WsServer, WsTokens, WsValidationException, codeOfStatus, decodeFrame, encodeErrorEvent, encodeErrorReply, encodeEvent, encodeReply, isFailure, isMessageId, jsonCodec, memoryTransport, validatePayload };
2087
+
2088
+ //# sourceMappingURL=index.mjs.map