vorma 0.86.0-pre.3

Vorma framework.
Documentation
use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::document::Document;
use crate::envutil::is_dev;
use crate::error::ViewErrorClientMsg;
use crate::htmlutil::Element;
use crate::manifest::Manifest;
use crate::mux::{RouteExecutionError, ViewStackExecution};
use crate::response::{ResponseEffects, merge_response_effects};

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub(crate) struct SsrPayload {
	#[serde(default, skip_serializing_if = "String::is_empty")]
	pub(crate) client_build_id: String,
	#[serde(default, skip_serializing_if = "is_false")]
	pub(crate) is_dev: bool,
	#[serde(default, skip_serializing_if = "String::is_empty")]
	pub(crate) deployment_id: String,

	#[serde(flatten)]
	pub(crate) view_payload: ViewPayload,
}

#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub(crate) struct ViewPayload {
	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) matched_patterns: Vec<String>,
	#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
	pub(crate) params: BTreeMap<String, String>,
	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) splat_values: Vec<String>,
	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) search_schemas: Vec<serde_json::Value>,

	#[serde(default, skip_serializing_if = "Option::is_none")]
	pub(crate) title: Option<Element>,
	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) meta_head_els: Vec<Element>,
	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) rest_head_els: Vec<Element>,

	#[serde(default, skip_serializing_if = "String::is_empty")]
	pub(crate) outermost_server_err: String,
	#[serde(default, skip_serializing_if = "Option::is_none")]
	pub(crate) outermost_server_err_idx: Option<usize>,

	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) import_urls: Vec<String>,
	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) deps: Vec<String>,
	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) css_bundles: Vec<String>,

	#[serde(default, skip_serializing_if = "Vec::is_empty")]
	pub(crate) views_data: Vec<serde_json::Value>,
}

fn is_false(value: &bool) -> bool {
	!*value
}

pub(crate) fn build_view_payload<E>(
	manifest: &Manifest,
	document: &Document,
	view_stack: &ViewStackExecution<E>,
	include_prod_preloads: bool,
) -> Result<(ViewPayload, ResponseEffects), String>
where
	E: ViewErrorClientMsg,
{
	let response_effects_refs = response_effects_refs_for_payload(view_stack);
	let merged_effects = merge_response_effects(&response_effects_refs);
	let effects_short_circuited = merged_effects.is_terminal_response();
	let matched_patterns = view_stack.matched_patterns().to_vec();
	let params = view_stack
		.params()
		.iter()
		.map(|(key, value)| (key.clone(), value.clone()))
		.collect();
	let splat_values = view_stack.splat_values().to_vec();

	if effects_short_circuited {
		let mut raw_head_els = Vec::new();
		if let Some(head_builder) = merged_effects.head_builder_ref() {
			raw_head_els.extend_from_slice(head_builder.elements());
		}
		let prepared_head = document.prepare_head(&raw_head_els);
		return Ok((
			ViewPayload {
				matched_patterns,
				params,
				splat_values,
				title: prepared_head.title,
				meta_head_els: prepared_head.meta,
				rest_head_els: prepared_head.rest,
				..ViewPayload::default()
			},
			merged_effects,
		));
	}

	let search_schemas = matched_patterns
		.iter()
		.map(|pattern| {
			manifest
				.search_schemas
				.get(pattern)
				.cloned()
				.ok_or_else(|| format!("no search schema found for matched pattern: {pattern}"))
		})
		.collect::<Result<Vec<_>, _>>()?;

	let mut import_urls = Vec::with_capacity(matched_patterns.len());
	let mut views_data = Vec::new();
	let mut deps = Vec::new();
	let mut seen_deps = BTreeSet::new();
	let mut css_bundles = Vec::new();
	let mut seen_css_bundles = BTreeSet::new();

	append_unique(&mut deps, &mut seen_deps, &manifest.client_entry.dep_urls);
	append_unique(
		&mut css_bundles,
		&mut seen_css_bundles,
		&manifest.client_entry.css_bundle_urls,
	);

	let mut outermost_server_err = String::new();
	let mut outermost_server_err_idx = Option::None;

	let terminal_view_index = terminal_view_index(view_stack);
	for (idx, pattern) in matched_patterns.iter().enumerate() {
		if terminal_view_index.is_some_and(|terminal_view_index| idx > terminal_view_index) {
			break;
		}
		let route_mod = manifest
			.client_views
			.get(pattern)
			.ok_or_else(|| format!("no view module found for matched pattern: {pattern}"))?;
		import_urls.push(route_mod.url.clone());
		append_unique(&mut deps, &mut seen_deps, &route_mod.dep_urls);
		append_unique(
			&mut css_bundles,
			&mut seen_css_bundles,
			&route_mod.css_bundle_urls,
		);

		let result = view_stack
			.view_results()
			.get(idx)
			.ok_or_else(|| format!("missing view result for matched pattern: {pattern}"))?;
		if let Some(error) = result.error() {
			outermost_server_err = view_client_msg(error);
			outermost_server_err_idx = Some(idx);
			break;
		}
		if result.ran_task() && !effects_short_circuited {
			let data = result
				.data()
				.ok_or_else(|| format!("missing view data for executed view pattern: {pattern}"))?;
			views_data.push(data.clone());
		}
	}

	let mut raw_head_els = Vec::new();
	if let Some(head_builder) = merged_effects.head_builder_ref() {
		raw_head_els.extend_from_slice(head_builder.elements());
	}

	if !is_dev() && include_prod_preloads {
		for dep in &deps {
			raw_head_els.push(Element {
				tag: "link".to_owned(),
				attributes: BTreeMap::from([
					("rel".to_owned(), "modulepreload".to_owned()),
					("href".to_owned(), dep.clone()),
				]),
				self_closing: true,
				..Element::default()
			});
		}
		if let Some(assets) = &manifest.client_core_assets {
			if !seen_deps.contains(&assets.module_url) {
				raw_head_els.push(Element {
					tag: "link".to_owned(),
					attributes: BTreeMap::from([
						("rel".to_owned(), "modulepreload".to_owned()),
						("href".to_owned(), assets.module_url.clone()),
					]),
					self_closing: true,
					..Element::default()
				});
			}
			raw_head_els.push(Element {
				tag: "link".to_owned(),
				attributes: BTreeMap::from([
					("rel".to_owned(), "preload".to_owned()),
					("href".to_owned(), assets.wasm_url.clone()),
					("as".to_owned(), "fetch".to_owned()),
					("type".to_owned(), "application/wasm".to_owned()),
					("crossorigin".to_owned(), "anonymous".to_owned()),
				]),
				self_closing: true,
				..Element::default()
			});
		}
	}

	let prepared_head = document.prepare_head(&raw_head_els);
	let payload = ViewPayload {
		matched_patterns,
		params,
		splat_values,
		search_schemas,
		title: prepared_head.title,
		meta_head_els: prepared_head.meta,
		rest_head_els: prepared_head.rest,
		outermost_server_err,
		outermost_server_err_idx,
		import_urls,
		deps,
		css_bundles,
		views_data,
	};
	Ok((payload, merged_effects))
}

