@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,473 @@
|
|
|
1
|
+
import type { Clock, Logger, TimerHandle } from "./ports.js";
|
|
2
|
+
import {
|
|
3
|
+
CHANNEL_WINDOW,
|
|
4
|
+
ENVELOPE,
|
|
5
|
+
KIB,
|
|
6
|
+
MAX_DATA_BYTES,
|
|
7
|
+
encodeCredit,
|
|
8
|
+
encodeEnvelope,
|
|
9
|
+
encodeAscii,
|
|
10
|
+
encodeReset,
|
|
11
|
+
type ResetReason,
|
|
12
|
+
} from "./envelope.js";
|
|
13
|
+
import type { BulkMessage, BulkSource, Scheduler } from "./scheduler.js";
|
|
14
|
+
import {
|
|
15
|
+
LinkByteLengthQueuingStrategy,
|
|
16
|
+
LinkReadableStream,
|
|
17
|
+
LinkWritableStream,
|
|
18
|
+
} from "./streams.js";
|
|
19
|
+
|
|
20
|
+
export type LinkChannelErrorReason =
|
|
21
|
+
| "aborted"
|
|
22
|
+
| "invalid"
|
|
23
|
+
| "not-accepted"
|
|
24
|
+
| "stalled"
|
|
25
|
+
| "closed"
|
|
26
|
+
| "stopped";
|
|
27
|
+
|
|
28
|
+
export class LinkChannelError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly reason: LinkChannelErrorReason,
|
|
31
|
+
message?: string,
|
|
32
|
+
) {
|
|
33
|
+
super(message ?? reason);
|
|
34
|
+
this.name = "LinkChannelError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LinkChannel {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly readable: ReadableStream<Uint8Array>;
|
|
41
|
+
readonly writable: WritableStream<Uint8Array>;
|
|
42
|
+
abort(reason?: string): void;
|
|
43
|
+
readonly credit: number;
|
|
44
|
+
whenCredit(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChannelHost {
|
|
48
|
+
readonly clock: Clock;
|
|
49
|
+
readonly logger: Logger;
|
|
50
|
+
readonly scheduler: Scheduler;
|
|
51
|
+
unsentBytes(): number;
|
|
52
|
+
channelDone(channel: Channel): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ACCEPT_WAIT_MS = 30_000;
|
|
56
|
+
const STALL_WAIT_MS = 60_000;
|
|
57
|
+
const WRITER_BUFFER = 64 * KIB;
|
|
58
|
+
|
|
59
|
+
interface Deferred {
|
|
60
|
+
resolve(): void;
|
|
61
|
+
reject(error: Error): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type QueueItem = { readonly bytes: Uint8Array; readonly payload: number; readonly end: boolean };
|
|
65
|
+
|
|
66
|
+
export class Channel implements BulkSource {
|
|
67
|
+
readonly number: number;
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly ownedByUs: boolean;
|
|
70
|
+
accepted: boolean;
|
|
71
|
+
readonly view: LinkChannel;
|
|
72
|
+
readonly #host: ChannelHost;
|
|
73
|
+
|
|
74
|
+
#failure: LinkChannelError | undefined;
|
|
75
|
+
#done = false;
|
|
76
|
+
|
|
77
|
+
#openHanded: boolean;
|
|
78
|
+
#queue: QueueItem[] = [];
|
|
79
|
+
#queuedBytes = 0;
|
|
80
|
+
#allowance = CHANNEL_WINDOW;
|
|
81
|
+
#handed = 0;
|
|
82
|
+
#inFlight = 0;
|
|
83
|
+
#endQueued = false;
|
|
84
|
+
#endHanded = false;
|
|
85
|
+
#stoppedByPeer = false;
|
|
86
|
+
#pendingWrite: Deferred | undefined;
|
|
87
|
+
#creditWaiters: Deferred[] = [];
|
|
88
|
+
#stallTimer: TimerHandle | undefined;
|
|
89
|
+
#acceptTimer: TimerHandle | undefined;
|
|
90
|
+
|
|
91
|
+
#received = 0;
|
|
92
|
+
#granted = 0;
|
|
93
|
+
#readTotal = 0;
|
|
94
|
+
#inQueue: Uint8Array[] = [];
|
|
95
|
+
#inEnded = false;
|
|
96
|
+
#stopSent = false;
|
|
97
|
+
#wantRead = false;
|
|
98
|
+
#readableController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
99
|
+
#writableController: WritableStreamDefaultController | undefined;
|
|
100
|
+
|
|
101
|
+
#resetQueued = false;
|
|
102
|
+
#resetHanded = false;
|
|
103
|
+
#resetReceived = false;
|
|
104
|
+
|
|
105
|
+
constructor(host: ChannelHost, number: number, id: string, ownedByUs: boolean) {
|
|
106
|
+
this.#host = host;
|
|
107
|
+
this.number = number;
|
|
108
|
+
this.id = id;
|
|
109
|
+
this.ownedByUs = ownedByUs;
|
|
110
|
+
this.accepted = ownedByUs;
|
|
111
|
+
this.#openHanded = !ownedByUs;
|
|
112
|
+
this.view = this.#buildView();
|
|
113
|
+
if (ownedByUs) {
|
|
114
|
+
host.scheduler.binary(
|
|
115
|
+
encodeEnvelope(ENVELOPE.OPEN, number, encodeAscii(id)),
|
|
116
|
+
() => {
|
|
117
|
+
this.#openHanded = true;
|
|
118
|
+
if (this.hasNext) host.scheduler.addBulk(this);
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
this.#acceptTimer = host.clock.setTimer(ACCEPT_WAIT_MS, () => {
|
|
123
|
+
this.#acceptTimer = undefined;
|
|
124
|
+
this.#fail(new LinkChannelError("not-accepted"), "not-accepted");
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get hasNext(): boolean {
|
|
130
|
+
return this.#queue.length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get credit(): number {
|
|
134
|
+
if (this.#failure !== undefined || this.#endQueued || this.#stoppedByPeer) return 0;
|
|
135
|
+
return Math.max(0, this.#allowance - this.#handed - this.#queuedBytes);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
markAccepted(): void {
|
|
139
|
+
this.accepted = true;
|
|
140
|
+
if (this.#acceptTimer !== undefined) {
|
|
141
|
+
this.#host.clock.cancel(this.#acceptTimer);
|
|
142
|
+
this.#acceptTimer = undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
takeNext(): BulkMessage | undefined {
|
|
147
|
+
const item = this.#queue.shift();
|
|
148
|
+
if (item === undefined) return undefined;
|
|
149
|
+
return {
|
|
150
|
+
bytes: item.bytes,
|
|
151
|
+
onHanded: () => {
|
|
152
|
+
if (item.end) {
|
|
153
|
+
this.#endHanded = true;
|
|
154
|
+
this.#checkDone();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.#queuedBytes -= item.payload;
|
|
158
|
+
this.#handed += item.payload;
|
|
159
|
+
this.#inFlight += 1;
|
|
160
|
+
this.#updateStall();
|
|
161
|
+
},
|
|
162
|
+
onWritten: () => {
|
|
163
|
+
if (item.end) return;
|
|
164
|
+
this.#inFlight -= 1;
|
|
165
|
+
this.#updateStall();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
transportMoved(): void {
|
|
171
|
+
this.#updateStall();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
onData(payload: Uint8Array): void {
|
|
175
|
+
if (this.#failure !== undefined) return;
|
|
176
|
+
if (this.#inEnded) return this.#invalid("DATA after the direction ended");
|
|
177
|
+
if (this.#stopSent) return;
|
|
178
|
+
if (payload.length > MAX_DATA_BYTES) return this.#invalid("DATA over 64 KiB");
|
|
179
|
+
if (this.#received + payload.length > CHANNEL_WINDOW + this.#granted) {
|
|
180
|
+
return this.#invalid("DATA past the allowed bytes");
|
|
181
|
+
}
|
|
182
|
+
if (payload.length === 0) return;
|
|
183
|
+
this.#received += payload.length;
|
|
184
|
+
this.#inQueue.push(payload);
|
|
185
|
+
this.#deliver();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onEnd(): void {
|
|
189
|
+
if (this.#failure !== undefined) {
|
|
190
|
+
// why: the peer's END still ends its direction after this side failed; the slot frees once both are done.
|
|
191
|
+
this.#inEnded = true;
|
|
192
|
+
this.#checkDone();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (this.#inEnded) return this.#invalid("END after the direction ended");
|
|
196
|
+
this.#inEnded = true;
|
|
197
|
+
this.#deliver();
|
|
198
|
+
this.#checkDone();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
onStop(): void {
|
|
202
|
+
if (this.#failure !== undefined) return;
|
|
203
|
+
if (this.#stoppedByPeer) return this.#invalid("a second STOP for one direction");
|
|
204
|
+
this.#dropQueuedData();
|
|
205
|
+
if (this.#endQueued) return;
|
|
206
|
+
this.#stoppedByPeer = true;
|
|
207
|
+
this.#queueEnd();
|
|
208
|
+
this.#failOutgoing(new LinkChannelError("stopped"));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
onCredit(amount: number | undefined): void {
|
|
212
|
+
if (this.#failure !== undefined) return;
|
|
213
|
+
if (amount === undefined || amount < 1) return this.#invalid("a CREDIT of 0");
|
|
214
|
+
const taken = this.#handed + this.#queuedBytes;
|
|
215
|
+
if (this.#allowance + amount > taken + CHANNEL_WINDOW) {
|
|
216
|
+
return this.#invalid("credit beyond 4 MiB outstanding");
|
|
217
|
+
}
|
|
218
|
+
this.#allowance += amount;
|
|
219
|
+
this.#wakeWriters();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
onReset(reason: ResetReason | undefined): void {
|
|
223
|
+
this.#resetReceived = true;
|
|
224
|
+
if (this.#failure === undefined) {
|
|
225
|
+
this.#fail(
|
|
226
|
+
new LinkChannelError(reason ?? "aborted"),
|
|
227
|
+
this.#resetQueued ? undefined : "aborted",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
this.#checkDone();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
closeWith(error: LinkChannelError): void {
|
|
234
|
+
this.#resetQueued = true;
|
|
235
|
+
this.#done = true;
|
|
236
|
+
this.#fail(error, undefined);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
abort(reason?: string): void {
|
|
240
|
+
if (this.#failure !== undefined) return;
|
|
241
|
+
this.#fail(new LinkChannelError("aborted", reason), "aborted");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#invalid(rule: string): void {
|
|
245
|
+
this.#host.logger.warn(`link: channel ${this.id} broke a rule: ${rule}`);
|
|
246
|
+
this.#fail(new LinkChannelError("invalid", rule), "invalid");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#fail(error: LinkChannelError, send: ResetReason | undefined): void {
|
|
250
|
+
if (this.#failure !== undefined) return;
|
|
251
|
+
this.#failure = error;
|
|
252
|
+
this.#clearTimers();
|
|
253
|
+
this.#host.scheduler.removeBulk(this);
|
|
254
|
+
this.#queue = [];
|
|
255
|
+
this.#queuedBytes = 0;
|
|
256
|
+
this.#inQueue = [];
|
|
257
|
+
this.#wantRead = false;
|
|
258
|
+
this.#failOutgoing(error);
|
|
259
|
+
try {
|
|
260
|
+
this.#readableController?.error(error);
|
|
261
|
+
} catch {
|
|
262
|
+
// why: the readable may already be closed or cancelled, and a stream cannot be errored twice.
|
|
263
|
+
}
|
|
264
|
+
if (send !== undefined && !this.#resetQueued) {
|
|
265
|
+
this.#resetQueued = true;
|
|
266
|
+
this.#host.scheduler.binary(encodeReset(this.number, send), () => {
|
|
267
|
+
this.#resetHanded = true;
|
|
268
|
+
this.#checkDone();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
this.#checkDone();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#failOutgoing(error: LinkChannelError): void {
|
|
275
|
+
this.#clearStall();
|
|
276
|
+
const pending = this.#pendingWrite;
|
|
277
|
+
this.#pendingWrite = undefined;
|
|
278
|
+
pending?.reject(error);
|
|
279
|
+
for (const waiter of this.#creditWaiters.splice(0)) waiter.reject(error);
|
|
280
|
+
try {
|
|
281
|
+
this.#writableController?.error(error);
|
|
282
|
+
} catch {
|
|
283
|
+
// why: the writable may already be closed or errored.
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#checkDone(): void {
|
|
288
|
+
if (this.#done) return;
|
|
289
|
+
const ours = this.#resetHanded || this.#endHanded;
|
|
290
|
+
const theirs = this.#resetReceived || this.#inEnded;
|
|
291
|
+
if (!ours || !theirs) return;
|
|
292
|
+
this.#done = true;
|
|
293
|
+
this.#clearTimers();
|
|
294
|
+
this.#host.scheduler.removeBulk(this);
|
|
295
|
+
this.#host.channelDone(this);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#clearTimers(): void {
|
|
299
|
+
this.#clearStall();
|
|
300
|
+
if (this.#acceptTimer !== undefined) {
|
|
301
|
+
this.#host.clock.cancel(this.#acceptTimer);
|
|
302
|
+
this.#acceptTimer = undefined;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#dropQueuedData(): void {
|
|
307
|
+
this.#queue = this.#queue.filter((item) => item.end);
|
|
308
|
+
this.#queuedBytes = 0;
|
|
309
|
+
if (this.#queue.length === 0) this.#host.scheduler.removeBulk(this);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#queueEnd(): void {
|
|
313
|
+
this.#endQueued = true;
|
|
314
|
+
this.#queue.push({ bytes: encodeEnvelope(ENVELOPE.END, this.number), end: true, payload: 0 });
|
|
315
|
+
if (this.#openHanded) this.#host.scheduler.addBulk(this);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#take(chunk: Uint8Array): void {
|
|
319
|
+
for (let offset = 0; offset < chunk.length; offset += MAX_DATA_BYTES) {
|
|
320
|
+
const piece = chunk.subarray(offset, Math.min(chunk.length, offset + MAX_DATA_BYTES));
|
|
321
|
+
this.#queue.push({
|
|
322
|
+
bytes: encodeEnvelope(ENVELOPE.DATA, this.number, piece),
|
|
323
|
+
end: false,
|
|
324
|
+
payload: piece.length,
|
|
325
|
+
});
|
|
326
|
+
this.#queuedBytes += piece.length;
|
|
327
|
+
}
|
|
328
|
+
if (this.#openHanded) this.#host.scheduler.addBulk(this);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#wakeWriters(): void {
|
|
332
|
+
const pending = this.#pendingWrite;
|
|
333
|
+
this.#pendingWrite = undefined;
|
|
334
|
+
pending?.resolve();
|
|
335
|
+
for (const waiter of this.#creditWaiters.splice(0)) waiter.resolve();
|
|
336
|
+
this.#updateStall();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#updateStall(): void {
|
|
340
|
+
const waiting = this.#pendingWrite !== undefined || this.#creditWaiters.length > 0;
|
|
341
|
+
const peerHoldsEverything =
|
|
342
|
+
this.#queuedBytes === 0 && (this.#host.unsentBytes() === 0 || this.#inFlight === 0);
|
|
343
|
+
if (!waiting || !peerHoldsEverything || this.#failure !== undefined) {
|
|
344
|
+
this.#clearStall();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (this.#stallTimer !== undefined) return;
|
|
348
|
+
this.#stallTimer = this.#host.clock.setTimer(STALL_WAIT_MS, () => {
|
|
349
|
+
this.#stallTimer = undefined;
|
|
350
|
+
this.#fail(new LinkChannelError("stalled"), "stalled");
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#clearStall(): void {
|
|
355
|
+
if (this.#stallTimer === undefined) return;
|
|
356
|
+
this.#host.clock.cancel(this.#stallTimer);
|
|
357
|
+
this.#stallTimer = undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#deliver(): void {
|
|
361
|
+
const controller = this.#readableController;
|
|
362
|
+
if (controller === undefined || !this.#wantRead || this.#stopSent) return;
|
|
363
|
+
const chunk = this.#inQueue.shift();
|
|
364
|
+
if (chunk !== undefined) {
|
|
365
|
+
this.#wantRead = false;
|
|
366
|
+
this.#readTotal += chunk.length;
|
|
367
|
+
controller.enqueue(chunk);
|
|
368
|
+
this.#grant();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (this.#inEnded) {
|
|
372
|
+
this.#wantRead = false;
|
|
373
|
+
controller.close();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#grant(): void {
|
|
378
|
+
const unread = this.#readTotal - this.#granted;
|
|
379
|
+
if (unread <= 0 || this.#inEnded) return;
|
|
380
|
+
if (unread < 64 * KIB && this.#inQueue.length > 0) return;
|
|
381
|
+
this.#granted += unread;
|
|
382
|
+
this.#host.scheduler.binary(encodeCredit(this.number, unread));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#cancelReadable(): void {
|
|
386
|
+
if (this.#stopSent || this.#failure !== undefined) return;
|
|
387
|
+
this.#inQueue = [];
|
|
388
|
+
this.#wantRead = false;
|
|
389
|
+
if (this.#inEnded) return;
|
|
390
|
+
this.#stopSent = true;
|
|
391
|
+
this.#host.scheduler.binary(encodeEnvelope(ENVELOPE.STOP, this.number));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#buildView(): LinkChannel {
|
|
395
|
+
const readable = new LinkReadableStream<Uint8Array>(
|
|
396
|
+
{
|
|
397
|
+
start: (controller) => {
|
|
398
|
+
this.#readableController = controller;
|
|
399
|
+
},
|
|
400
|
+
pull: () => {
|
|
401
|
+
this.#wantRead = true;
|
|
402
|
+
this.#deliver();
|
|
403
|
+
},
|
|
404
|
+
cancel: () => this.#cancelReadable(),
|
|
405
|
+
},
|
|
406
|
+
{ highWaterMark: 0 },
|
|
407
|
+
);
|
|
408
|
+
const writable = new LinkWritableStream<Uint8Array>(
|
|
409
|
+
{
|
|
410
|
+
start: (controller) => {
|
|
411
|
+
this.#writableController = controller;
|
|
412
|
+
},
|
|
413
|
+
write: (chunk) => this.#write(chunk),
|
|
414
|
+
close: () => {
|
|
415
|
+
if (this.#failure !== undefined) throw this.#failure;
|
|
416
|
+
if (this.#endQueued) return;
|
|
417
|
+
this.#queueEnd();
|
|
418
|
+
const error = new LinkChannelError("closed", "the channel's sending direction ended");
|
|
419
|
+
for (const waiter of this.#creditWaiters.splice(0)) waiter.reject(error);
|
|
420
|
+
this.#updateStall();
|
|
421
|
+
},
|
|
422
|
+
abort: (reason) => {
|
|
423
|
+
this.abort(reason === undefined ? undefined : String(reason));
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
new LinkByteLengthQueuingStrategy({ highWaterMark: WRITER_BUFFER }),
|
|
427
|
+
);
|
|
428
|
+
const currentCredit = () => this.credit;
|
|
429
|
+
return {
|
|
430
|
+
abort: (reason) => this.abort(reason),
|
|
431
|
+
get credit() {
|
|
432
|
+
return currentCredit();
|
|
433
|
+
},
|
|
434
|
+
id: this.id,
|
|
435
|
+
readable,
|
|
436
|
+
whenCredit: () => this.#whenCredit(),
|
|
437
|
+
writable,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async #write(chunk: Uint8Array): Promise<void> {
|
|
442
|
+
let offset = 0;
|
|
443
|
+
while (offset < chunk.length) {
|
|
444
|
+
if (this.#failure !== undefined) throw this.#failure;
|
|
445
|
+
if (this.#stoppedByPeer) throw new LinkChannelError("stopped");
|
|
446
|
+
const n = Math.min(this.credit, chunk.length - offset);
|
|
447
|
+
if (n > 0) {
|
|
448
|
+
this.#take(chunk.subarray(offset, offset + n));
|
|
449
|
+
offset += n;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
await new Promise<void>((resolve, reject) => {
|
|
453
|
+
this.#pendingWrite = { reject, resolve };
|
|
454
|
+
this.#updateStall();
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#whenCredit(): Promise<void> {
|
|
460
|
+
if (this.#failure !== undefined) return Promise.reject(this.#failure);
|
|
461
|
+
if (this.#stoppedByPeer) return Promise.reject(new LinkChannelError("stopped"));
|
|
462
|
+
if (this.#endQueued) {
|
|
463
|
+
return Promise.reject(
|
|
464
|
+
new LinkChannelError("closed", "the channel's sending direction ended"),
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
if (this.credit > 0) return Promise.resolve();
|
|
468
|
+
return new Promise<void>((resolve, reject) => {
|
|
469
|
+
this.#creditWaiters.push({ reject, resolve });
|
|
470
|
+
this.#updateStall();
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export const KIB = 1024;
|
|
2
|
+
export const MAX_DATA_BYTES = 64 * KIB;
|
|
3
|
+
export const CHANNEL_WINDOW = 4 * KIB * KIB;
|
|
4
|
+
export const MAX_CHANNEL_NUMBER = 0xffffffff;
|
|
5
|
+
|
|
6
|
+
export const ENVELOPE = { OPEN: 1, DATA: 2, END: 3, STOP: 4, CREDIT: 5, RESET: 6 } as const;
|
|
7
|
+
export type EnvelopeType = (typeof ENVELOPE)[keyof typeof ENVELOPE];
|
|
8
|
+
|
|
9
|
+
const RESET_CODES = { aborted: 1, stalled: 2, "not-accepted": 3, invalid: 4 } as const;
|
|
10
|
+
export type ResetReason = keyof typeof RESET_CODES;
|
|
11
|
+
|
|
12
|
+
const HEADER_BYTES = 5;
|
|
13
|
+
const CHANNEL_ID = /^[A-Za-z0-9-]{1,32}$/;
|
|
14
|
+
|
|
15
|
+
export interface ParsedEnvelope {
|
|
16
|
+
readonly type: number;
|
|
17
|
+
readonly number: number;
|
|
18
|
+
readonly payload: Uint8Array;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function encodeEnvelope(
|
|
22
|
+
type: EnvelopeType,
|
|
23
|
+
number: number,
|
|
24
|
+
payload: Uint8Array = new Uint8Array(0),
|
|
25
|
+
): Uint8Array {
|
|
26
|
+
const bytes = new Uint8Array(HEADER_BYTES + payload.length);
|
|
27
|
+
bytes[0] = type;
|
|
28
|
+
new DataView(bytes.buffer).setUint32(1, number);
|
|
29
|
+
bytes.set(payload, HEADER_BYTES);
|
|
30
|
+
return bytes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function encodeCredit(number: number, amount: number): Uint8Array {
|
|
34
|
+
const payload = new Uint8Array(4);
|
|
35
|
+
new DataView(payload.buffer).setUint32(0, amount);
|
|
36
|
+
return encodeEnvelope(ENVELOPE.CREDIT, number, payload);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function encodeReset(number: number, reason: ResetReason): Uint8Array {
|
|
40
|
+
return encodeEnvelope(ENVELOPE.RESET, number, new Uint8Array([RESET_CODES[reason]]));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseEnvelope(bytes: Uint8Array): ParsedEnvelope | undefined {
|
|
44
|
+
if (bytes.length < HEADER_BYTES) return undefined;
|
|
45
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
46
|
+
return { number: view.getUint32(1), payload: bytes.slice(HEADER_BYTES), type: bytes[0] ?? 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isEnvelopeType(type: number): type is EnvelopeType {
|
|
50
|
+
return type >= ENVELOPE.OPEN && type <= ENVELOPE.RESET;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseCredit(payload: Uint8Array): number | undefined {
|
|
54
|
+
if (payload.length !== 4) return undefined;
|
|
55
|
+
return new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseResetReason(payload: Uint8Array): ResetReason | undefined {
|
|
59
|
+
const code = payload[0];
|
|
60
|
+
for (const [reason, value] of Object.entries(RESET_CODES)) {
|
|
61
|
+
if (value === code) return reason as ResetReason;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// why: counted the way Buffer.byteLength counts, so the link needs no Buffer (a browser has none).
|
|
67
|
+
export function utf8Length(text: string): number {
|
|
68
|
+
let bytes = 0;
|
|
69
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
70
|
+
const code = text.charCodeAt(at);
|
|
71
|
+
if (code < 0x80) {
|
|
72
|
+
bytes += 1;
|
|
73
|
+
} else if (code < 0x800) {
|
|
74
|
+
bytes += 2;
|
|
75
|
+
} else if (code >= 0xd800 && code <= 0xdbff && isLowSurrogate(text.charCodeAt(at + 1))) {
|
|
76
|
+
bytes += 4;
|
|
77
|
+
at += 1;
|
|
78
|
+
} else {
|
|
79
|
+
bytes += 3;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return bytes;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isLowSurrogate(code: number): boolean {
|
|
86
|
+
return code >= 0xdc00 && code <= 0xdfff;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isChannelId(id: string): boolean {
|
|
90
|
+
return CHANNEL_ID.test(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseChannelId(payload: Uint8Array): string | undefined {
|
|
94
|
+
const id = decodeAscii(payload);
|
|
95
|
+
return isChannelId(id) ? id : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// harness: channel ids are ASCII, so the link needs no TextEncoder or TextDecoder (not every
|
|
99
|
+
// Hermes has TextDecoder). A byte over 0x7f decodes to a character the id pattern refuses.
|
|
100
|
+
export function encodeAscii(text: string): Uint8Array {
|
|
101
|
+
const bytes = new Uint8Array(text.length);
|
|
102
|
+
for (let at = 0; at < text.length; at += 1) bytes[at] = text.charCodeAt(at) & 0xff;
|
|
103
|
+
return bytes;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeAscii(bytes: Uint8Array): string {
|
|
107
|
+
let text = "";
|
|
108
|
+
for (let at = 0; at < bytes.length; at += 1) text += String.fromCharCode(bytes[at] ?? 0);
|
|
109
|
+
return text;
|
|
110
|
+
}
|