@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 +57 -18
- package/bin/protoc-gen-trevrpc-js.js +1 -1
- package/package.json +7 -10
- package/src/call.js +269 -36
- package/src/channel.js +152 -17
- package/src/client.js +481 -103
- package/src/datagram.js +336 -0
- package/src/framing.js +108 -111
- package/src/generator.js +41 -7
- package/src/index.d.ts +162 -17
- package/src/qmux/frames.js +262 -0
- package/src/qmux/params.js +104 -0
- package/src/qmux/session.js +739 -0
- package/src/race.js +90 -0
- package/src/status.js +1 -1
- package/src/varint.js +73 -0
- package/src/wire.js +17 -30
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
|
|
82
|
-
|
|
|
83
|
-
| `timeoutMs`
|
|
84
|
-
| `signal`
|
|
85
|
-
| `metadata`
|
|
86
|
-
| `maxFrameSize`
|
|
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
|
-
-
|
|
97
|
-
-
|
|
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
|
|
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
|
-
|
|
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|
|
|
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.
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevrpc/trevrpc-js",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"
|
|
40
|
-
"
|
|
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
|
-
"
|
|
52
|
-
"
|
|
48
|
+
"playwright": "1.64.1",
|
|
49
|
+
"rolldown": "^1.2.14",
|
|
53
50
|
"typescript": "^7.0.2"
|
|
54
51
|
},
|
|
55
52
|
"engines": {
|
|
56
|
-
"node": ">=24
|
|
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 {
|
|
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
|
|
114
|
-
* method
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
125
|
-
const
|
|
126
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
180
|
-
*
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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.#
|
|
428
|
+
this.#endUpload(reason);
|
|
220
429
|
this.#cancelReader(reason).catch(() => {});
|
|
221
430
|
}
|
|
222
431
|
}
|
|
223
432
|
|
|
224
|
-
|
|
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
|
|
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;
|