fn terminal_view_index<E>(view_stack: &ViewStackExecution<E>) -> Option<usize> {
	match view_stack.terminal_boundary() {
		Some(crate::mux::ViewStackTerminalBoundary::View { index }) => Some(index),
		Some(crate::mux::ViewStackTerminalBoundary::Middleware) | Option::None => Option::None,
	}
}

fn response_effects_refs_for_payload<E>(
	view_stack: &ViewStackExecution<E>,
) -> Vec<Option<&ResponseEffects>> {
	let mut effects_list = Vec::new();
	effects_list.push(Some(view_stack.middleware_effects()));

	if matches!(
		view_stack.terminal_boundary(),
		Some(crate::mux::ViewStackTerminalBoundary::Middleware)
	) {
		return effects_list;
	}

	let terminal_view_index = terminal_view_index(view_stack);
	for (idx, result) in view_stack.view_results().iter().enumerate() {
		if terminal_view_index.is_some_and(|terminal_view_index| idx > terminal_view_index) {
			break;
		}
		effects_list.push(result.response_effects());
	}

	effects_list
}

fn view_client_msg<E>(error: &RouteExecutionError<E>) -> String
where
	E: ViewErrorClientMsg,
{
	if let RouteExecutionError::Input(input_error) = error
		&& input_error.is_bad_request()
	{
		return input_error.to_string();
	}
	if let RouteExecutionError::Task(vorma_tasks::Error::Failed(error)) = error
		&& let Some(client_msg) = error.view_error_client_msg()
	{
		return client_msg.to_owned();
	}
	"An unexpected error occurred.".to_owned()
}

fn append_unique(out: &mut Vec<String>, seen: &mut BTreeSet<String>, items: &[String]) {
	for item in items {
		if seen.insert(item.clone()) {
			out.push(item.clone());
		}
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn view_payload_json_keys_are_snake_case() {
		let payload = SsrPayload {
			client_build_id: "build-id".to_owned(),
			is_dev: true,
			view_payload: ViewPayload {
				matched_patterns: vec!["/".to_owned()],
				params: BTreeMap::from([("id".to_owned(), "123".to_owned())]),
				title: Some(Element {
					tag: "title".to_owned(),
					text_content: "Hello".to_owned(),
					..Element::default()
				}),
				import_urls: vec!["/entry.js".to_owned()],
				views_data: vec![serde_json::json!({"ok": true})],
				..ViewPayload::default()
			},
			..SsrPayload::default()
		};

		let json = serde_json::to_value(&payload).unwrap();

		assert_eq!(json["client_build_id"], "build-id");
		assert_eq!(json["is_dev"], true);
		assert_eq!(json["matched_patterns"][0], "/");
		assert_eq!(json["params"]["id"], "123");
		assert!(json.get("title").is_some());
		assert_eq!(json["import_urls"][0], "/entry.js");
		assert_eq!(json["views_data"][0]["ok"], true);
	}
}