haven 0.1.1

Actix + React + Vite integration for server-rendered applications
Documentation
use std::convert::Infallible;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

use bytes::Bytes;
use serde::Serialize;
use tokio_stream::wrappers::UnboundedReceiverStream;

use crate::error::RendererError;
use crate::protocol::{HeadTag, PageData, PageMeta};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RendererMode {
    Development,
    Production,
}

#[derive(Debug, Clone)]
pub struct RuntimeRendererConfig {
    pub mode: RendererMode,
    pub js_entrypoint: PathBuf,
    pub js_source_dir: PathBuf,
    pub client_manifest_path: Option<PathBuf>,
    pub project_root: Option<PathBuf>,
    pub translations_dir: Option<PathBuf>,
    pub default_locale: String,
    pub fallback_locale: String,
    pub vite_dev_server_origin: Option<String>,
    pub start_vite_dev_server: bool,
    pub version: String,
    pub url: Option<String>,
    pub asset_script_url: Option<String>,
    pub title: String,
}

#[derive(Debug, Clone)]
pub(crate) struct ResolvedRuntimeRendererConfig {
    pub mode: RendererMode,
    pub js_entrypoint: PathBuf,
    pub js_source_dir: PathBuf,
    pub client_manifest_path: Option<PathBuf>,
    pub project_root: PathBuf,
    pub translations_dir: PathBuf,
    pub default_locale: String,
    pub fallback_locale: String,
    pub vite_dev_server_origin: Option<String>,
    pub start_vite_dev_server: bool,
    pub version: String,
    pub url: Option<String>,
    pub asset_script_url: Option<String>,
    pub title: String,
}

impl RuntimeRendererConfig {
    pub(crate) fn normalize(self) -> Result<ResolvedRuntimeRendererConfig, RendererError> {
        let cwd = std::env::current_dir()?;
        let project_root = self
            .project_root
            .as_ref()
            .map(|path| absolutize(&cwd, path))
            .unwrap_or_else(|| cwd.clone());

        let js_source_dir = resolve_js_source_dir(&project_root, &self.js_source_dir);
        let js_entrypoint = absolutize(&project_root, &self.js_entrypoint);
        let client_manifest_path = self
            .client_manifest_path
            .as_ref()
            .map(|path| absolutize(&project_root, path));
        let translations_dir = self
            .translations_dir
            .as_ref()
            .map(|path| absolutize(&project_root, path))
            .unwrap_or_else(|| project_root.join("locales"));

        let version =
            resolve_renderer_version(self.mode, &self.version, client_manifest_path.as_ref())?;

        Ok(ResolvedRuntimeRendererConfig {
            mode: self.mode,
            js_entrypoint,
            js_source_dir,
            client_manifest_path,
            project_root,
            translations_dir,
            default_locale: self.default_locale,
            fallback_locale: self.fallback_locale,
            vite_dev_server_origin: self.vite_dev_server_origin,
            start_vite_dev_server: self.start_vite_dev_server,
            version,
            url: self.url,
            asset_script_url: self.asset_script_url,
            title: self.title,
        })
    }

    pub(crate) fn development() -> Self {
        Self {
            mode: RendererMode::Development,
            js_entrypoint: PathBuf::from("dist/server/entry-server.mjs"),
            js_source_dir: PathBuf::from("app"),
            client_manifest_path: Some(PathBuf::from("dist/client/.vite/manifest.json")),
            project_root: None,
            translations_dir: Some(PathBuf::from("locales")),
            default_locale: "en".into(),
            fallback_locale: "en".into(),
            vite_dev_server_origin: Some("http://127.0.0.1:5173".into()),
            start_vite_dev_server: false,
            version: "dev".into(),
            url: Some("/".into()),
            asset_script_url: None,
            title: "App".into(),
        }
    }
}

impl Default for RuntimeRendererConfig {
    fn default() -> Self {
        Self::development()
    }
}

