@trevrpc/trevrpc-js 1.0.0 → 1.1.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # trevrpc-js
2
2
 
3
- TrevRPC is an RPC framework like gRPC, but uses QUIC (and HTTP/3 / WebTransport) instead of HTTP/2. Define services in protobuf, generate typed clients, and call Go or Rust servers over WebTransport.
3
+ TrevRPC is an RPC framework like gRPC, but uses QUIC (and HTTP/3 / WebTransport) instead of HTTP/2. Define services in protobuf, generate typed clients, and call Go or Rust servers over WebTransport, falling back to QMux over WebSocket where WebTransport is missing or UDP is blocked.
4
4
 
5
- `trevrpc-js` is the **browser client**: a WebTransport runtime plus `protoc-gen-trevrpc-js`, which generates typed clients (no server code). It has no Node.js runtime; servers are written in Go or Rust, native QUIC clients in Go or Rust, and HTTP/3 clients in Kotlin.
5
+ `trevrpc-js` is the **browser client**: a WebTransport runtime with a QMux over WebSocket fallback, plus `protoc-gen-trevrpc-js`, which generates typed clients (no server code). It has no Node.js runtime; servers are written in Go or Rust, native QUIC clients in Go or Rust, and HTTP/3 clients in Kotlin.
6
6
 
7
7
  Full documentation: https://trev.zip/llc/TrevRPC/wiki
8
8
 
