1
0
Fork 0
mirror of https://github.com/Nutomic/ibis.git synced 2024-11-22 07:11:08 +00:00

Use cargo-leptos, make server-side rendering work

This commit is contained in:
Felix Ableitner 2024-10-28 15:46:51 +01:00
parent 04b40a32f5
commit fceead8062
17 changed files with 126 additions and 178 deletions

View file

@ -50,14 +50,6 @@ steps:
- diesel print-schema --config-file=diesel.toml > tmp.schema - diesel print-schema --config-file=diesel.toml > tmp.schema
- diff tmp.schema src/backend/database/schema.rs - diff tmp.schema src/backend/database/schema.rs
frontend_wasm_build:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- "rustup target add wasm32-unknown-unknown"
- "cargo check --target wasm32-unknown-unknown --features csr,hydrate --no-default-features"
cargo_clippy: cargo_clippy:
image: *rust_image image: *rust_image
environment: environment:

View file

@ -21,8 +21,29 @@ ssr = [
"leptos/ssr", "leptos/ssr",
"leptos-use/ssr", "leptos-use/ssr",
] ]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr", "katex/wasm-js"] hydrate = [
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] "leptos/hydrate",
"leptos_meta/hydrate",
"leptos_router/hydrate",
"katex/wasm-js",
]
# This profile significantly speeds up build time. If debug info is needed you can comment the line
# out temporarily, but make sure to leave this in the main branch.
[profile.dev]
debug = 0
[profile.release]
lto = "thin"
strip = true
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[lints.clippy] [lints.clippy]
dbg_macro = "deny" dbg_macro = "deny"
@ -95,19 +116,10 @@ pretty_assertions = "1.4.1"
[package.metadata.leptos] [package.metadata.leptos]
output-name = "ibis" output-name = "ibis"
assets-dir = "assets"
bin-features = ["ssr"] bin-features = ["ssr"]
lib-features = ["csr"] lib-features = ["hydrate"]
lib-profile-release = "wasm-release"
[lib] [lib]
name = "ibis_lib"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
# This profile significantly speeds up build time. If debug info is needed you can comment the line
# out temporarily, but make sure to leave this in the main branch.
[profile.dev]
debug = 0
[profile.release]
lto = "thin"
strip = true # Automatically strip symbols from the binary.
#opt-level = "z" # Optimize for size.

View file

