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.
Files changed (93) hide show
  1. package/cmd/protoc-gen-es-starpc/typescript.ts +37 -0
  2. package/dist/cmd/protoc-gen-es-starpc/typescript.js +24 -0
  3. package/dist/echo/client-test.d.ts +1 -1
  4. package/dist/echo/client-test.js +110 -2
  5. package/dist/echo/echo_srpc.pb.d.ts +44 -1
  6. package/dist/echo/server.d.ts +9 -8
  7. package/dist/echo/server.js +6 -6
  8. package/dist/integration/cross-language/tcp-packet-stream.d.ts +3 -0
  9. package/dist/integration/cross-language/tcp-packet-stream.js +112 -0
  10. package/dist/integration/cross-language/tcp-packet-stream.test.d.ts +1 -0
  11. package/dist/integration/cross-language/tcp-packet-stream.test.js +121 -0
  12. package/dist/integration/cross-language/ts-client.js +50 -37
  13. package/dist/integration/cross-language/ts-server.js +1 -36
  14. package/dist/mock/mock_srpc.pb.d.ts +14 -1
  15. package/dist/rpcstream/rpcstream.d.ts +5 -1
  16. package/dist/rpcstream/rpcstream.js +75 -28
  17. package/dist/rpcstream/rpcstream.test.d.ts +1 -0
  18. package/dist/rpcstream/rpcstream.test.js +92 -0
  19. package/dist/srpc/channel.js +6 -3
  20. package/dist/srpc/channel.test.js +20 -1
  21. package/dist/srpc/client.js +18 -4
  22. package/dist/srpc/common-rpc.test.js +2 -0
  23. package/dist/srpc/handler.d.ts +12 -3
  24. package/dist/srpc/index.d.ts +2 -0
  25. package/dist/srpc/index.js +1 -0
  26. package/dist/srpc/invoker.d.ts +2 -1
  27. package/dist/srpc/invoker.js +2 -2
  28. package/dist/srpc/packet-codec.test.d.ts +1 -0
  29. package/dist/srpc/packet-codec.test.js +75 -0
  30. package/dist/srpc/packet.d.ts +1 -1
  31. package/dist/srpc/packet.js +11 -1
  32. package/dist/srpc/server-context.d.ts +11 -0
  33. package/dist/srpc/server-context.js +28 -0
  34. package/dist/srpc/server-rpc.js +3 -1
  35. package/dist/srpc/server.js +19 -6
  36. package/dist/srpc/server.test.js +78 -6
  37. package/dist/srpc/stream.d.ts +4 -1
  38. package/dist/srpc/stream.js +62 -3
  39. package/dist/srpc/stream.test.js +110 -1
  40. package/dist/srpc/termination.d.ts +27 -0
  41. package/dist/srpc/termination.js +56 -0
  42. package/dist/srpc/termination.test.d.ts +1 -0
  43. package/dist/srpc/termination.test.js +24 -0
  44. package/dist/srpc/watchdog.test.js +1 -0
  45. package/dist/testdata/packet-codec-vectors.json +64 -0
  46. package/echo/client-test.ts +124 -2
  47. package/echo/echo_pb2.py +40 -0
  48. package/echo/echo_pb2.pyi +13 -0
  49. package/echo/echo_srpc.pb.ts +74 -0
  50. package/echo/echo_srpc.py +306 -0
  51. package/echo/echo_srpc.pyi +85 -0
  52. package/echo/server.ts +24 -5
  53. package/go.mod +2 -2
  54. package/go.sum +14 -0
  55. package/integration/cross-language/go-client/main.go +79 -3
  56. package/integration/cross-language/python-client.py +146 -0
  57. package/integration/cross-language/python-server.py +140 -0
  58. package/integration/cross-language/run.bash +190 -65
  59. package/integration/cross-language/tcp-packet-stream.test.ts +154 -0
  60. package/integration/cross-language/tcp-packet-stream.ts +121 -0
  61. package/integration/cross-language/ts-client.ts +62 -40
  62. package/integration/cross-language/ts-server.ts +1 -45
  63. package/mock/mock_pb2.py +38 -0
  64. package/mock/mock_pb2.pyi +11 -0
  65. package/mock/mock_srpc.pb.ts +19 -1
  66. package/mock/mock_srpc.py +71 -0
  67. package/mock/mock_srpc.pyi +27 -0
  68. package/package.json +20 -6
  69. package/srpc/__init__.py +0 -0
  70. package/srpc/channel.test.ts +21 -1
  71. package/srpc/channel.ts +7 -3
  72. package/srpc/client.ts +20 -4
  73. package/srpc/codec.rs +6 -0
  74. package/srpc/common-rpc.test.ts +2 -0
  75. package/srpc/handler.ts +54 -4
  76. package/srpc/index.ts +7 -0
  77. package/srpc/invoker.ts +23 -6
  78. package/srpc/packet-codec-vectors_test.go +195 -0
  79. package/srpc/packet-codec.test.ts +139 -0
  80. package/srpc/packet-rw.go +9 -2
  81. package/srpc/packet.ts +15 -2
  82. package/srpc/py.typed +0 -0
  83. package/srpc/rpcproto_pb2.py +40 -0
  84. package/srpc/rpcproto_pb2.pyi +40 -0
  85. package/srpc/server-context.ts +55 -0
  86. package/srpc/server-rpc.ts +4 -1
  87. package/srpc/server.test.ts +100 -5
  88. package/srpc/server.ts +22 -6
  89. package/srpc/stream.test.ts +132 -1
  90. package/srpc/stream.ts +65 -9
  91. package/srpc/termination.test.ts +30 -0
  92. package/srpc/termination.ts +70 -0
  93. package/srpc/watchdog.test.ts +1 -0