fn absolutize(base: &Path, path: &Path) -> PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        base.join(path)
    }
}

fn resolve_js_source_dir(project_root: &Path, configured: &Path) -> PathBuf {
    let resolved = absolutize(project_root, configured);
    if configured.is_absolute() || configured != Path::new("app") || resolved.exists() {
        return resolved;
    }

    let app_dir = project_root.join("app");
    if app_dir.exists() {
        return app_dir;
    }

    resolved
}

#[derive(Debug, Clone)]
pub struct RenderedPage {
    pub html: String,
    pub head: Option<String>,
    pub status: Option<u16>,
}

pub struct RenderedPageStream {
    pub stream: UnboundedReceiverStream<Result<Bytes, Infallible>>,
    pub status: Option<u16>,
}

#[derive(Debug, Clone)]
pub struct PageRoute {
    pub page: PageData,
    pub meta: PageMeta,
}

#[derive(Debug, Clone, Copy, Default)]
pub struct DirectRender;

#[derive(Debug, Clone, Copy, Default)]
pub struct DirectStream;

impl PageRoute {
    pub fn set_props<T>(&mut self, props: T) -> Result<(), RendererError>
    where
        T: Serialize,
    {
        self.page.props = serde_json::to_value(props)?;
        Ok(())
    }

    pub fn props<T>(mut self, props: T) -> Result<Self, RendererError>
    where
        T: Serialize,
    {
        self.set_props(props)?;
        Ok(self)
    }

    pub fn locale(mut self, locale: impl Into<String>) -> Self {
        self.page.locale = locale.into();
        self
    }

    pub fn version(mut self, version: impl Into<String>) -> Self {
        self.page.version = version.into();
        self
    }

    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.meta.title = Some(title.into());
        self
    }

    pub fn set_resources<T>(&mut self, resources: T) -> Result<(), RendererError>
    where
        T: Serialize,
    {
        self.meta.resources = serde_json::to_value(resources)?;
        Ok(())
    }

    pub fn resources<T>(mut self, resources: T) -> Result<Self, RendererError>
    where
        T: Serialize,
    {
        self.set_resources(resources)?;
        Ok(self)
    }

    pub fn set_context<T>(&mut self, context: T) -> Result<(), RendererError>
    where
        T: Serialize,
    {
        let context = serde_json::to_value(context)?;
        let props = self.page.props.as_object_mut().ok_or_else(|| {
            RendererError::Serialization(
                "page props must be an object to attach page context".into(),
            )
        })?;
        props.insert("_context".into(), context);
        Ok(())
    }

    pub fn context<T>(mut self, context: T) -> Result<Self, RendererError>
    where
        T: Serialize,
    {
        self.set_context(context)?;
        Ok(self)
    }

    pub fn head(mut self, head: Vec<HeadTag>) -> Self {
        self.meta.head = head;
        self
    }

    pub fn with_page(mut self, page: PageData) -> Self {
        self.page = page;
        self
    }
}

fn resolve_renderer_version(
    mode: RendererMode,
    configured: &str,
    client_manifest_path: Option<&PathBuf>,
) -> Result<String, RendererError> {
    if mode == RendererMode::Development {
        return Ok(configured.to_owned());
    }

    if !configured.trim().is_empty() && configured != "dev" {
        return Ok(configured.to_owned());
    }

    let Some(manifest_path) = client_manifest_path else {
        return Ok(configured.to_owned());
    };

    if !manifest_path.exists() {
        return Ok(configured.to_owned());
    }

    let contents = std::fs::read(manifest_path)?;
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    contents.hash(&mut hasher);
    Ok(format!("{:x}", hasher.finish()))
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::{RendererMode, RuntimeRendererConfig};

    #[test]
    fn default_config_points_to_app_dir() {
        let config = RuntimeRendererConfig::development();
        assert_eq!(config.mode, RendererMode::Development);
        assert_eq!(config.js_source_dir, PathBuf::from("app"));
    }
}