@trevrpc/trevrpc-js 0.2.2 → 1.0.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.
Files changed (42) hide show
  1. package/README.md +89 -47
  2. package/bin/protoc-gen-trevrpc-js.js +1 -1
  3. package/package.json +11 -41
  4. package/src/bytes.js +10 -0
  5. package/src/call.js +287 -0
  6. package/src/channel.js +131 -296
  7. package/src/client.js +203 -1292
  8. package/src/framing.js +125 -519
  9. package/src/generator.js +26 -103
  10. package/src/index.d.ts +79 -452
  11. package/src/index.js +4 -3
  12. package/src/status.js +24 -70
  13. package/src/wire.js +4 -59
  14. package/examples/greeter/README.md +0 -35
  15. package/examples/greeter/client.js +0 -172
  16. package/examples/greeter/greeter.node.trevrpc.d.ts +0 -53
  17. package/examples/greeter/greeter.node.trevrpc.js +0 -9
  18. package/examples/greeter/greeter.proto +0 -18
  19. package/examples/greeter/greeter.trevrpc.d.ts +0 -55
  20. package/examples/greeter/greeter.trevrpc.js +0 -103
  21. package/examples/greeter/index.html +0 -140
  22. package/examples/greeter/static-server.js +0 -168
  23. package/src/advanced.d.ts +0 -29
  24. package/src/advanced.js +0 -1
  25. package/src/browser-channel.js +0 -49
  26. package/src/connect.js +0 -6
  27. package/src/connect.node.js +0 -6
  28. package/src/index.node.d.ts +0 -10
  29. package/src/index.node.js +0 -3
  30. package/src/native-loader.js +0 -118
  31. package/src/node-advanced.d.ts +0 -27
  32. package/src/node-advanced.js +0 -1
  33. package/src/node-generated.d.ts +0 -24
  34. package/src/node-generated.js +0 -126
  35. package/src/node-index.d.ts +0 -1
  36. package/src/node-index.js +0 -7
  37. package/src/node.d.ts +0 -298
  38. package/src/node.js +0 -1456
  39. package/src/runtime.js +0 -66
  40. package/src/server-response.js +0 -49
  41. package/src/timer.js +0 -29
  42. package/src/webtransport.js +0 -639
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # trevrpc-js
2
2
 
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 servers, and run them over QUIC.
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.
4
+
5
+ `trevrpc-js` is the **browser client**: a WebTransport runtime 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.
4
6
 
5
7
  Full documentation: https://trev.zip/llc/TrevRPC/wiki
6
8
 
@@ -22,75 +24,115 @@ message HelloRequest { string name = 1; }
22
24
  message HelloReply { string message = 1; }
23
25
  ```
24
26
 
25
- Generate with `protoc-gen-trevrpc-js` (`--trevrpc-js_out`).
27
+ Generate a client with the protoc plugin (it needs Node.js to run):
28
+
29
+ ```sh
30
+ npm install @trevrpc/trevrpc-js
31
+ protoc \
32
+ --plugin=protoc-gen-trevrpc-js=node_modules/.bin/protoc-gen-trevrpc-js \
33
+ --trevrpc-js_out=src/gen \
34
+ greeter.proto
35
+ ```
36
+
37
+ This writes `greeter.trevrpc.js` and `greeter.trevrpc.d.ts`. Plugin parameters (`--trevrpc-js_out=key=value,key=value:dir`):
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. |
26
44
 
27
45
  ## Client
28
46
 
29
47
  ```js
30
48
  import { connect } from "@trevrpc/trevrpc-js";
31
- import { GreeterClient } from "./greeter.trevrpc.js";
49
+ import { GreeterClient } from "./gen/greeter.trevrpc.js";
32
50
 
33
- const channel = await connect("https://127.0.0.1:50051/trevrpc");
34
- const client = new GreeterClient(channel);
51
+ const channel = await connect("https://example.com:4433/trevrpc");
52
+ const client = new GreeterClient(channel, { timeoutMs: 5_000 });
35
53
 
