@trevrpc/trevrpc-js 1.4.0 → 1.6.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  TrevRPC is an RPC framework like gRPC, but uses QUIC (and HTTP/3 / WebTransport) instead of HTTP/2. Define services in protobuf, generate typed clients, and call Go or Rust servers over WebTransport, falling back to QMux over WebSocket where WebTransport is missing or UDP is blocked.
4
4
 
5
- `trevrpc-js` is the **browser client**: a WebTransport runtime with a QMux over WebSocket fallback, plus `protoc-gen-trevrpc-js`, which generates typed clients (no server code). It has no Node.js runtime; servers are written in Go or Rust, native QUIC clients in Go or Rust, and HTTP/3 clients in Kotlin.
5
+ `trevrpc-js` is the **browser client**: a WebTransport runtime with a QMux over WebSocket fallback, for the messages and service descriptors that [protobuf-es](https://github.com/bufbuild/protobuf-es) generates. It has no Node.js runtime; servers are written in Go or Rust, native QUIC clients in Go or Rust, and HTTP/3 clients in Kotlin.
6
6
 
7
7
  Full documentation: https://trev.zip/llc/TrevRPC/wiki
8
8
 
@@ -24,61 +24,52 @@ message HelloRequest { string name = 1; }
24
24
  message HelloReply { string message = 1; }
25
25
  ```
26
26
 
27
- Generate a client with the protoc plugin (it needs Node.js to run):
27
+ TrevRPC has no code generator of its own for JavaScript: generate the messages and the service descriptor with protobuf-es's `protoc-gen-es`, and pass the descriptor to `createClient()`.
28
28
 
29
29
  ```sh
30
- npm install @trevrpc/trevrpc-js
30
+ npm install @trevrpc/trevrpc-js @bufbuild/protobuf
31
+ npm install --save-dev @bufbuild/protoc-gen-es
31
32
  protoc \
32
- --plugin=protoc-gen-trevrpc-js=node_modules/.bin/protoc-gen-trevrpc-js \
33
- --trevrpc-js_out=src/gen \
33
+ --plugin=protoc-gen-es=node_modules/.bin/protoc-gen-es \
34
+ --es_out=src/gen --es_opt=target=ts \
34
35
  greeter.proto
35
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
+ This writes `greeter_pb.ts` with `HelloRequestSchema`, `HelloReplySchema` and the `Greeter` service descriptor ([`buf generate`](https://buf.build/docs/generate/overview/) with the `buf.build/bufbuild/es` plugin works the same). `@bufbuild/protobuf` is a peer dependency, so the generated code and the client share one copy.
38
39
 
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`. |
40
+ ### Messages
45
41
 
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`.
42
+ Messages are protobuf-es messages; see its [manual](https://github.com/bufbuild/protobuf-es/blob/main/MANUAL.md) for the details. Requests take a message or the init object `create()` takes, in which every field may be left out; calls return and streams yield messages, in which scalars, repeated fields and maps always have a value and message fields are `undefined` when unset. Oneofs are `{ case, value }`, 64-bit integers are `bigint` (or `string` with `[jstype = JS_STRING]`), `google.protobuf.Struct` is a JSON object, and `@bufbuild/protobuf/wkt` has helpers for the well-known types, such as `timestampFromDate` and `timestampDate`.
54
43
 
55
44
  ```ts
56
- import { TimeInterval, type Sample } from "./gen/metrics.trevrpc.js";
45
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
57
46
 
58
- const sample: Sample = await client.get({ interval: TimeInterval.HOUR });
47
+ import { Metrics, TimeInterval } from "./gen/metrics_pb.js";
48
+
49
+ const metrics = createClient(Metrics, channel);
50
+ const sample = await metrics.get({
51
+ interval: TimeInterval.HOUR,
52
+ at: timestampFromDate(new Date()),
53
+ });
59
54
  sample.count; // bigint, never undefined
55
+ const at = sample.at && timestampDate(sample.at);
60
56
  ```
61
57
 
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`.
58
+ `JSON.stringify` throws on a `bigint`; use protobuf-es's `toJson()` or `toJsonString()` to serialize a message.
63
59
 
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.
60
+ ### Method options
65
61
 
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
- ```
62
+ `createClient()` reads the `trevrpc.method` option of each method (from [`trevrpc/options.proto`](https://trev.zip/llc/TrevRPC/wiki/Getting-Started#schema-options)) from the descriptor, which protoc-gen-es keeps: `unreliable_requests` and `unreliable_responses` mark directions whose messages may travel as WebTransport datagrams ([Datagram Messages](https://trev.zip/llc/TrevRPC/wiki/Protocol-and-Wire-Format#datagram-messages)), and `compression` lets messages be compressed without a dictionary or a stream be compressed ([Compression](#compression)). An option the method's kind cannot use, such as `unreliable_requests` on a unary method or `COMPRESSION_STREAM` on one that streams nothing, makes `createClient()` throw `InvalidArgument`. Message types with a field marked `debug_redact` are never compressed that way.
72
63
 
73
64
  ## Client
74
65
 
75
66
  ```js
76
- import { connect } from "@trevrpc/trevrpc-js";
67
+ import { connect, createClient } from "@trevrpc/trevrpc-js";
77
68
 
78
- import { GreeterClient } from "./gen/greeter.trevrpc.js";
69
+ import { Greeter } from "./gen/greeter_pb.js";
79
70
 
80
71
  const channel = await connect("https://example.com:4433/trevrpc");
81
- const client = new GreeterClient(channel, { timeoutMs: 5_000 });
72
+ const client = createClient(Greeter, channel, { timeoutMs: 5_000 });
82
73
 
83
74
  // Unary
84
75
  const reply = await client.sayHello({ name: "TrevRPC" });
@@ -110,7 +101,7 @@ channel.close();
110
101
 
111
102
  ### Call options
112
103
 
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)).
104
+ A client has one function per method, named by the method's `localName` (`SayHello` is `sayHello`). Every method takes `CallOptions` as its last argument; the same options can be given to `createClient()` as defaults, and per-call `metadata` is merged over the defaults per key. `createClient()` also takes `interceptors` ([below](#interceptors)).
114
105
 
115
106
  | Option | Meaning |
116
107
  | -------------------- | --------------------------------------------------------------------------------------------- |
@@ -142,11 +133,53 @@ call.
142
133
 
143
134
  - 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.
144
135
 
136
+ ### Rendering long streams
137
+
138
+ 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.
139
+
140
+ 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:
141
+
142
+ ```js
143
+ let batch = [];
144
+ let frame;
145
+ const flush = () => {
146
+ frame = undefined;
147
+ store.append(batch);
148
+ batch = [];
149
+ };
150
+
151
+ let sliceStart = performance.now();
152
+ try {
153
+ for await (const row of stream) {
154
+ batch.push(row);
155
+ frame ??= requestAnimationFrame(flush);
156
+ if (performance.now() - sliceStart > 8) {
157
+ if (document.hidden) {
158
+ cancelAnimationFrame(frame);
159
+ flush();
160
+ } else {
161
+ await new Promise((resolve) => setTimeout(resolve));
162
+ }
163
+ sliceStart = performance.now();
164
+ }
165
+ }
166
+ } finally {
167
+ if (frame !== undefined) {
168
+ cancelAnimationFrame(frame);
169
+ flush();
170
+ }
171
+ }
172
+ ```
173
+
174
+ - `scheduler.yield()` does not help: Chromium resumes its continuation before rendering, so every message still lands in one frame.
175
+ - 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.
176
+ - If the stream fails or the loop is left early, which cancels the call, the `finally` still applies the messages received before.
177
+
145
178
  ### Connections
146
179
 
147
180
  `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.
148
181
 
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.
182
+ `createChannel(url, options)` takes the same options but returns the channel right away, in the `"connecting"` state, so 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
183
 
151
184
  ```js
152
185
  const channel = await connect(url, {
@@ -160,7 +193,7 @@ const channel = await connect(url, {
160
193
 
161
194
  `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.
162
195
 
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.
196
+ Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a 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 clients build from the service and method names.
164
197
 
165
198
  ### Connection metadata
166
199
 
@@ -194,7 +227,7 @@ const channel = createChannel(url, {
194
227
  },
195
228
  ],
196
229
  });
197
- const client = new GreeterClient(channel, { interceptors: [logCalls] });
230
+ const client = createClient(Greeter, channel, { interceptors: [logCalls] });
198
231
  ```
199
232
 
200
233
  - 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`.
@@ -202,6 +235,10 @@ const client = new GreeterClient(channel, { interceptors: [logCalls] });
202
235
  - An interceptor that throws fails the call with what it threw before it starts; the finish functions of earlier interceptors still run.
203
236
  - A client-streaming call has a `status` promise too, like the other streaming calls.
204
237
 
238
+ ### Effect
239
+
240
+ The separate package [`@trevrpc/effect`](effect) makes clients for [Effect](https://effect.website): `createEffectClient(Greeter, channel)` returns a client whose unary calls are effects, whose server-streaming calls are streams, and whose client-streaming and bidirectional calls take their requests as a stream. Interrupting an effect or stream cancels its call. `TrevRpcError` has a `_tag` of `"TrevRpcError"`, so `Effect.catchTag` matches it.
241
+
205
242
  ### Compression
206
243
 
207
244
  Messages can travel compressed with zstd and a dictionary trained on them (see [Compression](https://trev.zip/llc/TrevRPC/wiki/Compression)). The codec lives in a separate entry point, `@trevrpc/trevrpc-js/zstd`, which loads libzstd as WebAssembly (about 85 KB gzipped), so applications without compression never load it:
@@ -238,7 +275,7 @@ console.log(channel.transport); // "webtransport" or "websocket"
238
275
 
239
276
  ## Interop client
240
277
 
241
- `trevrpc-interop-browser` runs the cross-runtime interop scenarios from [`interop/README.md`](../interop/README.md) in a real browser over WebTransport, QMux over WebSocket, or the fallback between them. It is built from `interop/proto/interop.proto` with the generator in this package, so it also serves as a complete example of generated client code.
278
+ `trevrpc-interop-browser` runs the cross-runtime interop scenarios from [`interop/README.md`](../interop/README.md) in a real browser over WebTransport, QMux over WebSocket, or the fallback between them. It is built from `interop/proto/interop.proto` with protoc-gen-es, so it also serves as a complete example of a client.
242
279
 
243
280
  ```sh
244
281
  trevrpc-interop-browser --browser chromium|firefox|safari \
@@ -255,10 +292,10 @@ It prints `ok <name>` or `fail <name>: <reason>` per scenario and exits 0 only i
255
292
  ```sh
256
293
  npm ci
257
294
  npm run check # format check, lint, typecheck, tests
258
- npm run build # generate the interop clients and bundle the interop and benchmark pages into build/interop/
295
+ npm run build # generate the interop and test descriptors and bundle the interop and benchmark pages into build/interop/
259
296
  npm run interop -- --browser chromium --url ... --cert-hash ...
260
297
  ```
261
298
 
262
- `nix build .#trevrpc-js` runs the same checks and installs the library, the plugin and the wrapped `trevrpc-interop-browser` with matching Playwright browsers. The pinned `playwright` version must equal nixpkgs' `playwright-driver`; `nix run .#sync-playwright` updates it.
299
+ `nix build .#trevrpc-js` runs the same checks and installs the library and the wrapped `trevrpc-interop-browser` with matching Playwright browsers. The pinned `playwright` version must equal nixpkgs' `playwright-driver`; `nix run .#sync-playwright` updates it.
263
300
 
264
- Tests (`node --test`) cover the wire format against `testdata/wire-golden-vectors.txt` (frame bytes, and the frame reader against the shared byte streams), the QMux frames against `testdata/qmux-golden-vectors.txt`, framing, the generator, and the client — deadlines, cancellation, stream termination and reconnection — against in-memory fake servers, including a fake Echo server that runs all interop scenarios.
301
+ Tests (`node --test`) cover the wire format against `testdata/wire-golden-vectors.txt` (frame bytes, and the frame reader against the shared byte streams), the QMux frames against `testdata/qmux-golden-vectors.txt`, framing, the `trevrpc.method` option, and the client — deadlines, cancellation, stream termination and reconnection — against in-memory fake servers, including a fake Echo server that runs all interop scenarios.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@trevrpc/trevrpc-js",
3
- "version": "1.4.0",
4
- "description": "Browser WebTransport client and protobuf.js code generator for TrevRPC",
3
+ "version": "1.6.0",
4
+ "description": "Browser WebTransport client for TrevRPC, on protobuf-es",
5
5
  "homepage": "https://trev.zip/llc/TrevRPC",
6
6
  "bugs": {
7
7
  "url": "https://trev.zip/llc/TrevRPC/issues"
@@ -11,12 +11,8 @@
11
11
  "type": "git",
12
12
  "url": "https://trev.zip/llc/TrevRPC.git"
13
13
  },
14
- "bin": {
15
- "protoc-gen-trevrpc-js": "bin/protoc-gen-trevrpc-js.js"
16
- },
17
14
  "files": [
18
15
  "src/**",
19
- "bin/protoc-gen-trevrpc-js.js",
20
16
  "README.md",
21
17
  "LICENSE"
22
18
  ],
@@ -46,20 +42,20 @@
46
42
  "check": "npm run format:check && npm run lint && npm run typecheck && npm test",
47
43
  "interop": "node interop-browser/cli.js"
48
44
  },
49
- "dependencies": {
50
- "protobufjs": "^8.8.1"
51
- },
52
45
  "devDependencies": {
53
- "@bufbuild/protobuf": "^2.16.0",
54
- "@bufbuild/protoc-gen-es": "^2.16.0",
46
+ "@bufbuild/protobuf": "^2.17.0",
47
+ "@bufbuild/protoc-gen-es": "^2.17.0",
55
48
  "@connectrpc/connect": "^2.3.0",
56
49
  "@connectrpc/connect-web": "^2.3.0",
57
50
  "playwright": "1.64.1",
58
- "rolldown": "^1.2.16",
51
+ "rolldown": "^1.3.0",
59
52
  "typescript": "^7.0.2"
60
53
  },
54
+ "peerDependencies": {
55
+ "@bufbuild/protobuf": "^2.17.0"
56
+ },
61
57
  "engines": {
62
58
  "node": ">=24"
63
59
  },
64
- "packageManager": "npm@12.2.1"
60
+ "packageManager": "npm@12.2.2"
65
61
  }
package/src/call.js CHANGED
@@ -859,5 +859,5 @@ function timeoutField({ timeoutMs }) {
859
859
  if (nanos > MaxUint64) {
860
860
  throw invalidArgument("RPC timeout does not fit in 64-bit nanoseconds");
861
861
  }
862
- return { timeoutNanos: String(nanos === 0n ? 1n : nanos) };
862
+ return { timeoutNanos: nanos === 0n ? 1n : nanos };
863
863
  }
package/src/channel.js CHANGED
@@ -3,7 +3,14 @@ import { ConnectionMetadataSource } from "./connection-metadata.js";
3
3
  import { interceptorList } from "./interceptor.js";
4
4
  import { QMuxWebSocketSession } from "./qmux/session.js";
5
5
  import { dialRace } from "./race.js";
6
- import { cancelled, failedPrecondition, statusFromTransportError, unavailable } from "./status.js";
6
+ import {
7
+ Code,
8
+ TrevRpcError,
9
+ cancelled,
10
+ failedPrecondition,
11
+ statusFromTransportError,
12
+ unavailable,
13
+ } from "./status.js";
7
14
  import { StreamBudget } from "./stream-compression.js";
8
15
 
9
16
  /**
@@ -63,7 +70,7 @@ export class Channel {
63
70
  try {
64
71
  const failure = await channel.#connecting;
65
72
  if (signal?.aborted) {
66
- throw abortError(signal);
73
+ throw abortError(signal, failure);
67
74
  }
68
75
  if (failure !== undefined) {
69
76
  throw failure;
@@ -433,7 +440,13 @@ function dialWebSocket(url, options, signal) {
433
440
  * may reject the ready session by throwing.
434
441
  */
435
442
  async function establish(session, name, signal, verify) {
436
- const abandon = () => closeQuietly(session);
443
+ const startedAt = Date.now();
444
+ let abandoned;
445
+ const abandon = () => {
446
+ // Closing ends the session, so how far it got is recorded first.
447
+ abandoned = abandonedMessage(session, name, startedAt);
448
+ closeQuietly(session);
449
+ };
437
450
  signal?.addEventListener("abort", abandon, { once: true });
438
451
  try {
439
452
  // A session that fails to connect settles both promises; one that closes first never becomes
@@ -447,7 +460,10 @@ async function establish(session, name, signal, verify) {
447
460
  verify?.(session);
448
461
  } catch (error) {
449
462
  closeQuietly(session);
450
- throw signal?.aborted ? abortError(signal) : statusFromTransportError(error);
463
+ if (signal?.aborted) {
464
+ throw cancelled(abandoned ?? abandonedMessage(session, name, startedAt), signal.reason);
465
+ }
466
+ throw statusFromTransportError(error);
451
467
  } finally {
452
468
  signal?.removeEventListener("abort", abandon);
453
469
  }
@@ -468,8 +484,30 @@ function transportOptions(options) {
468
484
  return picked;
469
485
  }
470
486
 
471
- function abortError(signal) {
472
- return cancelled("connection cancelled", signal.reason);
487
+ /**
488
+ * The Cancelled error of a connection attempt that `signal` aborted. It keeps the message of
489
+ * `failure`, the attempt's own Cancelled error, which says how far the attempt got.
490
+ */
491
+ function abortError(signal, failure) {
492
+ const message =
493
+ failure instanceof TrevRpcError && failure.code === Code.Cancelled
494
+ ? failure.statusMessage
495
+ : "connection cancelled";
496
+ return cancelled(message, signal.reason);
497
+ }
498
+
499
+ /**
500
+ * Says how far a session abandoned `startedAt` (from Date.now()) got: a QMux over WebSocket
501
+ * session tells the WebSocket opening from the QMux handshake; a WebTransport session cannot.
502
+ */
503
+ function abandonedMessage(session, name, startedAt) {
504
+ const progress =
505
+ {
506
+ opening: "the WebSocket was still opening",
507
+ handshaking:
508
+ "the WebSocket was open, but the server had not sent its QMux transport parameters",
509
+ }[session.stage] ?? `the ${name} session was not ready`;
510
+ return `connection cancelled after ${Date.now() - startedAt} ms: ${progress}`;
473
511
  }
474
512
 
475
513
  /** Exponential backoff with jitter, capped at maxDelayMs. */
package/src/client.js CHANGED
@@ -1,16 +1,20 @@
1
1
  import { Call, EarlyOk, defaultCallOptions, mergeCallOptions } from "./call.js";
2
- import { FrameType, marshalMessage, unmarshalMessage } from "./framing.js";
3
- import { int64Converter } from "./int64.js";
2
+ import { FrameType, decodeWireMessage, marshalMessage, unmarshalMessage } from "./framing.js";
4
3
  import { interceptCall, interceptorList } from "./interceptor.js";
5
4
  import { validateMetadata } from "./metadata.js";
6
- import { Code, cancelled, internal, statusError } from "./status.js";
5
+ import { methodSettings } from "./method-options.js";
6
+ import { Code, cancelled, internal, invalidArgument, statusError } from "./status.js";
7
7
  import { RpcAnswer, RpcHeaders, RpcKind, RpcStatus } from "./wire.js";
8
8
 
9
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`.
10
+ * Creates a client for `service`, a service descriptor that protoc-gen-es generates, with one
11
+ * function per method, named by the method's `localName`. Its calls run the interceptors of the
12
+ * transport, then those of `options.interceptors`.
12
13
  */
13
- export function createServiceClient(transport, service, root, options = {}) {
14
+ export function createClient(service, transport, options = {}) {
15
+ if (service?.kind !== "service" || !Array.isArray(service.methods)) {
16
+ throw invalidArgument("createClient needs a protobuf-es service descriptor");
17
+ }
14
18
  const { interceptors: clientInterceptors, ...callOptions } = options;
15
19
  const defaults = mergeCallOptions(defaultCallOptions(), callOptions);
16
20
  const interceptors = [
@@ -19,32 +23,26 @@ export function createServiceClient(transport, service, root, options = {}) {
19
23
  ];
20
24
  const client = {};
21
25
 
22
- for (const [jsName, method] of Object.entries(service.methods)) {
23
- const requestType = root.lookupType(method.inputType);
24
- const responseType = root.lookupType(method.outputType);
26
+ for (const method of service.methods) {
27
+ const settings = methodSettings(method);
25
28
  const rpc = {
26
29
  // The wire method path, computed once per method rather than per call.
27
- path: `/${service.fullName}/${method.name}`,
28
- requestType,
29
- responseType,
30
- // Converts the 64-bit integers of decoded responses, or undefined when they need nothing.
31
- convertResponse: int64Converter(responseType),
30
+ path: `/${service.typeName}/${method.name}`,
31
+ requestType: method.input,
32
+ responseType: method.output,
32
33
  // Full protobuf names, which pick the compression dictionaries.
33
- types: { request: fullName(requestType), response: fullName(responseType) },
34
- datagrams: datagramUse(method),
35
- compressStream: streamCompressionUse(method),
36
- compress: plainCompressionUse(method),
34
+ types: { request: method.input.typeName, response: method.output.typeName },
35
+ datagrams: datagramUse(settings),
36
+ compressStream: streamCompressionUse(settings),
37
+ compress: plainCompressionUse(settings),
37
38
  };
38
39
  const resolve = (override) => mergeCallOptions(defaults, override);
39
- const shape = MethodShapes[method.kind];
40
- if (shape === undefined) {
41
- throw internal(`unsupported generated RPC kind ${JSON.stringify(method.kind)}`);
42
- }
40
+ const shape = MethodShapes[settings.kind];
43
41
  const info = {
44
42
  method: rpc.path,
45
- service: service.fullName,
43
+ service: service.typeName,
46
44
  name: method.name,
47
- kind: method.kind,
45
+ kind: settings.kind,
48
46
  };
49
47
  const call =
50
48
  interceptors.length === 0
@@ -57,7 +55,7 @@ export function createServiceClient(transport, service, root, options = {}) {
57
55
  (options) => shape.start(transport, rpc, request, options),
58
56
  shape.ended,
59
57
  );
60
- client[jsName] = shape.takesRequest ? call : (override) => call(undefined, override);
58
+ client[method.localName] = shape.takesRequest ? call : (override) => call(undefined, override);
61
59
  }
62
60
 
63
61
  return client;
@@ -183,7 +181,7 @@ const ReadAheadBytes = 1 << 20;
183
181
  * How a method uses datagrams, or undefined if it does not: `send` for request messages, which
184
182
  * needs a streaming request and `unreliableRequests`, and `receive` for response messages, which
185
183
  * every call with a streaming response accepts. A method needs a flow if it marks either
186
- * direction unreliable; the generator rejects marks on a direction that does not stream.
184
+ * direction unreliable; methodSettings rejects marks on a direction that does not stream.
187
185
  */
188
186
  function datagramUse({ kind, unreliableRequests, unreliableResponses }) {
189
187
  const streamsRequest = kind === "clientStreaming" || kind === "bidirectionalStreaming";
@@ -196,7 +194,7 @@ function datagramUse({ kind, unreliableRequests, unreliableResponses }) {
196
194
 
197
195
  /**
198
196
  * The directions a method may stream-compress, `{ requests, responses }`, or undefined for none:
199
- * those its descriptor marks (the generator leaves out directions that do not stream and message
197
+ * those its settings mark (methodSettings leaves out directions that do not stream and message
200
198
  * types with redacted fields), except directions marked unreliable, whose messages mostly travel
201
199
  * as datagrams.
202
200
  */
@@ -208,7 +206,7 @@ function streamCompressionUse({ compressStreamRequests, compressStreamResponses,
208
206
 
209
207
  /**
210
208
  * The directions whose messages a method may compress without a dictionary, `{ requests,
211
- * responses }`, or undefined for none: those its descriptor marks (the generator marks both for
209
+ * responses }`, or undefined for none: those its settings mark (methodSettings marks both for
212
210
  * `COMPRESSION_MESSAGE` and `COMPRESSION_STREAM`, except message types with redacted fields).
213
211
  */
214
212
  function plainCompressionUse({ compressRequests, compressResponses }) {
@@ -418,8 +416,7 @@ export class BidirectionalStreamingCall {
418
416
  const item = await this.#take();
419
417
  if (item.payload !== undefined) {
420
418
  try {
421
- const message = unmarshalMessage(this.#rpc.responseType, item.payload);
422
- return this.#rpc.convertResponse?.(message) ?? message;
419
+ return unmarshalMessage(this.#rpc.responseType, item.payload);
423
420
  } catch (error) {
424
421
  throw this.#settleFailure(this.#call.fail(error));
425
422
  }
@@ -594,7 +591,7 @@ export class BidirectionalStreamingCall {
594
591
  );
595
592
  }
596
593
  this.#sawAnswer = true;
597
- this.#call.learnAnswers(unmarshalMessage(RpcAnswer, frame.payload));
594
+ this.#call.learnAnswers(decodeWireMessage(RpcAnswer, frame.payload));
598
595
  continue;
599
596
  }
600
597
  case FrameType.Headers: {
@@ -603,7 +600,7 @@ export class BidirectionalStreamingCall {
603
600
  this.#sawHeaders ? "duplicate HEADERS frame" : "HEADERS frame after MESSAGE",
604
601
  );
605
602
  }
606
- const headers = unmarshalMessage(RpcHeaders, frame.payload);
603
+ const headers = decodeWireMessage(RpcHeaders, frame.payload);
607
604
  validateResponseMetadata(headers.metadata, "headers");
608
605
  this.#sawHeaders = true;
609
606
  this.#settleHeaders(headers.metadata);
@@ -620,7 +617,7 @@ export class BidirectionalStreamingCall {
620
617
  return { type: FrameType.Message, payload };
621
618
  }
622
619
  case FrameType.Status: {
623
- const status = unmarshalMessage(RpcStatus, frame.payload);
620
+ const status = decodeWireMessage(RpcStatus, frame.payload);
624
621
  if (status.refusedStreamCompression && this.#call.replayable) {
625
622
  // The server refused the optimistic call before running anything for it: the call
626
623
  // continues on a replay, and its response is read from there.
@@ -791,11 +788,6 @@ function requestFields(rpc, kind) {
791
788
  return { method: rpc.path, kind };
792
789
  }
793
790
 
794
- /** The full name of a protobuf.js message type, without the leading dot. */
795
- function fullName(type) {
796
- return type.fullName.replace(/^\./u, "");
797
- }
798
-
799
791
  function validateResponseMetadata(metadata, what) {
800
792
  try {
801
793
  validateMetadata(metadata);
package/src/framing.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
2
+
1
3
  import { byteView } from "./bytes.js";
2
4
  import { internal, invalidArgument, resourceExhausted, unavailable } from "./status.js";
3
5
  import { readVarint, varintLength, writeVarint } from "./varint.js";
4
- import { RpcAnswer, RpcHeaders, RpcRequest, RpcStatus, preflightWireMessage } from "./wire.js";
6
+ import { RpcAnswer, RpcHeaders, RpcRequest, RpcStatus } from "./wire.js";
5
7
 
6
8
  export const DefaultMaxFrameSize = 4 * 1024 * 1024;
7
9
 
@@ -19,10 +21,13 @@ export const FrameType = Object.freeze({
19
21
  const KnownFrameTypes = new Set(Object.values(FrameType));
20
22
  const EmptyBytes = new Uint8Array(0);
21
23
 
22
- /** Encodes a protobuf message body. */
23
- export function marshalMessage(messageType, message) {
24
+ /**
25
+ * Encodes a protobuf message body: `message` is a message of `schema` (a protobuf-es message
26
+ * descriptor) or the init object `create()` takes.
27
+ */
28
+ export function marshalMessage(schema, message) {
24
29
  try {
25
- return messageType.encode(messageType.fromObject(message)).finish();
30
+ return toBinary(schema, create(schema, message));
26
31
  } catch (error) {
27
32
  throw invalidArgument(
28
33
  `failed to encode protobuf message: ${error?.message ?? String(error)}`,
@@ -31,11 +36,10 @@ export function marshalMessage(messageType, message) {
31
36
  }
32
37
  }
33
38
 
34
- /** Decodes a protobuf message body, rejecting malformed wire data. */
35
- export function unmarshalMessage(messageType, body) {
39
+ /** Decodes a protobuf message body of `schema`, rejecting malformed wire data. */
40
+ export function unmarshalMessage(schema, body) {
36
41
  try {
37
- preflightWireMessage(messageType, body);
38
- return messageType.decode(body);
42
+ return fromBinary(schema, byteView(body));
39
43
  } catch (error) {
40
44
  throw invalidArgument(
41
45
  `failed to decode protobuf message: ${error?.message ?? String(error)}`,
@@ -44,6 +48,30 @@ export function unmarshalMessage(messageType, body) {
44
48
  }
45
49
  }
46
50
 
51
+ /** Encodes a wire payload with its codec from wire.js, such as RpcStatus. */
52
+ export function encodeWireMessage(codec, fields) {
53
+ try {
54
+ return codec.encode(fields);
55
+ } catch (error) {
56
+ throw invalidArgument(
57
+ `failed to encode ${codec.name}: ${error?.message ?? String(error)}`,
58
+ error,
59
+ );
60
+ }
61
+ }
62
+
63
+ /** Decodes a wire payload with its codec from wire.js, rejecting malformed wire data. */
64
+ export function decodeWireMessage(codec, payload) {
65
+ try {
66
+ return codec.decode(byteView(payload));
67
+ } catch (error) {
68
+ throw invalidArgument(
69
+ `failed to decode ${codec.name}: ${error?.message ?? String(error)}`,
70
+ error,
71
+ );
72
+ }
73
+ }
74
+
47
75
  /** Encodes one frame carrying an already-encoded payload. */
48
76
  export function encodeFrame(type, payload, maxFrameSize = DefaultMaxFrameSize) {
49
77
  checkFrameLength(payload.byteLength, maxFrameSize);
@@ -71,7 +99,7 @@ export function encodeOutgoingFrame(message, maxFrameSize = DefaultMaxFrameSize)
71
99
  * encodeMessage, when it is given, in one buffer so both go out in a single write.
72
100
  */
73
101
  export function encodeRequestFrame(request, message, maxFrameSize = DefaultMaxFrameSize) {
74
- const payload = marshalMessage(RpcRequest, request);
102
+ const payload = encodeWireMessage(RpcRequest, request);
75
103
  checkFrameLength(payload.byteLength, maxFrameSize);
76
104
  if (message === undefined) {
77
105
  return encodeFrame(FrameType.Request, payload, maxFrameSize);
@@ -86,17 +114,17 @@ export function encodeRequestFrame(request, message, maxFrameSize = DefaultMaxFr
86
114
 
87
115
  /** Encodes a HEADERS frame from RpcHeaders fields. */
88
116
  export function encodeHeadersFrame(headers, maxFrameSize = DefaultMaxFrameSize) {
89
- return encodeFrame(FrameType.Headers, marshalMessage(RpcHeaders, headers), maxFrameSize);
117
+ return encodeFrame(FrameType.Headers, encodeWireMessage(RpcHeaders, headers), maxFrameSize);
90
118
  }
91
119
 
92
120
  /** Encodes an ANSWER frame from RpcAnswer fields. */
93
121
  export function encodeAnswerFrame(answer, maxFrameSize = DefaultMaxFrameSize) {
94
- return encodeFrame(FrameType.Answer, marshalMessage(RpcAnswer, answer), maxFrameSize);
122
+ return encodeFrame(FrameType.Answer, encodeWireMessage(RpcAnswer, answer), maxFrameSize);
95
123
  }
96
124
 
97
125
  /** Encodes a STATUS frame from RpcStatus fields. */
98
126
  export function encodeStatusFrame(status, maxFrameSize = DefaultMaxFrameSize) {
99
- return encodeFrame(FrameType.Status, marshalMessage(RpcStatus, status), maxFrameSize);
127
+ return encodeFrame(FrameType.Status, encodeWireMessage(RpcStatus, status), maxFrameSize);
100
128
  }
101
129
 
102
130
  /**