@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/src/call.js CHANGED
@@ -1,4 +1,18 @@
1
- import { DefaultMaxFrameSize, FrameReader, encodeFrame, encodeMessageFrame } from "./framing.js";
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 { RpcRequest, WireVersion } from "./wire.js";
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 request header. The request carries service,
114
- * method, kind and body; version, metadata and the wire timeout come from the options.
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 header = encodeFrame(
118
- RpcRequest,
119
- { ...request, version: WireVersion, metadata: options.metadata, ...timeoutField(options) },
120
- options.maxFrameSize,
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 stream = await openStream(transport, scope.signal);
125
- const call = new Call(stream, scope, options.maxFrameSize);
126
- await call.#write(header);
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
- /** Sends one streaming request message; writes are ordered and apply backpressure. */
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
- return this.#write(encodeMessageFrame(body, this.#maxFrameSize));
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
- return this.#guard(this.#writer.close());
166
- }
167
-
168
- /** Reads the unary response frame. */
169
- readFrame(messageType) {
170
- return this.#guard(this.#frames.readFrame(messageType, this.#maxFrameSize));
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
- /** Reads the next response stream frame, or null if the stream ended cleanly. */
174
- readStreamFrame() {
175
- return this.#guard(this.#frames.readStreamFrame(this.#maxFrameSize));
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 outcome (a final response or status) was received. An unfinished
180
- * request side is reset, and the rest of the response stream is drained in the background.
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
- if (!this.#sendClosed) {
185
- this.#sendClosed = true;
186
- this.#writer.abort(cancelled("RPC completed")).catch(() => {});
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
- async #write(bytes) {
202
- await this.#guard(this.#writer.write(bytes));
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.#writer.abort(reason).catch(() => {});
764
+ this.#endUpload(reason);
220
765
  this.#cancelReader(reason).catch(() => {});
221
766
  }
222
767
  }
223
768
 
224
- function openStream(transport, signal) {
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.createBidirectionalStream());
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;