react-native-web-serial-api 0.0.3 → 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 (177) hide show
  1. package/README.md +198 -104
  2. package/TESTING.md +542 -0
  3. package/android/build.gradle +16 -2
  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 +58 -26
  8. package/lib/commonjs/UsbSerial.js.map +1 -1
  9. package/lib/commonjs/WebSerial.js +273 -77
  10. package/lib/commonjs/WebSerial.js.map +1 -1
  11. package/lib/commonjs/index.js +15 -3
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/lib/dom-exception.js +176 -0
  14. package/lib/commonjs/lib/dom-exception.js.map +1 -0
  15. package/lib/commonjs/lib/event-target.js +140 -0
  16. package/lib/commonjs/lib/event-target.js.map +1 -0
  17. package/lib/commonjs/lib/promise.js +23 -0
  18. package/lib/commonjs/lib/promise.js.map +1 -0
  19. package/lib/commonjs/lib/web-streams.js +42 -0
  20. package/lib/commonjs/lib/web-streams.js.map +1 -0
  21. package/lib/commonjs/testing/device-fixture.js +70 -0
  22. package/lib/commonjs/testing/device-fixture.js.map +1 -0
  23. package/lib/commonjs/testing/expose.js +91 -0
  24. package/lib/commonjs/testing/expose.js.map +1 -0
  25. package/lib/commonjs/testing/harness.js +98 -0
  26. package/lib/commonjs/testing/harness.js.map +1 -0
  27. package/lib/commonjs/testing/in-memory-serial-transport.js +653 -0
  28. package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
  29. package/lib/commonjs/testing/index.js +153 -0
  30. package/lib/commonjs/testing/index.js.map +1 -0
  31. package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
  32. package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
  33. package/lib/commonjs/testing/serial-client.js +277 -0
  34. package/lib/commonjs/testing/serial-client.js.map +1 -0
  35. package/lib/commonjs/testing/simulated-device.js +164 -0
  36. package/lib/commonjs/testing/simulated-device.js.map +1 -0
  37. package/lib/commonjs/testing/test-suite.js +142 -0
  38. package/lib/commonjs/testing/test-suite.js.map +1 -0
  39. package/lib/commonjs/transport.js +61 -0
  40. package/lib/commonjs/transport.js.map +1 -0
  41. package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
  42. package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
  43. package/lib/commonjs/websocket/bridge.js +234 -0
  44. package/lib/commonjs/websocket/bridge.js.map +1 -0
  45. package/lib/commonjs/websocket/index.js +33 -0
  46. package/lib/commonjs/websocket/index.js.map +1 -0
  47. package/lib/commonjs/websocket/protocol.js +55 -0
  48. package/lib/commonjs/websocket/protocol.js.map +1 -0
  49. package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
  50. package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
  51. package/lib/typescript/src/UsbSerial.d.ts +24 -67
  52. package/lib/typescript/src/UsbSerial.d.ts.map +1 -1
  53. package/lib/typescript/src/WebSerial.d.ts +16 -7
  54. package/lib/typescript/src/WebSerial.d.ts.map +1 -1
  55. package/lib/typescript/src/index.d.ts +3 -1
  56. package/lib/typescript/src/index.d.ts.map +1 -1
  57. package/lib/typescript/src/lib/dom-exception.d.ts +100 -0
  58. package/lib/typescript/src/lib/dom-exception.d.ts.map +1 -0
  59. package/lib/typescript/src/lib/event-target.d.ts +55 -0
  60. package/lib/typescript/src/lib/event-target.d.ts.map +1 -0
  61. package/lib/typescript/src/lib/promise.d.ts +11 -0
  62. package/lib/typescript/src/lib/promise.d.ts.map +1 -0
  63. package/lib/typescript/src/lib/web-streams.d.ts +9 -0
  64. package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
  65. package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
  66. package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
  67. package/lib/typescript/src/testing/expose.d.ts +71 -0
  68. package/lib/typescript/src/testing/expose.d.ts.map +1 -0
  69. package/lib/typescript/src/testing/harness.d.ts +34 -0
  70. package/lib/typescript/src/testing/harness.d.ts.map +1 -0
  71. package/lib/typescript/src/testing/in-memory-serial-transport.d.ts +216 -0
  72. package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
  73. package/lib/typescript/src/testing/index.d.ts +33 -0
  74. package/lib/typescript/src/testing/index.d.ts.map +1 -0
  75. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
  76. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
  77. package/lib/typescript/src/testing/serial-client.d.ts +62 -0
  78. package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
  79. package/lib/typescript/src/testing/simulated-device.d.ts +127 -0
  80. package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
  81. package/lib/typescript/src/testing/test-suite.d.ts +75 -0
  82. package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
  83. package/lib/typescript/src/transport.d.ts +131 -0
  84. package/lib/typescript/src/transport.d.ts.map +1 -0
  85. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
  86. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
  87. package/lib/typescript/src/websocket/bridge.d.ts +66 -0
  88. package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
  89. package/lib/typescript/src/websocket/index.d.ts +19 -0
  90. package/lib/typescript/src/websocket/index.d.ts.map +1 -0
  91. package/lib/typescript/src/websocket/protocol.d.ts +64 -0
  92. package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
  93. package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
  94. package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
  95. package/package.json +57 -3
  96. package/src/UsbSerial.ts +65 -90
  97. package/src/WebSerial.ts +351 -113
  98. package/src/index.ts +6 -8
  99. package/src/lib/dom-exception.ts +129 -60
  100. package/src/lib/event-target.ts +58 -21
  101. package/src/lib/promise.ts +7 -7
  102. package/src/lib/web-streams.ts +43 -0
  103. package/src/testing/device-fixture.ts +150 -0
  104. package/src/testing/expose.ts +147 -0
  105. package/src/testing/harness.ts +124 -0
  106. package/src/testing/in-memory-serial-transport.ts +840 -0
  107. package/src/testing/index.ts +90 -0
  108. package/src/testing/install-in-memory-serial-transport.ts +65 -0
  109. package/src/testing/serial-client.ts +313 -0
  110. package/src/testing/simulated-device.ts +193 -0
  111. package/src/testing/test-suite.ts +186 -0
  112. package/src/transport.ts +200 -0
  113. package/src/websocket/WebSocketSerialTransport.ts +796 -0
  114. package/src/websocket/bridge.ts +299 -0
  115. package/src/websocket/index.ts +38 -0
  116. package/src/websocket/protocol.ts +101 -0
  117. package/src/websocket/serial-device-bridge.ts +160 -0
  118. package/babel.config.js +0 -3
  119. package/biome.json +0 -35
  120. package/example/.watchmanconfig +0 -1
  121. package/example/App.tsx +0 -71
  122. package/example/__tests__/App.test.tsx +0 -16
  123. package/example/__tests__/connectEvents.test.tsx +0 -81
  124. package/example/__tests__/getPorts.test.tsx +0 -140
  125. package/example/android/app/build.gradle +0 -120
  126. package/example/android/app/debug.keystore +0 -0
  127. package/example/android/app/proguard-rules.pro +0 -10
  128. package/example/android/app/src/debug/AndroidManifest.xml +0 -9
  129. package/example/android/app/src/main/AndroidManifest.xml +0 -38
  130. package/example/android/app/src/main/java/dev/uzlopak/MainActivity.kt +0 -22
  131. package/example/android/app/src/main/java/dev/uzlopak/MainApplication.kt +0 -41
  132. package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +0 -37
  133. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  134. package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  135. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  136. package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  137. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  138. package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  139. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  140. package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  141. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  142. package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  143. package/example/android/app/src/main/res/values/strings.xml +0 -3
  144. package/example/android/app/src/main/res/values/styles.xml +0 -9
  145. package/example/android/build.gradle +0 -22
  146. package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  147. package/example/android/gradle/wrapper/gradle-wrapper.properties +0 -7
  148. package/example/android/gradle.properties +0 -47
  149. package/example/android/gradlew +0 -252
  150. package/example/android/gradlew.bat +0 -94
  151. package/example/android/settings.gradle +0 -6
  152. package/example/app.json +0 -4
  153. package/example/babel.config.js +0 -21
  154. package/example/biome.json +0 -47
  155. package/example/deploy.sh +0 -11
  156. package/example/index.html +0 -26
  157. package/example/index.js +0 -9
  158. package/example/index.web.js +0 -8
  159. package/example/jest.config.js +0 -12
  160. package/example/metro.config.js +0 -58
  161. package/example/package-lock.json +0 -14510
  162. package/example/package.json +0 -48
  163. package/example/react-native.config.js +0 -17
  164. package/example/src/components/AppBar.tsx +0 -73
  165. package/example/src/components/Menu.tsx +0 -90
  166. package/example/src/components/SingleChoiceDialog.tsx +0 -120
  167. package/example/src/screens/ConnectScreen.tsx +0 -195
  168. package/example/src/screens/DevicesScreen.tsx +0 -252
  169. package/example/src/screens/TerminalScreen.tsx +0 -572
  170. package/example/src/settings.ts +0 -43
  171. package/example/src/theme.ts +0 -19
  172. package/example/src/util/TextUtil.ts +0 -129
  173. package/example/tsconfig.json +0 -10
  174. package/example/vite.config.mjs +0 -55
  175. package/scripts/deploy-release.sh +0 -127
  176. package/tsconfig.build.json +0 -7
  177. package/tsconfig.json +0 -20