36
54
  // Unary
37
55
  const reply = await client.sayHello({ name: "TrevRPC" });
38
56
 
39
57
  // Server streaming
40
- const stream = await client.lotsOfReplies({ name: "TrevRPC" });
41
- for await (const r of stream) console.log(r.message);
58
+ const replies = await client.lotsOfReplies({ name: "TrevRPC" });
59
+ for await (const reply of replies) console.log(reply.message);
42
60
 
43
61
  // Client streaming
44
- const cs = await client.lotsOfGreetings();
45
- await cs.send({ name: "Alice" });
46
- await cs.send({ name: "Bob" });
47
- const summary = await cs.closeAndRecv();
62
+ const upload = await client.lotsOfGreetings();
63
+ await upload.send({ name: "Alice" });
64
+ await upload.send({ name: "Bob" });
65
+ const summary = await upload.closeAndRecv();
48
66
 
49
67
  // Bidirectional streaming
50
- const bidi = await client.bidiHello();
51
- await bidi.send({ name: "Alice" });
52
- console.log(await bidi.recv());
53
- await bidi.closeSend();
54
- for await (const r of bidi) console.log(r.message);
68
+ const chat = await client.bidiHello();
69
+ await chat.send({ name: "Alice" });
70
+ console.log(await chat.recv());
71
+ await chat.closeSend();
72
+ for await (const reply of chat) console.log(reply.message);
55
73
 
56
74
  channel.close();
57
75
  ```
58
76
 
59
- Node uses the same API via `@trevrpc/trevrpc-js/node` (native MsQuic).
77
+ ### Call options
78
+
79
+ 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.
80
+
81
+ | Option | Meaning |
82
+ | -------------- | --------------------------------------------------------------------------------------------- |
83
+ | `timeoutMs` | Deadline for the whole call, from the request to the final status. Also sent to the server. |
84
+ | `signal` | `AbortSignal` that cancels the call. |
85
+ | `metadata` | Request metadata (`{ key: "value" }` or a `Map`); keys are lowercase, `trevrpc-` is reserved. |
86
+ | `maxFrameSize` | Largest frame accepted or sent in bytes (default 4 MiB). |
87
+
88
+ The client enforces no other response limits: there is no cap on the number of streamed
89
+ messages, their total size, or idle time between them. Use `timeoutMs` or `signal` to bound a
90
+ call.
91
+
92
+ ### Errors, cancellation and streams
93
+
94
+ - Failed calls throw `TrevRpcError` with a `code` (`Code.NotFound`, ...), `statusMessage` and `metadata`. A deadline surfaces as `Code.DeadlineExceeded`, a cancelled call as `Code.Cancelled` (with the abort reason as `cause`), and a lost connection as `Code.Unavailable`.
95
+ - Streaming responses end with the server's final status: iteration finishes after `OK` and throws the `TrevRpcError` otherwise. `stream.status` settles with the same outcome and carries trailing metadata. Leaving a `for await` loop early, or calling `close()`, cancels the call.
96
+ - Unary metadata is available through `...WithResponse(request)` and `closeAndRecvWithResponse()`.
97
+ - The server's response decides the outcome of a call: if it ends a call early (for instance with an error), the final status wins over errors from sending the rest of the request.
98
+
99
+ ### Connections
60
100
 
61
- ## Server (Node)
101
+ `connect()` opens a WebTransport session and resolves when it is ready; it rejects if the session cannot be established. 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.
62
102
 
63
103
  ```js
