starpc 0.49.2 → 0.49.3

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.
@@ -9,7 +9,7 @@ import { MessageStream, ProtoRpc } from 'starpc';
9
9
  * @generated from service echo.Echoer
10
10
  */
11
11
  export declare const EchoerDefinition: {
12
- readonly typeName: "echo.Echoer";
12
+ readonly typeName: 'echo.Echoer';
13
13
  readonly methods: {
14
14
  /**
15
15
  * Echo returns the given message.
@@ -17,7 +17,7 @@ export declare const EchoerDefinition: {
17
17
  * @generated from rpc echo.Echoer.Echo
18
18
  */
19
19
  readonly Echo: {
20
- readonly name: "Echo";
20
+ readonly name: 'Echo';
21
21
  readonly I: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
22
22
  readonly O: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
23
23
  readonly kind: MethodKind.Unary;
@@ -28,7 +28,7 @@ export declare const EchoerDefinition: {
28
28
  * @generated from rpc echo.Echoer.EchoServerStream
29
29
  */
30
30
  readonly EchoServerStream: {
31
- readonly name: "EchoServerStream";
31
+ readonly name: 'EchoServerStream';
32
32
  readonly I: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
33
33
  readonly O: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
34
34
  readonly kind: MethodKind.ServerStreaming;
@@ -39,7 +39,7 @@ export declare const EchoerDefinition: {
39
39
  * @generated from rpc echo.Echoer.EchoClientStream
40
40
  */
41
41
  readonly EchoClientStream: {
42
- readonly name: "EchoClientStream";
42
+ readonly name: 'EchoClientStream';
43
43
  readonly I: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
44
44
  readonly O: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
45
45
  readonly kind: MethodKind.ClientStreaming;
@@ -50,7 +50,7 @@ export declare const EchoerDefinition: {
50
50
  * @generated from rpc echo.Echoer.EchoBidiStream
51
51
  */
52
52
  readonly EchoBidiStream: {
53
- readonly name: "EchoBidiStream";
53
+ readonly name: 'EchoBidiStream';
54
54
  readonly I: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
55
55
  readonly O: import("@aptre/protobuf-es-lite").MessageType<EchoMsg>;
56
56
  readonly kind: MethodKind.BiDiStreaming;
@@ -61,7 +61,7 @@ export declare const EchoerDefinition: {
61
61
  * @generated from rpc echo.Echoer.RpcStream
62
62
  */
63
63
  readonly RpcStream: {
64
- readonly name: "RpcStream";
64
+ readonly name: 'RpcStream';
65
65
  readonly I: import("@aptre/protobuf-es-lite").MessageType<RpcStreamPacket>;
66
66
  readonly O: import("@aptre/protobuf-es-lite").MessageType<RpcStreamPacket>;
67
67
  readonly kind: MethodKind.BiDiStreaming;
@@ -72,7 +72,7 @@ export declare const EchoerDefinition: {
72
72
  * @generated from rpc echo.Echoer.DoNothing
73
73
  */
74
74
  readonly DoNothing: {
75
- readonly name: "DoNothing";
75
+ readonly name: 'DoNothing';
76
76
  readonly I: import("@aptre/protobuf-es-lite").MessageType<Empty>;
77
77
  readonly O: import("@aptre/protobuf-es-lite").MessageType<Empty>;
78
78
  readonly kind: MethodKind.Unary;
@@ -7,7 +7,7 @@ import { ProtoRpc } from 'starpc';
7
7
  * @generated from service e2e.mock.Mock
8
8
  */
9
9
  export declare const MockDefinition: {
10
- readonly typeName: "e2e.mock.Mock";
10
+ readonly typeName: 'e2e.mock.Mock';
11
11
  readonly methods: {
12
12
  /**
13
13
  * MockRequest runs a mock unary request.
@@ -15,7 +15,7 @@ export declare const MockDefinition: {
15
15
  * @generated from rpc e2e.mock.Mock.MockRequest
16
16
  */
17
17
  readonly MockRequest: {
18
- readonly name: "MockRequest";
18
+ readonly name: 'MockRequest';
19
19
  readonly I: import("@aptre/protobuf-es-lite").MessageType<MockMsg>;
20
20
  readonly O: import("@aptre/protobuf-es-lite").MessageType<MockMsg>;
21
21
  readonly kind: MethodKind.Unary;
@@ -31,6 +31,7 @@ export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenera
31
31
  private _remoteAck?;
32
32
  private keepAlive?;
33
33
  private idleWatchdog?;
34
+ private closed;
34
35
  get isAcked(): boolean;
35
36
  get isOpen(): boolean;
36
37
  get isIdlePaused(): boolean;
@@ -38,6 +39,7 @@ export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenera
38
39
  private postMessage;
39
40
  private idleElapsed;
40
41
  private keepAliveElapsed;
42
+ private finish;
41
43
  close(error?: Error): void;
42
44
  pauseIdle(): void;
43
45
  resumeIdle(): void;
@@ -37,6 +37,8 @@ export class ChannelStream {
37
37
  keepAlive;
38
38
  // idleWatchdog is the receive timeout watchdog.
39
39
  idleWatchdog;
40
+ // closed indicates the local channel has been torn down.
41
+ closed = false;
40
42
  // isAcked checks if the stream is acknowledged by the remote.
41
43
  get isAcked() {
42
44
  return this.remoteAck ?? false;
@@ -114,6 +116,9 @@ export class ChannelStream {
114
116
  }
115
117
  // postMessage writes a message to the stream.
116
118
  postMessage(msg) {
119
+ if (this.closed) {
120
+ return;
121
+ }
117
122
  msg.from = this.localId;
118
123
  if (this.channel instanceof MessagePort) {
119
124
  this.channel.postMessage(msg);
@@ -139,23 +144,37 @@ export class ChannelStream {
139
144
  this.postMessage({});
140
145
  }
141
146
  }
142
- // close closes the broadcast channels.
143
- close(error) {
144
- // write a message to indicate the stream is now closed.
145
- this.postMessage({ closed: true, error });
147
+ // finish tears down local stream state, optionally notifying the remote side.
148
+ finish(error, notifyRemote) {
149
+ if (this.closed) {
150
+ return;
151
+ }
152
+ if (notifyRemote) {
153
+ try {
154
+ this.postMessage({ closed: true, error });
155
+ }
156
+ catch {
157
+ // Ignore close races while tearing down the local channel.
158
+ }
159
+ }
160
+ this.closed = true;
146
161
  // close channels
147
162
  if (this.channel instanceof MessagePort) {
163
+ this.channel.onmessage = null;
148
164
  this.channel.close();
149
165
  }
150
166
  else {
167
+ this.channel.rx.onmessage = null;
151
168
  this.channel.tx.close();
152
169
  this.channel.rx.close();
153
170
  }
154
171
  if (!this.remoteOpen && this._remoteOpen) {
155
172
  this._remoteOpen(error || new Error('closed'));
173
+ delete this._remoteOpen;
156
174
  }
157
175
  if (!this.remoteAck && this._remoteAck) {
158
176
  this._remoteAck(error || new Error('closed'));
177
+ delete this._remoteAck;
159
178
  }
160
179
  if (this.idleWatchdog) {
161
180
  this.idleWatchdog.clear();
@@ -167,6 +186,10 @@ export class ChannelStream {
167
186
  }
168
187
  this._source.end(error);
169
188
  }
189
+ // close closes the broadcast channels.
190
+ close(error) {
191
+ this.finish(error, true);
192
+ }
170
193
  // pauseIdle pauses the idle watchdog, preventing the stream from timing out.
171
194
  // Use this when the remote is known to be inactive (e.g., browser tab hidden).
172
195
  pauseIdle() {
@@ -236,10 +259,11 @@ export class ChannelStream {
236
259
  this._source.push(data);
237
260
  }
238
261
  if (err) {
239
- this._source.end(err);
262
+ this.finish(err, false);
263
+ return;
240
264
  }
241
- else if (closed) {
242
- this._source.end();
265
+ if (closed) {
266
+ this.finish(undefined, false);
243
267
  }
244
268
  }
245
269
  }
@@ -81,13 +81,17 @@ export class Client {
81
81
  const openStreamFn = await this.openStreamCtr.wait();
82
82
  const stream = await openStreamFn();
83
83
  const call = new ClientRPC(rpcService, rpcMethod);
84
- abortSignal?.addEventListener('abort', () => {
84
+ const onAbort = () => {
85
85
  call.writeCallCancel();
86
86
  call.close(new Error(ERR_RPC_ABORT));
87
- });
87
+ };
88
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
88
89
  pipe(stream, decodePacketSource, call, encodePacketSource, stream)
89
90
  .catch((err) => call.close(err))
90
- .then(() => call.close());
91
+ .then(() => call.close())
92
+ .finally(() => {
93
+ abortSignal?.removeEventListener('abort', onAbort);
94
+ });
91
95
  await call.writeCallStart(data ?? undefined);
92
96
  return call;
93
97
  }
@@ -3,14 +3,14 @@ import { Source, Transform } from 'it-stream-types';
3
3
  import { Packet } from './rpcproto.pb.js';
4
4
  export declare const decodePacketSource: import("./message.js").DecodeMessageTransform<Packet>;
5
5
  export declare const encodePacketSource: import("./message.js").EncodeMessageTransform<Packet>;
6
- export declare const uint32LEDecode: {
7
- (data: Uint8ArrayList): number;
8
- bytes: number;
9
- };
10
- export declare const uint32LEEncode: {
11
- (value: number): Uint8ArrayList;
12
- bytes: number;
13
- };
6
+ export declare function uint32LEDecode(data: Uint8ArrayList): number;
7
+ export declare namespace uint32LEDecode {
8
+ var bytes: number;
9
+ }
10
+ export declare function uint32LEEncode(value: number): Uint8ArrayList;
11
+ export declare namespace uint32LEEncode {
12
+ var bytes: number;
13
+ }
14
14
  export declare function lengthPrefixEncode(source: Source<Uint8Array | Uint8ArrayList>, lengthEncoder: typeof uint32LEEncode): AsyncGenerator<Uint8ArrayList, void, unknown>;
15
15
  export declare function lengthPrefixDecode(source: Source<Uint8Array | Uint8ArrayList>, lengthDecoder: typeof uint32LEDecode): AsyncGenerator<Uint8ArrayList, void, unknown>;
16
16
  export declare function prependLengthPrefixTransform(lengthEncoder?: {
@@ -1,7 +1,7 @@
1
- import { describe, it, beforeEach } from 'vitest';
1
+ import { describe, it, beforeEach, expect, vi } from 'vitest';
2
2
  import { pipe } from 'it-pipe';
3
3
  import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, } from '../srpc/index.js';
4
- import { EchoerDefinition, EchoerServer, runClientTest } from '../echo/index.js';
4
+ import { EchoerDefinition, EchoerServer, EchoerServiceName, runClientTest, } from '../echo/index.js';
5
5
  import { runAbortControllerTest, runRpcStreamTest, } from '../echo/client-test.js';
6
6
  describe('srpc server', () => {
7
7
  let client;
@@ -39,4 +39,24 @@ describe('srpc server', () => {
39
39
  it('should pass rpc stream tests', async () => {
40
40
  await runRpcStreamTest(client);
41
41
  });
42
+ it('removes abort listeners after a request completes', async () => {
43
+ const controller = new AbortController();
44
+ const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener');
45
+ await client.request(EchoerServiceName, 'Echo', new TextEncoder().encode('hello'), controller.signal);
46
+ await new Promise((resolve) => setTimeout(resolve, 50));
47
+ expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function));
48
+ });
49
+ it('tears down passive channel close state', async () => {
50
+ const { port1, port2 } = new MessageChannel();
51
+ const opts = { idleTimeoutMs: 1000, keepAliveMs: 1000 };
52
+ const active = new ChannelStream('active', port1, opts);
53
+ const passive = new ChannelStream('passive', port2, opts);
54
+ const next = passive.source[Symbol.asyncIterator]().next();
55
+ active.close();
56
+ await expect(next).resolves.toEqual({ done: true, value: undefined });
57
+ expect(passive.closed).toBe(true);
58
+ expect(passive.idleWatchdog).toBeUndefined();
59
+ expect(passive.keepAlive).toBeUndefined();
60
+ expect(port2.onmessage).toBe(null);
61
+ });
42
62
  });
package/go.mod CHANGED
@@ -3,7 +3,7 @@ module github.com/aperturerobotics/starpc
3
3
  go 1.25.0
4
4
 
5
5
  require (
6
- github.com/aperturerobotics/common v0.32.1 // latest
6
+ github.com/aperturerobotics/common v0.32.2 // latest
7
7
  github.com/aperturerobotics/protobuf-go-lite v0.12.2 // latest
8
8
  github.com/aperturerobotics/util v1.33.0 // latest
9
9
  )
package/go.sum CHANGED
@@ -2,8 +2,8 @@ github.com/aperturerobotics/abseil-cpp v0.0.0-20260131110040-4bb56e2f9017 h1:3U7
2
2
  github.com/aperturerobotics/abseil-cpp v0.0.0-20260131110040-4bb56e2f9017/go.mod h1:lNSJTKECIUFAnfeSqy01kXYTYe1BHubW7198jNX3nEw=
3
3
  github.com/aperturerobotics/cli v1.1.0 h1:7a+YRC+EY3npAnTzhHV5gLCiw91KS0Ts3XwLILGOsT8=
4
4
  github.com/aperturerobotics/cli v1.1.0/go.mod h1:M7BFP9wow5ytTzMyJQOOO991fGfsUqdTI7gGEsHfTQ8=
5
- github.com/aperturerobotics/common v0.32.1 h1:pvmmDBttGnQM5HBoe8T1xHeHmFg2L7ecPZjx1oHLIX0=
6
- github.com/aperturerobotics/common v0.32.1/go.mod h1:6I+CZvcNEXf7Abd0tFVbMgOJ/pFE8C4qlYG9KZvBAnc=
5
+ github.com/aperturerobotics/common v0.32.2 h1:YAZfvBphNxGTF7x+O/e7UIFw8cCf5Pog6ptw7Y+PjTU=
6
+ github.com/aperturerobotics/common v0.32.2/go.mod h1:6glNxt6cEYab10WAVT4tOGhwgvDG2fnUMSyKuKZg02Y=
7
7
  github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0 h1:6/3RSSlPEQ6LeidslB1ZCJkxW+MnfYDkvdWMDklDXw4=
8
8
  github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0/go.mod h1:OBb/beWmr/pDIZAUfi86j/4tBh2v5ctTxKMqSnh9c/4=
9
9
  github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260329113540-600516012db3 h1:lp+V8RYcBwTX1p81swkpZn5fhw1wn2xLorzETIxRyZQ=
@@ -21,7 +21,7 @@ func main() {
21
21
  addr := os.Args[1]
22
22
 
23
23
  openStream := func(ctx context.Context, msgHandler srpc.PacketDataHandler, closeHandler srpc.CloseHandler) (srpc.PacketWriter, error) {
24
- conn, err := net.Dial("tcp", addr)
24
+ conn, err := net.Dial("tcp", addr) //nolint:gosec
25
25
  if err != nil {
26
26
  return nil, err
27
27
  }
@@ -55,7 +55,7 @@ echo "Building TypeScript server/client..."
55
55
  --outfile="$SCRIPT_DIR/ts-client.mjs"
56
56
 
57
57
  echo "Building Rust server/client..."
58
- cargo build --release --bin integration-server --bin integration-client 2>&1 | grep -v "^warning:" || true
58
+ cargo build --release -p echo-example --bin integration-server --bin integration-client
59
59
 
60
60
  echo "Vendoring Go dependencies (needed for C++ build)..."
61
61
  go mod vendor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starpc",
3
- "version": "0.49.2",
3
+ "version": "0.49.3",
4
4
  "description": "Streaming protobuf RPC service protocol over any two-way channel.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "scripts": {
64
64
  "clean": "rimraf ./dist",
65
- "build": "bun run clean && tsc --outDir ./dist/",
65
+ "build": "bun run clean && tsgo --outDir ./dist/",
66
66
  "check": "bun run typecheck",
67
67
  "typecheck": "tsgo --noEmit",
68
68
  "deps": "depcheck --ignores 'bufferutil,utf-8-validate,rimraf,starpc,@aptre/protobuf-es-lite,tsx'",
package/srpc/channel.ts CHANGED
@@ -80,6 +80,8 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
80
80
  private keepAlive?: Watchdog
81
81
  // idleWatchdog is the receive timeout watchdog.
82
82
  private idleWatchdog?: Watchdog
83
+ // closed indicates the local channel has been torn down.
84
+ private closed = false
83
85
 
84
86
  // isAcked checks if the stream is acknowledged by the remote.
85
87
  public get isAcked() {
@@ -168,6 +170,9 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
168
170
 
169
171
  // postMessage writes a message to the stream.
170
172
  private postMessage(msg: Partial<ChannelStreamMessage<T>>) {
173
+ if (this.closed) {
174
+ return
175
+ }
171
176
  msg.from = this.localId
172
177
  if (this.channel instanceof MessagePort) {
173
178
  this.channel.postMessage(msg)
@@ -195,22 +200,35 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
195
200
  }
196
201
  }
197
202
 
198
- // close closes the broadcast channels.
199
- public close(error?: Error) {
200
- // write a message to indicate the stream is now closed.
201
- this.postMessage({ closed: true, error })
203
+ // finish tears down local stream state, optionally notifying the remote side.
204
+ private finish(error?: Error, notifyRemote?: boolean) {
205
+ if (this.closed) {
206
+ return
207
+ }
208
+ if (notifyRemote) {
209
+ try {
210
+ this.postMessage({ closed: true, error })
211
+ } catch {
212
+ // Ignore close races while tearing down the local channel.
213
+ }
214
+ }
215
+ this.closed = true
202
216
  // close channels
203
217
  if (this.channel instanceof MessagePort) {
218
+ this.channel.onmessage = null
204
219
  this.channel.close()
205
220
  } else {
221
+ this.channel.rx.onmessage = null
206
222
  this.channel.tx.close()
207
223
  this.channel.rx.close()
208
224
  }
209
225
  if (!this.remoteOpen && this._remoteOpen) {
210
226
  this._remoteOpen(error || new Error('closed'))
227
+ delete this._remoteOpen
211
228
  }
212
229
  if (!this.remoteAck && this._remoteAck) {
213
230
  this._remoteAck(error || new Error('closed'))
231
+ delete this._remoteAck
214
232
  }
215
233
  if (this.idleWatchdog) {
216
234
  this.idleWatchdog.clear()
@@ -223,6 +241,11 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
223
241
  this._source.end(error)
224
242
  }
225
243
 
244
+ // close closes the broadcast channels.
245
+ public close(error?: Error) {
246
+ this.finish(error, true)
247
+ }
248
+
226
249
  // pauseIdle pauses the idle watchdog, preventing the stream from timing out.
227
250
  // Use this when the remote is known to be inactive (e.g., browser tab hidden).
228
251
  public pauseIdle() {
@@ -298,9 +321,11 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
298
321
  this._source.push(data)
299
322
  }
300
323
  if (err) {
301
- this._source.end(err)
302
- } else if (closed) {
303
- this._source.end()
324
+ this.finish(err, false)
325
+ return
326
+ }
327
+ if (closed) {
328
+ this.finish(undefined, false)
304
329
  }
305
330
  }
306
331
  }
package/srpc/client.ts CHANGED
@@ -116,13 +116,17 @@ export class Client implements ProtoRpc {
116
116
  const openStreamFn = await this.openStreamCtr.wait()
117
117
  const stream = await openStreamFn()
118
118
  const call = new ClientRPC(rpcService, rpcMethod)
119
- abortSignal?.addEventListener('abort', () => {
119
+ const onAbort = () => {
120
120
  call.writeCallCancel()
121
121
  call.close(new Error(ERR_RPC_ABORT))
122
- })
122
+ }
123
+ abortSignal?.addEventListener('abort', onAbort, { once: true })
123
124
  pipe(stream, decodePacketSource, call, encodePacketSource, stream)
124
125
  .catch((err) => call.close(err))
125
126
  .then(() => call.close())
127
+ .finally(() => {
128
+ abortSignal?.removeEventListener('abort', onAbort)
129
+ })
126
130
  await call.writeCallStart(data ?? undefined)
127
131
  return call
128
132
  }
@@ -1,4 +1,4 @@
1
- import { describe, it, beforeEach } from 'vitest'
1
+ import { describe, it, beforeEach, expect, vi } from 'vitest'
2
2
  import { pipe } from 'it-pipe'
3
3
  import {
4
4
  createHandler,
@@ -10,7 +10,12 @@ import {
10
10
  combineUint8ArrayListTransform,
11
11
  ChannelStreamOpts,
12
12
  } from '../srpc/index.js'
13
- import { EchoerDefinition, EchoerServer, runClientTest } from '../echo/index.js'
13
+ import {
14
+ EchoerDefinition,
15
+ EchoerServer,
16
+ EchoerServiceName,
17
+ runClientTest,
18
+ } from '../echo/index.js'
14
19
  import {
15
20
  runAbortControllerTest,
16
21
  runRpcStreamTest,
@@ -71,4 +76,41 @@ describe('srpc server', () => {
71
76
  it('should pass rpc stream tests', async () => {
72
77
  await runRpcStreamTest(client)
73
78
  })
79
+
80
+ it('removes abort listeners after a request completes', async () => {
81
+ const controller = new AbortController()
82
+ const removeEventListener = vi.spyOn(
83
+ controller.signal,
84
+ 'removeEventListener',
85
+ )
86
+
87
+ await client.request(
88
+ EchoerServiceName,
89
+ 'Echo',
90
+ new TextEncoder().encode('hello'),
91
+ controller.signal,
92
+ )
93
+ await new Promise((resolve) => setTimeout(resolve, 50))
94
+
95
+ expect(removeEventListener).toHaveBeenCalledWith(
96
+ 'abort',
97
+ expect.any(Function),
98
+ )
99
+ })
100
+
101
+ it('tears down passive channel close state', async () => {
102
+ const { port1, port2 } = new MessageChannel()
103
+ const opts: ChannelStreamOpts = { idleTimeoutMs: 1000, keepAliveMs: 1000 }
104
+ const active = new ChannelStream('active', port1, opts)
105
+ const passive = new ChannelStream('passive', port2, opts)
106
+ const next = passive.source[Symbol.asyncIterator]().next()
107
+
108
+ active.close()
109
+ await expect(next).resolves.toEqual({ done: true, value: undefined })
110
+
111
+ expect((passive as any).closed).toBe(true)
112
+ expect((passive as any).idleWatchdog).toBeUndefined()
113
+ expect((passive as any).keepAlive).toBeUndefined()
114
+ expect(port2.onmessage).toBe(null)
115
+ })
74
116
  })