@trevrpc/trevrpc-js 1.5.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 +33 -42
- 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 +55 -198
- package/src/index.js +1 -15
- package/src/method-options.js +170 -0
- package/src/qmux/session.js +17 -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";
|
|
46
|
+
|
|
47
|
+
import { Metrics, TimeInterval } from "./gen/metrics_pb.js";
|
|
57
48
|
|
|
58
|
-
const
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
`google.protobuf.Timestamp`, `Duration`, `Empty`, `Struct`, `Value`, `ListValue` and `NullValue` are declared once by the runtime, which generated files import (Timestamp and Duration only with `bigint`, since the runtime declares their seconds as one). The runtime has helpers for them: `timestampFromDate`, `timestampFromMs`, `timestampDate`, `timestampMs`, `durationFromMs`, `durationMs`, `structFromJson`, `structToJson`, `valueFromJson` and `valueToJson`. The Timestamp and Duration readers take seconds in any representation.
|
|
58
|
+
`JSON.stringify` throws on a `bigint`; use protobuf-es's `toJson()` or `toJsonString()` to serialize a message.
|
|
65
59
|
|
|
66
|
-
|
|
67
|
-
import { timestampDate, timestampFromDate } from "@trevrpc/trevrpc-js";
|
|
60
|
+
### Method options
|
|
68
61
|
|
|
69
|
-
|
|
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
|
| -------------------- | --------------------------------------------------------------------------------------------- |
|
|
@@ -188,7 +179,7 @@ try {
|
|
|
188
179
|
|
|
189
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.
|
|
190
181
|
|
|
191
|
-
`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.
|
|
192
183
|
|
|
193
184
|
```js
|
|
194
185
|
const channel = await connect(url, {
|
|
@@ -202,7 +193,7 @@ const channel = await connect(url, {
|
|
|
202
193
|
|
|
203
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.
|
|
204
195
|
|
|
205
|
-
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.
|
|
206
197
|
|
|
207
198
|
### Connection metadata
|
|
208
199
|
|
|
@@ -236,7 +227,7 @@ const channel = createChannel(url, {
|
|
|
236
227
|
},
|
|
237
228
|
],
|
|
238
229
|
});
|
|
239
|
-
const client =
|
|
230
|
+
const client = createClient(Greeter, channel, { interceptors: [logCalls] });
|
|
240
231
|
```
|
|
241
232
|
|
|
242
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`.
|
|
@@ -246,7 +237,7 @@ const client = new GreeterClient(channel, { interceptors: [logCalls] });
|
|
|
246
237
|
|
|
247
238
|
### Effect
|
|
248
239
|
|
|
249
|
-
The separate package [`@trevrpc/effect`](effect)
|
|
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.
|
|
250
241
|
|
|
251
242
|
### Compression
|
|
252
243
|
|
|
@@ -284,7 +275,7 @@ console.log(channel.transport); // "webtransport" or "websocket"
|
|
|
284
275
|
|
|
285
276
|
## Interop client
|
|
286
277
|
|
|
287
|
-
`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.
|
|
288
279
|
|
|
289
280
|
```sh
|
|
290
281
|
trevrpc-interop-browser --browser chromium|firefox|safari \
|
|
@@ -301,10 +292,10 @@ It prints `ok <name>` or `fail <name>: <reason>` per scenario and exits 0 only i
|
|
|
301
292
|
```sh
|
|
302
293
|
npm ci
|
|
303
294
|
npm run check # format check, lint, typecheck, tests
|
|
304
|
-
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/
|
|
305
296
|
npm run interop -- --browser chromium --url ... --cert-hash ...
|
|
306
297
|
```
|
|
307
298
|
|
|
308
|
-
`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.
|
|
309
300
|
|
|
310
|
-
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
|
/**
|