react-native-web-serial-api 0.1.0 → 0.2.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/README.md +188 -117
- package/TESTING.md +417 -176
- package/android/build.gradle +14 -0
- package/android/src/main/java/dev/webserialapi/NativeUsbSerialModule.java +74 -11
- package/android/src/main/java/dev/webserialapi/PortPickerActivity.java +61 -59
- package/bin/expose-serial.js +205 -0
- package/lib/commonjs/UsbSerial.js +1 -1
- package/lib/commonjs/WebSerial.js +110 -26
- package/lib/commonjs/WebSerial.js.map +1 -1
- package/lib/commonjs/index.js +2 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/lib/event-target.js +3 -1
- package/lib/commonjs/lib/event-target.js.map +1 -1
- package/lib/commonjs/lib/web-streams.js +42 -0
- package/lib/commonjs/lib/web-streams.js.map +1 -0
- package/lib/commonjs/testing/device-fixture.js +70 -0
- package/lib/commonjs/testing/device-fixture.js.map +1 -0
- package/lib/commonjs/testing/expose.js +91 -0
- package/lib/commonjs/testing/expose.js.map +1 -0
- package/lib/commonjs/testing/harness.js +98 -0
- package/lib/commonjs/testing/harness.js.map +1 -0
- package/lib/commonjs/testing/{virtual-serial.js → in-memory-serial-transport.js} +66 -28
- package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/index.js +100 -17
- package/lib/commonjs/testing/index.js.map +1 -1
- package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
- package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/serial-client.js +277 -0
- package/lib/commonjs/testing/serial-client.js.map +1 -0
- package/lib/commonjs/testing/{serial-device.js → simulated-device.js} +17 -17
- package/lib/commonjs/testing/simulated-device.js.map +1 -0
- package/lib/commonjs/testing/test-suite.js +142 -0
- package/lib/commonjs/testing/test-suite.js.map +1 -0
- package/lib/commonjs/transport.js +3 -3
- package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
- package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
- package/lib/commonjs/websocket/bridge.js +234 -0
- package/lib/commonjs/websocket/bridge.js.map +1 -0
- package/lib/commonjs/websocket/index.js +33 -0
- package/lib/commonjs/websocket/index.js.map +1 -0
- package/lib/commonjs/websocket/protocol.js +55 -0
- package/lib/commonjs/websocket/protocol.js.map +1 -0
- package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
- package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
- package/lib/typescript/src/UsbSerial.d.ts +1 -1
- package/lib/typescript/src/WebSerial.d.ts +7 -7
- package/lib/typescript/src/WebSerial.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/lib/event-target.d.ts +2 -0
- package/lib/typescript/src/lib/event-target.d.ts.map +1 -1
- package/lib/typescript/src/lib/web-streams.d.ts +9 -0
- package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
- package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
- package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
- package/lib/typescript/src/testing/expose.d.ts +71 -0
- package/lib/typescript/src/testing/expose.d.ts.map +1 -0
- package/lib/typescript/src/testing/harness.d.ts +34 -0
- package/lib/typescript/src/testing/harness.d.ts.map +1 -0
- package/lib/typescript/src/testing/{virtual-serial.d.ts → in-memory-serial-transport.d.ts} +37 -26
- package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/index.d.ts +18 -8
- package/lib/typescript/src/testing/index.d.ts.map +1 -1
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/serial-client.d.ts +62 -0
- package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
- package/lib/typescript/src/testing/{serial-device.d.ts → simulated-device.d.ts} +23 -23
- package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
- package/lib/typescript/src/testing/test-suite.d.ts +75 -0
- package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
- package/lib/typescript/src/transport.d.ts +3 -3
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
- package/lib/typescript/src/websocket/bridge.d.ts +66 -0
- package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
- package/lib/typescript/src/websocket/index.d.ts +19 -0
- package/lib/typescript/src/websocket/index.d.ts.map +1 -0
- package/lib/typescript/src/websocket/protocol.d.ts +64 -0
- package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
- package/package.json +21 -3
- package/src/UsbSerial.ts +1 -1
- package/src/WebSerial.ts +134 -35
- package/src/index.ts +4 -1
- package/src/lib/event-target.ts +12 -0
- package/src/lib/web-streams.ts +43 -0
- package/src/testing/device-fixture.ts +150 -0
- package/src/testing/expose.ts +147 -0
- package/src/testing/harness.ts +124 -0
- package/src/testing/{virtual-serial.ts → in-memory-serial-transport.ts} +95 -56
- package/src/testing/index.ts +69 -21
- package/src/testing/install-in-memory-serial-transport.ts +65 -0
- package/src/testing/serial-client.ts +313 -0
- package/src/testing/{serial-device.ts → simulated-device.ts} +23 -23
- package/src/testing/test-suite.ts +186 -0
- package/src/transport.ts +3 -3
- package/src/websocket/WebSocketSerialTransport.ts +796 -0
- package/src/websocket/bridge.ts +299 -0
- package/src/websocket/index.ts +38 -0
- package/src/websocket/protocol.ts +101 -0
- package/src/websocket/serial-device-bridge.ts +160 -0
- package/lib/commonjs/testing/install.js +0 -54
- package/lib/commonjs/testing/install.js.map +0 -1
- package/lib/commonjs/testing/serial-device.js.map +0 -1
- package/lib/commonjs/testing/virtual-serial.js.map +0 -1
- package/lib/typescript/src/testing/install.d.ts +0 -25
- package/lib/typescript/src/testing/install.d.ts.map +0 -1
- package/lib/typescript/src/testing/serial-device.d.ts.map +0 -1
- package/lib/typescript/src/testing/virtual-serial.d.ts.map +0 -1
- package/src/testing/install.ts +0 -65
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A {@link SerialTransport} that talks to a remote serial port over a WebSocket
|
|
3
|
+
* bridge (run `expose-serial-websocket` on the host — see `bin/expose-serial.js`).
|
|
4
|
+
*
|
|
5
|
+
* Because it implements the same transport contract as the native `UsbSerialModule` and the
|
|
6
|
+
* in-memory `InMemorySerialTransport`, it's a drop-in: the whole `Serial` /
|
|
7
|
+
* `SerialPort` polyfill (streams, signals, reconnect, the conformance suite)
|
|
8
|
+
* works on top of it unchanged.
|
|
9
|
+
*
|
|
10
|
+
* **Resilience.** The socket is supervised: an unexpected drop (server restart,
|
|
11
|
+
* network blip) is handled transparently — the transport reconnects with
|
|
12
|
+
* exponential backoff and *restores the session* (line coding, control signals,
|
|
13
|
+
* and read state) so the app's open `SerialPort` keeps working. A transient
|
|
14
|
+
* drop is therefore invisible to the polyfill (reads pause and resume; writes
|
|
15
|
+
* issued during the gap wait for the reconnection). Only a real remote
|
|
16
|
+
* disconnect (the host's serial port going away) or giving up after
|
|
17
|
+
* `maxReconnectAttempts` surfaces as a serial disconnect.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import {Serial} from 'react-native-web-serial-api';
|
|
21
|
+
* import {WebSocketSerialTransport} from 'react-native-web-serial-api/websocket';
|
|
22
|
+
*
|
|
23
|
+
* const serial = new Serial(new WebSocketSerialTransport('ws://localhost:8080'));
|
|
24
|
+
* const [port] = await serial.getPorts();
|
|
25
|
+
* await port.open({baudRate: 115200});
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
ConnectEvent,
|
|
30
|
+
ControlLine,
|
|
31
|
+
DataEvent,
|
|
32
|
+
ErrorEvent,
|
|
33
|
+
FlowControl,
|
|
34
|
+
OpenOptions,
|
|
35
|
+
PortFilter,
|
|
36
|
+
PortId,
|
|
37
|
+
PortPickerLabels,
|
|
38
|
+
SerialTransport,
|
|
39
|
+
Subscription,
|
|
40
|
+
} from '../transport';
|
|
41
|
+
import {type LineCoding, type Parity, parseControlMessage} from './protocol';
|
|
42
|
+
|
|
43
|
+
/** Minimal structural WebSocket shape (browser & React Native both provide it). */
|
|
44
|
+
export interface WebSocketLike {
|
|
45
|
+
binaryType: string;
|
|
46
|
+
send(data: string | ArrayBufferLike | ArrayBufferView): void;
|
|
47
|
+
close(): void;
|
|
48
|
+
addEventListener(
|
|
49
|
+
type: string,
|
|
50
|
+
listener: (event: {data?: unknown}) => void,
|
|
51
|
+
): void;
|
|
52
|
+
}
|
|
53
|
+
export type WebSocketCtor = new (url: string) => WebSocketLike;
|
|
54
|
+
|
|
55
|
+
export type WebSocketConnectionState =
|
|
56
|
+
| 'connecting'
|
|
57
|
+
| 'open'
|
|
58
|
+
| 'reconnecting'
|
|
59
|
+
// The consumer closed the port: the socket is dropped (releasing the remote
|
|
60
|
+
// serial session) but the transport will reconnect on demand when reopened.
|
|
61
|
+
| 'suspended'
|
|
62
|
+
| 'closed';
|
|
63
|
+
|
|
64
|
+
export type WebSocketSerialOptions = {
|
|
65
|
+
/** USB identity to report from getInfo() (cosmetic; default 0/0). */
|
|
66
|
+
usbVendorId?: number;
|
|
67
|
+
usbProductId?: number;
|
|
68
|
+
/** Serial number returned by getSerial(). */
|
|
69
|
+
serialNumber?: string;
|
|
70
|
+
/** WebSocket implementation (defaults to the global). Injectable for tests. */
|
|
71
|
+
WebSocket?: WebSocketCtor;
|
|
72
|
+
/** Auto-reconnect when the socket drops unexpectedly. Default `true`. */
|
|
73
|
+
reconnect?: boolean;
|
|
74
|
+
/** Initial reconnect backoff in ms; doubles each attempt. Default 250. */
|
|
75
|
+
reconnectInitialDelayMs?: number;
|
|
76
|
+
/** Cap on the reconnect backoff in ms. Default 10000. */
|
|
77
|
+
reconnectMaxDelayMs?: number;
|
|
78
|
+
/** Give up (surface a disconnect) after this many consecutive failures. Default Infinity. */
|
|
79
|
+
maxReconnectAttempts?: number;
|
|
80
|
+
/** How long write()/commands wait for a (re)connection before failing. Default 10000. */
|
|
81
|
+
connectTimeoutMs?: number;
|
|
82
|
+
/** How long a control command waits for its response before failing. Default 10000. */
|
|
83
|
+
commandTimeoutMs?: number;
|
|
84
|
+
/** Notified before each reconnect attempt (1-based attempt + the chosen delay). */
|
|
85
|
+
onReconnecting?: (attempt: number, delayMs: number) => void;
|
|
86
|
+
/** Notified once (re)connected; `reconnected` is false only for the first connect. */
|
|
87
|
+
onConnected?: (info: {reconnected: boolean}) => void;
|
|
88
|
+
/** Notified when the transport is permanently closed (gave up, or `disconnect()`). */
|
|
89
|
+
onClosed?: (reason: string) => void;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type Pending = {
|
|
93
|
+
resolve: (value: unknown) => void;
|
|
94
|
+
reject: (reason: Error) => void;
|
|
95
|
+
};
|
|
96
|
+
type Waiter = {resolve: () => void; reject: (reason: Error) => void};
|
|
97
|
+
type Listener<E> = (event: E) => void;
|
|
98
|
+
|
|
99
|
+
const PARITY: Record<number, Parity> = {
|
|
100
|
+
0: 'none',
|
|
101
|
+
1: 'odd',
|
|
102
|
+
2: 'even',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export class WebSocketSerialTransport implements SerialTransport {
|
|
106
|
+
readonly #deviceId = 1;
|
|
107
|
+
readonly #portNumber = 0;
|
|
108
|
+
#usbVendorId: number;
|
|
109
|
+
#usbProductId: number;
|
|
110
|
+
#serialNumber: string;
|
|
111
|
+
|
|
112
|
+
readonly #url: string;
|
|
113
|
+
readonly #Ctor: WebSocketCtor;
|
|
114
|
+
readonly #reconnectEnabled: boolean;
|
|
115
|
+
readonly #initialDelay: number;
|
|
116
|
+
readonly #maxDelay: number;
|
|
117
|
+
readonly #maxAttempts: number;
|
|
118
|
+
readonly #connectTimeout: number;
|
|
119
|
+
readonly #commandTimeout: number;
|
|
120
|
+
readonly #options: WebSocketSerialOptions;
|
|
121
|
+
|
|
122
|
+
#ws: WebSocketLike | null = null;
|
|
123
|
+
#state: WebSocketConnectionState = 'connecting';
|
|
124
|
+
#everConnected = false;
|
|
125
|
+
#reconnectAttempts = 0;
|
|
126
|
+
#reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
127
|
+
#metadataLoaded = false;
|
|
128
|
+
|
|
129
|
+
// Session state to restore on reconnect.
|
|
130
|
+
#portOpen = false;
|
|
131
|
+
#reading = false;
|
|
132
|
+
#lastLineCoding: LineCoding | null = null;
|
|
133
|
+
#nextId = 1;
|
|
134
|
+
#dtr = false;
|
|
135
|
+
#rts = false;
|
|
136
|
+
|
|
137
|
+
readonly #pending = new Map<number, Pending>();
|
|
138
|
+
readonly #connectWaiters = new Set<Waiter>();
|
|
139
|
+
readonly #dataListeners = new Set<Listener<DataEvent>>();
|
|
140
|
+
readonly #errorListeners = new Set<Listener<ErrorEvent>>();
|
|
141
|
+
readonly #connectListeners = new Set<Listener<ConnectEvent>>();
|
|
142
|
+
readonly #disconnectListeners = new Set<Listener<ConnectEvent>>();
|
|
143
|
+
|
|
144
|
+
constructor(url: string, options: WebSocketSerialOptions = {}) {
|
|
145
|
+
this.#options = options;
|
|
146
|
+
this.#usbVendorId = options.usbVendorId ?? 0;
|
|
147
|
+
this.#usbProductId = options.usbProductId ?? 0;
|
|
148
|
+
this.#serialNumber = options.serialNumber ?? '';
|
|
149
|
+
|
|
150
|
+
const Ctor =
|
|
151
|
+
options.WebSocket ??
|
|
152
|
+
(globalThis as {WebSocket?: WebSocketCtor}).WebSocket;
|
|
153
|
+
if (!Ctor) {
|
|
154
|
+
throw new Error('No WebSocket implementation is available.');
|
|
155
|
+
}
|
|
156
|
+
this.#url = url;
|
|
157
|
+
this.#Ctor = Ctor;
|
|
158
|
+
this.#reconnectEnabled = options.reconnect ?? true;
|
|
159
|
+
this.#initialDelay = options.reconnectInitialDelayMs ?? 250;
|
|
160
|
+
this.#maxDelay = options.reconnectMaxDelayMs ?? 10000;
|
|
161
|
+
this.#maxAttempts =
|
|
162
|
+
options.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
|
|
163
|
+
this.#connectTimeout = options.connectTimeoutMs ?? 10000;
|
|
164
|
+
this.#commandTimeout = options.commandTimeoutMs ?? 10000;
|
|
165
|
+
|
|
166
|
+
this.#connect();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Current connection state (advisory; the polyfill keeps working across reconnects). */
|
|
170
|
+
get connectionState(): WebSocketConnectionState {
|
|
171
|
+
return this.#state;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── connection lifecycle ────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
#connect(): void {
|
|
177
|
+
/* istanbul ignore next — reconnect timers are cleared on explicit disconnect() */
|
|
178
|
+
if (this.#state === 'closed') {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.#state = this.#everConnected ? 'reconnecting' : 'connecting';
|
|
182
|
+
let ws: WebSocketLike;
|
|
183
|
+
try {
|
|
184
|
+
ws = new this.#Ctor(this.#url);
|
|
185
|
+
} catch {
|
|
186
|
+
// Construction itself failed (e.g. bad URL on some impls) — treat as a drop.
|
|
187
|
+
this.#handleSocketDown();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
ws.binaryType = 'arraybuffer';
|
|
191
|
+
this.#ws = ws;
|
|
192
|
+
|
|
193
|
+
let dead = false;
|
|
194
|
+
const onDown = () => {
|
|
195
|
+
// Fire once per socket, and ignore a stale socket we've already replaced.
|
|
196
|
+
if (dead || ws !== this.#ws) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
dead = true;
|
|
200
|
+
this.#handleSocketDown();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
ws.addEventListener('open', () => {
|
|
204
|
+
if (ws === this.#ws) {
|
|
205
|
+
void this.#onOpen();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
ws.addEventListener('message', event => {
|
|
209
|
+
if (ws === this.#ws) {
|
|
210
|
+
this.#onMessage((event as {data?: unknown}).data);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
ws.addEventListener('error', onDown);
|
|
214
|
+
ws.addEventListener('close', onDown);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #onOpen(): Promise<void> {
|
|
218
|
+
const reconnected = this.#everConnected;
|
|
219
|
+
this.#everConnected = true;
|
|
220
|
+
this.#state = 'open';
|
|
221
|
+
this.#reconnectAttempts = 0;
|
|
222
|
+
try {
|
|
223
|
+
// Re-establish the remote session before letting queued I/O resume, so
|
|
224
|
+
// writes never go out at the wrong baud rate / signal state.
|
|
225
|
+
await this.#restoreSession();
|
|
226
|
+
await this.#loadPortInfo();
|
|
227
|
+
} catch {
|
|
228
|
+
// A drop happened mid-restore; the close handler schedules another retry.
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!reconnected) {
|
|
232
|
+
this.#emit(this.#connectListeners, this.#connectEvent());
|
|
233
|
+
}
|
|
234
|
+
this.#options.onConnected?.({reconnected});
|
|
235
|
+
this.#releaseWaiters();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async #restoreSession(): Promise<void> {
|
|
239
|
+
if (!this.#portOpen) {
|
|
240
|
+
return; // nothing to restore until the app has opened the port
|
|
241
|
+
}
|
|
242
|
+
/* istanbul ignore next */
|
|
243
|
+
if (this.#lastLineCoding) {
|
|
244
|
+
await this.#sendCommand('setLineCoding', {...this.#lastLineCoding});
|
|
245
|
+
}
|
|
246
|
+
if (this.#dtr || this.#rts) {
|
|
247
|
+
await this.#sendCommand('setSignals', {dtr: this.#dtr, rts: this.#rts});
|
|
248
|
+
}
|
|
249
|
+
// A fresh bridge connection forwards data by default; mirror the app's intent.
|
|
250
|
+
await this.#sendCommand(this.#reading ? 'startReading' : 'stopReading');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async #loadPortInfo(): Promise<void> {
|
|
254
|
+
if (this.#metadataLoaded) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let info: {
|
|
258
|
+
usbVendorId?: unknown;
|
|
259
|
+
usbProductId?: unknown;
|
|
260
|
+
serialNumber?: unknown;
|
|
261
|
+
} | null = null;
|
|
262
|
+
try {
|
|
263
|
+
info = (await this.#sendCommand('getPortInfo')) as {
|
|
264
|
+
usbVendorId?: unknown;
|
|
265
|
+
usbProductId?: unknown;
|
|
266
|
+
serialNumber?: unknown;
|
|
267
|
+
} | null;
|
|
268
|
+
} catch {
|
|
269
|
+
// Older bridge versions won't implement getPortInfo; keep defaults.
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (info && typeof info === 'object') {
|
|
273
|
+
if (typeof info.usbVendorId === 'number') {
|
|
274
|
+
this.#usbVendorId = info.usbVendorId;
|
|
275
|
+
}
|
|
276
|
+
if (typeof info.usbProductId === 'number') {
|
|
277
|
+
this.#usbProductId = info.usbProductId;
|
|
278
|
+
}
|
|
279
|
+
if (typeof info.serialNumber === 'string') {
|
|
280
|
+
this.#serialNumber = info.serialNumber;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
this.#metadataLoaded = true;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async #probeAlive(): Promise<void> {
|
|
287
|
+
try {
|
|
288
|
+
// `getSignals` is supported by all bridge versions and gives us a
|
|
289
|
+
// request/response roundtrip to detect half-open sockets.
|
|
290
|
+
await this.#sendCommand('getSignals');
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.#handleSocketDown();
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#handleSocketDown(): void {
|
|
298
|
+
if (this.#state === 'closed') {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Commands tied to the dead socket can never be answered — reject them.
|
|
302
|
+
this.#failAll('WebSocket connection lost');
|
|
303
|
+
if (this.#reconnectEnabled && this.#reconnectAttempts < this.#maxAttempts) {
|
|
304
|
+
this.#scheduleReconnect();
|
|
305
|
+
} else {
|
|
306
|
+
this.#terminate('WebSocket connection lost and will not be retried.');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#scheduleReconnect(): void {
|
|
311
|
+
this.#state = 'reconnecting';
|
|
312
|
+
const attempt = this.#reconnectAttempts++; // 0-based for the backoff curve
|
|
313
|
+
const delay = Math.min(this.#initialDelay * 2 ** attempt, this.#maxDelay);
|
|
314
|
+
// Full jitter in the upper half keeps a herd of clients from syncing up.
|
|
315
|
+
const jittered = Math.round(delay * (0.5 + Math.random() * 0.5));
|
|
316
|
+
this.#options.onReconnecting?.(attempt + 1, jittered);
|
|
317
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
318
|
+
this.#reconnectTimer = undefined;
|
|
319
|
+
this.#connect();
|
|
320
|
+
}, jittered);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#terminate(reason: string): void {
|
|
324
|
+
/* istanbul ignore next — terminate() only runs once per transport lifecycle */
|
|
325
|
+
if (this.#state === 'closed') {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
this.#state = 'closed';
|
|
329
|
+
this.#portOpen = false;
|
|
330
|
+
this.#reading = false;
|
|
331
|
+
/* istanbul ignore next — reconnect timers are either fired or cleared earlier */
|
|
332
|
+
if (this.#reconnectTimer !== undefined) {
|
|
333
|
+
clearTimeout(this.#reconnectTimer);
|
|
334
|
+
this.#reconnectTimer = undefined;
|
|
335
|
+
}
|
|
336
|
+
this.#failAll(reason);
|
|
337
|
+
this.#rejectWaiters(new Error(reason));
|
|
338
|
+
// Surface to the polyfill so any open stream fails with a NetworkError.
|
|
339
|
+
this.#emit(this.#errorListeners, {
|
|
340
|
+
deviceId: this.#deviceId,
|
|
341
|
+
portNumber: this.#portNumber,
|
|
342
|
+
error: reason,
|
|
343
|
+
});
|
|
344
|
+
this.#emit(this.#disconnectListeners, this.#connectEvent());
|
|
345
|
+
this.#options.onClosed?.(reason);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Release the socket because the consumer closed the port. Unlike a drop, this
|
|
350
|
+
* does not auto-reconnect (no lingering bridge session) — the next `open()` or
|
|
351
|
+
* discovery reconnects on demand. Distinct from the terminal `disconnect()`.
|
|
352
|
+
*/
|
|
353
|
+
#suspend(): void {
|
|
354
|
+
if (this.#reconnectTimer !== undefined) {
|
|
355
|
+
clearTimeout(this.#reconnectTimer);
|
|
356
|
+
this.#reconnectTimer = undefined;
|
|
357
|
+
}
|
|
358
|
+
this.#reconnectAttempts = 0;
|
|
359
|
+
this.#failAll('serial port closed');
|
|
360
|
+
this.#state = 'suspended';
|
|
361
|
+
const ws = this.#ws;
|
|
362
|
+
this.#ws = null; // detach first so the socket's close handler is a no-op
|
|
363
|
+
try {
|
|
364
|
+
ws?.close();
|
|
365
|
+
} catch {
|
|
366
|
+
// already closing
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Resolve when the socket is connected; reject on timeout or terminal close.
|
|
372
|
+
* Wakes a `suspended` transport (after `close()`) by reconnecting on demand.
|
|
373
|
+
*/
|
|
374
|
+
#whenConnected(): Promise<void> {
|
|
375
|
+
if (this.#state === 'open') {
|
|
376
|
+
return Promise.resolve();
|
|
377
|
+
}
|
|
378
|
+
if (this.#state === 'closed') {
|
|
379
|
+
return Promise.reject(new Error('WebSocket transport is closed.'));
|
|
380
|
+
}
|
|
381
|
+
if (this.#state === 'suspended') {
|
|
382
|
+
this.#connect(); // reconnect on demand
|
|
383
|
+
}
|
|
384
|
+
return new Promise<void>((resolve, reject) => {
|
|
385
|
+
const timer = setTimeout(() => {
|
|
386
|
+
this.#connectWaiters.delete(waiter);
|
|
387
|
+
reject(new Error('Timed out waiting for the WebSocket connection.'));
|
|
388
|
+
}, this.#connectTimeout);
|
|
389
|
+
const waiter: Waiter = {
|
|
390
|
+
resolve: () => {
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
resolve();
|
|
393
|
+
},
|
|
394
|
+
reject: e => {
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
reject(e);
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
this.#connectWaiters.add(waiter);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
#releaseWaiters(): void {
|
|
404
|
+
for (const waiter of [...this.#connectWaiters]) {
|
|
405
|
+
waiter.resolve();
|
|
406
|
+
}
|
|
407
|
+
this.#connectWaiters.clear();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#rejectWaiters(reason: Error): void {
|
|
411
|
+
for (const waiter of [...this.#connectWaiters]) {
|
|
412
|
+
waiter.reject(reason);
|
|
413
|
+
}
|
|
414
|
+
this.#connectWaiters.clear();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── message handling ───────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
#onMessage(data: unknown): void {
|
|
420
|
+
if (data instanceof ArrayBuffer) {
|
|
421
|
+
this.#emit(this.#dataListeners, {
|
|
422
|
+
deviceId: this.#deviceId,
|
|
423
|
+
portNumber: this.#portNumber,
|
|
424
|
+
data: Array.from(new Uint8Array(data)),
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (typeof data !== 'string') {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const msg = parseControlMessage(data);
|
|
432
|
+
if (!msg) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (msg.type === 'response') {
|
|
436
|
+
const pending = this.#pending.get(msg.id);
|
|
437
|
+
if (pending) {
|
|
438
|
+
this.#pending.delete(msg.id);
|
|
439
|
+
if (msg.error) {
|
|
440
|
+
pending.reject(new Error(msg.error));
|
|
441
|
+
} else {
|
|
442
|
+
pending.resolve(msg.result);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} else if (msg.type === 'event') {
|
|
446
|
+
if (msg.event === 'error') {
|
|
447
|
+
this.#emit(this.#errorListeners, {
|
|
448
|
+
deviceId: this.#deviceId,
|
|
449
|
+
portNumber: this.#portNumber,
|
|
450
|
+
error: msg.error ?? 'serial error',
|
|
451
|
+
});
|
|
452
|
+
} else if (msg.event === 'close') {
|
|
453
|
+
// The host's serial port itself went away — a real disconnect (distinct
|
|
454
|
+
// from a transport drop, which we reconnect through silently).
|
|
455
|
+
this.#portOpen = false;
|
|
456
|
+
this.#emit(this.#disconnectListeners, this.#connectEvent());
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#failAll(reason: string): void {
|
|
462
|
+
for (const [, pending] of this.#pending) {
|
|
463
|
+
pending.reject(new Error(reason));
|
|
464
|
+
}
|
|
465
|
+
this.#pending.clear();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#connectEvent(): ConnectEvent {
|
|
469
|
+
return {
|
|
470
|
+
deviceId: this.#deviceId,
|
|
471
|
+
usbVendorId: this.#usbVendorId,
|
|
472
|
+
usbProductId: this.#usbProductId,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Register a pending command and send it on the (already-open) socket. */
|
|
477
|
+
#sendCommand(
|
|
478
|
+
command: string,
|
|
479
|
+
args?: Record<string, unknown>,
|
|
480
|
+
): Promise<unknown> {
|
|
481
|
+
const ws = this.#ws;
|
|
482
|
+
if (!ws || this.#state === 'closed') {
|
|
483
|
+
return Promise.reject(new Error('WebSocket transport is closed.'));
|
|
484
|
+
}
|
|
485
|
+
const id = this.#nextId++;
|
|
486
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
487
|
+
const timer = setTimeout(() => {
|
|
488
|
+
if (this.#pending.delete(id)) {
|
|
489
|
+
reject(new Error(`Command "${command}" timed out.`));
|
|
490
|
+
}
|
|
491
|
+
}, this.#commandTimeout);
|
|
492
|
+
this.#pending.set(id, {
|
|
493
|
+
resolve: value => {
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
resolve(value);
|
|
496
|
+
},
|
|
497
|
+
reject: error => {
|
|
498
|
+
clearTimeout(timer);
|
|
499
|
+
reject(error);
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
try {
|
|
503
|
+
ws.send(JSON.stringify({type: 'command', id, command, args}));
|
|
504
|
+
} catch (e) {
|
|
505
|
+
this.#pending.delete(id);
|
|
506
|
+
clearTimeout(timer);
|
|
507
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Wait for a connection (reconnecting if necessary), then run a command. */
|
|
513
|
+
async #command(
|
|
514
|
+
command: string,
|
|
515
|
+
args?: Record<string, unknown>,
|
|
516
|
+
): Promise<unknown> {
|
|
517
|
+
await this.#whenConnected();
|
|
518
|
+
return this.#sendCommand(command, args);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#subscribe<E>(set: Set<Listener<E>>, listener: Listener<E>): Subscription {
|
|
522
|
+
set.add(listener);
|
|
523
|
+
return {remove: () => set.delete(listener)};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#emit<E>(set: Set<Listener<E>>, event: E): void {
|
|
527
|
+
for (const listener of [...set]) {
|
|
528
|
+
listener(event);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── discovery & permission ─────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
async findAllDrivers(): Promise<ReadonlyArray<PortId>> {
|
|
535
|
+
try {
|
|
536
|
+
await this.#whenConnected();
|
|
537
|
+
await this.#probeAlive();
|
|
538
|
+
await this.#loadPortInfo();
|
|
539
|
+
} catch {
|
|
540
|
+
// Discovery should be resilient: treat transport down as "no devices".
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
return [
|
|
544
|
+
{
|
|
545
|
+
deviceId: this.#deviceId,
|
|
546
|
+
portNumber: this.#portNumber,
|
|
547
|
+
usbVendorId: this.#usbVendorId,
|
|
548
|
+
usbProductId: this.#usbProductId,
|
|
549
|
+
hasPermission: true,
|
|
550
|
+
},
|
|
551
|
+
];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async showPortPicker(
|
|
555
|
+
_filter: ReadonlyArray<PortFilter>,
|
|
556
|
+
_labels?: PortPickerLabels,
|
|
557
|
+
): Promise<PortId> {
|
|
558
|
+
const [port] = await this.findAllDrivers();
|
|
559
|
+
return port;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
requestPermission(_deviceId: number): Promise<boolean> {
|
|
563
|
+
return Promise.resolve(true);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ── lifecycle ──────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
#lineCoding(options: OpenOptions): LineCoding {
|
|
569
|
+
return {
|
|
570
|
+
baudRate: options.baudRate,
|
|
571
|
+
dataBits: options.dataBits,
|
|
572
|
+
stopBits: options.stopBits,
|
|
573
|
+
parity: PARITY[options.parity ?? 0] ?? 'none',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async open(
|
|
578
|
+
_deviceId: number,
|
|
579
|
+
_portNumber: number,
|
|
580
|
+
options: OpenOptions,
|
|
581
|
+
): Promise<void> {
|
|
582
|
+
this.#lastLineCoding = this.#lineCoding(options);
|
|
583
|
+
await this.#command('setLineCoding', {...this.#lastLineCoding});
|
|
584
|
+
this.#portOpen = true;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async close(_deviceId: number, _portNumber: number): Promise<void> {
|
|
588
|
+
this.#portOpen = false;
|
|
589
|
+
this.#reading = false;
|
|
590
|
+
if (this.#state === 'closed' || this.#state === 'suspended') {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
// Tell the bridge to stop forwarding, then drop the socket so the remote
|
|
594
|
+
// serial session is released and the device isn't left in a stale state.
|
|
595
|
+
// A later open() reconnects on demand (see #whenConnected → #suspend).
|
|
596
|
+
if (this.#state === 'open') {
|
|
597
|
+
await this.#sendCommand('stopReading').catch(() => undefined);
|
|
598
|
+
}
|
|
599
|
+
this.#suspend();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
isOpen(_deviceId: number, _portNumber: number): boolean {
|
|
603
|
+
return this.#portOpen;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ── I/O ────────────────────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
async write(
|
|
609
|
+
_deviceId: number,
|
|
610
|
+
_portNumber: number,
|
|
611
|
+
data: number[],
|
|
612
|
+
_timeout?: number,
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
await this.#whenConnected();
|
|
615
|
+
const ws = this.#ws;
|
|
616
|
+
/* istanbul ignore next — #whenConnected() guarantees an attached socket */
|
|
617
|
+
if (!ws) {
|
|
618
|
+
throw new Error('WebSocket transport is closed.');
|
|
619
|
+
}
|
|
620
|
+
ws.send(Uint8Array.from(data));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async startReading(_deviceId: number, _portNumber: number): Promise<void> {
|
|
624
|
+
this.#reading = true;
|
|
625
|
+
await this.#command('startReading');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async stopReading(_deviceId: number, _portNumber: number): Promise<void> {
|
|
629
|
+
this.#reading = false;
|
|
630
|
+
await this.#command('stopReading');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async setParameters(
|
|
634
|
+
_deviceId: number,
|
|
635
|
+
_portNumber: number,
|
|
636
|
+
options: OpenOptions,
|
|
637
|
+
): Promise<void> {
|
|
638
|
+
this.#lastLineCoding = this.#lineCoding(options);
|
|
639
|
+
await this.#command('setLineCoding', {...this.#lastLineCoding});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── control signals ────────────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
async setDTR(_d: number, _p: number, value: boolean): Promise<void> {
|
|
645
|
+
this.#dtr = value;
|
|
646
|
+
await this.#command('setSignals', {dtr: value});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async setRTS(_d: number, _p: number, value: boolean): Promise<void> {
|
|
650
|
+
this.#rts = value;
|
|
651
|
+
await this.#command('setSignals', {rts: value});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
getDTR(_d: number, _p: number): Promise<boolean> {
|
|
655
|
+
return Promise.resolve(this.#dtr);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
getRTS(_d: number, _p: number): Promise<boolean> {
|
|
659
|
+
return Promise.resolve(this.#rts);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async #signals(): Promise<{
|
|
663
|
+
cts: boolean;
|
|
664
|
+
dsr: boolean;
|
|
665
|
+
dcd: boolean;
|
|
666
|
+
ri: boolean;
|
|
667
|
+
}> {
|
|
668
|
+
const s = (await this.#command('getSignals')) as {
|
|
669
|
+
cts?: boolean;
|
|
670
|
+
dsr?: boolean;
|
|
671
|
+
dcd?: boolean;
|
|
672
|
+
ri?: boolean;
|
|
673
|
+
} | null;
|
|
674
|
+
return {
|
|
675
|
+
cts: !!s?.cts,
|
|
676
|
+
dsr: !!s?.dsr,
|
|
677
|
+
dcd: !!s?.dcd,
|
|
678
|
+
ri: !!s?.ri,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async getCD(_d: number, _p: number): Promise<boolean> {
|
|
683
|
+
return (await this.#signals()).dcd;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async getCTS(_d: number, _p: number): Promise<boolean> {
|
|
687
|
+
return (await this.#signals()).cts;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async getDSR(_d: number, _p: number): Promise<boolean> {
|
|
691
|
+
return (await this.#signals()).dsr;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async getRI(_d: number, _p: number): Promise<boolean> {
|
|
695
|
+
return (await this.#signals()).ri;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async getControlLines(_d: number, _p: number): Promise<ControlLine[]> {
|
|
699
|
+
const s = await this.#signals();
|
|
700
|
+
const lines: ControlLine[] = [];
|
|
701
|
+
if (this.#rts) lines.push('RTS');
|
|
702
|
+
if (this.#dtr) lines.push('DTR');
|
|
703
|
+
if (s.cts) lines.push('CTS');
|
|
704
|
+
if (s.dsr) lines.push('DSR');
|
|
705
|
+
if (s.dcd) lines.push('CD');
|
|
706
|
+
if (s.ri) lines.push('RI');
|
|
707
|
+
return lines;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
getSupportedControlLines(_d: number, _p: number): Promise<ControlLine[]> {
|
|
711
|
+
return Promise.resolve(['RTS', 'CTS', 'DTR', 'DSR', 'CD', 'RI']);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ── flow control ───────────────────────────────────────────────────────────
|
|
715
|
+
|
|
716
|
+
setFlowControl(
|
|
717
|
+
_d: number,
|
|
718
|
+
_p: number,
|
|
719
|
+
_flowControl: FlowControl,
|
|
720
|
+
): Promise<void> {
|
|
721
|
+
// Flow control is fixed at the rate the bridge was started with; accept the
|
|
722
|
+
// call so open({flowControl}) doesn't fail, but it isn't changed live.
|
|
723
|
+
return Promise.resolve();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
getFlowControl(_d: number, _p: number): Promise<FlowControl> {
|
|
727
|
+
return Promise.resolve('NONE');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
getSupportedFlowControl(_d: number, _p: number): Promise<FlowControl[]> {
|
|
731
|
+
return Promise.resolve(['NONE', 'RTS_CTS']);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── misc ───────────────────────────────────────────────────────────────────
|
|
735
|
+
|
|
736
|
+
async setBreak(_d: number, _p: number, value: boolean): Promise<void> {
|
|
737
|
+
await this.#command('setSignals', {brk: value});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async purgeHwBuffers(
|
|
741
|
+
_d: number,
|
|
742
|
+
_p: number,
|
|
743
|
+
_purgeWrite: boolean,
|
|
744
|
+
_purgeRead: boolean,
|
|
745
|
+
): Promise<void> {
|
|
746
|
+
await this.#command('flush').catch(() => undefined);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
getSerial(_d: number, _p: number): Promise<string> {
|
|
750
|
+
return Promise.resolve(this.#serialNumber);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── subscriptions ──────────────────────────────────────────────────────────
|
|
754
|
+
|
|
755
|
+
onData(listener: Listener<DataEvent>): Subscription {
|
|
756
|
+
return this.#subscribe(this.#dataListeners, listener);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
onError(listener: Listener<ErrorEvent>): Subscription {
|
|
760
|
+
return this.#subscribe(this.#errorListeners, listener);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
onConnect(listener: Listener<ConnectEvent>): Subscription {
|
|
764
|
+
return this.#subscribe(this.#connectListeners, listener);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
onDisconnect(listener: Listener<ConnectEvent>): Subscription {
|
|
768
|
+
return this.#subscribe(this.#disconnectListeners, listener);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Permanently close the transport (and the remote port session). Cancels any
|
|
773
|
+
* pending reconnect and stops auto-reconnecting.
|
|
774
|
+
*/
|
|
775
|
+
disconnect(): void {
|
|
776
|
+
const alreadyClosed = this.#state === 'closed';
|
|
777
|
+
this.#state = 'closed'; // set first so the socket's close handler won't reconnect
|
|
778
|
+
if (this.#reconnectTimer !== undefined) {
|
|
779
|
+
clearTimeout(this.#reconnectTimer);
|
|
780
|
+
this.#reconnectTimer = undefined;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
this.#ws?.close();
|
|
784
|
+
} catch {
|
|
785
|
+
// already closing
|
|
786
|
+
}
|
|
787
|
+
if (!alreadyClosed) {
|
|
788
|
+
this.#portOpen = false;
|
|
789
|
+
this.#reading = false;
|
|
790
|
+
this.#failAll('WebSocket transport closed by client');
|
|
791
|
+
this.#rejectWaiters(new Error('WebSocket transport is closed.'));
|
|
792
|
+
this.#emit(this.#disconnectListeners, this.#connectEvent());
|
|
793
|
+
this.#options.onClosed?.('closed by client');
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|