Skip to content

Commit

Permalink
Add minimal proxy capabilities to dioxus serve
Browse files Browse the repository at this point in the history
This adds an MVP of some proxying capabilities to the `dioxus serve`
server. The config is similar to that of `trunk serve`: the user can
specify one or more proxy backends under `[[web.proxy]]` in Dioxus.toml,
and the server will intercept requests targeted at the _path_ of that
configured backend and forward them to the backend server.

Example
-------

For example, if the dev server is serving on port 8080 with this config:

```
[[web.proxy]]
backend = "http://localhost:9000/api"
```

then requests to http://localhost:8080/api,
http://localhost:8080/api/ and http://localhost:8080/api/any-subpath
to be forwarded to the respective paths on http://localhost:9000.

This PR doesn't handle path rewriting or anything yet but it would be
fairly simple to add in future if anyone needs it.
  • Loading branch information
sd2k committed Feb 27, 2023
1 parent 6c2a51e commit ead183d
Show file tree
Hide file tree
Showing 7 changed files with 240 additions and 12 deletions.
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ regex = "1.5.4"
chrono = "0.4.19"
anyhow = "1.0.53"
hyper = "0.14.17"
hyper-rustls = "0.23.2"
indicatif = "0.17.0-rc.11"
subprocess = "0.2.9"

Expand Down
13 changes: 13 additions & 0 deletions docs/src/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,16 @@ Only include resources at `Dev` mode.
]
```

### Web.Proxy

Proxy requests matching a path to a backend server.

1. ***backend*** - the URL to the backend server.
```
backend = "http://localhost:8000/api/"
```
This will cause any requests made to the dev server with prefix /api/ to be redirected to the backend server at http://localhost:8000. The path and query parameters will be passed on as-is (path rewriting is not currently supported).

## Config example

```toml
Expand Down Expand Up @@ -168,4 +178,7 @@ style = []

# Javascript code file
script = []

[[web.proxy]]
backend = "http://localhost:8000/api/"
```
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ impl Default for DioxusConfig {
title: Some("dioxus | ⛺".into()),
base_path: None,
},
proxy: Some(vec![]),
watcher: WebWatcherConfig {
watch_path: Some(vec![PathBuf::from("src")]),
reload_html: Some(false),
Expand Down Expand Up @@ -97,6 +98,7 @@ pub struct ApplicationConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebConfig {
pub app: WebAppConfig,
pub proxy: Option<Vec<WebProxyConfig>>,
pub watcher: WebWatcherConfig,
pub resource: WebResourceConfig,
}
Expand All @@ -107,6 +109,11 @@ pub struct WebAppConfig {
pub base_path: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebProxyConfig {
pub backend: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebWatcherConfig {
pub watch_path: Option<Vec<PathBuf>>,
Expand Down
6 changes: 6 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ pub enum Error {
#[error("{0}")]
CustomError(String),

#[error("Invalid proxy URL: {0}")]
InvalidProxy(#[from] hyper::http::uri::InvalidUri),

#[error("Error proxying request: {0}")]
ProxyRequestError(hyper::Error),

#[error(transparent)]
Other(#[from] anyhow::Error),
}
Expand Down
39 changes: 27 additions & 12 deletions src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ use tokio::sync::broadcast;
use tower::ServiceBuilder;
use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody};

mod proxy;

pub struct BuildManager {
config: CrateConfig,
reload_tx: broadcast::Sender<()>,
Expand Down Expand Up @@ -284,16 +286,18 @@ pub async fn startup_hot_reload(ip: String, port: u16, config: CrateConfig) -> R
)
.service(ServeDir::new(config.crate_dir.join(&dist_path)));

let router = Router::new()
.route("/_dioxus/ws", get(ws_handler))
.fallback(
get_service(file_service).handle_error(|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
}),
);
let mut router = Router::new().route("/_dioxus/ws", get(ws_handler));
for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() {
router = proxy::add_proxy(router, &proxy_config )?;
}
router = router.fallback(get_service(file_service).handle_error(
|error: std::io::Error| async move {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {}", error),
)
},
));

let router = router
.route("/_dioxus/hot_reload", get(hot_reload_handler))
Expand Down Expand Up @@ -427,8 +431,9 @@ pub async fn startup_default(ip: String, port: u16, config: CrateConfig) -> Resu
)
.service(ServeDir::new(config.crate_dir.join(&dist_path)));

let router = Router::new()
.route("/_dioxus/ws", get(ws_handler))
let mut router = Router::new().route("/_dioxus/ws", get(ws_handler));

router = router
.fallback(
get_service(file_service).handle_error(|error: std::io::Error| async move {
(
Expand Down Expand Up @@ -498,6 +503,8 @@ fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: Pre
"False"
};

let proxies = config.dioxus_config.web.proxy.as_ref();

if options.changed.is_empty() {
println!(
"{} @ v{} [{}] \n",
Expand Down Expand Up @@ -528,6 +535,14 @@ fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: Pre
println!("");
println!("\t> Profile : {}", profile.green());
println!("\t> Hot Reload : {}", hot_reload.cyan());
if let Some(proxies) = proxies {
if !proxies.is_empty() {
println!("\t> Proxies :");
for proxy in proxies {
println!("\t\t- {}", proxy.backend.blue());
}
}
}
println!("\t> Index Template : {}", custom_html_file.green());
println!("\t> URL Rewrite [index_on_404] : {}", url_rewrite.purple());
println!("");
Expand Down
171 changes: 171 additions & 0 deletions src/server/proxy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use crate::{Result, WebProxyConfig};

use anyhow::Context;
use axum::{http::StatusCode, routing::any, Router};
use hyper::{Request, Response, Uri};

#[derive(Debug, Clone)]
struct ProxyClient {
inner: hyper::Client<hyper_rustls::HttpsConnector<hyper::client::HttpConnector>>,
url: Uri,
}

impl ProxyClient {
fn new(url: Uri) -> Self {
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.https_or_http()
.enable_http1()
.build();
Self {
inner: hyper::Client::builder().build(https),
url,
}
}

async fn send(
&self,
mut req: Request<hyper::body::Body>,
) -> Result<Response<hyper::body::Body>> {
let mut uri_parts = req.uri().clone().into_parts();
uri_parts.authority = self.url.authority().cloned();
uri_parts.scheme = self.url.scheme().cloned();
*req.uri_mut() = Uri::from_parts(uri_parts).context("Invalid URI parts")?;
self.inner
.request(req)
.await
.map_err(crate::error::Error::ProxyRequestError)
}
}

/// Add routes to the router handling the specified proxy config.
///
/// We will proxy requests directed at either:
///
/// - the exact path of the proxy config's backend URL, e.g. /api
/// - the exact path with a trailing slash, e.g. /api/
/// - any subpath of the backend URL, e.g. /api/foo/bar
pub fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result<Router> {
let url: Uri = proxy.backend.parse()?;
let path = url.path().to_string();
let client = ProxyClient::new(url);

// We also match everything after the path using a wildcard matcher.
let wildcard_client = client.clone();

router = router.route(
// Always remove trailing /'s so that the exact route
// matches.
path.trim_end_matches('/'),
any(move |req| async move {
client
.send(req)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}),
);

// Wildcard match anything else _after_ the backend URL's path.
// Note that we know `path` ends with a trailing `/` in this branch,
// so `wildcard` will look like `http://localhost/api/*proxywildcard`.
let wildcard = format!("{}/*proxywildcard", path.trim_end_matches('/'));
router = router.route(
&wildcard,
any(move |req| async move {
wildcard_client
.send(req)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}),
);
Ok(router)
}

