@trevrpc/trevrpc-js 1.4.0 → 1.6.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 +78 -41
- package/package.json +9 -13
- package/src/call.js +1 -1
- package/src/channel.js +44 -6
- package/src/client.js +30 -38
- package/src/framing.js +40 -12
- package/src/index.d.ts +63 -198
- package/src/index.js +1 -15
- package/src/method-options.js +170 -0
- package/src/qmux/session.js +17 -0
- package/src/status.js +4 -0
- package/src/wire.js +400 -312
- package/bin/protoc-gen-trevrpc-js.js +0 -17
- package/src/generator.js +0 -1152
- package/src/int64.js +0 -288
- package/src/wkt.js +0 -137
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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 with a QMux over WebSocket fallback,
|
|
5
|
+
`trevrpc-js` is the **browser client**: a WebTransport runtime with a QMux over WebSocket fallback, for the messages and service descriptors that [protobuf-es](https://github.com/bufbuild/protobuf-es) generates. 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
|
|
|
@@ -24,61 +24,52 @@ message HelloRequest { string name = 1; }
|
|
|
24
24
|
message HelloReply { string message = 1; }
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
TrevRPC has no code generator of its own for JavaScript: generate the messages and the service descriptor with protobuf-es's `protoc-gen-es`, and pass the descriptor to `createClient()`.
|
|
28
28
|
|
|
29
29
|
```sh
|
|
30
|
-
npm install @trevrpc/trevrpc-js
|
|
30
|
+
npm install @trevrpc/trevrpc-js @bufbuild/protobuf
|
|
31
|
+
npm install --save-dev @bufbuild/protoc-gen-es
|
|
31
32
|
protoc \
|
|
32
|
-
--plugin=protoc-gen-
|
|
33
|
-
--
|
|
33
|
+
--plugin=protoc-gen-es=node_modules/.bin/protoc-gen-es \
|
|
34
|
+
--es_out=src/gen --es_opt=target=ts \
|
|
34
35
|
greeter.proto
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
This writes `
|
|
38
|
+
This writes `greeter_pb.ts` with `HelloRequestSchema`, `HelloReplySchema` and the `Greeter` service descriptor ([`buf generate`](https://buf.build/docs/generate/overview/) with the `buf.build/bufbuild/es` plugin works the same). `@bufbuild/protobuf` is a peer dependency, so the generated code and the client share one copy.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
| --------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
|
|
41
|
-
| `runtime_import` | `@trevrpc/trevrpc-js` | Module specifier the generated JavaScript imports. |
|
|
42
|
-
| `runtime_type_import` | same as `runtime_import` | Module specifier the declarations import; required when `runtime_import` is a URL. |
|
|
43
|
-
| `file_suffix` | `.trevrpc.js` | Suffix of the generated files. |
|
|
44
|
-
| `int64` | `bigint` | How decoded 64-bit integers are represented: `bigint`, `number`, `string` or `long`. |
|
|
40
|
+
### Messages
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
The declarations cover the file's own messages and enums and every type they and its services use, including those of imported files. Each message has two shapes:
|
|
49
|
-
|
|
50
|
-
- `Foo`, a decoded message, which calls return and streams yield. Every field reads a value: scalars and enums their default when unset, repeated fields and maps an empty array and object, and message and `optional` fields `null` when unset.
|
|
51
|
-
- `FooInit`, a message to send, in which every field may be left out. Requests take it, and a decoded `Foo` can be sent as one.
|
|
52
|
-
|
|
53
|
-
Each enum is exported as a frozen object of its values and as the type of those values. When every value starts with the enum's name in upper snake case, the prefix is dropped, as protobuf-es does: `TIME_INTERVAL_HOUR` of `TimeInterval` is `TimeInterval.HOUR`.
|
|
42
|
+
Messages are protobuf-es messages; see its [manual](https://github.com/bufbuild/protobuf-es/blob/main/MANUAL.md) for the details. Requests take a message or the init object `create()` takes, in which every field may be left out; calls return and streams yield messages, in which scalars, repeated fields and maps always have a value and message fields are `undefined` when unset. Oneofs are `{ case, value }`, 64-bit integers are `bigint` (or `string` with `[jstype = JS_STRING]`), `google.protobuf.Struct` is a JSON object, and `@bufbuild/protobuf/wkt` has helpers for the well-known types, such as `timestampFromDate` and `timestampDate`.
|
|
54
43
|
|
|
55
44
|
```ts
|
|
56
|
-
import {
|
|
45
|
+
import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
57
46
|
|
|
58
|
-
|
|
47
|
+
import { Metrics, TimeInterval } from "./gen/metrics_pb.js";
|
|
48
|
+
|
|
49
|
+
const metrics = createClient(Metrics, channel);
|
|
50
|
+
const sample = await metrics.get({
|
|
51
|
+
interval: TimeInterval.HOUR,
|
|
52
|
+
at: timestampFromDate(new Date()),
|
|
53
|
+
});
|
|
59
54
|
sample.count; // bigint, never undefined
|
|
55
|
+
const at = sample.at && timestampDate(sample.at);
|
|
60
56
|
```
|
|
61
57
|
|
|
62
|
-
|
|
58
|
+
`JSON.stringify` throws on a `bigint`; use protobuf-es's `toJson()` or `toJsonString()` to serialize a message.
|
|
63
59
|
|
|
64
|
-
|
|
60
|
+
### Method options
|
|
65
61
|
|
|
66
|
-
|
|
67
|
-
import { timestampDate, timestampFromDate } from "@trevrpc/trevrpc-js";
|
|
68
|
-
|
|
69
|
-
await client.put({ at: timestampFromDate(new Date()) });
|
|
70
|
-
const at = sample.at === null ? undefined : timestampDate(sample.at);
|
|
71
|
-
```
|
|
62
|
+
`createClient()` reads the `trevrpc.method` option of each method (from [`trevrpc/options.proto`](https://trev.zip/llc/TrevRPC/wiki/Getting-Started#schema-options)) from the descriptor, which protoc-gen-es keeps: `unreliable_requests` and `unreliable_responses` mark directions whose messages may travel as WebTransport datagrams ([Datagram Messages](https://trev.zip/llc/TrevRPC/wiki/Protocol-and-Wire-Format#datagram-messages)), and `compression` lets messages be compressed without a dictionary or a stream be compressed ([Compression](#compression)). An option the method's kind cannot use, such as `unreliable_requests` on a unary method or `COMPRESSION_STREAM` on one that streams nothing, makes `createClient()` throw `InvalidArgument`. Message types with a field marked `debug_redact` are never compressed that way.
|
|
72
63
|
|
|
73
64
|
## Client
|
|
74
65
|
|
|
75
66
|
```js
|
|
76
|
-
import { connect } from "@trevrpc/trevrpc-js";
|
|
67
|
+
import { connect, createClient } from "@trevrpc/trevrpc-js";
|
|
77
68
|
|
|
78
|
-
import {
|
|
69
|
+
import { Greeter } from "./gen/greeter_pb.js";
|
|
79
70
|
|
|
80
71
|
const channel = await connect("https://example.com:4433/trevrpc");
|
|
81
|
-
const client =
|
|
72
|
+
const client = createClient(Greeter, channel, { timeoutMs: 5_000 });
|
|
82
73
|
|
|
83
74
|
// Unary
|
|
84
75
|
const reply = await client.sayHello({ name: "TrevRPC" });
|
|
@@ -110,7 +101,7 @@ channel.close();
|
|
|
110
101
|
|
|
111
102
|
### Call options
|
|
112
103
|
|
|
113
|
-
Every method takes `CallOptions` as its last argument; the same options can be given to
|
|
104
|
+
A client has one function per method, named by the method's `localName` (`SayHello` is `sayHello`). Every method takes `CallOptions` as its last argument; the same options can be given to `createClient()` as defaults, and per-call `metadata` is merged over the defaults per key. `createClient()` also takes `interceptors` ([below](#interceptors)).
|
|
114
105
|
|
|
115
106
|
| Option | Meaning |
|
|
116
107
|
| -------------------- | --------------------------------------------------------------------------------------------- |
|
|
@@ -142,11 +133,53 @@ call.
|
|
|
142
133
|
|
|
143
134
|
- 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.
|
|
144
135
|
|
|
136
|
+
### Rendering long streams
|
|
137
|
+
|
|
138
|
+
Once response messages are buffered, `for await` hands them over without returning to the event loop: each `next()` resolves as a microtask, as with any async iterator over a `fetch` body. When the network delivers faster than the page consumes, which is always the case on localhost, a loop over a long stream runs to its end before the browser renders anything, timers fire, or input is handled. The client does not yield on its own, because a timer-based yield would slow streams in hidden tabs and add latency for consumers that do not render.
|
|
139
|
+
|
|
140
|
+
A page that renders a stream should batch messages per animation frame and, after a time budget, yield with a timer so that frame can render:
|
|
141
|
+
|
|
142
|
+
```js
|
|
143
|
+
let batch = [];
|
|
144
|
+
let frame;
|
|
145
|
+
const flush = () => {
|
|
146
|
+
frame = undefined;
|
|
147
|
+
store.append(batch);
|
|
148
|
+
batch = [];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
let sliceStart = performance.now();
|
|
152
|
+
try {
|
|
153
|
+
for await (const row of stream) {
|
|
154
|
+
batch.push(row);
|
|
155
|
+
frame ??= requestAnimationFrame(flush);
|
|
156
|
+
if (performance.now() - sliceStart > 8) {
|
|
157
|
+
if (document.hidden) {
|
|
158
|
+
cancelAnimationFrame(frame);
|
|
159
|
+
flush();
|
|
160
|
+
} else {
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
162
|
+
}
|
|
163
|
+
sliceStart = performance.now();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
if (frame !== undefined) {
|
|
168
|
+
cancelAnimationFrame(frame);
|
|
169
|
+
flush();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- `scheduler.yield()` does not help: Chromium resumes its continuation before rendering, so every message still lands in one frame.
|
|
175
|
+
- Hidden tabs run no animation frames and throttle timers to about one per second, so while `document.hidden` the loop applies the batch itself after each time budget instead of yielding. It holds at most one budget's worth of messages, and a stream in a background tab keeps its speed.
|
|
176
|
+
- If the stream fails or the loop is left early, which cancels the call, the `finally` still applies the messages received before.
|
|
177
|
+
|
|
145
178
|
### Connections
|
|
146
179
|
|
|
147
180
|
`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.
|
|
148
181
|
|
|
149
|
-
`createChannel(url, options)` takes the same options but returns the channel right away, in the `"connecting"` state, so
|
|
182
|
+
`createChannel(url, options)` takes the same options but returns the channel right away, in the `"connecting"` state, so clients can be created before it is connected. Calls started while it connects wait for that first attempt, within their own deadline and signal; if it fails they fail with its error, and the channel goes on reconnecting with backoff like after a lost session. The `signal` option does not apply to it; close the channel to give up.
|
|
150
183
|
|
|
151
184
|
```js
|
|
152
185
|
const channel = await connect(url, {
|
|
@@ -160,7 +193,7 @@ const channel = await connect(url, {
|
|
|
160
193
|
|
|
161
194
|
`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.
|
|
162
195
|
|
|
163
|
-
Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a
|
|
196
|
+
Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a client. A transport that wraps a `Channel` should forward its `createBidirectionalStreamWithSession()`, `compression`, `connectionMetadata`, `streamCompressionBudget` and `interceptors`; without them its calls carry no connection metadata, get a stream compression budget of their own, and skip the channel's interceptors. 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 clients build from the service and method names.
|
|
164
197
|
|
|
165
198
|
### Connection metadata
|
|
166
199
|
|
|
@@ -194,7 +227,7 @@ const channel = createChannel(url, {
|
|
|
194
227
|
},
|
|
195
228
|
],
|
|
196
229
|
});
|
|
197
|
-
const client =
|
|
230
|
+
const client = createClient(Greeter, channel, { interceptors: [logCalls] });
|
|
198
231
|
```
|
|
199
232
|
|
|
200
233
|
- An interceptor runs before the call starts, in order with the others, and may wait. It may add, replace and delete entries of `call.metadata`, which is validated after the last interceptor. The call's deadline starts after the interceptors; an interceptor that waits can watch `call.signal`.
|
|
@@ -202,6 +235,10 @@ const client = new GreeterClient(channel, { interceptors: [logCalls] });
|
|
|
202
235
|
- An interceptor that throws fails the call with what it threw before it starts; the finish functions of earlier interceptors still run.
|
|
203
236
|
- A client-streaming call has a `status` promise too, like the other streaming calls.
|
|
204
237
|
|
|
238
|
+
### Effect
|
|
239
|
+
|
|
240
|
+
The separate package [`@trevrpc/effect`](effect) makes clients for [Effect](https://effect.website): `createEffectClient(Greeter, channel)` returns a client whose unary calls are effects, whose server-streaming calls are streams, and whose client-streaming and bidirectional calls take their requests as a stream. Interrupting an effect or stream cancels its call. `TrevRpcError` has a `_tag` of `"TrevRpcError"`, so `Effect.catchTag` matches it.
|
|
241
|
+
|
|
205
242
|
### Compression
|
|
206
243
|
|
|
207
244
|
Messages can travel compressed with zstd and a dictionary trained on them (see [Compression](https://trev.zip/llc/TrevRPC/wiki/Compression)). The codec lives in a separate entry point, `@trevrpc/trevrpc-js/zstd`, which loads libzstd as WebAssembly (about 85 KB gzipped), so applications without compression never load it:
|
|
@@ -238,7 +275,7 @@ console.log(channel.transport); // "webtransport" or "websocket"
|
|
|
238
275
|
|
|
239
276
|
## Interop client
|
|
240
277
|
|
|
241
|
-
`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
|
|
278
|
+
`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 protoc-gen-es, so it also serves as a complete example of a client.
|
|
242
279
|
|
|
243
280
|
```sh
|
|
244
281
|
trevrpc-interop-browser --browser chromium|firefox|safari \
|
|
@@ -255,10 +292,10 @@ It prints `ok <name>` or `fail <name>: <reason>` per scenario and exits 0 only i
|
|
|
255
292
|
```sh
|
|
256
293
|
npm ci
|
|
257
294
|
npm run check # format check, lint, typecheck, tests
|
|
258
|
-
npm run build # generate the interop
|
|
295
|
+
npm run build # generate the interop and test descriptors and bundle the interop and benchmark pages into build/interop/
|
|
259
296
|
npm run interop -- --browser chromium --url ... --cert-hash ...
|
|
260
297
|
```
|
|
261
298
|
|
|
262
|
-
`nix build .#trevrpc-js` runs the same checks and installs the library
|
|
299
|
+
`nix build .#trevrpc-js` runs the same checks and installs the library 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.
|
|
263
300
|
|
|
264
|
-
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
|
|
301
|
+
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 `trevrpc.method` option, 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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevrpc/trevrpc-js",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Browser WebTransport client
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "Browser WebTransport client for TrevRPC, on protobuf-es",
|
|
5
5
|
"homepage": "https://trev.zip/llc/TrevRPC",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://trev.zip/llc/TrevRPC/issues"
|
|
@@ -11,12 +11,8 @@
|
|
|
11
11
|
"type": "git",
|
|
12
12
|
"url": "https://trev.zip/llc/TrevRPC.git"
|
|
13
13
|
},
|
|
14
|
-
"bin": {
|
|
15
|
-
"protoc-gen-trevrpc-js": "bin/protoc-gen-trevrpc-js.js"
|
|
16
|
-
},
|
|
17
14
|
"files": [
|
|
18
15
|
"src/**",
|
|
19
|
-
"bin/protoc-gen-trevrpc-js.js",
|
|
20
16
|
"README.md",
|
|
21
17
|
"LICENSE"
|
|
22
18
|
],
|
|
@@ -46,20 +42,20 @@
|
|
|
46
42
|
"check": "npm run format:check && npm run lint && npm run typecheck && npm test",
|
|
47
43
|
"interop": "node interop-browser/cli.js"
|
|
48
44
|
},
|
|
49
|
-
"dependencies": {
|
|
50
|
-
"protobufjs": "^8.8.1"
|
|
51
|
-
},
|
|
52
45
|
"devDependencies": {
|
|
53
|
-
"@bufbuild/protobuf": "^2.
|
|
54
|
-
"@bufbuild/protoc-gen-es": "^2.
|
|
46
|
+
"@bufbuild/protobuf": "^2.17.0",
|
|
47
|
+
"@bufbuild/protoc-gen-es": "^2.17.0",
|
|
55
48
|
"@connectrpc/connect": "^2.3.0",
|
|
56
49
|
"@connectrpc/connect-web": "^2.3.0",
|
|
57
50
|
"playwright": "1.64.1",
|
|
58
|
-
"rolldown": "^1.
|
|
51
|
+
"rolldown": "^1.3.0",
|
|
59
52
|
"typescript": "^7.0.2"
|
|
60
53
|
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@bufbuild/protobuf": "^2.17.0"
|
|
56
|
+
},
|
|
61
57
|
"engines": {
|
|
62
58
|
"node": ">=24"
|
|
63
59
|
},
|
|
64
|
-
"packageManager": "npm@12.2.
|
|
60
|
+
"packageManager": "npm@12.2.2"
|
|
65
61
|
}
|
package/src/call.js
CHANGED
|
@@ -859,5 +859,5 @@ function timeoutField({ timeoutMs }) {
|
|
|
859
859
|
if (nanos > MaxUint64) {
|
|
860
860
|
throw invalidArgument("RPC timeout does not fit in 64-bit nanoseconds");
|
|
861
861
|
}
|
|
862
|
-
return { timeoutNanos:
|
|
862
|
+
return { timeoutNanos: nanos === 0n ? 1n : nanos };
|
|
863
863
|
}
|
package/src/channel.js
CHANGED
|
@@ -3,7 +3,14 @@ import { ConnectionMetadataSource } from "./connection-metadata.js";
|
|
|
3
3
|
import { interceptorList } from "./interceptor.js";
|
|
4
4
|
import { QMuxWebSocketSession } from "./qmux/session.js";
|
|
5
5
|
import { dialRace } from "./race.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
Code,
|
|
8
|
+
TrevRpcError,
|
|
9
|
+
cancelled,
|
|
10
|
+
failedPrecondition,
|
|
11
|
+
statusFromTransportError,
|
|
12
|
+
unavailable,
|
|
13
|
+
} from "./status.js";
|
|
7
14
|
import { StreamBudget } from "./stream-compression.js";
|
|
8
15
|
|
|
9
16
|
/**
|
|
@@ -63,7 +70,7 @@ export class Channel {
|
|
|
63
70
|
try {
|
|
64
71
|
const failure = await channel.#connecting;
|
|
65
72
|
if (signal?.aborted) {
|
|
66
|
-
throw abortError(signal);
|
|
73
|
+
throw abortError(signal, failure);
|
|
67
74
|
}
|
|
68
75
|
if (failure !== undefined) {
|
|
69
76
|
throw failure;
|
|
@@ -433,7 +440,13 @@ function dialWebSocket(url, options, signal) {
|
|
|
433
440
|
* may reject the ready session by throwing.
|
|
434
441
|
*/
|
|
435
442
|
async function establish(session, name, signal, verify) {
|
|
436
|
-
const
|
|
443
|
+
const startedAt = Date.now();
|
|
444
|
+
let abandoned;
|
|
445
|
+
const abandon = () => {
|
|
446
|
+
// Closing ends the session, so how far it got is recorded first.
|
|
447
|
+
abandoned = abandonedMessage(session, name, startedAt);
|
|
448
|
+
closeQuietly(session);
|
|
449
|
+
};
|
|
437
450
|
signal?.addEventListener("abort", abandon, { once: true });
|
|
438
451
|
try {
|
|
439
452
|
// A session that fails to connect settles both promises; one that closes first never becomes
|
|
@@ -447,7 +460,10 @@ async function establish(session, name, signal, verify) {
|
|
|
447
460
|
verify?.(session);
|
|
448
461
|
} catch (error) {
|
|
449
462
|
closeQuietly(session);
|
|
450
|
-
|
|
463
|
+
if (signal?.aborted) {
|
|
464
|
+
throw cancelled(abandoned ?? abandonedMessage(session, name, startedAt), signal.reason);
|
|
465
|
+
}
|
|
466
|
+
throw statusFromTransportError(error);
|
|
451
467
|
} finally {
|
|
452
468
|
signal?.removeEventListener("abort", abandon);
|
|
453
469
|
}
|
|
@@ -468,8 +484,30 @@ function transportOptions(options) {
|
|
|
468
484
|
return picked;
|
|
469
485
|
}
|
|
470
486
|
|
|
471
|
-
|
|
472
|
-
|
|
487
|
+
/**
|
|
488
|
+
* The Cancelled error of a connection attempt that `signal` aborted. It keeps the message of
|
|
489
|
+
* `failure`, the attempt's own Cancelled error, which says how far the attempt got.
|
|
490
|
+
*/
|
|
491
|
+
function abortError(signal, failure) {
|
|
492
|
+
const message =
|
|
493
|
+
failure instanceof TrevRpcError && failure.code === Code.Cancelled
|
|
494
|
+
? failure.statusMessage
|
|
495
|
+
: "connection cancelled";
|
|
496
|
+
return cancelled(message, signal.reason);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Says how far a session abandoned `startedAt` (from Date.now()) got: a QMux over WebSocket
|
|
501
|
+
* session tells the WebSocket opening from the QMux handshake; a WebTransport session cannot.
|
|
502
|
+
*/
|
|
503
|
+
function abandonedMessage(session, name, startedAt) {
|
|
504
|
+
const progress =
|
|
505
|
+
{
|
|
506
|
+
opening: "the WebSocket was still opening",
|
|
507
|
+
handshaking:
|
|
508
|
+
"the WebSocket was open, but the server had not sent its QMux transport parameters",
|
|
509
|
+
}[session.stage] ?? `the ${name} session was not ready`;
|
|
510
|
+
return `connection cancelled after ${Date.now() - startedAt} ms: ${progress}`;
|
|
473
511
|
}
|
|
474
512
|
|
|
475
513
|
/** Exponential backoff with jitter, capped at maxDelayMs. */
|
package/src/client.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { Call, EarlyOk, defaultCallOptions, mergeCallOptions } from "./call.js";
|
|
2
|
-
import { FrameType, marshalMessage, unmarshalMessage } from "./framing.js";
|
|
3
|
-
import { int64Converter } from "./int64.js";
|
|
2
|
+
import { FrameType, decodeWireMessage, marshalMessage, unmarshalMessage } from "./framing.js";
|
|
4
3
|
import { interceptCall, interceptorList } from "./interceptor.js";
|
|
5
4
|
import { validateMetadata } from "./metadata.js";
|
|
6
|
-
import {
|
|
5
|
+
import { methodSettings } from "./method-options.js";
|
|
6
|
+
import { Code, cancelled, internal, invalidArgument, statusError } from "./status.js";
|
|
7
7
|
import { RpcAnswer, RpcHeaders, RpcKind, RpcStatus } from "./wire.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Creates a
|
|
11
|
-
*
|
|
10
|
+
* Creates a client for `service`, a service descriptor that protoc-gen-es generates, with one
|
|
11
|
+
* function per method, named by the method's `localName`. Its calls run the interceptors of the
|
|
12
|
+
* transport, then those of `options.interceptors`.
|
|
12
13
|
*/
|
|
13
|
-
export function
|
|
14
|
+
export function createClient(service, transport, options = {}) {
|
|
15
|
+
if (service?.kind !== "service" || !Array.isArray(service.methods)) {
|
|
16
|
+
throw invalidArgument("createClient needs a protobuf-es service descriptor");
|
|
17
|
+
}
|
|
14
18
|
const { interceptors: clientInterceptors, ...callOptions } = options;
|
|
15
19
|
const defaults = mergeCallOptions(defaultCallOptions(), callOptions);
|
|
16
20
|
const interceptors = [
|
|
@@ -19,32 +23,26 @@ export function createServiceClient(transport, service, root, options = {}) {
|
|
|
19
23
|
];
|
|
20
24
|
const client = {};
|
|
21
25
|
|
|
22
|
-
for (const
|
|
23
|
-
const
|
|
24
|
-
const responseType = root.lookupType(method.outputType);
|
|
26
|
+
for (const method of service.methods) {
|
|
27
|
+
const settings = methodSettings(method);
|
|
25
28
|
const rpc = {
|
|
26
29
|
// The wire method path, computed once per method rather than per call.
|
|
27
|
-
path: `/${service.
|
|
28
|
-
requestType,
|
|
29
|
-
responseType,
|
|
30
|
-
// Converts the 64-bit integers of decoded responses, or undefined when they need nothing.
|
|
31
|
-
convertResponse: int64Converter(responseType),
|
|
30
|
+
path: `/${service.typeName}/${method.name}`,
|
|
31
|
+
requestType: method.input,
|
|
32
|
+
responseType: method.output,
|
|
32
33
|
// Full protobuf names, which pick the compression dictionaries.
|
|
33
|
-
types: { request:
|
|
34
|
-
datagrams: datagramUse(
|
|
35
|
-
compressStream: streamCompressionUse(
|
|
36
|
-
compress: plainCompressionUse(
|
|
34
|
+
types: { request: method.input.typeName, response: method.output.typeName },
|
|
35
|
+
datagrams: datagramUse(settings),
|
|
36
|
+
compressStream: streamCompressionUse(settings),
|
|
37
|
+
compress: plainCompressionUse(settings),
|
|
37
38
|
};
|
|
38
39
|
const resolve = (override) => mergeCallOptions(defaults, override);
|
|
39
|
-
const shape = MethodShapes[
|
|
40
|
-
if (shape === undefined) {
|
|
41
|
-
throw internal(`unsupported generated RPC kind ${JSON.stringify(method.kind)}`);
|
|
42
|
-
}
|
|
40
|
+
const shape = MethodShapes[settings.kind];
|
|
43
41
|
const info = {
|
|
44
42
|
method: rpc.path,
|
|
45
|
-
service: service.
|
|
43
|
+
service: service.typeName,
|
|
46
44
|
name: method.name,
|
|
47
|
-
kind:
|
|
45
|
+
kind: settings.kind,
|
|
48
46
|
};
|
|
49
47
|
const call =
|
|
50
48
|
interceptors.length === 0
|
|
@@ -57,7 +55,7 @@ export function createServiceClient(transport, service, root, options = {}) {
|
|
|
57
55
|
(options) => shape.start(transport, rpc, request, options),
|
|
58
56
|
shape.ended,
|
|
59
57
|
);
|
|
60
|
-
client[
|
|
58
|
+
client[method.localName] = shape.takesRequest ? call : (override) => call(undefined, override);
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
return client;
|
|
@@ -183,7 +181,7 @@ const ReadAheadBytes = 1 << 20;
|
|
|
183
181
|
* How a method uses datagrams, or undefined if it does not: `send` for request messages, which
|
|
184
182
|
* needs a streaming request and `unreliableRequests`, and `receive` for response messages, which
|
|
185
183
|
* every call with a streaming response accepts. A method needs a flow if it marks either
|
|
186
|
-
* direction unreliable;
|
|
184
|
+
* direction unreliable; methodSettings rejects marks on a direction that does not stream.
|
|
187
185
|
*/
|
|
188
186
|
function datagramUse({ kind, unreliableRequests, unreliableResponses }) {
|
|
189
187
|
const streamsRequest = kind === "clientStreaming" || kind === "bidirectionalStreaming";
|
|
@@ -196,7 +194,7 @@ function datagramUse({ kind, unreliableRequests, unreliableResponses }) {
|
|
|
196
194
|
|
|
197
195
|
/**
|
|
198
196
|
* The directions a method may stream-compress, `{ requests, responses }`, or undefined for none:
|
|
199
|
-
* those its
|
|
197
|
+
* those its settings mark (methodSettings leaves out directions that do not stream and message
|
|
200
198
|
* types with redacted fields), except directions marked unreliable, whose messages mostly travel
|
|
201
199
|
* as datagrams.
|
|
202
200
|
*/
|
|
@@ -208,7 +206,7 @@ function streamCompressionUse({ compressStreamRequests, compressStreamResponses,
|
|
|
208
206
|
|
|
209
207
|
/**
|
|
210
208
|
* The directions whose messages a method may compress without a dictionary, `{ requests,
|
|
211
|
-
* responses }`, or undefined for none: those its
|
|
209
|
+
* responses }`, or undefined for none: those its settings mark (methodSettings marks both for
|
|
212
210
|
* `COMPRESSION_MESSAGE` and `COMPRESSION_STREAM`, except message types with redacted fields).
|
|
213
211
|
*/
|
|
214
212
|
function plainCompressionUse({ compressRequests, compressResponses }) {
|
|
@@ -418,8 +416,7 @@ export class BidirectionalStreamingCall {
|
|
|
418
416
|
const item = await this.#take();
|
|
419
417
|
if (item.payload !== undefined) {
|
|
420
418
|
try {
|
|
421
|
-
|
|
422
|
-
return this.#rpc.convertResponse?.(message) ?? message;
|
|
419
|
+
return unmarshalMessage(this.#rpc.responseType, item.payload);
|
|
423
420
|
} catch (error) {
|
|
424
421
|
throw this.#settleFailure(this.#call.fail(error));
|
|
425
422
|
}
|
|
@@ -594,7 +591,7 @@ export class BidirectionalStreamingCall {
|
|
|
594
591
|
);
|
|
595
592
|
}
|
|
596
593
|
this.#sawAnswer = true;
|
|
597
|
-
this.#call.learnAnswers(
|
|
594
|
+
this.#call.learnAnswers(decodeWireMessage(RpcAnswer, frame.payload));
|
|
598
595
|
continue;
|
|
599
596
|
}
|
|
600
597
|
case FrameType.Headers: {
|
|
@@ -603,7 +600,7 @@ export class BidirectionalStreamingCall {
|
|
|
603
600
|
this.#sawHeaders ? "duplicate HEADERS frame" : "HEADERS frame after MESSAGE",
|
|
604
601
|
);
|
|
605
602
|
}
|
|
606
|
-
const headers =
|
|
603
|
+
const headers = decodeWireMessage(RpcHeaders, frame.payload);
|
|
607
604
|
validateResponseMetadata(headers.metadata, "headers");
|
|
608
605
|
this.#sawHeaders = true;
|
|
609
606
|
this.#settleHeaders(headers.metadata);
|
|
@@ -620,7 +617,7 @@ export class BidirectionalStreamingCall {
|
|
|
620
617
|
return { type: FrameType.Message, payload };
|
|
621
618
|
}
|
|
622
619
|
case FrameType.Status: {
|
|
623
|
-
const status =
|
|
620
|
+
const status = decodeWireMessage(RpcStatus, frame.payload);
|
|
624
621
|
if (status.refusedStreamCompression && this.#call.replayable) {
|
|
625
622
|
// The server refused the optimistic call before running anything for it: the call
|
|
626
623
|
// continues on a replay, and its response is read from there.
|
|
@@ -791,11 +788,6 @@ function requestFields(rpc, kind) {
|
|
|
791
788
|
return { method: rpc.path, kind };
|
|
792
789
|
}
|
|
793
790
|
|
|
794
|
-
/** The full name of a protobuf.js message type, without the leading dot. */
|
|
795
|
-
function fullName(type) {
|
|
796
|
-
return type.fullName.replace(/^\./u, "");
|
|
797
|
-
}
|
|
798
|
-
|
|
799
791
|
function validateResponseMetadata(metadata, what) {
|
|
800
792
|
try {
|
|
801
793
|
validateMetadata(metadata);
|
package/src/framing.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
|
|
2
|
+
|
|
1
3
|
import { byteView } from "./bytes.js";
|
|
2
4
|
import { internal, invalidArgument, resourceExhausted, unavailable } from "./status.js";
|
|
3
5
|
import { readVarint, varintLength, writeVarint } from "./varint.js";
|
|
4
|
-
import { RpcAnswer, RpcHeaders, RpcRequest, RpcStatus
|
|
6
|
+
import { RpcAnswer, RpcHeaders, RpcRequest, RpcStatus } from "./wire.js";
|
|
5
7
|
|
|
6
8
|
export const DefaultMaxFrameSize = 4 * 1024 * 1024;
|
|
7
9
|
|
|
@@ -19,10 +21,13 @@ export const FrameType = Object.freeze({
|
|
|
19
21
|
const KnownFrameTypes = new Set(Object.values(FrameType));
|
|
20
22
|
const EmptyBytes = new Uint8Array(0);
|
|
21
23
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Encodes a protobuf message body: `message` is a message of `schema` (a protobuf-es message
|
|
26
|
+
* descriptor) or the init object `create()` takes.
|
|
27
|
+
*/
|
|
28
|
+
export function marshalMessage(schema, message) {
|
|
24
29
|
try {
|
|
25
|
-
return
|
|
30
|
+
return toBinary(schema, create(schema, message));
|
|
26
31
|
} catch (error) {
|
|
27
32
|
throw invalidArgument(
|
|
28
33
|
`failed to encode protobuf message: ${error?.message ?? String(error)}`,
|
|
@@ -31,11 +36,10 @@ export function marshalMessage(messageType, message) {
|
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
/** Decodes a protobuf message body
|
|
35
|
-
export function unmarshalMessage(
|
|
39
|
+
/** Decodes a protobuf message body of `schema`, rejecting malformed wire data. */
|
|
40
|
+
export function unmarshalMessage(schema, body) {
|
|
36
41
|
try {
|
|
37
|
-
|
|
38
|
-
return messageType.decode(body);
|
|
42
|
+
return fromBinary(schema, byteView(body));
|
|
39
43
|
} catch (error) {
|
|
40
44
|
throw invalidArgument(
|
|
41
45
|
`failed to decode protobuf message: ${error?.message ?? String(error)}`,
|
|
@@ -44,6 +48,30 @@ export function unmarshalMessage(messageType, body) {
|
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
/** Encodes a wire payload with its codec from wire.js, such as RpcStatus. */
|
|
52
|
+
export function encodeWireMessage(codec, fields) {
|
|
53
|
+
try {
|
|
54
|
+
return codec.encode(fields);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw invalidArgument(
|
|
57
|
+
`failed to encode ${codec.name}: ${error?.message ?? String(error)}`,
|
|
58
|
+
error,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Decodes a wire payload with its codec from wire.js, rejecting malformed wire data. */
|
|
64
|
+
export function decodeWireMessage(codec, payload) {
|
|
65
|
+
try {
|
|
66
|
+
return codec.decode(byteView(payload));
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw invalidArgument(
|
|
69
|
+
`failed to decode ${codec.name}: ${error?.message ?? String(error)}`,
|
|
70
|
+
error,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
47
75
|
/** Encodes one frame carrying an already-encoded payload. */
|
|
48
76
|
export function encodeFrame(type, payload, maxFrameSize = DefaultMaxFrameSize) {
|
|
49
77
|
checkFrameLength(payload.byteLength, maxFrameSize);
|
|
@@ -71,7 +99,7 @@ export function encodeOutgoingFrame(message, maxFrameSize = DefaultMaxFrameSize)
|
|
|
71
99
|
* encodeMessage, when it is given, in one buffer so both go out in a single write.
|
|
72
100
|
*/
|
|
73
101
|
export function encodeRequestFrame(request, message, maxFrameSize = DefaultMaxFrameSize) {
|
|
74
|
-
const payload =
|
|
102
|
+
const payload = encodeWireMessage(RpcRequest, request);
|
|
75
103
|
checkFrameLength(payload.byteLength, maxFrameSize);
|
|
76
104
|
if (message === undefined) {
|
|
77
105
|
return encodeFrame(FrameType.Request, payload, maxFrameSize);
|
|
@@ -86,17 +114,17 @@ export function encodeRequestFrame(request, message, maxFrameSize = DefaultMaxFr
|
|
|
86
114
|
|
|
87
115
|
/** Encodes a HEADERS frame from RpcHeaders fields. */
|
|
88
116
|
export function encodeHeadersFrame(headers, maxFrameSize = DefaultMaxFrameSize) {
|
|
89
|
-
return encodeFrame(FrameType.Headers,
|
|
117
|
+
return encodeFrame(FrameType.Headers, encodeWireMessage(RpcHeaders, headers), maxFrameSize);
|
|
90
118
|
}
|
|
91
119
|
|
|
92
120
|
/** Encodes an ANSWER frame from RpcAnswer fields. */
|
|
93
121
|
export function encodeAnswerFrame(answer, maxFrameSize = DefaultMaxFrameSize) {
|
|
94
|
-
return encodeFrame(FrameType.Answer,
|
|
122
|
+
return encodeFrame(FrameType.Answer, encodeWireMessage(RpcAnswer, answer), maxFrameSize);
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
/** Encodes a STATUS frame from RpcStatus fields. */
|
|
98
126
|
export function encodeStatusFrame(status, maxFrameSize = DefaultMaxFrameSize) {
|
|
99
|
-
return encodeFrame(FrameType.Status,
|
|
127
|
+
return encodeFrame(FrameType.Status, encodeWireMessage(RpcStatus, status), maxFrameSize);
|
|
100
128
|
}
|
|
101
129
|
|
|
102
130
|
/**
|