@@ -46,6 +46,7 @@ This writes `greeter.trevrpc.js` and `greeter.trevrpc.d.ts`. Plugin parameters (
46
46
 
47
47
  ```js
48
48
  import { connect } from "@trevrpc/trevrpc-js";
49
+
49
50
  import { GreeterClient } from "./gen/greeter.trevrpc.js";
50
51
 
51
52
  const channel = await connect("https://example.com:4433/trevrpc");
@@ -64,6 +65,11 @@ await upload.send({ name: "Alice" });
64
65
  await upload.send({ name: "Bob" });
65
66
  const summary = await upload.closeAndRecv();
66
67
 
68
+ // Client streaming from an iterable or async iterable
69
+ const batch = await client.lotsOfGreetings();
70
+ await batch.sendAll([{ name: "Alice" }, { name: "Bob" }]);
71
+ const batchSummary = await batch.closeAndRecv();
72
+
67
73
  // Bidirectional streaming
68
74
  const chat = await client.bidiHello();
69
75
  await chat.send({ name: "Alice" });
@@ -78,12 +84,14 @@ channel.close();
78
84
 
79
85
  Every method takes `CallOptions` as its last argument; the same options can be given to the client constructor as defaults, and per-call `metadata` is merged over the defaults per key.
80
86
 
81
- | Option | Meaning |
82
- | -------------- | --------------------------------------------------------------------------------------------- |
83
- | `timeoutMs` | Deadline for the whole call, from the request to the final status. Also sent to the server. |
84
- | `signal` | `AbortSignal` that cancels the call. |
85
- | `metadata` | Request metadata (`{ key: "value" }` or a `Map`); keys are lowercase, `trevrpc-` is reserved. |
86
- | `maxFrameSize` | Largest frame accepted or sent in bytes (default 4 MiB). |
87
+ | Option | Meaning |
88
+ | -------------------- | --------------------------------------------------------------------------------------------- |
89
+ | `timeoutMs` | Deadline for the whole call, from the request to the final status. Also sent to the server. |
90
+ | `signal` | `AbortSignal` that cancels the call. |
91
+ | `metadata` | Request metadata (`{ key: "value" }` or a `Map`); keys are lowercase, `trevrpc-` is reserved. |
92
+ | `maxFrameSize` | Largest frame accepted or sent in bytes (default 4 MiB). |
93
+ | `onResponseHeaders` | Called with the response headers. |
94
+ | `onResponseMetadata` | Called with the trailing metadata of a successful call. |
87
95
 
88
96
  The client enforces no other response limits: there is no cap on the number of streamed
89
97
  messages, their total size, or idle time between them. Use `timeoutMs` or `signal` to bound a
@@ -93,12 +101,22 @@ call.
93
101
 
94
102
  - Failed calls throw `TrevRpcError` with a `code` (`Code.NotFound`, ...), `statusMessage` and `metadata`. A deadline surfaces as `Code.DeadlineExceeded`, a cancelled call as `Code.Cancelled` (with the abort reason as `cause`), and a lost connection as `Code.Unavailable`.
95
103
  - Streaming responses end with the server's final status: iteration finishes after `OK` and throws the `TrevRpcError` otherwise. `stream.status` settles with the same outcome and carries trailing metadata. Leaving a `for await` loop early, or calling `close()`, cancels the call.
96
- - Unary metadata is available through `...WithResponse(request)` and `closeAndRecvWithResponse()`.
97
- - The server's response decides the outcome of a call: if it ends a call early (for instance with an error), the final status wins over errors from sending the rest of the request.
104
+ - Response headers and trailing metadata are separate. Every call reports them to the `onResponseHeaders(headers)` and `onResponseMetadata(metadata)` callbacks of its options: the headers (empty when the server sent none) as soon as the first response frame arrives, and the trailing metadata of the final status when the call succeeds, before its result is returned or its stream ends. A failed call carries its metadata on the `TrevRpcError`, and a callback that throws fails the call. Server-streaming and bidirectional calls also have a `headers` promise that resolves as soon as the first response frame arrives, so headers are available before the first message, and rejects with the call's error if the call fails first.
105
+ - Frames follow the [wire format](https://trev.zip/llc/TrevRPC/wiki/Protocol-and-Wire-Format): a type, a length and a payload, with messages sent as raw protobuf. The client skips frame types it does not know, and `maxFrameSize` limits every frame, unknown ones included.
106
+ - The server's response decides the outcome of a call, and a call reports it as soon as the final status arrives. If the server ends a call before the upload is done (client-streaming and bidirectional calls), the upload is cancelled and `send()` rejects at once, without waiting for the server to stop reading and whether or not the caller receives:
107
+ - with the server's `TrevRpcError` if its status is not OK, and the same error is the call's outcome (`closeAndRecv()`, `recv()`, `status`);
108
+ - with `Code.Cancelled` ("the server completed the call before the request stream ended") if its status is `OK`, for instance a server that answers after the first messages. The call itself succeeded: `closeAndRecv()` returns the response, and `recv()` and `status` end with `OK`;
109
+ - with the failure of the response stream, for instance `Code.Unavailable` for a lost connection, if the server stopped reading the request and the response then failed. A bare "stream closed" error from the transport is used only when the response gives no other explanation.
110
+
111
+ `send()` calls that are blocked on backpressure fail the same way, and so do all sends of a call that is cancelled or whose deadline expires. A deadline or abort that fires after the final status arrived does not change the outcome, even if the server has not finished the response stream yet.
112
+
113
+ `sendAll(requests)` sends every message of an iterable or async iterable and then closes the request stream. When the server ends the call first it stops and resolves, so `closeAndRecv()`, `recv()`, or `status` report the server's outcome instead of an upload error. Requests that throw cancel the call.
114
+
115
+ - To find the final status while the caller only sends, a client-streaming or bidirectional call reads the response ahead of `recv()` while its request side is open: at most 32 frames or 1 MiB (plus one frame of at most `maxFrameSize`) are held; beyond that reading pauses and flow control applies until `recv()` takes messages. Responses read ahead are delivered in order, including before an error that ended the stream.
98
116
 
99
117
  ### Connections
100
118
 
101
- `connect()` opens a WebTransport session and resolves when it is ready; it rejects if the session cannot be established. After that the `Channel` re-establishes the session in the background with exponential backoff whenever it is lost. Calls fail fast with `Unavailable` while `channel.state` is `"reconnecting"` and are never replayed; use `await channel.waitUntilReady()` or the `onStateChange` option to follow the state. `channel.close()` stops reconnecting.
119
+ `connect()` opens a session and resolves when it is ready; it rejects if no transport can establish one. After that the `Channel` re-establishes the session in the background with exponential backoff whenever it is lost. Calls fail fast with `Unavailable` while `channel.state` is `"reconnecting"` and are never replayed; use `await channel.waitUntilReady()` or the `onStateChange` option to follow the state. `channel.close()` stops reconnecting.
102
120
 
103
121
  ```js
104
122
  const channel = await connect(url, {
@@ -108,21 +126,42 @@ const channel = await connect(url, {
108
126
  });
109
127
  ```
110
128
 
111
- `serverCertificateHashes` trusts a self-signed ECDSA certificate that is valid for at most 14 days by its SHA-256 hash, which is how development servers and the interop tests are reached. `allowPooling`, `congestionControl` and `requireUnreliable` are passed to the `WebTransport` constructor.
129
+ `serverCertificateHashes` trusts a self-signed ECDSA certificate that is valid for at most 14 days by its SHA-256 hash, which is how development servers and the interop tests are reached. It applies to WebTransport only: the WebSocket fallback needs a certificate the browser trusts. `allowPooling`, `congestionControl` and `requireUnreliable` are passed to the `WebTransport` constructor.
130
+
131
+ `connect()` always offers the WebTransport protocol `trevrpc-1` (wire version 1) through the `protocols` constructor option; it cannot be changed. If the server selects any other protocol, that connection attempt fails with `Code.FailedPrecondition` and the channel carries on as after any failed dial: the WebSocket fallback starts, or a reconnect is retried with backoff. An empty or missing selection means version 1, because older browsers cannot negotiate protocols and the Rust server cannot echo one.
132
+
133
+ Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a generated client. Create such a session with `new WebTransport(url, { protocols: ["trevrpc-1"] })` so the server knows the wire version. Requests name the method by its full path, `/<package.Service>/<Method>` (for example `/example.greeter.Greeter/SayHello`), which the generated clients build from the service and method names.
134
+
135
+ ### WebSocket fallback
136
+
137
+ `connect()` races two transports. WebTransport starts first; QMux over WebSocket (subprotocol `qmux-02.trevrpc-1`, served by Go and Rust on the server's TCP port) starts after `fallbackDelayMs` (default 300) or immediately if WebTransport fails or the browser has no `WebTransport` API. The first session to be ready wins and the other is closed. After the WebSocket wins, reconnects use it directly until `reprobeIntervalMs` (default 5 minutes) has passed since WebTransport was lost, then race again.
138
+
139
+ ```js
140
+ const channel = await connect("https://example.com:4433/trevrpc", {
141
+ transports: ["webtransport", "websocket"], // default; the first is preferred, a single entry disables the race
142
+ webSocketUrl: "wss://example.com:4433/trevrpc", // default: `url` with the wss: scheme
143
+ fallbackDelayMs: 300,
144
+ reprobeIntervalMs: 5 * 60_000,
145
+ onStateChange: ({ state, transport }) => console.log(state, transport),
146
+ });
147
+ console.log(channel.transport); // "webtransport" or "websocket"
148
+ ```
112
149
 
113
- Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a generated client.
150
+ `channel.transport` and `ChannelStateChange.transport` (set when the channel becomes ready) report which one is in use. `WebSocket` replaces the global WebSocket constructor, like `WebTransport` does for WebTransport. The QMux client is in-tree (`src/qmux/`); WebSocket backpressure polls `bufferedAmount` against a 1 MiB limit.
114
151
 
115
152
  ## Interop client
116
153
 
117
- `trevrpc-interop-browser` runs the cross-runtime interop scenarios from [`interop/README.md`](../interop/README.md) in a real browser over WebTransport. It is built from `interop/proto/interop.proto` with the generator in this package, so it also serves as a complete example of generated client code.
154
+ `trevrpc-interop-browser` runs the cross-runtime interop scenarios from [`interop/README.md`](../interop/README.md) in a real browser over WebTransport, QMux over WebSocket, or the fallback between them. It is built from `interop/proto/interop.proto` with the generator in this package, so it also serves as a complete example of generated client code.
118
155
 
119
156
  ```sh
120
- trevrpc-interop-browser --browser chromium|firefox|webkit \
157
+ trevrpc-interop-browser --browser chromium|firefox|safari \
121
158
  --url https://127.0.0.1:4433/trevrpc \
122
- --cert-hash <hex sha256 of the server certificate DER>
159
+ --cert-hash <hex sha256 of the server certificate DER> \
160
+ [--transport webtransport|websocket|auto] [--ws-url wss://127.0.0.1:4433/trevrpc] \
161
+ [--expect-transport webtransport|websocket]
123
162
  ```
124
163
 
125
- It prints `ok <name>` or `fail <name>: <reason>` per scenario and exits 0 only if all scenarios passed. Browsers come from `PLAYWRIGHT_BROWSERS_PATH`, or from `TREVRPC_BROWSER_CHROMIUM`, `TREVRPC_BROWSER_FIREFOX` and `TREVRPC_BROWSER_WEBKIT` when those name an executable. WebKit on macOS passes against the Go server but not yet the Rust server ([h3#347](https://github.com/hyperium/h3/issues/347)); where the browser has no WebTransport at all, such as Playwright's WebKit on Linux, every scenario fails with a clear reason.
164
+ It prints `ok <name>` or `fail <name>: <reason>` per scenario and exits 0 only if all scenarios passed. Chromium and Firefox are launched through Playwright from `PLAYWRIGHT_BROWSERS_PATH`, or from `TREVRPC_BROWSER_CHROMIUM` and `TREVRPC_BROWSER_FIREFOX` when those name an executable, and ignore certificate errors because WSS cannot pin a certificate hash. Safari is driven through `safaridriver` (`TREVRPC_BROWSER_SAFARI` overrides its path), which needs `sudo safaridriver --enable` once; Safari cannot ignore certificate errors, so the server certificate must be trusted by the system, as `interop/run.py` does. The CLI serves the page from a loopback origin; the page fetches its input from there and posts each result back, so where the browser has no WebTransport at all every scenario fails with a clear reason. `--write-page <dir>` writes that page to a directory instead, and `--page-url <url> --cert <pem> --key <pem>` opens it from a TrevRPC server that serves the directory through its HTTP fallback; the page then reports to the CLI over HTTPS, at the origin in its `report` query parameter. `--page-protocol h2|h3` has the browser load that page over HTTP/2 or HTTP/3 and fails every scenario unless it did (`h3` in Chromium only).
126
165
 
127
166
  ## Development
128
167
 
@@ -135,4 +174,4 @@ npm run interop -- --browser chromium --url ... --cert-hash ...
135
174
 
136
175
  `nix build .#trevrpc-js` runs the same checks and installs the library, the plugin and the wrapped `trevrpc-interop-browser` with matching Playwright browsers. The pinned `playwright` version must equal nixpkgs' `playwright-driver`; `nix run .#sync-playwright` updates it.
137
176
 
138
- Tests (`node --test`) cover the wire format against `testdata/wire-golden-vectors.txt`, framing, the generator, and the client — deadlines, cancellation, stream termination and reconnection — against in-memory fake servers, including a fake Echo server that runs all interop scenarios.
177
+ Tests (`node --test`) cover the wire format against `testdata/wire-golden-vectors.txt` (frame bytes, and the frame reader against the shared byte streams), the QMux frames against `testdata/qmux-golden-vectors.txt`, framing, the generator, and the client — deadlines, cancellation, stream termination and reconnection — against in-memory fake servers, including a fake Echo server that runs all interop scenarios.
@@ -1,4 +1,4 @@
1
- #!/nix/store/lfaydgacdyngci7p60s8wwvgdm74fjkx-nodejs-24.19.0/bin/node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import { runGenerator } from "../src/generator.js";
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevrpc/trevrpc-js",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Browser WebTransport client and protobuf.js code generator for TrevRPC",
5
5
  "homepage": "https://trev.zip/llc/TrevRPC",
6
6
  "bugs": {
@@ -36,24 +36,21 @@
36
36
  "format": "oxfmt --write",
37
37
  "format:check": "oxfmt --check",
38
38
  "lint": "oxlint --deny-warnings",
39
- "pretypecheck": "npm run build",
40
- "typecheck": "tsc -p tsconfig.json",
41
- "pretest": "npm run build",
42
- "test": "node --test --test-concurrency=1 test/*.test.js",
39
+ "typecheck": "npm run build && tsc -p tsconfig.json",
40
+ "test": "npm run build && node --test --test-concurrency=1 test/*.test.js",
43
41
  "check": "npm run format:check && npm run lint && npm run typecheck && npm test",
44
- "interop": "node interop-browser/cli.js",
45
- "prepack": "node -e 'if (process.env.TREVRPC_NIX_RELEASE !== \"1\") throw new Error(\"Build release tarballs with nix build .#trevrpc-js^npm\")'"
42
+ "interop": "node interop-browser/cli.js"
46
43
  },
47
44
  "dependencies": {
48
45
  "protobufjs": "^8.8.1"
49
46
  },
50
47
  "devDependencies": {
51
- "esbuild": "^0.28.2",
52
- "playwright": "1.61.1",
48
+ "playwright": "1.64.1",
49
+ "rolldown": "^1.2.14",
53
50
  "typescript": "^7.0.2"
54
51
  },
55
52
  "engines": {
56
- "node": ">=24 <25"
53
+ "node": ">=24"
57
54
  },
58
55
  "packageManager": "npm@12.2.0"
59
56
  }
package/src/call.js CHANGED
@@ -1,4 +1,10 @@
1
- import { DefaultMaxFrameSize, FrameReader, encodeFrame, encodeMessageFrame } from "./framing.js";
1
+ import { datagramEndpoint, messageFrameSize } from "./datagram.js";
2
+ import {
3
+ DefaultMaxFrameSize,
4
+ FrameReader,
5
+ encodeMessageFrame,
6
+ encodeRequestFrame,
7
+ } from "./framing.js";
2
8
  import { normalizeMetadata, validateMetadata } from "./metadata.js";
3
9
  import {
4
10
  Code,
@@ -10,7 +16,6 @@ import {
10
16
  statusError,
11
17
  statusFromTransportError,
12
18
  } from "./status.js";
13
- import { RpcRequest, WireVersion } from "./wire.js";
14
19
 
15
20
  /** Longest delay setTimeout can represent; it fires immediately beyond this. */
16
21
  const MaxTimerDelayMs = 2_147_483_647;
@@ -108,28 +113,68 @@ export class Call {
108
113
  #frames;
109
114
  #maxFrameSize;
110
115
  #sendClosed = false;
116
+ /** Set once the close of the request side completed. */
117
+ #closeCompleted = false;
118
+ /**
119
+ * The error every request-side operation fails with once the upload was ended, or undefined
120
+ * while it is open.
121
+ */
122
+ #uploadEnded;
123
+ /** Rejectors of the request-side operations waiting for the transport. */
124
+ #pendingUploads = new Set();
125
+ /** The transport error that made the request side fail on its own, or undefined. */
126
+ #uploadFailure;
127
+ /** The call's datagram flow, or undefined if its messages all travel on the stream. */
128
+ #flow;
129
+ /** Whether request messages are sent as datagrams when they fit. */
130
+ #sendDatagrams = false;
131
+ /** Request messages of a call that uses datagrams, queued for the flush loop, in order. */
132
+ #unsent = [];
133
+ /** Settles when the flush loop has sent everything queued so far. */
134
+ #sendTail = Promise.resolve();
135
+ #flushing = false;
111
136
 
112
137
  /**
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.
138
+ * Opens a stream on the transport and sends the REQUEST frame. The request carries the
139
+ * 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.
141
+ *
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.
115
146
  */
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
- );
147
+ static async open(transport, request, message, options, datagrams) {
148
+ const fields = { ...request, metadata: options.metadata, ...timeoutField(options) };
149
+ const header =
150
+ datagrams === undefined ? encodeRequestFrame(fields, message, options.maxFrameSize) : null;
122
151
  const scope = new CallScope(options);
152
+ let call;
123
153
  try {
124
- const stream = await openStream(transport, scope.signal);
125
- const call = new Call(stream, scope, options.maxFrameSize);
126
- await call.#write(header);
154
+ const session = datagrams === undefined ? undefined : {};
155
+ const stream = await openStream(transport, scope.signal, session);
156
+ call = new Call(stream, scope, options.maxFrameSize);
157
+ await call.#write(header ?? call.#requestWithFlow(fields, message, session, datagrams));
127
158
  return call;
128
159
  } catch (error) {
160
+ if (call !== undefined) {
161
+ call.#releaseFlow();
162
+ }
129
163
  throw scope.abort(failureStatus(error));
130
164
  }
131
165
  }
132
166
 
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;
171
+ return encodeRequestFrame(
172
+ this.#flow === undefined ? fields : { ...fields, datagramFlow: this.#flow.id },
173
+ message,
174
+ this.#maxFrameSize,
175
+ );
176
+ }
177
+
133
178
  constructor(stream, scope, maxFrameSize) {
134
179
  const reader = stream.readable.getReader();
135
180
  this.#scope = scope;
@@ -137,6 +182,12 @@ export class Call {
137
182
  this.#cancelReader = (reason) => reader.cancel(reason);
138
183
  this.#frames = new FrameReader(reader);
139
184
  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
+ });
140
191
  scope.signal.addEventListener("abort", () => this.#teardown(), { once: true });
141
192
  if (scope.signal.aborted) {
142
193
  this.#teardown();
@@ -148,44 +199,145 @@ export class Call {
148
199
  return this.#scope.signal;
149
200
  }
150
201
 
151
- /** Sends one streaming request message; writes are ordered and apply backpressure. */
202
+ /**
203
+ * The transport error that made the request side fail on its own, for instance because the
204
+ * server stopped reading it; undefined while the request side works or was ended by the call.
205
+ */
206
+ get uploadFailure() {
207
+ return this.#uploadFailure;
208
+ }
209
+
210
+ /** Whether the request side can still be written: not ended, not closed. */
211
+ get uploadOpen() {
212
+ return this.#uploadEnded === undefined && !this.#closeCompleted;
213
+ }
214
+
215
+ /** The largest frame the call accepts and sends, in bytes. */
216
+ get maxFrameSize() {
217
+ return this.#maxFrameSize;
218
+ }
219
+
220
+ /** The call's datagram flow, or undefined if its messages all travel on the stream. */
221
+ get flow() {
222
+ return this.#flow;
223
+ }
224
+
225
+ /**
226
+ * Sends one streaming request message; writes are ordered and apply backpressure. Fails with
227
+ * the error that ended the upload, if any, even while the transport itself is still blocked.
228
+ * On a call that sends datagrams the message goes out as one if it fits, and on the stream
229
+ * otherwise.
230
+ */
152
231
  sendMessage(body) {
232
+ if (this.#uploadEnded !== undefined) {
233
+ return Promise.reject(this.#uploadEnded);
234
+ }
153
235
  if (this.#sendClosed) {
154
236
  return Promise.reject(failedPrecondition("request stream is closed"));
155
237
  }
238
+ if (this.#sendDatagrams) {
239
+ return this.#upload(
240
+ new Promise((resolve, reject) => {
241
+ this.#unsent.push({ body, resolve, reject });
242
+ if (!this.#flushing) {
243
+ this.#flushing = true;
244
+ // Messages sent in the same turn are packed together; nothing waits for a timer.
245
+ this.#sendTail = Promise.resolve().then(() => this.#flush());
246
+ }
247
+ }),
248
+ );
249
+ }
156
250
  return this.#write(encodeMessageFrame(body, this.#maxFrameSize));
157
251
  }
158
252
 
253
+ /**
254
+ * Sends the queued request messages in order: as many as fit into one datagram go together, a
255
+ * message that cannot be a datagram goes on the stream, and so does every message of a datagram
256
+ * that failed to send. Messages queued while a datagram waits for send-buffer space join the
257
+ * next one.
258
+ */
259
+ async #flush() {
260
+ let inFlight = [];
261
+ try {
262
+ while (this.#unsent.length > 0) {
263
+ if (this.#uploadEnded !== undefined) {
264
+ throw this.#uploadEnded;
265
+ }
266
+ inFlight = this.#takeBatch();
267
+ if (inFlight.length === 0 || !(await this.#flow.send(inFlight.map(bodyOf), this.signal))) {
268
+ // Not a datagram: a head message that never fits, or a datagram that failed to send.
269
+ inFlight = inFlight.length > 0 ? inFlight : [this.#unsent.shift()];
270
+ for (const { body } of inFlight) {
271
+ await this.#writer.write(encodeMessageFrame(body, this.#maxFrameSize));
272
+ }
273
+ }
274
+ for (const item of inFlight) {
275
+ item.resolve();
276
+ }
277
+ inFlight = [];
278
+ }
279
+ } catch (error) {
280
+ for (const item of [...inFlight, ...this.#unsent.splice(0)]) {
281
+ item.reject(error);
282
+ }
283
+ } finally {
284
+ this.#flushing = false;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Removes the longest run of queued messages from the front that fits one datagram, at least
290
+ * one if the head fits alone; empty if the head can only go on the stream.
291
+ */
292
+ #takeBatch() {
293
+ let room = this.#flow.frameRoom;
294
+ let count = 0;
295
+ while (count < this.#unsent.length) {
296
+ const { body } = this.#unsent[count];
297
+ const size = messageFrameSize(body);
298
+ if (body.byteLength > this.#maxFrameSize || size > room) {
299
+ break;
300
+ }
301
+ room -= size;
302
+ count += 1;
303
+ }
304
+ return this.#unsent.splice(0, count);
305
+ }
306
+
159
307
  /** Finishes the request stream gracefully. */
160
308
  closeSend() {
161
309
  if (this.#sendClosed) {
162
310
  return Promise.resolve();
163
311
  }
164
312
  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));
313
+ // Datagram sends in flight go first, so the stream does not end before them.
314
+ const closing = this.#sendDatagrams
315
+ ? this.#sendTail.then(() => this.#writer.close())
316
+ : this.#writer.close();
317
+ return this.#upload(closing).then(() => {
318
+ this.#closeCompleted = true;
319
+ });
171
320
  }
172
321
 
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));
322
+ /**
323
+ * Reads the next known response frame as `{ type, payload }`, or null if the stream ended
324
+ * cleanly. Unknown frames are skipped.
325
+ */
326
+ readFrame() {
327
+ return this.#guard(this.#frames.readFrame(this.#maxFrameSize));
176
328
  }
177
329
 
178
330
  /**
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.
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.
181
335
  */
182
- finish() {
336
+ finish(uploadError) {
183
337
  this.#scope.settle();
184
- if (!this.#sendClosed) {
185
- this.#sendClosed = true;
186
- this.#writer.abort(cancelled("RPC completed")).catch(() => {});
187
- }
188
- this.#frames.expectEnd().catch(() => this.#cancelReader().catch(() => {}));
338
+ this.#releaseFlow();
339
+ this.#endUpload(uploadError, cancelled("RPC completed"));
340
+ this.#frames.expectEnd(this.#maxFrameSize).catch(() => this.#cancelReader().catch(() => {}));
189
341
  }
190
342
 
191
343
  /** Cancels the call on behalf of the caller; any reason becomes a Cancelled status. */
@@ -198,8 +350,59 @@ export class Call {
198
350
  return this.#scope.abort(failureStatus(error));
199
351
  }
200
352
 
201
- async #write(bytes) {
202
- await this.#guard(this.#writer.write(bytes));
353
+ #write(bytes) {
354
+ return this.#upload(this.#writer.write(bytes));
355
+ }
356
+
357
+ /**
358
+ * Waits for a write or close of the request side. Ending the upload settles it at once: a
359
+ * transport blocked on flow control does not act on an abort while the write is in flight.
360
+ */
361
+ #upload(operation) {
362
+ return new Promise((resolve, reject) => {
363
+ this.#pendingUploads.add(reject);
364
+ operation.then(
365
+ (value) => {
366
+ this.#pendingUploads.delete(reject);
367
+ if (this.signal.aborted) {
368
+ reject(this.signal.reason);
369
+ } else {
370
+ resolve(value);
371
+ }
372
+ },
373
+ (error) => {
374
+ this.#pendingUploads.delete(reject);
375
+ reject(this.#uploadError(error));
376
+ },
377
+ );
378
+ });
379
+ }
380
+
381
+ /** Maps a failed request-side operation to the call's error. */
382
+ #uploadError(error) {
383
+ if (this.signal.aborted) {
384
+ return this.signal.reason;
385
+ }
386
+ if (error === this.#uploadEnded) {
387
+ return error;
388
+ }
389
+ this.#uploadFailure ??= statusFromTransportError(error);
390
+ return this.#uploadFailure;
391
+ }
392
+
393
+ /** Stops the request side with `error` for everything waiting on it; only the first call counts. */
394
+ #endUpload(error, abortReason = error) {
395
+ if (this.#uploadEnded !== undefined) {
396
+ return;
397
+ }
398
+ this.#uploadEnded = error;
399
+ this.#sendClosed = true;
400
+ this.#writer.abort(abortReason).catch(() => {});
401
+ const pending = [...this.#pendingUploads];
402
+ this.#pendingUploads.clear();
403
+ for (const reject of pending) {
404
+ reject(error);
405
+ }
203
406
  }
204
407
 
205
408
  async #guard(operation) {
@@ -214,14 +417,25 @@ export class Call {
214
417
  return result;
215
418
  }
216
419
 
420
+ /** Ends the datagram flow, if any; every way a call ends gets here. */
421
+ #releaseFlow() {
422
+ this.#flow?.release();
423
+ }
424
+
217
425
  #teardown() {