package/src/WebSerial.ts CHANGED
@@ -1,19 +1,19 @@
1
- import {
2
- ByteLengthQueuingStrategy,
3
- ReadableStream,
4
- WritableStream,
5
- } from 'web-streams-polyfill';
6
1
  import {DOMException} from './lib/dom-exception';
7
- import {Event, EventTarget} from './lib/event-target';
2
+ import {Event, EventTarget, setEventParent} from './lib/event-target';
8
3
  import {createDeferredPromise} from './lib/promise';
4
+ import {
5
+ ByteLengthQueuingStrategyImpl as ByteLengthQueuingStrategy,
6
+ ReadableStreamImpl as ReadableStream,
7
+ WritableStreamImpl as WritableStream,
8
+ } from './lib/web-streams';
9
9
  import type {
10
10
  ConnectEvent,
11
11
  DataEvent,
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
  /**
@@ -23,14 +23,14 @@ import {getUsbSerial} from './UsbSerial';
23
23
  * even - Data word plus parity bit has even parity.
24
24
  * odd - Data word plus parity bit has odd parity.
25
25
  */
26
- type ParityType = 'none' | 'even' | 'odd';
26
+ export type Parity = 'none' | 'even' | 'odd';
27
27
 
28
28
  /**
29
29
  * @see https://wicg.github.io/serial/#dom-flowcontroltype
30
30
  * none - No flow control is enabled.
31
31
  * hardware - Hardware flow control using the RTS and CTS signals is enabled.
32
32
  */
33
- type FlowControlType = 'none' | 'hardware';
33
+ type FlowControl = 'none' | 'hardware';
34
34
 
35
35
  /**
36
36
  * @see https://wicg.github.io/serial/#dom-serialoptions
@@ -60,7 +60,7 @@ export type SerialOptions = {
60
60
  /**
61
61
  * The parity mode.
62
62
  */
63
- parity?: ParityType;
63
+ parity?: Parity;
64
64
  /**
65
65
  * A positive, non-zero value indicating the size of the read and write
66
66
  * buffers that should be created.
@@ -69,7 +69,7 @@ export type SerialOptions = {
69
69
  /**
70
70
  * The flow control mode.
71
71
  */
72
- flowControl?: FlowControlType;
72
+ flowControl?: FlowControl;
73
73
  };
