@trevrpc/trevrpc-js 1.6.0 → 1.8.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 +17 -1
- package/package.json +2 -2
- package/src/call.js +80 -37
- package/src/client.js +15 -8
- package/src/connection-metadata.js +11 -14
- package/src/framing.js +10 -3
- package/src/index.d.ts +66 -9
- package/src/status.js +16 -3
- package/src/validator.js +36 -0
- package/src/wire.js +33 -1
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@ TrevRPC is an RPC framework like gRPC, but uses QUIC (and HTTP/3 / WebTransport)
|
|
|
4
4
|
|
|
5
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
|
+
> **Compatibility:** every TrevRPC package shares one version, and packages of the same version are compatible
|
|
8
|
+
> with each other: `trevrpc-rust` 1.8.0 works with `trevrpc-go` 1.8.0, `trevrpc-js` 1.8.0, and `trevrpc-kotlin`
|
|
9
|
+
> 1.8.0. Compatibility across different versions isn't guaranteed yet; that's a milestone for 2.0.
|
|
10
|
+
|
|
7
11
|
Full documentation: https://trev.zip/llc/TrevRPC/wiki
|
|
8
12
|
|
|
9
13
|
## Protobuf
|
|
@@ -205,7 +209,7 @@ channel.setConnectionMetadata({ authorization: `Bearer ${refreshed}` }); // call
|
|
|
205
209
|
channel.setConnectionMetadata(); // removes it
|
|
206
210
|
```
|
|
207
211
|
|
|
208
|
-
A changed set is sent in full again on every session, and so is the set on a session after a reconnect. Keys follow the rules of call metadata. Other transports carry it when they forward a channel's `connectionMetadata`.
|
|
212
|
+
A call carries the set that is current when it starts, once its interceptors have run, until it ends, like its own `metadata`: a change applies to calls that start after it, and a call still waiting for the channel to connect keeps the set it started with. A changed set is sent in full again on every session, and so is the set on a session after a reconnect. Keys follow the rules of call metadata. Other transports carry it when they forward a channel's `connectionMetadata`.
|
|
209
213
|
|
|
210
214
|
### Interceptors
|
|
211
215
|
|
|
@@ -235,6 +239,18 @@ const client = createClient(Greeter, channel, { interceptors: [logCalls] });
|
|
|
235
239
|
- An interceptor that throws fails the call with what it threw before it starts; the finish functions of earlier interceptors still run.
|
|
236
240
|
- A client-streaming call has a `status` promise too, like the other streaming calls.
|
|
237
241
|
|
|
242
|
+
### Request validation
|
|
243
|
+
|
|
244
|
+
The separate package [`@trevrpc/protovalidate`](protovalidate) checks request messages against their [`buf.validate`](https://buf.build/bufbuild/protovalidate) rules before they are sent, with [protovalidate](https://github.com/bufbuild/protovalidate-es):
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
import { createRequestValidator } from "@trevrpc/protovalidate";
|
|
248
|
+
|
|
249
|
+
const client = createClient(Greeter, channel, { validator: createRequestValidator() });
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
A request that breaks its rules fails with `Code.InvalidArgument` and a `buf.validate.Violations` detail, which `error.findDetails(ViolationsSchema)` decodes, as a Go or Rust server that validates requests reports it. The request of a unary or server-streaming call fails the call after its interceptors ran; a message passed to `send()` rejects that send, and the call goes on. Methods whose request type has no rules are not checked. The `validator` option takes any object with a `validateFunc(schema)` method that returns the check of that request type, or nothing to skip it; see `RequestValidator`.
|
|
253
|
+
|
|
238
254
|
### Effect
|
|
239
255
|
|
|
240
256
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevrpc/trevrpc-js",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Browser WebTransport client for TrevRPC, on protobuf-es",
|
|
5
5
|
"homepage": "https://trev.zip/llc/TrevRPC",
|
|
6
6
|
"bugs": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@connectrpc/connect": "^2.3.0",
|
|
49
49
|
"@connectrpc/connect-web": "^2.3.0",
|
|
50
50
|
"playwright": "1.64.1",
|
|
51
|
-
"rolldown": "^1.3.
|
|
51
|
+
"rolldown": "^1.3.1",
|
|
52
52
|
"typescript": "^7.0.2"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
package/src/call.js
CHANGED
|
@@ -167,8 +167,9 @@ export class Call {
|
|
|
167
167
|
#flushing = false;
|
|
168
168
|
/**
|
|
169
169
|
* What the call needs to open itself again, for the replay of a refused optimistic call:
|
|
170
|
-
* `{ transport, request, rpc, deadline }`, where `deadline` is the
|
|
171
|
-
* call's timeout ends, or undefined
|
|
170
|
+
* `{ transport, request, rpc, deadline, connectionMetadata }`, where `deadline` is the
|
|
171
|
+
* `performance.now()` time the call's timeout ends, or undefined, and `connectionMetadata` is
|
|
172
|
+
* the connection metadata set the call carries, or undefined.
|
|
172
173
|
*/
|
|
173
174
|
#setup;
|
|
174
175
|
/**
|
|
@@ -201,12 +202,14 @@ export class Call {
|
|
|
201
202
|
static async open(transport, request, message, options, rpc = {}) {
|
|
202
203
|
const fields = { ...request, metadata: options.metadata, ...timeoutField(options) };
|
|
203
204
|
const compression = transportCompression(transport);
|
|
205
|
+
// The call carries the connection metadata that is current as it starts, like its own
|
|
206
|
+
// metadata, until it ends: a change while its stream opens or before a replay is for later
|
|
207
|
+
// calls.
|
|
208
|
+
const connectionMetadata = transportConnectionMetadata(transport)?.current;
|
|
204
209
|
// The REQUEST depends on the session that carries the call if it negotiates compression, opens
|
|
205
210
|
// a datagram flow or carries connection metadata.
|
|
206
211
|
const perSession =
|
|
207
|
-
rpc.datagrams !== undefined ||
|
|
208
|
-
compression !== undefined ||
|
|
209
|
-
transportConnectionMetadata(transport)?.current !== undefined;
|
|
212
|
+
rpc.datagrams !== undefined || compression !== undefined || connectionMetadata !== undefined;
|
|
210
213
|
const header = perSession
|
|
211
214
|
? null
|
|
212
215
|
: encodeRequestFrame(fields, outgoing(message), options.maxFrameSize);
|
|
@@ -221,6 +224,7 @@ export class Call {
|
|
|
221
224
|
request: { ...request, metadata: options.metadata },
|
|
222
225
|
rpc,
|
|
223
226
|
deadline: options.timeoutMs == null ? undefined : performance.now() + options.timeoutMs,
|
|
227
|
+
connectionMetadata,
|
|
224
228
|
};
|
|
225
229
|
await call.#write(
|
|
226
230
|
header ?? call.#requestOnSession(fields, message, session, compression, transport, rpc),
|
|
@@ -236,7 +240,8 @@ export class Call {
|
|
|
236
240
|
|
|
237
241
|
/**
|
|
238
242
|
* Sets the call up on the session that carries it, negotiating compression and opening its
|
|
239
|
-
* datagram flow as the method needs, and encodes the REQUEST
|
|
243
|
+
* datagram flow as the method needs, and encodes the REQUEST with the connection metadata the
|
|
244
|
+
* call carries, offered or referred to as the session needs. A call whose requests may be
|
|
240
245
|
* stream-compressed offers optimistically while the session allows it, unless `replay` says this
|
|
241
246
|
* is the replay of a refused call or the call has a datagram flow, which a replay would need to
|
|
242
247
|
* open again.
|
|
@@ -277,13 +282,12 @@ export class Call {
|
|
|
277
282
|
}
|
|
278
283
|
}
|
|
279
284
|
}
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
|
|
285
|
+
const set = this.#setup.connectionMetadata;
|
|
286
|
+
if (set !== undefined) {
|
|
287
|
+
this.#connectionMetadata = transportConnectionMetadata(transport).session(session.key);
|
|
288
|
+
const { field, offered } = this.#connectionMetadata.prepare(set);
|
|
283
289
|
this.#offeredMetadata = offered;
|
|
284
|
-
|
|
285
|
-
request.connectionMetadata = field;
|
|
286
|
-
}
|
|
290
|
+
request.connectionMetadata = field;
|
|
287
291
|
}
|
|
288
292
|
return encodeRequestFrame(
|
|
289
293
|
request,
|
|
@@ -769,7 +773,8 @@ export class Call {
|
|
|
769
773
|
/**
|
|
770
774
|
* Opens a bidirectional stream. A call whose setup depends on its session passes `session`, which
|
|
771
775
|
* receives that session's `key` and `datagrams` object, so the compression state and datagram flow
|
|
772
|
-
* belong to the connection that carries the call and not to one a reconnect swapped in later.
|
|
776
|
+
* belong to the connection that carries the call and not to one a reconnect swapped in later. A
|
|
777
|
+
* stream the browser refuses for want of stream credit is opened again (see StreamRefusalRetryMs).
|
|
773
778
|
*/
|
|
774
779
|
function openStream(transport, signal, session) {
|
|
775
780
|
return new Promise((resolve, reject) => {
|
|
@@ -777,34 +782,72 @@ function openStream(transport, signal, session) {
|
|
|
777
782
|
reject(signal.reason);
|
|
778
783
|
return;
|
|
779
784
|
}
|
|
780
|
-
|
|
785
|
+
let retryTimer;
|
|
786
|
+
const onAbort = () => {
|
|
787
|
+
clearTimeout(retryTimer);
|
|
788
|
+
reject(signal.reason);
|
|
789
|
+
};
|
|
781
790
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
opening
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
791
|
+
const giveUpAt = Date.now() + StreamRefusalRetryMs;
|
|
792
|
+
|
|
793
|
+
const attempt = (delay) => {
|
|
794
|
+
let opening;
|
|
795
|
+
try {
|
|
796
|
+
opening = Promise.resolve(startStream(transport, session));
|
|
797
|
+
} catch (error) {
|
|
798
|
+
opening = Promise.reject(error);
|
|
799
|
+
}
|
|
800
|
+
opening.then(
|
|
801
|
+
(stream) => {
|
|
802
|
+
signal.removeEventListener("abort", onAbort);
|
|
803
|
+
if (signal.aborted) {
|
|
804
|
+
// The caller gave up while the stream was opening; do not leak it.
|
|
805
|
+
stream.writable.abort(signal.reason).catch(() => {});
|
|
806
|
+
stream.readable.cancel(signal.reason).catch(() => {});
|
|
807
|
+
} else {
|
|
808
|
+
resolve(stream);
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
(error) => {
|
|
812
|
+
if (signal.aborted) {
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (isStreamRefusal(error) && Date.now() + delay <= giveUpAt) {
|
|
816
|
+
// Nothing reached the server, so opening the stream again is safe.
|
|
817
|
+
retryTimer = setTimeout(
|
|
818
|
+
() => attempt(Math.min(delay * 2, StreamRefusalMaxDelayMs)),
|
|
819
|
+
delay,
|
|
820
|
+
);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
signal.removeEventListener("abort", onAbort);
|
|
824
|
+
reject(statusFromTransportError(error));
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
};
|
|
828
|
+
attempt(StreamRefusalFirstDelayMs);
|
|
805
829
|
});
|
|
806
830
|
}
|
|
807
831
|
|
|
832
|
+
/**
|
|
833
|
+
* How long a call keeps opening a stream the browser refused, and how long it waits between
|
|
834
|
+
* attempts. Chromium refuses a new WebTransport stream at once while the server's stream credit is
|
|
835
|
+
* used up, where the WebTransport spec has it wait for more. Credit comes back about two round
|
|
836
|
+
* trips after a stream opens, so the call waits for it itself, doubling the delay from 1 ms.
|
|
837
|
+
*/
|
|
838
|
+
const StreamRefusalRetryMs = 2000;
|
|
839
|
+
const StreamRefusalFirstDelayMs = 1;
|
|
840
|
+
const StreamRefusalMaxDelayMs = 64;
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Whether opening a stream failed because the browser refused it for now: Chromium's NetworkError
|
|
844
|
+
* "Failed to create bidirectional stream". A closed session fails differently, and a channel
|
|
845
|
+
* without a session fails with a status.
|
|
846
|
+
*/
|
|
847
|
+
function isStreamRefusal(error) {
|
|
848
|
+
return !(error instanceof TrevRpcError) && error?.name === "NetworkError";
|
|
849
|
+
}
|
|
850
|
+
|
|
808
851
|
function messageOf(item) {
|
|
809
852
|
return item.message;
|
|
810
853
|
}
|
package/src/client.js
CHANGED
|
@@ -4,18 +4,20 @@ import { interceptCall, interceptorList } from "./interceptor.js";
|
|
|
4
4
|
import { validateMetadata } from "./metadata.js";
|
|
5
5
|
import { methodSettings } from "./method-options.js";
|
|
6
6
|
import { Code, cancelled, internal, invalidArgument, statusError } from "./status.js";
|
|
7
|
+
import { requestValidation } from "./validator.js";
|
|
7
8
|
import { RpcAnswer, RpcHeaders, RpcKind, RpcStatus } from "./wire.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Creates a client for `service`, a service descriptor that protoc-gen-es generates, with one
|
|
11
12
|
* function per method, named by the method's `localName`. Its calls run the interceptors of the
|
|
12
|
-
* transport, then those of `options.interceptors
|
|
13
|
+
* transport, then those of `options.interceptors`, and `options.validator` checks the request
|
|
14
|
+
* messages they send.
|
|
13
15
|
*/
|
|
14
16
|
export function createClient(service, transport, options = {}) {
|
|
15
17
|
if (service?.kind !== "service" || !Array.isArray(service.methods)) {
|
|
16
18
|
throw invalidArgument("createClient needs a protobuf-es service descriptor");
|
|
17
19
|
}
|
|
18
|
-
const { interceptors: clientInterceptors, ...callOptions } = options;
|
|
20
|
+
const { interceptors: clientInterceptors, validator, ...callOptions } = options;
|
|
19
21
|
const defaults = mergeCallOptions(defaultCallOptions(), callOptions);
|
|
20
22
|
const interceptors = [
|
|
21
23
|
...interceptorList(transport.interceptors, "transport interceptors"),
|
|
@@ -30,6 +32,8 @@ export function createClient(service, transport, options = {}) {
|
|
|
30
32
|
path: `/${service.typeName}/${method.name}`,
|
|
31
33
|
requestType: method.input,
|
|
32
34
|
responseType: method.output,
|
|
35
|
+
// Checks each request message once it is encoded, or undefined to send them unchecked.
|
|
36
|
+
validate: requestValidation(validator, method.input),
|
|
33
37
|
// Full protobuf names, which pick the compression dictionaries.
|
|
34
38
|
types: { request: method.input.typeName, response: method.output.typeName },
|
|
35
39
|
datagrams: datagramUse(settings),
|
|
@@ -91,7 +95,7 @@ const MethodShapes = Object.freeze({
|
|
|
91
95
|
|
|
92
96
|
/** Calls a unary RPC and returns the decoded response. */
|
|
93
97
|
export async function unaryCall(transport, rpc, request, options) {
|
|
94
|
-
const body = marshalMessage(rpc.requestType, request);
|
|
98
|
+
const body = marshalMessage(rpc.requestType, request, rpc.validate);
|
|
95
99
|
const call = await Call.open(transport, requestFields(rpc, RpcKind.Unary), body, options, rpc);
|
|
96
100
|
const stream = new BidirectionalStreamingCall(call, rpc, { options });
|
|
97
101
|
// The response decides the outcome, so the request side finishes in the background.
|
|
@@ -101,7 +105,7 @@ export async function unaryCall(transport, rpc, request, options) {
|
|
|
101
105
|
|
|
102
106
|
/** Calls a server-streaming RPC and returns its response stream. */
|
|
103
107
|
export async function serverStreamingCall(transport, rpc, request, options) {
|
|
104
|
-
const body = marshalMessage(rpc.requestType, request);
|
|
108
|
+
const body = marshalMessage(rpc.requestType, request, rpc.validate);
|
|
105
109
|
const call = await Call.open(
|
|
106
110
|
transport,
|
|
107
111
|
requestFields(rpc, RpcKind.ServerStreaming),
|
|
@@ -329,10 +333,12 @@ export class BidirectionalStreamingCall {
|
|
|
329
333
|
/**
|
|
330
334
|
* Sends one request message. Fails with the server's final status if the server ended the
|
|
331
335
|
* call, as soon as it is known; with Cancelled if that status is OK; and with the error that
|
|
332
|
-
* ended the call otherwise.
|
|
336
|
+
* ended the call otherwise. A message that cannot be encoded, or that the client's validator
|
|
337
|
+
* rejects, fails with InvalidArgument (or the validator's status) and is not sent; the call
|
|
338
|
+
* goes on.
|
|
333
339
|
*/
|
|
334
340
|
async send(message) {
|
|
335
|
-
const body = marshalMessage(this.#rpc.requestType, message);
|
|
341
|
+
const body = marshalMessage(this.#rpc.requestType, message, this.#rpc.validate);
|
|
336
342
|
try {
|
|
337
343
|
await this.#call.sendMessage(body);
|
|
338
344
|
} catch (error) {
|
|
@@ -515,7 +521,7 @@ export class BidirectionalStreamingCall {
|
|
|
515
521
|
}
|
|
516
522
|
|
|
517
523
|
/** The final status arrived: it is the outcome, and it ends the upload. */
|
|
518
|
-
#endWithStatus({ code, message, metadata }) {
|
|
524
|
+
#endWithStatus({ code, message, metadata, details }) {
|
|
519
525
|
if (code === Code.Ok && this.#onResponseMetadata !== undefined) {
|
|
520
526
|
try {
|
|
521
527
|
this.#onResponseMetadata(metadata);
|
|
@@ -524,7 +530,8 @@ export class BidirectionalStreamingCall {
|
|
|
524
530
|
return;
|
|
525
531
|
}
|
|
526
532
|
}
|
|
527
|
-
const error =
|
|
533
|
+
const error =
|
|
534
|
+
code === Code.Ok ? undefined : statusError(code, message, metadata, undefined, details);
|
|
528
535
|
this.#terminal = { status: { code, message, metadata }, error };
|
|
529
536
|
this.#call.finish(error);
|
|
530
537
|
if (error === undefined) {
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// in any order, so a reference only follows a confirmation. The server merges the set into each
|
|
6
6
|
// call's metadata, the call's own entries winning, so the client just sends both; see the wiki's
|
|
7
7
|
// Connection Metadata section. This module holds the channel's set and what each session
|
|
8
|
-
// confirmed; call.js puts them on the REQUEST and learns the confirmation from the ANSWER.
|
|
8
|
+
// confirmed; call.js puts them on the REQUEST and learns the confirmation from the ANSWER. A call
|
|
9
|
+
// carries the set that was current when it started for its whole life, replays included, so a
|
|
10
|
+
// change applies to the calls that start after it.
|
|
9
11
|
import { normalizeMetadata } from "./metadata.js";
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -16,9 +18,6 @@ export function transportConnectionMetadata(transport) {
|
|
|
16
18
|
return transport.connectionMetadata ?? undefined;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
/** A request that carries no connection metadata. */
|
|
20
|
-
const NoConnectionMetadata = Object.freeze({ field: undefined, offered: 0 });
|
|
21
|
-
|
|
22
21
|
/**
|
|
23
22
|
* A channel's connection metadata, shared by its sessions: the current set, whose ID changes with
|
|
24
23
|
* it, and the state of each session.
|
|
@@ -83,17 +82,15 @@ class SessionConnectionMetadata {
|
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
/**
|
|
86
|
-
* The connection metadata of
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* which `learn` checks the ANSWER against, or zero.
|
|
85
|
+
* The connection metadata of a REQUEST that carries `set`, the current set when its call
|
|
86
|
+
* started, as `{ field, offered }`: `field` is the value of `connectionMetadata`, a reference to
|
|
87
|
+
* the set if this session confirmed it and it is still current, and an offer of it otherwise;
|
|
88
|
+
* `offered` is the ID the REQUEST offers, which `learn` checks the ANSWER against, or zero.
|
|
90
89
|
*/
|
|
91
|
-
prepare() {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
if (this.#confirmed === set.id) {
|
|
90
|
+
prepare(set) {
|
|
91
|
+
// The server keeps the newest few sets the session offered, so one replaced since its call
|
|
92
|
+
// started may be gone: only the current set is referred to.
|
|
93
|
+
if (this.#confirmed === set.id && this.#source.current === set) {
|
|
97
94
|
return { field: { id: set.id }, offered: 0 };
|
|
98
95
|
}
|
|
99
96
|
return { field: { id: set.id, metadata: set.metadata }, offered: set.id };
|
package/src/framing.js
CHANGED
|
@@ -23,17 +23,24 @@ const EmptyBytes = new Uint8Array(0);
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Encodes a protobuf message body: `message` is a message of `schema` (a protobuf-es message
|
|
26
|
-
* descriptor) or the init object `create()` takes.
|
|
26
|
+
* descriptor) or the init object `create()` takes. A message that cannot be encoded fails with
|
|
27
|
+
* InvalidArgument. Once it is encoded, `validate`, if given, checks it and throws to reject it,
|
|
28
|
+
* so that it sees only well-formed messages.
|
|
27
29
|
*/
|
|
28
|
-
export function marshalMessage(schema, message) {
|
|
30
|
+
export function marshalMessage(schema, message, validate) {
|
|
31
|
+
let value;
|
|
32
|
+
let body;
|
|
29
33
|
try {
|
|
30
|
-
|
|
34
|
+
value = create(schema, message);
|
|
35
|
+
body = toBinary(schema, value);
|
|
31
36
|
} catch (error) {
|
|
32
37
|
throw invalidArgument(
|
|
33
38
|
`failed to encode protobuf message: ${error?.message ?? String(error)}`,
|
|
34
39
|
error,
|
|
35
40
|
);
|
|
36
41
|
}
|
|
42
|
+
validate?.(value);
|
|
43
|
+
return body;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/** Decodes a protobuf message body of `schema`, rejecting malformed wire data. */
|
package/src/index.d.ts
CHANGED
|
@@ -29,15 +29,36 @@ export type MetadataValue = string | Uint8Array | ArrayBuffer | ArrayBufferView
|
|
|
29
29
|
export type MetadataInput = Record<string, MetadataValue> | ReadonlyMap<string, MetadataValue>;
|
|
30
30
|
export type Metadata = Record<string, Uint8Array>;
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* One detail of a status: a protobuf message, carried as its full name, such as
|
|
34
|
+
* `"buf.validate.Violations"`, and its encoding.
|
|
35
|
+
*/
|
|
36
|
+
export interface StatusDetail {
|
|
37
|
+
readonly type: string;
|
|
38
|
+
readonly value: Uint8Array;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TrevRpcErrorOptions extends ErrorOptions {
|
|
42
|
+
details?: readonly StatusDetail[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Error carrying a TrevRPC status code, message, metadata, and details. */
|
|
33
46
|
export class TrevRpcError extends Error {
|
|
34
47
|
/** Always `"TrevRpcError"`, for matching errors by tag, as Effect's `catchTag` does. */
|
|
35
48
|
readonly _tag: "TrevRpcError";
|
|
36
49
|
readonly code: StatusCode;
|
|
37
50
|
readonly statusMessage: string;
|
|
38
51
|
readonly metadata: Metadata;
|
|
52
|
+
/** The status details, still encoded; `findDetails` decodes those of one message type. */
|
|
53
|
+
readonly details: readonly StatusDetail[];
|
|
54
|
+
|
|
55
|
+
constructor(code: number, message?: string, metadata?: Metadata, options?: TrevRpcErrorOptions);
|
|
39
56
|
|
|
40
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Decodes the details whose type is the full name of `schema`, such as `ViolationsSchema` for
|
|
59
|
+
* `buf.validate.Violations`.
|
|
60
|
+
*/
|
|
61
|
+
findDetails<Desc extends DescMessage>(schema: Desc): MessageShape<Desc>[];
|
|
41
62
|
}
|
|
42
63
|
|
|
43
64
|
export interface CallOptions {
|
|
@@ -109,10 +130,39 @@ export type CallInterceptor = (
|
|
|
109
130
|
call: InterceptedCall,
|
|
110
131
|
) => void | CallFinish | Promise<void | CallFinish>;
|
|
111
132
|
|
|
112
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* Checks the request messages of a client before they are sent; see `ClientOptions.validator`.
|
|
135
|
+
* `@trevrpc/protovalidate` implements it with `buf.validate` rules.
|
|
136
|
+
*/
|
|
137
|
+
export interface RequestValidator {
|
|
138
|
+
/**
|
|
139
|
+
* Returns the function that checks request messages of type `schema`, or nothing when messages
|
|
140
|
+
* of that type never need checking, which costs their methods nothing. `createClient` calls it
|
|
141
|
+
* once for each method, and throws what it throws.
|
|
142
|
+
*
|
|
143
|
+
* The returned function throws to reject a message, which is then not sent: a TrevRpcError
|
|
144
|
+
* fails the request as it is, normally InvalidArgument with details that say why, and any other
|
|
145
|
+
* error fails it with InvalidArgument and the error as `cause`. It only sees messages that
|
|
146
|
+
* encode.
|
|
147
|
+
*/
|
|
148
|
+
validateFunc<Desc extends DescMessage>(
|
|
149
|
+
schema: Desc,
|
|
150
|
+
): ((message: MessageShape<Desc>) => void) | undefined | void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Options of a service client: defaults for its calls, the interceptors that run around them, and
|
|
155
|
+
* the validator of their request messages.
|
|
156
|
+
*/
|
|
113
157
|
export interface ClientOptions extends CallOptions {
|
|
114
158
|
/** Interceptors for the client's calls, after those of its transport, such as a Channel's. */
|
|
115
159
|
interceptors?: readonly CallInterceptor[];
|
|
160
|
+
/**
|
|
161
|
+
* Checks every request message before it is sent: the request of a unary or server-streaming
|
|
162
|
+
* call, which fails the call after its interceptors ran, and each message passed to `send()`,
|
|
163
|
+
* which rejects that send and leaves the call open. Without one, requests are not checked.
|
|
164
|
+
*/
|
|
165
|
+
validator?: RequestValidator;
|
|
116
166
|
}
|
|
117
167
|
|
|
118
168
|
export interface TransportStream {
|
|
@@ -239,7 +289,9 @@ export interface ClientStreamingCall<TRequest, TResponse> {
|
|
|
239
289
|
* error. A failed send is not retried or reported twice: later sends fail the same way, and
|
|
240
290
|
* `closeAndRecv()` returns the call's outcome.
|
|
241
291
|
*
|
|
242
|
-
* Pending sends are rejected too when the call is cancelled or its deadline expires.
|
|
292
|
+
* Pending sends are rejected too when the call is cancelled or its deadline expires. A
|
|
293
|
+
* message that cannot be encoded, or that the client's `validator` rejects, is not sent: the
|
|
294
|
+
* send rejects with InvalidArgument (or the validator's status) and the call goes on.
|
|
243
295
|
*/
|
|
244
296
|
send(request: TRequest): Promise<void>;
|
|
245
297
|
/**
|
|
@@ -293,7 +345,9 @@ export interface BidirectionalStreamingCall<TRequest, TResponse> extends AsyncIt
|
|
|
293
345
|
* status: at most 32 frames or 1 MiB (plus one frame of at most `maxFrameSize`), after which
|
|
294
346
|
* reading pauses until `recv()` takes messages. Responses read ahead are delivered in order.
|
|
295
347
|
*
|
|
296
|
-
* Pending sends are rejected too when the call is cancelled or its deadline expires.
|
|
348
|
+
* Pending sends are rejected too when the call is cancelled or its deadline expires. A
|
|
349
|
+
* message that cannot be encoded, or that the client's `validator` rejects, is not sent: the
|
|
350
|
+
* send rejects with InvalidArgument (or the validator's status) and the call goes on.
|
|
297
351
|
*/
|
|
298
352
|
send(request: TRequest): Promise<void>;
|
|
299
353
|
/**
|
|
@@ -363,7 +417,8 @@ export type Client<S extends DescService> = {
|
|
|
363
417
|
* Creates a client for `service`, a service descriptor that protoc-gen-es generates (such as
|
|
364
418
|
* `GreeterService` from `greeter_pb.js`). The `trevrpc.method` option of each method decides
|
|
365
419
|
* what its calls send as datagrams and compress; one the method's kind cannot use throws
|
|
366
|
-
* InvalidArgument. Calls run the transport's interceptors, then those of `options.interceptors
|
|
420
|
+
* InvalidArgument. Calls run the transport's interceptors, then those of `options.interceptors`,
|
|
421
|
+
* and `options.validator` checks the request messages they send.
|
|
367
422
|
*/
|
|
368
423
|
export function createClient<S extends DescService>(
|
|
369
424
|
service: S,
|
|
@@ -527,9 +582,11 @@ export class Channel implements Transport {
|
|
|
527
582
|
readonly interceptors: readonly CallInterceptor[];
|
|
528
583
|
/**
|
|
529
584
|
* Replaces the channel's connection metadata (see `ConnectOptions.connectionMetadata`); empty or
|
|
530
|
-
* missing metadata removes it.
|
|
531
|
-
*
|
|
532
|
-
*
|
|
585
|
+
* missing metadata removes it. A call carries the connection metadata that is current when it
|
|
586
|
+
* starts, once its interceptors have run, until it ends: calls started after this use the new
|
|
587
|
+
* metadata, and calls in flight keep theirs, even if they are still connecting. Each connection
|
|
588
|
+
* sends the new metadata again before referring to it. Throws if it is invalid, and then keeps
|
|
589
|
+
* the old.
|
|
533
590
|
*/
|
|
534
591
|
setConnectionMetadata(metadata?: MetadataInput): void;
|
|
535
592
|
createBidirectionalStream(): Promise<TransportStream>;
|
package/src/status.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fromBinary } from "@bufbuild/protobuf";
|
|
2
|
+
|
|
1
3
|
export const Code = Object.freeze({
|
|
2
4
|
Ok: 0,
|
|
3
5
|
Cancelled: 1,
|
|
@@ -20,7 +22,7 @@ export const Code = Object.freeze({
|
|
|
20
22
|
|
|
21
23
|
const CodeNames = Object.freeze(Object.keys(Code));
|
|
22
24
|
|
|
23
|
-
/** Error carrying a TrevRPC status code, message, and
|
|
25
|
+
/** Error carrying a TrevRPC status code, message, metadata, and details. */
|
|
24
26
|
export class TrevRpcError extends Error {
|
|
25
27
|
/** Creates a TrevRPC status error. */
|
|
26
28
|
constructor(code, message = "", metadata = {}, options = {}) {
|
|
@@ -34,6 +36,17 @@ export class TrevRpcError extends Error {
|
|
|
34
36
|
this.code = normalizedCode;
|
|
35
37
|
this.statusMessage = message;
|
|
36
38
|
this.metadata = metadata;
|
|
39
|
+
this.details = options.details ?? [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decodes the details whose type is the full name of `schema`, a message schema generated by
|
|
44
|
+
* protoc-gen-es, such as `ViolationsSchema` for `buf.validate.Violations`.
|
|
45
|
+
*/
|
|
46
|
+
findDetails(schema) {
|
|
47
|
+
return this.details
|
|
48
|
+
.filter((detail) => detail.type === schema.typeName)
|
|
49
|
+
.map((detail) => fromBinary(schema, detail.value));
|
|
37
50
|
}
|
|
38
51
|
}
|
|
39
52
|
|
|
@@ -52,8 +65,8 @@ export function codeName(code) {
|
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
/** Creates a TrevRPC status error. */
|
|
55
|
-
export function statusError(code, message = "", metadata = {}, cause) {
|
|
56
|
-
return new TrevRpcError(code, message, metadata, { cause });
|
|
68
|
+
export function statusError(code, message = "", metadata = {}, cause, details) {
|
|
69
|
+
return new TrevRpcError(code, message, metadata, { cause, details });
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
/** Creates an internal error status. */
|
package/src/validator.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { TrevRpcError, invalidArgument } from "./status.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the function that checks request messages of `schema` with `validator`, the
|
|
5
|
+
* `validator` option of a client, or undefined when there is no validator or it never needs to
|
|
6
|
+
* check that type, which costs the method nothing. The function throws a TrevRpcError: the one
|
|
7
|
+
* the validator threw, or InvalidArgument with any other error as cause. Throws InvalidArgument
|
|
8
|
+
* if `validator` is not a request validator, and what `validator.validateFunc` throws.
|
|
9
|
+
*/
|
|
10
|
+
export function requestValidation(validator, schema) {
|
|
11
|
+
if (validator == null) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
if (typeof validator.validateFunc !== "function") {
|
|
15
|
+
throw invalidArgument("validator must have a validateFunc method");
|
|
16
|
+
}
|
|
17
|
+
const validate = validator.validateFunc(schema);
|
|
18
|
+
if (validate == null) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
if (typeof validate !== "function") {
|
|
22
|
+
throw invalidArgument(
|
|
23
|
+
`validator.validateFunc must return a function or nothing for ${schema.typeName}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return (message) => {
|
|
27
|
+
try {
|
|
28
|
+
validate(message);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof TrevRpcError) {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
throw invalidArgument(error?.message ?? String(error), error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/wire.js
CHANGED
|
@@ -164,7 +164,10 @@ export const RpcAnswer = Object.freeze({
|
|
|
164
164
|
},
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* The STATUS payload: `{ code, message, metadata, refusedStreamCompression, details }`, where each
|
|
169
|
+
* detail is `{ type, value }`.
|
|
170
|
+
*/
|
|
168
171
|
export const RpcStatus = Object.freeze({
|
|
169
172
|
name: "RpcStatus",
|
|
170
173
|
encode(status) {
|
|
@@ -173,6 +176,14 @@ export const RpcStatus = Object.freeze({
|
|
|
173
176
|
writeString(writer, 2, status.message);
|
|
174
177
|
writeMetadata(writer, 3, status.metadata);
|
|
175
178
|
writeBool(writer, 4, status.refusedStreamCompression);
|
|
179
|
+
for (const detail of status.details ?? []) {
|
|
180
|
+
writer.tag(5, WireType.LengthDelimited).fork();
|
|
181
|
+
writeString(writer, 1, detail.type);
|
|
182
|
+
if (detail.value != null && detail.value.byteLength > 0) {
|
|
183
|
+
writer.tag(2, WireType.LengthDelimited).bytes(detail.value);
|
|
184
|
+
}
|
|
185
|
+
writer.join();
|
|
186
|
+
}
|
|
176
187
|
return writer.finish();
|
|
177
188
|
},
|
|
178
189
|
decode(bytes) {
|
|
@@ -181,6 +192,7 @@ export const RpcStatus = Object.freeze({
|
|
|
181
192
|
message: "",
|
|
182
193
|
metadata: emptyMetadata(),
|
|
183
194
|
refusedStreamCompression: false,
|
|
195
|
+
details: [],
|
|
184
196
|
};
|
|
185
197
|
const reader = new WireReader(bytes);
|
|
186
198
|
while (reader.more()) {
|
|
@@ -198,6 +210,9 @@ export const RpcStatus = Object.freeze({
|
|
|
198
210
|
case 4:
|
|
199
211
|
status.refusedStreamCompression = reader.bool(wireType, "refused_stream_compression");
|
|
200
212
|
break;
|
|
213
|
+
case 5:
|
|
214
|
+
status.details.push(reader.detail(wireType, "details"));
|
|
215
|
+
break;
|
|
201
216
|
default:
|
|
202
217
|
reader.skip(field, wireType);
|
|
203
218
|
}
|
|
@@ -366,6 +381,23 @@ class WireReader {
|
|
|
366
381
|
return info;
|
|
367
382
|
}
|
|
368
383
|
|
|
384
|
+
/** A status Detail message, `{ type, value }`. */
|
|
385
|
+
detail(wireType, name) {
|
|
386
|
+
const reader = this.#nested(wireType, name);
|
|
387
|
+
const detail = { type: "", value: new Uint8Array(0) };
|
|
388
|
+
while (reader.more()) {
|
|
389
|
+
const [field, fieldWireType] = reader.tag();
|
|
390
|
+
if (field === 1) {
|
|
391
|
+
detail.type = reader.string(fieldWireType, `${name}.type`);
|
|
392
|
+
} else if (field === 2) {
|
|
393
|
+
detail.value = reader.bytes(fieldWireType, `${name}.value`);
|
|
394
|
+
} else {
|
|
395
|
+
reader.skip(field, fieldWireType);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return detail;
|
|
399
|
+
}
|
|
400
|
+
|
|
369
401
|
/** A ConnectionMetadata message, `{ id, metadata }`. */
|
|
370
402
|
connectionMetadata(wireType, name) {
|
|
371
403
|
const reader = this.#nested(wireType, name);
|