@trevrpc/trevrpc-js 1.3.2 → 1.5.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 +113 -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 +288 -10
- package/src/index.js +15 -1
- package/src/int64.js +288 -0
- package/src/interceptor.js +102 -0
- package/src/status.js +4 -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
|
| -------------------- | --------------------------------------------------------------------------------------------- |
|
|
@@ -114,10 +142,54 @@ call.
|
|
|
114
142
|
|
|
115
143
|
- To find the final status while the caller only sends, a client-streaming or bidirectional call reads the response ahead of `recv()` while its request side is open: at most 32 frames or 1 MiB (plus one frame of at most `maxFrameSize`) are held; beyond that reading pauses and flow control applies until `recv()` takes messages. Responses read ahead are delivered in order, including before an error that ended the stream.
|
|
116
144
|
|
|
145
|
+
### Rendering long streams
|
|
146
|
+
|
|
147
|
+
Once response messages are buffered, `for await` hands them over without returning to the event loop: each `next()` resolves as a microtask, as with any async iterator over a `fetch` body. When the network delivers faster than the page consumes, which is always the case on localhost, a loop over a long stream runs to its end before the browser renders anything, timers fire, or input is handled. The client does not yield on its own, because a timer-based yield would slow streams in hidden tabs and add latency for consumers that do not render.
|
|
148
|
+
|
|
149
|
+
A page that renders a stream should batch messages per animation frame and, after a time budget, yield with a timer so that frame can render:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
let batch = [];
|
|
153
|
+
let frame;
|
|
154
|
+
const flush = () => {
|
|
155
|
+
frame = undefined;
|
|
156
|
+
store.append(batch);
|
|
157
|
+
batch = [];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
let sliceStart = performance.now();
|
|
161
|
+
try {
|
|
162
|
+
for await (const row of stream) {
|
|
163
|
+
batch.push(row);
|
|
164
|
+
frame ??= requestAnimationFrame(flush);
|
|
165
|
+
if (performance.now() - sliceStart > 8) {
|
|
166
|
+
if (document.hidden) {
|
|
167
|
+
cancelAnimationFrame(frame);
|
|
168
|
+
flush();
|
|
169
|
+
} else {
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
171
|
+
}
|
|
172
|
+
sliceStart = performance.now();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
if (frame !== undefined) {
|
|
177
|
+
cancelAnimationFrame(frame);
|
|
178
|
+
flush();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- `scheduler.yield()` does not help: Chromium resumes its continuation before rendering, so every message still lands in one frame.
|
|
184
|
+
- Hidden tabs run no animation frames and throttle timers to about one per second, so while `document.hidden` the loop applies the batch itself after each time budget instead of yielding. It holds at most one budget's worth of messages, and a stream in a background tab keeps its speed.
|
|
185
|
+
- If the stream fails or the loop is left early, which cancels the call, the `finally` still applies the messages received before.
|
|
186
|
+
|
|
117
187
|
### Connections
|
|
118
188
|
|
|
119
189
|
`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
190
|
|
|
191
|
+
`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.
|
|
192
|
+
|
|
121
193
|
```js
|
|
122
194
|
const channel = await connect(url, {
|
|
123
195
|
signal: AbortSignal.timeout(10_000), // cancels the initial connection only
|
|
@@ -126,11 +198,11 @@ const channel = await connect(url, {
|
|
|
126
198
|
});
|
|
127
199
|
```
|
|
128
200
|
|
|
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.
|
|
201
|
+
`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
202
|
|
|
131
203
|
`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
204
|
|
|
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.
|
|
205
|
+
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
206
|
|
|
135
207
|
### Connection metadata
|
|
136
208
|
|
|
@@ -142,7 +214,39 @@ channel.setConnectionMetadata({ authorization: `Bearer ${refreshed}` }); // call
|
|
|
142
214
|
channel.setConnectionMetadata(); // removes it
|
|
143
215
|
```
|
|
144
216
|
|
|
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.
|
|
217
|
+
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`.
|
|
218
|
+
|
|
219
|
+
### Interceptors
|
|
220
|
+
|
|
221
|
+
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:
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
const channel = createChannel(url, {
|
|
225
|
+
interceptors: [
|
|
226
|
+
async (call) => {
|
|
227
|
+
// call.method, .service, .name, .kind, .timeoutMs and .signal describe the call.
|
|
228
|
+
call.metadata.authorization = `Bearer ${await session.token()}`;
|
|
229
|
+
const started = session.id;
|
|
230
|
+
return ({ code }) => {
|
|
231
|
+
if (code === Code.Unauthenticated && session.id === started) {
|
|
232
|
+
session.clear();
|
|
233
|
+
location.assign("/sign-in");
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
const client = new GreeterClient(channel, { interceptors: [logCalls] });
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
- 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`.
|
|
243
|
+
- 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.
|
|
244
|
+
- An interceptor that throws fails the call with what it threw before it starts; the finish functions of earlier interceptors still run.
|
|
245
|
+
- A client-streaming call has a `status` promise too, like the other streaming calls.
|
|
246
|
+
|
|
247
|
+
### Effect
|
|
248
|
+
|
|
249
|
+
The separate package [`@trevrpc/effect`](effect) wraps a generated client for [Effect](https://effect.website): `createEffectClient(GreeterService, new GreeterClient(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.
|
|
146
250
|
|
|
147
251
|
### Compression
|
|
148
252
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevrpc/trevrpc-js",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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
|
|