├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── RELEASE.md ├── examples ├── axum │ ├── Cargo.toml │ ├── src │ │ ├── backend.rs │ │ ├── emitter.rs │ │ └── webserver.rs │ └── typescript │ │ ├── .gitignore │ │ ├── example.ts │ │ ├── generated │ │ ├── client.ts │ │ ├── jsonrpc.ts │ │ └── types.ts │ │ ├── index.html │ │ ├── package.json │ │ └── tsconfig.json └── tide │ ├── Cargo.toml │ ├── src │ ├── emitter.rs │ └── webserver.rs │ └── typescript │ ├── .gitignore │ ├── example.ts │ ├── generated │ ├── client.ts │ ├── jsonrpc.ts │ └── types.ts │ ├── index.html │ ├── package.json │ └── tsconfig.json ├── scripts ├── publish.sh └── test.sh ├── typescript ├── .gitignore ├── .npmignore ├── README.md ├── client.ts ├── index.ts ├── jsonrpc.ts ├── package.json ├── tsconfig.json ├── util │ └── emitter.ts └── websocket.ts ├── yerpc-derive ├── Cargo.toml ├── README.md └── src │ ├── client.ts │ ├── lib.rs │ ├── openrpc.rs │ ├── parse.rs │ ├── rpc.rs │ ├── ts.rs │ └── util.rs └── yerpc ├── Cargo.toml ├── README.md ├── src ├── bin │ └── generate-base-types.rs ├── integrations │ ├── axum.rs │ ├── mod.rs │ └── tungstenite.rs ├── lib.rs ├── openrpc.rs ├── requests.rs ├── typescript.rs └── version.rs ├── tests ├── axum.rs └── basic.rs └── typescript └── generated ├── client.ts ├── jsonrpc.ts └── types.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | 7 | name: Continuous integration 8 | 9 | jobs: 10 | check: 11 | name: Check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: 16 | cargo check 17 | 18 | test: 19 | name: Test Suite 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - run: 24 | cargo test --features support-axum,support-tungstenite 25 | 26 | fmt: 27 | name: Rustfmt 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - run: 32 | cargo fmt --all -- --check 33 | 34 | clippy: 35 | name: Clippy 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | - run: 40 | cargo clippy -- -D warnings 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | SANDBOX 3 | node_modules 4 | yarn.lock 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 0.6.4 - 2025-04-17 6 | 7 | - Remove 'anyhow:' prefix from errors. 8 | 9 | ## 0.6.3 - 2025-03-29 10 | 11 | - Add `RpcSession::handle_incoming_parsed` 12 | and `RpcSession::process_incoming_parsed` 13 | - Replace async-mutex with async-lock 14 | 15 | ## 0.6.0 - 2024-07-06 16 | 17 | - ts client: fix memory leak 18 | - Update yerpc-derive dependencies 19 | 20 | ## 0.5.3 - 2023-12-02 21 | 22 | - Update axum 23 | - Remove Cargo.lock 24 | - Use edition 2021 resolver 25 | - Fix clippy 26 | - Update autogenerated files 27 | 28 | ## 0.5.2 29 | 30 | - Add `openrpc_specification()` method to RpcServer trait 31 | - Remove `yerpc-tide` crate 32 | - Add documentation comments 33 | 34 | ## 0.5.1 35 | 36 | - Do not inline method types. All definitions go into `#/components/schemas` and parameters reference them. 37 | 38 | ## 0.5.0 39 | 40 | This release adds [OpenRPC](https://open-rpc.org/) generation support. 41 | 42 | - Added `openrpc.json` output: enable it with an `openrpc` feature and `openrpc_outdir` attribute, like this `#[rpc(openrpc_outdir = "./")]` 43 | - Breaking: you now need to specify that you want typescript bindings as they are not enabled by default `#[rpc(ts_outdir = "typescript/generated")]` instead of just `#[rpc]` 44 | - Add public `RpcSession.process_incoming()` method 45 | - Move scripts into scripts/ directory 46 | 47 | ## 0.4.4 48 | 49 | - add `RpcSession::server()` method 50 | 51 | ## 0.4.2 52 | 53 | - rust packages are unchanged from 0.4.1 54 | - removed unneeded files from NPM package 55 | 56 | ## 0.4.1 57 | 58 | - increase compatibility with jsonrpc 1.0 by allowing `id == 0` and to omit `"jsonrpc":"2.0"` property #31 59 | - upgrade axum to `0.6.6` 60 | - add CommonJS build 61 | - fix: update generated ts types that were forgotten in `0.4.0` 62 | 63 | ## 0.4.0 64 | 65 | - also allow strings as ids #27 66 | - remove `__AllTyps` ts type from output #18 67 | - Do not crash if "params" are omitted from request #22 68 | - fix: correct feature flags axum for tests 69 | 70 | ## Older 71 | 72 | see git commit history for older releases 73 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["yerpc-derive", "yerpc", "examples/tide", "examples/axum"] 3 | resolver = "2" 4 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yerpc 2 | 3 | [![docs.rs](https://img.shields.io/badge/docs.rs-documentation-green.svg)](https://docs.rs/yerpc) 4 | [![Crates.io](https://img.shields.io/crates/v/typescript-type-def.svg)](https://crates.io/crates/yerpc) 5 | 6 | A JSON-RPC 2.0 server handler for Rust, with automatic generation of a TypeScript client. 7 | 8 | yerpc includes (optional) integration with `axum` and `tokio-tungstenite` for easy setup and usage. Enable the `support-axum` and `support-tungstenite` feature flags for these integrations. 9 | 10 | ## Example 11 | ```rust 12 | use axum::{ 13 | extract::ws::WebSocketUpgrade, http::StatusCode, response::Response, routing::get, Router, 14 | }; 15 | use std::net::SocketAddr; 16 | use yerpc::{rpc, RpcClient, RpcSession}; 17 | use yerpc::axum::handle_ws_rpc; 18 | 19 | struct Api; 20 | 21 | #[rpc(all_positional, ts_outdir = "typescript/generated", openrpc_outdir = "./")] 22 | impl Api { 23 | async fn shout(&self, msg: String) -> String { 24 | msg.to_uppercase() 25 | } 26 | async fn add(&self, a: f32, b: f32) -> f32 { 27 | a + b 28 | } 29 | } 30 | 31 | #[tokio::main] 32 | async fn main() -> Result<(), std::io::Error> { 33 | let api = Api {} 34 | let app = Router::new() 35 | .route("/rpc", get(handler)) 36 | .layer(Extension(api)); 37 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 38 | eprintln!("listening on {}", addr); 39 | axum::Server::bind(&addr) 40 | .serve(app.into_make_service()) 41 | .await 42 | .unwrap(); 43 | 44 | Ok(()) 45 | } 46 | 47 | async fn handler( 48 | ws: WebSocketUpgrade, 49 | Extension(api): Extension, 50 | ) -> Response { 51 | let (client, out_channel) = RpcClient::new(); 52 | let session = RpcSession::new(client, api); 53 | handle_ws_rpc(ws, out_channel, session).await 54 | } 55 | ``` 56 | 57 | Now you can connect any JSON-RPC client to `ws://localhost:3000/rpc` and call the `shout` and `add` methods. 58 | 59 | After running `cargo test` you will find an autogenerated TypeScript client in the `typescript/generated` folder and an `openrpc.json` file in the root fo your project. 60 | See [`examples/axum`](examples/axum) for a full usage example with Rust server and TypeScript client for a chat server. 61 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Publishing a release 2 | 3 | 1. Update the changelog. If there is an "Unreleased" section, put the release version and date there. 4 | 2. Update the version in `yerpc/Cargo.toml`, `yerpc-derive/Cargo.toml` and `typescript/package.json`. 5 | 3. Make a commit titled "Prepare for XXX release" with a changelog and version update on the `main` branch. 6 | 4. Push the commit. 7 | 5. Create annotated tag prefixed with `v`, e.g. `v0.6.3`. 8 | 6. Push the tag. 9 | 7. Run `scripts/publish.sh` for a dry run. 10 | 8. Run `scripts/publish.sh publish`. 11 | -------------------------------------------------------------------------------- /examples/axum/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yerpc_example_axum" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [[bin]] 10 | name = "yerpc-axum" 11 | path = "src/webserver.rs" 12 | 13 | [dependencies] 14 | anyhow = "1.0.95" 15 | serde = "1.0.217" 16 | serde_json = "1.0.138" 17 | axum = { version = "0.8.1", features = ["ws"] } 18 | futures = "0.3.31" 19 | log = "0.4.25" 20 | async-trait = "0.1.86" 21 | typescript-type-def = { version = "0.5.13", features = ["json_value"] } 22 | async-broadcast = "0.4.1" 23 | tokio = { version = "1.43.0", features = ["full"] } 24 | tower-http = { version = "0.5.2", features = ["trace"] } 25 | tracing = "0.1.41" 26 | tracing-subscriber = "0.3.19" 27 | schemars = "0.8.21" 28 | yerpc = { path = "../../yerpc", features = ["anyhow", "support-axum"]} 29 | -------------------------------------------------------------------------------- /examples/axum/src/backend.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chatmail/yerpc/ed9aeba099bb521f2c3ee0de16c5cd94b0be8dcf/examples/axum/src/backend.rs -------------------------------------------------------------------------------- /examples/axum/src/emitter.rs: -------------------------------------------------------------------------------- 1 | pub struct EventEmitter { 2 | rx: async_broadcast::InactiveReceiver, 3 | tx: async_broadcast::Sender, 4 | } 5 | impl EventEmitter { 6 | pub fn new(cap: usize) -> Self { 7 | let (tx, rx) = async_broadcast::broadcast(cap); 8 | let rx = rx.deactivate(); 9 | Self { tx, rx } 10 | } 11 | pub async fn emit(&self, event: T) -> Result<(), async_broadcast::SendError> { 12 | if self.tx.receiver_count() == 0 { 13 | return Ok(()); 14 | } 15 | self.tx.broadcast(event).await?; 16 | Ok(()) 17 | } 18 | pub fn subscribe(&self) -> async_broadcast::Receiver { 19 | self.rx.activate_cloned() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/axum/src/webserver.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{ws::WebSocketUpgrade, ConnectInfo}, 3 | response::Response, 4 | routing::get, 5 | Extension, Router, 6 | }; 7 | use futures::stream::StreamExt; 8 | use schemars::JsonSchema; 9 | use serde::{Deserialize, Serialize}; 10 | use std::net::SocketAddr; 11 | use std::sync::Arc; 12 | use tokio::sync::RwLock; 13 | use tower_http::trace::TraceLayer; 14 | use yerpc::axum::handle_ws_rpc; 15 | use yerpc::typescript::TypeDef; 16 | use yerpc::{rpc, OutReceiver, RpcClient, RpcSession}; 17 | 18 | mod emitter; 19 | use emitter::EventEmitter; 20 | 21 | #[derive(Serialize, Deserialize, TypeDef, JsonSchema, Clone, Debug)] 22 | struct User { 23 | name: String, 24 | color: String, 25 | } 26 | 27 | #[derive(Serialize, Deserialize, TypeDef, JsonSchema, Clone, Debug)] 28 | struct ChatMessage { 29 | content: String, 30 | user: User, 31 | } 32 | 33 | #[derive(Serialize, Deserialize, TypeDef, JsonSchema, Clone, Debug)] 34 | #[serde(tag = "type")] 35 | enum Event { 36 | Message(ChatMessage), 37 | Joined(User), 38 | } 39 | 40 | #[derive(Clone)] 41 | struct Backend { 42 | messages: Arc>>, 43 | events: Arc>, 44 | } 45 | 46 | impl Backend { 47 | pub fn new() -> Self { 48 | Self { 49 | messages: Default::default(), 50 | events: Arc::new(EventEmitter::new(10)), 51 | } 52 | } 53 | 54 | pub async fn post(&self, peer_addr: SocketAddr, message: ChatMessage) -> anyhow::Result { 55 | let len = { 56 | let mut messages = self.messages.write().await; 57 | messages.push(message.clone()); 58 | messages.len() 59 | }; 60 | self.events.emit((peer_addr, message)).await?; 61 | Ok(len) 62 | } 63 | 64 | pub async fn list(&self) -> Vec { 65 | self.messages.read().await.clone() 66 | } 67 | 68 | pub async fn subscribe(&self) -> async_broadcast::Receiver<(SocketAddr, ChatMessage)> { 69 | self.events.subscribe() 70 | } 71 | 72 | pub fn session(&self, peer_addr: SocketAddr) -> (RpcSession, OutReceiver) { 73 | let (client, out_receiver) = RpcClient::new(); 74 | let backend_session = Session::new(peer_addr, self.clone(), client.clone()); 75 | let session = RpcSession::new(client, backend_session); 76 | (session, out_receiver) 77 | } 78 | } 79 | 80 | #[derive(Clone)] 81 | struct Session { 82 | peer_addr: SocketAddr, 83 | backend: Backend, 84 | client: RpcClient, 85 | } 86 | impl Session { 87 | pub fn new(peer_addr: SocketAddr, backend: Backend, client: RpcClient) -> Self { 88 | let this = Self { 89 | peer_addr, 90 | backend, 91 | client, 92 | }; 93 | log::info!("Client connected: {}", this.peer_addr); 94 | this.spawn_event_loop(); 95 | this 96 | } 97 | 98 | fn spawn_event_loop(&self) { 99 | let this = self.clone(); 100 | tokio::spawn(async move { 101 | let mut message_events = this.backend.subscribe().await; 102 | while let Some((_peer_addr, ev)) = message_events.next().await { 103 | // Optionally: This would be how to filter out messages that were emitted by ourselves. 104 | // if peer_addr != this.peer_addr { 105 | let res = this 106 | .client 107 | .send_notification("onevent", Some(Event::Message(ev))) 108 | .await; 109 | if res.is_err() { 110 | break; 111 | } 112 | // } 113 | } 114 | }); 115 | } 116 | } 117 | 118 | #[rpc(ts_outdir = "typescript/generated", openrpc_outdir = ".")] 119 | impl Session { 120 | /// Send a chat message. 121 | /// 122 | /// Pass the message to send. 123 | #[rpc(positional)] 124 | pub async fn send(&self, message: ChatMessage) -> yerpc::Result { 125 | let res = self.backend.post(self.peer_addr, message).await?; 126 | Ok(res) 127 | } 128 | 129 | /// List chat messages. 130 | #[rpc(positional)] 131 | pub async fn list(&self) -> yerpc::Result> { 132 | let list = self.backend.list().await; 133 | Ok(list) 134 | } 135 | } 136 | 137 | #[tokio::main] 138 | async fn main() -> Result<(), std::io::Error> { 139 | tracing_subscriber::fmt::init(); 140 | let backend = Backend::new(); 141 | let app = Router::new() 142 | .route("/rpc", get(handler)) 143 | .layer(TraceLayer::new_for_http()) 144 | .layer(Extension(backend)); 145 | let addr = SocketAddr::from(([127, 0, 0, 1], 20808)); 146 | println!("listening on {addr}"); 147 | let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 148 | axum::serve(listener, app).await.unwrap(); 149 | 150 | Ok(()) 151 | } 152 | 153 | async fn handler( 154 | ws: WebSocketUpgrade, 155 | Extension(backend): Extension, 156 | ConnectInfo(addr): ConnectInfo, 157 | ) -> Response { 158 | let (session, out_channel) = backend.session(addr); 159 | handle_ws_rpc(ws, out_channel, session).await 160 | } 161 | -------------------------------------------------------------------------------- /examples/axum/typescript/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | package-lock.json 4 | dist 5 | -------------------------------------------------------------------------------- /examples/axum/typescript/example.ts: -------------------------------------------------------------------------------- 1 | import { RawClient } from "./generated/client"; 2 | import { ChatMessage } from "./generated/types"; 3 | import { WebsocketTransport, WebSocketErrorEvent, Request } from "yerpc"; 4 | 5 | const USER_COLOR = '#'+(Math.random().toString(16)+'00000').slice(2,8) 6 | 7 | window.addEventListener("DOMContentLoaded", (_event) => { 8 | run().catch(err => { 9 | console.error(err) 10 | document.getElementById("status")!.innerHTML = `Error: ${String(err)}` 11 | }); 12 | }); 13 | async function run() { 14 | const url = "ws://localhost:20808/rpc" 15 | const transport = new WebsocketTransport(url); 16 | const client = new RawClient(transport); 17 | 18 | const form = document.getElementById("form") as HTMLFormElement; 19 | form.onsubmit = async (ev) => { 20 | ev.preventDefault(); 21 | const message = parseMessageFromForm(form); 22 | if (message) await client.send(message); 23 | }; 24 | 25 | const updateStatus = (err?: WebSocketErrorEvent) => { 26 | let status = `connected: ${transport.connected} (url: ${transport.url}, reconnect attempts: ${transport.reconnectAttempts})` 27 | if (err) status += `
Error: ${String(err)}
` 28 | document.getElementById("status")!.innerHTML = status 29 | } 30 | 31 | transport.on("connect", updateStatus) 32 | transport.on("disconnect", updateStatus) 33 | transport.on("error", updateStatus) 34 | transport.on("request", (request: Request) => { 35 | const message = request.params as ChatMessage; 36 | appendMessageToLog(message); 37 | }); 38 | 39 | const messages = await client.list(); 40 | messages.forEach(appendMessageToLog); 41 | 42 | } 43 | 44 | function parseMessageFromForm(form: HTMLFormElement): null | ChatMessage { 45 | const data = new FormData(form); 46 | const content = data.get("content"); 47 | const name = data.get("name"); 48 | if (!content || !name) return null; 49 | return { 50 | content: content as string, 51 | user: { 52 | name: name as string, 53 | color: USER_COLOR 54 | }, 55 | }; 56 | } 57 | 58 | function appendMessageToLog(message: ChatMessage) { 59 | const el = document.createElement("li"); 60 | el.innerHTML = `${message.user.name}: ${message.content}`; 61 | document.getElementById("log")!.prepend(el); 62 | } 63 | -------------------------------------------------------------------------------- /examples/axum/typescript/generated/client.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by yerpc-derive 2 | 3 | import * as T from "./types.js" 4 | import * as RPC from "./jsonrpc.js" 5 | 6 | type RequestMethod = (method: string, params?: RPC.Params) => Promise; 7 | type NotificationMethod = (method: string, params?: RPC.Params) => void; 8 | 9 | interface Transport { 10 | request: RequestMethod, 11 | notification: NotificationMethod 12 | } 13 | 14 | export class RawClient { 15 | constructor(private _transport: Transport) {} 16 | 17 | /** 18 | * Send a chat message. 19 | * 20 | * Pass the message to send. 21 | */ 22 | public send(message: T.ChatMessage): Promise { 23 | return (this._transport.request('send', [message] as RPC.Params)) as Promise; 24 | } 25 | 26 | /** 27 | * List chat messages. 28 | */ 29 | public list(): Promise<(T.ChatMessage)[]> { 30 | return (this._transport.request('list', [] as RPC.Params)) as Promise<(T.ChatMessage)[]>; 31 | } 32 | 33 | 34 | } 35 | -------------------------------------------------------------------------------- /examples/axum/typescript/generated/jsonrpc.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by typescript-type-def 2 | 3 | export type JSONValue = (null | boolean | number | string | (JSONValue)[] | { 4 | [key:string]:JSONValue; 5 | }); 6 | export type Params = ((JSONValue)[] | Record); 7 | export type U32 = number; 8 | 9 | /** 10 | * Request identifier as found in Request and Response objects. 11 | */ 12 | export type Id = (U32 | string); 13 | 14 | /** 15 | * Request object. 16 | */ 17 | export type Request = { 18 | 19 | /** 20 | * JSON-RPC protocol version. 21 | */ 22 | "jsonrpc"?: "2.0"; 23 | 24 | /** 25 | * Name of the method to be invoked. 26 | */ 27 | "method": string; 28 | 29 | /** 30 | * Method parameters. 31 | */ 32 | "params"?: Params; 33 | 34 | /** 35 | * Request identifier. 36 | */ 37 | "id"?: Id; 38 | }; 39 | export type I32 = number; 40 | 41 | /** 42 | * Error object returned in response to a failed RPC call. 43 | */ 44 | export type Error = { 45 | 46 | /** 47 | * Error code indicating the error type. 48 | */ 49 | "code": I32; 50 | 51 | /** 52 | * Short error description. 53 | */ 54 | "message": string; 55 | 56 | /** 57 | * Additional information about the error. 58 | */ 59 | "data"?: JSONValue; 60 | }; 61 | 62 | /** 63 | * Response object. 64 | */ 65 | export type Response = { 66 | 67 | /** 68 | * JSON-RPC protocol version. 69 | */ 70 | "jsonrpc": "2.0"; 71 | 72 | /** 73 | * Request identifier. 74 | */ 75 | "id": (Id | null); 76 | 77 | /** 78 | * Return value of the method. 79 | */ 80 | "result"?: JSONValue; 81 | 82 | /** 83 | * Error occured during the method invocation. 84 | */ 85 | "error"?: Error; 86 | }; 87 | export type Message = (Request | Response); 88 | -------------------------------------------------------------------------------- /examples/axum/typescript/generated/types.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by typescript-type-def 2 | 3 | export type User = { 4 | "name": string; 5 | "color": string; 6 | }; 7 | export type ChatMessage = { 8 | "content": string; 9 | "user": User; 10 | }; 11 | export type Usize = number; 12 | -------------------------------------------------------------------------------- /examples/axum/typescript/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | yerpc-axum chat example 6 | 7 | 8 | 9 |

yerpc-axum chat example

10 |
11 |

send a chat message

12 |
13 | 14 | 15 | 16 |
17 |

all messages

18 |
    19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/axum/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "tsc && esbuild --bundle dist/example.js --outfile=dist/example.bundle.js", 8 | "watch": "esbuild --bundle example.ts --outfile=dist/example.bundle.js --watch", 9 | "start": "http-server" 10 | }, 11 | "dependencies": { 12 | "yerpc": "file:../../../typescript" 13 | }, 14 | "devDependencies": { 15 | "esbuild": "^0.14.47", 16 | "http-server": "^14.1.1", 17 | "typescript": "^4.7.4" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/axum/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "strict": true, 5 | "sourceMap": true, 6 | "strictNullChecks": true, 7 | "rootDir": ".", 8 | "outDir": "dist", 9 | "lib": ["ES2015", "dom"], 10 | "target": "ES2017", 11 | "module": "es2015", 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "moduleResolution": "node", 15 | "noImplicitAny": true, 16 | "isolatedModules": true 17 | }, 18 | "include": ["*.ts"], 19 | "compileOnSave": false 20 | } 21 | -------------------------------------------------------------------------------- /examples/tide/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yerpc_example_tide" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [[bin]] 10 | name = "yerpc_example_tide" 11 | path = "src/webserver.rs" 12 | 13 | [dependencies] 14 | tide = "0.16.0" 15 | tide-websockets = "0.4.0" 16 | anyhow = "1.0.95" 17 | serde = "1.0.217" 18 | serde_json = "1.0.138" 19 | async-std = { version = "1.11.0", features = ["attributes"] } 20 | futures = "0.3.31" 21 | env_logger = "0.9.0" 22 | log = "0.4.25" 23 | schemars = "0.8.22" 24 | async-trait = "0.1.53" 25 | typescript-type-def = { version = "0.5.13", features = ["json_value"] } 26 | async-broadcast = "0.4.0" 27 | yerpc = { path = "../../yerpc", features = ["anyhow"]} 28 | -------------------------------------------------------------------------------- /examples/tide/src/emitter.rs: -------------------------------------------------------------------------------- 1 | pub struct EventEmitter { 2 | rx: async_broadcast::InactiveReceiver, 3 | tx: async_broadcast::Sender, 4 | } 5 | impl EventEmitter { 6 | pub fn new(cap: usize) -> Self { 7 | let (tx, rx) = async_broadcast::broadcast(cap); 8 | let rx = rx.deactivate(); 9 | Self { tx, rx } 10 | } 11 | pub async fn emit(&self, event: T) -> Result<(), async_broadcast::SendError> { 12 | if self.tx.receiver_count() == 0 { 13 | return Ok(()); 14 | } 15 | self.tx.broadcast(event).await?; 16 | Ok(()) 17 | } 18 | pub fn subscribe(&self) -> async_broadcast::Receiver { 19 | self.rx.activate_cloned() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/tide/src/webserver.rs: -------------------------------------------------------------------------------- 1 | use async_std::sync::RwLock; 2 | use async_std::task; 3 | use futures::stream::StreamExt; 4 | use serde::{Deserialize, Serialize}; 5 | use std::sync::Arc; 6 | use tide::Request; 7 | 8 | use yerpc::typescript::TypeDef; 9 | use yerpc::{rpc, RpcClient, RpcSession}; 10 | 11 | mod emitter; 12 | use emitter::EventEmitter; 13 | 14 | use tide_websockets::{Message as WsMessage, WebSocket}; 15 | 16 | #[derive(Serialize, Deserialize, TypeDef, Clone, Debug, schemars::JsonSchema)] 17 | struct User { 18 | name: String, 19 | color: String, 20 | } 21 | 22 | #[derive(Serialize, Deserialize, TypeDef, Clone, Debug, schemars::JsonSchema)] 23 | struct ChatMessage { 24 | content: String, 25 | user: User, 26 | } 27 | 28 | #[derive(Serialize, Deserialize, TypeDef, Clone, Debug)] 29 | #[serde(tag = "type")] 30 | enum Event { 31 | Message(ChatMessage), 32 | Joined(User), 33 | } 34 | 35 | #[derive(Clone)] 36 | struct Backend { 37 | messages: Arc>>, 38 | events: Arc>, 39 | } 40 | 41 | impl Backend { 42 | pub fn new() -> Self { 43 | Self { 44 | messages: Default::default(), 45 | events: Arc::new(EventEmitter::new(10)), 46 | } 47 | } 48 | 49 | pub async fn post(&self, peer_addr: String, message: ChatMessage) -> anyhow::Result { 50 | let len = { 51 | let mut messages = self.messages.write().await; 52 | messages.push(message.clone()); 53 | messages.len() 54 | }; 55 | self.events.emit((peer_addr, message)).await?; 56 | Ok(len) 57 | } 58 | 59 | pub async fn list(&self) -> Vec { 60 | self.messages.read().await.clone() 61 | } 62 | 63 | pub async fn subscribe(&self) -> async_broadcast::Receiver<(String, ChatMessage)> { 64 | self.events.subscribe() 65 | } 66 | } 67 | 68 | #[derive(Clone)] 69 | struct Session { 70 | peer_addr: String, 71 | backend: Backend, 72 | client: RpcClient, 73 | } 74 | impl Session { 75 | pub fn new(peer_addr: Option<&str>, backend: Backend, client: RpcClient) -> Self { 76 | let peer_addr = peer_addr.map(|addr| addr.to_string()).unwrap_or_default(); 77 | let this = Self { 78 | peer_addr, 79 | backend, 80 | client, 81 | }; 82 | log::info!("Client connected: {}", this.peer_addr); 83 | this.spawn_event_loop(); 84 | this 85 | } 86 | 87 | fn spawn_event_loop(&self) { 88 | let this = self.clone(); 89 | task::spawn(async move { 90 | let mut message_events = this.backend.subscribe().await; 91 | while let Some((_peer_addr, ev)) = message_events.next().await { 92 | // Optionally: This would be how to filter out messages that were emitted by ourselves. 93 | // if peer_addr != this.peer_addr { 94 | let res = this 95 | .client 96 | .send_notification("onevent", Some(Event::Message(ev))) 97 | .await; 98 | if res.is_err() { 99 | break; 100 | } 101 | // } 102 | } 103 | }); 104 | } 105 | } 106 | 107 | #[rpc(ts_outdir = "typescript/generated")] 108 | impl Session { 109 | /// Send a chat message. 110 | /// 111 | /// Pass the message to send. 112 | #[rpc(positional)] 113 | pub async fn send(&self, message: ChatMessage) -> yerpc::Result { 114 | let res = self.backend.post(self.peer_addr.clone(), message).await?; 115 | Ok(res) 116 | } 117 | 118 | /// List chat messages. 119 | #[rpc(positional)] 120 | pub async fn list(&self) -> yerpc::Result> { 121 | let list = self.backend.list().await; 122 | Ok(list) 123 | } 124 | } 125 | 126 | #[async_std::main] 127 | async fn main() -> Result<(), std::io::Error> { 128 | env_logger::init(); 129 | let backend = Backend::new(); 130 | let mut app = tide::with_state(backend); 131 | 132 | app.at("/ws") 133 | .get(WebSocket::new(move |req: Request, stream| { 134 | let backend = req.state().clone(); 135 | let (client, mut out_rx) = RpcClient::new(); 136 | let backend_session = Session::new(req.remote(), backend, client.clone()); 137 | let session = RpcSession::new(client, backend_session); 138 | let stream_rx = stream.clone(); 139 | task::spawn(async move { 140 | while let Some(message) = out_rx.next().await { 141 | let message = serde_json::to_string(&message)?; 142 | stream.send(WsMessage::Text(message)).await?; 143 | } 144 | let res: Result<(), anyhow::Error> = Ok(()); 145 | res 146 | }); 147 | async move { 148 | let sink = session.into_sink(); 149 | stream_rx 150 | .filter_map(|msg| async move { 151 | match msg { 152 | Ok(WsMessage::Text(input)) => Some(Ok(input)), 153 | _ => None, 154 | } 155 | }) 156 | .forward(sink) 157 | .await?; 158 | Ok(()) 159 | } 160 | })); 161 | app.listen("127.0.0.1:20808").await?; 162 | 163 | Ok(()) 164 | } 165 | -------------------------------------------------------------------------------- /examples/tide/typescript/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | package-lock.json 4 | dist 5 | -------------------------------------------------------------------------------- /examples/tide/typescript/example.ts: -------------------------------------------------------------------------------- 1 | import { RawClient } from "./generated/client"; 2 | import { ChatMessage } from "./generated/types"; 3 | import { WebsocketTransport, Request } from "yerpc"; 4 | 5 | window.addEventListener("DOMContentLoaded", (_event) => { 6 | run(); 7 | }); 8 | async function run() { 9 | const transport = new WebsocketTransport("ws://localhost:20808/ws"); 10 | const client = new RawClient(transport); 11 | 12 | transport.on("connect", () => { 13 | document.getElementById("status")!.innerHTML = "connected!" 14 | }) 15 | transport.on("disconnect", () => { 16 | document.getElementById("status")!.innerHTML = "disconnected!" 17 | }) 18 | transport.on("error", (err: Error) => { 19 | document.getElementById("status")!.innerHTML = `Error: ${String(err)}` 20 | }) 21 | transport.on("request", (request: Request) => { 22 | const message = request.params as ChatMessage; 23 | appendMessageToLog(message); 24 | }); 25 | 26 | const messages = await client.list(); 27 | messages.forEach(appendMessageToLog); 28 | 29 | const form = document.getElementById("form") as HTMLFormElement; 30 | form.onsubmit = async (ev) => { 31 | ev.preventDefault(); 32 | const message = parseMessageFromForm(form); 33 | if (message) await client.send(message); 34 | }; 35 | } 36 | 37 | function parseMessageFromForm(form: HTMLFormElement): null | ChatMessage { 38 | const data = new FormData(form); 39 | const content = data.get("content"); 40 | const name = data.get("name"); 41 | if (!content || !name) return null; 42 | return { 43 | content: content as string, 44 | user: { 45 | name: name as string, 46 | color: "black", 47 | }, 48 | }; 49 | } 50 | 51 | function appendMessageToLog(message: ChatMessage) { 52 | const el = document.createElement("li"); 53 | el.innerText = `${message.user.name}: ${message.content}`; 54 | document.getElementById("log")!.appendChild(el); 55 | } 56 | -------------------------------------------------------------------------------- /examples/tide/typescript/generated/client.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by yerpc-derive 2 | 3 | import * as T from "./types.js" 4 | import * as RPC from "./jsonrpc.js" 5 | 6 | type RequestMethod = (method: string, params?: RPC.Params) => Promise; 7 | type NotificationMethod = (method: string, params?: RPC.Params) => void; 8 | 9 | interface Transport { 10 | request: RequestMethod, 11 | notification: NotificationMethod 12 | } 13 | 14 | export class RawClient { 15 | constructor(private _transport: Transport) {} 16 | 17 | /** 18 | * Send a chat message. 19 | * 20 | * Pass the message to send. 21 | */ 22 | public send(message: T.ChatMessage): Promise { 23 | return (this._transport.request('send', [message] as RPC.Params)) as Promise; 24 | } 25 | 26 | /** 27 | * List chat messages. 28 | */ 29 | public list(): Promise<(T.ChatMessage)[]> { 30 | return (this._transport.request('list', [] as RPC.Params)) as Promise<(T.ChatMessage)[]>; 31 | } 32 | 33 | 34 | } 35 | -------------------------------------------------------------------------------- /examples/tide/typescript/generated/jsonrpc.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by typescript-type-def 2 | 3 | export type JSONValue = (null | boolean | number | string | (JSONValue)[] | { 4 | [key:string]:JSONValue; 5 | }); 6 | export type Params = ((JSONValue)[] | Record); 7 | export type U32 = number; 8 | 9 | /** 10 | * Request identifier as found in Request and Response objects. 11 | */ 12 | export type Id = (U32 | string); 13 | 14 | /** 15 | * Request object. 16 | */ 17 | export type Request = { 18 | 19 | /** 20 | * JSON-RPC protocol version. 21 | */ 22 | "jsonrpc"?: "2.0"; 23 | 24 | /** 25 | * Name of the method to be invoked. 26 | */ 27 | "method": string; 28 | 29 | /** 30 | * Method parameters. 31 | */ 32 | "params"?: Params; 33 | 34 | /** 35 | * Request identifier. 36 | */ 37 | "id"?: Id; 38 | }; 39 | export type I32 = number; 40 | 41 | /** 42 | * Error object returned in response to a failed RPC call. 43 | */ 44 | export type Error = { 45 | 46 | /** 47 | * Error code indicating the error type. 48 | */ 49 | "code": I32; 50 | 51 | /** 52 | * Short error description. 53 | */ 54 | "message": string; 55 | 56 | /** 57 | * Additional information about the error. 58 | */ 59 | "data"?: JSONValue; 60 | }; 61 | 62 | /** 63 | * Response object. 64 | */ 65 | export type Response = { 66 | 67 | /** 68 | * JSON-RPC protocol version. 69 | */ 70 | "jsonrpc": "2.0"; 71 | 72 | /** 73 | * Request identifier. 74 | */ 75 | "id": (Id | null); 76 | 77 | /** 78 | * Return value of the method. 79 | */ 80 | "result"?: JSONValue; 81 | 82 | /** 83 | * Error occured during the method invocation. 84 | */ 85 | "error"?: Error; 86 | }; 87 | export type Message = (Request | Response); 88 | -------------------------------------------------------------------------------- /examples/tide/typescript/generated/types.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by typescript-type-def 2 | 3 | export type User = { 4 | "name": string; 5 | "color": string; 6 | }; 7 | export type ChatMessage = { 8 | "content": string; 9 | "user": User; 10 | }; 11 | export type Usize = number; 12 | -------------------------------------------------------------------------------- /examples/tide/typescript/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
    5 |
    6 | 7 | 8 | 9 |
    10 |
      11 | 12 | -------------------------------------------------------------------------------- /examples/tide/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "yarn upgrade yerpc && tsc && esbuild --bundle dist/example.js --outfile=dist/example.bundle.js", 8 | "start": "yarn build && http-server" 9 | }, 10 | "dependencies": { 11 | "yerpc": "file:../../.." 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/tide/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "strict": true, 5 | "sourceMap": true, 6 | "strictNullChecks": true, 7 | "rootDir": ".", 8 | "outDir": "dist", 9 | "lib": ["ES2015", "dom"], 10 | "target": "ES2017", 11 | "module": "es2015", 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "moduleResolution": "node", 15 | "noImplicitAny": true, 16 | "isolatedModules": true 17 | }, 18 | "include": ["*.ts"], 19 | "compileOnSave": false 20 | } 21 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | dry="--dry-run" 4 | if [ "$1" == "publish" ]; then 5 | dry="" 6 | else 7 | echo "Pass \"publish\" as first argument to disable dry-run mode and publish for real." 8 | fi 9 | 10 | set -v 11 | cargo build 12 | cargo test 13 | cargo test --all-features 14 | cargo publish -p yerpc_derive $dry 15 | cargo publish -p yerpc $dry 16 | cd typescript 17 | npm run clean 18 | npm run lint 19 | npm run build 20 | npm publish $dry 21 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cargo test --features support-axum,support-tungstenite 3 | -------------------------------------------------------------------------------- /typescript/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | generated 3 | dist 4 | SANDBOX 5 | -------------------------------------------------------------------------------- /typescript/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | SANDBOX 4 | /target 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /typescript/README.md: -------------------------------------------------------------------------------- 1 | # yerpc 2 | 3 | A TypeScript integration for the [yerpc](https://github.com/deltachat/yerpc) JSON-RPC Rust library. 4 | 5 | See the [main README](https://github.com/deltachat/yerpc/blob/main/README.md) for details. 6 | 7 | *TODO: Add more docs* 8 | -------------------------------------------------------------------------------- /typescript/client.ts: -------------------------------------------------------------------------------- 1 | import { Emitter } from "./util/emitter.js"; 2 | import { Request, Response, Message, Error, Params, Id } from "./jsonrpc.js"; 3 | 4 | export interface Transport { 5 | request: (method: string, params?: Params) => Promise; 6 | notification: (method: string, params?: Params) => void; 7 | } 8 | 9 | type RequestMap = Map< 10 | Id, 11 | { resolve: (result: unknown) => void; reject: (error: Error) => void } 12 | >; 13 | 14 | type ClientEvents = { 15 | request: (request: Request) => void; 16 | } & T; 17 | 18 | export abstract class BaseTransport 19 | extends Emitter> 20 | implements Transport 21 | { 22 | private _requests: RequestMap = new Map(); 23 | private _requestId = 0; 24 | _send(_message: Message): void { 25 | throw new Error("_send method not implemented"); 26 | } 27 | 28 | close() {} 29 | 30 | protected _onmessage(message: Message): void { 31 | if ((message as Request).method) { 32 | const request = message as Request; 33 | this.emit("request", request); 34 | } 35 | 36 | if (!message.id) return; // TODO: Handle error; 37 | const response = message as Response; 38 | if (!response.id) return; // TODO: Handle error. 39 | const handler = this._requests.get(response.id); 40 | if (!handler) return; // TODO: Handle error. 41 | this._requests.delete(response.id); 42 | if (response.error) handler.reject(response.error); 43 | else handler.resolve(response.result); 44 | } 45 | 46 | notification(method: string, params?: Params): void { 47 | const request: Request = { 48 | jsonrpc: "2.0", 49 | method, 50 | id: 0, 51 | params, 52 | }; 53 | this._send(request); 54 | } 55 | 56 | request(method: string, params?: Params): Promise { 57 | // console.log('request', { method, params }, 'this', this) 58 | const id: number = ++this._requestId; 59 | const request: Request = { 60 | jsonrpc: "2.0", 61 | method, 62 | id, 63 | params, 64 | }; 65 | this._send(request as Message); 66 | return new Promise((resolve, reject) => { 67 | this._requests.set(id, { resolve, reject }); 68 | }); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /typescript/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./client.js"; 2 | export * from "./jsonrpc.js"; 3 | export * from "./websocket.js"; 4 | -------------------------------------------------------------------------------- /typescript/jsonrpc.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by typescript-type-def 2 | 3 | export type JSONValue = 4 | | null 5 | | boolean 6 | | number 7 | | string 8 | | JSONValue[] 9 | | { 10 | [key: string]: JSONValue; 11 | }; 12 | export type Params = JSONValue[] | Record; 13 | export type U32 = number; 14 | 15 | /** 16 | * Request identifier as found in Request and Response objects. 17 | */ 18 | export type Id = U32 | string; 19 | 20 | /** 21 | * Request object. 22 | */ 23 | export type Request = { 24 | /** 25 | * JSON-RPC protocol version. 26 | */ 27 | jsonrpc?: "2.0"; 28 | 29 | /** 30 | * Name of the method to be invoked. 31 | */ 32 | method: string; 33 | 34 | /** 35 | * Method parameters. 36 | */ 37 | params?: Params; 38 | 39 | /** 40 | * Request identifier. 41 | */ 42 | id?: Id; 43 | }; 44 | export type I32 = number; 45 | 46 | /** 47 | * Error object returned in response to a failed RPC call. 48 | */ 49 | export type Error = { 50 | /** 51 | * Error code indicating the error type. 52 | */ 53 | code: I32; 54 | 55 | /** 56 | * Short error description. 57 | */ 58 | message: string; 59 | 60 | /** 61 | * Additional information about the error. 62 | */ 63 | data?: JSONValue; 64 | }; 65 | 66 | /** 67 | * Response object. 68 | */ 69 | export type Response = { 70 | /** 71 | * JSON-RPC protocol version. 72 | */ 73 | jsonrpc: "2.0"; 74 | 75 | /** 76 | * Request identifier. 77 | */ 78 | id: Id | null; 79 | 80 | /** 81 | * Return value of the method. 82 | */ 83 | result?: JSONValue; 84 | 85 | /** 86 | * Error occured during the method invocation. 87 | */ 88 | error?: Error; 89 | }; 90 | export type Message = Request | Response; 91 | -------------------------------------------------------------------------------- /typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yerpc", 3 | "type": "module", 4 | "version": "0.6.4", 5 | "author": "Franz Heinzmann ", 6 | "license": "MIT OR Apache-2.0", 7 | "main": "./dist/index.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/deltachat/yerpc" 11 | }, 12 | "description": "An ergonomic JSON-RPC server library in Rust with autocreated TypeScript client", 13 | "exports": { 14 | ".": { 15 | "require": "./dist/index.cjs", 16 | "import": "./dist/index.js", 17 | "types": "./dist/index.d.ts" 18 | } 19 | }, 20 | "scripts": { 21 | "build": "run-s build:base-types build:tsc build:cjs", 22 | "clean": "rm -r dist", 23 | "build:base-types": "cargo run -p yerpc --bin generate-base-types ./jsonrpc.ts && prettier --write ./jsonrpc.ts", 24 | "build:tsc": "tsc", 25 | "build:cjs": "esbuild dist/index.js --bundle --packages=external --format=cjs --outfile=dist/index.cjs", 26 | "lint": "prettier --check ./**.ts", 27 | "lint:fix": "prettier --write ./**.ts", 28 | "prepublishOnly": "run-s lint clean build" 29 | }, 30 | "dependencies": { 31 | "@types/ws": "^8.2.2", 32 | "isomorphic-ws": "^4.0.1", 33 | "typescript": "^4.6.3" 34 | }, 35 | "optionalDependencies": { 36 | "ws": "^8.5.0" 37 | }, 38 | "devDependencies": { 39 | "esbuild": "^0.17.9", 40 | "npm-run-all": "^4.1.5", 41 | "prettier": "^2.6.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "strict": true, 5 | "sourceMap": true, 6 | "strictNullChecks": true, 7 | "rootDir": ".", 8 | "outDir": "dist", 9 | "lib": ["ES2015", "dom"], 10 | "target": "ES2017", 11 | "module": "es2015", 12 | "declaration": true, 13 | "esModuleInterop": true, 14 | "moduleResolution": "node", 15 | "noImplicitAny": true, 16 | "isolatedModules": true 17 | }, 18 | "include": ["*.ts"], 19 | "compileOnSave": false 20 | } 21 | -------------------------------------------------------------------------------- /typescript/util/emitter.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/scottcorgan/tiny-emitter 2 | // (c) Scott Corgan 3 | // License: MIT 4 | 5 | export type Arguments = [T] extends [(...args: infer U) => any] ? U : [T] extends [void] ? [] : [T]; 6 | 7 | export type EventsT = Record void> 8 | 9 | type Callback = (...args: any[]) => void 10 | 11 | type EventData = { 12 | callback: Callback 13 | ctx?: any 14 | } 15 | 16 | export class Emitter { 17 | e: Map 18 | constructor () { 19 | this.e = new Map() 20 | } 21 | on(event: E, callback: T[E], ctx?: any) { 22 | return this._on(event, callback, ctx) 23 | } 24 | 25 | private _on(event: E, callback: Callback, ctx?: any) { 26 | const data: EventData = { callback, ctx } 27 | if (!this.e.has(event)) this.e.set(event, []) 28 | this.e.get(event)!.push(data) 29 | return this; 30 | } 31 | 32 | once(event: E, callback: T[E], ctx?: any) { 33 | const listener = (...args: any[]) => { 34 | this.off(event, callback) 35 | callback.apply(ctx, args) 36 | } 37 | this._on(event, listener, ctx) 38 | } 39 | 40 | // TODO: the any here is a temporary measure because I couldn't get the 41 | // typescript inference right. 42 | emit(event: E | string, ...args: Arguments | any[]) { 43 | if (!this.e.has(event)) return 44 | this.e.get(event)!.forEach(data => { 45 | data.callback.apply(data.ctx, args) 46 | }) 47 | return this; 48 | } 49 | 50 | off(event: E, callback?: T[E]) { 51 | if (!this.e.has(event)) return 52 | const existing = this.e.get(event)! 53 | const filtered = existing.filter(data => { 54 | return data.callback !== callback 55 | }) 56 | if (filtered.length) { 57 | this.e.set(event, filtered) 58 | } else { 59 | this.e.delete(event) 60 | } 61 | return this 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /typescript/websocket.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from "isomorphic-ws"; 2 | import { Message } from "./jsonrpc.js"; 3 | import { BaseTransport } from "./client.js"; 4 | import { Emitter, EventsT } from "./util/emitter.js"; 5 | 6 | type WebsocketOptions = { 7 | reconnectDecay: number; 8 | reconnectInterval: number; 9 | maxReconnectInterval: number; 10 | }; 11 | 12 | export type WebSocketErrorEvent = WebSocket.ErrorEvent; 13 | 14 | export interface WebsocketEvents extends EventsT { 15 | connect: () => void; 16 | disconnect: () => void; 17 | error: (error: WebSocket.ErrorEvent) => void; 18 | } 19 | 20 | export class WebsocketTransport extends BaseTransport { 21 | _socket: ReconnectingWebsocket; 22 | get reconnectAttempts() { 23 | return this._socket.reconnectAttempts; 24 | } 25 | get connected() { 26 | return this._socket.connected; 27 | } 28 | constructor(public url: string, options?: WebsocketOptions) { 29 | super(); 30 | const onmessage = (event: WebSocket.MessageEvent) => { 31 | const message: Message = JSON.parse(event.data as string); 32 | this._onmessage(message); 33 | }; 34 | this._socket = new ReconnectingWebsocket(url, onmessage, options); 35 | 36 | this._socket.on("connect", () => this.emit("connect")); 37 | this._socket.on("disconnect", () => this.emit("disconnect")); 38 | this._socket.on("error", (error: WebSocket.ErrorEvent) => 39 | this.emit("error", error) 40 | ); 41 | } 42 | 43 | _send(message: Message): void { 44 | const serialized = JSON.stringify(message); 45 | this._socket.send(serialized); 46 | } 47 | 48 | close() { 49 | this._socket.close(); 50 | } 51 | } 52 | 53 | class ReconnectingWebsocket extends Emitter { 54 | socket!: WebSocket; 55 | ready!: Promise; 56 | options: WebsocketOptions; 57 | 58 | private preopenQueue: string[] = []; 59 | private _connected = false; 60 | private _reconnectAttempts = 0; 61 | 62 | onmessage: (event: WebSocket.MessageEvent) => void; 63 | closed = false; 64 | 65 | constructor( 66 | public url: string, 67 | onmessage: (event: WebSocket.MessageEvent) => void, 68 | options?: WebsocketOptions 69 | ) { 70 | super(); 71 | this.options = { 72 | reconnectDecay: 1.5, 73 | reconnectInterval: 1000, 74 | maxReconnectInterval: 10000, 75 | ...options, 76 | }; 77 | this.onmessage = onmessage; 78 | this._reconnect(); 79 | } 80 | 81 | get reconnectAttempts() { 82 | return this._reconnectAttempts; 83 | } 84 | 85 | private _reconnect() { 86 | if (this.closed) return; 87 | let resolveReady!: (_: void) => void; 88 | this.ready = new Promise((resolve) => (resolveReady = resolve)); 89 | 90 | this.socket = new WebSocket(this.url); 91 | this.socket.onmessage = this.onmessage.bind(this); 92 | this.socket.onopen = (_event) => { 93 | this._reconnectAttempts = 0; 94 | this._connected = true; 95 | while (this.preopenQueue.length) { 96 | this.socket.send(this.preopenQueue.shift() as string); 97 | } 98 | this.emit("connect"); 99 | resolveReady(); 100 | }; 101 | this.socket.onerror = (error) => { 102 | this.emit("error", error); 103 | }; 104 | 105 | this.socket.onclose = (_event) => { 106 | this._connected = false; 107 | this.emit("disconnect"); 108 | const wait = Math.min( 109 | this.options.reconnectInterval * 110 | Math.pow(this.options.reconnectDecay, this._reconnectAttempts), 111 | this.options.maxReconnectInterval 112 | ); 113 | setTimeout(() => { 114 | this._reconnectAttempts += 1; 115 | this._reconnect(); 116 | }, wait); 117 | }; 118 | } 119 | 120 | get connected(): boolean { 121 | return this._connected; 122 | } 123 | 124 | send(message: string): void { 125 | if (this.connected) this.socket.send(message); 126 | else this.preopenQueue.push(message); 127 | } 128 | 129 | close(): void { 130 | this.closed = true; 131 | this.socket.close(); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /yerpc-derive/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yerpc_derive" 3 | version = "0.6.4" 4 | edition = "2018" 5 | description = "Proc macros for yerpc" 6 | license = "Apache-2.0/MIT" 7 | repository = "https://github.com/deltachat/yerpc" 8 | authors = [ 9 | "Franz Heinzmann ", 10 | "Contributors to yerpc" 11 | ] 12 | 13 | [lib] 14 | proc-macro = true 15 | 16 | [dependencies] 17 | proc-macro2 = "1.0.37" 18 | quote = "1.0.18" 19 | syn = { version = "2.0.68", features = ["full", "parsing", "printing"] } 20 | darling = "0.20.0" 21 | convert_case = "0.5.0" 22 | 23 | [features] 24 | openrpc = [] 25 | -------------------------------------------------------------------------------- /yerpc-derive/README.md: -------------------------------------------------------------------------------- 1 | # yerpc-derive 2 | 3 | Proc macros for [yerpc](https://github.com/deltachat/yerpc). 4 | -------------------------------------------------------------------------------- /yerpc-derive/src/client.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by yerpc-derive 2 | 3 | import * as T from "./types.js" 4 | import * as RPC from "./jsonrpc.js" 5 | 6 | type RequestMethod = (method: string, params?: RPC.Params) => Promise; 7 | type NotificationMethod = (method: string, params?: RPC.Params) => void; 8 | 9 | interface Transport { 10 | request: RequestMethod, 11 | notification: NotificationMethod 12 | } 13 | 14 | export class RawClient { 15 | constructor(private _transport: Transport) {} 16 | 17 | #methods 18 | } 19 | -------------------------------------------------------------------------------- /yerpc-derive/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Derivation macros for yerpc 2 | 3 | #![warn(missing_debug_implementations, missing_docs, clippy::wildcard_imports)] 4 | 5 | extern crate darling; 6 | use darling::ast::NestedMeta; 7 | use darling::{FromAttributes, FromMeta}; 8 | #[cfg(feature = "openrpc")] 9 | use openrpc::generate_openrpc_generator; 10 | use proc_macro::TokenStream; 11 | use quote::quote; 12 | use syn::{parse_macro_input, Item}; 13 | 14 | #[cfg(feature = "openrpc")] 15 | mod openrpc; 16 | mod parse; 17 | mod rpc; 18 | mod ts; 19 | pub(crate) use parse::{Inputs, RpcInfo}; 20 | pub(crate) use rpc::generate_rpc_impl; 21 | pub(crate) use ts::generate_typescript_generator; 22 | pub(crate) mod util; 23 | 24 | /// Generates the jsonrpc handler and types. 25 | /// 26 | /// ### Root Attribute Arguments: 27 | /// - `all_positional: bool` Positional mode means that the parameters of the RPC call are expected to be a JSON array, 28 | /// which will be parsed as a tuple of this function's arguments. 29 | /// - `ts_outdir: Option` Set the path where typescript definitions are written to (relative to the crate root). 30 | /// If not set, no typescript definitions will be written. 31 | /// - `openrpc_outdir: Option` Set the path where openrpc specification file will be written to (relative to the crate root). 32 | /// If not set, no openrpc definition file will be written. 33 | /// 34 | /// Note that you need to specify atleast one type definition output: `ts_outdir`, `openrpc_outdir` or both. 35 | /// 36 | /// ### Method Attribute Arguments: 37 | /// - `name: Option` Set the name of the RPC method. Defaults to the function name. 38 | /// - `notification: bool` Make this a notification method. Notifications are received like method calls but cannot 39 | /// return anything. 40 | /// - `positional: bool` Positional mode means that the parameters of the RPC call are expected to be a JSON array, 41 | /// which will be parsed as a tuple of this function's arguments. 42 | #[proc_macro_attribute] 43 | pub fn rpc(attr: TokenStream, tokens: TokenStream) -> TokenStream { 44 | let item = parse_macro_input!(tokens as Item); 45 | match &item { 46 | Item::Impl(input) => { 47 | let attr_args = match NestedMeta::parse_meta_list(attr.into()) { 48 | Ok(v) => v, 49 | Err(err) => return TokenStream::from(darling::Error::from(err).write_errors()), 50 | }; 51 | 52 | let attr_args = match RootAttrArgs::from_list(&attr_args) { 53 | Ok(args) => args, 54 | Err(err) => return TokenStream::from(err.write_errors()), 55 | }; 56 | if attr_args.openrpc_outdir.is_none() && attr_args.ts_outdir.is_none() { 57 | return syn::Error::new_spanned( 58 | item, 59 | "The #[rpc] attribute needs atleast one type definition output. Please either set ts_outdir, openrpc_outdir or both.", 60 | ) 61 | .to_compile_error().into() 62 | } 63 | 64 | let info = RpcInfo::from_impl(&attr_args, input); 65 | let ts_impl = if let Some(outdir) = attr_args.ts_outdir.as_ref() { 66 | generate_typescript_generator(&info,outdir) 67 | } else { 68 | quote!() 69 | }; 70 | let rpc_impl = generate_rpc_impl(&info); 71 | 72 | #[cfg(feature = "openrpc")] 73 | let openrpc_impl = if let Some(outdir) = attr_args.openrpc_outdir.as_ref() { 74 | generate_openrpc_generator(&info, outdir) 75 | } else { 76 | quote!() 77 | }; 78 | 79 | #[cfg(not(feature = "openrpc"))] 80 | let openrpc_impl = quote!(); 81 | 82 | quote! { 83 | #item 84 | #rpc_impl 85 | #ts_impl 86 | #openrpc_impl 87 | } 88 | } 89 | Item::Fn(_) => quote!(#item), 90 | _ => syn::Error::new_spanned( 91 | item, 92 | "The #[rpc] attribute only works on impl and method items", 93 | ) 94 | .to_compile_error(), 95 | } 96 | .into() 97 | } 98 | 99 | #[derive(FromMeta, Debug, Default)] 100 | #[darling(default)] 101 | pub(crate) struct RootAttrArgs { 102 | /// Positional mode means that the parameters of the RPC call are expected to be a JSON array, 103 | /// which will be parsed as a tuple of this function's arguments. 104 | all_positional: bool, 105 | /// Set the path where typescript definitions are written to (relative to the crate root). 106 | /// If not set, no typescript definitions will be written 107 | ts_outdir: Option, 108 | /// Set the path where openrpc definitions will be written to (relative to the crate root). 109 | /// If not set, no openrpc definitions will be written. 110 | openrpc_outdir: Option, 111 | } 112 | 113 | #[derive(FromAttributes, Debug, Default)] 114 | #[darling(default, attributes(rpc))] 115 | pub(crate) struct MethodAttrArgs { 116 | /// Set the name of the RPC method. Defaults to the function name. 117 | name: Option, 118 | /// Make this a notification method. Notifications are received like method calls but cannot 119 | /// return anything. 120 | #[darling(default)] 121 | notification: bool, 122 | /// Positional mode means that the parameters of the RPC call are expected to be a JSON array, 123 | /// which will be parsed as a tuple of this function's arguments. 124 | positional: bool, 125 | } 126 | -------------------------------------------------------------------------------- /yerpc-derive/src/openrpc.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | parse::{Input, RemoteProcedure}, 3 | util::extract_result_ty, 4 | Inputs, RpcInfo, 5 | }; 6 | use convert_case::{Case, Casing}; 7 | use proc_macro2::{Span, TokenStream}; 8 | use quote::quote; 9 | use quote::ToTokens; 10 | use syn::Ident; 11 | 12 | fn generate_param(input: &Input, i: usize, definitions: Ident) -> TokenStream { 13 | let name = input 14 | .ident 15 | .map_or_else(|| format!("arg{}", i + 1), ToString::to_string) 16 | .to_case(Case::Camel); 17 | let ty = input.ty; 18 | quote! { 19 | ::yerpc::openrpc::Param { 20 | name: #name.to_string(), 21 | description: None, 22 | schema: { 23 | let (schema, defs) = ::yerpc::openrpc::generate_schema::<#ty>(); 24 | #definitions.extend(defs); 25 | schema 26 | }, 27 | required: true 28 | } 29 | } 30 | } 31 | 32 | fn generate_method(method: &RemoteProcedure, definitions: Ident) -> TokenStream { 33 | let (params, param_structure) = match &method.input { 34 | Inputs::Positional(ref inputs) => { 35 | let params = inputs 36 | .iter() 37 | .enumerate() 38 | .map(|(i, input)| generate_param(input, i, definitions.clone())) 39 | .collect::>(); 40 | let params = quote!(vec![#(#params),*]); 41 | let structure = quote!(::yerpc::openrpc::ParamStructure::ByPosition); 42 | (params, structure) 43 | } 44 | Inputs::Structured(Some(input)) => { 45 | let ty = &input.ty; 46 | let params = quote!({ 47 | let (params, defs) = ::yerpc::openrpc::object_schema_to_params::<#ty>().expect("Invalid parameter structure"); 48 | #definitions.extend(defs); 49 | params 50 | }); 51 | let structure = quote!(::yerpc::openrpc::ParamStructure::ByName); 52 | (params, structure) 53 | } 54 | Inputs::Structured(None) => { 55 | let params = quote!(vec![]); 56 | let structure = quote!(::yerpc::openrpc::ParamStructure::ByPosition); 57 | (params, structure) 58 | } 59 | }; 60 | let name = &method.name; 61 | // TODO: Support notifications. 62 | let _is_notification = method.is_notification; 63 | let docs = if let Some(docs) = &method.docs { 64 | quote!(Some(#docs.to_string())) 65 | } else { 66 | quote!(None) 67 | }; 68 | let output_ty = method 69 | .output 70 | .map(extract_result_ty) 71 | .map(|ty| quote!(#ty)) 72 | .unwrap_or(quote!(())); 73 | let output_name = format!("{}Result", name).to_case(Case::UpperCamel); 74 | let result = quote! { 75 | ::yerpc::openrpc::Param { 76 | name: #output_name.to_string(), 77 | description: None, 78 | schema: { 79 | let (res, defs) = ::yerpc::openrpc::generate_schema::<#output_ty>(); 80 | #definitions.extend(defs); 81 | res 82 | }, 83 | required: true 84 | } 85 | }; 86 | quote! { 87 | ::yerpc::openrpc::Method { 88 | name: #name.to_string(), 89 | summary: None, 90 | description: #docs, 91 | param_structure: #param_structure, 92 | params: #params, 93 | result: #result 94 | } 95 | } 96 | } 97 | 98 | /// A macro generating an OpenRPC Document. 99 | pub(crate) fn generate_doc(info: &RpcInfo) -> TokenStream { 100 | let definitions_ident = Ident::new("definitions", Span::call_site()); 101 | let methods = &info 102 | .methods 103 | .iter() 104 | .map(|method| generate_method(method, definitions_ident.clone())) 105 | .collect::>(); 106 | let title = format!("{}", &info.self_ty.to_token_stream()); 107 | let info = quote! { 108 | ::yerpc::openrpc::Info { 109 | version: "1.0.0".to_string(), 110 | title: #title.to_string() 111 | } 112 | }; 113 | quote! { 114 | { 115 | let mut definitions: ::schemars::Map = ::schemars::Map::new(); 116 | let methods = vec![#(#methods),*]; 117 | let components = ::yerpc::openrpc::Components { 118 | schemas: definitions 119 | }; 120 | ::yerpc::openrpc::Doc { 121 | openrpc: "1.0.0".to_string(), 122 | info: #info, 123 | methods, 124 | components 125 | } 126 | } 127 | } 128 | } 129 | 130 | pub(crate) fn generate_openrpc_generator(info: &RpcInfo, outdir_path: &String) -> TokenStream { 131 | let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); 132 | let outdir = std::path::PathBuf::from(&manifest_dir).join(outdir_path); 133 | let outdir = outdir.to_str().unwrap(); 134 | 135 | let doc_spec = generate_doc(info); 136 | 137 | quote! { 138 | /// Generate OpenRPC description for the JSON-RPC API. 139 | #[cfg(test)] 140 | #[test] 141 | fn generate_openrpc_document() { 142 | let doc = #doc_spec; 143 | let outdir = ::std::path::Path::new(#outdir); 144 | let json = ::serde_json::to_string_pretty(&doc).expect("Failed to serialize OpenRPC document into JSON."); 145 | ::std::fs::create_dir_all(&outdir).expect(&format!("Failed to create directory `{}`", outdir.display())); 146 | ::std::fs::write(&outdir.join("openrpc.json"), &json).expect("Failed to write OpenRPC document"); 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /yerpc-derive/src/parse.rs: -------------------------------------------------------------------------------- 1 | use super::MethodAttrArgs; 2 | use darling::FromAttributes; 3 | use syn::{FnArg, Generics, Ident, ImplItem, ImplItemFn, ItemImpl, Pat, ReturnType, Type}; 4 | 5 | use crate::RootAttrArgs; 6 | 7 | /// Result of parsing the `impl` of an RPC server. 8 | #[derive(Debug)] 9 | pub(crate) struct RpcInfo<'s> { 10 | pub self_ty: &'s Type, 11 | pub _attr_args: &'s RootAttrArgs, 12 | 13 | /// Descriptions of RPC methods. 14 | pub methods: Vec>, 15 | 16 | /// Lifetype and type parameters that appear 17 | /// between the `impl` keyword and the type. 18 | pub generics: &'s Generics, 19 | } 20 | 21 | impl<'s> RpcInfo<'s> { 22 | pub fn from_impl(attr_args: &'s RootAttrArgs, input: &'s ItemImpl) -> Self { 23 | let methods = input 24 | .items 25 | .iter() 26 | .filter_map(|item| { 27 | if let ImplItem::Fn(method) = item { 28 | Some(RemoteProcedure::from_method(attr_args, method)) 29 | } else { 30 | None 31 | } 32 | }) 33 | .collect(); 34 | Self { 35 | _attr_args: attr_args, 36 | methods, 37 | self_ty: &input.self_ty, 38 | generics: &input.generics, 39 | } 40 | } 41 | } 42 | 43 | /// Description of a single RPC method. 44 | #[derive(Debug)] 45 | pub(crate) struct RemoteProcedure<'s> { 46 | /// Identifier of the function implementing the method. 47 | pub ident: &'s Ident, 48 | 49 | /// Method name as should be sent in a JSON-RPC requst. 50 | /// 51 | /// By default the same as the function name, 52 | /// but may be overridden by an attribute. 53 | pub name: String, 54 | 55 | /// Description of the method parameters. 56 | pub input: Inputs<'s>, 57 | 58 | /// Output type of the method. 59 | pub output: Option<&'s Type>, 60 | pub is_notification: bool, 61 | 62 | /// Documentation extracted from the documentation comment. 63 | pub docs: Option, 64 | } 65 | 66 | /// Description of a single method parameters. 67 | #[derive(Debug)] 68 | pub(crate) enum Inputs<'s> { 69 | Positional(Vec>), 70 | Structured(Option>), 71 | } 72 | 73 | /// Description of a single method parameter. 74 | #[derive(Debug)] 75 | pub(crate) struct Input<'s> { 76 | pub ident: Option<&'s Ident>, 77 | pub ty: &'s Type, 78 | } 79 | 80 | impl<'s> Input<'s> { 81 | fn new(ty: &'s Type, ident: Option<&'s Ident>) -> Self { 82 | Self { ty, ident } 83 | } 84 | fn from_arg(arg: &'s FnArg) -> Option { 85 | match arg { 86 | FnArg::Typed(ref arg) => Some(Self::new(arg.ty.as_ref(), ident_from_pat(&arg.pat))), 87 | FnArg::Receiver(_) => None, 88 | } 89 | } 90 | } 91 | 92 | fn parse_doc_comment(attrs: &[syn::Attribute]) -> Option { 93 | let mut parts = vec![]; 94 | for attr in attrs { 95 | if let syn::Meta::NameValue(meta) = &attr.meta { 96 | if let syn::Expr::Lit(expr_lit) = &meta.value { 97 | if let syn::Lit::Str(lit_str) = &expr_lit.lit { 98 | parts.push(lit_str.value()); 99 | } 100 | } 101 | } 102 | } 103 | if !parts.is_empty() { 104 | Some(parts.join("\n")) 105 | } else { 106 | None 107 | } 108 | } 109 | 110 | impl<'s> RemoteProcedure<'s> { 111 | pub fn from_method(root_attr_args: &RootAttrArgs, method: &'s ImplItemFn) -> Self { 112 | let args = MethodAttrArgs::from_attributes(&method.attrs).unwrap_or_default(); 113 | let name = args.name.unwrap_or_else(|| method.sig.ident.to_string()); 114 | let output = match &method.sig.output { 115 | ReturnType::Default => None, 116 | ReturnType::Type(_, ref ty) => Some(ty.as_ref()), 117 | }; 118 | let positional = root_attr_args.all_positional || args.positional; 119 | let mut inputs_iter = method.sig.inputs.iter(); 120 | let input = if positional { 121 | let inputs = inputs_iter.filter_map(Input::from_arg); 122 | Inputs::Positional(inputs.collect()) 123 | } else { 124 | let input = inputs_iter.find_map(Input::from_arg); 125 | Inputs::Structured(input) 126 | }; 127 | let docs = parse_doc_comment(&method.attrs); 128 | Self { 129 | ident: &method.sig.ident, 130 | name, 131 | input, 132 | output, 133 | is_notification: args.notification, 134 | docs, 135 | } 136 | } 137 | } 138 | 139 | fn ident_from_pat(pat: &Pat) -> Option<&Ident> { 140 | match pat { 141 | Pat::Ident(pat_ident) => Some(&pat_ident.ident), 142 | _ => None, 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /yerpc-derive/src/rpc.rs: -------------------------------------------------------------------------------- 1 | use crate::{util::is_result_ty, Inputs, RpcInfo}; 2 | use proc_macro2::TokenStream; 3 | use quote::quote; 4 | 5 | #[cfg(feature = "openrpc")] 6 | use crate::openrpc::generate_doc; 7 | 8 | pub(crate) fn generate_rpc_impl(info: &RpcInfo) -> TokenStream { 9 | let mut request_arms = vec![]; 10 | let mut notification_arms = vec![]; 11 | 12 | for method in &info.methods[..] { 13 | let name = &method.name; 14 | let ident = &method.ident; 15 | 16 | let call = match &method.input { 17 | // Call with an array of multiple arguments. 18 | Inputs::Positional(inputs) => { 19 | let n_inputs = inputs.len(); 20 | let inputs = 21 | (0..n_inputs).map(|_| quote!(serde_json::from_value(params.next().unwrap())?)); 22 | quote!( 23 | let params: Vec = 24 | if params.is_null() { 25 | Vec::new() 26 | } else { 27 | ::serde_json::from_value(params)? 28 | }; 29 | if params.len() != #n_inputs { 30 | return Err(::yerpc::Error::invalid_args_len(#n_inputs)); 31 | } 32 | let mut params = params.into_iter(); 33 | let res = self.#ident(#(#inputs),*).await; 34 | ) 35 | } 36 | // Call with a single argument. 37 | Inputs::Structured(_input) => { 38 | quote!( 39 | let params = ::serde_json::from_value(params)?; 40 | let res = self.#ident(params).await; 41 | ) 42 | } 43 | }; 44 | 45 | let unwrap_output = match &method.output { 46 | Some(output) if is_result_ty(output) => quote!(let res = res?;), 47 | _ => quote!(), 48 | }; 49 | 50 | match method.is_notification { 51 | false => request_arms.push(quote! { 52 | #name => { 53 | #call 54 | #unwrap_output 55 | let res = ::serde_json::to_value(&res)?; 56 | Ok(res) 57 | }, 58 | }), 59 | true => notification_arms.push(quote! { 60 | #name => { 61 | #call 62 | #unwrap_output 63 | let _ = res; 64 | Ok(()) 65 | }, 66 | }), 67 | } 68 | } 69 | 70 | let struc = &info.self_ty; 71 | let crat = quote! { ::yerpc }; 72 | 73 | #[cfg(feature = "openrpc")] 74 | let openrpc_doc = generate_doc(info); 75 | 76 | #[cfg(not(feature = "openrpc"))] 77 | let openrpc_specification_method = quote! {}; 78 | 79 | #[cfg(feature = "openrpc")] 80 | let openrpc_specification_method = quote! { 81 | fn openrpc_specification() -> Result { 82 | let doc = #openrpc_doc; 83 | let json = ::serde_json::to_string_pretty(&doc)?; 84 | Ok(json.to_string()) 85 | } 86 | }; 87 | let (impl_generics, _ty_generics, where_clause) = &info.generics.split_for_impl(); 88 | 89 | quote! { 90 | #[automatically_derived] 91 | #[::yerpc::async_trait] 92 | impl #impl_generics #crat::RpcServer for #struc #where_clause { 93 | #openrpc_specification_method 94 | 95 | async fn handle_request( 96 | &self, 97 | method: String, 98 | params: ::serde_json::Value, 99 | ) -> Result<::serde_json::Value, #crat::Error> { 100 | match method.as_str() { 101 | #(#request_arms)* 102 | _ => Err(#crat::Error::method_not_found()) 103 | } 104 | } 105 | async fn handle_notification( 106 | &self, 107 | method: String, 108 | params: ::serde_json::Value, 109 | ) -> Result<(), #crat::Error> { 110 | match method.as_str() { 111 | #(#notification_arms)* 112 | _ => Err(#crat::Error::method_not_found()) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /yerpc-derive/src/ts.rs: -------------------------------------------------------------------------------- 1 | use crate::{util::extract_result_ty, Inputs, RpcInfo}; 2 | use convert_case::{Case, Casing}; 3 | use proc_macro2::TokenStream; 4 | use quote::quote; 5 | pub(crate) fn generate_typescript_generator(info: &RpcInfo, outdir_path: &String) -> TokenStream { 6 | let mut gen_types = vec![]; 7 | let mut gen_methods = vec![]; 8 | for method in &info.methods { 9 | let (is_positional, gen_args) = match &method.input { 10 | Inputs::Positional(ref inputs) => { 11 | let mut gen_args = vec![]; 12 | for (i, input) in inputs.iter().enumerate() { 13 | let ty = input.ty; 14 | let name = input 15 | .ident 16 | .map_or_else(|| format!("arg{}", i + 1), ToString::to_string) 17 | .to_case(Case::Camel); 18 | gen_types.push(quote!(#ty)); 19 | gen_args.push(quote!((#name.to_string(), &<#ty as TypeDef>::INFO))) 20 | } 21 | (true, gen_args) 22 | } 23 | Inputs::Structured(Some(input)) => { 24 | let mut gen_args = vec![]; 25 | let ty = input.ty; 26 | let name = input 27 | .ident 28 | .map_or_else(|| "params".to_string(), ToString::to_string) 29 | .to_case(Case::Camel); 30 | gen_types.push(quote!(#ty)); 31 | gen_args.push(quote!((#name.to_string(), &<#ty as TypeDef>::INFO))); 32 | (false, gen_args) 33 | } 34 | Inputs::Structured(None) => (false, vec![]), 35 | }; 36 | let gen_output = match (method.output, method.is_notification) { 37 | (_, true) | (None, _) => quote!(None), 38 | (Some(ty), false) => { 39 | let ty = extract_result_ty(ty); 40 | gen_types.push(quote!(#ty)); 41 | quote!(Some(&<#ty as TypeDef>::INFO)) 42 | } 43 | }; 44 | let ts_name = method.name.to_case(Case::Camel); 45 | let rpc_name = &method.name; 46 | let is_notification = method.is_notification; 47 | let docs = if let Some(docs) = &method.docs { 48 | quote!(Some(#docs)) 49 | } else { 50 | quote!(None) 51 | }; 52 | gen_methods.push(quote!( 53 | let args = vec![#(#gen_args),*]; 54 | let method = Method::new(#ts_name, #rpc_name, args, #gen_output, #is_notification, #is_positional, #docs); 55 | out.push_str(&method.to_string(root_namespace)); 56 | )); 57 | } 58 | 59 | let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"); 60 | let outdir = std::path::PathBuf::from(&manifest_dir).join(outdir_path); 61 | let outdir = outdir.to_str().unwrap(); 62 | 63 | let ts_base = include_str!("client.ts"); 64 | 65 | let mut all_types: Vec = gen_types 66 | .clone() 67 | .into_iter() 68 | .map(|ts| ts.to_string()) 69 | .collect(); 70 | all_types.sort(); 71 | all_types.dedup(); 72 | let all_types: Vec = all_types.into_iter().map(|s| s.parse().unwrap()).collect(); 73 | 74 | quote! { 75 | /// Generate typescript bindings for the JSON-RPC API. 76 | #[cfg(test)] 77 | #[test] 78 | fn generate_ts_bindings() { 79 | use ::yerpc::typescript::type_def::{TypeDef, type_expr::TypeInfo, DefinitionFileOptions}; 80 | use ::yerpc::typescript::{typedef_to_expr_string, export_types_to_file, Method}; 81 | use ::std::{fs, path::Path}; 82 | use ::std::io::Write; 83 | 84 | // Create output directory. 85 | let outdir = Path::new(#outdir); 86 | fs::create_dir_all(&outdir).expect(&format!("Failed to create directory `{}`", outdir.display())); 87 | 88 | // Create helper type with all exported types. 89 | // #(#gen_definitions)* 90 | #[derive(TypeDef)] 91 | struct __AllTyps(#(#all_types),*); 92 | // Write typescript types to file. 93 | export_types_to_file::<__AllTyps>(&outdir.join("types.ts"), None).expect("Failed to write TS out"); 94 | // remove __AllTyps ts type from output, 95 | // it's only used as a woraround to export all types and is not needed anymore now 96 | let new_content = { 97 | let string = 98 | ::std::fs::read_to_string(&outdir.join("types.ts")).expect("Failed to find TS out"); 99 | if let Some(index) = string.find("export type __AllTyps") { 100 | string[..index].to_string() 101 | } else { 102 | panic!("did not find __AllTyps in TS out"); 103 | } 104 | }; 105 | ::std::fs::File::create(&outdir.join("types.ts")) 106 | .expect("failed to open TS out") 107 | .write_all(new_content.as_bytes()) 108 | .expect("removing __AllTyps from TS failed"); 109 | export_types_to_file::<::yerpc::Message>(&outdir.join("jsonrpc.ts"), None).expect("Failed to write TS out"); 110 | 111 | // // Generate a raw client. 112 | let root_namespace = Some("T"); 113 | let mut out = String::new(); 114 | #(#gen_methods)* 115 | let ts_module = #ts_base.replace("#methods", &out); 116 | fs::write(&outdir.join("client.ts"), &ts_module).expect("Failed to write TS bindings"); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /yerpc-derive/src/util.rs: -------------------------------------------------------------------------------- 1 | use syn::{GenericArgument, PathArguments, Type}; 2 | 3 | pub fn is_result_ty(ty: &Type) -> bool { 4 | if let Type::Path(path) = ty { 5 | if let Some(last) = path.path.segments.last() { 6 | if last.ident == "Result" { 7 | return true; 8 | } 9 | } 10 | } 11 | false 12 | } 13 | 14 | pub fn extract_result_ty(ty: &Type) -> &Type { 15 | if let Type::Path(path) = ty { 16 | if let Some(last) = path.path.segments.last() { 17 | if last.ident == "Result" { 18 | if let PathArguments::AngleBracketed(ref generics) = last.arguments { 19 | if let Some(GenericArgument::Type(inner_ty)) = generics.args.first() { 20 | return inner_ty; 21 | } 22 | } 23 | } 24 | } 25 | } 26 | ty 27 | } 28 | 29 | // pub fn ty_ident(name: &str) -> Ident { 30 | // Ident::new(&name.to_case(Case::UpperCamel), Span::call_site()) 31 | // } 32 | -------------------------------------------------------------------------------- /yerpc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yerpc" 3 | version = "0.6.4" 4 | edition = "2021" 5 | license = "Apache-2.0/MIT" 6 | repository = "https://github.com/deltachat/yerpc" 7 | description = "Ergonomic JSON-RPC library for async Rust with Axum support" 8 | authors = [ 9 | "Franz Heinzmann ", 10 | "Contributors to yerpc" 11 | ] 12 | 13 | [[bin]] 14 | name = "generate-base-types" 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [dependencies] 19 | yerpc_derive = { path = "../yerpc-derive", version = "0.6" } 20 | async-channel = "1.9.0" 21 | async-lock = "3.4.0" 22 | async-trait = "0.1.86" 23 | futures = "0.3.31" 24 | futures-util = "0.3.31" 25 | log = "0.4.25" 26 | serde_json = "1.0.138" 27 | serde = { version = "1.0.217", features = ["derive"] } 28 | 29 | # type generating dependencies 30 | typescript-type-def = { version = "0.5.13", features = ["json_value"] } 31 | schemars = { version = "0.8.21", optional = true } 32 | 33 | # optional dependencies 34 | anyhow = { version = "1.0.95", optional = true } 35 | axum = { version = "0.8.1", features = ["ws"], optional = true } 36 | tokio-tungstenite = { version = "0.26.1", optional = true } 37 | tokio = { version = "1.43.0", features = ["rt", "macros"], optional = true } 38 | tracing = { version = "0.1.41", optional = true } 39 | 40 | [features] 41 | anyhow_expose = ["anyhow"] 42 | support-axum = ["axum", "tokio", "anyhow", "tracing"] 43 | support-tungstenite = ["tokio", "tokio-tungstenite", "anyhow"] 44 | openrpc = ["schemars", "yerpc_derive/openrpc"] 45 | 46 | [dev-dependencies] 47 | anyhow = "1.0.95" 48 | axum = { version = "0.8.1", features = ["ws"] } 49 | tokio-tungstenite = { version = "0.26.1" } 50 | tokio = { version = "1.43.0", features = ["rt", "macros"] } 51 | url = "2.5.4" 52 | -------------------------------------------------------------------------------- /yerpc/README.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /yerpc/src/bin/generate-base-types.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::PathBuf; 3 | use yerpc::{typescript::export_types_to_file, Message}; 4 | 5 | fn main() { 6 | let outpath: PathBuf = env::args().nth(1).expect("Outpath is required").into(); 7 | export_types_to_file::(&outpath, None).expect("Failed to write TS out"); 8 | } 9 | -------------------------------------------------------------------------------- /yerpc/src/integrations/axum.rs: -------------------------------------------------------------------------------- 1 | use crate::{OutReceiver, RpcServer, RpcSession}; 2 | use axum::{ 3 | extract::ws::{Message, WebSocket, WebSocketUpgrade}, 4 | response::Response, 5 | }; 6 | use futures_util::{SinkExt, StreamExt}; 7 | use tokio::task::JoinHandle; 8 | 9 | pub async fn handle_ws_rpc( 10 | ws: WebSocketUpgrade, 11 | out_rx: OutReceiver, 12 | session: RpcSession, 13 | ) -> Response { 14 | ws.on_upgrade(move |socket| async move { 15 | match handle_rpc(socket, out_rx, session).await { 16 | Ok(()) => {} 17 | Err(err) => tracing::warn!("yerpc websocket closed with error {err:?}"), 18 | } 19 | }) 20 | } 21 | 22 | pub async fn handle_rpc( 23 | socket: WebSocket, 24 | mut out_rx: OutReceiver, 25 | session: RpcSession, 26 | ) -> anyhow::Result<()> { 27 | let (mut sender, mut receiver) = socket.split(); 28 | let send_task: JoinHandle> = tokio::spawn(async move { 29 | while let Some(message) = out_rx.next().await { 30 | let message = serde_json::to_string(&message)?; 31 | tracing::trace!("RPC send {}", message); 32 | sender.send(Message::Text(message.into())).await?; 33 | } 34 | Ok(()) 35 | }); 36 | let recv_task: JoinHandle> = tokio::spawn(async move { 37 | while let Some(message) = receiver.next().await { 38 | match message { 39 | Ok(Message::Text(message)) => { 40 | tracing::trace!("RPC recv {}", message); 41 | session.handle_incoming(&message).await; 42 | } 43 | Ok(Message::Binary(_)) => { 44 | return Err(anyhow::anyhow!("Binary messages are not supported.")) 45 | } 46 | Ok(_) => {} 47 | Err(err) => return Err(anyhow::anyhow!(err)), 48 | } 49 | } 50 | Ok(()) 51 | }); 52 | recv_task.await??; 53 | send_task.await??; 54 | Ok(()) 55 | } 56 | -------------------------------------------------------------------------------- /yerpc/src/integrations/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "support-axum")] 2 | pub mod axum; 3 | 4 | #[cfg(feature = "support-tungstenite")] 5 | pub mod tungstenite; 6 | -------------------------------------------------------------------------------- /yerpc/src/integrations/tungstenite.rs: -------------------------------------------------------------------------------- 1 | use crate::{OutReceiver, RpcClient, RpcServer, RpcSession}; 2 | use futures_util::{SinkExt, StreamExt}; 3 | use tokio::{ 4 | io::{AsyncRead, AsyncWrite}, 5 | sync::oneshot, 6 | }; 7 | use tokio_tungstenite::{tungstenite::Message, WebSocketStream}; 8 | 9 | pub fn tungstenite_client( 10 | stream: WebSocketStream, 11 | service: R, 12 | ) -> (RpcClient, oneshot::Receiver>) 13 | where 14 | R: RpcServer, 15 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, 16 | { 17 | let (client, out_rx) = RpcClient::new(); 18 | let session = RpcSession::new(client.clone(), service); 19 | let (tx, rx) = oneshot::channel(); 20 | tokio::spawn(async move { 21 | let res = handle_tungstenite(stream, out_rx, session).await; 22 | let _ = tx.send(res); 23 | }); 24 | (client, rx) 25 | } 26 | 27 | pub async fn handle_tungstenite( 28 | mut stream: WebSocketStream, 29 | mut out_rx: OutReceiver, 30 | session: RpcSession, 31 | ) -> anyhow::Result<()> 32 | where 33 | R: RpcServer, 34 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, 35 | { 36 | loop { 37 | tokio::select! { 38 | message = out_rx.next() => { 39 | let message = serde_json::to_string(&message)?; 40 | stream.send(Message::Text(message.into())).await?; 41 | } 42 | message = stream.next() => { 43 | match message { 44 | Some(Ok(Message::Text(message))) => { 45 | session.handle_incoming(&message).await; 46 | }, 47 | Some(Ok(Message::Binary(_))) => { 48 | return Err(anyhow::anyhow!("Binary messages are not supported.")) 49 | } 50 | Some(Ok(_)) => {} 51 | Some(Err(err)) => { 52 | return Err(err.into()) 53 | } 54 | None => break, 55 | } 56 | } 57 | } 58 | } 59 | Ok(()) 60 | } 61 | -------------------------------------------------------------------------------- /yerpc/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::wildcard_imports)] 2 | 3 | pub use async_trait::async_trait; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | pub use yerpc_derive::rpc; 7 | 8 | #[cfg(feature = "openrpc")] 9 | pub mod openrpc; 10 | mod requests; 11 | pub mod typescript; 12 | mod version; 13 | 14 | #[cfg(feature = "openrpc")] 15 | pub use openrpc::JsonSchema; 16 | pub use requests::{OutReceiver, RpcClient, RpcSession, RpcSessionSink}; 17 | pub use typescript::TypeDef; 18 | pub use version::Version; 19 | 20 | mod integrations; 21 | pub use integrations::*; 22 | 23 | #[async_trait] 24 | pub trait RpcServer: Sync + Send + 'static { 25 | /// Returns OpenRPC specification as a string. 26 | #[cfg(feature = "openrpc")] 27 | fn openrpc_specification() -> Result { 28 | Ok(String::new()) 29 | } 30 | 31 | async fn handle_notification(&self, _method: String, _params: serde_json::Value) -> Result<()> { 32 | Ok(()) 33 | } 34 | async fn handle_request( 35 | &self, 36 | _method: String, 37 | _params: serde_json::Value, 38 | ) -> Result { 39 | Err(Error::new( 40 | Error::METHOD_NOT_FOUND, 41 | "Method not found".to_string(), 42 | )) 43 | } 44 | } 45 | 46 | impl RpcServer for () {} 47 | 48 | /// Request identifier as found in Request and Response objects. 49 | #[derive(Serialize, Deserialize, Debug, TypeDef, Eq, Hash, PartialEq, Clone)] 50 | #[serde(untagged)] 51 | pub enum Id { 52 | Number(u32), 53 | String(String), 54 | } 55 | 56 | pub type Result = std::result::Result; 57 | 58 | /// Only used for generated TS bindings 59 | #[derive(Serialize, Deserialize, Debug, TypeDef)] 60 | #[serde(untagged)] 61 | pub enum RpcResult { 62 | Ok(T), 63 | Err(Error), 64 | } 65 | 66 | #[derive(Serialize, Deserialize, Debug, TypeDef)] 67 | #[serde(untagged)] 68 | pub enum Message { 69 | Request(Request), 70 | Response(Response), 71 | } 72 | 73 | #[derive(Serialize, Deserialize, Debug, TypeDef)] 74 | #[serde(untagged)] 75 | pub enum Params { 76 | Positional(Vec), 77 | Structured(serde_json::Map), 78 | } 79 | 80 | impl Params { 81 | pub fn into_value(self) -> serde_json::Value { 82 | match self { 83 | Params::Positional(list) => serde_json::Value::Array(list), 84 | Params::Structured(object) => serde_json::Value::Object(object), 85 | } 86 | } 87 | } 88 | 89 | impl From for serde_json::Value { 90 | fn from(params: Params) -> Self { 91 | params.into_value() 92 | } 93 | } 94 | 95 | impl TryFrom for Params { 96 | type Error = Error; 97 | fn try_from(value: serde_json::Value) -> std::result::Result { 98 | match value { 99 | serde_json::Value::Object(object) => Ok(Params::Structured(object)), 100 | serde_json::Value::Array(list) => Ok(Params::Positional(list)), 101 | _ => Err(Error::invalid_params()), 102 | } 103 | } 104 | } 105 | 106 | /// Request object. 107 | #[derive(Serialize, Deserialize, Debug, TypeDef)] 108 | pub struct Request { 109 | /// JSON-RPC protocol version. 110 | #[serde(skip_serializing_if = "Option::is_none")] 111 | pub jsonrpc: Option, // JSON-RPC 1.0 has no jsonrpc field 112 | 113 | /// Name of the method to be invoked. 114 | pub method: String, 115 | 116 | /// Method parameters. 117 | #[serde(skip_serializing_if = "Option::is_none")] 118 | pub params: Option, 119 | 120 | /// Request identifier. 121 | #[serde(skip_serializing_if = "Option::is_none")] 122 | pub id: Option, 123 | } 124 | 125 | /// Response object. 126 | #[derive(Serialize, Deserialize, Debug, TypeDef)] 127 | pub struct Response { 128 | /// JSON-RPC protocol version. 129 | pub jsonrpc: Version, 130 | 131 | /// Request identifier. 132 | pub id: Option, 133 | 134 | /// Return value of the method. 135 | #[serde(skip_serializing_if = "Option::is_none")] 136 | pub result: Option, 137 | 138 | /// Error occured during the method invocation. 139 | #[serde(skip_serializing_if = "Option::is_none")] 140 | pub error: Option, 141 | } 142 | impl Response { 143 | /// Creates a new Response object indicating an error. 144 | pub fn error(id: Option, error: Error) -> Self { 145 | Self { 146 | jsonrpc: Version::V2, 147 | id, 148 | error: Some(error), 149 | result: None, 150 | } 151 | } 152 | /// Creates a new Response object indicating a success. 153 | pub fn success(id: Id, result: serde_json::Value) -> Self { 154 | Self { 155 | jsonrpc: Version::V2, 156 | id: Some(id), 157 | result: Some(result), 158 | error: None, 159 | } 160 | } 161 | } 162 | 163 | /// Error object returned in response to a failed RPC call. 164 | #[derive(Serialize, Deserialize, Debug, TypeDef)] 165 | #[cfg_attr(feature = "openrpc", derive(JsonSchema))] 166 | pub struct Error { 167 | /// Error code indicating the error type. 168 | pub code: i32, 169 | 170 | /// Short error description. 171 | pub message: String, 172 | 173 | /// Additional information about the error. 174 | #[serde(skip_serializing_if = "Option::is_none")] 175 | pub data: Option, 176 | } 177 | impl std::error::Error for Error {} 178 | impl std::fmt::Display for Error { 179 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 180 | write!(f, "JSON-RPC error: {} (code {})", self.message, self.code) 181 | } 182 | } 183 | impl Error { 184 | pub const PARSE_ERROR: i32 = -32700; 185 | pub const INVALID_REQUEST: i32 = -32600; 186 | pub const METHOD_NOT_FOUND: i32 = -32601; 187 | pub const INVALID_PARAMS: i32 = -32602; 188 | pub const INTERNAL_ERROR: i32 = -32603; 189 | 190 | pub const BAD_REQUEST: i32 = -32000; 191 | pub const BAD_RESPONSE: i32 = -32001; 192 | pub const REMOTE_DISCONNECTED: i32 = -32002; 193 | 194 | /// Creates a new error object. 195 | pub fn new(code: i32, message: String) -> Self { 196 | Self { 197 | code, 198 | message, 199 | data: None, 200 | } 201 | } 202 | 203 | /// Creates a new error object with additional information. 204 | pub fn with_data(code: i32, message: String, data: Option) -> Self { 205 | Self { 206 | code, 207 | message, 208 | data, 209 | } 210 | } 211 | 212 | /// Creates a new error object indicating invalid method parameters. 213 | pub fn invalid_params() -> Self { 214 | Self::new( 215 | Error::INVALID_PARAMS, 216 | "Params has to be an object or array".to_string(), 217 | ) 218 | } 219 | 220 | /// Creates a new error object indicating that the method does not exist. 221 | pub fn method_not_found() -> Self { 222 | Self::new(Error::METHOD_NOT_FOUND, "Method not found".to_string()) 223 | } 224 | 225 | pub fn invalid_args_len(n: usize) -> Self { 226 | Self::new( 227 | Error::INVALID_PARAMS, 228 | format!("This method takes an array of {n} arguments"), 229 | ) 230 | } 231 | 232 | pub fn bad_response() -> Self { 233 | Self::new( 234 | Error::BAD_RESPONSE, 235 | "Error while processing a response".to_string(), 236 | ) 237 | } 238 | pub fn bad_request() -> Self { 239 | Self::new( 240 | Error::BAD_REQUEST, 241 | "Error while serializing a request".to_string(), 242 | ) 243 | } 244 | pub fn remote_disconnected() -> Self { 245 | Self::new( 246 | Error::REMOTE_DISCONNECTED, 247 | "Remote disconnected".to_string(), 248 | ) 249 | } 250 | 251 | pub fn is_disconnnected(&self) -> bool { 252 | self.code == Error::REMOTE_DISCONNECTED 253 | } 254 | } 255 | 256 | impl From for Error { 257 | fn from(error: serde_json::Error) -> Self { 258 | Self { 259 | code: Error::PARSE_ERROR, 260 | message: format!("{error}"), 261 | data: None, 262 | } 263 | } 264 | } 265 | 266 | #[cfg(feature = "anyhow")] 267 | #[cfg(feature = "anyhow_expose")] 268 | impl From for Error { 269 | fn from(error: anyhow::Error) -> Self { 270 | Self { 271 | code: -1, 272 | message: format!("{:#}", error), 273 | data: None, 274 | } 275 | } 276 | } 277 | 278 | #[cfg(feature = "anyhow")] 279 | #[cfg(not(feature = "anyhow_expose"))] 280 | impl From for Error { 281 | fn from(_error: anyhow::Error) -> Self { 282 | Self { 283 | code: Error::INTERNAL_ERROR, 284 | message: "Internal server error".to_string(), 285 | data: None, 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /yerpc/src/openrpc.rs: -------------------------------------------------------------------------------- 1 | use schemars::{gen::SchemaSettings, schema::SchemaObject, Map}; 2 | use serde::Serialize; 3 | 4 | pub use schemars as type_def; 5 | pub use schemars::JsonSchema; 6 | 7 | /// [OpenRPC object](https://spec.open-rpc.org/#openrpc-object), 8 | /// the root of OpenRPC document. 9 | #[derive(Debug, Clone, Serialize)] 10 | pub struct Doc { 11 | pub openrpc: String, 12 | pub info: Info, 13 | pub methods: Vec, 14 | pub components: Components, 15 | } 16 | 17 | /// [Info Object](https://spec.open-rpc.org/#info-object) 18 | #[derive(Debug, Clone, Serialize)] 19 | pub struct Info { 20 | /// OpenRPC document version. 21 | pub version: String, 22 | 23 | /// Application title. 24 | pub title: String, 25 | } 26 | 27 | /// [Method Object](https://spec.open-rpc.org/#method-object) 28 | #[derive(Debug, Clone, Serialize)] 29 | #[serde(rename_all = "camelCase")] 30 | pub struct Method { 31 | /// Method name. 32 | pub name: String, 33 | #[serde(skip_serializing_if = "Option::is_none")] 34 | pub summary: Option, 35 | #[serde(skip_serializing_if = "Option::is_none")] 36 | pub description: Option, 37 | pub params: Vec, 38 | pub result: Param, 39 | 40 | /// Whether request params are an array or an object. 41 | pub param_structure: ParamStructure, 42 | } 43 | 44 | #[derive(Debug, Clone, Serialize)] 45 | #[serde(rename_all = "kebab-case")] 46 | pub enum ParamStructure { 47 | /// Request params are an object. 48 | ByName, 49 | 50 | /// Request params are an array. 51 | ByPosition, 52 | } 53 | 54 | #[derive(Debug, Clone, Serialize)] 55 | pub struct Param { 56 | pub name: String, 57 | #[serde(skip_serializing_if = "Option::is_none")] 58 | pub description: Option, 59 | pub schema: SchemaObject, 60 | pub required: bool, 61 | } 62 | 63 | #[derive(Debug, Clone, Serialize)] 64 | pub struct Components { 65 | #[serde(default, skip_serializing_if = "Map::is_empty")] 66 | pub schemas: Map, 67 | } 68 | 69 | pub fn object_schema_to_params( 70 | ) -> anyhow::Result<(Vec, Map)> { 71 | let (schema, definitions) = generate_schema::(); 72 | let properties = match schema.object.as_ref() { 73 | Some(obj) => &obj.properties, 74 | None => return Err(anyhow::anyhow!("Invalid parameter definition")), 75 | }; 76 | let mut params = vec![]; 77 | for (key, schema) in properties { 78 | params.push(Param { 79 | name: key.to_string(), 80 | schema: schema.clone().into_object(), 81 | description: None, 82 | required: true, 83 | }); 84 | } 85 | Ok((params, definitions)) 86 | } 87 | 88 | /// Generates a single schema. 89 | /// 90 | /// Returns schema object and referenced definitions 91 | /// to be put into `schemas` field 92 | /// of the [Components Object](https://spec.open-rpc.org/#components-object). 93 | pub fn generate_schema() -> (SchemaObject, Map) { 94 | let settings = SchemaSettings::draft07().with(|s| { 95 | s.inline_subschemas = false; 96 | s.definitions_path = "#/components/schemas/".to_string(); 97 | }); 98 | let mut gen = settings.into_generator(); 99 | let schema = gen.subschema_for::(); 100 | let definitions: Map = gen 101 | .take_definitions() 102 | .into_iter() 103 | .map(|(k, v)| (k, v.into_object())) 104 | .collect(); 105 | (schema.into_object(), definitions) 106 | } 107 | -------------------------------------------------------------------------------- /yerpc/src/requests.rs: -------------------------------------------------------------------------------- 1 | use async_lock::Mutex; 2 | use futures::channel::oneshot; 3 | use futures_util::{Future, Sink}; 4 | use serde::Serialize; 5 | use std::io; 6 | use std::{ 7 | collections::HashMap, 8 | pin::Pin, 9 | sync::Arc, 10 | task::{Context, Poll}, 11 | }; 12 | 13 | use crate::{Error, Id, Message, Params, Request, Response, RpcServer, Version}; 14 | 15 | // pub fn create_session(server_impl: impl RpcServer) -> (RpcSession, 16 | 17 | pub struct RpcSession { 18 | client: RpcClient, 19 | server: T, 20 | } 21 | 22 | impl Clone for RpcSession { 23 | fn clone(&self) -> Self { 24 | Self { 25 | client: self.client.clone(), 26 | server: self.server.clone(), 27 | } 28 | } 29 | } 30 | 31 | impl RpcSession { 32 | pub fn create(server: T) -> (Self, async_channel::Receiver) { 33 | let (client, receiver) = RpcClient::new(); 34 | (Self::new(client, server), receiver) 35 | } 36 | 37 | pub fn new(client: RpcClient, server: T) -> Self { 38 | Self { client, server } 39 | } 40 | 41 | /// Returns a reference to the JSON-RPC client. 42 | pub fn client(&self) -> &RpcClient { 43 | &self.client 44 | } 45 | 46 | /// Returns a reference to the JSON-RPC server. 47 | pub fn server(&self) -> &T { 48 | &self.server 49 | } 50 | 51 | pub fn into_sink(self) -> RpcSessionSink { 52 | RpcSessionSink::Idle(Some(self)) 53 | } 54 | 55 | /// Processes incoming JSON-RPC message. 56 | /// 57 | /// Handles incoming requests and notifications, 58 | /// returns a response if any. 59 | /// 60 | /// See also [`process_incoming_parsed`]. 61 | pub async fn process_incoming(&self, input: &str) -> Option { 62 | let message: Message = match serde_json::from_str(input) { 63 | Ok(message) => message, 64 | Err(err) => { 65 | return Some(Message::Response(Response::error( 66 | None, 67 | Error::new(Error::PARSE_ERROR, err.to_string()), 68 | ))); 69 | } 70 | }; 71 | self.process_incoming_parsed(message).await 72 | } 73 | /// Same as [`process_incoming`], but accepts a parsed [`Message`] 74 | /// instead of a string. 75 | pub async fn process_incoming_parsed(&self, message: Message) -> Option { 76 | match message { 77 | Message::Request(request) => { 78 | let params = request.params.map(Params::into_value).unwrap_or_default(); 79 | let response = match request.id { 80 | None => { 81 | match self 82 | .server 83 | .handle_notification(request.method, params) 84 | .await 85 | { 86 | Ok(()) => None, 87 | Err(err) => Some(Response::error(request.id, err)), 88 | } 89 | } 90 | Some(id) => match self.server.handle_request(request.method, params).await { 91 | Ok(payload) => Some(Response::success(id, payload)), 92 | Err(err) => Some(Response::error(Some(id), err)), 93 | }, 94 | }; 95 | response.map(Message::Response) 96 | } 97 | Message::Response(response) => { 98 | self.client.handle_response(response).await; 99 | None 100 | } 101 | } 102 | } 103 | 104 | /// Handles incoming JSON-RPC request. 105 | /// 106 | /// Sends response to the client. 107 | /// Blocks until request handler finishes. 108 | /// Spawn a task if you want to run the request handler 109 | /// concurrently. 110 | /// 111 | /// See also [`handle_incoming_parsed`]. 112 | pub async fn handle_incoming(&self, input: &str) { 113 | if let Some(response) = self.process_incoming(input).await { 114 | let _ = self.client.tx(response).await; 115 | } 116 | } 117 | /// Same as [`handle_incoming`], but accepts a parsed [`Message`] 118 | /// instead of a string. 119 | pub async fn handle_incoming_parsed(&self, message: Message) { 120 | if let Some(response) = self.process_incoming_parsed(message).await { 121 | let _ = self.client.tx(response).await; 122 | } 123 | } 124 | } 125 | 126 | #[derive(Clone)] 127 | pub struct RpcClient { 128 | inner: Arc>, 129 | tx: async_channel::Sender, 130 | } 131 | 132 | pub type OutReceiver = async_channel::Receiver; 133 | 134 | impl RpcClient { 135 | pub fn new() -> (Self, async_channel::Receiver) { 136 | let (tx, rx) = async_channel::bounded(10); 137 | let inner = PendingRequests::new(); 138 | let inner = Arc::new(Mutex::new(inner)); 139 | let this = Self { inner, tx }; 140 | (this, rx) 141 | } 142 | pub async fn send_request( 143 | &self, 144 | method: impl ToString, 145 | params: Option, 146 | ) -> Result { 147 | let method = method.to_string(); 148 | let params = downcast_params(params)?; 149 | let (message, rx) = self.inner.lock().await.insert(method, params); 150 | self.tx(message).await?; 151 | // Wait for response to arrive. 152 | // TODO: Better error. 153 | let res = rx.await.map_err(|_| Error::bad_response())?; 154 | match (res.result, res.error) { 155 | (Some(result), None) => Ok(result), 156 | (None, Some(error)) => Err(error), 157 | // TODO: better error. 158 | _ => Err(Error::bad_response()), 159 | } 160 | } 161 | 162 | pub async fn send_notification( 163 | &self, 164 | method: impl ToString, 165 | params: Option, 166 | ) -> Result<(), Error> { 167 | let method = method.to_string(); 168 | let params = downcast_params(params)?; 169 | let request = Request { 170 | jsonrpc: Some(Version::V2), 171 | method: method.to_string(), 172 | params, 173 | id: None, 174 | }; 175 | let message = Message::Request(request); 176 | self.tx(message).await?; 177 | Ok(()) 178 | } 179 | 180 | pub(crate) async fn tx(&self, message: Message) -> Result<(), Error> { 181 | self.tx 182 | .send(message) 183 | .await 184 | .map_err(|_| Error::remote_disconnected())?; 185 | Ok(()) 186 | } 187 | 188 | pub async fn handle_response(&self, response: Response) { 189 | self.inner.lock().await.handle_response(response) 190 | } 191 | } 192 | 193 | pub struct PendingRequests { 194 | next_request_id: u32, 195 | pending_requests: HashMap>, 196 | // tx: async_channel::Sender, 197 | } 198 | 199 | impl PendingRequests { 200 | pub fn new() -> Self { 201 | Self { 202 | next_request_id: 1, 203 | pending_requests: Default::default(), 204 | } 205 | } 206 | pub fn insert( 207 | &mut self, 208 | method: String, 209 | params: Option, 210 | ) -> (Message, oneshot::Receiver) { 211 | let request_id = Id::Number(self.next_request_id); 212 | self.next_request_id += 1; 213 | let (tx, rx) = oneshot::channel(); 214 | self.pending_requests.insert(request_id.clone(), tx); 215 | let request = Request { 216 | jsonrpc: Some(Version::V2), 217 | method, 218 | params, 219 | id: Some(request_id), 220 | }; 221 | let message = Message::Request(request); 222 | (message, rx) 223 | } 224 | pub fn handle_response(&mut self, response: Response) { 225 | if let Some(id) = &response.id { 226 | let tx = self.pending_requests.remove(id); 227 | if let Some(tx) = tx { 228 | let _ = tx.send(response); 229 | } 230 | } 231 | } 232 | } 233 | 234 | fn downcast_params(params: Option) -> Result, Error> { 235 | if let Some(params) = params { 236 | let params = serde_json::to_value(params).map_err(|_| Error::bad_request())?; 237 | match params { 238 | serde_json::Value::Array(params) => Ok(Some(Params::Positional(params))), 239 | serde_json::Value::Object(params) => Ok(Some(Params::Structured(params))), 240 | _ => Err(Error::bad_request()), 241 | } 242 | } else { 243 | Ok(None) 244 | } 245 | } 246 | 247 | pub enum RpcSessionSink { 248 | Idle(Option>), 249 | Sending(Pin> + 'static + Send>>), 250 | } 251 | 252 | impl Sink for RpcSessionSink 253 | where 254 | T: RpcServer + Unpin, 255 | { 256 | type Error = io::Error; 257 | fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 258 | let this = self.get_mut(); 259 | match this { 260 | Self::Idle(_) => Poll::Ready(Ok(())), 261 | Self::Sending(fut) => match fut.as_mut().poll(cx) { 262 | Poll::Ready(session) => { 263 | *this = Self::Idle(Some(session)); 264 | Poll::Ready(Ok(())) 265 | } 266 | Poll::Pending => Poll::Pending, 267 | }, 268 | } 269 | } 270 | fn start_send(self: Pin<&mut Self>, item: String) -> Result<(), Self::Error> { 271 | let this = self.get_mut(); 272 | match this { 273 | Self::Sending(_) => unreachable!(), 274 | Self::Idle(session) => { 275 | let session = session.take().unwrap(); 276 | let fut = async move { 277 | session.handle_incoming(&item).await; 278 | session 279 | }; 280 | let fut = Box::pin(fut); 281 | *this = Self::Sending(fut); 282 | Ok(()) 283 | } 284 | } 285 | } 286 | fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 287 | Poll::Ready(Ok(())) 288 | } 289 | fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { 290 | Poll::Ready(Ok(())) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /yerpc/src/typescript.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | use std::io; 3 | use std::path::Path; 4 | use typescript_type_def::{type_expr::TypeInfo, write_definition_file, DefinitionFileOptions}; 5 | 6 | pub use typescript_type_def as type_def; 7 | pub use typescript_type_def::TypeDef; 8 | 9 | pub fn typedef_to_expr_string(root_namespace: Option<&str>) -> io::Result { 10 | let mut expr = vec![]; 11 | ::INFO.write_ref_expr(&mut expr, root_namespace)?; 12 | Ok(String::from_utf8(expr).unwrap()) 13 | } 14 | 15 | pub fn export_types_to_file( 16 | path: &Path, 17 | options: Option, 18 | ) -> io::Result<()> { 19 | let options = options.unwrap_or_else(|| DefinitionFileOptions { 20 | root_namespace: None, 21 | ..Default::default() 22 | }); 23 | let mut file = std::fs::File::create(path)?; 24 | write_definition_file::<_, T>(&mut file, options)?; 25 | Ok(()) 26 | } 27 | 28 | pub struct Method { 29 | pub is_notification: bool, 30 | pub is_positional: bool, 31 | pub ts_name: String, 32 | pub rpc_name: String, 33 | pub args: Vec<(String, &'static TypeInfo)>, 34 | pub output: Option<&'static TypeInfo>, 35 | pub docs: Option, 36 | } 37 | 38 | impl Method { 39 | pub fn new( 40 | ts_name: &str, 41 | rpc_name: &str, 42 | args: Vec<(String, &'static TypeInfo)>, 43 | output: Option<&'static TypeInfo>, 44 | is_notification: bool, 45 | is_positional: bool, 46 | docs: Option<&str>, 47 | ) -> Self { 48 | Self { 49 | ts_name: ts_name.to_string(), 50 | rpc_name: rpc_name.to_string(), 51 | args, 52 | output, 53 | is_notification, 54 | is_positional, 55 | docs: docs.map(|d| d.to_string()), 56 | } 57 | } 58 | 59 | pub fn to_string(&self, root_namespace: Option<&str>) -> String { 60 | let (args, call) = if !self.is_positional { 61 | if let Some((name, ty)) = self.args.first() { 62 | ( 63 | format!("{}: {}", name, type_to_expr(ty, root_namespace)), 64 | name.to_string(), 65 | ) 66 | } else { 67 | ("".to_string(), "undefined".to_string()) 68 | } 69 | } else { 70 | let args = self 71 | .args 72 | .iter() 73 | .map(|(name, arg)| format!("{}: {}", name, type_to_expr(arg, root_namespace))) 74 | .collect::>() 75 | .join(", "); 76 | let call = format!( 77 | "[{}]", 78 | self.args 79 | .iter() 80 | .map(|(name, _)| name.clone()) 81 | .collect::>() 82 | .join(", ") 83 | ); 84 | (args, call) 85 | }; 86 | let output = self.output.map_or_else( 87 | || "void".to_string(), 88 | |output| type_to_expr(output, root_namespace), 89 | ); 90 | let (output, inner_method) = if !self.is_notification { 91 | (format!("Promise<{output}>"), "request") 92 | } else { 93 | (output, "notification") 94 | }; 95 | let docs = if let Some(docs) = &self.docs { 96 | let docs = docs.split('\n').fold(String::new(), |mut output, s| { 97 | let _ = writeln!(output, " *{s}"); 98 | output 99 | }); 100 | format!(" /**\n{docs} */") 101 | } else { 102 | "".into() 103 | }; 104 | format!( 105 | "{}\n public {}({}): {} {{\n return (this._transport.{}('{}', {} as RPC.Params)) as {};\n }}\n\n", 106 | docs, self.ts_name, args, output, inner_method, self.rpc_name, call, output 107 | ) 108 | } 109 | } 110 | 111 | fn type_to_expr(ty: &'static TypeInfo, root_namespace: Option<&str>) -> String { 112 | let mut expr = vec![]; 113 | ty.write_ref_expr(&mut expr, root_namespace).unwrap(); 114 | String::from_utf8(expr).unwrap() 115 | } 116 | -------------------------------------------------------------------------------- /yerpc/src/version.rs: -------------------------------------------------------------------------------- 1 | //! yerpc version field 2 | //! (c) Parity Technologies 3 | //! MIT License 4 | //! 5 | use serde::de::{self, Visitor}; 6 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 7 | use std::fmt; 8 | use typescript_type_def::{type_expr, TypeDef}; 9 | 10 | /// Protocol Version 11 | #[derive(Debug, PartialEq, Clone, Copy, Hash, Eq)] 12 | pub enum Version { 13 | /// JSONRPC 2.0 14 | V2, 15 | } 16 | 17 | // The TypeDef macro fails to translate the enum value, so here we implement 18 | // TypeDef manually. 19 | impl TypeDef for Version { 20 | const INFO: type_expr::TypeInfo = { 21 | type_expr::TypeInfo::Native(type_expr::NativeTypeInfo { 22 | r#ref: type_expr::TypeExpr::String(type_expr::TypeString { 23 | value: "2.0", 24 | docs: None, 25 | }), 26 | }) 27 | }; 28 | } 29 | 30 | impl Serialize for Version { 31 | fn serialize(&self, serializer: S) -> Result 32 | where 33 | S: Serializer, 34 | { 35 | match *self { 36 | Version::V2 => serializer.serialize_str("2.0"), 37 | } 38 | } 39 | } 40 | 41 | impl<'a> Deserialize<'a> for Version { 42 | fn deserialize(deserializer: D) -> Result 43 | where 44 | D: Deserializer<'a>, 45 | { 46 | deserializer.deserialize_identifier(VersionVisitor) 47 | } 48 | } 49 | 50 | struct VersionVisitor; 51 | 52 | impl Visitor<'_> for VersionVisitor { 53 | type Value = Version; 54 | 55 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 56 | formatter.write_str("a string") 57 | } 58 | 59 | fn visit_str(self, value: &str) -> Result 60 | where 61 | E: de::Error, 62 | { 63 | match value { 64 | "2.0" => Ok(Version::V2), 65 | _ => Err(de::Error::custom("invalid version")), 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /yerpc/tests/axum.rs: -------------------------------------------------------------------------------- 1 | #[cfg(all(test, feature = "support-axum", feature = "support-tungstenite"))] 2 | mod tests { 3 | use axum::{extract::ws::WebSocketUpgrade, response::Response, routing::get, Router}; 4 | use futures_util::{SinkExt, StreamExt}; 5 | use std::net::SocketAddr; 6 | use tokio::net::TcpStream; 7 | use tokio_tungstenite::client_async; 8 | use tokio_tungstenite::tungstenite::http::StatusCode; 9 | use tokio_tungstenite::tungstenite::Message; 10 | use yerpc::axum::handle_ws_rpc; 11 | use yerpc::tungstenite::tungstenite_client; 12 | use yerpc::{rpc, RpcClient, RpcSession}; 13 | 14 | struct Api; 15 | 16 | impl Api { 17 | pub fn new() -> Self { 18 | Self 19 | } 20 | } 21 | 22 | #[rpc(all_positional, ts_outdir = "typescript/generated")] 23 | impl Api { 24 | async fn shout(&self, msg: String) -> String { 25 | msg.to_uppercase() 26 | } 27 | async fn add(&self, a: f32, b: f32) -> f32 { 28 | a + b 29 | } 30 | } 31 | 32 | async fn handler(ws: WebSocketUpgrade) -> Response { 33 | let (client, out_receiver) = RpcClient::new(); 34 | let api = Api::new(); 35 | let session = RpcSession::new(client, api); 36 | handle_ws_rpc(ws, out_receiver, session).await 37 | } 38 | 39 | #[tokio::test] 40 | async fn test_axum_websocket() -> anyhow::Result<()> { 41 | let app = Router::new().route("/rpc", get(handler)); 42 | let addr = SocketAddr::from(([127, 0, 0, 1], 12345)); 43 | let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 44 | tokio::spawn(async move { 45 | axum::serve(listener, app).await.unwrap(); 46 | }); 47 | 48 | let tcp = TcpStream::connect("127.0.0.1:12345") 49 | .await 50 | .expect("Failed to connect"); 51 | let url = "ws://localhost:12345/rpc"; 52 | let (mut stream, response) = client_async(url, tcp) 53 | .await 54 | .expect("Client failed to connect"); 55 | assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); 56 | 57 | stream 58 | .send(Message::Text( 59 | r#"{"jsonrpc":"2.0","method":"shout","params":["foo"],"id":2}"#.into(), 60 | )) 61 | .await?; 62 | let res = stream.next().await.unwrap().unwrap(); 63 | match res { 64 | Message::Text(text) => { 65 | assert_eq!(text, r#"{"jsonrpc":"2.0","id":2,"result":"FOO"}"#); 66 | } 67 | _ => panic!("Received unexepcted message {:?}", res), 68 | } 69 | 70 | let (client, _on_close) = tungstenite_client(stream, ()); 71 | let res = client.send_request("add", Some([1.2, 2.3])).await?; 72 | let res: f32 = serde_json::from_value(res).unwrap(); 73 | assert_eq!(res, 3.5); 74 | let res: String = 75 | serde_json::from_value(client.send_request("shout", Some(["foo"])).await?)?; 76 | assert_eq!(res.as_str(), "FOO"); 77 | Ok(()) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /yerpc/tests/basic.rs: -------------------------------------------------------------------------------- 1 | use futures_util::StreamExt; 2 | use yerpc::{rpc, RpcSession}; 3 | 4 | #[tokio::test] 5 | async fn basic() -> anyhow::Result<()> { 6 | struct Api {} 7 | 8 | #[rpc(all_positional, ts_outdir = "typescript/generated")] 9 | impl Api { 10 | pub async fn constant(&self) -> String { 11 | "example".to_string() 12 | } 13 | 14 | pub async fn upper(&self, text: String) -> String { 15 | text.to_uppercase() 16 | } 17 | } 18 | 19 | let (session, mut out_rx) = RpcSession::create(Api {}); 20 | 21 | let req = r#"{"jsonrpc":"2.0","method":"constant","id":3}"#; 22 | session.handle_incoming(req).await; 23 | let out = out_rx.next().await.unwrap(); 24 | let out = serde_json::to_string(&out).unwrap(); 25 | assert_eq!(out, r#"{"jsonrpc":"2.0","id":3,"result":"example"}"#); 26 | 27 | let req = r#"{"jsonrpc":"2.0","method":"upper","params":["foo"],"id":7}"#; 28 | session.handle_incoming(req).await; 29 | let out = out_rx.next().await.unwrap(); 30 | let out = serde_json::to_string(&out).unwrap(); 31 | assert_eq!(out, r#"{"jsonrpc":"2.0","id":7,"result":"FOO"}"#); 32 | 33 | let client = session.client().clone(); 34 | tokio::spawn(async move { 35 | let out = out_rx.next().await.unwrap(); 36 | let out = serde_json::to_string(&out).unwrap(); 37 | assert_eq!( 38 | out, 39 | r#"{"jsonrpc":"2.0","method":"bar","params":["woo"],"id":1}"# 40 | ); 41 | session 42 | .handle_incoming(r#"{"jsonrpc":"2.0","id":1,"result":"boo"}"#) 43 | .await; 44 | }); 45 | let res = client.send_request("bar", Some(&["woo"])).await.unwrap(); 46 | assert_eq!(res, "boo"); 47 | Ok(()) 48 | } 49 | 50 | #[tokio::test] 51 | async fn basic_mixed_id_types() -> anyhow::Result<()> { 52 | struct Api {} 53 | 54 | #[rpc(all_positional, ts_outdir = "typescript/generated")] 55 | impl Api { 56 | pub async fn upper(&self, text: String) -> String { 57 | text.to_uppercase() 58 | } 59 | } 60 | 61 | let (session, mut out_rx) = RpcSession::create(Api {}); 62 | 63 | let req = r#"{"jsonrpc":"2.0","method":"upper","params":["foo"],"id":"7"}"#; 64 | session.handle_incoming(req).await; 65 | let out = out_rx.next().await.unwrap(); 66 | let out = serde_json::to_string(&out).unwrap(); 67 | assert_eq!(out, r#"{"jsonrpc":"2.0","id":"7","result":"FOO"}"#); 68 | 69 | let req = r#"{"jsonrpc":"2.0","method":"upper","params":["foo"],"id":9}"#; 70 | session.handle_incoming(req).await; 71 | let out = out_rx.next().await.unwrap(); 72 | let out = serde_json::to_string(&out).unwrap(); 73 | assert_eq!(out, r#"{"jsonrpc":"2.0","id":9,"result":"FOO"}"#); 74 | 75 | let req = r#"{"jsonrpc":"2.0","method":"upper","params":["foo"],"id":"hi"}"#; 76 | session.handle_incoming(req).await; 77 | let out = out_rx.next().await.unwrap(); 78 | let out = serde_json::to_string(&out).unwrap(); 79 | assert_eq!(out, r#"{"jsonrpc":"2.0","id":"hi","result":"FOO"}"#); 80 | 81 | Ok(()) 82 | } 83 | -------------------------------------------------------------------------------- /yerpc/typescript/generated/client.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by yerpc-derive 2 | 3 | import * as T from "./types.js" 4 | import * as RPC from "./jsonrpc.js" 5 | 6 | type RequestMethod = (method: string, params?: RPC.Params) => Promise; 7 | type NotificationMethod = (method: string, params?: RPC.Params) => void; 8 | 9 | interface Transport { 10 | request: RequestMethod, 11 | notification: NotificationMethod 12 | } 13 | 14 | export class RawClient { 15 | constructor(private _transport: Transport) {} 16 | 17 | 18 | public shout(msg: string): Promise { 19 | return (this._transport.request('shout', [msg] as RPC.Params)) as Promise; 20 | } 21 | 22 | 23 | public add(a: T.F32, b: T.F32): Promise { 24 | return (this._transport.request('add', [a, b] as RPC.Params)) as Promise; 25 | } 26 | 27 | 28 | } 29 | -------------------------------------------------------------------------------- /yerpc/typescript/generated/jsonrpc.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by typescript-type-def 2 | 3 | export type JSONValue = (null | boolean | number | string | (JSONValue)[] | { 4 | [key:string]:JSONValue; 5 | }); 6 | export type Params = ((JSONValue)[] | Record); 7 | export type U32 = number; 8 | 9 | /** 10 | * Request identifier as found in Request and Response objects. 11 | */ 12 | export type Id = (U32 | string); 13 | 14 | /** 15 | * Request object. 16 | */ 17 | export type Request = { 18 | 19 | /** 20 | * JSON-RPC protocol version. 21 | */ 22 | "jsonrpc"?: "2.0"; 23 | 24 | /** 25 | * Name of the method to be invoked. 26 | */ 27 | "method": string; 28 | 29 | /** 30 | * Method parameters. 31 | */ 32 | "params"?: Params; 33 | 34 | /** 35 | * Request identifier. 36 | */ 37 | "id"?: Id; 38 | }; 39 | export type I32 = number; 40 | 41 | /** 42 | * Error object returned in response to a failed RPC call. 43 | */ 44 | export type Error = { 45 | 46 | /** 47 | * Error code indicating the error type. 48 | */ 49 | "code": I32; 50 | 51 | /** 52 | * Short error description. 53 | */ 54 | "message": string; 55 | 56 | /** 57 | * Additional information about the error. 58 | */ 59 | "data"?: JSONValue; 60 | }; 61 | 62 | /** 63 | * Response object. 64 | */ 65 | export type Response = { 66 | 67 | /** 68 | * JSON-RPC protocol version. 69 | */ 70 | "jsonrpc": "2.0"; 71 | 72 | /** 73 | * Request identifier. 74 | */ 75 | "id": (Id | null); 76 | 77 | /** 78 | * Return value of the method. 79 | */ 80 | "result"?: JSONValue; 81 | 82 | /** 83 | * Error occured during the method invocation. 84 | */ 85 | "error"?: Error; 86 | }; 87 | export type Message = (Request | Response); 88 | -------------------------------------------------------------------------------- /yerpc/typescript/generated/types.ts: -------------------------------------------------------------------------------- 1 | // AUTO-GENERATED by typescript-type-def 2 | 3 | export type F32 = number; 4 | --------------------------------------------------------------------------------