426
+ this.#releaseFlow();
218
427
  const reason = this.signal.reason;
219
- this.#writer.abort(reason).catch(() => {});
428
+ this.#endUpload(reason);
220
429
  this.#cancelReader(reason).catch(() => {});
221
430
  }
222
431
  }
223
432
 
224
- function openStream(transport, signal) {
433
+ /**
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.
437
+ */
438
+ function openStream(transport, signal, session) {
225
439
  return new Promise((resolve, reject) => {
226
440
  if (signal.aborted) {
227
441
  reject(signal.reason);
@@ -232,7 +446,7 @@ function openStream(transport, signal) {
232
446
 
233
447
  let opening;
234
448
  try {
235
- opening = Promise.resolve(transport.createBidirectionalStream());
449
+ opening = Promise.resolve(startStream(transport, session));
236
450
  } catch (error) {
237
451
  opening = Promise.reject(error);
238
452
  }
@@ -255,6 +469,25 @@ function openStream(transport, signal) {
255
469
  });
256
470
  }
257
471
 
472
+ function bodyOf(item) {
473
+ return item.body;
474
+ }
475
+
476
+ function startStream(transport, session) {
477
+ if (session === undefined) {
478
+ return transport.createBidirectionalStream();
479
+ }
480
+ if (typeof transport.createBidirectionalStreamWithDatagrams === "function") {
481
+ return transport.createBidirectionalStreamWithDatagrams().then(({ stream, datagrams }) => {
482
+ session.datagrams = datagrams;
483
+ return stream;
484
+ });
485
+ }
486
+ // A WebTransport session used directly: its datagrams belong to the stream opened right now.
487
+ session.datagrams = transport.datagrams;
488
+ return transport.createBidirectionalStream();
489
+ }
490
+
258
491
  function throwIfAborted(signal) {
259
492
  if (signal.aborted) {
260
493
  throw signal.reason;