@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/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
- encodeMessageFrame,
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
- * `datagrams` is `{ send, receive }` for a method that marks a direction unreliable: the call
143
- * gets a datagram flow if the session that carries it has datagrams, which is only known once
144
- * its stream is open, so the REQUEST is encoded after that. `send` makes it send request
145
- * messages as datagrams, `receive` makes it accept response datagrams.
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, datagrams) {
201
+ static async open(transport, request, message, options, rpc = {}) {
148
202
  const fields = { ...request, metadata: options.metadata, ...timeoutField(options) };
149
- const header =
150
- datagrams === undefined ? encodeRequestFrame(fields, message, options.maxFrameSize) : null;
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 = datagrams === undefined ? undefined : {};
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
- await call.#write(header ?? call.#requestWithFlow(fields, message, session, datagrams));
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.#releaseFlow();
231
+ call.#releaseSessionState();
162
232
  }
163
233
  throw scope.abort(failureStatus(error));
164
234
  }
165
235
  }
166
236
 
167
- /** Opens the call's flow on its session, if it has datagrams, and encodes the REQUEST. */
168
- #requestWithFlow(fields, message, session, { send, receive }) {
169
- this.#flow = datagramEndpoint(session.datagrams)?.openFlow({ receive });
170
- this.#sendDatagrams = this.#flow !== undefined && send;
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
- this.#flow === undefined ? fields : { ...fields, datagramFlow: this.#flow.id },
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
- // The transport errors the request side as soon as the server stops reading it.
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({ body, resolve, reject });
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(encodeMessageFrame(body, this.#maxFrameSize));
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 (inFlight.length === 0 || !(await this.#flow.send(inFlight.map(bodyOf), this.signal))) {
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 { body } of inFlight) {
271
- await this.#writer.write(encodeMessageFrame(body, this.#maxFrameSize));
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 { body } = this.#unsent[count];
297
- const size = messageFrameSize(body);
298
- if (body.byteLength > this.#maxFrameSize || size > room) {
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. The request side stops: pending and later
332
- * sends fail with `uploadError`, and an unfinished upload is reset. The rest of the response
333
- * stream is drained in the background: unknown frames are skipped, and a known frame after
334
- * STATUS cancels the stream without changing the outcome.
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(uploadError) {
658
+ finish(error) {
337
659
  this.#scope.settle();
338
- this.#releaseFlow();
339
- this.#endUpload(uploadError, cancelled("RPC completed"));
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
- /** Maps a failed request-side operation to the call's error. */
382
- #uploadError(error) {
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
- /** Ends the datagram flow, if any; every way a call ends gets here. */
421
- #releaseFlow() {
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.#releaseFlow();
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 that may use datagrams passes `session`, which receives the
435
- * datagram object of the session that opened the stream, so the flow belongs to the connection
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 bodyOf(item) {
473
- return item.body;
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.createBidirectionalStreamWithDatagrams === "function") {
481
- return transport.createBidirectionalStreamWithDatagrams().then(({ stream, datagrams }) => {
482
- session.datagrams = datagrams;
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: its datagrams belong to the stream opened right now.
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
  }