@react-native-harness/bridge 1.5.0 → 1.7.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/dist/app-liveness.d.ts +33 -0
- package/dist/app-liveness.d.ts.map +1 -0
- package/dist/app-liveness.js +57 -0
- package/dist/binary.d.ts +16 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +47 -0
- package/dist/client.d.ts +6 -4
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +112 -105
- package/dist/connection-check.d.ts +22 -0
- package/dist/connection-check.d.ts.map +1 -0
- package/dist/connection-check.js +30 -0
- package/dist/errors.d.ts +6 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -4
- package/dist/image-snapshot.d.ts +2 -2
- package/dist/image-snapshot.d.ts.map +1 -1
- package/dist/image-snapshot.js +21 -8
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/js-block-budget-policy.d.ts +31 -0
- package/dist/js-block-budget-policy.d.ts.map +1 -0
- package/dist/js-block-budget-policy.js +60 -0
- package/dist/js-watchdog.d.ts +31 -0
- package/dist/js-watchdog.d.ts.map +1 -0
- package/dist/js-watchdog.js +46 -0
- package/dist/link/channel.d.ts +45 -0
- package/dist/link/channel.d.ts.map +1 -0
- package/dist/link/channel.js +414 -0
- package/dist/link/envelope.d.ts +38 -0
- package/dist/link/envelope.d.ts.map +1 -0
- package/dist/link/envelope.js +90 -0
- package/dist/link/index.d.ts +22 -0
- package/dist/link/index.d.ts.map +1 -0
- package/dist/link/index.js +271 -0
- package/dist/link/ports.d.ts +31 -0
- package/dist/link/ports.d.ts.map +1 -0
- package/dist/link/ports.js +7 -0
- package/dist/link/scheduler.d.ts +21 -0
- package/dist/link/scheduler.d.ts.map +1 -0
- package/dist/link/scheduler.js +116 -0
- package/dist/link/standard-websocket.d.ts +36 -0
- package/dist/link/standard-websocket.d.ts.map +1 -0
- package/dist/link/standard-websocket.js +214 -0
- package/dist/link/streams.d.ts +4 -0
- package/dist/link/streams.d.ts.map +1 -0
- package/dist/link/streams.js +15 -0
- package/dist/protocol.d.ts +10 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -2
- package/dist/rpc-peer.d.ts +3 -1
- package/dist/rpc-peer.d.ts.map +1 -1
- package/dist/rpc-peer.js +2 -2
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +71 -56
- package/dist/shared/test-runner.d.ts +11 -0
- package/dist/shared/test-runner.d.ts.map +1 -1
- package/dist/shared.d.ts +5 -1
- package/dist/shared.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/app-liveness.ts +103 -0
- package/src/binary.ts +74 -0
- package/src/client.ts +172 -170
- package/src/connection-check.ts +60 -0
- package/src/errors.ts +16 -5
- package/src/image-snapshot.ts +31 -9
- package/src/index.ts +0 -2
- package/src/js-block-budget-policy.ts +90 -0
- package/src/js-watchdog.ts +78 -0
- package/src/link/README.md +43 -0
- package/src/link/channel.ts +473 -0
- package/src/link/envelope.ts +110 -0
- package/src/link/index.ts +321 -0
- package/src/link/ports.ts +42 -0
- package/src/link/scheduler.ts +132 -0
- package/src/link/standard-websocket.ts +251 -0
- package/src/link/streams.ts +34 -0
- package/src/protocol.ts +14 -9
- package/src/rpc-peer.ts +6 -3
- package/src/server.ts +99 -67
- package/src/shared/test-runner.ts +12 -0
- package/src/shared.ts +6 -1
- package/dist/binary-transfer.d.ts +0 -17
- package/dist/binary-transfer.d.ts.map +0 -1
- package/dist/binary-transfer.js +0 -60
- package/dist/heartbeat.d.ts +0 -13
- package/dist/heartbeat.d.ts.map +0 -1
- package/dist/heartbeat.js +0 -49
- package/dist/transport.d.ts +0 -22
- package/dist/transport.d.ts.map +0 -1
- package/dist/transport.js +0 -22
- package/dist/websocket-client-transport.d.ts +0 -3
- package/dist/websocket-client-transport.d.ts.map +0 -1
- package/dist/websocket-client-transport.js +0 -45
- package/dist/websocket-server-transport.d.ts +0 -4
- package/dist/websocket-server-transport.d.ts.map +0 -1
- package/dist/websocket-server-transport.js +0 -43
- package/src/binary-transfer.ts +0 -79
- package/src/heartbeat.ts +0 -67
- package/src/transport.ts +0 -47
- package/src/websocket-client-transport.ts +0 -85
- package/src/websocket-server-transport.ts +0 -54
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { Clock, IpcConnection, Logger, UplinkTransportConnection } from "./ports.js";
|
|
2
|
+
import { Channel, LinkChannelError, type ChannelHost, type LinkChannel } from "./channel.js";
|
|
3
|
+
import {
|
|
4
|
+
ENVELOPE,
|
|
5
|
+
MAX_CHANNEL_NUMBER,
|
|
6
|
+
isChannelId,
|
|
7
|
+
isEnvelopeType,
|
|
8
|
+
parseChannelId,
|
|
9
|
+
parseCredit,
|
|
10
|
+
parseEnvelope,
|
|
11
|
+
parseResetReason,
|
|
12
|
+
type ParsedEnvelope,
|
|
13
|
+
} from "./envelope.js";
|
|
14
|
+
import { Scheduler } from "./scheduler.js";
|
|
15
|
+
|
|
16
|
+
export { LinkChannelError } from "./channel.js";
|
|
17
|
+
export type { LinkChannel, LinkChannelErrorReason } from "./channel.js";
|
|
18
|
+
export { globalClock } from "./ports.js";
|
|
19
|
+
export type {
|
|
20
|
+
Clock,
|
|
21
|
+
IpcConnection,
|
|
22
|
+
Logger,
|
|
23
|
+
TimerHandle,
|
|
24
|
+
UplinkTransportConnection,
|
|
25
|
+
} from "./ports.js";
|
|
26
|
+
export {
|
|
27
|
+
connectStandardWebSocket,
|
|
28
|
+
StandardWebSocketConnection,
|
|
29
|
+
type StandardWebSocket,
|
|
30
|
+
type StandardWebSocketDialOptions,
|
|
31
|
+
type StandardWebSocketOptions,
|
|
32
|
+
} from "./standard-websocket.js";
|
|
33
|
+
|
|
34
|
+
export interface LinkConnection extends IpcConnection {
|
|
35
|
+
openChannel(options: { readonly signal: AbortSignal }): Promise<LinkChannel>;
|
|
36
|
+
acceptChannel(id: string): LinkChannel;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LinkOptions {
|
|
40
|
+
readonly end: "worker" | "gateway";
|
|
41
|
+
readonly clock: Clock;
|
|
42
|
+
readonly logger: Logger;
|
|
43
|
+
readonly firstChannelNumber?: number;
|
|
44
|
+
readonly generateId?: () => string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const OPEN_LIMIT = 64;
|
|
48
|
+
|
|
49
|
+
interface OpenWaiter {
|
|
50
|
+
readonly resolve: (channel: LinkChannel) => void;
|
|
51
|
+
readonly reject: (error: unknown) => void;
|
|
52
|
+
readonly signal: AbortSignal;
|
|
53
|
+
readonly onAbort: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class Link implements LinkConnection, ChannelHost {
|
|
57
|
+
readonly clock: Clock;
|
|
58
|
+
readonly logger: Logger;
|
|
59
|
+
readonly scheduler: Scheduler;
|
|
60
|
+
readonly #transport: UplinkTransportConnection;
|
|
61
|
+
readonly #ownParity: number;
|
|
62
|
+
readonly #generateId: () => string;
|
|
63
|
+
readonly #channels = new Map<number, Channel>();
|
|
64
|
+
readonly #unaccepted = new Map<string, Channel>();
|
|
65
|
+
readonly #waiters: OpenWaiter[] = [];
|
|
66
|
+
readonly #dataListeners = new Set<(chunk: string) => void>();
|
|
67
|
+
readonly #closeListeners = new Set<() => void>();
|
|
68
|
+
readonly #errorListeners = new Set<(error: Error) => void>();
|
|
69
|
+
readonly #pendingText: string[] = [];
|
|
70
|
+
#nextOwn: number;
|
|
71
|
+
#highestPeer = 0;
|
|
72
|
+
#ownOpen = 0;
|
|
73
|
+
#peerOpen = 0;
|
|
74
|
+
#closed = false;
|
|
75
|
+
|
|
76
|
+
constructor(transport: UplinkTransportConnection, options: LinkOptions) {
|
|
77
|
+
this.clock = options.clock;
|
|
78
|
+
this.logger = options.logger;
|
|
79
|
+
this.#transport = transport;
|
|
80
|
+
this.#ownParity = options.end === "worker" ? 1 : 0;
|
|
81
|
+
this.#generateId = options.generateId ?? randomId;
|
|
82
|
+
this.#nextOwn = options.firstChannelNumber ?? (options.end === "worker" ? 1 : 2);
|
|
83
|
+
this.scheduler = new Scheduler(transport);
|
|
84
|
+
transport.onData((chunk) => this.#onText(chunk));
|
|
85
|
+
transport.onBinary((bytes) => this.#onBinary(bytes));
|
|
86
|
+
transport.onError((error) => {
|
|
87
|
+
for (const listener of this.#errorListeners) listener(error);
|
|
88
|
+
});
|
|
89
|
+
transport.onSent(() => {
|
|
90
|
+
for (const channel of this.#channels.values()) channel.transportMoved();
|
|
91
|
+
});
|
|
92
|
+
transport.onClose(() => this.#onTransportClosed());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get closed(): boolean {
|
|
96
|
+
return this.#closed || this.#transport.closed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
unsentBytes(): number {
|
|
100
|
+
return this.#transport.unsentBytes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onData(listener: (chunk: string) => void): () => void {
|
|
104
|
+
this.#dataListeners.add(listener);
|
|
105
|
+
if (this.#pendingText.length > 0) {
|
|
106
|
+
for (const chunk of this.#pendingText.splice(0)) listener(chunk);
|
|
107
|
+
}
|
|
108
|
+
return () => {
|
|
109
|
+
this.#dataListeners.delete(listener);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
onClose(listener: () => void): () => void {
|
|
114
|
+
if (this.#closed) {
|
|
115
|
+
listener();
|
|
116
|
+
return () => undefined;
|
|
117
|
+
}
|
|
118
|
+
this.#closeListeners.add(listener);
|
|
119
|
+
return () => {
|
|
120
|
+
this.#closeListeners.delete(listener);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onError(listener: (error: Error) => void): () => void {
|
|
125
|
+
this.#errorListeners.add(listener);
|
|
126
|
+
return () => {
|
|
127
|
+
this.#errorListeners.delete(listener);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
write(contents: string): Promise<void> {
|
|
132
|
+
return this.scheduler.text(contents);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async close(): Promise<void> {
|
|
136
|
+
await this.#transport.close();
|
|
137
|
+
this.#onTransportClosed();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
openChannel(options: { readonly signal: AbortSignal }): Promise<LinkChannel> {
|
|
141
|
+
const { signal } = options;
|
|
142
|
+
if (this.#closed) return Promise.reject(new LinkChannelError("closed"));
|
|
143
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
144
|
+
if (this.#ownOpen < OPEN_LIMIT && this.#waiters.length === 0) {
|
|
145
|
+
try {
|
|
146
|
+
return Promise.resolve(this.#open().view);
|
|
147
|
+
} catch (error: unknown) {
|
|
148
|
+
return Promise.reject(error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return new Promise<LinkChannel>((resolve, reject) => {
|
|
152
|
+
const onAbort = () => {
|
|
153
|
+
const at = this.#waiters.findIndex((waiter) => waiter.onAbort === onAbort);
|
|
154
|
+
if (at >= 0) this.#waiters.splice(at, 1);
|
|
155
|
+
reject(signal.reason);
|
|
156
|
+
};
|
|
157
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
158
|
+
this.#waiters.push({ onAbort, reject, resolve, signal });
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
acceptChannel(id: string): LinkChannel {
|
|
163
|
+
const channel = this.#unaccepted.get(id);
|
|
164
|
+
if (channel === undefined) {
|
|
165
|
+
throw new Error(`channel ${id} was not opened by the peer, or was already accepted or ended`);
|
|
166
|
+
}
|
|
167
|
+
this.#unaccepted.delete(id);
|
|
168
|
+
channel.markAccepted();
|
|
169
|
+
return channel.view;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
channelDone(channel: Channel): void {
|
|
173
|
+
if (this.#channels.get(channel.number) !== channel) return;
|
|
174
|
+
this.#forget(channel);
|
|
175
|
+
this.#drainWaiters();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#forget(channel: Channel): void {
|
|
179
|
+
this.#channels.delete(channel.number);
|
|
180
|
+
if (this.#unaccepted.get(channel.id) === channel) this.#unaccepted.delete(channel.id);
|
|
181
|
+
if (channel.ownedByUs) this.#ownOpen -= 1;
|
|
182
|
+
else this.#peerOpen -= 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#open(): Channel {
|
|
186
|
+
if (this.#nextOwn > MAX_CHANNEL_NUMBER) {
|
|
187
|
+
this.logger.warn("link: this side has used every channel number on the connection");
|
|
188
|
+
throw new Error("the link has no channel numbers left on this connection");
|
|
189
|
+
}
|
|
190
|
+
let id = this.#newId();
|
|
191
|
+
while (this.#channels.size > 0 && [...this.#channels.values()].some((c) => c.id === id)) {
|
|
192
|
+
id = this.#newId();
|
|
193
|
+
}
|
|
194
|
+
const number = this.#nextOwn;
|
|
195
|
+
this.#nextOwn += 2;
|
|
196
|
+
const channel = new Channel(this, number, id, true);
|
|
197
|
+
this.#channels.set(number, channel);
|
|
198
|
+
this.#ownOpen += 1;
|
|
199
|
+
return channel;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#newId(): string {
|
|
203
|
+
const id = this.#generateId();
|
|
204
|
+
if (!isChannelId(id)) {
|
|
205
|
+
throw new Error("link: `generateId` must make ids of 1 to 32 characters from [A-Za-z0-9-]");
|
|
206
|
+
}
|
|
207
|
+
return id;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#drainWaiters(): void {
|
|
211
|
+
while (this.#waiters.length > 0 && this.#ownOpen < OPEN_LIMIT && !this.#closed) {
|
|
212
|
+
const waiter = this.#waiters.shift();
|
|
213
|
+
if (waiter === undefined) break;
|
|
214
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
215
|
+
try {
|
|
216
|
+
waiter.resolve(this.#open().view);
|
|
217
|
+
} catch (error: unknown) {
|
|
218
|
+
waiter.reject(error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#onText(chunk: string): void {
|
|
224
|
+
if (this.#dataListeners.size === 0) {
|
|
225
|
+
this.#pendingText.push(chunk);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const listener of this.#dataListeners) listener(chunk);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#onTransportClosed(): void {
|
|
232
|
+
if (this.#closed) return;
|
|
233
|
+
this.#closed = true;
|
|
234
|
+
this.scheduler.close();
|
|
235
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
236
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
237
|
+
waiter.reject(new LinkChannelError("closed"));
|
|
238
|
+
}
|
|
239
|
+
const error = new LinkChannelError("closed");
|
|
240
|
+
for (const channel of this.#channels.values()) channel.closeWith(error);
|
|
241
|
+
this.#channels.clear();
|
|
242
|
+
this.#unaccepted.clear();
|
|
243
|
+
for (const listener of this.#closeListeners) listener();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#warn(rule: string): void {
|
|
247
|
+
this.logger.warn(`link: dropped a message from the peer: ${rule}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#onBinary(bytes: Uint8Array): void {
|
|
251
|
+
const envelope = parseEnvelope(bytes);
|
|
252
|
+
if (envelope === undefined) return this.#warn("a message too short to read");
|
|
253
|
+
if (!isEnvelopeType(envelope.type)) return this.#warn("an unknown envelope type");
|
|
254
|
+
if (envelope.type === ENVELOPE.OPEN) return this.#onOpen(envelope.number, envelope.payload);
|
|
255
|
+
const channel = this.#channels.get(envelope.number);
|
|
256
|
+
if (channel === undefined) {
|
|
257
|
+
if (!this.#isLate(envelope.number)) this.#warn("a channel number the peer never opened");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.#dispatch(channel, envelope);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#dispatch(channel: Channel, envelope: ParsedEnvelope): void {
|
|
264
|
+
switch (envelope.type) {
|
|
265
|
+
case ENVELOPE.DATA:
|
|
266
|
+
return channel.onData(envelope.payload);
|
|
267
|
+
case ENVELOPE.END:
|
|
268
|
+
return channel.onEnd();
|
|
269
|
+
case ENVELOPE.STOP:
|
|
270
|
+
return channel.onStop();
|
|
271
|
+
case ENVELOPE.CREDIT:
|
|
272
|
+
return channel.onCredit(parseCredit(envelope.payload));
|
|
273
|
+
case ENVELOPE.RESET:
|
|
274
|
+
return channel.onReset(parseResetReason(envelope.payload));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#isLate(number: number): boolean {
|
|
279
|
+
if (number < 1) return false;
|
|
280
|
+
if (number % 2 === this.#ownParity) return number < this.#nextOwn;
|
|
281
|
+
return number <= this.#highestPeer;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#onOpen(number: number, payload: Uint8Array): void {
|
|
285
|
+
if (number % 2 === this.#ownParity || number < 1)
|
|
286
|
+
return this.#warn("an OPEN with the wrong parity");
|
|
287
|
+
if (number <= this.#highestPeer) {
|
|
288
|
+
return this.#warn(
|
|
289
|
+
this.#channels.has(number)
|
|
290
|
+
? "an OPEN reusing a live number"
|
|
291
|
+
: "an OPEN reusing an ended number",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (this.#peerOpen >= OPEN_LIMIT) return this.#warn("a 65th OPEN from the peer");
|
|
295
|
+
const id = parseChannelId(payload);
|
|
296
|
+
if (id === undefined) return this.#warn("an OPEN with an id outside [A-Za-z0-9-]{1,32}");
|
|
297
|
+
if (this.#unaccepted.has(id)) return this.#warn("an OPEN reusing an id not yet accepted");
|
|
298
|
+
this.#highestPeer = number;
|
|
299
|
+
const channel = new Channel(this, number, id, false);
|
|
300
|
+
this.#channels.set(number, channel);
|
|
301
|
+
this.#unaccepted.set(id, channel);
|
|
302
|
+
this.#peerOpen += 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function randomId(): string {
|
|
307
|
+
const source = globalThis.crypto as Crypto | undefined;
|
|
308
|
+
if (typeof source?.randomUUID === "function") return source.randomUUID().replace(/-/g, "");
|
|
309
|
+
if (typeof source?.getRandomValues === "function") {
|
|
310
|
+
const bytes = source.getRandomValues(new Uint8Array(16));
|
|
311
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
312
|
+
}
|
|
313
|
+
throw new Error("link: this runtime has no crypto to make channel ids; pass `generateId`");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function createLink(
|
|
317
|
+
transport: UplinkTransportConnection,
|
|
318
|
+
options: LinkOptions,
|
|
319
|
+
): LinkConnection {
|
|
320
|
+
return new Link(transport, options);
|
|
321
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The parts of simlock's `src/ports` the link uses. Types only, plus `globalClock`,
|
|
2
|
+
// so the link runs unchanged in Node, a browser, and React Native.
|
|
3
|
+
|
|
4
|
+
export interface TimerHandle {
|
|
5
|
+
readonly _timerHandle?: never;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Clock {
|
|
9
|
+
now(): number;
|
|
10
|
+
setTimer(delayMs: number, callback: () => void): TimerHandle;
|
|
11
|
+
cancel(handle: TimerHandle): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const globalClock: Clock = {
|
|
15
|
+
cancel: (handle) => clearTimeout(handle as unknown as ReturnType<typeof setTimeout>),
|
|
16
|
+
now: () => Date.now(),
|
|
17
|
+
setTimer: (delayMs, callback) => setTimeout(callback, delayMs) as unknown as TimerHandle,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface Logger {
|
|
21
|
+
debug(message: string, fields?: Record<string, unknown>): void;
|
|
22
|
+
info(message: string, fields?: Record<string, unknown>): void;
|
|
23
|
+
warn(message: string, fields?: Record<string, unknown>): void;
|
|
24
|
+
error(message: string, fields?: Record<string, unknown>): void;
|
|
25
|
+
child(module: string): Logger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IpcConnection {
|
|
29
|
+
readonly closed: boolean;
|
|
30
|
+
onData(listener: (chunk: string) => void): () => void;
|
|
31
|
+
onClose(listener: () => void): () => void;
|
|
32
|
+
onError(listener: (error: Error) => void): () => void;
|
|
33
|
+
write(contents: string): Promise<void>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UplinkTransportConnection extends IpcConnection {
|
|
38
|
+
writeBinary(bytes: Uint8Array): Promise<void>;
|
|
39
|
+
onBinary(listener: (bytes: Uint8Array) => void): () => void;
|
|
40
|
+
readonly unsentBytes: number;
|
|
41
|
+
onSent(listener: () => void): () => void;
|
|
42
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { UplinkTransportConnection } from "./ports.js";
|
|
2
|
+
import { KIB, utf8Length } from "./envelope.js";
|
|
3
|
+
|
|
4
|
+
const UNSENT_MARK = 256 * KIB;
|
|
5
|
+
const PRIORITY_SHARE = 64 * KIB;
|
|
6
|
+
|
|
7
|
+
export interface BulkMessage {
|
|
8
|
+
readonly bytes: Uint8Array;
|
|
9
|
+
onHanded(): void;
|
|
10
|
+
onWritten(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BulkSource {
|
|
14
|
+
takeNext(): BulkMessage | undefined;
|
|
15
|
+
readonly hasNext: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PriorityMessage =
|
|
19
|
+
| { readonly kind: "text"; readonly text: string; readonly resolve: () => void }
|
|
20
|
+
| { readonly kind: "binary"; readonly bytes: Uint8Array; readonly onHanded?: () => void };
|
|
21
|
+
|
|
22
|
+
export class Scheduler {
|
|
23
|
+
readonly #transport: UplinkTransportConnection;
|
|
24
|
+
readonly #priority: PriorityMessage[] = [];
|
|
25
|
+
readonly #ring: BulkSource[] = [];
|
|
26
|
+
readonly #inRing = new Set<BulkSource>();
|
|
27
|
+
#prioritySinceBulk = 0;
|
|
28
|
+
#pumping = false;
|
|
29
|
+
#scheduled = false;
|
|
30
|
+
#closed = false;
|
|
31
|
+
|
|
32
|
+
constructor(transport: UplinkTransportConnection) {
|
|
33
|
+
this.#transport = transport;
|
|
34
|
+
transport.onSent(() => this.wake());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
text(text: string): Promise<void> {
|
|
38
|
+
if (this.#closed) return Promise.resolve();
|
|
39
|
+
return new Promise<void>((resolve) => {
|
|
40
|
+
this.#priority.push({ kind: "text", resolve, text });
|
|
41
|
+
this.wake();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
binary(bytes: Uint8Array, onHanded?: () => void): void {
|
|
46
|
+
if (this.#closed) return;
|
|
47
|
+
this.#priority.push(
|
|
48
|
+
onHanded === undefined ? { bytes, kind: "binary" } : { bytes, kind: "binary", onHanded },
|
|
49
|
+
);
|
|
50
|
+
this.wake();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
addBulk(source: BulkSource): void {
|
|
54
|
+
if (this.#inRing.has(source)) return;
|
|
55
|
+
this.#inRing.add(source);
|
|
56
|
+
this.#ring.push(source);
|
|
57
|
+
this.wake();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
removeBulk(source: BulkSource): void {
|
|
61
|
+
if (!this.#inRing.delete(source)) return;
|
|
62
|
+
this.#ring.splice(this.#ring.indexOf(source), 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
wake(): void {
|
|
66
|
+
if (this.#scheduled || this.#closed) return;
|
|
67
|
+
this.#scheduled = true;
|
|
68
|
+
queueMicrotask(() => {
|
|
69
|
+
this.#scheduled = false;
|
|
70
|
+
this.#pump();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
close(): void {
|
|
75
|
+
this.#closed = true;
|
|
76
|
+
for (const message of this.#priority.splice(0)) {
|
|
77
|
+
if (message.kind === "text") message.resolve();
|
|
78
|
+
}
|
|
79
|
+
this.#ring.length = 0;
|
|
80
|
+
this.#inRing.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#pump(): void {
|
|
84
|
+
if (this.#pumping) return;
|
|
85
|
+
this.#pumping = true;
|
|
86
|
+
try {
|
|
87
|
+
while (!this.#closed) {
|
|
88
|
+
const canBulk = this.#ring.length > 0 && this.#transport.unsentBytes < UNSENT_MARK;
|
|
89
|
+
const bulkDue = canBulk && this.#prioritySinceBulk >= PRIORITY_SHARE;
|
|
90
|
+
if (this.#priority.length > 0 && !bulkDue) {
|
|
91
|
+
this.#sendPriority();
|
|
92
|
+
} else if (canBulk) {
|
|
93
|
+
this.#sendBulk();
|
|
94
|
+
} else {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
this.#pumping = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#sendPriority(): void {
|
|
104
|
+
const message = this.#priority.shift();
|
|
105
|
+
if (message === undefined) return;
|
|
106
|
+
if (message.kind === "text") {
|
|
107
|
+
this.#prioritySinceBulk += utf8Length(message.text);
|
|
108
|
+
const done = () => message.resolve();
|
|
109
|
+
this.#transport.write(message.text).then(done, done);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.#prioritySinceBulk += message.bytes.length;
|
|
113
|
+
message.onHanded?.();
|
|
114
|
+
this.#transport.writeBinary(message.bytes).catch(() => undefined);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#sendBulk(): void {
|
|
118
|
+
const source = this.#ring.shift();
|
|
119
|
+
if (source === undefined) return;
|
|
120
|
+
const message = source.takeNext();
|
|
121
|
+
if (source.hasNext) {
|
|
122
|
+
this.#ring.push(source);
|
|
123
|
+
} else {
|
|
124
|
+
this.#inRing.delete(source);
|
|
125
|
+
}
|
|
126
|
+
if (message === undefined) return;
|
|
127
|
+
this.#prioritySinceBulk = 0;
|
|
128
|
+
message.onHanded();
|
|
129
|
+
const written = () => message.onWritten();
|
|
130
|
+
this.#transport.writeBinary(message.bytes).then(written, written);
|
|
131
|
+
}
|
|
132
|
+
}
|