74
74
 
75
75
  /**
@@ -171,24 +171,27 @@ 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;
178
+ /** Reset to a closed, re-openable state after a clean physical detach. */
179
+ handleDeviceDetached(): void;
178
180
  };
179
181
  const portInternals = new WeakMap<SerialPort, PortInternals>();
180
182
 
181
183
  const kDefaultDataBits = 8;
182
184
  const kDefaultStopBits = 1;
183
- const kDefaultParity: ParityType = 'none';
185
+ const kDefaultParity: Parity = 'none';
184
186
  const kDefaultBufferSize = 255;
185
- const kDefaultFlowControl: FlowControlType = 'none';
187
+ const kDefaultFlowControl: FlowControl = 'none';
186
188
 
187
189
  const kAcceptableDataBits = [7, 8] as const;
188
190
  const kAcceptableStopBits = [1, 2] as const;
189
- const kAcceptableParity: ParityType[] = ['none', 'even', 'odd'];
191
+ const kAcceptableParity: Parity[] = ['none', 'even', 'odd'];
192
+ const kAcceptableFlowControl: FlowControl[] = ['none', 'hardware'];
190
193
 
191
- function parityToNative(parity: ParityType): number {
194
+ function parityToNative(parity: Parity): number {
192
195
  switch (parity) {
193
196
  case 'odd':
194
197
  return 1;
@@ -199,8 +202,32 @@ function parityToNative(parity: ParityType): number {
199
202
  }
200
203
  }
201
204
 
202
- function flowControlToNative(flowControl: FlowControlType): 'RTS_CTS' | 'NONE' {
203
- return flowControl === 'hardware' ? 'RTS_CTS' : 'NONE';
205
+ const FLOW_CONTROL_NATIVE: Readonly<Record<FlowControl, 'RTS_CTS' | 'NONE'>> = {
206
+ none: 'NONE',
207
+ hardware: 'RTS_CTS',
208
+ };
209
+
210
+ function flowControlToNative(flowControl: FlowControl): 'RTS_CTS' | 'NONE' {
211
+ return FLOW_CONTROL_NATIVE[flowControl];
212
+ }
213
+
214
+ /**
215
+ * Normalise a writable chunk to a plain byte array. The Web Serial writable
216
+ * accepts any BufferSource — a bare `ArrayBuffer` or any `ArrayBufferView`
217
+ * (`DataView`, typed arrays, including views with a non-zero `byteOffset`). A
218
+ * naive `Array.from(chunk)` only works for the iterable typed arrays and
219
+ * silently yields `[]` for an `ArrayBuffer`/`DataView`, so handle each shape.
220
+ */
221
+ function bufferSourceToBytes(chunk: ArrayBufferView | ArrayBuffer): number[] {
222
+ if (chunk instanceof Uint8Array) {
223
+ return Array.from(chunk);
224
+ }
225
+ if (ArrayBuffer.isView(chunk)) {
226
+ return Array.from(
227
+ new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength),
228
+ );
229
+ }
230
+ return Array.from(new Uint8Array(chunk as ArrayBuffer));
204
231
  }
205
232
 
206
233
  /**
@@ -239,24 +266,60 @@ export class SerialPort extends EventTarget {
239
266
  #pendingClosePromise: ReturnType<typeof createDeferredPromise<void>> | null =
240
267
  null; // [[pendingClosePromise]] = null
241
268
 
242
- #usb: UsbSerialModule;
269
+ #usb: SerialTransport;
243
270
  #deviceId: number;
244
271
  #portNumber: number;
245
272
  #usbVendorId?: number;
246
273
  #usbProductId?: number;
247
-
248
- // Tracks the current state of output signals for partial updates
249
- #outputSignals: Required<SerialOutputSignals> = {
250
- dataTerminalReady: false,
251
- requestToSend: false,
252
- break: false,
253
- };
274
+ // Baud rate of the current open(), used to size the native write timeout.
275
+ #baudRate: number = 0;
254
276
 
255
277
  #dataSubscription: {remove: () => void} | null = null;
256
278
  #errorSubscription: {remove: () => void} | null = null;
257
279
 
280
+ // The live stream controllers, captured in the readable/writable getters, so
281
+ // a device loss can error the in-flight read/write with a "NetworkError".
282
+ #readableController: ReadableStreamDefaultController<Uint8Array> | null =
283
+ null;
284
+ #writableController: WritableStreamDefaultController | null = null;
285
+ #forgetRequested: boolean = false;
286
+
287
+ #resetToClosedState(state: 'closed' | 'forgotten' = 'closed'): void {
288
+ this.#dataSubscription?.remove();
289
+ this.#errorSubscription?.remove();
290
+ this.#dataSubscription = null;
291
+ this.#errorSubscription = null;
292
+ this.#readableController = null;
293
+ this.#writableController = null;
294
+
295
+ this.#readable = null;
296
+ this.#writable = null;
297
+ this.#readFatal = false;
298
+ this.#writeFatal = false;
299
+ this.#pendingClosePromise = null;
300
+ this.#bufferSize = undefined;
301
+ this.#baudRate = 0;
302
+ this.#state = state;
303
+ this.#connected = false;
304
+ this.#forgetRequested = state === 'forgotten';
305
+ }
306
+
307
+ /**
308
+ * Compute a native write timeout (ms) large enough to actually drain `length`
309
+ * bytes at the negotiated baud rate. A fixed 2 s timeout spuriously fails
310
+ * large writes on slow links; this scales with the payload while keeping a 2 s
311
+ * floor. ~10 bits per byte (start + 8 data + stop) with a 2× safety margin.
312
+ */
313
+ #writeTimeoutFor(length: number): number {
314
+ const kMinTimeoutMs = 2000;
315
+ /* istanbul ignore next — baudRate is always > 0 when the port is open */
316
+ const baud = this.#baudRate > 0 ? this.#baudRate : 9600;
317
+ const estimatedMs = Math.ceil((length * 10 * 1000) / baud) * 2;
318
+ return Math.max(kMinTimeoutMs, estimatedMs);
319
+ }
320
+
258
321
  constructor(
259
- usb: UsbSerialModule,
322
+ usb: SerialTransport,
260
323
  deviceId: number,
261
324
  portNumber: number,
262
325
  usbVendorId: number,
@@ -275,12 +338,13 @@ export class SerialPort extends EventTarget {
275
338
  getVid: () => this.#usbVendorId,
276
339
  getPid: () => this.#usbProductId,
277
340
  getPortNumber: () => this.#portNumber,
278
- getDeviceId: () => this.#deviceId,
341
+ isForgotten: () => this.#state === 'forgotten',
279
342
  setDeviceId: (id: number) => {
280
343
  this.#deviceId = id;
281
344
  serialPortDeviceIds.set(this, id);
282
345
  },
283
346
  handleDeviceLost: () => this.#handleDeviceLost(),
347
+ handleDeviceDetached: () => this.#handleDeviceDetached(),
284
348
  });
285
349
  }