@@ -1,59 +1,71 @@
1
1
  import net from 'net'
2
- import { pipe } from 'it-pipe'
3
2
  import { pushable } from 'it-pushable'
4
3
  import { Client } from '../../srpc/client.js'
5
4
  import {
6
- parseLengthPrefixTransform,
7
- prependLengthPrefixTransform,
8
- } from '../../srpc/packet.js'
9
- import { combineUint8ArrayListTransform } from '../../srpc/array-list.js'
10
- import { runClientTest } from '../../echo/index.js'
5
+ runClientTest,
6
+ runAbortControllerTest,
7
+ runRpcStreamTest,
8
+ } from '../../echo/client-test.js'
9
+ import { EchoerClient } from '../../echo/echo_srpc.pb.js'
11
10
  import type { OpenStreamFunc, PacketStream } from '../../srpc/stream.js'
12
- import type { Source } from 'it-stream-types'
11
+ import { tcpSocketToPacketStream } from './tcp-packet-stream.js'
13
12
 
14
- // tcpSocketToPacketStream wraps a Node.js TCP socket into a PacketStream.
15
- function tcpSocketToPacketStream(socket: net.Socket): PacketStream {
16
- const socketSource = async function* (): AsyncGenerator<Uint8Array> {
17
- const source = pushable<Uint8Array>({ objectMode: true })
18
- socket.on('data', (data: Buffer) => {
19
- source.push(new Uint8Array(data))
20
- })
21
- socket.on('end', () => source.end())
22
- socket.on('error', (err) => source.end(err))
23
- socket.on('close', () => source.end())
24
- yield* pipe(
25
- source,
26
- parseLengthPrefixTransform(),
27
- combineUint8ArrayListTransform(),
28
- )
13
+ async function runEchoBidiStreamTest(client: Client): Promise<void> {
14
+ const request = pushable<{ body: string }>({ objectMode: true })
15
+ const stream = new EchoerClient(client).EchoBidiStream(request)
16
+ const iterator = stream[Symbol.asyncIterator]()
17
+
18
+ const initial = await iterator.next()
19
+ if (initial.done || initial.value.body !== 'hello from server') {
20
+ throw new Error('expected initial bidi message "hello from server"')
21
+ }
22
+
23
+ const body = 'hello from TypeScript bidi client'
24
+ request.push({ body })
25
+ request.end()
26
+
27
+ const echo = await iterator.next()
28
+ if (echo.done || echo.value.body !== body) {
29
+ throw new Error(`expected bidi echo ${JSON.stringify(body)}`)
29
30
  }
30
31
 
31
- return {
32
- source: socketSource(),
33
- sink: async (source: Source<Uint8Array>): Promise<void> => {
34
- for await (const chunk of pipe(source, prependLengthPrefixTransform())) {
35
- const data = chunk instanceof Uint8Array ? chunk : chunk.subarray()
36
- await new Promise<void>((resolve, reject) => {
37
- socket.write(data, (err) => {
38
- if (err) reject(err)
39
- else resolve()
40
- })
41
- })
42
- }
43
- socket.end()
44
- },
32
+ const terminal = await iterator.next()
33
+ if (!terminal.done) {
34
+ throw new Error('expected bidi stream to terminate after input closes')
45
35
  }
46
36
  }
47
37
 
38
+ function parseAddr(addr: string): { host: string; port: number } {
39
+ const match = /^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/.exec(addr)
40
+ if (!match) {
41
+ throw new Error(`invalid host:port address: ${addr}`)
42
+ }
43
+
44
+ const port = Number(match[3])
45
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
46
+ throw new Error(`invalid port: ${match[3]}`)
47
+ }
48
+
49
+ return { host: match[1] ?? match[2], port }
50
+ }
51
+
48
52
  async function main() {
49
- const addr = process.argv[2]
53
+ const args = process.argv.slice(2)
54
+ const lifecycle = args.includes('lifecycle')
55
+ const nested = args.includes('--nested') || args.includes('--nested-release')
56
+ const nestedRelease = args.includes('--nested-release')
57
+ const addr = args.find(
58
+ (arg) =>
59
+ arg !== 'lifecycle' && arg !== '--nested' && arg !== '--nested-release',
60
+ )
50
61
  if (!addr) {
51
- console.error('usage: ts-client <host:port>')
62
+ console.error(
63
+ 'usage: ts-client [--nested] [--nested-release] [lifecycle] <host:port>',
64
+ )
52
65
  process.exit(1)
53
66
  }
54
67
 
55
- const [host, portStr] = addr.split(':')
56
- const port = parseInt(portStr, 10)
68
+ const { host, port } = parseAddr(addr)
57
69
  const openStream: OpenStreamFunc = async (): Promise<PacketStream> => {
58
70
  const { promise, resolve, reject } = Promise.withResolvers<PacketStream>()
59
71
  const socket = net.connect(port, host, () => {
@@ -66,6 +78,16 @@ async function main() {
66
78
  const client = new Client(openStream)
67
79
  console.log('Running client test via TCP...')
68
80
  await runClientTest(client)
81
+ console.log('Running EchoBidiStream test via TCP...')
82
+ await runEchoBidiStreamTest(client)
83
+ if (lifecycle) {
84
+ console.log('Running abort controller test via TCP...')
85
+ await runAbortControllerTest(client)
86
+ }
87
+ if (nested) {
88
+ console.log('Running RpcStream test via TCP...')
89
+ await runRpcStreamTest(client, nestedRelease)
90
+ }
69
91
  console.log('All tests passed.')
70
92
  }
71
93
 
@@ -1,53 +1,9 @@
1
1
  import net from 'net'
2
- import { pipe } from 'it-pipe'
3
- import { pushable } from 'it-pushable'
4
- import type { Source } from 'it-stream-types'
5
2
 
6
3
  import { createMux, createHandler, Server } from '../../srpc/index.js'
7
- import {
8
- parseLengthPrefixTransform,
9
- prependLengthPrefixTransform,
10
- } from '../../srpc/packet.js'
11
- import { combineUint8ArrayListTransform } from '../../srpc/array-list.js'
12
4
  import { EchoerServer } from '../../echo/index.js'
13
5
  import { EchoerDefinition } from '../../echo/echo_srpc.pb.js'
14
- import type { PacketStream } from '../../srpc/stream.js'
15
-
16
- // tcpSocketToPacketStream wraps a Node.js TCP socket into a PacketStream.
17
- // Each Uint8Array in source/sink is one packet (no length prefix).
18
- function tcpSocketToPacketStream(socket: net.Socket): PacketStream {
19
- // Source: read from socket, strip length prefix, yield individual packets.
20
- const socketSource = async function* (): AsyncGenerator<Uint8Array> {
21
- const source = pushable<Uint8Array>({ objectMode: true })
22
- socket.on('data', (data: Buffer) => {
23
- source.push(new Uint8Array(data))
24
- })
25
- socket.on('end', () => source.end())
26
- socket.on('error', (err) => source.end(err))
27
- socket.on('close', () => source.end())
28
- yield* pipe(
29
- source,
30
- parseLengthPrefixTransform(),
31
- combineUint8ArrayListTransform(),
32
- )
33
- }
34
-
35
- return {
36
- source: socketSource(),
37
- sink: async (source: Source<Uint8Array>): Promise<void> => {
38
- for await (const chunk of pipe(source, prependLengthPrefixTransform())) {
39
- const data = chunk instanceof Uint8Array ? chunk : chunk.subarray()
40
- await new Promise<void>((resolve, reject) => {
41
- socket.write(data, (err) => {
42
- if (err) reject(err)
43
- else resolve()
44
- })
45
- })
46
- }
47
- socket.end()
48
- },
49
- }
50
- }
6
+ import { tcpSocketToPacketStream } from './tcp-packet-stream.js'
51
7
 
52
8
  const mux = createMux()
53
9
  const server = new Server(mux.lookupMethod)
@@ -0,0 +1,38 @@
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/mock/mock.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/mock/mock.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'\n2github.com/aperturerobotics/starpc/mock/mock.proto\x12\x08\x65\x32\x65.mock\"\x17\n\x07MockMsg\x12\x0c\n\x04\x62ody\x18\x01 \x01(\t2;\n\x04Mock\x12\x33\n\x0bMockRequest\x12\x11.e2e.mock.MockMsg\x1a\x11.e2e.mock.MockMsgb\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'github.com.aperturerobotics.starpc.mock.mock_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ DESCRIPTOR._loaded_options = None
34
+ _globals['_MOCKMSG']._serialized_start=64
35
+ _globals['_MOCKMSG']._serialized_end=87
36
+ _globals['_MOCK']._serialized_start=89
37
+ _globals['_MOCK']._serialized_end=148
38
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,11 @@
1
+ from google.protobuf import descriptor as _descriptor
2
+ from google.protobuf import message as _message
3
+ from typing import ClassVar as _ClassVar, Optional as _Optional
4
+
5
+ DESCRIPTOR: _descriptor.FileDescriptor
6
+
7
+ class MockMsg(_message.Message):
8
+ __slots__ = ("body",)
9
+ BODY_FIELD_NUMBER: _ClassVar[int]
10
+ body: str
11
+ def __init__(self, body: _Optional[str] = ...) -> None: ...
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { MockMsg } from './mock.pb.js'
6
6
  import { MethodKind } from '@aptre/protobuf-es-lite'
7
- import { ProtoRpc } from 'starpc'
7
+ import { ProtoRpc, ServerContext } from 'starpc'
8
8
 
9
9
  /**
10
10
  * Mock service mocks some RPCs for the e2e tests.
@@ -42,6 +42,24 @@ export interface Mock {
42
42
  MockRequest(request: MockMsg, abortSignal?: AbortSignal): Promise<MockMsg>
43
43
  }
44
44
 
45
+ /**
46
+ * Mock service mocks some RPCs for the e2e tests.
47
+ *
48
+ * @generated from service e2e.mock.Mock
49
+ */
50
+ export interface MockHandler {
51
+ /**
52
+ * MockRequest runs a mock unary request.
53
+ *
54
+ * @generated from rpc e2e.mock.Mock.MockRequest
55
+ */
56
+ MockRequest(
57
+ request: MockMsg,
58
+ abortSignal: AbortSignal,
59
+ context: ServerContext,
60
+ ): Promise<MockMsg>
61
+ }
62
+
45
63
  export const MockServiceName = MockDefinition.typeName
46
64
 
47
65
  export class MockClient implements Mock {
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from mock import (
6
+ mock_pb2 as _github_com_aperturerobotics_starpc_mock_mock_pb2,
7
+ )
8
+ from starpc.call import Call, CallProtocolError
9
+ from starpc.client import Client
10
+ from starpc.server import ServiceRegistry
11
+ from starpc.service import MethodDescriptor, ServiceDescriptor
12
+
13
+ MOCK_SERVICE = ServiceDescriptor(
14
+ "e2e.mock.Mock",
15
+ (
16
+ MethodDescriptor(
17
+ "MockRequest",
18
+ _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg,
19
+ _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg,
20
+ False,
21
+ False,
22
+ ),
23
+ ),
24
+ )
25
+
26
+
27
+ class MockClient:
28
+ def __init__(self, client: Client, service: str | None = None) -> None:
29
+ self._client = client
30
+ self._service = service or "e2e.mock.Mock"
31
+
32
+ async def mock_request(
33
+ self, request: _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg
34
+ ) -> _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg:
35
+ call = await self._client.open_call(
36
+ self._service, "MockRequest", request.SerializeToString(deterministic=True)
37
+ )
38
+ try:
39
+ data = await call.receive()
40
+ if data is None:
41
+ raise CallProtocolError("missing unary response")
42
+ response = _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg()
43
+ response.ParseFromString(data)
44
+ if await call.receive() is not None:
45
+ raise CallProtocolError("extra unary response")
46
+ return response
47
+ finally:
48
+ await call.aclose()
49
+
50
+
51
+ class MockServer(Protocol):
52
+ async def mock_request(
53
+ self, request: _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg
54
+ ) -> _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg: ...
55
+
56
+
57
+ def register_mock(
58
+ registry: ServiceRegistry,
59
+ implementation: MockServer,
60
+ service: str = "e2e.mock.Mock",
61
+ ) -> None:
62
+ async def mock_request_handler(call: Call) -> None:
63
+ first = await call.receive()
64
+ if first is None:
65
+ raise CallProtocolError("missing initial request")
66
+ request = _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg()
67
+ request.ParseFromString(first)
68
+ response = await implementation.mock_request(request)
69
+ await call.send(response.SerializeToString(deterministic=True))
70
+
71
+ registry.register(service, "MockRequest", mock_request_handler)
@@ -0,0 +1,27 @@
1
+ from typing import Protocol
2
+
3
+ from mock import (
4
+ mock_pb2 as _github_com_aperturerobotics_starpc_mock_mock_pb2,
5
+ )
6
+ from starpc.client import Client
7
+ from starpc.server import ServiceRegistry
8
+ from starpc.service import ServiceDescriptor
9
+
10
+ MOCK_SERVICE: ServiceDescriptor
11
+
12
+ class MockClient:
13
+ def __init__(self, client: Client, service: str | None = None) -> None: ...
14
+ async def mock_request(
15
+ self, request: _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg
16
+ ) -> _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg: ...
17
+
18
+ class MockServer(Protocol):
19
+ async def mock_request(
20
+ self, request: _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg
21
+ ) -> _github_com_aperturerobotics_starpc_mock_mock_pb2.MockMsg: ...
22
+
23
+ def register_mock(
24
+ registry: ServiceRegistry,
25
+ implementation: MockServer,
26
+ service: str = "e2e.mock.Mock",
27
+ ) -> None: ...
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starpc",
3
- "version": "0.51.0",
3
+ "version": "0.52.1",
4
4
  "description": "Streaming protobuf RPC service protocol over any two-way channel.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -68,12 +68,12 @@
68
68
  "deps": "depcheck --ignores 'bufferutil,utf-8-validate,rimraf,starpc,@aptre/protobuf-es-lite,tsx'",
69
69
  "codegen": "bun run gen",
70
70
  "ci": "bun run build && bun run lint:js && bun run lint:go",
71
- "format": "bun run format:js && bun run format:go && bun run format:config",
71
+ "format": "bun run format:js && bun run format:go && bun run format:python && bun run format:config",
72
72
  "format:config": "oxfmt '*.json' '.github/**/*.json' '.github/**/*.yml' '.oxfmtrc.json' 'tsconfig.json'",
73
73
  "format:go": "bun run go:aptre -- format",
74
74
  "format:js": "oxfmt './{srpc,echo,e2e,integration,rpcstream,cmd,mock,scripts}/**/*.{ts,tsx,js,mjs,html,css,scss}' '*.{ts,tsx,js,mjs}'",
75
- "gen": "bun run go:aptre -- generate && bun run format",
76
- "gen:force": "bun run go:aptre -- generate --force && bun run format",
75
+ "gen": "bun run build && bun run go:aptre -- generate && bun run format",
76
+ "gen:force": "bun run build && bun run go:aptre -- generate --force && bun run format",
77
77
  "test": "bun run test:js && bun run test:go",
78
78
  "test:go": "bun run go:aptre -- test",
79
79
  "test:cpp": "mkdir -p build && cd build && cmake -G Ninja .. -DCMAKE_BUILD_TYPE=Release && cmake --build . --parallel && ctest --output-on-failure",
@@ -100,7 +100,8 @@
100
100
  "release:version": "bun scripts/release-version.ts patch",
101
101
  "release:version:minor": "bun scripts/release-version.ts minor",
102
102
  "release:commit": "version=$(bun -e 'console.log(require(\"./package.json\").version)') && git reset && git add package.json Cargo.toml && git commit -s -m \"release: v$version\" && git tag \"v$version\"",
103
- "release:publish": "git push && git push --tags"
103
+ "release:publish": "git push && git push --tags",
104
+ "format:python": "files=$(find echo mock -type f \\( -name '*_srpc.py' -o -name '*_srpc.pyi' \\)); if [ -n \"$files\" ]; then uv run ruff check --fix --ignore UP026 $files && uv run ruff format $files; fi"
104
105
  },
105
106
  "preferUnplugged": true,
106
107
  "lint-staged": {
@@ -114,7 +115,7 @@
114
115
  "happy-dom": "^20.9.0",
115
116
  "husky": "^9.1.7",
116
117
  "lint-staged": "^17.0.0",
117
- "oxfmt": "0.61.0",
118
+ "oxfmt": "0.62.0",
118
119
  "oxlint": "^1.76.0",
119
120
  "rimraf": "^6.1.3",
120
121
  "tsx": "^4.20.4",
@@ -138,5 +139,18 @@
138
139
  },
139
140
  "overrides": {
140
141
  "@aptre/protobuf-es-lite": "1.1.1"
142
+ },
143
+ "aptre": {
144
+ "languages": [
145
+ "go",
146
+ "ts",
147
+ "cpp",
148
+ "rust",
149
+ "python"
150
+ ],
151
+ "rpc": [
152
+ "starpc",
153
+ "starpc-python"
154
+ ]
141
155
  }
142
156
  }
File without changes
@@ -1,9 +1,29 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vitest'
2
2
  import { pushable } from 'it-pushable'
3
3
 
4
4
  import { ChannelStream } from './channel.js'
5
5
 
6
6
  describe('ChannelStream', () => {
7
+ it('recognizes MessagePort implementations from another realm', () => {
8
+ const port = {
9
+ close: vi.fn(),
10
+ onmessage: null,
11
+ postMessage: vi.fn(),
12
+ start: vi.fn(),
13
+ } as unknown as MessagePort
14
+
15
+ const stream = new ChannelStream<Uint8Array>('client', port)
16
+ try {
17
+ expect(port.start).toHaveBeenCalledOnce()
18
+ expect(port.postMessage).toHaveBeenCalledWith({
19
+ ack: true,
20
+ from: 'client',
21
+ })
22
+ } finally {
23
+ stream.close()
24
+ }
25
+ })
26
+
7
27
  it('keeps MessagePort peer writes open after local source completes normally', async () => {
8
28
  const { port1, port2 } = new MessageChannel()
9
29
  const client = new ChannelStream<Uint8Array>('client', port1)
package/srpc/channel.ts CHANGED
@@ -26,6 +26,10 @@ export type ChannelPort =
26
26
  | MessagePort
27
27
  | { tx: BroadcastChannel; rx: BroadcastChannel }
28
28
 
29
+ function isMessagePort(channel: ChannelPort): channel is MessagePort {
30
+ return 'postMessage' in channel && 'start' in channel
31
+ }
32
+
29
33
  // ChannelStreamOpts are options for ChannelStream.
30
34
  export interface ChannelStreamOpts {
31
35
  // remoteOpen indicates that the remote already knows the channel is open.
@@ -149,7 +153,7 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
149
153
 
150
154
  // wire up the message handlers
151
155
  const onMessage = this.onMessage.bind(this)
152
- if (channel instanceof MessagePort) {
156
+ if (isMessagePort(channel)) {
153
157
  // MessagePort
154
158
  channel.onmessage = onMessage
155
159
  channel.start()
@@ -180,7 +184,7 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
180
184
  return
181
185
  }
182
186
  msg.from = this.localId
183
- if (this.channel instanceof MessagePort) {
187
+ if (isMessagePort(this.channel)) {
184
188
  this.channel.postMessage(msg)
185
189
  } else {
186
190
  this.channel.tx.postMessage(msg)
@@ -222,7 +226,7 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
222
226
  this.localWriteClosed = true
223
227
  this.remoteWriteClosed = true
224
228
  // close channels
225
- if (this.channel instanceof MessagePort) {
229
+ if (isMessagePort(this.channel)) {
226
230
  this.channel.onmessage = null
227
231
  this.channel.close()
228
232
  } else {
package/srpc/client.ts CHANGED
@@ -117,13 +117,29 @@ export class Client implements ProtoRpc {
117
117
  const stream = await openStreamFn()
118
118
  const call = new ClientRPC(rpcService, rpcMethod)
119
119
  const onAbort = () => {
120
+ if (call.isClosed) return
121
+ const error = new Error(ERR_RPC_ABORT)
120
122
  void call.writeCallCancel().catch(() => undefined)
121
- void call.close(new Error(ERR_RPC_ABORT)).catch(() => undefined)
123
+ stream.abort(error)
124
+ void call.close(error).catch(() => undefined)
122
125
  }
123
126
  abortSignal?.addEventListener('abort', onAbort, { once: true })
124
- pipe(stream, decodePacketSource, call, encodePacketSource, stream)
125
- .catch((err) => call.close(err))
126
- .then(() => call.close())
127
+ void pipe(stream, decodePacketSource, call, encodePacketSource, stream)
128
+ .then(
129
+ async () => {
130
+ if (call.isClosed instanceof Error) {
131
+ stream.abort(call.isClosed)
132
+ return
133
+ }
134
+ await stream.close()
135
+ await call.close()
136
+ },
137
+ async (err: unknown) => {
138
+ const error = err instanceof Error ? err : new Error(String(err))
139
+ stream.abort(error)
140
+ await call.close(error)
141
+ },
142
+ )
127
143
  .finally(() => {
128
144
  abortSignal?.removeEventListener('abort', onAbort)
129
145
  })
package/srpc/codec.rs CHANGED
@@ -76,6 +76,9 @@ impl Encoder<Packet> for PacketCodec {
76
76
  let msg_size = item.encoded_len();
77
77
 
78
78
  // Validate message size.
79
+ if msg_size == 0 {
80
+ return Err(Error::MessageSizeZero);
81
+ }
79
82
  if msg_size > MAX_MESSAGE_SIZE {
80
83
  return Err(Error::MessageTooLarge(msg_size, MAX_MESSAGE_SIZE));
81
84
  }
@@ -96,6 +99,9 @@ impl Encoder<Packet> for PacketCodec {
96
99
  /// Encode a packet to bytes with length prefix.
97
100
  pub fn encode_packet(packet: &Packet) -> Result<Vec<u8>> {
98
101
  let msg_size = packet.encoded_len();
102
+ if msg_size == 0 {
103
+ return Err(Error::MessageSizeZero);
104
+ }
99
105
  if msg_size > MAX_MESSAGE_SIZE {
100
106
  return Err(Error::MessageTooLarge(msg_size, MAX_MESSAGE_SIZE));
101
107
  }
@@ -152,6 +152,8 @@ describe('CommonRPC', () => {
152
152
  const responseGate = deferred()
153
153
  const response = new Uint8Array([7])
154
154
  const client = new Client(async () => ({
155
+ close: async () => {},
156
+ abort: () => {},
155
157
  source: (async function* () {
156
158
  await responseGate.promise
157
159
  yield Packet.toBinary({
package/srpc/handler.ts CHANGED
@@ -1,12 +1,58 @@
1
1
  import type { Sink, Source } from 'it-stream-types'
2
- import { ServiceDefinition, ServiceMethodDefinitions } from './definition.js'
2
+ import { MethodKind, type MessageType } from '@aptre/protobuf-es-lite'
3
+ import type { MessageStream } from './message.js'
4
+ import {
5
+ type MethodDefinition,
6
+ ServiceDefinition,
7
+ ServiceMethodDefinitions,
8
+ } from './definition.js'
3
9
  import { createInvokeFn } from './invoker.js'
10
+ import type { ServerContext } from './server-context.js'
11
+
12
+ type MessageOf<T> = T extends MessageType<infer M> ? M : never
13
+
14
+ type ServerMethod<T> =
15
+ T extends MethodDefinition<
16
+ infer Request,
17
+ infer Response,
18
+ infer Kind,
19
+ infer _Idempotency
20
+ >
21
+ ? Kind extends MethodKind.Unary
22
+ ? (
23
+ request: MessageOf<Request>,
24
+ abortSignal: AbortSignal,
25
+ context: ServerContext,
26
+ ) => Promise<MessageOf<Response>>
27
+ : Kind extends MethodKind.ServerStreaming
28
+ ? (
29
+ request: MessageOf<Request>,
30
+ abortSignal: AbortSignal,
31
+ context: ServerContext,
32
+ ) => MessageStream<MessageOf<Response>>
33
+ : Kind extends MethodKind.ClientStreaming
34
+ ? (
35
+ request: MessageStream<MessageOf<Request>>,
36
+ abortSignal: AbortSignal,
37
+ context: ServerContext,
38
+ ) => Promise<MessageOf<Response>>
39
+ : (
40
+ request: MessageStream<MessageOf<Request>>,
41
+ abortSignal: AbortSignal,
42
+ context: ServerContext,
43
+ ) => MessageStream<MessageOf<Response>>
44
+ : never
45
+
46
+ export type HandlerImplementation<T extends ServiceMethodDefinitions> =
47
+ Partial<{
48
+ [Method in keyof T]: ServerMethod<T[Method]>
49
+ }>
4
50
 
5
51
  // InvokeFn describes an SRPC call method invoke function.
6
52
  export type InvokeFn = (
7
53
  dataSource: Source<Uint8Array>,
8
54
  dataSink: Sink<Source<Uint8Array>>,
9
- invocation?: AbortSignal,
55
+ context: ServerContext,
10
56
  ) => Promise<void>
11
57
 
12
58
  // Handler describes a SRPC call handler implementation.
@@ -62,7 +108,11 @@ export class StaticHandler implements Handler {
62
108
  // if serviceID is not set, uses the fullName of the service as the identifier.
63
109
  export function createHandler<
64
110
  T extends ServiceMethodDefinitions = ServiceMethodDefinitions,
65
- >(definition: ServiceDefinition<T>, impl: any, serviceID?: string): Handler {
111
+ >(
112
+ definition: ServiceDefinition<T>,
113
+ impl: HandlerImplementation<T>,
114
+ serviceID?: string,
115
+ ): Handler {
66
116
  // serviceID defaults to the full name of the service from Protobuf.
67
117
  serviceID = serviceID || definition.typeName
68
118
 
@@ -70,7 +120,7 @@ export function createHandler<
70
120
  const methodMap: MethodMap = {}
71
121
  for (const methodInfo of Object.values(definition.methods)) {
72
122
  const methodName = methodInfo.name
73
- let methodProto = impl[methodName]
123
+ let methodProto = impl[methodName as keyof T] as any
74
124
  if (!methodProto) {
75
125
  continue
76
126
  }
package/srpc/index.ts CHANGED
@@ -83,3 +83,10 @@ export {
83
83
  } from './pushable.js'
84
84
  export { Watchdog } from './watchdog.js'
85
85
  export type { ProtoRpc } from './proto-rpc.js'
86
+
87
+ export {
88
+ createContextKey,
89
+ serverContextValue,
90
+ withServerContextValue,
91
+ } from './server-context.js'
92
+ export type { ContextKey, ServerContext } from './server-context.js'