@trevrpc/trevrpc-js 1.0.0 → 1.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.
- package/README.md +86 -18
- package/bin/protoc-gen-trevrpc-js.js +1 -1
- package/package.json +17 -12
- package/src/call.js +616 -40
- package/src/channel.js +190 -18
- package/src/client.js +555 -106
- package/src/compression.js +330 -0
- package/src/connection-metadata.js +114 -0
- package/src/datagram.js +357 -0
- package/src/framing.js +124 -110
- package/src/generator.js +147 -16
- package/src/index.d.ts +223 -17
- package/src/qmux/frames.js +262 -0
- package/src/qmux/params.js +104 -0
- package/src/qmux/session.js +740 -0
- package/src/race.js +90 -0
- package/src/status.js +1 -1
- package/src/stream-compression.js +298 -0
- package/src/varint.js +73 -0
- package/src/wire.js +47 -28
- package/src/zstd.d.ts +35 -0
- package/src/zstd.js +407 -0
package/src/call.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
encodeMessage,
|
|
3
|
+
receivedMessage,
|
|
4
|
+
sessionCompression,
|
|
5
|
+
transportCompression,
|
|
6
|
+
} from "./compression.js";
|
|
7
|
+
import { transportConnectionMetadata } from "./connection-metadata.js";
|
|
8
|
+
import { datagramEndpoint, messageFrameSize } from "./datagram.js";
|
|
9
|
+
import {
|
|
10
|
+
DefaultMaxFrameSize,
|
|
11
|
+
FrameReader,
|
|
12
|
+
FrameType,
|
|
13
|
+
encodeOutgoingFrame,
|
|
14
|
+
encodeRequestFrame,
|
|
15
|
+
} from "./framing.js";
|
|
2
16
|
import { normalizeMetadata, validateMetadata } from "./metadata.js";
|
|
3
17
|
import {
|
|
4
18
|
Code,
|
|
@@ -6,11 +20,12 @@ import {
|
|
|
6
20
|
cancelled,
|
|
7
21
|
deadlineExceeded,
|
|
8
22
|
failedPrecondition,
|
|
23
|
+
internal,
|
|
9
24
|
invalidArgument,
|
|
10
25
|
statusError,
|
|
11
26
|
statusFromTransportError,
|
|
12
27
|
} from "./status.js";
|
|
13
|
-
import {
|
|
28
|
+
import { CallStreams, ReplayCopies, streamBudget } from "./stream-compression.js";
|
|
14
29
|
|
|
15
30
|
/** Longest delay setTimeout can represent; it fires immediately beyond this. */
|
|
16
31
|
const MaxTimerDelayMs = 2_147_483_647;
|
|
@@ -101,6 +116,9 @@ class CallScope {
|
|
|
101
116
|
* call's cancellation scope. Transport failures surface as TrevRpcError, and once the call is
|
|
102
117
|
* aborted every operation fails with the abort reason.
|
|
103
118
|
*/
|
|
119
|
+
/** Why sends fail once the server completed the call with OK before the upload ended. */
|
|
120
|
+
export const EarlyOk = "the server completed the call before the request stream ended";
|
|
121
|
+
|
|
104
122
|
export class Call {
|
|
105
123
|
#scope;
|
|
106
124
|
#writer;
|
|
@@ -108,35 +126,287 @@ export class Call {
|
|
|
108
126
|
#frames;
|
|
109
127
|
#maxFrameSize;
|
|
110
128
|
#sendClosed = false;
|
|
129
|
+
/** Set once the close of the request side completed. */
|
|
130
|
+
#closeCompleted = false;
|
|
131
|
+
/**
|
|
132
|
+
* The error every request-side operation fails with once the upload was ended, or undefined
|
|
133
|
+
* while it is open.
|
|
134
|
+
*/
|
|
135
|
+
#uploadEnded;
|
|
136
|
+
/** Rejectors of the request-side operations waiting for the transport. */
|
|
137
|
+
#pendingUploads = new Set();
|
|
138
|
+
/** The transport error that made the request side fail on its own, or undefined. */
|
|
139
|
+
#uploadFailure;
|
|
140
|
+
/** The call's datagram flow, or undefined if its messages all travel on the stream. */
|
|
141
|
+
#flow;
|
|
142
|
+
/** Whether request messages are sent as datagrams when they fit. */
|
|
143
|
+
#sendDatagrams = false;
|
|
144
|
+
/** The compression state of the session that carries the call, or undefined without one. */
|
|
145
|
+
#compression;
|
|
146
|
+
/** The connection metadata state of the session that carries the call, or undefined without. */
|
|
147
|
+
#connectionMetadata;
|
|
148
|
+
/** The ID of the connection metadata set the REQUEST offered, or zero. */
|
|
149
|
+
#offeredMetadata = 0;
|
|
150
|
+
/** The full protobuf type name of the request messages. */
|
|
151
|
+
#requestType;
|
|
152
|
+
/**
|
|
153
|
+
* What the call accepts on its response messages: a Map from dictionary ID to dictionary, 0 for
|
|
154
|
+
* compression without one; undefined without compression.
|
|
155
|
+
*/
|
|
156
|
+
#accept;
|
|
157
|
+
/** Whether request messages may be compressed without a dictionary. */
|
|
158
|
+
#plainRequests = false;
|
|
159
|
+
/** Whether the REQUEST queried the server's dictionaries, so its ANSWER answers the query. */
|
|
160
|
+
#queried = false;
|
|
161
|
+
/** The call's stream compression, or undefined if its method or transport has none. */
|
|
162
|
+
#streams;
|
|
163
|
+
/** Request messages of a call that uses datagrams, queued for the flush loop, in order. */
|
|
164
|
+
#unsent = [];
|
|
165
|
+
/** Settles when the flush loop has sent everything queued so far. */
|
|
166
|
+
#sendTail = Promise.resolve();
|
|
167
|
+
#flushing = false;
|
|
168
|
+
/**
|
|
169
|
+
* What the call needs to open itself again, for the replay of a refused optimistic call:
|
|
170
|
+
* `{ transport, request, rpc, deadline }`, where `deadline` is the `performance.now()` time the
|
|
171
|
+
* call's timeout ends, or undefined.
|
|
172
|
+
*/
|
|
173
|
+
#setup;
|
|
174
|
+
/**
|
|
175
|
+
* The requests an optimistic call has sent, kept until the server accepts or refuses it;
|
|
176
|
+
* undefined once it decided, and on calls that are not optimistic.
|
|
177
|
+
*/
|
|
178
|
+
#copies;
|
|
179
|
+
/** Whether the call was optimistic, so its sends and close go through #ordered. */
|
|
180
|
+
#optimistic = false;
|
|
181
|
+
/** Settles once the previous send or close of an optimistic call has written, in call order. */
|
|
182
|
+
#ordered = Promise.resolve();
|
|
183
|
+
/** The replay of a refused optimistic call, once it started. */
|
|
184
|
+
#replaying;
|
|
111
185
|
|
|
112
186
|
/**
|
|
113
|
-
* Opens a stream on the transport and sends the
|
|
114
|
-
* method
|
|
187
|
+
* Opens a stream on the transport and sends the REQUEST frame. The request carries the
|
|
188
|
+
* method path and kind; metadata and the wire timeout come from the options. The request
|
|
189
|
+
* message of a unary or server-streaming call, a marshalled body, goes out in the same write.
|
|
190
|
+
*
|
|
191
|
+
* `rpc` describes the method. `rpc.types` holds the full names of its request and response
|
|
192
|
+
* message types, which pick the dictionaries when the transport has compression. `rpc.datagrams`
|
|
193
|
+
* is `{ send, receive }` for a method that marks a direction unreliable: the call gets a
|
|
194
|
+
* datagram flow if the session that carries it has datagrams. Both depend on that session, which
|
|
195
|
+
* is only known once the stream is open, so then the REQUEST is encoded after that. `send` makes
|
|
196
|
+
* the call send request messages as datagrams, `receive` makes it accept response datagrams.
|
|
197
|
+
* `rpc.compressStream` is `{ requests, responses }`, the directions the call may
|
|
198
|
+
* stream-compress when the transport has compression, and `rpc.compress` the same for
|
|
199
|
+
* compression without a dictionary.
|
|
115
200
|
*/
|
|
116
|
-
static async open(transport, request, options) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
201
|
+
static async open(transport, request, message, options, rpc = {}) {
|
|
202
|
+
const fields = { ...request, metadata: options.metadata, ...timeoutField(options) };
|
|
203
|
+
const compression = transportCompression(transport);
|
|
204
|
+
// The REQUEST depends on the session that carries the call if it negotiates compression, opens
|
|
205
|
+
// a datagram flow or carries connection metadata.
|
|
206
|
+
const perSession =
|
|
207
|
+
rpc.datagrams !== undefined ||
|
|
208
|
+
compression !== undefined ||
|
|
209
|
+
transportConnectionMetadata(transport)?.current !== undefined;
|
|
210
|
+
const header = perSession
|
|
211
|
+
? null
|
|
212
|
+
: encodeRequestFrame(fields, outgoing(message), options.maxFrameSize);
|
|
122
213
|
const scope = new CallScope(options);
|
|
214
|
+
let call;
|
|
123
215
|
try {
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
216
|
+
const session = perSession ? {} : undefined;
|
|
217
|
+
const stream = await openStream(transport, scope.signal, session);
|
|
218
|
+
call = new Call(stream, scope, options.maxFrameSize);
|
|
219
|
+
call.#setup = {
|
|
220
|
+
transport,
|
|
221
|
+
request: { ...request, metadata: options.metadata },
|
|
222
|
+
rpc,
|
|
223
|
+
deadline: options.timeoutMs == null ? undefined : performance.now() + options.timeoutMs,
|
|
224
|
+
};
|
|
225
|
+
await call.#write(
|
|
226
|
+
header ?? call.#requestOnSession(fields, message, session, compression, transport, rpc),
|
|
227
|
+
);
|
|
127
228
|
return call;
|
|
128
229
|
} catch (error) {
|
|
230
|
+
if (call !== undefined) {
|
|
231
|
+
call.#releaseSessionState();
|
|
232
|
+
}
|
|
129
233
|
throw scope.abort(failureStatus(error));
|
|
130
234
|
}
|
|
131
235
|
}
|
|
132
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Sets the call up on the session that carries it, negotiating compression and opening its
|
|
239
|
+
* datagram flow as the method needs, and encodes the REQUEST. A call whose requests may be
|
|
240
|
+
* stream-compressed offers optimistically while the session allows it, unless `replay` says this
|
|
241
|
+
* is the replay of a refused call or the call has a datagram flow, which a replay would need to
|
|
242
|
+
* open again.
|
|
243
|
+
*/
|
|
244
|
+
#requestOnSession(fields, message, session, compression, transport, rpc, replay = false) {
|
|
245
|
+
const { types, datagrams, compressStream, compress } = rpc;
|
|
246
|
+
const request = { ...fields };
|
|
247
|
+
if (datagrams !== undefined) {
|
|
248
|
+
this.#flow = datagramEndpoint(session.datagrams)?.openFlow({
|
|
249
|
+
receive: datagrams.receive,
|
|
250
|
+
inflate: (frame) => this.receivedMessage(frame),
|
|
251
|
+
});
|
|
252
|
+
this.#sendDatagrams = this.#flow !== undefined && datagrams.send;
|
|
253
|
+
if (this.#flow !== undefined) {
|
|
254
|
+
request.datagramFlow = this.#flow.id;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (compression !== undefined) {
|
|
258
|
+
this.#compression = sessionCompression(compression, session.key);
|
|
259
|
+
this.#requestType = types.request;
|
|
260
|
+
const negotiation = this.#compression.prepare(types.response, Boolean(compress?.responses));
|
|
261
|
+
Object.assign(request, negotiation.fields);
|
|
262
|
+
this.#accept = negotiation.accept;
|
|
263
|
+
this.#plainRequests = Boolean(compress?.requests);
|
|
264
|
+
this.#queried = negotiation.queried;
|
|
265
|
+
if (compressStream?.requests || compressStream?.responses) {
|
|
266
|
+
const budget = streamBudget(transport);
|
|
267
|
+
this.#streams = new CallStreams(compression, budget, compressStream, {
|
|
268
|
+
session: this.#compression,
|
|
269
|
+
requestType: types.request,
|
|
270
|
+
accept: negotiation.accept,
|
|
271
|
+
});
|
|
272
|
+
const optimistic =
|
|
273
|
+
!replay && this.#flow === undefined && this.#compression.optimisticStreams;
|
|
274
|
+
if (this.#streams.offer(request, optimistic)) {
|
|
275
|
+
this.#copies = new ReplayCopies(budget);
|
|
276
|
+
this.#optimistic = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
this.#connectionMetadata = transportConnectionMetadata(transport)?.session(session.key);
|
|
281
|
+
if (this.#connectionMetadata !== undefined) {
|
|
282
|
+
const { field, offered } = this.#connectionMetadata.prepare();
|
|
283
|
+
this.#offeredMetadata = offered;
|
|
284
|
+
if (field !== undefined) {
|
|
285
|
+
request.connectionMetadata = field;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return encodeRequestFrame(
|
|
289
|
+
request,
|
|
290
|
+
message === undefined ? undefined : this.#encode(message),
|
|
291
|
+
this.#maxFrameSize,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Encodes a request message for sending: through the call's stream context once that exists,
|
|
297
|
+
* and otherwise compressed when the session's shared set has the dictionary of the request type,
|
|
298
|
+
* or compression without a dictionary where the method allows it. Both are checked per message,
|
|
299
|
+
* so a request stream starts compressing as soon as the server's answer arrives.
|
|
300
|
+
*/
|
|
301
|
+
#encode(body) {
|
|
302
|
+
if (body.byteLength > this.#maxFrameSize) {
|
|
303
|
+
return encodeMessage(body);
|
|
304
|
+
}
|
|
305
|
+
const dictionary = this.#compression?.requestCompressor(this.#requestType, this.#plainRequests);
|
|
306
|
+
return this.#streams === undefined
|
|
307
|
+
? encodeMessage(body, dictionary)
|
|
308
|
+
: this.#streams.encode(body, dictionary);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The message of a received MESSAGE, ZSTD_MESSAGE or ZSTD_STREAM_MESSAGE frame,
|
|
313
|
+
* `{ type, payload }`. A compressed message must use what the call negotiated and fit
|
|
314
|
+
* `maxFrameSize`; it fails with Internal or ResourceExhausted otherwise.
|
|
315
|
+
*/
|
|
316
|
+
receivedMessage(frame) {
|
|
317
|
+
if (frame.type === FrameType.ZstdStreamMessage) {
|
|
318
|
+
if (this.#streams === undefined) {
|
|
319
|
+
throw internal("stream-compressed message where stream compression is not enabled");
|
|
320
|
+
}
|
|
321
|
+
return this.#streams.receive(frame.payload, this.#maxFrameSize);
|
|
322
|
+
}
|
|
323
|
+
return receivedMessage(frame, this.#accept, this.#maxFrameSize);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Records the server's ANSWER to what the REQUEST asked. It answers a dictionary query whole,
|
|
328
|
+
* and must accept an optimistic call; a `stream_compression` answer also lets the session's
|
|
329
|
+
* calls be optimistic again after a refusal. A `connection_metadata` answer confirms the set the
|
|
330
|
+
* REQUEST offered, so the session's calls refer to it from now on.
|
|
331
|
+
*/
|
|
332
|
+
learnAnswers(answer) {
|
|
333
|
+
if (this.#copies !== undefined && !answer.streamCompression) {
|
|
334
|
+
throw internal("ANSWER of an optimistic call did not accept stream compression");
|
|
335
|
+
}
|
|
336
|
+
this.#connectionMetadata?.learn(this.#offeredMetadata, answer);
|
|
337
|
+
if (this.#queried) {
|
|
338
|
+
this.#compression.learn(answer.dictionaries);
|
|
339
|
+
}
|
|
340
|
+
if (answer.streamCompression) {
|
|
341
|
+
this.#streams?.accept();
|
|
342
|
+
if (this.#compression !== undefined) {
|
|
343
|
+
this.#compression.optimisticStreams = true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
this.#settleCopies("accepted");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Whether the server has yet to accept or refuse the call's optimistic stream compression, so a
|
|
351
|
+
* refusing STATUS can still be replayed.
|
|
352
|
+
*/
|
|
353
|
+
get replayable() {
|
|
354
|
+
return this.#copies !== undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Replays a call the server refused: opens a new stream for it with the same REQUEST, not
|
|
359
|
+
* optimistic and with the time left before its deadline, and sends the kept requests and, if the
|
|
360
|
+
* caller closed the request side, its end. Sends waiting meanwhile continue on the new stream.
|
|
361
|
+
* The session stops being optimistic until a call is answered. Resolves once the replay was
|
|
362
|
+
* written; the response is read from the new stream after that.
|
|
363
|
+
*/
|
|
364
|
+
replay() {
|
|
365
|
+
const copies = this.#copies;
|
|
366
|
+
this.#copies = undefined;
|
|
367
|
+
this.#compression.optimisticStreams = false;
|
|
368
|
+
this.#replaying = this.#reopen(copies.settle("refused"));
|
|
369
|
+
this.#replaying.catch(() => {});
|
|
370
|
+
return this.#replaying;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async #reopen(bodies) {
|
|
374
|
+
const { transport, request, rpc, deadline } = this.#setup;
|
|
375
|
+
// The old stream's failures belong to the refused call, not to this one.
|
|
376
|
+
this.#writer.abort(cancelled("RPC replayed")).catch(() => {});
|
|
377
|
+
this.#cancelReader(cancelled("RPC replayed")).catch(() => {});
|
|
378
|
+
this.#releaseSessionState();
|
|
379
|
+
const session = {};
|
|
380
|
+
const stream = await openStream(transport, this.signal, session);
|
|
381
|
+
this.#attach(stream);
|
|
382
|
+
this.#uploadFailure = undefined;
|
|
383
|
+
const timeoutMs =
|
|
384
|
+
deadline === undefined ? undefined : Math.max(0, deadline - performance.now());
|
|
385
|
+
const fields = { ...request, ...timeoutField({ timeoutMs }) };
|
|
386
|
+
const compression = transportCompression(transport);
|
|
387
|
+
await this.#write(
|
|
388
|
+
this.#requestOnSession(fields, undefined, session, compression, transport, rpc, true),
|
|
389
|
+
);
|
|
390
|
+
for (const body of bodies) {
|
|
391
|
+
await this.#write(encodeOutgoingFrame(this.#encode(body), this.#maxFrameSize));
|
|
392
|
+
}
|
|
393
|
+
if (this.#sendClosed && this.#uploadEnded === undefined) {
|
|
394
|
+
await this.#upload(this.#writer.close());
|
|
395
|
+
this.#closeCompleted = true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Ends an optimistic call's wait for the server's decision with `outcome`. */
|
|
400
|
+
#settleCopies(outcome) {
|
|
401
|
+
const copies = this.#copies;
|
|
402
|
+
this.#copies = undefined;
|
|
403
|
+
copies?.settle(outcome);
|
|
404
|
+
}
|
|
405
|
+
|
|
133
406
|
constructor(stream, scope, maxFrameSize) {
|
|
134
|
-
const reader = stream.readable.getReader();
|
|
135
407
|
this.#scope = scope;
|
|
136
|
-
this.#writer = stream.writable.getWriter();
|
|
137
|
-
this.#cancelReader = (reason) => reader.cancel(reason);
|
|
138
|
-
this.#frames = new FrameReader(reader);
|
|
139
408
|
this.#maxFrameSize = maxFrameSize;
|
|
409
|
+
this.#attach(stream);
|
|
140
410
|
scope.signal.addEventListener("abort", () => this.#teardown(), { once: true });
|
|
141
411
|
if (scope.signal.aborted) {
|
|
142
412
|
this.#teardown();
|
|
@@ -148,12 +418,189 @@ export class Call {
|
|
|
148
418
|
return this.#scope.signal;
|
|
149
419
|
}
|
|
150
420
|
|
|
151
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* The transport error that made the request side fail on its own, for instance because the
|
|
423
|
+
* server stopped reading it; undefined while the request side works or was ended by the call.
|
|
424
|
+
*/
|
|
425
|
+
get uploadFailure() {
|
|
426
|
+
return this.#uploadFailure;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Makes `stream` the one the call writes and reads. */
|
|
430
|
+
#attach(stream) {
|
|
431
|
+
const reader = stream.readable.getReader();
|
|
432
|
+
const writer = stream.writable.getWriter();
|
|
433
|
+
this.#writer = writer;
|
|
434
|
+
this.#cancelReader = (reason) => reader.cancel(reason);
|
|
435
|
+
this.#frames = new FrameReader(reader);
|
|
436
|
+
// The transport errors the request side as soon as the server stops reading it.
|
|
437
|
+
writer.closed.catch((error) => {
|
|
438
|
+
if (this.#writer === writer && this.#uploadEnded === undefined) {
|
|
439
|
+
this.#uploadFailure ??= statusFromTransportError(error);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Whether the request side can still be written: not ended, not closed. */
|
|
445
|
+
get uploadOpen() {
|
|
446
|
+
return this.#uploadEnded === undefined && !this.#closeCompleted;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The largest frame the call accepts and sends, in bytes. */
|
|
450
|
+
get maxFrameSize() {
|
|
451
|
+
return this.#maxFrameSize;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The call's datagram flow, or undefined if its messages all travel on the stream. */
|
|
455
|
+
get flow() {
|
|
456
|
+
return this.#flow;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Sends one streaming request message; writes are ordered and apply backpressure. Fails with
|
|
461
|
+
* the error that ended the upload, if any, even while the transport itself is still blocked.
|
|
462
|
+
* On a call that sends datagrams the message goes out as one if it fits, and on the stream
|
|
463
|
+
* otherwise.
|
|
464
|
+
*/
|
|
152
465
|
sendMessage(body) {
|
|
466
|
+
if (this.#uploadEnded !== undefined) {
|
|
467
|
+
return Promise.reject(this.#uploadEnded);
|
|
468
|
+
}
|
|
153
469
|
if (this.#sendClosed) {
|
|
154
470
|
return Promise.reject(failedPrecondition("request stream is closed"));
|
|
155
471
|
}
|
|
156
|
-
|
|
472
|
+
if (this.#optimistic) {
|
|
473
|
+
return this.#inOrder((issued) => this.#sendOptimistic(body, issued));
|
|
474
|
+
}
|
|
475
|
+
const message = this.#encode(body);
|
|
476
|
+
if (this.#sendDatagrams) {
|
|
477
|
+
return this.#upload(
|
|
478
|
+
new Promise((resolve, reject) => {
|
|
479
|
+
this.#unsent.push({ message, resolve, reject });
|
|
480
|
+
if (!this.#flushing) {
|
|
481
|
+
this.#flushing = true;
|
|
482
|
+
// Messages sent in the same turn are packed together; nothing waits for a timer.
|
|
483
|
+
this.#sendTail = Promise.resolve().then(() => this.#flush());
|
|
484
|
+
}
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return this.#write(encodeOutgoingFrame(message, this.#maxFrameSize));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Sends the queued request messages in order: as many as fit into one datagram go together, a
|
|
493
|
+
* message that cannot be a datagram goes on the stream, and so does every message of a datagram
|
|
494
|
+
* that failed to send. Messages queued while a datagram waits for send-buffer space join the
|
|
495
|
+
* next one.
|
|
496
|
+
*/
|
|
497
|
+
async #flush() {
|
|
498
|
+
let inFlight = [];
|
|
499
|
+
try {
|
|
500
|
+
while (this.#unsent.length > 0) {
|
|
501
|
+
if (this.#uploadEnded !== undefined) {
|
|
502
|
+
throw this.#uploadEnded;
|
|
503
|
+
}
|
|
504
|
+
inFlight = this.#takeBatch();
|
|
505
|
+
if (
|
|
506
|
+
inFlight.length === 0 ||
|
|
507
|
+
!(await this.#flow.send(inFlight.map(messageOf), this.signal))
|
|
508
|
+
) {
|
|
509
|
+
// Not a datagram: a head message that never fits, or a datagram that failed to send.
|
|
510
|
+
inFlight = inFlight.length > 0 ? inFlight : [this.#unsent.shift()];
|
|
511
|
+
for (const { message } of inFlight) {
|
|
512
|
+
await this.#writer.write(encodeOutgoingFrame(message, this.#maxFrameSize));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
for (const item of inFlight) {
|
|
516
|
+
item.resolve();
|
|
517
|
+
}
|
|
518
|
+
inFlight = [];
|
|
519
|
+
}
|
|
520
|
+
} catch (error) {
|
|
521
|
+
for (const item of [...inFlight, ...this.#unsent.splice(0)]) {
|
|
522
|
+
item.reject(error);
|
|
523
|
+
}
|
|
524
|
+
} finally {
|
|
525
|
+
this.#flushing = false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Removes the longest run of queued messages from the front that fits one datagram, at least
|
|
531
|
+
* one if the head fits alone; empty if the head can only go on the stream.
|
|
532
|
+
*/
|
|
533
|
+
#takeBatch() {
|
|
534
|
+
let room = this.#flow.frameRoom;
|
|
535
|
+
let count = 0;
|
|
536
|
+
while (count < this.#unsent.length) {
|
|
537
|
+
const { message } = this.#unsent[count];
|
|
538
|
+
const size = messageFrameSize(message);
|
|
539
|
+
if (message.size > this.#maxFrameSize || size > room) {
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
room -= size;
|
|
543
|
+
count += 1;
|
|
544
|
+
}
|
|
545
|
+
return this.#unsent.splice(0, count);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Runs `operation(issued)` after the previous send or close of an optimistic call has written,
|
|
550
|
+
* so they reach the stream in call order even when one waits; `operation` calls `issued` once
|
|
551
|
+
* it has written.
|
|
552
|
+
*/
|
|
553
|
+
#inOrder(operation) {
|
|
554
|
+
const previous = this.#ordered;
|
|
555
|
+
let issued;
|
|
556
|
+
this.#ordered = new Promise((resolve) => {
|
|
557
|
+
issued = resolve;
|
|
558
|
+
});
|
|
559
|
+
return previous.then(() => operation(issued)).finally(issued);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Sends a request of an optimistic call. Until the server decides, the request is kept for a
|
|
564
|
+
* replay; one that does not fit waits for the decision. A request written to a stream the
|
|
565
|
+
* server then refused does not fail: the replay sends it again.
|
|
566
|
+
*/
|
|
567
|
+
async #sendOptimistic(body, issued) {
|
|
568
|
+
let copies;
|
|
569
|
+
for (;;) {
|
|
570
|
+
if (this.#uploadEnded !== undefined) {
|
|
571
|
+
throw this.#uploadEnded;
|
|
572
|
+
}
|
|
573
|
+
if (this.#replaying !== undefined) {
|
|
574
|
+
await this.#replaying;
|
|
575
|
+
}
|
|
576
|
+
if (this.#copies === undefined) {
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
if (this.#copies.keep(body)) {
|
|
580
|
+
copies = this.#copies;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
await this.#copies.decided;
|
|
584
|
+
}
|
|
585
|
+
const writing = this.#write(encodeOutgoingFrame(this.#encode(body), this.#maxFrameSize));
|
|
586
|
+
issued();
|
|
587
|
+
await this.#survivingRefusal(writing, copies);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Waits for a write of an optimistic call. If it fails while the server's decision was pending
|
|
592
|
+
* and the server then refused the call, the replay covers it: this waits for the replay instead.
|
|
593
|
+
*/
|
|
594
|
+
async #survivingRefusal(writing, copies) {
|
|
595
|
+
try {
|
|
596
|
+
return await writing;
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (copies !== undefined && (await copies.decided) === "refused") {
|
|
599
|
+
await this.#replaying;
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
157
604
|
}
|
|
158
605
|
|
|
159
606
|
/** Finishes the request stream gracefully. */
|
|
@@ -162,30 +609,59 @@ export class Call {
|
|
|
162
609
|
return Promise.resolve();
|
|
163
610
|
}
|
|
164
611
|
this.#sendClosed = true;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
612
|
+
if (this.#optimistic) {
|
|
613
|
+
return this.#inOrder(async (issued) => {
|
|
614
|
+
if (this.#replaying !== undefined) {
|
|
615
|
+
// A replay in progress ends the new stream itself; after one, the stream is ended here.
|
|
616
|
+
issued();
|
|
617
|
+
await this.#replaying;
|
|
618
|
+
if (!this.#closeCompleted) {
|
|
619
|
+
await this.#upload(this.#writer.close());
|
|
620
|
+
this.#closeCompleted = true;
|
|
621
|
+
}
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const copies = this.#copies;
|
|
625
|
+
const closing = this.#upload(this.#writer.close());
|
|
626
|
+
issued();
|
|
627
|
+
await this.#survivingRefusal(closing, copies);
|
|
628
|
+
this.#closeCompleted = true;
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
// Datagram sends in flight go first, so the stream does not end before them.
|
|
632
|
+
const closing = this.#sendDatagrams
|
|
633
|
+
? this.#sendTail.then(() => this.#writer.close())
|
|
634
|
+
: this.#writer.close();
|
|
635
|
+
return this.#upload(closing).then(() => {
|
|
636
|
+
this.#closeCompleted = true;
|
|
637
|
+
});
|
|
171
638
|
}
|
|
172
639
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
640
|
+
/**
|
|
641
|
+
* Reads the next known response frame as `{ type, payload }`, or null if the stream ended
|
|
642
|
+
* cleanly. Unknown frames are skipped.
|
|
643
|
+
*/
|
|
644
|
+
readFrame() {
|
|
645
|
+
return this.#guard(this.#frames.readFrame(this.#maxFrameSize));
|
|
176
646
|
}
|
|
177
647
|
|
|
178
648
|
/**
|
|
179
|
-
* Ends the call after its
|
|
180
|
-
*
|
|
649
|
+
* Ends the call after its final status was received: `error` for a failed call, undefined for
|
|
650
|
+
* an OK one. An unfinished upload stops: pending and later sends fail with `error`, or with a
|
|
651
|
+
* Cancelled status saying the server completed the call early, and the request side is reset.
|
|
652
|
+
* A request side the caller already closed is left to finish, since the server either read all
|
|
653
|
+
* of it or stops it after its STATUS (see the wire format): resetting it would only cost the
|
|
654
|
+
* transport, and a browser's WebTransport a round trip to its network service, on every call.
|
|
655
|
+
* The rest of the response stream is drained in the background: unknown frames are skipped,
|
|
656
|
+
* and a known frame after STATUS cancels the stream without changing the outcome.
|
|
181
657
|
*/
|
|
182
|
-
finish() {
|
|
658
|
+
finish(error) {
|
|
183
659
|
this.#scope.settle();
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
this.#
|
|
660
|
+
this.#releaseSessionState();
|
|
661
|
+
if (!this.#sendClosed || this.#uploadEnded !== undefined) {
|
|
662
|
+
this.#endUpload(error ?? cancelled(EarlyOk), cancelled("RPC completed"));
|
|
187
663
|
}
|
|
188
|
-
this.#frames.expectEnd().catch(() => this.#cancelReader().catch(() => {}));
|
|
664
|
+
this.#frames.expectEnd(this.#maxFrameSize).catch(() => this.#cancelReader().catch(() => {}));
|
|
189
665
|
}
|
|
190
666
|
|
|
191
667
|
/** Cancels the call on behalf of the caller; any reason becomes a Cancelled status. */
|
|
@@ -198,8 +674,66 @@ export class Call {
|
|
|
198
674
|
return this.#scope.abort(failureStatus(error));
|
|
199
675
|
}
|
|
200
676
|
|
|
201
|
-
|
|
202
|
-
|
|
677
|
+
#write(bytes) {
|
|
678
|
+
return this.#upload(this.#writer.write(bytes));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Waits for a write or close of the request side. Ending the upload settles it at once: a
|
|
683
|
+
* transport blocked on flow control does not act on an abort while the write is in flight.
|
|
684
|
+
*/
|
|
685
|
+
#upload(operation) {
|
|
686
|
+
const writer = this.#writer;
|
|
687
|
+
return new Promise((resolve, reject) => {
|
|
688
|
+
this.#pendingUploads.add(reject);
|
|
689
|
+
operation.then(
|
|
690
|
+
(value) => {
|
|
691
|
+
this.#pendingUploads.delete(reject);
|
|
692
|
+
if (this.signal.aborted) {
|
|
693
|
+
reject(this.signal.reason);
|
|
694
|
+
} else {
|
|
695
|
+
resolve(value);
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
(error) => {
|
|
699
|
+
this.#pendingUploads.delete(reject);
|
|
700
|
+
reject(this.#uploadError(error, writer));
|
|
701
|
+
},
|
|
702
|
+
);
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Maps a failed request-side operation on `writer` to the call's error. Only the stream the call
|
|
708
|
+
* still writes records the upload failure; a replayed call's old stream does not.
|
|
709
|
+
*/
|
|
710
|
+
#uploadError(error, writer) {
|
|
711
|
+
if (this.signal.aborted) {
|
|
712
|
+
return this.signal.reason;
|
|
713
|
+
}
|
|
714
|
+
if (error === this.#uploadEnded) {
|
|
715
|
+
return error;
|
|
716
|
+
}
|
|
717
|
+
if (writer !== this.#writer) {
|
|
718
|
+
return statusFromTransportError(error);
|
|
719
|
+
}
|
|
720
|
+
this.#uploadFailure ??= statusFromTransportError(error);
|
|
721
|
+
return this.#uploadFailure;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Stops the request side with `error` for everything waiting on it; only the first call counts. */
|
|
725
|
+
#endUpload(error, abortReason = error) {
|
|
726
|
+
if (this.#uploadEnded !== undefined) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
this.#uploadEnded = error;
|
|
730
|
+
this.#sendClosed = true;
|
|
731
|
+
this.#writer.abort(abortReason).catch(() => {});
|
|
732
|
+
const pending = [...this.#pendingUploads];
|
|
733
|
+
this.#pendingUploads.clear();
|
|
734
|
+
for (const reject of pending) {
|
|
735
|
+
reject(error);
|
|
736
|
+
}
|
|
203
737
|
}
|
|
204
738
|
|
|
205
739
|
async #guard(operation) {
|
|
@@ -214,14 +748,30 @@ export class Call {
|
|
|
214
748
|
return result;
|
|
215
749
|
}
|
|
216
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Ends the datagram flow and frees the stream compression contexts, if any; every way a call
|
|
753
|
+
* ends gets here.
|
|
754
|
+
*/
|
|
755
|
+
#releaseSessionState() {
|
|
756
|
+
this.#flow?.release();
|
|
757
|
+
this.#streams?.close();
|
|
758
|
+
this.#settleCopies("ended");
|
|
759
|
+
}
|
|
760
|
+
|
|
217
761
|
#teardown() {
|
|
762
|
+
this.#releaseSessionState();
|
|
218
763
|
const reason = this.signal.reason;
|
|
219
|
-
this.#
|
|
764
|
+
this.#endUpload(reason);
|
|
220
765
|
this.#cancelReader(reason).catch(() => {});
|
|
221
766
|
}
|
|
222
767
|
}
|
|
223
768
|
|
|
224
|
-
|
|
769
|
+
/**
|
|
770
|
+
* Opens a bidirectional stream. A call whose setup depends on its session passes `session`, which
|
|
771
|
+
* receives that session's `key` and `datagrams` object, so the compression state and datagram flow
|
|
772
|
+
* belong to the connection that carries the call and not to one a reconnect swapped in later.
|
|
773
|
+
*/
|
|
774
|
+
function openStream(transport, signal, session) {
|
|
225
775
|
return new Promise((resolve, reject) => {
|
|
226
776
|
if (signal.aborted) {
|
|
227
777
|
reject(signal.reason);
|
|
@@ -232,7 +782,7 @@ function openStream(transport, signal) {
|
|
|
232
782
|
|
|
233
783
|
let opening;
|
|
234
784
|
try {
|
|
235
|
-
opening = Promise.resolve(transport
|
|
785
|
+
opening = Promise.resolve(startStream(transport, session));
|
|
236
786
|
} catch (error) {
|
|
237
787
|
opening = Promise.reject(error);
|
|
238
788
|
}
|
|
@@ -255,6 +805,32 @@ function openStream(transport, signal) {
|
|
|
255
805
|
});
|
|
256
806
|
}
|
|
257
807
|
|
|
808
|
+
function messageOf(item) {
|
|
809
|
+
return item.message;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** A request message body as an uncompressed outgoing message, or undefined. */
|
|
813
|
+
function outgoing(body) {
|
|
814
|
+
return body === undefined ? undefined : encodeMessage(body);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function startStream(transport, session) {
|
|
818
|
+
if (session === undefined) {
|
|
819
|
+
return transport.createBidirectionalStream();
|
|
820
|
+
}
|
|
821
|
+
if (typeof transport.createBidirectionalStreamWithSession === "function") {
|
|
822
|
+
return transport.createBidirectionalStreamWithSession().then(({ stream, session: opened }) => {
|
|
823
|
+
session.key = opened;
|
|
824
|
+
session.datagrams = opened.datagrams;
|
|
825
|
+
return stream;
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
// A WebTransport session used directly: the stream opened right now belongs to it.
|
|
829
|
+
session.key = transport;
|
|
830
|
+
session.datagrams = transport.datagrams;
|
|
831
|
+
return transport.createBidirectionalStream();
|
|
832
|
+
}
|
|
833
|
+
|
|
258
834
|
function throwIfAborted(signal) {
|
|
259
835
|
if (signal.aborted) {
|
|
260
836
|
throw signal.reason;
|