286
350
 
@@ -291,35 +355,75 @@ export class SerialPort extends EventTarget {
291
355
  * After this, a later open() (e.g. when the device is re-attached) succeeds.
292
356
  */
293
357
  #handleDeviceLost(): void {
294
- // Tear down read/write streams without invoking the OS on the dead device.
358
+ const wasForgotten = this.#state === 'forgotten';
359
+
360
+ // Reject any in-flight read/write with a "NetworkError" (per spec), erroring
361
+ // the stream controllers directly. We must NOT cancel()/abort() the streams:
362
+ // cancel() rejects on a reader-locked stream (leaving the read orphaned) and
363
+ // both would try to invoke the OS on the now-gone device.
364
+ const lost = new DOMException('The device has been lost.', 'NetworkError');
295
365
  if (this.#readable) {
296
366
  this.#readFatal = true;
297
367
  try {
298
- // No active reader is required for cancel(); errors are non-fatal here.
299
- this.#readable.cancel().catch(() => {});
368
+ this.#readableController?.error(lost);
300
369
  } catch {}
301
370
  }
302
371
  if (this.#writable) {
303
372
  this.#writeFatal = true;
304
373
  try {
305
- this.#writable.abort().catch(() => {});
374
+ this.#writableController?.error(lost);
306
375
  } catch {}
307
376
  }
308
- this.#dataSubscription?.remove();
309
- this.#errorSubscription?.remove();
310
- this.#dataSubscription = null;
311
- this.#errorSubscription = null;
377
+ this.#resetToClosedState(wasForgotten ? 'forgotten' : 'closed');
312
378
 
379
+ this.dispatchEvent(new Event('disconnect'));
380
+ }
381
+
382
+ /**
383
+ * Reset this port to a closed, re-openable state after a clean physical
384
+ * detach. Unlike device loss, buffered readable bytes should still be
385
+ * observable before the stream ends.
386
+ */
387
+ #handleDeviceDetached(): void {
388
+ const wasForgotten = this.#state === 'forgotten';
389
+ const readable = this.#readable;
390
+ const writable = this.#writable;
391
+
392
+ // Keep the public event and state transition synchronous so existing
393
+ // callers observe the detach immediately, but defer the stream teardown by
394
+ // one microtask to let already-queued data drain first.
395
+ this.#state = wasForgotten ? 'forgotten' : 'closed';
396
+ this.#connected = false;
313
397
  this.#readable = null;
