starpc 0.49.18 → 0.50.0
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 +1 -3
- package/cmd/protoc-gen-es-starpc/typescript.ts +11 -6
- package/dist/cmd/protoc-gen-es-starpc/typescript.js +6 -5
- package/dist/echo/echo.pb.d.ts +1 -1
- package/dist/echo/echo.pb.js +3 -3
- package/dist/integration/cross-language/ts-client.js +84 -11
- package/dist/integration/cross-language/ts-server.js +100 -4
- package/dist/mock/index.d.ts +2 -2
- package/dist/mock/index.js +2 -2
- package/dist/mock/mock.pb.d.ts +1 -1
- package/dist/mock/mock.pb.js +3 -3
- package/dist/rpcstream/receipt.test.d.ts +1 -0
- package/dist/rpcstream/receipt.test.js +41 -0
- package/dist/rpcstream/rpcstream.pb.d.ts +1 -1
- package/dist/rpcstream/rpcstream.pb.js +14 -8
- package/dist/srpc/call-receipt.d.ts +17 -0
- package/dist/srpc/call-receipt.js +106 -0
- package/dist/srpc/call-receipt.test.d.ts +1 -0
- package/dist/srpc/call-receipt.test.js +375 -0
- package/dist/srpc/client.d.ts +3 -1
- package/dist/srpc/client.js +20 -0
- package/dist/srpc/common-rpc.d.ts +13 -3
- package/dist/srpc/common-rpc.js +87 -10
- package/dist/srpc/handler.d.ts +2 -1
- package/dist/srpc/index.d.ts +4 -0
- package/dist/srpc/index.js +3 -0
- package/dist/srpc/invoker.d.ts +2 -1
- package/dist/srpc/invoker.js +2 -2
- package/dist/srpc/rpcproto.pb.d.ts +45 -1
- package/dist/srpc/rpcproto.pb.js +60 -7
- package/dist/srpc/server-invocation.d.ts +17 -0
- package/dist/srpc/server-invocation.js +37 -0
- package/dist/srpc/server-rpc.js +3 -1
- package/echo/echo.pb.go +1 -1
- package/echo/echo.pb.ts +6 -5
- package/echo/echo_srpc.pb.cpp +1 -1
- package/echo/echo_srpc.pb.go +1 -1
- package/echo/echo_srpc.pb.hpp +1 -1
- package/echo/echo_srpc.pb.rs +1 -1
- package/go.mod +13 -12
- package/go.sum +24 -24
- package/integration/cross-language/go-client/main.go +137 -5
- package/integration/cross-language/go-server/fixture-owner_test.go +127 -0
- package/integration/cross-language/go-server/main.go +169 -4
- package/integration/cross-language/run.bash +117 -13
- package/integration/cross-language/ts-client.ts +94 -13
- package/integration/cross-language/ts-server.ts +115 -7
- package/mock/index.ts +2 -2
- package/mock/mock.pb.go +1 -1
- package/mock/mock.pb.ts +6 -5
- package/mock/mock_srpc.pb.cpp +1 -1
- package/mock/mock_srpc.pb.go +1 -1
- package/mock/mock_srpc.pb.hpp +1 -1
- package/mock/mock_srpc.pb.rs +1 -1
- package/package.json +19 -26
- package/srpc/accept.go +1 -1
- package/srpc/call-receipt-e2e_test.go +111 -0
- package/srpc/call-receipt.go +112 -0
- package/srpc/call-receipt.test.ts +441 -0
- package/srpc/call-receipt.ts +131 -0
- package/srpc/call-receipt_test.go +536 -0
- package/srpc/client-rpc.go +1 -8
- package/srpc/client.ts +29 -2
- package/srpc/common-rpc.go +114 -29
- package/srpc/common-rpc.ts +104 -13
- package/srpc/handler.ts +2 -0
- package/srpc/index.ts +4 -0
- package/srpc/invoker.ts +10 -5
- package/srpc/msg-stream.go +8 -0
- package/srpc/muxed-conn.go +9 -4
- package/srpc/muxed-conn_test.go +79 -0
- package/srpc/muxed-yamux.go +1 -1
- package/srpc/rpcproto.pb.cc +15 -4
- package/srpc/rpcproto.pb.go +97 -1
- package/srpc/rpcproto.pb.h +59 -0
- package/srpc/rpcproto.pb.rs +45 -0
- package/srpc/rpcproto.pb.ts +90 -27
- package/srpc/rpcproto.proto +16 -0
- package/srpc/schema-ownership_test.go +115 -0
- package/srpc/server-invocation.go +40 -0
- package/srpc/server-invocation.ts +76 -0
- package/srpc/server-rpc.go +1 -1
- package/srpc/server-rpc.ts +6 -2
- package/srpc/stream-yamux.go +1 -1
- package/srpc/stream.go +20 -0
- package/srpc/websocket.go +1 -1
package/README.md
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/starpc)
|
|
4
4
|
[](https://crates.io/crates/starpc)
|
|
5
5
|
[](https://github.com/aperturerobotics/starpc/actions)
|
|
6
|
-
[![GoDoc Widget]][GoDoc]
|
|
6
|
+
[![GoDoc Widget]][GoDoc]
|
|
7
7
|
|
|
8
8
|
> Streaming Protobuf RPC with bidirectional streaming over any multiplexed transport.
|
|
9
9
|
|
|
10
10
|
[GoDoc]: https://godoc.org/github.com/aperturerobotics/starpc
|
|
11
11
|
[GoDoc Widget]: https://godoc.org/github.com/aperturerobotics/starpc?status.svg
|
|
12
|
-
[Go Report Card Widget]: https://goreportcard.com/badge/github.com/aperturerobotics/starpc
|
|
13
|
-
[Go Report Card]: https://goreportcard.com/report/github.com/aperturerobotics/starpc
|
|
14
12
|
|
|
15
13
|
## Table of Contents
|
|
16
14
|
|
|
@@ -26,6 +26,14 @@ import {
|
|
|
26
26
|
} from '@aptre/protobuf-es-lite/protoplugin/ecmascript'
|
|
27
27
|
|
|
28
28
|
const MessageStream = createImportSymbol('MessageStream', 'starpc')
|
|
29
|
+
const RuntimeMethodKind = createImportSymbol(
|
|
30
|
+
'MethodKind',
|
|
31
|
+
'@aptre/protobuf-es-lite',
|
|
32
|
+
)
|
|
33
|
+
const RuntimeMethodIdempotency = createImportSymbol(
|
|
34
|
+
'MethodIdempotency',
|
|
35
|
+
'@aptre/protobuf-es-lite',
|
|
36
|
+
)
|
|
29
37
|
// const Message = createImportSymbol('Message', '@aptre/protobuf-es-lite')
|
|
30
38
|
|
|
31
39
|
export function generateTs(schema: Schema) {
|
|
@@ -33,19 +41,16 @@ export function generateTs(schema: Schema) {
|
|
|
33
41
|
const file = schema.generateFile(protoFile.name + '_srpc.pb.ts')
|
|
34
42
|
file.preamble(protoFile)
|
|
35
43
|
for (const service of protoFile.services) {
|
|
36
|
-
generateService(
|
|
44
|
+
generateService(file, service)
|
|
37
45
|
}
|
|
38
46
|
}
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
// prettier-ignore
|
|
42
50
|
function generateService(
|
|
43
|
-
schema: Schema,
|
|
44
51
|
f: GeneratedFile,
|
|
45
52
|
service: DescService
|
|
46
53
|
) {
|
|
47
|
-
const { MethodKind: rtMethodKind, MethodIdempotency: rtMethodIdempotency } =
|
|
48
|
-
schema.runtime;
|
|
49
54
|
|
|
50
55
|
// NOTE: This matches generateService from @connectrpc/protoc-gen-connect-es.
|
|
51
56
|
f.print(f.jsDoc(service));
|
|
@@ -60,7 +65,7 @@ function generateService(
|
|
|
60
65
|
f.print(" O: ", method.output, ",");
|
|
61
66
|
f.print(
|
|
62
67
|
" kind: ",
|
|
63
|
-
|
|
68
|
+
RuntimeMethodKind,
|
|
64
69
|
".",
|
|
65
70
|
MethodKind[method.methodKind],
|
|
66
71
|
","
|
|
@@ -68,7 +73,7 @@ function generateService(
|
|
|
68
73
|
if (method.idempotency !== undefined) {
|
|
69
74
|
f.print(
|
|
70
75
|
" idempotency: ",
|
|
71
|
-
|
|
76
|
+
RuntimeMethodIdempotency,
|
|
72
77
|
".",
|
|
73
78
|
MethodIdempotency[method.idempotency],
|
|
74
79
|
","
|
|
@@ -15,19 +15,20 @@
|
|
|
15
15
|
import { MethodIdempotency, MethodKind, localName, } from '@aptre/protobuf-es-lite';
|
|
16
16
|
import { createImportSymbol, } from '@aptre/protobuf-es-lite/protoplugin/ecmascript';
|
|
17
17
|
const MessageStream = createImportSymbol('MessageStream', 'starpc');
|
|
18
|
+
const RuntimeMethodKind = createImportSymbol('MethodKind', '@aptre/protobuf-es-lite');
|
|
19
|
+
const RuntimeMethodIdempotency = createImportSymbol('MethodIdempotency', '@aptre/protobuf-es-lite');
|
|
18
20
|
// const Message = createImportSymbol('Message', '@aptre/protobuf-es-lite')
|
|
19
21
|
export function generateTs(schema) {
|
|
20
22
|
for (const protoFile of schema.files) {
|
|
21
23
|
const file = schema.generateFile(protoFile.name + '_srpc.pb.ts');
|
|
22
24
|
file.preamble(protoFile);
|
|
23
25
|
for (const service of protoFile.services) {
|
|
24
|
-
generateService(
|
|
26
|
+
generateService(file, service);
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
// prettier-ignore
|
|
29
|
-
function generateService(
|
|
30
|
-
const { MethodKind: rtMethodKind, MethodIdempotency: rtMethodIdempotency } = schema.runtime;
|
|
31
|
+
function generateService(f, service) {
|
|
31
32
|
// NOTE: This matches generateService from @connectrpc/protoc-gen-connect-es.
|
|
32
33
|
f.print(f.jsDoc(service));
|
|
33
34
|
f.print(f.exportDecl("const", localName(service)), "Definition = {");
|
|
@@ -39,9 +40,9 @@ function generateService(schema, f, service) {
|
|
|
39
40
|
f.print(` name: `, f.string(method.name), `,`);
|
|
40
41
|
f.print(" I: ", method.input, ",");
|
|
41
42
|
f.print(" O: ", method.output, ",");
|
|
42
|
-
f.print(" kind: ",
|
|
43
|
+
f.print(" kind: ", RuntimeMethodKind, ".", MethodKind[method.methodKind], ",");
|
|
43
44
|
if (method.idempotency !== undefined) {
|
|
44
|
-
f.print(" idempotency: ",
|
|
45
|
+
f.print(" idempotency: ", RuntimeMethodIdempotency, ".", MethodIdempotency[method.idempotency], ",");
|
|
45
46
|
}
|
|
46
47
|
// In case we start supporting options, we have to surface them here
|
|
47
48
|
f.print(" },");
|
package/dist/echo/echo.pb.d.ts
CHANGED
package/dist/echo/echo.pb.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false"
|
|
2
2
|
// @generated from file github.com/aperturerobotics/starpc/echo/echo.proto (package echo, syntax proto3)
|
|
3
3
|
/* eslint-disable */
|
|
4
|
-
import { createMessageType
|
|
4
|
+
import { createMessageType } from '@aptre/protobuf-es-lite/message';
|
|
5
|
+
import { ScalarType } from '@aptre/protobuf-es-lite/scalar';
|
|
5
6
|
export const protobufPackage = 'echo';
|
|
6
|
-
|
|
7
|
-
export const EchoMsg = createMessageType({
|
|
7
|
+
export const EchoMsg = /* @__PURE__ */ createMessageType({
|
|
8
8
|
typeName: 'echo.EchoMsg',
|
|
9
9
|
fields: [
|
|
10
10
|
{ no: 1, name: 'body', kind: 'scalar', T: ScalarType.STRING },
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import net from 'net';
|
|
2
|
+
import { closeSync, openSync, writeSync } from 'node:fs';
|
|
2
3
|
import { pipe } from 'it-pipe';
|
|
3
4
|
import { pushable } from 'it-pushable';
|
|
4
5
|
import { Client } from '../../srpc/client.js';
|
|
5
6
|
import { parseLengthPrefixTransform, prependLengthPrefixTransform, } from '../../srpc/packet.js';
|
|
6
7
|
import { combineUint8ArrayListTransform } from '../../srpc/array-list.js';
|
|
7
|
-
import { runClientTest } from '../../echo/
|
|
8
|
+
import { EchoMsg, runClientTest } from '../../echo/index.js';
|
|
9
|
+
function emitReceiptEvent(line) {
|
|
10
|
+
console.log(line);
|
|
11
|
+
const fifo = process.env.RECEIPT_EVENT_FIFO;
|
|
12
|
+
if (!fifo) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const fd = openSync(fifo, 'w');
|
|
16
|
+
try {
|
|
17
|
+
writeSync(fd, `${line}\n`);
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
closeSync(fd);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
8
23
|
// tcpSocketToPacketStream wraps a Node.js TCP socket into a PacketStream.
|
|
9
24
|
function tcpSocketToPacketStream(socket) {
|
|
10
25
|
const socketSource = async function* () {
|
|
@@ -36,26 +51,84 @@ function tcpSocketToPacketStream(socket) {
|
|
|
36
51
|
};
|
|
37
52
|
}
|
|
38
53
|
async function main() {
|
|
39
|
-
const
|
|
54
|
+
const receiptMode = process.argv[2] === 'receipt';
|
|
55
|
+
const receiptCase = receiptMode ? process.argv[3] : undefined;
|
|
56
|
+
const addr = receiptMode ? process.argv[4] : process.argv[2];
|
|
40
57
|
if (!addr) {
|
|
41
|
-
console.error('usage: ts-client <host:port>');
|
|
58
|
+
console.error('usage: ts-client [receipt <case>] <host:port>');
|
|
42
59
|
process.exit(1);
|
|
43
60
|
}
|
|
61
|
+
if (receiptMode &&
|
|
62
|
+
!['commit', 'abort', 'loss', 'bare-close'].includes(receiptCase ?? '')) {
|
|
63
|
+
throw new Error(`unknown receipt case: ${receiptCase}`);
|
|
64
|
+
}
|
|
44
65
|
const [host, portStr] = addr.split(':');
|
|
45
66
|
const port = parseInt(portStr, 10);
|
|
67
|
+
let activeSocket;
|
|
46
68
|
const openStream = async () => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
socket.on('error', reject);
|
|
69
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
70
|
+
const socket = net.connect(port, host, () => {
|
|
71
|
+
activeSocket = socket;
|
|
72
|
+
resolve(tcpSocketToPacketStream(socket));
|
|
52
73
|
});
|
|
74
|
+
socket.on('error', reject);
|
|
75
|
+
return promise;
|
|
53
76
|
};
|
|
54
77
|
const client = new Client(openStream);
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
if (receiptMode) {
|
|
79
|
+
console.log(`Running held receipt test via TCP (${receiptCase})...`);
|
|
80
|
+
const request = EchoMsg.create({ body: 'held receipt' });
|
|
81
|
+
const held = await client.requestWithReceipt('echo.Echoer', 'Echo', EchoMsg.toBinary(request));
|
|
82
|
+
const result = EchoMsg.fromBinary(held.response);
|
|
83
|
+
if (result.body !== request.body) {
|
|
84
|
+
throw new Error(`expected ${request.body}, got ${result.body}`);
|
|
85
|
+
}
|
|
86
|
+
switch (receiptCase) {
|
|
87
|
+
case 'commit':
|
|
88
|
+
await held.receipt.commit();
|
|
89
|
+
break;
|
|
90
|
+
case 'abort':
|
|
91
|
+
await held.receipt.abort();
|
|
92
|
+
break;
|
|
93
|
+
case 'loss':
|
|
94
|
+
activeSocket?.resetAndDestroy();
|
|
95
|
+
await expectReceiptFailure(held.receipt);
|
|
96
|
+
break;
|
|
97
|
+
case 'bare-close':
|
|
98
|
+
activeSocket?.end();
|
|
99
|
+
await expectReceiptFailure(held.receipt);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
emitReceiptEvent(`CLIENT_RECEIPT_RESOLVED ${receiptTerminalName(receiptCase ?? '')}`);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
console.log('Running client test via TCP...');
|
|
106
|
+
await runClientTest(client);
|
|
107
|
+
}
|
|
57
108
|
console.log('All tests passed.');
|
|
58
|
-
|
|
109
|
+
}
|
|
110
|
+
function receiptTerminalName(receiptCase) {
|
|
111
|
+
switch (receiptCase) {
|
|
112
|
+
case 'commit':
|
|
113
|
+
return 'committed';
|
|
114
|
+
case 'abort':
|
|
115
|
+
return 'canceled';
|
|
116
|
+
case 'loss':
|
|
117
|
+
return 'transportLost';
|
|
118
|
+
case 'bare-close':
|
|
119
|
+
return 'closed';
|
|
120
|
+
default:
|
|
121
|
+
return 'unknown';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function expectReceiptFailure(receipt) {
|
|
125
|
+
try {
|
|
126
|
+
await receipt.commit();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
throw new Error('receipt commit unexpectedly succeeded');
|
|
59
132
|
}
|
|
60
133
|
process.on('unhandledRejection', (ev) => {
|
|
61
134
|
console.error('Unhandled rejection', ev);
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import net from 'net';
|
|
2
|
+
import { closeSync, openSync, writeSync } from 'node:fs';
|
|
2
3
|
import { pipe } from 'it-pipe';
|
|
3
4
|
import { pushable } from 'it-pushable';
|
|
4
5
|
import { createMux, createHandler, Server } from '../../srpc/index.js';
|
|
5
6
|
import { parseLengthPrefixTransform, prependLengthPrefixTransform, } from '../../srpc/packet.js';
|
|
6
7
|
import { combineUint8ArrayListTransform } from '../../srpc/array-list.js';
|
|
7
|
-
import { EchoerServer } from '../../echo/
|
|
8
|
+
import { EchoerServer, EchoMsg } from '../../echo/index.js';
|
|
8
9
|
import { EchoerDefinition } from '../../echo/echo_srpc.pb.js';
|
|
10
|
+
import { Packet, TerminalKind } from '../../srpc/rpcproto.pb.js';
|
|
11
|
+
function emitReceiptEvent(line) {
|
|
12
|
+
console.log(line);
|
|
13
|
+
const fifo = process.env.RECEIPT_EVENT_FIFO;
|
|
14
|
+
if (!fifo) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const fd = openSync(fifo, 'w');
|
|
18
|
+
try {
|
|
19
|
+
writeSync(fd, `${line}\n`);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
closeSync(fd);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
9
25
|
// tcpSocketToPacketStream wraps a Node.js TCP socket into a PacketStream.
|
|
10
26
|
// Each Uint8Array in source/sink is one packet (no length prefix).
|
|
11
27
|
function tcpSocketToPacketStream(socket) {
|
|
@@ -25,7 +41,15 @@ function tcpSocketToPacketStream(socket) {
|
|
|
25
41
|
sink: async (source) => {
|
|
26
42
|
for await (const chunk of pipe(source, prependLengthPrefixTransform())) {
|
|
27
43
|
const data = chunk instanceof Uint8Array ? chunk : chunk.subarray();
|
|
28
|
-
|
|
44
|
+
const packet = Packet.fromBinary(data.subarray(4));
|
|
45
|
+
const receiptCompletion = receiptMode &&
|
|
46
|
+
packet.body?.case === 'callData' &&
|
|
47
|
+
packet.body.value.complete &&
|
|
48
|
+
!packet.body.value.error;
|
|
49
|
+
if (receiptCompletion) {
|
|
50
|
+
emitReceiptEvent('SERVER_RECEIPT_ACK_WRITE committed');
|
|
51
|
+
}
|
|
52
|
+
const writeDone = new Promise((resolve, reject) => {
|
|
29
53
|
socket.write(data, (err) => {
|
|
30
54
|
if (err)
|
|
31
55
|
reject(err);
|
|
@@ -33,15 +57,82 @@ function tcpSocketToPacketStream(socket) {
|
|
|
33
57
|
resolve();
|
|
34
58
|
});
|
|
35
59
|
});
|
|
60
|
+
await writeDone;
|
|
61
|
+
if (receiptCompletion) {
|
|
62
|
+
finishReceiptServer();
|
|
63
|
+
}
|
|
36
64
|
}
|
|
37
65
|
socket.end();
|
|
38
66
|
},
|
|
39
67
|
};
|
|
40
68
|
}
|
|
41
69
|
const mux = createMux();
|
|
70
|
+
const receiptMode = process.argv[2] === 'receipt';
|
|
71
|
+
const receiptCase = receiptMode ? process.argv[3] : undefined;
|
|
72
|
+
if (receiptMode &&
|
|
73
|
+
!['commit', 'abort', 'loss', 'bare-close'].includes(receiptCase ?? '')) {
|
|
74
|
+
console.error(`unknown receipt case: ${receiptCase}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
const receiptServerDone = Promise.withResolvers();
|
|
78
|
+
let receiptServerFinished = false;
|
|
79
|
+
function finishReceiptServer() {
|
|
80
|
+
if (!receiptServerFinished) {
|
|
81
|
+
receiptServerFinished = true;
|
|
82
|
+
receiptServerDone.resolve();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (receiptMode) {
|
|
86
|
+
mux.registerLookupMethod(async (serviceID, methodID) => {
|
|
87
|
+
if (serviceID !== 'echo.Echoer' || methodID !== 'Echo') {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return async (dataSource, dataSink, invocation) => {
|
|
91
|
+
let requestData;
|
|
92
|
+
for await (const data of dataSource) {
|
|
93
|
+
requestData = data;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (!requestData) {
|
|
97
|
+
throw new Error('receipt request was empty');
|
|
98
|
+
}
|
|
99
|
+
const requestMsg = EchoMsg.fromBinary(requestData);
|
|
100
|
+
await dataSink((async function* () {
|
|
101
|
+
yield EchoMsg.toBinary(requestMsg);
|
|
102
|
+
if (!invocation) {
|
|
103
|
+
throw new Error('receipt invocation was missing');
|
|
104
|
+
}
|
|
105
|
+
emitReceiptEvent('SERVER_RECEIPT_WAITING');
|
|
106
|
+
const terminal = await invocation.waitTerminal(new AbortController().signal);
|
|
107
|
+
emitReceiptEvent(`SERVER_RECEIPT_TERMINAL ${terminalName(terminal)}`);
|
|
108
|
+
if (terminal !== TerminalKind.COMMITTED) {
|
|
109
|
+
finishReceiptServer();
|
|
110
|
+
}
|
|
111
|
+
})());
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function terminalName(terminal) {
|
|
116
|
+
switch (terminal) {
|
|
117
|
+
case TerminalKind.COMMITTED:
|
|
118
|
+
return 'committed';
|
|
119
|
+
case TerminalKind.CANCELED:
|
|
120
|
+
return 'canceled';
|
|
121
|
+
case TerminalKind.TRANSPORT_LOST:
|
|
122
|
+
return 'transportLost';
|
|
123
|
+
case TerminalKind.CLOSED:
|
|
124
|
+
return 'closed';
|
|
125
|
+
case TerminalKind.ABANDONED:
|
|
126
|
+
return 'abandoned';
|
|
127
|
+
default:
|
|
128
|
+
return 'unknown';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
42
131
|
const server = new Server(mux.lookupMethod);
|
|
43
|
-
|
|
44
|
-
|
|
132
|
+
if (!receiptMode) {
|
|
133
|
+
const echoer = new EchoerServer(server);
|
|
134
|
+
mux.register(createHandler(EchoerDefinition, echoer));
|
|
135
|
+
}
|
|
45
136
|
const tcpServer = net.createServer((socket) => {
|
|
46
137
|
const stream = tcpSocketToPacketStream(socket);
|
|
47
138
|
server.handlePacketStream(stream);
|
|
@@ -50,6 +141,11 @@ tcpServer.listen(0, '127.0.0.1', () => {
|
|
|
50
141
|
const addr = tcpServer.address();
|
|
51
142
|
console.log(`LISTENING ${addr.address}:${addr.port}`);
|
|
52
143
|
});
|
|
144
|
+
if (receiptMode) {
|
|
145
|
+
void receiptServerDone.promise.then(() => {
|
|
146
|
+
tcpServer.close();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
53
149
|
process.on('SIGINT', () => {
|
|
54
150
|
tcpServer.close();
|
|
55
151
|
process.exit(0);
|
package/dist/mock/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
1
|
+
export * from './mock.pb.js';
|
|
2
|
+
export * from './mock_srpc.pb.js';
|
package/dist/mock/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
1
|
+
export * from './mock.pb.js';
|
|
2
|
+
export * from './mock_srpc.pb.js';
|
package/dist/mock/mock.pb.d.ts
CHANGED
package/dist/mock/mock.pb.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false"
|
|
2
2
|
// @generated from file github.com/aperturerobotics/starpc/mock/mock.proto (package e2e.mock, syntax proto3)
|
|
3
3
|
/* eslint-disable */
|
|
4
|
-
import { createMessageType
|
|
4
|
+
import { createMessageType } from '@aptre/protobuf-es-lite/message';
|
|
5
|
+
import { ScalarType } from '@aptre/protobuf-es-lite/scalar';
|
|
5
6
|
export const protobufPackage = 'e2e.mock';
|
|
6
|
-
|
|
7
|
-
export const MockMsg = createMessageType({
|
|
7
|
+
export const MockMsg = /* @__PURE__ */ createMessageType({
|
|
8
8
|
typeName: 'e2e.mock.MockMsg',
|
|
9
9
|
fields: [
|
|
10
10
|
{ no: 1, name: 'body', kind: 'scalar', T: ScalarType.STRING },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// <reference lib="es2024.promise" />
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { Client } from '../srpc/client.js';
|
|
4
|
+
import { Server } from '../srpc/server.js';
|
|
5
|
+
import { TerminalKind } from '../srpc/rpcproto.pb.js';
|
|
6
|
+
import { buildRpcStreamOpenStream, handleRpcStream } from './rpcstream.js';
|
|
7
|
+
const response = new Uint8Array([4, 5, 6]);
|
|
8
|
+
// This guard mirrors the Go rpcstream/receipt_test.go nested tunnel: an
|
|
9
|
+
// ExecCallReceipt-equivalent held unary call runs through a real handleRpcStream
|
|
10
|
+
// tunnel. The inner generated handler sends its single response, then the
|
|
11
|
+
// wrapping invocation synchronously holds on waitTerminal. The first response
|
|
12
|
+
// must be client-visible while the exact invocation stays open for commit.
|
|
13
|
+
describe('rpcstream held receipt tunnel', () => {
|
|
14
|
+
it('delivers the first inner response while the invocation stays held', async () => {
|
|
15
|
+
const terminal = Promise.withResolvers();
|
|
16
|
+
const innerServer = new Server(async () => {
|
|
17
|
+
return async (_source, dataSink, invocation) => {
|
|
18
|
+
await dataSink((async function* () {
|
|
19
|
+
yield response;
|
|
20
|
+
if (!invocation) {
|
|
21
|
+
throw new Error('missing invocation');
|
|
22
|
+
}
|
|
23
|
+
terminal.resolve(await invocation.waitTerminal(new AbortController().signal));
|
|
24
|
+
})());
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
const caller = (packetTx) => handleRpcStream(packetTx[Symbol.asyncIterator](), async () => innerServer.rpcStreamHandler);
|
|
28
|
+
const client = new Client(buildRpcStreamOpenStream('component-a', caller));
|
|
29
|
+
const held = await client.requestWithReceipt('test.Service', 'Unary', response);
|
|
30
|
+
// The first inner response is client-visible while the invocation is held.
|
|
31
|
+
expect(held.response).toEqual(response);
|
|
32
|
+
// The inner invocation must still be open: no terminal observed yet.
|
|
33
|
+
const raced = await Promise.race([
|
|
34
|
+
terminal.promise.then(() => 'terminal'),
|
|
35
|
+
Promise.resolve('pending'),
|
|
36
|
+
]);
|
|
37
|
+
expect(raced).toBe('pending');
|
|
38
|
+
await expect(held.receipt.commit()).resolves.toBeUndefined();
|
|
39
|
+
await expect(terminal.promise).resolves.toBe(TerminalKind.COMMITTED);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
// @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false"
|
|
2
2
|
// @generated from file github.com/aperturerobotics/starpc/rpcstream/rpcstream.proto (package rpcstream, syntax proto3)
|
|
3
3
|
/* eslint-disable */
|
|
4
|
-
import { createMessageType
|
|
4
|
+
import { createMessageType } from '@aptre/protobuf-es-lite/message';
|
|
5
|
+
import { ScalarType } from '@aptre/protobuf-es-lite/scalar';
|
|
5
6
|
export const protobufPackage = 'rpcstream';
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
export const RpcStreamInit =
|
|
8
|
+
/* @__PURE__ */ createMessageType({
|
|
8
9
|
typeName: 'rpcstream.RpcStreamInit',
|
|
9
10
|
fields: [
|
|
10
11
|
{ no: 1, name: 'component_id', kind: 'scalar', T: ScalarType.STRING },
|
|
11
12
|
],
|
|
12
13
|
packedByDefault: true,
|
|
13
14
|
});
|
|
14
|
-
|
|
15
|
-
export const RpcAck = createMessageType({
|
|
15
|
+
export const RpcAck = /* @__PURE__ */ createMessageType({
|
|
16
16
|
typeName: 'rpcstream.RpcAck',
|
|
17
17
|
fields: [
|
|
18
18
|
{ no: 1, name: 'error', kind: 'scalar', T: ScalarType.STRING },
|
|
19
19
|
],
|
|
20
20
|
packedByDefault: true,
|
|
21
21
|
});
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
export const RpcStreamPacket =
|
|
23
|
+
/* @__PURE__ */ createMessageType({
|
|
24
24
|
typeName: 'rpcstream.RpcStreamPacket',
|
|
25
25
|
fields: [
|
|
26
26
|
{
|
|
@@ -31,7 +31,13 @@ export const RpcStreamPacket = createMessageType({
|
|
|
31
31
|
oneof: 'body',
|
|
32
32
|
},
|
|
33
33
|
{ no: 2, name: 'ack', kind: 'message', T: () => RpcAck, oneof: 'body' },
|
|
34
|
-
{
|
|
34
|
+
{
|
|
35
|
+
no: 3,
|
|
36
|
+
name: 'data',
|
|
37
|
+
kind: 'scalar',
|
|
38
|
+
T: ScalarType.BYTES,
|
|
39
|
+
oneof: 'body',
|
|
40
|
+
},
|
|
35
41
|
],
|
|
36
42
|
packedByDefault: true,
|
|
37
43
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ClientRPC } from './client-rpc.js';
|
|
2
|
+
export interface ReceiptRpc {
|
|
3
|
+
requestWithReceipt(service: string, method: string, data: Uint8Array, abortSignal?: AbortSignal): Promise<HeldCall>;
|
|
4
|
+
}
|
|
5
|
+
export interface HeldCall {
|
|
6
|
+
readonly response: Uint8Array;
|
|
7
|
+
readonly receipt: CallReceipt;
|
|
8
|
+
}
|
|
9
|
+
export declare class CallReceipt {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(call: ClientRPC, iterator: AsyncIterator<Uint8Array>);
|
|
12
|
+
get done(): Promise<void>;
|
|
13
|
+
get settled(): boolean;
|
|
14
|
+
commit(): Promise<void>;
|
|
15
|
+
abort(reason?: Error): Promise<void>;
|
|
16
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/// <reference lib="es2024.promise" />
|
|
2
|
+
import { ERR_RPC_ABORT } from './errors.js';
|
|
3
|
+
import { TerminalKind } from './rpcproto.pb.js';
|
|
4
|
+
// CallReceipt holds a unary call until it is committed or aborted.
|
|
5
|
+
export class CallReceipt {
|
|
6
|
+
#call;
|
|
7
|
+
#iterator;
|
|
8
|
+
#terminalPromise;
|
|
9
|
+
#terminal;
|
|
10
|
+
#done;
|
|
11
|
+
#requestCommitted = false;
|
|
12
|
+
#resolveDone;
|
|
13
|
+
#rejectDone;
|
|
14
|
+
constructor(call, iterator) {
|
|
15
|
+
this.#call = call;
|
|
16
|
+
this.#iterator = iterator;
|
|
17
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
18
|
+
this.#done = promise;
|
|
19
|
+
this.#resolveDone = resolve;
|
|
20
|
+
this.#rejectDone = reject;
|
|
21
|
+
this.#done.catch(() => undefined);
|
|
22
|
+
this.#terminalPromise = this.#observeTerminal();
|
|
23
|
+
this.#terminalPromise.catch(() => undefined);
|
|
24
|
+
}
|
|
25
|
+
async #observeTerminal() {
|
|
26
|
+
try {
|
|
27
|
+
const result = await this.#iterator.next();
|
|
28
|
+
if (!result.done) {
|
|
29
|
+
throw new Error('unexpected trailing response data');
|
|
30
|
+
}
|
|
31
|
+
const terminal = this.#call.getTerminalKind();
|
|
32
|
+
if (terminal !== TerminalKind.COMMITTED ||
|
|
33
|
+
!this.#requestCommitted ||
|
|
34
|
+
this.#terminal !== 'committed') {
|
|
35
|
+
throw new Error('receipt closed before commit');
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
this.#rejectDone(err);
|
|
41
|
+
const error = err instanceof Error ? err : new Error('receipt terminal failed');
|
|
42
|
+
try {
|
|
43
|
+
await this.#call.close(error);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// The primary terminal error is already recorded on the receipt.
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// done resolves after committed close and rejects on terminal failure.
|
|
52
|
+
get done() {
|
|
53
|
+
return this.#done;
|
|
54
|
+
}
|
|
55
|
+
// settled reports whether a terminal transition has been claimed.
|
|
56
|
+
get settled() {
|
|
57
|
+
return this.#terminal !== undefined;
|
|
58
|
+
}
|
|
59
|
+
// commit sends request completion and waits for server finalization.
|
|
60
|
+
async commit() {
|
|
61
|
+
if (this.#terminal === 'aborted') {
|
|
62
|
+
throw new Error(ERR_RPC_ABORT);
|
|
63
|
+
}
|
|
64
|
+
if (this.#terminal === 'committed') {
|
|
65
|
+
return this.#done;
|
|
66
|
+
}
|
|
67
|
+
this.#terminal = 'committed';
|
|
68
|
+
this.#requestCommitted = true;
|
|
69
|
+
try {
|
|
70
|
+
await this.#call.writeCallData(undefined, true);
|
|
71
|
+
await this.#terminalPromise;
|
|
72
|
+
await this.#call.close();
|
|
73
|
+
this.#resolveDone();
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
await this.#call.close(err instanceof Error ? err : new Error('receipt commit failed'));
|
|
77
|
+
this.#rejectDone(err);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// abort sends request cancellation and never rejects.
|
|
82
|
+
async abort(reason) {
|
|
83
|
+
if (this.#terminal !== undefined) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.#terminal = 'aborted';
|
|
87
|
+
let terminalError = reason;
|
|
88
|
+
try {
|
|
89
|
+
await this.#call.writeCallCancel(true);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
terminalError ??= err;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await this.#call.close();
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
terminalError ??= err;
|
|
99
|
+
}
|
|
100
|
+
this.#rejectDone(terminalError ?? new Error(ERR_RPC_ABORT));
|
|
101
|
+
}
|
|
102
|
+
// asyncDispose aborts a receipt that has not reached a terminal.
|
|
103
|
+
async [Symbol.asyncDispose]() {
|
|
104
|
+
await this.abort();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|