-
Notifications
You must be signed in to change notification settings - Fork 283
feat: add --tunnel=cloudflare flag with embedded frontend #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,33 +2,132 @@ | |
|
|
||
| use std::sync::Arc; | ||
|
|
||
| use axum::routing::{any, get_service}; | ||
| use axum::body::Body; | ||
| use axum::extract::Request; | ||
| use axum::http::{header, StatusCode}; | ||
| use axum::response::{IntoResponse, Response}; | ||
| use axum::routing::any; | ||
| use axum::Router; | ||
| use tower_http::services::{ServeDir, ServeFile}; | ||
| use include_dir::{include_dir, Dir}; | ||
|
|
||
| use crate::ServerState; | ||
|
|
||
| pub mod protocol; | ||
| mod socket; | ||
|
|
||
| /// The SvelteKit static build, embedded at compile time. | ||
| /// Ensure `npm run build` has been run from the workspace root before compiling. | ||
| static BUILD_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../build"); | ||
|
|
||
|
Comment on lines
+18
to
+21
|
||
| /// Returns the web application server, routed with Axum. | ||
| pub fn app() -> Router<Arc<ServerState>> { | ||
| let root_spa = ServeFile::new("build/spa.html") | ||
| .precompressed_gzip() | ||
| .precompressed_br(); | ||
|
|
||
| // Serves static SvelteKit build files. | ||
| let static_files = ServeDir::new("build") | ||
| .precompressed_gzip() | ||
| .precompressed_br() | ||
| .fallback(root_spa); | ||
|
|
||
| Router::new() | ||
| .nest("/api", backend()) | ||
| .fallback_service(get_service(static_files)) | ||
| .fallback(serve_static) | ||
| } | ||
|
|
||
| /// Routes for the backend web API server. | ||
| fn backend() -> Router<Arc<ServerState>> { | ||
| Router::new().route("/s/{name}", any(socket::get_session_ws)) | ||
| } | ||
|
|
||
| /// Serve an embedded static file with content-negotiation for precompressed variants. | ||
| /// | ||
| /// Resolution order for a request path `P`: | ||
| /// 1. `P` with brotli encoding (`P.br`) if client accepts `br` | ||
| /// 2. `P` with gzip encoding (`P.gz`) if client accepts `gzip` | ||
| /// 3. `P` raw | ||
| /// 4. SPA fallback: `spa.html` (same compression priority) for unknown paths | ||
| async fn serve_static(req: Request) -> Response { | ||
| let path = req.uri().path().trim_start_matches('/'); | ||
|
|
||
| // Empty path → "index.html", which SvelteKit puts at root. | ||
| let path = if path.is_empty() { "index.html" } else { path }; | ||
|
|
||
|
Comment on lines
23
to
+46
|
||
| // Detect which encodings the client accepts. | ||
| let accept_enc = req | ||
| .headers() | ||
| .get(header::ACCEPT_ENCODING) | ||
| .and_then(|v| v.to_str().ok()) | ||
| .unwrap_or(""); | ||
| let accept_br = accept_enc.contains("br"); | ||
| let accept_gz = accept_enc.contains("gzip"); | ||
|
|
||
| // Try to find and serve the file (with optional precompressed variant). | ||
| if let Some(resp) = try_serve(path, accept_br, accept_gz) { | ||
| return resp; | ||
| } | ||
|
|
||
| // SPA fallback: unknown paths are handled by the SvelteKit router client-side. | ||
| if let Some(resp) = try_serve("spa.html", accept_br, accept_gz) { | ||
| return resp; | ||
| } | ||
|
|
||
| (StatusCode::NOT_FOUND, "Not found").into_response() | ||
| } | ||
|
|
||
| /// Try to serve `path` from the embedded build dir, preferring compressed variants. | ||
| fn try_serve(path: &str, accept_br: bool, accept_gz: bool) -> Option<Response> { | ||
| let content_type = mime_guess::from_path(path) | ||
| .first() | ||
| .map(|m| m.to_string()) | ||
| .unwrap_or_else(|| "application/octet-stream".to_string()); | ||
|
|
||
| // Cache-control: immutable for content-addressed _app/ assets, no-store for SPA HTML. | ||
| let cache_control = if path.starts_with("_app/immutable/") { | ||
| "public, max-age=31536000, immutable" | ||
| } else if path.ends_with(".html") { | ||
| "no-cache, no-store" | ||
| } else { | ||
| "public, max-age=3600" | ||
| }; | ||
|
|
||
| // Brotli preferred. | ||
| if accept_br { | ||
| let br_path = format!("{path}.br"); | ||
| if let Some(file) = BUILD_DIR.get_file(&br_path) { | ||
| return Some(build_response( | ||
| file.contents(), | ||
| &content_type, | ||
| Some("br"), | ||
| cache_control, | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| // Gzip fallback. | ||
| if accept_gz { | ||
| let gz_path = format!("{path}.gz"); | ||
| if let Some(file) = BUILD_DIR.get_file(&gz_path) { | ||
| return Some(build_response( | ||
| file.contents(), | ||
| &content_type, | ||
| Some("gzip"), | ||
| cache_control, | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| // Plain file. | ||
| BUILD_DIR.get_file(path).map(|file| { | ||
| build_response(file.contents(), &content_type, None, cache_control) | ||
| }) | ||
| } | ||
|
|
||
| fn build_response( | ||
| body: &'static [u8], | ||
| content_type: &str, | ||
| encoding: Option<&str>, | ||
| cache_control: &str, | ||
| ) -> Response { | ||
| let mut builder = Response::builder() | ||
| .status(StatusCode::OK) | ||
| .header(header::CONTENT_TYPE, content_type) | ||
| .header(header::CACHE_CONTROL, cache_control); | ||
|
|
||
| if let Some(enc) = encoding { | ||
| builder = builder.header(header::CONTENT_ENCODING, enc); | ||
| } | ||
|
Comment on lines
+117
to
+130
|
||
|
|
||
| builder.body(Body::from(body)).unwrap() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| use std::process::Stdio; | ||
| use std::time::Duration; | ||
|
|
||
| use anyhow::{anyhow, Context, Result}; | ||
| use sshx_server::{Server, ServerOptions}; | ||
| use tokio::io::{AsyncBufReadExt, BufReader}; | ||
| use tokio::net::TcpListener; | ||
| use tokio::process::{Child, Command}; | ||
| use tokio::sync::{mpsc, oneshot}; | ||
| use tokio::task::JoinHandle; | ||
| use tokio::time::timeout; | ||
| use tracing::{info, warn}; | ||
|
|
||
| /// A guard that manages the lifetime of the local server and cloudflared tunnel. | ||
| pub struct TunnelGuard { | ||
| /// The unencrypted HTTP local endpoint bounding the server. | ||
| pub local_endpoint: String, | ||
| /// The public HTTPS URL from Cloudflare. | ||
| pub public_url: String, | ||
| server_task: JoinHandle<()>, | ||
| _child: Child, | ||
| } | ||
|
|
||
| impl Drop for TunnelGuard { | ||
| fn drop(&mut self) { | ||
| self.server_task.abort(); | ||
| } | ||
| } | ||
|
Comment on lines
+24
to
+28
|
||
|
|
||
| /// Spawns a local sshx server and exposes it via a Cloudflare quick tunnel. | ||
| pub async fn start_cloudflare_tunnel() -> Result<TunnelGuard> { | ||
| let listener = TcpListener::bind("127.0.0.1:0") | ||
| .await | ||
| .context("failed to bind ephemeral port for local server")?; | ||
| let local_addr = listener.local_addr()?; | ||
| let local_endpoint = format!("http://127.0.0.1:{}", local_addr.port()); | ||
|
|
||
| info!("Spawning cloudflared tunnel..."); | ||
| let mut child = Command::new("cloudflared") | ||
| .arg("tunnel") | ||
| .arg("--url") | ||
| .arg(&local_endpoint) | ||
| .stderr(Stdio::piped()) | ||
| .kill_on_drop(true) | ||
| .spawn() | ||
| .context("failed to execute `cloudflared`; make sure it is installed and in your PATH")?; | ||
|
|
||
| let stderr = child.stderr.take().unwrap(); | ||
| let mut reader = BufReader::new(stderr).lines(); | ||
|
|
||
| let (url_tx, mut url_rx) = mpsc::channel(1); | ||
|
|
||
| // Drain cloudflared stderr in background so it never gets a broken pipe. | ||
| // We send the URL once we find it but keep reading to keep the pipe open. | ||
| tokio::spawn(async move { | ||
| let mut found = false; | ||
| while let Ok(Some(line)) = reader.next_line().await { | ||
| tracing::debug!("[cloudflared] {}", line); | ||
| if !found { | ||
| if let Some(idx) = line.find("https://") { | ||
| let sub = &line[idx..]; | ||
| let end_idx = sub | ||
| .find(|c: char| c.is_whitespace() || c == '|' || c == ']') | ||
| .unwrap_or(sub.len()); | ||
| let url = &sub[..end_idx]; | ||
| if url.ends_with(".trycloudflare.com") || url.ends_with(".cloudflare.com") { | ||
| let _ = url_tx.send(url.to_string()).await; | ||
| found = true; | ||
| // keep reading — don't break, so cloudflared's pipe stays open | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| let public_url = match timeout(Duration::from_secs(15), url_rx.recv()).await { | ||
| Ok(Some(url)) => url, | ||
| Ok(None) => return Err(anyhow!("cloudflared closed stderr without printing a tunnel URL")), | ||
| Err(_) => return Err(anyhow!("timeout waiting for cloudflared public URL")), | ||
| }; | ||
|
|
||
| info!("Tunnel public URL: {}", public_url); | ||
|
|
||
| let mut options = ServerOptions::default(); | ||
| options.override_origin = Some(public_url.clone()); | ||
|
|
||
| let (tx, rx) = oneshot::channel(); | ||
| let local_endpoint_clone = local_endpoint.clone(); | ||
| let server_task = tokio::spawn(async move { | ||
| let server = match Server::new(options) { | ||
| Ok(s) => s, | ||
| Err(e) => { | ||
| let _ = tx.send(Err(e)); | ||
| return; | ||
| } | ||
| }; | ||
| let _ = tx.send(Ok(())); | ||
|
|
||
| info!("Local sshx server listening on {}", local_endpoint_clone); | ||
| if let Err(err) = server.listen(listener).await { | ||
| warn!("Local server exited with error: {:?}", err); | ||
| } | ||
| }); | ||
|
|
||
| rx.await.context("local server failed to start")??; | ||
|
|
||
| Ok(TunnelGuard { | ||
| local_endpoint, | ||
| public_url, | ||
| server_task, | ||
| _child: child, | ||
| }) | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Embedding
../../buildwithinclude_dir!makes Rust compilation depend on frontend artifacts that are not checked into git (git ls-treefor this commit has nobuild/entries), so a clean checkout now fails unlessnpm run buildis run first. This regresses existing Rust-only flows (e.g.,.github/workflows/ci.yamlrust jobs runcargo test/cargo clippywithout a web build step), and it also breaks source installs in environments that only expect Cargo to be required.Useful? React with 👍 / 👎.