react-native-web-serial-api 0.0.3 → 0.1.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 (119) hide show
  1. package/README.md +23 -0
  2. package/TESTING.md +301 -0
  3. package/android/build.gradle +2 -2
  4. package/lib/commonjs/UsbSerial.js +58 -26
  5. package/lib/commonjs/UsbSerial.js.map +1 -1
  6. package/lib/commonjs/WebSerial.js +169 -57
  7. package/lib/commonjs/WebSerial.js.map +1 -1
  8. package/lib/commonjs/index.js +13 -1
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/lib/dom-exception.js +176 -0
  11. package/lib/commonjs/lib/dom-exception.js.map +1 -0
  12. package/lib/commonjs/lib/event-target.js +138 -0
  13. package/lib/commonjs/lib/event-target.js.map +1 -0
  14. package/lib/commonjs/lib/promise.js +23 -0
  15. package/lib/commonjs/lib/promise.js.map +1 -0
  16. package/lib/commonjs/testing/index.js +70 -0
  17. package/lib/commonjs/testing/index.js.map +1 -0
  18. package/lib/commonjs/testing/install.js +54 -0
  19. package/lib/commonjs/testing/install.js.map +1 -0
  20. package/lib/commonjs/testing/serial-device.js +164 -0
  21. package/lib/commonjs/testing/serial-device.js.map +1 -0
  22. package/lib/commonjs/testing/virtual-serial.js +615 -0
  23. package/lib/commonjs/testing/virtual-serial.js.map +1 -0
  24. package/lib/commonjs/transport.js +61 -0
  25. package/lib/commonjs/transport.js.map +1 -0
  26. package/lib/typescript/src/UsbSerial.d.ts +24 -67
  27. package/lib/typescript/src/UsbSerial.d.ts.map +1 -1
  28. package/lib/typescript/src/WebSerial.d.ts +11 -2
  29. package/lib/typescript/src/WebSerial.d.ts.map +1 -1
  30. package/lib/typescript/src/index.d.ts +2 -0
  31. package/lib/typescript/src/index.d.ts.map +1 -1
  32. package/lib/typescript/src/lib/dom-exception.d.ts +100 -0
  33. package/lib/typescript/src/lib/dom-exception.d.ts.map +1 -0
  34. package/lib/typescript/src/lib/event-target.d.ts +53 -0
  35. package/lib/typescript/src/lib/event-target.d.ts.map +1 -0
  36. package/lib/typescript/src/lib/promise.d.ts +11 -0
  37. package/lib/typescript/src/lib/promise.d.ts.map +1 -0
  38. package/lib/typescript/src/testing/index.d.ts +23 -0
  39. package/lib/typescript/src/testing/index.d.ts.map +1 -0
  40. package/lib/typescript/src/testing/install.d.ts +25 -0
  41. package/lib/typescript/src/testing/install.d.ts.map +1 -0
  42. package/lib/typescript/src/testing/serial-device.d.ts +127 -0
  43. package/lib/typescript/src/testing/serial-device.d.ts.map +1 -0
  44. package/lib/typescript/src/testing/virtual-serial.d.ts +205 -0
  45. package/lib/typescript/src/testing/virtual-serial.d.ts.map +1 -0
  46. package/lib/typescript/src/transport.d.ts +131 -0
  47. package/lib/typescript/src/transport.d.ts.map +1 -0
  48. package/package.json +38 -2
  49. package/src/UsbSerial.ts +65 -90
  50. package/src/WebSerial.ts +227 -88
  51. package/src/index.ts +2 -7
  52. package/src/lib/dom-exception.ts +129 -60
  53. package/src/lib/event-target.ts +46 -21
  54. package/src/lib/promise.ts +7 -7
  55. package/src/testing/index.ts +42 -0
  56. package/src/testing/install.ts +65 -0
  57. package/src/testing/serial-device.ts +193 -0
  58. package/src/testing/virtual-serial.ts +801 -0
  59. package/src/transport.ts +200 -0
  60. package/babel.config.js +0 -3
  61. package/biome.json +0 -35
  62. package/example/.watchmanconfig +0 -1
  63. package/example/App.tsx +0 -71
  64. package/example/__tests__/App.test.tsx +0 -16
  65. package/example/__tests__/connectEvents.test.tsx +0 -81
  66. package/example/__tests__/getPorts.test.tsx +0 -140
  67. package/example/android/app/build.gradle +0 -120
  68. package/example/android/app/debug.keystore +0 -0
  69. package/example/android/app/proguard-rules.pro +0 -10
  70. package/example/android/app/src/debug/AndroidManifest.xml +0 -9
  71. package/example/android/app/src/main/AndroidManifest.xml +0 -38
  72. package/example/android/app/src/main/java/dev/uzlopak/MainActivity.kt +0 -22
  73. package/example/android/app/src/main/java/dev/uzlopak/MainApplication.kt +0 -41
  74. package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +0 -37
  75. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  76. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  77. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  78. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  79. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  80. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  81. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  82. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  83. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  84. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  85. package/example/android/app/src/main/res/values/strings.xml +0 -3
  86. package/example/android/app/src/main/res/values/styles.xml +0 -9
  87. package/example/android/build.gradle +0 -22
  88. package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  89. package/example/android/gradle/wrapper/gradle-wrapper.properties +0 -7
  90. package/example/android/gradle.properties +0 -47
  91. package/example/android/gradlew +0 -252
  92. package/example/android/gradlew.bat +0 -94
  93. package/example/android/settings.gradle +0 -6
  94. package/example/app.json +0 -4
  95. package/example/babel.config.js +0 -21
  96. package/example/biome.json +0 -47
  97. package/example/deploy.sh +0 -11
  98. package/example/index.html +0 -26
  99. package/example/index.js +0 -9
  100. package/example/index.web.js +0 -8
  101. package/example/jest.config.js +0 -12
  102. package/example/metro.config.js +0 -58
  103. package/example/package-lock.json +0 -14510
  104. package/example/package.json +0 -48
  105. package/example/react-native.config.js +0 -17
  106. package/example/src/components/AppBar.tsx +0 -73
  107. package/example/src/components/Menu.tsx +0 -90
  108. package/example/src/components/SingleChoiceDialog.tsx +0 -120
  109. package/example/src/screens/ConnectScreen.tsx +0 -195
  110. package/example/src/screens/DevicesScreen.tsx +0 -252
  111. package/example/src/screens/TerminalScreen.tsx +0 -572
  112. package/example/src/settings.ts +0 -43
  113. package/example/src/theme.ts +0 -19
  114. package/example/src/util/TextUtil.ts +0 -129
  115. package/example/tsconfig.json +0 -10
  116. package/example/vite.config.mjs +0 -55
  117. package/scripts/deploy-release.sh +0 -127
  118. package/tsconfig.build.json +0 -7
  119. package/tsconfig.json +0 -20