@ -31,9 +31,9 @@ psql -c "CREATE USER ibis WITH PASSWORD 'ibis' SUPERUSER;" -U postgres
psql -c "CREATE DATABASE ibis WITH OWNER ibis;" -U postgres psql -c "CREATE DATABASE ibis WITH OWNER ibis;" -U postgres
``` ```
You need to install [cargo](https://rustup.rs/), [trunk](https://trunkrs.dev) and [cargo watch](https://github.com/watchexec/cargo-watch). Run `./scripts/watch.sh` which automatically rebuilds the project after changes. Then open the site at [127.0.0.1:8080](http://127.0.0.1:8080/). Then login with user `ibis` and password `ibis`. You need to install [cargo](https://rustup.rs/), and [cargo-leptos](https://github.com/leptos-rs/cargo-leptos). Run `cargo leptos watch` which automatically rebuilds the project after changes. Then open the site at [localhost:3000](http://localhost:3000/). You can login with user `ibis` and password `ibis`.
By default the frontend runs on port 8080, which can be changed with env var `TRUNK_SERVE_PORT`. The backend port is 8081 and can be changed with `IBIS_BACKEND_PORT`. By default the frontend runs on port 3000, which can be changed with env var `TRUNK_SERVE_PORT`. The backend port is 8081 and can be changed with `IBIS_BACKEND_PORT`.
## Federation ## Federation

View file

@ -1,5 +0,0 @@
[build]
filehash = false
target = "assets/index.html"
dist = "assets/dist"
minify = "on_release"

View file

@ -5,7 +5,7 @@
href=".." href=".."
data-bin="ibis" data-bin="ibis"
data-cargo-no-default-features data-cargo-no-default-features
data-cargo-features="csr,hydrate" /> data-cargo-features="hydrate" />
</head> </head>
<body></body> <body></body>
</html> </html>

View file

@ -1,20 +0,0 @@
use std::{
fs::{create_dir_all, File},
io::Result,
path::Path,
};
/// Create placeholders for wasm files so that `cargo check` etc work without explicitly building
/// frontend.
fn main() -> Result<()> {
create_dir_all("assets/dist/")?;
let js = "assets/dist/ibis.js";
if !Path::new(js).exists() {
File::create(js)?;
}
let wasm = "assets/dist/ibis_bg.wasm";
if !Path::new(wasm).exists() {
File::create(wasm)?;
}
Ok(())
}

View file

@ -1,6 +1,3 @@
# Address where ibis should listen for incoming requests
bind = "127.0.0.1:8081"
# Whether users can create new accounts # Whether users can create new accounts
registration_open = true registration_open = true

View file

@ -1,15 +0,0 @@
#!/bin/sh
set -e
IBIS__BIND="${IBIS_BIND:-"127.0.0.1:8081"}"
killall trunk || true
# run processes in parallel
# https://stackoverflow.com/a/52033580
(trap 'kill 0' INT;
# start frontend
CARGO_TARGET_DIR=target/frontend trunk serve -w src/frontend/ -w assets/ --proxy-backend http://$IBIS__BIND &
# start backend, with separate target folder to avoid rebuilds from arch change
cargo watch --ignore assets/ibis.css --exec run
)

View file

@ -1,79 +1,61 @@
use super::error::MyResult; use crate::frontend::app::App;
use anyhow::anyhow;
use axum::{ use axum::{
body::Body, body::Body,
extract::Path, extract::{Request, State},
http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get,
Router,
}; };
use axum_macros::debug_handler; use axum_macros::debug_handler;
use include_dir::include_dir; use leptos::LeptosOptions;
use once_cell::sync::OnceCell; use tower::ServiceExt;
use reqwest::header::HeaderMap; use tower_http::services::ServeDir;
use std::fs::read_to_string;
pub fn asset_routes() -> MyResult<Router<()>> { // from https://github.com/leptos-rs/start-axum
Ok(Router::new()
.route("/assets/ibis.css", get(ibis_css))
.route(
"/assets/simple.css",
get((css_headers(), include_str!("../../assets/simple.css"))),
)
.route(
"/assets/katex.min.css",
get((css_headers(), include_str!("../../assets/katex.min.css"))),
)
.route("/assets/fonts/*font", get(get_font))
.route(
"/assets/index.html",
get(include_str!("../../assets/index.html")),
)
.route("/pkg/ibis.js", get(serve_js))
.route("/pkg/ibis_bg.wasm", get(serve_wasm)))
}
fn css_headers() -> HeaderMap { #[debug_handler]
static INSTANCE: OnceCell<HeaderMap> = OnceCell::new(); pub async fn file_and_error_handler(
INSTANCE State(options): State<LeptosOptions>,
.get_or_init(|| { req: Request<Body>,
let mut css_headers = HeaderMap::new(); ) -> Result<Response<Body>, (StatusCode, String)> {
let val = "text/css".parse().expect("valid header value"); let root = options.site_root.clone();
css_headers.insert("Content-Type", val); let (parts, body) = req.into_parts();
css_headers
})
.clone()
}
async fn ibis_css() -> MyResult<(HeaderMap, Response<Body>)> { let mut static_parts = parts.clone();
let res = if cfg!(debug_assertions) { static_parts.headers.clear();
read_to_string("assets/ibis.css")?.into_response() if let Some(encodings) = parts.headers.get("accept-encoding") {
static_parts
.headers
.insert("accept-encoding", encodings.clone());
}
let res = get_static_file(Request::from_parts(static_parts, Body::empty()), &root).await?;
if res.status() == StatusCode::OK {
Ok(res.into_response())
} else { } else {
include_str!("../../assets/ibis.css").into_response() let handler = leptos_axum::render_app_to_stream(options.to_owned(), App);
}; Ok(handler(Request::from_parts(parts, body))
Ok((css_headers(), res)) .await
.into_response())
}
} }
#[debug_handler] async fn get_static_file(
async fn serve_js() -> MyResult<impl IntoResponse> { request: Request<Body>,
let mut headers = HeaderMap::new(); root: &str,
headers.insert("Content-Type", "application/javascript".parse()?); ) -> Result<Response<Body>, (StatusCode, String)> {
let content = include_str!("../../assets/dist/ibis.js"); // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
Ok((headers, content)) // This path is relative to the cargo root
} match ServeDir::new(root)
.precompressed_gzip()
#[debug_handler] .precompressed_br()
async fn serve_wasm() -> MyResult<impl IntoResponse> { .oneshot(request)
let mut headers = HeaderMap::new(); .await
headers.insert("Content-Type", "application/wasm".parse()?); {
let content = include_bytes!("../../assets/dist/ibis_bg.wasm"); Ok(res) => Ok(res.into_response()),
Ok((headers, content)) Err(err) => Err((
} StatusCode::INTERNAL_SERVER_ERROR,
format!("Error serving files: {err}"),
#[debug_handler] )),
async fn get_font(Path(font): Path<String>) -> MyResult<impl IntoResponse> { }
let headers = HeaderMap::new();
let font_dir = include_dir!("assets/fonts");
let file = font_dir.get_file(font).ok_or(anyhow!("invalid font"))?;
Ok((headers, file.contents()))
} }

View file

@ -3,15 +3,10 @@ use config::Config;
use doku::Document; use doku::Document;
use serde::Deserialize; use serde::Deserialize;
use smart_default::SmartDefault; use smart_default::SmartDefault;
use std::net::SocketAddr;
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Document, SmartDefault)] #[derive(Debug, Deserialize, PartialEq, Eq, Clone, Document, SmartDefault)]
#[serde(default)] #[serde(default)]
pub struct IbisConfig { pub struct IbisConfig {
/// Address where ibis should listen for incoming requests
#[default("127.0.0.1:8081".parse().expect("parse config bind"))]
#[doku(as = "String", example = "127.0.0.1:8081")]
pub bind: SocketAddr,
/// Details about the PostgreSQL database connection /// Details about the PostgreSQL database connection
pub database: IbisConfigDatabase, pub database: IbisConfigDatabase,
/// Whether users can create new accounts /// Whether users can create new accounts

View file

@ -22,7 +22,7 @@ use activitypub_federation::{
http_signatures::generate_actor_keypair, http_signatures::generate_actor_keypair,
}; };
use api::api_routes; use api::api_routes;
use assets::asset_routes; use assets::file_and_error_handler;
use axum::{ use axum::{
body::Body, body::Body,
http::{HeaderValue, Request}, http::{HeaderValue, Request},
@ -38,9 +38,10 @@ use diesel::{
PgConnection, PgConnection,
}; };
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use leptos::leptos_config::get_config_from_str; use leptos::get_configuration;
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use log::info; use log::info;
use std::net::SocketAddr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_layer::Layer; use tower_layer::Layer;
@ -58,7 +59,7 @@ const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
const FEDERATION_ROUTES_PREFIX: &str = "/federation_routes"; const FEDERATION_ROUTES_PREFIX: &str = "/federation_routes";
pub async fn start(config: IbisConfig) -> MyResult<()> { pub async fn start(config: IbisConfig, override_hostname: Option<SocketAddr>) -> MyResult<()> {
let manager = ConnectionManager::<PgConnection>::new(&config.database.connection_url); let manager = ConnectionManager::<PgConnection>::new(&config.database.connection_url);
let db_pool = Pool::builder() let db_pool = Pool::builder()
.max_size(config.database.pool_size) .max_size(config.database.pool_size)
@ -82,15 +83,18 @@ pub async fn start(config: IbisConfig) -> MyResult<()> {
setup(&data.to_request_data()).await?; setup(&data.to_request_data()).await?;
} }
let mut conf = get_config_from_str(include_str!("../../Cargo.toml"))?; let leptos_options = get_configuration(Some("Cargo.toml")).await?.leptos_options;
conf.site_addr = data.config.bind; let mut addr = leptos_options.site_addr;
if let Some(override_hostname) = override_hostname {
addr = override_hostname;
}
let routes = generate_route_list(App); let routes = generate_route_list(App);
let config = data.clone(); let config = data.clone();
let app = Router::new() let app = Router::new()
.leptos_routes(&conf, routes, App) .leptos_routes(&leptos_options, routes, App)
.with_state(conf) .fallback(file_and_error_handler)
.nest("", asset_routes()?) .with_state(leptos_options)
.nest(FEDERATION_ROUTES_PREFIX, federation_routes()) .nest(FEDERATION_ROUTES_PREFIX, federation_routes())
.nest("/api/v1", api_routes()) .nest("/api/v1", api_routes())
.nest("", nodeinfo::config()) .nest("", nodeinfo::config())
@ -102,8 +106,8 @@ pub async fn start(config: IbisConfig) -> MyResult<()> {
let middleware = axum::middleware::from_fn(federation_routes_middleware); let middleware = axum::middleware::from_fn(federation_routes_middleware);
let app_with_middleware = middleware.layer(app); let app_with_middleware = middleware.layer(app);
info!("Listening on {}", &data.config.bind); info!("Listening on {}", &addr);
let listener = TcpListener::bind(&data.config.bind).await?; let listener = TcpListener::bind(&addr).await?;
axum::serve(listener, app_with_middleware.into_make_service()).await?; axum::serve(listener, app_with_middleware.into_make_service()).await?;
Ok(()) Ok(())

View file

@ -47,10 +47,9 @@ impl ApiClient {
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
{ {
hostname = crate::backend::config::IbisConfig::read() use leptos::leptos_config::get_config_from_str;
.unwrap() let leptos_options = get_config_from_str(include_str!("../../Cargo.toml")).unwrap();
.bind hostname = leptos_options.site_addr.to_string();
.to_string();
ssl = false; ssl = false;
} }
// required for tests // required for tests

View file

@ -91,9 +91,9 @@ pub fn App() -> impl IntoView {
view! { view! {
<> <>
<Stylesheet id="simple" href="/assets/simple.css" /> <Stylesheet id="simple" href="/simple.css" />
<Stylesheet id="ibis" href="/assets/ibis.css" /> <Stylesheet id="ibis" href="/ibis.css" />
<Stylesheet id="katex" href="/assets/katex.min.css" /> <Stylesheet id="katex" href="/katex.min.css" />
<Router> <Router>
<Nav /> <Nav />
<main> <main>

View file

@ -11,7 +11,11 @@ pub mod pages;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen] #[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {} pub fn hydrate() {
use crate::frontend::app::App;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}
fn article_link(article: &DbArticle) -> String { fn article_link(article: &DbArticle) -> String {
if article.local { if article.local {

View file

@ -1,7 +1,7 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[tokio::main] #[tokio::main]
pub async fn main() -> ibis_lib::backend::error::MyResult<()> { pub async fn main() -> ibis::backend::error::MyResult<()> {
use ibis_lib::backend::config::IbisConfig; use ibis::backend::config::IbisConfig;
use log::LevelFilter; use log::LevelFilter;
if std::env::args().collect::<Vec<_>>().get(1) == Some(&"--print-config".to_string()) { if std::env::args().collect::<Vec<_>>().get(1) == Some(&"--print-config".to_string()) {
@ -16,10 +16,11 @@ pub async fn main() -> ibis_lib::backend::error::MyResult<()> {
.init(); .init();
let ibis_config = IbisConfig::read()?; let ibis_config = IbisConfig::read()?;
ibis_lib::backend::start(ibis_config).await?; ibis::backend::start(ibis_config, None).await?;
Ok(()) Ok(())
} }
/*
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
fn main() { fn main() {
use ibis_lib::frontend::app::App; use ibis_lib::frontend::app::App;
@ -31,3 +32,4 @@ fn main() {
view! { <App /> } view! { <App /> }
}); });
} }
*/

View file

@ -1,6 +1,6 @@
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
use ibis_lib::{ use ibis::{
backend::{ backend::{
config::{IbisConfig, IbisConfigDatabase, IbisConfigFederation}, config::{IbisConfig, IbisConfigDatabase, IbisConfigFederation},
start, start,
@ -21,7 +21,7 @@ use std::{
thread::{sleep, spawn}, thread::{sleep, spawn},
time::Duration, time::Duration,
}; };
use tokio::task::JoinHandle; use tokio::{join, task::JoinHandle};
use tracing::log::LevelFilter; use tracing::log::LevelFilter;
pub struct TestData { pub struct TestData {
@ -66,11 +66,13 @@ impl TestData {
j.join().unwrap(); j.join().unwrap();
} }
Self { let (alpha, beta, gamma) = join!(
alpha: IbisInstance::start(alpha_db_path, port_alpha, "alpha").await, IbisInstance::start(alpha_db_path, port_alpha, "alpha"),
beta: IbisInstance::start(beta_db_path, port_beta, "beta").await, IbisInstance::start(beta_db_path, port_beta, "beta"),
gamma: IbisInstance::start(gamma_db_path, port_gamma, "gamma").await, IbisInstance::start(gamma_db_path, port_gamma, "gamma")
} );
Self { alpha, beta, gamma }
} }
pub fn stop(self) -> MyResult<()> { pub fn stop(self) -> MyResult<()> {
@ -115,23 +117,26 @@ impl IbisInstance {
async fn start(db_path: String, port: i32, username: &str) -> Self { async fn start(db_path: String, port: i32, username: &str) -> Self {
let connection_url = format!("postgresql://ibis:password@/ibis?host={db_path}"); let connection_url = format!("postgresql://ibis:password@/ibis?host={db_path}");
let hostname = format!("localhost:{port}"); let hostname = format!("127.0.0.1:{port}");
let bind = format!("127.0.0.1:{port}").parse().unwrap(); let domain = format!("localhost:{port}");
let config = IbisConfig { let config = IbisConfig {
bind,
database: IbisConfigDatabase { database: IbisConfigDatabase {
connection_url, connection_url,
..Default::default() ..Default::default()
}, },
registration_open: true, registration_open: true,
federation: IbisConfigFederation { federation: IbisConfigFederation {
domain: hostname.clone(), domain: domain.clone(),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
}; };
let client = ClientBuilder::new().cookie_store(true).build().unwrap();
let api_client = ApiClient::new(client, Some(domain));
let handle = tokio::task::spawn(async move { let handle = tokio::task::spawn(async move {
start(config).await.unwrap(); start(config, Some(hostname.parse().unwrap()))
.await
.unwrap();
}); });
// wait a moment for the backend to start // wait a moment for the backend to start
tokio::time::sleep(Duration::from_millis(5000)).await; tokio::time::sleep(Duration::from_millis(5000)).await;
@ -139,8 +144,6 @@ impl IbisInstance {
username: username.to_string(), username: username.to_string(),
password: "hunter2".to_string(), password: "hunter2".to_string(),
}; };
let client = ClientBuilder::new().cookie_store(true).build().unwrap();
let api_client = ApiClient::new(client, Some(hostname));
api_client.register(form).await.unwrap(); api_client.register(form).await.unwrap();
Self { Self {
api_client, api_client,

View file

@ -1,11 +1,9 @@
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
extern crate ibis_lib;
mod common; mod common;
use crate::common::{TestData, TEST_ARTICLE_DEFAULT_TEXT}; use crate::common::{TestData, TEST_ARTICLE_DEFAULT_TEXT};
use ibis_lib::{ use ibis::{
common::{ common::{
utils::extract_domain, utils::extract_domain,
ArticleView, ArticleView,