├── .changes ├── config.json └── readme.md ├── .github └── workflows │ ├── checks.yml │ ├── covector-status.yml │ └── covector-version-or-publish.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── biome.json ├── crates └── qubit-macros │ ├── CHANGELOG.md │ ├── Cargo.toml │ └── src │ ├── handler │ ├── mod.rs │ └── options.rs │ ├── lib.rs │ └── macros │ ├── handler.rs │ └── mod.rs ├── examples ├── README.md ├── authentication │ ├── Cargo.lock │ ├── Cargo.toml │ ├── README.md │ ├── auth-demo │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── bindings │ │ │ │ └── index.ts │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── src │ │ └── main.rs ├── chaos │ ├── Cargo.lock │ ├── Cargo.toml │ ├── bindings │ │ ├── Metadata.ts │ │ ├── MyEnum.ts │ │ ├── NestedStruct.ts │ │ ├── Test.ts │ │ ├── UniqueType.ts │ │ ├── User.ts │ │ └── index.ts │ ├── index.ts │ ├── package.json │ ├── src │ │ └── main.rs │ └── tsconfig.json ├── chat-room-react │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src-rust │ │ ├── Cargo.lock │ │ ├── Cargo.toml │ │ └── src │ │ │ ├── main.rs │ │ │ └── manager.rs │ ├── src │ │ ├── App.tsx │ │ ├── api.ts │ │ ├── bindings │ │ │ ├── ChatMessage.ts │ │ │ └── index.ts │ │ ├── components │ │ │ ├── Avatar.tsx │ │ │ ├── History.tsx │ │ │ ├── Input.tsx │ │ │ ├── Message.tsx │ │ │ └── Online.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── counter │ ├── Cargo.lock │ ├── Cargo.toml │ ├── README.md │ ├── bindings │ │ └── index.ts │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── ctx.rs │ │ └── main.rs │ └── tsconfig.json └── hello-world │ ├── Cargo.lock │ ├── Cargo.toml │ ├── README.md │ ├── bindings │ └── index.ts │ ├── index.ts │ ├── package.json │ ├── src │ └── main.rs │ └── tsconfig.json ├── logo.png ├── package.json ├── packages ├── client │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── handler │ │ │ ├── index.ts │ │ │ ├── mutation.ts │ │ │ ├── query.ts │ │ │ └── subscription.ts │ │ ├── index.ts │ │ ├── jsonrpc │ │ │ └── index.ts │ │ ├── path_builder.ts │ │ ├── proxy │ │ │ ├── index.ts │ │ │ └── promise.ts │ │ ├── transport │ │ │ ├── http.ts │ │ │ ├── index.ts │ │ │ ├── multi.ts │ │ │ └── ws.ts │ │ └── util │ │ │ ├── index.ts │ │ │ ├── promise_manager.ts │ │ │ ├── socket.ts │ │ │ └── subscription_manager.ts │ └── tsconfig.json └── svelte │ ├── .gitignore │ ├── .npmrc │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── app.d.ts │ ├── app.html │ ├── lib │ │ └── index.ts │ └── routes │ │ └── +page.svelte │ ├── static │ └── favicon.png │ ├── svelte.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scripts └── generate-lock-files.sh └── src ├── builder ├── handler.rs ├── mod.rs ├── rpc_builder.rs └── ty │ ├── mod.rs │ └── util │ ├── exporter.rs │ ├── mod.rs │ └── qubit_type.rs ├── header.txt ├── lib.rs └── server ├── error.rs ├── mod.rs └── router.rs /.changes/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitSiteUrl": "https://github.com/andogq/qubit/", 3 | "pkgManagers": { 4 | "javascript": { 5 | "version": true, 6 | "getPublishedVersion": "npm view ${ pkgFile.pkg.name } version", 7 | "prepublish": ["pnpm i", "pnpm build"], 8 | "publish": ["pnpm publish --access public --no-git-checks"], 9 | "postversion": ["pnpm i --lockfile-only"], 10 | "postpublish": [ 11 | "git tag ${ pkg.pkg }-v${ pkgFile.versionMajor } -f", 12 | "git tag ${ pkg.pkg }-v${ pkgFile.versionMajor }.${ pkgFile.versionMinor } -f", 13 | "git push --tags -f" 14 | ] 15 | }, 16 | "rust": { 17 | "version": true, 18 | "getPublishedVersion": "cargo search ${ pkg.pkg } --limit 1 | sed -nE 's/^[^\"]*\"//; s/\".*//1p'", 19 | "postversion": [ 20 | { 21 | "command": "./scripts/generate-lock-files.sh", 22 | "dryRunCommand": true, 23 | "runFromRoot": true, 24 | "pipe": false 25 | } 26 | ], 27 | "publish": ["cargo publish"], 28 | "postpublish": [ 29 | "git tag ${ pkg.pkg }-v${ pkgFile.versionMajor } -f", 30 | "git tag ${ pkg.pkg }-v${ pkgFile.versionMajor }.${ pkgFile.versionMinor } -f", 31 | "git push --tags -f" 32 | ] 33 | } 34 | }, 35 | "packages": { 36 | "@qubit-rs/client": { 37 | "path": "./packages/client", 38 | "manager": "javascript" 39 | }, 40 | "@qubit-rs/svelte": { 41 | "path": "./packages/svelte", 42 | "manager": "javascript", 43 | "dependencies": ["@qubit-rs/client"] 44 | }, 45 | "qubit-macros": { 46 | "path": "./crates/qubit-macros", 47 | "manager": "rust" 48 | }, 49 | "qubit": { 50 | "path": ".", 51 | "manager": "rust", 52 | "dependencies": ["qubit-macros"] 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.changes/readme.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ##### via https://github.com/jbolda/covector 4 | 5 | As you create PRs and make changes that require a version bump, please add a new markdown file in this folder. You do not note the version _number_, but rather the type of bump that you expect: major, minor, or patch. The filename is not important, as long as it is a `.md`, but we recommend that it represents the overall change for organizational purposes. 6 | 7 | When you select the version bump required, you do _not_ need to consider dependencies. Only note the package with the actual change, and any packages that depend on that package will be bumped automatically in the process. 8 | 9 | Use the following format: 10 | 11 | ```md 12 | --- 13 | "package-a": patch 14 | "package-b": minor 15 | --- 16 | 17 | Change summary goes here 18 | 19 | ``` 20 | 21 | Summaries do not have a specific character limit, but are text only. These summaries are used within the (future implementation of) changelogs. They will give context to the change and also point back to the original PR if more details and context are needed. 22 | 23 | Changes will be designated as a `major`, `minor` or `patch` as further described in [semver](https://semver.org/). 24 | 25 | Given a version number MAJOR.MINOR.PATCH, increment the: 26 | 27 | - MAJOR version when you make incompatible API changes, 28 | - MINOR version when you add functionality in a backwards compatible manner, and 29 | - PATCH version when you make backwards compatible bug fixes. 30 | 31 | Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format, but will be discussed prior to usage (as extra steps will be necessary in consideration of merging and publishing). 32 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | 3 | on: push 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: corepack enable pnpm 15 | - uses: actions/setup-node@v4 16 | - run: pnpm i 17 | - run: pnpm biome ci --error-on-warnings --reporter=github 18 | 19 | typecheck: 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | dir: 24 | - packages/client 25 | - packages/svelte 26 | - examples/authentication/auth-demo 27 | - examples/chaos 28 | - examples/chat-room-react 29 | - examples/counter 30 | - examples/hello-world 31 | 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - run: corepack enable pnpm 36 | - uses: actions/setup-node@v4 37 | with: 38 | cache: pnpm 39 | cache-dependency-path: '**/pnpm-lock.yaml' 40 | - name: Build client package 41 | run: pnpm i && pnpm build 42 | working-directory: packages/client 43 | if: ${{ matrix.dir != 'packages/client' }} 44 | - run: pnpm i 45 | working-directory: ${{ matrix.dir }} 46 | - run: pnpm check 47 | working-directory: ${{ matrix.dir }} 48 | 49 | clippy: 50 | strategy: 51 | fail-fast: false 52 | matrix: 53 | dir: 54 | - '.' 55 | - examples/authentication 56 | - examples/chaos 57 | - examples/chat-room-react/src-rust 58 | - examples/counter 59 | - examples/hello-world 60 | 61 | env: 62 | RUSTFLAGS: "-Dwarnings" # Fail on warnings 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@v4 66 | - run: cargo clippy 67 | working-directory: ${{ matrix.dir }} 68 | -------------------------------------------------------------------------------- /.github/workflows/covector-status.yml: -------------------------------------------------------------------------------- 1 | name: covector status 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | covector: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 # required for use of git history 13 | - name: covector status 14 | uses: jbolda/covector/packages/action@covector-v0.10 15 | id: covector 16 | with: 17 | command: status 18 | -------------------------------------------------------------------------------- /.github/workflows/covector-version-or-publish.yml: -------------------------------------------------------------------------------- 1 | name: version or publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | version-or-publish: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 65 12 | outputs: 13 | change: ${{ steps.covector.outputs.change }} 14 | commandRan: ${{ steps.covector.outputs.commandRan }} 15 | successfulPublish: ${{ steps.covector.outputs.successfulPublish }} 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 # required for use of git history 21 | - run: corepack enable pnpm 22 | - uses: actions/setup-node@v4 23 | with: 24 | cache: pnpm 25 | cache-dependency-path: '**/pnpm-lock.yaml' 26 | registry-url: 'https://registry.npmjs.org' 27 | - name: cargo login 28 | run: cargo login ${{ secrets.CRATE_TOKEN }} 29 | - name: git config 30 | run: | 31 | git config --global user.name "${{ github.event.pusher.name }}" 32 | git config --global user.email "${{ github.event.pusher.email }}" 33 | - name: covector version or publish (publish when no change files present) 34 | uses: jbolda/covector/packages/action@covector-v0 35 | id: covector 36 | env: 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | CARGO_AUDIT_OPTIONS: ${{ secrets.CARGO_AUDIT_OPTIONS }} 39 | with: 40 | token: ${{ secrets.GITHUB_TOKEN }} 41 | command: 'version-or-publish' 42 | createRelease: true 43 | - name: Create Pull Request With Versions Bumped 44 | id: cpr 45 | uses: peter-evans/create-pull-request@v6 46 | if: steps.covector.outputs.commandRan == 'version' 47 | with: 48 | title: "Publish New Versions" 49 | commit-message: "publish new versions" 50 | labels: "version updates" 51 | branch: "release" 52 | body: ${{ steps.covector.outputs.change }} 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["rust-lang.rust-analyzer", "biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "editor.codeActionsOnSave": { 5 | "source.organizeImports.biome": "explicit" 6 | }, 7 | "rust-analyzer.check.command": "clippy", 8 | "[rust]": { 9 | "editor.defaultFormatter": "rust-lang.rust-analyzer", 10 | "editor.formatOnSave": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## \[0.10.3] 4 | 5 | - [`a5f1638`](https://github.com/andogq/qubit/commit/a5f1638e4a21c9f5fd4e7d1dfa740f5522753e99) Update Axum to 0.8, and other dependencies (#94, thanks @epatters). 6 | 7 | ### Dependencies 8 | 9 | - Upgraded to `qubit-macros@0.6.5` 10 | 11 | ## \[0.10.2] 12 | 13 | - [`1aec8be`](https://github.com/andogq/qubit/commit/1aec8beb9f689015dce7eef64f7b99a835c9058a) Enable the `util` feature on `tower` to close #82 14 | 15 | ## \[0.10.1] 16 | 17 | - [`6998257`](https://github.com/andogq/qubit/commit/69982570ec2183ff1e639f78d71281de91bb733f) Alter router type generation to re-export all types. 18 | - [`88ef762`](https://github.com/andogq/qubit/commit/88ef76278452faf46e8cadd6f5b3011f68674169) ([#84](https://github.com/andogq/qubit/pull/84)) Update `ts-rs` to 10.1.0 (thanks @epatters) 19 | 20 | ## \[0.10.0] 21 | 22 | ### feat 23 | 24 | - [`7fe1372`](https://github.com/andogq/qubit/commit/7fe1372b9a7c985814a21b1893dc62943685cde5) Add `/* eslint-disable */` to generated bindings (#77, thanks @lucasavila00!) 25 | - [`7fe1372`](https://github.com/andogq/qubit/commit/7fe1372b9a7c985814a21b1893dc62943685cde5) Upgrade `ts-rs` to latest version (#76, thanks @lucasavila00!) 26 | 27 | ## \[0.9.5] 28 | 29 | ### Dependencies 30 | 31 | - Upgraded to `qubit-macros@0.6.4` 32 | 33 | ### feat 34 | 35 | - [`1fb61ac`](https://github.com/andogq/qubit/commit/1fb61acff0f4264a1e996d143f17ae5e89849ec7) ([#69](https://github.com/andogq/qubit/pull/69)) return parameter deserialising errors if they are encountered (close #69) 36 | 37 | ## \[0.9.4] 38 | 39 | ### feat 40 | 41 | - [`886106b`](https://github.com/andogq/qubit/commit/886106b27b68fb1e2a24f7cd0f3a2e929032151b) support `GET` for queries in client 42 | - [`3f6b12b`](https://github.com/andogq/qubit/commit/3f6b12ba8c088fc266b49ad51fb9d15acf223503) include `// @ts-nocheck` at top of generated files 43 | 44 | ## \[0.9.3] 45 | 46 | ### Dependencies 47 | 48 | - Upgraded to `qubit-macros@0.6.3` 49 | 50 | ### feat 51 | 52 | - [`e17bbf0`](https://github.com/andogq/qubit/commit/e17bbf0fb8adce5f488247f298278342add2e478) refactor client to introduct plugins, simplify types, and prepare for future work 53 | 54 | ## \[0.9.2] 55 | 56 | ### Dependencies 57 | 58 | - Upgraded to `qubit-macros@0.6.2` 59 | 60 | ### feat 61 | 62 | - [`e426945`](https://github.com/andogq/qubit/commit/e426945cda8cacd9a33c7cc8705945324dc5c305) allow for `query` handlers to be accessed via `GET` as well as `POST` 63 | 64 | ## \[0.9.1] 65 | 66 | ### Dependencies 67 | 68 | - Upgraded to `qubit-macros@0.6.1` 69 | 70 | ### fix 71 | 72 | - [`dbf8fd5`](https://github.com/andogq/qubit/commit/dbf8fd5ee5745f070be7842a68d8fb6e8eb70cdf) update readme with correct instructions 73 | 74 | ## \[0.9.0] 75 | 76 | ### Dependencies 77 | 78 | - Upgraded to `qubit-macros@0.6.0` 79 | 80 | ### feat 81 | 82 | - [`9543d12`](https://github.com/andogq/qubit/commit/9543d126a915d5501a83ba207591858283cebe87) (**BREAKING**) pass single cloneable ctx to builder instead of closure that accepts a request 83 | - [`7274cb0`](https://github.com/andogq/qubit/commit/7274cb059af6ab1d00d92099fab2a7ee8ea2b6be) **BREAKING** replace `FromContext` with `FromRequestExtensions` to build ctx from request information (via tower middleware) 84 | 85 | ### fix 86 | 87 | - [`111db0a`](https://github.com/andogq/qubit/commit/111db0a3fb52c221749f12aeda5757df847df5a8) fix incorrect handling of deeply nested routers 88 | 89 | ## \[0.8.0] 90 | 91 | - [`cb95f67`](https://github.com/andogq/qubit/commit/cb95f67c1457458a7123814d872bcdc7bdb1fba9) fix example dependency versions in README 92 | 93 | ### feat 94 | 95 | - [`64913a8`](https://github.com/andogq/qubit/commit/64913a884e82ee35e6b63ded86755582a8031360) provide mutable reference to request parts, instead of the entire request to the context builder. 96 | 97 | ## \[0.7.0] 98 | 99 | - [`69669f4`](https://github.com/andogq/qubit/commit/69669f4dbb99cc179479ca6a5b2c33c0639b8531) update to jsonrpsee 0.23.0 100 | 101 | ### Dependencies 102 | 103 | - Upgraded to `qubit-macros@0.5.1` 104 | 105 | ### fix 106 | 107 | - [`fe5fd40`](https://github.com/andogq/qubit/commit/fe5fd4049510e7b9847da7518ae7ea01abd1bde6) bring README back up to date 108 | 109 | ## \[0.6.1] 110 | 111 | ### Dependencies 112 | 113 | - Upgraded to `qubit-macros@0.5.0` 114 | 115 | ## \[0.6.0] 116 | 117 | ### feat 118 | 119 | - [`57e124f`](https://github.com/andogq/qubit/commit/57e124faf3fc4f7af0e5b25f5ac18f982e1d820a) add `on_close` callback to `to_service`, which will be run when the client connection closes (close #44) 120 | 121 | ## \[0.5.2] 122 | 123 | ### fix 124 | 125 | - [`0bb7ac9`](https://github.com/andogq/qubit/commit/0bb7ac934730cca49acc3785074c65a356b5ebe5) ([#41](https://github.com/andogq/qubit/pull/41)) fix exporting tuple types returned from handlers (close #41) 126 | 127 | ## \[0.5.1] 128 | 129 | ### Dependencies 130 | 131 | - Upgraded to `qubit-macros@0.4.1` 132 | 133 | ### minor 134 | 135 | - [`a57ec51`](https://github.com/andogq/qubit/commit/a57ec51e05b8b4dc509a401f1a17dee1d3f45b5e) update crate description to match repository 136 | 137 | ## \[0.5.0] 138 | 139 | ### Dependencies 140 | 141 | - Upgraded to `qubit-macros@0.4.0` 142 | 143 | ### feat 144 | 145 | - [`625df36`](https://github.com/andogq/qubit/commit/625df3640b3a1134866040de56a1e29943c15e76) remove `ExportType` macro, to now only rely on `ts-rs::TS` (close #26) 146 | 147 | ## \[0.4.0] 148 | 149 | - [`ea54e2b`](https://github.com/andogq/qubit/commit/ea54e2b76ab11c2dae21eda5dfa7188cfcdb717a) change exported server type to `QubitServer` (close #28) 150 | - [`3f015f9`](https://github.com/andogq/qubit/commit/3f015f95de5776d2d07472f15cada703950e658a) pass all CI checks 151 | 152 | ### Dependencies 153 | 154 | - Upgraded to `qubit-macros@0.3.0` 155 | 156 | ## \[0.3.0] 157 | 158 | ### fix 159 | 160 | - [`55f4b31`](https://github.com/andogq/qubit/commit/55f4b31bfef67345e94a815c3c38062494bc1327) allow for `to_service` to return a future which produces the context 161 | 162 | ### feat 163 | 164 | - [`be65ee3`](https://github.com/andogq/qubit/commit/be65ee311aea16002d2311694bb2e30958f8f28b) add `HashSet`, `BTreeSet`, and `BTreeMap` to types that implement `ExportType` 165 | 166 | ## \[0.2.1] 167 | 168 | - [`3840c3b`](https://github.com/andogq/qubit/commit/3840c3b0854e59626410b15fb5eb57739fbd1902) automatically dervie `ExportType` for `f32` and `f64` 169 | 170 | ### Dependencies 171 | 172 | - Upgraded to `qubit-macros@0.2.1` 173 | 174 | ## \[0.2.0] 175 | 176 | ### Dependencies 177 | 178 | - Upgraded to `qubit-macros@0.2.0` 179 | 180 | ### feat 181 | 182 | - [`0758fe3`](https://github.com/andogq/qubit/commit/0758fe32bcf6b702177b88e3dbf7158acaf42523) alter `FromContext` trait to be `async` 183 | 184 | ## \[0.1.0] 185 | 186 | ### Dependencies 187 | 188 | - Upgraded to `qubit-macros@0.1.0` 189 | - [`a5f8e49`](https://github.com/andogq/qubit/commit/a5f8e49c70a1e82a983f4841482671ec16eab765) update dependencies 190 | 191 | ### refactor 192 | 193 | - [`99c8fd3`](https://github.com/andogq/qubit/commit/99c8fd3d5cfa4e2e662adf72ed7d410aee6bf73c) refactor `TypeDependencies` trait into `ExportType` trait 194 | 195 | ### feat 196 | 197 | - [`2aafe80`](https://github.com/andogq/qubit/commit/2aafe80cc0e3ad74f9182da20e8ea9bb8110fcad) switch over to `TypeRegistry` to export client, and now optionally export `Stream` as required 198 | 199 | ## \[0.0.10] 200 | 201 | ### fix 202 | 203 | - [`43eb9c4`](https://github.com/andogq/qubit/commit/43eb9c4ff8d1894cfc4256e8cd1d10a112bb6275) Make sure that the subscription and channel are both still active before attempting to send data 204 | down them. 205 | 206 | All notable changes to this project will be documented in this file. 207 | 208 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 209 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 210 | 211 | ## \[Unreleased] 212 | 213 | ## [0.0.9](https://github.com/andogq/qubit/compare/qubit-v0.0.8...qubit-v0.0.9) - 2024-05-23 214 | 215 | ### Other 216 | 217 | - bump client version 218 | - synchronously return unsubscribe function from client 219 | - improve build script for client lib 220 | - rename proc macro implementation for `TypeDependencies` 221 | - turn `exported_type` into a proc macro 222 | - properly generate `TypeDependencies` trait for built-in generic types 223 | 224 | ## [0.0.8](https://github.com/andogq/qubit/compare/qubit-v0.0.7...qubit-v0.0.8) - 2024-05-22 225 | 226 | ### Fixed 227 | 228 | - properly handle unit return type from handlers 229 | 230 | ### Other 231 | 232 | - remove whitespace in readme 233 | - add badges to readme 234 | 235 | ## [0.0.7](https://github.com/andogq/qubit/compare/qubit-v0.0.6...qubit-v0.0.7) - 2024-05-22 236 | 237 | ### Fixed 238 | 239 | - make some sub-modules with documentation public 240 | 241 | ### Other 242 | 243 | - try add github actions 244 | - continue adding documentation and re-factoring 245 | - begin refactoring and moving files into more reasonable layout 246 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["./crates/qubit-macros"] 3 | exclude = [ 4 | "./examples/authentication", 5 | "./examples/chaos", 6 | "./examples/chat-room-react/src-rust", 7 | "./examples/counter", 8 | "./examples/hello-world", 9 | ] 10 | 11 | [package] 12 | name = "qubit" 13 | version = "0.10.3" 14 | edition = "2021" 15 | authors = ["Tom Anderson "] 16 | repository = "https://github.com/andogq/qubit" 17 | license = "MIT" 18 | description = "Seamless RPC for Rust & TypeScript" 19 | exclude = ["./client", "./example"] 20 | 21 | [dependencies] 22 | axum = "0.8" 23 | futures = "0.3.31" 24 | http = "1.3" 25 | hyper = { version = "1.6", features = ["server"] } 26 | jsonrpsee = { version = "0.25", features = ["server"] } 27 | serde = { version = "1.0.219", features = ["derive"] } 28 | serde_json = "1.0.140" 29 | tokio = { version = "1.44", features = ["rt", "rt-multi-thread"] } 30 | tower = { version = "0.5", features = ["util"] } 31 | ts-rs = "10.1.0" 32 | qubit-macros = { version = "0.6.5", path = "./crates/qubit-macros" } 33 | trait-variant = "0.1.2" 34 | serde_qs = "0.13.0" 35 | urlencoding = "2.1.3" 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tom Anderson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Qubit: Seamless RPC For Rust & TypeScript

