use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::convert::Infallible;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use deno_core::error::JsError;
use deno_core::serde_v8;
use deno_core::v8;
use deno_core::{Extension, JsRuntime, ModuleSpecifier, OpDecl, OpState, RuntimeOptions, op2};
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use tracing::{error, info, warn};
use crate::error::RendererError;
use crate::i18n::{TranslationConfig, TranslationStore};
use crate::protocol::{RenderRequest, RenderResponse};
use crate::renderer::{RendererMode, ResolvedRuntimeRendererConfig};
const DEFAULT_RUNTIME_BOOTSTRAP: &str = include_str!("../app/runtime_bootstrap.js");
#[derive(Debug, Deserialize)]
struct ViteManifestEntry {
file: String,
#[serde(default, rename = "isEntry")]
is_entry: bool,
#[serde(default)]
imports: Vec<String>,
#[serde(default)]
css: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ViteDevRuntimeErrorPayload {
error: String,
#[serde(default)]
html: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct ClientAssetSet {
entry_script_url: Option<String>,
#[serde(default)]
module_preload_urls: Vec<String>,
#[serde(default)]
style_urls: Vec<String>,
}
#[derive(Clone)]
struct RuntimeTranslationStore(Arc<TranslationStore>);
#[derive(Clone, Default)]
struct RuntimeHtmlStream(Option<tokio::sync::mpsc::UnboundedSender<Result<Bytes, Infallible>>>);
#[op2(fast)]
#[bigint]
fn op_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[op2]
async fn op_sleep(ms: u32) {
tokio::time::sleep(Duration::from_millis(u64::from(ms))).await;
}
#[op2(fast)]
fn op_log(#[string] level: String, #[string] message: String) {
match level.as_str() {
"error" => error!(target = "js_runtime", "{message}"),
"warn" => warn!(target = "js_runtime", "{message}"),
_ => info!(target = "js_runtime", level = level.as_str(), "{message}"),
}
}
#[op2]
#[string]
fn op_translate(
state: Rc<RefCell<OpState>>,
#[string] locale: String,
#[string] key: String,
#[string] params_json: String,
) -> String {
let params: Value = serde_json::from_str(¶ms_json).unwrap_or(Value::Null);
let state = state.borrow();
let store = state.borrow::<RuntimeTranslationStore>();
store.0.translate(&locale, &key, ¶ms)
}
#[op2(fast)]
fn op_stream_chunk(state: Rc<RefCell<OpState>>, #[string] chunk: String) {
let sender = {
let state = state.borrow();
state.borrow::<RuntimeHtmlStream>().0.clone()
};
if let Some(sender) = sender {
let _ = sender.send(Ok(Bytes::from(chunk)));
}
}
fn runtime_extension(store: Arc<TranslationStore>) -> Extension {
const OPS: &[OpDecl] = &[
op_now(),
op_sleep(),
op_log(),
op_translate(),
op_stream_chunk(),
];
Extension {
name: "renderer_ext",
ops: std::borrow::Cow::Borrowed(OPS),
op_state_fn: Some(Box::new(move |state| {
state.put(RuntimeTranslationStore(store));
state.put(RuntimeHtmlStream::default());
})),
..Default::default()
}
}
struct JsEngine {
render_fn: v8::Global<v8::Function>,
stream_render_fn: v8::Global<v8::Function>,
runtime: JsRuntime,
}
struct ViteDevRuntimeClient {
client: reqwest::Client,
render_url: String,
render_stream_url: String,
}
enum RuntimeCommand {
Render {
request: RenderRequest,
tx: oneshot::Sender<Result<RenderResponse, RendererError>>,
},
StreamRender {
request: RenderRequest,
tx: oneshot::Sender<
Result<
(
tokio::sync::mpsc::UnboundedReceiver<Result<Bytes, Infallible>>,
Option<u16>,
),
RendererError,
>,
>,
},
Reload {
tx: oneshot::Sender<Result<(), RendererError>>,
rebuild_assets: bool,
},
}
struct EngineGeneration {
generation_id: u64,
engine: JsEngine,
}
struct EmbeddedRuntimeHost {
config: ResolvedRuntimeRendererConfig,
next_generation_id: u64,
active_generation: Option<EngineGeneration>,
}
struct DevServerHandle {
child: Mutex<Option<Child>>,
}
impl Drop for DevServerHandle {
fn drop(&mut self) {
if let Ok(mut child) = self.child.lock() {
if let Some(child) = child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
}
impl EngineGeneration {
fn new(generation_id: u64, engine: JsEngine) -> Self {
Self {
generation_id,
engine,
}
}
}
impl EmbeddedRuntimeHost {
async fn new(config: ResolvedRuntimeRendererConfig) -> Result<Self, RendererError> {
let active_generation = Some(EngineGeneration::new(1, JsEngine::new(&config).await?));
Ok(Self {
config,
next_generation_id: 2,
active_generation,
})
}
async fn render(&mut self, request: &RenderRequest) -> Result<RenderResponse, RendererError> {
let generation = ensure_active_generation(
&self.config,
&mut self.active_generation,
&mut self.next_generation_id,
)
.await?;
generation.engine.render(request).await
}
async fn render_stream(
&mut self,
request: &RenderRequest,
) -> Result<
(
tokio::sync::mpsc::UnboundedReceiver<Result<Bytes, Infallible>>,
Option<u16>,
),
RendererError,
> {
let (stream_tx, stream_rx) = tokio::sync::mpsc::unbounded_channel();
let generation = ensure_active_generation(
&self.config,
&mut self.active_generation,
&mut self.next_generation_id,
)
.await?;
generation.engine.set_stream_sink(Some(stream_tx.clone()));
let result = generation.engine.render_stream(request).await;
generation.engine.set_stream_sink(None);
match result {
Ok(response) => Ok((stream_rx, response.status)),
Err(err) => {
let _ = stream_tx.send(Ok(Bytes::from(format!(
"<!doctype html><html><body><pre>{}</pre></body></html>",
err
))));
Ok((stream_rx, Some(500)))
}
}
}
async fn reload(&mut self, rebuild_assets: bool) -> Result<(), RendererError> {
reload_engine(
&self.config,
&mut self.active_generation,
&mut self.next_generation_id,
rebuild_assets,
)
.await
}
}
impl JsEngine {
async fn new(config: &ResolvedRuntimeRendererConfig) -> Result<Self, RendererError> {
let (bootstrap_name, bootstrap) = load_runtime_bootstrap(&config.js_source_dir)?;
let server_entry = std::fs::read_to_string(&config.js_entrypoint).map_err(|err| {
RendererError::RuntimeInit(format!(
"failed reading server entry at {}: {err}",
config.js_entrypoint.display()
))
})?;
let client_assets = resolve_client_assets(config)?;
let vite_dev_client_url = resolve_vite_dev_client_url(config);
let translation_store = TranslationStore::load(&TranslationConfig {
translations_dir: config.translations_dir.clone(),
default_locale: config.default_locale.clone(),
fallback_locale: config.fallback_locale.clone(),
})?;
let mut runtime = JsRuntime::try_new(RuntimeOptions {
extensions: vec![runtime_extension(translation_store)],
..Default::default()
})
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?;
runtime
.execute_script(bootstrap_name, bootstrap)
.map_err(js_exception)?;
let config_script = format!(
"globalThis.__RUNTIME_CONFIG__ = {};",
json!({
"version": config.version,
"url": config.url,
"clientAssets": client_assets,
"viteDevClientUrl": vite_dev_client_url,
"locale": config.default_locale,
"fallbackLocale": config.fallback_locale,
"title": config.title,
})
);
runtime
.execute_script("<runtime-config>", config_script)
.map_err(js_exception)?;
let module_specifier = module_specifier_for(&config.js_entrypoint)?;
let mod_id = runtime
.load_main_es_module_from_code(&module_specifier, server_entry)
.await
.map_err(core_error)?;
let evaluation = runtime.mod_evaluate(mod_id);
runtime
.run_event_loop(Default::default())
.await
.map_err(core_error)?;
evaluation.await.map_err(core_error)?;
let (render_fn, stream_render_fn) = lookup_render_functions(&mut runtime)?;
Ok(Self {
runtime,
render_fn,
stream_render_fn,
})
}
async fn render(&mut self, request: &RenderRequest) -> Result<RenderResponse, RendererError> {
let arg = {
deno_core::scope!(scope, self.runtime);
v8::tc_scope!(let tc_scope, scope);
let arg = serde_v8::to_v8(tc_scope, request)
.map_err(|err| RendererError::Serialization(err.to_string()))?;
v8::Global::new(tc_scope, arg)
};
#[allow(
deprecated,
reason = "deno_core 0.398 still exposes async call helpers here"
)]
let value = self
.runtime
.call_with_args_and_await(&self.render_fn, &[arg])
.await
.map_err(core_error)?;
deno_core::scope!(scope, self.runtime);
let value = v8::Local::new(scope, value);
serde_v8::from_v8::<RenderResponse>(scope, value)
.map_err(|err| RendererError::RenderFailed(err.to_string()))
}
async fn render_stream(
&mut self,
request: &RenderRequest,
) -> Result<RenderResponse, RendererError> {
let arg = {
deno_core::scope!(scope, self.runtime);
v8::tc_scope!(let tc_scope, scope);
let arg = serde_v8::to_v8(tc_scope, request)
.map_err(|err| RendererError::Serialization(err.to_string()))?;
v8::Global::new(tc_scope, arg)
};
#[allow(
deprecated,
reason = "deno_core 0.398 still exposes async call helpers here"
)]
let value = self
.runtime
.call_with_args_and_await(&self.stream_render_fn, &[arg])
.await
.map_err(core_error)?;
deno_core::scope!(scope, self.runtime);
let value = v8::Local::new(scope, value);
serde_v8::from_v8::<RenderResponse>(scope, value)
.map_err(|err| RendererError::RenderFailed(err.to_string()))
}
fn set_stream_sink(
&mut self,
sender: Option<tokio::sync::mpsc::UnboundedSender<Result<Bytes, Infallible>>>,
) {
let state = self.runtime.op_state();
state.borrow_mut().put(RuntimeHtmlStream(sender));
}
}
impl ViteDevRuntimeClient {
fn new(config: &ResolvedRuntimeRendererConfig) -> Result<Self, RendererError> {
let origin = config
.vite_dev_server_origin
.as_deref()
.ok_or_else(|| RendererError::RuntimeInit("missing vite dev server origin".into()))?;
let origin = origin.trim_end_matches('/');
Ok(Self {
client: reqwest::Client::builder()
.pool_max_idle_per_host(0)
.timeout(Duration::from_secs(5))
.build()
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?,
render_url: format!("{origin}/__haven_internal/render"),
render_stream_url: format!("{origin}/__haven_internal/render-stream"),
})
}
async fn render(&self, request: &RenderRequest) -> Result<RenderResponse, RendererError> {
self.send_json(&self.render_url, request).await
}
async fn render_stream(
&self,
request: &RenderRequest,
) -> Result<RenderResponse, RendererError> {
self.send_json(&self.render_stream_url, request).await
}
async fn send_json(
&self,
url: &str,
request: &RenderRequest,
) -> Result<RenderResponse, RendererError> {
let response = self.post_with_retry(url, request).await?;
let status = response.status();
let body = response
.text()
.await
.map_err(|err| RendererError::RenderFailed(err.to_string()))?;
if !status.is_success() {
if let Ok(payload) = serde_json::from_str::<ViteDevRuntimeErrorPayload>(&body) {
if let Some(html) = payload.html {
return Err(RendererError::RenderFailedHtml {
message: payload.error,
html,
});
}
return Err(RendererError::RenderFailed(payload.error));
}
return Err(RendererError::RenderFailed(body));
}
serde_json::from_str(&body).map_err(|err| RendererError::RenderFailed(err.to_string()))
}
async fn post_with_retry(
&self,
url: &str,
request: &RenderRequest,
) -> Result<reqwest::Response, RendererError> {
let mut attempts = 0;
loop {
attempts += 1;
match self.client.post(url).json(request).send().await {
Ok(response) => return Ok(response),
Err(err) if attempts < 3 && is_transient_dev_transport_error(&err) => {
tokio::time::sleep(Duration::from_millis(75 * attempts as u64)).await;
}
Err(err) => return Err(RendererError::RenderFailed(err.to_string())),
}
}
}
}
fn is_transient_dev_transport_error(err: &reqwest::Error) -> bool {
err.is_connect() || err.is_timeout() || err.is_request() || err.is_body()
}
fn load_runtime_bootstrap(js_source_dir: &Path) -> Result<(String, String), RendererError> {
let bootstrap_path = js_source_dir.join("runtime_bootstrap.js");
match std::fs::read_to_string(&bootstrap_path) {
Ok(source) => Ok((bootstrap_path.display().to_string(), source)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok((
"<haven/runtime_bootstrap.js>".into(),
DEFAULT_RUNTIME_BOOTSTRAP.into(),
)),
Err(err) => Err(RendererError::RuntimeInit(format!(
"failed reading bootstrap at {}: {err}",
bootstrap_path.display()
))),
}
}
fn uses_vite_dev_runtime(config: &ResolvedRuntimeRendererConfig) -> bool {
config.mode == RendererMode::Development && config.vite_dev_server_origin.is_some()
}
pub(crate) struct LocalRuntime {
state: LocalRuntimeState,
_dev_server: Option<Arc<DevServerHandle>>,
}
struct EmbeddedWorker {
tx: mpsc::UnboundedSender<RuntimeCommand>,
}
enum LocalRuntimeState {
Embedded {
workers: Vec<EmbeddedWorker>,
next_worker: AtomicUsize,
},
ViteDev {
client: ViteDevRuntimeClient,
},
}
impl LocalRuntime {
pub(crate) fn new(config: ResolvedRuntimeRendererConfig) -> Result<Self, RendererError> {
let dev_server = ensure_vite_dev_server(&config)?;
if uses_vite_dev_runtime(&config) {
let client = ViteDevRuntimeClient::new(&config)?;
return Ok(Self {
state: LocalRuntimeState::ViteDev { client },
_dev_server: dev_server,
});
}
if config.mode == RendererMode::Production {
build_assets(&config)?;
}
let workers = spawn_embedded_runtime_pool(config)?;
Ok(Self {
state: LocalRuntimeState::Embedded {
workers,
next_worker: AtomicUsize::new(0),
},
_dev_server: dev_server,
})
}
pub(crate) async fn render(
&self,
request: &RenderRequest,
) -> Result<RenderResponse, RendererError> {
match &self.state {
LocalRuntimeState::Embedded {
workers,
next_worker,
} => {
let worker = select_embedded_worker(workers, next_worker);
let (reply_tx, reply_rx) = oneshot::channel();
worker
.tx
.send(RuntimeCommand::Render {
request: request.clone(),
tx: reply_tx,
})
.map_err(|_| RendererError::ChannelClosed)?;
reply_rx.await.map_err(|_| RendererError::ChannelClosed)?
}
LocalRuntimeState::ViteDev { client, .. } => client.render(request).await,
}
}
pub(crate) async fn render_stream(
&self,
request: &RenderRequest,
) -> Result<
(
tokio::sync::mpsc::UnboundedReceiver<Result<Bytes, Infallible>>,
Option<u16>,
),
RendererError,
> {
match &self.state {
LocalRuntimeState::Embedded {
workers,
next_worker,
} => {
let worker = select_embedded_worker(workers, next_worker);
let (reply_tx, reply_rx) = oneshot::channel();
worker
.tx
.send(RuntimeCommand::StreamRender {
request: request.clone(),
tx: reply_tx,
})
.map_err(|_| RendererError::ChannelClosed)?;
reply_rx.await.map_err(|_| RendererError::ChannelClosed)?
}
LocalRuntimeState::ViteDev { client, .. } => {
let response = client.render_stream(request).await?;
let (stream_tx, stream_rx) = tokio::sync::mpsc::unbounded_channel();
if !response.html.is_empty() {
let _ = stream_tx.send(Ok(Bytes::from(response.html)));
}
Ok((stream_rx, response.status))
}
}
}
pub(crate) async fn reload(&self) -> Result<(), RendererError> {
match &self.state {
LocalRuntimeState::Embedded { workers, .. } => {
let Some(first_worker) = workers.first() else {
return Ok(());
};
let (reply_tx, reply_rx) = oneshot::channel();
first_worker
.tx
.send(RuntimeCommand::Reload {
tx: reply_tx,
rebuild_assets: true,
})
.map_err(|_| RendererError::ChannelClosed)?;
reply_rx.await.map_err(|_| RendererError::ChannelClosed)??;
let mut replies = Vec::with_capacity(workers.len().saturating_sub(1));
for worker in workers.iter().skip(1) {
let (reply_tx, reply_rx) = oneshot::channel();
worker
.tx
.send(RuntimeCommand::Reload {
tx: reply_tx,
rebuild_assets: false,
})
.map_err(|_| RendererError::ChannelClosed)?;
replies.push(reply_rx);
}
for reply in replies {
reply.await.map_err(|_| RendererError::ChannelClosed)??;
}
Ok(())
}
LocalRuntimeState::ViteDev { .. } => Ok(()),
}
}
}
fn select_embedded_worker<'a>(
workers: &'a [EmbeddedWorker],
next_worker: &AtomicUsize,
) -> &'a EmbeddedWorker {
let index = next_worker.fetch_add(1, Ordering::Relaxed) % workers.len();
&workers[index]
}
fn embedded_runtime_pool_size() -> usize {
std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1)
.max(1)
}
fn spawn_embedded_runtime_pool(
config: ResolvedRuntimeRendererConfig,
) -> Result<Vec<EmbeddedWorker>, RendererError> {
let pool_size = embedded_runtime_pool_size();
let mut workers = Vec::with_capacity(pool_size);
for worker_id in 0..pool_size {
workers.push(EmbeddedWorker {
tx: spawn_embedded_runtime_worker(config.clone(), worker_id)?,
});
}
Ok(workers)
}
fn spawn_embedded_runtime_worker(
config: ResolvedRuntimeRendererConfig,
worker_id: usize,
) -> Result<mpsc::UnboundedSender<RuntimeCommand>, RendererError> {
let (tx, rx) = mpsc::unbounded_channel();
let (init_tx, init_rx) = std::sync::mpsc::channel();
thread::Builder::new()
.name(format!("haven-runtime-{worker_id}"))
.spawn(move || embedded_runtime_thread(config, rx, init_tx))
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?;
init_rx.recv().map_err(|_| RendererError::ChannelClosed)??;
Ok(tx)
}
fn embedded_runtime_thread(
config: ResolvedRuntimeRendererConfig,
rx: mpsc::UnboundedReceiver<RuntimeCommand>,
init_tx: std::sync::mpsc::Sender<Result<(), RendererError>>,
) {
let tokio = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(tokio) => tokio,
Err(err) => {
let _ = init_tx.send(Err(RendererError::RuntimeInit(err.to_string())));
return;
}
};
tokio.block_on(async move {
embedded_runtime_main(config, rx, init_tx).await;
});
}
async fn embedded_runtime_main(
config: ResolvedRuntimeRendererConfig,
mut rx: mpsc::UnboundedReceiver<RuntimeCommand>,
init_tx: std::sync::mpsc::Sender<Result<(), RendererError>>,
) {
let mut host = match EmbeddedRuntimeHost::new(config).await {
Ok(host) => {
let _ = init_tx.send(Ok(()));
host
}
Err(err) => {
let _ = init_tx.send(Err(err));
return;
}
};
while let Some(command) = rx.recv().await {
match command {
RuntimeCommand::Render { request, tx } => {
let _ = tx.send(host.render(&request).await);
}
RuntimeCommand::StreamRender { request, tx } => {
let _ = tx.send(host.render_stream(&request).await);
}
RuntimeCommand::Reload { tx, rebuild_assets } => {
let _ = tx.send(host.reload(rebuild_assets).await);
}
}
}
}
async fn reload_engine(
config: &ResolvedRuntimeRendererConfig,
active_generation: &mut Option<EngineGeneration>,
next_generation_id: &mut u64,
rebuild_assets: bool,
) -> Result<(), RendererError> {
if rebuild_assets {
build_assets(config)?;
}
let previous_generation_id = active_generation
.as_ref()
.map(|generation| generation.generation_id);
if config.mode == RendererMode::Development {
let old_generation = active_generation.take();
drop(old_generation);
}
match JsEngine::new(config).await {
Ok(new_engine) => {
if config.mode != RendererMode::Development {
let _ = active_generation.take();
}
let generation_id = *next_generation_id;
*next_generation_id += 1;
*active_generation = Some(EngineGeneration::new(generation_id, new_engine));
info!(
target = "js_runtime",
previous_generation_id,
generation_id,
mode = ?config.mode,
"reloaded JavaScript runtime"
);
Ok(())
}
Err(err) => {
if config.mode != RendererMode::Development {
}
warn!(
target = "js_runtime",
error = %err,
"reload failed; keeping previous runtime"
);
Err(RendererError::ReloadFailed(err.to_string()))
}
}
}
async fn ensure_active_generation<'a>(
config: &ResolvedRuntimeRendererConfig,
active_generation: &'a mut Option<EngineGeneration>,
next_generation_id: &mut u64,
) -> Result<&'a mut EngineGeneration, RendererError> {
if active_generation.is_none() {
let generation_id = *next_generation_id;
*next_generation_id += 1;
let engine = JsEngine::new(config).await?;
*active_generation = Some(EngineGeneration::new(generation_id, engine));
}
active_generation
.as_mut()
.ok_or_else(|| RendererError::RuntimeInit("runtime missing".into()))
}
fn ensure_vite_dev_server(
config: &ResolvedRuntimeRendererConfig,
) -> Result<Option<Arc<DevServerHandle>>, RendererError> {
if cfg!(test) || !uses_vite_dev_runtime(config) {
return Ok(None);
}
let Some(origin) = config.vite_dev_server_origin.as_deref() else {
return Ok(None);
};
if vite_dev_runtime_is_ready(origin)? {
return Ok(None);
}
if !config.start_vite_dev_server {
return Err(RendererError::RuntimeInit(format!(
"vite dev runtime is not ready at {origin}; enable start_vite_dev_server or run the Haven dev server script for this app"
)));
}
let addr = dev_server_addr(origin)?;
let child = Command::new("node")
.arg(dev_runtime_script_path(config)?)
.arg("--config")
.arg(config.project_root.join("vite.dev.config.mjs"))
.arg("--host")
.arg(addr.ip().to_string())
.arg("--port")
.arg(addr.port().to_string())
.arg("--strictPort")
.env("HAVEN_SOURCE_DIR", &config.js_source_dir)
.env(
"HAVEN_SERVER_ENTRY",
resolve_entry_file(&config.js_source_dir, "entry-server")?,
)
.env(
"HAVEN_CLIENT_ENTRY",
resolve_entry_file(&config.js_source_dir, "entry-client")?,
)
.env("HAVEN_TRANSLATIONS_DIR", &config.translations_dir)
.env("HAVEN_DEFAULT_LOCALE", &config.default_locale)
.env("HAVEN_FALLBACK_LOCALE", &config.fallback_locale)
.env("HAVEN_VERSION", &config.version)
.env("HAVEN_TITLE", &config.title)
.env("HAVEN_URL", config.url.as_deref().unwrap_or("/"))
.current_dir(&config.project_root)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(|err| {
RendererError::RuntimeInit(format!("failed to spawn vite dev runtime: {err}"))
})?;
for _ in 0..50 {
if vite_dev_runtime_is_ready(origin)? {
return Ok(Some(Arc::new(DevServerHandle {
child: Mutex::new(Some(child)),
})));
}
std::thread::sleep(Duration::from_millis(100));
}
Err(RendererError::RuntimeInit(format!(
"vite dev runtime did not become ready at {origin}"
)))
}
fn dev_runtime_script_path(
config: &ResolvedRuntimeRendererConfig,
) -> Result<PathBuf, RendererError> {
let installed = config
.project_root
.join("node_modules/@ovior/haven/scripts/vite-dev-runtime.mjs");
if installed.exists() {
return Ok(installed);
}
let local = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/vite-dev-runtime.mjs");
if local.exists() {
return Ok(local);
}
Err(RendererError::RuntimeInit(
"missing Haven vite dev runtime script".into(),
))
}
fn vite_dev_runtime_is_ready(origin: &str) -> Result<bool, RendererError> {
let addr = dev_server_addr(origin)?;
let mut stream = match std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(300)) {
Ok(stream) => stream,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::TimedOut
| std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::NotConnected
) =>
{
return Ok(false);
}
Err(err) => return Err(RendererError::RuntimeInit(err.to_string())),
};
stream
.set_read_timeout(Some(Duration::from_millis(300)))
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?;
stream
.set_write_timeout(Some(Duration::from_millis(300)))
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?;
use std::io::{Read, Write};
stream
.write_all(
b"GET /__haven_internal/health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
)
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?;
let mut response = String::new();
match stream.read_to_string(&mut response) {
Ok(_) => Ok(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")),
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
Ok(false)
}
Err(err) => Err(RendererError::RuntimeInit(err.to_string())),
}
}
fn dev_server_addr(origin: &str) -> Result<std::net::SocketAddr, RendererError> {
let url =
reqwest::Url::parse(origin).map_err(|err| RendererError::RuntimeInit(err.to_string()))?;
let host = url
.host_str()
.ok_or_else(|| RendererError::RuntimeInit("vite dev runtime origin missing host".into()))?;
let port = url
.port_or_known_default()
.ok_or_else(|| RendererError::RuntimeInit("vite dev runtime origin missing port".into()))?;
let addr = format!("{host}:{port}");
addr.parse().map_err(|err| {
RendererError::RuntimeInit(format!("invalid vite dev runtime address: {err}"))
})
}
fn build_assets(config: &ResolvedRuntimeRendererConfig) -> Result<(), RendererError> {
run_vite_build(config, "vite.server.config.mjs")?;
if config.mode == RendererMode::Production {
run_vite_build(config, "vite.client.config.mjs")?;
}
Ok(())
}
fn run_vite_build(
config: &ResolvedRuntimeRendererConfig,
config_file: &str,
) -> Result<(), RendererError> {
let server_entry = resolve_entry_file(&config.js_source_dir, "entry-server")?;
let client_entry = resolve_entry_file(&config.js_source_dir, "entry-client")?;
let client_manifest = config
.client_manifest_path
.clone()
.unwrap_or_else(|| config.project_root.join("dist/client/.vite/manifest.json"));
let client_out_dir = client_manifest
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or_else(|| config.project_root.join("dist/client"));
let server_out_dir = config
.js_entrypoint
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| config.project_root.join("dist/server"));
let output = Command::new("npm")
.arg("exec")
.arg("--")
.arg("vite")
.arg("build")
.arg("--config")
.arg(config.project_root.join(config_file))
.env("HAVEN_SOURCE_DIR", &config.js_source_dir)
.env("HAVEN_SERVER_ENTRY", server_entry)
.env("HAVEN_CLIENT_ENTRY", client_entry)
.env("HAVEN_SERVER_OUT_DIR", server_out_dir)
.env("HAVEN_CLIENT_OUT_DIR", client_out_dir)
.current_dir(&config.project_root)
.output()
.map_err(|err| RendererError::BundleBuildFailed(err.to_string()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
let detail = if !stderr.is_empty() { stderr } else { stdout };
Err(RendererError::BundleBuildFailed(format!(
"{config_file} failed: {}",
if detail.is_empty() {
output.status.to_string()
} else {
detail
}
)))
}
fn resolve_client_assets(
config: &ResolvedRuntimeRendererConfig,
) -> Result<ClientAssetSet, RendererError> {
if let Some(origin) = config.vite_dev_server_origin.as_deref() {
if config.mode == RendererMode::Development {
let client_entry = resolve_entry_file(&config.js_source_dir, "entry-client")?;
let relative = client_entry
.strip_prefix(&config.project_root)
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?
.to_string_lossy()
.replace('\\', "/");
return Ok(ClientAssetSet {
entry_script_url: Some(format!("{origin}/{relative}")),
module_preload_urls: Vec::new(),
style_urls: Vec::new(),
});
}
}
if let Some(asset_script_url) = &config.asset_script_url {
return Ok(ClientAssetSet {
entry_script_url: Some(asset_script_url.clone()),
module_preload_urls: Vec::new(),
style_urls: Vec::new(),
});
}
let Some(manifest_path) = &config.client_manifest_path else {
return Ok(ClientAssetSet::default());
};
if !manifest_path.exists() {
return Ok(ClientAssetSet::default());
}
let manifest: HashMap<String, ViteManifestEntry> =
serde_json::from_str(&std::fs::read_to_string(manifest_path)?)?;
let client_entry = resolve_entry_file(&config.js_source_dir, "entry-client")?;
let manifest_key = client_entry
.strip_prefix(&config.project_root)
.map_err(|err| RendererError::RuntimeInit(err.to_string()))?
.to_string_lossy()
.replace('\\', "/");
let Some(entry_key) = manifest
.get_key_value(&manifest_key)
.map(|(key, _)| key.as_str())
.or_else(|| {
manifest
.iter()
.find_map(|(key, entry)| entry.is_entry.then_some(key.as_str()))
})
else {
return Ok(ClientAssetSet::default());
};
let entry = manifest
.get(entry_key)
.ok_or_else(|| RendererError::RuntimeInit("client manifest entry missing".into()))?;
let mut imports = Vec::new();
let mut styles = Vec::new();
let mut seen_imports = HashSet::new();
let mut seen_styles = HashSet::new();
collect_manifest_assets(
&manifest,
entry_key,
&mut imports,
&mut styles,
&mut seen_imports,
&mut seen_styles,
);
Ok(ClientAssetSet {
entry_script_url: Some(format!("/{}", entry.file)),
module_preload_urls: imports,
style_urls: styles,
})
}
fn collect_manifest_assets(
manifest: &HashMap<String, ViteManifestEntry>,
key: &str,
imports: &mut Vec<String>,
styles: &mut Vec<String>,
seen_imports: &mut HashSet<String>,
seen_styles: &mut HashSet<String>,
) {
let Some(entry) = manifest.get(key) else {
return;
};
for css in &entry.css {
let css_url = format!("/{}", css);
if seen_styles.insert(css_url.clone()) {
styles.push(css_url);
}
}
for import_key in &entry.imports {
let Some(import_entry) = manifest.get(import_key) else {
continue;
};
let import_url = format!("/{}", import_entry.file);
if seen_imports.insert(import_url.clone()) {
imports.push(import_url);
}
collect_manifest_assets(
manifest,
import_key,
imports,
styles,
seen_imports,
seen_styles,
);
}
}
fn resolve_vite_dev_client_url(config: &ResolvedRuntimeRendererConfig) -> Option<String> {
if config.mode != RendererMode::Development {
return None;
}
config
.vite_dev_server_origin
.as_ref()
.map(|origin| format!("{origin}/@vite/client"))
}
fn resolve_entry_file(source_dir: &Path, base_name: &str) -> Result<PathBuf, RendererError> {
for extension in ["tsx", "jsx", "ts", "app"] {
let candidate = source_dir.join(format!("{base_name}.{extension}"));
if candidate.exists() {
return Ok(candidate);
}
}
Err(RendererError::RuntimeInit(format!(
"missing entry file for {base_name} in {}",
source_dir.display()
)))
}
fn module_specifier_for(path: &Path) -> Result<ModuleSpecifier, RendererError> {
deno_core::resolve_path(&path.display().to_string(), &std::env::current_dir()?)
.map_err(|err| RendererError::RuntimeInit(err.to_string()))
}
fn lookup_render_functions(
runtime: &mut JsRuntime,
) -> Result<(v8::Global<v8::Function>, v8::Global<v8::Function>), RendererError> {
deno_core::scope!(scope, runtime);
let context = scope.get_current_context();
let global = context.global(scope);
let key = v8::String::new(scope, "__RUNTIME_RENDER__")
.ok_or_else(|| RendererError::RuntimeInit("failed to create render key".into()))?;
let value = global
.get(scope, key.into())
.ok_or_else(|| RendererError::RuntimeInit("missing __RUNTIME_RENDER__ global".into()))?;
let function = value
.try_cast::<v8::Function>()
.map_err(|_| RendererError::RuntimeInit("__RUNTIME_RENDER__ is not a function".into()))?;
let render_fn = v8::Global::new(scope, function);
let stream_key = v8::String::new(scope, "__RUNTIME_RENDER_STREAM__")
.ok_or_else(|| RendererError::RuntimeInit("failed to create stream render key".into()))?;
let stream_value = global.get(scope, stream_key.into()).ok_or_else(|| {
RendererError::RuntimeInit(
"missing __RUNTIME_RENDER_STREAM__ global; use `defineServerEntry({ renderEnvelope, streamEnvelope })` from `@ovior/haven/page` or set `globalThis.__RUNTIME_RENDER_STREAM__ = streamEnvelope` in app/entry-server.jsx"
.into(),
)
})?;
let stream_function = stream_value.try_cast::<v8::Function>().map_err(|_| {
RendererError::RuntimeInit(
"__RUNTIME_RENDER_STREAM__ is not a function; make sure app/entry-server.jsx uses `defineServerEntry(...)` or wires `streamEnvelope` correctly"
.into(),
)
})?;
Ok((render_fn, v8::Global::new(scope, stream_function)))
}
fn js_exception(err: Box<JsError>) -> RendererError {
RendererError::JavaScriptException(err.to_string())
}
fn core_error(err: deno_core::error::CoreError) -> RendererError {
RendererError::JavaScriptException(err.to_string())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::Duration;
use tempfile::tempdir_in;
use tokio::time::timeout;
use super::*;
use crate::{RendererConfig, RendererMode, RendererState};
#[test]
fn resolve_client_assets_collects_entry_imports_and_css() {
let fixture_root = tempdir_in(std::env::current_dir().unwrap().join("target")).unwrap();
let fixture_dir = fixture_root.path();
fs::create_dir_all(fixture_dir.join("app")).unwrap();
fs::create_dir_all(fixture_dir.join("dist/client/.vite")).unwrap();
fs::write(fixture_dir.join("app/entry-client.jsx"), "export {};\n").unwrap();
fs::write(
fixture_dir.join("dist/client/.vite/manifest.json"),
serde_json::json!({
"app/entry-client.jsx": {
"file": "assets/entry-client.js",
"isEntry": true,
"imports": ["assets/chunk-a.js", "assets/chunk-b.js"],
"css": ["assets/entry.css"]
},
"assets/chunk-a.js": {
"file": "assets/chunk-a.js",
"imports": ["assets/chunk-b.js"],
"css": ["assets/chunk-a.css"]
},
"assets/chunk-b.js": {
"file": "assets/chunk-b.js",
"css": ["assets/chunk-b.css", "assets/chunk-a.css"]
}
})
.to_string(),
)
.unwrap();
let config = ResolvedRuntimeRendererConfig {
mode: RendererMode::Production,
js_entrypoint: fixture_dir.join("dist/server/entry-server.mjs"),
js_source_dir: fixture_dir.join("app"),
client_manifest_path: Some(fixture_dir.join("dist/client/.vite/manifest.json")),
project_root: fixture_dir.to_path_buf(),
translations_dir: fixture_dir.join("locales"),
default_locale: "en".into(),
fallback_locale: "en".into(),
vite_dev_server_origin: None,
start_vite_dev_server: false,
version: "dev".into(),
url: Some("/".into()),
asset_script_url: None,
title: "App".into(),
};
let assets = resolve_client_assets(&config).unwrap();
assert_eq!(
assets.entry_script_url,
Some("/assets/entry-client.js".into())
);
assert_eq!(
assets.module_preload_urls,
vec!["/assets/chunk-a.js", "/assets/chunk-b.js"]
);
assert_eq!(
assets.style_urls,
vec![
"/assets/entry.css",
"/assets/chunk-a.css",
"/assets/chunk-b.css"
]
);
}
#[test]
fn load_runtime_bootstrap_uses_builtin_default_when_local_file_is_missing() {
let fixture_root = tempdir_in(std::env::current_dir().unwrap().join("target")).unwrap();
let fixture_dir = fixture_root.path();
fs::create_dir_all(fixture_dir.join("app")).unwrap();
let (name, source) = load_runtime_bootstrap(&fixture_dir.join("app")).unwrap();
assert_eq!(name, "<haven/runtime_bootstrap.js>");
assert_eq!(source, DEFAULT_RUNTIME_BOOTSTRAP);
}
#[test]
fn load_runtime_bootstrap_prefers_local_override_when_present() {
let fixture_root = tempdir_in(std::env::current_dir().unwrap().join("target")).unwrap();
let fixture_dir = fixture_root.path();
fs::create_dir_all(fixture_dir.join("app")).unwrap();
fs::write(
fixture_dir.join("app/runtime_bootstrap.js"),
"globalThis.__LOCAL_BOOTSTRAP__ = true;\n",
)
.unwrap();
let (name, source) = load_runtime_bootstrap(&fixture_dir.join("app")).unwrap();
assert!(name.ends_with("runtime_bootstrap.js"));
assert_eq!(source, "globalThis.__LOCAL_BOOTSTRAP__ = true;\n");
}
#[test]
fn uses_vite_dev_runtime_only_when_origin_is_configured() {
let fixture_root = tempdir_in(std::env::current_dir().unwrap().join("target")).unwrap();
let fixture_dir = fixture_root.path();
let mut config = ResolvedRuntimeRendererConfig {
mode: RendererMode::Development,
js_entrypoint: fixture_dir.join("dist/server/entry-server.mjs"),
js_source_dir: fixture_dir.join("app"),
client_manifest_path: Some(fixture_dir.join("dist/client/.vite/manifest.json")),
project_root: fixture_dir.to_path_buf(),
translations_dir: fixture_dir.join("locales"),
default_locale: "en".into(),
fallback_locale: "en".into(),
vite_dev_server_origin: Some("http://127.0.0.1:5174".into()),
start_vite_dev_server: false,
version: "dev".into(),
url: Some("/".into()),
asset_script_url: None,
title: "App".into(),
};
assert!(uses_vite_dev_runtime(&config));
config.vite_dev_server_origin = None;
assert!(!uses_vite_dev_runtime(&config));
config.vite_dev_server_origin = Some("http://127.0.0.1:5174".into());
config.mode = RendererMode::Production;
assert!(!uses_vite_dev_runtime(&config));
}
#[test]
fn production_embedded_runtime_renders_hosting_app() {
let hosting_root = std::env::current_dir().unwrap().join("../hosting");
assert!(
hosting_root.join("dist/server/entry-server.mjs").exists(),
"expected ../hosting to be built before running this test"
);
let renderer = RendererState::new(RendererConfig {
mode: RendererMode::Production,
project_root: Some(hosting_root.clone()),
js_source_dir: hosting_root.join("app"),
js_entrypoint: hosting_root.join("dist/server/entry-server.mjs"),
client_manifest_path: Some(hosting_root.join("dist/client/.vite/manifest.json")),
vite_dev_server_origin: None,
start_vite_dev_server: false,
title: "Hosting".into(),
..RendererConfig::default()
})
.expect("renderer");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let rendered = runtime.block_on(async {
timeout(Duration::from_secs(5), renderer.render("index"))
.await
.expect("render timed out")
});
let rendered = rendered.expect("render failed");
assert!(rendered.html.contains("<!doctype html>"));
}
}