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
@@ -4,10 +4,10 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.serial = exports.SerialPort = exports.Serial = void 0;
7
- var _webStreamsPolyfill = require("web-streams-polyfill");
8
7
  var _domException = require("./lib/dom-exception");
9
8
  var _eventTarget = require("./lib/event-target");
10
9
  var _promise = require("./lib/promise");
10
+ var _webStreams = require("./lib/web-streams");
11
11
  var _UsbSerial = require("./UsbSerial");
12
12
  /**
13
13
  * @see https://wicg.github.io/serial/#dom-paritytype
@@ -65,6 +65,7 @@ const kDefaultFlowControl = 'none';
65
65
  const kAcceptableDataBits = [7, 8];
66
66
  const kAcceptableStopBits = [1, 2];
67
67
  const kAcceptableParity = ['none', 'even', 'odd'];
68
+ const kAcceptableFlowControl = ['none', 'hardware'];
68
69
  function parityToNative(parity) {
69
70
  switch (parity) {
70
71
  case 'odd':
@@ -75,8 +76,29 @@ function parityToNative(parity) {
75
76
  return 0;
76
77
  }
77
78
  }
79
+ const FLOW_CONTROL_NATIVE = {
80
+ none: 'NONE',
81
+ hardware: 'RTS_CTS'
82
+ };
78
83
  function flowControlToNative(flowControl) {
79
- return flowControl === 'hardware' ? 'RTS_CTS' : 'NONE';
84
+ return FLOW_CONTROL_NATIVE[flowControl];
85
+ }
86
+
87
+ /**
88
+ * Normalise a writable chunk to a plain byte array. The Web Serial writable
89
+ * accepts any BufferSource — a bare `ArrayBuffer` or any `ArrayBufferView`
90
+ * (`DataView`, typed arrays, including views with a non-zero `byteOffset`). A
91
+ * naive `Array.from(chunk)` only works for the iterable typed arrays and
92
+ * silently yields `[]` for an `ArrayBuffer`/`DataView`, so handle each shape.
93
+ */
94
+ function bufferSourceToBytes(chunk) {
95
+ if (chunk instanceof Uint8Array) {
96
+ return Array.from(chunk);
97
+ }
98
+ if (ArrayBuffer.isView(chunk)) {
99
+ return Array.from(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
100
+ }
101
+ return Array.from(new Uint8Array(chunk));
80
102
  }
81
103
 
82
104
  /**
@@ -113,15 +135,48 @@ class SerialPort extends _eventTarget.EventTarget {
113
135
  #portNumber;
114
136
  #usbVendorId;
115
137
  #usbProductId;
116
-
117
- // Tracks the current state of output signals for partial updates
118
- #outputSignals = {
119
- dataTerminalReady: false,
120
- requestToSend: false,
121
- break: false
122
- };
138
+ // Baud rate of the current open(), used to size the native write timeout.
139
+ #baudRate = 0;
123
140
  #dataSubscription = null;
124
141
  #errorSubscription = null;
142
+
143
+ // The live stream controllers, captured in the readable/writable getters, so
144
+ // a device loss can error the in-flight read/write with a "NetworkError".
145
+ #readableController = null;
146
+ #writableController = null;
147
+ #forgetRequested = false;
148
+ #resetToClosedState(state = 'closed') {
149
+ this.#dataSubscription?.remove();
150
+ this.#errorSubscription?.remove();
151
+ this.#dataSubscription = null;
152
+ this.#errorSubscription = null;
153
+ this.#readableController = null;
154
+ this.#writableController = null;
155
+ this.#readable = null;
156
+ this.#writable = null;
157
+ this.#readFatal = false;
158
+ this.#writeFatal = false;
159
+ this.#pendingClosePromise = null;
160
+ this.#bufferSize = undefined;
161
+ this.#baudRate = 0;
162
+ this.#state = state;
163
+ this.#connected = false;
164
+ this.#forgetRequested = state === 'forgotten';
165
+ }
166
+
167
+ /**
168
+ * Compute a native write timeout (ms) large enough to actually drain `length`
169
+ * bytes at the negotiated baud rate. A fixed 2 s timeout spuriously fails
170
+ * large writes on slow links; this scales with the payload while keeping a 2 s
171
+ * floor. ~10 bits per byte (start + 8 data + stop) with a 2× safety margin.
172
+ */
173
+ #writeTimeoutFor(length) {
174
+ const kMinTimeoutMs = 2000;
175
+ /* istanbul ignore next — baudRate is always > 0 when the port is open */
176
+ const baud = this.#baudRate > 0 ? this.#baudRate : 9600;
177
+ const estimatedMs = Math.ceil(length * 10 * 1000 / baud) * 2;
178
+ return Math.max(kMinTimeoutMs, estimatedMs);
179
+ }
125
180
  constructor(usb, deviceId, portNumber, usbVendorId, usbProductId) {
126
181
  super();
127
182
  this.#usb = usb;
@@ -136,12 +191,13 @@ class SerialPort extends _eventTarget.EventTarget {
136
191
  getVid: () => this.#usbVendorId,
137
192
  getPid: () => this.#usbProductId,
138
193
  getPortNumber: () => this.#portNumber,
139
- getDeviceId: () => this.#deviceId,
194
+ isForgotten: () => this.#state === 'forgotten',
140
195
  setDeviceId: id => {
141
196
  this.#deviceId = id;
142
197
  serialPortDeviceIds.set(this, id);
143
198
  },
144
- handleDeviceLost: () => this.#handleDeviceLost()
199
+ handleDeviceLost: () => this.#handleDeviceLost(),
200
+ handleDeviceDetached: () => this.#handleDeviceDetached()
145
201
  });