314
398
  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;
321
-
322
399
  this.dispatchEvent(new Event('disconnect'));
400
+
401
+ queueMicrotask(() => {
402
+ // Close the readable stream so any bytes already queued can still be
403
+ // drained by the consumer before EOF.
404
+ try {
405
+ if (readable) this.#readableController?.close();
406
+ } catch {}
407
+
408
+ // A detached device can no longer accept writes, so reject any writable
409
+ // traffic and clear local state.
410
+ const lost = new DOMException(
411
+ 'The device has been lost.',
412
+ 'NetworkError',
413
+ );
414
+ if (writable) {
415
+ this.#writeFatal = true;
416
+ try {
417
+ this.#writableController?.error(lost);
418
+ } catch {}
419
+ }
420
+
421
+ // Mirror the stream-closing bookkeeping without treating this as a hard
422
+ // read fatal error.
423
+ this.#handleClosingReadableStream();
424
+ this.#handleClosingWritableStream();
425
+ this.#resetToClosedState(wasForgotten ? 'forgotten' : 'closed');
426
+ });
323
427
  }
324
428
 
325
429
  /**
@@ -394,12 +498,21 @@ export class SerialPort extends EventTarget {
394
498
  const stream = new ReadableStream<Uint8Array>(
395
499
  {
396
500
  start(controller) {
501
+ self.#readableController = controller;
397
502
  self.#dataSubscription = self.#usb.onData((event: DataEvent) => {
398
503
  if (
399
504
  event.deviceId === deviceId &&
400
505
  event.portNumber === portNumber
401
506
  ) {
402
- controller.enqueue(new Uint8Array(event.data));
507
+ try {
508
+ controller.enqueue(new Uint8Array(event.data));
509
+ } catch {
510
+ // The stream may already be closing/closed — e.g. a data event
511
+ // racing cancel()/close(), where the stream is closed but this
512
+ // subscription is not yet torn down. Dropping the chunk is
513
+ // correct (the consumer has stopped reading) and avoids throwing
514
+ // out of the transport's event-dispatch loop.
515
+ }
403
516
  }
404
517
  });
405
518
 
@@ -409,7 +522,15 @@ export class SerialPort extends EventTarget {
409
522
  event.portNumber === portNumber
410
523
  ) {
411
524
  self.#readFatal = true; // Set this.[[readFatal]] to true.
412
- controller.error(new DOMException(event.error, 'NetworkError'));
525
+ // Surface the spec error type (BreakError, BufferOverrunError,
526
+ // FramingError, ParityError, …) when the transport reports one;
527
+ // fall back to NetworkError otherwise.
528
+ controller.error(
529
+ new DOMException(
530
+ event.error,
531
+ event.errorName ?? 'NetworkError',
532
+ ),
533
+ );
413
534
  self.#handleClosingReadableStream();
414
535
  }
415
536
  });
@@ -456,9 +577,18 @@ export class SerialPort extends EventTarget {
456
577
 
457
578
  const stream = new WritableStream<Uint8Array>(
458
579
  {
580
+ start(controller) {
581
+ self.#writableController = controller;
582
+ },
459
583
  async write(chunk) {
584
+ const bytes = bufferSourceToBytes(chunk);
460
585
  try {
461
- await self.#usb.write(deviceId, portNumber, Array.from(chunk));
586
+ await self.#usb.write(
587
+ deviceId,
588
+ portNumber,
589
+ bytes,
590
+ self.#writeTimeoutFor(bytes.length),
591
+ );
462
592
  } catch (e) {
463
593
  // If the port was disconnected, set this.[[writeFatal]] to true.
464
594
  self.#writeFatal = true;
@@ -541,7 +671,7 @@ export class SerialPort extends EventTarget {
541
671
 
542
672
  // 3. If options["baudRate"] is 0, reject promise with a TypeError and
543
673
  // return promise.
544
- if (options.baudRate === 0) {
674
+ if (!Number.isFinite(options.baudRate) || options.baudRate <= 0) {
545
675
  throw new TypeError('baudRate must be a positive, non-zero value.');
546
676
  }
547
677
 
@@ -562,7 +692,7 @@ export class SerialPort extends EventTarget {
562
692
  // 6. If options["bufferSize"] is 0, reject promise with a TypeError and
563
693
  // return promise.
564
694
  const bufferSize = options.bufferSize ?? kDefaultBufferSize;
565
- if (bufferSize === 0) {
695
+ if (!Number.isFinite(bufferSize) || bufferSize <= 0) {
566
696
  throw new TypeError('bufferSize must be a positive, non-zero value.');
567
697
  }
568
698
 
@@ -574,6 +704,11 @@ export class SerialPort extends EventTarget {
574
704
  }
575
705
 
576
706
  const flowControl = options.flowControl ?? kDefaultFlowControl;
707
+ if (!kAcceptableFlowControl.includes(flowControl)) {
708
+ throw new TypeError(
709
+ `flowControl must be one of: ${kAcceptableFlowControl.join(', ')}.`,
710
+ );
711
+ }
577
712
 
578
713
  // 8. Set this.[[state]] to "opening".
579
714
  this.#state = 'opening';
@@ -599,18 +734,60 @@ export class SerialPort extends EventTarget {
599
734
  );
600
735
  }
601
736
 
602
- if (flowControl !== 'none') {
603
- await this.#usb.setFlowControl(
604
- this.#deviceId,
605
- this.#portNumber,
606
- flowControlToNative(flowControl),
737
+ try {
738
+ if (flowControl !== 'none') {
739
+ await this.#usb.setFlowControl(
740
+ this.#deviceId,
741
+ this.#portNumber,
742
+ flowControlToNative(flowControl),
743
+ );
744
+ }
745
+
746
+ await this.#usb.startReading(this.#deviceId, this.#portNumber);
747
+ } catch (e) {
748
+ // If post-open setup fails (flow-control/read pump), best-effort close
749
+ // and reset local state so a subsequent open() can succeed cleanly.
750
+ try {
751
+ await this.#usb.close(this.#deviceId, this.#portNumber);
752
+ } catch {
753
+ // ignore
754
+ }
755
+
756
+ this.#resetToClosedState(this.#forgetRequested ? 'forgotten' : 'closed');
757
+
758
+ throw new DOMException(
759
+ `Failed to open serial port: ${(e as Error).message}`,
760
+ 'NetworkError',
761
+ );
762
+ }
763
+
764
+ // Guard against lifecycle races (e.g. forget() called while open() is
765
+ // still awaiting native setup). Do not revive a forgotten/changed state.
766
+ if (this.#state !== 'opening') {
767
+ try {
768
+ await this.#usb.close(this.#deviceId, this.#portNumber);
769
+ } catch {
770
+ // ignore
771
+ }
772
+
773
+ if (this.#state === 'forgotten' || this.#state === 'forgetting') {
774
+ this.#resetToClosedState('forgotten');
775
+ throw new DOMException(
776
+ 'The port has been forgotten while opening.',
777
+ 'InvalidStateError',
778
+ );
779
+ }
780
+
781
+ this.#resetToClosedState();
782
+ throw new DOMException(
783
+ 'Failed to open serial port: state changed while opening.',
784
+ 'NetworkError',
607
785
  );
608
786
  }
609
787
 
610
788
  this.#state = 'opened'; // 9.3. Set this.[[state]] to "opened".
611
789
  this.#bufferSize = bufferSize; // 9.4. Set this.[[bufferSize]] to options["bufferSize"].
612
-
613
- await this.#usb.startReading(this.#deviceId, this.#portNumber);
790
+ this.#baudRate = options.baudRate;
614
791
 
615
792
  // When the port becomes logically connected:
616
793
  this.#connected = true; // 2. Set port.[[connected]] to true.
@@ -618,6 +795,13 @@ export class SerialPort extends EventTarget {
618
795
  // device presence (attach/detach), dispatched by Serial from the native
619
796
  // USB state events — not logical open()/close(). So we do not dispatch them
620
797
  // here; that also avoids re-entrancy with auto-reconnect listeners.
798
+
799
+ // Materialise the readable eagerly so its data subscription is live the
800
+ // instant the native read pump (startReading, above) begins emitting. The
801
+ // device may transmit immediately on open; without an active subscription
802
+ // those first bytes would be delivered to no listener and silently lost in
803
+ // the window between open() resolving and the first port.readable access.
804
+ void this.readable;
621
805
  }
622
806
 
623
807
  /**
@@ -676,18 +860,10 @@ export class SerialPort extends EventTarget {
676
860
  // ignore
677
861
  }
678
862
 
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.
863
+ // 10.1.2-10.1.4 and logical disconnect bookkeeping.
864
+ // If forget() was requested while close() was in flight, preserve
865
+ // forgotten semantics instead of reviving the port to "closed".
866
+ this.#resetToClosedState(this.#forgetRequested ? 'forgotten' : 'closed');
691
867
  // (No port-level "disconnect" dispatch here — that event signals physical
692
868
  // detach, fired by Serial from the native USB state events. See open().)
693
869
  }
@@ -698,6 +874,14 @@ export class SerialPort extends EventTarget {
698
874
  async forget(): Promise<void> {
699
875
  // The forget() method steps are:
700
876
 
877
+ this.#forgetRequested = true;
878
+
879
+ // Forgetting an open port must not leave active streams/native resources
880
+ // behind. Close first so the instance becomes cleanly unusable afterwards.
881
+ if (this.#state === 'opened') {
882
+ await this.close();
883
+ }
884
+
701
885
  // 2.1. Set this.[[state]] to "forgetting".
702
886
  this.#state = 'forgetting';
703
887
  // 2.2. Remove this from the sequence of serial ports on the system which
@@ -729,9 +913,6 @@ export class SerialPort extends EventTarget {
729
913
  throw new TypeError('At least one signal must be specified.');
730
914
  }
731
915
 
732
- // Merge with current output signal state for partial updates
733
- this.#outputSignals = {...this.#outputSignals, ...signals};
734
-
735
916
  const promises: Promise<void>[] = [];
736
917
 
737
918
  // 4.1. If signals["dataTerminalReady"] is present, invoke the operating
@@ -824,6 +1005,17 @@ export class SerialPort extends EventTarget {
824
1005
  #handleClosingReadableStream(): void {
825
1006
  // To handle closing the readable stream perform the following steps:
826
1007
 
1008
+ // Drop the native data/error subscription tied to this readable. Without
1009
+ // this a later readable (after cancel() + re-acquire) would coexist with the
1010
+ // old subscription, which then enqueues into the cancelled controller and
1011
+ // throws. close()/handleDeviceLost() also clear these; doing it here covers
1012
+ // a standalone readable.cancel().
1013
+ this.#dataSubscription?.remove();
1014
+ this.#errorSubscription?.remove();
1015
+ this.#dataSubscription = null;
1016
+ this.#errorSubscription = null;
1017
+ this.#readableController = null;
1018
+
827
1019
  // 1. Set this.[[readable]] to null.
828
1020
  this.#readable = null;
829
1021
 
@@ -840,6 +1032,8 @@ export class SerialPort extends EventTarget {
840
1032
  #handleClosingWritableStream(): void {
841
1033
  // To handle closing the writable stream perform the following steps:
842
1034
 
1035
+ this.#writableController = null;
1036
+
843
1037
  // 1. Set this.[[writable]] to null.
844
1038
  this.#writable = null;
845
1039
 
@@ -860,9 +1054,23 @@ export class Serial extends EventTarget {
860
1054
  disconnect: null as ((event: Event) => void) | null,
861
1055
  };
862
1056
 
863
- #usb: UsbSerialModule | null = null;
1057
+ #usb: SerialTransport | null = null;
864
1058
  #knownPorts: Map<string, SerialPort> = new Map();
865
1059
  #initialized = false;
1060
+ #injected: SerialTransport | null;
1061
+
1062
+ /**
1063
+ * @param transport Optional transport override. When provided, this `Serial`
1064
+ * talks to it instead of the global native module — the transport override used by tests
1065
+ * and the virtual serial device harness (`new Serial(new InMemorySerialTransport())`).
1066
+ * Omit it for the normal native-backed instance; the singleton `serial`
1067
+ * export is created this way and can still be redirected globally via
1068
+ * `setUsbSerial()`.
1069
+ */
1070
+ constructor(transport?: SerialTransport) {
1071
+ super();
1072
+ this.#injected = transport ?? null;
1073
+ }
866
1074
 
