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.
- package/README.md +23 -0
- package/TESTING.md +301 -0
- package/android/build.gradle +2 -2
- package/lib/commonjs/UsbSerial.js +58 -26
- package/lib/commonjs/UsbSerial.js.map +1 -1
- package/lib/commonjs/WebSerial.js +169 -57
- package/lib/commonjs/WebSerial.js.map +1 -1
- package/lib/commonjs/index.js +13 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/lib/dom-exception.js +176 -0
- package/lib/commonjs/lib/dom-exception.js.map +1 -0
- package/lib/commonjs/lib/event-target.js +138 -0
- package/lib/commonjs/lib/event-target.js.map +1 -0
- package/lib/commonjs/lib/promise.js +23 -0
- package/lib/commonjs/lib/promise.js.map +1 -0
- package/lib/commonjs/testing/index.js +70 -0
- package/lib/commonjs/testing/index.js.map +1 -0
- package/lib/commonjs/testing/install.js +54 -0
- package/lib/commonjs/testing/install.js.map +1 -0
- package/lib/commonjs/testing/serial-device.js +164 -0
- package/lib/commonjs/testing/serial-device.js.map +1 -0
- package/lib/commonjs/testing/virtual-serial.js +615 -0
- package/lib/commonjs/testing/virtual-serial.js.map +1 -0
- package/lib/commonjs/transport.js +61 -0
- package/lib/commonjs/transport.js.map +1 -0
- package/lib/typescript/src/UsbSerial.d.ts +24 -67
- package/lib/typescript/src/UsbSerial.d.ts.map +1 -1
- package/lib/typescript/src/WebSerial.d.ts +11 -2
- package/lib/typescript/src/WebSerial.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/lib/dom-exception.d.ts +100 -0
- package/lib/typescript/src/lib/dom-exception.d.ts.map +1 -0
- package/lib/typescript/src/lib/event-target.d.ts +53 -0
- package/lib/typescript/src/lib/event-target.d.ts.map +1 -0
- package/lib/typescript/src/lib/promise.d.ts +11 -0
- package/lib/typescript/src/lib/promise.d.ts.map +1 -0
- package/lib/typescript/src/testing/index.d.ts +23 -0
- package/lib/typescript/src/testing/index.d.ts.map +1 -0
- package/lib/typescript/src/testing/install.d.ts +25 -0
- package/lib/typescript/src/testing/install.d.ts.map +1 -0
- package/lib/typescript/src/testing/serial-device.d.ts +127 -0
- package/lib/typescript/src/testing/serial-device.d.ts.map +1 -0
- package/lib/typescript/src/testing/virtual-serial.d.ts +205 -0
- package/lib/typescript/src/testing/virtual-serial.d.ts.map +1 -0
- package/lib/typescript/src/transport.d.ts +131 -0
- package/lib/typescript/src/transport.d.ts.map +1 -0
- package/package.json +38 -2
- package/src/UsbSerial.ts +65 -90
- package/src/WebSerial.ts +227 -88
- package/src/index.ts +2 -7
- package/src/lib/dom-exception.ts +129 -60
- package/src/lib/event-target.ts +46 -21
- package/src/lib/promise.ts +7 -7
- package/src/testing/index.ts +42 -0
- package/src/testing/install.ts +65 -0
- package/src/testing/serial-device.ts +193 -0
- package/src/testing/virtual-serial.ts +801 -0
- package/src/transport.ts +200 -0
- package/babel.config.js +0 -3
- package/biome.json +0 -35
- package/example/.watchmanconfig +0 -1
- package/example/App.tsx +0 -71
- package/example/__tests__/App.test.tsx +0 -16
- package/example/__tests__/connectEvents.test.tsx +0 -81
- package/example/__tests__/getPorts.test.tsx +0 -140
- package/example/android/app/build.gradle +0 -120
- package/example/android/app/debug.keystore +0 -0
- package/example/android/app/proguard-rules.pro +0 -10
- package/example/android/app/src/debug/AndroidManifest.xml +0 -9
- package/example/android/app/src/main/AndroidManifest.xml +0 -38
- package/example/android/app/src/main/java/dev/uzlopak/MainActivity.kt +0 -22
- package/example/android/app/src/main/java/dev/uzlopak/MainApplication.kt +0 -41
- package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +0 -37
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/example/android/app/src/main/res/values/strings.xml +0 -3
- package/example/android/app/src/main/res/values/styles.xml +0 -9
- package/example/android/build.gradle +0 -22
- package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/example/android/gradle/wrapper/gradle-wrapper.properties +0 -7
- package/example/android/gradle.properties +0 -47
- package/example/android/gradlew +0 -252
- package/example/android/gradlew.bat +0 -94
- package/example/android/settings.gradle +0 -6
- package/example/app.json +0 -4
- package/example/babel.config.js +0 -21
- package/example/biome.json +0 -47
- package/example/deploy.sh +0 -11
- package/example/index.html +0 -26
- package/example/index.js +0 -9
- package/example/index.web.js +0 -8
- package/example/jest.config.js +0 -12
- package/example/metro.config.js +0 -58
- package/example/package-lock.json +0 -14510
- package/example/package.json +0 -48
- package/example/react-native.config.js +0 -17
- package/example/src/components/AppBar.tsx +0 -73
- package/example/src/components/Menu.tsx +0 -90
- package/example/src/components/SingleChoiceDialog.tsx +0 -120
- package/example/src/screens/ConnectScreen.tsx +0 -195
- package/example/src/screens/DevicesScreen.tsx +0 -252
- package/example/src/screens/TerminalScreen.tsx +0 -572
- package/example/src/settings.ts +0 -43
- package/example/src/theme.ts +0 -19
- package/example/src/util/TextUtil.ts +0 -129
- package/example/tsconfig.json +0 -10
- package/example/vite.config.mjs +0 -55
- package/scripts/deploy-release.sh +0 -127
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -20
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VirtualSerialTransport — an in-memory {@link SerialTransport} for tests and
|
|
3
|
+
* on-device demos.
|
|
4
|
+
*
|
|
5
|
+
* It implements the exact same interface the production `UsbSerialModule` does,
|
|
6
|
+
* but talks to simulated devices instead of real USB hardware. Because it has
|
|
7
|
+
* **no `react-native` dependency**, the same instance drives:
|
|
8
|
+
*
|
|
9
|
+
* - Jest/Node unit tests and the conformance suite,
|
|
10
|
+
* - the example app's "virtual device" mode on a real Android device, and
|
|
11
|
+
* - the example app running in a browser (react-native-web).
|
|
12
|
+
*
|
|
13
|
+
* Inject it via `new Serial(transport)` or globally with `setUsbSerial(transport)`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const transport = new VirtualSerialTransport();
|
|
17
|
+
* transport.addDevice(new EchoDevice(), {hasPermission: true});
|
|
18
|
+
* const serial = new Serial(transport);
|
|
19
|
+
* const [port] = await serial.getPorts();
|
|
20
|
+
* await port.open({baudRate: 115200});
|
|
21
|
+
* // writes to port.writable now come back on port.readable (echo)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
ConnectEvent,
|
|
26
|
+
ControlLine,
|
|
27
|
+
DataEvent,
|
|
28
|
+
ErrorEvent,
|
|
29
|
+
FlowControl,
|
|
30
|
+
OpenOptions,
|
|
31
|
+
PortFilter,
|
|
32
|
+
PortId,
|
|
33
|
+
PortPickerLabels,
|
|
34
|
+
SerialTransport,
|
|
35
|
+
Subscription,
|
|
36
|
+
} from '../transport';
|
|
37
|
+
import {DEFAULT_OPEN_OPTIONS} from '../transport';
|
|
38
|
+
import type {
|
|
39
|
+
SerialDevice,
|
|
40
|
+
SerialDeviceHost,
|
|
41
|
+
SerialInputSignals,
|
|
42
|
+
} from './serial-device';
|
|
43
|
+
|
|
44
|
+
/** Transport-side knobs when registering a {@link SerialDevice}. */
|
|
45
|
+
export type VirtualDeviceOptions = {
|
|
46
|
+
/** Whether the app already holds USB permission. Defaults to false. */
|
|
47
|
+
hasPermission?: boolean;
|
|
48
|
+
/** Defaults to 0. A USB device may expose several ports. */
|
|
49
|
+
portNumber?: number;
|
|
50
|
+
/** Override the device's serialNumber for enumeration. */
|
|
51
|
+
serialNumber?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Cross-wire output signals onto inputs the way a null-modem/loopback plug
|
|
54
|
+
* would (DTR→DSR+DCD, RTS→CTS) so getSignals() reflects setSignals().
|
|
55
|
+
* Defaults to true.
|
|
56
|
+
*/
|
|
57
|
+
loopbackSignals?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* When the port is opened with hardware (RTS/CTS) flow control, the device
|
|
60
|
+
* de-asserts CTS once this many bytes have been written without the receiver
|
|
61
|
+
* draining — modelling a full receive buffer. Defaults to 256.
|
|
62
|
+
*/
|
|
63
|
+
flowControlThreshold?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type VirtualSerialOptions = {
|
|
67
|
+
/** Devices to register on construction (same as calling addDevice). */
|
|
68
|
+
devices?: SerialDevice[];
|
|
69
|
+
/**
|
|
70
|
+
* Delay (ms) applied to async operations and to inbound data delivery.
|
|
71
|
+
* 0 (default) resolves on a microtask — deterministic for Jest. A small
|
|
72
|
+
* positive value makes streaming feel realistic on a device.
|
|
73
|
+
*/
|
|
74
|
+
latencyMs?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Whether showPortPicker() grants USB permission to the chosen device,
|
|
77
|
+
* mirroring the real Android picker. Defaults to true.
|
|
78
|
+
*/
|
|
79
|
+
autoGrantPermission?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* If set, inbound data larger than this is delivered as several `onData`
|
|
82
|
+
* events of at most this many bytes — modelling how a real serial port hands
|
|
83
|
+
* data up in chunks. 0/undefined delivers each write's reply in one event.
|
|
84
|
+
*/
|
|
85
|
+
chunkSize?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Operations whose next invocation can be made to fail (error injection). */
|
|
89
|
+
export type FailableOp =
|
|
90
|
+
| 'open'
|
|
91
|
+
| 'close'
|
|
92
|
+
| 'write'
|
|
93
|
+
| 'startReading'
|
|
94
|
+
| 'stopReading'
|
|
95
|
+
| 'setSignals'
|
|
96
|
+
| 'getSignals';
|
|
97
|
+
|
|
98
|
+
type OutputSignals = {dtr: boolean; rts: boolean; brk: boolean};
|
|
99
|
+
type InputSignals = {dcd: boolean; cts: boolean; ri: boolean; dsr: boolean};
|
|
100
|
+
|
|
101
|
+
function toByte(n: number): number {
|
|
102
|
+
return n & 0xff;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Map the device's verbose input-signal names onto the internal short names. */
|
|
106
|
+
function mapInputSignals(s: SerialInputSignals): Partial<InputSignals> {
|
|
107
|
+
const out: Partial<InputSignals> = {};
|
|
108
|
+
if (s.dataCarrierDetect !== undefined) out.dcd = s.dataCarrierDetect;
|
|
109
|
+
if (s.clearToSend !== undefined) out.cts = s.clearToSend;
|
|
110
|
+
if (s.ringIndicator !== undefined) out.ri = s.ringIndicator;
|
|
111
|
+
if (s.dataSetReady !== undefined) out.dsr = s.dataSetReady;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A simulated USB-serial device. Returned by {@link VirtualSerialTransport.addDevice}.
|
|
117
|
+
* The mutable fields and the helper methods let a test or demo drive the device
|
|
118
|
+
* the way physical hardware (and a human plugging cables) otherwise would.
|
|
119
|
+
*/
|
|
120
|
+
export class VirtualDevice {
|
|
121
|
+
readonly usbVendorId: number;
|
|
122
|
+
readonly usbProductId: number;
|
|
123
|
+
readonly portNumber: number;
|
|
124
|
+
serialNumber: string;
|
|
125
|
+
|
|
126
|
+
/** Reassigned on every (re)attach, mirroring Android's behaviour. */
|
|
127
|
+
deviceId: number;
|
|
128
|
+
attached = true;
|
|
129
|
+
hasPermission: boolean;
|
|
130
|
+
isOpen = false;
|
|
131
|
+
reading = false;
|
|
132
|
+
/** The hosted behaviour. */
|
|
133
|
+
readonly serialDevice: SerialDevice;
|
|
134
|
+
loopbackSignals: boolean;
|
|
135
|
+
flowControl: FlowControl = 'NONE';
|
|
136
|
+
flowControlThreshold: number;
|
|
137
|
+
openOptions: Required<OpenOptions> | null = null;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* When non-null, the device delivers at most this many bytes before raising a
|
|
141
|
+
* `BufferOverrunError` — models a fixed-size receive buffer overflowing.
|
|
142
|
+
*/
|
|
143
|
+
overrunLimit: number | null = null;
|
|
144
|
+
|
|
145
|
+
// @internal counters used by the transport.
|
|
146
|
+
_rxDelivered = 0;
|
|
147
|
+
_overran = false;
|
|
148
|
+
_hwWritten = 0;
|
|
149
|
+
|
|
150
|
+
readonly output: OutputSignals = {dtr: false, rts: false, brk: false};
|
|
151
|
+
readonly input: InputSignals = {
|
|
152
|
+
dcd: false,
|
|
153
|
+
cts: false,
|
|
154
|
+
ri: false,
|
|
155
|
+
dsr: false,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Every byte frame the host has written to this device, in order. */
|
|
159
|
+
readonly written: number[][] = [];
|
|
160
|
+
|
|
161
|
+
readonly #fails = new Set<FailableOp>();
|
|
162
|
+
readonly #transport: VirtualSerialTransport;
|
|
163
|
+
|
|
164
|
+
constructor(
|
|
165
|
+
transport: VirtualSerialTransport,
|
|
166
|
+
deviceId: number,
|
|
167
|
+
device: SerialDevice,
|
|
168
|
+
options: VirtualDeviceOptions,
|
|
169
|
+
) {
|
|
170
|
+
this.#transport = transport;
|
|
171
|
+
this.deviceId = deviceId;
|
|
172
|
+
this.serialDevice = device;
|
|
173
|
+
this.usbVendorId = device.usbVendorId;
|
|
174
|
+
this.usbProductId = device.usbProductId;
|
|
175
|
+
this.portNumber = options.portNumber ?? 0;
|
|
176
|
+
this.serialNumber =
|
|
177
|
+
options.serialNumber ??
|
|
178
|
+
device.serialNumber ??
|
|
179
|
+
`VSERIAL-${device.usbVendorId.toString(16)}-${device.usbProductId.toString(16)}`;
|
|
180
|
+
this.hasPermission = options.hasPermission ?? false;
|
|
181
|
+
this.loopbackSignals = options.loopbackSignals ?? true;
|
|
182
|
+
this.flowControlThreshold = options.flowControlThreshold ?? 256;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Push inbound bytes to the host as if the device sent them unprompted. */
|
|
186
|
+
push(bytes: number[] | Uint8Array): void {
|
|
187
|
+
this.#transport._deliver(this, [...bytes].map(toByte));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Raise a read error on the host's readable stream. `name` is the W3C error
|
|
192
|
+
* type (e.g. "BreakError", "BufferOverrunError"); the current polyfill ignores
|
|
193
|
+
* it and surfaces "NetworkError" regardless (a documented spec gap).
|
|
194
|
+
*/
|
|
195
|
+
emitError(message: string, name?: string): void {
|
|
196
|
+
this.#transport._error(this, message, name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Make the device deliver at most `bytes` bytes and then raise a
|
|
201
|
+
* `BufferOverrunError`, modelling a receive buffer of that size overflowing.
|
|
202
|
+
*/
|
|
203
|
+
overrunAfter(bytes: number): this {
|
|
204
|
+
this.overrunLimit = bytes;
|
|
205
|
+
this._rxDelivered = 0;
|
|
206
|
+
this._overran = false;
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Make the next call to `op` reject once (error injection). */
|
|
211
|
+
failNext(op: FailableOp): this {
|
|
212
|
+
this.#fails.add(op);
|
|
213
|
+
return this;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @internal consume a queued failure for `op`. */
|
|
217
|
+
_consumeFail(op: FailableOp): boolean {
|
|
218
|
+
if (this.#fails.has(op)) {
|
|
219
|
+
this.#fails.delete(op);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Directly set device-asserted input signals (DCD/CTS/RI/DSR). */
|
|
226
|
+
setInputSignals(signals: Partial<InputSignals>): void {
|
|
227
|
+
Object.assign(this.input, signals);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Physically attach (or re-attach) this device — fires "connect". */
|
|
231
|
+
attach(): void {
|
|
232
|
+
this.#transport.attach(this);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Physically detach this device — fires "disconnect". */
|
|
236
|
+
detach(): void {
|
|
237
|
+
this.#transport.detach(this);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Simulate an unplug while open: errors the open stream, then disconnects. */
|
|
241
|
+
loseDevice(): void {
|
|
242
|
+
this.#transport.loseDevice(this);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type Listener<E> = (event: E) => void;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* In-memory transport backing one or more {@link VirtualDevice}s.
|
|
250
|
+
*/
|
|
251
|
+
export class VirtualSerialTransport implements SerialTransport {
|
|
252
|
+
readonly #devices: VirtualDevice[] = [];
|
|
253
|
+
readonly #latencyMs: number;
|
|
254
|
+
readonly #autoGrant: boolean;
|
|
255
|
+
readonly #chunkSize: number;
|
|
256
|
+
#nextDeviceId = 1;
|
|
257
|
+
|
|
258
|
+
readonly #dataListeners = new Set<Listener<DataEvent>>();
|
|
259
|
+
readonly #errorListeners = new Set<Listener<ErrorEvent>>();
|
|
260
|
+
readonly #connectListeners = new Set<Listener<ConnectEvent>>();
|
|
261
|
+
readonly #disconnectListeners = new Set<Listener<ConnectEvent>>();
|
|
262
|
+
|
|
263
|
+
/** Scripts the next showPortPicker() outcome. */
|
|
264
|
+
#pendingPick:
|
|
265
|
+
| VirtualDevice
|
|
266
|
+
| ((d: VirtualDevice) => boolean)
|
|
267
|
+
| 'reject'
|
|
268
|
+
| null = null;
|
|
269
|
+
|
|
270
|
+
constructor(options: VirtualSerialOptions = {}) {
|
|
271
|
+
this.#latencyMs = options.latencyMs ?? 0;
|
|
272
|
+
this.#autoGrant = options.autoGrantPermission ?? true;
|
|
273
|
+
this.#chunkSize = options.chunkSize ?? 0;
|
|
274
|
+
for (const device of options.devices ?? []) this.addDevice(device);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Device management ──────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/** All devices known to the transport (attached or not). */
|
|
280
|
+
get devices(): readonly VirtualDevice[] {
|
|
281
|
+
return this.#devices;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Register a {@link SerialDevice}. It starts attached but does not fire
|
|
286
|
+
* "connect"; its identity (usbVendorId/usbProductId/serialNumber) is read from
|
|
287
|
+
* the device, and `options` carries the transport-side knobs.
|
|
288
|
+
*/
|
|
289
|
+
addDevice(
|
|
290
|
+
serialDevice: SerialDevice,
|
|
291
|
+
options: VirtualDeviceOptions = {},
|
|
292
|
+
): VirtualDevice {
|
|
293
|
+
const device = new VirtualDevice(
|
|
294
|
+
this,
|
|
295
|
+
this.#nextDeviceId++,
|
|
296
|
+
serialDevice,
|
|
297
|
+
options,
|
|
298
|
+
);
|
|
299
|
+
serialDevice._bind(this.#hostFor(device));
|
|
300
|
+
this.#devices.push(device);
|
|
301
|
+
return device;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The handle a hosted {@link SerialDevice} uses to talk back to the host. */
|
|
305
|
+
#hostFor(device: VirtualDevice): SerialDeviceHost {
|
|
306
|
+
return {
|
|
307
|
+
get deviceId() {
|
|
308
|
+
return device.deviceId;
|
|
309
|
+
},
|
|
310
|
+
get portNumber() {
|
|
311
|
+
return device.portNumber;
|
|
312
|
+
},
|
|
313
|
+
get isOpen() {
|
|
314
|
+
return device.isOpen;
|
|
315
|
+
},
|
|
316
|
+
get openOptions() {
|
|
317
|
+
return device.openOptions;
|
|
318
|
+
},
|
|
319
|
+
send: bytes => this._deliver(device, bytes.map(toByte)),
|
|
320
|
+
raiseError: (message, name) => this._error(device, message, name),
|
|
321
|
+
setSignals: signals => device.setInputSignals(mapInputSignals(signals)),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Invoke a device hook, isolating the transport from author errors. */
|
|
326
|
+
#invokeHook(fn: () => void | Promise<void>): void {
|
|
327
|
+
try {
|
|
328
|
+
const result = fn();
|
|
329
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
330
|
+
(result as Promise<void>).catch((err: unknown) => {
|
|
331
|
+
console.error('SerialDevice hook rejected:', err);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.error('SerialDevice hook threw:', err);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Remove a device entirely; detaches it first if attached. */
|
|
340
|
+
removeDevice(device: VirtualDevice): void {
|
|
341
|
+
if (device.attached) this.detach(device);
|
|
342
|
+
const i = this.#devices.indexOf(device);
|
|
343
|
+
if (i >= 0) this.#devices.splice(i, 1);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** (Re)attach a device, assigning it a fresh deviceId, and fire "connect". */
|
|
347
|
+
attach(device: VirtualDevice): void {
|
|
348
|
+
device.deviceId = this.#nextDeviceId++;
|
|
349
|
+
device.attached = true;
|
|
350
|
+
this.#emit(this.#connectListeners, {
|
|
351
|
+
deviceId: device.deviceId,
|
|
352
|
+
usbVendorId: device.usbVendorId,
|
|
353
|
+
usbProductId: device.usbProductId,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Detach a device and fire "disconnect"; any open port becomes closed. */
|
|
358
|
+
detach(device: VirtualDevice): void {
|
|
359
|
+
const {deviceId, usbVendorId, usbProductId} = device;
|
|
360
|
+
device.attached = false;
|
|
361
|
+
device.isOpen = false;
|
|
362
|
+
device.reading = false;
|
|
363
|
+
this.#emit(this.#disconnectListeners, {
|
|
364
|
+
deviceId,
|
|
365
|
+
usbVendorId,
|
|
366
|
+
usbProductId,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Simulate an unplug while open: error the stream first, then disconnect. */
|
|
371
|
+
loseDevice(device: VirtualDevice): void {
|
|
372
|
+
if (device.isOpen) this._error(device, 'Device disconnected');
|
|
373
|
+
this.detach(device);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Script the next showPortPicker() resolution (a device or a predicate). */
|
|
377
|
+
selectNextPort(
|
|
378
|
+
target: VirtualDevice | ((d: VirtualDevice) => boolean),
|
|
379
|
+
): void {
|
|
380
|
+
this.#pendingPick = target;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Make the next showPortPicker() reject (user cancelled / no port). */
|
|
384
|
+
rejectNextPortPicker(): void {
|
|
385
|
+
this.#pendingPick = 'reject';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Internal event helpers (called by VirtualDevice) ───────────────────────
|
|
389
|
+
|
|
390
|
+
/** @internal deliver inbound bytes to the host's readable stream. */
|
|
391
|
+
_deliver(device: VirtualDevice, data: number[]): void {
|
|
392
|
+
if (!device.attached || !device.isOpen || !device.reading) return;
|
|
393
|
+
|
|
394
|
+
if (device.overrunLimit != null) {
|
|
395
|
+
if (device._overran) return; // buffer already overflowed; drop the rest
|
|
396
|
+
const remaining = device.overrunLimit - device._rxDelivered;
|
|
397
|
+
if (data.length > remaining) {
|
|
398
|
+
const head = data.slice(0, Math.max(0, remaining));
|
|
399
|
+
device._rxDelivered += head.length;
|
|
400
|
+
device._overran = true;
|
|
401
|
+
if (head.length) this.#emitData(device, head);
|
|
402
|
+
this._error(device, 'Receive buffer overrun', 'BufferOverrunError');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
device._rxDelivered += data.length;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.#emitData(device, data);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** @internal raise a read error for a device's open port. */
|
|
412
|
+
_error(device: VirtualDevice, message: string, name?: string): void {
|
|
413
|
+
const event: ErrorEvent = {
|
|
414
|
+
deviceId: device.deviceId,
|
|
415
|
+
portNumber: device.portNumber,
|
|
416
|
+
error: message,
|
|
417
|
+
errorName: name,
|
|
418
|
+
};
|
|
419
|
+
this.#schedule(() => this.#emit(this.#errorListeners, event));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Deliver `data` as one or more onData events, honouring `chunkSize`. */
|
|
423
|
+
#emitData(device: VirtualDevice, data: number[]): void {
|
|
424
|
+
const emitOne = (slice: number[]) => {
|
|
425
|
+
const event: DataEvent = {
|
|
426
|
+
deviceId: device.deviceId,
|
|
427
|
+
portNumber: device.portNumber,
|
|
428
|
+
data: slice,
|
|
429
|
+
};
|
|
430
|
+
this.#schedule(() => this.#emit(this.#dataListeners, event));
|
|
431
|
+
};
|
|
432
|
+
const chunk = this.#chunkSize;
|
|
433
|
+
if (chunk && data.length > chunk) {
|
|
434
|
+
for (let i = 0; i < data.length; i += chunk) {
|
|
435
|
+
emitOne(data.slice(i, i + chunk));
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
emitOne(data);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── SerialTransport: discovery & permission ────────────────────────────────
|
|
443
|
+
|
|
444
|
+
findAllDrivers(): Promise<ReadonlyArray<PortId>> {
|
|
445
|
+
const ports = this.#devices.filter(d => d.attached).map(this.#toPortId);
|
|
446
|
+
return this.#resolve(ports);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
showPortPicker(
|
|
450
|
+
filter: ReadonlyArray<PortFilter>,
|
|
451
|
+
_labels?: PortPickerLabels,
|
|
452
|
+
): Promise<PortId> {
|
|
453
|
+
const pick = this.#pendingPick;
|
|
454
|
+
this.#pendingPick = null;
|
|
455
|
+
|
|
456
|
+
if (pick === 'reject') {
|
|
457
|
+
return this.#reject(new Error('No port selected'));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const candidates = this.#devices.filter(
|
|
461
|
+
d => d.attached && this.#matchesFilters(d, filter),
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
let chosen: VirtualDevice | undefined;
|
|
465
|
+
if (typeof pick === 'function') {
|
|
466
|
+
chosen = candidates.find(pick);
|
|
467
|
+
} else if (pick) {
|
|
468
|
+
chosen = candidates.includes(pick) ? pick : undefined;
|
|
469
|
+
} else {
|
|
470
|
+
chosen = candidates[0];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!chosen) return this.#reject(new Error('No port selected'));
|
|
474
|
+
if (this.#autoGrant) chosen.hasPermission = true;
|
|
475
|
+
return this.#resolve(this.#toPortId(chosen));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
requestPermission(deviceId: number): Promise<boolean> {
|
|
479
|
+
const device = this.#find(deviceId);
|
|
480
|
+
if (device) device.hasPermission = true;
|
|
481
|
+
return this.#resolve(!!device);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── SerialTransport: lifecycle ─────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
open(
|
|
487
|
+
deviceId: number,
|
|
488
|
+
portNumber: number,
|
|
489
|
+
options: OpenOptions,
|
|
490
|
+
): Promise<void> {
|
|
491
|
+
const device = this.#find(deviceId, portNumber);
|
|
492
|
+
if (!device) return this.#reject(new Error('Device not found'));
|
|
493
|
+
if (device._consumeFail('open')) {
|
|
494
|
+
return this.#reject(new Error('open failed (injected)'));
|
|
495
|
+
}
|
|
496
|
+
device.isOpen = true;
|
|
497
|
+
device.openOptions = {...DEFAULT_OPEN_OPTIONS, ...options};
|
|
498
|
+
device._hwWritten = 0;
|
|
499
|
+
this.#invokeHook(() => device.serialDevice.onOpen(device.openOptions!));
|
|
500
|
+
return this.#resolve();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
close(deviceId: number, portNumber: number): Promise<void> {
|
|
504
|
+
const device = this.#find(deviceId, portNumber);
|
|
505
|
+
if (device?._consumeFail('close')) {
|
|
506
|
+
return this.#reject(new Error('close failed (injected)'));
|
|
507
|
+
}
|
|
508
|
+
if (device) {
|
|
509
|
+
device.isOpen = false;
|
|
510
|
+
device.reading = false;
|
|
511
|
+
this.#invokeHook(() => device.serialDevice.onClose());
|
|
512
|
+
}
|
|
513
|
+
return this.#resolve();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
isOpen(deviceId: number, portNumber: number): boolean {
|
|
517
|
+
return this.#find(deviceId, portNumber)?.isOpen ?? false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── SerialTransport: I/O ───────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
write(
|
|
523
|
+
deviceId: number,
|
|
524
|
+
portNumber: number,
|
|
525
|
+
data: number[],
|
|
526
|
+
_timeout?: number,
|
|
527
|
+
): Promise<void> {
|
|
528
|
+
const device = this.#find(deviceId, portNumber);
|
|
529
|
+
if (!device?.isOpen) {
|
|
530
|
+
return this.#reject(new Error('Port is not open'));
|
|
531
|
+
}
|
|
532
|
+
if (device._consumeFail('write')) {
|
|
533
|
+
return this.#reject(new Error('write failed (injected)'));
|
|
534
|
+
}
|
|
535
|
+
const bytes = data.map(toByte);
|
|
536
|
+
device.written.push(bytes);
|
|
537
|
+
if (device.flowControl === 'RTS_CTS') device._hwWritten += bytes.length;
|
|
538
|
+
this.#invokeHook(() => device.serialDevice.onData(Uint8Array.from(bytes)));
|
|
539
|
+
return this.#resolve();
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
startReading(deviceId: number, portNumber: number): Promise<void> {
|
|
543
|
+
const device = this.#find(deviceId, portNumber);
|
|
544
|
+
if (device?._consumeFail('startReading')) {
|
|
545
|
+
return this.#reject(new Error('startReading failed (injected)'));
|
|
546
|
+
}
|
|
547
|
+
if (device) device.reading = true;
|
|
548
|
+
return this.#resolve();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
stopReading(deviceId: number, portNumber: number): Promise<void> {
|
|
552
|
+
const device = this.#find(deviceId, portNumber);
|
|
553
|
+
if (device?._consumeFail('stopReading')) {
|
|
554
|
+
return this.#reject(new Error('stopReading failed (injected)'));
|
|
555
|
+
}
|
|
556
|
+
if (device) device.reading = false;
|
|
557
|
+
return this.#resolve();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
setParameters(
|
|
561
|
+
deviceId: number,
|
|
562
|
+
portNumber: number,
|
|
563
|
+
options: OpenOptions,
|
|
564
|
+
): Promise<void> {
|
|
565
|
+
const device = this.#find(deviceId, portNumber);
|
|
566
|
+
if (device) device.openOptions = {...DEFAULT_OPEN_OPTIONS, ...options};
|
|
567
|
+
return this.#resolve();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── SerialTransport: control signals ───────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
setDTR(deviceId: number, portNumber: number, value: boolean): Promise<void> {
|
|
573
|
+
return this.#setOutput(deviceId, portNumber, 'dtr', value);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
setRTS(deviceId: number, portNumber: number, value: boolean): Promise<void> {
|
|
577
|
+
return this.#setOutput(deviceId, portNumber, 'rts', value);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
setBreak(
|
|
581
|
+
deviceId: number,
|
|
582
|
+
portNumber: number,
|
|
583
|
+
value: boolean,
|
|
584
|
+
): Promise<void> {
|
|
585
|
+
return this.#setOutput(deviceId, portNumber, 'brk', value);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
getDTR(deviceId: number, portNumber: number): Promise<boolean> {
|
|
589
|
+
return this.#resolve(this.#find(deviceId, portNumber)?.output.dtr ?? false);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
getRTS(deviceId: number, portNumber: number): Promise<boolean> {
|
|
593
|
+
return this.#resolve(this.#find(deviceId, portNumber)?.output.rts ?? false);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
getCD(deviceId: number, portNumber: number): Promise<boolean> {
|
|
597
|
+
const device = this.#find(deviceId, portNumber);
|
|
598
|
+
if (device?._consumeFail('getSignals')) {
|
|
599
|
+
return this.#reject(new Error('getSignals failed (injected)'));
|
|
600
|
+
}
|
|
601
|
+
return this.#resolve(device?.input.dcd ?? false);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
getCTS(deviceId: number, portNumber: number): Promise<boolean> {
|
|
605
|
+
const device = this.#find(deviceId, portNumber);
|
|
606
|
+
// Under hardware (RTS/CTS) flow control the device drives CTS itself,
|
|
607
|
+
// de-asserting it once its receive buffer fills (modelled by the byte
|
|
608
|
+
// threshold). Otherwise CTS just reflects the loopback wiring.
|
|
609
|
+
if (device && device.flowControl === 'RTS_CTS') {
|
|
610
|
+
return this.#resolve(device._hwWritten < device.flowControlThreshold);
|
|
611
|
+
}
|
|
612
|
+
return this.#resolve(device?.input.cts ?? false);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
getRI(deviceId: number, portNumber: number): Promise<boolean> {
|
|
616
|
+
return this.#resolve(this.#find(deviceId, portNumber)?.input.ri ?? false);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
getDSR(deviceId: number, portNumber: number): Promise<boolean> {
|
|
620
|
+
return this.#resolve(this.#find(deviceId, portNumber)?.input.dsr ?? false);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
getControlLines(
|
|
624
|
+
deviceId: number,
|
|
625
|
+
portNumber: number,
|
|
626
|
+
): Promise<ControlLine[]> {
|
|
627
|
+
const device = this.#find(deviceId, portNumber);
|
|
628
|
+
const lines: ControlLine[] = [];
|
|
629
|
+
if (device) {
|
|
630
|
+
if (device.output.dtr) lines.push('DTR');
|
|
631
|
+
if (device.output.rts) lines.push('RTS');
|
|
632
|
+
if (device.input.cts) lines.push('CTS');
|
|
633
|
+
if (device.input.dsr) lines.push('DSR');
|
|
634
|
+
if (device.input.dcd) lines.push('CD');
|
|
635
|
+
if (device.input.ri) lines.push('RI');
|
|
636
|
+
}
|
|
637
|
+
return this.#resolve(lines);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
getSupportedControlLines(
|
|
641
|
+
_deviceId: number,
|
|
642
|
+
_portNumber: number,
|
|
643
|
+
): Promise<ControlLine[]> {
|
|
644
|
+
return this.#resolve(['RTS', 'CTS', 'DTR', 'DSR', 'CD', 'RI']);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ── SerialTransport: flow control & misc ───────────────────────────────────
|
|
648
|
+
|
|
649
|
+
setFlowControl(
|
|
650
|
+
deviceId: number,
|
|
651
|
+
portNumber: number,
|
|
652
|
+
flowControl: FlowControl,
|
|
653
|
+
): Promise<void> {
|
|
654
|
+
const device = this.#find(deviceId, portNumber);
|
|
655
|
+
if (device) device.flowControl = flowControl;
|
|
656
|
+
return this.#resolve();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
getFlowControl(deviceId: number, portNumber: number): Promise<FlowControl> {
|
|
660
|
+
return this.#resolve(
|
|
661
|
+
this.#find(deviceId, portNumber)?.flowControl ?? 'NONE',
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
getSupportedFlowControl(
|
|
666
|
+
_deviceId: number,
|
|
667
|
+
_portNumber: number,
|
|
668
|
+
): Promise<FlowControl[]> {
|
|
669
|
+
return this.#resolve(['NONE', 'RTS_CTS']);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
purgeHwBuffers(
|
|
673
|
+
_deviceId: number,
|
|
674
|
+
_portNumber: number,
|
|
675
|
+
_purgeWriteBuffers: boolean,
|
|
676
|
+
_purgeReadBuffers: boolean,
|
|
677
|
+
): Promise<void> {
|
|
678
|
+
return this.#resolve();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
getSerial(deviceId: number, portNumber: number): Promise<string> {
|
|
682
|
+
return this.#resolve(this.#find(deviceId, portNumber)?.serialNumber ?? '');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ── SerialTransport: subscriptions ─────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
onData(listener: Listener<DataEvent>): Subscription {
|
|
688
|
+
return this.#subscribe(this.#dataListeners, listener);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
onError(listener: Listener<ErrorEvent>): Subscription {
|
|
692
|
+
return this.#subscribe(this.#errorListeners, listener);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
onConnect(listener: Listener<ConnectEvent>): Subscription {
|
|
696
|
+
return this.#subscribe(this.#connectListeners, listener);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
onDisconnect(listener: Listener<ConnectEvent>): Subscription {
|
|
700
|
+
return this.#subscribe(this.#disconnectListeners, listener);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ── Private helpers ────────────────────────────────────────────────────────
|
|
704
|
+
|
|
705
|
+
#setOutput(
|
|
706
|
+
deviceId: number,
|
|
707
|
+
portNumber: number,
|
|
708
|
+
key: keyof OutputSignals,
|
|
709
|
+
value: boolean,
|
|
710
|
+
): Promise<void> {
|
|
711
|
+
const device = this.#find(deviceId, portNumber);
|
|
712
|
+
if (device?._consumeFail('setSignals')) {
|
|
713
|
+
return this.#reject(new Error('setSignals failed (injected)'));
|
|
714
|
+
}
|
|
715
|
+
if (device) {
|
|
716
|
+
device.output[key] = value;
|
|
717
|
+
if (device.loopbackSignals) {
|
|
718
|
+
if (key === 'dtr') {
|
|
719
|
+
device.input.dsr = value;
|
|
720
|
+
device.input.dcd = value;
|
|
721
|
+
} else if (key === 'rts') {
|
|
722
|
+
device.input.cts = value;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
this.#invokeHook(() =>
|
|
726
|
+
device.serialDevice.onHostSignals({
|
|
727
|
+
dataTerminalReady: device.output.dtr,
|
|
728
|
+
requestToSend: device.output.rts,
|
|
729
|
+
break: device.output.brk,
|
|
730
|
+
}),
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
return this.#resolve();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
#toPortId = (d: VirtualDevice): PortId => ({
|
|
737
|
+
deviceId: d.deviceId,
|
|
738
|
+
portNumber: d.portNumber,
|
|
739
|
+
usbVendorId: d.usbVendorId,
|
|
740
|
+
usbProductId: d.usbProductId,
|
|
741
|
+
hasPermission: d.hasPermission,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
#matchesFilters(
|
|
745
|
+
device: VirtualDevice,
|
|
746
|
+
filters: ReadonlyArray<PortFilter>,
|
|
747
|
+
): boolean {
|
|
748
|
+
if (!filters || filters.length === 0) return true;
|
|
749
|
+
return filters.some(f => {
|
|
750
|
+
if (f.usbVendorId !== undefined && f.usbVendorId !== device.usbVendorId) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
if (
|
|
754
|
+
f.usbProductId !== undefined &&
|
|
755
|
+
f.usbProductId !== device.usbProductId
|
|
756
|
+
) {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
return true;
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
#find(deviceId: number, portNumber?: number): VirtualDevice | undefined {
|
|
764
|
+
return this.#devices.find(
|
|
765
|
+
d =>
|
|
766
|
+
d.attached &&
|
|
767
|
+
d.deviceId === deviceId &&
|
|
768
|
+
(portNumber === undefined || d.portNumber === portNumber),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
#subscribe<E>(set: Set<Listener<E>>, listener: Listener<E>): Subscription {
|
|
773
|
+
set.add(listener);
|
|
774
|
+
return {remove: () => set.delete(listener)};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
#emit<E>(set: Set<Listener<E>>, event: E): void {
|
|
778
|
+
for (const listener of [...set]) listener(event);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
#schedule(fn: () => void): void {
|
|
782
|
+
if (this.#latencyMs > 0) setTimeout(fn, this.#latencyMs);
|
|
783
|
+
else queueMicrotask(fn);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
#resolve<T>(value?: T): Promise<T> {
|
|
787
|
+
if (this.#latencyMs > 0) {
|
|
788
|
+
return new Promise(r => setTimeout(() => r(value as T), this.#latencyMs));
|
|
789
|
+
}
|
|
790
|
+
return Promise.resolve(value as T);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
#reject(error: Error): Promise<never> {
|
|
794
|
+
if (this.#latencyMs > 0) {
|
|
795
|
+
return new Promise((_, reject) =>
|
|
796
|
+
setTimeout(() => reject(error), this.#latencyMs),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
return Promise.reject(error);
|
|
800
|
+
}
|
|
801
|
+
}
|