4 | 5 | crates.io 6 | docs.rs 7 | npm 8 | checks 9 |
10 | 11 | Tired of wrestling with RPC boilerplate? Qubit simplifies communication between your Rust services 12 | and TypeScript clients, offering a type-safe and feature-rich development experience, so you can 13 | focus on building amazing applications. 14 | 15 | ## Features: 16 | 17 | - **Generated Type-Safe Clients**: Say goodbye to manual type definitions, Qubit automatically 18 | generates TypeScript clients based on your Rust API, ensuring a smooth development experience. 19 | 20 | - **Subscriptions**: Build real-time, data-driven applications with subscriptions, allowing for 21 | your Rust server to push data directly to connected TypeScript clients. 22 | 23 | - **Build Modular APIs**: Organise your API handlers into nested routers, ensuring simplicity and 24 | maintainability as your service grows. 25 | 26 | - **Serde Compatibility**: Leverage Serde for seamless data serialisation and deserialisation 27 | between Rust and TypeScript. 28 | 29 | - **Built on JSONRPC 2.0**: Need a non-TypeScript client? Use any JSONRPC client in any language 30 | over WebSockets or HTTP. 31 | 32 | - **Proven Base**: Built on established libraries like 33 | [`ts-rs`](https://github.com/Aleph-Alpha/ts-rs) for type generation and 34 | [`jsonrpsee`](https://github.com/paritytech/jsonrpsee) as the JSONRPC implementation. 35 | 36 | ## Getting Started 37 | 38 | 1. Add the required dependencies 39 | 40 | ```toml 41 | # Cargo.toml 42 | [dependencies] 43 | qubit = "0.6.1" 44 | 45 | ts-rs = "8.1.0" # Required to generate TS types 46 | serde = { version = "1.0", features = ["derive"] } # Required for serialisable types 47 | futures = "0.3.30" # Required for streaming functionality 48 | 49 | tokio = { version = "1.38", features = ["full"] } 50 | axum = "0.7" 51 | hyper = { version = "1.0", features = ["server"] } 52 | ``` 53 | 54 | ```bash 55 | pnpm i @qubit-rs/client@latest 56 | ``` 57 | 58 | 2. Setup a Qubit router, and save the generated types 59 | 60 | ```rs 61 | #[handler(query)] 62 | async fn hello_world() -> String { 63 | "Hello, world!".to_string() 64 | } 65 | 66 | let router = Router::new() 67 | .handler(hello_world); 68 | 69 | router.write_bindings_to_dir("./bindings"); 70 | ``` 71 | 72 | 3. Attach the Qubit router to an Axum router, and start it 73 | 74 | ```rs 75 | // Create a service and handle 76 | let (qubit_service, qubit_handle) = router.to_service(()); 77 | 78 | // Nest into an Axum router 79 | let axum_router = axum::Router::<()>::new() 80 | .nest_service("/rpc", qubit_service); 81 | 82 | // Start a Hyper server 83 | axum::serve( 84 | tokio::net::TcpListener::bind(&SocketAddr::from(([127, 0, 0, 1], 9944))) 85 | .await 86 | .unwrap(), 87 | axum_router, 88 | ) 89 | .await 90 | .unwrap(); 91 | 92 | qubit_handle.stop().unwrap(); 93 | ``` 94 | 95 | 4. Make requests from the TypeScript client 96 | 97 | ```ts 98 | // Import transport from client, and generated server type 99 | import { build_client, http } from "@qubit-rs/client"; 100 | import type { QubitServer } from "./bindings"; 101 | 102 | // Connect with the API 103 | const api = build_client(http("http://localhost:9944/rpc")); 104 | 105 | // Call the handlers 106 | const message = await api.hello_world.query(); 107 | console.log("received from server:", message); 108 | ``` 109 | 110 | ## Examples 111 | 112 | Checkout all the examples in the [`examples`](./examples) directory. 113 | 114 | ## FAQs 115 | 116 | ### Qubit? 117 | 118 | The term "Qubit" refers to the fundamental unit of quantum information. Just as a qubit can exist 119 | in a superposition of states, Qubit bridges the gap between Rust and TypeScript, empowering 120 | developers to create truly exceptional applications. 121 | 122 | ## Prior Art 123 | 124 | - [`rspc`](https://github.com/oscartbeaumont/rspc): Similar concept, however uses a bespoke 125 | solution for generating TypeScript types from Rust structs, which isn't completely compatible with 126 | all of Serde's features for serialising and deserialising structs. 127 | 128 | - [`trpc`](https://github.com/trpc/trpc): Needs no introduction, however it being restricted to 129 | TypeScript backends makes it relatively useless for Rust developers. 130 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true, 7 | "defaultBranch": "main" 8 | }, 9 | "files": { 10 | "include": ["*.ts", "*.tsx", "*.json"], 11 | "ignore": ["bindings*", "package.json", ".svelte-kit*"] 12 | }, 13 | "organizeImports": { 14 | "enabled": true 15 | }, 16 | "linter": { 17 | "enabled": true, 18 | "rules": { 19 | "recommended": true, 20 | "correctness": { 21 | "noUnusedImports": "warn", 22 | "noUnusedVariables": "warn" 23 | } 24 | } 25 | }, 26 | "formatter": { 27 | "indentStyle": "space", 28 | "lineWidth": 120 29 | }, 30 | "overrides": [ 31 | { 32 | "include": ["packages/client/**/*.ts"], 33 | "linter": { 34 | "rules": { 35 | "suspicious": { 36 | "noExplicitAny": "off" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /crates/qubit-macros/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## \[0.6.5] 4 | 5 | - [`a5f1638`](https://github.com/andogq/qubit/commit/a5f1638e4a21c9f5fd4e7d1dfa740f5522753e99) Update Axum to 0.8, and other dependencies (#94, thanks @epatters). 6 | 7 | ## \[0.6.4] 8 | 9 | ### feat 10 | 11 | - [`1fb61ac`](https://github.com/andogq/qubit/commit/1fb61acff0f4264a1e996d143f17ae5e89849ec7) ([#69](https://github.com/andogq/qubit/pull/69)) return parameter deserialising errors if they are encountered (close #69) 12 | 13 | ## \[0.6.3] 14 | 15 | ### feat 16 | 17 | - [`e17bbf0`](https://github.com/andogq/qubit/commit/e17bbf0fb8adce5f488247f298278342add2e478) refactor client to introduct plugins, simplify types, and prepare for future work 18 | 19 | ## \[0.6.2] 20 | 21 | ### feat 22 | 23 | - [`e426945`](https://github.com/andogq/qubit/commit/e426945cda8cacd9a33c7cc8705945324dc5c305) allow for `query` handlers to be accessed via `GET` as well as `POST` 24 | 25 | ## \[0.6.1] 26 | 27 | ### fix 28 | 29 | - [`dbf8fd5`](https://github.com/andogq/qubit/commit/dbf8fd5ee5745f070be7842a68d8fb6e8eb70cdf) update readme with correct instructions 30 | 31 | ## \[0.6.0] 32 | 33 | ### feat 34 | 35 | - [`7274cb0`](https://github.com/andogq/qubit/commit/7274cb059af6ab1d00d92099fab2a7ee8ea2b6be) **BREAKING** replace `FromContext` with `FromRequestExtensions` to build ctx from request information (via tower middleware) 36 | 37 | ## \[0.5.1] 38 | 39 | ### fix 40 | 41 | - [`bf93414`](https://github.com/andogq/qubit/commit/bf93414c1e2732d5e0ae5c13425529038303a935) use absolute paths in the macro 42 | 43 | ## \[0.5.0] 44 | 45 | ### feat 46 | 47 | - [`429c19f`](https://github.com/andogq/qubit/commit/429c19f3506bdd225b2c2762907d8c880a07bbca) require handlers to have attribute of `query`, `mutation`, or `subscription` 48 | 49 | ## \[0.4.1] 50 | 51 | ### feat 52 | 53 | - [`f227cc9`](https://github.com/andogq/qubit/commit/f227cc96e6170cb039905fe0b55b5585ca5b81ee) handlers with no parameters no longer need to take `ctx` 54 | 55 | ## \[0.4.0] 56 | 57 | ### feat 58 | 59 | - [`625df36`](https://github.com/andogq/qubit/commit/625df3640b3a1134866040de56a1e29943c15e76) remove `ExportType` macro, to now only rely on `ts-rs::TS` (close #26) 60 | 61 | ## \[0.3.0] 62 | 63 | - [`3f015f9`](https://github.com/andogq/qubit/commit/3f015f95de5776d2d07472f15cada703950e658a) pass all CI checks 64 | 65 | ## \[0.2.1] 66 | 67 | - [`d2bf039`](https://github.com/andogq/qubit/commit/d2bf03992c9ea1b160497e371882b51377f4c2ec) implement `ExportType` derive for enums (close #20) 68 | 69 | ## \[0.2.0] 70 | 71 | ### feat 72 | 73 | - [`0758fe3`](https://github.com/andogq/qubit/commit/0758fe32bcf6b702177b88e3dbf7158acaf42523) alter `FromContext` trait to be `async` 74 | 75 | ## \[0.1.0] 76 | 77 | ### feat 78 | 79 | - [`ff7bf89`](https://github.com/andogq/qubit/commit/ff7bf89cb2b419aba7fd8fd98685abaccd407753) specify custom names for handlers using `#[handler(name = "my_handler")]` 80 | - [`2aafe80`](https://github.com/andogq/qubit/commit/2aafe80cc0e3ad74f9182da20e8ea9bb8110fcad) switch over to `TypeRegistry` to export client, and now optionally export `Stream` as required 81 | 82 | ### refactor 83 | 84 | - [`d6ccc9a`](https://github.com/andogq/qubit/commit/d6ccc9a4431656df2dc35d1d1326a8b4358a7c4b) Refactor macros 85 | - [`99c8fd3`](https://github.com/andogq/qubit/commit/99c8fd3d5cfa4e2e662adf72ed7d410aee6bf73c) refactor `TypeDependencies` trait into `ExportType` trait 86 | 87 | ### fix 88 | 89 | - [`b399c8b`](https://github.com/andogq/qubit/commit/b399c8bfa38f8c82a819668b4139b936905263c8) respect visibilitly modifier on handler function when macro-ing 90 | 91 | All notable changes to this project will be documented in this file. 92 | 93 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 94 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 95 | 96 | ## \[Unreleased] 97 | 98 | ## [0.0.7](https://github.com/andogq/qubit/compare/qubit-macros-v0.0.6...qubit-macros-v0.0.7) - 2024-05-23 99 | 100 | ### Other 101 | 102 | - rename proc macro implementation for `TypeDependencies` 103 | - turn `exported_type` into a proc macro 104 | 105 | ## [0.0.6](https://github.com/andogq/qubit/compare/qubit-macros-v0.0.5...qubit-macros-v0.0.6) - 2024-05-22 106 | 107 | ### Fixed 108 | 109 | - properly handle unit return type from handlers 110 | 111 | ## [0.0.5](https://github.com/andogq/qubit/compare/qubit-macros-v0.0.4...qubit-macros-v0.0.5) - 2024-05-22 112 | 113 | ### Other 114 | 115 | - continue adding documentation and re-factoring 116 | -------------------------------------------------------------------------------- /crates/qubit-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "qubit-macros" 3 | version = "0.6.5" 4 | edition = "2021" 5 | description = "Macros to accompany `qubit`." 6 | license = "MIT" 7 | 8 | [lib] 9 | proc-macro = true 10 | 11 | [dependencies] 12 | proc-macro2 = "1.0.95" 13 | quote = "1.0.40" 14 | syn = "2.0.101" 15 | ts-rs = "10.1.0" 16 | -------------------------------------------------------------------------------- /crates/qubit-macros/src/handler/mod.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::{Ident, Span, TokenStream}; 2 | use quote::{quote, ToTokens}; 3 | use syn::{ 4 | parse_quote, spanned::Spanned, Error, FnArg, ItemFn, Pat, Result, ReturnType, Type, 5 | TypeImplTrait, Visibility, 6 | }; 7 | 8 | mod options; 9 | 10 | pub use options::*; 11 | 12 | /// Kind of return value from a handler. 13 | enum HandlerReturn { 14 | /// Handler returns a stream of the provided type. 15 | Stream(Type), 16 | 17 | /// Handle returns a single instance of the provided type. 18 | Return(Type), 19 | } 20 | 21 | impl ToTokens for HandlerReturn { 22 | fn to_tokens(&self, tokens: &mut TokenStream) { 23 | match self { 24 | Self::Stream(ty) | Self::Return(ty) => ty.to_tokens(tokens), 25 | } 26 | } 27 | } 28 | 29 | impl HandlerKind { 30 | pub fn ts_type(&self) -> String { 31 | match self { 32 | HandlerKind::Query => "Query<[{params}], {return_ty}>", 33 | HandlerKind::Mutation => "Mutation<[{params}], {return_ty}>", 34 | HandlerKind::Subscription => "Subscription<[{params}], {return_ty}>", 35 | } 36 | .to_string() 37 | } 38 | } 39 | 40 | /// All relevant information about the handler extracted from the macro. 41 | pub struct Handler { 42 | /// Visibility of the handler. 43 | visibility: Visibility, 44 | 45 | /// Name of the handler. 46 | name: Ident, 47 | 48 | /// Type of the context used in the handler. 49 | ctx_ty: Option, 50 | 51 | /// Inputs for the handler. Currently does not support any kind of destructuring. 52 | inputs: Vec<(Ident, Type)>, 53 | 54 | /// Return type of the handler. 55 | return_type: HandlerReturn, 56 | 57 | /// The kind of the handler (`query`, `mutation`, `subscription`) 58 | kind: HandlerKind, 59 | 60 | /// The actual handler implementation. 61 | implementation: ItemFn, 62 | } 63 | 64 | impl Handler { 65 | /// Parse a handler from an [`ItemFn`] and some options. This will return [`syn::Error`]s if 66 | /// parsing cannot take place. 67 | pub fn parse(handler: ItemFn, options: HandlerOptions) -> Result { 68 | let span = handler.span(); 69 | 70 | // TODO: Could this be relaxed? 71 | if handler.sig.asyncness.is_none() { 72 | return Err(Error::new(span, "handlers must be async")); 73 | } 74 | 75 | let implementation = { 76 | // Create the implementation by cloning the original function, and changing the 77 | // name to be `handler`. 78 | let mut implementation = handler.clone(); 79 | implementation.sig.ident = Ident::new("handler", Span::call_site()); 80 | implementation 81 | }; 82 | 83 | let mut inputs = handler 84 | .sig 85 | .inputs 86 | .into_iter() 87 | .map(|arg| { 88 | let FnArg::Typed(arg) = arg else { 89 | return Err(Error::new(span, "handlers cannot take `self` parameter")); 90 | }; 91 | 92 | let Pat::Ident(ident) = *arg.pat else { 93 | return Err(Error::new( 94 | span, 95 | "destructured parameters are not supported in handlers", 96 | )); 97 | }; 98 | 99 | let ty = *arg.ty; 100 | 101 | Ok((ident.ident, ty)) 102 | }) 103 | .collect::>>()?; 104 | 105 | let ctx_ty = if inputs.is_empty() { 106 | None 107 | } else { 108 | Some(inputs.remove(0).1) 109 | }; 110 | 111 | Ok(Self { 112 | implementation, 113 | visibility: handler.vis, 114 | name: options.name.unwrap_or(handler.sig.ident), 115 | kind: options.kind.clone(), 116 | ctx_ty, 117 | inputs, 118 | return_type: { 119 | let return_type = match handler.sig.output { 120 | ReturnType::Default => HandlerReturn::Return(parse_quote! { () }), 121 | ReturnType::Type(_, ty) => match *ty { 122 | // BUG: Assuming that any trait implementation is a stream, which definitely isn't 123 | // the case. 124 | Type::ImplTrait(TypeImplTrait { bounds, .. }) => HandlerReturn::Stream( 125 | parse_quote! { ::Item }, 126 | ), 127 | // All other return types will be treated as a regular return type. 128 | return_type => HandlerReturn::Return(return_type), 129 | }, 130 | }; 131 | 132 | match (&return_type, options.kind) { 133 | // Valid case, return type matches with handler annotation 134 | (HandlerReturn::Stream(_), HandlerKind::Subscription) 135 | | (HandlerReturn::Return(_), HandlerKind::Query | HandlerKind::Mutation) => { 136 | return_type 137 | } 138 | 139 | // Mismatches 140 | (HandlerReturn::Stream(_), HandlerKind::Query | HandlerKind::Mutation) => { 141 | return Err(Error::new( 142 | span, 143 | "handler indicated to be a query, however a stream was returned", 144 | )); 145 | } 146 | (HandlerReturn::Return(_), HandlerKind::Subscription) => { 147 | return Err(Error::new( 148 | span, 149 | "handler indicated to be a subscription, however a stream was not returned", 150 | )); 151 | } 152 | } 153 | }, 154 | }) 155 | } 156 | 157 | /// Produce a list of parameter names as idents that this handler accepts. 158 | fn parameter_names(&self) -> Vec { 159 | self.inputs.iter().map(|(name, _)| name).cloned().collect() 160 | } 161 | 162 | /// Produce a list of parameter names as strings that this handler accepts. 163 | fn parameter_names_str(&self) -> Vec { 164 | self.parameter_names() 165 | .iter() 166 | .map(|name| name.to_string()) 167 | .collect() 168 | } 169 | 170 | /// Produce a list of parameter types that this handler accepts. 171 | fn parameter_tys(&self) -> Vec { 172 | self.inputs.iter().map(|(_, ty)| ty).cloned().collect() 173 | } 174 | 175 | /// Produce a token stream that will generate the TS signature of this handler. 176 | fn get_signature(&self) -> TokenStream { 177 | let return_type = &self.return_type; 178 | 179 | let param_names_str = self.parameter_names_str(); 180 | let param_tys = self.parameter_tys(); 181 | 182 | let base_ty = self.kind.ts_type(); 183 | 184 | quote! { 185 | { 186 | let parameters = [ 187 | #((#param_names_str, <#param_tys as ::ts_rs::TS>::name())),* 188 | ] 189 | .into_iter() 190 | .map(|(param, ty): (&str, String)| { 191 | format!("{param}: {ty}, ") 192 | }) 193 | .collect::<::std::string::String>(); 194 | 195 | format!(#base_ty, params=parameters, return_ty=<#return_type as ::ts_rs::TS>::name()) 196 | } 197 | } 198 | } 199 | } 200 | 201 | impl From for TokenStream { 202 | fn from(handler: Handler) -> Self { 203 | // Generate the signature 204 | let param_names = handler.parameter_names(); 205 | let param_tys = handler.parameter_tys(); 206 | let signature = handler.get_signature(); 207 | 208 | // Extract required elements from handler 209 | let Handler { 210 | visibility, 211 | name, 212 | ctx_ty, 213 | inputs, 214 | return_type, 215 | kind, 216 | implementation, 217 | } = handler; 218 | 219 | let handler_name_str = name.to_string(); 220 | 221 | // Must be a collision-free ident to use as a generic within the handler 222 | let inner_ctx_ty: Type = parse_quote! { __internal_AppCtx }; 223 | 224 | // Record whether the handler needs a ctx passed to it 225 | let ctx_required = ctx_ty.is_some(); 226 | 227 | // Use the ctx type, or default back to the app ctx type if none is provided 228 | let ctx_ty = ctx_ty.unwrap_or_else(|| inner_ctx_ty.clone()); 229 | 230 | let kind_str = kind.to_string(); 231 | 232 | let register_impl = { 233 | // Define idents in one place, so they will be checked by the compiler 234 | let ctx_ident = quote! { ctx }; 235 | let params_ident = quote! { params }; 236 | 237 | // Generate the parameter parsing implementation 238 | let parse_params = (!inputs.is_empty()).then(|| { 239 | quote! { 240 | let (#(#param_names,)*) = match #params_ident.parse::<(#(#param_tys,)*)>() { 241 | ::std::result::Result::Ok(params) => params, 242 | ::std::result::Result::Err(e) => return ::std::result::Result::Err(e), 243 | }; 244 | } 245 | }); 246 | 247 | let handler_call = if ctx_required { 248 | quote! { handler(#ctx_ident, #(#param_names,)*).await } 249 | } else { 250 | quote! { handler().await } 251 | }; 252 | 253 | // Body of the handler registration implementation 254 | let register_inner = quote! { 255 | #parse_params 256 | 257 | let result = #handler_call; 258 | ::std::result::Result::Ok::<_, ::qubit::ErrorObject>(result) 259 | }; 260 | 261 | let register_method = match kind { 262 | HandlerKind::Query => quote! { query }, 263 | HandlerKind::Mutation => quote! { mutation }, 264 | HandlerKind::Subscription => quote! { subscription }, 265 | }; 266 | 267 | match &return_type { 268 | HandlerReturn::Return(_) => { 269 | quote! { 270 | rpc_builder.#register_method(#handler_name_str, |#ctx_ident: #ctx_ty, #params_ident| async move { 271 | #register_inner 272 | }) 273 | } 274 | } 275 | HandlerReturn::Stream(_) => { 276 | let notification_name = format!("{handler_name_str}_notif"); 277 | let unsubscribe_name = format!("{handler_name_str}_unsub"); 278 | 279 | quote! { 280 | rpc_builder.#register_method( 281 | #handler_name_str, 282 | #notification_name, 283 | #unsubscribe_name, 284 | |#ctx_ident: #ctx_ty, #params_ident| async move { 285 | #register_inner 286 | } 287 | ) 288 | } 289 | } 290 | } 291 | }; 292 | 293 | // Generate implementation of the `qubit_types` method. 294 | let qubit_type_base = quote! { ::qubit::ty::util::QubitType }; 295 | let qubit_types = match kind { 296 | HandlerKind::Query => quote! { ::std::vec![#qubit_type_base::Query] }, 297 | HandlerKind::Mutation => quote! { ::std::vec![#qubit_type_base::Mutation] }, 298 | HandlerKind::Subscription => { 299 | quote! { ::std::vec![#qubit_type_base::Subscription] } 300 | } 301 | }; 302 | 303 | quote! { 304 | #[allow(non_camel_case_types)] 305 | #visibility struct #name; 306 | impl<#inner_ctx_ty> ::qubit::Handler<#inner_ctx_ty> for #name 307 | where #inner_ctx_ty: 'static + ::std::marker::Send + ::std::marker::Sync + ::std::clone::Clone, 308 | #ctx_ty: ::qubit::FromRequestExtensions<#inner_ctx_ty>, 309 | { 310 | fn get_type() -> ::qubit::HandlerType { 311 | ::qubit::HandlerType { 312 | name: #handler_name_str.to_string(), 313 | signature: #signature, 314 | kind: #kind_str.to_string(), 315 | } 316 | } 317 | 318 | fn register(rpc_builder: ::qubit::RpcBuilder<#inner_ctx_ty>) -> ::qubit::RpcBuilder<#inner_ctx_ty> { 319 | #implementation 320 | 321 | #register_impl 322 | } 323 | 324 | fn export_all_dependencies_to(out_dir: &::std::path::Path) -> ::std::result::Result<::std::vec::Vec<::ts_rs::Dependency>, ::ts_rs::ExportError> { 325 | // Export the return type 326 | let mut dependencies = ::qubit::ty::util::export_with_dependencies::<#return_type>(out_dir)?; 327 | 328 | // Export each of the parameters 329 | #(dependencies.extend(::qubit::ty::util::export_with_dependencies::<#param_tys>(out_dir)?);)* 330 | 331 | ::std::result::Result::Ok(dependencies) 332 | } 333 | 334 | fn qubit_types() -> ::std::vec::Vec<::qubit::ty::util::QubitType> { 335 | #qubit_types 336 | } 337 | } 338 | } 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /crates/qubit-macros/src/handler/options.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use syn::{meta::ParseNestedMeta, Ident, LitStr, Result}; 4 | 5 | /// Handlers can have different variations depending on how they interact with the client. 6 | #[derive(Clone)] 7 | pub enum HandlerKind { 8 | /// Query handlers support the standard request/response pattern, and are safe to be cached. 9 | Query, 10 | 11 | /// Mutation handlers also support the standard request/response pattern, however they should 12 | /// not be cached. 13 | Mutation, 14 | 15 | /// Subscriptions have an initial request, and returns a stream of responses that the client 16 | /// will continue to consume. 17 | Subscription, 18 | } 19 | 20 | impl Display for HandlerKind { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | write!( 23 | f, 24 | "{}", 25 | match self { 26 | HandlerKind::Query => "Query", 27 | HandlerKind::Mutation => "Mutation", 28 | HandlerKind::Subscription => "Subscription", 29 | } 30 | ) 31 | } 32 | } 33 | 34 | /// Options that may be attached to a handler. 35 | pub struct HandlerOptions { 36 | /// The kind of handler. 37 | pub kind: HandlerKind, 38 | 39 | /// Overridden name for the handler. 40 | pub name: Option, 41 | } 42 | 43 | impl HandlerOptions { 44 | /// Build up an instance of [`HandlerOptions`]. 45 | pub fn builder() -> HandlerOptionsBuilder { 46 | HandlerOptionsBuilder::default() 47 | } 48 | } 49 | 50 | /// Builder for [`HandlerOptions`]. Allows for the kind to be empty until it's provided. 51 | #[derive(Default)] 52 | pub struct HandlerOptionsBuilder { 53 | /// Kind of the handler. 54 | pub kind: Option, 55 | 56 | /// Overridden name of the handler. 57 | pub name: Option, 58 | } 59 | 60 | impl HandlerOptionsBuilder { 61 | /// Attempt to parse the handler kind from [`ParseNestedMeta`]. 62 | pub fn parse(&mut self, meta: ParseNestedMeta) -> Result<()> { 63 | if meta.path.is_ident("query") { 64 | self.kind = Some(HandlerKind::Query); 65 | Ok(()) 66 | } else if meta.path.is_ident("mutation") { 67 | self.kind = Some(HandlerKind::Mutation); 68 | Ok(()) 69 | } else if meta.path.is_ident("subscription") { 70 | self.kind = Some(HandlerKind::Subscription); 71 | Ok(()) 72 | } else if meta.path.is_ident("name") { 73 | // Extract name from the attribute 74 | let name = meta.value()?.parse::()?.value(); 75 | 76 | // Create the ident for the handler name 77 | let ident = Ident::new(&name, meta.input.span()); 78 | 79 | self.name = Some(ident); 80 | Ok(()) 81 | } else { 82 | Err(meta.error("unsupported handler property")) 83 | } 84 | } 85 | 86 | /// Consume the builder to produce [`HandlerOptions`]. Will return `None` if the builder was 87 | /// in an invalid state. 88 | pub fn build(self) -> Option { 89 | Some(HandlerOptions { 90 | kind: self.kind?, 91 | name: self.name, 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /crates/qubit-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod handler; 2 | mod macros; 3 | 4 | /// See [`qubit::builder::handler`] for more information. 5 | #[proc_macro_attribute] 6 | pub fn handler( 7 | attr: proc_macro::TokenStream, 8 | input: proc_macro::TokenStream, 9 | ) -> proc_macro::TokenStream { 10 | macros::handler(attr, input) 11 | } 12 | -------------------------------------------------------------------------------- /crates/qubit-macros/src/macros/handler.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::Span; 2 | use syn::{meta, parse_macro_input, spanned::Spanned, Error, Item}; 3 | 4 | use crate::handler::{Handler, HandlerOptions}; 5 | 6 | pub fn handler( 7 | attr: proc_macro::TokenStream, 8 | input: proc_macro::TokenStream, 9 | ) -> proc_macro::TokenStream { 10 | // Extract information from the attribute 11 | let options = { 12 | let mut options_builder = HandlerOptions::builder(); 13 | 14 | let attribute_parser = meta::parser(|meta| options_builder.parse(meta)); 15 | 16 | parse_macro_input!(attr with attribute_parser); 17 | 18 | let Some(options) = options_builder.build() else { 19 | // Produce a compiler error 20 | // TODO: Make it a better erro 21 | return Error::new( 22 | Span::call_site(), 23 | "handler type must be provided (`query`, `mutation`, or `subscription`)", 24 | ) 25 | .into_compile_error() 26 | .into(); 27 | }; 28 | 29 | options 30 | }; 31 | 32 | // Attempt to match as a function 33 | syn::parse::(input) 34 | .and_then(|item| { 35 | if let Item::Fn(handler) = item { 36 | let handler = Handler::parse(handler, options)?; 37 | Ok(handler.into()) 38 | } else { 39 | Err(Error::new(item.span(), "handlers must be a method")) 40 | } 41 | }) 42 | .unwrap_or_else(Error::into_compile_error) 43 | .into() 44 | } 45 | -------------------------------------------------------------------------------- /crates/qubit-macros/src/macros/mod.rs: -------------------------------------------------------------------------------- 1 | mod handler; 2 | 3 | pub use handler::handler; 4 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - [Hello, World](./hello-world) 4 | - [Counter](./counter) 5 | - [Authentication](./authentication) 6 | - [Chat Room (React)](./chat-room-react) 7 | -------------------------------------------------------------------------------- /examples/authentication/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "authentication" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | qubit = { path = "../../" } 8 | 9 | ts-rs = "10.1.0" 10 | serde = { version = "1.0", features = ["derive"] } 11 | futures = "0.3.31" 12 | 13 | tokio = { version = "1.44", features = ["full"] } 14 | axum = "0.8" 15 | hyper = { version = "1.6", features = ["server"] } 16 | 17 | cookie = "0.18" 18 | tower = { version = "0.5.2", features = ["util"] } 19 | -------------------------------------------------------------------------------- /examples/authentication/README.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | In one terminal, start the server. 4 | 5 | ```sh 6 | cargo run 7 | ``` 8 | 9 | In another terminal, install TypeScript dependencies, and run the client! 10 | 11 | ```sh 12 | cd auth-demo 13 | corepack enable # if not enabled 14 | pnpm i 15 | pnpm dev 16 | ``` 17 | -------------------------------------------------------------------------------- /examples/authentication/auth-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Qubit Authentication Demo 9 | 10 | 11 | 12 |

