@trevrpc/trevrpc-js 1.1.0 → 1.3.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 +34 -5
- package/package.json +12 -4
- package/src/call.js +401 -58
- package/src/channel.js +47 -10
- package/src/client.js +93 -22
- package/src/compression.js +330 -0
- package/src/connection-metadata.js +114 -0
- package/src/datagram.js +48 -27
- package/src/framing.js +23 -6
- package/src/generator.js +134 -14
- package/src/index.d.ts +75 -14
- package/src/qmux/session.js +2 -1
- package/src/stream-compression.js +298 -0
- package/src/wire.js +43 -11
- package/src/zstd.d.ts +35 -0
- package/src/zstd.js +407 -0
package/src/call.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
encodeMessage,
|
|
3
|
+
receivedMessage,
|
|
4
|
+
sessionCompression,
|
|
5
|
+
transportCompression,
|
|
6
|
+
} from "./compression.js";
|
|
7
|
+
import { transportConnectionMetadata } from "./connection-metadata.js";
|
|
1
8
|
import { datagramEndpoint, messageFrameSize } from "./datagram.js";
|
|
2
9
|
import {
|
|
3
10
|
DefaultMaxFrameSize,
|
|
4
11
|
FrameReader,
|
|
5
|
-
|
|
12
|
+
FrameType,
|
|
13
|
+
encodeOutgoingFrame,
|
|
6
14
|
encodeRequestFrame,
|
|
7
15
|
} from "./framing.js";
|
|
8
16
|
import { normalizeMetadata, validateMetadata } from "./metadata.js";
|
|
@@ -12,10 +20,12 @@ import {
|
|
|
12
20
|
cancelled,
|
|
13
21
|
deadlineExceeded,
|
|
14
22
|
failedPrecondition,
|
|
23
|
+
internal,
|
|
15
24
|
invalidArgument,
|
|
16
25
|
statusError,
|
|
17
26
|
statusFromTransportError,
|
|
18
27
|
} from "./status.js";
|
|
28
|
+
import { CallStreams, ReplayCopies, streamBudget } from "./stream-compression.js";
|
|
19
29
|
|
|
20
30
|
/** Longest delay setTimeout can represent; it fires immediately beyond this. */
|
|
21
31
|
const MaxTimerDelayMs = 2_147_483_647;
|
|
@@ -106,6 +116,9 @@ class CallScope {
|
|
|
106
116
|
* call's cancellation scope. Transport failures surface as TrevRpcError, and once the call is
|
|
107
117
|
* aborted every operation fails with the abort reason.
|
|
108
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
|
+
|
|
109
122
|
export class Call {
|
|
110
123
|
#scope;
|
|
111
124
|
#writer;
|
|
@@ -128,66 +141,272 @@ export class Call {
|
|
|
128
141
|
#flow;
|
|
129
142
|
/** Whether request messages are sent as datagrams when they fit. */
|
|
130
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;
|
|
131
163
|
/** Request messages of a call that uses datagrams, queued for the flush loop, in order. */
|
|
132
164
|
#unsent = [];
|
|
133
165
|
/** Settles when the flush loop has sent everything queued so far. */
|
|
134
166
|
#sendTail = Promise.resolve();
|
|
135
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;
|
|
136
185
|
|
|
137
186
|
/**
|
|
138
187
|
* Opens a stream on the transport and sends the REQUEST frame. The request carries the
|
|
139
188
|
* method path and kind; metadata and the wire timeout come from the options. The request
|
|
140
|
-
* message of a unary or server-streaming call goes out in the same write.
|
|
189
|
+
* message of a unary or server-streaming call, a marshalled body, goes out in the same write.
|
|
141
190
|
*
|
|
142
|
-
* `
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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.
|
|
146
200
|
*/
|
|
147
|
-
static async open(transport, request, message, options,
|
|
201
|
+
static async open(transport, request, message, options, rpc = {}) {
|
|
148
202
|
const fields = { ...request, metadata: options.metadata, ...timeoutField(options) };
|
|
149
|
-
const
|
|
150
|
-
|
|
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);
|
|
151
213
|
const scope = new CallScope(options);
|
|
152
214
|
let call;
|
|
153
215
|
try {
|
|
154
|
-
const session =
|
|
216
|
+
const session = perSession ? {} : undefined;
|
|
155
217
|
const stream = await openStream(transport, scope.signal, session);
|
|
156
218
|
call = new Call(stream, scope, options.maxFrameSize);
|
|
157
|
-
|
|
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
|
+
);
|
|
158
228
|
return call;
|
|
159
229
|
} catch (error) {
|
|
160
230
|
if (call !== undefined) {
|
|
161
|
-
call.#
|
|
231
|
+
call.#releaseSessionState();
|
|
162
232
|
}
|
|
163
233
|
throw scope.abort(failureStatus(error));
|
|
164
234
|
}
|
|
165
235
|
}
|
|
166
236
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
}
|
|
171
288
|
return encodeRequestFrame(
|
|
172
|
-
|
|
173
|
-
message,
|
|
289
|
+
request,
|
|
290
|
+
message === undefined ? undefined : this.#encode(message),
|
|
174
291
|
this.#maxFrameSize,
|
|
175
292
|
);
|
|
176
293
|
}
|
|
177
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
|
+
|
|
178
406
|
constructor(stream, scope, maxFrameSize) {
|
|
179
|
-
const reader = stream.readable.getReader();
|
|
180
407
|
this.#scope = scope;
|
|
181
|
-
this.#writer = stream.writable.getWriter();
|
|
182
|
-
this.#cancelReader = (reason) => reader.cancel(reason);
|
|
183
|
-
this.#frames = new FrameReader(reader);
|
|
184
408
|
this.#maxFrameSize = maxFrameSize;
|
|
185
|
-
|
|
186
|
-
this.#writer.closed.catch((error) => {
|
|
187
|
-
if (this.#uploadEnded === undefined) {
|
|
188
|
-
this.#uploadFailure ??= statusFromTransportError(error);
|
|
189
|
-
}
|
|
190
|
-
});
|
|
409
|
+
this.#attach(stream);
|
|
191
410
|
scope.signal.addEventListener("abort", () => this.#teardown(), { once: true });
|
|
192
411
|
if (scope.signal.aborted) {
|
|
193
412
|
this.#teardown();
|
|
@@ -207,6 +426,21 @@ export class Call {
|
|
|
207
426
|
return this.#uploadFailure;
|
|
208
427
|
}
|
|
209
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
|
+
|
|
210
444
|
/** Whether the request side can still be written: not ended, not closed. */
|
|
211
445
|
get uploadOpen() {
|
|
212
446
|
return this.#uploadEnded === undefined && !this.#closeCompleted;
|
|
@@ -235,10 +469,14 @@ export class Call {
|
|
|
235
469
|
if (this.#sendClosed) {
|
|
236
470
|
return Promise.reject(failedPrecondition("request stream is closed"));
|
|
237
471
|
}
|
|
472
|
+
if (this.#optimistic) {
|
|
473
|
+
return this.#inOrder((issued) => this.#sendOptimistic(body, issued));
|
|
474
|
+
}
|
|
475
|
+
const message = this.#encode(body);
|
|
238
476
|
if (this.#sendDatagrams) {
|
|
239
477
|
return this.#upload(
|
|
240
478
|
new Promise((resolve, reject) => {
|
|
241
|
-
this.#unsent.push({
|
|
479
|
+
this.#unsent.push({ message, resolve, reject });
|
|
242
480
|
if (!this.#flushing) {
|
|
243
481
|
this.#flushing = true;
|
|
244
482
|
// Messages sent in the same turn are packed together; nothing waits for a timer.
|
|
@@ -247,7 +485,7 @@ export class Call {
|
|
|
247
485
|
}),
|
|
248
486
|
);
|
|
249
487
|
}
|
|
250
|
-
return this.#write(
|
|
488
|
+
return this.#write(encodeOutgoingFrame(message, this.#maxFrameSize));
|
|
251
489
|
}
|
|
252
490
|
|
|
253
491
|
/**
|
|
@@ -264,11 +502,14 @@ export class Call {
|
|
|
264
502
|
throw this.#uploadEnded;
|
|
265
503
|
}
|
|
266
504
|
inFlight = this.#takeBatch();
|
|
267
|
-
if (
|
|
505
|
+
if (
|
|
506
|
+
inFlight.length === 0 ||
|
|
507
|
+
!(await this.#flow.send(inFlight.map(messageOf), this.signal))
|
|
508
|
+
) {
|
|
268
509
|
// Not a datagram: a head message that never fits, or a datagram that failed to send.
|
|
269
510
|
inFlight = inFlight.length > 0 ? inFlight : [this.#unsent.shift()];
|
|
270
|
-
for (const {
|
|
271
|
-
await this.#writer.write(
|
|
511
|
+
for (const { message } of inFlight) {
|
|
512
|
+
await this.#writer.write(encodeOutgoingFrame(message, this.#maxFrameSize));
|
|
272
513
|
}
|
|
273
514
|
}
|
|
274
515
|
for (const item of inFlight) {
|
|
@@ -293,9 +534,9 @@ export class Call {
|
|
|
293
534
|
let room = this.#flow.frameRoom;
|
|
294
535
|
let count = 0;
|
|
295
536
|
while (count < this.#unsent.length) {
|
|
296
|
-
const {
|
|
297
|
-
const size = messageFrameSize(
|
|
298
|
-
if (
|
|
537
|
+
const { message } = this.#unsent[count];
|
|
538
|
+
const size = messageFrameSize(message);
|
|
539
|
+
if (message.size > this.#maxFrameSize || size > room) {
|
|
299
540
|
break;
|
|
300
541
|
}
|
|
301
542
|
room -= size;
|
|
@@ -304,12 +545,89 @@ export class Call {
|
|
|
304
545
|
return this.#unsent.splice(0, count);
|
|
305
546
|
}
|
|
306
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
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
307
606
|
/** Finishes the request stream gracefully. */
|
|
308
607
|
closeSend() {
|
|
309
608
|
if (this.#sendClosed) {
|
|
310
609
|
return Promise.resolve();
|
|
311
610
|
}
|
|
312
611
|
this.#sendClosed = true;
|
|
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
|
+
}
|
|
313
631
|
// Datagram sends in flight go first, so the stream does not end before them.
|
|
314
632
|
const closing = this.#sendDatagrams
|
|
315
633
|
? this.#sendTail.then(() => this.#writer.close())
|
|
@@ -328,15 +646,21 @@ export class Call {
|
|
|
328
646
|
}
|
|
329
647
|
|
|
330
648
|
/**
|
|
331
|
-
* Ends the call after its final status was received
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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.
|
|
335
657
|
*/
|
|
336
|
-
finish(
|
|
658
|
+
finish(error) {
|
|
337
659
|
this.#scope.settle();
|
|
338
|
-
this.#
|
|
339
|
-
this.#
|
|
660
|
+
this.#releaseSessionState();
|
|
661
|
+
if (!this.#sendClosed || this.#uploadEnded !== undefined) {
|
|
662
|
+
this.#endUpload(error ?? cancelled(EarlyOk), cancelled("RPC completed"));
|
|
663
|
+
}
|
|
340
664
|
this.#frames.expectEnd(this.#maxFrameSize).catch(() => this.#cancelReader().catch(() => {}));
|
|
341
665
|
}
|
|
342
666
|
|
|
@@ -359,6 +683,7 @@ export class Call {
|
|
|
359
683
|
* transport blocked on flow control does not act on an abort while the write is in flight.
|
|
360
684
|
*/
|
|
361
685
|
#upload(operation) {
|
|
686
|
+
const writer = this.#writer;
|
|
362
687
|
return new Promise((resolve, reject) => {
|
|
363
688
|
this.#pendingUploads.add(reject);
|
|
364
689
|
operation.then(
|
|
@@ -372,20 +697,26 @@ export class Call {
|
|
|
372
697
|
},
|
|
373
698
|
(error) => {
|
|
374
699
|
this.#pendingUploads.delete(reject);
|
|
375
|
-
reject(this.#uploadError(error));
|
|
700
|
+
reject(this.#uploadError(error, writer));
|
|
376
701
|
},
|
|
377
702
|
);
|
|
378
703
|
});
|
|
379
704
|
}
|
|
380
705
|
|
|
381
|
-
/**
|
|
382
|
-
|
|
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) {
|
|
383
711
|
if (this.signal.aborted) {
|
|
384
712
|
return this.signal.reason;
|
|
385
713
|
}
|
|
386
714
|
if (error === this.#uploadEnded) {
|
|
387
715
|
return error;
|
|
388
716
|
}
|
|
717
|
+
if (writer !== this.#writer) {
|
|
718
|
+
return statusFromTransportError(error);
|
|
719
|
+
}
|
|
389
720
|
this.#uploadFailure ??= statusFromTransportError(error);
|
|
390
721
|
return this.#uploadFailure;
|
|
391
722
|
}
|
|
@@ -417,13 +748,18 @@ export class Call {
|
|
|
417
748
|
return result;
|
|
418
749
|
}
|
|
419
750
|
|
|
420
|
-
/**
|
|
421
|
-
|
|
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() {
|
|
422
756
|
this.#flow?.release();
|
|
757
|
+
this.#streams?.close();
|
|
758
|
+
this.#settleCopies("ended");
|
|
423
759
|
}
|
|
424
760
|
|
|
425
761
|
#teardown() {
|
|
426
|
-
this.#
|
|
762
|
+
this.#releaseSessionState();
|
|
427
763
|
const reason = this.signal.reason;
|
|
428
764
|
this.#endUpload(reason);
|
|
429
765
|
this.#cancelReader(reason).catch(() => {});
|
|
@@ -431,9 +767,9 @@ export class Call {
|
|
|
431
767
|
}
|
|
432
768
|
|
|
433
769
|
/**
|
|
434
|
-
* Opens a bidirectional stream. A call
|
|
435
|
-
*
|
|
436
|
-
* that carries the call and not to one a reconnect swapped in later.
|
|
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.
|
|
437
773
|
*/
|
|
438
774
|
function openStream(transport, signal, session) {
|
|
439
775
|
return new Promise((resolve, reject) => {
|
|
@@ -469,21 +805,28 @@ function openStream(transport, signal, session) {
|
|
|
469
805
|
});
|
|
470
806
|
}
|
|
471
807
|
|
|
472
|
-
function
|
|
473
|
-
return item.
|
|
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);
|
|
474
815
|
}
|
|
475
816
|
|
|
476
817
|
function startStream(transport, session) {
|
|
477
818
|
if (session === undefined) {
|
|
478
819
|
return transport.createBidirectionalStream();
|
|
479
820
|
}
|
|
480
|
-
if (typeof transport.
|
|
481
|
-
return transport.
|
|
482
|
-
session.
|
|
821
|
+
if (typeof transport.createBidirectionalStreamWithSession === "function") {
|
|
822
|
+
return transport.createBidirectionalStreamWithSession().then(({ stream, session: opened }) => {
|
|
823
|
+
session.key = opened;
|
|
824
|
+
session.datagrams = opened.datagrams;
|
|
483
825
|
return stream;
|
|
484
826
|
});
|
|
485
827
|
}
|
|
486
|
-
// A WebTransport session used directly:
|
|
828
|
+
// A WebTransport session used directly: the stream opened right now belongs to it.
|
|
829
|
+
session.key = transport;
|
|
487
830
|
session.datagrams = transport.datagrams;
|
|
488
831
|
return transport.createBidirectionalStream();
|
|
489
832
|
}
|