867
1075
  /**
868
1076
  * Subscribing to "connect"/"disconnect" must wire up the native USB state
@@ -880,42 +1088,49 @@ export class Serial extends EventTarget {
880
1088
  * Deferring native access out of the module-import path avoids touching the
881
1089
  * TurboModule before the runtime is ready.
882
1090
  */
883
- #ensureInit(): UsbSerialModule | null {
1091
+ #ensureInit(): SerialTransport | null {
884
1092
  if (this.#initialized) return this.#usb;
885
1093
  this.#initialized = true;
886
1094
  try {
887
- this.#usb = getUsbSerial();
1095
+ this.#usb = this.#injected ?? getUsbSerial();
888
1096
 
889
1097
  // A USB device was attached. Android assigns a NEW deviceId on every
890
1098
  // attach, so match previously-known ports of the same physical device by
891
1099
  // VID/PID, update their deviceId, re-key them, and fire "connect" on the
892
1100
  // same SerialPort instance (W3C spec model: the port is reused).
893
1101
  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
- }
1102
+ // Native connect events expose VID/PID but not a stable unique
1103
+ // identifier. To avoid mis-associating identical devices, remap only
1104
+ // when there is exactly one disconnected candidate.
1105
+ const candidates = Array.from(this.#knownPorts.entries()).filter(
1106
+ ([, port]) => {
1107
+ const internals = portInternals.get(port)!;
1108
+ if (internals.getVid() !== event.usbVendorId) return false;
1109
+ if (internals.getPid() !== event.usbProductId) return false;
1110
+ if (internals.isForgotten()) return false;
1111
+ return !port.connected;
1112
+ },
1113
+ );
1114
+
1115
+ if (candidates.length === 1) {
1116
+ const [key, port] = candidates[0];
1117
+ const internals = portInternals.get(port)!;
1118
+ internals.setDeviceId(event.deviceId);
1119
+ const newKey = this.#portKey(
1120
+ event.deviceId,
1121
+ internals.getPortNumber(),
1122
+ );
1123
+ this.#knownPorts.delete(key);
1124
+ this.#knownPorts.set(newKey, port);
1125
+ // Dispatched on the port; it bubbles to this Serial (event.target
1126
+ // stays the port, per spec — see setEventParent below).
1127
+ port.dispatchEvent(new Event('connect'));
1128
+ return;
914
1129
  }
