@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 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.6.0",
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.0",
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 `performance.now()` time 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. A call whose requests may be
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
- this.#connectionMetadata = transportConnectionMetadata(transport)?.session(session.key);
281
- if (this.#connectionMetadata !== undefined) {
282
- const { field, offered } = this.#connectionMetadata.prepare();
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
- if (field !== undefined) {
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
- const onAbort = () => reject(signal.reason);
785
+ let retryTimer;
786
+ const onAbort = () => {
787
+ clearTimeout(retryTimer);
788
+ reject(signal.reason);
789
+ };
781
790
  signal.addEventListener("abort", onAbort, { once: true });
782
-
783
- let opening;
784
- try {
785
- opening = Promise.resolve(startStream(transport, session));
786
- } catch (error) {
787
- opening = Promise.reject(error);
788
- }
789
- opening.then(
790
- (stream) => {
791
- signal.removeEventListener("abort", onAbort);
792
- if (signal.aborted) {
793
- // The caller gave up while the stream was opening; do not leak it.
794
- stream.writable.abort(signal.reason).catch(() => {});
795
- stream.readable.cancel(signal.reason).catch(() => {});
796
- } else {
797
- resolve(stream);
798
- }
799
- },
800
- (error) => {
801
- signal.removeEventListener("abort", onAbort);
802
- reject(statusFromTransportError(error));
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 = code === Code.Ok ? undefined : statusError(code, message, metadata);
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 one REQUEST as `{ field, offered }`: `field` is the value of
87
- * `connectionMetadata`, a reference to the current set once the session confirmed it and an
88
- * offer of it otherwise, or undefined without a set; `offered` is the ID the REQUEST offers,
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
- const set = this.#source.current;
93
- if (set === undefined) {
94
- return NoConnectionMetadata;
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
- return toBinary(schema, create(schema, message));
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
- /** Error carrying a TrevRPC status code, message, and metadata. */
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
- constructor(code: number, message?: string, metadata?: Metadata, options?: ErrorOptions);
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
- /** Options of a service client: defaults for its calls, and interceptors that run around them. */
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. Calls started after this use the new metadata, and each
531
- * connection sends it again before referring to it. Throws if it is invalid, and then keeps the
532
- * old.
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 metadata. */
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. */
@@ -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
- /** The STATUS payload: `{ code, message, metadata, refusedStreamCompression }`. */
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);