64
- import { NodeServer } from "@trevrpc/trevrpc-js/node";
65
- import { registerGreeterServer } from "./greeter.node.trevrpc.js";
66
- import { createUnaryResponse, createStreamingResponse } from "@trevrpc/trevrpc-js";
67
-
68
- const server = await NodeServer.listen({
69
- host: "127.0.0.1",
70
- port: 50051,
71
- certFile: "cert.pem",
72
- keyFile: "key.pem",
104
+ const channel = await connect(url, {
105
+ signal: AbortSignal.timeout(10_000), // cancels the initial connection only
106
+ serverCertificateHashes: [{ algorithm: "sha-256", value: certificateSha256 }],
107
+ onStateChange: ({ state, attempt, error }) => console.log(state, attempt, error),
73
108
  });
109
+ ```
74
110
 
75
- registerGreeterServer(server, {
76
- sayHello(req) {
77
- return createUnaryResponse({ message: `hello, ${req.name}` });
78
- },
79
- lotsOfReplies(req) {
80
- return createStreamingResponse([
81
- { message: `hello, ${req.name}` },
82
- { message: `goodbye, ${req.name}` },
83
- ]);
84
- },
85
- async lotsOfGreetings(reqs) {
86
- const names = [];
87
- for await (const r of reqs) names.push(r.name);
88
- return createUnaryResponse({ message: names.join(", ") });
89
- },
90
- async *bidiHello(reqs) {
91
- for await (const r of reqs) yield { message: `hello, ${r.name}` };
92
- },
93
- });
111
+ `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. `allowPooling`, `congestionControl` and `requireUnreliable` are passed to the `WebTransport` constructor.
112
+
113
+ Any object with `createBidirectionalStream()`, including a plain `WebTransport` session, can be used as the transport of a generated client.
94
114
 
95
- await server.serve();
115
+ ## Interop client
116
+
117
+ `trevrpc-interop-browser` runs the cross-runtime interop scenarios from [`interop/README.md`](../interop/README.md) in a real browser over WebTransport. 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.
118
+
119
+ ```sh
120
+ trevrpc-interop-browser --browser chromium|firefox|webkit \
121
+ --url https://127.0.0.1:4433/trevrpc \
122
+ --cert-hash <hex sha256 of the server certificate DER>
96
123
  ```
124
+
125
+ It prints `ok <name>` or `fail <name>: <reason>` per scenario and exits 0 only if all scenarios passed. Browsers come from `PLAYWRIGHT_BROWSERS_PATH`, or from `TREVRPC_BROWSER_CHROMIUM`, `TREVRPC_BROWSER_FIREFOX` and `TREVRPC_BROWSER_WEBKIT` when those name an executable. WebKit on macOS passes against the Go server but not yet the Rust server ([h3#347](https://github.com/hyperium/h3/issues/347)); where the browser has no WebTransport at all, such as Playwright's WebKit on Linux, every scenario fails with a clear reason.
126
+
127
+ ## Development
128
+
129
+ ```sh
130
+ npm ci
131
+ npm run check # format check, lint, typecheck, tests
132
+ npm run build # generate the Echo client and bundle the interop page into build/interop/
133
+ npm run interop -- --browser chromium --url ... --cert-hash ...
134
+ ```
135
+
136
+ `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.
137
+
138
+ Tests (`node --test`) cover the wire format against `testdata/wire-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.
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/nix/store/lfaydgacdyngci7p60s8wwvgdm74fjkx-nodejs-24.19.0/bin/node
2
2
 
3
3
  import { runGenerator } from "../src/generator.js";
4
4
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@trevrpc/trevrpc-js",
3
- "version": "0.2.2",
4
- "description": "JavaScript WebTransport runtime and protobuf.js code generator for TrevRPC",
3
+ "version": "1.0.0",
4
+ "description": "Browser WebTransport client and protobuf.js code generator for TrevRPC",
5
5
  "homepage": "https://trev.zip/llc/TrevRPC",
6
6
  "bugs": {
7
7
  "url": "https://trev.zip/llc/TrevRPC/issues"
@@ -17,7 +17,6 @@
17
17
  "files": [
18
18
  "src/**",
19
19
  "bin/protoc-gen-trevrpc-js.js",
20
- "examples/greeter/**",
21
20
  "README.md",
22
21
  "LICENSE"
23
22
  ],
@@ -25,56 +24,30 @@
25
24
  "sideEffects": false,
26
25
  "exports": {
27
26
  ".": {
28
- "node": {
29
- "types": "./src/index.node.d.ts",
30
- "import": "./src/index.node.js"
31
- },
32
- "browser": {
33
- "types": "./src/index.d.ts",
34
- "import": "./src/index.js"
35
- },
36
27
  "types": "./src/index.d.ts",
37
28
  "import": "./src/index.js"
38
- },
39
- "./node": {
40
- "types": "./src/node.d.ts",
41
- "import": "./src/node-index.js"
42
- },
43
- "./advanced": {
44
- "types": "./src/advanced.d.ts",
45
- "import": "./src/advanced.js"
46
- },
47
- "./node/advanced": {
48
- "types": "./src/node-advanced.d.ts",
49
- "import": "./src/node-advanced.js"
50
- },
51
- "./node/generated": {
52
- "types": "./src/node-generated.d.ts",
53
- "import": "./src/node-generated.js"
54
29
  }
55
30
  },
56
31
  "publishConfig": {
57
32
  "access": "public"
58
33
  },
59
34
  "scripts": {
60
- "build:native": "TREVRPC_NODE_TEST_HOOKS=OFF npm run build:native:cmake",
61
- "build:native:test": "TREVRPC_NODE_TEST_HOOKS=ON npm run build:native:cmake",
62
- "build:native:cmake": "cmake -S native -B build/native -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE:-Release} -DTREVRPC_NODE_TEST_HOOKS=${TREVRPC_NODE_TEST_HOOKS:?} ${TREVRPC_C_ROOT:+-DTREVRPC_C_ROOT=$TREVRPC_C_ROOT} ${NODE_INCLUDE_DIR:+-DNODE_INCLUDE_DIR=$NODE_INCLUDE_DIR} && cmake --build build/native --config ${CMAKE_BUILD_TYPE:-Release}",
63
- "check": "npm run lint && npm run typecheck && npm run build:native:test && npm test && npm run build:native && npm run verify:native:production",
64
- "example:greeter": "node examples/greeter/static-server.js",
35
+ "build": "node interop-browser/build.js",
65
36
  "format": "oxfmt --write",
66
37
  "format:check": "oxfmt --check",
67
38
  "lint": "oxlint --deny-warnings",
68
- "prepack": "node -e 'if (process.env.TREVRPC_NIX_PUBLICATION !== \"1\") throw new Error(\"Build release tarballs with nix build .#trevrpc-js^npm\")'",
39
+ "pretypecheck": "npm run build",
40
+ "typecheck": "tsc -p tsconfig.json",
41
+ "pretest": "npm run build",
69
42
  "test": "node --test --test-concurrency=1 test/*.test.js",
70
- "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.browser.json && tsc -p tsconfig.node.json",
71
- "verify:native:production": "node -e 'const { readFileSync } = require(\"node:fs\"); const path = \"./build/native/trevrpc_native.node\"; const expectedExports = [\"connectMsQuic\", \"createCancellation\", \"listenMsQuic\"]; const actualExports = Object.getOwnPropertyNames(require(path)).sort(); const exportsMatch = JSON.stringify(actualExports) === JSON.stringify(expectedExports); const embedsDebugNames = readFileSync(path).includes(Buffer.from(\"_debug\")); if (!exportsMatch || embedsDebugNames) throw new Error(`production addon exports ${JSON.stringify(actualExports)}; expected exactly ${JSON.stringify(expectedExports)}${embedsDebugNames ? \"; binary contains _debug\" : \"\"}`);'"
43
+ "check": "npm run format:check && npm run lint && npm run typecheck && npm test",
44
+ "interop": "node interop-browser/cli.js",
45
+ "prepack": "node -e 'if (process.env.TREVRPC_NIX_RELEASE !== \"1\") throw new Error(\"Build release tarballs with nix build .#trevrpc-js^npm\")'"
72
46
  },
73
47
  "dependencies": {
74
- "protobufjs": "^8.8.0"
48
+ "protobufjs": "^8.8.1"
75
49
  },
76
50
  "devDependencies": {
77
- "@types/node": "^24.13.3",
78
51
  "esbuild": "^0.28.2",
79
52
  "playwright": "1.61.1",
80
53
  "typescript": "^7.0.2"
@@ -82,8 +55,5 @@
82
55
  "engines": {
83
56
  "node": ">=24 <25"
84
57
  },
85
- "packageManager": "npm@12.0.2",
86
- "optionalDependencies": {
87
- "@trevrpc/trevrpc-js-native-linux-x64-gnu": "0.2.2"
88
- }
58
+ "packageManager": "npm@12.2.0"
89
59
  }
package/src/bytes.js ADDED
@@ -0,0 +1,10 @@
1
+ /** Returns a Uint8Array view over bytes, typed-array views, or ArrayBuffers without copying. */
2
+ export function byteView(value) {
3
+ if (value instanceof Uint8Array) {
4
+ return value;
5
+ }
6
+ if (ArrayBuffer.isView(value)) {
7
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
8
+ }
9
+ return new Uint8Array(value ?? 0);
10
+ }
package/src/call.js ADDED
@@ -0,0 +1,287 @@
1
+ import { DefaultMaxFrameSize, FrameReader, encodeFrame, encodeMessageFrame } from "./framing.js";
2
+ import { normalizeMetadata, validateMetadata } from "./metadata.js";
3
+ import {
4
+ Code,
5
+ TrevRpcError,
6
+ cancelled,
7
+ deadlineExceeded,
8
+ failedPrecondition,
9
+ invalidArgument,
10
+ statusError,
11
+ statusFromTransportError,
12
+ } from "./status.js";
13
+ import { RpcRequest, WireVersion } from "./wire.js";
14
+
15
+ /** Longest delay setTimeout can represent; it fires immediately beyond this. */
16
+ const MaxTimerDelayMs = 2_147_483_647;
17
+ const MaxUint64 = 0xffffffffffffffffn;
18
+
19
+ /** Returns the default call options. */
20
+ export function defaultCallOptions() {
21
+ return {
22
+ timeoutMs: undefined,
23
+ maxFrameSize: DefaultMaxFrameSize,
24
+ metadata: {},
25
+ signal: undefined,
26
+ };
27
+ }
28
+
29
+ /** Merges override options over resolved base options; metadata merges per key. */
30
+ export function mergeCallOptions(base, override = {}) {
31
+ const merged = { ...base };
32
+ for (const [key, value] of Object.entries(override)) {
33
+ if (value !== undefined && key !== "metadata") {
34
+ merged[key] = value;
35
+ }
36
+ }
37
+ if (override.metadata != null) {
38
+ merged.metadata = Object.assign(
39
+ Object.create(null),
40
+ base.metadata,
41
+ normalizeMetadata(override.metadata),
42
+ );
43
+ validateMetadata(merged.metadata);
44
+ }
45
+ return merged;
46
+ }
47
+
48
+ /**
49
+ * Cancellation and deadline for one call. The signal aborts with a TrevRpcError reason: the
50
+ * failure that ended the call, the caller's own status error, DeadlineExceeded, or Cancelled
51
+ * wrapping any other abort reason.
52
+ */
53
+ class CallScope {
54
+ #controller = new AbortController();
55
+ #parent;
56
+ #timer;
57
+
58
+ /** timeoutMs must be validated by the caller. */
59
+ constructor({ signal, timeoutMs }) {
60
+ if (signal?.aborted) {
61
+ this.abort(signal.reason);
62
+ return;
63
+ }
64
+ if (signal != null) {
65
+ this.#parent = { signal, listener: () => this.abort(signal.reason) };
66
+ signal.addEventListener("abort", this.#parent.listener, { once: true });
67
+ }
68
+ if (timeoutMs != null && timeoutMs <= MaxTimerDelayMs) {
69
+ this.#timer = setTimeout(
70
+ () => this.abort(deadlineExceeded("RPC deadline exceeded")),
71
+ timeoutMs,
72
+ );
73
+ }
74
+ }
75
+
76
+ get signal() {
77
+ return this.#controller.signal;
78
+ }
79
+
80
+ /** Aborts the call; only the first reason counts. Returns the effective reason. */
81
+ abort(reason) {
82
+ if (!this.signal.aborted) {
83
+ this.settle();
84
+ this.#controller.abort(
85
+ reason instanceof TrevRpcError ? reason : cancelled("RPC cancelled", reason),
86
+ );
87
+ }
88
+ return this.signal.reason;
89
+ }
90
+
91
+ /** Stops the deadline and detaches from the caller's signal once the outcome is known. */
92
+ settle() {
93
+ clearTimeout(this.#timer);
94
+ this.#parent?.signal.removeEventListener("abort", this.#parent.listener);
95
+ this.#parent = undefined;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * One RPC over one bidirectional stream: the request writer, the response frame reader, and the
101
+ * call's cancellation scope. Transport failures surface as TrevRpcError, and once the call is
102
+ * aborted every operation fails with the abort reason.
103
+ */
104
+ export class Call {
105
+ #scope;
106
+ #writer;
107
+ #cancelReader;
108
+ #frames;
109
+ #maxFrameSize;
110
+ #sendClosed = false;
111
+
112
+ /**
113
+ * Opens a stream on the transport and sends the request header. The request carries service,
114
+ * method, kind and body; version, metadata and the wire timeout come from the options.
115
+ */
116
+ static async open(transport, request, options) {
117
+ const header = encodeFrame(
118
+ RpcRequest,
119
+ { ...request, version: WireVersion, metadata: options.metadata, ...timeoutField(options) },
120
+ options.maxFrameSize,
121
+ );
122
+ const scope = new CallScope(options);
123
+ try {
124
+ const stream = await openStream(transport, scope.signal);
125
+ const call = new Call(stream, scope, options.maxFrameSize);
126
+ await call.#write(header);
127
+ return call;
128
+ } catch (error) {
129
+ throw scope.abort(failureStatus(error));
130
+ }
131
+ }
132
+
133
+ constructor(stream, scope, maxFrameSize) {
134
+ const reader = stream.readable.getReader();
135
+ this.#scope = scope;
136
+ this.#writer = stream.writable.getWriter();
137
+ this.#cancelReader = (reason) => reader.cancel(reason);
138
+ this.#frames = new FrameReader(reader);
139
+ this.#maxFrameSize = maxFrameSize;
140
+ scope.signal.addEventListener("abort", () => this.#teardown(), { once: true });
141
+ if (scope.signal.aborted) {
142
+ this.#teardown();
143
+ }
144
+ }
145
+
146
+ /** The call's abort signal. */
147
+ get signal() {
148
+ return this.#scope.signal;
149
+ }
150
+
151
+ /** Sends one streaming request message; writes are ordered and apply backpressure. */
152
+ sendMessage(body) {
153
+ if (this.#sendClosed) {
154
+ return Promise.reject(failedPrecondition("request stream is closed"));
155
+ }
156
+ return this.#write(encodeMessageFrame(body, this.#maxFrameSize));
157
+ }
158
+
159
+ /** Finishes the request stream gracefully. */
160
+ closeSend() {
161
+ if (this.#sendClosed) {
162
+ return Promise.resolve();
163
+ }
164
+ this.#sendClosed = true;
165
+ return this.#guard(this.#writer.close());
166
+ }
167
+
168
+ /** Reads the unary response frame. */
169
+ readFrame(messageType) {
170
+ return this.#guard(this.#frames.readFrame(messageType, this.#maxFrameSize));
171
+ }
172
+
173
+ /** Reads the next response stream frame, or null if the stream ended cleanly. */
174
+ readStreamFrame() {
175
+ return this.#guard(this.#frames.readStreamFrame(this.#maxFrameSize));
176
+ }
177
+
178
+ /**
179
+ * Ends the call after its outcome (a final response or status) was received. An unfinished
180
+ * request side is reset, and the rest of the response stream is drained in the background.
181
+ */
182
+ finish() {
183
+ this.#scope.settle();
184
+ if (!this.#sendClosed) {
185
+ this.#sendClosed = true;
186
+ this.#writer.abort(cancelled("RPC completed")).catch(() => {});
187
+ }
188
+ this.#frames.expectEnd().catch(() => this.#cancelReader().catch(() => {}));
189
+ }
190
+
191
+ /** Cancels the call on behalf of the caller; any reason becomes a Cancelled status. */
192
+ cancel(reason) {
193
+ this.#scope.abort(reason);
194
+ }
195
+
196
+ /** Fails the call, tearing down both stream directions. Returns the effective error. */
197
+ fail(error) {
198
+ return this.#scope.abort(failureStatus(error));
199
+ }
200
+
201
+ async #write(bytes) {
202
+ await this.#guard(this.#writer.write(bytes));
203
+ }
204
+
205
+ async #guard(operation) {
206
+ let result;
207
+ try {
208
+ result = await operation;
209
+ } catch (error) {
210
+ throw this.signal.aborted ? this.signal.reason : statusFromTransportError(error);
211
+ }
212
+ // Cancelling the reader completes pending reads as end-of-stream instead of rejecting them.
213
+ throwIfAborted(this.signal);
214
+ return result;
215
+ }
216
+
217
+ #teardown() {
218
+ const reason = this.signal.reason;
219
+ this.#writer.abort(reason).catch(() => {});
220
+ this.#cancelReader(reason).catch(() => {});
221
+ }
222
+ }
223
+
224
+ function openStream(transport, signal) {
225
+ return new Promise((resolve, reject) => {
226
+ if (signal.aborted) {
227
+ reject(signal.reason);
228
+ return;
229
+ }
230
+ const onAbort = () => reject(signal.reason);
231
+ signal.addEventListener("abort", onAbort, { once: true });
232
+
233
+ let opening;
234
+ try {
235
+ opening = Promise.resolve(transport.createBidirectionalStream());
236
+ } catch (error) {
237
+ opening = Promise.reject(error);
238
+ }
239
+ opening.then(
240
+ (stream) => {
241
+ signal.removeEventListener("abort", onAbort);
242
+ if (signal.aborted) {
243
+ // The caller gave up while the stream was opening; do not leak it.
244
+ stream.writable.abort(signal.reason).catch(() => {});
245
+ stream.readable.cancel(signal.reason).catch(() => {});
246
+ } else {
247
+ resolve(stream);
248
+ }
249
+ },
250
+ (error) => {
251
+ signal.removeEventListener("abort", onAbort);
252
+ reject(statusFromTransportError(error));
253
+ },
254
+ );
255
+ });
256
+ }
257
+
258
+ function throwIfAborted(signal) {
259
+ if (signal.aborted) {
260
+ throw signal.reason;
261
+ }
262
+ }
263
+
264
+ /** Status errors describe themselves; anything else reaching the call is an internal bug. */
265
+ function failureStatus(error) {
266
+ return error instanceof TrevRpcError
267
+ ? error
268
+ : statusError(Code.Internal, error?.message ?? String(error), {}, error);
269
+ }
270
+
271
+ /**
272
+ * Validates a call timeout and returns the request field for it. Zero means unset on the wire, so
273
+ * a zero timeout is sent as 1ns.
274
+ */
275
+ function timeoutField({ timeoutMs }) {
276
+ if (timeoutMs == null) {
277
+ return {};
278
+ }
279
+ if (!(timeoutMs >= 0 && Number.isFinite(timeoutMs))) {
280
+ throw invalidArgument("RPC timeout must be a non-negative finite number of milliseconds");
281
+ }
282
+ const nanos = BigInt(Math.ceil(timeoutMs)) * 1_000_000n;
283
+ if (nanos > MaxUint64) {
284
+ throw invalidArgument("RPC timeout does not fit in 64-bit nanoseconds");
285
+ }
286
+ return { timeoutNanos: String(nanos === 0n ? 1n : nanos) };
287
+ }