@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 +67 -9
- package/package.json +2 -2
- package/src/certificate-hashes.js +57 -0
- package/src/channel.js +172 -54
- package/src/client.js +70 -22
- package/src/connection-metadata.js +5 -5
- package/src/generator.js +275 -68
- package/src/index.d.ts +280 -10
- package/src/index.js +15 -1
- package/src/int64.js +288 -0
- package/src/interceptor.js +102 -0
- package/src/stream-compression.js +5 -5
- package/src/wire.js +31 -7
- package/src/wkt.js +137 -0
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.
|
|
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
|
+
"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 {
|
|
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
|
|
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 = "
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.#
|
|
67
|
-
this.#
|
|
68
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
134
|
+
this.#connectionMetadata.set(metadata);
|
|
98
135
|
}
|
|
99
136
|
|
|
100
137
|
/** Opens a bidirectional stream on the current session. */
|
|
101
138
|
createBidirectionalStream() {
|
|
102
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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.#
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
16
|
+
return transport.connectionMetadata ?? undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** A request that carries no connection metadata. */
|