Checkout the console!

13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /examples/authentication/auth-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-demo", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "check": "tsc" 11 | }, 12 | "dependencies": { 13 | "@qubit-rs/client": "workspace:*", 14 | "typescript": "^5.7.2", 15 | "vite": "^5.4.11" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/authentication/auth-demo/src/bindings/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | /* @@@@@@@@@@@@@ & ############### 4 | @@@@@@@@@@@@@@ &&& ############### 5 | @@@@@@@@@@@@@@ &&&&& ############### 6 | ############### &&&&& ############### 7 | ######## Generated by Qubit! ######## 8 | ############### &&&&& ############### 9 | ############### &&&&& @@@@@@@@@@@@@@ 10 | ############### && @@@@@@@@@@@@@@ 11 | ############### & @@@@@@@@@@@@@ */ 12 | 13 | import type { Query } from "@qubit-rs/client"; 14 | 15 | export type { Query } from "@qubit-rs/client"; 16 | 17 | export type QubitServer = { echo_cookie: Query<[], string>, secret_endpoint: Query<[], string> }; -------------------------------------------------------------------------------- /examples/authentication/auth-demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { build_client, ws } from "@qubit-rs/client"; 2 | import type { QubitServer } from "./bindings"; 3 | 4 | async function main() { 5 | console.log("----- Beginning Authentication Flow -----"); 6 | 7 | function build_api() { 8 | return build_client(ws(`ws://${window.location.host}/rpc`)); 9 | } 10 | 11 | document.cookie += "qubit-auth=;expires=Thu, 01 Jan 1970 00:00:01 GMT;SameSite=Lax"; 12 | 13 | // Make some un-authenticated requests 14 | { 15 | const api = build_api(); 16 | 17 | console.log("Cookie echo from server:", await api.echo_cookie.query()); 18 | 19 | // Can we get the secret? 20 | await api.secret_endpoint.query().catch((e) => { 21 | console.error("Error whilst accessing secret:", e); 22 | }); 23 | } 24 | // Authenticate with the API 25 | await fetch("/login", { 26 | method: "POST", 27 | body: new URLSearchParams({ username: "user", password: "password" }), 28 | headers: { 29 | "Content-Type": "application/x-www-form-urlencoded", 30 | }, 31 | }); 32 | 33 | console.log("Successfully authenticated with the API"); 34 | 35 | { 36 | // Re-create the API now that we're authenticated 37 | const api = build_api(); 38 | console.log("Cookie is:", await api.echo_cookie.query()); 39 | console.log("Can we get the secret?", await api.secret_endpoint.query()); 40 | } 41 | 42 | console.log("----- Ending Authentication Flow -----"); 43 | } 44 | 45 | main(); 46 | -------------------------------------------------------------------------------- /examples/authentication/auth-demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/authentication/auth-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /examples/authentication/auth-demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | server: { 5 | proxy: { 6 | "/rpc": { 7 | target: "http://localhost:9944", 8 | ws: true, 9 | }, 10 | "/login": { 11 | target: "http://localhost:9944", 12 | ws: true, 13 | }, 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /examples/authentication/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use axum::{ 4 | response::{IntoResponse, Response}, 5 | routing::post, 6 | Form, 7 | }; 8 | use cookie::Cookie; 9 | use hyper::{header::SET_COOKIE, StatusCode}; 10 | use qubit::{handler, ErrorCode, Extensions, FromRequestExtensions, Router, RpcError}; 11 | use serde::Deserialize; 12 | use tokio::net::TcpListener; 13 | use tower::ServiceBuilder; 14 | 15 | const COOKIE_NAME: &str = "qubit-auth"; 16 | 17 | /// Don't do this 18 | const USERNAME: &str = "user"; 19 | const PASSWORD: &str = "password"; 20 | 21 | #[derive(Deserialize)] 22 | struct LoginForm { 23 | username: String, 24 | password: String, 25 | } 26 | 27 | /// Axum endpoint to handle form login 28 | async fn login(Form(login_form): Form) -> impl IntoResponse { 29 | if login_form.username == USERNAME && login_form.password == PASSWORD { 30 | Response::builder() 31 | .status(StatusCode::OK) 32 | .header( 33 | SET_COOKIE, 34 | Cookie::build((COOKIE_NAME, "abc-123")) 35 | .path("/") 36 | .same_site(cookie::SameSite::Lax) 37 | .build() 38 | .to_string(), 39 | ) 40 | .body("login success".to_string()) 41 | .unwrap() 42 | } else { 43 | Response::builder() 44 | .status(StatusCode::UNAUTHORIZED) 45 | .body("login fail".to_string()) 46 | .unwrap() 47 | } 48 | } 49 | 50 | /// A simple context that optionally contains a cookie. 51 | struct ReqCtx { 52 | auth_cookie: Option>, 53 | } 54 | 55 | impl FromRequestExtensions<()> for ReqCtx { 56 | async fn from_request_extensions( 57 | _ctx: (), 58 | mut extensions: Extensions, 59 | ) -> Result { 60 | Ok(Self { 61 | // Extract the auth cookie from the extensions 62 | auth_cookie: extensions.remove(), 63 | }) 64 | } 65 | } 66 | 67 | /// Another context, used to represent an authenticated request. Will act as a middleware, as a 68 | /// handler that relies on this context will only be run if it can be successfully generated. 69 | struct AuthCtx { 70 | user: String, 71 | } 72 | 73 | impl FromRequestExtensions<()> for AuthCtx { 74 | async fn from_request_extensions(_ctx: (), extensions: Extensions) -> Result { 75 | // Build up the `ReqCtx` 76 | let req_ctx = ReqCtx::from_request_extensions((), extensions).await?; 77 | 78 | // Enforce that the auth cookie is present 79 | let Some(cookie) = req_ctx.auth_cookie else { 80 | // Return an error to cancel the request if it's not 81 | return Err(RpcError { 82 | code: ErrorCode::ServerError(-32001), 83 | message: "Authentication required".to_string(), 84 | data: None, 85 | }); 86 | }; 87 | 88 | // Otherwise, progress using this new context. 89 | Ok(AuthCtx { 90 | user: cookie.value().to_string(), 91 | }) 92 | } 93 | } 94 | 95 | /// Handler takes in [`ReqCtx`], so will run regardless of authentication status. 96 | #[handler(query)] 97 | async fn echo_cookie(ctx: ReqCtx) -> String { 98 | if let Some(cookie) = ctx.auth_cookie { 99 | format!("A cookie is set: {cookie}") 100 | } else { 101 | "No cookie is set".to_string() 102 | } 103 | } 104 | 105 | /// Handler takes in [`AuthCtx`], so will only run if the middleware can be properly constructed. 106 | #[handler(query)] 107 | async fn secret_endpoint(ctx: AuthCtx) -> String { 108 | format!("Welcome {}. The secret is: `super_secret`", ctx.user) 109 | } 110 | 111 | #[tokio::main] 112 | async fn main() { 113 | // Create the qubit router 114 | let router = Router::new().handler(echo_cookie).handler(secret_endpoint); 115 | router.write_bindings_to_dir("./auth-demo/src/bindings"); 116 | 117 | let (qubit_service, handle) = router.to_service(()); 118 | 119 | let qubit_service = ServiceBuilder::new() 120 | .map_request(|mut req: hyper::Request<_>| { 121 | // Extract a certain cookie from the request 122 | let auth_cookie = req 123 | // Pull out the request headers 124 | .headers() 125 | // Select the cookie header 126 | .get(hyper::header::COOKIE) 127 | // Get the value of the header 128 | .and_then(|cookie| cookie.to_str().ok()) 129 | .and_then(|cookie_header| { 130 | // Parse the cookie header 131 | Cookie::split_parse(cookie_header.to_string()) 132 | .filter_map(|cookie| cookie.ok()) 133 | // Attempt to find a specific cookie that matches the cookie we want 134 | .find(|cookie| cookie.name() == COOKIE_NAME) 135 | }); 136 | 137 | // If we find the auth cookie, save it to the request extension 138 | if let Some(auth_cookie) = auth_cookie { 139 | req.extensions_mut().insert(auth_cookie); 140 | } 141 | 142 | req 143 | }) 144 | .service(qubit_service); 145 | 146 | // Once the handle is dropped the server will automatically shutdown, so leak it to keep it 147 | // running. Don't actually do this. 148 | Box::leak(Box::new(handle)); 149 | 150 | // Create a simple axum router with the different implementations attached 151 | let axum_router = axum::Router::new() 152 | .route("/login", post(login)) 153 | .nest_service("/rpc", qubit_service); 154 | 155 | // Start a Hyper server 156 | println!("Listening at 127.0.0.1:9944"); 157 | axum::serve( 158 | TcpListener::bind(&SocketAddr::from(([127, 0, 0, 1], 9944))) 159 | .await 160 | .unwrap(), 161 | axum_router, 162 | ) 163 | .await 164 | .unwrap(); 165 | } 166 | -------------------------------------------------------------------------------- /examples/chaos/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chaos" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | qubit = { path = "../../" } 10 | axum = "0.8" 11 | hyper = { version = "1.6", features = ["server"] } 12 | futures = "0.3.31" 13 | http = "1.3.1" 14 | serde = { version = "1.0.219", features = ["derive"] } 15 | serde_json = "1.0.140" 16 | tokio = { version = "1.44.2", features = ["rt", "rt-multi-thread"] } 17 | tower = { version = "0.5.2", features = ["util"] } 18 | ts-rs = "10.1.0" 19 | -------------------------------------------------------------------------------- /examples/chaos/bindings/Metadata.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type Metadata = { param_a: string, param_b: number, param_c: boolean, more_metadata: Metadata | null, }; 4 | -------------------------------------------------------------------------------- /examples/chaos/bindings/MyEnum.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { NestedStruct } from "./NestedStruct"; 3 | 4 | export type MyEnum = "A" | { "B": number } | { "C": { field: number, } } | { "D": NestedStruct }; 5 | -------------------------------------------------------------------------------- /examples/chaos/bindings/NestedStruct.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type NestedStruct = { a: number, b: boolean, }; 4 | -------------------------------------------------------------------------------- /examples/chaos/bindings/Test.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type Test = { a: number, b: boolean, }; 4 | -------------------------------------------------------------------------------- /examples/chaos/bindings/UniqueType.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type UniqueType = { value: number, }; 4 | -------------------------------------------------------------------------------- /examples/chaos/bindings/User.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { Metadata } from "./Metadata"; 3 | 4 | export type User = { name: string, email: string, age: number, metadata: Metadata, }; 5 | -------------------------------------------------------------------------------- /examples/chaos/bindings/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | /* @@@@@@@@@@@@@ & ############### 4 | @@@@@@@@@@@@@@ &&& ############### 5 | @@@@@@@@@@@@@@ &&&&& ############### 6 | ############### &&&&& ############### 7 | ######## Generated by Qubit! ######## 8 | ############### &&&&& ############### 9 | ############### &&&&& @@@@@@@@@@@@@@ 10 | ############### && @@@@@@@@@@@@@@ 11 | ############### & @@@@@@@@@@@@@ */ 12 | 13 | import type { Query } from "@qubit-rs/client"; 14 | import type { Mutation } from "@qubit-rs/client"; 15 | import type { Subscription } from "@qubit-rs/client"; 16 | import type { NestedStruct } from "./NestedStruct.ts"; 17 | import type { MyEnum } from "./MyEnum.ts"; 18 | import type { UniqueType } from "./UniqueType.ts"; 19 | import type { Metadata } from "./Metadata.ts"; 20 | import type { User } from "./User.ts"; 21 | import type { Test } from "./Test.ts"; 22 | 23 | export type { Query } from "@qubit-rs/client"; 24 | export type { Mutation } from "@qubit-rs/client"; 25 | export type { Subscription } from "@qubit-rs/client"; 26 | export type { NestedStruct } from "./NestedStruct.ts"; 27 | export type { MyEnum } from "./MyEnum.ts"; 28 | export type { UniqueType } from "./UniqueType.ts"; 29 | export type { Metadata } from "./Metadata.ts"; 30 | export type { User } from "./User.ts"; 31 | export type { Test } from "./Test.ts"; 32 | 33 | export type QubitServer = { version: Query<[], string>, count: Mutation<[], number>, countdown: Subscription<[min: number, max: number, ], number>, array: Query<[], Array>, enum_test: Query<[], MyEnum>, array_type: Query<[], Array>, user: { someHandler: Query<[_id: string, ], User>, create: Mutation<[name: string, email: string, age: number, ], User>, list: Query<[], Array>, asdf: Query<[], null> } }; -------------------------------------------------------------------------------- /examples/chaos/index.ts: -------------------------------------------------------------------------------- 1 | import { http, build_client } from "@qubit-rs/client"; 2 | import type { QubitServer } from "./bindings"; 3 | 4 | const client = build_client(http("http://localhost:9944/rpc")); 5 | 6 | client.version 7 | .query() 8 | .then((version) => console.log({ version })) 9 | .catch(console.error); 10 | client.user.someHandler 11 | .query("test") 12 | .then((user) => console.log(user)) 13 | .catch(console.error); 14 | client.count 15 | .mutate() 16 | .then((value) => console.log({ value })) 17 | .catch(console.error); 18 | -------------------------------------------------------------------------------- /examples/chaos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chaos", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node ./index.ts", 8 | "check": "tsc" 9 | }, 10 | "dependencies": { 11 | "@qubit-rs/client": "workspace:*", 12 | "@types/node": "^20.17.9", 13 | "ts-node": "^10.9.2", 14 | "typescript": "^5.7.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/chaos/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::SocketAddr, 3 | sync::{ 4 | atomic::{AtomicUsize, Ordering}, 5 | Arc, 6 | }, 7 | time::Duration, 8 | }; 9 | 10 | use futures::{stream, Stream, StreamExt}; 11 | use qubit::*; 12 | 13 | use axum::routing::get; 14 | use serde::{Deserialize, Serialize}; 15 | use tokio::net::TcpListener; 16 | use ts_rs::TS; 17 | 18 | #[derive(ts_rs::TS, Clone, Serialize, Deserialize, Debug)] 19 | pub struct Metadata { 20 | param_a: String, 21 | param_b: u32, 22 | param_c: bool, 23 | 24 | more_metadata: Option>, 25 | } 26 | 27 | #[derive(ts_rs::TS, Clone, Serialize, Deserialize, Debug)] 28 | pub struct User { 29 | name: String, 30 | email: String, 31 | age: u32, 32 | 33 | metadata: Metadata, 34 | } 35 | 36 | #[derive(ts_rs::TS, Clone, Serialize, Deserialize, Debug)] 37 | pub struct Test { 38 | a: usize, 39 | b: bool, 40 | } 41 | 42 | #[derive(Clone, Default)] 43 | #[allow(dead_code)] 44 | pub struct AppCtx { 45 | database: bool, 46 | log: String, 47 | 48 | count: Arc, 49 | } 50 | 51 | mod user { 52 | use super::*; 53 | 54 | #[derive(Clone)] 55 | #[allow(dead_code)] 56 | pub struct UserCtx { 57 | app_ctx: AppCtx, 58 | user: u32, 59 | } 60 | 61 | impl FromRequestExtensions for UserCtx { 62 | async fn from_request_extensions( 63 | ctx: AppCtx, 64 | _extensions: Extensions, 65 | ) -> Result { 66 | Ok(UserCtx { 67 | app_ctx: ctx, 68 | user: 0, 69 | }) 70 | } 71 | } 72 | 73 | pub fn create_router() -> Router { 74 | Router::new() 75 | .handler(someHandler) 76 | .handler(create) 77 | .handler(list) 78 | .handler(nested::asdf) 79 | } 80 | 81 | #[handler(query, name = "someHandler")] 82 | async fn get(_ctx: AppCtx, _id: String) -> User { 83 | User { 84 | name: "some user".to_string(), 85 | email: "email@example.com".to_string(), 86 | age: 100, 87 | metadata: Metadata { 88 | param_a: String::new(), 89 | param_b: 123, 90 | param_c: true, 91 | 92 | more_metadata: None, 93 | }, 94 | } 95 | } 96 | 97 | mod nested { 98 | use super::*; 99 | 100 | #[handler(query)] 101 | pub async fn asdf() { 102 | todo!() 103 | } 104 | } 105 | 106 | #[handler(mutation)] 107 | async fn create(_ctx: AppCtx, name: String, email: String, age: u32) -> User { 108 | println!("creating user: {name}"); 109 | 110 | User { 111 | name, 112 | email, 113 | age, 114 | metadata: Metadata { 115 | param_a: String::new(), 116 | param_b: 123, 117 | param_c: true, 118 | 119 | more_metadata: None, 120 | }, 121 | } 122 | } 123 | 124 | #[handler(query)] 125 | async fn list() -> Vec { 126 | todo!() 127 | } 128 | } 129 | 130 | struct CountCtx { 131 | count: Arc, 132 | } 133 | 134 | impl FromRequestExtensions for CountCtx { 135 | async fn from_request_extensions( 136 | ctx: AppCtx, 137 | _extensions: Extensions, 138 | ) -> Result { 139 | Ok(Self { 140 | count: ctx.count.clone(), 141 | }) 142 | } 143 | } 144 | 145 | #[handler(mutation)] 146 | async fn count(ctx: CountCtx) -> usize { 147 | ctx.count.fetch_add(1, Ordering::Relaxed) 148 | } 149 | 150 | #[handler(subscription)] 151 | async fn countdown(_ctx: CountCtx, min: usize, max: usize) -> impl Stream { 152 | stream::iter(min..=max).then(|n| async move { 153 | tokio::time::sleep(Duration::from_secs(1)).await; 154 | 155 | n 156 | }) 157 | } 158 | 159 | #[handler(query)] 160 | async fn version() -> String { 161 | "v1.0.0".to_string() 162 | } 163 | 164 | #[handler(query)] 165 | async fn array() -> Vec { 166 | vec!["a".to_string(), "b".to_string(), "c".to_string()] 167 | } 168 | 169 | #[derive(Clone, Serialize, TS)] 170 | struct UniqueType { 171 | value: usize, 172 | } 173 | 174 | #[handler(query)] 175 | async fn array_type() -> Vec { 176 | vec![] 177 | } 178 | 179 | #[derive(Clone, Serialize, TS)] 180 | struct NestedStruct { 181 | a: f32, 182 | b: bool, 183 | } 184 | 185 | #[derive(Clone, Serialize, TS)] 186 | #[allow(dead_code)] 187 | enum MyEnum { 188 | A, 189 | B(u8), 190 | C { field: u8 }, 191 | D(NestedStruct), 192 | } 193 | #[handler(query)] 194 | async fn enum_test() -> MyEnum { 195 | MyEnum::B(10) 196 | } 197 | 198 | #[tokio::main] 199 | async fn main() { 200 | // Build up the router 201 | let app = Router::new() 202 | .handler(version) 203 | .handler(count) 204 | .handler(countdown) 205 | .handler(array) 206 | .handler(enum_test) 207 | .handler(array_type) 208 | .nest("user", user::create_router()); 209 | 210 | // Save the router's bindings 211 | app.write_bindings_to_dir("./bindings"); 212 | 213 | // Create a service and handle for the app 214 | let (app_service, app_handle) = app.to_service(AppCtx::default()); 215 | 216 | // Set up the axum router 217 | let router = axum::Router::<()>::new() 218 | .route("/", get(|| async { "working" })) 219 | .nest_service("/rpc", app_service); 220 | 221 | // Start the server 222 | axum::serve( 223 | TcpListener::bind(&SocketAddr::from(([127, 0, 0, 1], 9944))) 224 | .await 225 | .unwrap(), 226 | router, 227 | ) 228 | .await 229 | .unwrap(); 230 | 231 | // Once the server has stopped, ensure that the app is shutdown 232 | app_handle.stop().unwrap(); 233 | } 234 | -------------------------------------------------------------------------------- /examples/chaos/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "node16", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | /* Bundler mode */ 9 | "moduleResolution": "node16", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | /* Linting */ 15 | "strict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noFallthroughCasesInSwitch": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/chat-room-react/README.md: -------------------------------------------------------------------------------- 1 | > [!caution] 2 | > This example is in the process of being updated, and currently does not work. 3 | 4 | # Chat Room (React) 5 | 6 | An example of using Qubit with React (and Vite) to build a simple live chat room with websockets. 7 | 8 | ## Local Development 9 | 10 | 1. Enable corepack with `corepack enable` if not enabled 11 | 1. Install dependencies with `pnpm i` 12 | 2. Run `pnpm dev` to run the Rust API and Vite frontend together 13 | -------------------------------------------------------------------------------- /examples/chat-room-react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chat Room (React) 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /examples/chat-room-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat_room_react", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "conc -rk \"cargo watch -C ./src-rust -x run -w ./src -w Cargo.toml\" \"vite --clearScreen=false\"", 8 | "build": "tsc && vite build", 9 | "check": "tsc" 10 | }, 11 | "dependencies": { 12 | "@qubit-rs/client": "workspace:*", 13 | "@tanstack/react-query": "^5.62.0", 14 | "@types/react": "^18.3.12", 15 | "@types/react-dom": "^18.3.1", 16 | "@vitejs/plugin-react": "^4.3.4", 17 | "concurrently": "^8.2.2", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "typescript": "^5.7.2", 21 | "vite": "^5.4.11" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/chat-room-react/src-rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chat-room-react" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | qubit = { path = "../../../" } 8 | 9 | ts-rs = "10.1.0" 10 | serde = { version = "1.0", features = ["derive"] } 11 | futures = "0.3.31" 12 | 13 | tokio = { version = "1.44", features = ["full"] } 14 | axum = "0.8" 15 | hyper = { version = "1.6", features = ["server"] } 16 | 17 | rand = "0.8.5" 18 | -------------------------------------------------------------------------------- /examples/chat-room-react/src-rust/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use futures::Stream; 4 | use manager::{ChatMessage, Client, Manager}; 5 | use qubit::{handler, Router}; 6 | use rand::{thread_rng, Rng}; 7 | use tokio::net::TcpListener; 8 | 9 | mod manager; 10 | 11 | #[derive(Clone)] 12 | struct Ctx { 13 | client: Client, 14 | name: char, 15 | } 16 | 17 | #[handler(query)] 18 | async fn get_name(ctx: Ctx) -> char { 19 | ctx.name 20 | } 21 | 22 | #[handler(mutation)] 23 | async fn send_message(ctx: Ctx, message: String) { 24 | ctx.client.send_message(ctx.name, message).await 25 | } 26 | 27 | #[handler(subscription)] 28 | async fn list_online(ctx: Ctx) -> impl Stream> { 29 | ctx.client.stream_online().await 30 | } 31 | 32 | #[handler(subscription)] 33 | async fn list_messages(ctx: Ctx) -> impl Stream> { 34 | ctx.client.stream_messages().await 35 | } 36 | 37 | #[tokio::main] 38 | async fn main() { 39 | // Construct the qubit router 40 | let router = Router::new() 41 | .handler(get_name) 42 | .handler(send_message) 43 | .handler(list_online) 44 | .handler(list_messages); 45 | 46 | // Save the type 47 | router.write_bindings_to_dir("../src/bindings"); 48 | println!("Successfully wrote server bindings to `./bindings`"); 49 | 50 | // Create service and handle 51 | let client = Manager::start(); 52 | let (qubit_service, qubit_handle) = router.to_service( 53 | Ctx { 54 | client, name: '🦀' 55 | }, // move |_| { 56 | // let client = client.clone(); 57 | // let name = random_emoji(); 58 | // async move { 59 | // client.join(name).await; 60 | // Ctx { client, name } 61 | // } 62 | // }, 63 | // |ctx| async move { 64 | // ctx.client.leave(ctx.name).await; 65 | // }, 66 | ); 67 | 68 | // Nest into an Axum rouer 69 | let axum_router = axum::Router::<()>::new().nest_service("/rpc", qubit_service); 70 | 71 | // Start a Hyper server 72 | println!("Listening at 127.0.0.1:9944"); 73 | axum::serve( 74 | TcpListener::bind(&SocketAddr::from(([127, 0, 0, 1], 9944))) 75 | .await 76 | .unwrap(), 77 | axum_router, 78 | ) 79 | .await 80 | .unwrap(); 81 | 82 | // Shutdown Qubit 83 | qubit_handle.stop().unwrap(); 84 | } 85 | 86 | #[allow(dead_code)] 87 | fn random_emoji() -> char { 88 | char::from_u32(thread_rng().gen_range(0x1F600..0x1F64F)).unwrap_or('🦀') 89 | } 90 | -------------------------------------------------------------------------------- /examples/chat-room-react/src-rust/src/manager.rs: -------------------------------------------------------------------------------- 1 | use futures::{stream, Stream}; 2 | use serde::Serialize; 3 | use tokio::sync::mpsc; 4 | use ts_rs::TS; 5 | 6 | #[derive(Clone, Serialize, TS)] 7 | pub struct ChatMessage { 8 | user: char, 9 | content: String, 10 | } 11 | 12 | #[derive(Clone, Default)] 13 | pub struct Manager { 14 | users: Vec, 15 | messages: Vec, 16 | subscriptions: Subscriptions, 17 | } 18 | 19 | pub enum Message { 20 | /// Add a user to the list 21 | #[allow(dead_code)] 22 | Join { name: char }, 23 | 24 | /// Remove a user from the list 25 | #[allow(dead_code)] 26 | Leave { name: char }, 27 | 28 | /// Send a chat message 29 | Send { user: char, message: String }, 30 | 31 | /// Subscribe to a list of online users 32 | RegisterOnline { tx: mpsc::Sender> }, 33 | 34 | /// Subscribe to a list of messages 35 | RegisterMessages { tx: mpsc::Sender> }, 36 | } 37 | 38 | #[derive(Clone)] 39 | pub struct Client { 40 | tx: mpsc::Sender, 41 | } 42 | 43 | impl Client { 44 | pub fn new(tx: mpsc::Sender) -> Self { 45 | Self { tx } 46 | } 47 | 48 | #[allow(dead_code)] 49 | pub async fn join(&self, name: char) { 50 | self.tx.send(Message::Join { name }).await.unwrap(); 51 | } 52 | 53 | #[allow(dead_code)] 54 | pub async fn leave(&self, name: char) { 55 | self.tx.send(Message::Leave { name }).await.unwrap(); 56 | } 57 | 58 | pub async fn send_message(&self, user: char, message: String) { 59 | self.tx.send(Message::Send { user, message }).await.unwrap(); 60 | } 61 | 62 | pub async fn stream_online(&self) -> impl Stream> { 63 | let (tx, rx) = mpsc::channel(10); 64 | self.tx.send(Message::RegisterOnline { tx }).await.unwrap(); 65 | stream::unfold(rx, |mut rx| async move { Some((rx.recv().await?, rx)) }) 66 | } 67 | 68 | pub async fn stream_messages(&self) -> impl Stream> { 69 | let (tx, rx) = mpsc::channel(10); 70 | self.tx 71 | .send(Message::RegisterMessages { tx }) 72 | .await 73 | .unwrap(); 74 | stream::unfold(rx, |mut rx| async move { Some((rx.recv().await?, rx)) }) 75 | } 76 | } 77 | 78 | impl Manager { 79 | pub fn start() -> Client { 80 | let (tx, mut rx) = mpsc::channel(10); 81 | 82 | tokio::spawn(async move { 83 | let mut manager = Manager::default(); 84 | 85 | while let Some(message) = rx.recv().await { 86 | manager.process(message).await; 87 | } 88 | }); 89 | 90 | Client::new(tx) 91 | } 92 | 93 | async fn process(&mut self, message: Message) { 94 | match message { 95 | Message::Join { name } => { 96 | self.join(name).await; 97 | } 98 | Message::Leave { name } => { 99 | self.leave(name).await; 100 | } 101 | Message::Send { user, message } => { 102 | self.send_message(user, message).await; 103 | } 104 | Message::RegisterOnline { tx } => { 105 | self.register_online_subscription(tx).await; 106 | } 107 | Message::RegisterMessages { tx } => { 108 | self.register_messages_subscription(tx).await; 109 | } 110 | } 111 | } 112 | 113 | async fn join(&mut self, name: char) { 114 | self.users.push(name); 115 | self.subscriptions.update_register_online(&self.users).await; 116 | } 117 | 118 | async fn leave(&mut self, name: char) { 119 | self.users.retain(|c| *c != name); 120 | self.subscriptions.update_register_online(&self.users).await; 121 | } 122 | 123 | async fn send_message(&mut self, user: char, message: String) { 124 | self.messages.push(ChatMessage { 125 | user, 126 | content: message, 127 | }); 128 | self.subscriptions 129 | .update_register_messages(&self.messages) 130 | .await; 131 | } 132 | 133 | async fn register_online_subscription(&mut self, tx: mpsc::Sender>) { 134 | self.subscriptions 135 | .register_online(tx, self.users.clone()) 136 | .await; 137 | } 138 | 139 | async fn register_messages_subscription(&mut self, tx: mpsc::Sender>) { 140 | self.subscriptions 141 | .register_messages(tx, self.messages.clone()) 142 | .await; 143 | } 144 | } 145 | 146 | #[derive(Default, Clone)] 147 | struct Subscriptions { 148 | online: Vec>>, 149 | messages: Vec>>, 150 | } 151 | 152 | impl Subscriptions { 153 | async fn register_online(&mut self, tx: mpsc::Sender>, users: Vec) { 154 | if tx.send(users).await.is_ok() { 155 | self.online.push(tx); 156 | } 157 | } 158 | 159 | async fn update_register_online(&mut self, users: &[char]) { 160 | self.online.retain(|tx| !tx.is_closed()); 161 | for tx in self.online.iter() { 162 | tx.send(users.to_vec()).await.unwrap(); 163 | } 164 | } 165 | 166 | async fn register_messages( 167 | &mut self, 168 | tx: mpsc::Sender>, 169 | messages: Vec, 170 | ) { 171 | if tx.send(messages).await.is_ok() { 172 | self.messages.push(tx); 173 | } 174 | } 175 | 176 | async fn update_register_messages(&mut self, messages: &[ChatMessage]) { 177 | self.messages.retain(|tx| !tx.is_closed()); 178 | for tx in self.messages.iter() { 179 | tx.send(messages.to_vec()).await.unwrap(); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { History } from "./components/History"; 3 | import { Input } from "./components/Input"; 4 | import { Online } from "./components/Online"; 5 | 6 | const queryClient = new QueryClient({ 7 | defaultOptions: { 8 | queries: { refetchOnWindowFocus: false }, 9 | }, 10 | }); 11 | 12 | export const App = () => { 13 | return ( 14 | 15 |