146
202
  }
147
203
 
@@ -152,33 +208,70 @@ class SerialPort extends _eventTarget.EventTarget {
152
208
  * After this, a later open() (e.g. when the device is re-attached) succeeds.
153
209
  */
154
210
  #handleDeviceLost() {
155
- // Tear down read/write streams without invoking the OS on the dead device.
211
+ const wasForgotten = this.#state === 'forgotten';
212
+
213
+ // Reject any in-flight read/write with a "NetworkError" (per spec), erroring
214
+ // the stream controllers directly. We must NOT cancel()/abort() the streams:
215
+ // cancel() rejects on a reader-locked stream (leaving the read orphaned) and
216
+ // both would try to invoke the OS on the now-gone device.
217
+ const lost = new _domException.DOMException('The device has been lost.', 'NetworkError');
156
218
  if (this.#readable) {
157
219
  this.#readFatal = true;
158
220
  try {
159
- // No active reader is required for cancel(); errors are non-fatal here.
160
- this.#readable.cancel().catch(() => {});
221
+ this.#readableController?.error(lost);
161
222
  } catch {}
162
223
  }
163
224
  if (this.#writable) {
164
225
  this.#writeFatal = true;
165
226
  try {
166
- this.#writable.abort().catch(() => {});
227
+ this.#writableController?.error(lost);
167
228
  } catch {}
168
229
  }
169
- this.#dataSubscription?.remove();
170
- this.#errorSubscription?.remove();
171
- this.#dataSubscription = null;
172
- this.#errorSubscription = null;
230
+ this.#resetToClosedState(wasForgotten ? 'forgotten' : 'closed');
231
+ this.dispatchEvent(new _eventTarget.Event('disconnect'));
232
+ }
233
+
234
+ /**
235
+ * Reset this port to a closed, re-openable state after a clean physical
236
+ * detach. Unlike device loss, buffered readable bytes should still be
237
+ * observable before the stream ends.
238
+ */
239
+ #handleDeviceDetached() {
240
+ const wasForgotten = this.#state === 'forgotten';
241
+ const readable = this.#readable;
242
+ const writable = this.#writable;
243
+
244
+ // Keep the public event and state transition synchronous so existing
245
+ // callers observe the detach immediately, but defer the stream teardown by
246
+ // one microtask to let already-queued data drain first.
247
+ this.#state = wasForgotten ? 'forgotten' : 'closed';
248
+ this.#connected = false;
173
249
  this.#readable = null;
174
250
  this.#writable = null;
175
- this.#readFatal = false;
176
- this.#writeFatal = false;
177
- this.#pendingClosePromise = null;
178
- this.#bufferSize = undefined;
179
- this.#state = 'closed';
180
- this.#connected = false;
181
251
  this.dispatchEvent(new _eventTarget.Event('disconnect'));
