starpc 0.25.1 → 0.25.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.
package/dist/e2e/e2e.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { pipe } from 'it-pipe';
2
- import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform } from '../srpc';
2
+ import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, } from '../srpc';
3
3
  import { EchoerDefinition, EchoerServer, runClientTest } from '../echo';
4
4
  import { runAbortControllerTest, runRpcStreamTest } from '../echo/client-test';
5
5
  async function runRPC() {
@@ -13,8 +13,9 @@ async function runRPC() {
13
13
  const serverConn = new StreamConn(server, { direction: 'inbound' });
14
14
  // pipe clientConn -> messageStream -> serverConn -> messageStream -> clientConn
15
15
  const { port1: clientPort, port2: serverPort } = new MessageChannel();
16
- const clientChannelStream = new ChannelStream('client', clientPort);
17
- const serverChannelStream = new ChannelStream('server', serverPort);
16
+ const opts = { idleTimeoutMs: 250, keepAliveMs: 100 };
17
+ const clientChannelStream = new ChannelStream('client', clientPort, opts);
18
+ const serverChannelStream = new ChannelStream('server', serverPort, opts);
18
19
  // Pipe the client traffic via the client end of the MessageChannel.
19
20
  pipe(clientChannelStream, clientConn, combineUint8ArrayListTransform(), clientChannelStream)
20
21
  .catch((err) => clientConn.close(err))
@@ -5,7 +5,7 @@ import { OpenStreamFunc, PacketStream } from '../srpc/stream.js';
5
5
  export type RpcStreamCaller = (request: AsyncIterable<RpcStreamPacket>) => AsyncIterable<RpcStreamPacket>;
6
6
  export declare function openRpcStream(componentId: string, caller: RpcStreamCaller, waitAck?: boolean): Promise<PacketStream>;
7
7
  export declare function buildRpcStreamOpenStream(componentId: string, caller: RpcStreamCaller): OpenStreamFunc;
8
- export type RpcStreamHandler = (stream: PacketStream) => void;
8
+ export type RpcStreamHandler = (stream: PacketStream) => Promise<void>;
9
9
  export type RpcStreamGetter = (componentId: string) => Promise<RpcStreamHandler | null>;
10
10
  export declare function handleRpcStream(packetRx: AsyncIterator<RpcStreamPacket>, getter: RpcStreamGetter): AsyncIterable<RpcStreamPacket>;
11
11
  export declare class RpcStream implements PacketStream {
@@ -86,7 +86,9 @@ export async function* handleRpcStream(packetRx, getter) {
86
86
  const packetTx = pushable({ objectMode: true });
87
87
  // start the handler
88
88
  const rpcStream = new RpcStream(packetTx, packetRx);
89
- handler(rpcStream);
89
+ handler(rpcStream)
90
+ .catch((err) => packetTx.end(err))
91
+ .then(() => packetTx.end());
90
92
  // process packets
91
93
  for await (const packet of packetTx) {
92
94
  yield* [packet];
@@ -3,7 +3,6 @@ export interface ChannelStreamMessage<T> {
3
3
  from: string;
4
4
  ack?: true;
5
5
  opened?: true;
6
- alive?: true;
7
6
  closed?: true;
8
7
  error?: Error;
9
8
  data?: T;
@@ -14,6 +13,8 @@ export type ChannelPort = MessagePort | {
14
13
  };
15
14
  export interface ChannelStreamOpts {
16
15
  remoteOpen?: boolean;
16
+ keepAliveMs?: number;
17
+ idleTimeoutMs?: number;
17
18
  }
18
19
  export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenerator<T>, Source<T>, Promise<void>> {
19
20
  readonly channel: ChannelPort;
@@ -28,10 +29,14 @@ export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenera
28
29
  private remoteAck;
29
30
  readonly waitRemoteAck: Promise<void>;
30
31
  private _remoteAck?;
32
+ private keepAlive?;
33
+ private idleWatchdog?;
31
34
  get isAcked(): boolean;
32
35
  get isOpen(): boolean;
33
36
  constructor(localId: string, channel: ChannelPort, opts?: ChannelStreamOpts);
34
37
  private postMessage;
38
+ private idleElapsed;
39
+ private keepAliveElapsed;
35
40
  close(error?: Error): void;
36
41
  private onLocalOpened;
37
42
  private onRemoteAcked;
@@ -1,9 +1,12 @@
1
1
  import { pushable } from 'it-pushable';
2
+ import { Watchdog } from './watchdog.js';
3
+ import { ERR_STREAM_IDLE } from './errors.js';
2
4
  // ChannelStream implements a Stream over a BroadcastChannel duplex or MessagePort.
3
5
  //
4
6
  // NOTE: there is no way to tell if a BroadcastChannel or MessagePort is closed.
5
7
  // This implementation sends a "closed" message when close() is called.
6
8
  // However: if the remote is removed w/o closing cleanly, the stream will be left open!
9
+ // Enable keepAliveMs and idleTimeoutMs to mitigate this issue with keep-alive messages.
7
10
  export class ChannelStream {
8
11
  // isAcked checks if the stream is acknowledged by the remote.
9
12
  get isAcked() {
@@ -15,12 +18,13 @@ export class ChannelStream {
15
18
  }
16
19
  // remoteOpen indicates that we know the remote has already opened the stream.
17
20
  constructor(localId, channel, opts) {
21
+ // initial state
18
22
  this.localId = localId;
19
23
  this.channel = channel;
20
- this.sink = this._createSink();
21
24
  this.localOpen = false;
22
25
  this.remoteOpen = opts?.remoteOpen ?? false;
23
26
  this.remoteAck = this.remoteOpen;
27
+ // wire up the promises for remote ack and remote open
24
28
  if (this.remoteOpen) {
25
29
  this.waitRemoteOpen = Promise.resolve();
26
30
  this.waitRemoteAck = Promise.resolve();
@@ -49,9 +53,13 @@ export class ChannelStream {
49
53
  });
50
54
  this.waitRemoteAck.catch(() => { });
51
55
  }
56
+ // create the sink
57
+ this.sink = this._createSink();
58
+ // create the pushable source
52
59
  const source = pushable({ objectMode: true });
53
60
  this.source = source;
54
61
  this._source = source;
62
+ // wire up the message handlers
55
63
  const onMessage = this.onMessage.bind(this);
56
64
  if (channel instanceof MessagePort) {
57
65
  // MessagePort
@@ -62,6 +70,14 @@ export class ChannelStream {
62
70
  // BroadcastChannel
63
71
  channel.rx.onmessage = onMessage;
64
72
  }
73
+ // handle the keep alive or idle timeout opts
74
+ if (opts?.idleTimeoutMs != null) {
75
+ this.idleWatchdog = new Watchdog(opts.idleTimeoutMs, () => this.idleElapsed());
76
+ }
77
+ if (opts?.keepAliveMs != null) {
78
+ this.keepAlive = new Watchdog(opts.keepAliveMs, () => this.keepAliveElapsed());
79
+ }
80
+ // broadcast ack to start the stream
65
81
  this.postMessage({ ack: true });
66
82
  }
67
83
  // postMessage writes a message to the stream.
@@ -73,6 +89,23 @@ export class ChannelStream {
73
89
  else {
74
90
  this.channel.tx.postMessage(msg);
75
91
  }
92
+ if (!msg.closed) {
93
+ this.keepAlive?.feed();
94
+ }
95
+ }
96
+ // idleElapsed is called if the idle timeout was elapsed.
97
+ idleElapsed() {
98
+ if (this.idleWatchdog) {
99
+ delete this.idleWatchdog;
100
+ this.close(new Error(ERR_STREAM_IDLE));
101
+ }
102
+ }
103
+ // keepAliveElapsed is called if the keep alive timeout was elapsed.
104
+ keepAliveElapsed() {
105
+ if (this.keepAlive) {
106
+ // send a keep-alive message
107
+ this.postMessage({});
108
+ }
76
109
  }
77
110
  // close closes the broadcast channels.
78
111
  close(error) {
@@ -92,6 +125,15 @@ export class ChannelStream {
92
125
  if (!this.remoteAck && this._remoteAck) {
93
126
  this._remoteAck(error || new Error('closed'));
94
127
  }
128
+ if (this.idleWatchdog) {
129
+ this.idleWatchdog.clear();
130
+ delete this.idleWatchdog;
131
+ }
132
+ if (this.keepAlive) {
133
+ this.keepAlive.clear();
134
+ delete this.keepAlive;
135
+ }
136
+ this._source.end(error);
95
137
  }
96
138
  // onLocalOpened indicates the local side has opened the read stream.
97
139
  onLocalOpened() {
@@ -140,6 +182,7 @@ export class ChannelStream {
140
182
  if (!msg || msg.from === this.localId || !msg.from) {
141
183
  return;
142
184
  }
185
+ this.idleWatchdog?.feed();
143
186
  if (msg.ack || msg.opened) {
144
187
  this.onRemoteAcked();
145
188
  }
@@ -85,8 +85,8 @@ export class Client {
85
85
  call.close(new Error(ERR_RPC_ABORT));
86
86
  });
87
87
  pipe(stream, decodePacketSource, call, encodePacketSource, stream)
88
- .then(() => call.close())
89
- .catch((err) => call.close(err));
88
+ .catch((err) => call.close(err))
89
+ .then(() => call.close());
90
90
  await call.writeCallStart(data || undefined);
91
91
  return call;
92
92
  }
@@ -1,3 +1,5 @@
1
1
  export declare const ERR_RPC_ABORT = "ERR_RPC_ABORT";
2
2
  export declare function isAbortError(err: unknown): boolean;
3
+ export declare const ERR_STREAM_IDLE = "ERR_STREAM_IDLE";
4
+ export declare function isStreamIdleError(err: unknown): boolean;
3
5
  export declare function castToError(err: any, defaultMsg?: string): Error;
@@ -8,6 +8,16 @@ export function isAbortError(err) {
8
8
  const message = err.message;
9
9
  return message === ERR_RPC_ABORT;
10
10
  }
11
+ // ERR_STREAM_IDLE is returned if the stream idle timeout was exceeded.
12
+ export const ERR_STREAM_IDLE = 'ERR_STREAM_IDLE';
13
+ // isStreamIdleError checks if the error object is ERR_STREAM_IDLE.
14
+ export function isStreamIdleError(err) {
15
+ if (typeof err !== 'object') {
16
+ return false;
17
+ }
18
+ const message = err.message;
19
+ return message === ERR_STREAM_IDLE;
20
+ }
11
21
  // castToError casts an object to an Error.
12
22
  // if err is a string, uses it as the message.
13
23
  // if err is undefined, returns new Error(defaultMsg)
@@ -1,4 +1,4 @@
1
- export { ERR_RPC_ABORT, isAbortError, castToError } from './errors.js';
1
+ export { ERR_RPC_ABORT, isAbortError, ERR_STREAM_IDLE, isStreamIdleError, castToError, } from './errors.js';
2
2
  export { Client } from './client.js';
3
3
  export { Server } from './server.js';
4
4
  export { StreamConn, StreamConnParams, StreamHandler } from './conn.js';
@@ -8,7 +8,7 @@ export { Handler, InvokeFn, MethodMap, StaticHandler, createHandler, } from './h
8
8
  export { MethodProto, createInvokeFn } from './invoker.js';
9
9
  export { Packet, CallStart, CallData } from './rpcproto.pb.js';
10
10
  export { Mux, StaticMux, LookupMethod, createMux } from './mux.js';
11
- export { ChannelStreamMessage, ChannelPort, ChannelStream, newBroadcastChannelStream, } from './channel.js';
11
+ export { ChannelStreamMessage, ChannelPort, ChannelStream, ChannelStreamOpts, newBroadcastChannelStream, } from './channel.js';
12
12
  export { BroadcastChannelDuplex, BroadcastChannelConn, newBroadcastChannelDuplex, } from './broadcast-channel.js';
13
13
  export { MessagePortDuplex, MessagePortConn, newMessagePortDuplex, } from './message-port.js';
14
14
  export { MessageDefinition, DecodeMessageTransform, buildDecodeMessageTransform, EncodeMessageTransform, buildEncodeMessageTransform, memoProto, memoProtoDecode, } from './message.js';
@@ -17,3 +17,4 @@ export { combineUint8ArrayListTransform } from './array-list.js';
17
17
  export { ValueCtr } from './value-ctr.js';
18
18
  export { OpenStreamCtr } from './open-stream-ctr.js';
19
19
  export { writeToPushable, buildPushableSink } from './pushable.js';
20
+ export { Watchdog } from './watchdog.js';
@@ -1,4 +1,4 @@
1
- export { ERR_RPC_ABORT, isAbortError, castToError } from './errors.js';
1
+ export { ERR_RPC_ABORT, isAbortError, ERR_STREAM_IDLE, isStreamIdleError, castToError, } from './errors.js';
2
2
  export { Client } from './client.js';
3
3
  export { Server } from './server.js';
4
4
  export { StreamConn } from './conn.js';
@@ -16,3 +16,4 @@ export { combineUint8ArrayListTransform } from './array-list.js';
16
16
  export { ValueCtr } from './value-ctr.js';
17
17
  export { OpenStreamCtr } from './open-stream-ctr.js';
18
18
  export { writeToPushable, buildPushableSink } from './pushable.js';
19
+ export { Watchdog } from './watchdog.js';
@@ -9,7 +9,12 @@ export class Server {
9
9
  // rpcStreamHandler implements the RpcStreamHandler interface.
10
10
  // uses handlePacketDuplex (expects 1 buf = 1 Packet)
11
11
  get rpcStreamHandler() {
12
- return this.handlePacketStream.bind(this);
12
+ return async (stream) => {
13
+ const rpc = this.startRpc();
14
+ return pipe(stream, decodePacketSource, rpc, encodePacketSource, stream)
15
+ .catch((err) => rpc.close(err))
16
+ .then(() => rpc.close());
17
+ };
13
18
  }
14
19
  // startRpc starts a new server-side RPC.
15
20
  // the returned RPC handles incoming Packets.
@@ -0,0 +1,35 @@
1
+ export declare class Watchdog {
2
+ private timeoutDuration;
3
+ private expiredCallback;
4
+ private timerId;
5
+ private lastFeedTimestamp;
6
+ /**
7
+ * Constructs a Watchdog instance.
8
+ * The Watchdog will not start ticking until feed() is called.
9
+ * @param timeoutDuration The duration in milliseconds after which the watchdog should expire if not fed.
10
+ * @param expiredCallback The callback function to be called when the watchdog expires.
11
+ */
12
+ constructor(timeoutDuration: number, expiredCallback: () => void);
13
+ /**
14
+ * Feeds the watchdog, preventing it from expiring.
15
+ * This resets the timeout and reschedules the next tick.
16
+ */
17
+ feed(): void;
18
+ /**
19
+ * Clears the current timeout, effectively stopping the watchdog.
20
+ * This prevents the expired callback from being called until the watchdog is fed again.
21
+ */
22
+ clear(): void;
23
+ /**
24
+ * Schedules the next tick of the watchdog.
25
+ * This method calculates the delay for the next tick based on the last feed time
26
+ * and schedules a call to tickWatchdog after that delay.
27
+ */
28
+ private scheduleTickWatchdog;
29
+ /**
30
+ * Handler for the watchdog tick.
31
+ * Checks if the time since the last feed is greater than the timeout duration.
32
+ * If so, it calls the expired callback. Otherwise, it reschedules the tick.
33
+ */
34
+ private tickWatchdog;
35
+ }
@@ -0,0 +1,64 @@
1
+ // Watchdog must be fed every timeoutDuration or it will call the expired callback.
2
+ export class Watchdog {
3
+ /**
4
+ * Constructs a Watchdog instance.
5
+ * The Watchdog will not start ticking until feed() is called.
6
+ * @param timeoutDuration The duration in milliseconds after which the watchdog should expire if not fed.
7
+ * @param expiredCallback The callback function to be called when the watchdog expires.
8
+ */
9
+ constructor(timeoutDuration, expiredCallback) {
10
+ this.timerId = null;
11
+ this.lastFeedTimestamp = null;
12
+ this.timeoutDuration = timeoutDuration;
13
+ this.expiredCallback = expiredCallback;
14
+ }
15
+ /**
16
+ * Feeds the watchdog, preventing it from expiring.
17
+ * This resets the timeout and reschedules the next tick.
18
+ */
19
+ feed() {
20
+ this.lastFeedTimestamp = Date.now();
21
+ this.scheduleTickWatchdog(this.timeoutDuration);
22
+ }
23
+ /**
24
+ * Clears the current timeout, effectively stopping the watchdog.
25
+ * This prevents the expired callback from being called until the watchdog is fed again.
26
+ */
27
+ clear() {
28
+ if (this.timerId != null) {
29
+ clearTimeout(this.timerId);
30
+ this.timerId = null;
31
+ }
32
+ this.lastFeedTimestamp = null;
33
+ }
34
+ /**
35
+ * Schedules the next tick of the watchdog.
36
+ * This method calculates the delay for the next tick based on the last feed time
37
+ * and schedules a call to tickWatchdog after that delay.
38
+ */
39
+ scheduleTickWatchdog(delay) {
40
+ if (this.timerId != null) {
41
+ clearTimeout(this.timerId);
42
+ }
43
+ this.timerId = setTimeout(() => this.tickWatchdog(), delay);
44
+ }
45
+ /**
46
+ * Handler for the watchdog tick.
47
+ * Checks if the time since the last feed is greater than the timeout duration.
48
+ * If so, it calls the expired callback. Otherwise, it reschedules the tick.
49
+ */
50
+ tickWatchdog() {
51
+ this.timerId = null;
52
+ if (this.lastFeedTimestamp == null) {
53
+ this.expiredCallback();
54
+ return;
55
+ }
56
+ const elapsedSinceLastFeed = Date.now() - this.lastFeedTimestamp;
57
+ if (elapsedSinceLastFeed >= this.timeoutDuration) {
58
+ this.expiredCallback();
59
+ }
60
+ else {
61
+ this.scheduleTickWatchdog(this.timeoutDuration - elapsedSinceLastFeed);
62
+ }
63
+ }
64
+ }
@@ -10,7 +10,9 @@ export class WebSocketConn extends StreamConn {
10
10
  const socketDuplex = duplex(socket);
11
11
  pipe(socketDuplex, this,
12
12
  // it-ws only supports sending Uint8Array.
13
- combineUint8ArrayListTransform(), socketDuplex);
13
+ combineUint8ArrayListTransform(), socketDuplex)
14
+ .catch((err) => this.close(err))
15
+ .then(() => this.close());
14
16
  }
15
17
  // getSocket returns the websocket.
16
18
  getSocket() {
package/e2e/e2e.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { pipe } from 'it-pipe'
2
- import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform } from '../srpc'
2
+ import {
3
+ createHandler,
4
+ createMux,
5
+ Server,
6
+ Client,
7
+ StreamConn,
8
+ ChannelStream,
9
+ combineUint8ArrayListTransform,
10
+ ChannelStreamOpts,
11
+ } from '../srpc'
3
12
  import { EchoerDefinition, EchoerServer, runClientTest } from '../echo'
4
13
  import { runAbortControllerTest, runRpcStreamTest } from '../echo/client-test'
5
14
 
@@ -15,17 +24,28 @@ async function runRPC() {
15
24
  const serverConn = new StreamConn(server, { direction: 'inbound' })
16
25
 
17
26
  // pipe clientConn -> messageStream -> serverConn -> messageStream -> clientConn
18
- const {port1: clientPort, port2: serverPort} = new MessageChannel()
19
- const clientChannelStream = new ChannelStream('client', clientPort)
20
- const serverChannelStream = new ChannelStream('server', serverPort)
27
+ const { port1: clientPort, port2: serverPort } = new MessageChannel()
28
+ const opts: ChannelStreamOpts = { idleTimeoutMs: 250, keepAliveMs: 100 }
29
+ const clientChannelStream = new ChannelStream('client', clientPort, opts)
30
+ const serverChannelStream = new ChannelStream('server', serverPort, opts)
21
31
 
22
32
  // Pipe the client traffic via the client end of the MessageChannel.
23
- pipe(clientChannelStream, clientConn, combineUint8ArrayListTransform(), clientChannelStream)
33
+ pipe(
34
+ clientChannelStream,
35
+ clientConn,
36
+ combineUint8ArrayListTransform(),
37
+ clientChannelStream,
38
+ )
24
39
  .catch((err: Error) => clientConn.close(err))
25
40
  .then(() => clientConn.close())
26
41
 
27
42
  // Pipe the server traffic via the server end of the MessageChannel.
28
- pipe(serverChannelStream, serverConn, combineUint8ArrayListTransform(), serverChannelStream)
43
+ pipe(
44
+ serverChannelStream,
45
+ serverConn,
46
+ combineUint8ArrayListTransform(),
47
+ serverChannelStream,
48
+ )
29
49
  .catch((err: Error) => serverConn.close(err))
30
50
  .then(() => serverConn.close())
31
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starpc",
3
- "version": "0.25.1",
3
+ "version": "0.25.3",
4
4
  "description": "Streaming protobuf RPC service protocol over any two-way channel.",
5
5
  "license": "MIT",
6
6
  "author": {
package/srpc/channel.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Sink, Source, Duplex } from 'it-stream-types'
2
2
  import { pushable, Pushable } from 'it-pushable'
3
+ import { Watchdog } from './watchdog.js'
4
+ import { ERR_STREAM_IDLE } from './errors.js'
3
5
 
4
6
  // ChannelStreamMessage is a message sent over the stream.
5
7
  export interface ChannelStreamMessage<T> {
@@ -9,9 +11,6 @@ export interface ChannelStreamMessage<T> {
9
11
  ack?: true
10
12
  // opened indicates the remote has opened the stream.
11
13
  opened?: true
12
- // alive indicates this is a keep-alive packet.
13
- // not set unless keep-alives are enabled.
14
- alive?: true
15
14
  // closed indicates the stream is closed.
16
15
  closed?: true
17
16
  // error indicates the stream has an error.
@@ -30,6 +29,12 @@ export interface ChannelStreamOpts {
30
29
  // remoteOpen indicates that the remote already knows the channel is open.
31
30
  // this skips sending and waiting for the open+ack messages.
32
31
  remoteOpen?: boolean
32
+ // keepAliveMs is the maximum time between sending before we send a keep-alive.
33
+ // if idleTimeoutMs is set on the remote end, this should be less by some margin.
34
+ keepAliveMs?: number
35
+ // idleTimeoutMs is the maximum time between receiving before we close the stream.
36
+ // if keepAliveMs is set on the remote end, this should be more by some margin.
37
+ idleTimeoutMs?: number
33
38
  }
34
39
 
35
40
  // ChannelStream implements a Stream over a BroadcastChannel duplex or MessagePort.
@@ -37,6 +42,7 @@ export interface ChannelStreamOpts {
37
42
  // NOTE: there is no way to tell if a BroadcastChannel or MessagePort is closed.
38
43
  // This implementation sends a "closed" message when close() is called.
39
44
  // However: if the remote is removed w/o closing cleanly, the stream will be left open!
45
+ // Enable keepAliveMs and idleTimeoutMs to mitigate this issue with keep-alive messages.
40
46
  export class ChannelStream<T = Uint8Array>
41
47
  implements Duplex<AsyncGenerator<T>, Source<T>, Promise<void>>
42
48
  {
@@ -67,6 +73,10 @@ export class ChannelStream<T = Uint8Array>
67
73
  public readonly waitRemoteAck: Promise<void>
68
74
  // _remoteAck fulfills the waitRemoteAck promise.
69
75
  private _remoteAck?: (err?: Error) => void
76
+ // keepAliveWatchdog is the transmission timeout watchdog.
77
+ private keepAlive?: Watchdog
78
+ // idleWatchdog is the receive timeout watchdog.
79
+ private idleWatchdog?: Watchdog
70
80
 
71
81
  // isAcked checks if the stream is acknowledged by the remote.
72
82
  public get isAcked() {
@@ -80,13 +90,14 @@ export class ChannelStream<T = Uint8Array>
80
90
 
81
91
  // remoteOpen indicates that we know the remote has already opened the stream.
82
92
  constructor(localId: string, channel: ChannelPort, opts?: ChannelStreamOpts) {
93
+ // initial state
83
94
  this.localId = localId
84
95
  this.channel = channel
85
- this.sink = this._createSink()
86
-
87
96
  this.localOpen = false
88
97
  this.remoteOpen = opts?.remoteOpen ?? false
89
98
  this.remoteAck = this.remoteOpen
99
+
100
+ // wire up the promises for remote ack and remote open
90
101
  if (this.remoteOpen) {
91
102
  this.waitRemoteOpen = Promise.resolve()
92
103
  this.waitRemoteAck = Promise.resolve()
@@ -113,10 +124,15 @@ export class ChannelStream<T = Uint8Array>
113
124
  this.waitRemoteAck.catch(() => {})
114
125
  }
115
126
 
127
+ // create the sink
128
+ this.sink = this._createSink()
129
+
130
+ // create the pushable source
116
131
  const source: Pushable<T> = pushable({ objectMode: true })
117
132
  this.source = source
118
133
  this._source = source
119
134
 
135
+ // wire up the message handlers
120
136
  const onMessage = this.onMessage.bind(this)
121
137
  if (channel instanceof MessagePort) {
122
138
  // MessagePort
@@ -126,6 +142,20 @@ export class ChannelStream<T = Uint8Array>
126
142
  // BroadcastChannel
127
143
  channel.rx.onmessage = onMessage
128
144
  }
145
+
146
+ // handle the keep alive or idle timeout opts
147
+ if (opts?.idleTimeoutMs != null) {
148
+ this.idleWatchdog = new Watchdog(opts.idleTimeoutMs, () =>
149
+ this.idleElapsed(),
150
+ )
151
+ }
152
+ if (opts?.keepAliveMs != null) {
153
+ this.keepAlive = new Watchdog(opts.keepAliveMs, () =>
154
+ this.keepAliveElapsed(),
155
+ )
156
+ }
157
+
158
+ // broadcast ack to start the stream
129
159
  this.postMessage({ ack: true })
130
160
  }
131
161
 
@@ -137,6 +167,25 @@ export class ChannelStream<T = Uint8Array>
137
167
  } else {
138
168
  this.channel.tx.postMessage(msg)
139
169
  }
170
+ if (!msg.closed) {
171
+ this.keepAlive?.feed()
172
+ }
173
+ }
174
+
175
+ // idleElapsed is called if the idle timeout was elapsed.
176
+ private idleElapsed() {
177
+ if (this.idleWatchdog) {
178
+ delete this.idleWatchdog
179
+ this.close(new Error(ERR_STREAM_IDLE))
180
+ }
181
+ }
182
+
183
+ // keepAliveElapsed is called if the keep alive timeout was elapsed.
184
+ private keepAliveElapsed() {
185
+ if (this.keepAlive) {
186
+ // send a keep-alive message
187
+ this.postMessage({})
188
+ }
140
189
  }
141
190
 
142
191
  // close closes the broadcast channels.
@@ -156,6 +205,15 @@ export class ChannelStream<T = Uint8Array>
156
205
  if (!this.remoteAck && this._remoteAck) {
157
206
  this._remoteAck(error || new Error('closed'))
158
207
  }
208
+ if (this.idleWatchdog) {
209
+ this.idleWatchdog.clear()
210
+ delete this.idleWatchdog
211
+ }
212
+ if (this.keepAlive) {
213
+ this.keepAlive.clear()
214
+ delete this.keepAlive
215
+ }
216
+ this._source.end(error)
159
217
  }
160
218
 
161
219
  // onLocalOpened indicates the local side has opened the read stream.
@@ -209,6 +267,7 @@ export class ChannelStream<T = Uint8Array>
209
267
  if (!msg || msg.from === this.localId || !msg.from) {
210
268
  return
211
269
  }
270
+ this.idleWatchdog?.feed()
212
271
  if (msg.ack || msg.opened) {
213
272
  this.onRemoteAcked()
214
273
  }
package/srpc/client.ts CHANGED
@@ -122,8 +122,8 @@ export class Client implements TsProtoRpc {
122
122
  call.close(new Error(ERR_RPC_ABORT))
123
123
  })
124
124
  pipe(stream, decodePacketSource, call, encodePacketSource, stream)
125
- .then(() => call.close())
126
125
  .catch((err) => call.close(err))
126
+ .then(() => call.close())
127
127
  await call.writeCallStart(data || undefined)
128
128
  return call
129
129
  }
package/srpc/errors.ts CHANGED
@@ -10,6 +10,18 @@ export function isAbortError(err: unknown): boolean {
10
10
  return message === ERR_RPC_ABORT
11
11
  }
12
12
 
13
+ // ERR_STREAM_IDLE is returned if the stream idle timeout was exceeded.
14
+ export const ERR_STREAM_IDLE = 'ERR_STREAM_IDLE'
15
+
16
+ // isStreamIdleError checks if the error object is ERR_STREAM_IDLE.
17
+ export function isStreamIdleError(err: unknown): boolean {
18
+ if (typeof err !== 'object') {
19
+ return false
20
+ }
21
+ const message = (err as Error).message
22
+ return message === ERR_STREAM_IDLE
23
+ }
24
+
13
25
  // castToError casts an object to an Error.
14
26
  // if err is a string, uses it as the message.
15
27
  // if err is undefined, returns new Error(defaultMsg)
package/srpc/index.ts CHANGED
@@ -1,4 +1,10 @@
1
- export { ERR_RPC_ABORT, isAbortError, castToError } from './errors.js'
1
+ export {
2
+ ERR_RPC_ABORT,
3
+ isAbortError,
4
+ ERR_STREAM_IDLE,
5
+ isStreamIdleError,
6
+ castToError,
7
+ } from './errors.js'
2
8
  export { Client } from './client.js'
3
9
  export { Server } from './server.js'
4
10
  export { StreamConn, StreamConnParams, StreamHandler } from './conn.js'
@@ -23,6 +29,7 @@ export {
23
29
  ChannelStreamMessage,
24
30
  ChannelPort,
25
31
  ChannelStream,
32
+ ChannelStreamOpts,
26
33
  newBroadcastChannelStream,
27
34
  } from './channel.js'
28
35
  export {
@@ -61,3 +68,4 @@ export { combineUint8ArrayListTransform } from './array-list.js'
61
68
  export { ValueCtr } from './value-ctr.js'
62
69
  export { OpenStreamCtr } from './open-stream-ctr.js'
63
70
  export { writeToPushable, buildPushableSink } from './pushable.js'
71
+ export { Watchdog } from './watchdog.js'
package/srpc/server.ts CHANGED
@@ -19,7 +19,12 @@ export class Server implements StreamHandler {
19
19
  // rpcStreamHandler implements the RpcStreamHandler interface.
20
20
  // uses handlePacketDuplex (expects 1 buf = 1 Packet)
21
21
  public get rpcStreamHandler(): RpcStreamHandler {
22
- return this.handlePacketStream.bind(this)
22
+ return async (stream: PacketStream) => {
23
+ const rpc = this.startRpc()
24
+ return pipe(stream, decodePacketSource, rpc, encodePacketSource, stream)
25
+ .catch((err: Error) => rpc.close(err))
26
+ .then(() => rpc.close())
27
+ }
23
28
  }
24
29
 
25
30
  // startRpc starts a new server-side RPC.
@@ -0,0 +1,70 @@
1
+ // Watchdog must be fed every timeoutDuration or it will call the expired callback.
2
+ export class Watchdog {
3
+ private timeoutDuration: number
4
+ private expiredCallback: () => void
5
+ private timerId: NodeJS.Timeout | null = null
6
+ private lastFeedTimestamp: number | null = null
7
+
8
+ /**
9
+ * Constructs a Watchdog instance.
10
+ * The Watchdog will not start ticking until feed() is called.
11
+ * @param timeoutDuration The duration in milliseconds after which the watchdog should expire if not fed.
12
+ * @param expiredCallback The callback function to be called when the watchdog expires.
13
+ */
14
+ constructor(timeoutDuration: number, expiredCallback: () => void) {
15
+ this.timeoutDuration = timeoutDuration
16
+ this.expiredCallback = expiredCallback
17
+ }
18
+
19
+ /**
20
+ * Feeds the watchdog, preventing it from expiring.
21
+ * This resets the timeout and reschedules the next tick.
22
+ */
23
+ public feed(): void {
24
+ this.lastFeedTimestamp = Date.now()
25
+ this.scheduleTickWatchdog(this.timeoutDuration)
26
+ }
27
+
28
+ /**
29
+ * Clears the current timeout, effectively stopping the watchdog.
30
+ * This prevents the expired callback from being called until the watchdog is fed again.
31
+ */
32
+ public clear(): void {
33
+ if (this.timerId != null) {
34
+ clearTimeout(this.timerId)
35
+ this.timerId = null
36
+ }
37
+ this.lastFeedTimestamp = null
38
+ }
39
+
40
+ /**
41
+ * Schedules the next tick of the watchdog.
42
+ * This method calculates the delay for the next tick based on the last feed time
43
+ * and schedules a call to tickWatchdog after that delay.
44
+ */
45
+ private scheduleTickWatchdog(delay: number): void {
46
+ if (this.timerId != null) {
47
+ clearTimeout(this.timerId)
48
+ }
49
+ this.timerId = setTimeout(() => this.tickWatchdog(), delay)
50
+ }
51
+
52
+ /**
53
+ * Handler for the watchdog tick.
54
+ * Checks if the time since the last feed is greater than the timeout duration.
55
+ * If so, it calls the expired callback. Otherwise, it reschedules the tick.
56
+ */
57
+ private tickWatchdog(): void {
58
+ this.timerId = null
59
+ if (this.lastFeedTimestamp == null) {
60
+ this.expiredCallback()
61
+ return
62
+ }
63
+ const elapsedSinceLastFeed = Date.now() - this.lastFeedTimestamp
64
+ if (elapsedSinceLastFeed >= this.timeoutDuration) {
65
+ this.expiredCallback()
66
+ } else {
67
+ this.scheduleTickWatchdog(this.timeoutDuration - elapsedSinceLastFeed)
68
+ }
69
+ }
70
+ }
package/srpc/websocket.ts CHANGED
@@ -24,6 +24,8 @@ export class WebSocketConn extends StreamConn {
24
24
  combineUint8ArrayListTransform(),
25
25
  socketDuplex,
26
26
  )
27
+ .catch((err) => this.close(err))
28
+ .then(() => this.close())
27
29
  }
28
30
 
29
31
  // getSocket returns the websocket.