#[cfg(test)]
mod test {

use super::*;

use axum::{extract::Path, Router};

fn setup_servers(
mut config: WebProxyConfig,
) -> (
tokio::task::JoinHandle<()>,
tokio::task::JoinHandle<()>,
String,
) {
let backend_router = Router::new().route(
"/*path",
any(|path: Path<String>| async move { format!("backend: {}", path.0) }),
);
let backend_server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap())
.serve(backend_router.into_make_service());
let backend_addr = backend_server.local_addr();
let backend_handle = tokio::spawn(async move { backend_server.await.unwrap() });
config.backend = format!("http://{}{}", backend_addr, config.backend);
let router = super::add_proxy(Router::new(), &config);
let server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap())
.serve(router.unwrap().into_make_service());
let server_addr = server.local_addr();
let server_handle = tokio::spawn(async move { server.await.unwrap() });
(backend_handle, server_handle, server_addr.to_string())
}

async fn test_proxy_requests(path: String) {
let config = WebProxyConfig {
// Normally this would be an absolute URL including scheme/host/port,
// but in these tests we need to let the OS choose the port so tests
// don't conflict, so we'll concatenate the final address and this
// path together.
// So in day to day usage, use `http://localhost:8000/api` instead!
backend: path,
};
let (backend_handle, server_handle, server_addr) = setup_servers(config);
let resp = hyper::Client::new()
.get(format!("http://{}/api", server_addr).parse().unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
hyper::body::to_bytes(resp.into_body()).await.unwrap(),
"backend: /api"
);

let resp = hyper::Client::new()
.get(format!("http://{}/api/", server_addr).parse().unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
hyper::body::to_bytes(resp.into_body()).await.unwrap(),
"backend: /api/"
);

let resp = hyper::Client::new()
.get(
format!("http://{}/api/subpath", server_addr)
.parse()
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
hyper::body::to_bytes(resp.into_body()).await.unwrap(),
"backend: /api/subpath"
);
backend_handle.abort();
server_handle.abort();
}

#[tokio::test]
async fn add_proxy() {
test_proxy_requests("/api".to_string()).await;
}

#[tokio::test]
async fn add_proxy_trailing_slash() {
test_proxy_requests("/api/".to_string()).await;
}
}

0 comments on commit ead183d

Please sign in to comment.