252
+ queueMicrotask(() => {
253
+ // Close the readable stream so any bytes already queued can still be
254
+ // drained by the consumer before EOF.
255
+ try {
256
+ if (readable) this.#readableController?.close();
257
+ } catch {}
258
+
259
+ // A detached device can no longer accept writes, so reject any writable
260
+ // traffic and clear local state.
261
+ const lost = new _domException.DOMException('The device has been lost.', 'NetworkError');
262
+ if (writable) {
263
+ this.#writeFatal = true;
264
+ try {
265
+ this.#writableController?.error(lost);
266
+ } catch {}
267
+ }
268
+
269
+ // Mirror the stream-closing bookkeeping without treating this as a hard
270
+ // read fatal error.
271
+ this.#handleClosingReadableStream();
272
+ this.#handleClosingWritableStream();
273
+ this.#resetToClosedState(wasForgotten ? 'forgotten' : 'closed');
274
+ });
182
275
  }
183
276
 
184
277
  /**
@@ -247,17 +340,29 @@ class SerialPort extends _eventTarget.EventTarget {
247
340
  const deviceId = this.#deviceId;
248
341
  const portNumber = this.#portNumber;
249
342
  const bufferSize = this.#bufferSize;
250
- const stream = new _webStreamsPolyfill.ReadableStream({
343
+ const stream = new _webStreams.ReadableStreamImpl({
251
344
  start(controller) {
345
+ self.#readableController = controller;
252
346
  self.#dataSubscription = self.#usb.onData(event => {
253
347
  if (event.deviceId === deviceId && event.portNumber === portNumber) {
254
- controller.enqueue(new Uint8Array(event.data));
348
+ try {
349
+ controller.enqueue(new Uint8Array(event.data));
350
+ } catch {
351
+ // The stream may already be closing/closed — e.g. a data event
352
+ // racing cancel()/close(), where the stream is closed but this
353
+ // subscription is not yet torn down. Dropping the chunk is
354
+ // correct (the consumer has stopped reading) and avoids throwing
355
+ // out of the transport's event-dispatch loop.
356
+ }
255
357
  }
256
358
  });
257
359
  self.#errorSubscription = self.#usb.onError(event => {
258
360
  if (event.deviceId === deviceId && event.portNumber === portNumber) {
259
361
  self.#readFatal = true; // Set this.[[readFatal]] to true.
260
- controller.error(new _domException.DOMException(event.error, 'NetworkError'));
362
+ // Surface the spec error type (BreakError, BufferOverrunError,
363
+ // FramingError, ParityError, …) when the transport reports one;
364
+ // fall back to NetworkError otherwise.
365
+ controller.error(new _domException.DOMException(event.error, event.errorName ?? 'NetworkError'));
261
366
  self.#handleClosingReadableStream();
262
367
  }
263
368
  });
@@ -268,7 +373,7 @@ class SerialPort extends _eventTarget.EventTarget {
268
373
  await self.#usb.purgeHwBuffers(deviceId, portNumber, false, true).catch(() => {});
269
374
  self.#handleClosingReadableStream();
270
375
  }
271
- }, new _webStreamsPolyfill.ByteLengthQueuingStrategy({
376
+ }, new _webStreams.ByteLengthQueuingStrategyImpl({
272
377
  highWaterMark: bufferSize
273
378
  }));
274
379
 
@@ -299,10 +404,14 @@ class SerialPort extends _eventTarget.EventTarget {
299
404
  const deviceId = this.#deviceId;
300
405
  const portNumber = this.#portNumber;
301
406
  const bufferSize = this.#bufferSize;
302
- const stream = new _webStreamsPolyfill.WritableStream({
407
+ const stream = new _webStreams.WritableStreamImpl({
408
+ start(controller) {
409
+ self.#writableController = controller;
410
+ },
303
411
  async write(chunk) {
412
+ const bytes = bufferSourceToBytes(chunk);
304
413
  try {
305
- await self.#usb.write(deviceId, portNumber, Array.from(chunk));
414
+ await self.#usb.write(deviceId, portNumber, bytes, self.#writeTimeoutFor(bytes.length));
306
415
  } catch (e) {
307
416
  // If the port was disconnected, set this.[[writeFatal]] to true.
308
417
  self.#writeFatal = true;
@@ -321,7 +430,7 @@ class SerialPort extends _eventTarget.EventTarget {
321
430
  // of all software and hardware transmit buffers for the port.
322
431
  self.#handleClosingWritableStream();
323
432
  }
324
- }, new _webStreamsPolyfill.ByteLengthQueuingStrategy({
433
+ }, new _webStreams.ByteLengthQueuingStrategyImpl({
325
434
  highWaterMark: bufferSize
326
435
  }));
327
436
 
@@ -380,7 +489,7 @@ class SerialPort extends _eventTarget.EventTarget {
380
489
 
381
490
  // 3. If options["baudRate"] is 0, reject promise with a TypeError and
382
491
  // return promise.
383
- if (options.baudRate === 0) {
492
+ if (!Number.isFinite(options.baudRate) || options.baudRate <= 0) {
384
493
  throw new TypeError('baudRate must be a positive, non-zero value.');
385
494
  }
386
495
 
@@ -401,7 +510,7 @@ class SerialPort extends _eventTarget.EventTarget {
401
510
  // 6. If options["bufferSize"] is 0, reject promise with a TypeError and
402
511
  // return promise.
403
512
  const bufferSize = options.bufferSize ?? kDefaultBufferSize;
404
- if (bufferSize === 0) {
513
+ if (!Number.isFinite(bufferSize) || bufferSize <= 0) {
405
514
  throw new TypeError('bufferSize must be a positive, non-zero value.');
406
515
  }
407
516
  const parity = options.parity ?? kDefaultParity;
@@ -409,6 +518,9 @@ class SerialPort extends _eventTarget.EventTarget {
409
518
  throw new TypeError(`parity must be one of: ${kAcceptableParity.join(', ')}.`);
410
519
  }
411
520
  const flowControl = options.flowControl ?? kDefaultFlowControl;
521
+ if (!kAcceptableFlowControl.includes(flowControl)) {
522
+ throw new TypeError(`flowControl must be one of: ${kAcceptableFlowControl.join(', ')}.`);
523
+ }
412
524
 
413
525
  // 8. Set this.[[state]] to "opening".
414
526
  this.#state = 'opening';
@@ -429,13 +541,41 @@ class SerialPort extends _eventTarget.EventTarget {
429
541
  this.#state = 'closed';
430
542
  throw new _domException.DOMException(`Failed to open serial port: ${e.message}`, 'NetworkError');
431
543
  }
432
- if (flowControl !== 'none') {
433
- await this.#usb.setFlowControl(this.#deviceId, this.#portNumber, flowControlToNative(flowControl));
544
+ try {
545
+ if (flowControl !== 'none') {
546
+ await this.#usb.setFlowControl(this.#deviceId, this.#portNumber, flowControlToNative(flowControl));
547
+ }
548
+ await this.#usb.startReading(this.#deviceId, this.#portNumber);
549
+ } catch (e) {
550
+ // If post-open setup fails (flow-control/read pump), best-effort close
551
+ // and reset local state so a subsequent open() can succeed cleanly.
552
+ try {
553
+ await this.#usb.close(this.#deviceId, this.#portNumber);
554
+ } catch {
555
+ // ignore
556
+ }
557
+ this.#resetToClosedState(this.#forgetRequested ? 'forgotten' : 'closed');
558
+ throw new _domException.DOMException(`Failed to open serial port: ${e.message}`, 'NetworkError');
559
+ }
560
+
561
+ // Guard against lifecycle races (e.g. forget() called while open() is
562
+ // still awaiting native setup). Do not revive a forgotten/changed state.
563
+ if (this.#state !== 'opening') {
564
+ try {
565
+ await this.#usb.close(this.#deviceId, this.#portNumber);
566
+ } catch {
567
+ // ignore
568
+ }
569
+ if (this.#state === 'forgotten' || this.#state === 'forgetting') {
570
+ this.#resetToClosedState('forgotten');
571
+ throw new _domException.DOMException('The port has been forgotten while opening.', 'InvalidStateError');
572
+ }
573
+ this.#resetToClosedState();
574
+ throw new _domException.DOMException('Failed to open serial port: state changed while opening.', 'NetworkError');
434
575
  }
435
576
  this.#state = 'opened'; // 9.3. Set this.[[state]] to "opened".
436
577
  this.#bufferSize = bufferSize; // 9.4. Set this.[[bufferSize]] to options["bufferSize"].
437
-
438
- await this.#usb.startReading(this.#deviceId, this.#portNumber);
578
+ this.#baudRate = options.baudRate;
439
579
 
440
580
  // When the port becomes logically connected:
441
581
  this.#connected = true; // 2. Set port.[[connected]] to true.
@@ -443,6 +583,13 @@ class SerialPort extends _eventTarget.EventTarget {
443
583
  // device presence (attach/detach), dispatched by Serial from the native
444
584
  // USB state events — not logical open()/close(). So we do not dispatch them
445
585
  // here; that also avoids re-entrancy with auto-reconnect listeners.
586
+
587
+ // Materialise the readable eagerly so its data subscription is live the
588
+ // instant the native read pump (startReading, above) begins emitting. The
589
+ // device may transmit immediately on open; without an active subscription
590
+ // those first bytes would be delivered to no listener and silently lost in
591
+ // the window between open() resolving and the first port.readable access.
592
+ void this.readable;
446
593
  }
447
594
 
448
595
  /**
@@ -492,17 +639,11 @@ class SerialPort extends _eventTarget.EventTarget {
492
639
  } catch {
493
640
  // ignore
494
641
  }
495
- this.#dataSubscription?.remove();
496
- this.#errorSubscription?.remove();
497
- this.#dataSubscription = null;
498
- this.#errorSubscription = null;
499
- this.#state = 'closed'; // 10.1.2. Set this.[[state]] to "closed".
500
- this.#readFatal = false; // 10.1.3. Set this.[[readFatal]] and this.[[writeFatal]] to false.
501
- this.#writeFatal = false; // 10.1.3. (continued)
502
- this.#pendingClosePromise = null; // 10.1.4. Set this.[[pendingClosePromise]] to null.
503
642
 
504
- // When the port is no longer logically connected:
505
- this.#connected = false; // 2. Set port.[[connected]] to false.
643
+ // 10.1.2-10.1.4 and logical disconnect bookkeeping.
644
+ // If forget() was requested while close() was in flight, preserve
645
+ // forgotten semantics instead of reviving the port to "closed".
646
+ this.#resetToClosedState(this.#forgetRequested ? 'forgotten' : 'closed');
506
647
  // (No port-level "disconnect" dispatch here — that event signals physical
507
648
  // detach, fired by Serial from the native USB state events. See open().)
508
649
  }
@@ -513,6 +654,14 @@ class SerialPort extends _eventTarget.EventTarget {
513
654
  async forget() {
514
655
  // The forget() method steps are:
515
656
 
657
+ this.#forgetRequested = true;
658
+
659
+ // Forgetting an open port must not leave active streams/native resources
660
+ // behind. Close first so the instance becomes cleanly unusable afterwards.
661
+ if (this.#state === 'opened') {
662
+ await this.close();
663
+ }
664
+
516
665
  // 2.1. Set this.[[state]] to "forgetting".
517
666
  this.#state = 'forgetting';
518
667
  // 2.2. Remove this from the sequence of serial ports on the system which
@@ -539,12 +688,6 @@ class SerialPort extends _eventTarget.EventTarget {
539
688
  if (signals.dataTerminalReady === undefined && signals.requestToSend === undefined && signals.break === undefined) {
540
689
  throw new TypeError('At least one signal must be specified.');
541
690
  }
542
-
543
- // Merge with current output signal state for partial updates
544
- this.#outputSignals = {
545
- ...this.#outputSignals,
546
- ...signals
547
- };
548
691
  const promises = [];
549
692
 
550
693
  // 4.1. If signals["dataTerminalReady"] is present, invoke the operating
@@ -618,6 +761,17 @@ class SerialPort extends _eventTarget.EventTarget {
618
761
  #handleClosingReadableStream() {
619
762
  // To handle closing the readable stream perform the following steps:
620
763
 
764
+ // Drop the native data/error subscription tied to this readable. Without
765
+ // this a later readable (after cancel() + re-acquire) would coexist with the
766
+ // old subscription, which then enqueues into the cancelled controller and
767
+ // throws. close()/handleDeviceLost() also clear these; doing it here covers
768
+ // a standalone readable.cancel().
769
+ this.#dataSubscription?.remove();
770
+ this.#errorSubscription?.remove();
771
+ this.#dataSubscription = null;
772
+ this.#errorSubscription = null;
773
+ this.#readableController = null;
774
+
621
775
  // 1. Set this.[[readable]] to null.
622
776
  this.#readable = null;
623
777
 
@@ -634,6 +788,8 @@ class SerialPort extends _eventTarget.EventTarget {
634
788
  #handleClosingWritableStream() {
635
789
  // To handle closing the writable stream perform the following steps:
636
790
 
791
+ this.#writableController = null;
792
+
637
793
  // 1. Set this.[[writable]] to null.
638
794
  this.#writable = null;
639
795
 
@@ -657,6 +813,20 @@ class Serial extends _eventTarget.EventTarget {
657
813
  #usb = null;
658
814
  #knownPorts = new Map();
659
815
  #initialized = false;
816
+ #injected;
817
+
818
+ /**
819
+ * @param transport Optional transport override. When provided, this `Serial`
820
+ * talks to it instead of the global native module — the transport override used by tests
821
+ * and the virtual serial device harness (`new Serial(new InMemorySerialTransport())`).
822
+ * Omit it for the normal native-backed instance; the singleton `serial`
823
+ * export is created this way and can still be redirected globally via
824
+ * `setUsbSerial()`.
825
+ */
826
+ constructor(transport) {
827
+ super();
828
+ this.#injected = transport ?? null;
829
+ }
660
830
 
