├── .eslintrc.cjs ├── .github ├── renovate.json └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ └── tests.yml ├── .gitignore ├── .golangci.yml ├── .husky └── pre-commit ├── .ignore ├── .prettierrc.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── protoc-gen-es-starpc │ ├── dev │ │ └── protoc-gen-es-starpc │ ├── plugin.ts │ ├── protoc-gen-es-starpc │ ├── protoc-gen-es-starpc.ts │ └── typescript.ts └── protoc-gen-go-starpc │ ├── .gitignore │ ├── LICENSE.drpc │ └── main.go ├── echo ├── EchoerClientImpl.ts ├── client-test.ts ├── echo.go ├── echo.pb.go ├── echo.pb.ts ├── echo.proto ├── echo_srpc.pb.go ├── echo_srpc.pb.ts ├── index.ts ├── server.go └── server.ts ├── go.mod ├── go.sum ├── index.ts ├── integration ├── .gitignore ├── integration.bash ├── integration.go ├── integration.ts └── tsconfig.json ├── mock ├── mock.go ├── mock.pb.go ├── mock.pb.ts ├── mock.proto ├── mock_srpc.pb.go └── mock_srpc.pb.ts ├── package.json ├── rpcstream ├── README.md ├── errors.go ├── index.ts ├── proxy.go ├── pushable-sink.ts ├── raw-stream.go ├── read-writer.go ├── rpcstream.go ├── rpcstream.pb.go ├── rpcstream.pb.ts ├── rpcstream.proto ├── rpcstream.ts └── writer.go ├── srpc ├── accept.go ├── array-list.ts ├── broadcast-channel.ts ├── channel.ts ├── client-prefix.go ├── client-rpc.go ├── client-rpc.ts ├── client-set.go ├── client-verbose.go ├── client.go ├── client.ts ├── common-rpc.go ├── common-rpc.ts ├── conn.ts ├── definition.ts ├── errors.go ├── errors.ts ├── handle-stream-ctr.ts ├── handler.go ├── handler.ts ├── index.ts ├── invoker-prefix.go ├── invoker.go ├── invoker.ts ├── length-prefix.ts ├── log.ts ├── message-port.ts ├── message.go ├── message.ts ├── message_test.go ├── msg-stream.go ├── mux-verbose.go ├── mux.go ├── mux.ts ├── muxed-conn.go ├── net.go ├── open-stream-ctr.ts ├── packet-rw.go ├── packet.go ├── packet.ts ├── proto-rpc.ts ├── pushable.ts ├── pushable_js.go ├── rpcproto.pb.go ├── rpcproto.pb.ts ├── rpcproto.proto ├── rwc-conn.go ├── server-http.go ├── server-http_js.go ├── server-pipe.go ├── server-rpc.go ├── server-rpc.ts ├── server.go ├── server.test.ts ├── server.ts ├── server_test.go ├── stream-pipe.go ├── stream-rwc.go ├── stream.go ├── stream.ts ├── strip-prefix.go ├── value-ctr.ts ├── watchdog.ts ├── websocket.go ├── websocket.ts └── writer.go ├── tools ├── .gitignore ├── Makefile ├── go.mod ├── go.sum └── tools.go ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'prettier', 9 | ], 10 | parserOptions: { 11 | project: './tsconfig.json', 12 | }, 13 | rules: { 14 | '@typescript-eslint/explicit-module-boundary-types': 'off', 15 | '@typescript-eslint/no-non-null-assertion': 'off', 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":semanticPrefixFixDepsChoreOthers", 5 | ":ignoreModulesAndTests", 6 | "group:all", 7 | "workarounds:all" 8 | ], 9 | "branchConcurrentLimit": 0, 10 | "packageRules": [ 11 | { 12 | "matchManagers": ["gomod"], 13 | "matchDepTypes": ["replace"], 14 | "enabled": false 15 | }, 16 | { 17 | "matchPackageNames": ["github.com/aperturerobotics/starpc"], 18 | "enabled": false 19 | }, 20 | { 21 | "matchManagers": ["gomod"], 22 | "matchPackageNames": ["github.com/libp2p/go-yamux/v4"], 23 | "matchUpdateTypes": ["major"], 24 | "enabled": false 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "master" ] 9 | schedule: 10 | - cron: '41 13 * * 6' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | analyze: 17 | name: Analyze 18 | runs-on: ubuntu-latest 19 | permissions: 20 | actions: read 21 | contents: read 22 | security-events: write 23 | 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | language: [ 'go', 'javascript' ] 28 | go: ['1.24'] 29 | node: [23.x] 30 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 31 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | 37 | - name: Setup Go ${{ matrix.go }} 38 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 39 | with: 40 | go-version: ${{ matrix.go }} 41 | 42 | - name: Cache Go modules 43 | uses: actions/cache/restore@v4 44 | with: 45 | path: | 46 | ~/.cache/go-build 47 | ~/go/pkg/mod 48 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 49 | restore-keys: | 50 | ${{ runner.os }}-go- 51 | 52 | - name: Setup Node.JS ${{ matrix.node }} 53 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 54 | with: 55 | node-version: ${{ matrix.node }} 56 | cache: 'yarn' 57 | 58 | - name: Initialize CodeQL 59 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 60 | with: 61 | languages: ${{ matrix.language }} 62 | 63 | 64 | - name: Autobuild 65 | uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 69 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 21 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "master" ] 9 | 10 | # Builds images for target boards. 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | tests: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | go: ['1.24'] 20 | node: [23.x] 21 | timeout-minutes: 10 22 | steps: 23 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | 25 | - name: Setup Go ${{ matrix.go }} 26 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 27 | with: 28 | go-version: ${{ matrix.go }} 29 | 30 | - name: Setup Node.JS ${{ matrix.node }} 31 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 32 | with: 33 | node-version: ${{ matrix.node }} 34 | cache: 'yarn' 35 | 36 | - name: Yarn install 37 | run: yarn install 38 | 39 | - name: Cache tools 40 | uses: actions/cache@v4 41 | with: 42 | path: | 43 | ./hack/bin 44 | key: ${{ runner.os }}-aptre-tools-${{ hashFiles('hack/go.sum') }} 45 | 46 | - name: Go mod vendor 47 | run: go mod vendor 48 | 49 | - name: Build Javascript 50 | run: yarn run build 51 | 52 | - name: Test Go 53 | run: make test 54 | 55 | - name: Test Js 56 | run: yarn test:js 57 | 58 | - name: Lint Js 59 | run: yarn run lint:js 60 | 61 | - name: Lint Go 62 | run: yarn run lint:go 63 | 64 | - name: Test integration 65 | run: yarn run integration 66 | 67 | - name: Depcheck Js 68 | run: yarn run deps 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /build 4 | /.log 5 | /.snowpack 6 | 7 | # misc 8 | .DS_Store 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | .#* 19 | /dist 20 | .*.swp 21 | .vs/ 22 | .vscode/ 23 | !.vscode/launch.json 24 | *.test 25 | 26 | vendor/ 27 | debug.test 28 | .aider* 29 | starpc-*.tgz 30 | .env 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # Config file version V2 has been introduced, read more in lintersdb. 2 | # Visit https://golangci-lint.run/usage/configuration/#config-file 3 | version: "2" 4 | 5 | # Settings for the `run` command. 6 | run: 7 | # Concurrency defines how many analyses can run simultaneously. 8 | # Default is 1. 9 | concurrency: 4 10 | # Modules-download-mode specifies how to download modules. 11 | # Allowed values: readonly, vendor, mod. Default is readonly. 12 | modules-download-mode: vendor 13 | 14 | # Linters configuration. 15 | linters: 16 | enable: 17 | - depguard 18 | - gosec 19 | - importas 20 | - misspell 21 | - revive 22 | - unconvert 23 | disable: 24 | - errcheck 25 | settings: 26 | depguard: 27 | rules: 28 | main: 29 | deny: 30 | - pkg: io/ioutil 31 | desc: The io/ioutil package has been deprecated, see https://go.dev/doc/go1.16#ioutil 32 | - pkg: "github.com/stretchr/testify/assert" 33 | desc: Use "gotest.tools/v3/assert" instead 34 | - pkg: "github.com/stretchr/testify/require" 35 | desc: Use "gotest.tools/v3/assert" instead 36 | - pkg: "github.com/stretchr/testify/suite" 37 | desc: Do not use 38 | gosec: 39 | excludes: 40 | - G306 # Allow WriteFile permissions to be 0644. 41 | importas: 42 | # Do not allow unaliased imports of aliased packages. 43 | no-unaliased: true 44 | revive: 45 | rules: 46 | - name: package-comments 47 | disabled: true 48 | staticcheck: 49 | # All SA checks are enabled by default, customize as needed. 50 | # Refer to https://staticcheck.io/docs/checks for check details. 51 | checks: 52 | - all 53 | - '-SA1012' # Allow passing nil contexts. 54 | - '-ST1003' # Example of disabling another check if needed 55 | 56 | # Exclusions based on common patterns and presets. 57 | exclusions: 58 | # Treat generated files leniently. 59 | generated: lax 60 | # Use predefined sets of common exclusions. 61 | presets: 62 | - comments 63 | - common-false-positives 64 | - legacy # Excludes checks deprecated in new Go versions 65 | - std-error-handling # Excludes some common stdlib error patterns 66 | # Exclude specific paths using regex. 67 | paths: 68 | - third_party$ 69 | - builtin$ 70 | - examples$ 71 | 72 | # Issues reporting configuration. 73 | issues: 74 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 75 | max-issues-per-linter: 0 76 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 77 | max-same-issues: 0 78 | 79 | # Formatters configuration (new in V2). 80 | formatters: 81 | enable: 82 | - goimports # Enable goimports as a formatter. 83 | exclusions: 84 | # Treat generated files leniently for formatting. 85 | generated: lax 86 | # Exclude specific paths from formatting using regex. 87 | paths: 88 | - third_party$ 89 | - builtin$ 90 | - examples$ 91 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run precommit 2 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.pb.go 3 | *.pb.ts 4 | go.sum 5 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | experimentalTernaries: true 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022-2025 Aperture Robotics, LLC. 2 | Copyright (c) 2022-2025 Christian Stewart 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # https://github.com/aperturerobotics/template 2 | 3 | SHELL:=bash 4 | PROTOWRAP=tools/bin/protowrap 5 | PROTOC_GEN_GO=tools/bin/protoc-gen-go-lite 6 | PROTOC_GEN_STARPC=tools/bin/protoc-gen-go-starpc 7 | GOIMPORTS=tools/bin/goimports 8 | GOFUMPT=tools/bin/gofumpt 9 | GOLANGCI_LINT=tools/bin/golangci-lint 10 | GO_MOD_OUTDATED=tools/bin/go-mod-outdated 11 | GOLIST=go list -f "{{ .Dir }}" -m 12 | 13 | export GO111MODULE=on 14 | undefine GOARCH 15 | undefine GOOS 16 | 17 | all: 18 | 19 | vendor: 20 | go mod vendor 21 | 22 | $(PROTOC_GEN_GO): 23 | cd ./tools; \ 24 | go build -v \ 25 | -o ./bin/protoc-gen-go-lite \ 26 | github.com/aperturerobotics/protobuf-go-lite/cmd/protoc-gen-go-lite 27 | 28 | $(GOIMPORTS): 29 | cd ./tools; \ 30 | go build -v \ 31 | -o ./bin/goimports \ 32 | golang.org/x/tools/cmd/goimports 33 | 34 | $(GOFUMPT): 35 | cd ./tools; \ 36 | go build -v \ 37 | -o ./bin/gofumpt \ 38 | mvdan.cc/gofumpt 39 | 40 | $(PROTOWRAP): 41 | cd ./tools; \ 42 | go build -v \ 43 | -o ./bin/protowrap \ 44 | github.com/aperturerobotics/goprotowrap/cmd/protowrap 45 | 46 | $(GOLANGCI_LINT): 47 | cd ./tools; \ 48 | go build -v \ 49 | -o ./bin/golangci-lint \ 50 | github.com/golangci/golangci-lint/v2/cmd/golangci-lint 51 | 52 | $(GO_MOD_OUTDATED): 53 | cd ./tools; \ 54 | go build -v \ 55 | -o ./bin/go-mod-outdated \ 56 | github.com/psampaz/go-mod-outdated 57 | 58 | $(PROTOC_GEN_STARPC): 59 | cd ./tools; \ 60 | go build -v \ 61 | -o ./bin/protoc-gen-go-starpc \ 62 | github.com/aperturerobotics/starpc/cmd/protoc-gen-go-starpc 63 | 64 | node_modules: 65 | yarn install 66 | 67 | .PHONY: genproto 68 | genproto: vendor node_modules $(GOIMPORTS) $(PROTOWRAP) $(PROTOC_GEN_GO) $(PROTOC_GEN_STARPC) 69 | shopt -s globstar; \ 70 | set -eo pipefail; \ 71 | export PROTOBUF_GO_TYPES_PKG=github.com/aperturerobotics/protobuf-go-lite/types; \ 72 | export PROJECT=$$(go list -m); \ 73 | export PATH=$$(pwd)/tools/bin:$${PATH}; \ 74 | export OUT=./vendor; \ 75 | mkdir -p $${OUT}/$$(dirname $${PROJECT}); \ 76 | rm ./vendor/$${PROJECT} || true; \ 77 | ln -s $$(pwd) ./vendor/$${PROJECT} ; \ 78 | protogen() { \ 79 | PROTO_FILES=$$(git ls-files "$$1"); \ 80 | $(PROTOWRAP) \ 81 | -I $${OUT} \ 82 | --plugin=./node_modules/.bin/protoc-gen-es-lite \ 83 | --plugin=./cmd/protoc-gen-es-starpc/dev/protoc-gen-es-starpc \ 84 | --go-lite_out=$${OUT} \ 85 | --go-lite_opt=features=marshal+unmarshal+size+equal+json+clone+text \ 86 | --es-lite_out=$${OUT} \ 87 | --es-lite_opt target=ts \ 88 | --es-lite_opt ts_nocheck=false \ 89 | --go-starpc_out=$${OUT} \ 90 | --es-starpc_out=$${OUT} \ 91 | --es-starpc_opt target=ts \ 92 | --es-starpc_opt ts_nocheck=false \ 93 | --proto_path $${OUT} \ 94 | --print_structure \ 95 | --only_specified_files \ 96 | $$(echo "$$PROTO_FILES" | xargs printf -- "./vendor/$${PROJECT}/%s "); \ 97 | for proto_file in $${PROTO_FILES}; do \ 98 | proto_dir=$$(dirname $$proto_file); \ 99 | proto_name=$${proto_file%".proto"}; \ 100 | TS_FILES=$$(git ls-files ":(glob)$${proto_dir}/${proto_name}*_pb.ts"); \ 101 | if [ -z "$$TS_FILES" ]; then continue; fi; \ 102 | for ts_file in $${TS_FILES}; do \ 103 | prettier -w $$ts_file; \ 104 | ts_file_dir=$$(dirname $$ts_file); \ 105 | relative_path=$${ts_file_dir#"./"}; \ 106 | depth=$$(echo $$relative_path | awk -F/ '{print NF+1}'); \ 107 | prefix=$$(printf '../%0.s' $$(seq 1 $$depth)); \ 108 | istmts=$$(grep -oE "from\s+\"$$prefix[^\"]+\"" $$ts_file) || continue; \ 109 | if [ -z "$$istmts" ]; then continue; fi; \ 110 | ipaths=$$(echo "$$istmts" | awk -F'"' '{print $$2}'); \ 111 | for import_path in $$ipaths; do \ 112 | rel_import_path=$$(realpath -s --relative-to=./vendor \ 113 | "./vendor/$${PROJECT}/$${ts_file_dir}/$${import_path}"); \ 114 | go_import_path=$$(echo $$rel_import_path | sed -e "s|^|@go/|"); \ 115 | sed -i -e "s|$$import_path|$$go_import_path|g" $$ts_file; \ 116 | done; \ 117 | done; \ 118 | done; \ 119 | }; \ 120 | protogen "./*.proto"; \ 121 | rm -f ./vendor/$${PROJECT} 122 | $(GOIMPORTS) -w ./ 123 | 124 | .PHONY: gen 125 | gen: genproto 126 | 127 | .PHONY: outdated 128 | outdated: $(GO_MOD_OUTDATED) 129 | go list -mod=mod -u -m -json all | $(GO_MOD_OUTDATED) -update -direct 130 | 131 | .PHONY: list 132 | list: $(GO_MOD_OUTDATED) 133 | go list -mod=mod -u -m -json all | $(GO_MOD_OUTDATED) 134 | 135 | .PHONY: lint 136 | lint: $(GOLANGCI_LINT) 137 | $(GOLANGCI_LINT) run 138 | 139 | .PHONY: fix 140 | fix: $(GOLANGCI_LINT) 141 | $(GOLANGCI_LINT) run --fix 142 | 143 | .PHONY: test 144 | test: 145 | go test -v ./... 146 | 147 | .PHONY: format 148 | format: $(GOFUMPT) $(GOIMPORTS) 149 | $(GOIMPORTS) -w ./ 150 | $(GOFUMPT) -w ./ 151 | 152 | .PHONY: integration 153 | integration: node_modules vendor 154 | cd ./integration && bash ./integration.bash 155 | -------------------------------------------------------------------------------- /cmd/protoc-gen-es-starpc/dev/protoc-gen-es-starpc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | exec tsx "./cmd/protoc-gen-es-starpc/protoc-gen-es-starpc.ts" "$@" 3 | -------------------------------------------------------------------------------- /cmd/protoc-gen-es-starpc/plugin.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Aperture Robotics, LLC. 2 | // Copyright 2021-2024 The Connect Authors 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import { createEcmaScriptPlugin } from '@aptre/protobuf-es-lite/protoplugin' 17 | import { generateTs } from './typescript.js' 18 | 19 | export const protocGenEsStarpc = createEcmaScriptPlugin({ 20 | name: 'protoc-gen-es-starpc', 21 | version: `none`, 22 | generateTs, 23 | }) 24 | -------------------------------------------------------------------------------- /cmd/protoc-gen-es-starpc/protoc-gen-es-starpc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { runNodeJs } from "@aptre/protobuf-es-lite/protoplugin"; 4 | import { protocGenEsStarpc } from "../../dist/cmd/protoc-gen-es-starpc/plugin.js"; 5 | 6 | runNodeJs(protocGenEsStarpc) 7 | -------------------------------------------------------------------------------- /cmd/protoc-gen-es-starpc/protoc-gen-es-starpc.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Aperture Robotics, LLC. 2 | // Copyright 2021-2024 The Connect Authors 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); 5 | // you may not use this file except in compliance with the License. 6 | // You may obtain a copy of the License at 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, 12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | // See the License for the specific language governing permissions and 14 | // limitations under the License. 15 | 16 | import { runNodeJs } from '@aptre/protobuf-es-lite/protoplugin' 17 | import { protocGenEsStarpc } from './plugin.js' 18 | 19 | runNodeJs(protocGenEsStarpc) 20 | -------------------------------------------------------------------------------- /cmd/protoc-gen-go-starpc/.gitignore: -------------------------------------------------------------------------------- 1 | protoc-gen-*-starpc 2 | protoc-gen-go-starpc -------------------------------------------------------------------------------- /cmd/protoc-gen-go-starpc/LICENSE.drpc: -------------------------------------------------------------------------------- 1 | Portions of this package are based on protoc-gen-drpc: 2 | 3 | Copyright (c) 2020 Storj Labs, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /echo/EchoerClientImpl.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aperturerobotics/starpc/854fe4b859ed3d836a4e7e8e5f1223b5473a146f/echo/EchoerClientImpl.ts -------------------------------------------------------------------------------- /echo/client-test.ts: -------------------------------------------------------------------------------- 1 | import { Client, ERR_RPC_ABORT } from '../srpc/index.js' 2 | import { EchoMsg } from './echo.pb.js' 3 | import { EchoerClient } from './echo_srpc.pb.js' 4 | import { pushable } from 'it-pushable' 5 | import { buildRpcStreamOpenStream } from '../rpcstream/rpcstream.js' 6 | import { Message } from '@aptre/protobuf-es-lite' 7 | 8 | export async function runClientTest(client: Client) { 9 | const demoServiceClient = new EchoerClient(client) 10 | 11 | console.log('Calling Echo: unary call...') 12 | let result = await demoServiceClient.Echo({ 13 | body: 'Hello world!', 14 | }) 15 | console.log('success: output', result.body) 16 | 17 | console.log('Calling Echo: unary call with empty request/response...') 18 | await demoServiceClient.DoNothing({ 19 | body: 'Hello world!', 20 | }) 21 | console.log('success') 22 | 23 | // observable for client requests 24 | const clientRequestStream = pushable>({ 25 | objectMode: true, 26 | }) 27 | clientRequestStream.push({ body: 'Hello world from streaming request.' }) 28 | clientRequestStream.end() 29 | 30 | console.log('Calling EchoClientStream: client -> server...') 31 | result = await demoServiceClient.EchoClientStream(clientRequestStream) 32 | console.log('success: output', result.body) 33 | 34 | console.log('Calling EchoServerStream: server -> client...') 35 | const serverStream = demoServiceClient.EchoServerStream({ 36 | body: 'Hello world from server to client streaming request.', 37 | }) 38 | for await (const msg of serverStream) { 39 | console.log('server: output', msg.body) 40 | } 41 | } 42 | 43 | // runAbortControllerTest tests aborting a RPC call. 44 | export async function runAbortControllerTest(client: Client) { 45 | const demoServiceClient = new EchoerClient(client) 46 | 47 | console.log('Testing EchoClientStream with AbortController...') 48 | let errorReturned = false 49 | 50 | const testRpc = async (rpc: (signal: AbortSignal) => Promise) => { 51 | const clientAbort = new AbortController() 52 | new Promise((resolve) => setTimeout(resolve, 1000)).then(() => { 53 | clientAbort.abort() 54 | }) 55 | try { 56 | await rpc(clientAbort.signal) 57 | } catch (err) { 58 | const errMsg = (err as Error).message 59 | errorReturned = true 60 | if (errMsg !== ERR_RPC_ABORT) { 61 | throw new Error('unexpected error: ' + errMsg) 62 | } 63 | } 64 | if (!errorReturned) { 65 | throw new Error('expected aborted rpc to throw error') 66 | } 67 | } 68 | 69 | await testRpc(async (signal) => { 70 | const clientNoopStream = pushable({ objectMode: true }) 71 | await demoServiceClient.EchoClientStream(clientNoopStream, signal) 72 | }) 73 | 74 | await testRpc(async (signal) => { 75 | const stream = demoServiceClient.EchoServerStream({ body: 'test' }, signal) 76 | const msgs = [] 77 | try { 78 | for await (const msg of stream) { 79 | msgs.push(msg) 80 | } 81 | } catch (err) { 82 | if (msgs.length < 3) { 83 | throw new Error('expected at least three messages before error') 84 | } 85 | throw err 86 | } 87 | }) 88 | } 89 | 90 | // runRpcStreamTest tests a RPCStream. 91 | export async function runRpcStreamTest(client: Client) { 92 | console.log('Calling RpcStream to open a RPC stream client...') 93 | const service = new EchoerClient(client) 94 | const openStreamFn = buildRpcStreamOpenStream( 95 | 'test', 96 | service.RpcStream.bind(service), 97 | ) 98 | const proxiedClient = new Client(openStreamFn) 99 | const proxiedService = new EchoerClient(proxiedClient) 100 | 101 | console.log('Calling Echo via RPC stream...') 102 | const resp = await proxiedService.Echo({ body: 'hello world via proxy' }) 103 | console.log('rpc stream test: succeeded: response: ' + resp.body) 104 | 105 | console.log('Running client test over RPC stream...') 106 | await runClientTest(proxiedClient) 107 | } 108 | -------------------------------------------------------------------------------- /echo/echo.go: -------------------------------------------------------------------------------- 1 | package echo 2 | 3 | import ( 4 | "github.com/aperturerobotics/starpc/rpcstream" 5 | srpc "github.com/aperturerobotics/starpc/srpc" 6 | ) 7 | 8 | // _ is a type assertion 9 | var ( 10 | _ srpc.StreamRecv[*EchoMsg] = (SRPCEchoer_EchoBidiStreamClient)(nil) 11 | _ srpc.StreamRecv[*EchoMsg] = (SRPCEchoer_EchoServerStreamClient)(nil) 12 | 13 | _ srpc.StreamSend[*EchoMsg] = (SRPCEchoer_EchoBidiStreamClient)(nil) 14 | _ srpc.StreamSend[*EchoMsg] = (SRPCEchoer_EchoClientStreamClient)(nil) 15 | 16 | _ srpc.StreamSendAndClose[*EchoMsg] = (SRPCEchoer_EchoBidiStreamStream)(nil) 17 | _ srpc.StreamSendAndClose[*EchoMsg] = (SRPCEchoer_EchoServerStreamStream)(nil) 18 | 19 | _ srpc.StreamRecv[*rpcstream.RpcStreamPacket] = (SRPCEchoer_RpcStreamStream)(nil) 20 | _ srpc.StreamSendAndClose[*rpcstream.RpcStreamPacket] = (SRPCEchoer_RpcStreamStream)(nil) 21 | ) 22 | -------------------------------------------------------------------------------- /echo/echo.pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false" 2 | // @generated from file github.com/aperturerobotics/starpc/echo/echo.proto (package echo, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import type { MessageType, PartialFieldInfo } from "@aptre/protobuf-es-lite"; 6 | import { createMessageType, ScalarType } from "@aptre/protobuf-es-lite"; 7 | 8 | export const protobufPackage = "echo"; 9 | 10 | /** 11 | * EchoMsg is the message body for Echo. 12 | * 13 | * @generated from message echo.EchoMsg 14 | */ 15 | export interface EchoMsg { 16 | /** 17 | * @generated from field: string body = 1; 18 | */ 19 | body?: string; 20 | 21 | }; 22 | 23 | // EchoMsg contains the message type declaration for EchoMsg. 24 | export const EchoMsg: MessageType = createMessageType({ 25 | typeName: "echo.EchoMsg", 26 | fields: [ 27 | { no: 1, name: "body", kind: "scalar", T: ScalarType.STRING }, 28 | ] as readonly PartialFieldInfo[], 29 | packedByDefault: true, 30 | }); 31 | 32 | -------------------------------------------------------------------------------- /echo/echo.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package echo; 3 | 4 | import "github.com/aperturerobotics/starpc/rpcstream/rpcstream.proto"; 5 | import "google/protobuf/empty.proto"; 6 | 7 | // Echoer service returns the given message. 8 | service Echoer { 9 | // Echo returns the given message. 10 | rpc Echo(EchoMsg) returns (EchoMsg); 11 | // EchoServerStream is an example of a server -> client one-way stream. 12 | rpc EchoServerStream(EchoMsg) returns (stream EchoMsg); 13 | // EchoClientStream is an example of client->server one-way stream. 14 | rpc EchoClientStream(stream EchoMsg) returns (EchoMsg); 15 | // EchoBidiStream is an example of a two-way stream. 16 | rpc EchoBidiStream(stream EchoMsg) returns (stream EchoMsg); 17 | // RpcStream opens a nested rpc call stream. 18 | rpc RpcStream(stream .rpcstream.RpcStreamPacket) returns (stream .rpcstream.RpcStreamPacket); 19 | // DoNothing does nothing. 20 | rpc DoNothing(.google.protobuf.Empty) returns (.google.protobuf.Empty); 21 | } 22 | 23 | // EchoMsg is the message body for Echo. 24 | message EchoMsg { 25 | string body = 1; 26 | } 27 | -------------------------------------------------------------------------------- /echo/index.ts: -------------------------------------------------------------------------------- 1 | export { EchoMsg } from './echo.pb.js' 2 | export { 3 | Echoer, 4 | EchoerClient, 5 | EchoerDefinition, 6 | EchoerServiceName, 7 | } from './echo_srpc.pb.js' 8 | export { EchoerServer } from './server.js' 9 | export { runClientTest } from './client-test.js' 10 | -------------------------------------------------------------------------------- /echo/server.go: -------------------------------------------------------------------------------- 1 | package echo 2 | 3 | import ( 4 | context "context" 5 | "errors" 6 | "io" 7 | "time" 8 | 9 | "github.com/aperturerobotics/protobuf-go-lite/types/known/emptypb" 10 | rpcstream "github.com/aperturerobotics/starpc/rpcstream" 11 | srpc "github.com/aperturerobotics/starpc/srpc" 12 | ) 13 | 14 | // EchoServer implements the server side of Echo. 15 | type EchoServer struct { 16 | rpcStreamMux srpc.Mux 17 | } 18 | 19 | // NewEchoServer constructs a EchoServer with a RpcStream mux. 20 | func NewEchoServer(rpcStreamMux srpc.Mux) *EchoServer { 21 | return &EchoServer{rpcStreamMux: rpcStreamMux} 22 | } 23 | 24 | // Register registers the Echo server with the Mux. 25 | func (s *EchoServer) Register(mux srpc.Mux) error { 26 | return SRPCRegisterEchoer(mux, s) 27 | } 28 | 29 | // Echo implements echo.SRPCEchoerServer 30 | func (*EchoServer) Echo(ctx context.Context, msg *EchoMsg) (*EchoMsg, error) { 31 | return msg.CloneVT(), nil 32 | } 33 | 34 | // EchoServerStream implements SRPCEchoerServer 35 | func (*EchoServer) EchoServerStream(msg *EchoMsg, strm SRPCEchoer_EchoServerStreamStream) error { 36 | // send 5 responses, with a 200ms delay for each 37 | responses := 5 38 | tkr := time.NewTicker(time.Millisecond * 200) 39 | defer tkr.Stop() 40 | for i := 0; i < responses; i++ { 41 | if err := strm.MsgSend(msg); err != nil { 42 | return err 43 | } 44 | select { 45 | case <-strm.Context().Done(): 46 | return context.Canceled 47 | case <-tkr.C: 48 | } 49 | } 50 | return nil 51 | } 52 | 53 | // EchoClientStream implements SRPCEchoerServer 54 | func (*EchoServer) EchoClientStream(strm SRPCEchoer_EchoClientStreamStream) (*EchoMsg, error) { 55 | return strm.Recv() 56 | } 57 | 58 | // EchoBidiStream implements SRPCEchoerServer 59 | func (s *EchoServer) EchoBidiStream(strm SRPCEchoer_EchoBidiStreamStream) error { 60 | // server sends initial message 61 | if err := strm.MsgSend(&EchoMsg{Body: "hello from server"}); err != nil { 62 | return err 63 | } 64 | for { 65 | msg, err := strm.Recv() 66 | if err != nil { 67 | if err == io.EOF { 68 | return nil 69 | } 70 | return err 71 | } 72 | if len(msg.GetBody()) == 0 { 73 | return errors.New("got message with empty body") 74 | } 75 | if err := strm.Send(msg); err != nil { 76 | return err 77 | } 78 | } 79 | } 80 | 81 | // RpcStream runs a rpc stream 82 | func (s *EchoServer) RpcStream(stream SRPCEchoer_RpcStreamStream) error { 83 | return rpcstream.HandleRpcStream(stream, func(ctx context.Context, componentID string, _ func()) (srpc.Invoker, func(), error) { 84 | if s.rpcStreamMux == nil { 85 | return nil, nil, errors.New("not implemented") 86 | } 87 | return s.rpcStreamMux, nil, nil 88 | }) 89 | } 90 | 91 | // DoNothing does nothing. 92 | func (s *EchoServer) DoNothing(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { 93 | return &emptypb.Empty{}, nil 94 | } 95 | 96 | // _ is a type assertion 97 | var _ SRPCEchoerServer = ((*EchoServer)(nil)) 98 | -------------------------------------------------------------------------------- /echo/server.ts: -------------------------------------------------------------------------------- 1 | import first from 'it-first' 2 | import { Empty, Message } from '@aptre/protobuf-es-lite' 3 | import { EchoMsg } from './echo.pb.js' 4 | import { Server } from '../srpc/server.js' 5 | import { messagePushable, writeToPushable } from '../srpc/pushable.js' 6 | import { RpcStreamPacket } from '../rpcstream/rpcstream.pb.js' 7 | import { MessageStream } from '../srpc/message.js' 8 | import { handleRpcStream, RpcStreamHandler } from '../rpcstream/rpcstream.js' 9 | import { Echoer } from './echo_srpc.pb.js' 10 | 11 | // EchoServer implements the Echoer server. 12 | export class EchoerServer implements Echoer { 13 | // proxyServer is the server used for RpcStream requests. 14 | private proxyServer?: Server 15 | 16 | constructor(proxyServer?: Server) { 17 | this.proxyServer = proxyServer 18 | } 19 | 20 | public async Echo(request: EchoMsg): Promise> { 21 | return request 22 | } 23 | 24 | public async *EchoServerStream(request: EchoMsg): MessageStream { 25 | for (let i = 0; i < 5; i++) { 26 | yield request 27 | await new Promise((resolve) => setTimeout(resolve, 200)) 28 | } 29 | } 30 | 31 | public async EchoClientStream( 32 | request: MessageStream, 33 | ): Promise> { 34 | // return the first message sent by the client. 35 | const message = await first(request) 36 | if (!message) { 37 | throw new Error('received no messages') 38 | } 39 | return message 40 | } 41 | 42 | public EchoBidiStream( 43 | request: MessageStream, 44 | ): MessageStream { 45 | // build result observable 46 | const result = messagePushable() 47 | result.push({ body: 'hello from server' }) 48 | writeToPushable(request, result) 49 | return result 50 | } 51 | 52 | public RpcStream( 53 | request: MessageStream, 54 | ): MessageStream { 55 | return handleRpcStream( 56 | request[Symbol.asyncIterator](), 57 | async (): Promise => { 58 | if (!this.proxyServer) { 59 | throw new Error('rpc stream proxy server not set') 60 | } 61 | return this.proxyServer.rpcStreamHandler 62 | }, 63 | ) 64 | } 65 | 66 | public async DoNothing(): Promise { 67 | return {} 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aperturerobotics/starpc 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.3 6 | 7 | replace ( 8 | // This fork uses go-protobuf-lite and adds post-quantum crypto support. 9 | github.com/libp2p/go-libp2p => github.com/aperturerobotics/go-libp2p v0.37.1-0.20241111002741-5cfbb50b74e0 // aperture 10 | 11 | // This fork uses go-protobuf-lite. 12 | github.com/libp2p/go-msgio => github.com/aperturerobotics/go-libp2p-msgio v0.0.0-20240511033615-1b69178aa5c8 // aperture 13 | ) 14 | 15 | require ( 16 | github.com/aperturerobotics/protobuf-go-lite v0.9.1 // latest 17 | github.com/aperturerobotics/util v1.30.0 // latest 18 | ) 19 | 20 | require ( 21 | github.com/coder/websocket v1.8.13 // latest 22 | github.com/libp2p/go-libp2p v0.41.1 // latest 23 | github.com/pkg/errors v0.9.1 // latest 24 | github.com/sirupsen/logrus v1.9.3 // latest 25 | google.golang.org/protobuf v1.36.6 // latest 26 | ) 27 | 28 | require github.com/libp2p/go-yamux/v4 v4.0.1 29 | 30 | require ( 31 | github.com/aperturerobotics/json-iterator-lite v1.0.1-0.20240713111131-be6bf89c3008 // indirect 32 | github.com/ipfs/go-cid v0.4.1 // indirect 33 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 34 | github.com/libp2p/go-buffer-pool v0.1.0 // indirect 35 | github.com/minio/sha256-simd v1.0.1 // indirect 36 | github.com/mr-tron/base58 v1.2.0 // indirect 37 | github.com/multiformats/go-base32 v0.1.0 // indirect 38 | github.com/multiformats/go-base36 v0.2.0 // indirect 39 | github.com/multiformats/go-multiaddr v0.13.0 // indirect 40 | github.com/multiformats/go-multibase v0.2.0 // indirect 41 | github.com/multiformats/go-multihash v0.2.3 // indirect 42 | github.com/multiformats/go-multistream v0.5.0 // indirect 43 | github.com/multiformats/go-varint v0.0.7 // indirect 44 | github.com/spaolacci/murmur3 v1.1.0 // indirect 45 | golang.org/x/crypto v0.37.0 // indirect 46 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect 47 | golang.org/x/sys v0.32.0 // indirect 48 | lukechampine.com/blake3 v1.3.0 // indirect 49 | ) 50 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './srpc/index.js' 2 | export * from './rpcstream/index.js' 3 | export * from './echo/index.js' -------------------------------------------------------------------------------- /integration/.gitignore: -------------------------------------------------------------------------------- 1 | integration 2 | integration.js 3 | integration.js.map 4 | integration.mjs 5 | integration.mjs.map -------------------------------------------------------------------------------- /integration/integration.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | unset GOOS 5 | unset GOARCH 6 | 7 | # Fixes errors with the generated esm using require() 8 | # https://github.com/evanw/esbuild/issues/1944#issuecomment-1936954345 9 | ESM_BANNER='import{fileURLToPath}from"node:url";import{dirname}from"node:path";import{createRequire as topLevelCreateRequire}from"node:module";const require=topLevelCreateRequire(import.meta.url);const __filename=fileURLToPath(import.meta.url);const __dirname=dirname(__filename);' 10 | echo "Compiling ts..." 11 | ../node_modules/.bin/esbuild integration.ts \ 12 | --bundle \ 13 | --sourcemap \ 14 | --platform=node \ 15 | --format=esm \ 16 | --banner:js="$ESM_BANNER" \ 17 | --outfile=integration.mjs 18 | 19 | echo "Compiling go..." 20 | go build -o integration -v ./ 21 | 22 | echo "Starting server..." 23 | ./integration & 24 | PID=$! 25 | 26 | function cleanup { 27 | kill -9 ${PID} 28 | } 29 | trap cleanup EXIT 30 | 31 | sleep 1 32 | 33 | pushd ../ 34 | echo "Starting client..." 35 | node ./integration/integration.mjs 36 | popd 37 | -------------------------------------------------------------------------------- /integration/integration.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/aperturerobotics/starpc/echo" 9 | "github.com/aperturerobotics/starpc/srpc" 10 | "github.com/coder/websocket" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func main() { 15 | mux := srpc.NewMux() 16 | echoServer := echo.NewEchoServer(mux) 17 | if err := echo.SRPCRegisterEchoer(mux, echoServer); err != nil { 18 | logrus.Fatal(err.Error()) 19 | } 20 | 21 | // listen at: ws://localhost:4352/demo 22 | server, err := srpc.NewHTTPServer(mux, "/demo", &websocket.AcceptOptions{ 23 | InsecureSkipVerify: true, 24 | }) 25 | if err != nil { 26 | logrus.Fatal(err.Error()) 27 | } 28 | 29 | fmt.Print("listening on localhost:4352\n") 30 | hserver := &http.Server{ 31 | Addr: "localhost:4352", 32 | Handler: server, 33 | ReadHeaderTimeout: time.Second * 10, 34 | } 35 | if err := hserver.ListenAndServe(); err != nil { 36 | logrus.Fatal(err.Error()) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /integration/integration.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketConn } from '../srpc/websocket.js' 2 | import { 3 | runClientTest, 4 | runRpcStreamTest, 5 | runAbortControllerTest, 6 | } from '../echo/client-test.js' 7 | import WebSocket from 'isomorphic-ws' 8 | 9 | async function runRPC() { 10 | const addr = 'ws://localhost:4352/demo' 11 | console.log(`Connecting to ${addr}`) 12 | const ws = new WebSocket(addr) 13 | const channel = new WebSocketConn(ws, 'outbound') 14 | const client = channel.buildClient() 15 | 16 | console.log('Running client test via WebSocket..') 17 | await runClientTest(client) 18 | 19 | console.log('Running abort controller test via WebSocket..') 20 | await runAbortControllerTest(client) 21 | 22 | console.log('Running RpcStream test via WebSocket..') 23 | await runRpcStreamTest(client) 24 | } 25 | 26 | process.on('unhandledRejection', (ev) => { 27 | console.error('Unhandled rejection', ev) 28 | throw ev 29 | }) 30 | 31 | runRPC() 32 | .then(() => { 33 | process.exit(0) 34 | }) 35 | .catch((err) => { 36 | console.error('runRPC threw error', err) 37 | process.exit(1) 38 | }) 39 | -------------------------------------------------------------------------------- /integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "system", 5 | "noEmit": false, 6 | "declaration": false 7 | }, 8 | "include": [ 9 | "./" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /mock/mock.go: -------------------------------------------------------------------------------- 1 | package e2e_mock 2 | 3 | import ( 4 | context "context" 5 | 6 | srpc "github.com/aperturerobotics/starpc/srpc" 7 | ) 8 | 9 | // MockServer implements the server for Mock. 10 | type MockServer struct { 11 | // MockRequestCb is the callback to implement MockRequest. 12 | MockRequestCb func(ctx context.Context, msg *MockMsg) (*MockMsg, error) 13 | } 14 | 15 | // Register registers the Echo server with the Mux. 16 | func (e *MockServer) Register(mux srpc.Mux) error { 17 | return SRPCRegisterMock(mux, e) 18 | } 19 | 20 | // MockRequest implements the mock request rpc. 21 | func (e *MockServer) MockRequest(ctx context.Context, msg *MockMsg) (*MockMsg, error) { 22 | if e.MockRequestCb == nil { 23 | return nil, srpc.ErrUnimplemented 24 | } 25 | return e.MockRequestCb(ctx, msg) 26 | } 27 | 28 | // _ is a type assertion 29 | var _ SRPCMockServer = ((*MockServer)(nil)) 30 | -------------------------------------------------------------------------------- /mock/mock.pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false" 2 | // @generated from file github.com/aperturerobotics/starpc/mock/mock.proto (package e2e.mock, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import type { MessageType, PartialFieldInfo } from "@aptre/protobuf-es-lite"; 6 | import { createMessageType, ScalarType } from "@aptre/protobuf-es-lite"; 7 | 8 | export const protobufPackage = "e2e.mock"; 9 | 10 | /** 11 | * MockMsg is the mock message body. 12 | * 13 | * @generated from message e2e.mock.MockMsg 14 | */ 15 | export interface MockMsg { 16 | /** 17 | * @generated from field: string body = 1; 18 | */ 19 | body?: string; 20 | 21 | }; 22 | 23 | // MockMsg contains the message type declaration for MockMsg. 24 | export const MockMsg: MessageType = createMessageType({ 25 | typeName: "e2e.mock.MockMsg", 26 | fields: [ 27 | { no: 1, name: "body", kind: "scalar", T: ScalarType.STRING }, 28 | ] as readonly PartialFieldInfo[], 29 | packedByDefault: true, 30 | }); 31 | 32 | -------------------------------------------------------------------------------- /mock/mock.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package e2e.mock; 3 | 4 | // Mock service mocks some RPCs for the e2e tests. 5 | service Mock { 6 | // MockRequest runs a mock unary request. 7 | rpc MockRequest(MockMsg) returns (MockMsg); 8 | } 9 | 10 | // MockMsg is the mock message body. 11 | message MockMsg { 12 | string body = 1; 13 | } 14 | -------------------------------------------------------------------------------- /mock/mock_srpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-srpc. DO NOT EDIT. 2 | // protoc-gen-srpc version: v0.38.0 3 | // source: github.com/aperturerobotics/starpc/mock/mock.proto 4 | 5 | package e2e_mock 6 | 7 | import ( 8 | context "context" 9 | 10 | srpc "github.com/aperturerobotics/starpc/srpc" 11 | ) 12 | 13 | type SRPCMockClient interface { 14 | // SRPCClient returns the underlying SRPC client. 15 | SRPCClient() srpc.Client 16 | 17 | // MockRequest runs a mock unary request. 18 | MockRequest(ctx context.Context, in *MockMsg) (*MockMsg, error) 19 | } 20 | 21 | type srpcMockClient struct { 22 | cc srpc.Client 23 | serviceID string 24 | } 25 | 26 | func NewSRPCMockClient(cc srpc.Client) SRPCMockClient { 27 | return &srpcMockClient{cc: cc, serviceID: SRPCMockServiceID} 28 | } 29 | 30 | func NewSRPCMockClientWithServiceID(cc srpc.Client, serviceID string) SRPCMockClient { 31 | if serviceID == "" { 32 | serviceID = SRPCMockServiceID 33 | } 34 | return &srpcMockClient{cc: cc, serviceID: serviceID} 35 | } 36 | 37 | func (c *srpcMockClient) SRPCClient() srpc.Client { return c.cc } 38 | 39 | func (c *srpcMockClient) MockRequest(ctx context.Context, in *MockMsg) (*MockMsg, error) { 40 | out := new(MockMsg) 41 | err := c.cc.ExecCall(ctx, c.serviceID, "MockRequest", in, out) 42 | if err != nil { 43 | return nil, err 44 | } 45 | return out, nil 46 | } 47 | 48 | type SRPCMockServer interface { 49 | // MockRequest runs a mock unary request. 50 | MockRequest(context.Context, *MockMsg) (*MockMsg, error) 51 | } 52 | 53 | const SRPCMockServiceID = "e2e.mock.Mock" 54 | 55 | type SRPCMockHandler struct { 56 | serviceID string 57 | impl SRPCMockServer 58 | } 59 | 60 | // NewSRPCMockHandler constructs a new RPC handler. 61 | // serviceID: if empty, uses default: e2e.mock.Mock 62 | func NewSRPCMockHandler(impl SRPCMockServer, serviceID string) srpc.Handler { 63 | if serviceID == "" { 64 | serviceID = SRPCMockServiceID 65 | } 66 | return &SRPCMockHandler{impl: impl, serviceID: serviceID} 67 | } 68 | 69 | // SRPCRegisterMock registers the implementation with the mux. 70 | // Uses the default serviceID: e2e.mock.Mock 71 | func SRPCRegisterMock(mux srpc.Mux, impl SRPCMockServer) error { 72 | return mux.Register(NewSRPCMockHandler(impl, "")) 73 | } 74 | 75 | func (d *SRPCMockHandler) GetServiceID() string { return d.serviceID } 76 | 77 | func (SRPCMockHandler) GetMethodIDs() []string { 78 | return []string{ 79 | "MockRequest", 80 | } 81 | } 82 | 83 | func (d *SRPCMockHandler) InvokeMethod( 84 | serviceID, methodID string, 85 | strm srpc.Stream, 86 | ) (bool, error) { 87 | if serviceID != "" && serviceID != d.GetServiceID() { 88 | return false, nil 89 | } 90 | 91 | switch methodID { 92 | case "MockRequest": 93 | return true, d.InvokeMethod_MockRequest(d.impl, strm) 94 | default: 95 | return false, nil 96 | } 97 | } 98 | 99 | func (SRPCMockHandler) InvokeMethod_MockRequest(impl SRPCMockServer, strm srpc.Stream) error { 100 | req := new(MockMsg) 101 | if err := strm.MsgRecv(req); err != nil { 102 | return err 103 | } 104 | out, err := impl.MockRequest(strm.Context(), req) 105 | if err != nil { 106 | return err 107 | } 108 | return strm.MsgSend(out) 109 | } 110 | 111 | type SRPCMock_MockRequestStream interface { 112 | srpc.Stream 113 | } 114 | 115 | type srpcMock_MockRequestStream struct { 116 | srpc.Stream 117 | } 118 | -------------------------------------------------------------------------------- /mock/mock_srpc.pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es-starpc none with parameter "target=ts,ts_nocheck=false" 2 | // @generated from file github.com/aperturerobotics/starpc/mock/mock.proto (package e2e.mock, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import { MockMsg } from "./mock.pb.js"; 6 | import { MethodKind } from "@aptre/protobuf-es-lite"; 7 | import { ProtoRpc } from "starpc"; 8 | 9 | /** 10 | * Mock service mocks some RPCs for the e2e tests. 11 | * 12 | * @generated from service e2e.mock.Mock 13 | */ 14 | export const MockDefinition = { 15 | typeName: "e2e.mock.Mock", 16 | methods: { 17 | /** 18 | * MockRequest runs a mock unary request. 19 | * 20 | * @generated from rpc e2e.mock.Mock.MockRequest 21 | */ 22 | MockRequest: { 23 | name: "MockRequest", 24 | I: MockMsg, 25 | O: MockMsg, 26 | kind: MethodKind.Unary, 27 | }, 28 | } 29 | } as const; 30 | 31 | /** 32 | * Mock service mocks some RPCs for the e2e tests. 33 | * 34 | * @generated from service e2e.mock.Mock 35 | */ 36 | export interface Mock { 37 | /** 38 | * MockRequest runs a mock unary request. 39 | * 40 | * @generated from rpc e2e.mock.Mock.MockRequest 41 | */ 42 | MockRequest( 43 | request: MockMsg, abortSignal?: AbortSignal 44 | ): 45 | Promise 46 | 47 | } 48 | 49 | export const MockServiceName = MockDefinition.typeName 50 | 51 | export class MockClient implements Mock { 52 | private readonly rpc: ProtoRpc 53 | private readonly service: string 54 | constructor(rpc: ProtoRpc, opts?: { service?: string }) { 55 | this.service = opts?.service || MockServiceName 56 | this.rpc = rpc 57 | this.MockRequest = this.MockRequest.bind(this) 58 | } 59 | /** 60 | * MockRequest runs a mock unary request. 61 | * 62 | * @generated from rpc e2e.mock.Mock.MockRequest 63 | */ 64 | async MockRequest( 65 | request: MockMsg, abortSignal?: AbortSignal 66 | ): 67 | Promise { 68 | const requestMsg = MockMsg.create(request) 69 | const result = await this.rpc.request( 70 | this.service, 71 | MockDefinition.methods.MockRequest.name, 72 | MockMsg.toBinary(requestMsg), 73 | abortSignal || undefined, 74 | ) 75 | return MockMsg.fromBinary(result) 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "starpc", 3 | "version": "0.39.1", 4 | "description": "Streaming protobuf RPC service protocol over any two-way channel.", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Aperture Robotics LLC.", 8 | "email": "support@aperture.us", 9 | "url": "http://aperture.us" 10 | }, 11 | "contributors": [ 12 | { 13 | "name": "Christian Stewart", 14 | "email": "christian@aperture.us", 15 | "url": "http://github.com/paralin" 16 | } 17 | ], 18 | "bin": { 19 | "protoc-gen-es-starpc": "./cmd/protoc-gen-es-starpc/protoc-gen-es-starpc" 20 | }, 21 | "type": "module", 22 | "exports": { 23 | ".": { 24 | "types": "./dist/index.d.ts", 25 | "import": "./dist/index.js", 26 | "require": "./dist/index.js" 27 | } 28 | }, 29 | "files": [ 30 | "!**/*.tsbuildinfo", 31 | "Makefile", 32 | "dist", 33 | "mock", 34 | "echo", 35 | "go.mod", 36 | "go.sum", 37 | "integration", 38 | "srpc", 39 | "cmd/protoc-gen-es-starpc" 40 | ], 41 | "repository": { 42 | "url": "git+ssh://git@github.com/aperturerobotics/starpc.git" 43 | }, 44 | "scripts": { 45 | "clean": "rimraf ./dist", 46 | "build": "npm run clean && tsc --project tsconfig.build.json --outDir ./dist/", 47 | "check": "npm run typecheck", 48 | "typecheck": "tsc --noEmit", 49 | "deps": "depcheck --ignores 'bufferutil,utf-8-validate,rimraf,starpc,@aptre/protobuf-es-lite,tsx'", 50 | "codegen": "npm run gen", 51 | "ci": "npm run build && npm run lint:js && npm run lint:go", 52 | "format": "npm run format:go && npm run format:js && npm run format:config", 53 | "format:config": "prettier --write tsconfig.json package.json", 54 | "format:go": "make format", 55 | "format:js": "prettier --write './{srpc,echo,e2e,integration,rpcstream,cmd}/**/(*.ts|*.tsx|*.html|*.css)'", 56 | "gen": "make genproto", 57 | "test": "npm run test:js && npm run test:go", 58 | "test:go": "make test", 59 | "build:e2e": "npm run build && cd e2e && esbuild e2e.ts --sourcemap --outfile=e2e.cjs --bundle --platform=node", 60 | "test:js": "vitest run", 61 | "test:js:watch": "vitest", 62 | "debug:js": "npm run build:e2e && cd e2e && node --inspect --inspect-brk ./e2e.cjs", 63 | "test:integration": "make integration", 64 | "integration": "npm run test:integration", 65 | "lint": "npm run lint:go && npm run lint:js", 66 | "lint:go": "make lint", 67 | "lint:js": "ESLINT_USE_FLAT_CONFIG=false eslint -c .eslintrc.cjs --ignore-pattern *.js --ignore-pattern *.d.ts ./", 68 | "prepare": "husky", 69 | "precommit": "lint-staged", 70 | "release": "npm run release:version && npm run release:commit", 71 | "release:minor": "npm run release:version:minor && npm run release:commit", 72 | "release:version": "npm version patch -m \"release: v%s\" --no-git-tag-version", 73 | "release:version:minor": "npm version minor -m \"release: v%s\" --no-git-tag-version", 74 | "release:commit": "git reset && git add package.json && git commit -s -m \"release: v$npm_package_version\" && git tag v$npm_package_version", 75 | "release:publish": "git push && git push --tags && npm run build && npm publish" 76 | }, 77 | "preferUnplugged": true, 78 | "lint-staged": { 79 | "package.json": "prettier --config .prettierrc.yaml --write", 80 | "./{srpc,echo,e2e,integration,rpcstream,cmd}/**/(*.ts|*.tsx|*.html|*.css)": "prettier --config .prettierrc.yaml --write" 81 | }, 82 | "devDependencies": { 83 | "@typescript-eslint/eslint-plugin": "^8.26.0", 84 | "@typescript-eslint/parser": "^8.26.0", 85 | "depcheck": "^1.4.6", 86 | "esbuild": "^0.25.0", 87 | "eslint": "^9.21.0", 88 | "eslint-config-prettier": "^10.0.2", 89 | "husky": "^9.1.7", 90 | "lint-staged": "^16.0.0", 91 | "prettier": "^3.5.3", 92 | "rimraf": "^6.0.1", 93 | "tsx": "^4.19.3", 94 | "typescript": "^5.8.2", 95 | "vitest": "^3.0.7" 96 | }, 97 | "dependencies": { 98 | "@aptre/it-ws": "^1.0.1", 99 | "@aptre/protobuf-es-lite": "^0.4.6", 100 | "@chainsafe/libp2p-yamux": "^7.0.1", 101 | "@libp2p/interface": "^2.6.1", 102 | "@libp2p/logger": "^5.1.11", 103 | "event-iterator": "^2.0.0", 104 | "isomorphic-ws": "^5.0.0", 105 | "it-first": "^3.0.6", 106 | "it-pipe": "^3.0.1", 107 | "it-pushable": "^3.2.3", 108 | "it-stream-types": "^2.0.2", 109 | "uint8arraylist": "^2.4.7", 110 | "ws": "^8.18.1" 111 | }, 112 | "resolutions": { 113 | "@aptre/protobuf-es-lite": "0.4.9" 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /rpcstream/README.md: -------------------------------------------------------------------------------- 1 | # RPC Stream 2 | 3 | This package implements running a RPC service on top of another. 4 | 5 | The "host" service has a signature like: 6 | 7 | ```protobuf 8 | syntax = "proto3"; 9 | package mypackage; 10 | 11 | import "github.com/aperturerobotics/starpc/rpcstream/rpcstream.proto"; 12 | 13 | // HostService proxies RPC calls to a target Mux. 14 | service HostService { 15 | // MyRpc opens a stream to proxy a RPC call. 16 | rpc MyRpc(stream .rpcstream.RpcStreamPacket) returns (stream .rpcstream.RpcStreamPacket); 17 | } 18 | ``` 19 | 20 | `NewRpcStreamOpenStream(componentID, hostService.MyRpc)` will construct a new 21 | `OpenStreamFunc` which starts a RPC call to `MyRpc` and forwards the starpc 22 | packets over the two-way stream. 23 | 24 | The component ID can be used to determine which Mux the client should access. 25 | 26 | -------------------------------------------------------------------------------- /rpcstream/errors.go: -------------------------------------------------------------------------------- 1 | package rpcstream 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrNoServerForComponent is returned if the getter returns nil. 7 | ErrNoServerForComponent = errors.New("no server for that component") 8 | // ErrUnexpectedPacket is returned if the packet was unexpected. 9 | ErrUnexpectedPacket = errors.New("unexpected rpcstream packet") 10 | ) 11 | -------------------------------------------------------------------------------- /rpcstream/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | RpcStream, 3 | RpcStreamCaller, 4 | RpcStreamGetter, 5 | RpcStreamHandler, 6 | openRpcStream, 7 | handleRpcStream, 8 | buildRpcStreamOpenStream, 9 | } from './rpcstream.js' 10 | export { RpcStreamPacket, RpcStreamInit, RpcAck } from './rpcstream.pb.js' 11 | -------------------------------------------------------------------------------- /rpcstream/proxy.go: -------------------------------------------------------------------------------- 1 | package rpcstream 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/aperturerobotics/starpc/srpc" 8 | ) 9 | 10 | // RpcProxyGetter returns a remote rpcstream call to proxy to. 11 | // Returns the component ID to pass to the caller. 12 | // 13 | // Returns a release function to call when done with the stream. 14 | // The caller will cancel the context and close the rpc when done. 15 | // Returns nil, "", nil, nil if not found. 16 | type RpcProxyGetter[T RpcStream] func(ctx context.Context, componentID string) ( 17 | caller RpcStreamCaller[T], 18 | callerComponentID string, 19 | rel func(), 20 | err error, 21 | ) 22 | 23 | // HandleProxyRpcStream handles an incoming RPC stream proxying to a ReadWriteCloser. 24 | func HandleProxyRpcStream[T RpcStream](stream RpcStream, getter RpcProxyGetter[T]) error { 25 | // Read the "init" packet. 26 | initPkt, err := stream.Recv() 27 | if err != nil { 28 | return err 29 | } 30 | initInner, ok := initPkt.GetBody().(*RpcStreamPacket_Init) 31 | if !ok || initInner.Init == nil { 32 | return ErrUnexpectedPacket 33 | } 34 | 35 | // lookup the caller for this component id 36 | ctx := stream.Context() 37 | componentID := initInner.Init.GetComponentId() 38 | remoteCaller, remoteComponentID, remoteCallerRel, err := getter(ctx, componentID) 39 | if remoteCallerRel != nil { 40 | defer remoteCallerRel() 41 | } else if err == nil { 42 | err = ErrNoServerForComponent 43 | } 44 | 45 | // call the remote caller 46 | var remoteStrm RpcStream 47 | if err == nil { 48 | remoteStrm, err = remoteCaller(ctx) 49 | if remoteStrm != nil { 50 | defer remoteStrm.Close() 51 | } else if err == nil { 52 | err = ErrNoServerForComponent 53 | } 54 | } 55 | 56 | // send the init message 57 | if err == nil { 58 | err = remoteStrm.Send(&RpcStreamPacket{ 59 | Body: &RpcStreamPacket_Init{ 60 | Init: &RpcStreamInit{ 61 | ComponentId: remoteComponentID, 62 | }, 63 | }, 64 | }) 65 | } 66 | 67 | // send ack, but only if we have an error 68 | // otherwise: we will proxy the ack from the remote stream. 69 | if err != nil { 70 | errStr := err.Error() 71 | _ = stream.Send(&RpcStreamPacket{ 72 | Body: &RpcStreamPacket_Ack{ 73 | Ack: &RpcAck{Error: errStr}, 74 | }, 75 | }) 76 | return err 77 | } 78 | 79 | errCh := make(chan error, 2) 80 | go copyRpcStreamTo(remoteStrm, stream, errCh) 81 | go copyRpcStreamTo(stream, remoteStrm, errCh) 82 | 83 | // wait for both errors 84 | var outErr error 85 | for range 2 { 86 | if err := <-errCh; err != nil && outErr == nil && err != io.EOF { 87 | outErr = err 88 | } 89 | } 90 | return outErr 91 | } 92 | 93 | // copies s1 to s2 94 | func copyRpcStreamTo(s1, s2 RpcStream, errCh chan error) { 95 | rerr := func() error { 96 | pkt := srpc.NewRawMessage(nil, true) 97 | for { 98 | err := s1.MsgRecv(pkt) 99 | if err != nil { 100 | return err 101 | } 102 | if len(pkt.GetData()) == 0 { 103 | continue 104 | } 105 | err = s2.MsgSend(pkt) 106 | pkt.Clear() 107 | if err != nil { 108 | return err 109 | } 110 | } 111 | }() 112 | 113 | s1Err := s1.Close() 114 | if rerr == nil && s1Err != nil { 115 | rerr = s1Err 116 | } 117 | if rerr != nil { 118 | if errCh != nil { 119 | errCh <- rerr 120 | } 121 | _ = s2.Close() 122 | return 123 | } 124 | 125 | rerr = s2.CloseSend() 126 | if errCh != nil { 127 | errCh <- rerr 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /rpcstream/pushable-sink.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aperturerobotics/starpc/854fe4b859ed3d836a4e7e8e5f1223b5473a146f/rpcstream/pushable-sink.ts -------------------------------------------------------------------------------- /rpcstream/raw-stream.go: -------------------------------------------------------------------------------- 1 | package rpcstream 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | // RpcRawGetter returns a read/write/closer to proxy data to/from. 9 | // Returns a release function to call when done with the stream. 10 | // The caller will call stream.Close as well as the release function (if any). 11 | // Returns nil, nil, nil if not found. 12 | type RpcRawGetter func(ctx context.Context, componentID string) (io.ReadWriteCloser, func(), error) 13 | 14 | // HandleRawRpcStream handles an incoming RPC stream proxying to a ReadWriteCloser. 15 | func HandleRawRpcStream(stream RpcStream, getter RpcRawGetter) error { 16 | // Read the "init" packet. 17 | initPkt, err := stream.Recv() 18 | if err != nil { 19 | return err 20 | } 21 | initInner, ok := initPkt.GetBody().(*RpcStreamPacket_Init) 22 | if !ok || initInner.Init == nil { 23 | return ErrUnexpectedPacket 24 | } 25 | 26 | // lookup the server for this component id 27 | ctx := stream.Context() 28 | componentID := initInner.Init.GetComponentId() 29 | remoteRwc, remoteRwcRel, err := getter(ctx, componentID) 30 | if err == nil && remoteRwc == nil { 31 | err = ErrNoServerForComponent 32 | } 33 | if remoteRwcRel != nil { 34 | defer remoteRwcRel() 35 | } 36 | if remoteRwc != nil { 37 | defer remoteRwc.Close() 38 | } 39 | 40 | // send ack 41 | var errStr string 42 | if err != nil { 43 | errStr = err.Error() 44 | } 45 | sendErr := stream.Send(&RpcStreamPacket{ 46 | Body: &RpcStreamPacket_Ack{ 47 | Ack: &RpcAck{Error: errStr}, 48 | }, 49 | }) 50 | if err != nil { 51 | return err 52 | } 53 | if sendErr != nil { 54 | return sendErr 55 | } 56 | 57 | // proxy the stream 58 | // we re-use the rpcstream message framing here. 59 | // 1 incoming message = 1 outgoing message 60 | srw := NewRpcStreamReadWriter(stream) 61 | errCh := make(chan error, 2) 62 | go copyRwcTo(remoteRwc, srw, errCh) 63 | go copyRwcTo(srw, remoteRwc, errCh) 64 | 65 | // wait for both errors 66 | var outErr error 67 | for i := 0; i < 2; i++ { 68 | if err := <-errCh; err != nil && outErr == nil && err != io.EOF { 69 | outErr = err 70 | } 71 | } 72 | return outErr 73 | } 74 | 75 | func copyRwcTo(s1, s2 io.ReadWriteCloser, errCh chan error) { 76 | buf := make([]byte, 8192) 77 | _, err := io.CopyBuffer(s2, s1, buf) 78 | _ = s1.Close() 79 | _ = s2.Close() 80 | if errCh != nil { 81 | errCh <- err 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /rpcstream/read-writer.go: -------------------------------------------------------------------------------- 1 | package rpcstream 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | 8 | "github.com/aperturerobotics/starpc/srpc" 9 | ) 10 | 11 | // RpcStreamReadWriter reads and writes a buffered RpcStream. 12 | type RpcStreamReadWriter struct { 13 | // stream is the RpcStream 14 | stream RpcStream 15 | // buf is the incoming data buffer 16 | buf bytes.Buffer 17 | } 18 | 19 | // NewRpcStreamReadWriter constructs a new read/writer. 20 | func NewRpcStreamReadWriter(stream RpcStream) *RpcStreamReadWriter { 21 | return &RpcStreamReadWriter{stream: stream} 22 | } 23 | 24 | // ReadPump executes the read pump in a goroutine. 25 | // 26 | // calls the handler when closed or returning an error 27 | func ReadPump(strm RpcStream, cb srpc.PacketDataHandler, closed srpc.CloseHandler) { 28 | err := ReadToHandler(strm, cb) 29 | // signal that the stream is now closed. 30 | if closed != nil { 31 | closed(err) 32 | } 33 | } 34 | 35 | // ReadToHandler reads data to the given handler. 36 | // Does not handle closing the stream, use ReadPump instead. 37 | func ReadToHandler(strm RpcStream, cb srpc.PacketDataHandler) error { 38 | for { 39 | // read packet 40 | pkt, err := strm.Recv() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | data := pkt.GetData() 46 | if len(data) == 0 { 47 | continue 48 | } 49 | 50 | // call handler 51 | if err := cb(data); err != nil { 52 | return err 53 | } 54 | } 55 | } 56 | 57 | // Write writes a packet to the writer. 58 | func (r *RpcStreamReadWriter) Write(p []byte) (n int, err error) { 59 | if len(p) == 0 { 60 | return 0, nil 61 | } 62 | err = r.stream.Send(&RpcStreamPacket{ 63 | Body: &RpcStreamPacket_Data{ 64 | Data: p, 65 | }, 66 | }) 67 | if err != nil { 68 | return 0, err 69 | } 70 | return len(p), nil 71 | } 72 | 73 | // Read reads a packet from the writer. 74 | func (r *RpcStreamReadWriter) Read(p []byte) (n int, err error) { 75 | readBuf := p 76 | for len(readBuf) != 0 && err == nil { 77 | var rn int 78 | 79 | // if the buffer has data, read from it. 80 | if r.buf.Len() != 0 { 81 | rn, err = r.buf.Read(readBuf) 82 | } else { 83 | if n != 0 { 84 | // if we read data to p already, return now. 85 | break 86 | } 87 | 88 | var pkt *RpcStreamPacket 89 | pkt, err = r.stream.Recv() 90 | if err != nil { 91 | break 92 | } 93 | 94 | if errStr := pkt.GetAck().GetError(); errStr != "" { 95 | return n, errors.New(errStr) 96 | } 97 | 98 | data := pkt.GetData() 99 | if len(data) == 0 { 100 | continue 101 | } 102 | 103 | // read as much as possible directly to the output 104 | rn = copy(readBuf, data) 105 | if rn < len(data) { 106 | // we read some of the data, buffer the rest. 107 | _, _ = r.buf.Write(data[rn:]) // never returns an error 108 | } 109 | } 110 | 111 | // advance readBuf by rn 112 | n += rn 113 | readBuf = readBuf[rn:] 114 | } 115 | return n, err 116 | } 117 | 118 | // Close closes the packet rw. 119 | func (r *RpcStreamReadWriter) Close() error { 120 | return r.stream.Close() 121 | } 122 | 123 | // _ is a type assertion 124 | var _ io.ReadWriteCloser = (*RpcStreamReadWriter)(nil) 125 | -------------------------------------------------------------------------------- /rpcstream/rpcstream.go: -------------------------------------------------------------------------------- 1 | package rpcstream 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/aperturerobotics/starpc/srpc" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | // RpcStream implements a RPC call stream over a RPC call. Used to implement 11 | // sub-components which have a different set of services & calls available. 12 | type RpcStream interface { 13 | srpc.Stream 14 | Send(*RpcStreamPacket) error 15 | Recv() (*RpcStreamPacket, error) 16 | } 17 | 18 | // RpcStreamGetter returns the Mux for the component ID from the remote. 19 | // The released function can be called to cancel the RPC stream if the Invoker is no longer valid. 20 | // Returns a release function to call when done with the Mux. 21 | // Returns nil, nil, nil if not found. 22 | type RpcStreamGetter func(ctx context.Context, componentID string, released func()) (srpc.Invoker, func(), error) 23 | 24 | // RpcStreamCaller is a function which starts the RpcStream call. 25 | type RpcStreamCaller[T RpcStream] func(ctx context.Context) (T, error) 26 | 27 | // OpenRpcStream opens a RPC stream with a remote. 28 | // 29 | // if waitAck is set, waits for acknowledgment from the remote before returning. 30 | func OpenRpcStream[T RpcStream](ctx context.Context, rpcCaller RpcStreamCaller[T], componentID string, waitAck bool) (RpcStream, error) { 31 | // open the rpc stream 32 | rpcStream, err := rpcCaller(ctx) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | // write the component id 38 | err = rpcStream.Send(&RpcStreamPacket{ 39 | Body: &RpcStreamPacket_Init{ 40 | Init: &RpcStreamInit{ 41 | ComponentId: componentID, 42 | }, 43 | }, 44 | }) 45 | if err != nil { 46 | _ = rpcStream.Close() 47 | return nil, err 48 | } 49 | 50 | // wait for ack 51 | if waitAck { 52 | pkt, err := rpcStream.Recv() 53 | if err == nil { 54 | switch b := pkt.GetBody().(type) { 55 | case *RpcStreamPacket_Ack: 56 | if errStr := b.Ack.GetError(); errStr != "" { 57 | err = errors.Errorf("remote: %s", errStr) 58 | } 59 | default: 60 | err = errors.New("expected ack packet") 61 | } 62 | } 63 | if err != nil { 64 | _ = rpcStream.Close() 65 | return nil, err 66 | } 67 | } 68 | 69 | return rpcStream, nil 70 | } 71 | 72 | // NewRpcStreamOpenStream constructs an OpenStream function with a RpcStream. 73 | // 74 | // if waitAck is set, OpenStream waits for acknowledgment from the remote. 75 | func NewRpcStreamOpenStream[T RpcStream](rpcCaller RpcStreamCaller[T], componentID string, waitAck bool) srpc.OpenStreamFunc { 76 | return func(ctx context.Context, msgHandler srpc.PacketDataHandler, closeHandler srpc.CloseHandler) (srpc.PacketWriter, error) { 77 | // open the stream 78 | rw, err := OpenRpcStream(ctx, rpcCaller, componentID, waitAck) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | // start the read pump 84 | go ReadPump(rw, msgHandler, closeHandler) 85 | // return the writer 86 | return NewRpcStreamWriter(rw), nil 87 | } 88 | } 89 | 90 | // NewRpcStreamClient constructs a Client which opens streams with a RpcStream. 91 | // 92 | // if waitAck is set, OpenStream waits for acknowledgment from the remote. 93 | func NewRpcStreamClient[T RpcStream](rpcCaller RpcStreamCaller[T], componentID string, waitAck bool) srpc.Client { 94 | openStream := NewRpcStreamOpenStream(rpcCaller, componentID, waitAck) 95 | return srpc.NewClient(openStream) 96 | } 97 | 98 | // HandleRpcStream handles an incoming RPC stream (remote is the initiator). 99 | func HandleRpcStream(stream RpcStream, getter RpcStreamGetter) error { 100 | // Read the "init" packet. 101 | initPkt, err := stream.Recv() 102 | if err != nil { 103 | return err 104 | } 105 | initInner, ok := initPkt.GetBody().(*RpcStreamPacket_Init) 106 | if !ok || initInner.Init == nil { 107 | return ErrUnexpectedPacket 108 | } 109 | 110 | ctx, ctxCancel := context.WithCancel(stream.Context()) 111 | defer ctxCancel() 112 | 113 | // lookup the server for this component id 114 | componentID := initInner.Init.GetComponentId() 115 | mux, muxRel, err := getter(ctx, componentID, ctxCancel) 116 | if err == nil && ctx.Err() != nil { 117 | err = context.Canceled 118 | } 119 | if err == nil && mux == nil { 120 | err = ErrNoServerForComponent 121 | } 122 | if muxRel != nil { 123 | defer muxRel() 124 | } 125 | 126 | // send ack 127 | var errStr string 128 | if err != nil { 129 | errStr = err.Error() 130 | } 131 | sendErr := stream.Send(&RpcStreamPacket{ 132 | Body: &RpcStreamPacket_Ack{ 133 | Ack: &RpcAck{Error: errStr}, 134 | }, 135 | }) 136 | if err != nil { 137 | return err 138 | } 139 | if sendErr != nil { 140 | return sendErr 141 | } 142 | 143 | // handle the rpc 144 | serverRPC := srpc.NewServerRPC(ctx, mux, NewRpcStreamWriter(stream)) 145 | go ReadPump(stream, serverRPC.HandlePacketData, serverRPC.HandleStreamClose) 146 | return serverRPC.Wait(ctx) 147 | } 148 | -------------------------------------------------------------------------------- /rpcstream/rpcstream.pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false" 2 | // @generated from file github.com/aperturerobotics/starpc/rpcstream/rpcstream.proto (package rpcstream, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import type { MessageType, PartialFieldInfo } from "@aptre/protobuf-es-lite"; 6 | import { createMessageType, ScalarType } from "@aptre/protobuf-es-lite"; 7 | 8 | export const protobufPackage = "rpcstream"; 9 | 10 | /** 11 | * RpcStreamInit is the first message in a RPC stream. 12 | * 13 | * @generated from message rpcstream.RpcStreamInit 14 | */ 15 | export interface RpcStreamInit { 16 | /** 17 | * ComponentId is the identifier of the component making the request. 18 | * 19 | * @generated from field: string component_id = 1; 20 | */ 21 | componentId?: string; 22 | 23 | }; 24 | 25 | // RpcStreamInit contains the message type declaration for RpcStreamInit. 26 | export const RpcStreamInit: MessageType = createMessageType({ 27 | typeName: "rpcstream.RpcStreamInit", 28 | fields: [ 29 | { no: 1, name: "component_id", kind: "scalar", T: ScalarType.STRING }, 30 | ] as readonly PartialFieldInfo[], 31 | packedByDefault: true, 32 | }); 33 | 34 | /** 35 | * RpcAck is the ack message in a RPC stream. 36 | * 37 | * @generated from message rpcstream.RpcAck 38 | */ 39 | export interface RpcAck { 40 | /** 41 | * Error indicates there was some error setting up the stream. 42 | * 43 | * @generated from field: string error = 1; 44 | */ 45 | error?: string; 46 | 47 | }; 48 | 49 | // RpcAck contains the message type declaration for RpcAck. 50 | export const RpcAck: MessageType = createMessageType({ 51 | typeName: "rpcstream.RpcAck", 52 | fields: [ 53 | { no: 1, name: "error", kind: "scalar", T: ScalarType.STRING }, 54 | ] as readonly PartialFieldInfo[], 55 | packedByDefault: true, 56 | }); 57 | 58 | /** 59 | * RpcStreamPacket is a packet encapsulating data for a RPC stream. 60 | * 61 | * @generated from message rpcstream.RpcStreamPacket 62 | */ 63 | export interface RpcStreamPacket { 64 | 65 | /** 66 | * @generated from oneof rpcstream.RpcStreamPacket.body 67 | */ 68 | body?: { 69 | value?: undefined, 70 | case: undefined 71 | } | { 72 | /** 73 | * Init is the first packet in the stream. 74 | * Sent by the initiator. 75 | * 76 | * @generated from field: rpcstream.RpcStreamInit init = 1; 77 | */ 78 | value: RpcStreamInit; 79 | case: "init"; 80 | } | { 81 | /** 82 | * Ack is sent in response to Init. 83 | * Sent by the server. 84 | * 85 | * @generated from field: rpcstream.RpcAck ack = 2; 86 | */ 87 | value: RpcAck; 88 | case: "ack"; 89 | } | { 90 | /** 91 | * Data is the encapsulated data packet. 92 | * 93 | * @generated from field: bytes data = 3; 94 | */ 95 | value: Uint8Array; 96 | case: "data"; 97 | }; 98 | 99 | }; 100 | 101 | // RpcStreamPacket contains the message type declaration for RpcStreamPacket. 102 | export const RpcStreamPacket: MessageType = createMessageType({ 103 | typeName: "rpcstream.RpcStreamPacket", 104 | fields: [ 105 | { no: 1, name: "init", kind: "message", T: () => RpcStreamInit, oneof: "body" }, 106 | { no: 2, name: "ack", kind: "message", T: () => RpcAck, oneof: "body" }, 107 | { no: 3, name: "data", kind: "scalar", T: ScalarType.BYTES, oneof: "body" }, 108 | ] as readonly PartialFieldInfo[], 109 | packedByDefault: true, 110 | }); 111 | 112 | -------------------------------------------------------------------------------- /rpcstream/rpcstream.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package rpcstream; 3 | 4 | // RpcStreamPacket is a packet encapsulating data for a RPC stream. 5 | message RpcStreamPacket { 6 | oneof body { 7 | // Init is the first packet in the stream. 8 | // Sent by the initiator. 9 | RpcStreamInit init = 1; 10 | // Ack is sent in response to Init. 11 | // Sent by the server. 12 | RpcAck ack = 2; 13 | // Data is the encapsulated data packet. 14 | bytes data = 3; 15 | } 16 | } 17 | 18 | // RpcStreamInit is the first message in a RPC stream. 19 | message RpcStreamInit { 20 | // ComponentId is the identifier of the component making the request. 21 | string component_id = 1; 22 | } 23 | 24 | // RpcAck is the ack message in a RPC stream. 25 | message RpcAck { 26 | // Error indicates there was some error setting up the stream. 27 | string error = 1; 28 | } 29 | -------------------------------------------------------------------------------- /rpcstream/writer.go: -------------------------------------------------------------------------------- 1 | package rpcstream 2 | 3 | import "github.com/aperturerobotics/starpc/srpc" 4 | 5 | // RpcStreamWriter implements the Writer only. 6 | type RpcStreamWriter struct { 7 | RpcStream 8 | } 9 | 10 | // NewRpcStreamWriter constructs a new rpc stream writer. 11 | func NewRpcStreamWriter(rpcStream RpcStream) *RpcStreamWriter { 12 | return &RpcStreamWriter{RpcStream: rpcStream} 13 | } 14 | 15 | // Write writes a packet to the writer. 16 | func (r *RpcStreamWriter) Write(p []byte) (n int, err error) { 17 | if len(p) == 0 { 18 | return 0, nil 19 | } 20 | err = r.Send(&RpcStreamPacket{ 21 | Body: &RpcStreamPacket_Data{ 22 | Data: p, 23 | }, 24 | }) 25 | if err != nil { 26 | return 0, err 27 | } 28 | return len(p), nil 29 | } 30 | 31 | // WritePacket writes a packet to the remote. 32 | func (r *RpcStreamWriter) WritePacket(p *srpc.Packet) error { 33 | pktData, err := p.MarshalVT() 34 | if err != nil { 35 | return err 36 | } 37 | _, err = r.Write(pktData) 38 | return err 39 | } 40 | 41 | // Close closes the writer. 42 | func (r *RpcStreamWriter) Close() error { 43 | return r.CloseSend() 44 | } 45 | 46 | // _ is a type assertion 47 | var _ srpc.PacketWriter = ((*RpcStreamWriter)(nil)) 48 | -------------------------------------------------------------------------------- /srpc/accept.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "net" 6 | 7 | "github.com/libp2p/go-yamux/v4" 8 | ) 9 | 10 | // AcceptMuxedListener accepts incoming connections from a net.Listener. 11 | // 12 | // Uses the default yamux muxer. 13 | // If yamux conf is nil, uses the defaults. 14 | func AcceptMuxedListener(ctx context.Context, lis net.Listener, srv *Server, yamuxConf *yamux.Config) error { 15 | for { 16 | nc, err := lis.Accept() 17 | if err != nil { 18 | return err 19 | } 20 | 21 | mc, err := NewMuxedConn(nc, false, yamuxConf) 22 | if err != nil { 23 | _ = nc.Close() 24 | continue 25 | } 26 | 27 | if err := srv.AcceptMuxedConn(ctx, mc); err != nil { 28 | _ = nc.Close() 29 | continue 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /srpc/array-list.ts: -------------------------------------------------------------------------------- 1 | import type { Source, Transform } from 'it-stream-types' 2 | import { isUint8ArrayList, Uint8ArrayList } from 'uint8arraylist' 3 | 4 | // combineUint8ArrayListTransform combines a Uint8ArrayList into a Uint8Array. 5 | export function combineUint8ArrayListTransform(): Transform< 6 | Source, 7 | AsyncGenerator 8 | > { 9 | return async function* decodeMessageSource( 10 | source: Source, 11 | ): AsyncGenerator { 12 | for await (const obj of source) { 13 | if (isUint8ArrayList(obj)) { 14 | yield obj.subarray() 15 | } else { 16 | yield obj 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /srpc/broadcast-channel.ts: -------------------------------------------------------------------------------- 1 | import type { Duplex, Source } from 'it-stream-types' 2 | import { EventIterator } from 'event-iterator' 3 | 4 | import { StreamConn, StreamConnParams } from './conn.js' 5 | import { Server } from './server.js' 6 | import { combineUint8ArrayListTransform } from './array-list.js' 7 | import { pipe } from 'it-pipe' 8 | 9 | // BroadcastChannelDuplex is a AsyncIterable wrapper for BroadcastChannel. 10 | // 11 | // When the sink is closed, the broadcast channel also be closed. 12 | // Note: there is no way to know when a BroadcastChannel is closed! 13 | // You will need an additional keep-alive on top of BroadcastChannelDuplex. 14 | export class BroadcastChannelDuplex 15 | implements Duplex, Source, Promise> 16 | { 17 | // read is the read channel 18 | public readonly read: BroadcastChannel 19 | // write is the write channel 20 | public readonly write: BroadcastChannel 21 | // sink is the sink for incoming messages. 22 | public sink: (source: Source) => Promise 23 | // source is the source for outgoing messages. 24 | public source: AsyncGenerator 25 | 26 | constructor(read: BroadcastChannel, write: BroadcastChannel) { 27 | this.read = read 28 | this.write = write 29 | this.sink = this._createSink() 30 | this.source = this._createSource() 31 | } 32 | 33 | // close closes the message port. 34 | public close() { 35 | this.write.postMessage(null) 36 | this.write.close() 37 | this.read.close() 38 | } 39 | 40 | // _createSink initializes the sink field. 41 | private _createSink(): (source: Source) => Promise { 42 | return async (source) => { 43 | try { 44 | for await (const msg of source) { 45 | this.write.postMessage(msg) 46 | } 47 | } catch (err: unknown) { 48 | this.close() 49 | throw err 50 | } 51 | 52 | this.close() 53 | } 54 | } 55 | 56 | // _createSource initializes the source field. 57 | private async *_createSource(): AsyncGenerator { 58 | const iterator = new EventIterator((queue) => { 59 | const messageListener = (ev: MessageEvent) => { 60 | const data = ev.data 61 | if (data !== null) { 62 | queue.push(data) 63 | } else { 64 | queue.stop() 65 | } 66 | } 67 | 68 | this.read.addEventListener('message', messageListener) 69 | return () => { 70 | this.read.removeEventListener('message', messageListener) 71 | } 72 | }) 73 | 74 | try { 75 | for await (const value of iterator) { 76 | yield value 77 | } 78 | } catch (err) { 79 | this.close() 80 | throw err 81 | } 82 | 83 | this.close() 84 | } 85 | } 86 | 87 | // newBroadcastChannelDuplex constructs a BroadcastChannelDuplex with a channel name. 88 | export function newBroadcastChannelDuplex( 89 | readName: string, 90 | writeName: string, 91 | ): BroadcastChannelDuplex { 92 | return new BroadcastChannelDuplex( 93 | new BroadcastChannel(readName), 94 | new BroadcastChannel(writeName), 95 | ) 96 | } 97 | 98 | // BroadcastChannelConn implements a connection with a BroadcastChannel. 99 | // 100 | // expects Uint8Array objects over the BroadcastChannel. 101 | // uses Yamux to mux streams over the port. 102 | export class BroadcastChannelConn extends StreamConn { 103 | // duplex is the broadcast channel duplex. 104 | public readonly duplex: BroadcastChannelDuplex 105 | 106 | constructor( 107 | duplex: BroadcastChannelDuplex, 108 | server?: Server, 109 | connParams?: StreamConnParams, 110 | ) { 111 | super(server, { 112 | ...connParams, 113 | yamuxParams: { 114 | // There is no way to tell when a BroadcastChannel is closed. 115 | // We will send an undefined object through the BroadcastChannel to indicate closed. 116 | // We still need a way to detect when the connection is not cleanly terminated. 117 | // Enable keep-alive to detect this on the other end. 118 | enableKeepAlive: true, 119 | keepAliveInterval: 1500, 120 | ...connParams?.yamuxParams, 121 | }, 122 | }) 123 | this.duplex = duplex 124 | pipe( 125 | duplex, 126 | this, 127 | // Uint8ArrayList usually cannot be sent over BroadcastChannel, so we combine to a Uint8Array as part of the pipe. 128 | combineUint8ArrayListTransform(), 129 | duplex, 130 | ) 131 | .catch((err) => this.close(err)) 132 | .then(() => this.close()) 133 | } 134 | 135 | // close closes the message port. 136 | public override close(err?: Error) { 137 | super.close(err) 138 | this.duplex.close() 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /srpc/client-prefix.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import "context" 4 | 5 | // PrefixClient checks for and strips a set of prefixes from a Client. 6 | type PrefixClient struct { 7 | // client is the underlying client 8 | client Client 9 | // serviceIDPrefixes is the list of service id prefixes to match. 10 | serviceIDPrefixes []string 11 | } 12 | 13 | // NewPrefixClient constructs a new PrefixClient. 14 | // 15 | // serviceIDPrefixes is the list of service id prefixes to match. 16 | // strips the prefix before calling the underlying Invoke function. 17 | // if none of the prefixes match, returns unimplemented. 18 | // if empty: forwards all services w/o stripping any prefix. 19 | func NewPrefixClient(client Client, serviceIDPrefixes []string) *PrefixClient { 20 | return &PrefixClient{ 21 | client: client, 22 | serviceIDPrefixes: serviceIDPrefixes, 23 | } 24 | } 25 | 26 | // ExecCall executes a request/reply RPC with the remote. 27 | func (i *PrefixClient) ExecCall(ctx context.Context, service, method string, in, out Message) error { 28 | service, err := i.stripCheckServiceIDPrefix(service) 29 | if err != nil { 30 | return err 31 | } 32 | return i.client.ExecCall(ctx, service, method, in, out) 33 | } 34 | 35 | // NewStream starts a streaming RPC with the remote & returns the stream. 36 | // firstMsg is optional. 37 | func (i *PrefixClient) NewStream(ctx context.Context, service, method string, firstMsg Message) (Stream, error) { 38 | service, err := i.stripCheckServiceIDPrefix(service) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return i.client.NewStream(ctx, service, method, firstMsg) 43 | } 44 | 45 | // stripCheckServiceIDPrefix strips the prefix & returns unimplemented if necessary. 46 | func (i *PrefixClient) stripCheckServiceIDPrefix(service string) (string, error) { 47 | if len(i.serviceIDPrefixes) != 0 { 48 | strippedID, matchedPrefix := CheckStripPrefix(service, i.serviceIDPrefixes) 49 | if len(matchedPrefix) == 0 { 50 | return service, ErrUnimplemented 51 | } 52 | return strippedID, nil 53 | } 54 | return service, nil 55 | } 56 | 57 | // _ is a type assertion 58 | var _ Client = ((*PrefixClient)(nil)) 59 | -------------------------------------------------------------------------------- /srpc/client-rpc.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // ClientRPC represents the client side of an on-going RPC call message stream. 10 | type ClientRPC struct { 11 | commonRPC 12 | } 13 | 14 | // NewClientRPC constructs a new ClientRPC session and writes CallStart. 15 | // the writer will be closed when the ClientRPC completes. 16 | // service and method must be specified. 17 | // must call Start after creating the RPC object. 18 | func NewClientRPC(ctx context.Context, service, method string) *ClientRPC { 19 | rpc := &ClientRPC{} 20 | initCommonRPC(ctx, &rpc.commonRPC) 21 | rpc.service = service 22 | rpc.method = method 23 | return rpc 24 | } 25 | 26 | // Start sets the writer and writes the MsgSend message. 27 | // must only be called once! 28 | func (r *ClientRPC) Start(writer PacketWriter, writeFirstMsg bool, firstMsg []byte) error { 29 | if writer == nil { 30 | return ErrNilWriter 31 | } 32 | 33 | if err := r.ctx.Err(); err != nil { 34 | r.ctxCancel() 35 | _ = writer.Close() 36 | return context.Canceled 37 | } 38 | 39 | var firstMsgEmpty bool 40 | var err error 41 | r.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 42 | r.writer = writer 43 | 44 | if writeFirstMsg { 45 | firstMsgEmpty = len(firstMsg) == 0 46 | } 47 | 48 | pkt := NewCallStartPacket(r.service, r.method, firstMsg, firstMsgEmpty) 49 | err = writer.WritePacket(pkt) 50 | if err != nil { 51 | r.ctxCancel() 52 | _ = writer.Close() 53 | } 54 | 55 | broadcast() 56 | }) 57 | 58 | return err 59 | } 60 | 61 | // HandlePacketData handles an incoming unparsed message packet. 62 | func (r *ClientRPC) HandlePacketData(data []byte) error { 63 | pkt := &Packet{} 64 | if err := pkt.UnmarshalVT(data); err != nil { 65 | return err 66 | } 67 | return r.HandlePacket(pkt) 68 | } 69 | 70 | // HandleStreamClose handles the stream closing optionally w/ an error. 71 | func (r *ClientRPC) HandleStreamClose(closeErr error) { 72 | r.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 73 | if closeErr != nil && r.remoteErr == nil { 74 | r.remoteErr = closeErr 75 | } 76 | r.dataClosed = true 77 | r.ctxCancel() 78 | broadcast() 79 | }) 80 | } 81 | 82 | // HandlePacket handles an incoming parsed message packet. 83 | func (r *ClientRPC) HandlePacket(msg *Packet) error { 84 | if err := msg.Validate(); err != nil { 85 | return err 86 | } 87 | 88 | switch b := msg.GetBody().(type) { 89 | case *Packet_CallStart: 90 | return r.HandleCallStart(b.CallStart) 91 | case *Packet_CallData: 92 | return r.HandleCallData(b.CallData) 93 | case *Packet_CallCancel: 94 | if b.CallCancel { 95 | return r.HandleCallCancel() 96 | } 97 | return nil 98 | default: 99 | return nil 100 | } 101 | } 102 | 103 | // HandleCallStart handles the call start packet. 104 | func (r *ClientRPC) HandleCallStart(pkt *CallStart) error { 105 | // server-to-client calls not supported 106 | return errors.Wrap(ErrUnrecognizedPacket, "call start packet unexpected") 107 | } 108 | 109 | // Close releases any resources held by the ClientRPC. 110 | func (r *ClientRPC) Close() { 111 | r.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 112 | // call did not start yet if writer is nil. 113 | if r.writer != nil { 114 | _ = r.WriteCallCancel() 115 | r.closeLocked(broadcast) 116 | } 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /srpc/client-rpc.ts: -------------------------------------------------------------------------------- 1 | import type { CompleteMessage } from '@aptre/protobuf-es-lite' 2 | import type { CallStart } from './rpcproto.pb.js' 3 | import { CommonRPC } from './common-rpc.js' 4 | 5 | // ClientRPC is an ongoing RPC from the client side. 6 | export class ClientRPC extends CommonRPC { 7 | constructor(service: string, method: string) { 8 | super() 9 | this.service = service 10 | this.method = method 11 | } 12 | 13 | // writeCallStart writes the call start packet. 14 | // if data === undefined and data.length === 0 sends empty data packet. 15 | public async writeCallStart(data?: Uint8Array) { 16 | if (!this.service || !this.method) { 17 | throw new Error('service and method must be set') 18 | } 19 | const callStart: CompleteMessage = { 20 | rpcService: this.service, 21 | rpcMethod: this.method, 22 | data: data || new Uint8Array(0), 23 | dataIsZero: !!data && data.length === 0, 24 | } 25 | await this.writePacket({ 26 | body: { 27 | case: 'callStart', 28 | value: callStart, 29 | }, 30 | }) 31 | } 32 | 33 | // handleCallStart handles a CallStart packet. 34 | public override async handleCallStart(packet: CallStart) { 35 | // we do not implement server -> client RPCs. 36 | throw new Error( 37 | `unexpected server to client rpc: ${packet.rpcService || ''}/${packet.rpcMethod || ''}`, 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /srpc/client-set.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // ClientSet wraps a list of clients into one Client. 8 | type ClientSet struct { 9 | clients []Client 10 | } 11 | 12 | // NewClientSet constructs a new client set. 13 | func NewClientSet(clients []Client) *ClientSet { 14 | return &ClientSet{clients: clients} 15 | } 16 | 17 | // ExecCall executes a request/reply RPC with the remote. 18 | func (c *ClientSet) ExecCall( 19 | ctx context.Context, 20 | service, method string, 21 | in, out Message, 22 | ) error { 23 | return c.execCall(ctx, func(client Client) error { 24 | return client.ExecCall(ctx, service, method, in, out) 25 | }) 26 | } 27 | 28 | // NewStream starts a streaming RPC with the remote & returns the stream. 29 | // firstMsg is optional. 30 | func (c *ClientSet) NewStream( 31 | ctx context.Context, 32 | service, method string, 33 | firstMsg Message, 34 | ) (Stream, error) { 35 | var strm Stream 36 | err := c.execCall(ctx, func(client Client) error { 37 | var err error 38 | strm, err = client.NewStream(ctx, service, method, firstMsg) 39 | return err 40 | }) 41 | return strm, err 42 | } 43 | 44 | // execCall executes the call conditionally retrying against subsequent client handles. 45 | func (c *ClientSet) execCall(ctx context.Context, doCall func(client Client) error) error { 46 | var any bool 47 | for _, client := range c.clients { 48 | if client == nil { 49 | continue 50 | } 51 | err := doCall(client) 52 | any = true 53 | if err == nil { 54 | return nil 55 | } 56 | if err == context.Canceled { 57 | select { 58 | case <-ctx.Done(): 59 | return context.Canceled 60 | default: 61 | continue 62 | } 63 | } 64 | if err.Error() == ErrUnimplemented.Error() { 65 | continue 66 | } 67 | return err 68 | } 69 | 70 | if !any { 71 | return ErrNoAvailableClients 72 | } 73 | 74 | return ErrUnimplemented 75 | } 76 | 77 | // _ is a type assertion 78 | var _ Client = ((*ClientSet)(nil)) 79 | -------------------------------------------------------------------------------- /srpc/client-verbose.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "sync/atomic" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // VClient implements a verbose SRPC client which can log RPC streams. 12 | type VClient struct { 13 | le *logrus.Entry 14 | client Client 15 | execID atomic.Int32 16 | } 17 | 18 | // NewVClient constructs a new verbose client wrapper. 19 | func NewVClient(c Client, le *logrus.Entry) *VClient { 20 | return &VClient{le: le, client: c} 21 | } 22 | 23 | // ExecCall executes a request/reply RPC with the remote. 24 | func (c *VClient) ExecCall(ctx context.Context, service, method string, in, out Message) (err error) { 25 | t1 := time.Now() 26 | id := c.execID.Add(1) - 1 27 | c.le.Debugf( 28 | "ExecCall(service(%s), method(%s)) => id(%d) started", 29 | service, 30 | method, 31 | id, 32 | ) 33 | defer func() { 34 | c.le.Debugf( 35 | "ExecCall(service(%s), method(%s)) => id(%d) dur(%v) err(%v)", 36 | service, 37 | method, 38 | id, 39 | time.Since(t1).String(), 40 | err, 41 | ) 42 | }() 43 | 44 | err = c.client.ExecCall(ctx, service, method, in, out) 45 | return err 46 | } 47 | 48 | // NewStream starts a streaming RPC with the remote & returns the stream. 49 | // firstMsg is optional. 50 | func (c *VClient) NewStream(ctx context.Context, service, method string, firstMsg Message) (stream Stream, err error) { 51 | t1 := time.Now() 52 | defer func() { 53 | c.le.Debugf( 54 | "NewStream(service(%s), method(%s)) => dur(%v) err(%v)", 55 | service, 56 | method, 57 | time.Since(t1).String(), 58 | err, 59 | ) 60 | }() 61 | stream, err = c.client.NewStream(ctx, service, method, firstMsg) 62 | return stream, err 63 | } 64 | -------------------------------------------------------------------------------- /srpc/client.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // Client implements a SRPC client which can initiate RPC streams. 10 | type Client interface { 11 | // ExecCall executes a request/reply RPC with the remote. 12 | ExecCall(ctx context.Context, service, method string, in, out Message) error 13 | 14 | // NewStream starts a streaming RPC with the remote & returns the stream. 15 | // firstMsg is optional. 16 | NewStream(ctx context.Context, service, method string, firstMsg Message) (Stream, error) 17 | } 18 | 19 | // OpenStreamFunc opens a stream with a remote. 20 | // msgHandler must not be called concurrently. 21 | type OpenStreamFunc = func( 22 | ctx context.Context, 23 | msgHandler PacketDataHandler, 24 | closeHandler CloseHandler, 25 | ) (PacketWriter, error) 26 | 27 | // client implements Client with a transport. 28 | type client struct { 29 | // openStream opens a new stream. 30 | openStream OpenStreamFunc 31 | } 32 | 33 | // NewClient constructs a client with a OpenStreamFunc. 34 | func NewClient(openStream OpenStreamFunc) Client { 35 | return &client{ 36 | openStream: openStream, 37 | } 38 | } 39 | 40 | // ExecCall executes a request/reply RPC with the remote. 41 | func (c *client) ExecCall(ctx context.Context, service, method string, in, out Message) error { 42 | firstMsg, err := in.MarshalVT() 43 | if err != nil { 44 | return err 45 | } 46 | 47 | clientRPC := NewClientRPC(ctx, service, method) 48 | defer clientRPC.Close() 49 | 50 | writer, err := c.openStream(ctx, clientRPC.HandlePacketData, clientRPC.HandleStreamClose) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if err := clientRPC.Start(writer, true, firstMsg); err != nil { 56 | return err 57 | } 58 | 59 | msg, err := clientRPC.ReadOne() 60 | if err != nil { 61 | // this includes any server returned error. 62 | return err 63 | } 64 | if err := out.UnmarshalVT(msg); err != nil { 65 | return errors.Wrap(ErrInvalidMessage, err.Error()) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // NewStream starts a streaming RPC with the remote & returns the stream. 72 | // firstMsg is optional. 73 | func (c *client) NewStream(ctx context.Context, service, method string, firstMsg Message) (Stream, error) { 74 | var firstMsgData []byte 75 | if firstMsg != nil { 76 | var err error 77 | firstMsgData, err = firstMsg.MarshalVT() 78 | if err != nil { 79 | return nil, err 80 | } 81 | } 82 | 83 | clientRPC := NewClientRPC(ctx, service, method) 84 | writer, err := c.openStream(ctx, clientRPC.HandlePacketData, clientRPC.HandleStreamClose) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | if err := clientRPC.Start(writer, firstMsg != nil, firstMsgData); err != nil { 90 | return nil, err 91 | } 92 | 93 | return NewMsgStream(ctx, clientRPC, func() { 94 | clientRPC.ctxCancel() 95 | _ = writer.Close() 96 | }), nil 97 | } 98 | 99 | // _ is a type assertion 100 | var _ Client = ((*client)(nil)) 101 | -------------------------------------------------------------------------------- /srpc/client.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'it-pipe' 2 | import { pushable, Pushable } from 'it-pushable' 3 | 4 | import { ERR_RPC_ABORT } from './errors.js' 5 | import type { ProtoRpc } from './proto-rpc.js' 6 | import type { OpenStreamFunc } from './stream.js' 7 | import { ClientRPC } from './client-rpc.js' 8 | import { writeToPushable } from './pushable.js' 9 | import { decodePacketSource, encodePacketSource } from './packet.js' 10 | import { OpenStreamCtr } from './open-stream-ctr.js' 11 | 12 | // Client implements the ts-proto Rpc interface with the drpcproto protocol. 13 | export class Client implements ProtoRpc { 14 | // openStreamCtr contains the OpenStreamFunc. 15 | private openStreamCtr: OpenStreamCtr 16 | 17 | constructor(openStreamFn?: OpenStreamFunc) { 18 | this.openStreamCtr = new OpenStreamCtr(openStreamFn || undefined) 19 | } 20 | 21 | // setOpenStreamFn updates the openStreamFn for the Client. 22 | public setOpenStreamFn(openStreamFn?: OpenStreamFunc) { 23 | this.openStreamCtr.set(openStreamFn || undefined) 24 | } 25 | 26 | // request starts a non-streaming request. 27 | public async request( 28 | service: string, 29 | method: string, 30 | data: Uint8Array, 31 | abortSignal?: AbortSignal, 32 | ): Promise { 33 | const call = await this.startRpc(service, method, data, abortSignal) 34 | for await (const data of call.rpcDataSource) { 35 | call.close() 36 | return data 37 | } 38 | const err = new Error('empty response') 39 | call.close(err) 40 | throw err 41 | } 42 | 43 | // clientStreamingRequest starts a client side streaming request. 44 | public async clientStreamingRequest( 45 | service: string, 46 | method: string, 47 | data: AsyncIterable, 48 | abortSignal?: AbortSignal, 49 | ): Promise { 50 | const call = await this.startRpc(service, method, null, abortSignal) 51 | call.writeCallDataFromSource(data).catch((err) => call.close(err)) 52 | for await (const data of call.rpcDataSource) { 53 | call.close() 54 | return data 55 | } 56 | const err = new Error('empty response') 57 | call.close(err) 58 | throw err 59 | } 60 | 61 | // serverStreamingRequest starts a server-side streaming request. 62 | public serverStreamingRequest( 63 | service: string, 64 | method: string, 65 | data: Uint8Array, 66 | abortSignal?: AbortSignal, 67 | ): AsyncIterable { 68 | const serverData: Pushable = pushable({ objectMode: true }) 69 | this.startRpc(service, method, data, abortSignal) 70 | .then(async (call) => writeToPushable(call.rpcDataSource, serverData)) 71 | .catch((err) => serverData.end(err)) 72 | return serverData 73 | } 74 | 75 | // bidirectionalStreamingRequest starts a two-way streaming request. 76 | public bidirectionalStreamingRequest( 77 | service: string, 78 | method: string, 79 | data: AsyncIterable, 80 | abortSignal?: AbortSignal, 81 | ): AsyncIterable { 82 | const serverData: Pushable = pushable({ objectMode: true }) 83 | this.startRpc(service, method, null, abortSignal) 84 | .then(async (call) => { 85 | const handleErr = (err: Error) => { 86 | serverData.end(err) 87 | call.close(err) 88 | } 89 | call.writeCallDataFromSource(data).catch(handleErr) 90 | try { 91 | for await (const message of call.rpcDataSource) { 92 | serverData.push(message) 93 | } 94 | serverData.end() 95 | call.close() 96 | } catch (err) { 97 | handleErr(err as Error) 98 | } 99 | }) 100 | .catch((err) => serverData.end(err)) 101 | return serverData 102 | } 103 | 104 | // startRpc is a common utility function to begin a rpc call. 105 | // throws any error starting the rpc call 106 | // if data == null and data.length == 0, sends a separate data packet. 107 | private async startRpc( 108 | rpcService: string, 109 | rpcMethod: string, 110 | data: Uint8Array | null, 111 | abortSignal?: AbortSignal, 112 | ): Promise { 113 | if (abortSignal?.aborted) { 114 | throw new Error(ERR_RPC_ABORT) 115 | } 116 | const openStreamFn = await this.openStreamCtr.wait() 117 | const stream = await openStreamFn() 118 | const call = new ClientRPC(rpcService, rpcMethod) 119 | abortSignal?.addEventListener('abort', () => { 120 | call.writeCallCancel() 121 | call.close(new Error(ERR_RPC_ABORT)) 122 | }) 123 | pipe(stream, decodePacketSource, call, encodePacketSource, stream) 124 | .catch((err) => call.close(err)) 125 | .then(() => call.close()) 126 | await call.writeCallStart(data ?? undefined) 127 | return call 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /srpc/common-rpc.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/aperturerobotics/util/broadcast" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // commonRPC contains common logic between server/client rpc. 12 | type commonRPC struct { 13 | // ctx is the context, canceled when the rpc ends. 14 | ctx context.Context 15 | // ctxCancel is called when the rpc ends. 16 | ctxCancel context.CancelFunc 17 | // service is the rpc service 18 | service string 19 | // method is the rpc method 20 | method string 21 | // bcast guards below fields 22 | bcast broadcast.Broadcast 23 | // writer is the writer to write messages to 24 | writer PacketWriter 25 | // dataQueue contains incoming data packets. 26 | // note: packets may be len() == 0 27 | dataQueue [][]byte 28 | // dataClosed is a flag set after dataQueue is closed. 29 | // controlled by HandlePacket. 30 | dataClosed bool 31 | // remoteErr is an error set by the remote. 32 | remoteErr error 33 | } 34 | 35 | // initCommonRPC initializes the commonRPC. 36 | func initCommonRPC(ctx context.Context, rpc *commonRPC) { 37 | rpc.ctx, rpc.ctxCancel = context.WithCancel(ctx) 38 | } 39 | 40 | // Context is canceled when the rpc has finished. 41 | func (c *commonRPC) Context() context.Context { 42 | return c.ctx 43 | } 44 | 45 | // Wait waits for the RPC to finish (remote end closed the stream). 46 | func (c *commonRPC) Wait(ctx context.Context) error { 47 | for { 48 | var err error 49 | var waitCh <-chan struct{} 50 | var rpcCtx context.Context 51 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 52 | rpcCtx, err = c.ctx, c.remoteErr 53 | waitCh = getWaitCh() 54 | }) 55 | 56 | if err != nil { 57 | return err 58 | } 59 | if rpcCtx.Err() != nil { 60 | // rpc must have ended w/o an error being set 61 | return context.Canceled 62 | } 63 | 64 | select { 65 | case <-ctx.Done(): 66 | return context.Canceled 67 | case <-rpcCtx.Done(): 68 | case <-waitCh: 69 | } 70 | } 71 | } 72 | 73 | // ReadOne reads a single message and returns. 74 | // 75 | // returns io.EOF if the stream ended without a packet. 76 | func (c *commonRPC) ReadOne() ([]byte, error) { 77 | var hasMsg bool 78 | var msg []byte 79 | var err error 80 | var ctxDone bool 81 | for { 82 | var waitCh <-chan struct{} 83 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 84 | if ctxDone && !c.dataClosed { 85 | // context must have been canceled locally 86 | c.closeLocked(broadcast) 87 | err = context.Canceled 88 | return 89 | } 90 | 91 | if len(c.dataQueue) != 0 { 92 | msg = c.dataQueue[0] 93 | hasMsg = true 94 | c.dataQueue[0] = nil 95 | c.dataQueue = c.dataQueue[1:] 96 | } else if c.dataClosed || c.remoteErr != nil { 97 | err = c.remoteErr 98 | if err == nil { 99 | err = io.EOF 100 | } 101 | } 102 | 103 | waitCh = getWaitCh() 104 | }) 105 | 106 | if hasMsg { 107 | return msg, nil 108 | } 109 | 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | select { 115 | case <-c.ctx.Done(): 116 | ctxDone = true 117 | case <-waitCh: 118 | } 119 | } 120 | } 121 | 122 | // WriteCallData writes a call data packet. 123 | func (c *commonRPC) WriteCallData(data []byte, complete bool, err error) error { 124 | outPkt := NewCallDataPacket(data, len(data) == 0, complete, err) 125 | return c.writer.WritePacket(outPkt) 126 | } 127 | 128 | // HandleStreamClose handles the incoming stream closing w/ optional error. 129 | func (c *commonRPC) HandleStreamClose(closeErr error) { 130 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 131 | if closeErr != nil && c.remoteErr == nil { 132 | c.remoteErr = closeErr 133 | } 134 | c.dataClosed = true 135 | c.ctxCancel() 136 | _ = c.writer.Close() 137 | broadcast() 138 | }) 139 | } 140 | 141 | // HandleCallCancel handles the call cancel packet. 142 | func (c *commonRPC) HandleCallCancel() error { 143 | c.HandleStreamClose(context.Canceled) 144 | return nil 145 | } 146 | 147 | // HandleCallData handles the call data packet. 148 | func (c *commonRPC) HandleCallData(pkt *CallData) error { 149 | var err error 150 | c.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 151 | if c.dataClosed { 152 | err = ErrCompleted 153 | return 154 | } 155 | 156 | if data := pkt.GetData(); len(data) != 0 || pkt.GetDataIsZero() { 157 | c.dataQueue = append(c.dataQueue, data) 158 | } 159 | 160 | complete := pkt.GetComplete() 161 | if err := pkt.GetError(); len(err) != 0 { 162 | complete = true 163 | c.remoteErr = errors.New(err) 164 | } 165 | 166 | if complete { 167 | c.dataClosed = true 168 | } 169 | 170 | broadcast() 171 | }) 172 | 173 | return err 174 | } 175 | 176 | // WriteCallCancel writes a call cancel packet. 177 | func (c *commonRPC) WriteCallCancel() error { 178 | return c.writer.WritePacket(NewCallCancelPacket()) 179 | } 180 | 181 | // closeLocked releases resources held by the RPC. 182 | func (c *commonRPC) closeLocked(broadcast func()) { 183 | c.dataClosed = true 184 | if c.remoteErr == nil { 185 | c.remoteErr = context.Canceled 186 | } 187 | _ = c.writer.Close() 188 | broadcast() 189 | c.ctxCancel() 190 | } 191 | -------------------------------------------------------------------------------- /srpc/conn.ts: -------------------------------------------------------------------------------- 1 | import { YamuxMuxerInit, yamux } from '@chainsafe/libp2p-yamux' 2 | import type { 3 | Direction, 4 | Stream, 5 | StreamMuxer, 6 | StreamMuxerFactory, 7 | } from '@libp2p/interface' 8 | import type { Duplex } from 'it-stream-types' 9 | import { Uint8ArrayList } from 'uint8arraylist' 10 | import { defaultLogger } from '@libp2p/logger' 11 | 12 | import { 13 | streamToPacketStream, 14 | type OpenStreamFunc, 15 | type PacketStream, 16 | } from './stream.js' 17 | import { Client } from './client.js' 18 | 19 | // ConnParams are parameters that can be passed to the StreamConn constructor. 20 | export interface StreamConnParams { 21 | // muxerFactory overrides using the default yamux factory. 22 | muxerFactory?: StreamMuxerFactory 23 | // direction is the muxer connection direction. 24 | // defaults to outbound (client). 25 | direction?: Direction 26 | // yamuxParams are parameters to pass to yamux. 27 | // only used if muxerFactory is unset 28 | yamuxParams?: YamuxMuxerInit 29 | } 30 | 31 | // StreamHandler handles incoming streams. 32 | // Implemented by Server. 33 | export interface StreamHandler { 34 | // handlePacketStream handles an incoming Uint8Array duplex. 35 | // the stream has one Uint8Array per packet w/o length prefix. 36 | handlePacketStream(strm: PacketStream): void 37 | } 38 | 39 | // StreamConn implements a generic connection with a two-way stream. 40 | // The stream is not expected to manage packet boundaries. 41 | // Packets will be sent with uint32le length prefixes. 42 | // Uses Yamux to manage streams over the connection. 43 | // 44 | // Implements the client by opening streams with the remote. 45 | // Implements the server by handling incoming streams. 46 | // If the server is unset, rejects any incoming streams. 47 | export class StreamConn 48 | implements Duplex> 49 | { 50 | // muxer is the stream muxer. 51 | private _muxer: StreamMuxer 52 | // server is the server side, if set. 53 | private _server?: StreamHandler 54 | 55 | constructor(server?: StreamHandler, connParams?: StreamConnParams) { 56 | if (server) { 57 | this._server = server 58 | } 59 | const muxerFactory = 60 | connParams?.muxerFactory ?? 61 | yamux({ enableKeepAlive: false, ...connParams?.yamuxParams })({ 62 | logger: defaultLogger(), 63 | }) 64 | this._muxer = muxerFactory.createStreamMuxer({ 65 | onIncomingStream: this.handleIncomingStream.bind(this), 66 | direction: connParams?.direction || 'outbound', 67 | }) 68 | } 69 | 70 | // sink returns the message sink. 71 | get sink() { 72 | return this._muxer.sink 73 | } 74 | 75 | // source returns the outgoing message source. 76 | get source() { 77 | return this._muxer.source 78 | } 79 | 80 | // streams returns the set of all ongoing streams. 81 | get streams() { 82 | return this._muxer.streams 83 | } 84 | 85 | // muxer returns the muxer 86 | get muxer() { 87 | return this._muxer 88 | } 89 | 90 | // server returns the server, if any. 91 | get server() { 92 | return this._server 93 | } 94 | 95 | // buildClient builds a new client from the connection. 96 | public buildClient(): Client { 97 | return new Client(this.openStream.bind(this)) 98 | } 99 | 100 | // openStream implements the client open stream function. 101 | public async openStream(): Promise { 102 | const strm = await this.muxer.newStream() 103 | return streamToPacketStream(strm) 104 | } 105 | 106 | // buildOpenStreamFunc returns openStream bound to this conn. 107 | public buildOpenStreamFunc(): OpenStreamFunc { 108 | return this.openStream.bind(this) 109 | } 110 | 111 | // handleIncomingStream handles an incoming stream. 112 | // 113 | // this is usually called by the muxer when streams arrive. 114 | public handleIncomingStream(strm: Stream) { 115 | const server = this.server 116 | if (!server) { 117 | return strm.abort(new Error('server not implemented')) 118 | } 119 | server.handlePacketStream(streamToPacketStream(strm)) 120 | } 121 | 122 | // close closes or aborts the muxer with an optional error. 123 | public close(err?: Error) { 124 | if (err) { 125 | this.muxer.abort(err) 126 | } else { 127 | this.muxer.close() 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /srpc/definition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MethodIdempotency, 3 | MethodKind, 4 | MessageType, 5 | } from '@aptre/protobuf-es-lite' 6 | 7 | // ServiceMethodDefinitions is the type of the methods map on the service definition. 8 | export type ServiceMethodDefinitions = { 9 | [id: string]: MethodDefinition< 10 | MessageType, 11 | MessageType, 12 | MethodKind, 13 | MethodIdempotency | undefined 14 | > 15 | } 16 | 17 | // ServiceDefinition describes the service definitions generated by protoc-gen-es-starpc. 18 | export interface ServiceDefinition< 19 | MethodDefinitions extends ServiceMethodDefinitions, 20 | > { 21 | // typeName is the fully qualified name of the service 22 | // Example: echo.Echoer 23 | typeName: string 24 | // methods is the set of available RPC methods. 25 | methods: MethodDefinitions 26 | } 27 | 28 | // MethodDefinition describes the service method definitions generated by protoc-gen-es-starpc. 29 | export interface MethodDefinition< 30 | RequestT extends MessageType, 31 | ResponseT extends MessageType, 32 | MethodK extends MethodKind, 33 | MethodI extends MethodIdempotency | undefined, 34 | > { 35 | // name is the name and function name of the method. 36 | // e.x.: Echo 37 | name: string 38 | // I is the message type used for the request. 39 | I: RequestT 40 | // O is the message type used for the response. 41 | O: ResponseT 42 | // kind is the rpc kind. 43 | kind: MethodK 44 | // idempotency is the idempotency of the method. 45 | idempotency?: MethodI 46 | } 47 | -------------------------------------------------------------------------------- /srpc/errors.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import "errors" 4 | 5 | var ( 6 | // ErrUnimplemented is returned if the RPC method was not implemented. 7 | ErrUnimplemented = errors.New("unimplemented") 8 | // ErrCompleted is returned if a message is received after the rpc was completed. 9 | ErrCompleted = errors.New("unexpected packet after rpc was completed") 10 | // ErrUnrecognizedPacket is returned if the packet type was not recognized. 11 | ErrUnrecognizedPacket = errors.New("unrecognized packet type") 12 | // ErrEmptyPacket is returned if nothing is specified in a packet. 13 | ErrEmptyPacket = errors.New("invalid empty packet") 14 | // ErrInvalidMessage indicates the message failed to parse. 15 | ErrInvalidMessage = errors.New("invalid message") 16 | // ErrEmptyMethodID is returned if the method id was empty. 17 | ErrEmptyMethodID = errors.New("method id empty") 18 | // ErrEmptyServiceID is returned if the service id was empty. 19 | ErrEmptyServiceID = errors.New("service id empty") 20 | // ErrNoAvailableClients is returned if no clients were available. 21 | ErrNoAvailableClients = errors.New("no available rpc clients") 22 | // ErrNilWriter is returned if the rpc writer is nil. 23 | ErrNilWriter = errors.New("writer cannot be nil") 24 | ) 25 | -------------------------------------------------------------------------------- /srpc/errors.ts: -------------------------------------------------------------------------------- 1 | // ERR_RPC_ABORT is returned if the RPC was aborted. 2 | export const ERR_RPC_ABORT = 'ERR_RPC_ABORT' 3 | 4 | // isAbortError checks if the error object is ERR_RPC_ABORT. 5 | export function isAbortError(err: unknown): boolean { 6 | if (typeof err !== 'object') { 7 | return false 8 | } 9 | const message = (err as Error).message 10 | return message === ERR_RPC_ABORT 11 | } 12 | 13 | // ERR_STREAM_IDLE is returned if the stream idle timeout was exceeded. 14 | export const ERR_STREAM_IDLE = 'ERR_STREAM_IDLE' 15 | 16 | // isStreamIdleError checks if the error object is ERR_STREAM_IDLE. 17 | export function isStreamIdleError(err: unknown): boolean { 18 | if (typeof err !== 'object') { 19 | return false 20 | } 21 | const message = (err as Error).message 22 | return message === ERR_STREAM_IDLE 23 | } 24 | 25 | // castToError casts an object to an Error. 26 | // if err is a string, uses it as the message. 27 | // if err is undefined, returns new Error(defaultMsg) 28 | export function castToError(err: any, defaultMsg?: string): Error { 29 | defaultMsg = defaultMsg || 'error' 30 | if (!err) { 31 | return new Error(defaultMsg) 32 | } 33 | if (typeof err === 'string') { 34 | return new Error(err) 35 | } 36 | const asError = err as Error 37 | if (asError.message) { 38 | return asError 39 | } 40 | if (err.toString) { 41 | const errString = err.toString() 42 | if (errString) { 43 | return new Error(errString) 44 | } 45 | } 46 | return new Error(defaultMsg) 47 | } 48 | -------------------------------------------------------------------------------- /srpc/handle-stream-ctr.ts: -------------------------------------------------------------------------------- 1 | import { HandleStreamFunc } from './stream.js' 2 | import { ValueCtr } from './value-ctr.js' 3 | 4 | // HandleStreamCtr contains an OpenStream func which can be awaited. 5 | export class HandleStreamCtr extends ValueCtr { 6 | constructor(handleStreamFn?: HandleStreamFunc) { 7 | super(handleStreamFn) 8 | } 9 | 10 | // handleStreamFunc returns an HandleStreamFunc which waits for the underlying HandleStreamFunc. 11 | get handleStreamFunc(): HandleStreamFunc { 12 | return async (stream) => { 13 | let handleFn = this.value 14 | if (!handleFn) { 15 | handleFn = await this.wait() 16 | } 17 | return handleFn(stream) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /srpc/handler.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | // Handler describes a SRPC call handler implementation. 4 | type Handler interface { 5 | // Invoker invokes the methods. 6 | Invoker 7 | 8 | // GetServiceID returns the ID of the service. 9 | GetServiceID() string 10 | // GetMethodIDs returns the list of methods for the service. 11 | GetMethodIDs() []string 12 | } 13 | -------------------------------------------------------------------------------- /srpc/handler.ts: -------------------------------------------------------------------------------- 1 | import type { Sink, Source } from 'it-stream-types' 2 | import { ServiceDefinition, ServiceMethodDefinitions } from './definition.js' 3 | import { createInvokeFn } from './invoker.js' 4 | 5 | // InvokeFn describes an SRPC call method invoke function. 6 | export type InvokeFn = ( 7 | dataSource: Source, 8 | dataSink: Sink>, 9 | ) => Promise 10 | 11 | // Handler describes a SRPC call handler implementation. 12 | export interface Handler { 13 | // getServiceID returns the ID of the service. 14 | getServiceID(): string 15 | // getMethodIDs returns the IDs of the methods. 16 | getMethodIDs(): string[] 17 | // lookupMethod looks up the method matching the service & method ID. 18 | // returns null if not found. 19 | lookupMethod(serviceID: string, methodID: string): Promise 20 | } 21 | 22 | // MethodMap is a map from method id to invoke function. 23 | export type MethodMap = { [name: string]: InvokeFn } 24 | 25 | // StaticHandler is a handler with a definition and implementation. 26 | export class StaticHandler implements Handler { 27 | // service is the service id 28 | private service: string 29 | // methods is the map of method to invoke fn 30 | private methods: MethodMap 31 | 32 | constructor(serviceID: string, methods: MethodMap) { 33 | this.service = serviceID 34 | this.methods = methods 35 | } 36 | 37 | // getServiceID returns the ID of the service. 38 | public getServiceID(): string { 39 | return this.service 40 | } 41 | 42 | // getMethodIDs returns the IDs of the methods. 43 | public getMethodIDs(): string[] { 44 | return Object.keys(this.methods) 45 | } 46 | 47 | // lookupMethod looks up the method matching the service & method ID. 48 | // returns null if not found. 49 | public async lookupMethod( 50 | serviceID: string, 51 | methodID: string, 52 | ): Promise { 53 | if (serviceID && serviceID !== this.service) { 54 | return null 55 | } 56 | return this.methods[methodID] || null 57 | } 58 | } 59 | 60 | // createHandler creates a handler from a definition and an implementation. 61 | // if serviceID is not set, uses the fullName of the service as the identifier. 62 | export function createHandler< 63 | T extends ServiceMethodDefinitions = ServiceMethodDefinitions, 64 | >(definition: ServiceDefinition, impl: any, serviceID?: string): Handler { 65 | // serviceID defaults to the full name of the service from Protobuf. 66 | serviceID = serviceID || definition.typeName 67 | 68 | // build map of method ID -> method prototype. 69 | const methodMap: MethodMap = {} 70 | for (const methodInfo of Object.values(definition.methods)) { 71 | const methodName = methodInfo.name 72 | let methodProto = impl[methodName] 73 | if (!methodProto) { 74 | continue 75 | } 76 | methodProto = methodProto.bind(impl) 77 | methodMap[methodName] = createInvokeFn(methodInfo, methodProto) 78 | } 79 | 80 | return new StaticHandler(serviceID, methodMap) 81 | } 82 | -------------------------------------------------------------------------------- /srpc/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | ERR_RPC_ABORT, 3 | isAbortError, 4 | ERR_STREAM_IDLE, 5 | isStreamIdleError, 6 | castToError, 7 | } from './errors.js' 8 | export { Client } from './client.js' 9 | export { Server } from './server.js' 10 | export { StreamConn, StreamConnParams, StreamHandler } from './conn.js' 11 | export { WebSocketConn } from './websocket.js' 12 | export type { 13 | PacketHandler, 14 | OpenStreamFunc, 15 | HandleStreamFunc, 16 | PacketStream, 17 | streamToPacketStream, 18 | } from './stream.js' 19 | export { 20 | Handler, 21 | InvokeFn, 22 | MethodMap, 23 | StaticHandler, 24 | createHandler, 25 | } from './handler.js' 26 | export { MethodProto, createInvokeFn } from './invoker.js' 27 | export { Packet, CallStart, CallData } from './rpcproto.pb.js' 28 | export { Mux, StaticMux, LookupMethod, createMux } from './mux.js' 29 | export { 30 | ChannelStreamMessage, 31 | ChannelPort, 32 | ChannelStream, 33 | ChannelStreamOpts, 34 | newBroadcastChannelStream, 35 | } from './channel.js' 36 | export { 37 | BroadcastChannelDuplex, 38 | BroadcastChannelConn, 39 | newBroadcastChannelDuplex, 40 | } from './broadcast-channel.js' 41 | export { 42 | MessagePortDuplex, 43 | MessagePortConn, 44 | newMessagePortDuplex, 45 | } from './message-port.js' 46 | export { 47 | MessageStream, 48 | DecodeMessageTransform, 49 | buildDecodeMessageTransform, 50 | EncodeMessageTransform, 51 | buildEncodeMessageTransform, 52 | } from './message.js' 53 | export { 54 | parseLengthPrefixTransform, 55 | prependLengthPrefixTransform, 56 | decodePacketSource, 57 | encodePacketSource, 58 | uint32LEDecode, 59 | uint32LEEncode, 60 | decodeUint32Le, 61 | encodeUint32Le, 62 | lengthPrefixDecode, 63 | lengthPrefixEncode, 64 | prependPacketLen, 65 | } from './packet.js' 66 | export { combineUint8ArrayListTransform } from './array-list.js' 67 | export { ValueCtr } from './value-ctr.js' 68 | export { OpenStreamCtr } from './open-stream-ctr.js' 69 | export { HandleStreamCtr } from './handle-stream-ctr.js' 70 | export { 71 | writeToPushable, 72 | buildPushableSink, 73 | messagePushable, 74 | } from './pushable.js' 75 | export { Watchdog } from './watchdog.js' 76 | export { ProtoRpc } from './proto-rpc.js' 77 | -------------------------------------------------------------------------------- /srpc/invoker-prefix.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | // PrefixInvoker checks for and strips a set of prefixes from a Invoker. 4 | type PrefixInvoker struct { 5 | // inv is the underlying invoker 6 | inv Invoker 7 | // serviceIDPrefixes is the list of service id prefixes to match. 8 | serviceIDPrefixes []string 9 | } 10 | 11 | // NewPrefixInvoker constructs a new PrefixInvoker. 12 | // 13 | // serviceIDPrefixes is the list of service id prefixes to match. 14 | // strips the prefix before calling the underlying Invoke function. 15 | // if none of the prefixes match, returns unimplemented. 16 | // if empty: forwards all services w/o stripping any prefix. 17 | func NewPrefixInvoker(inv Invoker, serviceIDPrefixes []string) *PrefixInvoker { 18 | return &PrefixInvoker{ 19 | inv: inv, 20 | serviceIDPrefixes: serviceIDPrefixes, 21 | } 22 | } 23 | 24 | // InvokeMethod invokes the method matching the service & method ID. 25 | // Returns false, nil if not found. 26 | // If service string is empty, ignore it. 27 | func (i *PrefixInvoker) InvokeMethod(serviceID, methodID string, strm Stream) (bool, error) { 28 | if len(i.serviceIDPrefixes) != 0 { 29 | strippedID, matchedPrefix := CheckStripPrefix(serviceID, i.serviceIDPrefixes) 30 | if len(matchedPrefix) == 0 { 31 | return false, nil 32 | } 33 | serviceID = strippedID 34 | } 35 | 36 | return i.inv.InvokeMethod(serviceID, methodID, strm) 37 | } 38 | 39 | // _ is a type assertion 40 | var _ Invoker = ((*PrefixInvoker)(nil)) 41 | -------------------------------------------------------------------------------- /srpc/invoker.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | // Invoker is a function for invoking SRPC service methods. 4 | type Invoker interface { 5 | // InvokeMethod invokes the method matching the service & method ID. 6 | // Returns false, nil if not found. 7 | // If service string is empty, ignore it. 8 | InvokeMethod(serviceID, methodID string, strm Stream) (bool, error) 9 | } 10 | 11 | // InvokerSlice is a list of invokers. 12 | type InvokerSlice []Invoker 13 | 14 | // InvokeMethod invokes the method matching the service & method ID. 15 | // Returns false, nil if not found. 16 | // If service string is empty, ignore it. 17 | func (s InvokerSlice) InvokeMethod(serviceID, methodID string, strm Stream) (bool, error) { 18 | for _, invoker := range s { 19 | if invoker == nil { 20 | continue 21 | } 22 | 23 | found, err := invoker.InvokeMethod(serviceID, methodID, strm) 24 | if found || err != nil { 25 | return true, err 26 | } 27 | } 28 | return false, nil 29 | } 30 | 31 | // _ is a type assertion 32 | var _ Invoker = (InvokerSlice)(nil) 33 | 34 | // InvokerFunc is a function implementing InvokeMethod. 35 | type InvokerFunc func(serviceID, methodID string, strm Stream) (bool, error) 36 | 37 | // InvokeMethod invokes the method matching the service & method ID. 38 | // Returns false, nil if not found. 39 | // If service string is empty, ignore it. 40 | func (f InvokerFunc) InvokeMethod(serviceID, methodID string, strm Stream) (bool, error) { 41 | if f == nil { 42 | return false, nil 43 | } 44 | return f(serviceID, methodID, strm) 45 | } 46 | 47 | var _ Invoker = (InvokerFunc)(nil) 48 | -------------------------------------------------------------------------------- /srpc/invoker.ts: -------------------------------------------------------------------------------- 1 | import { Sink, Source } from 'it-stream-types' 2 | import { pushable } from 'it-pushable' 3 | import { pipe } from 'it-pipe' 4 | import type { MethodDefinition } from './definition.js' 5 | import { InvokeFn } from './handler.js' 6 | import { 7 | buildDecodeMessageTransform, 8 | buildEncodeMessageTransform, 9 | } from './message.js' 10 | import { writeToPushable } from './pushable.js' 11 | import type { MessageType, Message } from '@aptre/protobuf-es-lite' 12 | import { MethodIdempotency, MethodKind } from '@aptre/protobuf-es-lite' 13 | 14 | // MethodProto is a function which matches one of the RPC signatures. 15 | export type MethodProto, O extends Message> = 16 | | ((request: R) => Promise) 17 | | ((request: R) => AsyncIterable) 18 | | ((request: AsyncIterable) => Promise) 19 | | ((request: AsyncIterable) => AsyncIterable) 20 | 21 | // createInvokeFn builds an InvokeFn from a method definition and a function prototype. 22 | export function createInvokeFn, O extends Message>( 23 | methodInfo: MethodDefinition< 24 | MessageType, 25 | MessageType, 26 | MethodKind, 27 | MethodIdempotency | undefined 28 | >, 29 | methodProto: MethodProto, 30 | ): InvokeFn { 31 | const requestDecode = buildDecodeMessageTransform(methodInfo.I) 32 | return async ( 33 | dataSource: Source, 34 | dataSink: Sink>, 35 | ) => { 36 | // responseSink is a Sink for response messages. 37 | const responseSink = pushable({ 38 | objectMode: true, 39 | }) 40 | 41 | // pipe responseSink to dataSink. 42 | pipe(responseSink, buildEncodeMessageTransform(methodInfo.O), dataSink) 43 | 44 | // requestSource is a Source of decoded request messages. 45 | const requestSource = pipe(dataSource, requestDecode) 46 | 47 | // build the request argument. 48 | let requestArg: any 49 | if ( 50 | methodInfo.kind === MethodKind.ClientStreaming || 51 | methodInfo.kind === MethodKind.BiDiStreaming 52 | ) { 53 | // use the request source as the argument. 54 | requestArg = requestSource 55 | } else { 56 | // receive a single message for the argument. 57 | for await (const msg of requestSource) { 58 | requestArg = msg 59 | break 60 | } 61 | } 62 | 63 | if (!requestArg) { 64 | throw new Error('request object was empty') 65 | } 66 | 67 | // Call the implementation. 68 | try { 69 | const responseObj = methodProto(requestArg) 70 | if (!responseObj) { 71 | throw new Error('return value was undefined') 72 | } 73 | if ( 74 | methodInfo.kind === MethodKind.ServerStreaming || 75 | methodInfo.kind === MethodKind.BiDiStreaming 76 | ) { 77 | const response = responseObj as AsyncIterable 78 | return writeToPushable(response as AsyncIterable, responseSink) 79 | } else { 80 | const responsePromise = responseObj as Promise 81 | if (!responsePromise.then) { 82 | throw new Error('expected return value to be a Promise') 83 | } 84 | const responseMsg = await responsePromise 85 | if (!responseMsg) { 86 | throw new Error('expected non-empty response object') 87 | } 88 | responseSink.push(responseMsg) 89 | responseSink.end() 90 | } 91 | } catch (err) { 92 | let asError = err as Error 93 | if (!asError?.message) { 94 | asError = new Error('error calling implementation: ' + err) 95 | } 96 | // mux will return the error to the rpc caller. 97 | responseSink.end() 98 | throw asError 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /srpc/length-prefix.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aperturerobotics/starpc/854fe4b859ed3d836a4e7e8e5f1223b5473a146f/srpc/length-prefix.ts -------------------------------------------------------------------------------- /srpc/log.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentLogger, Logger } from '@libp2p/interface' 2 | 3 | // https://github.com/libp2p/js-libp2p/issues/2276 4 | // https://github.com/libp2p/js-libp2p/blob/bca8d6e689b47d85dda74082ed72e671139391de/packages/logger/src/index.ts#L86 5 | // https://github.com/libp2p/js-libp2p/issues/2275 6 | // https://github.com/ChainSafe/js-libp2p-yamux/issues/69 7 | export function createDisabledLogger(namespace: string): Logger { 8 | const logger = (): void => {} 9 | logger.enabled = false 10 | logger.color = '' 11 | logger.diff = 0 12 | logger.log = (): void => {} 13 | logger.namespace = namespace 14 | logger.destroy = () => true 15 | logger.extend = () => logger 16 | logger.debug = logger 17 | logger.error = logger 18 | logger.trace = logger 19 | 20 | return logger 21 | } 22 | 23 | export function createDisabledComponentLogger(): ComponentLogger { 24 | return { forComponent: createDisabledLogger } 25 | } 26 | -------------------------------------------------------------------------------- /srpc/message-port.ts: -------------------------------------------------------------------------------- 1 | import type { Duplex, Source } from 'it-stream-types' 2 | import { EventIterator } from 'event-iterator' 3 | import { pipe } from 'it-pipe' 4 | 5 | import { StreamConn, StreamConnParams } from './conn.js' 6 | import { Server } from './server.js' 7 | import { combineUint8ArrayListTransform } from './array-list.js' 8 | 9 | // MessagePortDuplex is a AsyncIterable wrapper for MessagePort. 10 | // 11 | // When the sink is closed, the message port will also be closed. 12 | // null will be written through the channel to indicate closure when the sink is closed. 13 | // Note: there is no way to know for sure when a MessagePort is closed! 14 | // You will need an additional keep-alive on top of MessagePortDuplex. 15 | export class MessagePortDuplex> 16 | implements Duplex, Source, Promise> 17 | { 18 | // port is the message port 19 | public readonly port: MessagePort 20 | // sink is the sink for incoming messages. 21 | public sink: (source: Source) => Promise 22 | // source is the source for outgoing messages. 23 | public source: AsyncGenerator 24 | 25 | constructor(port: MessagePort) { 26 | this.port = port 27 | this.sink = this._createSink() 28 | this.source = this._createSource() 29 | } 30 | 31 | // close closes the message port. 32 | public close() { 33 | this.port.postMessage(null) 34 | this.port.close() 35 | } 36 | 37 | // _createSink initializes the sink field. 38 | private _createSink(): (source: Source) => Promise { 39 | return async (source) => { 40 | try { 41 | for await (const msg of source) { 42 | this.port.postMessage(msg) 43 | } 44 | } catch (err: unknown) { 45 | this.close() 46 | throw err 47 | } 48 | 49 | this.close() 50 | } 51 | } 52 | 53 | // _createSource initializes the source field. 54 | private async *_createSource(): AsyncGenerator { 55 | const iterator = new EventIterator((queue) => { 56 | const messageListener = (ev: MessageEvent) => { 57 | const data = ev.data 58 | if (data !== null) { 59 | queue.push(data) 60 | } else { 61 | queue.stop() 62 | } 63 | } 64 | 65 | this.port.addEventListener('message', messageListener) 66 | this.port.start() 67 | 68 | return () => { 69 | this.port.removeEventListener('message', messageListener) 70 | } 71 | }) 72 | 73 | try { 74 | for await (const value of iterator) { 75 | yield value 76 | } 77 | } catch (err) { 78 | this.close() 79 | throw err 80 | } 81 | 82 | this.close() 83 | } 84 | } 85 | 86 | // newMessagePortDuplex constructs a MessagePortDuplex with a channel name. 87 | export function newMessagePortDuplex>( 88 | port: MessagePort, 89 | ): MessagePortDuplex { 90 | return new MessagePortDuplex(port) 91 | } 92 | 93 | // MessagePortConn implements a connection with a MessagePort. 94 | // 95 | // expects Uint8Array objects over the MessagePort. 96 | // uses Yamux to mux streams over the port. 97 | export class MessagePortConn extends StreamConn { 98 | // _messagePort is the message port iterable. 99 | private _messagePort: MessagePortDuplex 100 | 101 | constructor( 102 | port: MessagePort, 103 | server?: Server, 104 | connParams?: StreamConnParams, 105 | ) { 106 | const messagePort = new MessagePortDuplex(port) 107 | super(server, { 108 | ...connParams, 109 | yamuxParams: { 110 | // There is no way to tell when a MessagePort is closed. 111 | // We will send an undefined object through the MessagePort to indicate closed. 112 | // We still need a way to detect when the connection is not cleanly terminated. 113 | // Enable keep-alive to detect this on the other end. 114 | enableKeepAlive: true, 115 | keepAliveInterval: 1500, 116 | ...connParams?.yamuxParams, 117 | }, 118 | }) 119 | this._messagePort = messagePort 120 | pipe( 121 | messagePort, 122 | this, 123 | // Uint8ArrayList usually cannot be sent over MessagePort, so we combine to a Uint8Array as part of the pipe. 124 | combineUint8ArrayListTransform(), 125 | messagePort, 126 | ) 127 | .catch((err) => this.close(err)) 128 | .then(() => this.close()) 129 | } 130 | 131 | // messagePort returns the MessagePort. 132 | get messagePort(): MessagePort { 133 | return this._messagePort.port 134 | } 135 | 136 | // close closes the message port. 137 | public override close(err?: Error) { 138 | super.close(err) 139 | this.messagePort.close() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /srpc/message.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "errors" 5 | 6 | protobuf_go_lite "github.com/aperturerobotics/protobuf-go-lite" 7 | ) 8 | 9 | // Message is the vtprotobuf message interface. 10 | type Message = protobuf_go_lite.Message 11 | 12 | // RawMessage is a raw protobuf message container. 13 | // 14 | // The empty value is valid with copy=false. 15 | type RawMessage struct { 16 | data []byte 17 | copy bool 18 | } 19 | 20 | // NewRawMessage constructs a new raw message. 21 | // If copy=true, copies data in MarshalVT. 22 | // Note: the data buffer will be retained and used. 23 | // The data buffer will be written to and/or replaced in UnmarshalVT. 24 | func NewRawMessage(data []byte, copy bool) *RawMessage { 25 | return &RawMessage{data: data, copy: copy} 26 | } 27 | 28 | // GetData returns the data buffer without copying. 29 | func (m *RawMessage) GetData() []byte { 30 | return m.data 31 | } 32 | 33 | // SetData sets the data buffer. 34 | // if copy=true, copies the data to the internal slice. 35 | // otherwise retains the buffer. 36 | func (m *RawMessage) SetData(data []byte) { 37 | if m.copy { 38 | if cap(m.data) >= len(data) { 39 | m.data = m.data[:len(data)] 40 | } else { 41 | m.data = make([]byte, len(data)) 42 | } 43 | copy(m.data, data) 44 | } else { 45 | m.data = data 46 | } 47 | } 48 | 49 | // Clear sets the length of the data buffer to 0 without releasing it. 50 | func (m *RawMessage) Clear() { 51 | m.data = m.data[:0] 52 | } 53 | 54 | // Reset releases the data buffer. 55 | func (m *RawMessage) Reset() { 56 | m.data = nil 57 | } 58 | 59 | func (m *RawMessage) MarshalVT() ([]byte, error) { 60 | if !m.copy { 61 | return m.data, nil 62 | } 63 | 64 | data := make([]byte, len(m.data)) 65 | copy(data, m.data) 66 | return data, nil 67 | } 68 | 69 | func (m *RawMessage) UnmarshalVT(data []byte) error { 70 | m.SetData(data) 71 | return nil 72 | } 73 | 74 | // SizeVT returns the size of the message when marshaled. 75 | func (m *RawMessage) SizeVT() int { 76 | return len(m.data) 77 | } 78 | 79 | // MarshalToSizedBufferVT marshals to a buffer that already is SizeVT bytes long. 80 | func (m *RawMessage) MarshalToSizedBufferVT(dAtA []byte) (int, error) { 81 | if len(dAtA) != len(m.data) { 82 | return 0, errors.New("invalid buffer length") 83 | } 84 | copy(dAtA, m.data) 85 | return len(dAtA), nil 86 | } 87 | 88 | // _ is a type assertion 89 | var _ Message = ((*RawMessage)(nil)) 90 | -------------------------------------------------------------------------------- /srpc/message.ts: -------------------------------------------------------------------------------- 1 | import { MessageType, Message } from '@aptre/protobuf-es-lite' 2 | import type { Source } from 'it-stream-types' 3 | 4 | // MessageStream is an async iterable of partial messages. 5 | export type MessageStream> = AsyncIterable 6 | 7 | // DecodeMessageTransform decodes messages to objects. 8 | export type DecodeMessageTransform = ( 9 | source: Source, 10 | ) => AsyncIterable 11 | 12 | // buildDecodeMessageTransform builds a source of decoded messages. 13 | export function buildDecodeMessageTransform>( 14 | def: MessageType, 15 | ): DecodeMessageTransform { 16 | const decode = def.fromBinary.bind(def) 17 | // decodeMessageSource unmarshals and async yields encoded Messages. 18 | return async function* decodeMessageSource( 19 | source: Source, 20 | ): AsyncIterable { 21 | for await (const pkt of source) { 22 | if (Array.isArray(pkt)) { 23 | for (const p of pkt) { 24 | yield* [decode(p)] 25 | } 26 | } else { 27 | yield* [decode(pkt)] 28 | } 29 | } 30 | } 31 | } 32 | 33 | // EncodeMessageTransform is a transformer that encodes messages. 34 | export type EncodeMessageTransform> = ( 35 | source: Source>, 36 | ) => AsyncIterable 37 | 38 | // buildEncodeMessageTransform builds a transformer that encodes messages. 39 | export function buildEncodeMessageTransform>( 40 | def: MessageType, 41 | ): EncodeMessageTransform { 42 | // encodeMessageSource marshals and async yields Messages. 43 | return async function* encodeMessageSource( 44 | source: Source>, 45 | ): AsyncIterable { 46 | for await (const pkt of source) { 47 | if (Array.isArray(pkt)) { 48 | for (const p of pkt) { 49 | yield def.toBinary(p) 50 | } 51 | } else { 52 | yield def.toBinary(pkt) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /srpc/message_test.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | // TestRawMessage tests the raw message container. 9 | func TestRawMessage(t *testing.T) { 10 | pkt := NewCallStartPacket("test-service", "test-method", nil, false) 11 | data, err := pkt.MarshalVT() 12 | if err != nil { 13 | t.Fatal(err.Error()) 14 | } 15 | 16 | rawMsg := &RawMessage{} 17 | if err := rawMsg.UnmarshalVT(data); err != nil { 18 | t.Fatal(err.Error()) 19 | } 20 | 21 | outMsg, err := rawMsg.MarshalVT() 22 | if err != nil { 23 | t.Fatal(err.Error()) 24 | } 25 | 26 | if !bytes.Equal(outMsg, data) { 27 | t.Fatal("not equal") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /srpc/msg-stream.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // MsgStreamRw is the read-write interface for MsgStream. 8 | type MsgStreamRw interface { 9 | // ReadOne reads a single message and returns. 10 | // 11 | // returns io.EOF if the stream ended. 12 | ReadOne() ([]byte, error) 13 | 14 | // WriteCallData writes a call data packet. 15 | WriteCallData(data []byte, complete bool, err error) error 16 | 17 | // WriteCallCancel writes a call cancel (close) packet. 18 | WriteCallCancel() error 19 | } 20 | 21 | // MsgStream implements the stream interface passed to implementations. 22 | type MsgStream struct { 23 | // ctx is the stream context 24 | ctx context.Context 25 | // rw is the msg stream read-writer 26 | rw MsgStreamRw 27 | // closeCb is the close callback 28 | closeCb func() 29 | } 30 | 31 | // NewMsgStream constructs a new Stream with a ClientRPC. 32 | // dataCh should be closed when no more messages will arrive. 33 | func NewMsgStream( 34 | ctx context.Context, 35 | rw MsgStreamRw, 36 | closeCb func(), 37 | ) *MsgStream { 38 | return &MsgStream{ 39 | ctx: ctx, 40 | rw: rw, 41 | closeCb: closeCb, 42 | } 43 | } 44 | 45 | // Context is canceled when the Stream is no longer valid. 46 | func (r *MsgStream) Context() context.Context { 47 | return r.ctx 48 | } 49 | 50 | // MsgSend sends the message to the remote. 51 | func (r *MsgStream) MsgSend(msg Message) error { 52 | if err := r.ctx.Err(); err != nil { 53 | return context.Canceled 54 | } 55 | 56 | msgData, err := msg.MarshalVT() 57 | if err != nil { 58 | return err 59 | } 60 | 61 | return r.rw.WriteCallData(msgData, false, nil) 62 | } 63 | 64 | // MsgRecv receives an incoming message from the remote. 65 | // Parses the message into the object at msg. 66 | func (r *MsgStream) MsgRecv(msg Message) error { 67 | data, err := r.rw.ReadOne() 68 | if err != nil { 69 | return err 70 | } 71 | return msg.UnmarshalVT(data) 72 | } 73 | 74 | // CloseSend signals to the remote that we will no longer send any messages. 75 | func (r *MsgStream) CloseSend() error { 76 | return r.rw.WriteCallData(nil, true, nil) 77 | } 78 | 79 | // Close closes the stream. 80 | func (r *MsgStream) Close() error { 81 | err := r.rw.WriteCallCancel() 82 | if r.closeCb != nil { 83 | r.closeCb() 84 | } 85 | 86 | return err 87 | } 88 | 89 | // _ is a type assertion 90 | var _ Stream = ((*MsgStream)(nil)) 91 | -------------------------------------------------------------------------------- /srpc/mux-verbose.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/sirupsen/logrus" 7 | ) 8 | 9 | // VMux implements a verbose logging wrapper for a Mux. 10 | type VMux struct { 11 | mx Mux 12 | le *logrus.Entry 13 | veryVerbose bool 14 | } 15 | 16 | // NewVMux constructs a verbose logging wrapper for a Mux. 17 | // 18 | // if veryVerbose is set, we also log very chatty logs: HasService, HasServiceMethod, Register 19 | func NewVMux(mux Mux, le *logrus.Entry, veryVerbose bool) *VMux { 20 | return &VMux{mx: mux, le: le, veryVerbose: veryVerbose} 21 | } 22 | 23 | // InvokeMethod invokes the method matching the service & method ID. 24 | // Returns false, nil if not found. 25 | // If service string is empty, ignore it. 26 | func (v *VMux) InvokeMethod(serviceID, methodID string, strm Stream) (done bool, err error) { 27 | t1 := time.Now() 28 | v.le.Debugf( 29 | "InvokeMethod(serviceID(%s), methodID(%s)) => started", 30 | serviceID, 31 | methodID, 32 | ) 33 | defer func() { 34 | v.le.Debugf( 35 | "InvokeMethod(serviceID(%s), methodID(%s)) => dur(%v) done(%v) err(%v)", 36 | serviceID, 37 | methodID, 38 | time.Since(t1).String(), 39 | done, 40 | err, 41 | ) 42 | }() 43 | return v.mx.InvokeMethod(serviceID, methodID, strm) 44 | } 45 | 46 | // Register registers a new RPC method handler (service). 47 | func (v *VMux) Register(handler Handler) (err error) { 48 | if v.veryVerbose { 49 | t1 := time.Now() 50 | defer func() { 51 | v.le.Debugf( 52 | "Register(handler(%v)) => dur(%v) err(%v)", 53 | handler, 54 | time.Since(t1).String(), 55 | err, 56 | ) 57 | }() 58 | } 59 | return v.mx.Register(handler) 60 | } 61 | 62 | // HasService checks if the service ID exists in the handlers. 63 | func (v *VMux) HasService(serviceID string) (has bool) { 64 | if v.veryVerbose { 65 | t1 := time.Now() 66 | defer func() { 67 | v.le.Debugf( 68 | "HasService(serviceID(%s)) => dur(%v) has(%v)", 69 | serviceID, 70 | time.Since(t1).String(), 71 | has, 72 | ) 73 | }() 74 | } 75 | return v.mx.HasService(serviceID) 76 | } 77 | 78 | // HasServiceMethod checks if exists in the handlers. 79 | func (v *VMux) HasServiceMethod(serviceID, methodID string) (has bool) { 80 | if v.veryVerbose { 81 | t1 := time.Now() 82 | defer func() { 83 | v.le.Debugf( 84 | "HasServiceMethod(serviceID(%s), methodID(%s)) => dur(%v) has(%v)", 85 | serviceID, 86 | methodID, 87 | time.Since(t1).String(), 88 | has, 89 | ) 90 | }() 91 | } 92 | return v.mx.HasServiceMethod(serviceID, methodID) 93 | } 94 | 95 | // _ is a type assertion 96 | var _ Mux = ((*VMux)(nil)) 97 | -------------------------------------------------------------------------------- /srpc/mux.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import "sync" 4 | 5 | // Mux contains a set of handlers. 6 | type Mux interface { 7 | // Invoker invokes the methods. 8 | Invoker 9 | 10 | // Register registers a new RPC method handler (service). 11 | Register(handler Handler) error 12 | // HasService checks if the service ID exists in the handlers. 13 | HasService(serviceID string) bool 14 | // HasServiceMethod checks if exists in the handlers. 15 | HasServiceMethod(serviceID, methodID string) bool 16 | } 17 | 18 | // muxMethods is a mapping from method id to handler. 19 | type muxMethods map[string]Handler 20 | 21 | // mux is the default implementation of Mux. 22 | type mux struct { 23 | // fallback is the list of fallback invokers 24 | // if the mux doesn't match the service, calls the invokers. 25 | fallback []Invoker 26 | // rmtx guards below fields 27 | rmtx sync.RWMutex 28 | // services contains a mapping from services to handlers. 29 | services map[string]muxMethods 30 | } 31 | 32 | // NewMux constructs a new Mux. 33 | // 34 | // fallbackInvokers is the list of fallback Invokers to call in the case that 35 | // the service/method is not found on this mux. 36 | func NewMux(fallbackInvokers ...Invoker) Mux { 37 | return &mux{ 38 | fallback: fallbackInvokers, 39 | services: make(map[string]muxMethods), 40 | } 41 | } 42 | 43 | // Register registers a new RPC method handler (service). 44 | func (m *mux) Register(handler Handler) error { 45 | serviceID := handler.GetServiceID() 46 | methodIDs := handler.GetMethodIDs() 47 | if serviceID == "" { 48 | return ErrEmptyServiceID 49 | } 50 | 51 | m.rmtx.Lock() 52 | defer m.rmtx.Unlock() 53 | 54 | serviceMethods := m.services[serviceID] 55 | if serviceMethods == nil { 56 | serviceMethods = make(muxMethods) 57 | m.services[serviceID] = serviceMethods 58 | } 59 | for _, methodID := range methodIDs { 60 | if methodID != "" { 61 | serviceMethods[methodID] = handler 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // HasService checks if the service ID exists in the handlers. 69 | func (m *mux) HasService(serviceID string) bool { 70 | if serviceID == "" { 71 | return false 72 | } 73 | 74 | m.rmtx.Lock() 75 | defer m.rmtx.Unlock() 76 | 77 | return len(m.services[serviceID]) != 0 78 | } 79 | 80 | // HasServiceMethod checks if exists in the handlers. 81 | func (m *mux) HasServiceMethod(serviceID, methodID string) bool { 82 | if serviceID == "" || methodID == "" { 83 | return false 84 | } 85 | 86 | m.rmtx.Lock() 87 | defer m.rmtx.Unlock() 88 | 89 | handlers := m.services[serviceID] 90 | for _, mh := range handlers { 91 | for _, mhMethodID := range mh.GetMethodIDs() { 92 | if mhMethodID == methodID { 93 | return true 94 | } 95 | } 96 | } 97 | 98 | return false 99 | } 100 | 101 | // InvokeMethod invokes the method matching the service & method ID. 102 | // Returns false, nil if not found. 103 | // If service string is empty, ignore it. 104 | func (m *mux) InvokeMethod(serviceID, methodID string, strm Stream) (bool, error) { 105 | var handler Handler 106 | m.rmtx.RLock() 107 | if serviceID == "" { 108 | for _, svc := range m.services { 109 | if handler = svc[methodID]; handler != nil { 110 | break 111 | } 112 | } 113 | } else { 114 | svcMethods := m.services[serviceID] 115 | if svcMethods != nil { 116 | handler = svcMethods[methodID] 117 | } 118 | } 119 | m.rmtx.RUnlock() 120 | 121 | if handler != nil { 122 | return handler.InvokeMethod(serviceID, methodID, strm) 123 | } 124 | 125 | for _, invoker := range m.fallback { 126 | if invoker != nil { 127 | handled, err := invoker.InvokeMethod(serviceID, methodID, strm) 128 | if err != nil || handled { 129 | return handled, err 130 | } 131 | } 132 | } 133 | 134 | return false, nil 135 | } 136 | 137 | // _ is a type assertion 138 | var _ Mux = ((*mux)(nil)) 139 | -------------------------------------------------------------------------------- /srpc/mux.ts: -------------------------------------------------------------------------------- 1 | import { InvokeFn, Handler } from './handler.js' 2 | 3 | // LookupMethod is a function to lookup a RPC method. 4 | export type LookupMethod = ( 5 | serviceID: string, 6 | methodID: string, 7 | ) => Promise 8 | 9 | // Mux contains a set of handlers. 10 | export interface Mux { 11 | // lookupMethod looks up the method matching the service & method ID. 12 | // returns null if not found. 13 | lookupMethod(serviceID: string, methodID: string): Promise 14 | } 15 | 16 | // createMux builds a new StaticMux. 17 | export function createMux(): StaticMux { 18 | return new StaticMux() 19 | } 20 | 21 | // staticMuxMethods is a mapping from method id to handler. 22 | type staticMuxMethods = { [methodID: string]: Handler } 23 | 24 | // StaticMux contains a in-memory mapping between service ID and handlers. 25 | // implements Mux 26 | export class StaticMux implements Mux { 27 | // services contains a mapping from service id to handlers. 28 | private services: { [id: string]: staticMuxMethods } = {} 29 | // lookups is the list of lookup methods to call. 30 | // called if the method is not resolved by the services list. 31 | private lookups: LookupMethod[] = [] 32 | 33 | // lookupMethod implements the LookupMethod type. 34 | public get lookupMethod(): LookupMethod { 35 | return this._lookupMethod.bind(this) 36 | } 37 | 38 | public register(handler: Handler): void { 39 | const serviceID = handler?.getServiceID() 40 | if (!serviceID) { 41 | throw new Error('service id cannot be empty') 42 | } 43 | const serviceMethods = this.services[serviceID] || {} 44 | const methodIDs = handler.getMethodIDs() 45 | for (const methodID of methodIDs) { 46 | serviceMethods[methodID] = handler 47 | } 48 | this.services[serviceID] = serviceMethods 49 | } 50 | 51 | // registerLookupMethod registers a extra lookup function to the mux. 52 | public registerLookupMethod(lookupMethod: LookupMethod) { 53 | this.lookups.push(lookupMethod) 54 | } 55 | 56 | private async _lookupMethod( 57 | serviceID: string, 58 | methodID: string, 59 | ): Promise { 60 | if (serviceID) { 61 | const invokeFn = await this.lookupViaMap(serviceID, methodID) 62 | if (invokeFn) { 63 | return invokeFn 64 | } 65 | } 66 | 67 | return await this.lookupViaLookups(serviceID, methodID) 68 | } 69 | 70 | // lookupViaMap looks up the method via the services map. 71 | private async lookupViaMap( 72 | serviceID: string, 73 | methodID: string, 74 | ): Promise { 75 | const serviceMethods = this.services[serviceID] 76 | if (!serviceMethods) { 77 | return null 78 | } 79 | const handler = serviceMethods[methodID] 80 | if (!handler) { 81 | return null 82 | } 83 | return await handler.lookupMethod(serviceID, methodID) 84 | } 85 | 86 | // lookupViaLookups looks up the method via the lookup funcs. 87 | private async lookupViaLookups( 88 | serviceID: string, 89 | methodID: string, 90 | ): Promise { 91 | for (const lookupMethod of this.lookups) { 92 | const invokeFn = await lookupMethod(serviceID, methodID) 93 | if (invokeFn) { 94 | return invokeFn 95 | } 96 | } 97 | 98 | return null 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /srpc/muxed-conn.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | 8 | "github.com/libp2p/go-libp2p/core/network" 9 | ymuxer "github.com/libp2p/go-libp2p/p2p/muxer/yamux" 10 | yamux "github.com/libp2p/go-yamux/v4" 11 | ) 12 | 13 | // NewYamuxConfig builds the default yamux configuration. 14 | func NewYamuxConfig() *yamux.Config { 15 | // Configuration options from go-libp2p-yamux: 16 | config := *ymuxer.DefaultTransport.Config() 17 | config.AcceptBacklog = 512 18 | config.EnableKeepAlive = false 19 | return &config 20 | } 21 | 22 | // NewMuxedConn constructs a new MuxedConn from a net.Conn. 23 | // 24 | // If yamuxConf is nil, uses defaults. 25 | func NewMuxedConn(conn net.Conn, outbound bool, yamuxConf *yamux.Config) (network.MuxedConn, error) { 26 | if yamuxConf == nil { 27 | yamuxConf = NewYamuxConfig() 28 | } 29 | 30 | var sess *yamux.Session 31 | var err error 32 | if outbound { 33 | sess, err = yamux.Client(conn, yamuxConf, nil) 34 | } else { 35 | sess, err = yamux.Server(conn, yamuxConf, nil) 36 | } 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return ymuxer.NewMuxedConn(sess), nil 42 | } 43 | 44 | // NewMuxedConnWithRwc builds a new MuxedConn with a io.ReadWriteCloser. 45 | // 46 | // If yamuxConf is nil, uses defaults. 47 | func NewMuxedConnWithRwc( 48 | ctx context.Context, 49 | rwc io.ReadWriteCloser, 50 | outbound bool, 51 | yamuxConf *yamux.Config, 52 | ) (network.MuxedConn, error) { 53 | return NewMuxedConn(NewRwcConn(ctx, rwc, nil, nil, 10), outbound, yamuxConf) 54 | } 55 | 56 | // NewClientWithConn constructs the muxer and the client. 57 | // 58 | // if yamuxConf is nil, uses defaults. 59 | func NewClientWithConn(conn net.Conn, outbound bool, yamuxConf *yamux.Config) (Client, error) { 60 | mconn, err := NewMuxedConn(conn, outbound, yamuxConf) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return NewClientWithMuxedConn(mconn), nil 65 | } 66 | 67 | // NewClientWithMuxedConn constructs a new client with a MuxedConn. 68 | func NewClientWithMuxedConn(conn network.MuxedConn) Client { 69 | openStreamFn := NewOpenStreamWithMuxedConn(conn) 70 | return NewClient(openStreamFn) 71 | } 72 | 73 | // NewOpenStreamWithMuxedConn constructs a OpenStream func with a MuxedConn. 74 | func NewOpenStreamWithMuxedConn(conn network.MuxedConn) OpenStreamFunc { 75 | return func(ctx context.Context, msgHandler PacketDataHandler, closeHandler CloseHandler) (PacketWriter, error) { 76 | mstrm, err := conn.OpenStream(ctx) 77 | if err != nil { 78 | // If the error is a timeout, context may be canceled. 79 | // Prefer the context canceled error (yamux returns timeout for context cancel.) 80 | timeoutErr, ok := err.(interface{ Timeout() bool }) 81 | if ok && timeoutErr.Timeout() && ctx.Err() != nil { 82 | return nil, context.Canceled 83 | } 84 | return nil, err 85 | } 86 | rw := NewPacketReadWriter(mstrm) 87 | go rw.ReadPump(msgHandler, closeHandler) 88 | return rw, nil 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /srpc/net.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "net" 6 | ) 7 | 8 | // Dial dials a remote server using TCP with the default muxed conn type. 9 | func Dial(addr string) (Client, error) { 10 | nconn, err := net.Dial("tcp", addr) 11 | if err != nil { 12 | return nil, err 13 | } 14 | muxedConn, err := NewMuxedConn(nconn, false, nil) 15 | if err != nil { 16 | return nil, err 17 | } 18 | return NewClientWithMuxedConn(muxedConn), nil 19 | } 20 | 21 | // Listen listens for incoming connections with TCP on the given address with the default muxed conn type. 22 | // Returns on any fatal error or if ctx was canceled. 23 | // errCh is an optional error channel (can be nil) 24 | func Listen(ctx context.Context, addr string, srv *Server, errCh <-chan error) error { 25 | lis, err := net.Listen("tcp", addr) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | listenErrCh := make(chan error, 1) 31 | go func() { 32 | listenErrCh <- AcceptMuxedListener(ctx, lis, srv, nil) 33 | _ = lis.Close() 34 | }() 35 | 36 | select { 37 | case <-ctx.Done(): 38 | return context.Canceled 39 | case err := <-errCh: 40 | return err 41 | case err := <-listenErrCh: 42 | return err 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /srpc/open-stream-ctr.ts: -------------------------------------------------------------------------------- 1 | import { OpenStreamFunc } from './stream.js' 2 | import { ValueCtr } from './value-ctr.js' 3 | 4 | // OpenStreamCtr contains an OpenStream func which can be awaited. 5 | export class OpenStreamCtr extends ValueCtr { 6 | constructor(openStreamFn?: OpenStreamFunc) { 7 | super(openStreamFn) 8 | } 9 | 10 | // openStreamFunc returns an OpenStreamFunc which waits for the underlying OpenStreamFunc. 11 | get openStreamFunc(): OpenStreamFunc { 12 | return async () => { 13 | let openFn = this.value 14 | if (!openFn) { 15 | openFn = await this.wait() 16 | } 17 | return openFn() 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /srpc/packet-rw.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/binary" 7 | "io" 8 | "math" 9 | "sync" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // maxMessageSize is the max message size in bytes 15 | var maxMessageSize = 1e7 16 | 17 | // PacketReadWriter reads and writes packets from a io.ReadWriter. 18 | // Uses a LittleEndian uint32 length prefix. 19 | type PacketReadWriter struct { 20 | // rw is the io.ReadWriterCloser 21 | rw io.ReadWriteCloser 22 | // buf is the buffered data 23 | buf bytes.Buffer 24 | // writeMtx is the write mutex 25 | writeMtx sync.Mutex 26 | } 27 | 28 | // NewPacketReadWriter constructs a new read/writer. 29 | func NewPacketReadWriter(rw io.ReadWriteCloser) *PacketReadWriter { 30 | return &PacketReadWriter{rw: rw} 31 | } 32 | 33 | // Write writes raw data to the remote. 34 | func (r *PacketReadWriter) Write(p []byte) (n int, err error) { 35 | r.writeMtx.Lock() 36 | defer r.writeMtx.Unlock() 37 | return r.rw.Write(p) 38 | } 39 | 40 | // WritePacket writes a packet to the writer. 41 | func (r *PacketReadWriter) WritePacket(p *Packet) error { 42 | r.writeMtx.Lock() 43 | defer r.writeMtx.Unlock() 44 | 45 | msgSize := p.SizeVT() 46 | 47 | // G115: integer overflow conversion int -> uint32 (gosec) 48 | if msgSize > math.MaxUint32 { 49 | return errors.New("message size exceeds maximum uint32 value") 50 | } 51 | 52 | data := make([]byte, 4+msgSize) 53 | binary.LittleEndian.PutUint32(data, uint32(msgSize)) //nolint:gosec 54 | 55 | _, err := p.MarshalToSizedBufferVT(data[4:]) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | var written, n int 61 | for written < len(data) { 62 | n, err = r.rw.Write(data) 63 | if err != nil { 64 | return err 65 | } 66 | written += n 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // ReadPump executes the read pump in a goroutine. 73 | // 74 | // calls the handler when closed or returning an error 75 | func (r *PacketReadWriter) ReadPump(cb PacketDataHandler, closed CloseHandler) { 76 | err := r.ReadToHandler(cb) 77 | // signal that the stream is now closed. 78 | if closed != nil { 79 | closed(err) 80 | } 81 | } 82 | 83 | // ReadToHandler reads data to the given handler. 84 | // Does not handle closing the stream, use ReadPump instead. 85 | func (r *PacketReadWriter) ReadToHandler(cb PacketDataHandler) error { 86 | var currLen uint32 87 | buf := make([]byte, 2048) 88 | isOpen := true 89 | 90 | for isOpen { 91 | // read some data into the buffer 92 | n, err := r.rw.Read(buf) 93 | if err != nil { 94 | if err == io.EOF || err == context.Canceled { 95 | isOpen = false 96 | } else { 97 | return err 98 | } 99 | } 100 | 101 | // push the data to r.buf 102 | _, err = r.buf.Write(buf[:n]) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | EmitIfEnough: 108 | 109 | // check if we have enough data for a length prefix 110 | bufLen := r.buf.Len() 111 | if bufLen < 4 { 112 | continue 113 | } 114 | 115 | // parse the length prefix if not done already 116 | if currLen == 0 { 117 | currLen = r.readLengthPrefix(r.buf.Bytes()[:4]) 118 | if currLen == 0 { 119 | return errors.New("unexpected zero len prefix") 120 | } 121 | if currLen > uint32(maxMessageSize) { 122 | return errors.Errorf("message size %v greater than maximum %v", currLen, maxMessageSize) 123 | } 124 | } 125 | 126 | // emit the packet if fully buffered 127 | if currLen != 0 && bufLen >= int(currLen)+4 { 128 | pkt := r.buf.Next(int(currLen + 4))[4:] 129 | currLen = 0 130 | if err := cb(pkt); err != nil { 131 | return err 132 | } 133 | 134 | // check if there's still enough in the buffer 135 | goto EmitIfEnough 136 | } 137 | } 138 | 139 | // closed 140 | return nil 141 | } 142 | 143 | // Close closes the packet rw. 144 | func (r *PacketReadWriter) Close() error { 145 | return r.rw.Close() 146 | } 147 | 148 | // readLengthPrefix reads the length prefix. 149 | func (r *PacketReadWriter) readLengthPrefix(b []byte) uint32 { 150 | if len(b) < 4 { 151 | return 0 152 | } 153 | return binary.LittleEndian.Uint32(b) 154 | } 155 | 156 | // _ is a type assertion 157 | var _ PacketWriter = (*PacketReadWriter)(nil) 158 | -------------------------------------------------------------------------------- /srpc/packet.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | // CloseHandler handles the stream closing with an optional error. 4 | type CloseHandler = func(closeErr error) 5 | 6 | // PacketHandler handles a packet. 7 | // 8 | // pkt is optional (can be nil) 9 | // if closeErr is set, the stream is closed after pkt. 10 | type PacketHandler = func(pkt *Packet) error 11 | 12 | // PacketDataHandler handles a packet before it is parsed. 13 | type PacketDataHandler = func(data []byte) error 14 | 15 | // NewPacketDataHandler wraps a PacketHandler with a decoding step. 16 | func NewPacketDataHandler(handler PacketHandler) PacketDataHandler { 17 | return func(data []byte) error { 18 | pkt := &Packet{} 19 | if err := pkt.UnmarshalVT(data); err != nil { 20 | return err 21 | } 22 | return handler(pkt) 23 | } 24 | } 25 | 26 | // Validate performs cursory validation of the packet. 27 | func (p *Packet) Validate() error { 28 | switch b := p.GetBody().(type) { 29 | case *Packet_CallStart: 30 | return b.CallStart.Validate() 31 | case *Packet_CallData: 32 | return b.CallData.Validate() 33 | case *Packet_CallCancel: 34 | return nil 35 | default: 36 | return ErrUnrecognizedPacket 37 | } 38 | } 39 | 40 | // NewCallStartPacket constructs a new CallStart packet. 41 | func NewCallStartPacket(service, method string, data []byte, dataIsZero bool) *Packet { 42 | return &Packet{Body: &Packet_CallStart{ 43 | CallStart: &CallStart{ 44 | RpcService: service, 45 | RpcMethod: method, 46 | Data: data, 47 | DataIsZero: dataIsZero, 48 | }, 49 | }} 50 | } 51 | 52 | // Validate performs cursory validation of the packet. 53 | func (p *CallStart) Validate() error { 54 | method := p.GetRpcMethod() 55 | if len(method) == 0 { 56 | return ErrEmptyMethodID 57 | } 58 | service := p.GetRpcService() 59 | if len(service) == 0 { 60 | return ErrEmptyServiceID 61 | } 62 | return nil 63 | } 64 | 65 | // NewCallDataPacket constructs a new CallData packet. 66 | func NewCallDataPacket(data []byte, dataIsZero bool, complete bool, err error) *Packet { 67 | var errStr string 68 | if err != nil { 69 | errStr = err.Error() 70 | } 71 | return &Packet{Body: &Packet_CallData{ 72 | CallData: &CallData{ 73 | Data: data, 74 | DataIsZero: dataIsZero, 75 | Complete: err != nil || complete, 76 | Error: errStr, 77 | }, 78 | }} 79 | } 80 | 81 | // NewCallCancelPacket constructs a new CallCancel packet with cancel. 82 | func NewCallCancelPacket() *Packet { 83 | return &Packet{Body: &Packet_CallCancel{CallCancel: true}} 84 | } 85 | 86 | // Validate performs cursory validation of the packet. 87 | func (p *CallData) Validate() error { 88 | if len(p.GetData()) == 0 && !p.GetComplete() && len(p.GetError()) == 0 && !p.GetDataIsZero() { 89 | return ErrEmptyPacket 90 | } 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /srpc/packet.ts: -------------------------------------------------------------------------------- 1 | import { Uint8ArrayList } from 'uint8arraylist' 2 | import { Source, Transform } from 'it-stream-types' 3 | 4 | import { Packet } from './rpcproto.pb.js' 5 | import { 6 | buildDecodeMessageTransform, 7 | buildEncodeMessageTransform, 8 | } from './message.js' 9 | 10 | // decodePacketSource decodes packets from a binary data stream. 11 | export const decodePacketSource = buildDecodeMessageTransform(Packet) 12 | 13 | // encodePacketSource encodes packets from a packet object stream. 14 | export const encodePacketSource = buildEncodeMessageTransform(Packet) 15 | 16 | // uint32LEDecode removes the length prefix. 17 | export const uint32LEDecode = (data: Uint8ArrayList) => { 18 | if (data.length < 4) { 19 | throw RangeError('Could not decode int32BE') 20 | } 21 | 22 | return data.getUint32(0, true) 23 | } 24 | uint32LEDecode.bytes = 4 25 | 26 | // uint32LEEncode adds the length prefix. 27 | export const uint32LEEncode = (value: number) => { 28 | const data = new Uint8ArrayList(new Uint8Array(4)) 29 | data.setUint32(0, value, true) 30 | return data 31 | } 32 | uint32LEEncode.bytes = 4 33 | 34 | // lengthPrefixEncode transforms a source to a length-prefixed Uint8ArrayList stream. 35 | export async function* lengthPrefixEncode( 36 | source: Source, 37 | lengthEncoder: typeof uint32LEEncode, 38 | ) { 39 | for await (const chunk of source) { 40 | // Encode the length of the chunk. 41 | const length = chunk instanceof Uint8Array ? chunk.length : chunk.byteLength 42 | const lengthEncoded = lengthEncoder(length) 43 | 44 | // Concatenate the length prefix and the data. 45 | yield new Uint8ArrayList(lengthEncoded, chunk) 46 | } 47 | } 48 | 49 | // lengthPrefixDecode decodes a length-prefixed source to a Uint8ArrayList stream. 50 | export async function* lengthPrefixDecode( 51 | source: Source, 52 | lengthDecoder: typeof uint32LEDecode, 53 | ) { 54 | const buffer = new Uint8ArrayList() 55 | 56 | for await (const chunk of source) { 57 | buffer.append(chunk) 58 | 59 | // Continue extracting messages while buffer contains enough data for decoding. 60 | while (buffer.length >= lengthDecoder.bytes) { 61 | const messageLength = lengthDecoder(buffer) 62 | const totalLength = lengthDecoder.bytes + messageLength 63 | 64 | if (buffer.length < totalLength) break // Wait for more data if the full message hasn't arrived. 65 | 66 | // Extract the message excluding the length prefix. 67 | const message = buffer.sublist(lengthDecoder.bytes, totalLength) 68 | yield message 69 | 70 | // Remove the processed message from the buffer. 71 | buffer.consume(totalLength) 72 | } 73 | } 74 | } 75 | 76 | // prependLengthPrefixTransform adds a length prefix to a message source. 77 | // little-endian uint32 78 | export function prependLengthPrefixTransform( 79 | lengthEncoder = uint32LEEncode, 80 | ): Transform< 81 | Source, 82 | | AsyncGenerator 83 | | Generator 84 | > { 85 | return (source: Source) => { 86 | return lengthPrefixEncode(source, lengthEncoder) 87 | } 88 | } 89 | 90 | // parseLengthPrefixTransform parses the length prefix from a message source. 91 | // little-endian uint32 92 | export function parseLengthPrefixTransform( 93 | lengthDecoder = uint32LEDecode, 94 | ): Transform< 95 | Source, 96 | | AsyncGenerator 97 | | Generator 98 | > { 99 | return (source: Source) => { 100 | return lengthPrefixDecode(source, lengthDecoder) 101 | } 102 | } 103 | 104 | // encodeUint32Le encodes the number as a uint32 with little endian. 105 | export function encodeUint32Le(value: number): Uint8Array { 106 | // output is a 4 byte array 107 | const output = new Uint8Array(4) 108 | for (let index = 0; index < output.length; index++) { 109 | const b = value & 0xff 110 | output[index] = b 111 | value = (value - b) / 256 112 | } 113 | return output 114 | } 115 | 116 | // decodeUint32Le decodes a uint32 from a 4 byte Uint8Array. 117 | // returns 0 if decoding failed. 118 | // callers should check that len(data) == 4 119 | export function decodeUint32Le(data: Uint8Array): number { 120 | let value = 0 121 | let nbytes = 4 122 | if (data.length < nbytes) { 123 | nbytes = data.length 124 | } 125 | for (let i = nbytes - 1; i >= 0; i--) { 126 | value = value * 256 + data[i] 127 | } 128 | return value 129 | } 130 | 131 | // prependPacketLen adds the message length prefix to a packet. 132 | export function prependPacketLen(msgData: Uint8Array): Uint8Array { 133 | const msgLen = msgData.length 134 | const msgLenData = encodeUint32Le(msgLen) 135 | const merged = new Uint8Array(msgLen + msgLenData.length) 136 | merged.set(msgLenData) 137 | merged.set(msgData, msgLenData.length) 138 | return merged 139 | } 140 | -------------------------------------------------------------------------------- /srpc/proto-rpc.ts: -------------------------------------------------------------------------------- 1 | // ProtoRpc matches the Rpc interface generated by ts-proto. 2 | // Implemented by the srpc client. 3 | export interface ProtoRpc { 4 | // request fires a one-off unary RPC request. 5 | request( 6 | service: string, 7 | method: string, 8 | data: Uint8Array, 9 | abortSignal?: AbortSignal, 10 | ): Promise 11 | // clientStreamingRequest fires a one-way client->server streaming request. 12 | clientStreamingRequest( 13 | service: string, 14 | method: string, 15 | data: AsyncIterable, 16 | abortSignal?: AbortSignal, 17 | ): Promise 18 | // serverStreamingRequest fires a one-way server->client streaming request. 19 | serverStreamingRequest( 20 | service: string, 21 | method: string, 22 | data: Uint8Array, 23 | abortSignal?: AbortSignal, 24 | ): AsyncIterable 25 | // bidirectionalStreamingRequest implements a two-way streaming request. 26 | bidirectionalStreamingRequest( 27 | service: string, 28 | method: string, 29 | data: AsyncIterable, 30 | abortSignal?: AbortSignal, 31 | ): AsyncIterable 32 | } 33 | -------------------------------------------------------------------------------- /srpc/pushable.ts: -------------------------------------------------------------------------------- 1 | import { Message } from '@aptre/protobuf-es-lite' 2 | import { Pushable, pushable } from 'it-pushable' 3 | import { Sink, Source } from 'it-stream-types' 4 | 5 | // messagePushable is a shortcut to build a pushable for messages. 6 | export function messagePushable>(): Pushable { 7 | return pushable({ objectMode: true }) 8 | } 9 | 10 | // writeToPushable writes the incoming server data to the pushable. 11 | // 12 | // this will not throw an error: it instead ends out w/ the error. 13 | export async function writeToPushable( 14 | dataSource: AsyncIterable, 15 | out: Pushable, 16 | ) { 17 | try { 18 | for await (const data of dataSource) { 19 | out.push(data) 20 | } 21 | out.end() 22 | } catch (err) { 23 | out.end(err as Error) 24 | } 25 | } 26 | 27 | export function buildPushableSink( 28 | target: Pushable, 29 | ): Sink, Promise> { 30 | return async (source: Source): Promise => { 31 | try { 32 | if (Symbol.asyncIterator in source) { 33 | for await (const pkt of source) { 34 | target.push(pkt) 35 | } 36 | } else { 37 | for (const pkt of source) { 38 | target.push(pkt) 39 | } 40 | } 41 | target.end() 42 | } catch (err) { 43 | target.end(err as Error) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /srpc/pushable_js.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | 3 | package srpc 4 | 5 | import ( 6 | "io" 7 | "sync/atomic" 8 | "syscall/js" 9 | ) 10 | 11 | // PushablePacketWriter is a PacketWriter which writes packets to a Pushable. 12 | type PushablePacketWriter struct { 13 | closed atomic.Bool 14 | pushable js.Value 15 | } 16 | 17 | // NewPushablePacketWriter creates a new PushablePacketWriter. 18 | func NewPushablePacketWriter(pushable js.Value) *PushablePacketWriter { 19 | return &PushablePacketWriter{pushable: pushable} 20 | } 21 | 22 | // WritePacket writes a packet to the remote. 23 | func (w *PushablePacketWriter) WritePacket(pkt *Packet) error { 24 | if w.closed.Load() { 25 | return io.ErrClosedPipe 26 | } 27 | 28 | data, err := pkt.MarshalVT() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | a := js.Global().Get("Uint8Array").New(len(data)) 34 | js.CopyBytesToJS(a, data) 35 | w.pushable.Call("push", a) 36 | return nil 37 | } 38 | 39 | // Close closes the writer. 40 | func (w *PushablePacketWriter) Close() error { 41 | if !w.closed.Swap(true) { 42 | w.pushable.Call("end") 43 | } 44 | return nil 45 | } 46 | 47 | // _ is a type assertion 48 | var _ PacketWriter = (*PushablePacketWriter)(nil) 49 | -------------------------------------------------------------------------------- /srpc/rpcproto.pb.ts: -------------------------------------------------------------------------------- 1 | // @generated by protoc-gen-es-lite unknown with parameter "target=ts,ts_nocheck=false" 2 | // @generated from file github.com/aperturerobotics/starpc/srpc/rpcproto.proto (package srpc, syntax proto3) 3 | /* eslint-disable */ 4 | 5 | import type { MessageType, PartialFieldInfo } from "@aptre/protobuf-es-lite"; 6 | import { createMessageType, ScalarType } from "@aptre/protobuf-es-lite"; 7 | 8 | export const protobufPackage = "srpc"; 9 | 10 | /** 11 | * CallStart requests starting a new RPC call. 12 | * 13 | * @generated from message srpc.CallStart 14 | */ 15 | export interface CallStart { 16 | /** 17 | * RpcService is the service to contact. 18 | * Must be set. 19 | * 20 | * @generated from field: string rpc_service = 1; 21 | */ 22 | rpcService?: string; 23 | /** 24 | * RpcMethod is the RPC method to call. 25 | * Must be set. 26 | * 27 | * @generated from field: string rpc_method = 2; 28 | */ 29 | rpcMethod?: string; 30 | /** 31 | * Data contains the request or the first message in the stream. 32 | * Optional if streaming. 33 | * 34 | * @generated from field: bytes data = 3; 35 | */ 36 | data?: Uint8Array; 37 | /** 38 | * DataIsZero indicates Data is set with an empty message. 39 | * 40 | * @generated from field: bool data_is_zero = 4; 41 | */ 42 | dataIsZero?: boolean; 43 | 44 | }; 45 | 46 | // CallStart contains the message type declaration for CallStart. 47 | export const CallStart: MessageType = createMessageType({ 48 | typeName: "srpc.CallStart", 49 | fields: [ 50 | { no: 1, name: "rpc_service", kind: "scalar", T: ScalarType.STRING }, 51 | { no: 2, name: "rpc_method", kind: "scalar", T: ScalarType.STRING }, 52 | { no: 3, name: "data", kind: "scalar", T: ScalarType.BYTES }, 53 | { no: 4, name: "data_is_zero", kind: "scalar", T: ScalarType.BOOL }, 54 | ] as readonly PartialFieldInfo[], 55 | packedByDefault: true, 56 | }); 57 | 58 | /** 59 | * CallData contains a message in a streaming RPC sequence. 60 | * 61 | * @generated from message srpc.CallData 62 | */ 63 | export interface CallData { 64 | /** 65 | * Data contains the packet in the sequence. 66 | * 67 | * @generated from field: bytes data = 1; 68 | */ 69 | data?: Uint8Array; 70 | /** 71 | * DataIsZero indicates Data is set with an empty message. 72 | * 73 | * @generated from field: bool data_is_zero = 2; 74 | */ 75 | dataIsZero?: boolean; 76 | /** 77 | * Complete indicates the RPC call is completed. 78 | * 79 | * @generated from field: bool complete = 3; 80 | */ 81 | complete?: boolean; 82 | /** 83 | * Error contains any error that caused the RPC to fail. 84 | * If set, implies complete=true. 85 | * 86 | * @generated from field: string error = 4; 87 | */ 88 | error?: string; 89 | 90 | }; 91 | 92 | // CallData contains the message type declaration for CallData. 93 | export const CallData: MessageType = createMessageType({ 94 | typeName: "srpc.CallData", 95 | fields: [ 96 | { no: 1, name: "data", kind: "scalar", T: ScalarType.BYTES }, 97 | { no: 2, name: "data_is_zero", kind: "scalar", T: ScalarType.BOOL }, 98 | { no: 3, name: "complete", kind: "scalar", T: ScalarType.BOOL }, 99 | { no: 4, name: "error", kind: "scalar", T: ScalarType.STRING }, 100 | ] as readonly PartialFieldInfo[], 101 | packedByDefault: true, 102 | }); 103 | 104 | /** 105 | * Packet is a message sent over a srpc packet connection. 106 | * 107 | * @generated from message srpc.Packet 108 | */ 109 | export interface Packet { 110 | 111 | /** 112 | * Body is the packet body. 113 | * 114 | * @generated from oneof srpc.Packet.body 115 | */ 116 | body?: { 117 | value?: undefined, 118 | case: undefined 119 | } | { 120 | /** 121 | * CallStart initiates a new call. 122 | * 123 | * @generated from field: srpc.CallStart call_start = 1; 124 | */ 125 | value: CallStart; 126 | case: "callStart"; 127 | } | { 128 | /** 129 | * CallData is a message in a streaming RPC sequence. 130 | * 131 | * @generated from field: srpc.CallData call_data = 2; 132 | */ 133 | value: CallData; 134 | case: "callData"; 135 | } | { 136 | /** 137 | * CallCancel cancels the call. 138 | * 139 | * @generated from field: bool call_cancel = 3; 140 | */ 141 | value: boolean; 142 | case: "callCancel"; 143 | }; 144 | 145 | }; 146 | 147 | // Packet contains the message type declaration for Packet. 148 | export const Packet: MessageType = createMessageType({ 149 | typeName: "srpc.Packet", 150 | fields: [ 151 | { no: 1, name: "call_start", kind: "message", T: () => CallStart, oneof: "body" }, 152 | { no: 2, name: "call_data", kind: "message", T: () => CallData, oneof: "body" }, 153 | { no: 3, name: "call_cancel", kind: "scalar", T: ScalarType.BOOL, oneof: "body" }, 154 | ] as readonly PartialFieldInfo[], 155 | packedByDefault: true, 156 | }); 157 | 158 | -------------------------------------------------------------------------------- /srpc/rpcproto.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package srpc; 3 | 4 | // Packet is a message sent over a srpc packet connection. 5 | message Packet { 6 | // Body is the packet body. 7 | oneof body { 8 | // CallStart initiates a new call. 9 | CallStart call_start = 1; 10 | // CallData is a message in a streaming RPC sequence. 11 | CallData call_data = 2; 12 | // CallCancel cancels the call. 13 | bool call_cancel = 3; 14 | } 15 | } 16 | 17 | // CallStart requests starting a new RPC call. 18 | message CallStart { 19 | // RpcService is the service to contact. 20 | // Must be set. 21 | string rpc_service = 1; 22 | // RpcMethod is the RPC method to call. 23 | // Must be set. 24 | string rpc_method = 2; 25 | // Data contains the request or the first message in the stream. 26 | // Optional if streaming. 27 | bytes data = 3; 28 | // DataIsZero indicates Data is set with an empty message. 29 | bool data_is_zero = 4; 30 | } 31 | 32 | // CallData contains a message in a streaming RPC sequence. 33 | message CallData { 34 | // Data contains the packet in the sequence. 35 | bytes data = 1; 36 | // DataIsZero indicates Data is set with an empty message. 37 | bool data_is_zero = 2; 38 | // Complete indicates the RPC call is completed. 39 | bool complete = 3; 40 | // Error contains any error that caused the RPC to fail. 41 | // If set, implies complete=true. 42 | string error = 4; 43 | } 44 | -------------------------------------------------------------------------------- /srpc/rwc-conn.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "os" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // connPktSize is the size of the buffers to use for packets for the RwcConn. 13 | const connPktSize = 2048 14 | 15 | // RwcConn implements a Conn with a buffered ReadWriteCloser. 16 | type RwcConn struct { 17 | // ctx is the context 18 | ctx context.Context 19 | // ctxCancel is the context canceler 20 | ctxCancel context.CancelFunc 21 | // rwc is the read-write-closer 22 | rwc io.ReadWriteCloser 23 | // laddr is the local addr 24 | laddr net.Addr 25 | // raddr is the remote addr 26 | raddr net.Addr 27 | 28 | ar sync.Pool // packet arena 29 | rd time.Time // read deadline 30 | wd time.Time // write deadline 31 | packetCh chan []byte // packet ch 32 | closeErr error 33 | } 34 | 35 | // NewRwcConn constructs a new packet conn and starts the rx pump. 36 | func NewRwcConn( 37 | ctx context.Context, 38 | rwc io.ReadWriteCloser, 39 | laddr, raddr net.Addr, 40 | bufferPacketN int, 41 | ) *RwcConn { 42 | ctx, ctxCancel := context.WithCancel(ctx) 43 | if bufferPacketN <= 0 { 44 | bufferPacketN = 10 45 | } 46 | 47 | c := &RwcConn{ 48 | ctx: ctx, 49 | ctxCancel: ctxCancel, 50 | rwc: rwc, 51 | laddr: laddr, 52 | raddr: raddr, 53 | packetCh: make(chan []byte, bufferPacketN), 54 | } 55 | go func() { 56 | _ = c.rxPump() 57 | }() 58 | return c 59 | } 60 | 61 | // LocalAddr returns the local network address. 62 | func (p *RwcConn) LocalAddr() net.Addr { 63 | return p.laddr 64 | } 65 | 66 | // RemoteAddr returns the bound remote network address. 67 | func (p *RwcConn) RemoteAddr() net.Addr { 68 | return p.raddr 69 | } 70 | 71 | // Read reads data from the connection. 72 | // Read can be made to time out and return an error after a fixed 73 | // time limit; see SetDeadline and SetReadDeadline. 74 | func (p *RwcConn) Read(b []byte) (n int, err error) { 75 | deadline := p.rd 76 | ctx := p.ctx 77 | if !deadline.IsZero() { 78 | var ctxCancel context.CancelFunc 79 | ctx, ctxCancel = context.WithDeadline(ctx, deadline) 80 | defer ctxCancel() 81 | } 82 | 83 | var pkt []byte 84 | var ok bool 85 | select { 86 | case <-ctx.Done(): 87 | if !deadline.IsZero() { 88 | return 0, os.ErrDeadlineExceeded 89 | } 90 | return 0, context.Canceled 91 | case pkt, ok = <-p.packetCh: 92 | if !ok { 93 | err = p.closeErr 94 | if err == nil { 95 | err = io.EOF 96 | } 97 | return 0, err 98 | } 99 | } 100 | 101 | pl := len(pkt) 102 | copy(b, pkt) 103 | p.ar.Put(&pkt) 104 | if len(b) < pl { 105 | return len(b), io.ErrShortBuffer 106 | } 107 | return pl, nil 108 | } 109 | 110 | // Write writes data to the connection. 111 | func (p *RwcConn) Write(pkt []byte) (n int, err error) { 112 | if len(pkt) == 0 { 113 | return 0, nil 114 | } 115 | 116 | written := 0 117 | for written < len(pkt) { 118 | n, err = p.rwc.Write(pkt[written:]) 119 | written += n 120 | if err != nil { 121 | return written, err 122 | } 123 | } 124 | return written, nil 125 | } 126 | 127 | // SetDeadline sets the read and write deadlines associated 128 | // with the connection. It is equivalent to calling both 129 | // SetReadDeadline and SetWriteDeadline. 130 | // 131 | // A deadline is an absolute time after which I/O operations 132 | // fail instead of blocking. The deadline applies to all future 133 | // and pending I/O, not just the immediately following call to 134 | // Read or Write. After a deadline has been exceeded, the 135 | // connection can be refreshed by setting a deadline in the future. 136 | // 137 | // If the deadline is exceeded a call to Read or Write or to other 138 | // I/O methods will return an error that wraps os.ErrDeadlineExceeded. 139 | // This can be tested using errors.Is(err, os.ErrDeadlineExceeded). 140 | // The error's Timeout method will return true, but note that there 141 | // are other possible errors for which the Timeout method will 142 | // return true even if the deadline has not been exceeded. 143 | // 144 | // An idle timeout can be implemented by repeatedly extending 145 | // the deadline after successful ReadFrom or WriteTo calls. 146 | // 147 | // A zero value for t means I/O operations will not time out. 148 | func (p *RwcConn) SetDeadline(t time.Time) error { 149 | p.rd = t 150 | p.wd = t 151 | return nil 152 | } 153 | 154 | // SetReadDeadline sets the deadline for future ReadFrom calls 155 | // and any currently-blocked ReadFrom call. 156 | // A zero value for t means ReadFrom will not time out. 157 | func (p *RwcConn) SetReadDeadline(t time.Time) error { 158 | p.rd = t 159 | return nil 160 | } 161 | 162 | // SetWriteDeadline sets the deadline for future WriteTo calls 163 | // and any currently-blocked WriteTo call. 164 | // Even if write times out, it may return n > 0, indicating that 165 | // some of the data was successfully written. 166 | // A zero value for t means WriteTo will not time out. 167 | func (p *RwcConn) SetWriteDeadline(t time.Time) error { 168 | p.wd = t 169 | return nil 170 | } 171 | 172 | // Close closes the connection. 173 | // Any blocked ReadFrom or WriteTo operations will be unblocked and return errors. 174 | func (p *RwcConn) Close() error { 175 | return p.rwc.Close() 176 | } 177 | 178 | // getArenaBuf returns a buf from the packet arena with at least the given size 179 | func (p *RwcConn) getArenaBuf(size int) []byte { 180 | var buf []byte 181 | bufp := p.ar.Get() 182 | if bufp != nil { 183 | buf = *bufp.(*[]byte) 184 | } 185 | if size != 0 { 186 | if cap(buf) < size { 187 | buf = make([]byte, size) 188 | } else { 189 | buf = buf[:size] 190 | } 191 | } else { 192 | buf = buf[:cap(buf)] 193 | } 194 | return buf 195 | } 196 | 197 | // rxPump receives messages from the underlying connection. 198 | func (p *RwcConn) rxPump() (rerr error) { 199 | defer func() { 200 | p.closeErr = rerr 201 | close(p.packetCh) 202 | }() 203 | 204 | for { 205 | select { 206 | case <-p.ctx.Done(): 207 | return p.ctx.Err() 208 | default: 209 | } 210 | 211 | pktBuf := p.getArenaBuf(int(connPktSize)) 212 | n, err := p.rwc.Read(pktBuf) 213 | if n == 0 { 214 | p.ar.Put(&pktBuf) 215 | } else { 216 | select { 217 | case <-p.ctx.Done(): 218 | return context.Canceled 219 | case p.packetCh <- pktBuf[:n]: 220 | } 221 | } 222 | if err != nil { 223 | return err 224 | } 225 | } 226 | } 227 | 228 | // _ is a type assertion 229 | var _ net.Conn = ((*RwcConn)(nil)) 230 | -------------------------------------------------------------------------------- /srpc/server-http.go: -------------------------------------------------------------------------------- 1 | //go:build !js 2 | 3 | package srpc 4 | 5 | import ( 6 | "context" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/coder/websocket" 11 | ) 12 | 13 | // HTTPServer implements the SRPC HTTP/WebSocket server. 14 | // 15 | // NOTE: accepting websocket connections is stubbed out on GOOS=js! 16 | type HTTPServer struct { 17 | mux Mux 18 | srpc *Server 19 | path string 20 | acceptOpts *websocket.AcceptOptions 21 | } 22 | 23 | // NewHTTPServer builds a http server / handler. 24 | // if path is empty, serves on all routes. 25 | func NewHTTPServer(mux Mux, path string, acceptOpts *websocket.AcceptOptions) (*HTTPServer, error) { 26 | return &HTTPServer{ 27 | mux: mux, 28 | srpc: NewServer(mux), 29 | path: path, 30 | acceptOpts: acceptOpts, 31 | }, nil 32 | } 33 | 34 | func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 35 | if s.path != "" && r.URL.Path != s.path { 36 | return 37 | } 38 | 39 | c, err := websocket.Accept(w, r, s.acceptOpts) 40 | if err != nil { 41 | // NOTE: the error is already written with http.Error 42 | // w.WriteHeader(500) 43 | // _, _ = w.Write([]byte(err.Error() + "\n")) 44 | return 45 | } 46 | defer c.Close(websocket.StatusInternalError, "closed") 47 | 48 | ctx := r.Context() 49 | wsConn, err := NewWebSocketConn(ctx, c, true, nil) 50 | if err != nil { 51 | c.Close(websocket.StatusInternalError, err.Error()) 52 | return 53 | } 54 | 55 | // handle incoming streams 56 | for { 57 | strm, err := wsConn.AcceptStream() 58 | if err != nil { 59 | if err != io.EOF && err != context.Canceled { 60 | // TODO: handle / log error? 61 | c.Close(websocket.StatusInternalError, err.Error()) 62 | } 63 | return 64 | } 65 | go s.srpc.HandleStream(ctx, strm) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /srpc/server-http_js.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | 3 | package srpc 4 | 5 | import "errors" 6 | 7 | // HTTPServer implements the SRPC HTTP/WebSocket server. 8 | // 9 | // NOTE: accepting websocket connections is stubbed out on GOOS=js! 10 | type HTTPServer struct{} 11 | 12 | // NewHTTPServer builds a http server / handler. 13 | func NewHTTPServer(mux Mux, path string) (*HTTPServer, error) { 14 | return nil, errors.New("srpc: http server not implemented on js") 15 | } 16 | 17 | // stub for js 18 | func (s *HTTPServer) ServeHTTP(w any, r any) {} 19 | -------------------------------------------------------------------------------- /srpc/server-pipe.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "net" 6 | ) 7 | 8 | // NewServerPipe constructs a open stream func which creates an in-memory Pipe 9 | // Stream with the given Server. Starts read pumps for both. Starts the 10 | // HandleStream function on the server in a separate goroutine. 11 | func NewServerPipe(server *Server) OpenStreamFunc { 12 | return func(ctx context.Context, msgHandler PacketDataHandler, closeHandler CloseHandler) (PacketWriter, error) { 13 | srvPipe, clientPipe := net.Pipe() 14 | go server.HandleStream(ctx, srvPipe) 15 | clientPrw := NewPacketReadWriter(clientPipe) 16 | go clientPrw.ReadPump(msgHandler, closeHandler) 17 | return clientPrw, nil 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /srpc/server-rpc.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // ServerRPC represents the server side of an on-going RPC call message stream. 10 | type ServerRPC struct { 11 | commonRPC 12 | // invoker is the rpc call invoker 13 | invoker Invoker 14 | } 15 | 16 | // NewServerRPC constructs a new ServerRPC session. 17 | // note: call SetWriter before handling any incoming messages. 18 | func NewServerRPC(ctx context.Context, invoker Invoker, writer PacketWriter) *ServerRPC { 19 | rpc := &ServerRPC{invoker: invoker} 20 | initCommonRPC(ctx, &rpc.commonRPC) 21 | rpc.writer = writer 22 | return rpc 23 | } 24 | 25 | // HandlePacketData handles an incoming unparsed message packet. 26 | func (r *ServerRPC) HandlePacketData(data []byte) error { 27 | msg := &Packet{} 28 | if err := msg.UnmarshalVT(data); err != nil { 29 | return err 30 | } 31 | return r.HandlePacket(msg) 32 | } 33 | 34 | // HandlePacket handles an incoming parsed message packet. 35 | func (r *ServerRPC) HandlePacket(msg *Packet) error { 36 | if msg == nil { 37 | return nil 38 | } 39 | if err := msg.Validate(); err != nil { 40 | return err 41 | } 42 | 43 | switch b := msg.GetBody().(type) { 44 | case *Packet_CallStart: 45 | return r.HandleCallStart(b.CallStart) 46 | case *Packet_CallData: 47 | return r.HandleCallData(b.CallData) 48 | case *Packet_CallCancel: 49 | if b.CallCancel { 50 | return r.HandleCallCancel() 51 | } 52 | return nil 53 | default: 54 | return nil 55 | } 56 | } 57 | 58 | // HandleCallStart handles the call start packet. 59 | func (r *ServerRPC) HandleCallStart(pkt *CallStart) error { 60 | var err error 61 | 62 | r.bcast.HoldLock(func(broadcast func(), getWaitCh func() <-chan struct{}) { 63 | // process start: method and service 64 | if r.method != "" || r.service != "" { 65 | err = errors.New("call start must be sent only once") 66 | return 67 | } 68 | if r.dataClosed { 69 | err = ErrCompleted 70 | return 71 | } 72 | 73 | service, method := pkt.GetRpcService(), pkt.GetRpcMethod() 74 | r.service, r.method = service, method 75 | 76 | // process first data packet, if included 77 | if data := pkt.GetData(); len(data) != 0 || pkt.GetDataIsZero() { 78 | r.dataQueue = append(r.dataQueue, data) 79 | } 80 | 81 | // invoke the rpc 82 | broadcast() 83 | go r.invokeRPC(service, method) 84 | }) 85 | 86 | return err 87 | } 88 | 89 | // invokeRPC invokes the RPC after CallStart is received. 90 | func (r *ServerRPC) invokeRPC(serviceID, methodID string) { 91 | // on the server side, the writer is closed by invokeRPC. 92 | strm := NewMsgStream(r.ctx, r, r.ctxCancel) 93 | ok, err := r.invoker.InvokeMethod(serviceID, methodID, strm) 94 | if err == nil && !ok { 95 | err = ErrUnimplemented 96 | } 97 | outPkt := NewCallDataPacket(nil, false, true, err) 98 | _ = r.writer.WritePacket(outPkt) 99 | _ = r.writer.Close() 100 | r.ctxCancel() 101 | } 102 | -------------------------------------------------------------------------------- /srpc/server-rpc.ts: -------------------------------------------------------------------------------- 1 | import type { Sink, Source } from 'it-stream-types' 2 | 3 | import type { CallData, CallStart } from './rpcproto.pb.js' 4 | import { CommonRPC } from './common-rpc.js' 5 | import { InvokeFn } from './handler.js' 6 | import { LookupMethod } from './mux.js' 7 | 8 | // ServerRPC is an ongoing RPC from the server side. 9 | export class ServerRPC extends CommonRPC { 10 | // lookupMethod looks up the incoming RPC methods. 11 | private lookupMethod: LookupMethod 12 | 13 | constructor(lookupMethod: LookupMethod) { 14 | super() 15 | this.lookupMethod = lookupMethod 16 | } 17 | 18 | // handleCallStart handles a CallStart cket. 19 | public override async handleCallStart(packet: Partial) { 20 | if (this.service || this.method) { 21 | throw new Error('call start must be sent only once') 22 | } 23 | this.service = packet.rpcService 24 | this.method = packet.rpcMethod 25 | if (!this.service || !this.method) { 26 | throw new Error('rpcService and rpcMethod cannot be empty') 27 | } 28 | if (!this.lookupMethod) { 29 | throw new Error('LookupMethod is not defined') 30 | } 31 | const methodDef = await this.lookupMethod(this.service, this.method) 32 | if (!methodDef) { 33 | throw new Error(`not found: ${this.service}/${this.method}`) 34 | } 35 | this.pushRpcData(packet.data, packet.dataIsZero) 36 | this.invokeRPC(methodDef) 37 | } 38 | 39 | // handleCallData handles a CallData packet. 40 | public override async handleCallData(packet: Partial) { 41 | if (!this.service || !this.method) { 42 | throw new Error('call start must be sent before call data') 43 | } 44 | return super.handleCallData(packet) 45 | } 46 | 47 | // invokeRPC starts invoking the RPC handler. 48 | private async invokeRPC(invokeFn: InvokeFn) { 49 | const dataSink = this._createDataSink() 50 | try { 51 | await invokeFn(this.rpcDataSource, dataSink) 52 | } catch (err) { 53 | this.close(err as Error) 54 | } 55 | } 56 | 57 | // _createDataSink creates a sink for outgoing data packets. 58 | private _createDataSink(): Sink> { 59 | return async (source) => { 60 | try { 61 | for await (const msg of source) { 62 | await this.writeCallData(msg) 63 | } 64 | await this.writeCallData(undefined, true) 65 | this.close() 66 | } catch (err) { 67 | this.close(err as Error) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /srpc/server.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/libp2p/go-libp2p/core/network" 8 | ) 9 | 10 | // Server handles incoming RPC streams with a mux. 11 | type Server struct { 12 | // invoker is the method invoker 13 | invoker Invoker 14 | } 15 | 16 | // NewServer constructs a new SRPC server. 17 | func NewServer(invoker Invoker) *Server { 18 | return &Server{ 19 | invoker: invoker, 20 | } 21 | } 22 | 23 | // GetInvoker returns the invoker. 24 | func (s *Server) GetInvoker() Invoker { 25 | return s.invoker 26 | } 27 | 28 | // HandleStream handles an incoming stream and runs the read loop. 29 | // Uses length-prefixed packets. 30 | func (s *Server) HandleStream(ctx context.Context, rwc io.ReadWriteCloser) { 31 | subCtx, subCtxCancel := context.WithCancel(ctx) 32 | defer subCtxCancel() 33 | prw := NewPacketReadWriter(rwc) 34 | serverRPC := NewServerRPC(subCtx, s.invoker, prw) 35 | prw.ReadPump(serverRPC.HandlePacketData, serverRPC.HandleStreamClose) 36 | } 37 | 38 | // AcceptMuxedConn runs a loop which calls Accept on a muxer to handle streams. 39 | // 40 | // Starts HandleStream in a separate goroutine to handle the stream. 41 | // Returns context.Canceled or io.EOF when the loop is complete / closed. 42 | func (s *Server) AcceptMuxedConn(ctx context.Context, mc network.MuxedConn) error { 43 | for { 44 | if err := ctx.Err(); err != nil { 45 | return context.Canceled 46 | } 47 | 48 | if mc.IsClosed() { 49 | return io.EOF 50 | } 51 | 52 | muxedStream, err := mc.AcceptStream() 53 | if err != nil { 54 | return err 55 | } 56 | go s.HandleStream(ctx, muxedStream) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /srpc/server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, beforeEach } from 'vitest' 2 | import { pipe } from 'it-pipe' 3 | import { 4 | createHandler, 5 | createMux, 6 | Server, 7 | Client, 8 | StreamConn, 9 | ChannelStream, 10 | combineUint8ArrayListTransform, 11 | ChannelStreamOpts, 12 | } from '../srpc/index.js' 13 | import { EchoerDefinition, EchoerServer, runClientTest } from '../echo/index.js' 14 | import { 15 | runAbortControllerTest, 16 | runRpcStreamTest, 17 | } from '../echo/client-test.js' 18 | 19 | describe('srpc server', () => { 20 | let client: Client 21 | 22 | beforeEach(async () => { 23 | const mux = createMux() 24 | const server = new Server(mux.lookupMethod) 25 | const echoer = new EchoerServer(server) 26 | mux.register(createHandler(EchoerDefinition, echoer)) 27 | 28 | // StreamConn is unnecessary since ChannelStream has packet framing. 29 | // Use it here to include yamux in this e2e test. 30 | const clientConn = new StreamConn() 31 | const serverConn = new StreamConn(server, { direction: 'inbound' }) 32 | 33 | // pipe clientConn -> messageStream -> serverConn -> messageStream -> clientConn 34 | const { port1: clientPort, port2: serverPort } = new MessageChannel() 35 | const opts: ChannelStreamOpts = {} // { idleTimeoutMs: 250, keepAliveMs: 100 } 36 | const clientChannelStream = new ChannelStream('client', clientPort, opts) 37 | const serverChannelStream = new ChannelStream('server', serverPort, opts) 38 | 39 | // Pipe the client traffic via the client end of the MessageChannel. 40 | pipe( 41 | clientChannelStream, 42 | clientConn, 43 | combineUint8ArrayListTransform(), 44 | clientChannelStream, 45 | ) 46 | .catch((err: Error) => clientConn.close(err)) 47 | .then(() => clientConn.close()) 48 | 49 | // Pipe the server traffic via the server end of the MessageChannel. 50 | pipe( 51 | serverChannelStream, 52 | serverConn, 53 | combineUint8ArrayListTransform(), 54 | serverChannelStream, 55 | ) 56 | .catch((err: Error) => serverConn.close(err)) 57 | .then(() => serverConn.close()) 58 | 59 | // Build the client 60 | client = new Client(clientConn.buildOpenStreamFunc()) 61 | }) 62 | 63 | it('should pass client tests', async () => { 64 | await runClientTest(client) 65 | }) 66 | 67 | it('should pass abort controller tests', async () => { 68 | await runAbortControllerTest(client) 69 | }) 70 | 71 | it('should pass rpc stream tests', async () => { 72 | await runRpcStreamTest(client) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /srpc/server.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'it-pipe' 2 | 3 | import { LookupMethod } from './mux.js' 4 | import { ServerRPC } from './server-rpc.js' 5 | import { decodePacketSource, encodePacketSource } from './packet.js' 6 | import { StreamHandler } from './conn.js' 7 | import { PacketStream } from './stream.js' 8 | import { RpcStreamHandler } from '../rpcstream/rpcstream.js' 9 | 10 | // Server implements the SRPC server in TypeScript with a Mux. 11 | export class Server implements StreamHandler { 12 | // lookupMethod looks up the incoming RPC methods. 13 | private lookupMethod: LookupMethod 14 | 15 | constructor(lookupMethod: LookupMethod) { 16 | this.lookupMethod = lookupMethod 17 | } 18 | 19 | // rpcStreamHandler implements the RpcStreamHandler interface. 20 | // uses handlePacketDuplex (expects 1 buf = 1 Packet) 21 | public get rpcStreamHandler(): RpcStreamHandler { 22 | return async (stream: PacketStream) => { 23 | const rpc = this.startRpc() 24 | return pipe(stream, decodePacketSource, rpc, encodePacketSource, stream) 25 | .catch((err: Error) => rpc.close(err)) 26 | .then(() => rpc.close()) 27 | } 28 | } 29 | 30 | // startRpc starts a new server-side RPC. 31 | // the returned RPC handles incoming Packets. 32 | public startRpc(): ServerRPC { 33 | return new ServerRPC(this.lookupMethod) 34 | } 35 | 36 | // handlePacketStream handles an incoming Uint8Array duplex. 37 | // the stream has one Uint8Array per packet w/o length prefix. 38 | public handlePacketStream(stream: PacketStream): ServerRPC { 39 | const rpc = this.startRpc() 40 | pipe(stream, decodePacketSource, rpc, encodePacketSource, stream) 41 | .catch((err: Error) => rpc.close(err)) 42 | .then(() => rpc.close()) 43 | return rpc 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /srpc/stream-pipe.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "sync" 7 | ) 8 | 9 | // pipeStream implements an in-memory stream. 10 | // intended for testing 11 | type pipeStream struct { 12 | ctx context.Context 13 | ctxCancel context.CancelFunc 14 | // other is the other end of the stream. 15 | other *pipeStream 16 | // closeOnce ensures we close only once. 17 | closeOnce sync.Once 18 | // dataCh is the data channel 19 | dataCh chan []byte 20 | } 21 | 22 | // NewPipeStream constructs a new in-memory stream. 23 | func NewPipeStream(ctx context.Context) (Stream, Stream) { 24 | s1 := &pipeStream{dataCh: make(chan []byte, 5)} 25 | s1.ctx, s1.ctxCancel = context.WithCancel(ctx) 26 | s2 := &pipeStream{other: s1, dataCh: make(chan []byte, 5)} 27 | s2.ctx, s2.ctxCancel = context.WithCancel(ctx) 28 | s1.other = s2 29 | return s1, s2 30 | } 31 | 32 | // Context is canceled when the Stream is no longer valid. 33 | func (p *pipeStream) Context() context.Context { 34 | return p.ctx 35 | } 36 | 37 | // MsgSend sends the message to the remote. 38 | func (p *pipeStream) MsgSend(msg Message) error { 39 | data, err := msg.MarshalVT() 40 | if err != nil { 41 | return err 42 | } 43 | select { 44 | case <-p.ctx.Done(): 45 | return context.Canceled 46 | case p.other.dataCh <- data: 47 | return nil 48 | } 49 | } 50 | 51 | // MsgRecv receives an incoming message from the remote. 52 | // Parses the message into the object at msg. 53 | func (p *pipeStream) MsgRecv(msg Message) error { 54 | select { 55 | case <-p.ctx.Done(): 56 | return context.Canceled 57 | case data, ok := <-p.dataCh: 58 | if !ok { 59 | return io.EOF 60 | } 61 | return msg.UnmarshalVT(data) 62 | } 63 | } 64 | 65 | // CloseSend signals to the remote that we will no longer send any messages. 66 | func (p *pipeStream) CloseSend() error { 67 | p.closeRemote() 68 | return nil 69 | } 70 | 71 | // Close closes the stream. 72 | func (p *pipeStream) Close() error { 73 | p.ctxCancel() 74 | p.closeRemote() 75 | return nil 76 | } 77 | 78 | // closeRemote closes the remote data channel. 79 | func (p *pipeStream) closeRemote() { 80 | p.closeOnce.Do(func() { 81 | close(p.other.dataCh) 82 | }) 83 | } 84 | 85 | // _ is a type assertion 86 | var _ Stream = ((*pipeStream)(nil)) 87 | -------------------------------------------------------------------------------- /srpc/stream-rwc.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | // StreamRwc implements an io.ReadWriteCloser with a srpc.Stream. 9 | type StreamRwc struct { 10 | // Stream is the base stream interface. 11 | Stream 12 | 13 | // buf is the incoming data buffer 14 | buf bytes.Buffer 15 | // readMsg is the raw read message 16 | readMsg RawMessage 17 | // writeMsg is the raw write message 18 | writeMsg RawMessage 19 | } 20 | 21 | // NewStreamRwc constructs a new stream read write closer. 22 | func NewStreamRwc(strm Stream) *StreamRwc { 23 | rwc := &StreamRwc{Stream: strm} 24 | rwc.readMsg.copy = true 25 | rwc.writeMsg.copy = true 26 | return rwc 27 | } 28 | 29 | // Read reads data from the stream to p. 30 | // Implements io.Reader. 31 | func (s *StreamRwc) Read(p []byte) (n int, err error) { 32 | readBuf := p 33 | for len(readBuf) != 0 && err == nil { 34 | var rn int 35 | 36 | // if the buffer has data, read from it. 37 | if s.buf.Len() != 0 { 38 | rn, err = s.buf.Read(readBuf) 39 | } else { 40 | if n != 0 { 41 | // if we read data to p already, return now. 42 | break 43 | } 44 | 45 | s.readMsg.Clear() 46 | if err := s.MsgRecv(&s.readMsg); err != nil { 47 | return n, err 48 | } 49 | data := s.readMsg.GetData() 50 | if len(data) == 0 { 51 | continue 52 | } 53 | 54 | // read as much as possible directly to the output 55 | copy(readBuf, data) 56 | if len(data) > len(readBuf) { 57 | // we read some of the data, buffer the rest. 58 | rn = len(readBuf) 59 | _, _ = s.buf.Write(data[rn:]) // never returns an error 60 | } else { 61 | // we read all of data 62 | rn = len(data) 63 | } 64 | } 65 | 66 | // advance readBuf by rn 67 | n += rn 68 | readBuf = readBuf[rn:] 69 | } 70 | return n, err 71 | } 72 | 73 | // Write writes data to the stream. 74 | func (s *StreamRwc) Write(p []byte) (n int, err error) { 75 | s.writeMsg.SetData(p) 76 | err = s.MsgSend(&s.writeMsg) 77 | s.writeMsg.Clear() 78 | if err != nil { 79 | return 0, err 80 | } 81 | return len(p), nil 82 | } 83 | 84 | // _ is a type assertion 85 | var _ io.ReadWriteCloser = ((*StreamRwc)(nil)) 86 | -------------------------------------------------------------------------------- /srpc/stream.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Stream is a handle to an on-going bi-directional or one-directional stream RPC handle. 8 | type Stream interface { 9 | // Context is canceled when the Stream is no longer valid. 10 | Context() context.Context 11 | 12 | // MsgSend sends the message to the remote. 13 | MsgSend(msg Message) error 14 | 15 | // MsgRecv receives an incoming message from the remote. 16 | // Parses the message into the object at msg. 17 | MsgRecv(msg Message) error 18 | 19 | // CloseSend signals to the remote that we will no longer send any messages. 20 | CloseSend() error 21 | 22 | // Close closes the stream for reading and writing. 23 | Close() error 24 | } 25 | 26 | // StreamRecv is a stream that can receive typed messages. 27 | // 28 | // T is the response type. 29 | type StreamRecv[T any] interface { 30 | Stream 31 | Recv() (T, error) 32 | RecvTo(T) error 33 | } 34 | 35 | // StreamSend is a stream that can send typed messages. 36 | // 37 | // T is the outgoing type. 38 | type StreamSend[T any] interface { 39 | Stream 40 | Send(T) error 41 | } 42 | 43 | // StreamSendAndClose is a stream that can send typed messages, closing after. 44 | // 45 | // T is the outgoing type. 46 | type StreamSendAndClose[T any] interface { 47 | StreamSend[T] 48 | SendAndClose(T) error 49 | } 50 | 51 | // streamWithClose is a Stream with a wrapped Close function. 52 | type streamWithClose struct { 53 | Stream 54 | closeFn func() error 55 | } 56 | 57 | // NewStreamWithClose wraps a Stream with a close function to call when Close is called. 58 | func NewStreamWithClose(strm Stream, close func() error) Stream { 59 | return &streamWithClose{Stream: strm, closeFn: close} 60 | } 61 | 62 | // Close closes the stream for reading and writing. 63 | func (s *streamWithClose) Close() error { 64 | err := s.Stream.Close() 65 | err2 := s.closeFn() 66 | if err != nil { 67 | return err 68 | } 69 | return err2 70 | } 71 | 72 | // _ is a type assertion 73 | var _ Stream = (*streamWithClose)(nil) 74 | -------------------------------------------------------------------------------- /srpc/stream.ts: -------------------------------------------------------------------------------- 1 | import type { Duplex, Source } from 'it-stream-types' 2 | import { pipe } from 'it-pipe' 3 | import { Stream } from '@libp2p/interface' 4 | 5 | import type { Packet } from './rpcproto.pb.js' 6 | import { combineUint8ArrayListTransform } from './array-list.js' 7 | import { 8 | parseLengthPrefixTransform, 9 | prependLengthPrefixTransform, 10 | } from './packet.js' 11 | 12 | // PacketHandler handles incoming packets. 13 | export type PacketHandler = (packet: Packet) => Promise 14 | 15 | // PacketStream represents a stream of packets where each Uint8Array represents one packet. 16 | export type PacketStream = Duplex< 17 | AsyncGenerator, 18 | Source, 19 | Promise 20 | > 21 | 22 | // OpenStreamFunc is a function to start a new RPC by opening a Stream. 23 | export type OpenStreamFunc = () => Promise 24 | 25 | // HandleStreamFunc handles an incoming RPC stream. 26 | // Returns as soon as the stream has been passed off to be handled. 27 | // Throws an error if we can't handle the incoming stream. 28 | export type HandleStreamFunc = (ch: PacketStream) => Promise 29 | 30 | // streamToPacketStream converts a Stream into a PacketStream using length-prefix framing. 31 | // 32 | // The stream is closed when the source writing to the sink ends. 33 | export function streamToPacketStream(stream: Stream): PacketStream { 34 | return { 35 | source: pipe( 36 | stream, 37 | parseLengthPrefixTransform(), 38 | combineUint8ArrayListTransform(), 39 | ), 40 | sink: async (source: Source): Promise => { 41 | await pipe(source, prependLengthPrefixTransform(), stream) 42 | .catch((err) => stream.close(err)) 43 | .then(() => stream.close()) 44 | }, 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /srpc/strip-prefix.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import "strings" 4 | 5 | // CheckStripPrefix checks if the string has any of the given prefixes and 6 | // strips the matched prefix if any. 7 | // 8 | // if len(matchPrefixes) == 0 returns the ID without changing it. 9 | func CheckStripPrefix(id string, matchPrefixes []string) (strippedID string, matchedPrefix string) { 10 | if len(matchPrefixes) == 0 { 11 | return id, "" 12 | } 13 | 14 | var matched bool 15 | for _, prefix := range matchPrefixes { 16 | matched = strings.HasPrefix(id, prefix) 17 | if matched { 18 | matchedPrefix = prefix 19 | break 20 | } 21 | } 22 | if !matched { 23 | return id, "" 24 | } 25 | return id[len(matchedPrefix):], matchedPrefix 26 | } 27 | -------------------------------------------------------------------------------- /srpc/value-ctr.ts: -------------------------------------------------------------------------------- 1 | // ValueCtr contains a value that can be set asynchronously. 2 | export class ValueCtr { 3 | // _value contains the current value. 4 | private _value: T | undefined 5 | // _waiters contains the list of waiters. 6 | // called when the value is set to any value other than undefined. 7 | private _waiters: ((fn: T) => void)[] 8 | 9 | constructor(initialValue?: T) { 10 | this._value = initialValue || undefined 11 | this._waiters = [] 12 | } 13 | 14 | // value returns the current value. 15 | get value(): T | undefined { 16 | return this._value 17 | } 18 | 19 | // wait waits for the value to not be undefined. 20 | public async wait(): Promise { 21 | const currVal = this._value 22 | if (currVal !== undefined) { 23 | return currVal 24 | } 25 | return new Promise((resolve) => { 26 | this.waitWithCb((val: T) => { 27 | resolve(val) 28 | }) 29 | }) 30 | } 31 | 32 | // waitWithCb adds a callback to be called when the value is not undefined. 33 | public waitWithCb(cb: (val: T) => void) { 34 | if (cb) { 35 | this._waiters.push(cb) 36 | } 37 | } 38 | 39 | // set sets the value and calls the callbacks. 40 | public set(val: T | undefined) { 41 | this._value = val 42 | if (val === undefined) { 43 | return 44 | } 45 | const waiters = this._waiters 46 | if (waiters.length === 0) { 47 | return 48 | } 49 | this._waiters = [] 50 | for (const waiter of waiters) { 51 | waiter(val) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /srpc/watchdog.ts: -------------------------------------------------------------------------------- 1 | // Watchdog must be fed every timeoutDuration or it will call the expired callback. 2 | // NOTE: Browsers will throttle setTimeout in background tabs. 3 | export class Watchdog { 4 | private timeoutDuration: number 5 | private expiredCallback: () => void 6 | private timerId: NodeJS.Timeout | null = null 7 | private lastFeedTimestamp: number | null = null 8 | 9 | /** 10 | * Constructs a Watchdog instance. 11 | * The Watchdog will not start ticking until feed() is called. 12 | * @param timeoutDuration The duration in milliseconds after which the watchdog should expire if not fed. 13 | * @param expiredCallback The callback function to be called when the watchdog expires. 14 | */ 15 | constructor(timeoutDuration: number, expiredCallback: () => void) { 16 | this.timeoutDuration = timeoutDuration 17 | this.expiredCallback = expiredCallback 18 | } 19 | 20 | /** 21 | * Feeds the watchdog, preventing it from expiring. 22 | * This resets the timeout and reschedules the next tick. 23 | */ 24 | public feed(): void { 25 | this.lastFeedTimestamp = Date.now() 26 | this.scheduleTickWatchdog(this.timeoutDuration) 27 | } 28 | 29 | /** 30 | * Clears the current timeout, effectively stopping the watchdog. 31 | * This prevents the expired callback from being called until the watchdog is fed again. 32 | */ 33 | public clear(): void { 34 | if (this.timerId != null) { 35 | clearTimeout(this.timerId) 36 | this.timerId = null 37 | } 38 | this.lastFeedTimestamp = null 39 | } 40 | 41 | /** 42 | * Schedules the next tick of the watchdog. 43 | * This method calculates the delay for the next tick based on the last feed time 44 | * and schedules a call to tickWatchdog after that delay. 45 | */ 46 | private scheduleTickWatchdog(delay: number): void { 47 | if (this.timerId != null) { 48 | clearTimeout(this.timerId) 49 | } 50 | this.timerId = setTimeout(() => this.tickWatchdog(), delay) 51 | } 52 | 53 | /** 54 | * Handler for the watchdog tick. 55 | * Checks if the time since the last feed is greater than the timeout duration. 56 | * If so, it calls the expired callback. Otherwise, it reschedules the tick. 57 | */ 58 | private tickWatchdog(): void { 59 | this.timerId = null 60 | if (this.lastFeedTimestamp == null) { 61 | this.expiredCallback() 62 | return 63 | } 64 | const elapsedSinceLastFeed = Date.now() - this.lastFeedTimestamp 65 | if (elapsedSinceLastFeed >= this.timeoutDuration) { 66 | this.expiredCallback() 67 | } else { 68 | this.scheduleTickWatchdog(this.timeoutDuration - elapsedSinceLastFeed) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /srpc/websocket.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/coder/websocket" 7 | "github.com/libp2p/go-libp2p/core/network" 8 | "github.com/libp2p/go-yamux/v4" 9 | ) 10 | 11 | // NewWebSocketConn wraps a websocket into a MuxedConn. 12 | // if yamuxConf is unset, uses the defaults. 13 | func NewWebSocketConn( 14 | ctx context.Context, 15 | conn *websocket.Conn, 16 | isServer bool, 17 | yamuxConf *yamux.Config, 18 | ) (network.MuxedConn, error) { 19 | nc := websocket.NetConn(ctx, conn, websocket.MessageBinary) 20 | return NewMuxedConn(nc, !isServer, yamuxConf) 21 | } 22 | -------------------------------------------------------------------------------- /srpc/websocket.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from 'it-pipe' 2 | import { Direction } from '@libp2p/interface' 3 | 4 | import duplex from '@aptre/it-ws/duplex' 5 | import type WebSocket from '@aptre/it-ws/web-socket' 6 | 7 | import { StreamConn } from './conn.js' 8 | import { Server } from './server.js' 9 | import { combineUint8ArrayListTransform } from './array-list.js' 10 | 11 | // WebSocketConn implements a connection with a WebSocket and optional Server. 12 | export class WebSocketConn extends StreamConn { 13 | // socket is the web socket 14 | private socket: WebSocket 15 | 16 | constructor(socket: WebSocket, direction: Direction, server?: Server) { 17 | super(server, { direction }) 18 | this.socket = socket 19 | const socketDuplex = duplex(socket) 20 | pipe( 21 | socketDuplex, 22 | this, 23 | // it-ws only supports sending Uint8Array. 24 | combineUint8ArrayListTransform(), 25 | socketDuplex, 26 | ) 27 | .catch((err) => this.close(err)) 28 | .then(() => this.close()) 29 | } 30 | 31 | // getSocket returns the websocket. 32 | public getSocket(): WebSocket { 33 | return this.socket 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /srpc/writer.go: -------------------------------------------------------------------------------- 1 | package srpc 2 | 3 | // PacketWriter is the interface used to write messages to a PacketStream. 4 | type PacketWriter interface { 5 | // WritePacket writes a packet to the remote. 6 | WritePacket(p *Packet) error 7 | // Close closes the writer. 8 | Close() error 9 | } 10 | 11 | // packetWriterWithClose is a PacketWriter with a wrapped Close function. 12 | type packetWriterWithClose struct { 13 | PacketWriter 14 | closeFn func() error 15 | } 16 | 17 | // NewPacketWriterWithClose wraps a PacketWriter with a close function to call when Close is called. 18 | func NewPacketWriterWithClose(prw PacketWriter, close func() error) PacketWriter { 19 | return &packetWriterWithClose{PacketWriter: prw, closeFn: close} 20 | } 21 | 22 | // Close closes the stream for reading and writing. 23 | func (s *packetWriterWithClose) Close() error { 24 | err := s.PacketWriter.Close() 25 | err2 := s.closeFn() 26 | if err != nil { 27 | return err 28 | } 29 | return err2 30 | } 31 | 32 | // _ is a type assertion 33 | var _ PacketWriter = (*packetWriterWithClose)(nil) 34 | -------------------------------------------------------------------------------- /tools/.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | vendor/ 3 | -------------------------------------------------------------------------------- /tools/Makefile: -------------------------------------------------------------------------------- 1 | GO_MOD_OUTDATED=bin/go-mod-outdated 2 | 3 | export GO111MODULE=on 4 | undefine GOARCH 5 | undefine GOOS 6 | 7 | $(GO_MOD_OUTDATED): 8 | go build -v \ 9 | -o ./bin/go-mod-outdated \ 10 | github.com/psampaz/go-mod-outdated 11 | 12 | .PHONY: outdated 13 | outdated: $(GO_MOD_OUTDATED) 14 | go list -mod=mod -u -m -json all | $(GO_MOD_OUTDATED) -update -direct 15 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build deps_only 2 | // +build deps_only 3 | 4 | package tools 5 | 6 | import ( 7 | // _ imports the parent project. 8 | // this forces the versions in tools to be at least the versions in .. 9 | _ "github.com/aperturerobotics/starpc/srpc" 10 | 11 | // _ imports protowrap 12 | _ "github.com/aperturerobotics/goprotowrap/cmd/protowrap" 13 | // _ imports protoc-gen-go-lite 14 | _ "github.com/aperturerobotics/protobuf-go-lite/cmd/protoc-gen-go-lite" 15 | // _ imports golangci-lint 16 | _ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" 17 | // _ imports golangci-lint commands 18 | _ "github.com/golangci/golangci-lint/v2/pkg/commands" 19 | // _ imports go-mod-outdated 20 | _ "github.com/psampaz/go-mod-outdated" 21 | // _ imports protoc-gen-starpc 22 | _ "github.com/aperturerobotics/starpc/cmd/protoc-gen-go-starpc" 23 | // _ imports goimports 24 | _ "golang.org/x/tools/cmd/goimports" 25 | // _ imports gofumpt 26 | _ "mvdan.cc/gofumpt" 27 | 28 | // _ imports esbuild 29 | _ "github.com/evanw/esbuild/cmd/esbuild" 30 | ) 31 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "declaration": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "jsx": "preserve", 7 | "baseUrl": "./", 8 | "rootDir": "./", 9 | "paths": { 10 | "starpc": ["./"] 11 | }, 12 | "noEmit": true, 13 | "strict": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "resolveJsonModule": true, 17 | "allowSyntheticDefaultImports": true, 18 | "importsNotUsedAsValues": "remove", 19 | "esModuleInterop": true, 20 | "noImplicitOverride": true 21 | }, 22 | "include": [ 23 | "echo", 24 | "srpc", 25 | "mock", 26 | "integration", 27 | "rpcstream", 28 | "index.ts", 29 | "tools", 30 | "cmd/protoc-gen-es-starpc" 31 | ], 32 | "ts-node": { 33 | "esm": true, 34 | "experimentalSpecifierResolution": true 35 | } 36 | } 37 | --------------------------------------------------------------------------------