starpc 0.52.0 → 0.52.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/README.md +37 -8
- package/dist/echo/client-test.d.ts +1 -1
- package/dist/echo/client-test.js +110 -2
- package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
- package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
- package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
- package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
- package/dist/integration/cross-language/ts-client.js +50 -37
- package/dist/integration/cross-language/ts-server.js +1 -36
- package/dist/rpcstream/rpcstream.d.ts +5 -1
- package/dist/rpcstream/rpcstream.js +75 -28
- package/dist/rpcstream/rpcstream.test.d.ts +1 -0
- package/dist/rpcstream/rpcstream.test.js +92 -0
- package/dist/srpc/client-rpc.d.ts +1 -0
- package/dist/srpc/client-rpc.js +9 -0
- package/dist/srpc/client-rpc.test.d.ts +1 -0
- package/dist/srpc/client-rpc.test.js +50 -0
- package/dist/srpc/client.js +18 -4
- package/dist/srpc/common-rpc.d.ts +5 -0
- package/dist/srpc/common-rpc.js +37 -8
- package/dist/srpc/common-rpc.test.js +2 -0
- package/dist/srpc/errors.d.ts +2 -0
- package/dist/srpc/errors.js +14 -0
- package/dist/srpc/index.d.ts +1 -1
- package/dist/srpc/index.js +1 -1
- package/dist/srpc/packet-codec.test.d.ts +1 -0
- package/dist/srpc/packet-codec.test.js +75 -0
- package/dist/srpc/packet.d.ts +1 -1
- package/dist/srpc/packet.js +11 -1
- package/dist/srpc/server.js +19 -6
- package/dist/srpc/server.test.js +43 -0
- package/dist/srpc/stream.d.ts +4 -1
- package/dist/srpc/stream.js +62 -3
- package/dist/srpc/stream.test.js +110 -1
- package/dist/srpc/termination.d.ts +27 -0
- package/dist/srpc/termination.js +56 -0
- package/dist/srpc/termination.test.d.ts +1 -0
- package/dist/srpc/termination.test.js +24 -0
- package/dist/testdata/packet-codec-vectors.json +64 -0
- package/echo/Cargo.toml +8 -0
- package/echo/client-test.ts +124 -2
- package/echo/echo_e2e_test.cpp +542 -985
- package/echo/echo_pb2.py +40 -0
- package/echo/echo_pb2.pyi +13 -0
- package/echo/echo_srpc.pb.cpp +3 -5
- package/echo/echo_srpc.pb.go +6 -9
- package/echo/echo_srpc.pb.hpp +23 -3
- package/echo/echo_srpc.pb.rs +61 -26
- package/echo/echo_srpc.py +309 -0
- package/echo/echo_srpc.pyi +85 -0
- package/echo/half_close_test.rs +178 -0
- package/echo/integration_server.rs +14 -21
- package/echo/main.rs +28 -48
- package/go.mod +3 -3
- package/go.sum +6 -8
- package/integration/cross-language/cpp-client.cpp +446 -402
- package/integration/cross-language/cpp-pipe-server.cpp +139 -0
- package/integration/cross-language/cpp-pipe_test.go +114 -0
- package/integration/cross-language/cpp-server.cpp +222 -186
- package/integration/cross-language/go-client/main.go +79 -3
- package/integration/cross-language/python-client.py +146 -0
- package/integration/cross-language/python-server.py +140 -0
- package/integration/cross-language/run.bash +190 -65
- package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
- package/integration/cross-language/tcp-packet-stream.ts +121 -0
- package/integration/cross-language/ts-client.ts +62 -40
- package/integration/cross-language/ts-server.ts +1 -45
- package/mock/mock_pb2.py +38 -0
- package/mock/mock_pb2.pyi +11 -0
- package/mock/mock_srpc.pb.cpp +1 -1
- package/mock/mock_srpc.pb.hpp +1 -1
- package/mock/mock_srpc.pb.rs +10 -9
- package/mock/mock_srpc.py +71 -0
- package/mock/mock_srpc.pyi +27 -0
- package/package.json +19 -5
- package/srpc/CMakeLists.txt +3 -58
- package/srpc/README-cpp.md +69 -0
- package/srpc/__init__.py +0 -0
- package/srpc/accept.go +10 -4
- package/srpc/accept_test.go +25 -0
- package/srpc/build.rs +129 -34
- package/srpc/client-half-close_test.go +152 -0
- package/srpc/client-invoker.go +32 -42
- package/srpc/client-invoker_test.go +53 -0
- package/srpc/client-rpc.cpp +65 -80
- package/srpc/client-rpc.go +32 -33
- package/srpc/client-rpc.hpp +52 -38
- package/srpc/client-rpc.test.ts +67 -0
- package/srpc/client-rpc.ts +10 -0
- package/srpc/client-rpc_test.go +76 -0
- package/srpc/client.cpp +99 -87
- package/srpc/client.hpp +46 -35
- package/srpc/client.rs +78 -38
- package/srpc/client.ts +20 -4
- package/srpc/client_half_close_test.cc +331 -0
- package/srpc/client_invoker.rs +75 -0
- package/srpc/codec.rs +6 -0
- package/srpc/common-rpc.cpp +186 -146
- package/srpc/common-rpc.go +5 -4
- package/srpc/common-rpc.hpp +119 -89
- package/srpc/common-rpc.test.ts +2 -0
- package/srpc/common-rpc.ts +43 -8
- package/srpc/common-rpc_test.go +23 -0
- package/srpc/error.rs +19 -2
- package/srpc/errors.hpp +80 -50
- package/srpc/errors.ts +16 -0
- package/srpc/handler.hpp +9 -7
- package/srpc/handler.rs +7 -45
- package/srpc/index.ts +2 -0
- package/srpc/invoker.hpp +82 -55
- package/srpc/invoker.rs +14 -52
- package/srpc/lib.rs +2 -0
- package/srpc/message.hpp +17 -12
- package/srpc/msg-stream.go +1 -0
- package/srpc/msg-stream.hpp +108 -61
- package/srpc/mux.cpp +84 -84
- package/srpc/mux.hpp +48 -38
- package/srpc/mux.rs +4 -4
- package/srpc/net.go +12 -3
- package/srpc/packet-codec-vectors_test.go +195 -0
- package/srpc/packet-codec.test.ts +139 -0
- package/srpc/packet-rw.go +9 -2
- package/srpc/packet.cpp +60 -58
- package/srpc/packet.hpp +35 -20
- package/srpc/packet.rs +8 -27
- package/srpc/packet.ts +15 -2
- package/srpc/py.typed +0 -0
- package/srpc/rpc.rs +332 -115
- package/srpc/rpc_lifecycle_test.cc +333 -0
- package/srpc/rpcproto_pb2.py +40 -0
- package/srpc/rpcproto_pb2.pyi +40 -0
- package/srpc/server-cancel_test.go +39 -0
- package/srpc/server-rpc.cpp +83 -73
- package/srpc/server-rpc.go +21 -19
- package/srpc/server-rpc.hpp +56 -37
- package/srpc/server.rs +76 -131
- package/srpc/server.test.ts +50 -0
- package/srpc/server.ts +22 -6
- package/srpc/starpc.hpp +4 -2
- package/srpc/stream.go +4 -0
- package/srpc/stream.hpp +95 -34
- package/srpc/stream.rs +7 -2
- package/srpc/stream.test.ts +132 -1
- package/srpc/stream.ts +65 -9
- package/srpc/termination.test.ts +30 -0
- package/srpc/termination.ts +70 -0
- package/srpc/testing.rs +11 -66
- package/srpc/transport.rs +21 -71
- package/srpc/websocket.rs +106 -57
- package/srpc/writer.hpp +46 -32
- package/srpc/yamux.rs +82 -26
- package/srpc/tests/integration_test.rs +0 -495
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { pushable } from 'it-pushable';
|
|
2
|
+
import { closeIterator, sourceIterator, TerminationGate, } from '../srpc/termination.js';
|
|
2
3
|
// openRpcStream attempts to open a stream over a RPC call.
|
|
3
4
|
// if waitAck is set, waits for the remote to ack the stream before returning.
|
|
4
5
|
export async function openRpcStream(componentId, caller, waitAck) {
|
|
@@ -33,7 +34,7 @@ export async function openRpcStream(componentId, caller, waitAck) {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
// build & return the data stream
|
|
36
|
-
return new RpcStream(packetTx, packetIt);
|
|
37
|
+
return new RpcStream(packetTx, packetIt, () => closeIterator(packetIt));
|
|
37
38
|
}
|
|
38
39
|
// buildRpcStreamOpenStream builds a OpenStream func with a RpcStream.
|
|
39
40
|
export function buildRpcStreamOpenStream(componentId, caller) {
|
|
@@ -87,7 +88,7 @@ export async function* handleRpcStream(packetRx, getter) {
|
|
|
87
88
|
// build the outgoing packet sink & the packet source
|
|
88
89
|
const packetTx = pushable({ objectMode: true });
|
|
89
90
|
// start the handler
|
|
90
|
-
const rpcStream = new RpcStream(packetTx, packetRx);
|
|
91
|
+
const rpcStream = new RpcStream(packetTx, packetRx, () => closeIterator(packetRx));
|
|
91
92
|
handler(rpcStream)
|
|
92
93
|
.catch((err) => packetTx.end(err))
|
|
93
94
|
.then(() => packetTx.end());
|
|
@@ -107,54 +108,100 @@ export class RpcStream {
|
|
|
107
108
|
_packetRx;
|
|
108
109
|
// _packetTx writes packets to the remote.
|
|
109
110
|
_packetTx;
|
|
111
|
+
_termination = new TerminationGate();
|
|
112
|
+
_cancelRpc;
|
|
110
113
|
// packetTx writes packets to the remote.
|
|
111
114
|
// packetRx receives packets from the remote.
|
|
112
|
-
constructor(packetTx, packetRx) {
|
|
115
|
+
constructor(packetTx, packetRx, cancelRpc = () => closeIterator(packetRx)) {
|
|
113
116
|
this._packetTx = packetTx;
|
|
114
117
|
this._packetRx = packetRx;
|
|
118
|
+
this._cancelRpc = cancelRpc;
|
|
115
119
|
this.sink = this._createSink();
|
|
116
120
|
this.source = this._createSource();
|
|
117
121
|
}
|
|
122
|
+
// close cleanly ends both directions of the stream.
|
|
123
|
+
async close() {
|
|
124
|
+
if (this._termination.terminate()) {
|
|
125
|
+
this._packetTx.end();
|
|
126
|
+
this._cancelRpc();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// abort ends both directions of the stream with err.
|
|
130
|
+
abort(err) {
|
|
131
|
+
if (this._termination.terminate(err)) {
|
|
132
|
+
this._packetTx.end(err);
|
|
133
|
+
this._cancelRpc();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
118
136
|
// _createSink initializes the sink field.
|
|
119
137
|
_createSink() {
|
|
120
138
|
return async (source) => {
|
|
139
|
+
const iterator = sourceIterator(source);
|
|
121
140
|
try {
|
|
122
|
-
|
|
141
|
+
while (true) {
|
|
142
|
+
const next = await this._termination.next(iterator);
|
|
143
|
+
if ('terminated' in next) {
|
|
144
|
+
if (next.error)
|
|
145
|
+
throw next.error;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if ('error' in next)
|
|
149
|
+
throw next.error;
|
|
150
|
+
if (next.result.done) {
|
|
151
|
+
this._packetTx.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (this._termination.terminated)
|
|
155
|
+
return;
|
|
123
156
|
this._packetTx.push({
|
|
124
|
-
body: { case: 'data', value:
|
|
157
|
+
body: { case: 'data', value: next.result.value },
|
|
125
158
|
});
|
|
126
159
|
}
|
|
127
|
-
this._packetTx.end();
|
|
128
160
|
}
|
|
129
161
|
catch (err) {
|
|
130
|
-
|
|
162
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
163
|
+
this.abort(error);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
closeIterator(iterator);
|
|
131
168
|
}
|
|
132
169
|
};
|
|
133
170
|
}
|
|
134
171
|
// _createSource initializes the source field.
|
|
135
172
|
_createSource() {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
173
|
+
const packetRx = this._packetRx;
|
|
174
|
+
const termination = this._termination;
|
|
175
|
+
return (async function* () {
|
|
176
|
+
try {
|
|
177
|
+
while (true) {
|
|
178
|
+
const next = await termination.next(packetRx);
|
|
179
|
+
if ('terminated' in next) {
|
|
180
|
+
if (next.error)
|
|
181
|
+
throw next.error;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if ('error' in next)
|
|
185
|
+
throw next.error;
|
|
186
|
+
if (next.result.done)
|
|
187
|
+
return;
|
|
188
|
+
const body = next.result.value?.body;
|
|
189
|
+
if (!body)
|
|
190
|
+
continue;
|
|
191
|
+
switch (body.case) {
|
|
192
|
+
case 'ack':
|
|
193
|
+
if (body.value.error?.length)
|
|
194
|
+
throw new Error(body.value.error);
|
|
195
|
+
break;
|
|
196
|
+
case 'data':
|
|
197
|
+
yield body.value;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
156
200
|
}
|
|
157
201
|
}
|
|
158
|
-
|
|
202
|
+
finally {
|
|
203
|
+
closeIterator(packetRx);
|
|
204
|
+
}
|
|
205
|
+
})();
|
|
159
206
|
}
|
|
160
207
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { pushable } from 'it-pushable';
|
|
3
|
+
import { RpcStream } from './rpcstream.js';
|
|
4
|
+
describe('RpcStream lifecycle', () => {
|
|
5
|
+
it('closes while its source is blocked', async () => {
|
|
6
|
+
const tx = pushable({ objectMode: true });
|
|
7
|
+
const rx = pushable({ objectMode: true });
|
|
8
|
+
const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
|
|
9
|
+
const pending = stream.source.next();
|
|
10
|
+
await stream.close();
|
|
11
|
+
await expect(pending).resolves.toEqual({ done: true, value: undefined });
|
|
12
|
+
await expect(tx.next()).resolves.toEqual({ done: true, value: undefined });
|
|
13
|
+
});
|
|
14
|
+
it('aborts while its source is blocked', async () => {
|
|
15
|
+
const tx = pushable({ objectMode: true });
|
|
16
|
+
const rx = pushable({ objectMode: true });
|
|
17
|
+
const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
|
|
18
|
+
const pending = stream.source.next();
|
|
19
|
+
const error = new Error('stopped');
|
|
20
|
+
stream.abort(error);
|
|
21
|
+
await expect(pending).rejects.toBe(error);
|
|
22
|
+
await expect(tx.next()).rejects.toBe(error);
|
|
23
|
+
});
|
|
24
|
+
it('closes while its sink source is blocked', async () => {
|
|
25
|
+
const tx = pushable({ objectMode: true });
|
|
26
|
+
const rx = pushable({ objectMode: true });
|
|
27
|
+
const input = pushable({ objectMode: true });
|
|
28
|
+
const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
|
|
29
|
+
const pending = stream.sink(input);
|
|
30
|
+
await stream.close();
|
|
31
|
+
await expect(pending).resolves.toBeUndefined();
|
|
32
|
+
await expect(tx.next()).resolves.toEqual({ done: true, value: undefined });
|
|
33
|
+
});
|
|
34
|
+
it('aborts while its sink source is blocked', async () => {
|
|
35
|
+
const tx = pushable({ objectMode: true });
|
|
36
|
+
const rx = pushable({ objectMode: true });
|
|
37
|
+
const input = pushable({ objectMode: true });
|
|
38
|
+
const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
|
|
39
|
+
const pending = stream.sink(input);
|
|
40
|
+
const error = new Error('stopped');
|
|
41
|
+
stream.abort(error);
|
|
42
|
+
await expect(pending).rejects.toBe(error);
|
|
43
|
+
await expect(tx.next()).rejects.toBe(error);
|
|
44
|
+
});
|
|
45
|
+
it('does not write input that becomes ready as the stream closes', async () => {
|
|
46
|
+
const tx = pushable({ objectMode: true });
|
|
47
|
+
const rx = pushable({ objectMode: true });
|
|
48
|
+
const input = pushable({ objectMode: true });
|
|
49
|
+
const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
|
|
50
|
+
input.push(new Uint8Array([1]));
|
|
51
|
+
const pending = stream.sink(input);
|
|
52
|
+
await stream.close();
|
|
53
|
+
await expect(pending).resolves.toBeUndefined();
|
|
54
|
+
await expect(tx.next()).resolves.toEqual({ done: true, value: undefined });
|
|
55
|
+
});
|
|
56
|
+
it('does not close when its sink completes', async () => {
|
|
57
|
+
const tx = pushable({ objectMode: true });
|
|
58
|
+
const rx = pushable({ objectMode: true });
|
|
59
|
+
const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
|
|
60
|
+
await stream.sink((async function* () { })());
|
|
61
|
+
rx.push({ body: { case: 'data', value: new Uint8Array([1]) } });
|
|
62
|
+
await expect(stream.source.next()).resolves.toMatchObject({ done: false });
|
|
63
|
+
});
|
|
64
|
+
it('finalizes a sink input iterator when closed', async () => {
|
|
65
|
+
const tx = pushable({ objectMode: true });
|
|
66
|
+
const rx = pushable({ objectMode: true });
|
|
67
|
+
const returned = vi.fn(() => Promise.resolve({ done: true, value: undefined }));
|
|
68
|
+
const input = {
|
|
69
|
+
[Symbol.asyncIterator]: () => ({
|
|
70
|
+
next: () => new Promise(() => { }),
|
|
71
|
+
return: returned,
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
const stream = new RpcStream(tx, rx[Symbol.asyncIterator]());
|
|
75
|
+
const pending = stream.sink(input);
|
|
76
|
+
await stream.close();
|
|
77
|
+
await pending;
|
|
78
|
+
expect(returned).toHaveBeenCalledOnce();
|
|
79
|
+
});
|
|
80
|
+
it('cancels the outer RPC when aborted after sink completion', async () => {
|
|
81
|
+
const tx = pushable({ objectMode: true });
|
|
82
|
+
const returned = vi.fn(() => Promise.resolve({ done: true, value: undefined }));
|
|
83
|
+
const rx = {
|
|
84
|
+
next: () => new Promise(() => { }),
|
|
85
|
+
return: returned,
|
|
86
|
+
};
|
|
87
|
+
const stream = new RpcStream(tx, rx);
|
|
88
|
+
await stream.sink((async function* () { })());
|
|
89
|
+
stream.abort(new Error('stopped'));
|
|
90
|
+
expect(returned).toHaveBeenCalledOnce();
|
|
91
|
+
});
|
|
92
|
+
});
|
package/dist/srpc/client-rpc.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CommonRPC } from './common-rpc.js';
|
|
2
|
+
import { ERR_CLOSED_BEFORE_COMPLETION } from './errors.js';
|
|
2
3
|
// ClientRPC is an ongoing RPC from the client side.
|
|
3
4
|
export class ClientRPC extends CommonRPC {
|
|
4
5
|
constructor(service, method) {
|
|
@@ -30,4 +31,12 @@ export class ClientRPC extends CommonRPC {
|
|
|
30
31
|
// we do not implement server -> client RPCs.
|
|
31
32
|
throw new Error(`unexpected server to client rpc: ${packet.rpcService || '<empty>'}/${packet.rpcMethod || '<empty>'}`);
|
|
32
33
|
}
|
|
34
|
+
// handleSourceEnd fails the call when the transport ends before the remote
|
|
35
|
+
// sent a completion or an error, so a truncated stream does not read as a
|
|
36
|
+
// complete one.
|
|
37
|
+
async handleSourceEnd() {
|
|
38
|
+
if (!this.isRemoteCompleted) {
|
|
39
|
+
await this.close(new Error(ERR_CLOSED_BEFORE_COMPLETION));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
33
42
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ClientRPC } from './client-rpc.js';
|
|
3
|
+
import { isClosedBeforeCompletionError } from './errors.js';
|
|
4
|
+
import { Packet } from './rpcproto.pb.js';
|
|
5
|
+
describe('ClientRPC', () => {
|
|
6
|
+
it('fails a call whose transport ends before the remote completes it', async () => {
|
|
7
|
+
const call = new ClientRPC('svc', 'method');
|
|
8
|
+
await call.sink(packetSource(callDataPacket(new Uint8Array([1]))));
|
|
9
|
+
const received = new Array();
|
|
10
|
+
const read = async () => {
|
|
11
|
+
for await (const data of call.rpcDataSource) {
|
|
12
|
+
received.push(data[0]);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const err = await read().then(() => undefined, (err) => err);
|
|
16
|
+
expect(received).toEqual([1]);
|
|
17
|
+
expect(isClosedBeforeCompletionError(err)).toBe(true);
|
|
18
|
+
expect(isClosedBeforeCompletionError(call.isClosed)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
it('ends a call cleanly when the remote completes it before the transport ends', async () => {
|
|
21
|
+
const call = new ClientRPC('svc', 'method');
|
|
22
|
+
await call.sink(packetSource(callDataPacket(new Uint8Array([1])), callDataPacket(undefined, true)));
|
|
23
|
+
const received = new Array();
|
|
24
|
+
for await (const data of call.rpcDataSource) {
|
|
25
|
+
received.push(data[0]);
|
|
26
|
+
}
|
|
27
|
+
expect(received).toEqual([1]);
|
|
28
|
+
expect(call.isClosed).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
// callDataPacket builds an incoming call-data packet.
|
|
32
|
+
function callDataPacket(data, complete = false) {
|
|
33
|
+
return Packet.create({
|
|
34
|
+
body: {
|
|
35
|
+
case: 'callData',
|
|
36
|
+
value: {
|
|
37
|
+
data: data ?? new Uint8Array(0),
|
|
38
|
+
dataIsZero: false,
|
|
39
|
+
complete,
|
|
40
|
+
error: '',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// packetSource yields packets and then ends like a closed transport.
|
|
46
|
+
async function* packetSource(...packets) {
|
|
47
|
+
for (const packet of packets) {
|
|
48
|
+
yield packet;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/srpc/client.js
CHANGED
|
@@ -82,13 +82,27 @@ export class Client {
|
|
|
82
82
|
const stream = await openStreamFn();
|
|
83
83
|
const call = new ClientRPC(rpcService, rpcMethod);
|
|
84
84
|
const onAbort = () => {
|
|
85
|
+
if (call.isClosed)
|
|
86
|
+
return;
|
|
87
|
+
const error = new Error(ERR_RPC_ABORT);
|
|
85
88
|
void call.writeCallCancel().catch(() => undefined);
|
|
86
|
-
|
|
89
|
+
stream.abort(error);
|
|
90
|
+
void call.close(error).catch(() => undefined);
|
|
87
91
|
};
|
|
88
92
|
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
89
|
-
pipe(stream, decodePacketSource, call, encodePacketSource, stream)
|
|
90
|
-
.
|
|
91
|
-
.
|
|
93
|
+
void pipe(stream, decodePacketSource, call, encodePacketSource, stream)
|
|
94
|
+
.then(async () => {
|
|
95
|
+
if (call.isClosed instanceof Error) {
|
|
96
|
+
stream.abort(call.isClosed);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await stream.close();
|
|
100
|
+
await call.close();
|
|
101
|
+
}, async (err) => {
|
|
102
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
103
|
+
stream.abort(error);
|
|
104
|
+
await call.close(error);
|
|
105
|
+
})
|
|
92
106
|
.finally(() => {
|
|
93
107
|
abortSignal?.removeEventListener('abort', onAbort);
|
|
94
108
|
});
|
|
@@ -10,10 +10,12 @@ export declare class CommonRPC {
|
|
|
10
10
|
protected method?: string;
|
|
11
11
|
private closed?;
|
|
12
12
|
private remoteError?;
|
|
13
|
+
private remoteCompleted;
|
|
13
14
|
private readonly invocationController;
|
|
14
15
|
private readonly writeDrainAbort;
|
|
15
16
|
constructor();
|
|
16
17
|
get isClosed(): boolean | Error;
|
|
18
|
+
protected get isRemoteCompleted(): boolean;
|
|
17
19
|
protected get invocationSignal(): AbortSignal;
|
|
18
20
|
writeCallData(data?: Uint8Array, complete?: boolean, error?: string): Promise<void>;
|
|
19
21
|
private writeCallDataPacket;
|
|
@@ -27,6 +29,9 @@ export declare class CommonRPC {
|
|
|
27
29
|
handleCallData(packet: Partial<CallData>): Promise<void>;
|
|
28
30
|
handleCallCancel(): Promise<void>;
|
|
29
31
|
close(err?: Error): Promise<void>;
|
|
32
|
+
private endRpcData;
|
|
33
|
+
private readRpcData;
|
|
34
|
+
protected handleSourceEnd(): Promise<void>;
|
|
30
35
|
private _createSink;
|
|
31
36
|
}
|
|
32
37
|
interface WritePacketOptions {
|
package/dist/srpc/common-rpc.js
CHANGED
|
@@ -14,7 +14,9 @@ export class CommonRPC {
|
|
|
14
14
|
_source = pushable({
|
|
15
15
|
objectMode: true,
|
|
16
16
|
});
|
|
17
|
-
// _rpcDataSource
|
|
17
|
+
// _rpcDataSource queues incoming rpc messages and the terminal error.
|
|
18
|
+
// The error is queued as an item because ending a pushable with an error
|
|
19
|
+
// discards the messages it buffered.
|
|
18
20
|
_rpcDataSource = pushable({
|
|
19
21
|
objectMode: true,
|
|
20
22
|
});
|
|
@@ -26,6 +28,8 @@ export class CommonRPC {
|
|
|
26
28
|
closed;
|
|
27
29
|
// remoteError records a remote error or transport failure.
|
|
28
30
|
remoteError;
|
|
31
|
+
// remoteCompleted records that the remote sent a completion or an error.
|
|
32
|
+
remoteCompleted = false;
|
|
29
33
|
// invocationController cancels the server invocation when the RPC closes.
|
|
30
34
|
invocationController = new AbortController();
|
|
31
35
|
// writeDrainAbort wakes writers waiting for outbound stream drain on close.
|
|
@@ -33,12 +37,16 @@ export class CommonRPC {
|
|
|
33
37
|
constructor() {
|
|
34
38
|
this.sink = this._createSink();
|
|
35
39
|
this.source = this._source;
|
|
36
|
-
this.rpcDataSource = this.
|
|
40
|
+
this.rpcDataSource = this.readRpcData();
|
|
37
41
|
}
|
|
38
42
|
// isClosed returns one of: true (closed w/o error), Error (closed w/ error), or false (not closed).
|
|
39
43
|
get isClosed() {
|
|
40
44
|
return this.closed ?? false;
|
|
41
45
|
}
|
|
46
|
+
// isRemoteCompleted reports whether the remote sent a completion or an error.
|
|
47
|
+
get isRemoteCompleted() {
|
|
48
|
+
return this.remoteCompleted;
|
|
49
|
+
}
|
|
42
50
|
// invocationSignal is canceled when the RPC closes.
|
|
43
51
|
get invocationSignal() {
|
|
44
52
|
return this.invocationController.signal;
|
|
@@ -171,11 +179,9 @@ export class CommonRPC {
|
|
|
171
179
|
this.remoteError ??= remoteError;
|
|
172
180
|
this.invocationController.abort();
|
|
173
181
|
}
|
|
174
|
-
if (packet.complete
|
|
175
|
-
this.
|
|
176
|
-
|
|
177
|
-
else if (remoteError) {
|
|
178
|
-
this._rpcDataSource.end(remoteError);
|
|
182
|
+
if (packet.complete || remoteError) {
|
|
183
|
+
this.remoteCompleted = true;
|
|
184
|
+
this.endRpcData(remoteError);
|
|
179
185
|
}
|
|
180
186
|
}
|
|
181
187
|
// handleCallCancel aborts the invocation and closes the call.
|
|
@@ -201,7 +207,29 @@ export class CommonRPC {
|
|
|
201
207
|
}
|
|
202
208
|
this.writeDrainAbort.abort();
|
|
203
209
|
this._source.end();
|
|
204
|
-
this.
|
|
210
|
+
this.endRpcData(err);
|
|
211
|
+
}
|
|
212
|
+
// endRpcData ends the rpc message source after its buffered messages,
|
|
213
|
+
// optionally with an error.
|
|
214
|
+
endRpcData(err) {
|
|
215
|
+
if (err) {
|
|
216
|
+
this._rpcDataSource.push(err);
|
|
217
|
+
}
|
|
218
|
+
this._rpcDataSource.end();
|
|
219
|
+
}
|
|
220
|
+
// readRpcData yields the buffered rpc messages, then throws the terminal
|
|
221
|
+
// error, if any.
|
|
222
|
+
async *readRpcData() {
|
|
223
|
+
for await (const item of this._rpcDataSource) {
|
|
224
|
+
if (item instanceof Error) {
|
|
225
|
+
throw item;
|
|
226
|
+
}
|
|
227
|
+
yield item;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// handleSourceEnd handles the end of the incoming packet source.
|
|
231
|
+
async handleSourceEnd() {
|
|
232
|
+
// no-op
|
|
205
233
|
}
|
|
206
234
|
_createSink() {
|
|
207
235
|
return async (source) => {
|
|
@@ -216,6 +244,7 @@ export class CommonRPC {
|
|
|
216
244
|
await this.handlePacket(msg);
|
|
217
245
|
}
|
|
218
246
|
}
|
|
247
|
+
await this.handleSourceEnd();
|
|
219
248
|
}
|
|
220
249
|
catch (err) {
|
|
221
250
|
this.close(err);
|
|
@@ -101,6 +101,8 @@ describe('CommonRPC', () => {
|
|
|
101
101
|
const responseGate = deferred();
|
|
102
102
|
const response = new Uint8Array([7]);
|
|
103
103
|
const client = new Client(async () => ({
|
|
104
|
+
close: async () => { },
|
|
105
|
+
abort: () => { },
|
|
104
106
|
source: (async function* () {
|
|
105
107
|
await responseGate.promise;
|
|
106
108
|
yield Packet.toBinary({
|
package/dist/srpc/errors.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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_CLOSED_BEFORE_COMPLETION = "ERR_CLOSED_BEFORE_COMPLETION";
|
|
4
|
+
export declare function isClosedBeforeCompletionError(err: unknown): boolean;
|
|
3
5
|
export declare const ERR_STREAM_IDLE = "ERR_STREAM_IDLE";
|
|
4
6
|
export declare function isStreamIdleError(err: unknown): boolean;
|
|
5
7
|
export declare class RemoteRPCError extends Error {
|
package/dist/srpc/errors.js
CHANGED
|
@@ -8,6 +8,20 @@ export function isAbortError(err) {
|
|
|
8
8
|
const message = err.message;
|
|
9
9
|
return message === ERR_RPC_ABORT;
|
|
10
10
|
}
|
|
11
|
+
// ERR_CLOSED_BEFORE_COMPLETION is returned if the stream closed before the
|
|
12
|
+
// remote sent a completion or an error. The call has no verdict: the handler
|
|
13
|
+
// may have finished with its answer lost in the transport, or it may never
|
|
14
|
+
// have run.
|
|
15
|
+
export const ERR_CLOSED_BEFORE_COMPLETION = 'ERR_CLOSED_BEFORE_COMPLETION';
|
|
16
|
+
// isClosedBeforeCompletionError checks if the error object is
|
|
17
|
+
// ERR_CLOSED_BEFORE_COMPLETION.
|
|
18
|
+
export function isClosedBeforeCompletionError(err) {
|
|
19
|
+
if (typeof err !== 'object') {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const message = err.message;
|
|
23
|
+
return message === ERR_CLOSED_BEFORE_COMPLETION;
|
|
24
|
+
}
|
|
11
25
|
// ERR_STREAM_IDLE is returned if the stream idle timeout was exceeded.
|
|
12
26
|
export const ERR_STREAM_IDLE = 'ERR_STREAM_IDLE';
|
|
13
27
|
// isStreamIdleError checks if the error object is ERR_STREAM_IDLE.
|
package/dist/srpc/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { ERR_RPC_ABORT, isAbortError, ERR_STREAM_IDLE, isStreamIdleError, castToError, } from './errors.js';
|
|
1
|
+
export { ERR_RPC_ABORT, isAbortError, ERR_CLOSED_BEFORE_COMPLETION, isClosedBeforeCompletionError, 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';
|
package/dist/srpc/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { ERR_RPC_ABORT, isAbortError, ERR_STREAM_IDLE, isStreamIdleError, castToError, } from './errors.js';
|
|
1
|
+
export { ERR_RPC_ABORT, isAbortError, ERR_CLOSED_BEFORE_COMPLETION, isClosedBeforeCompletionError, 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';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import vectors from '../testdata/packet-codec-vectors.json';
|
|
3
|
+
import { Packet } from './rpcproto.pb.js';
|
|
4
|
+
import { decodePacketSource, encodePacketSource, lengthPrefixDecode, prependLengthPrefixTransform, uint32LEDecode, } from './packet.js';
|
|
5
|
+
const bytes = (hex) => Uint8Array.from(hex.match(/../g) ?? [], (b) => parseInt(b, 16));
|
|
6
|
+
const hex = (data) => Buffer.from(data.subarray()).toString('hex');
|
|
7
|
+
const collect = async (source) => {
|
|
8
|
+
const out = [];
|
|
9
|
+
for await (const value of source)
|
|
10
|
+
out.push(value);
|
|
11
|
+
return out;
|
|
12
|
+
};
|
|
13
|
+
const validCases = vectors.cases.filter((entry) => Boolean(entry.packet_hex && entry.frame_hex));
|
|
14
|
+
describe('packet codec golden vectors', () => {
|
|
15
|
+
it.each(validCases)('$name has exact protobuf and frame bytes', async (entry) => {
|
|
16
|
+
const packet = Packet.fromBinary(bytes(entry.packet_hex));
|
|
17
|
+
const encoded = (await collect(encodePacketSource((async function* () {
|
|
18
|
+
yield packet;
|
|
19
|
+
})())))[0];
|
|
20
|
+
expect(hex(encoded)).toBe(entry.packet_hex);
|
|
21
|
+
const framed = (await collect(prependLengthPrefixTransform()((async function* () {
|
|
22
|
+
yield encoded;
|
|
23
|
+
})())))[0];
|
|
24
|
+
expect(hex(framed)).toBe(entry.frame_hex);
|
|
25
|
+
});
|
|
26
|
+
it('rejects zero and oversized encoded chunks', async () => {
|
|
27
|
+
const zero = (async function* () {
|
|
28
|
+
yield new Uint8Array();
|
|
29
|
+
})();
|
|
30
|
+
await expect(collect(prependLengthPrefixTransform()(zero))).rejects.toThrow('invalid packet length');
|
|
31
|
+
const oversized = (async function* () {
|
|
32
|
+
yield new Uint8Array(10_000_001);
|
|
33
|
+
})();
|
|
34
|
+
await expect(collect(prependLengthPrefixTransform()(oversized))).rejects.toThrow('invalid packet length');
|
|
35
|
+
});
|
|
36
|
+
it('rejects zero and oversized lengths, and truncated bodies', async () => {
|
|
37
|
+
const zero = (async function* () {
|
|
38
|
+
yield bytes('00000000');
|
|
39
|
+
})();
|
|
40
|
+
await expect(collect(lengthPrefixDecode(zero, uint32LEDecode))).rejects.toThrow('invalid packet length');
|
|
41
|
+
const oversized = (async function* () {
|
|
42
|
+
yield bytes('81969800');
|
|
43
|
+
})();
|
|
44
|
+
await expect(collect(lengthPrefixDecode(oversized, uint32LEDecode))).rejects.toThrow('invalid packet length');
|
|
45
|
+
const truncated = (async function* () {
|
|
46
|
+
yield bytes('040000000a01');
|
|
47
|
+
})();
|
|
48
|
+
await expect(collect(lengthPrefixDecode(truncated, uint32LEDecode))).rejects.toThrow('truncated packet frame');
|
|
49
|
+
});
|
|
50
|
+
it('decodes fragmented and coalesced frames', async () => {
|
|
51
|
+
const frames = validCases.map((entry) => bytes(entry.frame_hex));
|
|
52
|
+
const combined = new Uint8Array(frames.reduce((n, frame) => n + frame.length, 0));
|
|
53
|
+
let offset = 0;
|
|
54
|
+
for (const frame of frames) {
|
|
55
|
+
combined.set(frame, offset);
|
|
56
|
+
offset += frame.length;
|
|
57
|
+
}
|
|
58
|
+
const payloads = await collect(lengthPrefixDecode((async function* () {
|
|
59
|
+
yield combined.subarray(0, 3);
|
|
60
|
+
yield combined.subarray(3, 11);
|
|
61
|
+
yield combined.subarray(11);
|
|
62
|
+
})(), uint32LEDecode));
|
|
63
|
+
expect(payloads.map(hex)).toEqual(validCases.map((entry) => entry.packet_hex));
|
|
64
|
+
const decoded = await collect(decodePacketSource(payloads.map((payload) => payload.slice())));
|
|
65
|
+
expect(decoded).toHaveLength(validCases.length);
|
|
66
|
+
});
|
|
67
|
+
it('rejects malformed protobuf and incomplete frame prefix', async () => {
|
|
68
|
+
const malformed = vectors.cases.find((entry) => entry.name === 'malformed_complete');
|
|
69
|
+
expect(() => Packet.fromBinary(bytes(malformed.frame_hex.slice(8)))).toThrow();
|
|
70
|
+
const incomplete = (async function* () {
|
|
71
|
+
yield bytes('010203');
|
|
72
|
+
})();
|
|
73
|
+
await expect(collect(lengthPrefixDecode(incomplete, uint32LEDecode))).rejects.toThrow('truncated packet frame');
|
|
74
|
+
});
|
|
75
|
+
});
|
package/dist/srpc/packet.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare namespace uint32LEEncode {
|
|
|
12
12
|
var bytes: number;
|
|
13
13
|
}
|
|
14
14
|
export declare function lengthPrefixEncode(source: Source<Uint8Array | Uint8ArrayList>, lengthEncoder: typeof uint32LEEncode): AsyncGenerator<Uint8ArrayList<ArrayBufferLike>, void, unknown>;
|
|
15
|
-
export declare function lengthPrefixDecode(source: Source<Uint8Array | Uint8ArrayList>, lengthDecoder: typeof uint32LEDecode): AsyncGenerator<Uint8ArrayList
|
|
15
|
+
export declare function lengthPrefixDecode(source: Source<Uint8Array | Uint8ArrayList>, lengthDecoder: typeof uint32LEDecode): AsyncGenerator<Uint8ArrayList>;
|
|
16
16
|
export declare function prependLengthPrefixTransform(lengthEncoder?: {
|
|
17
17
|
(value: number): Uint8ArrayList<ArrayBuffer>;
|
|
18
18
|
bytes: number;
|
package/dist/srpc/packet.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Uint8ArrayList } from 'uint8arraylist';
|
|
2
2
|
import { Packet } from './rpcproto.pb.js';
|
|
3
3
|
import { buildDecodeMessageTransform, buildEncodeMessageTransform, } from './message.js';
|
|
4
|
+
const MAX_MESSAGE_SIZE = 10_000_000;
|
|
4
5
|
// decodePacketSource decodes packets from a binary data stream.
|
|
5
6
|
export const decodePacketSource = buildDecodeMessageTransform(Packet);
|
|
6
7
|
// encodePacketSource encodes packets from a packet object stream.
|
|
@@ -25,6 +26,9 @@ export async function* lengthPrefixEncode(source, lengthEncoder) {
|
|
|
25
26
|
for await (const chunk of source) {
|
|
26
27
|
// Encode the length of the chunk.
|
|
27
28
|
const length = chunk instanceof Uint8Array ? chunk.length : chunk.byteLength;
|
|
29
|
+
if (length === 0 || length > MAX_MESSAGE_SIZE) {
|
|
30
|
+
throw RangeError(`invalid packet length: ${length}`);
|
|
31
|
+
}
|
|
28
32
|
const lengthEncoded = lengthEncoder(length);
|
|
29
33
|
// Concatenate the length prefix and the data.
|
|
30
34
|
yield new Uint8ArrayList(lengthEncoded, chunk);
|
|
@@ -38,16 +42,22 @@ export async function* lengthPrefixDecode(source, lengthDecoder) {
|
|
|
38
42
|
// Continue extracting messages while buffer contains enough data for decoding.
|
|
39
43
|
while (buffer.length >= lengthDecoder.bytes) {
|
|
40
44
|
const messageLength = lengthDecoder(buffer);
|
|
45
|
+
if (messageLength === 0 || messageLength > MAX_MESSAGE_SIZE) {
|
|
46
|
+
throw RangeError(`invalid packet length: ${messageLength}`);
|
|
47
|
+
}
|
|
41
48
|
const totalLength = lengthDecoder.bytes + messageLength;
|
|
42
49
|
if (buffer.length < totalLength)
|
|
43
50
|
break; // Wait for more data if the full message hasn't arrived.
|
|
44
51
|
// Extract the message excluding the length prefix.
|
|
45
|
-
const message = buffer.
|
|
52
|
+
const message = new Uint8ArrayList(buffer.slice(lengthDecoder.bytes, totalLength));
|
|
46
53
|
yield message;
|
|
47
54
|
// Remove the processed message from the buffer.
|
|
48
55
|
buffer.consume(totalLength);
|
|
49
56
|
}
|
|
50
57
|
}
|
|
58
|
+
if (buffer.length !== 0) {
|
|
59
|
+
throw new RangeError('truncated packet frame');
|
|
60
|
+
}
|
|
51
61
|
}
|
|
52
62
|
// prependLengthPrefixTransform adds a length prefix to a message source.
|
|
53
63
|
// little-endian uint32
|