@trevrpc/trevrpc-js 1.3.1 → 1.4.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
@@ -36,11 +36,39 @@ protoc \
36
36
 
37
37
  This writes `greeter.trevrpc.js` and `greeter.trevrpc.d.ts`. Plugin parameters (`--trevrpc-js_out=key=value,key=value:dir`):
38
38
 
39
- | Parameter | Default | Meaning |
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. |
39
+ | Parameter | Default | Meaning |
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`. |
45
+
46
+ ### Generated types
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`.
54
+
55
+ ```ts
56
+ import { TimeInterval, type Sample } from "./gen/metrics.trevrpc.js";
57
+
58
+ const sample: Sample = await client.get({ interval: TimeInterval.HOUR });
59
+ sample.count; // bigint, never undefined
60
+ ```
61
+
62
+ 64-bit integers decode as `bigint` by default; requests take a `bigint`, a `number` or a decimal `string`. The `int64` plugin option picks `number` (exact up to 2^53), `string`, or `long` (protobuf.js `Long` objects, without the conversion) instead, and a field's `jstype` option (`[jstype = JS_STRING]` or `JS_NUMBER`) overrides it for that field. 64-bit map keys are decimal strings. `JSON.stringify` throws on a `bigint`, so a decoded message with a 64-bit field needs a replacer to serialize, such as `(key, value) => (typeof value === "bigint" ? value.toString() : value)`, or generate with `int64=string`.
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.
65
+
66
+ ```ts
67
+ import { timestampDate, timestampFromDate } from "@trevrpc/trevrpc-js";
68
+
69
+ await client.put({ at: timestampFromDate(new Date()) });
70
+ const at = sample.at === null ? undefined : timestampDate(sample.at);
71
+ ```
44
72
 
45
73
  ## Client
46
74
 
@@ -82,7 +110,7 @@ channel.close();
82
110
 
83
111
  ### Call options
84
112
 
