starpc 0.51.0 → 0.52.1
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/cmd/protoc-gen-es-starpc/typescript.ts +37 -0
- package/dist/cmd/protoc-gen-es-starpc/typescript.js +24 -0
- package/dist/echo/client-test.d.ts +1 -1
- package/dist/echo/client-test.js +110 -2
- package/dist/echo/echo_srpc.pb.d.ts +44 -1
- package/dist/echo/server.d.ts +9 -8
- package/dist/echo/server.js +6 -6
- 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/mock/mock_srpc.pb.d.ts +14 -1
- 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/channel.js +6 -3
- package/dist/srpc/channel.test.js +20 -1
- package/dist/srpc/client.js +18 -4
- package/dist/srpc/common-rpc.test.js +2 -0
- package/dist/srpc/handler.d.ts +12 -3
- package/dist/srpc/index.d.ts +2 -0
- package/dist/srpc/index.js +1 -0
- package/dist/srpc/invoker.d.ts +2 -1
- package/dist/srpc/invoker.js +2 -2
- 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-context.d.ts +11 -0
- package/dist/srpc/server-context.js +28 -0
- package/dist/srpc/server-rpc.js +3 -1
- package/dist/srpc/server.js +19 -6
- package/dist/srpc/server.test.js +78 -6
- 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/srpc/watchdog.test.js +1 -0
- package/dist/testdata/packet-codec-vectors.json +64 -0
- package/echo/client-test.ts +124 -2
- package/echo/echo_pb2.py +40 -0
- package/echo/echo_pb2.pyi +13 -0
- package/echo/echo_srpc.pb.ts +74 -0
- package/echo/echo_srpc.py +306 -0
- package/echo/echo_srpc.pyi +85 -0
- package/echo/server.ts +24 -5
- package/go.mod +2 -2
- package/go.sum +14 -0
- 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.ts +19 -1
- package/mock/mock_srpc.py +71 -0
- package/mock/mock_srpc.pyi +27 -0
- package/package.json +20 -6
- package/srpc/__init__.py +0 -0
- package/srpc/channel.test.ts +21 -1
- package/srpc/channel.ts +7 -3
- package/srpc/client.ts +20 -4
- package/srpc/codec.rs +6 -0
- package/srpc/common-rpc.test.ts +2 -0
- package/srpc/handler.ts +54 -4
- package/srpc/index.ts +7 -0
- package/srpc/invoker.ts +23 -6
- 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.ts +15 -2
- package/srpc/py.typed +0 -0
- package/srpc/rpcproto_pb2.py +40 -0
- package/srpc/rpcproto_pb2.pyi +40 -0
- package/srpc/server-context.ts +55 -0
- package/srpc/server-rpc.ts +4 -1
- package/srpc/server.test.ts +100 -5
- package/srpc/server.ts +22 -6
- 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/watchdog.test.ts +1 -0
|
@@ -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
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare const contextKeyValue: unique symbol;
|
|
2
|
+
export interface ContextKey<T> {
|
|
3
|
+
readonly [contextKeyValue]?: T;
|
|
4
|
+
}
|
|
5
|
+
export interface ServerContext {
|
|
6
|
+
readonly signal: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
export declare function createContextKey<T>(): ContextKey<T>;
|
|
9
|
+
export declare function withServerContextValue<T>(context: ServerContext, key: ContextKey<T>, value: T): ServerContext;
|
|
10
|
+
export declare function serverContextValue<T>(context: ServerContext, key: ContextKey<T>): T | undefined;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const contextParent = Symbol('server context parent');
|
|
2
|
+
const contextKey = Symbol('server context key');
|
|
3
|
+
const contextStoredValue = Symbol('server context value');
|
|
4
|
+
// createContextKey constructs an identity key for one server-context value.
|
|
5
|
+
export function createContextKey() {
|
|
6
|
+
return {};
|
|
7
|
+
}
|
|
8
|
+
// withServerContextValue derives a context with one immutable typed value.
|
|
9
|
+
export function withServerContextValue(context, key, value) {
|
|
10
|
+
const derived = {
|
|
11
|
+
signal: context.signal,
|
|
12
|
+
[contextParent]: context,
|
|
13
|
+
[contextKey]: key,
|
|
14
|
+
[contextStoredValue]: value,
|
|
15
|
+
};
|
|
16
|
+
return derived;
|
|
17
|
+
}
|
|
18
|
+
// serverContextValue retrieves the nearest value for a typed identity key.
|
|
19
|
+
export function serverContextValue(context, key) {
|
|
20
|
+
let current = context;
|
|
21
|
+
while (current) {
|
|
22
|
+
if (current[contextKey] === key) {
|
|
23
|
+
return current[contextStoredValue];
|
|
24
|
+
}
|
|
25
|
+
current = current[contextParent];
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
package/dist/srpc/server-rpc.js
CHANGED
|
@@ -38,7 +38,9 @@ export class ServerRPC extends CommonRPC {
|
|
|
38
38
|
async invokeRPC(invokeFn) {
|
|
39
39
|
const dataSink = this._createDataSink();
|
|
40
40
|
try {
|
|
41
|
-
await invokeFn(this.rpcDataSource, dataSink,
|
|
41
|
+
await invokeFn(this.rpcDataSource, dataSink, {
|
|
42
|
+
signal: this.invocationSignal,
|
|
43
|
+
});
|
|
42
44
|
}
|
|
43
45
|
catch (err) {
|
|
44
46
|
this.close(err);
|
package/dist/srpc/server.js
CHANGED
|
@@ -13,9 +13,7 @@ export class Server {
|
|
|
13
13
|
get rpcStreamHandler() {
|
|
14
14
|
return async (stream) => {
|
|
15
15
|
const rpc = this.startRpc();
|
|
16
|
-
return
|
|
17
|
-
.catch((err) => rpc.close(err))
|
|
18
|
-
.then(() => rpc.close());
|
|
16
|
+
return runPacketStream(stream, rpc);
|
|
19
17
|
};
|
|
20
18
|
}
|
|
21
19
|
// startRpc starts a new server-side RPC.
|
|
@@ -27,9 +25,24 @@ export class Server {
|
|
|
27
25
|
// the stream has one Uint8Array per packet w/o length prefix.
|
|
28
26
|
handlePacketStream(stream) {
|
|
29
27
|
const rpc = this.startRpc();
|
|
30
|
-
|
|
31
|
-
.catch((err) => rpc.close(err))
|
|
32
|
-
.then(() => rpc.close());
|
|
28
|
+
void runPacketStream(stream, rpc).catch(() => undefined);
|
|
33
29
|
return rpc;
|
|
34
30
|
}
|
|
35
31
|
}
|
|
32
|
+
async function runPacketStream(stream, rpc) {
|
|
33
|
+
try {
|
|
34
|
+
await pipe(stream, decodePacketSource, rpc, encodePacketSource, stream);
|
|
35
|
+
if (rpc.isClosed instanceof Error) {
|
|
36
|
+
stream.abort(rpc.isClosed);
|
|
37
|
+
throw rpc.isClosed;
|
|
38
|
+
}
|
|
39
|
+
await stream.close();
|
|
40
|
+
await rpc.close();
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
44
|
+
stream.abort(error);
|
|
45
|
+
await rpc.close(error);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/srpc/server.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
2
2
|
import { pipe } from 'it-pipe';
|
|
3
|
-
import {
|
|
3
|
+
import { pushable } from 'it-pushable';
|
|
4
|
+
import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, Packet, createContextKey, serverContextValue, withServerContextValue, } from '../srpc/index.js';
|
|
4
5
|
import { EchoerDefinition, EchoerServer, EchoerServiceName, EchoMsg, runClientTest, } from '../echo/index.js';
|
|
5
6
|
import { runAbortControllerTest, runRpcStreamTest, } from '../echo/client-test.js';
|
|
6
7
|
describe('srpc server', () => {
|
|
@@ -39,12 +40,17 @@ describe('srpc server', () => {
|
|
|
39
40
|
it('should pass rpc stream tests', async () => {
|
|
40
41
|
await runRpcStreamTest(client);
|
|
41
42
|
});
|
|
42
|
-
it('passes the exact invocation
|
|
43
|
+
it('passes the exact invocation context after async request decode', async () => {
|
|
43
44
|
const controller = new AbortController();
|
|
44
|
-
|
|
45
|
+
const callerKey = createContextKey();
|
|
46
|
+
let observedAbortSignal;
|
|
47
|
+
let observedContextSignal;
|
|
48
|
+
let observedCaller;
|
|
45
49
|
const handler = createHandler(EchoerDefinition, {
|
|
46
|
-
Echo: async (request,
|
|
47
|
-
|
|
50
|
+
Echo: async (request, abortSignal, context) => {
|
|
51
|
+
observedAbortSignal = abortSignal;
|
|
52
|
+
observedContextSignal = context.signal;
|
|
53
|
+
observedCaller = serverContextValue(context, callerKey);
|
|
48
54
|
return request;
|
|
49
55
|
},
|
|
50
56
|
});
|
|
@@ -62,8 +68,32 @@ describe('srpc server', () => {
|
|
|
62
68
|
// Drain the encoded response so the invocation pipeline completes.
|
|
63
69
|
}
|
|
64
70
|
drained.resolve();
|
|
65
|
-
}, controller.signal);
|
|
71
|
+
}, withServerContextValue({ signal: controller.signal }, callerKey, 'caller-1'));
|
|
66
72
|
await drained.promise;
|
|
73
|
+
expect(observedAbortSignal).toBe(controller.signal);
|
|
74
|
+
expect(observedContextSignal).toBe(controller.signal);
|
|
75
|
+
expect(observedCaller).toBe('caller-1');
|
|
76
|
+
});
|
|
77
|
+
it('keeps two-argument server handlers compatible', async () => {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
let observedSignal;
|
|
80
|
+
const handler = createHandler(EchoerDefinition, {
|
|
81
|
+
Echo: async (request, abortSignal) => {
|
|
82
|
+
observedSignal = abortSignal;
|
|
83
|
+
return request;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const invokeFn = await handler.lookupMethod(EchoerServiceName, 'Echo');
|
|
87
|
+
if (!invokeFn)
|
|
88
|
+
throw new Error('Echo method was not found');
|
|
89
|
+
const request = EchoMsg.create({ body: 'legacy handler' });
|
|
90
|
+
await invokeFn((async function* () {
|
|
91
|
+
yield EchoMsg.toBinary(request);
|
|
92
|
+
})(), async (source) => {
|
|
93
|
+
for await (const _data of source) {
|
|
94
|
+
// Drain the response.
|
|
95
|
+
}
|
|
96
|
+
}, { signal: controller.signal });
|
|
67
97
|
expect(observedSignal).toBe(controller.signal);
|
|
68
98
|
});
|
|
69
99
|
it('keeps detached server-streaming responses open after request source completes', async () => {
|
|
@@ -83,6 +113,8 @@ describe('srpc server', () => {
|
|
|
83
113
|
const server = new Server(mux.lookupMethod);
|
|
84
114
|
const firstResponse = new Promise((resolve, reject) => {
|
|
85
115
|
server.handlePacketStream({
|
|
116
|
+
close: async () => { },
|
|
117
|
+
abort: () => { },
|
|
86
118
|
source: (async function* () {
|
|
87
119
|
yield Packet.toBinary({
|
|
88
120
|
body: {
|
|
@@ -200,6 +232,46 @@ describe('srpc server', () => {
|
|
|
200
232
|
controller.abort();
|
|
201
233
|
await Promise.resolve();
|
|
202
234
|
});
|
|
235
|
+
it('closes the packet stream when the server pipeline completes', async () => {
|
|
236
|
+
const server = new Server(createMux().lookupMethod);
|
|
237
|
+
const close = vi.fn(async () => { });
|
|
238
|
+
const source = pushable({ objectMode: true });
|
|
239
|
+
const stream = {
|
|
240
|
+
close,
|
|
241
|
+
abort: vi.fn(),
|
|
242
|
+
source,
|
|
243
|
+
sink: async (output) => {
|
|
244
|
+
for await (const _packet of output) {
|
|
245
|
+
// Drain the response pipeline.
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
const rpc = server.handlePacketStream(stream);
|
|
250
|
+
await rpc.close();
|
|
251
|
+
source.end();
|
|
252
|
+
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
|
|
253
|
+
expect(stream.abort).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
it('aborts the packet stream when the server pipeline fails', async () => {
|
|
256
|
+
const server = new Server(createMux().lookupMethod);
|
|
257
|
+
const error = new Error('input failed');
|
|
258
|
+
const abort = vi.fn();
|
|
259
|
+
const source = pushable({ objectMode: true });
|
|
260
|
+
const stream = {
|
|
261
|
+
close: vi.fn(async () => { }),
|
|
262
|
+
abort,
|
|
263
|
+
source,
|
|
264
|
+
sink: async (output) => {
|
|
265
|
+
for await (const _packet of output) {
|
|
266
|
+
// Drain the response pipeline.
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
server.handlePacketStream(stream);
|
|
271
|
+
source.end(error);
|
|
272
|
+
await vi.waitFor(() => expect(abort).toHaveBeenCalledWith(error));
|
|
273
|
+
expect(stream.close).not.toHaveBeenCalled();
|
|
274
|
+
});
|
|
203
275
|
it('tears down passive channel close state', async () => {
|
|
204
276
|
const { port1, port2 } = new MessageChannel();
|
|
205
277
|
const opts = { idleTimeoutMs: 1000, keepAliveMs: 1000 };
|
package/dist/srpc/stream.d.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { Duplex, Source } from 'it-stream-types';
|
|
|
2
2
|
import type { Stream } from './stream-muxer.js';
|
|
3
3
|
import type { Packet } from './rpcproto.pb.js';
|
|
4
4
|
export type PacketHandler = (packet: Packet) => Promise<void>;
|
|
5
|
-
export
|
|
5
|
+
export interface PacketStream extends Duplex<AsyncGenerator<Uint8Array>, Source<Uint8Array>, Promise<void>> {
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
abort(err: Error): void;
|
|
8
|
+
}
|
|
6
9
|
export type OpenStreamFunc = () => Promise<PacketStream>;
|
|
7
10
|
export type HandleStreamFunc = (ch: PacketStream) => Promise<void>;
|
|
8
11
|
export declare function streamToPacketStream(stream: Stream): PacketStream;
|
package/dist/srpc/stream.js
CHANGED
|
@@ -1,20 +1,79 @@
|
|
|
1
1
|
import { pipe } from 'it-pipe';
|
|
2
2
|
import { combineUint8ArrayListTransform } from './array-list.js';
|
|
3
3
|
import { parseLengthPrefixTransform, prependLengthPrefixTransform, } from './packet.js';
|
|
4
|
+
import { closeIterator, sourceIterator, TerminationGate, } from './termination.js';
|
|
4
5
|
// streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
|
|
5
6
|
export function streamToPacketStream(stream) {
|
|
7
|
+
const termination = new TerminationGate();
|
|
6
8
|
return {
|
|
7
|
-
|
|
9
|
+
close: async () => {
|
|
10
|
+
if (termination.terminate())
|
|
11
|
+
await stream.close();
|
|
12
|
+
},
|
|
13
|
+
abort: (err) => {
|
|
14
|
+
if (termination.terminate(err))
|
|
15
|
+
stream.abort(err);
|
|
16
|
+
},
|
|
17
|
+
source: (async function* () {
|
|
18
|
+
const packets = pipe(stream, parseLengthPrefixTransform(), combineUint8ArrayListTransform())[Symbol.asyncIterator]();
|
|
19
|
+
try {
|
|
20
|
+
while (true) {
|
|
21
|
+
const next = await termination.next(packets);
|
|
22
|
+
if ('terminated' in next) {
|
|
23
|
+
if (next.error)
|
|
24
|
+
throw next.error;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if ('error' in next)
|
|
28
|
+
throw next.error;
|
|
29
|
+
if (next.result.done)
|
|
30
|
+
return;
|
|
31
|
+
yield next.result.value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
closeIterator(packets);
|
|
36
|
+
}
|
|
37
|
+
})(),
|
|
8
38
|
sink: async (source) => {
|
|
39
|
+
const iterator = sourceIterator(pipe(source, prependLengthPrefixTransform()));
|
|
40
|
+
const gatedSource = (async function* () {
|
|
41
|
+
while (true) {
|
|
42
|
+
const next = await termination.next(iterator);
|
|
43
|
+
if ('terminated' in next) {
|
|
44
|
+
if (next.error)
|
|
45
|
+
throw next.error;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if ('error' in next)
|
|
49
|
+
throw next.error;
|
|
50
|
+
if (next.result.done)
|
|
51
|
+
return;
|
|
52
|
+
if (termination.terminated)
|
|
53
|
+
return;
|
|
54
|
+
yield next.result.value;
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
9
57
|
try {
|
|
10
|
-
await
|
|
58
|
+
const result = await termination.wait(stream.sink(gatedSource));
|
|
59
|
+
if ('terminated' in result) {
|
|
60
|
+
if (result.error)
|
|
61
|
+
throw result.error;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if ('error' in result)
|
|
65
|
+
throw result.error;
|
|
11
66
|
await stream.closeWrite();
|
|
12
67
|
}
|
|
13
68
|
catch (err) {
|
|
14
69
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
15
|
-
|
|
70
|
+
if (termination.terminate(error))
|
|
71
|
+
stream.abort(error);
|
|
16
72
|
throw error;
|
|
17
73
|
}
|
|
74
|
+
finally {
|
|
75
|
+
closeIterator(iterator);
|
|
76
|
+
}
|
|
18
77
|
},
|
|
19
78
|
};
|
|
20
79
|
}
|
package/dist/srpc/stream.test.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { pipe } from 'it-pipe';
|
|
3
|
+
import { pushable } from 'it-pushable';
|
|
4
|
+
import { streamToPacketStream } from './stream.js';
|
|
3
5
|
import { ChannelStream, combineUint8ArrayListTransform, StreamConn, } from '../srpc/index.js';
|
|
4
6
|
describe('StreamConn packet stream', () => {
|
|
5
7
|
it('keeps yamux peer writes open after local packet source completes normally', async () => {
|
|
@@ -48,6 +50,113 @@ describe('StreamConn packet stream', () => {
|
|
|
48
50
|
await serverDone;
|
|
49
51
|
expect(serverError).toBeUndefined();
|
|
50
52
|
});
|
|
53
|
+
it('settles a blocked packet source when closed', async () => {
|
|
54
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
55
|
+
handlePacketStream() { },
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
const stream = await clientConn.openStream();
|
|
59
|
+
const pending = stream.source.next();
|
|
60
|
+
await stream.close();
|
|
61
|
+
await expect(pending).resolves.toEqual({ done: true, value: undefined });
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
cleanup();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
it('settles a blocked packet source when aborted', async () => {
|
|
68
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
69
|
+
handlePacketStream() { },
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
const stream = await clientConn.openStream();
|
|
73
|
+
const pending = stream.source.next();
|
|
74
|
+
const error = new Error('stopped');
|
|
75
|
+
stream.abort(error);
|
|
76
|
+
await expect(pending).rejects.toBe(error);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
cleanup();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
it('settles a blocked packet sink when closed', async () => {
|
|
83
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
84
|
+
handlePacketStream() { },
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const stream = await clientConn.openStream();
|
|
88
|
+
const input = pushable({ objectMode: true });
|
|
89
|
+
const pending = stream.sink(input);
|
|
90
|
+
await stream.close();
|
|
91
|
+
await expect(pending).resolves.toBeUndefined();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
cleanup();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
it('settles a packet sink blocked in the underlying write', async () => {
|
|
98
|
+
const sinkStarted = Promise.withResolvers();
|
|
99
|
+
const transport = {
|
|
100
|
+
source: (async function* () { })(),
|
|
101
|
+
sink: async (source) => {
|
|
102
|
+
for await (const _chunk of source) {
|
|
103
|
+
sinkStarted.resolve();
|
|
104
|
+
await new Promise(() => { });
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
close: vi.fn(async () => { }),
|
|
108
|
+
closeRead: vi.fn(async () => { }),
|
|
109
|
+
closeWrite: vi.fn(async () => { }),
|
|
110
|
+
abort: vi.fn(),
|
|
111
|
+
};
|
|
112
|
+
const stream = streamToPacketStream(transport);
|
|
113
|
+
const input = pushable({ objectMode: true });
|
|
114
|
+
input.push(new Uint8Array([1]));
|
|
115
|
+
const pending = stream.sink(input);
|
|
116
|
+
await sinkStarted.promise;
|
|
117
|
+
await stream.close();
|
|
118
|
+
await expect(pending).resolves.toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
it('rejects a blocked packet sink with the abort error', async () => {
|
|
121
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
122
|
+
handlePacketStream() { },
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
const stream = await clientConn.openStream();
|
|
126
|
+
const input = pushable({ objectMode: true });
|
|
127
|
+
const pending = stream.sink(input);
|
|
128
|
+
const error = new Error('stopped');
|
|
129
|
+
stream.abort(error);
|
|
130
|
+
await expect(pending).rejects.toBe(error);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
cleanup();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
it('does not write ready input after close', async () => {
|
|
137
|
+
const serverStream = Promise.withResolvers();
|
|
138
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
139
|
+
handlePacketStream(stream) {
|
|
140
|
+
serverStream.resolve(stream);
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
const stream = await clientConn.openStream();
|
|
145
|
+
const peer = await serverStream.promise;
|
|
146
|
+
const input = pushable({ objectMode: true });
|
|
147
|
+
input.push(new Uint8Array([1]));
|
|
148
|
+
const pending = stream.sink(input);
|
|
149
|
+
await stream.close();
|
|
150
|
+
await expect(pending).resolves.toBeUndefined();
|
|
151
|
+
await expect(nextWithTimeout(peer.source, 'server eof')).resolves.toEqual({
|
|
152
|
+
done: true,
|
|
153
|
+
value: undefined,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
cleanup();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
51
160
|
it('aborts the yamux stream when the packet source errors', async () => {
|
|
52
161
|
const request = new TextEncoder().encode('request');
|
|
53
162
|
const sourceError = new Error('source failed');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Source } from 'it-stream-types';
|
|
2
|
+
type Terminated = {
|
|
3
|
+
terminated: true;
|
|
4
|
+
error?: Error;
|
|
5
|
+
};
|
|
6
|
+
type Received<T> = {
|
|
7
|
+
result: IteratorResult<T>;
|
|
8
|
+
} | {
|
|
9
|
+
error: unknown;
|
|
10
|
+
};
|
|
11
|
+
type WaitResult<T> = {
|
|
12
|
+
result: T;
|
|
13
|
+
} | {
|
|
14
|
+
error: unknown;
|
|
15
|
+
};
|
|
16
|
+
export declare class TerminationGate {
|
|
17
|
+
private readonly _waiters;
|
|
18
|
+
private _error;
|
|
19
|
+
private _terminated;
|
|
20
|
+
get terminated(): boolean;
|
|
21
|
+
terminate(error?: Error): boolean;
|
|
22
|
+
wait<T>(promise: Promise<T>): Promise<WaitResult<T> | Terminated>;
|
|
23
|
+
next<T>(iterator: AsyncIterator<T>): Promise<Received<T> | Terminated>;
|
|
24
|
+
}
|
|
25
|
+
export declare function sourceIterator<T>(source: Source<T>): AsyncIterator<T>;
|
|
26
|
+
export declare function closeIterator<T>(iterator: AsyncIterator<T>): void;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// TerminationGate makes the first clean close or abort visible to blocked I/O.
|
|
2
|
+
export class TerminationGate {
|
|
3
|
+
_waiters = new Set();
|
|
4
|
+
_error;
|
|
5
|
+
_terminated = false;
|
|
6
|
+
get terminated() {
|
|
7
|
+
return this._terminated;
|
|
8
|
+
}
|
|
9
|
+
terminate(error) {
|
|
10
|
+
if (this._terminated)
|
|
11
|
+
return false;
|
|
12
|
+
this._terminated = true;
|
|
13
|
+
this._error = error;
|
|
14
|
+
for (const waiter of this._waiters)
|
|
15
|
+
waiter(error);
|
|
16
|
+
this._waiters.clear();
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
async wait(promise) {
|
|
20
|
+
if (this._terminated)
|
|
21
|
+
return { terminated: true, error: this._error };
|
|
22
|
+
let notify;
|
|
23
|
+
const terminated = new Promise((resolve) => {
|
|
24
|
+
notify = (error) => resolve({ terminated: true, error });
|
|
25
|
+
this._waiters.add(notify);
|
|
26
|
+
});
|
|
27
|
+
const received = promise.then((result) => ({ result }), (error) => ({ error }));
|
|
28
|
+
try {
|
|
29
|
+
const result = await Promise.race([received, terminated]);
|
|
30
|
+
if (this._terminated) {
|
|
31
|
+
return { terminated: true, error: this._error };
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
this._waiters.delete(notify);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
next(iterator) {
|
|
40
|
+
return this.wait(iterator.next());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function sourceIterator(source) {
|
|
44
|
+
if (Symbol.asyncIterator in source)
|
|
45
|
+
return source[Symbol.asyncIterator]();
|
|
46
|
+
const iterator = source[Symbol.iterator]();
|
|
47
|
+
return { next: async () => iterator.next() };
|
|
48
|
+
}
|
|
49
|
+
// closeIterator asks an abandoned iterator to release its upstream resources.
|
|
50
|
+
// Async generators may wait for an active next call before running return;
|
|
51
|
+
// rejection is observed here so cleanup cannot create an unhandled promise.
|
|
52
|
+
export function closeIterator(iterator) {
|
|
53
|
+
if (!iterator.return)
|
|
54
|
+
return;
|
|
55
|
+
void Promise.resolve(iterator.return()).catch(() => undefined);
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TerminationGate } from './termination.js';
|
|
3
|
+
describe('TerminationGate', () => {
|
|
4
|
+
it('prioritizes termination over an already-ready result', async () => {
|
|
5
|
+
const gate = new TerminationGate();
|
|
6
|
+
const iterator = (async function* () {
|
|
7
|
+
yield 1;
|
|
8
|
+
})();
|
|
9
|
+
const pending = gate.next(iterator);
|
|
10
|
+
const error = new Error('stopped');
|
|
11
|
+
gate.terminate(error);
|
|
12
|
+
await expect(pending).resolves.toEqual({ terminated: true, error });
|
|
13
|
+
});
|
|
14
|
+
it('keeps the first termination result', async () => {
|
|
15
|
+
const gate = new TerminationGate();
|
|
16
|
+
const error = new Error('stopped');
|
|
17
|
+
expect(gate.terminate(error)).toBe(true);
|
|
18
|
+
expect(gate.terminate()).toBe(false);
|
|
19
|
+
await expect(gate.wait(new Promise(() => { }))).resolves.toEqual({
|
|
20
|
+
terminated: true,
|
|
21
|
+
error,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|