Chat Room

16 | 17 |
18 | 19 | 20 | 21 |
22 | 23 | 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/api.ts: -------------------------------------------------------------------------------- 1 | import { build_client, ws } from "@qubit-rs/client"; 2 | import type { QubitServer } from "./bindings"; 3 | 4 | export const api = build_client(ws("ws://localhost:9944/rpc")); 5 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/bindings/ChatMessage.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type ChatMessage = { user: string, content: string, }; 4 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/bindings/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | /* @@@@@@@@@@@@@ & ############### 4 | @@@@@@@@@@@@@@ &&& ############### 5 | @@@@@@@@@@@@@@ &&&&& ############### 6 | ############### &&&&& ############### 7 | ######## Generated by Qubit! ######## 8 | ############### &&&&& ############### 9 | ############### &&&&& @@@@@@@@@@@@@@ 10 | ############### && @@@@@@@@@@@@@@ 11 | ############### & @@@@@@@@@@@@@ */ 12 | 13 | import type { Query } from "@qubit-rs/client"; 14 | import type { Mutation } from "@qubit-rs/client"; 15 | import type { Subscription } from "@qubit-rs/client"; 16 | import type { ChatMessage } from "./ChatMessage.ts"; 17 | 18 | export type { Query } from "@qubit-rs/client"; 19 | export type { Mutation } from "@qubit-rs/client"; 20 | export type { Subscription } from "@qubit-rs/client"; 21 | export type { ChatMessage } from "./ChatMessage.ts"; 22 | 23 | export type QubitServer = { get_name: Query<[], string>, send_message: Mutation<[message: string, ], null>, list_online: Subscription<[], Array>, list_messages: Subscription<[], Array> }; -------------------------------------------------------------------------------- /examples/chat-room-react/src/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | export const Avatar = ({ emoji }: { emoji: string }) => { 2 | return ( 3 |
4 |
{emoji}
5 |
6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/components/History.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { api } from "../api"; 4 | import type { ChatMessage } from "../bindings/ChatMessage"; 5 | import { Message } from "./Message"; 6 | 7 | export const History = () => { 8 | const containerRef = useRef(null); 9 | 10 | const { data: name } = useQuery({ 11 | queryKey: ["name"], 12 | queryFn: () => api.get_name.query(), 13 | }); 14 | 15 | const [messages, setMessages] = useState([]); 16 | 17 | useEffect(() => api.list_messages.subscribe({ on_data: setMessages }), []); 18 | 19 | // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when messages changes 20 | useEffect(() => { 21 | containerRef.current?.scrollTo({ 22 | top: containerRef.current.scrollHeight, 23 | behavior: "smooth", 24 | }); 25 | }, [messages]); 26 | 27 | return ( 28 | 29 | {messages.map((message, i) => ( 30 | 37 | ))} 38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { useState } from "react"; 3 | import { api } from "../api"; 4 | import { Avatar } from "./Avatar"; 5 | 6 | export const Input = () => { 7 | const [value, setValue] = useState(""); 8 | 9 | const { data: name } = useQuery({ 10 | queryKey: ["name"], 11 | queryFn: () => api.get_name.query(), 12 | }); 13 | 14 | return ( 15 |
{ 17 | e.preventDefault(); 18 | const message = value.trim(); 19 | if (value.length > 0) { 20 | api.send_message.mutate(message); 21 | setValue(""); 22 | } 23 | }} 24 | > 25 | 26 | setValue(e.target.value)} /> 27 | 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "./Avatar"; 2 | 3 | export const Message = ({ emoji, message, you }: { emoji: string; message: string; you?: boolean }) => { 4 | return ( 5 |
6 | 7 | {message} 8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/components/Online.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { api } from "../api"; 3 | import { Avatar } from "./Avatar"; 4 | 5 | export const Online = () => { 6 | const [users, setUsers] = useState([]); 7 | 8 | useEffect(() => api.list_online.subscribe({ on_data: setUsers }), []); 9 | 10 | return ( 11 |
12 |

Online ({users.length})

13 | 14 |
15 | {users.map((user) => ( 16 | 17 | ))} 18 |
19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: sans-serif; 3 | color-scheme: light dark; 4 | } 5 | 6 | body { 7 | margin: 0 0 2rem; 8 | } 9 | 10 | #root { 11 | width: 500px; 12 | max-width: calc(100% - 20px); 13 | margin: auto; 14 | } 15 | 16 | main { 17 | border: 1px solid currentColor; 18 | border-radius: .5rem; 19 | overflow: hidden; 20 | } 21 | 22 | .avatar { 23 | height: 2em; 24 | width: 2em; 25 | position: relative; 26 | overflow: hidden; 27 | border-radius: .2rem; 28 | user-select: none; 29 | 30 | div { 31 | font-size: 1.5em; 32 | position: absolute; 33 | inset: 0; 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | text-shadow: 0 0 .4em #00000070; 38 | } 39 | 40 | &::before { 41 | content: attr(data-emoji); 42 | position: absolute; 43 | inset: 0; 44 | display: flex; 45 | align-items: center; 46 | justify-content: center; 47 | scale: 5; 48 | z-index: -1; 49 | filter: blur(3px); 50 | } 51 | } 52 | 53 | main output { 54 | display: flex; 55 | flex-direction: column; 56 | padding: .5rem; 57 | height: min(500px, 60dvh); 58 | overflow-y: auto; 59 | gap: .5rem; 60 | 61 | .message { 62 | display: flex; 63 | align-items: end; 64 | gap: .5rem; 65 | 66 | span { 67 | display: block; 68 | padding: .5rem .7rem; 69 | border: 1px solid currentColor; 70 | border-radius: .5rem; 71 | border-bottom-left-radius: 0; 72 | max-width: 70%; 73 | } 74 | 75 | &.you { 76 | flex-direction: row-reverse; 77 | 78 | span { 79 | background: CanvasText; 80 | color: Canvas; 81 | border-bottom-left-radius: .5rem; 82 | border-bottom-right-radius: 0; 83 | } 84 | } 85 | } 86 | } 87 | 88 | main form { 89 | border-top: 1px solid currentColor; 90 | display: flex; 91 | 92 | .avatar { 93 | font-size: 1.5rem; 94 | border-radius: 0; 95 | } 96 | 97 | input { 98 | appearance: none; 99 | border: 0; 100 | margin: 0; 101 | padding: 0 1rem; 102 | font: inherit; 103 | line-height: 3rem; 104 | outline: none; 105 | flex: 1; 106 | width: auto; 107 | } 108 | 109 | button { 110 | appearance: none; 111 | border: 0; 112 | background: CanvasText; 113 | color: Canvas; 114 | font: inherit; 115 | padding: 0 .5rem; 116 | margin: 0; 117 | font-size: .8rem; 118 | font-weight: 600; 119 | cursor: pointer; 120 | } 121 | } 122 | 123 | h2 { 124 | font-size: 1.2rem; 125 | margin-top: 2rem; 126 | } 127 | 128 | #online { 129 | display: flex; 130 | flex-wrap: wrap; 131 | gap: .5rem; 132 | } 133 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { App } from "./App.tsx"; 3 | import "./index.css"; 4 | 5 | createRoot(document.getElementById("root") as Element).render(); 6 | -------------------------------------------------------------------------------- /examples/chat-room-react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/chat-room-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /examples/chat-room-react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /examples/chat-room-react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /examples/counter/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "counter" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | qubit = { path = "../../" } 8 | 9 | ts-rs = "10.1.0" 10 | serde = { version = "1.0", features = ["derive"] } 11 | futures = "0.3.31" 12 | 13 | tokio = { version = "1.44", features = ["full"] } 14 | axum = "0.8" 15 | hyper = { version = "1.6", features = ["server"] } 16 | -------------------------------------------------------------------------------- /examples/counter/README.md: -------------------------------------------------------------------------------- 1 | # Counter 2 | 3 | This service includes handlers that share some state, accept parameters, return values, and even 4 | subscriptions! 5 | 6 | In one terminal, start the server. 7 | 8 | ```sh 9 | cargo run 10 | ``` 11 | 12 | In another terminal, install TypeScript dependencies, and run the client! 13 | 14 | ```sh 15 | corepack enable # if not enabled 16 | pnpm i 17 | pnpm start 18 | ``` 19 | -------------------------------------------------------------------------------- /examples/counter/bindings/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | /* @@@@@@@@@@@@@ & ############### 4 | @@@@@@@@@@@@@@ &&& ############### 5 | @@@@@@@@@@@@@@ &&&&& ############### 6 | ############### &&&&& ############### 7 | ######## Generated by Qubit! ######## 8 | ############### &&&&& ############### 9 | ############### &&&&& @@@@@@@@@@@@@@ 10 | ############### && @@@@@@@@@@@@@@ 11 | ############### & @@@@@@@@@@@@@ */ 12 | 13 | import type { Mutation } from "@qubit-rs/client"; 14 | import type { Query } from "@qubit-rs/client"; 15 | import type { Subscription } from "@qubit-rs/client"; 16 | 17 | export type { Mutation } from "@qubit-rs/client"; 18 | export type { Query } from "@qubit-rs/client"; 19 | export type { Subscription } from "@qubit-rs/client"; 20 | 21 | export type QubitServer = { increment: Mutation<[], null>, decrement: Mutation<[], null>, add: Mutation<[n: number, ], null>, get: Query<[], number>, countdown: Subscription<[], number> }; -------------------------------------------------------------------------------- /examples/counter/index.ts: -------------------------------------------------------------------------------- 1 | // Import transport from client, and generated server type 2 | import { build_client, ws } from "@qubit-rs/client"; 3 | import type { QubitServer } from "./bindings"; 4 | 5 | // Polyfill only required for running in NodeJS 6 | import { WebSocket } from "ws"; 7 | 8 | async function main() { 9 | // Connect with the API 10 | const api = build_client( 11 | ws( 12 | "ws://localhost:9944/rpc", 13 | // @ts-ignore mis-matching WebSocket definitions 14 | { WebSocket }, 15 | ), 16 | ); 17 | 18 | // Do some maths 19 | for (let i = 0; i < 5; i++) { 20 | await api.increment.mutate(); 21 | } 22 | console.log("The value is", await api.get.query()); 23 | 24 | for (let i = 0; i < 3; i++) { 25 | await api.decrement.mutate(); 26 | } 27 | console.log("The value is", await api.get.query()); 28 | 29 | await api.add.mutate(10); 30 | console.log("The value is", await api.get.query()); 31 | 32 | console.log("=== Beginning Countdown ==="); 33 | await new Promise((resolve) => { 34 | api.countdown.subscribe({ 35 | on_data: (n) => { 36 | console.log(`${n}...`); 37 | }, 38 | on_end: () => { 39 | resolve(); 40 | }, 41 | }); 42 | }); 43 | console.log("=== Lift Off! ==="); 44 | } 45 | 46 | main(); 47 | -------------------------------------------------------------------------------- /examples/counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node ./index.ts", 8 | "check": "tsc" 9 | }, 10 | "dependencies": { 11 | "@qubit-rs/client": "workspace:*", 12 | "@types/ws": "^8.5.13", 13 | "ts-node": "^10.9.2", 14 | "typescript": "^5.7.2", 15 | "ws": "^8.18.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/counter/src/ctx.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{ 2 | atomic::{AtomicI32, Ordering}, 3 | Arc, 4 | }; 5 | 6 | /// App context that includes the current counter value. 7 | #[derive(Clone, Default)] 8 | pub struct Ctx { 9 | counter: Arc, 10 | } 11 | 12 | impl Ctx { 13 | /// Increment the counter. 14 | pub fn increment(&self) { 15 | (*self.counter).fetch_add(1, Ordering::Relaxed); 16 | } 17 | 18 | /// Decrement the counter. 19 | pub fn decrement(&self) { 20 | (*self.counter).fetch_sub(1, Ordering::Relaxed); 21 | } 22 | 23 | /// Add some value to the counter. 24 | pub fn add(&self, n: i32) { 25 | (*self.counter).fetch_add(n, Ordering::Relaxed); 26 | } 27 | 28 | /// Get the current counter value. 29 | pub fn get(&self) -> i32 { 30 | (*self.counter).load(Ordering::Relaxed) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/counter/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{net::SocketAddr, time::Duration}; 2 | 3 | use futures::{stream, Stream, StreamExt}; 4 | use qubit::{handler, Router}; 5 | use tokio::net::TcpListener; 6 | 7 | use crate::ctx::Ctx; 8 | 9 | mod ctx; 10 | 11 | // Simple handler, with no parameters from the client and no return values. 12 | #[handler(mutation)] 13 | async fn increment(ctx: Ctx) { 14 | ctx.increment(); 15 | } 16 | 17 | // Another simple handler. 18 | #[handler(mutation)] 19 | async fn decrement(ctx: Ctx) { 20 | ctx.decrement(); 21 | } 22 | 23 | // Handler that takes a parameter from the client. 24 | #[handler(mutation)] 25 | async fn add(ctx: Ctx, n: i32) { 26 | ctx.add(n); 27 | } 28 | 29 | // Handler that returns a value to the client. 30 | #[handler(query)] 31 | async fn get(ctx: Ctx) -> i32 { 32 | ctx.get() 33 | } 34 | 35 | // Handler that sets up a subscription, to continually stream data to the client. 36 | #[handler(subscription)] 37 | async fn countdown(ctx: Ctx) -> impl Stream { 38 | stream::iter((0..=ctx.get()).rev()).then(|item| async move { 39 | tokio::time::sleep(Duration::from_secs(1)).await; 40 | item 41 | }) 42 | } 43 | 44 | #[tokio::main] 45 | async fn main() { 46 | // Construct the qubit router 47 | let router = Router::new() 48 | .handler(increment) 49 | .handler(decrement) 50 | .handler(add) 51 | .handler(get) 52 | .handler(countdown); 53 | 54 | // Save the type 55 | router.write_bindings_to_dir("./bindings"); 56 | println!("Successfully write bindings to `./bindings`"); 57 | 58 | // Create service and handle 59 | let (qubit_service, qubit_handle) = router.to_service(Ctx::default()); 60 | 61 | // Nest into an Axum rouer 62 | let axum_router = axum::Router::<()>::new().nest_service("/rpc", qubit_service); 63 | 64 | // Start a Hyper server 65 | println!("Listening at 127.0.0.1:9944"); 66 | axum::serve( 67 | TcpListener::bind(&SocketAddr::from(([127, 0, 0, 1], 9944))) 68 | .await 69 | .unwrap(), 70 | axum_router, 71 | ) 72 | .await 73 | .unwrap(); 74 | 75 | // Shutdown Qubit 76 | qubit_handle.stop().unwrap(); 77 | } 78 | -------------------------------------------------------------------------------- /examples/counter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "node16", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "node16", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/hello-world/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hello-world" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | qubit = { path = "../../" } 8 | 9 | ts-rs = "10.1.0" 10 | serde = { version = "1.0", features = ["derive"] } 11 | futures = "0.3.31" 12 | 13 | tokio = { version = "1.44", features = ["full"] } 14 | axum = "0.8" 15 | hyper = { version = "1.6", features = ["server"] } 16 | -------------------------------------------------------------------------------- /examples/hello-world/README.md: -------------------------------------------------------------------------------- 1 | # Hello, world! 2 | 3 | The simplest Qubit setup possible. 4 | 5 | In one terminal, start the server. 6 | 7 | ```sh 8 | cargo run 9 | ``` 10 | 11 | In another terminal, install TypeScript dependencies, and run the client! 12 | 13 | ```sh 14 | corepack enable # if not enabled 15 | pnpm i 16 | pnpm start 17 | ``` 18 | 19 | ## Note 20 | 21 | The TypeScript client has some additional dependencies in order to get it up and running quickly, 22 | namely `ws`. This is not required for clients that are running in the web browser due to 23 | `WebSocket` existing, however in Node this is not the case. 24 | -------------------------------------------------------------------------------- /examples/hello-world/bindings/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | /* @@@@@@@@@@@@@ & ############### 4 | @@@@@@@@@@@@@@ &&& ############### 5 | @@@@@@@@@@@@@@ &&&&& ############### 6 | ############### &&&&& ############### 7 | ######## Generated by Qubit! ######## 8 | ############### &&&&& ############### 9 | ############### &&&&& @@@@@@@@@@@@@@ 10 | ############### && @@@@@@@@@@@@@@ 11 | ############### & @@@@@@@@@@@@@ */ 12 | 13 | import type { Query } from "@qubit-rs/client"; 14 | 15 | export type { Query } from "@qubit-rs/client"; 16 | 17 | export type QubitServer = { hello_world: Query<[], string> }; -------------------------------------------------------------------------------- /examples/hello-world/index.ts: -------------------------------------------------------------------------------- 1 | // Import transport from client, and generated server type 2 | import { http, build_client } from "@qubit-rs/client"; 3 | import type { QubitServer } from "./bindings"; 4 | 5 | async function main() { 6 | // Connect with the API 7 | const api = build_client(http("http://localhost:9944/rpc")); 8 | 9 | // Call the handlers 10 | const message = await api.hello_world.query(); 11 | console.log("recieved from server:", message); 12 | } 13 | 14 | main(); 15 | -------------------------------------------------------------------------------- /examples/hello-world/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello-world", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node ./index.ts", 8 | "check": "tsc" 9 | }, 10 | "dependencies": { 11 | "@qubit-rs/client": "workspace:*", 12 | "ts-node": "^10.9.2", 13 | "typescript": "^5.7.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/hello-world/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::net::SocketAddr; 2 | 3 | use qubit::{handler, Router}; 4 | use tokio::net::TcpListener; 5 | 6 | #[handler(query)] 7 | async fn hello_world() -> String { 8 | "Hello, world!".to_string() 9 | } 10 | 11 | #[tokio::main] 12 | async fn main() { 13 | // Construct the qubit router 14 | let router = Router::new().handler(hello_world); 15 | 16 | // Save the type 17 | router.write_bindings_to_dir("./bindings"); 18 | println!("Successfully write server type to `./bindings`"); 19 | 20 | // Create service and handle 21 | let (qubit_service, qubit_handle) = router.to_service(()); 22 | 23 | // Nest into an Axum rouer 24 | let axum_router = axum::Router::<()>::new().nest_service("/rpc", qubit_service); 25 | 26 | // Start a Hyper server 27 | println!("Listening at 127.0.0.1:9944"); 28 | axum::serve( 29 | TcpListener::bind(&SocketAddr::from(([127, 0, 0, 1], 9944))) 30 | .await 31 | .unwrap(), 32 | axum_router, 33 | ) 34 | .await 35 | .unwrap(); 36 | 37 | // Shutdown Qubit 38 | qubit_handle.stop().unwrap(); 39 | } 40 | -------------------------------------------------------------------------------- /examples/hello-world/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "node16", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "node16", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andogq/qubit/3c610ee64fcacd15606de1f8e7899136fb7426af/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "qubit", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "lint": "biome check", 7 | "format": "biome check --fix" 8 | }, 9 | "dependencies": { 10 | "@biomejs/biome": "^1.9.4" 11 | }, 12 | "packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0" 13 | } 14 | -------------------------------------------------------------------------------- /packages/client/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## \[0.4.5] 4 | 5 | - [`b6ef950`](https://github.com/andogq/qubit/commit/b6ef95077345cb4db4143e364c48eef010f41fc8) Bump dependencies 6 | 7 | ## \[0.4.4] 8 | 9 | ### bug 10 | 11 | - [`ba75bd4`](https://github.com/andogq/qubit/commit/ba75bd43fab2b2421fcf27694fcf9deca59860ea) Fix memory leak in promise manager due to holding references to resolved promises. 12 | 13 | ## \[0.4.3] 14 | 15 | ### feat 16 | 17 | - [`e72078a`](https://github.com/andogq/qubit/commit/e72078a340b4f61703770036327b39a6abeedd5d) throw error value from RPC if encountered 18 | 19 | ## \[0.4.2] 20 | 21 | ### feat 22 | 23 | - [`0b7457a`](https://github.com/andogq/qubit/commit/0b7457ab5f2647892880fdb3d45ee4f2a9d3adfc) create multi tranasport 24 | - [`2591127`](https://github.com/andogq/qubit/commit/2591127f0cfb78b1917bac317552099475d1fc72) create svelte integration 25 | 26 | ## \[0.4.1] 27 | 28 | ### feat 29 | 30 | - [`886106b`](https://github.com/andogq/qubit/commit/886106b27b68fb1e2a24f7cd0f3a2e929032151b) support `GET` for queries in client 31 | 32 | ## \[0.4.0] 33 | 34 | ### feat 35 | 36 | - [`e17bbf0`](https://github.com/andogq/qubit/commit/e17bbf0fb8adce5f488247f298278342add2e478) refactor client to introduct plugins, simplify types, and prepare for future work 37 | 38 | ## \[0.3.3] 39 | 40 | ### feat 41 | 42 | - [`f8ff07b`](https://github.com/andogq/qubit/commit/f8ff07b8d3b92aef60687b868a04ff08f4a8de2f) feat: allow for polyfilling `fetch` for `http` transport (close #55) 43 | 44 | ## \[0.3.2] 45 | 46 | ### fix 47 | 48 | - [`61f46c3`](https://github.com/andogq/qubit/commit/61f46c3ad82f4b869579d896697c3c4312154ac2) remove test import 49 | 50 | ## \[0.3.1] 51 | 52 | ### fix 53 | 54 | - [`8fca0ce`](https://github.com/andogq/qubit/commit/8fca0ceee34786f28c17f5e979dad7f4125d517a) remove old client builder 55 | 56 | ## \[0.3.0] 57 | 58 | ### feat 59 | 60 | - [`200efef`](https://github.com/andogq/qubit/commit/200efef21d10ed674afb27c336b6a9e2d02f58ad) output a Qubit logo header to binding files 61 | - [`45510bf`](https://github.com/andogq/qubit/commit/45510bfc270c076012f6179a2567ae9c6c9fbff4) change handler syntax within the client 62 | 63 | ## \[0.2.1] 64 | 65 | - [`39fb781`](https://github.com/andogq/qubit/commit/39fb781d89b47b97780cc8683976027a5f127dc7) update package.json with repo and keywords 66 | 67 | ### feat 68 | 69 | - [`223833d`](https://github.com/andogq/qubit/commit/223833d94baf47ac6200bd9db44a7a39af102019) implement reconnecting web socket in client 70 | 71 | ## \[0.2.0] 72 | 73 | - [`032d01e`](https://github.com/andogq/qubit/commit/032d01ef832b437d21b04e9d422204d216fc0397) run `pnpm build` before publishing (close #29) 74 | 75 | ## \[0.1.0] 76 | 77 | ### feat 78 | 79 | - [`46ea1a9`](https://github.com/andogq/qubit/commit/46ea1a97483357a031ce5229e31d7de3c690e16a) allow for subscription method to be overloaded if only `on_data` is required. 80 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@qubit-rs/client", 3 | "version": "0.4.5", 4 | "description": "Seamless RPC for Rust & TypeScript", 5 | "keywords": [ 6 | "api", 7 | "rust", 8 | "subscriptions", 9 | "rpc-framework", 10 | "jsonrpc", 11 | "trpc" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/andogq/qubit.git", 16 | "directory": "pacakges/client" 17 | }, 18 | "type": "module", 19 | "scripts": { 20 | "build": "tsup ./src/index.ts", 21 | "check": "tsc" 22 | }, 23 | "files": [ 24 | "dist" 25 | ], 26 | "main": "./dist/index.cjs", 27 | "types": "./dist/index.d.cts", 28 | "exports": { 29 | ".": { 30 | "import": { 31 | "default": "./dist/index.js", 32 | "types": "./dist/index.d.ts" 33 | }, 34 | "require": { 35 | "default": "./dist/index.cjs", 36 | "types": "./dist/index.d.cts" 37 | } 38 | } 39 | }, 40 | "author": { 41 | "name": "Tom Anderson", 42 | "email": "tom@ando.gq", 43 | "url": "https://ando.gq" 44 | }, 45 | "license": "MIT", 46 | "devDependencies": { 47 | "tsup": "^8.3.5", 48 | "typescript": "^5.7.2" 49 | }, 50 | "tsup": { 51 | "format": [ 52 | "esm", 53 | "cjs" 54 | ], 55 | "splitting": true, 56 | "dts": true 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/client/src/client.ts: -------------------------------------------------------------------------------- 1 | import type { StreamHandler, StreamHandlers } from "./handler/subscription"; 2 | import { type RpcRequest, type RpcResponse, create_payload } from "./jsonrpc"; 3 | import { type Handlers, type Plugins, create_path_builder } from "./path_builder"; 4 | import type { Transport } from "./transport"; 5 | 6 | /** 7 | * Convert promise that resolves into a callback into a callback that can be called synchronously. 8 | */ 9 | function sync_promise(implementation: () => Promise<() => void>): () => void { 10 | // Run the implementation, save the promise 11 | const callback_promise = implementation(); 12 | 13 | // Synchronously return a callback that will call the asynchronous callback 14 | return () => { 15 | callback_promise.then((callback) => { 16 | callback(); 17 | }); 18 | }; 19 | } 20 | 21 | /** 22 | * Destructure user handlers, and ensure that they all exist. 23 | */ 24 | function get_handlers(handler: StreamHandler): StreamHandlers { 25 | let on_data = (_: unknown) => {}; 26 | let on_error = (_: Error) => {}; 27 | let on_end = () => {}; 28 | 29 | if (typeof handler === "function") { 30 | on_data = handler; 31 | } else { 32 | if (handler?.on_data) { 33 | on_data = handler.on_data; 34 | } 35 | if (handler?.on_error) { 36 | on_error = handler.on_error; 37 | } 38 | if (handler?.on_end) { 39 | on_end = handler.on_end; 40 | } 41 | } 42 | 43 | return { on_data, on_error, on_end }; 44 | } 45 | 46 | /** 47 | * Determines if the provided type has a nested object, or is just made up of functions. 48 | */ 49 | type HasNestedObject = { 50 | [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : never; 51 | }; 52 | type AtEdge = T extends HasNestedObject ? Yes : No; 53 | 54 | /** 55 | * Inject the provided plugins into the edges of the server. 56 | */ 57 | type InjectPlugins = AtEdge< 58 | TServer, 59 | // Is at edge, merge with plugins 60 | TServer & TPlugins, 61 | // Not at edge, recurse 62 | { [K in keyof TServer]: InjectPlugins } 63 | >; 64 | 65 | /** 66 | * Build a new client for a server. 67 | */ 68 | export function build_client(client: Transport): Server; 69 | /** 70 | * Build a new client and inject the following plugins. 71 | */ 72 | export function build_client( 73 | transport: Transport, 74 | plugins: TPlugins, 75 | ): InjectPlugins>; 76 | export function build_client( 77 | client: Transport, 78 | plugins?: TPlugins, 79 | ): InjectPlugins> { 80 | let next_id = 0; 81 | 82 | async function send( 83 | method: string[], 84 | sender: (id: string | number, payload: RpcRequest) => Promise | null>, 85 | args: unknown, 86 | ): Promise { 87 | const id = next_id++; 88 | 89 | const payload = create_payload(id, method.join("."), args); 90 | const response = await sender(id, payload); 91 | 92 | if (response === null) { 93 | throw new Error("malformed response from the API"); 94 | } 95 | if (response.type !== "ok") { 96 | // API returned an error, pass it on 97 | throw response.value; 98 | } 99 | 100 | return response.value; 101 | } 102 | 103 | return create_path_builder({ 104 | ...(plugins ?? {}), 105 | query: (method, ...args: unknown[]) => { 106 | return send(method, client.query, args); 107 | }, 108 | mutate: async (method, ...args: unknown[]) => { 109 | return send(method, client.mutate, args); 110 | }, 111 | subscribe: (method, ...args: unknown[]) => { 112 | const { on_data, on_error, on_end } = get_handlers(args.pop() as StreamHandler); 113 | const p = send(method, client.mutate, args); 114 | 115 | const transport_unsubscribe = sync_promise(async () => { 116 | // Get the response of the request 117 | const subscription_id = await p; 118 | 119 | let count = 0; 120 | let required_count: number | null = null; 121 | 122 | // Result should be a subscription ID 123 | if (typeof subscription_id !== "string" && typeof subscription_id !== "number") { 124 | // TODO: Throw an error 125 | on_error(new Error("cannot subscribe to subscription")); 126 | return () => {}; 127 | } 128 | 129 | if (!client.subscribe) { 130 | on_error(new Error("client does not support subscriptions")); 131 | return () => {}; 132 | } 133 | 134 | // Subscribe to incoming requests 135 | return client.subscribe(subscription_id, (data) => { 136 | if (typeof data === "object" && "close_stream" in data && data.close_stream === subscription_id) { 137 | // Prepare to start closing the subscription 138 | required_count = data.count; 139 | } else { 140 | // Keep a count of incoming messages 141 | count += 1; 142 | 143 | // Forward the response onto the user 144 | if (on_data) { 145 | on_data(data); 146 | } 147 | } 148 | 149 | if (count === required_count) { 150 | // The expected amount of messages have been recieved, so it is safe to terminate the connection 151 | unsubscribe(); 152 | } 153 | }); 154 | }); 155 | 156 | const unsubscribe = async () => { 157 | // Send an unsubscribe message so the server knows we're not interested in the subscription 158 | const unsubscribe_method = method.slice(0, -1); 159 | const subscription_id = await p; 160 | unsubscribe_method.push(`${method.at(-1)}_unsub`); 161 | send(unsubscribe_method, client.query, [subscription_id]); 162 | 163 | // Allow the transport to clean up 164 | transport_unsubscribe(); 165 | 166 | // Notify the user 167 | on_end(); 168 | }; 169 | 170 | return unsubscribe; 171 | }, 172 | }); 173 | } 174 | -------------------------------------------------------------------------------- /packages/client/src/handler/index.ts: -------------------------------------------------------------------------------- 1 | export type { Query } from "./query"; 2 | export type { Mutation } from "./mutation"; 3 | export type { 4 | Subscription, 5 | StreamHandler, 6 | StreamUnsubscribe, 7 | } from "./subscription"; 8 | -------------------------------------------------------------------------------- /packages/client/src/handler/mutation.ts: -------------------------------------------------------------------------------- 1 | export type Mutation = { 2 | mutate: (...args: Args) => Promise; 3 | }; 4 | -------------------------------------------------------------------------------- /packages/client/src/handler/query.ts: -------------------------------------------------------------------------------- 1 | export type Query = { 2 | query: (...args: Args) => Promise; 3 | }; 4 | -------------------------------------------------------------------------------- /packages/client/src/handler/subscription.ts: -------------------------------------------------------------------------------- 1 | export type StreamHandlers = { 2 | on_data: (data: T) => void; 3 | on_error: (error: Error) => void; 4 | on_end: () => void; 5 | }; 6 | export type StreamHandler = ((data: T) => void) | Partial>; 7 | 8 | export type StreamUnsubscribe = () => void; 9 | 10 | /** 11 | * Helper type to add handler to a list of arguments, in a way that it will be named. 12 | */ 13 | type AddHandler = [...Arr, handler: StreamHandler]; 14 | 15 | export type Subscription = { 16 | subscribe: (...args: AddHandler) => StreamUnsubscribe; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/client/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./transport"; 2 | export * from "./handler"; 3 | export { build_client } from "./client"; 4 | export type { Plugins, HandlerFn } from "./path_builder"; 5 | -------------------------------------------------------------------------------- /packages/client/src/jsonrpc/index.ts: -------------------------------------------------------------------------------- 1 | export type RpcRequest = { 2 | jsonrpc: "2.0"; 3 | method: string; 4 | id: string | number; 5 | params: unknown; 6 | }; 7 | 8 | export function create_payload(id: string | number, method: string, params: any): RpcRequest { 9 | return { 10 | jsonrpc: "2.0", 11 | method, 12 | id, 13 | params, 14 | }; 15 | } 16 | 17 | /** 18 | * A JSONRPC-2.0 error. 19 | */ 20 | export type RpcError = { 21 | code: number; 22 | message: string; 23 | data: any; 24 | }; 25 | 26 | /** 27 | * An incoming message for a specific subscription 28 | */ 29 | export type RpcSubscriptionMessage = { 30 | type: "message"; 31 | id: string | number; 32 | value: T; 33 | }; 34 | 35 | export type RpcResponse = 36 | | { type: "ok"; id: string | number; value: T } 37 | | { type: "error"; id: string | number; value: RpcError } 38 | | RpcSubscriptionMessage; 39 | 40 | export function parse_response(response: any): RpcResponse | null { 41 | try { 42 | if (typeof response === "string") { 43 | // biome-ignore lint/style/noParameterAssign: rust-pilled 44 | response = JSON.parse(response); 45 | } 46 | 47 | if (response?.jsonrpc !== "2.0") { 48 | throw new Error("invalid value for `jsonrpc`"); 49 | } 50 | 51 | if ("params" in response && "subscription" in response.params && "result" in response.params) { 52 | return { 53 | type: "message", 54 | id: response.params.subscription, 55 | value: response.params.result, 56 | }; 57 | } 58 | 59 | if (typeof response?.id !== "number" && typeof response?.id !== "string" && response?.id !== null) { 60 | throw new Error("missing `id` field from response"); 61 | } 62 | 63 | if ("result" in response && !("error" in response)) { 64 | return { type: "ok", id: response.id, value: response.result }; 65 | } 66 | 67 | if ("error" in response && !("result" in response)) { 68 | if (typeof response.error?.code === "number" && typeof response.error?.message === "string") { 69 | // TODO: Validate error.data field when it's decided 70 | return { type: "error", id: response.id, value: response.error }; 71 | } 72 | throw new Error("malformed error object in response"); 73 | } 74 | 75 | throw new Error("invalid response object"); 76 | } catch (e) { 77 | console.error("Error encountered whilst parsing response"); 78 | console.error(e); 79 | 80 | return null; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/client/src/path_builder.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A handler function which must always take the path, represented as an array of strings. 3 | */ 4 | export type HandlerFn = (path: string[], ...args: TArgs) => TReturn; 5 | 6 | /** 7 | * Strips the `path` parameter (first parameter) from a `HandlerFn`. This represents the function 8 | * that is exposed to the end-user. 9 | */ 10 | type StripPath = F extends HandlerFn ? (...args: TArgs) => TReturn : never; 11 | 12 | /** 13 | * A collection of plugins, meaning handlers that include the path parameter. 14 | */ 15 | export type Plugins = Record>; 16 | 17 | /** 18 | * For all available handlers, will produce a handler that has the `path` parameter stripped from 19 | * it. 20 | */ 21 | export type Handlers = { 22 | [K in keyof THandlers]: StripPath; 23 | }; 24 | 25 | /** 26 | * Creates a proxied object that will call the provided builder method when accessed. This is 27 | * useful for building a new 'instance' any time it's accessed. 28 | */ 29 | function proxy_builder>(builder: (property: string | symbol) => T) { 30 | return new Proxy({} as TOut, { 31 | get(_target, property) { 32 | return builder(property); 33 | }, 34 | }); 35 | } 36 | 37 | /** 38 | * Converts an object of raw handlers into handlers suitable for end-users. It requires a 39 | * reference to the path array which will be captured by all handlers. 40 | */ 41 | function wrap_handlers(handlers: THandlers, path: string[]): Handlers { 42 | const wrapped: Partial> = {}; 43 | 44 | for (const [key, handler] of Object.entries(handlers)) { 45 | // @ts-ignore: Indexing into wrapped object with known key 46 | wrapped[key] = (...args: unknown[]) => { 47 | return handler(path, ...args); 48 | }; 49 | } 50 | 51 | // Will contain all keys after loop is done. 52 | return wrapped as Handlers; 53 | } 54 | 55 | /** 56 | * Create a proxy that will collect property accessing into an array of strings, and upon 57 | * accessing an item from the `handlers` parameter, return the method with the traversed path 58 | * provided. 59 | */ 60 | export function create_path_builder(handlers: THandlers): TOut { 61 | return proxy_builder((property) => { 62 | const path = []; 63 | 64 | // If the accessed property isn't a string, just skip adding it to the path 65 | if (typeof property === "string") { 66 | path.push(property); 67 | } 68 | 69 | // Build the underlying proxy from the user's handlers 70 | const proxy = new Proxy(wrap_handlers(handlers, path), { 71 | get: (target, property, proxy) => { 72 | // If the accessed property wasn't a string, ignore it. 73 | if (typeof property !== "string") { 74 | console.warn("attempted to access non-string property:", property); 75 | return proxy; 76 | } 77 | 78 | // The requested item is an underlying handler 79 | if (property in target) { 80 | return target[property]; 81 | } 82 | 83 | // Track this item in the path and continue 84 | path.push(property); 85 | return proxy; 86 | }, 87 | }); 88 | 89 | return proxy; 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /packages/client/src/proxy/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./promise"; 2 | -------------------------------------------------------------------------------- /packages/client/src/proxy/promise.ts: -------------------------------------------------------------------------------- 1 | export function wrap_promise(p: Promise, extensions: Record void>): Promise { 2 | return new Proxy(p, { 3 | get(target, property, receiver) { 4 | if (typeof property === "string" && extensions[property]) { 5 | return extensions[property]; 6 | } 7 | // Get the property from the original handler 8 | const value = Reflect.get(target, property, receiver); 9 | 10 | if (typeof value === "function") { 11 | // Make sure value is bounded to the original target 12 | return value.bind(target); 13 | } 14 | // Return the value as-is 15 | return value; 16 | }, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /packages/client/src/transport/http.ts: -------------------------------------------------------------------------------- 1 | import type { Transport } from "."; 2 | import { parse_response } from "../jsonrpc"; 3 | 4 | export type HttpOptions = { 5 | fetch?: typeof fetch; 6 | }; 7 | 8 | export function http(host: string, http_options?: HttpOptions) { 9 | const fetch_impl = http_options?.fetch || fetch; 10 | 11 | return { 12 | query: async (_id, payload) => { 13 | // Create a temporary URL with a fallback host to appease the URL constructor 14 | const temp_url = new URL(host, "http://example.com"); 15 | 16 | // Set the search parameters, to let it do the processing for us 17 | temp_url.searchParams.set("input", encodeURIComponent(JSON.stringify(payload))); 18 | 19 | // Use the original host, but replace anything after the `?` with our modified query parameters 20 | const url = `${host.replace(/\?.*$/, "")}?${temp_url.searchParams.toString()}`; 21 | 22 | const res = await fetch_impl(url, { 23 | method: "GET", 24 | mode: "cors", 25 | }); 26 | 27 | const body = await res.json(); 28 | 29 | return parse_response(body); 30 | }, 31 | mutate: async (_id, payload) => { 32 | const res = await fetch_impl(host, { 33 | method: "POST", 34 | mode: "cors", 35 | headers: { "Content-Type": "application/json" }, 36 | body: JSON.stringify(payload), 37 | }); 38 | 39 | const body = await res.json(); 40 | 41 | return parse_response(body); 42 | }, 43 | } satisfies Transport; 44 | } 45 | -------------------------------------------------------------------------------- /packages/client/src/transport/index.ts: -------------------------------------------------------------------------------- 1 | import type { RpcRequest, RpcResponse } from "../jsonrpc"; 2 | 3 | export { ws } from "./ws"; 4 | export { http, type HttpOptions } from "./http"; 5 | export { multi, type MultiOptions } from "./multi"; 6 | export type { SocketOptions } from "../util"; 7 | 8 | export type ClientBuilder = (host: string) => Server; 9 | 10 | /** 11 | * Interface required for a transport. 12 | */ 13 | export type Transport = { 14 | /** 15 | * Make a request that is a query, meaning that it is safe to be cached. 16 | */ 17 | query: (id: string | number, payload: RpcRequest) => Promise | null>; 18 | /** 19 | * Make a request that is a mutation, meaning that it should not be cached. 20 | */ 21 | mutate: (id: string | number, payload: RpcRequest) => Promise | null>; 22 | /** 23 | * Start a subscription, calling `on_data` for every message from the server. An unsubscribe 24 | * method must be returned, which must terminate the subscription when called. 25 | */ 26 | subscribe?: (id: string | number, on_data?: (value: any) => void) => () => void; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/client/src/transport/multi.ts: -------------------------------------------------------------------------------- 1 | import { http, type HttpOptions, type SocketOptions, type Transport, ws } from "."; 2 | 3 | export type MultiOptions = { 4 | ws?: SocketOptions; 5 | http?: HttpOptions; 6 | }; 7 | 8 | /** 9 | * Transport that combines both `http` for query and mutate, and `ws` for subscriptions. 10 | */ 11 | export function multi(host: string, options?: MultiOptions) { 12 | const http_client = http(host, options?.http); 13 | const ws_client = ws(host, options?.ws); 14 | 15 | return { 16 | query: (id, payload) => { 17 | return http_client.query(id, payload); 18 | }, 19 | mutate: (id, payload) => { 20 | return http_client.mutate(id, payload); 21 | }, 22 | subscribe: (id, on_data) => { 23 | return ws_client.subscribe(id, on_data); 24 | }, 25 | } satisfies Transport; 26 | } 27 | -------------------------------------------------------------------------------- /packages/client/src/transport/ws.ts: -------------------------------------------------------------------------------- 1 | import type { Transport } from "."; 2 | import type { RpcResponse } from "../jsonrpc"; 3 | import { type SocketOptions, create_promise_manager, create_socket, create_subscription_manager } from "../util"; 4 | 5 | export function ws(host: string, socket_options?: SocketOptions) { 6 | const subscriptions = create_subscription_manager(); 7 | const requests = create_promise_manager(); 8 | 9 | // Create a WS client 10 | const socket = create_socket( 11 | host, 12 | (message) => { 13 | if (message.type === "message") { 14 | subscriptions.handle(message); 15 | } else if ("id" in message) { 16 | requests.resolve(message); 17 | } 18 | }, 19 | socket_options, 20 | ); 21 | 22 | const send_request = (id: string | number, payload: string): Promise> => { 23 | // Send the data to the socket 24 | socket.send(payload); 25 | 26 | // Return a promise to wait for the request 27 | return requests.wait_for(id); 28 | }; 29 | 30 | return { 31 | query: (id, payload) => send_request(id, JSON.stringify(payload)), 32 | mutate: (id, payload) => send_request(id, JSON.stringify(payload)), 33 | subscribe: (id, on_data) => { 34 | if (on_data) { 35 | // Subscribe to the events 36 | subscriptions.register(id, on_data); 37 | } 38 | 39 | // Return an unsubscribe handler 40 | return () => { 41 | // Remove the subscription 42 | subscriptions.remove(id); 43 | }; 44 | }, 45 | } satisfies Transport; 46 | } 47 | -------------------------------------------------------------------------------- /packages/client/src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./promise_manager"; 2 | export * from "./socket"; 3 | export * from "./subscription_manager"; 4 | -------------------------------------------------------------------------------- /packages/client/src/util/promise_manager.ts: -------------------------------------------------------------------------------- 1 | import type { RpcResponse } from "../jsonrpc"; 2 | 3 | /** 4 | * Utility to create promises assigned with some ID, and later resolve them by referring to the 5 | * same ID. 6 | */ 7 | export function create_promise_manager() { 8 | const promises: Record) => void> = {}; 9 | 10 | return { 11 | /** Send some payload, for a given ID */ 12 | wait_for: (id: string | number): Promise> => { 13 | return new Promise((resolve) => { 14 | promises[id] = resolve; 15 | }); 16 | }, 17 | 18 | /** Resolve a response based on an ID */ 19 | resolve: (response: RpcResponse) => { 20 | const handler = promises[response.id]; 21 | if (handler) { 22 | delete promises[response.id]; 23 | handler(response); 24 | } 25 | }, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/client/src/util/socket.ts: -------------------------------------------------------------------------------- 1 | import { type RpcResponse, parse_response } from "../jsonrpc"; 2 | 3 | type Socket = { send: (payload: string) => void }; 4 | 5 | export type SocketOptions = { 6 | WebSocket: unknown; // TODO: Work out how to properly type this 7 | }; 8 | 9 | export function create_socket( 10 | host: string, 11 | on_message: (message: RpcResponse) => void, 12 | options?: SocketOptions, 13 | ): Socket { 14 | /** Track whether the socket has been opened. */ 15 | let socket_open = false; 16 | let next_timeout = 0.5; 17 | 18 | /** Queue of requests that were made before the socket was opened. */ 19 | let queue: string[] = []; 20 | 21 | // TODO: Type this 22 | const WS: any = options?.WebSocket || WebSocket; 23 | 24 | let socket: WebSocket; 25 | 26 | function new_socket() { 27 | socket = new WS(host); 28 | 29 | socket.addEventListener("open", () => { 30 | socket_open = true; 31 | next_timeout = 0.5; // Reset timeout 32 | 33 | // Run through the items in the queue and send them off 34 | for (const payload of queue) { 35 | socket.send(payload); 36 | } 37 | 38 | queue = []; 39 | }); 40 | 41 | socket.addEventListener("message", (e) => { 42 | const message = parse_response(e.data); 43 | 44 | if (message) { 45 | on_message(message); 46 | } 47 | }); 48 | 49 | socket.addEventListener("close", () => { 50 | // Start attempting to re-open the socket 51 | socket_open = false; 52 | 53 | setTimeout(() => { 54 | // Increase the timeout 55 | next_timeout *= 2; 56 | 57 | // Try re-create the socket 58 | new_socket(); 59 | 60 | // TODO: Re-subscribe to subscriptions 61 | }, next_timeout * 1000); 62 | }); 63 | } 64 | 65 | new_socket(); 66 | 67 | return { 68 | send: (payload: string) => { 69 | if (!socket_open) { 70 | // Queue the request up for when the socket opens 71 | queue.push(payload); 72 | } else { 73 | socket.send(payload); 74 | } 75 | }, 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /packages/client/src/util/subscription_manager.ts: -------------------------------------------------------------------------------- 1 | import type { RpcSubscriptionMessage } from "../jsonrpc"; 2 | 3 | type SubscriptionHandler = (value: unknown) => void; 4 | type Subscription = { queue: unknown[]; handler?: SubscriptionHandler }; 5 | 6 | /** 7 | * Collects incoming messages until a handler is registered for them. Once a handler is 8 | * registered, all previous messages will be replayed. 9 | */ 10 | export function create_subscription_manager() { 11 | const subscriptions: Record = {}; 12 | 13 | function get_subscription(id: string | number): Subscription { 14 | let subscription = subscriptions[id]; 15 | if (!subscription) { 16 | subscription = subscriptions[id] = { queue: [] }; 17 | } 18 | 19 | return subscription; 20 | } 21 | 22 | return { 23 | /** Handle an incoming message */ 24 | handle: (message: RpcSubscriptionMessage) => { 25 | // Fetch the subscription (creating it if it doesn't exist) 26 | const subscription = get_subscription(message.id); 27 | 28 | // If the handler doesn't exist, save it for later 29 | if (!subscription.handler) { 30 | subscription.queue.push(message.value); 31 | return; 32 | } 33 | 34 | // Pass the message value on to the handler 35 | subscription.handler(message.value); 36 | }, 37 | 38 | /** Register a handler for when new data arrives for a given subscription. */ 39 | register: (id: string | number, handler: SubscriptionHandler) => { 40 | const subscription = get_subscription(id); 41 | 42 | // Make sure the handler won't be over written 43 | if (subscription.handler) { 44 | console.error(`attempted to subscribe to a subscription multiple times (subscription ID: ${id})`); 45 | return; 46 | } 47 | 48 | // Save the handler 49 | subscription.handler = handler; 50 | 51 | // Empty out anything that's currently in the queue 52 | for (const value of subscription.queue) { 53 | handler(value); 54 | } 55 | subscription.queue = []; 56 | }, 57 | 58 | /** Remove the given subscription. */ 59 | remove: (id: string | number) => { 60 | delete subscriptions[id]; 61 | }, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "verbatimModuleSyntax": true, 8 | "allowJs": true, 9 | "resolveJsonModule": true, 10 | "moduleDetection": "force", 11 | /* Strictness */ 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | /* If NOT transpiling with TypeScript: */ 15 | "moduleResolution": "Bundler", 16 | "module": "ESNext", 17 | "noEmit": true, 18 | /* If your code doesn't run in the DOM: */ 19 | "lib": ["es2022", "dom"] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/svelte/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # Output 4 | .output 5 | .vercel 6 | /.svelte-kit 7 | /build 8 | /dist 9 | 10 | # OS 11 | .DS_Store 12 | Thumbs.db 13 | 14 | # Env 15 | .env 16 | .env.* 17 | !.env.example 18 | !.env.test 19 | 20 | # Vite 21 | vite.config.js.timestamp-* 22 | vite.config.ts.timestamp-* 23 | -------------------------------------------------------------------------------- /packages/svelte/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /packages/svelte/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## \[0.0.6] 4 | 5 | - [`b6ef950`](https://github.com/andogq/qubit/commit/b6ef95077345cb4db4143e364c48eef010f41fc8) Bump dependencies 6 | 7 | ### Dependencies 8 | 9 | - Upgraded to `@qubit-rs/client@0.4.5` 10 | 11 | ## \[0.0.5] 12 | 13 | ### Dependencies 14 | 15 | - Upgraded to `@qubit-rs/client@0.4.4` 16 | 17 | ## \[0.0.4] 18 | 19 | ### fix 20 | 21 | - [`1b3cc0f`](https://github.com/andogq/qubit/commit/1b3cc0f0108d505b8b02db9c9d9fe734dc1f9106) fix svelte package deps 22 | 23 | ## \[0.0.3] 24 | 25 | ### Dependencies 26 | 27 | - Upgraded to `@qubit-rs/client@0.4.3` 28 | 29 | ## \[0.0.2] 30 | 31 | ### Dependencies 32 | 33 | - Upgraded to `@qubit-rs/client@0.4.2` 34 | 35 | ### feat 36 | 37 | - [`2591127`](https://github.com/andogq/qubit/commit/2591127f0cfb78b1917bac317552099475d1fc72) create svelte integration 38 | -------------------------------------------------------------------------------- /packages/svelte/README.md: -------------------------------------------------------------------------------- 1 | # create-svelte 2 | 3 | Everything you need to build a Svelte library, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). 4 | 5 | Read more about creating a library [in the docs](https://kit.svelte.dev/docs/packaging). 6 | 7 | ## Creating a project 8 | 9 | If you're seeing this, you've probably already done this step. Congrats! 10 | 11 | ```bash 12 | # create a new project in the current directory 13 | npm create svelte@latest 14 | 15 | # create a new project in my-app 16 | npm create svelte@latest my-app 17 | ``` 18 | 19 | ## Developing 20 | 21 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 22 | 23 | ```bash 24 | npm run dev 25 | 26 | # or start the server and open the app in a new browser tab 27 | npm run dev -- --open 28 | ``` 29 | 30 | Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app. 31 | 32 | ## Building 33 | 34 | To build your library: 35 | 36 | ```bash 37 | npm run package 38 | ``` 39 | 40 | To create a production version of your showcase app: 41 | 42 | ```bash 43 | npm run build 44 | ``` 45 | 46 | You can preview the production build with `npm run preview`. 47 | 48 | > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 49 | 50 | ## Publishing 51 | 52 | Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)). 53 | 54 | To publish your library to [npm](https://www.npmjs.com): 55 | 56 | ```bash 57 | npm publish 58 | ``` 59 | -------------------------------------------------------------------------------- /packages/svelte/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@qubit-rs/svelte", 3 | "version": "0.0.6", 4 | "description": "Seamless RPC for Rust & TypeScript - Svelte style", 5 | "keywords": [ 6 | "api", 7 | "rust", 8 | "svelte", 9 | "svelte-kit", 10 | "svelte-store", 11 | "subscriptions", 12 | "rpc-framework", 13 | "jsonrpc", 14 | "trpc" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/andogq/qubit.git", 19 | "directory": "pacakges/svelte" 20 | }, 21 | "author": { 22 | "name": "Tom Anderson", 23 | "email": "tom@ando.gq", 24 | "url": "https://ando.gq" 25 | }, 26 | "license": "MIT", 27 | "scripts": { 28 | "dev": "vite dev", 29 | "build": "vite build && npm run package", 30 | "preview": "vite preview", 31 | "package": "svelte-kit sync && svelte-package && publint", 32 | "prepublishOnly": "npm run package", 33 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 34 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" 35 | }, 36 | "exports": { 37 | ".": { 38 | "types": "./dist/index.d.ts", 39 | "svelte": "./dist/index.js" 40 | } 41 | }, 42 | "files": [ 43 | "dist", 44 | "!dist/**/*.test.*", 45 | "!dist/**/*.spec.*" 46 | ], 47 | "peerDependencies": { 48 | "svelte": "^4.0.0 || ^5.0.0" 49 | }, 50 | "devDependencies": { 51 | "@sveltejs/adapter-auto": "^3.3.1", 52 | "@sveltejs/kit": "^2.9.0", 53 | "@sveltejs/package": "^2.3.7", 54 | "@sveltejs/vite-plugin-svelte": "^3.1.2", 55 | "publint": "^0.1.16", 56 | "svelte": "^4.2.19", 57 | "svelte-check": "^3.8.6", 58 | "tslib": "^2.8.1", 59 | "typescript": "^5.7.2", 60 | "vite": "^5.4.11" 61 | }, 62 | "svelte": "./dist/index.js", 63 | "types": "./dist/index.d.ts", 64 | "type": "module", 65 | "dependencies": { 66 | "@qubit-rs/client": "0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/svelte/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export type {}; 14 | -------------------------------------------------------------------------------- /packages/svelte/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/svelte/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { http, type HttpOptions, type MultiOptions, type Transport, build_client, multi } from "@qubit-rs/client"; 2 | import { getContext, hasContext, setContext } from "svelte"; 3 | 4 | const CONTEXT_NAME = "__qubit-rs-svelte-context"; 5 | 6 | type SvelteQubitOptions = MultiOptions & { 7 | /** 8 | * Whether running in the browser (provided by `$app/environment`). Assumed `true` if not provided. 9 | */ 10 | browser?: boolean; 11 | }; 12 | 13 | /** 14 | * Create a new Qubit instance. 15 | * 16 | * @param host - Host URL where the Qubit server is running. 17 | * @param options - Configuration for the underlying transport. 18 | */ 19 | export function create_qubit_api(host: string, options?: SvelteQubitOptions) { 20 | let client: Server; 21 | 22 | function get_client(overrides?: { fetch: HttpOptions["fetch"] }) { 23 | if (!client) { 24 | let transport: Transport; 25 | 26 | if (options?.browser === true) { 27 | // biome-ignore lint/style/noParameterAssign: 28 | options ??= {}; 29 | options.http = options.http ?? ({} as HttpOptions); 30 | options.http.fetch = overrides?.fetch; 31 | 32 | transport = multi(host, options); 33 | } else { 34 | const http_options = options?.http ?? ({} as HttpOptions); 35 | 36 | if (overrides?.fetch) { 37 | http_options.fetch = overrides.fetch; 38 | } 39 | 40 | transport = http(host, http_options); 41 | } 42 | 43 | client = build_client(transport); 44 | } 45 | 46 | return client; 47 | } 48 | 49 | return { 50 | /** 51 | * Initialise the context so the API instance can be accessible within the application. This 52 | * should be run at the root layout, and only done once. 53 | */ 54 | init_context: () => { 55 | setContext(CONTEXT_NAME, get_client()); 56 | }, 57 | 58 | /** 59 | * Fetch the API instance. 60 | */ 61 | get_api: () => { 62 | if (!hasContext(CONTEXT_NAME)) { 63 | throw new Error("@qubit-rs/svelte: ensure that `init_context` has been called at the root layout."); 64 | } 65 | 66 | return getContext(CONTEXT_NAME); 67 | }, 68 | 69 | // biome-ignore lint/correctness/noUnusedVariables: 70 | load_api: ({ fetch, depends }: LoadApiOptions): Server => { 71 | return get_client({ fetch }); 72 | }, 73 | }; 74 | } 75 | 76 | type LoadApiOptions = { 77 | fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; 78 | depends: (...deps: `${string}:${string}`[]) => void; 79 | }; 80 | -------------------------------------------------------------------------------- /packages/svelte/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 |

Welcome to your library project

2 |

Create your package using @sveltejs/package and preview/showcase your work with SvelteKit

3 |

Visit kit.svelte.dev to read the documentation

4 | -------------------------------------------------------------------------------- /packages/svelte/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andogq/qubit/3c610ee64fcacd15606de1f8e7899136fb7426af/packages/svelte/static/favicon.png -------------------------------------------------------------------------------- /packages/svelte/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /packages/svelte/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "module": "NodeNext", 13 | "moduleResolution": "NodeNext" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/svelte/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from "@sveltejs/kit/vite"; 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | }); 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/client 3 | - packages/svelte 4 | - examples/authentication/auth-demo 5 | - examples/chaos 6 | - examples/chat-room-react 7 | - examples/counter 8 | - examples/hello-world 9 | -------------------------------------------------------------------------------- /scripts/generate-lock-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MANIFESTS=$(find . -name Cargo.toml ! -path './target/*') 4 | 5 | for MANIFEST in $MANIFESTS 6 | do 7 | echo "Updating Cargo.lock for $MANIFEST" 8 | 9 | cargo generate-lockfile --manifest-path="$MANIFEST" 10 | done 11 | -------------------------------------------------------------------------------- /src/builder/handler.rs: -------------------------------------------------------------------------------- 1 | //! Handlers appear to be regular functions, however [`qubit_macros::handler`] expands them into 2 | //! structs that implement the [`Handler`] trait. This expansion assists with both the run-time 3 | //! [`crate::Router`] type generation, as well as other ergonomic features like parameter 4 | //! deserialization from requests. 5 | //! 6 | //! There are two primary features that a handler must implement: 7 | //! 8 | //! - Normalisation and registration: The handlers must register themselves against a 9 | //! [`RpcBuilder`] instance in a uniform manner, so any parameters for this handler must be 10 | //! transformed from the parameters provided by the server. 11 | //! 12 | //! - Type specification: The handlers must emit both the signature of the handler 13 | //! ([`Handler::get_type`]), as well as any dependencies that they rely on 14 | //! ([`Handler::add_dependencies`]). 15 | //! 16 | //! # Handler Erasure 17 | //! 18 | //! In an effort to cut down on dynamic dispatch, [`HandlerCallbacks`] is a grab-bag of function 19 | //! pointers to the methods of [`Handler`]. This is possible since none of these methods reference 20 | //! `self`. This is what is actually stored on [`crate::Router`]. 21 | 22 | use std::path::Path; 23 | 24 | use ts_rs::{Dependency, ExportError}; 25 | 26 | use crate::{builder::RpcBuilder, util::QubitType, HandlerType}; 27 | 28 | /// Handlers run for specific RPC requests. This trait will automatically be implemented if the 29 | /// [`crate::handler`] macro is attached to a function containing a handler implementation. 30 | pub trait Handler { 31 | /// Register this handler against the provided RPC builder. 32 | fn register(rpc_builder: RpcBuilder) -> RpcBuilder; 33 | 34 | /// Get the type of this handler, to generate the client. 35 | fn get_type() -> HandlerType; 36 | 37 | fn export_all_dependencies_to(out_dir: &Path) -> Result, ExportError>; 38 | 39 | /// Provide a list of Qubit types that this handler relies on. 40 | fn qubit_types() -> Vec; 41 | } 42 | 43 | /// Wrapper struct to assist with erasure of concrete [`Handler`] type. Contains function pointers 44 | /// to all of the implementations required to process the handler, allowing different handler types 45 | /// to be contained together. 46 | #[derive(Clone)] 47 | pub(crate) struct HandlerCallbacks { 48 | /// Function pointer to the register implementation for the handler, which will register it 49 | /// against an RPC builder. 50 | pub register: fn(RpcBuilder) -> RpcBuilder, 51 | 52 | /// Function pointer to the implementation which will return the [`HandlerType`] for this 53 | /// handler. 54 | pub get_type: fn() -> HandlerType, 55 | 56 | pub export_all_dependencies_to: fn(&Path) -> Result, ExportError>, 57 | pub qubit_types: fn() -> Vec, 58 | } 59 | 60 | impl HandlerCallbacks 61 | where 62 | Ctx: 'static + Send + Sync + Clone, 63 | { 64 | /// Automatically implement the creation of [`HandlerCallbacks`] for anything that implements 65 | /// [`Handler`]. This is possible since the trait only contains static methods, which can simply 66 | /// be expressed as function pointers. 67 | pub fn from_handler>(_handler: H) -> Self { 68 | Self { 69 | register: H::register, 70 | get_type: H::get_type, 71 | export_all_dependencies_to: H::export_all_dependencies_to, 72 | qubit_types: H::qubit_types, 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/builder/mod.rs: -------------------------------------------------------------------------------- 1 | //! Components used to 'build' the [`crate::Router`]. This module mostly contains components that 2 | //! will primarily be used by [`qubit_macros`], directly using them probably isn't a great idea. 3 | 4 | pub mod handler; 5 | mod rpc_builder; 6 | pub mod ty; 7 | 8 | pub use handler::Handler; 9 | pub(crate) use handler::HandlerCallbacks; 10 | pub use rpc_builder::RpcBuilder; 11 | pub use ty::*; 12 | 13 | pub use jsonrpsee::types::ErrorObject; 14 | pub use jsonrpsee::IntoResponse; 15 | -------------------------------------------------------------------------------- /src/builder/rpc_builder.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | use futures::{Future, Stream, StreamExt}; 4 | use jsonrpsee::{ 5 | types::{ErrorCode, ErrorObject, ErrorObjectOwned, Params, ResponsePayload}, 6 | RpcModule, SubscriptionCloseResponse, SubscriptionMessage, 7 | }; 8 | use serde::Serialize; 9 | use serde_json::json; 10 | 11 | use crate::{FromRequestExtensions, RequestKind, RpcError}; 12 | 13 | /// Builder to construct the RPC module. Handlers can be registered using the [`RpcBuilder::query`] 14 | /// and [`RpcBuilder::subscription`] methods. It tracks an internally mutable [`RpcModule`] and 15 | /// it's namespace, ensuring that handlers names are correctly created. 16 | /// 17 | /// For the most part, this should not be used manually, but rather with the [`qubit_macros::handler`] 18 | /// macro. 19 | pub struct RpcBuilder { 20 | /// The namespace for this module, which will be prepended onto handler names (if present). 21 | namespace: Option<&'static str>, 22 | 23 | /// The actual [`RpcModule`] that is being constructed. 24 | module: RpcModule, 25 | } 26 | 27 | impl RpcBuilder 28 | where 29 | Ctx: Clone + Send + Sync + 'static, 30 | { 31 | /// Create a builder with the provided namespace. 32 | pub(crate) fn with_namespace(ctx: Ctx, namespace: Option<&'static str>) -> Self { 33 | Self { 34 | namespace, 35 | module: RpcModule::new(ctx), 36 | } 37 | } 38 | 39 | /// Consume the builder to produce the internal [`RpcModule`], ready to be used. 40 | pub(crate) fn build(self) -> RpcModule { 41 | self.module 42 | } 43 | 44 | /// Register a new query handler with the provided name. 45 | /// 46 | /// The `handler` can take its own `Ctx`, so long as it implements [`FromRequestExtensions`]. It 47 | /// must return a future which outputs a serializable value. 48 | pub fn query(self, name: &'static str, handler: F) -> Self 49 | where 50 | T: Serialize + Clone + 'static, 51 | C: FromRequestExtensions, 52 | F: Fn(C, Params<'static>) -> Fut + Send + Sync + Clone + 'static, 53 | Fut: Future>> + Send + 'static, 54 | { 55 | self.register_handler(name, handler, RequestKind::Query) 56 | } 57 | 58 | /// Register a new mutation handler with the provided name. 59 | pub fn mutation(self, name: &'static str, handler: F) -> Self 60 | where 61 | T: Serialize + Clone + 'static, 62 | C: FromRequestExtensions, 63 | F: Fn(C, Params<'static>) -> Fut + Send + Sync + Clone + 'static, 64 | Fut: Future>> + Send + 'static, 65 | { 66 | self.register_handler(name, handler, RequestKind::Mutation) 67 | } 68 | 69 | /// Internal implementation for handler registrations, which will only run the internal handler 70 | /// if the request kind is correct. 71 | fn register_handler( 72 | mut self, 73 | name: &'static str, 74 | handler: F, 75 | request_kind: RequestKind, 76 | ) -> Self 77 | where 78 | T: Serialize + Clone + 'static, 79 | C: FromRequestExtensions, 80 | F: Fn(C, Params<'static>) -> Fut + Send + Sync + Clone + 'static, 81 | Fut: Future>> + Send + 'static, 82 | { 83 | self.module 84 | .register_async_method(self.namespace_str(name), move |params, ctx, extensions| { 85 | // NOTE: Handler has to be cloned in since `register_async_method` takes `Fn`, not 86 | // `FnOnce`. Not sure if it's better to be an `Rc`/leaked/??? 87 | let handler = handler.clone(); 88 | 89 | async move { 90 | if &request_kind 91 | != extensions 92 | .get::() 93 | .expect("request kind to be added to request extensions") 94 | { 95 | return ResponsePayload::Error( 96 | RpcError { 97 | code: ErrorCode::MethodNotFound, 98 | message: "method not found".to_string(), 99 | data: None, 100 | } 101 | .into(), 102 | ); 103 | } 104 | 105 | // Build the context 106 | let ctx = 107 | match C::from_request_extensions(ctx.deref().clone(), extensions).await { 108 | Ok(ctx) => ctx, 109 | Err(e) => { 110 | // Handle any error building the context by turning it into a response 111 | // payload. 112 | return ResponsePayload::Error(e.into()); 113 | } 114 | }; 115 | 116 | // Run the actual handler 117 | match handler(ctx, params).await { 118 | Ok(result) => ResponsePayload::success(result), 119 | Err(e) => ResponsePayload::error(e), 120 | } 121 | } 122 | }) 123 | .unwrap(); 124 | 125 | self 126 | } 127 | 128 | /// Register a new subscription handler with the provided name. 129 | /// 130 | /// The `handler` can take its own `Ctx`, so long as it implements [`FromRequestExtensions`]. It 131 | /// must return a future that outputs a stream of serializable values. 132 | pub fn subscription( 133 | mut self, 134 | name: &'static str, 135 | notification_name: &'static str, 136 | unsubscribe_name: &'static str, 137 | handler: F, 138 | ) -> Self 139 | where 140 | T: Serialize + Send + Clone + 'static, 141 | C: FromRequestExtensions, 142 | F: Fn(C, Params<'static>) -> Fut + Send + Sync + Clone + 'static, 143 | Fut: Future>> + Send + 'static, 144 | S: Stream + Send + 'static, 145 | { 146 | self.module 147 | .register_subscription( 148 | self.namespace_str(name), 149 | self.namespace_str(notification_name), 150 | self.namespace_str(unsubscribe_name), 151 | move |params, subscription, ctx, extensions| { 152 | // NOTE: Same deal here with cloning the handler as in the query registration. 153 | let handler = handler.clone(); 154 | 155 | async move { 156 | // Build the context 157 | // NOTE: It won't be held across await so that `C` doesn't have to be 158 | // `Send` 159 | let ctx = match C::from_request_extensions(ctx.deref().clone(), extensions) 160 | .await 161 | { 162 | Ok(ctx) => ctx, 163 | Err(e) => { 164 | // Handle any error building the context by turning it into a 165 | // subscriptions close response 166 | subscription.reject(ErrorObjectOwned::from(e)).await; 167 | return SubscriptionCloseResponse::None; 168 | } 169 | }; 170 | 171 | // Run the handler, capturing each of the values sand forwarding it onwards 172 | // to the channel 173 | let mut stream = match handler(ctx, params).await { 174 | Ok(s) => Box::pin(s), 175 | Err(e) => { 176 | subscription.reject(e).await; 177 | return SubscriptionCloseResponse::None; 178 | } 179 | }; 180 | 181 | // Accept the subscription 182 | let subscription = subscription.accept().await.unwrap(); 183 | 184 | // Track the number of items emitted through the subscription 185 | let mut count = 0; 186 | let subscription_id = subscription.subscription_id(); 187 | 188 | while let Some(value) = stream.next().await { 189 | if subscription.is_closed() { 190 | break; 191 | } 192 | 193 | subscription 194 | .send(serde_json::value::to_raw_value(&value).unwrap()) 195 | .await 196 | .unwrap(); 197 | 198 | count += 1; 199 | } 200 | 201 | // Notify that stream is closing 202 | SubscriptionCloseResponse::Notif(SubscriptionMessage::from( 203 | serde_json::value::to_raw_value( 204 | &json!({ "close_stream": subscription_id, "count": count }), 205 | ) 206 | .unwrap(), 207 | )) 208 | } 209 | }, 210 | ) 211 | .unwrap(); 212 | 213 | self 214 | } 215 | 216 | /// Helper to 'resolve' some string with the namespace of this module (if it's present) 217 | fn namespace_str(&self, s: &'static str) -> &'static str { 218 | if let Some(namespace) = self.namespace { 219 | Box::leak(format!("{namespace}.{s}").into_boxed_str()) 220 | } else { 221 | s 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/builder/ty/mod.rs: -------------------------------------------------------------------------------- 1 | //! Type generation specific functionality. There is no real need for this to be directly used, 2 | //! [`qubit_macros::handler`] should handle it all. 3 | 4 | pub mod util; 5 | 6 | /// Components used to construct the client type for this handler. 7 | #[derive(Debug)] 8 | pub struct HandlerType { 9 | /// Unique name of the handler. This will automatically be namespaced as appropriate when the 10 | /// attached router is nested. 11 | pub name: String, 12 | 13 | /// Signature of this handler. 14 | pub signature: String, 15 | 16 | /// Kind of the handler, which will be used as the final part of the call in TypeScript. 17 | pub kind: String, 18 | } 19 | -------------------------------------------------------------------------------- /src/builder/ty/util/exporter.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | use ts_rs::{Dependency, ExportError, TypeVisitor, TS}; 4 | 5 | /// Visitor for [`ts_rs::TypeList`], which will export each type and any dependent types. It will 6 | /// also save a list of top level [`Dependency`]s that must be imported in order for the original 7 | /// [`ts_rs::TypeList`] to be used. 8 | struct TypeListExporter { 9 | out_dir: PathBuf, 10 | dependencies: Vec, 11 | } 12 | 13 | impl TypeListExporter { 14 | /// Create a new empty instance. 15 | pub fn new(out_dir: impl AsRef) -> Self { 16 | Self { 17 | out_dir: out_dir.as_ref().to_owned(), 18 | dependencies: Vec::new(), 19 | } 20 | } 21 | 22 | /// Consume the exporter to produce the dependency list. 23 | pub fn into_inner(self) -> Vec { 24 | self.dependencies 25 | } 26 | } 27 | 28 | impl TypeVisitor for TypeListExporter { 29 | fn visit(&mut self) { 30 | // Ensure that the type is an exportable type (otherwise likely just a primitive) 31 | let Some(dep) = Dependency::from_ty::() else { 32 | return; 33 | }; 34 | 35 | // Don't duplicate dependencies 36 | if self.dependencies.contains(&dep) { 37 | return; 38 | } 39 | 40 | // Save the top level dependency 41 | self.dependencies.push(dep); 42 | 43 | // Export all required types to files 44 | T::export_all_to(&self.out_dir) 45 | .expect("type is not a primitive, so can initiate an export"); 46 | } 47 | } 48 | 49 | /// Export the type definitions to the privided directory. Will return a list of top level 50 | /// dependencies that must be imported in order to use this type. 51 | pub fn export_with_dependencies( 52 | out_dir: impl AsRef, 53 | ) -> Result, ExportError> { 54 | // Set up an exporter 55 | let mut exporter = TypeListExporter::new(&out_dir); 56 | 57 | // Export all dependencies and generics for the type 58 | T::visit_dependencies(&mut exporter); 59 | T::visit_generics(&mut exporter); 60 | 61 | let mut dependencies = exporter.into_inner(); 62 | 63 | // Make sure the top-level type isn't a primitive, so it can be exported. 64 | if T::output_path().is_some() { 65 | // Can directly export the type (and dependencies)! 66 | T::export_all_to(&out_dir)?; 67 | 68 | // Only the top level type is required to be imported 69 | dependencies.push(Dependency::from_ty::().expect("type is non-primitive")); 70 | } 71 | 72 | Ok(dependencies) 73 | } 74 | -------------------------------------------------------------------------------- /src/builder/ty/util/mod.rs: -------------------------------------------------------------------------------- 1 | mod exporter; 2 | mod qubit_type; 3 | 4 | pub use exporter::*; 5 | pub use qubit_type::*; 6 | -------------------------------------------------------------------------------- /src/builder/ty/util/qubit_type.rs: -------------------------------------------------------------------------------- 1 | /// Name of the Qubit NPM package. 2 | const CLIENT_PACKAGE: &str = "@qubit-rs/client"; 3 | 4 | /// Built-in Qubit types. 5 | pub enum QubitType { 6 | Query, 7 | Mutation, 8 | Subscription, 9 | } 10 | 11 | impl QubitType { 12 | /// Produce the package and exported type corresponding to a Qubit type. 13 | pub fn to_ts(&self) -> (String, String) { 14 | match self { 15 | Self::Query => (CLIENT_PACKAGE.to_string(), "Query".to_string()), 16 | Self::Mutation => (CLIENT_PACKAGE.to_string(), "Mutation".to_string()), 17 | Self::Subscription => (CLIENT_PACKAGE.to_string(), "Subscription".to_string()), 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/header.txt: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // @ts-nocheck 3 | /* @@@@@@@@@@@@@ & ############### 4 | @@@@@@@@@@@@@@ &&& ############### 5 | @@@@@@@@@@@@@@ &&&&& ############### 6 | ############### &&&&& ############### 7 | ######## Generated by Qubit! ######## 8 | ############### &&&&& ############### 9 | ############### &&&&& @@@@@@@@@@@@@@ 10 | ############### && @@@@@@@@@@@@@@ 11 | ############### & @@@@@@@@@@@@@ */ 12 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod builder; 2 | pub mod server; 3 | 4 | pub use qubit_macros::*; 5 | 6 | pub use builder::*; 7 | pub use server::*; 8 | -------------------------------------------------------------------------------- /src/server/error.rs: -------------------------------------------------------------------------------- 1 | pub use jsonrpsee::types::error::ErrorCode; 2 | use jsonrpsee::{types::ErrorObjectOwned, IntoResponse}; 3 | use serde::Serialize; 4 | use serde_json::Value; 5 | 6 | /// An RPC error response. [See documentation](https://www.jsonrpc.org/specification#response_object). 7 | /// for addtional information. 8 | #[derive(Debug, Clone)] 9 | pub struct RpcError { 10 | /// Error code. 11 | pub code: ErrorCode, 12 | 13 | /// Message describing error. 14 | pub message: String, 15 | 16 | /// Optional serialisable data to include with the error. 17 | pub data: Option, 18 | } 19 | 20 | /// Convert into [`jsonrpsee::types::ErrorObjectOwned`]. 21 | impl From for ErrorObjectOwned { 22 | fn from(rpc_error: RpcError) -> Self { 23 | Self::from(&rpc_error) 24 | } 25 | } 26 | 27 | /// Convert into [`jsonrpsee::types::ErrorObjectOwned`]. 28 | impl From<&RpcError> for ErrorObjectOwned { 29 | fn from(rpc_error: &RpcError) -> Self { 30 | Self::owned( 31 | rpc_error.code.code(), 32 | &rpc_error.message, 33 | rpc_error.data.clone(), 34 | ) 35 | } 36 | } 37 | 38 | /// Allow for [`RpcError`] to directly be returned from handlers. 39 | impl IntoResponse for RpcError { 40 | type Output = ::Output; 41 | 42 | fn into_response(self) -> jsonrpsee::ResponsePayload<'static, Self::Output> { 43 | ErrorObjectOwned::from(self).into_response() 44 | } 45 | } 46 | 47 | impl Serialize for RpcError { 48 | fn serialize(&self, serializer: S) -> Result 49 | where 50 | S: serde::Serializer, 51 | { 52 | ErrorObjectOwned::from(self).serialize(serializer) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/server/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod router; 3 | 4 | pub use error::*; 5 | pub use http::Extensions; 6 | pub use router::{Router, ServerHandle}; 7 | 8 | /// Context can be built from request information by implementing the following trait. The 9 | /// extensions are passed in from the request (see [`Extensions`]), which can be added using tower 10 | /// middleware. 11 | #[trait_variant::make(Send)] 12 | pub trait FromRequestExtensions 13 | where 14 | Self: Sized, 15 | { 16 | /// Using the provided context and extensions, build a new extension. 17 | async fn from_request_extensions(ctx: Ctx, extensions: Extensions) -> Result; 18 | } 19 | 20 | impl FromRequestExtensions for Ctx { 21 | async fn from_request_extensions(ctx: Ctx, _extensions: Extensions) -> Result { 22 | Ok(ctx) 23 | } 24 | } 25 | 26 | /// Utility type to describe the kind of request. 27 | #[derive(Clone, Copy, Debug)] 28 | pub(crate) enum RequestKind { 29 | /// Query requests, which can be made with `GET` or `POST` requests. 30 | Query, 31 | /// Mutation requests, which can be made only with `POST` requests. 32 | Mutation, 33 | /// Any type of request. 34 | Any, 35 | } 36 | 37 | impl PartialEq for RequestKind { 38 | fn eq(&self, other: &Self) -> bool { 39 | matches!( 40 | (self, other), 41 | (Self::Any, _) 42 | | (_, Self::Any) 43 | | (Self::Query, Self::Query) 44 | | (Self::Mutation, Self::Mutation) 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/server/router.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::{collections::HashSet, convert::Infallible, fmt::Write as _, fs, path::Path}; 3 | 4 | use axum::body::Body; 5 | use futures::FutureExt; 6 | use http::{HeaderValue, Method, Request}; 7 | use jsonrpsee::server::ws::is_upgrade_request; 8 | pub use jsonrpsee::server::ServerHandle; 9 | use jsonrpsee::RpcModule; 10 | use tower::service_fn; 11 | use tower::Service; 12 | use tower::ServiceBuilder; 13 | 14 | use crate::builder::*; 15 | use crate::RequestKind; 16 | 17 | /// Router for the RPC server. Can have different handlers attached to it, as well as nested 18 | /// routers in order to create a hierarchy. It is also capable of generating its own type, suitable 19 | /// for consumption by a TypeScript client. 20 | #[derive(Clone)] 21 | pub struct Router { 22 | nested_routers: Vec<(&'static str, Router)>, 23 | handlers: Vec>, 24 | } 25 | 26 | impl Router 27 | where 28 | Ctx: Clone + Send + Sync + 'static, 29 | { 30 | /// Create a new instance of the router. 31 | pub fn new() -> Self { 32 | Self::default() 33 | } 34 | 35 | /// Attach a handler to the router. 36 | pub fn handler>(mut self, handler: H) -> Self { 37 | self.handlers.push(HandlerCallbacks::from_handler(handler)); 38 | 39 | self 40 | } 41 | 42 | /// Nest another router within this router, under the provided namespace. 43 | pub fn nest(mut self, namespace: &'static str, router: Router) -> Self { 44 | self.nested_routers.push((namespace, router)); 45 | 46 | self 47 | } 48 | 49 | /// Write required bindings for this router the the provided directory. The directory will be 50 | /// cleared, so anything within will be lost. 51 | pub fn write_bindings_to_dir(&self, out_dir: impl AsRef) { 52 | let out_dir = out_dir.as_ref(); 53 | 54 | // Make sure the directory path exists 55 | fs::create_dir_all(out_dir).unwrap(); 56 | 57 | // Clear the directiry 58 | fs::remove_dir_all(out_dir).unwrap(); 59 | 60 | // Re-create the directory 61 | fs::create_dir_all(out_dir).unwrap(); 62 | 63 | let header = String::from(include_str!("../header.txt")); 64 | 65 | // Export all the dependencies, and create their import statements 66 | let (imports, exports, _types) = self 67 | .get_handlers() 68 | .into_iter() 69 | .flat_map(|handler| { 70 | (handler.export_all_dependencies_to)(out_dir) 71 | .unwrap() 72 | .into_iter() 73 | .map(|dep| { 74 | ( 75 | format!("./{}", dep.output_path.to_str().unwrap()), 76 | dep.ts_name, 77 | ) 78 | }) 79 | .chain((handler.qubit_types)().into_iter().map(|ty| ty.to_ts())) 80 | }) 81 | .fold( 82 | (String::new(), String::new(), HashSet::new()), 83 | |(mut imports, mut exports, mut types), ty| { 84 | if types.contains(&ty) { 85 | return (imports, exports, types); 86 | } 87 | 88 | let (package, ty_name) = ty; 89 | 90 | writeln!( 91 | &mut imports, 92 | r#"import type {{ {ty_name} }} from "{package}";"#, 93 | ) 94 | .unwrap(); 95 | 96 | writeln!( 97 | &mut exports, 98 | r#"export type {{ {ty_name} }} from "{package}";"#, 99 | ) 100 | .unwrap(); 101 | 102 | types.insert((package, ty_name)); 103 | 104 | (imports, exports, types) 105 | }, 106 | ); 107 | 108 | // Generate server type 109 | let server_type = format!("export type QubitServer = {};", self.get_type()); 110 | 111 | // Write out index file 112 | fs::write( 113 | out_dir.join("index.ts"), 114 | [header, imports, exports, server_type] 115 | .into_iter() 116 | .filter(|part| !part.is_empty()) 117 | .collect::>() 118 | .join("\n"), 119 | ) 120 | .unwrap(); 121 | } 122 | 123 | /// Turn the router into a [`tower::Service`], so that it can be nested into a HTTP server. 124 | /// The provided `ctx` will be cloned for each request. 125 | pub fn to_service( 126 | self, 127 | ctx: Ctx, 128 | ) -> ( 129 | impl Service< 130 | hyper::Request, 131 | Response = jsonrpsee::server::HttpResponse, 132 | Error = Infallible, 133 | Future = impl Send, 134 | > + Clone, 135 | ServerHandle, 136 | ) { 137 | // Generate the stop and server handles for the service 138 | let (stop_handle, server_handle) = jsonrpsee::server::stop_channel(); 139 | 140 | // Build out the RPC module into a service 141 | let mut service = jsonrpsee::server::Server::builder() 142 | .set_http_middleware(ServiceBuilder::new().map_request(|mut req: Request<_>| { 143 | // Check if this is a GET request, and if it is convert it to a regular POST 144 | let request_type = if matches!(req.method(), &Method::GET) 145 | && !is_upgrade_request(&req) 146 | { 147 | // Change this request into a regular POST request, and indicate that it should 148 | // be a query. 149 | *req.method_mut() = Method::POST; 150 | 151 | // Update the headers 152 | let headers = req.headers_mut(); 153 | headers.insert( 154 | hyper::header::CONTENT_TYPE, 155 | HeaderValue::from_static("application/json"), 156 | ); 157 | headers.insert( 158 | hyper::header::ACCEPT, 159 | HeaderValue::from_static("application/json"), 160 | ); 161 | 162 | // Convert the `input` field of the query string into the request body 163 | if let Some(body) = req 164 | // Extract the query string 165 | .uri() 166 | .query() 167 | // Parse the query string 168 | .and_then(|query| serde_qs::from_str::>(query).ok()) 169 | // Take out the input 170 | .and_then(|mut query| query.remove("input")) 171 | // URL decode the input 172 | .map(|input| urlencoding::decode(&input).unwrap_or_default().to_string()) 173 | { 174 | // Set the request body 175 | *req.body_mut() = Body::from(body); 176 | } 177 | 178 | RequestKind::Query 179 | } else { 180 | RequestKind::Any 181 | }; 182 | 183 | // Set the request kind 184 | req.extensions_mut().insert(request_type); 185 | 186 | req 187 | })) 188 | .to_service_builder() 189 | .build(self.build_rpc_module(ctx, None), stop_handle); 190 | 191 | ( 192 | service_fn(move |req: hyper::Request| { 193 | let call = service.call(req); 194 | 195 | async move { 196 | match call.await { 197 | Ok(response) => Ok::<_, Infallible>(response), 198 | Err(_) => unreachable!(), 199 | } 200 | } 201 | .boxed() 202 | }), 203 | server_handle, 204 | ) 205 | } 206 | 207 | /// Get the TypeScript type of this router. 208 | fn get_type(&self) -> String { 209 | // Generate types of all handlers, including nested handlers 210 | let handlers = self 211 | .handlers 212 | .iter() 213 | // Generate types of handlers 214 | .map(|handler| { 215 | let handler_type = (handler.get_type)(); 216 | format!("{}: {}", handler_type.name, handler_type.signature) 217 | }) 218 | .chain( 219 | // Generate types of nested routers 220 | self.nested_routers.iter().map(|(namespace, router)| { 221 | let router_type = router.get_type(); 222 | format!("{namespace}: {router_type}") 223 | }), 224 | ) 225 | .collect::>(); 226 | 227 | // Generate the router type 228 | format!("{{ {} }}", handlers.join(", ")) 229 | } 230 | 231 | /// Generate a [`jsonrpsee::RpcModule`] from this router, with an optional namespace. 232 | /// 233 | /// Uses an [`RpcBuilder`] to incrementally add query and subcription handlers, passing the 234 | /// instance through to the [`HandlerCallbacks`] attached to this router, so they can register 235 | /// against the [`RpcModule`] (including namespacing). 236 | fn build_rpc_module(self, ctx: Ctx, namespace: Option<&'static str>) -> RpcModule { 237 | let rpc_module = self 238 | .handlers 239 | .into_iter() 240 | .fold( 241 | RpcBuilder::with_namespace(ctx.clone(), namespace), 242 | |rpc_builder, handler| (handler.register)(rpc_builder), 243 | ) 244 | .build(); 245 | 246 | // Generate modules for nested routers, and merge them with the existing router 247 | let parent_namespace = namespace; 248 | self.nested_routers 249 | .into_iter() 250 | .fold(rpc_module, |mut rpc_module, (namespace, router)| { 251 | let namespace = if let Some(parent_namespace) = parent_namespace { 252 | // WARN: Probably not great leaking here 253 | format!("{parent_namespace}.{namespace}").leak() 254 | } else { 255 | namespace 256 | }; 257 | 258 | rpc_module 259 | .merge(router.build_rpc_module(ctx.clone(), Some(namespace))) 260 | .unwrap(); 261 | 262 | rpc_module 263 | }) 264 | } 265 | 266 | fn get_handlers(&self) -> Vec> { 267 | self.handlers 268 | .iter() 269 | .cloned() 270 | .chain( 271 | self.nested_routers 272 | .iter() 273 | .flat_map(|(_, router)| router.get_handlers()), 274 | ) 275 | .collect() 276 | } 277 | } 278 | 279 | impl Default for Router { 280 | fn default() -> Self { 281 | Self { 282 | nested_routers: Default::default(), 283 | handlers: Default::default(), 284 | } 285 | } 286 | } 287 | --------------------------------------------------------------------------------