@znight/websocket 0.0.0 → 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.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/dist/bun.d.mts +4 -0
- package/dist/bun.mjs +112 -0
- package/dist/bun.mjs.map +1 -0
- package/dist/compiler.d.mts +90 -0
- package/dist/compiler.mjs +864 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +501 -0
- package/dist/index.mjs +2088 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server-CI_Bh9o1.d.mts +331 -0
- package/dist/server-D40_bC88.mjs +72 -0
- package/dist/server-D40_bC88.mjs.map +1 -0
- package/dist/testing.d.mts +60 -0
- package/dist/testing.mjs +189 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/transport-xnJqvB93.d.mts +63 -0
- package/dist/ws.d.mts +9 -0
- package/dist/ws.mjs +143 -0
- package/dist/ws.mjs.map +1 -0
- package/package.json +102 -1
- package/src/client.ts +246 -0
- package/src/compiler/analyze.ts +850 -0
- package/src/compiler/constants.ts +102 -0
- package/src/compiler/data.ts +78 -0
- package/src/compiler/generate.ts +262 -0
- package/src/compiler/index.ts +53 -0
- package/src/compiler/validate.ts +81 -0
- package/src/connection.ts +455 -0
- package/src/context.ts +43 -0
- package/src/decorators.ts +83 -0
- package/src/dispatcher.ts +343 -0
- package/src/envelope.ts +136 -0
- package/src/exceptions.ts +91 -0
- package/src/index.ts +89 -0
- package/src/metadata.ts +74 -0
- package/src/options.ts +343 -0
- package/src/plugin.ts +820 -0
- package/src/rate-limit.ts +27 -0
- package/src/rooms.ts +185 -0
- package/src/server.ts +125 -0
- package/src/testing/index.ts +279 -0
- package/src/tokens.ts +23 -0
- package/src/transport.ts +72 -0
- package/src/transports/bun.ts +163 -0
- package/src/transports/memory.ts +174 -0
- package/src/transports/ws.ts +237 -0
- package/src/validate.ts +31 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.mjs","names":[],"sources":["../src/testing/index.ts"],"sourcesContent":["/**\n * `@znight/websocket/testing` (RFC 0003 §5.10): fakes for unit tests of gateways and services.\n * `createTestConnection()` is a `WsConnection` recording what the code under test sends to it;\n * `createTestServer()` is a `WsServer` recording broadcasts instead of delivering them.\n *\n * ```ts\n * const server = createTestServer({ paths: [[ChatGateway, '/chat']] })\n * const { instance } = await TestBed.create(ChatGateway, { metadata, overrides: [[WsServer, server]] })\n * const conn = createTestConnection<ChatData, ChatEvents>({ data: { user: ALICE }, rooms: ['general'] })\n * await instance.send(conn, { room: 'general', text: 'hi' })\n * expect(server.broadcasts).toEqual([{ gateway: '/chat', rooms: ['general'], except: [], event: 'message.new', data: MESSAGE }])\n * ```\n */\nimport type { HttpContext } from '@znight/http'\nimport type { WsCloseInfo, WsConnection, WsSendResult } from '../connection.ts'\nimport type { Broadcast } from '../rooms.ts'\nimport type { GatewayChannel, GatewayRef } from '../server.ts'\n\nimport { WsServer } from '../server.ts'\n\n/** One recorded broadcast (or a `close`, `join` or `leave` of a selection). */\nexport interface RecordedBroadcast {\n /** The gateway path, or the class name when no path was given for it. */\n readonly gateway: string\n /** Selected rooms, `[]` for every connection of the gateway. */\n readonly rooms: readonly string[]\n /** Excluded connection ids. */\n readonly except: readonly string[]\n /** Excluded rooms (omitted when none). */\n readonly exceptRooms?: readonly string[]\n /** The emitted event, `undefined` for `send()` and the selection operations. */\n readonly event?: string\n readonly data?: unknown\n /** A raw frame (`send()`). */\n readonly frame?: string | Uint8Array\n /** `join`, `leave` or `close` of the selection. */\n readonly action?: 'join' | 'leave' | 'close'\n readonly room?: string\n}\n\n/** Options of {@link createTestConnection}. */\nexport interface TestConnectionOptions<TData extends object> {\n readonly id?: string\n /** Gateway path. Default: `'/'`. */\n readonly gateway?: string\n readonly data?: TData\n readonly rooms?: readonly string[]\n readonly params?: Readonly<Record<string, string>>\n readonly protocol?: string\n readonly requestId?: string\n /** The handshake context. Default: a minimal object with `params`, `query`, `headers` and `state`. */\n readonly handshake?: Partial<HttpContext>\n}\n\n/** A fake connection: a `WsConnection` with the record of what it was sent. */\nexport interface TestConnection<\n TData extends object = Record<string, unknown>,\n E extends object = Record<string, unknown>,\n> extends WsConnection<TData, E> {\n /** Events emitted to this connection only (`conn.emit`), in order. */\n readonly emitted: { readonly event: string; readonly data: unknown }[]\n /** Raw frames sent to this connection (`conn.send`). */\n readonly sent: (string | Uint8Array)[]\n /** Broadcasts made through `conn.to()` (the connection itself is in `except`). */\n readonly broadcasts: RecordedBroadcast[]\n}\n\n/** A fake server: a `WsServer` recording broadcasts. */\nexport interface TestServer extends WsServer {\n readonly broadcasts: RecordedBroadcast[]\n}\n\ntype Record_ = (entry: RecordedBroadcast) => void\n\n/** A selection recording what is done with it. */\nclass RecordingBroadcast<E extends object> implements Broadcast<E> {\n readonly #gateway: string\n readonly #rooms: readonly string[]\n readonly #except: readonly string[]\n readonly #exceptRooms: readonly string[]\n readonly #record: Record_\n\n constructor(\n gateway: string,\n rooms: readonly string[],\n except: readonly string[],\n exceptRooms: readonly string[],\n record: Record_,\n ) {\n this.#gateway = gateway\n this.#rooms = rooms\n this.#except = except\n this.#exceptRooms = exceptRooms\n this.#record = record\n }\n\n to(room: string | readonly string[]): Broadcast<E> {\n return new RecordingBroadcast(\n this.#gateway,\n [...this.#rooms, ...list(room)],\n this.#except,\n this.#exceptRooms,\n this.#record,\n )\n }\n\n except(target: string | { readonly id: string } | readonly (string | { readonly id: string })[]): Broadcast<E> {\n const refs = Array.isArray(target) ? target : [target]\n const ids = refs.map(ref => (typeof ref === 'string' ? ref : (ref as { readonly id: string }).id))\n return new RecordingBroadcast(\n this.#gateway,\n this.#rooms,\n [...this.#except, ...ids],\n this.#exceptRooms,\n this.#record,\n )\n }\n\n exceptRoom(room: string | readonly string[]): Broadcast<E> {\n return new RecordingBroadcast(\n this.#gateway,\n this.#rooms,\n this.#except,\n [...this.#exceptRooms, ...list(room)],\n this.#record,\n )\n }\n\n emit<K extends keyof E & string>(event: K, data: E[K]): void {\n this.#record({ ...this.#base(), event, data })\n }\n\n send(frame: string | Uint8Array): void {\n this.#record({ ...this.#base(), frame })\n }\n\n join(room: string): void {\n this.#record({ ...this.#base(), action: 'join', room })\n }\n\n leave(room: string): void {\n this.#record({ ...this.#base(), action: 'leave', room })\n }\n\n close(): void {\n this.#record({ ...this.#base(), action: 'close' })\n }\n\n #base(): RecordedBroadcast {\n return {\n gateway: this.#gateway,\n rooms: [...this.#rooms],\n except: [...this.#except],\n ...(this.#exceptRooms.length === 0 ? {} : { exceptRooms: [...this.#exceptRooms] }),\n }\n }\n}\n\nfunction list(value: string | readonly string[]): readonly string[] {\n return typeof value === 'string' ? [value] : value\n}\n\n/** Creates a fake connection for unit tests. */\nexport function createTestConnection<\n TData extends object = Record<string, unknown>,\n E extends object = Record<string, unknown>,\n>(options: TestConnectionOptions<TData> = {}): TestConnection<TData, E> {\n const id = options.id ?? crypto.randomUUID()\n const gateway = options.gateway ?? '/'\n const rooms = new Set(options.rooms ?? [])\n const abort = new AbortController()\n const params = options.params ?? {}\n const handshake = {\n params,\n query: {},\n headers: {},\n state: new Map(),\n requestId: options.requestId ?? 'test-request',\n ...options.handshake,\n } as unknown as HttpContext\n let state: 'open' | 'closing' | 'closed' = 'open'\n let closeInfo: WsCloseInfo | undefined\n const emitted: { event: string; data: unknown }[] = []\n const sent: (string | Uint8Array)[] = []\n const broadcasts: RecordedBroadcast[] = []\n const result = (): WsSendResult => (state === 'open' ? 'sent' : 'dropped')\n const connection: TestConnection<TData, E> = {\n id,\n gateway,\n requestId: options.requestId ?? 'test-request',\n handshake,\n params,\n protocol: options.protocol ?? '',\n data: (options.data ?? {}) as TData,\n rooms,\n get readyState() {\n return state\n },\n bufferedAmount: 0,\n signal: abort.signal,\n get closeInfo() {\n return closeInfo\n },\n emitted,\n sent,\n broadcasts,\n emit(event, data) {\n if (state === 'open') emitted.push({ event, data })\n return result()\n },\n send(frame) {\n if (state === 'open') sent.push(frame)\n return result()\n },\n join(room) {\n for (const name of list(room)) rooms.add(name)\n },\n leave(room) {\n rooms.delete(room)\n },\n to(room) {\n return new RecordingBroadcast<E>(gateway, list(room), [id], [], entry => broadcasts.push(entry))\n },\n drained() {\n return state === 'open' ? Promise.resolve() : Promise.reject(new Error('The WebSocket connection is closed'))\n },\n close(code = 1000, reason = '') {\n if (state !== 'open') return\n state = 'closed'\n closeInfo = { code, reason, initiator: 'server' }\n abort.abort()\n },\n terminate() {\n if (state !== 'open') return\n state = 'closed'\n closeInfo = { code: 1006, reason: '', initiator: 'server' }\n abort.abort()\n },\n }\n return connection\n}\n\n/** Options of {@link createTestServer}. */\nexport interface TestServerOptions {\n /** Paths of gateway classes, for the `gateway` of recorded broadcasts (`[[ChatGateway, '/chat']]`). */\n readonly paths?: readonly (readonly [abstract new (...args: never[]) => object, string])[]\n}\n\n/** Creates a fake `WsServer` recording broadcasts (no connection, every room empty). */\nexport function createTestServer(options: TestServerOptions = {}): TestServer {\n const paths = new Map<unknown, string>(options.paths ?? [])\n const broadcasts: RecordedBroadcast[] = []\n const record: Record_ = entry => broadcasts.push(entry)\n class FakeServer extends WsServer {\n readonly broadcasts: RecordedBroadcast[] = broadcasts\n\n override get connections(): number {\n return 0\n }\n\n override of<E extends object = Record<string, unknown>>(gateway: GatewayRef): GatewayChannel<E> {\n const path = typeof gateway === 'string' ? gateway : (paths.get(gateway) ?? gateway.name)\n const all = (): RecordingBroadcast<E> => new RecordingBroadcast<E>(path, [], [], [], record)\n return {\n path,\n size: 0,\n connection: () => undefined,\n connections: () => [][Symbol.iterator](),\n members: () => new Set(),\n rooms: () => new Map(),\n to: room => all().to(room),\n except: target => all().except(target as never),\n emit: (event, data) => all().emit(event, data),\n send: frame => all().send(frame),\n }\n }\n }\n return new FakeServer()\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { BunUpgrade, HeaderValue, MemoryUpgrade, NodeUpgrade } from "@znight/http";
|
|
2
|
+
/** Server-wide settings given once at bootstrap. */
|
|
3
|
+
interface TransportSettings {
|
|
4
|
+
/** The largest inbound message any gateway accepts, in bytes. */
|
|
5
|
+
readonly maxPayload: number;
|
|
6
|
+
/** The largest outbound buffer any gateway tolerates, in bytes. */
|
|
7
|
+
readonly backpressureLimit: number;
|
|
8
|
+
/** Heartbeat interval in ms, `0` when disabled. */
|
|
9
|
+
readonly heartbeatMs: number;
|
|
10
|
+
}
|
|
11
|
+
/** What {@link WebSocketTransport.accept} receives besides the native upgrade. */
|
|
12
|
+
interface AcceptInit {
|
|
13
|
+
/** The selected subprotocol, `''` when none. */
|
|
14
|
+
readonly protocol: string;
|
|
15
|
+
/** Extra headers of the `101` response. */
|
|
16
|
+
readonly headers: Readonly<Record<string, HeaderValue>>;
|
|
17
|
+
/** The largest inbound message of the gateway, in bytes. */
|
|
18
|
+
readonly maxPayload: number;
|
|
19
|
+
}
|
|
20
|
+
/** The socket events of one connection, given to the transport by the plugin. */
|
|
21
|
+
interface SocketEvents {
|
|
22
|
+
message(frame: string | Uint8Array, binary: boolean): void;
|
|
23
|
+
pong(): void;
|
|
24
|
+
/**
|
|
25
|
+
* The outbound buffer may have shrunk (after a write, or Bun's `drain`): the connection compares it
|
|
26
|
+
* with its own threshold, and polls the buffer while `conn.drained()` waits.
|
|
27
|
+
*/
|
|
28
|
+
drain(): void;
|
|
29
|
+
close(code: number, reason: string): void;
|
|
30
|
+
}
|
|
31
|
+
/** The result of {@link TransportSocket.send}. */
|
|
32
|
+
type SendStatus = "sent" | "queued" | "dropped";
|
|
33
|
+
/** One accepted connection, the only object holding native handles. */
|
|
34
|
+
interface TransportSocket {
|
|
35
|
+
send(frame: string | Uint8Array, binary: boolean): SendStatus;
|
|
36
|
+
readonly bufferedAmount: number;
|
|
37
|
+
ping(): void;
|
|
38
|
+
/** Stops reading; `false` when the transport cannot pause. */
|
|
39
|
+
pause(): boolean;
|
|
40
|
+
resume(): void;
|
|
41
|
+
close(code: number, reason: string): void;
|
|
42
|
+
terminate(): void;
|
|
43
|
+
/** Negotiated extensions (`permessage-deflate`...), `''` when none. */
|
|
44
|
+
readonly extensions: string;
|
|
45
|
+
}
|
|
46
|
+
/** A transport: `ws` on `node:http` servers, Bun's built-in WebSocket on `Bun.serve()`, or memory. */
|
|
47
|
+
interface WebSocketTransport {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
/** Which native upgrade it completes. */
|
|
50
|
+
readonly accepts: "node" | "bun" | "memory";
|
|
51
|
+
configure(settings: TransportSettings): void;
|
|
52
|
+
accept(native: NodeUpgrade | BunUpgrade | MemoryUpgrade, init: AcceptInit, events: SocketEvents): TransportSocket | Promise<TransportSocket>;
|
|
53
|
+
/**
|
|
54
|
+
* Loads what the transport needs before the server listens (the `ws` package), so that a missing
|
|
55
|
+
* dependency fails the startup (ZNIGHT051) rather than the first handshake.
|
|
56
|
+
*/
|
|
57
|
+
ready?(): Promise<void>;
|
|
58
|
+
/** `Bun.serve()` transports only: the settings of its `websocket` option. */
|
|
59
|
+
bunWebSocket?(): Readonly<Record<string, unknown>>;
|
|
60
|
+
close(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
export { TransportSocket as a, TransportSettings as i, SendStatus as n, WebSocketTransport as o, SocketEvents as r, AcceptInit as t };
|
|
63
|
+
//# sourceMappingURL=transport-xnJqvB93.d.mts.map
|
package/dist/ws.d.mts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { o as WebSocketTransport } from "./transport-xnJqvB93.mjs";
|
|
2
|
+
/** Options of {@link wsTransport}. */
|
|
3
|
+
export interface WsTransportOptions {
|
|
4
|
+
/** `permessage-deflate` (`ws` option). Default: `false`. */
|
|
5
|
+
readonly perMessageDeflate?: boolean | Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
/** The `ws` transport: completes the `node` upgrades of `node:http` servers. */
|
|
8
|
+
export declare function wsTransport(options?: WsTransportOptions): WebSocketTransport;
|
|
9
|
+
//# sourceMappingURL=ws.d.mts.map
|
package/dist/ws.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ErrorCode, ZnightError } from "@znight/core";
|
|
2
|
+
import { HttpException } from "@znight/http";
|
|
3
|
+
// src/transports/ws.ts
|
|
4
|
+
let loading;
|
|
5
|
+
function loadWs() {
|
|
6
|
+
loading ??= import("ws").then((module) => module.WebSocketServer === void 0 && module.default !== void 0 ? module.default : module, (cause) => {
|
|
7
|
+
loading = void 0;
|
|
8
|
+
throw new ZnightError(ErrorCode.WebSocketTransportUnavailable, "The \"ws\" package is required to serve WebSocket gateways on Node.js", {
|
|
9
|
+
hints: ["Install it: npm install ws"],
|
|
10
|
+
cause
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
return loading;
|
|
14
|
+
}
|
|
15
|
+
function toFrame(data, binary) {
|
|
16
|
+
let bytes;
|
|
17
|
+
if (Array.isArray(data)) bytes = Buffer.concat(data);
|
|
18
|
+
else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
|
19
|
+
else bytes = data;
|
|
20
|
+
return binary ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Buffer.from(bytes).toString("utf8");
|
|
21
|
+
}
|
|
22
|
+
/** One `ws` connection. */
|
|
23
|
+
var WsTransportSocket = class {
|
|
24
|
+
#ws;
|
|
25
|
+
#limit;
|
|
26
|
+
/** `drain` after every write: the connection compares the buffer with its own threshold. */
|
|
27
|
+
#events;
|
|
28
|
+
constructor(ws, limit, events) {
|
|
29
|
+
this.#ws = ws;
|
|
30
|
+
this.#limit = limit;
|
|
31
|
+
this.#events = events;
|
|
32
|
+
}
|
|
33
|
+
get bufferedAmount() {
|
|
34
|
+
return this.#ws.bufferedAmount;
|
|
35
|
+
}
|
|
36
|
+
get extensions() {
|
|
37
|
+
return this.#ws.extensions;
|
|
38
|
+
}
|
|
39
|
+
send(frame, binary) {
|
|
40
|
+
if (this.#ws.readyState !== 1) return "dropped";
|
|
41
|
+
this.#ws.send(frame, { binary }, () => this.#events.drain());
|
|
42
|
+
return this.#ws.bufferedAmount <= this.#limit ? "sent" : "queued";
|
|
43
|
+
}
|
|
44
|
+
ping() {
|
|
45
|
+
this.#ws.ping();
|
|
46
|
+
}
|
|
47
|
+
pause() {
|
|
48
|
+
this.#ws.pause();
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
resume() {
|
|
52
|
+
this.#ws.resume();
|
|
53
|
+
}
|
|
54
|
+
close(code, reason) {
|
|
55
|
+
this.#ws.close(code, reason);
|
|
56
|
+
}
|
|
57
|
+
terminate() {
|
|
58
|
+
this.#ws.terminate();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
/** The `ws` transport: completes the `node` upgrades of `node:http` servers. */
|
|
62
|
+
function wsTransport(options = {}) {
|
|
63
|
+
let settings = {
|
|
64
|
+
maxPayload: 1048576,
|
|
65
|
+
backpressureLimit: 1048576,
|
|
66
|
+
heartbeatMs: 0
|
|
67
|
+
};
|
|
68
|
+
let server;
|
|
69
|
+
const pendingHeaders = /* @__PURE__ */ new WeakMap();
|
|
70
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
71
|
+
async function serverOf() {
|
|
72
|
+
if (server !== void 0) return server;
|
|
73
|
+
const { WebSocketServer } = await loadWs();
|
|
74
|
+
server ??= new WebSocketServer({
|
|
75
|
+
noServer: true,
|
|
76
|
+
maxPayload: settings.maxPayload,
|
|
77
|
+
perMessageDeflate: options.perMessageDeflate ?? false,
|
|
78
|
+
clientTracking: false,
|
|
79
|
+
handleProtocols: (protocols, request) => {
|
|
80
|
+
const selected = request.znightProtocol ?? "";
|
|
81
|
+
return selected !== "" && protocols.has(selected) ? selected : false;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
server.on("headers", (lines, request) => {
|
|
85
|
+
const extra = pendingHeaders.get(request);
|
|
86
|
+
if (extra === void 0) return;
|
|
87
|
+
for (const [name, value] of Object.entries(extra)) for (const item of typeof value === "string" ? [value] : value) lines.push(`${name}: ${item}`);
|
|
88
|
+
});
|
|
89
|
+
return server;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
name: "ws",
|
|
93
|
+
accepts: "node",
|
|
94
|
+
configure(next) {
|
|
95
|
+
settings = next;
|
|
96
|
+
},
|
|
97
|
+
async ready() {
|
|
98
|
+
await serverOf();
|
|
99
|
+
},
|
|
100
|
+
async accept(native, init, events) {
|
|
101
|
+
if (native.kind !== "node") throw new ZnightError(ErrorCode.WebSocketTransportUnavailable, `wsTransport() completes node:http upgrades, not ${native.kind} upgrades`, { hints: ["Use bunTransport() with honoAdapter() on Bun"] });
|
|
102
|
+
const wss = await serverOf();
|
|
103
|
+
const request = native.request;
|
|
104
|
+
const raw = native.socket;
|
|
105
|
+
if (init.protocol === "") delete request.headers["sec-websocket-protocol"];
|
|
106
|
+
else request.headers["sec-websocket-protocol"] = init.protocol;
|
|
107
|
+
request.znightProtocol = init.protocol;
|
|
108
|
+
pendingHeaders.set(request, init.headers);
|
|
109
|
+
const ws = await new Promise((resolve, reject) => {
|
|
110
|
+
const aborted = () => reject(new HttpException(400, "The WebSocket handshake was aborted"));
|
|
111
|
+
if (raw.destroyed || !raw.writable) {
|
|
112
|
+
aborted();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
raw.once("close", aborted);
|
|
116
|
+
wss.handleUpgrade(request, raw, native.head, (accepted) => {
|
|
117
|
+
raw.removeListener("close", aborted);
|
|
118
|
+
resolve(accepted);
|
|
119
|
+
});
|
|
120
|
+
}).finally(() => pendingHeaders.delete(request));
|
|
121
|
+
sockets.add(ws);
|
|
122
|
+
const socket = new WsTransportSocket(ws, settings.backpressureLimit, events);
|
|
123
|
+
ws.on("error", () => void 0);
|
|
124
|
+
ws.on("message", (data, binary) => events.message(toFrame(data, binary), binary));
|
|
125
|
+
ws.on("pong", () => events.pong());
|
|
126
|
+
ws.on("close", (code, reason) => {
|
|
127
|
+
sockets.delete(ws);
|
|
128
|
+
events.close(code, reason.toString("utf8"));
|
|
129
|
+
});
|
|
130
|
+
return socket;
|
|
131
|
+
},
|
|
132
|
+
close() {
|
|
133
|
+
for (const ws of sockets) ws.terminate();
|
|
134
|
+
sockets.clear();
|
|
135
|
+
server?.close();
|
|
136
|
+
server = void 0;
|
|
137
|
+
return Promise.resolve();
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export { wsTransport };
|
|
142
|
+
|
|
143
|
+
//# sourceMappingURL=ws.mjs.map
|
package/dist/ws.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ws.mjs","names":[],"sources":["../src/transports/ws.ts"],"sourcesContent":["/**\n * The `ws` transport (RFC 0003 §8.3): completes the upgrades of every `node:http` adapter, on\n * Node.js and on Bun. `ws` is an optional peer dependency, loaded lazily on the first connection.\n *\n * ```ts\n * import { wsTransport } from '@znight/websocket/ws'\n * ```\n */\nimport type { BunUpgrade, HeaderValue, MemoryUpgrade, NodeUpgrade } from '@znight/http'\nimport type {\n AcceptInit,\n SendStatus,\n SocketEvents,\n TransportSettings,\n TransportSocket,\n WebSocketTransport,\n} from '../transport.ts'\n\nimport { ErrorCode, ZnightError } from '@znight/core'\nimport { HttpException } from '@znight/http'\n\n/** The part of a `ws` `WebSocket` the transport uses (no `@types/ws`). */\ninterface WsSocket {\n /** `1` while open. */\n readonly readyState: number\n readonly bufferedAmount: number\n readonly extensions: string\n send(data: string | Uint8Array, options: { binary: boolean }, callback: (error?: Error) => void): void\n ping(): void\n pause(): void\n resume(): void\n close(code: number, reason: string): void\n terminate(): void\n on(event: 'message', listener: (data: Buffer | ArrayBuffer | Buffer[], binary: boolean) => void): void\n on(event: 'pong', listener: () => void): void\n on(event: 'close', listener: (code: number, reason: Buffer) => void): void\n on(event: 'error', listener: (error: Error) => void): void\n}\n\n/** The part of a `ws` `WebSocketServer` the transport uses. */\ninterface WsServer {\n handleUpgrade(request: unknown, socket: unknown, head: Uint8Array, callback: (ws: WsSocket) => void): void\n on(event: 'headers', listener: (headers: string[], request: unknown) => void): void\n close(): void\n}\n\n/** The part of the handshake's `net.Socket` the transport watches. */\ninterface NodeSocket {\n readonly destroyed: boolean\n readonly writable: boolean\n once(event: 'close', listener: () => void): void\n removeListener(event: 'close', listener: () => void): void\n}\n\n/** The part of the handshake's `IncomingMessage` the transport rewrites. */\ninterface NodeRequest {\n znightProtocol?: string\n readonly headers: Record<string, string | string[] | undefined>\n}\n\ninterface WsModule {\n readonly WebSocketServer: new (options: Record<string, unknown>) => WsServer\n}\n\n/** Options of {@link wsTransport}. */\nexport interface WsTransportOptions {\n /** `permessage-deflate` (`ws` option). Default: `false`. */\n readonly perMessageDeflate?: boolean | Record<string, unknown>\n}\n\nlet loading: Promise<WsModule> | undefined\n\nfunction loadWs(): Promise<WsModule> {\n const name = 'ws'\n loading ??= (import(name) as Promise<WsModule & { default?: WsModule }>).then(\n module => (module.WebSocketServer === undefined && module.default !== undefined ? module.default : module),\n (cause: unknown) => {\n loading = undefined\n throw new ZnightError(\n ErrorCode.WebSocketTransportUnavailable,\n 'The \"ws\" package is required to serve WebSocket gateways on Node.js',\n { hints: ['Install it: npm install ws'], cause },\n )\n },\n )\n return loading\n}\n\nfunction toFrame(data: Buffer | ArrayBuffer | Buffer[], binary: boolean): string | Uint8Array {\n let bytes: Uint8Array\n if (Array.isArray(data)) bytes = Buffer.concat(data)\n else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data)\n else bytes = data\n return binary ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Buffer.from(bytes).toString('utf8')\n}\n\n/** One `ws` connection. */\nclass WsTransportSocket implements TransportSocket {\n readonly #ws: WsSocket\n readonly #limit: number\n /** `drain` after every write: the connection compares the buffer with its own threshold. */\n readonly #events: SocketEvents\n\n constructor(ws: WsSocket, limit: number, events: SocketEvents) {\n this.#ws = ws\n this.#limit = limit\n this.#events = events\n }\n\n get bufferedAmount(): number {\n return this.#ws.bufferedAmount\n }\n\n get extensions(): string {\n return this.#ws.extensions\n }\n\n send(frame: string | Uint8Array, binary: boolean): SendStatus {\n if (this.#ws.readyState !== 1) return 'dropped'\n this.#ws.send(frame, { binary }, () => this.#events.drain())\n return this.#ws.bufferedAmount <= this.#limit ? 'sent' : 'queued'\n }\n\n ping(): void {\n this.#ws.ping()\n }\n\n pause(): boolean {\n this.#ws.pause()\n return true\n }\n\n resume(): void {\n this.#ws.resume()\n }\n\n close(code: number, reason: string): void {\n this.#ws.close(code, reason)\n }\n\n terminate(): void {\n this.#ws.terminate()\n }\n}\n\n/** The `ws` transport: completes the `node` upgrades of `node:http` servers. */\nexport function wsTransport(options: WsTransportOptions = {}): WebSocketTransport {\n let settings: TransportSettings = { maxPayload: 1024 * 1024, backpressureLimit: 1024 * 1024, heartbeatMs: 0 }\n let server: WsServer | undefined\n const pendingHeaders = new WeakMap<object, Readonly<Record<string, HeaderValue>>>()\n const sockets = new Set<WsSocket>()\n\n async function serverOf(): Promise<WsServer> {\n if (server !== undefined) return server\n const { WebSocketServer } = await loadWs()\n server ??= new WebSocketServer({\n noServer: true,\n maxPayload: settings.maxPayload,\n perMessageDeflate: options.perMessageDeflate ?? false,\n clientTracking: false,\n handleProtocols: (protocols: Set<string>, request: object) => {\n const selected = (request as { znightProtocol?: string }).znightProtocol ?? ''\n return selected !== '' && protocols.has(selected) ? selected : false\n },\n })\n server.on('headers', (lines, request) => {\n const extra = pendingHeaders.get(request as object)\n if (extra === undefined) return\n for (const [name, value] of Object.entries(extra)) {\n for (const item of typeof value === 'string' ? [value] : value) lines.push(`${name}: ${item}`)\n }\n })\n return server\n }\n\n return {\n name: 'ws',\n accepts: 'node',\n configure(next: TransportSettings): void {\n settings = next\n },\n async ready(): Promise<void> {\n await serverOf()\n },\n async accept(\n native: NodeUpgrade | BunUpgrade | MemoryUpgrade,\n init: AcceptInit,\n events: SocketEvents,\n ): Promise<TransportSocket> {\n if (native.kind !== 'node') {\n throw new ZnightError(\n ErrorCode.WebSocketTransportUnavailable,\n `wsTransport() completes node:http upgrades, not ${native.kind} upgrades`,\n { hints: ['Use bunTransport() with honoAdapter() on Bun'] },\n )\n }\n const wss = await serverOf()\n const request = native.request as NodeRequest\n const raw = native.socket as NodeSocket\n // the plugin selected the subprotocol: ws would abort (400, no callback) on a header it cannot parse\n if (init.protocol === '') delete request.headers['sec-websocket-protocol']\n else request.headers['sec-websocket-protocol'] = init.protocol\n request.znightProtocol = init.protocol\n pendingHeaders.set(request, init.headers)\n // ws destroys the socket without calling back when the client left or a header is malformed\n const ws = await new Promise<WsSocket>((resolve, reject) => {\n const aborted = (): void => reject(new HttpException(400, 'The WebSocket handshake was aborted'))\n if (raw.destroyed || !raw.writable) {\n aborted()\n return\n }\n raw.once('close', aborted)\n wss.handleUpgrade(request, raw, native.head, accepted => {\n raw.removeListener('close', aborted)\n resolve(accepted)\n })\n }).finally(() => pendingHeaders.delete(request))\n sockets.add(ws)\n const socket = new WsTransportSocket(ws, settings.backpressureLimit, events)\n ws.on('error', () => undefined) // reported through 'close'; an unhandled 'error' would kill the process\n ws.on('message', (data, binary) => events.message(toFrame(data, binary), binary))\n ws.on('pong', () => events.pong())\n ws.on('close', (code, reason) => {\n sockets.delete(ws)\n events.close(code, reason.toString('utf8'))\n })\n return socket\n },\n close(): Promise<void> {\n for (const ws of sockets) ws.terminate()\n sockets.clear()\n server?.close()\n server = undefined\n return Promise.resolve()\n },\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1 +1,102 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@znight/websocket",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Znight WebSocket gateways served by the HTTP server, validated at build time",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ugho STEPHAN",
|
|
7
|
+
"homepage": "https://github.com/znight-org/znight/tree/main/packages/websocket#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/znight-org/znight.git",
|
|
11
|
+
"directory": "packages/websocket"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/znight-org/znight/issues",
|
|
14
|
+
"keywords": ["znight", "websocket", "realtime", "gateway"],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"@znight/source": "./src/index.ts",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"default": "./dist/index.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./ws": {
|
|
27
|
+
"@znight/source": "./src/transports/ws.ts",
|
|
28
|
+
"types": "./dist/ws.d.mts",
|
|
29
|
+
"default": "./dist/ws.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./bun": {
|
|
32
|
+
"@znight/source": "./src/transports/bun.ts",
|
|
33
|
+
"types": "./dist/bun.d.mts",
|
|
34
|
+
"default": "./dist/bun.mjs"
|
|
35
|
+
},
|
|
36
|
+
"./testing": {
|
|
37
|
+
"@znight/source": "./src/testing/index.ts",
|
|
38
|
+
"types": "./dist/testing.d.mts",
|
|
39
|
+
"default": "./dist/testing.mjs"
|
|
40
|
+
},
|
|
41
|
+
"./compiler": {
|
|
42
|
+
"@znight/source": "./src/compiler/index.ts",
|
|
43
|
+
"types": "./dist/compiler.d.mts",
|
|
44
|
+
"default": "./dist/compiler.mjs"
|
|
45
|
+
},
|
|
46
|
+
"./package.json": "./package.json"
|
|
47
|
+
},
|
|
48
|
+
"types": "./dist/index.d.mts",
|
|
49
|
+
"files": ["dist", "src"],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
53
|
+
"test": "bun test --conditions=@znight/source test"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@znight/core": "0.6.0",
|
|
60
|
+
"@znight/http": "0.6.0"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@znight/compiler": "^0.6.0",
|
|
64
|
+
"ws": "^8.21.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"@znight/compiler": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"ws": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@znight/compiler": "0.6.0",
|
|
76
|
+
"@znight/testing": "0.6.0",
|
|
77
|
+
"hono": "4.13.14",
|
|
78
|
+
"ws": "8.22.0",
|
|
79
|
+
"zod": "4.6.7"
|
|
80
|
+
},
|
|
81
|
+
"znight": {
|
|
82
|
+
"apiVersion": 1,
|
|
83
|
+
"compiler": "@znight/websocket/compiler",
|
|
84
|
+
"provides": [
|
|
85
|
+
{
|
|
86
|
+
"export": "WsServer",
|
|
87
|
+
"plugin": "WebSocketPlugin",
|
|
88
|
+
"scope": "singleton"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"export": "WebSocketPlugin",
|
|
92
|
+
"plugin": "WebSocketPlugin",
|
|
93
|
+
"scope": "singleton"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"export": "WsConnection",
|
|
97
|
+
"plugin": "WebSocketPlugin",
|
|
98
|
+
"scope": "request"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client of `WebSocketPlugin.connect()` (RFC 0003 §5.10): an in-memory connection that speaks
|
|
3
|
+
* the `events` protocol (requests, replies, server events) or sends raw frames, with every received
|
|
4
|
+
* frame buffered from the start so that tests never race the server.
|
|
5
|
+
*/
|
|
6
|
+
import type { InjectResponse } from '@znight/http'
|
|
7
|
+
import type { MemoryClient, MemoryCloseInfo } from './transports/memory.ts'
|
|
8
|
+
|
|
9
|
+
/** Options of `WebSocketPlugin.connect()`. */
|
|
10
|
+
export interface WsConnectOptions {
|
|
11
|
+
/** Query parameters appended to the path. */
|
|
12
|
+
readonly query?: Readonly<Record<string, string | readonly string[]>>
|
|
13
|
+
/** Request headers of the handshake. */
|
|
14
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
15
|
+
/** Offered subprotocols (`sec-websocket-protocol`), in preference order. */
|
|
16
|
+
readonly protocols?: readonly string[]
|
|
17
|
+
/** The `origin` header (a browser's). Default: none, like a non-browser client. */
|
|
18
|
+
readonly origin?: string
|
|
19
|
+
/** Cookies of the handshake. */
|
|
20
|
+
readonly cookies?: Readonly<Record<string, string>>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Options of the waiting methods of {@link WsClient}. */
|
|
24
|
+
export interface WsWaitOptions {
|
|
25
|
+
/** Default: 1 000 ms (`request()`: 5 000 ms). */
|
|
26
|
+
readonly timeoutMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A refused handshake: the HTTP response the server answered. */
|
|
30
|
+
export class WsHandshakeError extends Error {
|
|
31
|
+
readonly status: number
|
|
32
|
+
readonly headers: Readonly<Record<string, string | string[]>>
|
|
33
|
+
readonly body: string
|
|
34
|
+
readonly #response: InjectResponse
|
|
35
|
+
|
|
36
|
+
constructor(response: InjectResponse) {
|
|
37
|
+
super(`The WebSocket handshake was refused with ${response.status}: ${response.body}`)
|
|
38
|
+
this.name = 'WsHandshakeError'
|
|
39
|
+
this.status = response.status
|
|
40
|
+
this.headers = response.headers
|
|
41
|
+
this.body = response.body
|
|
42
|
+
this.#response = response
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The body parsed as JSON. */
|
|
46
|
+
json<T = unknown>(): T {
|
|
47
|
+
return this.#response.json<T>()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The failure reply of a request (`{ id, error }`). */
|
|
52
|
+
export class WsRequestError extends Error {
|
|
53
|
+
readonly code: string
|
|
54
|
+
readonly details: unknown
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
event: string,
|
|
58
|
+
error: { readonly code?: unknown; readonly message?: unknown; readonly details?: unknown },
|
|
59
|
+
) {
|
|
60
|
+
super(typeof error.message === 'string' ? error.message : `The request "${event}" failed`)
|
|
61
|
+
this.name = 'WsRequestError'
|
|
62
|
+
this.code = typeof error.code === 'string' ? error.code : 'INTERNAL'
|
|
63
|
+
this.details = error.details
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Entry {
|
|
68
|
+
readonly frame: string | Uint8Array
|
|
69
|
+
/** The decoded envelope, `undefined` for a binary or non-JSON frame. */
|
|
70
|
+
readonly envelope: Readonly<Record<string, unknown>> | undefined
|
|
71
|
+
/** Taken by `next()` or a request. */
|
|
72
|
+
consumed: boolean
|
|
73
|
+
/** Taken by `nextRaw()`. */
|
|
74
|
+
rawConsumed: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface Waiter {
|
|
78
|
+
readonly match: (entry: Entry) => boolean
|
|
79
|
+
readonly take: (entry: Entry) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function decode(frame: string | Uint8Array): Readonly<Record<string, unknown>> | undefined {
|
|
83
|
+
if (typeof frame !== 'string') return undefined
|
|
84
|
+
try {
|
|
85
|
+
const value: unknown = JSON.parse(frame)
|
|
86
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
87
|
+
? (value as Record<string, unknown>)
|
|
88
|
+
: undefined
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An in-memory WebSocket client (`WebSocketPlugin.connect()`, `TestApp.connect()`). */
|
|
95
|
+
export class WsClient implements AsyncDisposable {
|
|
96
|
+
readonly #peer: MemoryClient
|
|
97
|
+
readonly #entries: Entry[] = []
|
|
98
|
+
readonly #waiters = new Set<Waiter>()
|
|
99
|
+
readonly #closed: PromiseWithResolvers<MemoryCloseInfo> = Promise.withResolvers()
|
|
100
|
+
#nextId = 1
|
|
101
|
+
|
|
102
|
+
/** @internal Created by `WebSocketPlugin.connect()` once the handshake succeeded. */
|
|
103
|
+
constructor(peer: MemoryClient) {
|
|
104
|
+
this.#peer = peer
|
|
105
|
+
for (const frame of peer.received) this.#receive(frame)
|
|
106
|
+
peer.onmessage = frame => this.#receive(frame)
|
|
107
|
+
const ended = peer.closed
|
|
108
|
+
if (ended === undefined) {
|
|
109
|
+
peer.onclose = info => this.#closed.resolve(info)
|
|
110
|
+
} else {
|
|
111
|
+
this.#closed.resolve(ended)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The negotiated subprotocol, `''` when none. */
|
|
116
|
+
get protocol(): string {
|
|
117
|
+
return this.#peer.protocol
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every frame received, in order: JSON objects decoded, other text frames as strings, binary frames as bytes. */
|
|
121
|
+
get frames(): readonly unknown[] {
|
|
122
|
+
return this.#entries.map(entry => entry.envelope ?? entry.frame)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Resolves once the connection closed (by either side). */
|
|
126
|
+
get closed(): Promise<MemoryCloseInfo> {
|
|
127
|
+
return this.#closed.promise
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Sends an event without id (fire and forget). */
|
|
131
|
+
send(event: string, data?: unknown): void {
|
|
132
|
+
this.#peer.send(JSON.stringify(data === undefined ? { event } : { event, data }))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Sends a request and resolves with the `data` of its reply; rejects with {@link WsRequestError}. */
|
|
136
|
+
request<T = unknown>(event: string, data?: unknown, options: WsWaitOptions = {}): Promise<T> {
|
|
137
|
+
const id = this.#nextId++
|
|
138
|
+
const reply = this.#wait<T>(
|
|
139
|
+
entry => !entry.consumed && entry.envelope?.['id'] === id && entry.envelope['event'] === undefined,
|
|
140
|
+
(entry, resolve, reject) => {
|
|
141
|
+
entry.consumed = true
|
|
142
|
+
const envelope = entry.envelope as Record<string, unknown>
|
|
143
|
+
if (envelope['error'] === undefined) {
|
|
144
|
+
resolve(envelope['data'] as T)
|
|
145
|
+
} else {
|
|
146
|
+
reject(new WsRequestError(event, envelope['error'] as object))
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
options.timeoutMs ?? 5_000,
|
|
150
|
+
`the reply to "${event}" (id ${id})`,
|
|
151
|
+
)
|
|
152
|
+
this.#peer.send(JSON.stringify(data === undefined ? { event, id } : { event, data, id }))
|
|
153
|
+
return reply
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The `data` of the oldest server event (matching `event`) not taken yet. */
|
|
157
|
+
next<T = unknown>(event?: string, options: WsWaitOptions = {}): Promise<T> {
|
|
158
|
+
return this.#wait<T>(
|
|
159
|
+
entry =>
|
|
160
|
+
!entry.consumed &&
|
|
161
|
+
typeof entry.envelope?.['event'] === 'string' &&
|
|
162
|
+
(event === undefined || entry.envelope['event'] === event),
|
|
163
|
+
(entry, resolve) => {
|
|
164
|
+
entry.consumed = true
|
|
165
|
+
resolve((entry.envelope as Record<string, unknown>)['data'] as T)
|
|
166
|
+
},
|
|
167
|
+
options.timeoutMs ?? 1_000,
|
|
168
|
+
event === undefined ? 'a server event' : `the event "${event}"`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Sends a frame as is (a string is a text frame). */
|
|
173
|
+
sendRaw(frame: string | Uint8Array): void {
|
|
174
|
+
this.#peer.send(frame)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The oldest frame not taken by `nextRaw()` yet, as received. */
|
|
178
|
+
nextRaw(options: WsWaitOptions = {}): Promise<string | Uint8Array> {
|
|
179
|
+
return this.#wait<string | Uint8Array>(
|
|
180
|
+
entry => !entry.rawConsumed,
|
|
181
|
+
(entry, resolve) => {
|
|
182
|
+
entry.rawConsumed = true
|
|
183
|
+
resolve(entry.frame)
|
|
184
|
+
},
|
|
185
|
+
options.timeoutMs ?? 1_000,
|
|
186
|
+
'a frame',
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Closes the connection and resolves with how it closed. */
|
|
191
|
+
close(code = 1000, reason = ''): Promise<MemoryCloseInfo> {
|
|
192
|
+
this.#peer.close(code, reason)
|
|
193
|
+
return this.#closed.promise
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
197
|
+
await this.close()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#wait<T>(
|
|
201
|
+
match: (entry: Entry) => boolean,
|
|
202
|
+
take: (entry: Entry, resolve: (value: T) => void, reject: (error: Error) => void) => void,
|
|
203
|
+
timeoutMs: number,
|
|
204
|
+
what: string,
|
|
205
|
+
): Promise<T> {
|
|
206
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>()
|
|
207
|
+
const found = this.#entries.find(match)
|
|
208
|
+
if (found !== undefined) {
|
|
209
|
+
take(found, resolve, reject)
|
|
210
|
+
return promise
|
|
211
|
+
}
|
|
212
|
+
if (this.#peer.closed !== undefined) {
|
|
213
|
+
reject(new Error(`The WebSocket connection is closed: no ${what} will arrive`))
|
|
214
|
+
return promise
|
|
215
|
+
}
|
|
216
|
+
const timer = setTimeout(() => {
|
|
217
|
+
this.#waiters.delete(waiter)
|
|
218
|
+
reject(new Error(`Timed out after ${timeoutMs} ms waiting for ${what}`))
|
|
219
|
+
}, timeoutMs)
|
|
220
|
+
const waiter: Waiter = {
|
|
221
|
+
match,
|
|
222
|
+
take: entry => {
|
|
223
|
+
clearTimeout(timer)
|
|
224
|
+
take(entry, resolve, reject)
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
this.#waiters.add(waiter)
|
|
228
|
+
void this.#closed.promise.then(() => {
|
|
229
|
+
if (!this.#waiters.delete(waiter)) return
|
|
230
|
+
clearTimeout(timer)
|
|
231
|
+
reject(new Error(`The WebSocket connection closed while waiting for ${what}`))
|
|
232
|
+
})
|
|
233
|
+
return promise
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#receive(frame: string | Uint8Array): void {
|
|
237
|
+
const entry: Entry = { frame, envelope: decode(frame), consumed: false, rawConsumed: false }
|
|
238
|
+
this.#entries.push(entry)
|
|
239
|
+
// waiters in arrival order; each re-checks the entry, which an earlier waiter may have taken
|
|
240
|
+
for (const waiter of this.#waiters) {
|
|
241
|
+
if (!waiter.match(entry)) continue
|
|
242
|
+
this.#waiters.delete(waiter)
|
|
243
|
+
waiter.take(entry)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|