starpc 0.25.0 → 0.25.2
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 +21 -2
- package/dist/srpc/channel.d.ts +15 -6
- package/dist/srpc/channel.js +52 -10
- package/dist/srpc/errors.d.ts +2 -0
- package/dist/srpc/errors.js +10 -0
- package/dist/srpc/index.d.ts +3 -2
- package/dist/srpc/index.js +2 -1
- package/dist/srpc/watchdog.d.ts +35 -0
- package/dist/srpc/watchdog.js +64 -0
- package/e2e/e2e.ts +43 -2
- package/package.json +1 -1
- package/srpc/channel.ts +88 -18
- package/srpc/errors.ts +12 -0
- package/srpc/index.ts +9 -1
- package/srpc/watchdog.ts +70 -0
package/dist/e2e/e2e.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pipe } from 'it-pipe';
|
|
2
|
-
import { createHandler, createMux, Server, Client, StreamConn } 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() {
|
|
@@ -7,13 +7,32 @@ async function runRPC() {
|
|
|
7
7
|
const server = new Server(mux.lookupMethodFunc);
|
|
8
8
|
const echoer = new EchoerServer(server);
|
|
9
9
|
mux.register(createHandler(EchoerDefinition, echoer));
|
|
10
|
+
// StreamConn is unnecessary since ChannelStream has packet framing.
|
|
11
|
+
// Use it here to include yamux in this e2e test.
|
|
10
12
|
const clientConn = new StreamConn();
|
|
11
13
|
const serverConn = new StreamConn(server, { direction: 'inbound' });
|
|
12
|
-
pipe
|
|
14
|
+
// pipe clientConn -> messageStream -> serverConn -> messageStream -> clientConn
|
|
15
|
+
const { port1: clientPort, port2: serverPort } = new MessageChannel();
|
|
16
|
+
const opts = { idleTimeoutMs: 250, keepAliveMs: 100 };
|
|
17
|
+
const clientChannelStream = new ChannelStream('client', clientPort, opts);
|
|
18
|
+
const serverChannelStream = new ChannelStream('server', serverPort, opts);
|
|
19
|
+
// Pipe the client traffic via the client end of the MessageChannel.
|
|
20
|
+
pipe(clientChannelStream, clientConn, combineUint8ArrayListTransform(), clientChannelStream)
|
|
21
|
+
.catch((err) => clientConn.close(err))
|
|
22
|
+
.then(() => clientConn.close());
|
|
23
|
+
// Pipe the server traffic via the server end of the MessageChannel.
|
|
24
|
+
pipe(serverChannelStream, serverConn, combineUint8ArrayListTransform(), serverChannelStream)
|
|
25
|
+
.catch((err) => serverConn.close(err))
|
|
26
|
+
.then(() => serverConn.close());
|
|
27
|
+
// Build the client
|
|
13
28
|
const client = new Client(clientConn.buildOpenStreamFunc());
|
|
29
|
+
// Run the tests
|
|
14
30
|
await runClientTest(client);
|
|
15
31
|
await runAbortControllerTest(client);
|
|
16
32
|
await runRpcStreamTest(client);
|
|
33
|
+
// Close cleanly
|
|
34
|
+
clientConn.close();
|
|
35
|
+
serverConn.close();
|
|
17
36
|
}
|
|
18
37
|
runRPC()
|
|
19
38
|
.then(() => {
|
package/dist/srpc/channel.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Sink, Source, Duplex } from 'it-stream-types';
|
|
2
2
|
export interface ChannelStreamMessage<T> {
|
|
3
3
|
from: string;
|
|
4
|
-
ack?:
|
|
5
|
-
opened?:
|
|
6
|
-
closed?:
|
|
4
|
+
ack?: true;
|
|
5
|
+
opened?: true;
|
|
6
|
+
closed?: true;
|
|
7
7
|
error?: Error;
|
|
8
8
|
data?: T;
|
|
9
9
|
}
|
|
@@ -11,7 +11,12 @@ export type ChannelPort = MessagePort | {
|
|
|
11
11
|
tx: BroadcastChannel;
|
|
12
12
|
rx: BroadcastChannel;
|
|
13
13
|
};
|
|
14
|
-
export
|
|
14
|
+
export interface ChannelStreamOpts {
|
|
15
|
+
remoteOpen?: boolean;
|
|
16
|
+
keepAliveMs?: number;
|
|
17
|
+
idleTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenerator<T>, Source<T>, Promise<void>> {
|
|
15
20
|
readonly channel: ChannelPort;
|
|
16
21
|
sink: Sink<Source<T>, Promise<void>>;
|
|
17
22
|
source: AsyncGenerator<T>;
|
|
@@ -24,10 +29,14 @@ export declare class ChannelStream<T> implements Duplex<AsyncGenerator<T>, Sourc
|
|
|
24
29
|
private remoteAck;
|
|
25
30
|
readonly waitRemoteAck: Promise<void>;
|
|
26
31
|
private _remoteAck?;
|
|
32
|
+
private keepAlive?;
|
|
33
|
+
private idleWatchdog?;
|
|
27
34
|
get isAcked(): boolean;
|
|
28
35
|
get isOpen(): boolean;
|
|
29
|
-
constructor(localId: string, channel: ChannelPort,
|
|
36
|
+
constructor(localId: string, channel: ChannelPort, opts?: ChannelStreamOpts);
|
|
30
37
|
private postMessage;
|
|
38
|
+
private idleElapsed;
|
|
39
|
+
private keepAliveElapsed;
|
|
31
40
|
close(error?: Error): void;
|
|
32
41
|
private onLocalOpened;
|
|
33
42
|
private onRemoteAcked;
|
|
@@ -35,4 +44,4 @@ export declare class ChannelStream<T> implements Duplex<AsyncGenerator<T>, Sourc
|
|
|
35
44
|
private _createSink;
|
|
36
45
|
private onMessage;
|
|
37
46
|
}
|
|
38
|
-
export declare function newBroadcastChannelStream<T>(id: string, readName: string, writeName: string,
|
|
47
|
+
export declare function newBroadcastChannelStream<T>(id: string, readName: string, writeName: string, opts?: ChannelStreamOpts): ChannelStream<T>;
|
package/dist/srpc/channel.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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.
|
|
@@ -7,21 +9,22 @@ import { pushable } from 'it-pushable';
|
|
|
7
9
|
export class ChannelStream {
|
|
8
10
|
// isAcked checks if the stream is acknowledged by the remote.
|
|
9
11
|
get isAcked() {
|
|
10
|
-
return this.remoteAck
|
|
12
|
+
return this.remoteAck ?? false;
|
|
11
13
|
}
|
|
12
14
|
// isOpen checks if the stream is opened by the remote.
|
|
13
15
|
get isOpen() {
|
|
14
|
-
return this.remoteOpen
|
|
16
|
+
return this.remoteOpen ?? false;
|
|
15
17
|
}
|
|
16
|
-
// remoteOpen indicates
|
|
17
|
-
constructor(localId, channel,
|
|
18
|
+
// remoteOpen indicates that we know the remote has already opened the stream.
|
|
19
|
+
constructor(localId, channel, opts) {
|
|
20
|
+
// initial state
|
|
18
21
|
this.localId = localId;
|
|
19
22
|
this.channel = channel;
|
|
20
|
-
this.sink = this._createSink();
|
|
21
23
|
this.localOpen = false;
|
|
22
|
-
this.
|
|
23
|
-
this.
|
|
24
|
-
|
|
24
|
+
this.remoteOpen = opts?.remoteOpen ?? false;
|
|
25
|
+
this.remoteAck = this.remoteOpen;
|
|
26
|
+
// wire up the promises for remote ack and remote open
|
|
27
|
+
if (this.remoteOpen) {
|
|
25
28
|
this.waitRemoteOpen = Promise.resolve();
|
|
26
29
|
this.waitRemoteAck = Promise.resolve();
|
|
27
30
|
}
|
|
@@ -49,9 +52,13 @@ export class ChannelStream {
|
|
|
49
52
|
});
|
|
50
53
|
this.waitRemoteAck.catch(() => { });
|
|
51
54
|
}
|
|
55
|
+
// create the sink
|
|
56
|
+
this.sink = this._createSink();
|
|
57
|
+
// create the pushable source
|
|
52
58
|
const source = pushable({ objectMode: true });
|
|
53
59
|
this.source = source;
|
|
54
60
|
this._source = source;
|
|
61
|
+
// wire up the message handlers
|
|
55
62
|
const onMessage = this.onMessage.bind(this);
|
|
56
63
|
if (channel instanceof MessagePort) {
|
|
57
64
|
// MessagePort
|
|
@@ -62,6 +69,14 @@ export class ChannelStream {
|
|
|
62
69
|
// BroadcastChannel
|
|
63
70
|
channel.rx.onmessage = onMessage;
|
|
64
71
|
}
|
|
72
|
+
// handle the keep alive or idle timeout opts
|
|
73
|
+
if (opts?.idleTimeoutMs != null) {
|
|
74
|
+
this.idleWatchdog = new Watchdog(opts.idleTimeoutMs, () => this.idleElapsed());
|
|
75
|
+
}
|
|
76
|
+
if (opts?.keepAliveMs != null) {
|
|
77
|
+
this.keepAlive = new Watchdog(opts.keepAliveMs, () => this.keepAliveElapsed());
|
|
78
|
+
}
|
|
79
|
+
// broadcast ack to start the stream
|
|
65
80
|
this.postMessage({ ack: true });
|
|
66
81
|
}
|
|
67
82
|
// postMessage writes a message to the stream.
|
|
@@ -73,6 +88,23 @@ export class ChannelStream {
|
|
|
73
88
|
else {
|
|
74
89
|
this.channel.tx.postMessage(msg);
|
|
75
90
|
}
|
|
91
|
+
if (!msg.closed) {
|
|
92
|
+
this.keepAlive?.feed();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// idleElapsed is called if the idle timeout was elapsed.
|
|
96
|
+
idleElapsed() {
|
|
97
|
+
if (this.idleWatchdog) {
|
|
98
|
+
delete this.idleWatchdog;
|
|
99
|
+
this.close(new Error(ERR_STREAM_IDLE));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// keepAliveElapsed is called if the keep alive timeout was elapsed.
|
|
103
|
+
keepAliveElapsed() {
|
|
104
|
+
if (this.keepAlive) {
|
|
105
|
+
// send a keep-alive message
|
|
106
|
+
this.postMessage({});
|
|
107
|
+
}
|
|
76
108
|
}
|
|
77
109
|
// close closes the broadcast channels.
|
|
78
110
|
close(error) {
|
|
@@ -92,6 +124,15 @@ export class ChannelStream {
|
|
|
92
124
|
if (!this.remoteAck && this._remoteAck) {
|
|
93
125
|
this._remoteAck(error || new Error('closed'));
|
|
94
126
|
}
|
|
127
|
+
if (this.idleWatchdog) {
|
|
128
|
+
this.idleWatchdog.clear();
|
|
129
|
+
delete this.idleWatchdog;
|
|
130
|
+
}
|
|
131
|
+
if (this.keepAlive) {
|
|
132
|
+
this.keepAlive.clear();
|
|
133
|
+
delete this.keepAlive;
|
|
134
|
+
}
|
|
135
|
+
this._source.end(error);
|
|
95
136
|
}
|
|
96
137
|
// onLocalOpened indicates the local side has opened the read stream.
|
|
97
138
|
onLocalOpened() {
|
|
@@ -140,6 +181,7 @@ export class ChannelStream {
|
|
|
140
181
|
if (!msg || msg.from === this.localId || !msg.from) {
|
|
141
182
|
return;
|
|
142
183
|
}
|
|
184
|
+
this.idleWatchdog?.feed();
|
|
143
185
|
if (msg.ack || msg.opened) {
|
|
144
186
|
this.onRemoteAcked();
|
|
145
187
|
}
|
|
@@ -159,6 +201,6 @@ export class ChannelStream {
|
|
|
159
201
|
}
|
|
160
202
|
}
|
|
161
203
|
// newBroadcastChannelStream constructs a ChannelStream with a channel name.
|
|
162
|
-
export function newBroadcastChannelStream(id, readName, writeName,
|
|
163
|
-
return new ChannelStream(id, { tx: new BroadcastChannel(writeName), rx: new BroadcastChannel(readName) },
|
|
204
|
+
export function newBroadcastChannelStream(id, readName, writeName, opts) {
|
|
205
|
+
return new ChannelStream(id, { tx: new BroadcastChannel(writeName), rx: new BroadcastChannel(readName) }, opts);
|
|
164
206
|
}
|
package/dist/srpc/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/srpc/errors.js
CHANGED
|
@@ -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)
|
package/dist/srpc/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/srpc/index.js
CHANGED
|
@@ -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';
|
|
@@ -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
|
+
}
|
package/e2e/e2e.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { pipe } from 'it-pipe'
|
|
2
|
-
import {
|
|
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
|
|
|
@@ -9,16 +18,48 @@ async function runRPC() {
|
|
|
9
18
|
const echoer = new EchoerServer(server)
|
|
10
19
|
mux.register(createHandler(EchoerDefinition, echoer))
|
|
11
20
|
|
|
21
|
+
// StreamConn is unnecessary since ChannelStream has packet framing.
|
|
22
|
+
// Use it here to include yamux in this e2e test.
|
|
12
23
|
const clientConn = new StreamConn()
|
|
13
24
|
const serverConn = new StreamConn(server, { direction: 'inbound' })
|
|
14
25
|
|
|
15
|
-
pipe
|
|
26
|
+
// pipe clientConn -> messageStream -> serverConn -> messageStream -> clientConn
|
|
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)
|
|
16
31
|
|
|
32
|
+
// Pipe the client traffic via the client end of the MessageChannel.
|
|
33
|
+
pipe(
|
|
34
|
+
clientChannelStream,
|
|
35
|
+
clientConn,
|
|
36
|
+
combineUint8ArrayListTransform(),
|
|
37
|
+
clientChannelStream,
|
|
38
|
+
)
|
|
39
|
+
.catch((err: Error) => clientConn.close(err))
|
|
40
|
+
.then(() => clientConn.close())
|
|
41
|
+
|
|
42
|
+
// Pipe the server traffic via the server end of the MessageChannel.
|
|
43
|
+
pipe(
|
|
44
|
+
serverChannelStream,
|
|
45
|
+
serverConn,
|
|
46
|
+
combineUint8ArrayListTransform(),
|
|
47
|
+
serverChannelStream,
|
|
48
|
+
)
|
|
49
|
+
.catch((err: Error) => serverConn.close(err))
|
|
50
|
+
.then(() => serverConn.close())
|
|
51
|
+
|
|
52
|
+
// Build the client
|
|
17
53
|
const client = new Client(clientConn.buildOpenStreamFunc())
|
|
18
54
|
|
|
55
|
+
// Run the tests
|
|
19
56
|
await runClientTest(client)
|
|
20
57
|
await runAbortControllerTest(client)
|
|
21
58
|
await runRpcStreamTest(client)
|
|
59
|
+
|
|
60
|
+
// Close cleanly
|
|
61
|
+
clientConn.close()
|
|
62
|
+
serverConn.close()
|
|
22
63
|
}
|
|
23
64
|
|
|
24
65
|
runRPC()
|
package/package.json
CHANGED
package/srpc/channel.ts
CHANGED
|
@@ -1,31 +1,48 @@
|
|
|
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> {
|
|
6
8
|
// from indicates who sent the message.
|
|
7
9
|
from: string
|
|
8
|
-
// ack indicates a remote
|
|
9
|
-
ack?:
|
|
10
|
+
// ack indicates a remote acked establishing the stream.
|
|
11
|
+
ack?: true
|
|
10
12
|
// opened indicates the remote has opened the stream.
|
|
11
|
-
opened?:
|
|
13
|
+
opened?: true
|
|
12
14
|
// closed indicates the stream is closed.
|
|
13
|
-
closed?:
|
|
15
|
+
closed?: true
|
|
14
16
|
// error indicates the stream has an error.
|
|
15
17
|
error?: Error
|
|
16
18
|
// data is any message data.
|
|
17
19
|
data?: T
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
//
|
|
21
|
-
export type ChannelPort =
|
|
22
|
+
// ChannelPort represents a channel we can open a stream over.
|
|
23
|
+
export type ChannelPort =
|
|
24
|
+
| MessagePort
|
|
25
|
+
| { tx: BroadcastChannel; rx: BroadcastChannel }
|
|
26
|
+
|
|
27
|
+
// ChannelStreamOpts are options for ChannelStream.
|
|
28
|
+
export interface ChannelStreamOpts {
|
|
29
|
+
// remoteOpen indicates that the remote already knows the channel is open.
|
|
30
|
+
// this skips sending and waiting for the open+ack messages.
|
|
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
|
|
38
|
+
}
|
|
22
39
|
|
|
23
40
|
// ChannelStream implements a Stream over a BroadcastChannel duplex or MessagePort.
|
|
24
41
|
//
|
|
25
42
|
// NOTE: there is no way to tell if a BroadcastChannel or MessagePort is closed.
|
|
26
43
|
// This implementation sends a "closed" message when close() is called.
|
|
27
44
|
// However: if the remote is removed w/o closing cleanly, the stream will be left open!
|
|
28
|
-
export class ChannelStream<T>
|
|
45
|
+
export class ChannelStream<T = Uint8Array>
|
|
29
46
|
implements Duplex<AsyncGenerator<T>, Source<T>, Promise<void>>
|
|
30
47
|
{
|
|
31
48
|
// channel is the read/write channel.
|
|
@@ -55,27 +72,32 @@ export class ChannelStream<T>
|
|
|
55
72
|
public readonly waitRemoteAck: Promise<void>
|
|
56
73
|
// _remoteAck fulfills the waitRemoteAck promise.
|
|
57
74
|
private _remoteAck?: (err?: Error) => void
|
|
75
|
+
// keepAliveWatchdog is the transmission timeout watchdog.
|
|
76
|
+
private keepAlive?: Watchdog
|
|
77
|
+
// idleWatchdog is the receive timeout watchdog.
|
|
78
|
+
private idleWatchdog?: Watchdog
|
|
58
79
|
|
|
59
80
|
// isAcked checks if the stream is acknowledged by the remote.
|
|
60
81
|
public get isAcked() {
|
|
61
|
-
return this.remoteAck
|
|
82
|
+
return this.remoteAck ?? false
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
// isOpen checks if the stream is opened by the remote.
|
|
65
86
|
public get isOpen() {
|
|
66
|
-
return this.remoteOpen
|
|
87
|
+
return this.remoteOpen ?? false
|
|
67
88
|
}
|
|
68
89
|
|
|
69
|
-
// remoteOpen indicates
|
|
70
|
-
constructor(localId: string, channel: ChannelPort,
|
|
90
|
+
// remoteOpen indicates that we know the remote has already opened the stream.
|
|
91
|
+
constructor(localId: string, channel: ChannelPort, opts?: ChannelStreamOpts) {
|
|
92
|
+
// initial state
|
|
71
93
|
this.localId = localId
|
|
72
94
|
this.channel = channel
|
|
73
|
-
this.sink = this._createSink()
|
|
74
|
-
|
|
75
95
|
this.localOpen = false
|
|
76
|
-
this.
|
|
77
|
-
this.
|
|
78
|
-
|
|
96
|
+
this.remoteOpen = opts?.remoteOpen ?? false
|
|
97
|
+
this.remoteAck = this.remoteOpen
|
|
98
|
+
|
|
99
|
+
// wire up the promises for remote ack and remote open
|
|
100
|
+
if (this.remoteOpen) {
|
|
79
101
|
this.waitRemoteOpen = Promise.resolve()
|
|
80
102
|
this.waitRemoteAck = Promise.resolve()
|
|
81
103
|
} else {
|
|
@@ -101,10 +123,15 @@ export class ChannelStream<T>
|
|
|
101
123
|
this.waitRemoteAck.catch(() => {})
|
|
102
124
|
}
|
|
103
125
|
|
|
126
|
+
// create the sink
|
|
127
|
+
this.sink = this._createSink()
|
|
128
|
+
|
|
129
|
+
// create the pushable source
|
|
104
130
|
const source: Pushable<T> = pushable({ objectMode: true })
|
|
105
131
|
this.source = source
|
|
106
132
|
this._source = source
|
|
107
133
|
|
|
134
|
+
// wire up the message handlers
|
|
108
135
|
const onMessage = this.onMessage.bind(this)
|
|
109
136
|
if (channel instanceof MessagePort) {
|
|
110
137
|
// MessagePort
|
|
@@ -114,6 +141,20 @@ export class ChannelStream<T>
|
|
|
114
141
|
// BroadcastChannel
|
|
115
142
|
channel.rx.onmessage = onMessage
|
|
116
143
|
}
|
|
144
|
+
|
|
145
|
+
// handle the keep alive or idle timeout opts
|
|
146
|
+
if (opts?.idleTimeoutMs != null) {
|
|
147
|
+
this.idleWatchdog = new Watchdog(opts.idleTimeoutMs, () =>
|
|
148
|
+
this.idleElapsed(),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (opts?.keepAliveMs != null) {
|
|
152
|
+
this.keepAlive = new Watchdog(opts.keepAliveMs, () =>
|
|
153
|
+
this.keepAliveElapsed(),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// broadcast ack to start the stream
|
|
117
158
|
this.postMessage({ ack: true })
|
|
118
159
|
}
|
|
119
160
|
|
|
@@ -125,6 +166,25 @@ export class ChannelStream<T>
|
|
|
125
166
|
} else {
|
|
126
167
|
this.channel.tx.postMessage(msg)
|
|
127
168
|
}
|
|
169
|
+
if (!msg.closed) {
|
|
170
|
+
this.keepAlive?.feed()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// idleElapsed is called if the idle timeout was elapsed.
|
|
175
|
+
private idleElapsed() {
|
|
176
|
+
if (this.idleWatchdog) {
|
|
177
|
+
delete this.idleWatchdog
|
|
178
|
+
this.close(new Error(ERR_STREAM_IDLE))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// keepAliveElapsed is called if the keep alive timeout was elapsed.
|
|
183
|
+
private keepAliveElapsed() {
|
|
184
|
+
if (this.keepAlive) {
|
|
185
|
+
// send a keep-alive message
|
|
186
|
+
this.postMessage({})
|
|
187
|
+
}
|
|
128
188
|
}
|
|
129
189
|
|
|
130
190
|
// close closes the broadcast channels.
|
|
@@ -144,6 +204,15 @@ export class ChannelStream<T>
|
|
|
144
204
|
if (!this.remoteAck && this._remoteAck) {
|
|
145
205
|
this._remoteAck(error || new Error('closed'))
|
|
146
206
|
}
|
|
207
|
+
if (this.idleWatchdog) {
|
|
208
|
+
this.idleWatchdog.clear()
|
|
209
|
+
delete this.idleWatchdog
|
|
210
|
+
}
|
|
211
|
+
if (this.keepAlive) {
|
|
212
|
+
this.keepAlive.clear()
|
|
213
|
+
delete this.keepAlive
|
|
214
|
+
}
|
|
215
|
+
this._source.end(error)
|
|
147
216
|
}
|
|
148
217
|
|
|
149
218
|
// onLocalOpened indicates the local side has opened the read stream.
|
|
@@ -197,6 +266,7 @@ export class ChannelStream<T>
|
|
|
197
266
|
if (!msg || msg.from === this.localId || !msg.from) {
|
|
198
267
|
return
|
|
199
268
|
}
|
|
269
|
+
this.idleWatchdog?.feed()
|
|
200
270
|
if (msg.ack || msg.opened) {
|
|
201
271
|
this.onRemoteAcked()
|
|
202
272
|
}
|
|
@@ -220,11 +290,11 @@ export function newBroadcastChannelStream<T>(
|
|
|
220
290
|
id: string,
|
|
221
291
|
readName: string,
|
|
222
292
|
writeName: string,
|
|
223
|
-
|
|
293
|
+
opts?: ChannelStreamOpts,
|
|
224
294
|
): ChannelStream<T> {
|
|
225
295
|
return new ChannelStream<T>(
|
|
226
296
|
id,
|
|
227
297
|
{ tx: new BroadcastChannel(writeName), rx: new BroadcastChannel(readName) },
|
|
228
|
-
|
|
298
|
+
opts,
|
|
229
299
|
)
|
|
230
300
|
}
|
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 {
|
|
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/watchdog.ts
ADDED
|
@@ -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
|
+
}
|