85
- Every method takes `CallOptions` as its last argument; the same options can be given to the client constructor as defaults, and per-call `metadata` is merged over the defaults per key.
113
+ Every method takes `CallOptions` as its last argument; the same options can be given to the client constructor as defaults, and per-call `metadata` is merged over the defaults per key. The constructor also takes `interceptors` ([below](#interceptors)).
86
114
 
87
115
  | Option | Meaning |
88
116
  | -------------------- | --------------------------------------------------------------------------------------------- |
@@ -118,6 +146,8 @@ call.
118
146
 
119
147
  `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.
120
148
 
149
+ `createChannel(url, options)` takes the same options but returns the channel right away, in the `"connecting"` state, so generated clients can be created before it is connected. Calls started while it connects wait for that first attempt, within their own deadline and signal; if it fails they fail with its error, and the channel goes on reconnecting with backoff like after a lost session. The `signal` option does not apply to it; close the channel to give up.
150
+
121
151
  ```js
122
152
  const channel = await connect(url, {
123
153
  signal: AbortSignal.timeout(10_000), // cancels the initial connection only
@@ -126,11 +156,11 @@ const channel = await connect(url, {
126
156
  });
127
157
  ```
128
158
 
129
- `serverCertificateHashes` trusts a self-signed ECDSA certificate that is valid for at most 14 days by its SHA-256 hash, which is how development servers and the interop tests are reached. It applies to WebTransport only: the WebSocket fallback needs a certificate the browser trusts. `allowPooling`, `congestionControl` and `requireUnreliable` are passed to the `WebTransport` constructor.
159
+ `serverCertificateHashes` trusts a self-signed ECDSA certificate that is valid for at most 14 days by its SHA-256 hash, which is how development servers and the interop tests are reached. It applies to WebTransport only: the WebSocket fallback needs a certificate the browser trusts. `serverCertificateHashesUrl` names a URL that publishes a development server's hashes, such as a path the development web server proxies to the Go or Rust `DevelopmentCertificate`. Every WebTransport attempt fetches it, so reconnects follow a rotated certificate, and adds its hashes; a 404 adds none, so the same build works against a server with a trusted certificate. `fetchServerCertificateHashes(url)` returns the same hashes for code that builds its own options. See [Development certificates](https://trev.zip/llc/TrevRPC/wiki/WebTransport#development-certificates). `allowPooling`, `congestionControl` and `requireUnreliable` are passed to the `WebTransport` constructor.
130
160
 
131
161
  `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.
132
162
 
133
- Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a generated client. Create such a session with `new WebTransport(url, { protocols: ["trevrpc-1"] })` so the server knows the wire version. Requests name the method by its full path, `/<package.Service>/<Method>` (for example `/example.greeter.Greeter/SayHello`), which the generated clients build from the service and method names.
163
+ Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a generated 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 the generated clients build from the service and method names.
134
164
 
135
165
  ### Connection metadata
136
166
 
@@ -142,7 +172,35 @@ channel.setConnectionMetadata({ authorization: `Bearer ${refreshed}` }); // call
142
172
  channel.setConnectionMetadata(); // removes it
143
173
  ```
144
174
 
145
- 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. The `Channel` is the only transport that supports it.
175
+ 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`.
176
+
177
+ ### Interceptors
178
+
179
+ Interceptors run around every call of a client, for what each call needs, such as reacting to an expired session. Set them on the channel, for every client created on it, or on a client, after the channel's:
180
+
181
+ ```js
182
+ const channel = createChannel(url, {
183
+ interceptors: [
184
+ async (call) => {
185
+ // call.method, .service, .name, .kind, .timeoutMs and .signal describe the call.
186
+ call.metadata.authorization = `Bearer ${await session.token()}`;
187
+ const started = session.id;
188
+ return ({ code }) => {
189
+ if (code === Code.Unauthenticated && session.id === started) {
190
+ session.clear();
191
+ location.assign("/sign-in");
192
+ }
193
+ };
194
+ },
195
+ ],
196
+ });
197
+ const client = new GreeterClient(channel, { interceptors: [logCalls] });
198
+ ```
199
+
200
+ - 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`.
201
+ - It may return a finish function, which is called once with `{ code, error }` when the call ends: when a unary call settles, and when a streaming call receives its final status or fails, including when it is cancelled, so nothing has to wrap a stream's iterator. `code` is `Code.Ok` on success. Finish functions run innermost first; one that throws is reported and does not affect the call. A streaming call that is neither read to its end nor closed does not end.
202
+ - An interceptor that throws fails the call with what it threw before it starts; the finish functions of earlier interceptors still run.
203
+ - A client-streaming call has a `status` promise too, like the other streaming calls.
146
204
 
147
205
  ### Compression
148
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevrpc/trevrpc-js",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Browser WebTransport client and protobuf.js code generator for TrevRPC",
5
5
  "homepage": "https://trev.zip/llc/TrevRPC",
6
6
  "bugs": {
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "scripts": {
40
- "build": "node zstd-wasm/embed.js && node interop-browser/build.js",
40
+ "build": "node zstd-wasm/embed.js && node interop-browser/build.js && node test/generate.js",
41
41
  "format": "oxfmt --write",
42
42
  "format:check": "oxfmt --check",
43
43
  "lint": "oxlint --deny-warnings",
@@ -0,0 +1,57 @@
1
+ import { unavailable } from "./status.js";
2
+
3
+ /**
4
+ * Fetches the certificate hashes a TrevRPC development server publishes, as WebTransport
5
+ * `serverCertificateHashes`. The server answers JSON such as
6
+ * `[{"algorithm":"sha-256","value":"<hex>"}]`; a 404 means it has a certificate the browser trusts
7
+ * already, and yields no hashes. Any other failure rejects with Unavailable.
8
+ */
9
+ export async function fetchServerCertificateHashes(url, { signal, fetch = globalThis.fetch } = {}) {
10
+ if (typeof fetch !== "function") {
11
+ throw unavailable("fetch is not available in this JavaScript runtime");
12
+ }
13
+ let response;
14
+ try {
15
+ response = await fetch(url, { signal, cache: "no-store" });
16
+ } catch (error) {
17
+ if (signal?.aborted) {
18
+ throw error;
19
+ }
20
+ throw unavailable(`cannot fetch server certificate hashes: ${error?.message ?? error}`, error);
21
+ }
22
+ if (response.status === 404) {
23
+ return [];
24
+ }
25
+ if (!response.ok) {
26
+ throw unavailable(`cannot fetch server certificate hashes: HTTP ${response.status}`);
27
+ }
28
+ let entries;
29
+ try {
30
+ entries = await response.json();
31
+ } catch (error) {
32
+ if (signal?.aborted) {
33
+ throw error;
34
+ }
35
+ throw unavailable("server certificate hashes are not JSON", error);
36
+ }
37
+ if (!Array.isArray(entries)) {
38
+ throw unavailable("server certificate hashes are not a JSON array");
39
+ }
40
+ return entries.map(parseHash);
41
+ }
42
+
43
+ function parseHash(entry) {
44
+ const { algorithm, value } = entry ?? {};
45
+ if (
46
+ typeof algorithm !== "string" ||
47
+ typeof value !== "string" ||
48
+ !/^(?:[0-9a-f]{2})+$/iu.test(value)
49
+ ) {
50
+ throw unavailable(`invalid server certificate hash ${JSON.stringify(entry)}`);
51
+ }
52
+ const bytes = new Uint8Array(value.length / 2);
53
+ for (let index = 0; index < bytes.length; index += 1) {
54
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
55
+ }
56
+ return { algorithm, value: bytes };
57
+ }
package/src/channel.js CHANGED
@@ -1,8 +1,10 @@
1
- import { ConnectionMetadataKey, ConnectionMetadataSource } from "./connection-metadata.js";
1
+ import { fetchServerCertificateHashes } from "./certificate-hashes.js";
2
+ import { ConnectionMetadataSource } from "./connection-metadata.js";
3
+ import { interceptorList } from "./interceptor.js";
2
4
  import { QMuxWebSocketSession } from "./qmux/session.js";
3
5
  import { dialRace } from "./race.js";
4
6
  import { cancelled, failedPrecondition, statusFromTransportError, unavailable } from "./status.js";
5
- import { StreamBudget, StreamBudgetKey } from "./stream-compression.js";
7
+ import { StreamBudget } from "./stream-compression.js";
6
8
 
7
9
  /**
8
10
  * The WebTransport protocol that names wire version 1, offered through the `protocols` constructor
@@ -28,57 +30,77 @@ const DefaultReprobeIntervalMs = 5 * 60_000;
28
30
  * A session that reconnects in the background after it is lost: WebTransport, or QMux over
29
31
  * WebSocket when WebTransport is unavailable or blocked. Calls open their streams on the current
30
32
  * session and fail fast with Unavailable while it reconnects; a call that was in flight when the
31
- * session was lost is never replayed.
33
+ * session was lost is never replayed. Calls started while the channel first connects wait for
34
+ * that attempt, and fail with its error if it fails.
32
35
  */
33
36
  export class Channel {
34
37
  #dial;
35
38
  #compression;
39
+ #streamCompressionBudget;
40
+ #connectionMetadata;
41
+ #interceptors;
36
42
  #onStateChange;
37
43
  #session;
38
44
  #transport;
39
- #state = "ready";
45
+ #state = "connecting";
46
+ /**
47
+ * Settles once the first attempt to connect did: with undefined if it established a session,
48
+ * and with its error otherwise.
49
+ */
50
+ #connecting;
40
51
  #waiters = [];
41
52
  #stopped = new AbortController();
42
53
 
43
54
  /** Opens a channel and waits for its first session; use connect() instead. */
44
55
  static async connect(url, options = {}) {
45
- const dial = sessionDialer(url, options);
46
- // Checked before dialing, so an invalid option never leaves a session behind.
47
- const streamBudget = new StreamBudget(options.streamCompressionMemory);
48
- const connectionMetadata = new ConnectionMetadataSource(options.connectionMetadata);
49
- return new Channel(await dial(options.signal), dial, {
50
- ...options,
51
- streamBudget,
52
- connectionMetadata,
53
- });
56
+ const { signal } = options;
57
+ if (signal?.aborted) {
58
+ throw abortError(signal);
59
+ }
60
+ const channel = new Channel(url, options, { retry: false });
61
+ const abandon = () => channel.close();
62
+ signal?.addEventListener("abort", abandon, { once: true });
63
+ try {
64
+ const failure = await channel.#connecting;
65
+ if (signal?.aborted) {
66
+ throw abortError(signal);
67
+ }
68
+ if (failure !== undefined) {
69
+ throw failure;
70
+ }
71
+ return channel;
72
+ } finally {
73
+ signal?.removeEventListener("abort", abandon);
74
+ }
54
75
  }
55
76
 
56
- constructor(
57
- dialed,
58
- dial,
59
- {
60
- compression,
61
- streamBudget = new StreamBudget(),
62
- connectionMetadata = new ConnectionMetadataSource(),
63
- onStateChange,
64
- } = {},
65
- ) {
66
- this.#dial = dial;
67
- this.#compression = compression;
68
- // The memory budget of the stream compression contexts of this channel's calls.
69
- this[StreamBudgetKey] = streamBudget;
70
- // The connection metadata every call of this channel carries.
71
- this[ConnectionMetadataKey] = connectionMetadata;
72
- this.#onStateChange = onStateChange;
73
- this.#adopt(dialed);
77
+ /**
78
+ * Starts connecting; use createChannel() or connect() instead. Unless `retry` is false, a first
79
+ * attempt that fails is retried like a reconnect; otherwise it closes the channel.
80
+ */
81
+ constructor(url, options = {}, { retry = true } = {}) {
82
+ // Checked before dialing, so an invalid option never leaves a session behind.
83
+ this.#dial = sessionDialer(url, options);
84
+ this.#interceptors = interceptorList(options.interceptors, "interceptors");
85
+ this.#streamCompressionBudget = new StreamBudget(options.streamCompressionMemory);
86
+ this.#connectionMetadata = new ConnectionMetadataSource(options.connectionMetadata);
87
+ this.#compression = options.compression;
88
+ this.#onStateChange = options.onStateChange;
89
+ this.#connecting = this.#connect(retry);
74
90
  }
75
91
 
76
- /** "ready", "reconnecting" while the session is being re-established, or "closed". */
92
+ /**
93
+ * "connecting" until the first session is ready, "ready", "reconnecting" while the session is
94
+ * being re-established, or "closed".
95
+ */
77
96
  get state() {
78
97
  return this.#state;
79
98
  }
80
99
 
81
- /** The transport of the current or last session: "webtransport" or "websocket". */
100
+ /**
101
+ * The transport of the current or last session: "webtransport" or "websocket"; undefined before
102
+ * the first.
103
+ */
82
104
  get transport() {
83
105
  return this.#transport;
84
106
  }
@@ -88,21 +110,33 @@ export class Channel {
88
110
  return this.#compression;
89
111
  }
90
112
 
113
+ /** The memory budget of the stream compression contexts of this channel's calls. */
114
+ get streamCompressionBudget() {
115
+ return this.#streamCompressionBudget;
116
+ }
117
+
118
+ /** The connection metadata every call of this channel carries. */
119
+ get connectionMetadata() {
120
+ return this.#connectionMetadata;
121
+ }
122
+
123
+ /** The interceptors that every client created on this channel runs around its calls. */
124
+ get interceptors() {
125
+ return this.#interceptors;
126
+ }
127
+
91
128
  /**
92
129
  * Replaces the channel's connection metadata, which every call carries; empty or missing
93
130
  * metadata removes it. Calls started after this use the new metadata. Throws InvalidArgument if
94
131
  * it is invalid, and then keeps the old.
95
132
  */
96
133
  setConnectionMetadata(metadata) {
97
- this[ConnectionMetadataKey].set(metadata);
134
+ this.#connectionMetadata.set(metadata);
98
135
  }
99
136
 
100
137
  /** Opens a bidirectional stream on the current session. */
101
138
  createBidirectionalStream() {
102
- if (this.#state !== "ready") {
103
- return Promise.reject(unavailable(`channel is ${this.#state}`));
104
- }
105
- return this.#session.createBidirectionalStream();
139
+ return this.#withSession((session) => session.createBidirectionalStream());
106
140
  }
107
141
 
108
142
  /**
@@ -113,11 +147,29 @@ export class Channel {
113
147
  * meanwhile.
114
148
  */
115
149
  createBidirectionalStreamWithSession() {
116
- if (this.#state !== "ready") {
117
- return Promise.reject(unavailable(`channel is ${this.#state}`));
150
+ return this.#withSession((session) =>
151
+ session.createBidirectionalStream().then((stream) => ({ stream, session })),
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Calls `open` with the current session. While the channel first connects, that waits for the
157
+ * attempt, and a failed attempt fails the call with its error; otherwise a channel without a
158
+ * session fails it with Unavailable.
159
+ */
160
+ #withSession(open) {
161
+ if (this.#state === "ready") {
162
+ return open(this.#session);
118
163
  }
119
- const session = this.#session;
120
- return session.createBidirectionalStream().then((stream) => ({ stream, session }));
164
+ if (this.#state === "connecting") {
165
+ // The state has moved on once the attempt settled, so this does not wait again.
166
+ return this.#connecting.then((failure) =>
167
+ failure === undefined || this.#state === "closed"
168
+ ? this.#withSession(open)
169
+ : Promise.reject(failure),
170
+ );
171
+ }
172
+ return Promise.reject(unavailable(`channel is ${this.#state}`));
121
173
  }
122
174
 
123
175
  /** Resolves once the channel is ready, immediately if it already is. */
@@ -146,6 +198,42 @@ export class Channel {
146
198
  closeQuietly(session, closeInfo);
147
199
  }
148
200
 
201
+ /**
202
+ * Makes the first attempt to connect, and returns its error, or undefined once it established a
203
+ * session. A failure starts reconnecting if `retry` is true and closes the channel otherwise.
204
+ */
205
+ async #connect(retry) {
206
+ const { signal } = this.#stopped;
207
+ let dialed;
208
+ try {
209
+ dialed = await this.#dial(signal);
210
+ } catch (error) {
211
+ if (!signal.aborted) {
212
+ if (retry) {
213
+ void this.#reconnect(error);
214
+ } else {
215
+ this.close();
216
+ }
217
+ }
218
+ return error;
219
+ }
220
+ if (signal.aborted) {
221
+ closeQuietly(dialed.session);
222
+ return abortError(signal);
223
+ }
224
+ this.#ready(dialed);
225
+ return undefined;
226
+ }
227
+
228
+ /** Adopts a new session and wakes up whoever waits for the channel to be ready. */
229
+ #ready(dialed, extra) {
230
+ this.#adopt(dialed);
231
+ this.#transition("ready", { ...extra, transport: dialed.transport });
232
+ for (const waiter of this.#waiters.splice(0)) {
233
+ waiter.resolve();
234
+ }
235
+ }
236
+
149
237
  #adopt({ session, transport }) {
150
238
  this.#session = session;
151
239
  this.#transport = transport;
@@ -178,11 +266,7 @@ export class Channel {
178
266
  closeQuietly(dialed.session);
179
267
  return;
180
268
  }
181
- this.#adopt(dialed);
182
- this.#transition("ready", { attempt, transport: dialed.transport });
183
- for (const waiter of this.#waiters.splice(0)) {
184
- waiter.resolve();
185
- }
269
+ this.#ready(dialed, { attempt });
186
270
  return;
187
271
  } catch (dialError) {
188
272
  error = dialError;
@@ -201,11 +285,24 @@ export class Channel {
201
285
  }
202
286
  }
203
287
 
204
- /** Opens a channel to a TrevRPC server and waits for its session to be ready. */
288
+ /**
289
+ * Opens a channel to a TrevRPC server and waits for its session to be ready. It rejects, leaving
290
+ * nothing behind, if the first attempt fails.
291
+ */
205
292
  export function connect(url, options = {}) {
206
293
  return Channel.connect(url, options);
207
294
  }
208
295
 
296
+ /**
297
+ * Returns a channel to a TrevRPC server that connects in the background, so that clients can be
298
+ * created on it right away. Calls started before its first session is ready wait for that attempt;
299
+ * a failed attempt fails them and the channel goes on reconnecting with backoff. The `signal`
300
+ * option does not apply: close the channel instead.
301
+ */
302
+ export function createChannel(url, options = {}) {
303
+ return new Channel(url, options);
304
+ }
305
+
209
306
  /**
210
307
  * Returns `dial(signal)`, which establishes the next session as `{ session, transport }`.
211
308
  *
@@ -263,7 +360,11 @@ function defaultWebSocketUrl(url) {
263
360
  return webSocketUrl.href;
264
361
  }
265
362
 
266
- /** Establishes a ready WebTransport session; aborting the signal abandons the attempt. */
363
+ /**
364
+ * Establishes a ready WebTransport session; aborting the signal abandons the attempt. With
365
+ * `serverCertificateHashesUrl`, each attempt first fetches the hashes, so that reconnects trust a
366
+ * development certificate that rotated meanwhile.
367
+ */
267
368
  function dialWebTransport(url, options, signal) {
268
369
  const WebTransportConstructor = options.WebTransport ?? globalThis.WebTransport;
269
370
  if (typeof WebTransportConstructor !== "function") {
@@ -272,11 +373,28 @@ function dialWebTransport(url, options, signal) {
272
373
  if (signal?.aborted) {
273
374
  throw abortError(signal);
274
375
  }
275
- return establish(
276
- new WebTransportConstructor(url, transportOptions(options)),
277
- "WebTransport",
278
- signal,
279
- requireWireVersion,
376
+ const open = (transport) =>
377
+ establish(
378
+ new WebTransportConstructor(url, transport),
379
+ "WebTransport",
380
+ signal,
381
+ requireWireVersion,
382
+ );
383
+ if (options.serverCertificateHashesUrl === undefined) {
384
+ return open(transportOptions(options));
385
+ }
386
+ return fetchServerCertificateHashes(options.serverCertificateHashesUrl, { signal }).then(
387
+ (fetched) => {
388
+ if (signal?.aborted) {
389
+ throw abortError(signal);
390
+ }
391
+ const transport = transportOptions(options);
392
+ const hashes = [...(transport.serverCertificateHashes ?? []), ...fetched];
393
+ if (hashes.length > 0) {
394
+ transport.serverCertificateHashes = hashes;
395
+ }
396
+ return open(transport);
397
+ },
280
398
  );
281
399
  }
282
400
 
package/src/client.js CHANGED
@@ -1,12 +1,22 @@
1
1
  import { Call, EarlyOk, defaultCallOptions, mergeCallOptions } from "./call.js";
2
2
  import { FrameType, marshalMessage, unmarshalMessage } from "./framing.js";
3
+ import { int64Converter } from "./int64.js";
4
+ import { interceptCall, interceptorList } from "./interceptor.js";
3
5
  import { validateMetadata } from "./metadata.js";
4
6
  import { Code, cancelled, internal, statusError } from "./status.js";
5
7
  import { RpcAnswer, RpcHeaders, RpcKind, RpcStatus } from "./wire.js";
6
8
 
7
- /** Creates a service client from a generated service descriptor. */
9
+ /**
10
+ * Creates a service client from a generated service descriptor. Its calls run the interceptors of
11
+ * the transport, then those of `options.interceptors`.
12
+ */
8
13
  export function createServiceClient(transport, service, root, options = {}) {
9
- const defaults = mergeCallOptions(defaultCallOptions(), options);
14
+ const { interceptors: clientInterceptors, ...callOptions } = options;
15
+ const defaults = mergeCallOptions(defaultCallOptions(), callOptions);
16
+ const interceptors = [
17
+ ...interceptorList(transport.interceptors, "transport interceptors"),
18
+ ...interceptorList(clientInterceptors, "interceptors"),
19
+ ];
10
20
  const client = {};
11
21
 
12
22
  for (const [jsName, method] of Object.entries(service.methods)) {
@@ -17,6 +27,8 @@ export function createServiceClient(transport, service, root, options = {}) {
17
27
  path: `/${service.fullName}/${method.name}`,
18
28
  requestType,
19
29
  responseType,
30
+ // Converts the 64-bit integers of decoded responses, or undefined when they need nothing.
31
+ convertResponse: int64Converter(responseType),
20
32
  // Full protobuf names, which pick the compression dictionaries.
21
33
  types: { request: fullName(requestType), response: fullName(responseType) },
22
34
  datagrams: datagramUse(method),
@@ -24,31 +36,61 @@ export function createServiceClient(transport, service, root, options = {}) {
24
36
  compress: plainCompressionUse(method),
25
37
  };
26
38
  const resolve = (override) => mergeCallOptions(defaults, override);
27
-
28
- switch (method.kind) {
29
- case "unary":
30
- client[jsName] = async (request, override) =>
31
- unaryCall(transport, rpc, request, resolve(override));
32
- break;
33
- case "serverStreaming":
34
- client[jsName] = (request, override) =>
35
- serverStreamingCall(transport, rpc, request, resolve(override));
36
- break;
37
- case "clientStreaming":
38
- client[jsName] = (override) => clientStreamingCall(transport, rpc, resolve(override));
39
- break;
40
- case "bidirectionalStreaming":
41
- client[jsName] = (override) =>
42
- bidirectionalStreamingCall(transport, rpc, resolve(override));
43
- break;
44
- default:
45
- throw internal(`unsupported generated RPC kind ${JSON.stringify(method.kind)}`);
39
+ const shape = MethodShapes[method.kind];
40
+ if (shape === undefined) {
41
+ throw internal(`unsupported generated RPC kind ${JSON.stringify(method.kind)}`);
46
42
  }
43
+ const info = {
44
+ method: rpc.path,
45
+ service: service.fullName,
46
+ name: method.name,
47
+ kind: method.kind,
48
+ };
49
+ const call =
50
+ interceptors.length === 0
51
+ ? async (request, override) => shape.start(transport, rpc, request, resolve(override))
52
+ : async (request, override) =>
53
+ interceptCall(
54
+ interceptors,
55
+ info,
56
+ resolve(override),
57
+ (options) => shape.start(transport, rpc, request, options),
58
+ shape.ended,
59
+ );
60
+ client[jsName] = shape.takesRequest ? call : (override) => call(undefined, override);
47
61
  }
48
62
 
49
63
  return client;
50
64
  }
51
65
 
66
+ /**
67
+ * How each kind of RPC is called: whether its client method takes a request message, how it
68
+ * starts, and, for streaming calls, what settles when the call ends after it started.
69
+ */
70
+ const MethodShapes = Object.freeze({
71
+ __proto__: null,
72
+ unary: {
73
+ takesRequest: true,
74
+ start: unaryCall,
75
+ },
76
+ serverStreaming: {
77
+ takesRequest: true,
78
+ start: serverStreamingCall,
79
+ ended: (stream) => stream.status,
80
+ },
81
+ clientStreaming: {
82
+ takesRequest: false,
83
+ start: (transport, rpc, _request, options) => clientStreamingCall(transport, rpc, options),
84
+ ended: (call) => call.status,
85
+ },
86
+ bidirectionalStreaming: {
87
+ takesRequest: false,
88
+ start: (transport, rpc, _request, options) =>
89
+ bidirectionalStreamingCall(transport, rpc, options),
90
+ ended: (call) => call.status,
91
+ },
92
+ });
93
+
52
94
  /** Calls a unary RPC and returns the decoded response. */
53
95
  export async function unaryCall(transport, rpc, request, options) {
54
96
  const body = marshalMessage(rpc.requestType, request);
@@ -376,7 +418,8 @@ export class BidirectionalStreamingCall {
376
418
  const item = await this.#take();
377
419
  if (item.payload !== undefined) {
378
420
  try {
379
- return unmarshalMessage(this.#rpc.responseType, item.payload);
421
+ const message = unmarshalMessage(this.#rpc.responseType, item.payload);
422
+ return this.#rpc.convertResponse?.(message) ?? message;
380
423
  } catch (error) {
381
424
  throw this.#settleFailure(this.#call.fail(error));
382
425
  }
@@ -678,6 +721,11 @@ export class ClientStreamingCall {
678
721
  this.#call = call;
679
722
  }
680
723
 
724
+ /** The final status, or the error that ended the call. */
725
+ get status() {
726
+ return this.#call.status;
727
+ }
728
+
681
729
  /** Sends one request message. */
682
730
  send(message) {
683
731
  return this.#call.send(message);
@@ -8,12 +8,12 @@
8
8
  // confirmed; call.js puts them on the REQUEST and learns the confirmation from the ANSWER.
9
9
  import { normalizeMetadata } from "./metadata.js";
10
10
 
11
- /** The property of a transport that holds its ConnectionMetadataSource. */
12
- export const ConnectionMetadataKey = Symbol("trevrpc.connectionMetadata");
13
-
14
- /** The connection metadata of a transport, or undefined without a source. */
11
+ /**
12
+ * The connection metadata of a transport: a Channel's own, which a transport that wraps one
13
+ * forwards; undefined without a source.
14
+ */
15
15
  export function transportConnectionMetadata(transport) {
16
- return transport[ConnectionMetadataKey];
16
+ return transport.connectionMetadata ?? undefined;
17
17
  }
18
18
 
19
19
  /** A request that carries no connection metadata. */