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
package/srpc/invoker.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Sink, Source } from 'it-stream-types'
|
|
|
2
2
|
import { pushable } from 'it-pushable'
|
|
3
3
|
import { pipe } from 'it-pipe'
|
|
4
4
|
import type { MethodDefinition } from './definition.js'
|
|
5
|
+
import type { ServerContext } from './server-context.js'
|
|
5
6
|
import { InvokeFn } from './handler.js'
|
|
6
7
|
import {
|
|
7
8
|
buildDecodeMessageTransform,
|
|
@@ -13,10 +14,26 @@ import { MethodIdempotency, MethodKind } from '@aptre/protobuf-es-lite'
|
|
|
13
14
|
|
|
14
15
|
// MethodProto is a function which matches one of the RPC signatures.
|
|
15
16
|
export type MethodProto<R extends Message<R>, O extends Message<O>> =
|
|
16
|
-
| ((
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
| ((
|
|
18
|
+
request: R,
|
|
19
|
+
abortSignal: AbortSignal,
|
|
20
|
+
context: ServerContext,
|
|
21
|
+
) => Promise<O>)
|
|
22
|
+
| ((
|
|
23
|
+
request: R,
|
|
24
|
+
abortSignal: AbortSignal,
|
|
25
|
+
context: ServerContext,
|
|
26
|
+
) => AsyncIterable<O>)
|
|
27
|
+
| ((
|
|
28
|
+
request: AsyncIterable<R>,
|
|
29
|
+
abortSignal: AbortSignal,
|
|
30
|
+
context: ServerContext,
|
|
31
|
+
) => Promise<O>)
|
|
32
|
+
| ((
|
|
33
|
+
request: AsyncIterable<R>,
|
|
34
|
+
abortSignal: AbortSignal,
|
|
35
|
+
context: ServerContext,
|
|
36
|
+
) => AsyncIterable<O>)
|
|
20
37
|
|
|
21
38
|
// createInvokeFn builds an InvokeFn from a method definition and a function prototype.
|
|
22
39
|
export function createInvokeFn<R extends Message<R>, O extends Message<O>>(
|
|
@@ -32,7 +49,7 @@ export function createInvokeFn<R extends Message<R>, O extends Message<O>>(
|
|
|
32
49
|
return async (
|
|
33
50
|
dataSource: Source<Uint8Array>,
|
|
34
51
|
dataSink: Sink<Source<Uint8Array>>,
|
|
35
|
-
|
|
52
|
+
context: ServerContext,
|
|
36
53
|
) => {
|
|
37
54
|
// responseSink is a Sink for response messages.
|
|
38
55
|
const responseSink = pushable<O>({
|
|
@@ -67,7 +84,7 @@ export function createInvokeFn<R extends Message<R>, O extends Message<O>>(
|
|
|
67
84
|
|
|
68
85
|
// Call the implementation.
|
|
69
86
|
try {
|
|
70
|
-
const responseObj = methodProto(requestArg,
|
|
87
|
+
const responseObj = methodProto(requestArg, context.signal, context)
|
|
71
88
|
if (!responseObj) {
|
|
72
89
|
throw new Error('return value was undefined')
|
|
73
90
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
package srpc
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/binary"
|
|
6
|
+
"encoding/hex"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"errors"
|
|
9
|
+
"io"
|
|
10
|
+
"os"
|
|
11
|
+
"testing"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type packetCodecVector struct {
|
|
15
|
+
Name string `json:"name"`
|
|
16
|
+
PacketHex string `json:"packet_hex"`
|
|
17
|
+
FrameHex string `json:"frame_hex"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestPacketCodecGoldenVectors(t *testing.T) {
|
|
21
|
+
data, err := os.ReadFile("../testdata/packet-codec-vectors.json")
|
|
22
|
+
if err != nil {
|
|
23
|
+
t.Fatal(err)
|
|
24
|
+
}
|
|
25
|
+
var document struct {
|
|
26
|
+
Cases []packetCodecVector `json:"cases"`
|
|
27
|
+
}
|
|
28
|
+
if err := json.Unmarshal(data, &document); err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
for _, tc := range document.Cases {
|
|
32
|
+
if tc.PacketHex == "" || tc.FrameHex == "" {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
t.Run(tc.Name, func(t *testing.T) {
|
|
36
|
+
packet := goldenPacket(t, tc.Name)
|
|
37
|
+
packetData, err := packet.MarshalVT()
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatal(err)
|
|
40
|
+
}
|
|
41
|
+
if want := decodeHex(t, tc.PacketHex); !bytes.Equal(packetData, want) {
|
|
42
|
+
t.Fatalf("packet = %x, want %x", packetData, want)
|
|
43
|
+
}
|
|
44
|
+
stream := &packetTestStream{}
|
|
45
|
+
if err := NewPacketReadWriter(stream).WritePacket(packet); err != nil {
|
|
46
|
+
t.Fatal(err)
|
|
47
|
+
}
|
|
48
|
+
if want := decodeHex(t, tc.FrameHex); !bytes.Equal(stream.writes, want) {
|
|
49
|
+
t.Fatalf("frame = %x, want %x", stream.writes, want)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func goldenPacket(t *testing.T, name string) *Packet {
|
|
56
|
+
t.Helper()
|
|
57
|
+
switch name {
|
|
58
|
+
case "call_start_data":
|
|
59
|
+
return NewCallStartPacket("svc", "method", []byte("abc"), false)
|
|
60
|
+
case "call_start_absent_empty":
|
|
61
|
+
return NewCallStartPacket("svc", "method", nil, false)
|
|
62
|
+
case "call_start_present_empty":
|
|
63
|
+
return NewCallStartPacket("svc", "method", nil, true)
|
|
64
|
+
case "call_data_terminal":
|
|
65
|
+
return NewCallDataPacket([]byte("out"), false, true, nil)
|
|
66
|
+
case "call_data_error":
|
|
67
|
+
return NewCallDataPacket(nil, false, false, errors.New("failed"))
|
|
68
|
+
case "call_cancel":
|
|
69
|
+
return NewCallCancelPacket()
|
|
70
|
+
default:
|
|
71
|
+
t.Fatalf("unknown golden packet %q", name)
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func decodeHex(t *testing.T, value string) []byte {
|
|
77
|
+
t.Helper()
|
|
78
|
+
data, err := hex.DecodeString(value)
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatal(err)
|
|
81
|
+
}
|
|
82
|
+
return data
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func framePacket(t *testing.T, packet *Packet) []byte {
|
|
86
|
+
t.Helper()
|
|
87
|
+
packetSize := packet.SizeVT()
|
|
88
|
+
if packetSize > maxMessageSize {
|
|
89
|
+
t.Fatalf("packet size %d exceeds maximum %d", packetSize, maxMessageSize)
|
|
90
|
+
}
|
|
91
|
+
frame := make([]byte, 4+packetSize)
|
|
92
|
+
binary.LittleEndian.PutUint32(frame, uint32(packetSize)) //nolint:gosec // bounded by maxMessageSize
|
|
93
|
+
if _, err := packet.MarshalToSizedBufferVT(frame[4:]); err != nil {
|
|
94
|
+
t.Fatal(err)
|
|
95
|
+
}
|
|
96
|
+
return frame
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type packetTestStream struct {
|
|
100
|
+
reads bytes.Buffer
|
|
101
|
+
writes []byte
|
|
102
|
+
maxRead int
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func (s *packetTestStream) Read(data []byte) (int, error) {
|
|
106
|
+
if s.maxRead > 0 && len(data) > s.maxRead {
|
|
107
|
+
data = data[:s.maxRead]
|
|
108
|
+
}
|
|
109
|
+
return s.reads.Read(data)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func (s *packetTestStream) Write(data []byte) (int, error) {
|
|
113
|
+
s.writes = append(s.writes, data...)
|
|
114
|
+
return len(data), nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func (s *packetTestStream) Close() error { return nil }
|
|
118
|
+
|
|
119
|
+
func TestPacketCodecReadFragmentsAndCoalesces(t *testing.T) {
|
|
120
|
+
first := framePacket(t, NewCallCancelPacket())
|
|
121
|
+
second := framePacket(t, NewCallDataPacket([]byte("x"), false, true, nil))
|
|
122
|
+
input := append(first, second...)
|
|
123
|
+
stream := &packetTestStream{reads: *bytes.NewBuffer(input)}
|
|
124
|
+
decode := NewPacketDataHandler(func(*Packet) error { return nil })
|
|
125
|
+
var count int
|
|
126
|
+
err := NewPacketReadWriter(stream).ReadToHandler(func(data []byte) error {
|
|
127
|
+
count++
|
|
128
|
+
return decode(data)
|
|
129
|
+
})
|
|
130
|
+
if err != nil || count != 2 {
|
|
131
|
+
t.Fatalf("ReadToHandler() = %v, packets=%d", err, count)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func TestPacketCodecReadEveryFragmentBoundary(t *testing.T) {
|
|
136
|
+
frame := framePacket(t, NewCallCancelPacket())
|
|
137
|
+
decode := NewPacketDataHandler(func(*Packet) error { return nil })
|
|
138
|
+
for size := 1; size <= len(frame); size++ {
|
|
139
|
+
stream := &packetTestStream{reads: *bytes.NewBuffer(frame), maxRead: size}
|
|
140
|
+
var count int
|
|
141
|
+
err := NewPacketReadWriter(stream).ReadToHandler(func(data []byte) error {
|
|
142
|
+
count++
|
|
143
|
+
return decode(data)
|
|
144
|
+
})
|
|
145
|
+
if err != nil || count != 1 {
|
|
146
|
+
t.Fatalf("chunk %d: err=%v count=%d", size, err, count)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func TestPacketCodecRejectsInvalidPrefixesAndMalformedPacket(t *testing.T) {
|
|
152
|
+
for name, frame := range map[string][]byte{
|
|
153
|
+
"zero": {0, 0, 0, 0},
|
|
154
|
+
"oversized": {0x81, 0x96, 0x98, 0x00},
|
|
155
|
+
} {
|
|
156
|
+
t.Run(name, func(t *testing.T) {
|
|
157
|
+
stream := &packetTestStream{reads: *bytes.NewBuffer(frame)}
|
|
158
|
+
if err := NewPacketReadWriter(stream).ReadToHandler(func([]byte) error { return nil }); err == nil {
|
|
159
|
+
t.Fatal("accepted invalid prefix")
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
frame := []byte{3, 0, 0, 0, 0x0a, 0x01, 0xff}
|
|
164
|
+
stream := &packetTestStream{reads: *bytes.NewBuffer(frame)}
|
|
165
|
+
decode := NewPacketDataHandler(func(*Packet) error { return nil })
|
|
166
|
+
if err := NewPacketReadWriter(stream).ReadToHandler(decode); err == nil {
|
|
167
|
+
t.Fatal("accepted malformed protobuf")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func TestPacketCodecCleanEOFReturnsNil(t *testing.T) {
|
|
172
|
+
stream := &packetTestStream{reads: *bytes.NewBuffer(nil)}
|
|
173
|
+
if err := NewPacketReadWriter(stream).ReadToHandler(func([]byte) error { return nil }); err != nil {
|
|
174
|
+
t.Fatalf("clean EOF: %v", err)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
func TestPacketCodecTruncatedBodyAtEOF(t *testing.T) {
|
|
179
|
+
frame := []byte{4, 0, 0, 0, 0x0a, 0x01}
|
|
180
|
+
stream := &packetTestStream{reads: *bytes.NewBuffer(frame)}
|
|
181
|
+
var count int
|
|
182
|
+
err := NewPacketReadWriter(stream).ReadToHandler(func([]byte) error {
|
|
183
|
+
count++
|
|
184
|
+
return nil
|
|
185
|
+
})
|
|
186
|
+
if err != io.ErrUnexpectedEOF || count != 0 {
|
|
187
|
+
t.Fatalf("err=%v count=%d", err, count)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func TestPacketCodecWriteRejectsZeroPacket(t *testing.T) {
|
|
192
|
+
if err := NewPacketReadWriter(&packetTestStream{}).WritePacket(&Packet{}); err == nil {
|
|
193
|
+
t.Fatal("accepted zero-size packet")
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
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 {
|
|
5
|
+
decodePacketSource,
|
|
6
|
+
encodePacketSource,
|
|
7
|
+
lengthPrefixDecode,
|
|
8
|
+
prependLengthPrefixTransform,
|
|
9
|
+
uint32LEDecode,
|
|
10
|
+
} from './packet.js'
|
|
11
|
+
|
|
12
|
+
const bytes = (hex: string) =>
|
|
13
|
+
Uint8Array.from(hex.match(/../g) ?? [], (b) => parseInt(b, 16))
|
|
14
|
+
const hex = (
|
|
15
|
+
data: Uint8Array | { subarray: (start?: number, end?: number) => Uint8Array },
|
|
16
|
+
) => Buffer.from(data.subarray()).toString('hex')
|
|
17
|
+
const collect = async <T>(source: AsyncIterable<T> | Iterable<T>) => {
|
|
18
|
+
const out: T[] = []
|
|
19
|
+
for await (const value of source) out.push(value)
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ValidVector = (typeof vectors.cases)[number] & {
|
|
24
|
+
packet_hex: string
|
|
25
|
+
frame_hex: string
|
|
26
|
+
}
|
|
27
|
+
const validCases: ValidVector[] = vectors.cases.filter(
|
|
28
|
+
(entry): entry is ValidVector => Boolean(entry.packet_hex && entry.frame_hex),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
describe('packet codec golden vectors', () => {
|
|
32
|
+
it.each(validCases)(
|
|
33
|
+
'$name has exact protobuf and frame bytes',
|
|
34
|
+
async (entry) => {
|
|
35
|
+
const packet = Packet.fromBinary(bytes(entry.packet_hex))
|
|
36
|
+
const encoded = (
|
|
37
|
+
await collect(
|
|
38
|
+
encodePacketSource(
|
|
39
|
+
(async function* () {
|
|
40
|
+
yield packet
|
|
41
|
+
})(),
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
)[0]
|
|
45
|
+
expect(hex(encoded)).toBe(entry.packet_hex)
|
|
46
|
+
|
|
47
|
+
const framed = (
|
|
48
|
+
await collect(
|
|
49
|
+
prependLengthPrefixTransform()(
|
|
50
|
+
(async function* () {
|
|
51
|
+
yield encoded
|
|
52
|
+
})(),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
)[0]
|
|
56
|
+
expect(hex(framed)).toBe(entry.frame_hex)
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
it('rejects zero and oversized encoded chunks', async () => {
|
|
61
|
+
const zero = (async function* () {
|
|
62
|
+
yield new Uint8Array()
|
|
63
|
+
})()
|
|
64
|
+
await expect(collect(prependLengthPrefixTransform()(zero))).rejects.toThrow(
|
|
65
|
+
'invalid packet length',
|
|
66
|
+
)
|
|
67
|
+
const oversized = (async function* () {
|
|
68
|
+
yield new Uint8Array(10_000_001)
|
|
69
|
+
})()
|
|
70
|
+
await expect(
|
|
71
|
+
collect(prependLengthPrefixTransform()(oversized)),
|
|
72
|
+
).rejects.toThrow('invalid packet length')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('rejects zero and oversized lengths, and truncated bodies', async () => {
|
|
76
|
+
const zero = (async function* () {
|
|
77
|
+
yield bytes('00000000')
|
|
78
|
+
})()
|
|
79
|
+
await expect(
|
|
80
|
+
collect(lengthPrefixDecode(zero, uint32LEDecode)),
|
|
81
|
+
).rejects.toThrow('invalid packet length')
|
|
82
|
+
const oversized = (async function* () {
|
|
83
|
+
yield bytes('81969800')
|
|
84
|
+
})()
|
|
85
|
+
await expect(
|
|
86
|
+
collect(lengthPrefixDecode(oversized, uint32LEDecode)),
|
|
87
|
+
).rejects.toThrow('invalid packet length')
|
|
88
|
+
const truncated = (async function* () {
|
|
89
|
+
yield bytes('040000000a01')
|
|
90
|
+
})()
|
|
91
|
+
await expect(
|
|
92
|
+
collect(lengthPrefixDecode(truncated, uint32LEDecode)),
|
|
93
|
+
).rejects.toThrow('truncated packet frame')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('decodes fragmented and coalesced frames', async () => {
|
|
97
|
+
const frames = validCases.map((entry) => bytes(entry.frame_hex))
|
|
98
|
+
const combined = new Uint8Array(
|
|
99
|
+
frames.reduce((n, frame) => n + frame.length, 0),
|
|
100
|
+
)
|
|
101
|
+
let offset = 0
|
|
102
|
+
for (const frame of frames) {
|
|
103
|
+
combined.set(frame, offset)
|
|
104
|
+
offset += frame.length
|
|
105
|
+
}
|
|
106
|
+
const payloads = await collect(
|
|
107
|
+
lengthPrefixDecode(
|
|
108
|
+
(async function* () {
|
|
109
|
+
yield combined.subarray(0, 3)
|
|
110
|
+
yield combined.subarray(3, 11)
|
|
111
|
+
yield combined.subarray(11)
|
|
112
|
+
})(),
|
|
113
|
+
uint32LEDecode,
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
expect(payloads.map(hex)).toEqual(
|
|
117
|
+
validCases.map((entry) => entry.packet_hex),
|
|
118
|
+
)
|
|
119
|
+
const decoded = await collect(
|
|
120
|
+
decodePacketSource(payloads.map((payload) => payload.slice())),
|
|
121
|
+
)
|
|
122
|
+
expect(decoded).toHaveLength(validCases.length)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('rejects malformed protobuf and incomplete frame prefix', async () => {
|
|
126
|
+
const malformed = vectors.cases.find(
|
|
127
|
+
(entry) => entry.name === 'malformed_complete',
|
|
128
|
+
)!
|
|
129
|
+
expect(() =>
|
|
130
|
+
Packet.fromBinary(bytes(malformed.frame_hex!.slice(8))),
|
|
131
|
+
).toThrow()
|
|
132
|
+
const incomplete = (async function* () {
|
|
133
|
+
yield bytes('010203')
|
|
134
|
+
})()
|
|
135
|
+
await expect(
|
|
136
|
+
collect(lengthPrefixDecode(incomplete, uint32LEDecode)),
|
|
137
|
+
).rejects.toThrow('truncated packet frame')
|
|
138
|
+
})
|
|
139
|
+
})
|
package/srpc/packet-rw.go
CHANGED
|
@@ -65,7 +65,10 @@ func (r *PacketReadWriter) WritePacket(p *Packet) error {
|
|
|
65
65
|
defer r.writeMtx.Unlock()
|
|
66
66
|
|
|
67
67
|
msgSize := p.SizeVT()
|
|
68
|
-
if msgSize
|
|
68
|
+
if msgSize <= 0 {
|
|
69
|
+
return errors.New("unexpected zero message size")
|
|
70
|
+
}
|
|
71
|
+
if msgSize > maxMessageSize {
|
|
69
72
|
return errors.Errorf("message size %v greater than maximum %v", msgSize, maxMessageSize)
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -163,7 +166,11 @@ func (r *PacketReadWriter) ReadToHandler(cb PacketDataHandler) error {
|
|
|
163
166
|
}
|
|
164
167
|
}
|
|
165
168
|
|
|
166
|
-
// closed
|
|
169
|
+
// closed: a clean frame boundary has no buffered bytes; otherwise EOF
|
|
170
|
+
// ended a prefix or packet body before completion.
|
|
171
|
+
if currLen != 0 || r.buf.Len() != 0 {
|
|
172
|
+
return io.ErrUnexpectedEOF
|
|
173
|
+
}
|
|
167
174
|
return nil
|
|
168
175
|
}
|
|
169
176
|
|
package/srpc/packet.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
buildEncodeMessageTransform,
|
|
8
8
|
} from './message.js'
|
|
9
9
|
|
|
10
|
+
const MAX_MESSAGE_SIZE = 10_000_000
|
|
11
|
+
|
|
10
12
|
// decodePacketSource decodes packets from a binary data stream.
|
|
11
13
|
export const decodePacketSource = buildDecodeMessageTransform<Packet>(Packet)
|
|
12
14
|
|
|
@@ -39,6 +41,9 @@ export async function* lengthPrefixEncode(
|
|
|
39
41
|
for await (const chunk of source) {
|
|
40
42
|
// Encode the length of the chunk.
|
|
41
43
|
const length = chunk instanceof Uint8Array ? chunk.length : chunk.byteLength
|
|
44
|
+
if (length === 0 || length > MAX_MESSAGE_SIZE) {
|
|
45
|
+
throw RangeError(`invalid packet length: ${length}`)
|
|
46
|
+
}
|
|
42
47
|
const lengthEncoded = lengthEncoder(length)
|
|
43
48
|
|
|
44
49
|
// Concatenate the length prefix and the data.
|
|
@@ -50,7 +55,7 @@ export async function* lengthPrefixEncode(
|
|
|
50
55
|
export async function* lengthPrefixDecode(
|
|
51
56
|
source: Source<Uint8Array | Uint8ArrayList>,
|
|
52
57
|
lengthDecoder: typeof uint32LEDecode,
|
|
53
|
-
) {
|
|
58
|
+
): AsyncGenerator<Uint8ArrayList> {
|
|
54
59
|
const buffer = new Uint8ArrayList()
|
|
55
60
|
|
|
56
61
|
for await (const chunk of source) {
|
|
@@ -59,18 +64,26 @@ export async function* lengthPrefixDecode(
|
|
|
59
64
|
// Continue extracting messages while buffer contains enough data for decoding.
|
|
60
65
|
while (buffer.length >= lengthDecoder.bytes) {
|
|
61
66
|
const messageLength = lengthDecoder(buffer)
|
|
67
|
+
if (messageLength === 0 || messageLength > MAX_MESSAGE_SIZE) {
|
|
68
|
+
throw RangeError(`invalid packet length: ${messageLength}`)
|
|
69
|
+
}
|
|
62
70
|
const totalLength = lengthDecoder.bytes + messageLength
|
|
63
71
|
|
|
64
72
|
if (buffer.length < totalLength) break // Wait for more data if the full message hasn't arrived.
|
|
65
73
|
|
|
66
74
|
// Extract the message excluding the length prefix.
|
|
67
|
-
const message =
|
|
75
|
+
const message = new Uint8ArrayList(
|
|
76
|
+
buffer.slice(lengthDecoder.bytes, totalLength),
|
|
77
|
+
)
|
|
68
78
|
yield message
|
|
69
79
|
|
|
70
80
|
// Remove the processed message from the buffer.
|
|
71
81
|
buffer.consume(totalLength)
|
|
72
82
|
}
|
|
73
83
|
}
|
|
84
|
+
if (buffer.length !== 0) {
|
|
85
|
+
throw new RangeError('truncated packet frame')
|
|
86
|
+
}
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
// prependLengthPrefixTransform adds a length prefix to a message source.
|
package/srpc/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: github.com/aperturerobotics/starpc/srpc/rpcproto.proto
|
|
5
|
+
# Protobuf Python Version: 6.33.4
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
6,
|
|
15
|
+
33,
|
|
16
|
+
4,
|
|
17
|
+
'',
|
|
18
|
+
'github.com/aperturerobotics/starpc/srpc/rpcproto.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n6github.com/aperturerobotics/starpc/srpc/rpcproto.proto\x12\x04srpc\"s\n\x06Packet\x12%\n\ncall_start\x18\x01 \x01(\x0b\x32\x0f.srpc.CallStartH\x00\x12#\n\tcall_data\x18\x02 \x01(\x0b\x32\x0e.srpc.CallDataH\x00\x12\x15\n\x0b\x63\x61ll_cancel\x18\x03 \x01(\x08H\x00\x42\x06\n\x04\x62ody\"X\n\tCallStart\x12\x13\n\x0brpc_service\x18\x01 \x01(\t\x12\x12\n\nrpc_method\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\x12\x14\n\x0c\x64\x61ta_is_zero\x18\x04 \x01(\x08\"O\n\x08\x43\x61llData\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x12\x14\n\x0c\x64\x61ta_is_zero\x18\x02 \x01(\x08\x12\x10\n\x08\x63omplete\x18\x03 \x01(\x08\x12\r\n\x05\x65rror\x18\x04 \x01(\tb\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'github.com.aperturerobotics.starpc.srpc.rpcproto_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_PACKET']._serialized_start=64
|
|
35
|
+
_globals['_PACKET']._serialized_end=179
|
|
36
|
+
_globals['_CALLSTART']._serialized_start=181
|
|
37
|
+
_globals['_CALLSTART']._serialized_end=269
|
|
38
|
+
_globals['_CALLDATA']._serialized_start=271
|
|
39
|
+
_globals['_CALLDATA']._serialized_end=350
|
|
40
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from google.protobuf import descriptor as _descriptor
|
|
2
|
+
from google.protobuf import message as _message
|
|
3
|
+
from collections.abc import Mapping as _Mapping
|
|
4
|
+
from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
|
|
5
|
+
|
|
6
|
+
DESCRIPTOR: _descriptor.FileDescriptor
|
|
7
|
+
|
|
8
|
+
class Packet(_message.Message):
|
|
9
|
+
__slots__ = ("call_start", "call_data", "call_cancel")
|
|
10
|
+
CALL_START_FIELD_NUMBER: _ClassVar[int]
|
|
11
|
+
CALL_DATA_FIELD_NUMBER: _ClassVar[int]
|
|
12
|
+
CALL_CANCEL_FIELD_NUMBER: _ClassVar[int]
|
|
13
|
+
call_start: CallStart
|
|
14
|
+
call_data: CallData
|
|
15
|
+
call_cancel: bool
|
|
16
|
+
def __init__(self, call_start: _Optional[_Union[CallStart, _Mapping]] = ..., call_data: _Optional[_Union[CallData, _Mapping]] = ..., call_cancel: _Optional[bool] = ...) -> None: ...
|
|
17
|
+
|
|
18
|
+
class CallStart(_message.Message):
|
|
19
|
+
__slots__ = ("rpc_service", "rpc_method", "data", "data_is_zero")
|
|
20
|
+
RPC_SERVICE_FIELD_NUMBER: _ClassVar[int]
|
|
21
|
+
RPC_METHOD_FIELD_NUMBER: _ClassVar[int]
|
|
22
|
+
DATA_FIELD_NUMBER: _ClassVar[int]
|
|
23
|
+
DATA_IS_ZERO_FIELD_NUMBER: _ClassVar[int]
|
|
24
|
+
rpc_service: str
|
|
25
|
+
rpc_method: str
|
|
26
|
+
data: bytes
|
|
27
|
+
data_is_zero: bool
|
|
28
|
+
def __init__(self, rpc_service: _Optional[str] = ..., rpc_method: _Optional[str] = ..., data: _Optional[bytes] = ..., data_is_zero: _Optional[bool] = ...) -> None: ...
|
|
29
|
+
|
|
30
|
+
class CallData(_message.Message):
|
|
31
|
+
__slots__ = ("data", "data_is_zero", "complete", "error")
|
|
32
|
+
DATA_FIELD_NUMBER: _ClassVar[int]
|
|
33
|
+
DATA_IS_ZERO_FIELD_NUMBER: _ClassVar[int]
|
|
34
|
+
COMPLETE_FIELD_NUMBER: _ClassVar[int]
|
|
35
|
+
ERROR_FIELD_NUMBER: _ClassVar[int]
|
|
36
|
+
data: bytes
|
|
37
|
+
data_is_zero: bool
|
|
38
|
+
complete: bool
|
|
39
|
+
error: str
|
|
40
|
+
def __init__(self, data: _Optional[bytes] = ..., data_is_zero: _Optional[bool] = ..., complete: _Optional[bool] = ..., error: _Optional[str] = ...) -> None: ...
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
declare const contextKeyValue: unique symbol
|
|
2
|
+
const contextParent = Symbol('server context parent')
|
|
3
|
+
const contextKey = Symbol('server context key')
|
|
4
|
+
const contextStoredValue = Symbol('server context value')
|
|
5
|
+
|
|
6
|
+
// ContextKey identifies one typed server-context value.
|
|
7
|
+
export interface ContextKey<T> {
|
|
8
|
+
readonly [contextKeyValue]?: T
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ServerContext carries cancellation for one server invocation.
|
|
12
|
+
export interface ServerContext {
|
|
13
|
+
readonly signal: AbortSignal
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type StoredServerContext = ServerContext & {
|
|
17
|
+
readonly [contextParent]?: ServerContext
|
|
18
|
+
readonly [contextKey]?: ContextKey<unknown>
|
|
19
|
+
readonly [contextStoredValue]?: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// createContextKey constructs an identity key for one server-context value.
|
|
23
|
+
export function createContextKey<T>(): ContextKey<T> {
|
|
24
|
+
return {}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// withServerContextValue derives a context with one immutable typed value.
|
|
28
|
+
export function withServerContextValue<T>(
|
|
29
|
+
context: ServerContext,
|
|
30
|
+
key: ContextKey<T>,
|
|
31
|
+
value: T,
|
|
32
|
+
): ServerContext {
|
|
33
|
+
const derived: StoredServerContext = {
|
|
34
|
+
signal: context.signal,
|
|
35
|
+
[contextParent]: context,
|
|
36
|
+
[contextKey]: key as ContextKey<unknown>,
|
|
37
|
+
[contextStoredValue]: value,
|
|
38
|
+
}
|
|
39
|
+
return derived
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// serverContextValue retrieves the nearest value for a typed identity key.
|
|
43
|
+
export function serverContextValue<T>(
|
|
44
|
+
context: ServerContext,
|
|
45
|
+
key: ContextKey<T>,
|
|
46
|
+
): T | undefined {
|
|
47
|
+
let current: StoredServerContext | undefined = context as StoredServerContext
|
|
48
|
+
while (current) {
|
|
49
|
+
if (current[contextKey] === key) {
|
|
50
|
+
return current[contextStoredValue] as T
|
|
51
|
+
}
|
|
52
|
+
current = current[contextParent] as StoredServerContext | undefined
|
|
53
|
+
}
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
package/srpc/server-rpc.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { CallData, CallStart } from './rpcproto.pb.js'
|
|
|
4
4
|
import { CommonRPC } from './common-rpc.js'
|
|
5
5
|
import { InvokeFn } from './handler.js'
|
|
6
6
|
import { LookupMethod } from './mux.js'
|
|
7
|
+
import type { ServerContext } from './server-context.js'
|
|
7
8
|
|
|
8
9
|
// ServerRPC is an ongoing RPC from the server side.
|
|
9
10
|
export class ServerRPC extends CommonRPC {
|
|
@@ -47,7 +48,9 @@ export class ServerRPC extends CommonRPC {
|
|
|
47
48
|
private async invokeRPC(invokeFn: InvokeFn) {
|
|
48
49
|
const dataSink = this._createDataSink()
|
|
49
50
|
try {
|
|
50
|
-
await invokeFn(this.rpcDataSource, dataSink,
|
|
51
|
+
await invokeFn(this.rpcDataSource, dataSink, {
|
|
52
|
+
signal: this.invocationSignal,
|
|
53
|
+
} satisfies ServerContext)
|
|
51
54
|
} catch (err) {
|
|
52
55
|
this.close(err as Error)
|
|
53
56
|
}
|