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