1130
+
1131
+ // No known port matched (or matching is ambiguous): fire a Serial-level
1132
+ // "connect" so listeners refresh; getPorts() surfaces the new port.
915
1133
  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
1134
  });
920
1135
 
921
1136
  // A USB device was detached. Reset the matching port(s) to a closed,
@@ -923,13 +1138,22 @@ export class Serial extends EventTarget {
923
1138
  // SerialPort instance so a re-attach can reuse it. handleDeviceLost()
924
1139
  // dispatches "disconnect" on the port.
925
1140
  this.#usb.onDisconnect((event: ConnectEvent) => {
1141
+ const wasLost = (event as ConnectEvent & {lost?: boolean}).lost ?? true;
926
1142
  const prefix = `${event.deviceId}:`;
1143
+ let matched = false;
927
1144
  for (const [key, port] of [...this.#knownPorts.entries()]) {
928
1145
  if (key.startsWith(prefix)) {
929
- portInternals.get(port)?.handleDeviceLost();
1146
+ // Physical loss tears down the stream immediately; a clean detach
1147
+ // lets any already-buffered bytes drain before EOF.
1148
+ const internals = portInternals.get(port);
1149
+ if (wasLost) internals?.handleDeviceLost();
1150
+ else internals?.handleDeviceDetached();
1151
+ matched = true;
930
1152
  }
931
1153
  }
932
- this.dispatchEvent(new Event('disconnect'));
1154
+ // No known port matched (e.g. a device never opened): fire a
1155
+ // Serial-level "disconnect" so listeners can still refresh.
1156
+ if (!matched) this.dispatchEvent(new Event('disconnect'));
933
1157
  });