package/src/WebSerial.ts CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  WritableStream,
5
5
  } from 'web-streams-polyfill';
6
6
  import {DOMException} from './lib/dom-exception';
7
- import {Event, EventTarget} from './lib/event-target';
7
+ import {Event, EventTarget, setEventParent} from './lib/event-target';
8
8
  import {createDeferredPromise} from './lib/promise';
9
9
  import type {
10
10
  ConnectEvent,
@@ -12,8 +12,8 @@ import type {
12
12
  ErrorEvent,
13
13
  OpenOptions,
14
14
  PortId,
15
- UsbSerialModule,
16
- } from './UsbSerial';
15
+ SerialTransport,
16
+ } from './transport';
17
17
  import {getUsbSerial} from './UsbSerial';
18
18
 
19
19
  /**
@@ -171,7 +171,7 @@ type PortInternals = {
171
171
  getVid(): number | undefined;
172
172
  getPid(): number | undefined;
173
173
  getPortNumber(): number;
174
- getDeviceId(): number;
174
+ isForgotten(): boolean;
175
175
  setDeviceId(id: number): void;
176
176
  /** Reset to a closed, re-openable state after the device is physically lost. */
177
177
  handleDeviceLost(): void;
@@ -187,6 +187,7 @@ const kDefaultFlowControl: FlowControlType = 'none';
187
187
  const kAcceptableDataBits = [7, 8] as const;
