@forgeax/engine-net-websocket 0.1.2

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/src/node.ts ADDED
@@ -0,0 +1,201 @@
1
+ import {
2
+ ENDPOINT_ERROR_HINTS,
3
+ ENDPOINT_EXPECTED,
4
+ EndpointError,
5
+ type EndpointEvent,
6
+ type NetEndpoint,
7
+ type PeerId,
8
+ } from '@forgeax/engine-net';
9
+ import { err, ok, type Result } from '@forgeax/engine-types';
10
+ import WebSocket, { WebSocketServer } from 'ws';
11
+ import { BoundedEventQueue, DEFAULT_MAX_QUEUED_EVENTS } from './event-queue';
12
+ import { createWebSocketClientEndpoint } from './websocket-client-core';
13
+
14
+ export interface ListenWebSocketEndpointOptions {
15
+ readonly port: number;
16
+ readonly host?: string;
17
+ readonly maxPeers?: number;
18
+ readonly maxQueuedEvents?: number | undefined;
19
+ }
20
+
21
+ export interface ConnectWebSocketClientEndpointOptions {
22
+ readonly maxQueuedEvents?: number | undefined;
23
+ }
24
+
25
+ export function connectWebSocketClientEndpoint(
26
+ url: string,
27
+ options: ConnectWebSocketClientEndpointOptions = {},
28
+ ): Promise<Result<NetEndpoint, EndpointError>> {
29
+ return createWebSocketClientEndpoint(
30
+ WebSocket as unknown as import('./websocket-client-core').WebSocketConstructor,
31
+ {
32
+ url,
33
+ maxQueuedEvents: options.maxQueuedEvents,
34
+ toBytes: toBytes,
35
+ },
36
+ );
37
+ }
38
+
39
+ export function listenWebSocketEndpoint(
40
+ options: ListenWebSocketEndpointOptions,
41
+ ): Promise<Result<NetEndpoint, EndpointError>> {
42
+ return new Promise((resolve) => {
43
+ const host = options.host ?? '127.0.0.1';
44
+ const address = `ws://${host}:${options.port}`;
45
+ let queue: BoundedEventQueue;
46
+ try {
47
+ queue = new BoundedEventQueue(options.maxQueuedEvents ?? DEFAULT_MAX_QUEUED_EVENTS);
48
+ } catch (cause) {
49
+ resolve(connectionFailed(address, cause));
50
+ return;
51
+ }
52
+
53
+ const terminalEvents: EndpointEvent[] = [];
54
+ const peers = new Map<PeerId, WebSocket>();
55
+ const disconnectedPeers = new Set<PeerId>();
56
+ const sockets = new Map<WebSocket, PeerId>();
57
+ const maxPeers = options.maxPeers ?? Number.POSITIVE_INFINITY;
58
+ let nextPeerId = 1;
59
+ let settled = false;
60
+ let closed = false;
61
+ const server = new WebSocketServer({ host, port: options.port, perMessageDeflate: false });
62
+
63
+ const endpoint: NetEndpoint = {
64
+ poll: () => [...queue.drain(), ...terminalEvents.splice(0)],
65
+ send: (peerId, data) => {
66
+ if (closed) return alreadyClosed('The WebSocket listener endpoint is closed.');
67
+ const socket = peers.get(peerId);
68
+ if (!socket && disconnectedPeers.has(peerId))
69
+ return err(
70
+ new EndpointError({
71
+ code: 'connection-closed',
72
+ expected: ENDPOINT_EXPECTED['connection-closed'],
73
+ hint: ENDPOINT_ERROR_HINTS['connection-closed'],
74
+ detail: { peerId },
75
+ }),
76
+ );
77
+ if (!socket)
78
+ return err(
79
+ new EndpointError({
80
+ code: 'peer-not-found',
81
+ expected: ENDPOINT_EXPECTED['peer-not-found'],
82
+ hint: ENDPOINT_ERROR_HINTS['peer-not-found'],
83
+ detail: { peerId },
84
+ }),
85
+ );
86
+ if (socket.readyState !== socket.OPEN)
87
+ return err(
88
+ new EndpointError({
89
+ code: 'connection-closed',
90
+ expected: ENDPOINT_EXPECTED['connection-closed'],
91
+ hint: ENDPOINT_ERROR_HINTS['connection-closed'],
92
+ detail: { peerId },
93
+ }),
94
+ );
95
+ try {
96
+ socket.send(data, { binary: true });
97
+ return ok(undefined);
98
+ } catch (cause) {
99
+ return err(
100
+ new EndpointError({
101
+ code: 'send-failed',
102
+ expected: ENDPOINT_EXPECTED['send-failed'],
103
+ hint: ENDPOINT_ERROR_HINTS['send-failed'],
104
+ detail: { peerId, cause: normalizeCause(cause) },
105
+ }),
106
+ );
107
+ }
108
+ },
109
+ close: () => {
110
+ if (closed) return alreadyClosed('The WebSocket listener endpoint is already closed.');
111
+ closed = true;
112
+ for (const socket of peers.values()) socket.close();
113
+ server.close();
114
+ return ok(undefined);
115
+ },
116
+ };
117
+
118
+ const enqueue = (event: EndpointEvent, socket?: WebSocket): void => {
119
+ if (queue.enqueue(event)) return;
120
+ if (event.kind !== 'peer-disconnected') {
121
+ terminalEvents.push({ kind: 'peer-disconnected', peerId: event.peerId });
122
+ }
123
+ socket?.close();
124
+ };
125
+
126
+ server.on('connection', (socket) => {
127
+ if (closed || peers.size >= maxPeers) {
128
+ socket.close();
129
+ return;
130
+ }
131
+ const peerId = nextPeerId++ as PeerId;
132
+ peers.set(peerId, socket);
133
+ sockets.set(socket, peerId);
134
+ enqueue({ kind: 'peer-connected', peerId }, socket);
135
+ socket.on('message', (data, isBinary) => {
136
+ if (!isBinary) {
137
+ socket.close();
138
+ return;
139
+ }
140
+ const bytes = toBytes(data);
141
+ if (!bytes) {
142
+ socket.close();
143
+ return;
144
+ }
145
+ enqueue({ kind: 'message', peerId, data: bytes }, socket);
146
+ });
147
+ socket.on('close', () => {
148
+ if (!peers.delete(peerId)) return;
149
+ disconnectedPeers.add(peerId);
150
+ sockets.delete(socket);
151
+ enqueue({ kind: 'peer-disconnected', peerId });
152
+ });
153
+ socket.on('error', () => socket.close());
154
+ });
155
+ server.on('error', (cause) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ resolve(connectionFailed(address, cause));
159
+ });
160
+ server.on('listening', () => {
161
+ if (settled) return;
162
+ settled = true;
163
+ resolve(ok(endpoint));
164
+ });
165
+ });
166
+ }
167
+
168
+ function toBytes(data: unknown): Uint8Array | undefined {
169
+ if (data instanceof Uint8Array)
170
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
171
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
172
+ return undefined;
173
+ }
174
+
175
+ function connectionFailed(address: string, cause: unknown): Result<never, EndpointError> {
176
+ return err(
177
+ new EndpointError({
178
+ code: 'connection-failed',
179
+ expected: ENDPOINT_EXPECTED['connection-failed'],
180
+ hint: ENDPOINT_ERROR_HINTS['connection-failed'],
181
+ detail: { address, cause: normalizeCause(cause) },
182
+ }),
183
+ );
184
+ }
185
+
186
+ function alreadyClosed(cause: string): Result<never, EndpointError> {
187
+ return err(
188
+ new EndpointError({
189
+ code: 'already-closed',
190
+ expected: ENDPOINT_EXPECTED['already-closed'],
191
+ hint: ENDPOINT_ERROR_HINTS['already-closed'],
192
+ detail: { cause },
193
+ }),
194
+ );
195
+ }
196
+
197
+ function normalizeCause(cause: unknown): string {
198
+ if (cause instanceof Error) return cause.message;
199
+ if (typeof cause === 'string') return cause;
200
+ return 'WebSocket operation failed without a platform error message.';
201
+ }
@@ -0,0 +1,203 @@
1
+ import {
2
+ ENDPOINT_ERROR_HINTS,
3
+ ENDPOINT_EXPECTED,
4
+ EndpointError,
5
+ type EndpointEvent,
6
+ type NetEndpoint,
7
+ type PeerId,
8
+ } from '@forgeax/engine-net';
9
+ import { err, ok, type Result } from '@forgeax/engine-types';
10
+ import { BoundedEventQueue, DEFAULT_MAX_QUEUED_EVENTS } from './event-queue';
11
+
12
+ export interface WebSocketLike {
13
+ readonly CONNECTING: number;
14
+ readonly OPEN: number;
15
+ readonly CLOSING: number;
16
+ readonly CLOSED: number;
17
+ readonly readyState: number;
18
+ binaryType?: string;
19
+ onopen: ((event: unknown) => void) | null;
20
+ onmessage: ((event: { data: unknown }) => void) | null;
21
+ onerror: ((event: unknown) => void) | null;
22
+ onclose: ((event: unknown) => void) | null;
23
+ send(data: Uint8Array): void;
24
+ close(): void;
25
+ }
26
+
27
+ export interface WebSocketConstructor {
28
+ new (url: string): WebSocketLike;
29
+ }
30
+
31
+ export interface WebSocketClientCoreOptions {
32
+ readonly url: string;
33
+ readonly maxQueuedEvents?: number | undefined;
34
+ readonly toBytes: (data: unknown) => Uint8Array | Promise<Uint8Array | undefined> | undefined;
35
+ }
36
+
37
+ const CLIENT_PEER_ID = 1 as PeerId;
38
+
39
+ export function createWebSocketClientEndpoint(
40
+ WebSocket: WebSocketConstructor,
41
+ options: WebSocketClientCoreOptions,
42
+ ): Promise<Result<NetEndpoint, EndpointError>> {
43
+ return new Promise((resolve) => {
44
+ let queue: BoundedEventQueue;
45
+ try {
46
+ queue = new BoundedEventQueue(options.maxQueuedEvents ?? DEFAULT_MAX_QUEUED_EVENTS);
47
+ } catch (cause) {
48
+ resolve(connectionFailed(options.url, cause));
49
+ return;
50
+ }
51
+
52
+ let socket: WebSocketLike;
53
+ try {
54
+ socket = new WebSocket(options.url);
55
+ socket.binaryType = 'arraybuffer';
56
+ } catch (cause) {
57
+ resolve(connectionFailed(options.url, cause));
58
+ return;
59
+ }
60
+
61
+ const terminalEvents: EndpointEvent[] = [];
62
+ let opened = false;
63
+ let settled = false;
64
+ let closed = false;
65
+ let locallyClosed = false;
66
+ let messageTail = Promise.resolve();
67
+
68
+ const disconnect = (reason: string): void => {
69
+ if (closed) return;
70
+ closed = true;
71
+ queue.close(reason);
72
+ terminalEvents.push({ kind: 'peer-disconnected', peerId: CLIENT_PEER_ID });
73
+ };
74
+
75
+ const endpoint: NetEndpoint = {
76
+ poll: () => [...queue.drain(), ...terminalEvents.splice(0)],
77
+ send: (peerId, data) => {
78
+ if (closed) return locallyClosed ? alreadyClosed() : connectionClosed(peerId);
79
+ if (peerId !== CLIENT_PEER_ID)
80
+ return err(
81
+ new EndpointError({
82
+ code: 'peer-not-found',
83
+ expected: ENDPOINT_EXPECTED['peer-not-found'],
84
+ hint: ENDPOINT_ERROR_HINTS['peer-not-found'],
85
+ detail: { peerId },
86
+ }),
87
+ );
88
+ if (socket.readyState !== socket.OPEN)
89
+ return err(
90
+ new EndpointError({
91
+ code: 'connection-closed',
92
+ expected: ENDPOINT_EXPECTED['connection-closed'],
93
+ hint: ENDPOINT_ERROR_HINTS['connection-closed'],
94
+ detail: { peerId },
95
+ }),
96
+ );
97
+ try {
98
+ socket.send(data);
99
+ return ok(undefined);
100
+ } catch (cause) {
101
+ return err(
102
+ new EndpointError({
103
+ code: 'send-failed',
104
+ expected: ENDPOINT_EXPECTED['send-failed'],
105
+ hint: ENDPOINT_ERROR_HINTS['send-failed'],
106
+ detail: { peerId, cause: normalizeCause(cause) },
107
+ }),
108
+ );
109
+ }
110
+ },
111
+ close: () => {
112
+ if (closed)
113
+ return err(
114
+ new EndpointError({
115
+ code: 'already-closed',
116
+ expected: ENDPOINT_EXPECTED['already-closed'],
117
+ hint: ENDPOINT_ERROR_HINTS['already-closed'],
118
+ detail: { cause: 'The WebSocket endpoint is already closed.' },
119
+ }),
120
+ );
121
+ locallyClosed = true;
122
+ disconnect('Endpoint close requested.');
123
+ socket.close();
124
+ return ok(undefined);
125
+ },
126
+ };
127
+
128
+ socket.onopen = () => {
129
+ if (settled) return;
130
+ opened = true;
131
+ settled = true;
132
+ queue.enqueue({ kind: 'peer-connected', peerId: CLIENT_PEER_ID });
133
+ resolve(ok(endpoint));
134
+ };
135
+ socket.onmessage = ({ data }) => {
136
+ // Blob.arrayBuffer() is asynchronous. Serialize conversion so that
137
+ // ordered WebSocket messages retain their wire order after decoding.
138
+ messageTail = messageTail
139
+ .then(async () => {
140
+ const bytes = await options.toBytes(data);
141
+ if (!bytes || closed) return;
142
+ if (!queue.enqueue({ kind: 'message', peerId: CLIENT_PEER_ID, data: bytes })) {
143
+ socket.close();
144
+ }
145
+ })
146
+ .catch(() => undefined);
147
+ };
148
+ socket.onerror = (cause) => {
149
+ if (opened) disconnect(`WebSocket error: ${normalizeCause(cause)}`);
150
+ else if (!settled) {
151
+ settled = true;
152
+ resolve(connectionFailed(options.url, cause));
153
+ }
154
+ };
155
+ socket.onclose = (cause) => {
156
+ if (!opened && !settled) {
157
+ settled = true;
158
+ resolve(connectionFailed(options.url, cause));
159
+ return;
160
+ }
161
+ disconnect(`WebSocket closed: ${normalizeCause(cause)}`);
162
+ };
163
+ });
164
+ }
165
+
166
+ function connectionFailed(address: string, cause: unknown): Result<never, EndpointError> {
167
+ return err(
168
+ new EndpointError({
169
+ code: 'connection-failed',
170
+ expected: ENDPOINT_EXPECTED['connection-failed'],
171
+ hint: ENDPOINT_ERROR_HINTS['connection-failed'],
172
+ detail: { address, cause: normalizeCause(cause) },
173
+ }),
174
+ );
175
+ }
176
+
177
+ function alreadyClosed(): Result<never, EndpointError> {
178
+ return err(
179
+ new EndpointError({
180
+ code: 'already-closed',
181
+ expected: ENDPOINT_EXPECTED['already-closed'],
182
+ hint: ENDPOINT_ERROR_HINTS['already-closed'],
183
+ detail: { cause: 'The WebSocket endpoint is closed.' },
184
+ }),
185
+ );
186
+ }
187
+
188
+ function connectionClosed(peerId: PeerId): Result<never, EndpointError> {
189
+ return err(
190
+ new EndpointError({
191
+ code: 'connection-closed',
192
+ expected: ENDPOINT_EXPECTED['connection-closed'],
193
+ hint: ENDPOINT_ERROR_HINTS['connection-closed'],
194
+ detail: { peerId },
195
+ }),
196
+ );
197
+ }
198
+
199
+ function normalizeCause(cause: unknown): string {
200
+ if (cause instanceof Error) return cause.message;
201
+ if (typeof cause === 'string') return cause;
202
+ return 'WebSocket operation failed without a platform error message.';
203
+ }