├── .gitignore ├── test-suite ├── gzip │ ├── client │ │ ├── src │ │ │ └── lib.rs │ │ ├── build.rs │ │ ├── Cargo.toml │ │ └── tests │ │ │ └── web.rs │ ├── server │ │ ├── build.rs │ │ ├── Cargo.toml │ │ └── src │ │ │ └── main.rs │ └── proto │ │ └── echo.proto └── simple │ ├── client │ ├── src │ │ └── lib.rs │ ├── build.rs │ ├── Cargo.toml │ └── tests │ │ └── web.rs │ ├── server │ ├── build.rs │ ├── Cargo.toml │ └── src │ │ └── main.rs │ └── proto │ └── echo.proto ├── README.tpl ├── src ├── options │ ├── redirect.rs │ ├── credentials.rs │ ├── mode.rs │ ├── cache.rs │ ├── referrer_policy.rs │ └── mod.rs ├── fetch.rs ├── client.rs ├── content_type.rs ├── abort_guard.rs ├── body_stream.rs ├── lib.rs ├── error.rs ├── call.rs └── response_body.rs ├── .github └── workflows │ ├── test.yml │ ├── gzip.yml │ └── build.yml ├── LICENSE_MIT ├── Cargo.toml ├── Justfile ├── README.md └── LICENSE_APACHE /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/*.rs.bk 3 | Cargo.lock 4 | .cargo 5 | .config 6 | -------------------------------------------------------------------------------- /test-suite/gzip/client/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod proto { 2 | tonic::include_proto!("echo"); 3 | } 4 | -------------------------------------------------------------------------------- /test-suite/simple/client/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod proto { 2 | tonic::include_proto!("echo"); 3 | } 4 | -------------------------------------------------------------------------------- /test-suite/gzip/server/build.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | fn main() -> io::Result<()> { 4 | tonic_prost_build::configure() 5 | .build_server(true) 6 | .build_client(false) 7 | .compile_protos(&["echo.proto"], &["../proto"]) 8 | } 9 | -------------------------------------------------------------------------------- /test-suite/simple/server/build.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | fn main() -> io::Result<()> { 4 | tonic_prost_build::configure() 5 | .build_server(true) 6 | .build_client(false) 7 | .compile_protos(&["echo.proto"], &["../proto"]) 8 | } 9 | -------------------------------------------------------------------------------- /test-suite/gzip/client/build.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | fn main() -> io::Result<()> { 4 | tonic_prost_build::configure() 5 | .build_server(false) 6 | .build_transport(false) 7 | .build_client(true) 8 | .compile_protos(&["echo.proto"], &["../proto"]) 9 | } 10 | -------------------------------------------------------------------------------- /test-suite/simple/client/build.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | fn main() -> io::Result<()> { 4 | tonic_prost_build::configure() 5 | .build_server(false) 6 | .build_transport(false) 7 | .build_client(true) 8 | .compile_protos(&["echo.proto"], &["../proto"]) 9 | } 10 | -------------------------------------------------------------------------------- /test-suite/gzip/proto/echo.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package echo; 4 | 5 | service Echo { 6 | rpc Echo (EchoRequest) returns (EchoResponse) {} 7 | 8 | rpc EchoStream (EchoRequest) returns (stream EchoResponse) {} 9 | 10 | rpc EchoInfiniteStream (EchoRequest) returns (stream EchoResponse) {} 11 | } 12 | 13 | message EchoRequest { 14 | string message = 1; 15 | } 16 | 17 | message EchoResponse { 18 | string message = 1; 19 | } 20 | -------------------------------------------------------------------------------- /README.tpl: -------------------------------------------------------------------------------- 1 | # {{crate}} 2 | 3 | {{readme}} 4 | 5 | ## License 6 | 7 | Licensed under either of 8 | 9 | - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) 10 | - MIT license ([LICENSE-MIT](LICENSE-MIT)) 11 | 12 | at your option. 13 | 14 | ## Contribution 15 | 16 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as 17 | defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. 18 | -------------------------------------------------------------------------------- /test-suite/gzip/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client" 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 | prost = "0.14" 10 | tonic = { version = "0.14", default-features = false, features = ["codegen", "gzip"] } 11 | tonic-prost = "0.14" 12 | 13 | [build-dependencies] 14 | tonic-prost-build = { version = "0.14", default-features = false } 15 | 16 | [dev-dependencies] 17 | tonic-web-wasm-client = { path = "../../.." } 18 | wasm-bindgen-test = "0.3" 19 | -------------------------------------------------------------------------------- /test-suite/simple/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "client" 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 | prost = "0.14" 10 | tonic = { version = "0.14", default-features = false, features = ["codegen"] } 11 | tonic-prost = { version = "0.14" } 12 | 13 | [build-dependencies] 14 | tonic-prost-build = { version = "0.14", default-features = false } 15 | 16 | [dev-dependencies] 17 | tonic-web-wasm-client = { path = "../../.." } 18 | wasm-bindgen-test = "0.3" 19 | -------------------------------------------------------------------------------- /test-suite/simple/proto/echo.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package echo; 4 | 5 | service Echo { 6 | rpc Echo (EchoRequest) returns (EchoResponse) {} 7 | 8 | rpc EchoStream (EchoRequest) returns (stream EchoResponse) {} 9 | 10 | rpc EchoInfiniteStream (EchoRequest) returns (stream EchoResponse) {} 11 | 12 | rpc EchoErrorResponse (EchoRequest) returns (EchoResponse) {} 13 | 14 | rpc EchoTimeout (EchoRequest) returns (EchoResponse) {} 15 | } 16 | 17 | message EchoRequest { 18 | string message = 1; 19 | } 20 | 21 | message EchoResponse { 22 | string message = 1; 23 | } 24 | -------------------------------------------------------------------------------- /test-suite/simple/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "server" 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 | futures-core = "0.3" 10 | http = "1" 11 | prost = "0.14" 12 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 13 | tonic = "0.14" 14 | tonic-prost = "0.14" 15 | tonic-web = "0.14" 16 | tower-http = { version = "0.6", default-features = false, features = ["cors"] } 17 | 18 | [build-dependencies] 19 | tonic-prost-build = { version = "0.14" } 20 | -------------------------------------------------------------------------------- /test-suite/gzip/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "server" 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 | futures-core = "0.3" 10 | http = "1" 11 | prost = "0.14" 12 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 13 | tonic = { version = "0.14", features = ["gzip"] } 14 | tonic-prost = "0.14" 15 | tonic-web = "0.14" 16 | tower-http = { version = "0.6", default-features = false, features = ["cors"] } 17 | 18 | [build-dependencies] 19 | tonic-prost-build = "0.14" 20 | -------------------------------------------------------------------------------- /src/options/redirect.rs: -------------------------------------------------------------------------------- 1 | use web_sys::RequestRedirect; 2 | 3 | /// Request's redirect mode 4 | #[derive(Debug, Clone, Copy, Default)] 5 | pub enum Redirect { 6 | /// Follow all redirects incurred when fetching a resource. 7 | #[default] 8 | Follow, 9 | 10 | /// Return a network error when a request is met with a redirect. 11 | Error, 12 | 13 | /// Retrieves an opaque-redirect filtered response when a request is met with a redirect, to allow a service worker 14 | /// to replay the redirect offline. The response is otherwise indistinguishable from a network error, to not violate 15 | /// atomic HTTP redirect handling. 16 | Manual, 17 | } 18 | 19 | impl From for RequestRedirect { 20 | fn from(value: Redirect) -> Self { 21 | match value { 22 | Redirect::Follow => RequestRedirect::Follow, 23 | Redirect::Error => RequestRedirect::Error, 24 | Redirect::Manual => RequestRedirect::Manual, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/options/credentials.rs: -------------------------------------------------------------------------------- 1 | use web_sys::RequestCredentials; 2 | 3 | /// Request's credential mode 4 | #[derive(Debug, Clone, Copy, Default)] 5 | pub enum Credentials { 6 | /// Excludes credentials from this request, and causes any credentials sent back in the response to be ignored. 7 | Omit, 8 | 9 | /// Include credentials with requests made to same-origin URLs, and use any credentials sent back in responses from 10 | /// same-origin URLs. 11 | #[default] 12 | SameOrigin, 13 | 14 | /// Always includes credentials with this request, and always use any credentials sent back in the response. 15 | Include, 16 | } 17 | 18 | impl From for RequestCredentials { 19 | fn from(credentials: Credentials) -> Self { 20 | match credentials { 21 | Credentials::Omit => RequestCredentials::Omit, 22 | Credentials::SameOrigin => RequestCredentials::SameOrigin, 23 | Credentials::Include => RequestCredentials::Include, 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Integration Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout sources 15 | uses: actions/checkout@v3 16 | - name: Install Rust 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | profile: minimal 20 | toolchain: stable 21 | target: wasm32-unknown-unknown 22 | - name: Install wasm-pack 23 | run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh 24 | - name: Install just 25 | uses: extractions/setup-just@v1 26 | - name: Install Protoc 27 | uses: arduino/setup-protoc@v1 28 | - name: Build test `tonic-web` server 29 | run: just build-test-server 30 | - name: Run test `tonic-web` server 31 | run: just start-test-server & 32 | - name: Run headless browser test 33 | run: just test-headless 34 | -------------------------------------------------------------------------------- /.github/workflows/gzip.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | name: Integration Tests with gzip compression 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout sources 15 | uses: actions/checkout@v3 16 | - name: Install Rust 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | profile: minimal 20 | toolchain: stable 21 | target: wasm32-unknown-unknown 22 | - name: Install wasm-pack 23 | run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh 24 | - name: Install just 25 | uses: extractions/setup-just@v1 26 | - name: Install Protoc 27 | uses: arduino/setup-protoc@v1 28 | - name: Build test `tonic-web` server 29 | run: just build-gzip-test-server 30 | - name: Run test `tonic-web` server 31 | run: just start-gzip-test-server & 32 | - name: Run headless browser test 33 | run: just test-gzip-headless 34 | -------------------------------------------------------------------------------- /src/fetch.rs: -------------------------------------------------------------------------------- 1 | use js_sys::Promise; 2 | use wasm_bindgen::{prelude::wasm_bindgen, JsCast, JsValue}; 3 | use wasm_bindgen_futures::JsFuture; 4 | use web_sys::{Request, RequestInit, Response}; 5 | 6 | use crate::Error; 7 | 8 | #[wasm_bindgen] 9 | extern "C" { 10 | #[wasm_bindgen(js_name = fetch)] 11 | fn fetch_with_request_and_init(input: &Request, init: &RequestInit) -> Promise; 12 | } 13 | 14 | fn js_fetch(request: &Request, init: &RequestInit) -> Promise { 15 | let global = js_sys::global(); 16 | 17 | if let Ok(true) = js_sys::Reflect::has(&global, &JsValue::from_str("ServiceWorkerGlobalScope")) 18 | { 19 | global 20 | .unchecked_into::() 21 | .fetch_with_request_and_init(request, init) 22 | } else { 23 | fetch_with_request_and_init(request, init) 24 | } 25 | } 26 | 27 | pub async fn fetch(request: &Request, init: &RequestInit) -> Result { 28 | let js_response = JsFuture::from(js_fetch(request, init)) 29 | .await 30 | .map_err(Error::js_error)?; 31 | 32 | Ok(js_response.unchecked_into()) 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE_MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Devashish Dixit 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /src/options/mode.rs: -------------------------------------------------------------------------------- 1 | use web_sys::RequestMode; 2 | 3 | /// Request's mode 4 | #[derive(Debug, Clone, Copy, Default)] 5 | pub enum Mode { 6 | /// Used to ensure requests are made to same-origin URLs. Fetch will return a network error if the request is not 7 | /// made to a same-origin URL. 8 | SameOrigin, 9 | 10 | /// For requests whose response tainting gets set to "cors", makes the request a CORS request — in which case, fetch 11 | /// will return a network error if the requested resource does not understand the CORS protocol, or if the requested 12 | /// resource is one that intentionally does not participate in the CORS protocol. 13 | Cors, 14 | 15 | /// Restricts requests to using CORS-safelisted methods and CORS-safelisted request-headers. Upon success, fetch 16 | /// will return an opaque filtered response. 17 | #[default] 18 | NoCors, 19 | 20 | /// This is a special mode used only when navigating between documents. 21 | Navigate, 22 | } 23 | 24 | impl From for RequestMode { 25 | fn from(value: Mode) -> Self { 26 | match value { 27 | Mode::SameOrigin => RequestMode::SameOrigin, 28 | Mode::Cors => RequestMode::Cors, 29 | Mode::NoCors => RequestMode::NoCors, 30 | Mode::Navigate => RequestMode::Navigate, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tonic-web-wasm-client" 3 | version = "0.8.0" 4 | authors = ["Devashish Dixit "] 5 | license = "MIT/Apache-2.0" 6 | description = "grpc-web implementation for use by tonic clients in browsers via webassembly" 7 | homepage = "https://github.com/devashishdxt/tonic-web-wasm-client" 8 | repository = "https://github.com/devashishdxt/tonic-web-wasm-client" 9 | readme = "README.md" 10 | categories = ["web-programming", "network-programming", "asynchronous"] 11 | keywords = ["grpc", "grpc-web", "tonic", "wasm"] 12 | edition = "2021" 13 | 14 | [dependencies] 15 | base64 = "0.22" 16 | byteorder = "1" 17 | bytes = "1" 18 | futures-util = { version = "0.3", default-features = false } 19 | http = "1" 20 | http-body = "1" 21 | http-body-util = "0.1" 22 | httparse = "1" 23 | js-sys = "0.3" 24 | pin-project = "1" 25 | thiserror = "2" 26 | tonic = { version = "0.14", default-features = false } 27 | tower-service = "0.3" 28 | wasm-bindgen = "0.2" 29 | wasm-bindgen-futures = "0.4" 30 | wasm-streams = "0.4" 31 | web-sys = { version = "0.3", features = [ 32 | "AbortController", 33 | "AbortSignal", 34 | "Headers", 35 | "ReadableStream", 36 | "ReferrerPolicy", 37 | "Request", 38 | "RequestCache", 39 | "RequestCredentials", 40 | "RequestInit", 41 | "RequestMode", 42 | "RequestRedirect", 43 | "Response", 44 | "ServiceWorkerGlobalScope", 45 | ] } 46 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | # Builds `tonic-web-wasm-client` 2 | build: 3 | @echo 'Building...' 4 | cargo build --target wasm32-unknown-unknown 5 | 6 | # Builds test `tonic-web` server 7 | build-test-server: 8 | @echo 'Building test server...' 9 | cd test-suite/simple/server && cargo build 10 | 11 | # Starts test `tonic-web` server 12 | start-test-server: 13 | @echo 'Starting test server...' 14 | cd test-suite/simple/server && cargo run 15 | 16 | # Runs browser tests for `tonic-web-wasm-client` 17 | test: 18 | @echo 'Testing...' 19 | cd test-suite/simple/client && wasm-pack test --chrome 20 | 21 | # Runs browser tests for `tonic-web-wasm-server` (in headless mode) 22 | test-headless: 23 | @echo 'Testing...' 24 | cd test-suite/simple/client && wasm-pack test --headless --chrome 25 | 26 | # Builds test `tonic-web` server (with compression enabled: gzip) 27 | build-gzip-test-server: 28 | @echo 'Building test server...' 29 | cd test-suite/gzip/server && cargo build 30 | 31 | # Starts test `tonic-web` server (with compression enabled: gzip) 32 | start-gzip-test-server: 33 | @echo 'Starting test server...' 34 | cd test-suite/gzip/server && cargo run 35 | 36 | # Runs browser tests for `tonic-web-wasm-client` (with compression enabled: gzip) 37 | test-gzip: 38 | @echo 'Testing...' 39 | cd test-suite/gzip/client && wasm-pack test --chrome 40 | 41 | # Runs browser tests for `tonic-web-wasm-server` (in headless mode) (with compression enabled: gzip) 42 | test-gzip-headless: 43 | @echo 'Testing...' 44 | cd test-suite/gzip/client && wasm-pack test --headless --chrome 45 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | future::Future, 3 | pin::Pin, 4 | task::{Context, Poll}, 5 | }; 6 | 7 | use http::{Request, Response}; 8 | use tonic::body::Body; 9 | use tower_service::Service; 10 | 11 | use crate::{call::call, options::FetchOptions, Error, ResponseBody}; 12 | 13 | /// `grpc-web` based transport layer for `tonic` clients 14 | #[derive(Debug, Clone)] 15 | pub struct Client { 16 | base_url: String, 17 | options: Option, 18 | } 19 | 20 | impl Client { 21 | /// Creates a new client 22 | pub fn new(base_url: String) -> Self { 23 | Self { 24 | base_url, 25 | options: None, 26 | } 27 | } 28 | 29 | /// Creates a new client with options 30 | pub fn new_with_options(base_url: String, options: FetchOptions) -> Self { 31 | Self { 32 | base_url, 33 | options: Some(options), 34 | } 35 | } 36 | 37 | /// Sets the options for the client 38 | pub fn with_options(&mut self, options: FetchOptions) -> &mut Self { 39 | self.options = Some(options); 40 | self 41 | } 42 | } 43 | 44 | impl Service> for Client { 45 | type Response = Response; 46 | 47 | type Error = Error; 48 | 49 | type Future = Pin>>>; 50 | 51 | fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { 52 | Poll::Ready(Ok(())) 53 | } 54 | 55 | fn call(&mut self, request: Request) -> Self::Future { 56 | Box::pin(call( 57 | self.base_url.clone(), 58 | request, 59 | self.options.clone().unwrap_or_default(), 60 | )) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/content_type.rs: -------------------------------------------------------------------------------- 1 | use crate::Error; 2 | 3 | const GRPC_WEB: &str = "application/grpc-web"; 4 | const GRPC_WEB_PROTO: &str = "application/grpc-web+proto"; 5 | const GRPC_WEB_TEXT: &str = "application/grpc-web-text"; 6 | const GRPC_WEB_TEXT_PROTO: &str = "application/grpc-web-text+proto"; 7 | 8 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 9 | pub enum Encoding { 10 | Base64, 11 | None, 12 | } 13 | 14 | impl Encoding { 15 | pub fn from_content_type(content_type: &str) -> Result { 16 | for ct in content_type.split(';') { 17 | match ct.trim() { 18 | GRPC_WEB_TEXT | GRPC_WEB_TEXT_PROTO => return Ok(Encoding::Base64), 19 | GRPC_WEB | GRPC_WEB_PROTO => return Ok(Encoding::None), 20 | _ => continue, 21 | } 22 | } 23 | Err(Error::InvalidContentType(content_type.to_owned())) 24 | } 25 | } 26 | 27 | #[cfg(test)] 28 | mod tests { 29 | use super::*; 30 | 31 | #[test] 32 | fn test_encoding_from_content_type() { 33 | let vals = [ 34 | (GRPC_WEB, Encoding::None), 35 | (GRPC_WEB_PROTO, Encoding::None), 36 | (GRPC_WEB_TEXT, Encoding::Base64), 37 | (GRPC_WEB_TEXT_PROTO, Encoding::Base64), 38 | ("application/grpc-web+proto;charset=utf-8", Encoding::None), 39 | ("application/grpc-web+proto; charset=utf-8", Encoding::None), 40 | ("charset=utf-8; application/grpc-web+proto", Encoding::None), 41 | ]; 42 | for (content_type, expected) in vals.iter() { 43 | assert_eq!( 44 | Encoding::from_content_type(content_type).ok(), 45 | Some(*expected) 46 | ); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout sources 15 | uses: actions/checkout@v3 16 | - name: Install Rust 17 | uses: actions-rs/toolchain@v1 18 | with: 19 | profile: minimal 20 | toolchain: stable 21 | target: wasm32-unknown-unknown 22 | - name: Build 23 | uses: actions-rs/cargo@v1 24 | with: 25 | command: build 26 | args: --target wasm32-unknown-unknown 27 | fmt: 28 | name: Format Check 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout sources 32 | uses: actions/checkout@v3 33 | - name: Install Rust 34 | uses: actions-rs/toolchain@v1 35 | with: 36 | profile: minimal 37 | toolchain: stable 38 | components: rustfmt 39 | target: wasm32-unknown-unknown 40 | - name: Run cargo fmt 41 | uses: actions-rs/cargo@v1 42 | with: 43 | command: fmt 44 | args: --all -- --check 45 | clippy: 46 | name: Clippy Check 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Checkout sources 50 | uses: actions/checkout@v3 51 | - name: Install Rust 52 | uses: actions-rs/toolchain@v1 53 | with: 54 | profile: minimal 55 | toolchain: stable 56 | components: clippy 57 | target: wasm32-unknown-unknown 58 | - name: Run cargo clippy 59 | uses: actions-rs/cargo@v1 60 | with: 61 | command: clippy 62 | args: --target wasm32-unknown-unknown -- -D warnings 63 | -------------------------------------------------------------------------------- /src/abort_guard.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use js_sys::Function; 4 | use wasm_bindgen::{ 5 | prelude::{wasm_bindgen, Closure}, 6 | JsCast, JsValue, 7 | }; 8 | use web_sys::{AbortController, AbortSignal}; 9 | 10 | use crate::Error; 11 | 12 | #[wasm_bindgen] 13 | extern "C" { 14 | #[wasm_bindgen(js_name = "setTimeout")] 15 | fn set_timeout(handler: &Function, timeout: i32) -> JsValue; 16 | 17 | #[wasm_bindgen(js_name = "clearTimeout")] 18 | fn clear_timeout(handle: JsValue) -> JsValue; 19 | } 20 | 21 | /// A guard that cancels a fetch request when dropped. 22 | pub struct AbortGuard { 23 | ctrl: AbortController, 24 | timeout: Option<(JsValue, Closure)>, 25 | } 26 | 27 | impl AbortGuard { 28 | pub fn new() -> Result { 29 | Ok(AbortGuard { 30 | ctrl: AbortController::new().map_err(Error::js_error)?, 31 | timeout: None, 32 | }) 33 | } 34 | 35 | pub fn signal(&self) -> AbortSignal { 36 | self.ctrl.signal() 37 | } 38 | 39 | pub fn timeout(&mut self, timeout: Duration) { 40 | let ctrl = self.ctrl.clone(); 41 | let abort = Closure::once(move || { 42 | ctrl.abort_with_reason(&"tonic_web_wasm_client::Error::TimedOut".into()) 43 | }); 44 | let timeout = set_timeout( 45 | abort.as_ref().unchecked_ref::(), 46 | timeout.as_millis().try_into().expect("timeout"), 47 | ); 48 | if let Some((id, _)) = self.timeout.replace((timeout, abort)) { 49 | clear_timeout(id); 50 | } 51 | } 52 | } 53 | 54 | impl Drop for AbortGuard { 55 | fn drop(&mut self) { 56 | self.ctrl.abort(); 57 | 58 | if let Some((id, _)) = self.timeout.take() { 59 | clear_timeout(id); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/body_stream.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | pin::Pin, 3 | task::{Context, Poll}, 4 | }; 5 | 6 | use bytes::Bytes; 7 | use futures_util::{stream::empty, Stream, TryStreamExt}; 8 | use http_body::{Body, Frame}; 9 | use js_sys::Uint8Array; 10 | use wasm_streams::readable::IntoStream; 11 | 12 | use crate::{abort_guard::AbortGuard, Error}; 13 | 14 | pub struct BodyStream { 15 | body_stream: Pin>>>, 16 | _abort: Option, 17 | } 18 | 19 | impl BodyStream { 20 | pub fn new(body_stream: IntoStream<'static>, abort: AbortGuard) -> Self { 21 | let body_stream = body_stream 22 | .map_ok(|js_value| { 23 | let buffer = Uint8Array::new(&js_value); 24 | 25 | let mut bytes_vec = vec![0; buffer.length() as usize]; 26 | buffer.copy_to(&mut bytes_vec); 27 | 28 | bytes_vec.into() 29 | }) 30 | .map_err(Error::js_error); 31 | 32 | Self { 33 | body_stream: Box::pin(body_stream), 34 | _abort: Some(abort), 35 | } 36 | } 37 | 38 | pub fn empty() -> Self { 39 | let body_stream = empty(); 40 | 41 | Self { 42 | body_stream: Box::pin(body_stream), 43 | _abort: None, 44 | } 45 | } 46 | } 47 | 48 | impl Body for BodyStream { 49 | type Data = Bytes; 50 | 51 | type Error = Error; 52 | 53 | fn poll_frame( 54 | mut self: Pin<&mut Self>, 55 | cx: &mut Context<'_>, 56 | ) -> Poll, Self::Error>>> { 57 | match self.body_stream.as_mut().poll_next(cx) { 58 | Poll::Ready(maybe) => Poll::Ready(maybe.map(|result| result.map(Frame::data))), 59 | Poll::Pending => Poll::Pending, 60 | } 61 | } 62 | } 63 | 64 | unsafe impl Send for BodyStream {} 65 | unsafe impl Sync for BodyStream {} 66 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Rust implementation of [`grpc-web`](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) protocol that allows 2 | //! using [`tonic`](https://crates.io/crates/tonic) in browsers via webassembly. 3 | //! 4 | //! # Usage 5 | //! 6 | //! To use `tonic-web-wasm-client`, you need to add the following to your `Cargo.toml`: 7 | //! 8 | //! ```toml 9 | //! [dependencies] 10 | //! tonic-web-wasm-client = "0.8" 11 | //! ``` 12 | //! 13 | //! ## Example 14 | //! To use `tonic` gRPC clients in browser, compile your code with tonic's `transport` feature disabled (this will disable 15 | //! the default transport layer of tonic). Then initialize the query client as follows: 16 | //! 17 | //! ```rust,ignore 18 | //! use tonic_web_wasm_client::Client; 19 | //! 20 | //! let base_url = "http://localhost:9001"; // URL of the gRPC-web server 21 | //! let query_client = QueryClient::new(Client::new(base_url)); // `QueryClient` is the client generated by tonic 22 | //! 23 | //! let response = query_client.status().await; // Execute your queries the same way as you do with defaule transport layer 24 | //! ``` 25 | //! 26 | //! ## Building 27 | //! 28 | //! Since `tonic-web-wasm-client` is primarily intended for use in browsers, a crate that uses `tonic-web-wasm-client` 29 | //! can only be built for `wasm32` target architectures: 30 | //! 31 | //! ```shell 32 | //! cargo build --target wasm32-unknown-unknown 33 | //! ``` 34 | //! 35 | //! Other option is to create a `.cargo/config.toml` in your crate repository and add a build target there: 36 | //! 37 | //! ```toml 38 | //! [build] 39 | //! target = "wasm32-unknown-unknown" 40 | //! ``` 41 | //! 42 | //! ## Custom `Accept` header: 43 | //! 44 | //! This library allows you to set a custom `Accept` header for the requests. This can be useful if you need to specify 45 | //! a different content type for the responses. But, be aware that if you set a custom `Accept` header, the client may 46 | //! not be able to handle the response correctly. 47 | mod abort_guard; 48 | mod body_stream; 49 | mod call; 50 | mod client; 51 | mod content_type; 52 | mod error; 53 | mod fetch; 54 | pub mod options; 55 | mod response_body; 56 | 57 | pub use self::{client::Client, error::Error, response_body::ResponseBody}; 58 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use http::header::{InvalidHeaderName, InvalidHeaderValue, ToStrError}; 2 | use js_sys::Object; 3 | use thiserror::Error; 4 | use wasm_bindgen::{JsCast, JsValue}; 5 | 6 | /// Error type for `tonic-web-wasm-client` 7 | #[derive(Debug, Error)] 8 | pub enum Error { 9 | /// Base64 decode error 10 | #[error("base64 decode error")] 11 | Base64DecodeError(#[from] base64::DecodeError), 12 | /// Header parsing error 13 | #[error("failed to parse headers")] 14 | HeaderParsingError, 15 | /// Header value error 16 | #[error("failed to convert header value to string")] 17 | HeaderValueError(#[from] ToStrError), 18 | /// HTTP error 19 | #[error("HTTP error")] 20 | HttpError(#[from] http::Error), 21 | /// Invalid content type 22 | #[error("invalid content type: {0}")] 23 | InvalidContentType(String), 24 | /// Invalid header name 25 | #[error("invalid header name")] 26 | InvalidHeaderName(#[from] InvalidHeaderName), 27 | /// Invalid header value 28 | #[error("invalid header value")] 29 | InvalidHeaderValue(#[from] InvalidHeaderValue), 30 | /// JS API error 31 | #[error("JS API error: {0}")] 32 | JsError(String), 33 | /// Malformed response 34 | #[error("malformed response")] 35 | MalformedResponse, 36 | /// Missing `content-type` header in gRPC response 37 | #[error("missing content-type header in gRPC response")] 38 | MissingContentTypeHeader, 39 | /// Missing response body in HTTP call 40 | #[error("missing response body in HTTP call")] 41 | MissingResponseBody, 42 | /// gRPC error 43 | #[error("gRPC error")] 44 | TonicStatusError(#[from] tonic::Status), 45 | } 46 | 47 | impl Error { 48 | /// Initialize js error from js value 49 | pub(crate) fn js_error(value: JsValue) -> Self { 50 | let message = js_object_display(&value); 51 | 52 | if message.contains("tonic_web_wasm_client::Error::TimedOut") { 53 | Self::TonicStatusError(tonic::Status::deadline_exceeded("Request timed out")) 54 | } else { 55 | Self::JsError(message) 56 | } 57 | } 58 | } 59 | 60 | fn js_object_display(option: &JsValue) -> String { 61 | let object: &Object = option.unchecked_ref(); 62 | ToString::to_string(&object.to_string()) 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tonic-web-wasm-client 2 | 3 | Rust implementation of [`grpc-web`](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md) protocol that allows 4 | using [`tonic`](https://crates.io/crates/tonic) in browsers via webassembly. 5 | 6 | ## Usage 7 | 8 | To use `tonic-web-wasm-client`, you need to add the following to your `Cargo.toml`: 9 | 10 | ```toml 11 | [dependencies] 12 | tonic-web-wasm-client = "0.8" 13 | ``` 14 | 15 | ### Example 16 | To use `tonic` gRPC clients in browser, compile your code with tonic's `transport` feature disabled (this will disable 17 | the default transport layer of tonic). Then initialize the query client as follows: 18 | 19 | ```rust 20 | use tonic_web_wasm_client::Client; 21 | 22 | let base_url = "http://localhost:9001"; // URL of the gRPC-web server 23 | let query_client = QueryClient::new(Client::new(base_url)); // `QueryClient` is the client generated by tonic 24 | 25 | let response = query_client.status().await; // Execute your queries the same way as you do with defaule transport layer 26 | ``` 27 | 28 | ### Building 29 | 30 | Since `tonic-web-wasm-client` is primarily intended for use in browsers, a crate that uses `tonic-web-wasm-client` 31 | can only be built for `wasm32` target architectures: 32 | 33 | ```shell 34 | cargo build --target wasm32-unknown-unknown 35 | ``` 36 | 37 | Other option is to create a `.cargo/config.toml` in your crate repository and add a build target there: 38 | 39 | ```toml 40 | [build] 41 | target = "wasm32-unknown-unknown" 42 | ``` 43 | 44 | ### Custom `Accept` header: 45 | 46 | This library allows you to set a custom `Accept` header for the requests. This can be useful if you need to specify 47 | a different content type for the responses. But, be aware that if you set a custom `Accept` header, the client may 48 | not be able to handle the response correctly. 49 | 50 | ## License 51 | 52 | Licensed under either of 53 | 54 | - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE)) 55 | - MIT license ([LICENSE-MIT](LICENSE-MIT)) 56 | 57 | at your option. 58 | 59 | ## Contribution 60 | 61 | Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as 62 | defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. 63 | -------------------------------------------------------------------------------- /src/options/cache.rs: -------------------------------------------------------------------------------- 1 | use web_sys::RequestCache; 2 | 3 | /// Request's cache mode 4 | #[derive(Debug, Clone, Copy, Default)] 5 | pub enum Cache { 6 | /// Fetch will inspect the HTTP cache on the way to the network. If the HTTP cache contains a matching fresh 7 | /// response it will be returned. If the HTTP cache contains a matching stale-while-revalidate response it will be 8 | /// returned, and a conditional network fetch will be made to update the entry in the HTTP cache. If the HTTP cache 9 | /// contains a matching stale response, a conditional network fetch will be returned to update the entry in the HTTP 10 | /// cache. Otherwise, a non-conditional network fetch will be returned to update the entry in the HTTP cache. 11 | #[default] 12 | Default, 13 | 14 | /// Fetch behaves as if there is no HTTP cache at all. 15 | NoStore, 16 | 17 | /// Fetch behaves as if there is no HTTP cache on the way to the network. Ergo, it creates a normal request and 18 | /// updates the HTTP cache with the response. 19 | Reload, 20 | 21 | /// Fetch creates a conditional request if there is a response in the HTTP cache and a normal request otherwise. It 22 | /// then updates the HTTP cache with the response. 23 | NoCache, 24 | 25 | /// Fetch uses any response in the HTTP cache matching the request, not paying attention to staleness. If there was 26 | /// no response, it creates a normal request and updates the HTTP cache with the response. 27 | ForceCache, 28 | 29 | /// Fetch uses any response in the HTTP cache matching the request, not paying attention to staleness. If there was 30 | /// no response, it returns a network error. (Can only be used when request’s mode is "same-origin". Any cached 31 | /// redirects will be followed assuming request’s redirect mode is "follow" and the redirects do not violate 32 | /// request’s mode.) 33 | OnlyIfCached, 34 | } 35 | 36 | impl From for RequestCache { 37 | fn from(value: Cache) -> Self { 38 | match value { 39 | Cache::Default => RequestCache::Default, 40 | Cache::NoStore => RequestCache::NoStore, 41 | Cache::Reload => RequestCache::Reload, 42 | Cache::NoCache => RequestCache::NoCache, 43 | Cache::ForceCache => RequestCache::ForceCache, 44 | Cache::OnlyIfCached => RequestCache::OnlyIfCached, 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test-suite/gzip/client/tests/web.rs: -------------------------------------------------------------------------------- 1 | use client::proto::{echo_client::EchoClient, EchoRequest}; 2 | use tonic::codegen::CompressionEncoding; 3 | use tonic_web_wasm_client::Client; 4 | use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; 5 | 6 | wasm_bindgen_test_configure!(run_in_browser); 7 | 8 | fn build_client() -> EchoClient { 9 | let base_url = "http://localhost:50051".to_string(); 10 | let wasm_client = Client::new(base_url); 11 | 12 | EchoClient::new(wasm_client).accept_compressed(CompressionEncoding::Gzip) 13 | } 14 | 15 | #[wasm_bindgen_test] 16 | async fn test_echo() { 17 | let mut client = build_client(); 18 | 19 | let response = client 20 | .echo(EchoRequest { 21 | message: "John".to_string(), 22 | }) 23 | .await 24 | .expect("success response") 25 | .into_inner(); 26 | 27 | assert_eq!(response.message, "echo(John)"); 28 | } 29 | 30 | #[wasm_bindgen_test] 31 | async fn test_echo_stream() { 32 | let mut client = build_client(); 33 | 34 | let mut stream_response = client 35 | .echo_stream(EchoRequest { 36 | message: "John".to_string(), 37 | }) 38 | .await 39 | .expect("success stream response") 40 | .into_inner(); 41 | 42 | for i in 0..3 { 43 | let response = stream_response.message().await.expect("stream message"); 44 | assert!(response.is_some(), "{}", i); 45 | let response = response.unwrap(); 46 | 47 | assert_eq!(response.message, "echo(John)"); 48 | } 49 | 50 | let response = stream_response.message().await.expect("stream message"); 51 | assert!(response.is_none()); 52 | } 53 | 54 | #[wasm_bindgen_test] 55 | async fn test_infinite_echo_stream() { 56 | let mut client = build_client(); 57 | 58 | let mut stream_response = client 59 | .echo_infinite_stream(EchoRequest { 60 | message: "John".to_string(), 61 | }) 62 | .await 63 | .expect("success stream response") 64 | .into_inner(); 65 | 66 | for i in 0..3 { 67 | let response = stream_response.message().await.expect("stream message"); 68 | assert!(response.is_some(), "{}", i); 69 | let response = response.unwrap(); 70 | 71 | assert_eq!(response.message, format!("echo(John, {})", i + 1)); 72 | } 73 | 74 | let response = stream_response.message().await.expect("stream message"); 75 | assert!(response.is_some()); 76 | } 77 | -------------------------------------------------------------------------------- /test-suite/simple/client/tests/web.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use client::proto::{echo_client::EchoClient, EchoRequest}; 4 | use tonic::Code; 5 | use tonic_web_wasm_client::{options::FetchOptions, Client}; 6 | use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; 7 | 8 | wasm_bindgen_test_configure!(run_in_browser); 9 | 10 | fn build_client() -> EchoClient { 11 | let base_url = "http://localhost:50051".to_string(); 12 | 13 | let mut wasm_client = Client::new(base_url); 14 | wasm_client.with_options(FetchOptions::default().timeout(Duration::from_secs(2))); 15 | 16 | EchoClient::new(wasm_client) 17 | } 18 | 19 | #[wasm_bindgen_test] 20 | async fn test_echo() { 21 | let mut client = build_client(); 22 | 23 | let response = client 24 | .echo(EchoRequest { 25 | message: "John".to_string(), 26 | }) 27 | .await 28 | .expect("success response") 29 | .into_inner(); 30 | 31 | assert_eq!(response.message, "echo(John)"); 32 | } 33 | 34 | #[wasm_bindgen_test] 35 | async fn test_echo_timeout() { 36 | let mut client = build_client(); 37 | 38 | let error = client 39 | .echo_timeout(EchoRequest { 40 | message: "John".to_string(), 41 | }) 42 | .await 43 | .unwrap_err(); 44 | 45 | assert_eq!(error.code(), Code::DeadlineExceeded); 46 | } 47 | 48 | #[wasm_bindgen_test] 49 | async fn test_echo_stream() { 50 | let mut client = build_client(); 51 | 52 | let mut stream_response = client 53 | .echo_stream(EchoRequest { 54 | message: "John".to_string(), 55 | }) 56 | .await 57 | .expect("success stream response") 58 | .into_inner(); 59 | 60 | for i in 0..3 { 61 | let response = stream_response.message().await.expect("stream message"); 62 | assert!(response.is_some(), "{}", i); 63 | let response = response.unwrap(); 64 | 65 | assert_eq!(response.message, "echo(John)"); 66 | } 67 | 68 | let response = stream_response.message().await.expect("stream message"); 69 | assert!(response.is_none()); 70 | } 71 | 72 | #[wasm_bindgen_test] 73 | async fn test_infinite_echo_stream() { 74 | let mut client = build_client(); 75 | 76 | let mut stream_response = client 77 | .echo_infinite_stream(EchoRequest { 78 | message: "John".to_string(), 79 | }) 80 | .await 81 | .expect("success stream response") 82 | .into_inner(); 83 | 84 | for i in 0..3 { 85 | let response = stream_response.message().await.expect("stream message"); 86 | assert!(response.is_some(), "{}", i); 87 | let response = response.unwrap(); 88 | 89 | assert_eq!(response.message, format!("echo(John, {})", i + 1)); 90 | } 91 | 92 | let response = stream_response.message().await.expect("stream message"); 93 | assert!(response.is_some()); 94 | } 95 | 96 | #[wasm_bindgen_test] 97 | async fn test_error_response() { 98 | let mut client = build_client(); 99 | 100 | let error = client 101 | .echo_error_response(EchoRequest { 102 | message: "John".to_string(), 103 | }) 104 | .await 105 | .unwrap_err(); 106 | 107 | assert_eq!(error.code(), Code::Unauthenticated); 108 | } 109 | -------------------------------------------------------------------------------- /src/options/referrer_policy.rs: -------------------------------------------------------------------------------- 1 | use web_sys::ReferrerPolicy as RequestReferrerPolicy; 2 | 3 | /// Request's referrer policy 4 | #[derive(Debug, Clone, Copy, Default)] 5 | pub enum ReferrerPolicy { 6 | /// Corresponds to no referrer policy, causing a fallback to a referrer policy defined elsewhere, or in the case 7 | /// where no such higher-level policy is available, falling back to the default referrer policy 8 | None, 9 | 10 | /// Specifies that no referrer information is to be sent along with requests to any origin 11 | NoReferrer, 12 | 13 | /// The "no-referrer-when-downgrade" policy sends a request’s full referrerURL stripped for use as a referrer for 14 | /// requests: 15 | /// 16 | /// - whose referrerURL and current URL are both potentially trustworthy URLs, or 17 | /// - whose referrerURL is a non-potentially trustworthy URL 18 | NoReferrerWhenDowngrade, 19 | 20 | /// Specifies that only the ASCII serialization of the request’s referrerURL is sent as referrer information when 21 | /// making both same-origin-referrer requests and cross-origin-referrer requests 22 | Origin, 23 | 24 | /// Specifies that a request’s full referrerURL is sent as referrer information when making same-origin-referrer 25 | /// requests, and only the ASCII serialization of the origin of the request’s referrerURL is sent as referrer 26 | /// information when making cross-origin-referrer requests 27 | OriginWhenCrossOrigin, 28 | 29 | /// specifies that a request’s full referrerURL is sent along for both same-origin-referrer requests and 30 | /// cross-origin-referrer requests 31 | UnsafeUrl, 32 | 33 | /// Specifies that a request’s full referrerURL is sent as referrer information when making same-origin-referrer 34 | /// requests 35 | SameOrigin, 36 | 37 | /// The "strict-origin" policy sends the ASCII serialization of the origin of the referrerURL for requests: 38 | /// 39 | /// - whose referrerURL and current URL are both potentially trustworthy URLs, or 40 | /// - whose referrerURL is a non-potentially trustworthy URL. 41 | StrictOrigin, 42 | 43 | /// Specifies that a request’s full referrerURL is sent as referrer information when making same-origin-referrer 44 | /// requests, and only the ASCII serialization of the origin of the request’s referrerURL when making 45 | /// cross-origin-referrer requests: 46 | /// 47 | /// - whose referrerURL and current URL are both potentially trustworthy URLs, or 48 | /// - whose referrerURL is a non-potentially trustworthy URL 49 | #[default] 50 | StrictOriginWhenCrossOrigin, 51 | } 52 | 53 | impl From for RequestReferrerPolicy { 54 | fn from(value: ReferrerPolicy) -> Self { 55 | match value { 56 | ReferrerPolicy::None => RequestReferrerPolicy::None, 57 | ReferrerPolicy::NoReferrer => RequestReferrerPolicy::NoReferrer, 58 | ReferrerPolicy::NoReferrerWhenDowngrade => { 59 | RequestReferrerPolicy::NoReferrerWhenDowngrade 60 | } 61 | ReferrerPolicy::Origin => RequestReferrerPolicy::Origin, 62 | ReferrerPolicy::OriginWhenCrossOrigin => RequestReferrerPolicy::OriginWhenCrossOrigin, 63 | ReferrerPolicy::UnsafeUrl => RequestReferrerPolicy::UnsafeUrl, 64 | ReferrerPolicy::SameOrigin => RequestReferrerPolicy::SameOrigin, 65 | ReferrerPolicy::StrictOrigin => RequestReferrerPolicy::StrictOrigin, 66 | ReferrerPolicy::StrictOriginWhenCrossOrigin => { 67 | RequestReferrerPolicy::StrictOriginWhenCrossOrigin 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/options/mod.rs: -------------------------------------------------------------------------------- 1 | //! Options for underlying `fetch` call 2 | mod cache; 3 | mod credentials; 4 | mod mode; 5 | mod redirect; 6 | mod referrer_policy; 7 | 8 | use std::time::Duration; 9 | 10 | use crate::abort_guard::AbortGuard; 11 | 12 | pub use self::{ 13 | cache::Cache, credentials::Credentials, mode::Mode, redirect::Redirect, 14 | referrer_policy::ReferrerPolicy, 15 | }; 16 | use web_sys::RequestInit; 17 | 18 | /// Options for underlying `fetch` call 19 | #[derive(Debug, Clone, Default)] 20 | pub struct FetchOptions { 21 | /// Request's cache mode 22 | pub cache: Option, 23 | 24 | /// Request's credentials mode 25 | pub credentials: Option, 26 | 27 | /// Requests's integrity 28 | pub integrity: Option, 29 | 30 | /// Request's mode 31 | pub mode: Option, 32 | 33 | /// Request's redirect mode 34 | pub redirect: Option, 35 | 36 | /// Request's referrer 37 | pub referrer: Option, 38 | 39 | /// Request's referrer policy 40 | pub referrer_policy: Option, 41 | 42 | /// Request's timeout duration 43 | pub timeout: Option, 44 | } 45 | 46 | impl FetchOptions { 47 | /// Create new `Options` with default values 48 | pub fn new() -> Self { 49 | Default::default() 50 | } 51 | 52 | /// Set request's cache mode 53 | pub fn cache(mut self, cache: Cache) -> Self { 54 | self.cache = Some(cache); 55 | self 56 | } 57 | 58 | /// Set request's credentials mode 59 | pub fn credentials(mut self, credentials: Credentials) -> Self { 60 | self.credentials = Some(credentials); 61 | self 62 | } 63 | 64 | /// Set request's integrity 65 | pub fn integrity(mut self, integrity: String) -> Self { 66 | self.integrity = Some(integrity); 67 | self 68 | } 69 | 70 | /// Set request's mode 71 | pub fn mode(mut self, mode: Mode) -> Self { 72 | self.mode = Some(mode); 73 | self 74 | } 75 | 76 | /// Set request's redirect mode 77 | pub fn redirect(mut self, redirect: Redirect) -> Self { 78 | self.redirect = Some(redirect); 79 | self 80 | } 81 | 82 | /// Set request's referrer 83 | pub fn referrer(mut self, referrer: String) -> Self { 84 | self.referrer = Some(referrer); 85 | self 86 | } 87 | 88 | /// Set request's referrer policy 89 | pub fn referrer_policy(mut self, referrer_policy: ReferrerPolicy) -> Self { 90 | self.referrer_policy = Some(referrer_policy); 91 | self 92 | } 93 | 94 | /// Set request's timeout duration 95 | pub fn timeout(mut self, timeout: Duration) -> Self { 96 | self.timeout = Some(timeout); 97 | self 98 | } 99 | 100 | pub(crate) fn request_init(&self) -> Result<(RequestInit, AbortGuard), crate::Error> { 101 | let init = RequestInit::new(); 102 | 103 | if let Some(cache) = self.cache { 104 | init.set_cache(cache.into()); 105 | } 106 | 107 | if let Some(credentials) = self.credentials { 108 | init.set_credentials(credentials.into()); 109 | } 110 | 111 | if let Some(ref integrity) = self.integrity { 112 | init.set_integrity(integrity); 113 | } 114 | 115 | if let Some(mode) = self.mode { 116 | init.set_mode(mode.into()); 117 | } 118 | 119 | if let Some(redirect) = self.redirect { 120 | init.set_redirect(redirect.into()); 121 | } 122 | 123 | if let Some(ref referrer) = self.referrer { 124 | init.set_referrer(referrer); 125 | } 126 | 127 | if let Some(referrer_policy) = self.referrer_policy { 128 | init.set_referrer_policy(referrer_policy.into()); 129 | } 130 | 131 | let mut abort = AbortGuard::new()?; 132 | 133 | if let Some(timeout) = self.timeout { 134 | abort.timeout(timeout); 135 | } 136 | 137 | init.set_signal(Some(&abort.signal())); 138 | 139 | Ok((init, abort)) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/call.rs: -------------------------------------------------------------------------------- 1 | use http::{ 2 | header::{ACCEPT, CONTENT_TYPE}, 3 | response::Builder, 4 | HeaderMap, HeaderValue, Request, Response, 5 | }; 6 | use http_body_util::BodyExt; 7 | use js_sys::{Array, Uint8Array}; 8 | use tonic::body::Body; 9 | use wasm_bindgen::JsValue; 10 | use web_sys::{Headers, RequestCredentials, RequestInit}; 11 | 12 | use crate::{fetch::fetch, options::FetchOptions, Error, ResponseBody}; 13 | 14 | pub async fn call( 15 | mut base_url: String, 16 | request: Request, 17 | options: FetchOptions, 18 | ) -> Result, Error> { 19 | base_url.push_str(&request.uri().to_string()); 20 | 21 | let headers = prepare_headers(request.headers())?; 22 | let body = prepare_body(request).await?; 23 | 24 | let request = prepare_request(&base_url, headers, body)?; 25 | let (init, abort) = options.request_init()?; 26 | let response = fetch(&request, &init).await?; 27 | 28 | let result = Response::builder().status(response.status()); 29 | let (result, content_type) = set_response_headers(result, &response)?; 30 | 31 | let content_type = content_type.ok_or(Error::MissingContentTypeHeader)?; 32 | let body_stream = response.body().ok_or(Error::MissingResponseBody)?; 33 | 34 | let body = ResponseBody::new(body_stream, &content_type, abort)?; 35 | 36 | result.body(body).map_err(Into::into) 37 | } 38 | 39 | fn prepare_headers(header_map: &HeaderMap) -> Result { 40 | // Construct default headers. 41 | let headers = Headers::new().map_err(Error::js_error)?; 42 | headers 43 | .append(CONTENT_TYPE.as_str(), "application/grpc-web+proto") 44 | .map_err(Error::js_error)?; 45 | headers 46 | .append(ACCEPT.as_str(), "application/grpc-web+proto") 47 | .map_err(Error::js_error)?; 48 | headers.append("x-grpc-web", "1").map_err(Error::js_error)?; 49 | 50 | // Apply default headers. 51 | for (header_name, header_value) in header_map.iter() { 52 | // Allow default headers to be overridden except for `content-type`. 53 | if header_name != CONTENT_TYPE { 54 | headers 55 | .set(header_name.as_str(), header_value.to_str()?) 56 | .map_err(Error::js_error)?; 57 | } 58 | } 59 | 60 | Ok(headers) 61 | } 62 | 63 | async fn prepare_body(request: Request) -> Result, Error> { 64 | let body = Some(request.collect().await?.to_bytes()); 65 | Ok(body.map(|bytes| Uint8Array::from(bytes.as_ref()).into())) 66 | } 67 | 68 | fn prepare_request( 69 | url: &str, 70 | headers: Headers, 71 | body: Option, 72 | ) -> Result { 73 | let init = RequestInit::new(); 74 | 75 | init.set_method("POST"); 76 | init.set_headers(headers.as_ref()); 77 | if let Some(ref body) = body { 78 | init.set_body(body); 79 | } 80 | init.set_credentials(RequestCredentials::SameOrigin); 81 | 82 | web_sys::Request::new_with_str_and_init(url, &init).map_err(Error::js_error) 83 | } 84 | 85 | fn set_response_headers( 86 | mut result: Builder, 87 | response: &web_sys::Response, 88 | ) -> Result<(Builder, Option), Error> { 89 | let headers = response.headers(); 90 | 91 | let header_iter = js_sys::try_iter(headers.as_ref()).map_err(Error::js_error)?; 92 | 93 | let mut content_type = None; 94 | 95 | if let Some(header_iter) = header_iter { 96 | for header in header_iter { 97 | let header = header.map_err(Error::js_error)?; 98 | let pair: Array = header.into(); 99 | 100 | let header_name = pair.get(0).as_string(); 101 | let header_value = pair.get(1).as_string(); 102 | 103 | match (header_name, header_value) { 104 | (Some(header_name), Some(header_value)) => { 105 | if header_name == CONTENT_TYPE.as_str() { 106 | content_type = Some(header_value.clone()); 107 | } 108 | 109 | result = result.header(header_name, header_value); 110 | } 111 | _ => continue, 112 | } 113 | } 114 | } 115 | 116 | Ok((result, content_type)) 117 | } 118 | -------------------------------------------------------------------------------- /test-suite/gzip/server/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | pin::Pin, 4 | task::{Context, Poll}, 5 | time::Duration, 6 | }; 7 | 8 | use futures_core::Stream; 9 | use http::header::HeaderName; 10 | use proto::echo_server::EchoServer; 11 | use tonic::{codegen::CompressionEncoding, transport::Server, Request, Response, Status}; 12 | use tonic_web::GrpcWebLayer; 13 | use tower_http::cors::{AllowOrigin, CorsLayer}; 14 | 15 | use self::proto::{echo_server::Echo, EchoRequest, EchoResponse}; 16 | 17 | pub mod proto { 18 | tonic::include_proto!("echo"); 19 | } 20 | 21 | pub struct EchoService; 22 | 23 | #[tonic::async_trait] 24 | impl Echo for EchoService { 25 | type EchoStreamStream = MessageStream; 26 | 27 | type EchoInfiniteStreamStream = InfiniteMessageStream; 28 | 29 | async fn echo(&self, request: Request) -> Result, Status> { 30 | let request = request.into_inner(); 31 | Ok(Response::new(EchoResponse { 32 | message: format!("echo({})", request.message), 33 | })) 34 | } 35 | 36 | async fn echo_stream( 37 | &self, 38 | request: Request, 39 | ) -> Result, Status> { 40 | let request = request.into_inner(); 41 | Ok(Response::new(MessageStream::new(request.message))) 42 | } 43 | 44 | async fn echo_infinite_stream( 45 | &self, 46 | request: tonic::Request, 47 | ) -> Result, tonic::Status> { 48 | let request = request.into_inner(); 49 | Ok(Response::new(InfiniteMessageStream::new(request.message))) 50 | } 51 | } 52 | 53 | pub struct MessageStream { 54 | message: String, 55 | count: u8, 56 | } 57 | 58 | impl MessageStream { 59 | pub fn new(message: String) -> Self { 60 | Self { message, count: 0 } 61 | } 62 | } 63 | 64 | impl Stream for MessageStream { 65 | type Item = Result; 66 | 67 | fn poll_next(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { 68 | if self.count < 3 { 69 | self.count += 1; 70 | Poll::Ready(Some(Ok(EchoResponse { 71 | message: format!("echo({})", self.message), 72 | }))) 73 | } else { 74 | Poll::Ready(None) 75 | } 76 | } 77 | } 78 | 79 | pub struct InfiniteMessageStream { 80 | message: String, 81 | count: u8, 82 | } 83 | 84 | impl InfiniteMessageStream { 85 | pub fn new(message: String) -> Self { 86 | Self { message, count: 0 } 87 | } 88 | } 89 | 90 | impl Stream for InfiniteMessageStream { 91 | type Item = Result; 92 | 93 | fn poll_next(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { 94 | self.count = self.count.saturating_add(1); 95 | 96 | Poll::Ready(Some(Ok(EchoResponse { 97 | message: format!("echo({}, {})", self.message, self.count), 98 | }))) 99 | } 100 | } 101 | 102 | const DEFAULT_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); 103 | const DEFAULT_EXPOSED_HEADERS: [&str; 4] = [ 104 | "grpc-status", 105 | "grpc-message", 106 | "grpc-status-details-bin", 107 | "grpc-encoding", 108 | ]; 109 | const DEFAULT_ALLOW_HEADERS: [&str; 5] = [ 110 | "x-grpc-web", 111 | "content-type", 112 | "x-user-agent", 113 | "grpc-timeout", 114 | "grpc-accept-encoding", 115 | ]; 116 | 117 | #[tokio::main] 118 | async fn main() -> Result<(), Box> { 119 | let addr = "[::1]:50051".parse().unwrap(); 120 | let echo = EchoServer::new(EchoService).send_compressed(CompressionEncoding::Gzip); 121 | 122 | Server::builder() 123 | .accept_http1(true) 124 | .layer( 125 | CorsLayer::new() 126 | .allow_origin(AllowOrigin::mirror_request()) 127 | .allow_credentials(true) 128 | .max_age(DEFAULT_MAX_AGE) 129 | .expose_headers( 130 | DEFAULT_EXPOSED_HEADERS 131 | .iter() 132 | .cloned() 133 | .map(HeaderName::from_static) 134 | .collect::>(), 135 | ) 136 | .allow_headers( 137 | DEFAULT_ALLOW_HEADERS 138 | .iter() 139 | .cloned() 140 | .map(HeaderName::from_static) 141 | .collect::>(), 142 | ), 143 | ) 144 | .layer(GrpcWebLayer::new()) 145 | .add_service(echo) 146 | .serve(addr) 147 | .await?; 148 | 149 | Ok(()) 150 | } 151 | -------------------------------------------------------------------------------- /test-suite/simple/server/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | error::Error, 3 | pin::Pin, 4 | task::{Context, Poll}, 5 | time::Duration, 6 | }; 7 | 8 | use futures_core::Stream; 9 | use http::header::HeaderName; 10 | use proto::echo_server::EchoServer; 11 | use tonic::{transport::Server, Request, Response, Status}; 12 | use tonic_web::GrpcWebLayer; 13 | use tower_http::cors::{AllowOrigin, CorsLayer}; 14 | 15 | use self::proto::{echo_server::Echo, EchoRequest, EchoResponse}; 16 | 17 | pub mod proto { 18 | tonic::include_proto!("echo"); 19 | } 20 | 21 | pub struct EchoService; 22 | 23 | #[tonic::async_trait] 24 | impl Echo for EchoService { 25 | type EchoStreamStream = MessageStream; 26 | 27 | type EchoInfiniteStreamStream = InfiniteMessageStream; 28 | 29 | async fn echo(&self, request: Request) -> Result, Status> { 30 | let request = request.into_inner(); 31 | Ok(Response::new(EchoResponse { 32 | message: format!("echo({})", request.message), 33 | })) 34 | } 35 | 36 | async fn echo_timeout( 37 | &self, 38 | request: Request, 39 | ) -> Result, Status> { 40 | let request = request.into_inner(); 41 | // Simulate a long processing time to trigger client timeout 42 | tokio::time::sleep(Duration::from_secs(10)).await; 43 | Ok(Response::new(EchoResponse { 44 | message: format!("echo({})", request.message), 45 | })) 46 | } 47 | 48 | async fn echo_stream( 49 | &self, 50 | request: Request, 51 | ) -> Result, Status> { 52 | let request = request.into_inner(); 53 | Ok(Response::new(MessageStream::new(request.message))) 54 | } 55 | 56 | async fn echo_infinite_stream( 57 | &self, 58 | request: tonic::Request, 59 | ) -> Result, tonic::Status> { 60 | let request = request.into_inner(); 61 | Ok(Response::new(InfiniteMessageStream::new(request.message))) 62 | } 63 | 64 | async fn echo_error_response( 65 | &self, 66 | _: tonic::Request, 67 | ) -> Result, tonic::Status> { 68 | Err(tonic::Status::unauthenticated("user not authenticated")) 69 | } 70 | } 71 | 72 | pub struct MessageStream { 73 | message: String, 74 | count: u8, 75 | } 76 | 77 | impl MessageStream { 78 | pub fn new(message: String) -> Self { 79 | Self { message, count: 0 } 80 | } 81 | } 82 | 83 | impl Stream for MessageStream { 84 | type Item = Result; 85 | 86 | fn poll_next(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { 87 | if self.count < 3 { 88 | self.count += 1; 89 | Poll::Ready(Some(Ok(EchoResponse { 90 | message: format!("echo({})", self.message), 91 | }))) 92 | } else { 93 | Poll::Ready(None) 94 | } 95 | } 96 | } 97 | 98 | pub struct InfiniteMessageStream { 99 | message: String, 100 | count: u8, 101 | } 102 | 103 | impl InfiniteMessageStream { 104 | pub fn new(message: String) -> Self { 105 | Self { message, count: 0 } 106 | } 107 | } 108 | 109 | impl Stream for InfiniteMessageStream { 110 | type Item = Result; 111 | 112 | fn poll_next(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { 113 | self.count = self.count.saturating_add(1); 114 | 115 | Poll::Ready(Some(Ok(EchoResponse { 116 | message: format!("echo({}, {})", self.message, self.count), 117 | }))) 118 | } 119 | } 120 | 121 | const DEFAULT_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); 122 | const DEFAULT_EXPOSED_HEADERS: [HeaderName; 3] = [ 123 | HeaderName::from_static("grpc-status"), 124 | HeaderName::from_static("grpc-message"), 125 | HeaderName::from_static("grpc-status-details-bin"), 126 | ]; 127 | const DEFAULT_ALLOW_HEADERS: [HeaderName; 4] = [ 128 | HeaderName::from_static("x-grpc-web"), 129 | HeaderName::from_static("content-type"), 130 | HeaderName::from_static("x-user-agent"), 131 | HeaderName::from_static("grpc-timeout"), 132 | ]; 133 | 134 | #[tokio::main] 135 | async fn main() -> Result<(), Box> { 136 | let addr = "[::1]:50051".parse().unwrap(); 137 | let echo = EchoServer::new(EchoService); 138 | 139 | Server::builder() 140 | .accept_http1(true) 141 | .layer( 142 | CorsLayer::new() 143 | .allow_origin(AllowOrigin::mirror_request()) 144 | .allow_credentials(true) 145 | .max_age(DEFAULT_MAX_AGE) 146 | .expose_headers(DEFAULT_EXPOSED_HEADERS) 147 | .allow_headers(DEFAULT_ALLOW_HEADERS), 148 | ) 149 | .layer(GrpcWebLayer::new()) 150 | .add_service(echo) 151 | .serve(addr) 152 | .await?; 153 | 154 | Ok(()) 155 | } 156 | -------------------------------------------------------------------------------- /LICENSE_APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /src/response_body.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ops::{Deref, DerefMut}, 3 | pin::Pin, 4 | task::{ready, Context, Poll}, 5 | }; 6 | 7 | use base64::{prelude::BASE64_STANDARD, Engine}; 8 | use byteorder::{BigEndian, ByteOrder}; 9 | use bytes::{BufMut, Bytes, BytesMut}; 10 | use http::{header::HeaderName, HeaderMap, HeaderValue}; 11 | use http_body::Body; 12 | use httparse::{Status, EMPTY_HEADER}; 13 | use pin_project::pin_project; 14 | use wasm_bindgen::JsCast; 15 | use web_sys::ReadableStream; 16 | 17 | use crate::{abort_guard::AbortGuard, body_stream::BodyStream, content_type::Encoding, Error}; 18 | 19 | /// If 8th MSB of a frame is `0` for data and `1` for trailer 20 | const TRAILER_BIT: u8 = 0b10000000; 21 | 22 | pub struct EncodedBytes { 23 | encoding: Encoding, 24 | raw_buf: BytesMut, 25 | buf: BytesMut, 26 | } 27 | 28 | impl EncodedBytes { 29 | pub fn new(content_type: &str) -> Result { 30 | Ok(Self { 31 | encoding: Encoding::from_content_type(content_type)?, 32 | raw_buf: BytesMut::new(), 33 | buf: BytesMut::new(), 34 | }) 35 | } 36 | 37 | // This is to avoid passing a slice of bytes with a length that the base64 38 | // decoder would consider invalid. 39 | #[inline] 40 | fn max_decodable(&self) -> usize { 41 | (self.raw_buf.len() / 4) * 4 42 | } 43 | 44 | fn decode_base64_chunk(&mut self) -> Result<(), Error> { 45 | let index = self.max_decodable(); 46 | 47 | if self.raw_buf.len() >= index { 48 | let decoded = BASE64_STANDARD 49 | .decode(self.buf.split_to(index)) 50 | .map(Bytes::from)?; 51 | self.buf.put(decoded); 52 | } 53 | 54 | Ok(()) 55 | } 56 | 57 | fn append(&mut self, bytes: Bytes) -> Result<(), Error> { 58 | match self.encoding { 59 | Encoding::None => self.buf.put(bytes), 60 | Encoding::Base64 => { 61 | self.raw_buf.put(bytes); 62 | self.decode_base64_chunk()?; 63 | } 64 | } 65 | 66 | Ok(()) 67 | } 68 | 69 | fn take(&mut self, length: usize) -> BytesMut { 70 | let new_buf = self.buf.split_off(length); 71 | std::mem::replace(&mut self.buf, new_buf) 72 | } 73 | } 74 | 75 | impl Deref for EncodedBytes { 76 | type Target = BytesMut; 77 | 78 | fn deref(&self) -> &Self::Target { 79 | &self.buf 80 | } 81 | } 82 | 83 | impl DerefMut for EncodedBytes { 84 | fn deref_mut(&mut self) -> &mut Self::Target { 85 | &mut self.buf 86 | } 87 | } 88 | 89 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 90 | pub enum ReadState { 91 | CompressionFlag, 92 | DataLength, 93 | Data(u32), 94 | TrailerLength, 95 | Trailer(u32), 96 | Done, 97 | } 98 | 99 | impl ReadState { 100 | fn finished_data(&self) -> bool { 101 | matches!(self, ReadState::TrailerLength) 102 | || matches!(self, ReadState::Trailer(_)) 103 | || matches!(self, ReadState::Done) 104 | } 105 | } 106 | 107 | /// Type to handle HTTP response 108 | #[pin_project] 109 | pub struct ResponseBody { 110 | #[pin] 111 | body_stream: BodyStream, 112 | buf: EncodedBytes, 113 | incomplete_data: BytesMut, 114 | data: Option, 115 | trailer: Option, 116 | state: ReadState, 117 | finished_stream: bool, 118 | } 119 | 120 | impl ResponseBody { 121 | pub(crate) fn new( 122 | body_stream: ReadableStream, 123 | content_type: &str, 124 | abort: AbortGuard, 125 | ) -> Result { 126 | let body_stream = 127 | wasm_streams::ReadableStream::from_raw(body_stream.unchecked_into()).into_stream(); 128 | 129 | Ok(Self { 130 | body_stream: BodyStream::new(body_stream, abort), 131 | buf: EncodedBytes::new(content_type)?, 132 | incomplete_data: BytesMut::new(), 133 | data: None, 134 | trailer: None, 135 | state: ReadState::CompressionFlag, 136 | finished_stream: false, 137 | }) 138 | } 139 | 140 | fn read_stream(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 141 | if self.finished_stream { 142 | return Poll::Ready(Ok(())); 143 | } 144 | 145 | let this = self.project(); 146 | 147 | match ready!(this.body_stream.poll_frame(cx)) { 148 | Some(Ok(frame)) => { 149 | if let Some(data) = frame.data_ref() { 150 | if let Err(e) = this.buf.append(data.clone()) { 151 | return Poll::Ready(Err(e)); 152 | } 153 | }; 154 | 155 | Poll::Ready(Ok(())) 156 | } 157 | Some(Err(e)) => Poll::Ready(Err(e)), 158 | None => { 159 | *this.finished_stream = true; 160 | Poll::Ready(Ok(())) 161 | } 162 | } 163 | } 164 | 165 | fn step(self: Pin<&mut Self>) -> Result<(), Error> { 166 | let this = self.project(); 167 | 168 | loop { 169 | match this.state { 170 | ReadState::CompressionFlag => { 171 | if this.buf.is_empty() { 172 | // Can't read compression flag right now 173 | return Ok(()); 174 | } else { 175 | let compression_flag = this.buf.take(1); 176 | 177 | if compression_flag[0] & TRAILER_BIT == 0 { 178 | this.incomplete_data.unsplit(compression_flag); 179 | *this.state = ReadState::DataLength; 180 | } else { 181 | *this.state = ReadState::TrailerLength; 182 | } 183 | } 184 | } 185 | ReadState::DataLength => { 186 | if this.buf.len() < 4 { 187 | // Can't read data length right now 188 | return Ok(()); 189 | } else { 190 | let data_length_bytes = this.buf.take(4); 191 | let data_length = BigEndian::read_u32(data_length_bytes.as_ref()); 192 | 193 | this.incomplete_data.unsplit(data_length_bytes); 194 | *this.state = ReadState::Data(data_length); 195 | } 196 | } 197 | ReadState::Data(data_length) => { 198 | let data_length = *data_length as usize; 199 | 200 | if this.buf.len() < data_length { 201 | // Can't read data right now 202 | return Ok(()); 203 | } else { 204 | this.incomplete_data.unsplit(this.buf.take(data_length)); 205 | 206 | let new_data = this.incomplete_data.split(); 207 | 208 | if let Some(data) = this.data { 209 | data.unsplit(new_data); 210 | } else { 211 | *this.data = Some(new_data); 212 | } 213 | 214 | *this.state = ReadState::CompressionFlag; 215 | } 216 | } 217 | ReadState::TrailerLength => { 218 | if this.buf.len() < 4 { 219 | // Can't read data length right now 220 | return Ok(()); 221 | } else { 222 | let trailer_length_bytes = this.buf.take(4); 223 | let trailer_length = BigEndian::read_u32(trailer_length_bytes.as_ref()); 224 | *this.state = ReadState::Trailer(trailer_length); 225 | } 226 | } 227 | ReadState::Trailer(trailer_length) => { 228 | let trailer_length = *trailer_length as usize; 229 | 230 | if this.buf.len() < trailer_length { 231 | // Can't read trailer right now 232 | return Ok(()); 233 | } else { 234 | let mut trailer_bytes = this.buf.take(trailer_length); 235 | trailer_bytes.put_u8(b'\n'); 236 | 237 | let mut trailers_buf = [EMPTY_HEADER; 64]; 238 | let parsed_trailers = 239 | match httparse::parse_headers(&trailer_bytes, &mut trailers_buf) 240 | .map_err(|_| Error::HeaderParsingError)? 241 | { 242 | Status::Complete((_, headers)) => Ok(headers), 243 | Status::Partial => Err(Error::HeaderParsingError), 244 | }?; 245 | 246 | let mut trailers = HeaderMap::with_capacity(parsed_trailers.len()); 247 | 248 | for parsed_trailer in parsed_trailers { 249 | let header_name = 250 | HeaderName::from_bytes(parsed_trailer.name.as_bytes())?; 251 | let header_value = HeaderValue::from_bytes(parsed_trailer.value)?; 252 | trailers.insert(header_name, header_value); 253 | } 254 | 255 | *this.trailer = Some(trailers); 256 | 257 | *this.state = ReadState::Done; 258 | } 259 | } 260 | ReadState::Done => return Ok(()), 261 | } 262 | } 263 | } 264 | } 265 | 266 | impl Body for ResponseBody { 267 | type Data = Bytes; 268 | 269 | type Error = Error; 270 | 271 | fn poll_frame( 272 | mut self: Pin<&mut Self>, 273 | cx: &mut Context<'_>, 274 | ) -> Poll, Self::Error>>> { 275 | // Check if there's already some data in buffer and return that 276 | if self.data.is_some() { 277 | let data = self.data.take().unwrap(); 278 | 279 | return Poll::Ready(Some(Ok(http_body::Frame::data(data.freeze())))); 280 | } 281 | 282 | // If reading data is finished return `None` 283 | if self.state.finished_data() { 284 | return Poll::Ready(None); 285 | } 286 | 287 | loop { 288 | // Read bytes from stream 289 | if let Err(e) = ready!(self.as_mut().read_stream(cx)) { 290 | return Poll::Ready(Some(Err(e))); 291 | } 292 | 293 | // Step the state machine 294 | if let Err(e) = self.as_mut().step() { 295 | return Poll::Ready(Some(Err(e))); 296 | } 297 | 298 | if self.data.is_some() { 299 | // If data is available in buffer, return that 300 | let data = self.data.take().unwrap(); 301 | return Poll::Ready(Some(Ok(http_body::Frame::data(data.freeze())))); 302 | } else if self.state.finished_data() { 303 | // If we finished reading data continue return `None` 304 | return Poll::Ready(None); 305 | } else if self.finished_stream { 306 | // If stream is finished but data is not finished return error 307 | return Poll::Ready(Some(Err(Error::MalformedResponse))); 308 | } 309 | } 310 | } 311 | } 312 | 313 | impl Default for ResponseBody { 314 | fn default() -> Self { 315 | Self { 316 | body_stream: BodyStream::empty(), 317 | buf: EncodedBytes { 318 | encoding: Encoding::None, 319 | raw_buf: BytesMut::new(), 320 | buf: BytesMut::new(), 321 | }, 322 | incomplete_data: BytesMut::new(), 323 | data: None, 324 | trailer: None, 325 | state: ReadState::Done, 326 | finished_stream: true, 327 | } 328 | } 329 | } 330 | --------------------------------------------------------------------------------