188
188
  const kAcceptableStopBits = [1, 2] as const;
189
189
  const kAcceptableParity: ParityType[] = ['none', 'even', 'odd'];
190
+ const kAcceptableFlowControl: FlowControlType[] = ['none', 'hardware'];
190
191
 
191
192
  function parityToNative(parity: ParityType): number {
192
193
  switch (parity) {
@@ -199,8 +200,15 @@ function parityToNative(parity: ParityType): number {
199
200
  }
200
201
  }
201
202
 
203
+ const FLOW_CONTROL_NATIVE: Readonly<
204
+ Record<FlowControlType, 'RTS_CTS' | 'NONE'>
205
+ > = {
206
+ none: 'NONE',
207
+ hardware: 'RTS_CTS',
208
+ };
209
+
202
210
  function flowControlToNative(flowControl: FlowControlType): 'RTS_CTS' | 'NONE' {
203
- return flowControl === 'hardware' ? 'RTS_CTS' : 'NONE';
211
+ return FLOW_CONTROL_NATIVE[flowControl];
204
212
  }
205
213
 
206
214
  /**
@@ -239,7 +247,7 @@ export class SerialPort extends EventTarget {
239
247
  #pendingClosePromise: ReturnType<typeof createDeferredPromise<void>> | null =
240
248
  null; // [[pendingClosePromise]] = null
241
249
 
242
- #usb: UsbSerialModule;
250
+ #usb: SerialTransport;
243
251
  #deviceId: number;
244
252
  #portNumber: number;
245
253
  #usbVendorId?: number;
@@ -255,8 +263,34 @@ export class SerialPort extends EventTarget {
255
263
  #dataSubscription: {remove: () => void} | null = null;
256
264
  #errorSubscription: {remove: () => void} | null = null;
257
265
 
266
+ // The live stream controllers, captured in the readable/writable getters, so
267
+ // a device loss can error the in-flight read/write with a "NetworkError".
268
+ #readableController: ReadableStreamDefaultController<Uint8Array> | null =
269
+ null;
270
+ #writableController: WritableStreamDefaultController | null = null;
271
+ #forgetRequested: boolean = false;
272
+
273
+ #resetToClosedState(state: 'closed' | 'forgotten' = 'closed'): void {
274
+ this.#dataSubscription?.remove();
275
+ this.#errorSubscription?.remove();
276
+ this.#dataSubscription = null;
277
+ this.#errorSubscription = null;
278
+ this.#readableController = null;
279
+ this.#writableController = null;
280
+
281
+ this.#readable = null;
282
+ this.#writable = null;
283
+ this.#readFatal = false;
284
+ this.#writeFatal = false;
285
+ this.#pendingClosePromise = null;
286
+ this.#bufferSize = undefined;
287
+ this.#state = state;
288
+ this.#connected = false;
289
+ this.#forgetRequested = state === 'forgotten';
290
+ }
291
+
258
292
  constructor(
259
- usb: UsbSerialModule,
293
+ usb: SerialTransport,
260
294
  deviceId: number,
261
295
  portNumber: number,
262
296
  usbVendorId: number,
@@ -275,7 +309,7 @@ export class SerialPort extends EventTarget {
275
309
  getVid: () => this.#usbVendorId,
276
310
  getPid: () => this.#usbProductId,
277
311
  getPortNumber: () => this.#portNumber,
278
- getDeviceId: () => this.#deviceId,
312
+ isForgotten: () => this.#state === 'forgotten',
279
313
  setDeviceId: (id: number) => {
280
314
  this.#deviceId = id;
281
315
  serialPortDeviceIds.set(this, id);
@@ -291,33 +325,26 @@ export class SerialPort extends EventTarget {
291
325
  * After this, a later open() (e.g. when the device is re-attached) succeeds.
292
326
  */
293
327
  #handleDeviceLost(): void {
294
- // Tear down read/write streams without invoking the OS on the dead device.
328
+ const wasForgotten = this.#state === 'forgotten';
329
+
330
+ // Reject any in-flight read/write with a "NetworkError" (per spec), erroring
331
+ // the stream controllers directly. We must NOT cancel()/abort() the streams:
332
+ // cancel() rejects on a reader-locked stream (leaving the read orphaned) and
333
+ // both would try to invoke the OS on the now-gone device.
334
+ const lost = new DOMException('The device has been lost.', 'NetworkError');
295
335
  if (this.#readable) {
296
336
  this.#readFatal = true;
297
337
  try {
298
- // No active reader is required for cancel(); errors are non-fatal here.
299
- this.#readable.cancel().catch(() => {});
338
+ this.#readableController?.error(lost);
300
339
  } catch {}
301
340
  }
302
341
  if (this.#writable) {
303
342
  this.#writeFatal = true;
304
343
  try {
305
- this.#writable.abort().catch(() => {});
344
+ this.#writableController?.error(lost);
306
345
  } catch {}
307
346
  }
308
- this.#dataSubscription?.remove();
309
- this.#errorSubscription?.remove();
310
- this.#dataSubscription = null;
311
- this.#errorSubscription = null;
312
-
313
- this.#readable = null;
314
- this.#writable = null;
315
- this.#readFatal = false;
316
- this.#writeFatal = false;
317
- this.#pendingClosePromise = null;
318
- this.#bufferSize = undefined;
319
- this.#state = 'closed';
320
- this.#connected = false;
347
+ this.#resetToClosedState(wasForgotten ? 'forgotten' : 'closed');
321
348
 
322
349
  this.dispatchEvent(new Event('disconnect'));
323
350
  }