661
831
  /**
662
832
  * Subscribing to "connect"/"disconnect" must wire up the native USB state
@@ -678,32 +848,39 @@ class Serial extends _eventTarget.EventTarget {
678
848
  if (this.#initialized) return this.#usb;
679
849
  this.#initialized = true;
680
850
  try {
681
- this.#usb = (0, _UsbSerial.getUsbSerial)();
851
+ this.#usb = this.#injected ?? (0, _UsbSerial.getUsbSerial)();
682
852
 
683
853
  // A USB device was attached. Android assigns a NEW deviceId on every
684
854
  // attach, so match previously-known ports of the same physical device by
685
855
  // VID/PID, update their deviceId, re-key them, and fire "connect" on the
686
856
  // same SerialPort instance (W3C spec model: the port is reused).
687
857
  this.#usb.onConnect(event => {
688
- let matched = false;
689
- for (const [key, port] of [...this.#knownPorts.entries()]) {
858
+ // Native connect events expose VID/PID but not a stable unique
859
+ // identifier. To avoid mis-associating identical devices, remap only
860
+ // when there is exactly one disconnected candidate.
861
+ const candidates = Array.from(this.#knownPorts.entries()).filter(([, port]) => {
690
862
  const internals = portInternals.get(port);
691
- if (!internals) continue;
692
- if (internals.getVid() === event.usbVendorId && internals.getPid() === event.usbProductId) {
693
- internals.setDeviceId(event.deviceId);
694
- const newKey = this.#portKey(event.deviceId, internals.getPortNumber());
695
- if (newKey !== key) {
696
- this.#knownPorts.delete(key);
697
- this.#knownPorts.set(newKey, port);
698
- }
699
- port.dispatchEvent(new _eventTarget.Event('connect'));
700
- matched = true;
701
- }
863
+ if (internals.getVid() !== event.usbVendorId) return false;
864
+ if (internals.getPid() !== event.usbProductId) return false;
865
+ if (internals.isForgotten()) return false;
866
+ return !port.connected;
867
+ });
868
+ if (candidates.length === 1) {
869
+ const [key, port] = candidates[0];
870
+ const internals = portInternals.get(port);
871
+ internals.setDeviceId(event.deviceId);
872
+ const newKey = this.#portKey(event.deviceId, internals.getPortNumber());
873
+ this.#knownPorts.delete(key);
874
+ this.#knownPorts.set(newKey, port);
875
+ // Dispatched on the port; it bubbles to this Serial (event.target
876
+ // stays the port, per spec — see setEventParent below).
877
+ port.dispatchEvent(new _eventTarget.Event('connect'));
878
+ return;
702
879
  }
880
+
881
+ // No known port matched (or matching is ambiguous): fire a Serial-level
882
+ // "connect" so listeners refresh; getPorts() surfaces the new port.
703
883
  this.dispatchEvent(new _eventTarget.Event('connect'));
704
- // If we matched no known port this is a brand-new device; getPorts()
705
- // will surface it on the next refresh.
706
- void matched;
707
884
  });
708
885
 
709
886
  // A USB device was detached. Reset the matching port(s) to a closed,
@@ -711,13 +888,21 @@ class Serial extends _eventTarget.EventTarget {
711
888
  // SerialPort instance so a re-attach can reuse it. handleDeviceLost()
712
889
  // dispatches "disconnect" on the port.
713
890
  this.#usb.onDisconnect(event => {
891
+ const wasLost = event.lost ?? true;
714
892
  const prefix = `${event.deviceId}:`;
893
+ let matched = false;
715
894
  for (const [key, port] of [...this.#knownPorts.entries()]) {
716
895
  if (key.startsWith(prefix)) {
717
- portInternals.get(port)?.handleDeviceLost();
896
+ // Physical loss tears down the stream immediately; a clean detach
897
+ // lets any already-buffered bytes drain before EOF.
898
+ const internals = portInternals.get(port);
899
+ if (wasLost) internals?.handleDeviceLost();else internals?.handleDeviceDetached();
900
+ matched = true;
718
901
  }
719
902
  }
720
- this.dispatchEvent(new _eventTarget.Event('disconnect'));
903
+ // No known port matched (e.g. a device never opened): fire a
904
+ // Serial-level "disconnect" so listeners can still refresh.
905
+ if (!matched) this.dispatchEvent(new _eventTarget.Event('disconnect'));
721
906
  });
722
907
  } catch {
723
908
  this.#usb = null;
@@ -792,8 +977,12 @@ class Serial extends _eventTarget.EventTarget {
792
977
  } of portIds) {
793
978
  if (!hasPermission) continue;
794
979
  const key = this.#portKey(deviceId, portNumber);
795
- if (!this.#knownPorts.has(key)) {
796
- this.#knownPorts.set(key, new SerialPort(usb, deviceId, portNumber, usbVendorId, usbProductId));
980
+ const known = this.#knownPorts.get(key);
981
+ if (!known || portInternals.get(known)?.isForgotten()) {
982
+ const port = new SerialPort(usb, deviceId, portNumber, usbVendorId, usbProductId);
983
+ // The port's connect/disconnect events bubble to this Serial.
984
+ (0, _eventTarget.setEventParent)(port, this);
985
+ this.#knownPorts.set(key, port);
797
986
  }
798
987
  ports.push(this.#knownPorts.get(key));
799
988
  }
@@ -812,6 +1001,9 @@ class Serial extends _eventTarget.EventTarget {
812
1001
  if (!usb) {
813
1002
  throw new _domException.DOMException('NativeUsbSerial is not available.', 'NotFoundError');
814
1003
  }
1004
+ if ((options.allowedBluetoothServiceClassIds?.length ?? 0) > 0) {
1005
+ throw new TypeError('allowedBluetoothServiceClassIds is not supported in Android USB mode.');
1006
+ }
815
1007
 
816
1008
  // 4. If options["filters"] is present, then for each filter in
817
1009
  // options["filters"] run the following steps:
@@ -854,8 +1046,12 @@ class Serial extends _eventTarget.EventTarget {
854
1046
 
855
1047
  // 5.6. Let port be a SerialPort representing the port chosen by the user.
856
1048
  const key = `${portId.deviceId}:${portId.portNumber}`;
857
- if (!this.#knownPorts.has(key)) {
858
- this.#knownPorts.set(key, new SerialPort(usb, portId.deviceId, portId.portNumber, portId.usbVendorId, portId.usbProductId));
1049
+ const known = this.#knownPorts.get(key);
1050
+ if (!known || portInternals.get(known)?.isForgotten()) {
1051
+ const port = new SerialPort(usb, portId.deviceId, portId.portNumber, portId.usbVendorId, portId.usbProductId);
1052
+ // The port's connect/disconnect events bubble to this Serial.
1053
+ (0, _eventTarget.setEventParent)(port, this);
1054
+ this.#knownPorts.set(key, port);
859
1055
  }
860
1056
 
861
1057
  // 5.7. Resolve promise with port.