diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d96f847..3585238 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,7 +27,7 @@ jobs: uses: davidB/rust-cargo-make@v1 - name: Run Lints - run: ci/feature-soundness.sh + run: cargo make clippy rustfmt: name: Check Formatting @@ -68,21 +68,39 @@ jobs: with: tool: cargo-generate + - name: Install cargo-make + uses: davidB/rust-cargo-make@v1 + - name: Set Stellation Target to 'ci' run: | echo 'variable::set("stellation_target", "ci");' >> stellation/templates/default/resolve-crates.rhai - name: Generate Template run: | - cargo generate --path stellation/templates/default \ - --name template-default-generated + set -x + mkdir templates-generated/ + cd templates-generated + + for x in $(ls ../stellation/templates); do + if [ -d ../stellation/templates/$x ]; + then + echo "Creating Template $x..." + cargo generate --path ../stellation/templates/$x \ + --name generated-$x + fi + done - name: Run Lints run: | - cargo clippy --bin stctl - cargo clippy --bin template-default-generated-client - cargo clippy --bin template-default-generated-server - working-directory: template-default-generated + set -x + + for x in $(ls); do + cd $x + cargo make clippy + cd .. + done + + working-directory: templates-generated publish: name: Publish to crates.io @@ -109,6 +127,9 @@ jobs: - name: Configure sccache uses: visvirial/sccache-action@v1.0.1 + - name: Install cargo-make + uses: davidB/rust-cargo-make@v1 + - name: Set Git Information run: | git config --global user.name "Stellation Actions" @@ -141,23 +162,29 @@ jobs: run: | echo "CARGO_PUBLISH_EXTRA_ARGS=--token=${{ secrets.CRATES_IO_TOKEN }}" >> $GITHUB_ENV + # Run lints first so it will be faster to run publish checks. + - name: Run Lints + run: cargo make clippy + - name: Run cargo publish run: | CRATES=( stellation-core stellation-bridge stellation-backend + stellation-backend-warp + stellation-backend-tower stellation-backend-cli stellation-frontend stctl stellation ) - for CRATE_NAME in "${CRATES[@]}"; + for s in "${CRATES[@]}"; do cargo publish \ ${{ env.CARGO_PUBLISH_EXTRA_ARGS }} \ - --manifest-path crates/$CRATE_NAME/Cargo.toml + --manifest-path crates/$s/Cargo.toml done env: diff --git a/Cargo.lock b/Cargo.lock index f50fa35..cbc7dfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,6 +487,7 @@ dependencies = [ "rust-embed", "stellation-backend", "stellation-backend-cli", + "stellation-backend-tower", "tokio", "tracing", "yew", @@ -2288,28 +2289,15 @@ dependencies = [ name = "stellation-backend" version = "0.1.4" dependencies = [ - "bincode", "bounce", - "bytes", "futures", - "http", - "hyper", "lol_html", - "mime_guess", - "once_cell", - "rand 0.8.5", - "rust-embed", "serde", "serde_urlencoded", "stellation-bridge", "stellation-core", "thiserror", "thread_local", - "tokio", - "tower", - "tracing", - "typed-builder", - "warp", "yew", "yew-router", ] @@ -2322,6 +2310,7 @@ dependencies = [ "clap", "console", "stellation-backend", + "stellation-backend-tower", "stellation-core", "tracing", "tracing-subscriber", @@ -2329,6 +2318,45 @@ dependencies = [ "yew", ] +[[package]] +name = "stellation-backend-tower" +version = "0.1.4" +dependencies = [ + "futures", + "hyper", + "stellation-backend", + "stellation-backend-warp", + "stellation-bridge", + "tokio", + "tower", + "warp", + "yew", +] + +[[package]] +name = "stellation-backend-warp" +version = "0.1.4" +dependencies = [ + "bounce", + "bytes", + "futures", + "http", + "hyper", + "lol_html", + "mime_guess", + "once_cell", + "rand 0.8.5", + "rust-embed", + "serde_urlencoded", + "stellation-backend", + "stellation-bridge", + "tokio", + "tracing", + "warp", + "yew", + "yew-router", +] + [[package]] name = "stellation-bridge" version = "0.1.4" diff --git a/Makefile.toml b/Makefile.toml index 9ba1f5d..cc98e34 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -7,5 +7,19 @@ workspace = false command = "cargo" args = ["run", "--bin", "stctl", "--", "${@}"] -[config] -log_level = "info" +[tasks.clippy] +clear = true +workspace = false +script = ''' +#!/usr/bin/env bash +set -e + +cargo clippy --workspace --all-features -- -D warnings + +# We should lint bridge separately as resolvable features result in different behaviours. +cargo clippy -p stellation-bridge -- -D warnings +cargo clippy -p stellation-bridge --features=resolvable -- -D warnings + +cargo clippy -p example-fullstack-client -- -D warnings +cargo clippy -p example-fullstack-server -- -D warnings +''' diff --git a/ci/feature-soundness.sh b/ci/feature-soundness.sh deleted file mode 100755 index a45c812..0000000 --- a/ci/feature-soundness.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -xe - -cargo clippy -p stellation-core -- -D warnings - -cargo clippy -p stellation-bridge -- -D warnings -cargo clippy -p stellation-bridge --features=resolvable -- -D warnings - -cargo clippy -p stellation-backend -- -D warnings -cargo clippy -p stellation-backend --features=warp-filter -- -D warnings -cargo clippy -p stellation-backend --features=tower-service -- -D warnings -cargo clippy -p stellation-backend --features=hyper-server -- -D warnings - -cargo clippy -p stellation-backend-cli -- -D warnings - -cargo clippy -p stellation-frontend -- -D warnings - -cargo clippy -p stctl -- -D warnings - -cargo clippy -p stellation -- -D warnings - -cargo clippy -p example-fullstack-client -- -D warnings -cargo clippy -p example-fullstack-server -- -D warnings diff --git a/ci/switch-registry.py b/ci/switch-registry.py index 5107dfb..8577914 100644 --- a/ci/switch-registry.py +++ b/ci/switch-registry.py @@ -13,7 +13,7 @@ def main() -> None: print(f"Updating {cargo_toml_path}...") for (key, value) in cfg["dependencies"].items(): - if key != "stctl" and not key.startswith("stellation-"): + if not isinstance(value, dict) or "path" not in value.keys(): print(f" Skipping {key}...") continue diff --git a/ci/update-version.py b/ci/update-version.py new file mode 100644 index 0000000..02cfa4d --- /dev/null +++ b/ci/update-version.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +import tomlkit +import glob +import sys +from pathlib import Path + +def main() -> None: + cwd = Path.cwd() + next_ver = sys.argv[1] + + if next_ver.startswith('v'): + next_ver = next_ver[1:] + + print(f"Running in {cwd}...") + + for cargo_toml_path in cwd.glob("crates/*/Cargo.toml"): + cfg = tomlkit.loads(cargo_toml_path.open().read()) + print(f"Updating {cargo_toml_path} to version {next_ver}...") + + cfg["package"]["version"] = next_ver + + for (key, value) in cfg["dependencies"].items(): + if not isinstance(value, dict) or "path" not in value.keys(): + print(f" Skipping {key}...") + continue + + print(f" Updating {key} to version {next_ver}...") + value["version"] = next_ver + + with cargo_toml_path.open("w") as f: + f.write(tomlkit.dumps(cfg)) + f.flush() + +if __name__ == '__main__': + main() diff --git a/crates/stellation-backend-cli/Cargo.toml b/crates/stellation-backend-cli/Cargo.toml index 5f8f5d6..a7b7280 100644 --- a/crates/stellation-backend-cli/Cargo.toml +++ b/crates/stellation-backend-cli/Cargo.toml @@ -16,7 +16,8 @@ license = "MIT OR Apache-2.0" [dependencies] # Stellation Components -stellation-backend = { version = "0.1.4", path = "../stellation-backend", features = ["hyper-server"] } +stellation-backend = { version = "0.1.4", path = "../stellation-backend" } +stellation-backend-tower = { version = "0.1.4", path = "../stellation-backend-tower" } stellation-core = { version = "0.1.4", path = "../stellation-core" } # Yew / Component Related diff --git a/crates/stellation-backend-cli/src/cli.rs b/crates/stellation-backend-cli/src/cli.rs index c08539a..9d07beb 100644 --- a/crates/stellation-backend-cli/src/cli.rs +++ b/crates/stellation-backend-cli/src/cli.rs @@ -4,7 +4,8 @@ use std::path::PathBuf; use anyhow::{anyhow, Context}; use clap::Parser; -use stellation_backend::{Endpoint, Frontend, Server, ServerAppProps}; +use stellation_backend::ServerAppProps; +use stellation_backend_tower::{Frontend, Server, TowerEndpoint, TowerRequest}; use stellation_core::dev::StctlMetadata; use typed_builder::TypedBuilder; use yew::BaseComponent; @@ -21,17 +22,18 @@ struct Arguments { /// The default command line instance for the backend server. #[derive(Debug, TypedBuilder)] -pub struct Cli +pub struct Cli where COMP: BaseComponent, { - endpoint: Endpoint, + endpoint: TowerEndpoint, } -impl Cli +impl Cli where - COMP: BaseComponent>, + COMP: BaseComponent>>, CTX: 'static, + BCTX: 'static, { /// Parses the arguments and runs the server. pub async fn run(self) -> anyhow::Result<()> { diff --git a/crates/stellation-backend-tower/Cargo.toml b/crates/stellation-backend-tower/Cargo.toml new file mode 100644 index 0000000..5809b7f --- /dev/null +++ b/crates/stellation-backend-tower/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "stellation-backend-tower" +version = "0.1.4" +edition = "2021" +rust-version = "1.66" +repository = "https://github.com/futursolo/stellation" +authors = [ + "Kaede Hoshiakwa ", +] +description = "The framework experience for Yew." +keywords = ["web", "wasm", "yew", "framework", "ssr"] +categories = ["wasm", "web-programming"] +readme = "../../README.md" +homepage = "https://github.com/futursolo/stellation" +license = "MIT OR Apache-2.0" + +[dependencies] +hyper = { version = "0.14.23", features = ["runtime", "server", "http1"] } +tower = { version = "0.4", features = ["util"] } +tokio = { version = "1" } +futures = { version = "0.3", default-features = false, features = ["std"] } +yew = { version = "0.20", features = ["ssr"] } +warp = { version = "0.3.3", default-features = false } + +# Stellation Components +stellation-backend-warp = { version = "0.1.4", path = "../stellation-backend-warp" } +stellation-backend = { version = "0.1.4", path = "../stellation-backend" } +stellation-bridge = { version = "0.1.4", path = "../stellation-bridge", features = ["resolvable"] } + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "documenting"] diff --git a/crates/stellation-backend-tower/src/endpoint.rs b/crates/stellation-backend-tower/src/endpoint.rs new file mode 100644 index 0000000..8c165fb --- /dev/null +++ b/crates/stellation-backend-tower/src/endpoint.rs @@ -0,0 +1,121 @@ +use std::convert::Infallible; +use std::future::Future; + +use hyper::{Body, Request, Response}; +use stellation_backend::ServerAppProps; +use stellation_backend_warp::{Frontend, WarpEndpoint}; +use stellation_bridge::{Bridge, BridgeMetadata}; +use tower::Service; +use yew::BaseComponent; + +use crate::TowerRequest; + +/// Creates a stellation endpoint that can be turned into a tower service. +/// +/// This endpoint serves bridge requests and frontend requests. +/// You can turn this type into a tower service by calling [`into_tower_service()`]. +#[derive(Debug)] +pub struct TowerEndpoint +where + COMP: BaseComponent, +{ + inner: WarpEndpoint, +} + +impl Default for TowerEndpoint +where + COMP: BaseComponent, + CTX: 'static + Default, + BCTX: 'static + Default, +{ + fn default() -> Self { + Self::new() + } +} + +impl TowerEndpoint +where + COMP: BaseComponent, + CTX: 'static, + BCTX: 'static, +{ + /// Creates an endpoint. + pub fn new() -> Self + where + CTX: Default, + BCTX: Default, + { + Self { + inner: WarpEndpoint::default(), + } + } + + /// Appends a context to current request. + pub fn with_append_context(self, append_context: F) -> TowerEndpoint + where + F: 'static + Clone + Send + Fn(TowerRequest<()>) -> Fut, + Fut: 'static + Future>, + C: 'static, + { + TowerEndpoint { + inner: self.inner.with_append_context(append_context), + } + } + + /// Appends a bridge context to current request. + pub fn with_append_bridge_context( + self, + append_bridge_context: F, + ) -> TowerEndpoint + where + F: 'static + Clone + Send + Fn(BridgeMetadata<()>) -> Fut, + Fut: 'static + Future>, + C: 'static, + { + TowerEndpoint { + inner: self.inner.with_append_bridge_context(append_bridge_context), + } + } + + /// Serves a bridge on current endpoint. + pub fn with_bridge(mut self, bridge: Bridge) -> Self { + self.inner = self.inner.with_bridge(bridge); + self + } + + /// Enables auto refresh. + /// + /// This is useful during development. + pub fn with_auto_refresh(mut self) -> Self { + self.inner = self.inner.with_auto_refresh(); + self + } + + /// Serves a frontend with current endpoint. + pub fn with_frontend(mut self, frontend: Frontend) -> Self { + self.inner = self.inner.with_frontend(frontend); + self + } +} + +impl TowerEndpoint +where + COMP: BaseComponent>>, + CTX: 'static, + BCTX: 'static, +{ + /// Creates a tower service from current endpoint. + pub fn into_tower_service( + self, + ) -> impl 'static + + Clone + + Service< + Request, + Response = Response, + Error = Infallible, + Future = impl 'static + Send + Future, Infallible>>, + > { + let routes = self.inner.into_warp_filter(); + warp::service(routes) + } +} diff --git a/crates/stellation-backend-tower/src/lib.rs b/crates/stellation-backend-tower/src/lib.rs new file mode 100644 index 0000000..f5ab813 --- /dev/null +++ b/crates/stellation-backend-tower/src/lib.rs @@ -0,0 +1,23 @@ +//! Stellation's tower support. + +#![deny(clippy::all)] +#![deny(missing_debug_implementations)] +#![deny(unsafe_code)] +#![deny(non_snake_case)] +#![deny(clippy::cognitive_complexity)] +#![deny(missing_docs)] +#![cfg_attr(documenting, feature(doc_cfg))] +#![cfg_attr(documenting, feature(doc_auto_cfg))] +#![cfg_attr(any(releasing, not(debug_assertions)), deny(dead_code, unused_imports))] + +mod endpoint; +pub use endpoint::TowerEndpoint; +/// A stellation request with information extracted with tower services. +/// +/// Currently, this is a type alias to [`WarpRequest`](stellation_backend_warp::WarpRequest). +pub type TowerRequest = stellation_backend_warp::WarpRequest; +#[doc(inline)] +pub use stellation_backend_warp::Frontend; + +mod server; +pub use server::Server; diff --git a/crates/stellation-backend/src/server.rs b/crates/stellation-backend-tower/src/server.rs similarity index 100% rename from crates/stellation-backend/src/server.rs rename to crates/stellation-backend-tower/src/server.rs diff --git a/crates/stellation-backend-warp/Cargo.toml b/crates/stellation-backend-warp/Cargo.toml new file mode 100644 index 0000000..0e8be93 --- /dev/null +++ b/crates/stellation-backend-warp/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "stellation-backend-warp" +version = "0.1.4" +edition = "2021" +rust-version = "1.66" +repository = "https://github.com/futursolo/stellation" +authors = [ + "Kaede Hoshiakwa ", +] +description = "The framework experience for Yew." +keywords = ["web", "wasm", "yew", "framework", "ssr"] +categories = ["wasm", "web-programming"] +readme = "../../README.md" +homepage = "https://github.com/futursolo/stellation" +license = "MIT OR Apache-2.0" + +[dependencies] +# Yew / Component Related +yew = { version = "0.20", features = ["ssr"] } +yew-router = "0.17" +bounce = { version = "0.6", features = ["helmet", "ssr"] } + +# Stellation Components +stellation-backend = { version = "0.1.4", path = "../stellation-backend" } +stellation-bridge = { version = "0.1.4", path = "../stellation-bridge", features = ["resolvable"] } + +# HTTP +hyper = { version = "0.14.23", features = ["runtime", "server", "http1"] } +warp = { version = "0.3.3", default-features = false, features = ["websocket"] } +serde_urlencoded = "0.7.1" +bytes = { version = "1" } +http = { version = "0.2" } +rust-embed = { version = "6.4.2" } +mime_guess = "2.0.4" +lol_html = "0.3.2" + +# Other +futures = { version = "0.3", default-features = false, features = ["std"] } +tokio = { version = "1" } +once_cell = "1.17.0" +tracing = { version = "0.1.37" } +rand = "0.8.5" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "documenting"] diff --git a/crates/stellation-backend-warp/src/endpoint.rs b/crates/stellation-backend-warp/src/endpoint.rs new file mode 100644 index 0000000..bf58569 --- /dev/null +++ b/crates/stellation-backend-warp/src/endpoint.rs @@ -0,0 +1,406 @@ +use core::fmt; +use std::future::Future; +use std::marker::PhantomData; +use std::ops::Deref; + +use bytes::Bytes; +use futures::future::LocalBoxFuture; +use futures::{FutureExt, SinkExt, StreamExt, TryFutureExt}; +use http::status::StatusCode; +use stellation_backend::utils::ThreadLocalLazy; +use stellation_backend::{ServerAppProps, ServerRenderer}; +use stellation_bridge::{Bridge, BridgeError, BridgeMetadata}; +use tokio::sync::oneshot as sync_oneshot; +use warp::body::bytes; +use warp::path::FullPath; +use warp::reject::not_found; +use warp::reply::Response; +use warp::ws::{Message, Ws}; +use warp::{header, log, reply, Filter, Rejection, Reply}; +use yew::platform::{LocalHandle, Runtime}; +use yew::prelude::*; + +use crate::frontend::Frontend; +use crate::request::WarpRequest; +use crate::{html, SERVER_ID}; + +type BoxedSendFn = Box LocalBoxFuture<'static, OUT>>; +type SendFn = ThreadLocalLazy>; + +/// Creates a stellation endpoint that can be turned into a wrap filter. +/// +/// This endpoint serves bridge requests and frontend requests. +/// You can turn this type into a tower service by calling [`into_warp_filter()`]. +pub struct WarpEndpoint +where + COMP: BaseComponent, +{ + frontend: Option, + affix_context: SendFn, WarpRequest>, + + bridge: Option, + affix_bridge_context: SendFn, BridgeMetadata>, + + auto_refresh: bool, + _marker: PhantomData, +} + +impl fmt::Debug for WarpEndpoint +where + COMP: BaseComponent, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("WarpEndpoint<_>") + } +} + +impl Default for WarpEndpoint +where + COMP: BaseComponent, + CTX: 'static + Default, + BCTX: 'static + Default, +{ + fn default() -> Self { + Self::new() + } +} + +impl WarpEndpoint +where + COMP: BaseComponent, + CTX: 'static, + BCTX: 'static, +{ + /// Creates an endpoint. + pub fn new() -> Self + where + CTX: Default, + BCTX: Default, + { + Self { + affix_context: SendFn::, WarpRequest>::new(move || { + Box::new(|m| async move { m.with_context(CTX::default()) }.boxed()) + }), + affix_bridge_context: SendFn::, BridgeMetadata>::new( + move || Box::new(|m| async move { m.with_context(BCTX::default()) }.boxed()), + ), + bridge: None, + frontend: None, + auto_refresh: false, + _marker: PhantomData, + } + } + + /// Appends a context to current request. + pub fn with_append_context(self, append_context: F) -> WarpEndpoint + where + F: 'static + Clone + Send + Fn(WarpRequest<()>) -> Fut, + Fut: 'static + Future>, + C: 'static, + { + WarpEndpoint { + affix_context: SendFn::, WarpRequest>::new(move || { + let append_context = append_context.clone(); + Box::new(move |input| append_context(input).boxed_local()) + }), + affix_bridge_context: self.affix_bridge_context, + bridge: self.bridge, + frontend: self.frontend, + auto_refresh: self.auto_refresh, + _marker: PhantomData, + } + } + + /// Appends a bridge context to current request. + pub fn with_append_bridge_context( + self, + append_bridge_context: F, + ) -> WarpEndpoint + where + F: 'static + Clone + Send + Fn(BridgeMetadata<()>) -> Fut, + Fut: 'static + Future>, + C: 'static, + { + WarpEndpoint { + affix_context: self.affix_context, + affix_bridge_context: SendFn::, BridgeMetadata>::new(move || { + let append_bridge_context = append_bridge_context.clone(); + Box::new(move |input| append_bridge_context(input).boxed_local()) + }), + bridge: self.bridge, + frontend: self.frontend, + auto_refresh: self.auto_refresh, + _marker: PhantomData, + } + } + + /// Serves a bridge on current endpoint. + pub fn with_bridge(mut self, bridge: Bridge) -> Self { + self.bridge = Some(bridge); + self + } + + /// Enables auto refresh. + /// + /// This is useful during development. + pub fn with_auto_refresh(mut self) -> Self { + self.auto_refresh = true; + + self + } + + /// Serves a frontend with current endpoint. + pub fn with_frontend(mut self, frontend: Frontend) -> Self { + self.frontend = Some(frontend); + + self + } +} + +impl WarpEndpoint +where + COMP: BaseComponent>>, + CTX: 'static, + BCTX: 'static, +{ + fn create_index_filter( + &self, + ) -> Option< + impl Clone + + Send + + Filter< + Extract = (Response,), + Error = Rejection, + Future = impl Future>, + >, + > { + let index_html = self.frontend.as_ref()?.index_html(); + let affix_context = self.affix_context.clone(); + let bridge = self.bridge.clone().unwrap_or_default(); + let auto_refresh = self.auto_refresh; + let affix_bridge_context = self.affix_bridge_context.clone(); + + let create_render_inner = move |req, tx: sync_oneshot::Sender| async move { + let req = (affix_context.deref())(req).await; + let bridge_metadata = (affix_bridge_context.deref())(BridgeMetadata::new()).await; + + let s = ServerRenderer::, CTX>::new(req) + .bridge(bridge, bridge_metadata) + .render() + .await; + + let _ = tx.send(s); + }; + + let render_html = move |req| async move { + let (tx, rx) = sync_oneshot::channel::(); + + // We spawn into a local runtime early for higher efficiency. + match LocalHandle::try_current() { + Some(handle) => handle.spawn_local(create_render_inner(req, tx)), + // TODO: Allow Overriding Runtime with Endpoint. + None => Runtime::default().spawn_pinned(move || create_render_inner(req, tx)), + } + + warp::reply::html(rx.await.expect("renderer panicked?")) + }; + + let f = warp::get() + .and(warp::path::full()) + .and( + warp::query::raw().or_else(|_| async move { Ok::<_, Rejection>((String::new(),)) }), + ) + .then(move |path: FullPath, raw_queries| { + let render_html = render_html.clone(); + let index_html = index_html.clone(); + + async move { + let mut template = index_html.read_content().await; + if auto_refresh { + template = html::add_refresh_script(&template).into(); + } + + let req = WarpRequest { + path, + raw_queries, + template, + context: (), + }; + + render_html(req).await.into_response() + } + }); + + Some(f) + } + + fn create_refresh_filter( + ) -> impl Clone + Send + Filter { + warp::path::path("_refresh") + .and(warp::ws()) + .then(|m: Ws| async move { + m.on_upgrade(|mut ws| async move { + let read_refresh = { + || async move { + while let Some(m) = ws.next().await { + let m = match m { + Ok(m) => m, + Err(e) => { + tracing::error!("receive message error: {:?}", e); + + if let Err(e) = ws.close().await { + tracing::error!("failed to close websocket: {:?}", e); + } + + return; + } + }; + + if m.is_ping() || m.is_pong() { + continue; + } + + let m = match m.to_str() { + Ok(m) => m, + Err(_) => { + tracing::error!("received unknown message: {:?}", m); + return; + } + }; + + // Ping client if string matches. + // Otherwise, tell the client to reload the page. + let message_to_send = if m == SERVER_ID.as_str() { + Message::ping("") + } else { + Message::text("restart") + }; + + if let Err(e) = ws.send(message_to_send).await { + tracing::error!("error sending message: {:?}", e); + return; + } + } + } + }; + + match LocalHandle::try_current() { + Some(handle) => handle.spawn_local(read_refresh()), + // TODO: Allow Overriding Runtime with Endpoint. + None => Runtime::default().spawn_pinned(read_refresh), + } + }) + .into_response() + }) + } + + fn create_bridge_filter( + &self, + ) -> Option> { + let bridge = self.bridge.clone()?; + + let http_bridge_f = warp::post() + .and(header::exact_ignore_case( + "content-type", + "application/x-bincode", + )) + .and(header::optional("authorization")) + .and(bytes()) + .then(move |token: Option, input: Bytes| { + let bridge = bridge.clone(); + let (tx, rx) = sync_oneshot::channel(); + + let resolve_encoded = move || async move { + let mut meta = BridgeMetadata::<()>::new(); + + if let Some(m) = token { + if !m.starts_with("Bearer ") { + let reply = + reply::with_status("", StatusCode::BAD_REQUEST).into_response(); + + let _ = tx.send(reply); + return; + } + + meta = meta.with_token(m.split_at(7).1); + } + + let content = bridge + .connect(meta) + .and_then(|m| async move { m.resolve_encoded(&input).await }) + .await; + + let reply = match content { + Ok(m) => reply::with_header(m, "content-type", "application/x-bincode") + .into_response(), + Err(BridgeError::Encoding(_)) + | Err(BridgeError::InvalidIndex(_)) + | Err(BridgeError::InvalidType(_)) => { + reply::with_status("", StatusCode::BAD_REQUEST).into_response() + } + Err(BridgeError::Network(_)) => { + reply::with_status("", StatusCode::INTERNAL_SERVER_ERROR) + .into_response() + } + }; + + let _ = tx.send(reply); + }; + + match LocalHandle::try_current() { + Some(handle) => handle.spawn_local(resolve_encoded()), + // TODO: Allow Overriding Runtime with Endpoint. + None => Runtime::default().spawn_pinned(resolve_encoded), + } + + async move { rx.await.expect("failed to resolve the bridge request") } + }); + + Some(warp::path::path("_bridge").and(http_bridge_f)) + } + + /// Creates a warp filter from current endpoint. + pub fn into_warp_filter( + self, + ) -> impl Clone + Send + Filter { + let bridge_f = self.create_bridge_filter(); + let index_html_f = self.create_index_filter(); + + let Self { frontend, .. } = self; + + let mut routes = match index_html_f.clone() { + None => warp::path::end() + .and_then(|| async move { Err::(not_found()) }) + .boxed(), + Some(m) => warp::path::end().and(m).boxed(), + }; + + if let Some(m) = bridge_f { + routes = routes.or(m).unify().boxed(); + } + + if let Some(m) = frontend { + routes = routes.or(m.into_warp_filter()).unify().boxed(); + } + + if self.auto_refresh { + routes = routes.or(Self::create_refresh_filter()).unify().boxed(); + } + + if let Some(m) = index_html_f { + routes = routes.or(m).unify().boxed(); + } + + routes.with(log::custom(|info| { + // We emit a custom span so it won't interfere with warp's default tracing event. + tracing::info!(target: "stellation_backend::endpoint::trace", + remote_addr = ?info.remote_addr(), + method = %info.method(), + path = info.path(), + status = info.status().as_u16(), + referer = ?info.referer(), + user_agent = ?info.user_agent(), + duration = info.elapsed().as_nanos()); + })) + } +} diff --git a/crates/stellation-backend/src/frontend.rs b/crates/stellation-backend-warp/src/frontend.rs similarity index 57% rename from crates/stellation-backend/src/frontend.rs rename to crates/stellation-backend-warp/src/frontend.rs index d96a3f9..fb17e15 100644 --- a/crates/stellation-backend/src/frontend.rs +++ b/crates/stellation-backend-warp/src/frontend.rs @@ -1,12 +1,10 @@ -use std::borrow::Cow; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{fmt, str}; -use bounce::helmet::HelmetTag; -use lol_html::{doc_comments, element, rewrite_str, Settings}; use rust_embed::{EmbeddedFile, RustEmbed}; +use stellation_backend::utils::ThreadLocalLazy; use tokio::fs; use warp::filters::fs::File; use warp::filters::BoxedFilter; @@ -14,8 +12,6 @@ use warp::path::Tail; use warp::reply::{with_header, Response}; use warp::{Filter, Rejection, Reply}; -use crate::utils::ThreadLocalLazy; - type GetFileFn = Box Option>; type GetFile = ThreadLocalLazy; @@ -117,82 +113,14 @@ pub(crate) enum IndexHtml { } impl IndexHtml { - async fn read_content(&self) -> Cow<'_, str> { + pub async fn read_content(&self) -> Arc { match self { IndexHtml::Path(p) => fs::read_to_string(&p) .await - .map(Cow::from) + .map(Arc::from) .expect("TODO: implement failure."), - IndexHtml::Embedded(ref s) => s.as_ref().into(), + IndexHtml::Embedded(ref s) => s.clone(), } } - - pub async fn render(&self, tags: I, head_s: H, body_s: B) -> String - where - I: IntoIterator, - H: Into, - B: AsRef, - { - let mut head_s = head_s.into(); - let body_s = body_s.as_ref(); - - let mut html_tag = None; - let mut body_tag = None; - - for tag in tags.into_iter() { - match tag { - HelmetTag::Html { .. } => { - html_tag = Some(tag); - } - HelmetTag::Body { .. } => { - body_tag = Some(tag); - } - _ => { - let _ = tag.write_static(&mut head_s); - } - } - } - - let index_html_s = self.read_content().await; - - rewrite_str( - &index_html_s, - Settings { - element_content_handlers: vec![ - element!("html", |h| { - if let Some(HelmetTag::Html { attrs }) = html_tag.take() { - for (k, v) in attrs { - h.set_attribute(k.as_ref(), v.as_ref())?; - } - } - - Ok(()) - }), - element!("body", |h| { - if let Some(HelmetTag::Body { attrs }) = body_tag.take() { - for (k, v) in attrs { - h.set_attribute(k.as_ref(), v.as_ref())?; - } - } - - Ok(()) - }), - ], - - document_content_handlers: vec![doc_comments!(|c| { - if c.text() == "%STELLATION_HEAD%" { - c.replace(&head_s, lol_html::html_content::ContentType::Html); - } - if c.text() == "%STELLATION_BODY%" { - c.replace(body_s, lol_html::html_content::ContentType::Html); - } - - Ok(()) - })], - ..Default::default() - }, - ) - .expect("failed to render html") - } } diff --git a/crates/stellation-backend-warp/src/html.rs b/crates/stellation-backend-warp/src/html.rs new file mode 100644 index 0000000..aa9b9a6 --- /dev/null +++ b/crates/stellation-backend-warp/src/html.rs @@ -0,0 +1,63 @@ +use lol_html::{doc_comments, rewrite_str, Settings}; +use once_cell::sync::Lazy; + +use crate::SERVER_ID; + +static AUTO_REFRESH_SCRIPT: Lazy = Lazy::new(|| { + format!( + r#" +"#, + SERVER_ID.as_str() + ) +}); + +pub(crate) fn add_refresh_script(html_s: &str) -> String { + rewrite_str( + html_s, + Settings { + document_content_handlers: vec![doc_comments!(|c| { + if c.text() == "%STELLATION_BODY%" { + c.after( + AUTO_REFRESH_SCRIPT.as_str(), + lol_html::html_content::ContentType::Html, + ); + } + Ok(()) + })], + ..Default::default() + }, + ) + .expect("failed to render html") +} diff --git a/crates/stellation-backend-warp/src/lib.rs b/crates/stellation-backend-warp/src/lib.rs new file mode 100644 index 0000000..398cbac --- /dev/null +++ b/crates/stellation-backend-warp/src/lib.rs @@ -0,0 +1,25 @@ +//! Stellation's wrap support. + +#![deny(clippy::all)] +#![deny(missing_debug_implementations)] +#![deny(unsafe_code)] +#![deny(non_snake_case)] +#![deny(clippy::cognitive_complexity)] +#![deny(missing_docs)] +#![cfg_attr(documenting, feature(doc_cfg))] +#![cfg_attr(documenting, feature(doc_auto_cfg))] +#![cfg_attr(any(releasing, not(debug_assertions)), deny(dead_code, unused_imports))] + +mod endpoint; +mod frontend; +mod html; +mod request; +mod utils; + +pub use endpoint::WarpEndpoint; +pub use frontend::Frontend; +use once_cell::sync::Lazy; +pub use request::WarpRequest; + +// A server id that is different every time it starts. +static SERVER_ID: Lazy = Lazy::new(crate::utils::random_str); diff --git a/crates/stellation-backend-warp/src/request.rs b/crates/stellation-backend-warp/src/request.rs new file mode 100644 index 0000000..08ee444 --- /dev/null +++ b/crates/stellation-backend-warp/src/request.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use stellation_backend::Request; +use warp::path::FullPath; + +/// A stellation request with information extracted with warp filters. +#[derive(Debug)] +pub struct WarpRequest { + pub(crate) path: FullPath, + pub(crate) raw_queries: String, + pub(crate) template: Arc, + pub(crate) context: CTX, +} + +impl Request for WarpRequest { + type Context = CTX; + + fn path(&self) -> &str { + self.path.as_str() + } + + fn raw_queries(&self) -> &str { + &self.raw_queries + } + + fn template(&self) -> &str { + self.template.as_ref() + } + + fn context(&self) -> &Self::Context { + &self.context + } +} + +impl WarpRequest { + /// Appends a context to current server app to help resolving the request. + pub fn with_context(self, context: C) -> WarpRequest { + WarpRequest { + path: self.path, + raw_queries: self.raw_queries, + template: self.template, + context, + } + } +} diff --git a/crates/stellation-backend-warp/src/utils.rs b/crates/stellation-backend-warp/src/utils.rs new file mode 100644 index 0000000..6108073 --- /dev/null +++ b/crates/stellation-backend-warp/src/utils.rs @@ -0,0 +1,13 @@ +//! Warp utilities. + +/// Creates a random string. +pub(crate) fn random_str() -> String { + use rand::distributions::Alphanumeric; + use rand::Rng; + + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(7) + .map(char::from) + .collect() +} diff --git a/crates/stellation-backend/Cargo.toml b/crates/stellation-backend/Cargo.toml index 999df60..d1334ef 100644 --- a/crates/stellation-backend/Cargo.toml +++ b/crates/stellation-backend/Cargo.toml @@ -16,15 +16,11 @@ license = "MIT OR Apache-2.0" [dependencies] futures = { version = "0.3", default-features = false, features = ["std"] } -typed-builder = "0.11.0" serde = { version = "1", features = ["derive"] } thiserror = "1" thread_local = "1.1.4" -once_cell = "1.17.0" -bincode = "1.3.3" -rand = "0.8.5" lol_html = "0.3.2" -tracing = { version = "0.1.37" } +serde_urlencoded = "0.7.1" # Stellation Components stellation-bridge = { version = "0.1.4", path = "../stellation-bridge", features = ["resolvable"] } @@ -35,22 +31,6 @@ yew = { version = "0.20", features = ["ssr"] } bounce = { version = "0.6", features = ["helmet", "ssr"] } yew-router = "0.17" -# Hyper Server and Tower Service. -hyper = { version = "0.14.23", features = ["runtime", "server", "http1"], optional = true } -tower = { version = "0.4", features = ["util"], optional = true } -warp = { version = "0.3.3", default-features = false, optional = true, features = ["websocket"] } -tokio = { version = "1", optional = true } -serde_urlencoded = "0.7.1" -bytes = { version = "1", optional = true } -http = { version = "0.2", optional = true } -rust-embed = { version = "6.4.2", optional = true } -mime_guess = "2.0.4" - -[features] -warp-filter = ["dep:warp", "dep:tokio", "dep:bytes", "dep:http", "dep:rust-embed"] -tower-service = ["warp-filter", "dep:tower", "dep:hyper"] -hyper-server = ["tower-service"] - [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "documenting"] diff --git a/crates/stellation-backend/src/endpoint.rs b/crates/stellation-backend/src/endpoint.rs deleted file mode 100644 index 0d72766..0000000 --- a/crates/stellation-backend/src/endpoint.rs +++ /dev/null @@ -1,532 +0,0 @@ -use core::fmt; -use std::marker::PhantomData; - -use futures::future::LocalBoxFuture; -use futures::{Future, FutureExt}; -use stellation_bridge::{Bridge, BridgeMetadata}; -use yew::prelude::*; - -use crate::props::ServerAppProps; -use crate::utils::ThreadLocalLazy; - -type BoxedSendFn = Box LocalBoxFuture<'static, OUT>>; -type SendFn = ThreadLocalLazy>; - -/// A stellation endpoint. -/// -/// This endpoint serves bridge requests and frontend requests. -/// -/// This type can be turned into a tower service or a warp filter. -/// -/// # Note -/// -/// Stellation provides [`BrowserRouter`](yew_router::BrowserRouter) and -/// [`BounceRoot`](bounce::BounceRoot) to all applications. -/// -/// Bounce Helmet is also bridged automatically. -/// -/// You do not need to add them manually. -pub struct Endpoint -where - COMP: BaseComponent, -{ - #[allow(dead_code)] - affix_context: SendFn, ServerAppProps>, - bridge: Option, - #[allow(dead_code)] - affix_bridge_context: SendFn, BridgeMetadata>, - #[cfg(feature = "warp-filter")] - frontend: Option, - - #[cfg(feature = "warp-filter")] - auto_refresh: bool, - - _marker: PhantomData, -} - -impl fmt::Debug for Endpoint -where - COMP: BaseComponent, -{ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("Endpoint<_>") - } -} - -impl Default for Endpoint -where - COMP: BaseComponent, - CTX: 'static + Default, - BCTX: 'static + Default, -{ - fn default() -> Self { - Self::new() - } -} - -impl Endpoint -where - COMP: BaseComponent, - CTX: 'static, - BCTX: 'static, -{ - /// Creates an endpoint. - pub fn new() -> Self - where - CTX: Default, - BCTX: Default, - { - Self { - affix_context: SendFn::, ServerAppProps>::new(move || { - Box::new(|m| async move { m.with_context(CTX::default()) }.boxed()) - }), - affix_bridge_context: SendFn::, BridgeMetadata>::new( - move || Box::new(|m| async move { m.with_context(BCTX::default()) }.boxed()), - ), - bridge: None, - #[cfg(feature = "warp-filter")] - frontend: None, - #[cfg(feature = "warp-filter")] - auto_refresh: false, - _marker: PhantomData, - } - } - - /// Appends a context to current request. - pub fn with_append_context(self, append_context: F) -> Endpoint - where - F: 'static + Clone + Send + Fn(ServerAppProps<()>) -> Fut, - Fut: 'static + Future>, - C: 'static, - { - Endpoint { - affix_context: SendFn::, ServerAppProps>::new(move || { - let append_context = append_context.clone(); - Box::new(move |input| append_context(input).boxed_local()) - }), - affix_bridge_context: self.affix_bridge_context, - bridge: self.bridge, - #[cfg(feature = "warp-filter")] - frontend: self.frontend, - #[cfg(feature = "warp-filter")] - auto_refresh: self.auto_refresh, - _marker: PhantomData, - } - } - - /// Appends a bridge context to current request. - pub fn with_append_bridge_context( - self, - append_bridge_context: F, - ) -> Endpoint - where - F: 'static + Clone + Send + Fn(BridgeMetadata<()>) -> Fut, - Fut: 'static + Future>, - C: 'static, - { - Endpoint { - affix_context: self.affix_context, - affix_bridge_context: SendFn::, BridgeMetadata>::new(move || { - let append_bridge_context = append_bridge_context.clone(); - Box::new(move |input| append_bridge_context(input).boxed_local()) - }), - bridge: self.bridge, - #[cfg(feature = "warp-filter")] - frontend: self.frontend, - #[cfg(feature = "warp-filter")] - auto_refresh: self.auto_refresh, - _marker: PhantomData, - } - } - - /// Serves a bridge on current endpoint. - pub fn with_bridge(mut self, bridge: Bridge) -> Self { - self.bridge = Some(bridge); - self - } -} - -#[cfg(feature = "warp-filter")] -mod feat_warp_filter { - use std::fmt::Write; - use std::future::Future; - use std::ops::Deref; - use std::rc::Rc; - - use bounce::helmet::render_static; - use bytes::Bytes; - use futures::{SinkExt, StreamExt, TryFutureExt}; - use http::status::StatusCode; - use once_cell::sync::Lazy; - use stellation_bridge::{BridgeError, BridgeMetadata}; - use tokio::sync::oneshot as sync_oneshot; - use warp::body::bytes; - use warp::path::FullPath; - use warp::reject::not_found; - use warp::reply::Response; - use warp::ws::{Message, Ws}; - use warp::{header, log, reply, Filter, Rejection, Reply}; - use yew::platform::{LocalHandle, Runtime}; - - use super::*; - use crate::root::{StellationRoot, StellationRootProps}; - use crate::utils::random_str; - use crate::Frontend; - - // A server id that is different every time it starts. - static SERVER_ID: Lazy = Lazy::new(random_str); - - static AUTO_REFRESH_SCRIPT: Lazy = Lazy::new(|| { - format!( - r#" -"#, - SERVER_ID.as_str() - ) - }); - - impl Endpoint - where - COMP: BaseComponent>, - CTX: 'static, - BCTX: 'static, - { - /// Enables auto refresh. - /// - /// This is useful during development. - pub fn with_auto_refresh(mut self) -> Self { - self.auto_refresh = true; - - self - } - - fn create_index_filter( - &self, - ) -> Option< - impl Clone - + Send - + Filter< - Extract = (Response,), - Error = Rejection, - Future = impl Future>, - >, - > { - let index_html = self.frontend.as_ref()?.index_html(); - let affix_context = self.affix_context.clone(); - let bridge = self.bridge.clone().unwrap_or_default(); - let auto_refresh = self.auto_refresh; - let affix_bridge_context = self.affix_bridge_context.clone(); - - let create_render_inner = move |props, tx: sync_oneshot::Sender| async move { - let props = (affix_context.deref())(props).await; - let bridge_metadata = - Rc::new((affix_bridge_context.deref())(BridgeMetadata::new()).await); - - let mut head_s = String::new(); - let mut body_s = String::new(); - let mut helmet_tags = Vec::new(); - - if !props.is_client_only() { - let (reader, writer) = render_static(); - - body_s = - yew::LocalServerRenderer::>::with_props( - StellationRootProps { - server_app_props: props, - helmet_writer: writer, - bridge, - bridge_metadata, - }, - ) - .render() - .await; - - helmet_tags = reader.render().await; - let _ = write!( - &mut head_s, - r#""# - ); - } - - // With development server, we read index.html every time. - if auto_refresh { - body_s.push_str(AUTO_REFRESH_SCRIPT.as_str()); - } - - let s = index_html.render(helmet_tags, head_s, body_s).await; - let _ = tx.send(s); - }; - - let render_html = move |props| async move { - let (tx, rx) = sync_oneshot::channel::(); - - // We spawn into a local runtime early for higher efficiency. - match LocalHandle::try_current() { - Some(handle) => handle.spawn_local(create_render_inner(props, tx)), - // TODO: Allow Overriding Runtime with Endpoint. - None => Runtime::default().spawn_pinned(move || create_render_inner(props, tx)), - } - - warp::reply::html(rx.await.expect("renderer panicked?")) - }; - - let f = warp::get() - .and(warp::path::full()) - .and( - warp::query::raw() - .or_else(|_| async move { Ok::<_, Rejection>((String::new(),)) }), - ) - .then(move |path: FullPath, raw_queries| { - let props = ServerAppProps::from_warp_request(path, raw_queries); - let render_html = render_html.clone(); - - async move { render_html(props).await.into_response() } - }); - - Some(f) - } - - fn create_refresh_filter( - ) -> impl Clone + Send + Filter { - warp::path::path("_refresh") - .and(warp::ws()) - .then(|m: Ws| async move { - m.on_upgrade(|mut ws| async move { - let read_refresh = { - || async move { - while let Some(m) = ws.next().await { - let m = match m { - Ok(m) => m, - Err(e) => { - tracing::error!("receive message error: {:?}", e); - - if let Err(e) = ws.close().await { - tracing::error!( - "failed to close websocket: {:?}", - e - ); - } - - return; - } - }; - - if m.is_ping() || m.is_pong() { - continue; - } - - let m = match m.to_str() { - Ok(m) => m, - Err(_) => { - tracing::error!("received unknown message: {:?}", m); - return; - } - }; - - // Ping client if string matches. - // Otherwise, tell the client to reload the page. - let message_to_send = if m == SERVER_ID.as_str() { - Message::ping("") - } else { - Message::text("restart") - }; - - if let Err(e) = ws.send(message_to_send).await { - tracing::error!("error sending message: {:?}", e); - return; - } - } - } - }; - - match LocalHandle::try_current() { - Some(handle) => handle.spawn_local(read_refresh()), - // TODO: Allow Overriding Runtime with Endpoint. - None => Runtime::default().spawn_pinned(read_refresh), - } - }) - .into_response() - }) - } - - fn create_bridge_filter( - &self, - ) -> Option> { - let bridge = self.bridge.clone()?; - - let http_bridge_f = warp::post() - .and(header::exact_ignore_case( - "content-type", - "application/x-bincode", - )) - .and(header::optional("authorization")) - .and(bytes()) - .then(move |token: Option, input: Bytes| { - let bridge = bridge.clone(); - let (tx, rx) = sync_oneshot::channel(); - - let resolve_encoded = move || async move { - let mut meta = BridgeMetadata::<()>::new(); - - if let Some(m) = token { - if !m.starts_with("Bearer ") { - let reply = - reply::with_status("", StatusCode::BAD_REQUEST).into_response(); - - let _ = tx.send(reply); - return; - } - - meta = meta.with_token(m.split_at(7).1); - } - - let content = bridge - .connect(meta) - .and_then(|m| async move { m.resolve_encoded(&input).await }) - .await; - - let reply = match content { - Ok(m) => reply::with_header(m, "content-type", "application/x-bincode") - .into_response(), - Err(BridgeError::Encoding(_)) - | Err(BridgeError::InvalidIndex(_)) - | Err(BridgeError::InvalidType(_)) => { - reply::with_status("", StatusCode::BAD_REQUEST).into_response() - } - Err(BridgeError::Network(_)) => { - reply::with_status("", StatusCode::INTERNAL_SERVER_ERROR) - .into_response() - } - }; - - let _ = tx.send(reply); - }; - - match LocalHandle::try_current() { - Some(handle) => handle.spawn_local(resolve_encoded()), - // TODO: Allow Overriding Runtime with Endpoint. - None => Runtime::default().spawn_pinned(resolve_encoded), - } - - async move { rx.await.expect("failed to resolve the bridge request") } - }); - - Some(warp::path::path("_bridge").and(http_bridge_f)) - } - - /// Serves a frontend with current endpoint. - pub fn with_frontend(mut self, frontend: Frontend) -> Self { - self.frontend = Some(frontend); - - self - } - - /// Creates a warp filter from current endpoint. - pub fn into_warp_filter( - self, - ) -> impl Clone + Send + Filter { - let bridge_f = self.create_bridge_filter(); - let index_html_f = self.create_index_filter(); - - let Self { frontend, .. } = self; - - let mut routes = match index_html_f.clone() { - None => warp::path::end() - .and_then(|| async move { Err::(not_found()) }) - .boxed(), - Some(m) => warp::path::end().and(m).boxed(), - }; - - if let Some(m) = bridge_f { - routes = routes.or(m).unify().boxed(); - } - - if let Some(m) = frontend { - routes = routes.or(m.into_warp_filter()).unify().boxed(); - } - - if self.auto_refresh { - routes = routes.or(Self::create_refresh_filter()).unify().boxed(); - } - - if let Some(m) = index_html_f { - routes = routes.or(m).unify().boxed(); - } - - routes.with(log::custom(|info| { - // We emit a custom span so it won't interfere with warp's default tracing event. - tracing::info!(target: "stellation_backend::endpoint::trace", - remote_addr = ?info.remote_addr(), - method = %info.method(), - path = info.path(), - status = info.status().as_u16(), - referer = ?info.referer(), - user_agent = ?info.user_agent(), - duration = info.elapsed().as_nanos()); - })) - } - } -} - -#[cfg(feature = "tower-service")] -mod feat_tower_service { - use std::convert::Infallible; - use std::future::Future; - - use hyper::{Body, Request, Response}; - use tower::Service; - - use super::*; - impl Endpoint - where - COMP: BaseComponent>, - CTX: 'static, - BCTX: 'static, - { - /// Creates a tower service from current endpoint. - pub fn into_tower_service( - self, - ) -> impl 'static - + Clone - + Service< - Request, - Response = Response, - Error = Infallible, - Future = impl 'static + Send + Future, Infallible>>, - > { - let routes = self.into_warp_filter(); - warp::service(routes) - } - } -} diff --git a/crates/stellation-backend/src/html.rs b/crates/stellation-backend/src/html.rs new file mode 100644 index 0000000..179fcee --- /dev/null +++ b/crates/stellation-backend/src/html.rs @@ -0,0 +1,68 @@ +use bounce::helmet::HelmetTag; +use lol_html::{doc_comments, element, rewrite_str, Settings}; + +pub(crate) async fn format_html(html_s: &str, tags: I, head_s: H, body_s: B) -> String +where + I: IntoIterator, + H: Into, + B: AsRef, +{ + let mut head_s = head_s.into(); + let body_s = body_s.as_ref(); + + let mut html_tag = None; + let mut body_tag = None; + + for tag in tags.into_iter() { + match tag { + HelmetTag::Html { .. } => { + html_tag = Some(tag); + } + HelmetTag::Body { .. } => { + body_tag = Some(tag); + } + _ => { + let _ = tag.write_static(&mut head_s); + } + } + } + + rewrite_str( + html_s, + Settings { + element_content_handlers: vec![ + element!("html", |h| { + if let Some(HelmetTag::Html { attrs }) = html_tag.take() { + for (k, v) in attrs { + h.set_attribute(k.as_ref(), v.as_ref())?; + } + } + + Ok(()) + }), + element!("body", |h| { + if let Some(HelmetTag::Body { attrs }) = body_tag.take() { + for (k, v) in attrs { + h.set_attribute(k.as_ref(), v.as_ref())?; + } + } + + Ok(()) + }), + ], + + document_content_handlers: vec![doc_comments!(|c| { + if c.text() == "%STELLATION_HEAD%" { + c.replace(&head_s, lol_html::html_content::ContentType::Html); + } + if c.text() == "%STELLATION_BODY%" { + c.replace(body_s, lol_html::html_content::ContentType::Html); + } + + Ok(()) + })], + ..Default::default() + }, + ) + .expect("failed to render html") +} diff --git a/crates/stellation-backend/src/lib.rs b/crates/stellation-backend/src/lib.rs index cd7b9ce..1fb5d67 100644 --- a/crates/stellation-backend/src/lib.rs +++ b/crates/stellation-backend/src/lib.rs @@ -1,6 +1,6 @@ //! Stellation Backend //! -//! This crate contains the backend server and utilities for backend. +//! This crate contains the server renderer and tools used for server-side rendering. #![deny(clippy::all)] #![deny(missing_debug_implementations)] @@ -12,21 +12,14 @@ #![cfg_attr(documenting, feature(doc_auto_cfg))] #![cfg_attr(any(releasing, not(debug_assertions)), deny(dead_code, unused_imports))] -mod endpoint; mod error; mod props; mod root; pub mod utils; -pub use endpoint::Endpoint; pub use error::{ServerAppError, ServerAppResult}; pub use props::ServerAppProps; - -#[cfg(feature = "warp-filter")] -mod frontend; -#[cfg(feature = "warp-filter")] -pub use frontend::Frontend; - -#[cfg(feature = "hyper-server")] -mod server; -#[cfg(feature = "hyper-server")] -pub use server::Server; +mod request; +pub use request::Request; +mod renderer; +pub use renderer::ServerRenderer; +mod html; diff --git a/crates/stellation-backend/src/props.rs b/crates/stellation-backend/src/props.rs index a26f8d9..d2e2a26 100644 --- a/crates/stellation-backend/src/props.rs +++ b/crates/stellation-backend/src/props.rs @@ -1,46 +1,26 @@ -use std::sync::Arc; +use std::marker::PhantomData; +use std::rc::Rc; use serde::{Deserialize, Serialize}; use yew::Properties; use crate::error::ServerAppResult; - -#[derive(Debug)] -#[non_exhaustive] -enum Path { - #[cfg(feature = "warp-filter")] - Warp(warp::path::FullPath), -} - -impl Path { - fn as_str(&self) -> &str { - match self { - #[cfg(feature = "warp-filter")] - Self::Warp(m) => m.as_str(), - #[cfg(not(feature = "warp-filter"))] - _ => panic!("not implemented variant"), - } - } -} - -#[derive(Debug)] -pub struct Inner { - path: Path, - raw_queries: String, -} +use crate::Request; /// The Properties provided to a server app. #[derive(Properties, Debug)] -pub struct ServerAppProps { - inner: Arc, - context: Arc, - client_only: bool, +pub struct ServerAppProps { + request: Rc, + _marker: PhantomData, } -impl ServerAppProps { +impl ServerAppProps +where + REQ: Request, +{ /// Returns the path of current request. pub fn path(&self) -> &str { - self.inner.path.as_str() + self.request.path() } /// Returns queries of current request. @@ -48,75 +28,38 @@ impl ServerAppProps { where Q: Serialize + for<'de> Deserialize<'de>, { - Ok(serde_urlencoded::from_str(&self.inner.raw_queries)?) + self.request.queries() } /// Returns queries as a raw string. pub fn raw_queries(&self) -> &str { - &self.inner.raw_queries + self.request.raw_queries() } /// Returns the current request context. - pub fn context(&self) -> &T { - &self.context - } -} - -impl PartialEq for ServerAppProps { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.inner, &other.inner) && Arc::ptr_eq(&self.context, &other.context) + pub fn context(&self) -> &CTX { + self.request.context() } -} -impl Clone for ServerAppProps { - fn clone(&self) -> Self { + pub(crate) fn from_request(request: Rc) -> Self { Self { - inner: self.inner.clone(), - context: self.context.clone(), - client_only: self.client_only, + request, + _marker: PhantomData, } } } -impl ServerAppProps { - /// Appends a context to current server app to help resolving the request. - pub fn with_context(self, context: CTX) -> ServerAppProps { - ServerAppProps { - inner: self.inner, - context: context.into(), - client_only: false, - } - } - - /// Excludes this request from server-side rendering. - pub fn client_only(mut self) -> Self { - self.client_only = true; - self - } - - #[cfg(feature = "warp-filter")] - pub(crate) fn is_client_only(&self) -> bool { - self.client_only +impl PartialEq for ServerAppProps { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.request, &other.request) } } -#[cfg(feature = "warp-filter")] -mod feat_warp_filter { - use warp::path::FullPath; - - use super::*; - - impl ServerAppProps<()> { - pub(crate) fn from_warp_request(path: FullPath, raw_queries: String) -> Self { - Self { - inner: Inner { - path: Path::Warp(path), - raw_queries, - } - .into(), - context: ().into(), - client_only: false, - } +impl Clone for ServerAppProps { + fn clone(&self) -> Self { + Self { + request: self.request.clone(), + _marker: PhantomData, } } } diff --git a/crates/stellation-backend/src/renderer.rs b/crates/stellation-backend/src/renderer.rs new file mode 100644 index 0000000..ff38486 --- /dev/null +++ b/crates/stellation-backend/src/renderer.rs @@ -0,0 +1,133 @@ +use std::fmt; +use std::fmt::Write; +use std::marker::PhantomData; +use std::rc::Rc; + +use bounce::helmet::render_static; +use stellation_bridge::{Bridge, BridgeMetadata}; +use yew::BaseComponent; + +use crate::root::{StellationRoot, StellationRootProps}; +use crate::{html, Request, ServerAppProps}; + +/// The Stellation Backend Renderer. +/// +/// This type wraps the [Yew Server Renderer](yew::ServerRenderer) and provides additional features. +/// +/// # Note +/// +/// Stellation provides [`BrowserRouter`](yew_router::BrowserRouter) and +/// [`BounceRoot`](bounce::BounceRoot) to all applications. +/// +/// Bounce Helmet is also bridged automatically. +/// +/// You do not need to add them manually. +pub struct ServerRenderer +where + COMP: BaseComponent, +{ + request: REQ, + bridge: Option<(Bridge, BridgeMetadata)>, + _marker: PhantomData<(COMP, REQ, CTX, BCTX)>, +} + +impl fmt::Debug for ServerRenderer +where + COMP: BaseComponent, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ServerRenderer<_>") + } +} + +impl ServerRenderer +where + COMP: BaseComponent>, + REQ: Request, +{ + /// Creates a Renderer with specified request. + pub fn new(request: REQ) -> ServerRenderer { + ServerRenderer { + request, + bridge: None, + _marker: PhantomData, + } + } +} + +impl ServerRenderer +where + COMP: BaseComponent>, +{ + /// Connects a bridge to the application. + pub fn bridge( + self, + bridge: Bridge, + metadata: BridgeMetadata, + ) -> ServerRenderer { + ServerRenderer { + request: self.request, + bridge: Some((bridge, metadata)), + _marker: PhantomData, + } + } + + /// Renders the application. + /// + /// # Note: + /// + /// This future is `!Send`. + pub async fn render(self) -> String + where + CTX: 'static, + REQ: 'static, + BCTX: 'static, + REQ: Request, + { + let Self { + bridge, request, .. + } = self; + + let mut head_s = String::new(); + + let (reader, writer) = render_static(); + let request: Rc<_> = request.into(); + + let props = ServerAppProps::from_request(request.clone()); + + let body_s = match bridge { + Some((bridge, bridge_metadata)) => { + yew::LocalServerRenderer::>::with_props( + StellationRootProps { + server_app_props: props, + helmet_writer: writer, + bridge, + bridge_metadata: bridge_metadata.into(), + }, + ) + .render() + .await + } + None => { + yew::LocalServerRenderer::>::with_props( + StellationRootProps { + server_app_props: props, + helmet_writer: writer, + bridge: Bridge::default(), + bridge_metadata: BridgeMetadata::new().into(), + }, + ) + .render() + .await + } + }; + + let helmet_tags = reader.render().await; + let _ = write!( + &mut head_s, + r#""# + ); + + html::format_html(request.template(), helmet_tags, head_s, body_s).await + } +} diff --git a/crates/stellation-backend/src/request.rs b/crates/stellation-backend/src/request.rs new file mode 100644 index 0000000..6fff0a7 --- /dev/null +++ b/crates/stellation-backend/src/request.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +use crate::ServerAppResult; + +/// A trait that describes a request for server-side rendering. +pub trait Request { + /// A request context that can be used to provide other information. + type Context; + + /// Returns the template of the html file. + fn template(&self) -> &str; + + /// Returns the path of current request. + fn path(&self) -> &str; + + /// Returns queries as a raw string. + fn raw_queries(&self) -> &str; + + /// Returns queries of current request. + fn queries(&self) -> ServerAppResult + where + Q: Serialize + for<'de> Deserialize<'de>, + { + Ok(serde_urlencoded::from_str(self.raw_queries())?) + } + + /// Returns the current request context. + fn context(&self) -> &Self::Context; +} diff --git a/crates/stellation-backend/src/root.rs b/crates/stellation-backend/src/root.rs index e4e0edb..4aae724 100644 --- a/crates/stellation-backend/src/root.rs +++ b/crates/stellation-backend/src/root.rs @@ -10,16 +10,17 @@ use yew_router::history::{AnyHistory, History, MemoryHistory}; use yew_router::Router; use crate::props::ServerAppProps; +use crate::Request; #[derive(Properties)] -pub(crate) struct StellationRootProps { +pub(crate) struct StellationRootProps { pub helmet_writer: StaticWriter, - pub server_app_props: ServerAppProps, + pub server_app_props: ServerAppProps, pub bridge: Bridge, pub bridge_metadata: Rc>, } -impl PartialEq for StellationRootProps { +impl PartialEq for StellationRootProps { fn eq(&self, other: &Self) -> bool { self.helmet_writer == other.helmet_writer && self.server_app_props == other.server_app_props @@ -28,7 +29,7 @@ impl PartialEq for StellationRootProps { } } -impl Clone for StellationRootProps { +impl Clone for StellationRootProps { fn clone(&self) -> Self { Self { helmet_writer: self.helmet_writer.clone(), @@ -40,9 +41,10 @@ impl Clone for StellationRootProps { } #[function_component] -fn Inner(props: &StellationRootProps) -> Html +fn Inner(props: &StellationRootProps) -> Html where - COMP: BaseComponent>, + COMP: BaseComponent>, + REQ: Request, BCTX: 'static, { let StellationRootProps { @@ -85,9 +87,12 @@ where } #[function_component] -pub(crate) fn StellationRoot(props: &StellationRootProps) -> Html +pub(crate) fn StellationRoot( + props: &StellationRootProps, +) -> Html where - COMP: BaseComponent>, + COMP: BaseComponent>, + REQ: 'static + Request, CTX: 'static, BCTX: 'static, { @@ -95,7 +100,7 @@ where html! { - ..props /> + ..props /> } } diff --git a/crates/stellation-backend/src/utils/mod.rs b/crates/stellation-backend/src/utils/mod.rs index 8214095..18360fc 100644 --- a/crates/stellation-backend/src/utils/mod.rs +++ b/crates/stellation-backend/src/utils/mod.rs @@ -1,18 +1,4 @@ //! Server utilities. mod thread_local; - pub use self::thread_local::ThreadLocalLazy; - -/// Creates a random string. -#[cfg(feature = "warp-filter")] -pub(crate) fn random_str() -> String { - use rand::distributions::Alphanumeric; - use rand::Rng; - - rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(7) - .map(char::from) - .collect() -} diff --git a/examples/fullstack/server/Cargo.toml b/examples/fullstack/server/Cargo.toml index 118bbaa..3efbdda 100644 --- a/examples/fullstack/server/Cargo.toml +++ b/examples/fullstack/server/Cargo.toml @@ -8,12 +8,15 @@ publish = false [dependencies] anyhow = "1" -stellation-backend = { version = "0.1.4", path = "../../../crates/stellation-backend" } -stellation-backend-cli = { version = "0.1.4", path = "../../../crates/stellation-backend-cli" } tokio = { version = "1.24.1", features = ["full"] } tracing = { version = "0.1.37" } yew = "0.20.0" +# Stellation Components +stellation-backend = { version = "0.1.4", path = "../../../crates/stellation-backend" } +stellation-backend-tower = { version = "0.1.4", path = "../../../crates/stellation-backend-tower" } +stellation-backend-cli = { version = "0.1.4", path = "../../../crates/stellation-backend-cli" } + # Example Workspace example-fullstack-view = { path = "../view" } example-fullstack-api = { path = "../api", features = ["resolvable"] } diff --git a/examples/fullstack/server/src/app.rs b/examples/fullstack/server/src/app.rs index 4f8cbb5..fe07b4b 100644 --- a/examples/fullstack/server/src/app.rs +++ b/examples/fullstack/server/src/app.rs @@ -1,9 +1,12 @@ use example_fullstack_view::Main; -use stellation_backend::ServerAppProps; +use stellation_backend::{Request, ServerAppProps}; use yew::prelude::*; #[function_component] -pub fn ServerApp(_props: &ServerAppProps<()>) -> Html { +pub fn ServerApp(_props: &ServerAppProps<(), REQ>) -> Html +where + REQ: Request, +{ html! {
} diff --git a/examples/fullstack/server/src/main.rs b/examples/fullstack/server/src/main.rs index 6c3038f..a6494c3 100644 --- a/examples/fullstack/server/src/main.rs +++ b/examples/fullstack/server/src/main.rs @@ -2,8 +2,8 @@ #![deny(missing_debug_implementations)] use example_fullstack_api::create_bridge; -use stellation_backend::Endpoint; use stellation_backend_cli::Cli; +use stellation_backend_tower::TowerEndpoint; mod app; use app::ServerApp; @@ -17,10 +17,11 @@ struct Frontend; async fn main() -> anyhow::Result<()> { stellation_backend_cli::trace::init_default("STELLATION_APP_SERVER_LOG"); - let endpoint = Endpoint::::new().with_bridge(create_bridge()); + let endpoint = TowerEndpoint::>::new().with_bridge(create_bridge()); #[cfg(stellation_embedded_frontend)] - let endpoint = endpoint.with_frontend(stellation_backend::Frontend::new_embedded::()); + let endpoint = + endpoint.with_frontend(stellation_backend_tower::Frontend::new_embedded::()); Cli::builder().endpoint(endpoint).build().run().await?; diff --git a/templates/default/Makefile.toml b/templates/default/Makefile.toml index 17f24c3..31c0471 100644 --- a/templates/default/Makefile.toml +++ b/templates/default/Makefile.toml @@ -1,5 +1,6 @@ [env] CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true +CARGO_MAKE_CLIPPY_ARGS = "--all-features -- -D warnings" # stctl [tasks.stctl] diff --git a/templates/default/crates/api/Cargo.toml.liquid b/templates/default/crates/api/Cargo.toml.liquid index f97d367..86dcd09 100644 --- a/templates/default/crates/api/Cargo.toml.liquid +++ b/templates/default/crates/api/Cargo.toml.liquid @@ -21,7 +21,7 @@ stellation-bridge = { git = "https://github.com/futursolo/stellation" } {% elsif stellation_target == "ci" %} # Stellation -stellation-bridge = { path = "../../../stellation/crates/stellation-bridge" } +stellation-bridge = { path = "../../../../stellation/crates/stellation-bridge" } {% endif %} diff --git a/templates/default/crates/client/Cargo.toml.liquid b/templates/default/crates/client/Cargo.toml.liquid index b1a8517..ffb745a 100644 --- a/templates/default/crates/client/Cargo.toml.liquid +++ b/templates/default/crates/client/Cargo.toml.liquid @@ -20,7 +20,7 @@ stellation-frontend = { git = "https://github.com/futursolo/stellation" } {% elsif stellation_target == "ci" %} # Stellation -stellation-frontend = { path = "../../../stellation/crates/stellation-frontend" } +stellation-frontend = { path = "../../../../stellation/crates/stellation-frontend" } {% endif %} # Logging diff --git a/templates/default/crates/server/Cargo.toml.liquid b/templates/default/crates/server/Cargo.toml.liquid index c999906..da92658 100644 --- a/templates/default/crates/server/Cargo.toml.liquid +++ b/templates/default/crates/server/Cargo.toml.liquid @@ -16,17 +16,20 @@ rust-embed = { version = "6.4.2", features = ["interpolate-folder-path"] } {% if stellation_target == "release" %} # Stellation stellation-backend = { version = "{{stellation_release_ver}}" } +stellation-backend-tower = { version = "{{stellation_release_ver}}" } stellation-backend-cli = { version = "{{stellation_release_ver}}" } {% elsif stellation_target == "main" %} # Stellation stellation-backend = { git = "https://github.com/futursolo/stellation" } +stellation-backend-tower = { git = "https://github.com/futursolo/stellation" } stellation-backend-cli = { git = "https://github.com/futursolo/stellation" } {% elsif stellation_target == "ci" %} # Stellation -stellation-backend = { path = "../../../stellation/crates/stellation-backend" } -stellation-backend-cli = { path = "../../../stellation/crates/stellation-backend-cli" } +stellation-backend = { path = "../../../../stellation/crates/stellation-backend" } +stellation-backend-tower = { path = "../../../../stellation/crates/stellation-backend-tower" } +stellation-backend-cli = { path = "../../../../stellation/crates/stellation-backend-cli" } {% endif %} # Example Workspace diff --git a/templates/default/crates/server/src/app.rs b/templates/default/crates/server/src/app.rs index 00b805e..d504e4c 100644 --- a/templates/default/crates/server/src/app.rs +++ b/templates/default/crates/server/src/app.rs @@ -1,10 +1,13 @@ -use stellation_backend::ServerAppProps; +use stellation_backend::{Request, ServerAppProps}; use yew::prelude::*; use crate::view::Main; #[function_component] -pub fn ServerApp(_props: &ServerAppProps<()>) -> Html { +pub fn ServerApp(_props: &ServerAppProps<(), REQ>) -> Html +where + REQ: Request, +{ html! {
} diff --git a/templates/default/crates/server/src/main.rs.liquid b/templates/default/crates/server/src/main.rs.liquid index ad633ec..210fd94 100644 --- a/templates/default/crates/server/src/main.rs.liquid +++ b/templates/default/crates/server/src/main.rs.liquid @@ -3,7 +3,7 @@ use {{crate_name}}_api as api; use {{crate_name}}_view as view; -use stellation_backend::Endpoint; +use stellation_backend_tower::TowerEndpoint; use stellation_backend_cli::Cli; mod app; @@ -19,10 +19,10 @@ struct Frontend; async fn main() -> anyhow::Result<()> { stellation_backend_cli::trace::init_default("STELLATION_APP_SERVER_LOG"); - let endpoint = Endpoint::::new().with_bridge(create_bridge()); + let endpoint = TowerEndpoint::>::new().with_bridge(create_bridge()); #[cfg(stellation_embedded_frontend)] - let endpoint = endpoint.with_frontend(stellation_backend::Frontend::new_embedded::()); + let endpoint = endpoint.with_frontend(stellation_backend_tower::Frontend::new_embedded::()); Cli::builder().endpoint(endpoint).build().run().await?; diff --git a/templates/default/crates/stctl/Cargo.toml.liquid b/templates/default/crates/stctl/Cargo.toml.liquid index a617148..e2a8190 100644 --- a/templates/default/crates/stctl/Cargo.toml.liquid +++ b/templates/default/crates/stctl/Cargo.toml.liquid @@ -18,5 +18,5 @@ stctl = "{{stellation_release_ver}}" stctl = { git = "https://github.com/futursolo/stellation" } {% elsif stellation_target == "ci" %} # Stellation -stctl = { path = "../../../stellation/crates/stctl" } +stctl = { path = "../../../../stellation/crates/stctl" } {% endif %} diff --git a/templates/default/crates/view/Cargo.toml.liquid b/templates/default/crates/view/Cargo.toml.liquid index 53390b1..a47bfd4 100644 --- a/templates/default/crates/view/Cargo.toml.liquid +++ b/templates/default/crates/view/Cargo.toml.liquid @@ -25,8 +25,8 @@ stellation-bridge = { git = "https://github.com/futursolo/stellation" } {% elsif stellation_target == "ci" %} # Stellation -stellation-frontend = { path = "../../../stellation/crates/stellation-frontend" } -stellation-bridge = { path = "../../../stellation/crates/stellation-bridge" } +stellation-frontend = { path = "../../../../stellation/crates/stellation-frontend" } +stellation-bridge = { path = "../../../../stellation/crates/stellation-bridge" } {% endif %} [dependencies.web-sys]