@trevrpc/trevrpc-js 1.6.0 → 1.7.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 (3) hide show
  1. package/README.md +4 -0
  2. package/package.json +2 -2
  3. package/src/call.js +64 -25
package/README.md CHANGED
@@ -4,6 +4,10 @@ TrevRPC is an RPC framework like gRPC, but uses QUIC (and HTTP/3 / WebTransport)
4
4
 
5
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
+ > **Compatibility:** every TrevRPC package shares one version, and packages of the same version are compatible
8
+ > with each other: `trevrpc-rust` 1.7.0 works with `trevrpc-go` 1.7.0, `trevrpc-js` 1.7.0, and `trevrpc-kotlin`
9
+ > 1.7.0. Compatibility across different versions isn't guaranteed yet; that's a milestone for 2.0.
10
+
7
11
  Full documentation: https://trev.zip/llc/TrevRPC/wiki
8
12
 
9
13
  ## Protobuf
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevrpc/trevrpc-js",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Browser WebTransport client for TrevRPC, on protobuf-es",
5
5
  "homepage": "https://trev.zip/llc/TrevRPC",
6
6
  "bugs": {
@@ -48,7 +48,7 @@
48
48
  "@connectrpc/connect": "^2.3.0",
49
49
  "@connectrpc/connect-web": "^2.3.0",
50
50
  "playwright": "1.64.1",
51
- "rolldown": "^1.3.0",
51
+ "rolldown": "^1.3.1",
52
52
  "typescript": "^7.0.2"
53
53
  },
54
54
  "peerDependencies": {
package/src/call.js CHANGED
@@ -769,7 +769,8 @@ export class Call {
769
769
  /**
770
770
  * Opens a bidirectional stream. A call whose setup depends on its session passes `session`, which
771
771
  * receives that session's `key` and `datagrams` object, so the compression state and datagram flow
772
- * belong to the connection that carries the call and not to one a reconnect swapped in later.
772
+ * belong to the connection that carries the call and not to one a reconnect swapped in later. A
773
+ * stream the browser refuses for want of stream credit is opened again (see StreamRefusalRetryMs).
773
774
  */
774
775
  function openStream(transport, signal, session) {
775
776
  return new Promise((resolve, reject) => {
@@ -777,34 +778,72 @@ function openStream(transport, signal, session) {
777
778
  reject(signal.reason);
778
779
  return;
779
780
  }
780
- const onAbort = () => reject(signal.reason);
781
+ let retryTimer;
782
+ const onAbort = () => {
783
+ clearTimeout(retryTimer);
784
+ reject(signal.reason);
785
+ };
781
786
  signal.addEventListener("abort", onAbort, { once: true });
782
-
783
- let opening;
784
- try {
785
- opening = Promise.resolve(startStream(transport, session));
786
- } catch (error) {
787
- opening = Promise.reject(error);
788
- }
789
- opening.then(
790
- (stream) => {
791
- signal.removeEventListener("abort", onAbort);
792
- if (signal.aborted) {
793
- // The caller gave up while the stream was opening; do not leak it.
794
- stream.writable.abort(signal.reason).catch(() => {});
795
- stream.readable.cancel(signal.reason).catch(() => {});
796
- } else {
797
- resolve(stream);
798
- }
799
- },
800
- (error) => {
801
- signal.removeEventListener("abort", onAbort);
802
- reject(statusFromTransportError(error));
803
- },
804
- );
787
+ const giveUpAt = Date.now() + StreamRefusalRetryMs;
788
+
789
+ const attempt = (delay) => {
790
+ let opening;
791
+ try {
792
+ opening = Promise.resolve(startStream(transport, session));
793
+ } catch (error) {
794
+ opening = Promise.reject(error);
795
+ }
796
+ opening.then(
797
+ (stream) => {
798
+ signal.removeEventListener("abort", onAbort);
799
+ if (signal.aborted) {
800
+ // The caller gave up while the stream was opening; do not leak it.
801
+ stream.writable.abort(signal.reason).catch(() => {});
802
+ stream.readable.cancel(signal.reason).catch(() => {});
803
+ } else {
804
+ resolve(stream);
805
+ }
806
+ },
807
+ (error) => {
808
+ if (signal.aborted) {
809
+ return;
810
+ }
811
+ if (isStreamRefusal(error) && Date.now() + delay <= giveUpAt) {
812
+ // Nothing reached the server, so opening the stream again is safe.
813
+ retryTimer = setTimeout(
814
+ () => attempt(Math.min(delay * 2, StreamRefusalMaxDelayMs)),
815
+ delay,
816
+ );
817
+ return;
818
+ }
819
+ signal.removeEventListener("abort", onAbort);
820
+ reject(statusFromTransportError(error));
821
+ },
822
+ );
823
+ };
824
+ attempt(StreamRefusalFirstDelayMs);
805
825
  });
806
826
  }
807
827
 
828
+ /**
829
+ * How long a call keeps opening a stream the browser refused, and how long it waits between
830
+ * attempts. Chromium refuses a new WebTransport stream at once while the server's stream credit is
831
+ * used up, where the WebTransport spec has it wait for more. Credit comes back about two round
832
+ * trips after a stream opens, so the call waits for it itself, doubling the delay from 1 ms.
833
+ */
834
+ const StreamRefusalRetryMs = 2000;
835
+ const StreamRefusalFirstDelayMs = 1;
836
+ const StreamRefusalMaxDelayMs = 64;
837
+
838
+ /**
839
+ * Whether opening a stream failed because the browser refused it for now: Chromium's NetworkError
840
+ * "Failed to create bidirectional stream". A closed session fails differently, and a channel
841
+ * without a session fails with a status.
842
+ */
843
+ function isStreamRefusal(error) {
844
+ return !(error instanceof TrevRpcError) && error?.name === "NetworkError";
845
+ }
846
+
808
847
  function messageOf(item) {
809
848
  return item.message;
810
849
  }