@@ -394,6 +421,7 @@ export class SerialPort extends EventTarget {
394
421
  const stream = new ReadableStream<Uint8Array>(
395
422
  {
396
423
  start(controller) {
424
+ self.#readableController = controller;
397
425
  self.#dataSubscription = self.#usb.onData((event: DataEvent) => {
398
426
  if (
399
427
  event.deviceId === deviceId &&
@@ -409,7 +437,15 @@ export class SerialPort extends EventTarget {
409
437
  event.portNumber === portNumber
410
438
  ) {
411
439
  self.#readFatal = true; // Set this.[[readFatal]] to true.
412
- controller.error(new DOMException(event.error, 'NetworkError'));
440
+ // Surface the spec error type (BreakError, BufferOverrunError,
441
+ // FramingError, ParityError, …) when the transport reports one;
442
+ // fall back to NetworkError otherwise.
443
+ controller.error(
444
+ new DOMException(
445
+ event.error,
446
+ event.errorName ?? 'NetworkError',
447
+ ),
448
+ );
413
449
  self.#handleClosingReadableStream();
414
450
  }
415
451
  });
@@ -456,6 +492,9 @@ export class SerialPort extends EventTarget {
456
492
 
457
493
  const stream = new WritableStream<Uint8Array>(
458
494
  {
495
+ start(controller) {
496
+ self.#writableController = controller;
497
+ },
459
498
  async write(chunk) {
460
499
  try {
461
500
  await self.#usb.write(deviceId, portNumber, Array.from(chunk));
@@ -541,7 +580,7 @@ export class SerialPort extends EventTarget {
541
580
 
542
581
  // 3. If options["baudRate"] is 0, reject promise with a TypeError and
543
582
  // return promise.
544
- if (options.baudRate === 0) {
583
+ if (!Number.isFinite(options.baudRate) || options.baudRate <= 0) {
545
584
  throw new TypeError('baudRate must be a positive, non-zero value.');
546
585
  }
547
586
 
@@ -562,7 +601,7 @@ export class SerialPort extends EventTarget {
562
601
  // 6. If options["bufferSize"] is 0, reject promise with a TypeError and
563
602
  // return promise.
564
603
  const bufferSize = options.bufferSize ?? kDefaultBufferSize;
565
- if (bufferSize === 0) {
604
+ if (!Number.isFinite(bufferSize) || bufferSize <= 0) {
566
605
  throw new TypeError('bufferSize must be a positive, non-zero value.');
567
606
  }
568
607
 
@@ -574,6 +613,11 @@ export class SerialPort extends EventTarget {
574
613
  }
575
614
 
576
615
  const flowControl = options.flowControl ?? kDefaultFlowControl;
616
+ if (!kAcceptableFlowControl.includes(flowControl)) {
617
+ throw new TypeError(
618
+ `flowControl must be one of: ${kAcceptableFlowControl.join(', ')}.`,
619
+ );
620
+ }
577
621
 
578
622
  // 8. Set this.[[state]] to "opening".
579
623
  this.#state = 'opening';
@@ -599,19 +643,60 @@ export class SerialPort extends EventTarget {
599
643
  );
600
644
  }
601
645
 
602
- if (flowControl !== 'none') {
603
- await this.#usb.setFlowControl(
604
- this.#deviceId,
605
- this.#portNumber,
606
- flowControlToNative(flowControl),
646
+ try {
647
+ if (flowControl !== 'none') {
648
+ await this.#usb.setFlowControl(
649
+ this.#deviceId,
650
+ this.#portNumber,
651
+ flowControlToNative(flowControl),
652
+ );
653
+ }
654
+
655
+ await this.#usb.startReading(this.#deviceId, this.#portNumber);
656
+ } catch (e) {
657
+ // If post-open setup fails (flow-control/read pump), best-effort close
658
+ // and reset local state so a subsequent open() can succeed cleanly.
659
+ try {
660
+ await this.#usb.close(this.#deviceId, this.#portNumber);
661
+ } catch {
662
+ // ignore
663
+ }
664
+
665
+ this.#resetToClosedState(this.#forgetRequested ? 'forgotten' : 'closed');
666
+
667
+ throw new DOMException(
668
+ `Failed to open serial port: ${(e as Error).message}`,
669
+ 'NetworkError',
670
+ );
671
+ }
672
+
673
+ // Guard against lifecycle races (e.g. forget() called while open() is
674
+ // still awaiting native setup). Do not revive a forgotten/changed state.
675
+ if (this.#state !== 'opening') {
676
+ try {
677
+ await this.#usb.close(this.#deviceId, this.#portNumber);
678
+ } catch {
679
+ // ignore
680
+ }
681
+
682
+ if (this.#state === 'forgotten' || this.#state === 'forgetting') {
683
+ this.#resetToClosedState('forgotten');
684
+ throw new DOMException(
685
+ 'The port has been forgotten while opening.',
686
+ 'InvalidStateError',
687
+ );
688
+ }
689
+
690
+ this.#resetToClosedState();
691
+ throw new DOMException(
692
+ 'Failed to open serial port: state changed while opening.',
693
+ 'NetworkError',
607
694
  );
608
695
  }
609
696
 
610
697
  this.#state = 'opened'; // 9.3. Set this.[[state]] to "opened".
611
698
  this.#bufferSize = bufferSize; // 9.4. Set this.[[bufferSize]] to options["bufferSize"].
612
699
 
613
- await this.#usb.startReading(this.#deviceId, this.#portNumber);
614
-
615
700
  // When the port becomes logically connected:
616
701
  this.#connected = true; // 2. Set port.[[connected]] to true.
617
702
  // NOTE: the port-level "connect"/"disconnect" events represent physical
@@ -676,18 +761,10 @@ export class SerialPort extends EventTarget {
676
761
  // ignore
677
762
  }
678
763
 
679
- this.#dataSubscription?.remove();
680
- this.#errorSubscription?.remove();
681
- this.#dataSubscription = null;
682
- this.#errorSubscription = null;
683
-
684
- this.#state = 'closed'; // 10.1.2. Set this.[[state]] to "closed".
685
- this.#readFatal = false; // 10.1.3. Set this.[[readFatal]] and this.[[writeFatal]] to false.
686
- this.#writeFatal = false; // 10.1.3. (continued)
687
- this.#pendingClosePromise = null; // 10.1.4. Set this.[[pendingClosePromise]] to null.
688
-
689
- // When the port is no longer logically connected:
690
- this.#connected = false; // 2. Set port.[[connected]] to false.
764
+ // 10.1.2-10.1.4 and logical disconnect bookkeeping.
765
+ // If forget() was requested while close() was in flight, preserve
766
+ // forgotten semantics instead of reviving the port to "closed".
767
+ this.#resetToClosedState(this.#forgetRequested ? 'forgotten' : 'closed');
691
768
  // (No port-level "disconnect" dispatch here — that event signals physical
692
769
  // detach, fired by Serial from the native USB state events. See open().)
693
770
  }
@@ -698,6 +775,14 @@ export class SerialPort extends EventTarget {
698
775
  async forget(): Promise<void> {
699
776
  // The forget() method steps are:
700
777
 
778
+ this.#forgetRequested = true;
779
+
780
+ // Forgetting an open port must not leave active streams/native resources
781
+ // behind. Close first so the instance becomes cleanly unusable afterwards.
782
+ if (this.#state === 'opened') {
783
+ await this.close();
784
+ }
785
+
701
786
  // 2.1. Set this.[[state]] to "forgetting".
702
787
  this.#state = 'forgetting';
703
788
  // 2.2. Remove this from the sequence of serial ports on the system which
@@ -824,6 +909,17 @@ export class SerialPort extends EventTarget {
824
909
  #handleClosingReadableStream(): void {
825
910
  // To handle closing the readable stream perform the following steps:
826
911
 
912
+ // Drop the native data/error subscription tied to this readable. Without
913
+ // this a later readable (after cancel() + re-acquire) would coexist with the
914
+ // old subscription, which then enqueues into the cancelled controller and
915
+ // throws. close()/handleDeviceLost() also clear these; doing it here covers
916
+ // a standalone readable.cancel().
917
+ this.#dataSubscription?.remove();
918
+ this.#errorSubscription?.remove();
919
+ this.#dataSubscription = null;
920
+ this.#errorSubscription = null;
921
+ this.#readableController = null;
922
+
827
923
  // 1. Set this.[[readable]] to null.
828
924
  this.#readable = null;
829
925
 
@@ -840,6 +936,8 @@ export class SerialPort extends EventTarget {
840
936
  #handleClosingWritableStream(): void {
841
937
  // To handle closing the writable stream perform the following steps:
842
938
 
939
+ this.#writableController = null;
940
+
843
941
  // 1. Set this.[[writable]] to null.
844
942
  this.#writable = null;
845
943
 
@@ -860,9 +958,23 @@ export class Serial extends EventTarget {
860
958
  disconnect: null as ((event: Event) => void) | null,
861
959
  };
862
960
 
863
- #usb: UsbSerialModule | null = null;
961
+ #usb: SerialTransport | null = null;
864
962
  #knownPorts: Map<string, SerialPort> = new Map();
865
963
  #initialized = false;
964
+ #injected: SerialTransport | null;
965
+
966
+ /**
967
+ * @param transport Optional transport override. When provided, this `Serial`
968
+ * talks to it instead of the global native module — the seam used by tests
969
+ * and the virtual-device harness (`new Serial(new VirtualSerialTransport())`).
970
+ * Omit it for the normal native-backed instance; the singleton `serial`
971
+ * export is created this way and can still be redirected globally via
972
+ * `setUsbSerial()`.
973
+ */
974
+ constructor(transport?: SerialTransport) {
975
+ super();
976
+ this.#injected = transport ?? null;
977
+ }
866
978
 
867
979
  /**
868
980
  * Subscribing to "connect"/"disconnect" must wire up the native USB state
@@ -880,42 +992,49 @@ export class Serial extends EventTarget {
880
992
  * Deferring native access out of the module-import path avoids touching the
881
993
  * TurboModule before the runtime is ready.
882
994
  */
883
- #ensureInit(): UsbSerialModule | null {
995
+ #ensureInit(): SerialTransport | null {
884
996
  if (this.#initialized) return this.#usb;
885
997
  this.#initialized = true;
886
998
  try {
887
- this.#usb = getUsbSerial();
999
+ this.#usb = this.#injected ?? getUsbSerial();
888
1000
 
889
1001
  // A USB device was attached. Android assigns a NEW deviceId on every
890
1002
  // attach, so match previously-known ports of the same physical device by
891
1003
  // VID/PID, update their deviceId, re-key them, and fire "connect" on the
892
1004
  // same SerialPort instance (W3C spec model: the port is reused).
893
1005
  this.#usb.onConnect((event: ConnectEvent) => {
894
- let matched = false;
895
- for (const [key, port] of [...this.#knownPorts.entries()]) {
896
- const internals = portInternals.get(port);
897
- if (!internals) continue;
898
- if (
899
- internals.getVid() === event.usbVendorId &&
900
- internals.getPid() === event.usbProductId
901
- ) {
902
- internals.setDeviceId(event.deviceId);
903
- const newKey = this.#portKey(
904
- event.deviceId,
905
- internals.getPortNumber(),
906
- );
907
- if (newKey !== key) {
908
- this.#knownPorts.delete(key);
909
- this.#knownPorts.set(newKey, port);
910
- }
911
- port.dispatchEvent(new Event('connect'));
912
- matched = true;
913
- }
1006
+ // Native connect events expose VID/PID but not a stable unique
1007
+ // identifier. To avoid mis-associating identical devices, remap only
1008
+ // when there is exactly one disconnected candidate.
1009
+ const candidates = Array.from(this.#knownPorts.entries()).filter(
1010
+ ([, port]) => {
1011
+ const internals = portInternals.get(port)!;
1012
+ if (internals.getVid() !== event.usbVendorId) return false;
1013
+ if (internals.getPid() !== event.usbProductId) return false;
1014
+ if (internals.isForgotten()) return false;
1015
+ return !port.connected;
1016
+ },
1017
+ );
1018
+
1019
+ if (candidates.length === 1) {
1020
+ const [key, port] = candidates[0];
1021
+ const internals = portInternals.get(port)!;
1022
+ internals.setDeviceId(event.deviceId);
1023
+ const newKey = this.#portKey(
1024
+ event.deviceId,
1025
+ internals.getPortNumber(),
1026
+ );
1027
+ this.#knownPorts.delete(key);
1028
+ this.#knownPorts.set(newKey, port);
1029
+ // Dispatched on the port; it bubbles to this Serial (event.target
1030
+ // stays the port, per spec — see setEventParent below).
1031
+ port.dispatchEvent(new Event('connect'));
1032
+ return;
914
1033
  }
1034
+
1035
+ // No known port matched (or matching is ambiguous): fire a Serial-level
1036
+ // "connect" so listeners refresh; getPorts() surfaces the new port.
915
1037
  this.dispatchEvent(new Event('connect'));
916
- // If we matched no known port this is a brand-new device; getPorts()
917
- // will surface it on the next refresh.
918
- void matched;
919
1038
  });
920
1039
 
921
1040
  // A USB device was detached. Reset the matching port(s) to a closed,
@@ -924,12 +1043,18 @@ export class Serial extends EventTarget {
924
1043
  // dispatches "disconnect" on the port.
925
1044
  this.#usb.onDisconnect((event: ConnectEvent) => {
926
1045
  const prefix = `${event.deviceId}:`;
1046
+ let matched = false;
927
1047
  for (const [key, port] of [...this.#knownPorts.entries()]) {
928
1048
  if (key.startsWith(prefix)) {
1049
+ // handleDeviceLost() dispatches "disconnect" on the port, which
1050
+ // bubbles here with event.target === the port (per spec).
929
1051
  portInternals.get(port)?.handleDeviceLost();
1052
+ matched = true;
930
1053
  }
931
1054
  }
932
- this.dispatchEvent(new Event('disconnect'));
1055
+ // No known port matched (e.g. a device never opened): fire a
1056
+ // Serial-level "disconnect" so listeners can still refresh.
1057
+ if (!matched) this.dispatchEvent(new Event('disconnect'));
933
1058
  });
934
1059
  } catch {
935
1060
  this.#usb = null;
@@ -1007,11 +1132,18 @@ export class Serial extends EventTarget {
1007
1132
  } of portIds) {
1008
1133
  if (!hasPermission) continue;
1009
1134
  const key = this.#portKey(deviceId, portNumber);
1010
- if (!this.#knownPorts.has(key)) {
1011
- this.#knownPorts.set(
1012
- key,
1013
- new SerialPort(usb, deviceId, portNumber, usbVendorId, usbProductId),
1135
+ const known = this.#knownPorts.get(key);
1136
+ if (!known || portInternals.get(known)?.isForgotten()) {
1137
+ const port = new SerialPort(
1138
+ usb,
1139
+ deviceId,
1140
+ portNumber,
1141
+ usbVendorId,
1142
+ usbProductId,
1014
1143
  );
1144
+ // The port's connect/disconnect events bubble to this Serial.
1145
+ setEventParent(port, this);
1146
+ this.#knownPorts.set(key, port);
1015
1147
  }
1016
1148
  ports.push(this.#knownPorts.get(key)!);
1017
1149
  }
@@ -1036,6 +1168,12 @@ export class Serial extends EventTarget {
1036
1168
  );
1037
1169
  }
1038
1170
 
1171
+ if ((options.allowedBluetoothServiceClassIds?.length ?? 0) > 0) {
1172
+ throw new TypeError(
1173
+ 'allowedBluetoothServiceClassIds is not supported in Android USB mode.',
1174
+ );
1175
+ }
1176
+
1039
1177
  // 4. If options["filters"] is present, then for each filter in
1040
1178
  // options["filters"] run the following steps:
1041
1179
  if (options.filters) {
@@ -1083,17 +1221,18 @@ export class Serial extends EventTarget {
1083
1221
 
1084
1222
  // 5.6. Let port be a SerialPort representing the port chosen by the user.
1085
1223
  const key = `${portId.deviceId}:${portId.portNumber}`;
1086
- if (!this.#knownPorts.has(key)) {
1087
- this.#knownPorts.set(
1088
- key,
1089
- new SerialPort(
1090
- usb,
1091
- portId.deviceId,
1092
- portId.portNumber,
1093
- portId.usbVendorId,
1094
- portId.usbProductId,
1095
- ),
1224
+ const known = this.#knownPorts.get(key);
1225
+ if (!known || portInternals.get(known)?.isForgotten()) {
1226
+ const port = new SerialPort(
1227
+ usb,
1228
+ portId.deviceId,
1229
+ portId.portNumber,
1230
+ portId.usbVendorId,
1231
+ portId.usbProductId,
1096
1232
  );
1233
+ // The port's connect/disconnect events bubble to this Serial.
1234
+ setEventParent(port, this);
1235
+ this.#knownPorts.set(key, port);
1097
1236
  }
1098
1237
 
1099
1238
  // 5.7. Resolve promise with port.
package/src/index.ts CHANGED
@@ -1,7 +1,6 @@
1
- // Platform-resolved Web Serial API instance.
2
- // On React Native (Android) this is the USB-serial-backed polyfill; on web it
3
- // is the browser's native navigator.serial. (See serial.android.ts / serial.web.ts)
4
1
  export {default as serial} from './serial';
2
+ export type {SerialTransport} from './transport';
3
+ export {resetUsbSerial, setUsbSerial} from './UsbSerial';
5
4
  export type {
6
5
  SerialInputSignals,
7
6
  SerialOptions,
@@ -13,11 +12,7 @@ export type {
13
12
  // W3C Web Serial API classes
14
13
  export {Serial, SerialPort} from './WebSerial';
15
14
 
16
- // Lower-level access to the raw USB-serial TurboModule (Android only).
17
- // (Imported + re-exported rather than `export * as` so older Babel presets
18
- // without @babel/plugin-transform-export-namespace-from can consume the source.)
19
15
  import * as UsbSerial from './UsbSerial';
20
16
 
21
- // Web Serial API event/exception primitives used by the polyfill
22
17
  export {Event, EventTarget} from './lib/event-target';
23
18
  export {UsbSerial};