934
1158
  } catch {
935
1159
  this.#usb = null;
@@ -1007,11 +1231,18 @@ export class Serial extends EventTarget {
1007
1231
  } of portIds) {
1008
1232
  if (!hasPermission) continue;
1009
1233
  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),
1234
+ const known = this.#knownPorts.get(key);
1235
+ if (!known || portInternals.get(known)?.isForgotten()) {
1236
+ const port = new SerialPort(
1237
+ usb,
1238
+ deviceId,
1239
+ portNumber,
1240
+ usbVendorId,
1241
+ usbProductId,
1014
1242
  );
1243
+ // The port's connect/disconnect events bubble to this Serial.
1244
+ setEventParent(port, this);
1245
+ this.#knownPorts.set(key, port);
1015
1246
  }
1016
1247
  ports.push(this.#knownPorts.get(key)!);
1017
1248
  }
@@ -1036,6 +1267,12 @@ export class Serial extends EventTarget {
1036
1267
  );
1037
1268
  }
1038
1269
 
1270
+ if ((options.allowedBluetoothServiceClassIds?.length ?? 0) > 0) {
1271
+ throw new TypeError(
1272
+ 'allowedBluetoothServiceClassIds is not supported in Android USB mode.',
1273
+ );
1274
+ }
1275
+
1039
1276
  // 4. If options["filters"] is present, then for each filter in
1040
1277
  // options["filters"] run the following steps:
1041
1278
  if (options.filters) {
@@ -1083,17 +1320,18 @@ export class Serial extends EventTarget {
1083
1320
 
1084
1321
  // 5.6. Let port be a SerialPort representing the port chosen by the user.
1085
1322
  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
- ),
1323
+ const known = this.#knownPorts.get(key);
1324
+ if (!known || portInternals.get(known)?.isForgotten()) {
1325
+ const port = new SerialPort(
1326
+ usb,
1327
+ portId.deviceId,
1328
+ portId.portNumber,
1329
+ portId.usbVendorId,
1330
+ portId.usbProductId,
1096
1331
  );
1332
+ // The port's connect/disconnect events bubble to this Serial.
1333
+ setEventParent(port, this);
1334
+ this.#knownPorts.set(key, port);
1097
1335
  }
1098
1336
 
1099
1337
  // 5.7. Resolve promise with port.