use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket},
FromRequestParts, Request, State, WebSocketUpgrade,
},
http::{header, Method, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use futures_util::SinkExt;
use tokio::sync::broadcast;
use crate::start_host::{ScriptEngine, StartEngine, StartRequest, StartResponse};
struct StartState {
proxy_prefixes: Vec<String>,
plugin_serve: Arc<oj_server::PluginServe>,
engine: Arc<StartEngine>,
loopback_port: u16,
regen_hashes: std::sync::Mutex<std::collections::HashMap<PathBuf, Option<u64>>>,
bundle: std::sync::RwLock<Arc<oj_cache::start_bundle::PinnedBundle>>,
verify: oj_cache::integrity::VerifyMode,
live_reload: PathBuf,
workspace_root: PathBuf,
reload_tx: broadcast::Sender<()>,
ws_tx: broadcast::Sender<String>,
css_host: Option<Arc<oj_server::css_engine::CssEngine>>,
mode: String,
gate: Option<oj_server::HmrGateHandle>,
runner_dirty: Arc<std::sync::atomic::AtomicBool>,
}
impl StartState {
fn lazy_runner(&self) -> bool {
self.plugin_serve.runner_environments()
}
}
fn watch_relevant(p: &Path) -> bool {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
!name.contains("routeTree.gen") && !p.is_dir()
}
fn change_type(p: &Path, created: &std::collections::HashSet<PathBuf>) -> &'static str {
if !p.exists() {
"delete"
} else if created.contains(p) {
"create"
} else {
"update"
}
}
fn config_mentions_cloudflare_plugin(root: &Path, config: &Option<PathBuf>) -> bool {
let file = config
.clone()
.or_else(|| oj_server::plugins::vite_config_file(root));
file.and_then(|f| std::fs::read_to_string(f).ok())
.is_some_and(|s| s.contains("@cloudflare/vite-plugin"))
}
fn config_path(root: &Path, config: Option<PathBuf>) -> Option<PathBuf> {
config.map(|c| if c.is_absolute() { c } else { root.join(c) })
}
pub async fn start_dev(
root: PathBuf,
port: Option<u16>,
host: Option<String>,
config: Option<PathBuf>,
mode: Option<String>,
) -> anyhow::Result<()> {
let root = root
.canonicalize()
.map_err(|e| anyhow::anyhow!("app root not found: {}: {e}", root.display()))?;
let mode = mode
.filter(|m| !m.is_empty())
.unwrap_or_else(|| "development".to_string());
let config = config_path(&root, config);
let cf_hint = config_mentions_cloudflare_plugin(&root, &config);
if let Some(cfg) = &config {
oj_server::plugins::set_vite_config_override(cfg.clone());
}
let cache = oj_cache::cache_root(&root).join("start");
oj_server::prepare_cache_root(&root);
oj_server::write_start_assets(&cache)?;
oj_server::boot_phase("start_dev begin");
let built_task = tokio::spawn(
oj_server::DevServer {
root: root.clone(),
port,
host,
config,
enable_cache: false,
no_cache: false,
lazy: false,
mode: Some(mode.clone()),
}
.build_app(),
);
let route_tree = {
let (root, cache, mode) = (root.clone(), cache.clone(), mode.clone());
tokio::task::spawn_blocking(move || generate_route_tree(&root, &cache, &mode))
};
route_tree.await??;
oj_server::boot_phase("route tree ready");
let resolver = {
let (root, cache, mode) = (root.clone(), cache.clone(), mode.clone());
tokio::task::spawn_blocking(move || generate_server_fn_resolver(&root, &cache, &mode))
};
resolver.await??;
oj_server::boot_phase("resolver ready");
let bundle = {
let (root, cache, mode) = (root.clone(), cache.clone(), mode.clone());
tokio::task::spawn_blocking(move || bundle_client_entry_cached(&root, &cache, &mode))
};
let (reload_tx, _) = broadcast::channel::<()>(16);
let (bundle_res, built_res) = tokio::join!(bundle, built_task);
let pinned = bundle_res??;
let built = built_res??;
oj_server::boot_phase("bundle+build joined");
let engine = {
let mut config = oj_config::load(&root).unwrap_or_default();
oj_server::plugins::adopt_vite_config_values(&mut config, &root, "serve", &mode)
.map_err(|e| anyhow::anyhow!(e))?;
let entry = configured_start_server_entry(&config, &root)
.unwrap_or_else(|| cache.join("server-entry.tsx"));
let mut init_env = vec![
(
"OJ_APP_ROOT".to_string(),
root.to_string_lossy().into_owned(),
),
(
"OJ_CACHE_ROOT".to_string(),
oj_cache::cache_root(&root).to_string_lossy().into_owned(),
),
("NODE_ENV".to_string(), dev_node_env(&root, &mode)),
("OJ_MODE".to_string(), mode.clone()),
];
init_env.extend(start_script_env(&root, "serve", &mode)?);
Arc::new(StartEngine::new(
root.clone(),
&cache,
built.ssr.clone(),
entry,
oj_config::ssr_externals(&config),
oj_config::public_dir(&config, &root),
init_env,
)?)
};
let loopback_port = spawn_engine_loopback(Arc::clone(&engine)).await?;
oj_server::boot_phase("engine ready");
let prewarm_hold = built.plugin_host.as_ref().map(|h| PrewarmHold {
updates: h.serve_info_updates(),
host_gone: h.host_gone_updates(),
init_failed: h.init_failure_updates(),
init_deadline: h.init_deadline_at(),
confirm: oj_server::plugins::plugin_rpc_timeout(),
});
{
let engine = Arc::clone(&engine);
let reload_tx = reload_tx.clone();
tokio::spawn(async move {
if cf_hint {
if let Some(mut hold) = prewarm_hold {
if let Some(known) = hold_prewarm_for_serve_info(&mut hold).await {
if known.middleware_port.is_some() && known.runner_environments {
oj_server::boot_phase("prewarm skipped (worker environments)");
return;
}
}
}
}
let _ = engine
.handle(engine_request(
"GET".into(),
"/".into(),
&header::HeaderMap::new(),
None,
false,
))
.await;
oj_server::boot_phase("prewarm complete");
if engine.revalidate().await {
let _ = reload_tx.send(());
}
oj_server::boot_phase("revalidate complete");
});
}
let css_host = if app_uses_tailwind(&root) {
oj_server::css_engine::CssEngine::tailwind(&root, oj_server::css_engine::START_DEADLINE)
.await
.ok()
} else {
None
};
let state = Arc::new(StartState {
proxy_prefixes: built.proxy_prefixes.clone(),
plugin_serve: Arc::clone(&built.plugin_serve),
engine,
loopback_port,
bundle: std::sync::RwLock::new(Arc::new(pinned)),
verify: oj_cache::integrity::VerifyMode::from_env(),
live_reload: cache.join("live-reload.js"),
workspace_root: workspace_root(&root),
reload_tx: reload_tx.clone(),
ws_tx: built.reload_tx.clone(),
gate: built.hmr_gate.clone(),
css_host,
mode: mode.clone(),
runner_dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
regen_hashes: std::sync::Mutex::new(
regen_output_files(&root, &cache)
.into_iter()
.map(|p| {
let h = file_content_hash(&p);
(p, h)
})
.collect(),
),
});
{
let runner_dirty = Arc::clone(&state.runner_dirty);
state.plugin_serve.set_on_activate(Box::new(move || {
runner_dirty.store(true, std::sync::atomic::Ordering::SeqCst);
}));
if state.plugin_serve.activated_late() {
state
.runner_dirty
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
if let Some(gate) = &state.gate {
let mut flushes = gate.subscribe_flush();
let reload_tx = state.reload_tx.clone();
tokio::spawn(async move {
while flushes.recv().await.is_ok() {
let _ = reload_tx.send(());
}
});
}
spawn_start_watcher(root.clone(), cache.clone(), Arc::clone(&state));
{
let (root, mode) = (root.clone(), mode.clone());
tokio::task::spawn_blocking(move || {
start_bundle_store(&root, &mode)
.prune(oj_cache::start_bundle::DEFAULT_PRUNE_BUDGET_BYTES);
});
}
let app = built.router.layer(axum::middleware::from_fn_with_state(
Arc::clone(&state),
start_route,
));
let (listener, port) =
oj_server::bind_dev_listener(built.host, built.port, built.strict_port).await?;
oj_server::boot_phase("listening");
tokio::spawn(async move {
std::process::exit(shutdown_signal().await);
});
println!(" {} dev (tanstack start)", oj_server::oj_brand());
let url = format!("http://localhost:{}/", port);
println!(" {}", oj_server::link(&url, &oj_server::cell(&url)));
axum::serve(listener, app).await?;
Ok(())
}
async fn shutdown_signal() -> i32 {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(_) => return tokio::signal::ctrl_c().await.map(|_| 130).unwrap_or(130),
};
tokio::select! {
_ = tokio::signal::ctrl_c() => 130,
_ = term.recv() => 143,
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
130
}
}
async fn ensure_runner_fresh(state: &StartState) {
if state.lazy_runner()
&& state
.runner_dirty
.swap(false, std::sync::atomic::Ordering::SeqCst)
{
state.engine.reload().await;
}
}
fn list_route_files(root: &Path) -> std::collections::BTreeSet<PathBuf> {
let mut out = std::collections::BTreeSet::new();
fn walk(dir: &Path, out: &mut std::collections::BTreeSet<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "ts" || x == "tsx") {
out.insert(p);
}
}
}
walk(&root.join("src").join("routes"), &mut out);
out
}
fn workspace_root(app: &Path) -> PathBuf {
let mut best = app.to_path_buf();
let mut cur = app;
while let Some(parent) = cur.parent() {
if parent.join("node_modules").is_dir() {
best = parent.to_path_buf();
}
cur = parent;
}
best
}
fn asset_mime(ext: &str) -> &'static str {
match ext {
"css" => "text/css; charset=utf-8",
"js" | "mjs" => "text/javascript",
"json" => "application/json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"wasm" => "application/wasm",
_ => "application/octet-stream",
}
}
const RELOAD_CLIENT: &str = "<script type=\"module\" src=\"/@oj-start/live-reload.js\"></script>";
#[derive(Default)]
struct PendingRebundle {
paths: std::collections::HashSet<PathBuf>,
invalidates: Vec<tokio::task::JoinHandle<()>>,
batch: u64,
}
fn spawn_mw_invalidate(
rt: &tokio::runtime::Handle,
state: &StartState,
paths: &std::collections::HashSet<PathBuf>,
created: &std::collections::HashSet<PathBuf>,
) -> Option<tokio::task::JoinHandle<()>> {
let port = state.plugin_serve.mw_port()?;
let changed: Vec<(String, &'static str)> = paths
.iter()
.filter(|p| watch_relevant(p))
.map(|p| (p.to_string_lossy().into_owned(), change_type(p, created)))
.collect();
if changed.is_empty() {
return None;
}
Some(rt.spawn(async move {
oj_server::notify_plugin_mw_invalidate(port, &changed).await;
}))
}
fn regen_output_files(root: &Path, cache: &Path) -> [PathBuf; 2] {
let configured = std::fs::read_to_string(root.join("tsr.config.json"))
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| {
let rel = v.get("generatedRouteTree")?.as_str()?.to_owned();
Some(root.join(rel.trim_start_matches("./")))
});
[
configured.unwrap_or_else(|| root.join("src").join("routeTree.gen.ts")),
cache.join("server-fn-resolver.mjs"),
]
}
fn file_content_hash(p: &Path) -> Option<u64> {
use std::hash::{Hash, Hasher};
let bytes = std::fs::read(p).ok()?;
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
Some(h.finish())
}
fn changed_regen_outputs(
files: &[PathBuf],
seen: &mut std::collections::HashMap<PathBuf, Option<u64>>,
) -> Vec<(String, &'static str)> {
files
.iter()
.filter(|f| {
let current = file_content_hash(f);
match seen.insert((*f).clone(), current) {
Some(last) => current != last,
None => current.is_some(),
}
})
.map(|f| (f.to_string_lossy().into_owned(), "update"))
.collect()
}
fn spawn_start_watcher(root: PathBuf, cache: PathBuf, state: Arc<StartState>) {
let rt = tokio::runtime::Handle::current();
let pending: Arc<std::sync::Mutex<Option<PendingRebundle>>> =
Arc::new(std::sync::Mutex::new(None));
let wake = Arc::new(tokio::sync::Notify::new());
let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
rt.spawn(rebundle_worker(
root.clone(),
cache.clone(),
Arc::clone(&state),
Arc::clone(&pending),
Arc::clone(&wake),
Arc::clone(&shutdown),
));
struct StopWorker(Arc<std::sync::atomic::AtomicBool>, Arc<tokio::sync::Notify>);
impl Drop for StopWorker {
fn drop(&mut self) {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
self.1.notify_one();
}
}
let stop_worker = StopWorker(Arc::clone(&shutdown), Arc::clone(&wake));
std::thread::spawn(move || {
use notify::{RecursiveMode, Watcher};
use std::sync::mpsc::RecvTimeoutError;
let _stop_worker = stop_worker;
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(e) => {
eprintln!("oj start: file watcher failed: {e}");
return;
}
};
let src = root.join("src");
if let Err(e) = watcher.watch(&src, RecursiveMode::Recursive) {
eprintln!("oj start: cannot watch {}: {e}", src.display());
return;
}
let mut batch = 0u64;
let mut changes = oj_server::ContentChanges::new();
loop {
let mut created: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let mut paths: std::collections::HashSet<PathBuf> = match rx.recv() {
Ok(Ok(ev)) => {
if matches!(ev.kind, notify::EventKind::Create(_)) {
created.extend(ev.paths.iter().cloned());
}
changes.changed_paths(&ev).into_iter().collect()
}
Ok(Err(_)) => continue,
Err(_) => break,
};
let _ = spawn_mw_invalidate(&rt, &state, &paths, &created);
loop {
match rx.recv_timeout(std::time::Duration::from_millis(50)) {
Ok(Ok(ev)) => {
if matches!(ev.kind, notify::EventKind::Create(_)) {
created.extend(ev.paths.iter().cloned());
}
paths.extend(changes.changed_paths(&ev));
}
Ok(Err(_)) => {}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => return,
}
}
if paths.is_empty() {
continue;
}
let relevant = paths.iter().any(|p| watch_relevant(p));
if !relevant {
continue;
}
batch += 1;
let _ = state.ws_tx.send(oj_server::update_progress_frame(
batch, "watch", 0, 0, None, false,
));
let invalidate = spawn_mw_invalidate(&rt, &state, &paths, &created);
if state.lazy_runner() {
state
.runner_dirty
.store(true, std::sync::atomic::Ordering::SeqCst);
}
{
let mut slot = pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let run = slot.get_or_insert_with(PendingRebundle::default);
run.paths.extend(paths.iter().cloned());
run.invalidates.extend(invalidate);
run.batch = batch;
}
wake.notify_one();
}
});
}
async fn rebundle_worker(
root: PathBuf,
cache: PathBuf,
state: Arc<StartState>,
pending: Arc<std::sync::Mutex<Option<PendingRebundle>>>,
wake: Arc<tokio::sync::Notify>,
shutdown: Arc<std::sync::atomic::AtomicBool>,
) {
let mut prev_routes = list_route_files(&root);
loop {
let run = pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let Some(run) = run else {
if shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
wake.notified().await;
continue;
};
let paths: Vec<PathBuf> = run.paths.into_iter().collect();
let regen_files = regen_output_files(&root, &cache);
let client = {
let (r, c, m) = (root.clone(), cache.clone(), state.mode.clone());
let routes_prev = prev_routes.clone();
let changed: Vec<PathBuf> = paths.clone();
tokio::task::spawn_blocking(move || {
let routes_now = list_route_files(&r);
let routes_changed = routes_now != routes_prev;
if routes_changed {
let _ = generate_route_tree(&r, &c, &m);
}
let server_fn_changed = changed.iter().any(|p| {
let is_ts = p.extension().is_some_and(|e| e == "ts" || e == "tsx");
is_ts
&& (!p.exists()
|| std::fs::read_to_string(p)
.is_ok_and(|s| s.contains("createServerFn")))
});
if routes_changed || server_fn_changed {
let _ = generate_server_fn_resolver(&r, &c, &m);
}
let pinned = if bundle_client_entry(&r, &c, &m).is_err() {
None
} else {
match start_bundle_store(&r, &m).persist(&c) {
Some((_, pinned)) => Some(pinned),
None => oj_cache::start_bundle::PinnedBundle::from_build_dir(&c),
}
};
(routes_now, pinned)
})
};
let side = async {
for handle in run.invalidates {
let _ = handle.await;
}
};
let (client, _) = tokio::join!(client, side);
if let Ok((routes_now, pinned)) = client {
prev_routes = routes_now;
if let Some(pinned) = pinned {
*state.bundle.write().unwrap() = Arc::new(pinned);
}
}
let regen_changed = {
let mut seen = state
.regen_hashes
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
changed_regen_outputs(®en_files, &mut seen)
};
if !regen_changed.is_empty() {
if state.lazy_runner() {
state
.runner_dirty
.store(true, std::sync::atomic::Ordering::SeqCst);
}
if let Some(port) = state.plugin_serve.mw_port() {
let names: Vec<&str> = regen_changed
.iter()
.filter_map(|(p, _)| Path::new(p).file_name()?.to_str())
.collect();
println!(
" oj start: regen outputs changed ({}), invalidating worker",
names.join(", ")
);
oj_server::notify_plugin_mw_invalidate(port, ®en_changed).await;
}
}
if !state.lazy_runner() {
state.engine.reload().await;
}
let held = state.gate.as_ref().is_some_and(|g| g.hold_reload(&paths));
if !held {
let _ = state.reload_tx.send(());
}
let modules = client_module_count(&cache);
let _ = state.ws_tx.send(oj_server::update_progress_frame(
run.batch,
"watch",
0,
modules,
Some(0),
true,
));
if held {
println!(" oj start: rebuilt, reload held for the editor's flush");
} else {
println!(" oj start: rebuilt, reloading");
}
}
}
fn start_script_env(
root: &Path,
command: &str,
mode: &str,
) -> anyhow::Result<Vec<(String, String)>> {
let mut config = oj_config::load(root).unwrap_or_default();
oj_server::plugins::adopt_vite_config_values(&mut config, root, command, mode)
.map_err(|e| anyhow::anyhow!(e))?;
let env_dir = match config.env_dir.as_deref() {
Some(d) => root.join(d),
None => root.to_path_buf(),
};
let prefixes = oj_config::env_prefixes(&config);
let mut vars: Vec<(String, String)> = oj_env::load(&env_dir, mode)
.into_iter()
.filter(|(k, _)| {
prefixes.iter().any(|p| k.starts_with(p.as_str())) && std::env::var_os(k).is_none()
})
.collect();
if prefixes != ["VITE_"] {
vars.push((
"OJ_ENV_PREFIX".into(),
serde_json::to_string(&prefixes).unwrap_or_default(),
));
}
for (env_name, var) in [("client", "OJ_DEFINE_CLIENT"), ("ssr", "OJ_DEFINE_SSR")] {
let defines: serde_json::Map<String, serde_json::Value> =
oj_config::environment_defines(&config, env_name)
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
if !defines.is_empty() {
vars.push((var.into(), serde_json::Value::Object(defines).to_string()));
}
}
let jsx = oj_config::jsx_settings(&config);
if jsx != oj_config::JsxSettings::default() {
vars.push((
"OJ_JSX".into(),
serde_json::to_string(&jsx).unwrap_or_default(),
));
}
let defines: serde_json::Map<String, serde_json::Value> = oj_config::config_defines(&config)
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
if !defines.is_empty() {
vars.push((
"OJ_DEFINE".into(),
serde_json::Value::Object(defines).to_string(),
));
}
let dev = command != "build";
let map_dev = |list: Vec<String>| -> Vec<String> {
let dev_prod = if dev { "development" } else { "production" };
list.into_iter()
.map(|c| {
if c == "development|production" {
dev_prod.to_string()
} else {
c
}
})
.collect()
};
let runner_backed = oj_config::ssr_runner_backed(&config);
let resolve_conditions = if runner_backed {
Some(oj_config::node_server_conditions(&config, dev))
} else {
oj_config::user_resolve_conditions(&config, "ssr").map(&map_dev)
};
if let Some(conditions) = resolve_conditions.filter(|c| !c.is_empty()) {
vars.push((
"OJ_RESOLVE_CONDITIONS".into(),
serde_json::to_string(&conditions).unwrap_or_default(),
));
}
let external_conditions = if runner_backed {
Some(oj_config::node_server_external_conditions(&config, dev))
} else {
oj_config::user_external_conditions(&config, "ssr").map(&map_dev)
};
if let Some(conditions) = external_conditions.filter(|c| !c.is_empty()) {
vars.push((
"OJ_EXTERNAL_CONDITIONS".into(),
serde_json::to_string(&conditions).unwrap_or_default(),
));
}
let externals = oj_config::ssr_externals(&config);
if externals != oj_config::SsrExternals::default() {
vars.push((
"OJ_SSR_EXTERNALS".into(),
serde_json::to_string(&externals).unwrap_or_default(),
));
}
vars.push((
"OJ_CLIENT_CONDITIONS".into(),
serde_json::to_string(&oj_config::resolve_conditions_for(
&config,
"client",
command != "build",
))
.unwrap_or_default(),
));
if let Some(entry) = configured_start_server_entry(&config, root) {
vars.push((
"OJ_START_SERVER_ENTRY".into(),
entry.to_string_lossy().into_owned(),
));
}
match oj_cache::start_bundle::vendored_rolldown() {
oj_cache::start_bundle::VendoredRolldown::None => {}
oj_cache::start_bundle::VendoredRolldown::Broken { path, reason } => {
let hint = if reason.contains("UTF-8") {
"unset it or re-export a valid path"
} else {
"repair the vendor dir, or set OJ_VENDORED_ROLLDOWN= (empty) to use the app's rolldown"
};
anyhow::bail!("OJ_VENDORED_ROLLDOWN ({path}) is unusable: {reason}; {hint}");
}
oj_cache::start_bundle::VendoredRolldown::Resolved { path, .. } => {
vars.push(("OJ_VENDORED_ROLLDOWN".into(), path.clone()));
}
}
Ok(vars)
}
fn configured_start_server_entry(config: &oj_config::OjConfig, root: &Path) -> Option<PathBuf> {
let target = oj_config::resolve_alias(config, "ssr")
.into_iter()
.find(|(find, _)| find == "virtual:tanstack-start-server-entry")
.map(|(_, replacement)| replacement)?;
let path = PathBuf::from(target.split('?').next().unwrap_or(&target));
let path = if path.is_absolute() {
path
} else {
root.join(path)
};
let real = path.canonicalize().ok()?;
let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
if !real.starts_with(&root)
|| real.components().any(|c| c.as_os_str() == "node_modules")
|| !real.is_file()
{
return None;
}
Some(real)
}
pub async fn start_build(root: PathBuf, mode: &str, out: Option<PathBuf>) -> anyhow::Result<()> {
let root = root
.canonicalize()
.map_err(|e| anyhow::anyhow!("app root not found: {}: {e}", root.display()))?;
let cache = oj_cache::cache_root(&root).join("start");
oj_server::prepare_cache_root(&root);
oj_server::write_start_assets(&cache)?;
let shell_node_env = std::env::var("NODE_ENV").ok().filter(|v| !v.is_empty());
let scripts = Arc::new(ScriptEngine::new(&root)?);
{
let (r, c) = (root.clone(), cache.clone());
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
generate_route_tree(&r, &c, "development")?;
generate_server_fn_resolver(&r, &c, "development")
})
.await??;
}
let mut config = oj_config::load_with(&root, "build", mode).unwrap_or_default();
oj_server::plugins::adopt_vite_config_values(&mut config, &root, "build", mode)
.map_err(|e| anyhow::anyhow!(e))?;
let build_cfg = config.build.clone().unwrap_or_default();
let prerender = build_cfg.prerender.clone().unwrap_or_default().join(",");
let out = out
.or_else(|| build_cfg.out_dir.as_ref().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("dist"));
let out_dir = if out.is_absolute() {
out
} else {
root.join(out)
};
if out_dir.canonicalize().ok().as_deref() == Some(root.as_path()) {
anyhow::bail!(
"build.outDir {} is the project root; refusing to empty it. Point outDir at a subdirectory.",
out_dir.display()
);
}
let base = match config.base.as_deref().unwrap_or("/") {
"" | "./" => "./".to_string(),
"/" => "/".to_string(),
b => format!("/{}/", b.trim_matches('/')),
};
let sourcemap = match oj_config::build_sourcemap(&config) {
oj_config::Sourcemap::Off => "false",
oj_config::Sourcemap::File => "true",
oj_config::Sourcemap::Inline => "inline",
oj_config::Sourcemap::Hidden => "hidden",
};
let minify = oj_config::build_minify(&config);
let env_dir = match config.env_dir.as_deref() {
Some(d) => root.join(d),
None => root.to_path_buf(),
};
let node_env = oj_env::resolve_node_env(
shell_node_env.as_deref(),
&oj_env::load(&env_dir, mode),
"production",
);
let mut env = vec![
(
"OJ_APP_ROOT".to_string(),
root.to_string_lossy().into_owned(),
),
(
"OJ_CACHE_ROOT".to_string(),
oj_cache::cache_root(&root).to_string_lossy().into_owned(),
),
("NODE_ENV".to_string(), node_env.clone()),
("OJ_MODE".to_string(), mode.to_string()),
("OJ_PRERENDER".to_string(), prerender),
(
"OJ_OUT_DIR".to_string(),
out_dir.to_string_lossy().into_owned(),
),
("OJ_BASE".to_string(), base),
("OJ_SOURCEMAP".to_string(), sourcemap.to_string()),
(
"OJ_MINIFY".to_string(),
if minify { "true" } else { "false" }.to_string(),
),
];
env.extend(start_script_env(&root, "build", mode)?);
scripts
.run_async(&cache.join("build.mjs"), &env, "production build")
.await?;
println!(
" {} build (tanstack start) -> {}",
oj_server::oj_brand(),
out_dir.display()
);
if out_dir.join("server.mjs").exists() {
println!(" run: node {}", out_dir.join("server.mjs").display());
}
Ok(())
}
fn codegen_store(
root: &Path,
cache: &Path,
kind: &str,
script: &str,
marker: Option<&str>,
) -> oj_cache::start_codegen::StartCodegenStore {
let mut extra = std::fs::read(cache.join(script)).unwrap_or_default();
for name in [
"tsr.config.json",
"package.json",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"bun.lockb",
] {
if let Ok(bytes) = std::fs::read(root.join(name)) {
extra.extend_from_slice(name.as_bytes());
extra.push(0);
extra.extend_from_slice(&bytes);
extra.push(0);
}
}
oj_cache::start_codegen::StartCodegenStore::new(
root,
kind,
env!("CARGO_PKG_VERSION"),
&extra,
marker,
)
}
fn generate_route_tree(root: &Path, cache: &Path, mode: &str) -> anyhow::Result<()> {
let store = codegen_store(root, cache, "route-tree", "generate.mjs", None);
let dest = root.join("src").join("routeTree.gen.ts");
let outputs = [("routeTree.gen.ts", dest.as_path())];
let inputs: Vec<PathBuf> = list_route_files(root).into_iter().collect();
match store.restore(&inputs, &outputs) {
Ok(stats) => {
println!(
" oj start: route tree restored from cache (key {}…, {} route file(s) verified in {}ms)",
stats.key.get(..8).unwrap_or(&stats.key),
stats.keyed_files,
stats.elapsed_ms
);
return Ok(());
}
Err(miss) => println!(" oj start: route tree cache miss ({miss})"),
}
run_script_process(
root,
&cache.join("generate.mjs"),
&script_env(root, "serve", mode)?,
"route tree generation",
)?;
let inputs: Vec<PathBuf> = list_route_files(root).into_iter().collect();
store.persist(&inputs, &outputs);
Ok(())
}
fn generate_server_fn_resolver(root: &Path, cache: &Path, mode: &str) -> anyhow::Result<()> {
let store = codegen_store(
root,
cache,
"server-fn",
"gen-resolver.mjs",
Some("createServerFn"),
);
let dest = cache.join("server-fn-resolver.mjs");
let outputs = [("server-fn-resolver.mjs", dest.as_path())];
let inputs = list_src_ts_files(root);
match store.restore(&inputs, &outputs) {
Ok(stats) => {
println!(
" oj start: server-fn resolver restored from cache (key {}…, {} server-fn file(s), {} of {} rehashed, {}ms)",
stats.key.get(..8).unwrap_or(&stats.key),
stats.keyed_files,
stats.rehashed,
inputs.len(),
stats.elapsed_ms
);
return Ok(());
}
Err(miss) => println!(" oj start: server-fn resolver cache miss ({miss})"),
}
run_script_process(
root,
&cache.join("gen-resolver.mjs"),
&script_env(root, "serve", mode)?,
"server-fn resolver",
)?;
store.persist(&inputs, &outputs);
Ok(())
}
fn list_src_ts_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "ts" || x == "tsx") {
out.push(p);
}
}
}
walk(&root.join("src"), &mut out);
out
}
fn run_script_process(
root: &Path,
script: &Path,
env: &[(String, String)],
what: &str,
) -> anyhow::Result<()> {
let exe = std::env::current_exe().map_err(|e| anyhow::anyhow!("oj executable path: {e}"))?;
let mut child = std::process::Command::new(exe)
.arg("start-script")
.env("OJ_PARENT_PID", std::process::id().to_string())
.arg(script)
.arg("--root")
.arg(root)
.current_dir(root)
.env(
oj_server::plugins::PARENT_PID_ENV,
std::process::id().to_string(),
)
.stdin(std::process::Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("could not run {what}: {e}"))?;
{
use std::io::Write;
let mut stdin = child.stdin.take().expect("piped stdin");
stdin
.write_all(serde_json::to_string(env)?.as_bytes())
.map_err(|e| anyhow::anyhow!("could not send {what} env: {e}"))?;
}
let status = child
.wait()
.map_err(|e| anyhow::anyhow!("could not wait for {what}: {e}"))?;
if !status.success() {
anyhow::bail!("{what} failed");
}
Ok(())
}
fn bundle_client_entry(root: &Path, cache: &Path, mode: &str) -> anyhow::Result<()> {
run_script_process(
root,
&cache.join("bundle-client.mjs"),
&script_env(root, "serve", mode)?,
"client entry bundling",
)
}
fn start_bundle_store(root: &Path, mode: &str) -> oj_cache::start_bundle::StartBundleStore {
oj_cache::start_bundle::StartBundleStore::for_mode(
root,
env!("CARGO_PKG_VERSION"),
oj_cache::integrity::VerifyMode::from_env(),
mode,
)
}
fn bundle_client_entry_cached(
root: &Path,
cache: &Path,
mode: &str,
) -> anyhow::Result<oj_cache::start_bundle::PinnedBundle> {
let store = start_bundle_store(root, mode);
match store.restore(cache) {
Ok((stats, pinned)) => {
let rehashed = match stats.rehashed {
0 => String::new(),
n => format!(", {n} re-hashed"),
};
println!(
" oj start: client bundle restored from cache (key {}…, {} chunks, {} files verified in {}ms{rehashed})",
stats.key.get(..8).unwrap_or(&stats.key),
stats.chunks,
stats.files,
stats.elapsed_ms
);
return Ok(pinned);
}
Err(miss) => println!(" oj start: client bundle cache miss ({miss})"),
}
bundle_client_entry(root, cache, mode)?;
if let Some((key, pinned)) = store.persist(cache) {
println!(
" oj start: client bundle cached (key {}…, {} chunks)",
key.get(..8).unwrap_or(&key),
pinned.len()
);
return Ok(pinned);
}
oj_cache::start_bundle::PinnedBundle::from_build_dir(cache)
.ok_or_else(|| anyhow::anyhow!("client chunk index missing after successful bundle"))
}
fn client_module_count(cache: &Path) -> usize {
std::fs::read_to_string(cache.join("client-entry.modules"))
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}
fn dev_node_env(root: &Path, mode: &str) -> String {
oj_env::resolve_node_env(
std::env::var("NODE_ENV").ok().as_deref(),
&oj_env::load(root, mode),
"development",
)
}
fn script_env(root: &Path, command: &str, mode: &str) -> anyhow::Result<Vec<(String, String)>> {
let mut env = vec![
(
"OJ_APP_ROOT".to_string(),
root.to_string_lossy().into_owned(),
),
(
"OJ_CACHE_ROOT".to_string(),
oj_cache::cache_root(root).to_string_lossy().into_owned(),
),
("NODE_ENV".to_string(), dev_node_env(root, mode)),
("OJ_MODE".to_string(), mode.to_string()),
];
env.extend(start_script_env(root, command, mode)?);
Ok(env)
}
fn app_uses_tailwind(root: &Path) -> bool {
std::fs::read_to_string(root.join("package.json"))
.map(|s| {
s.contains("@tailwindcss/postcss")
|| s.contains("@tailwindcss/vite")
|| s.contains("\"tailwindcss\"")
})
.unwrap_or(false)
}
fn needs_css_compile(src: &str) -> bool {
src.contains("tailwindcss")
|| src.contains("@tailwind")
|| src.contains("@plugin")
|| src.contains("@apply")
}
async fn compile_css(
host: &Arc<oj_server::css_engine::CssEngine>,
source: &str,
path: &Path,
) -> Option<String> {
host.compile_path(source, path, serde_json::Value::Null, true)
.await
.ok()
}
#[derive(Debug, PartialEq)]
enum Route {
Document,
StaticOrDocument,
Api,
Pass,
}
fn classify(req: &Request, proxy_prefixes: &[String]) -> Route {
let path = req.uri().path();
if path.starts_with("/@") || path.starts_with("/__") {
return Route::Pass;
}
if proxy_prefixes.iter().any(|p| path.starts_with(p.as_str())) {
return Route::Pass;
}
let last = path.rsplit('/').next().unwrap_or("");
if last != "index.html" && last.contains('.') {
return if *req.method() == Method::GET {
Route::StaticOrDocument
} else {
Route::Api
};
}
if proxy_prefixes.iter().any(|p| path.starts_with(p.as_str())) {
return Route::Pass;
}
match *req.method() {
Method::GET => Route::Document,
_ => Route::Api,
}
}
fn document_url(url: &str) -> String {
let (path, query) = match url.split_once('?') {
Some((p, q)) => (p, format!("?{q}")),
None => (url, String::new()),
};
let path = match path.strip_suffix("/index.html") {
Some(prefix) => format!("{prefix}/"),
None => path.to_string(),
};
format!("{path}{query}")
}
async fn start_route(State(state): State<Arc<StartState>>, req: Request, next: Next) -> Response {
if req.uri().path() == "/@oj-start/hmr" {
let (mut parts, _) = req.into_parts();
return match WebSocketUpgrade::from_request_parts(&mut parts, &()).await {
Ok(ws) => {
let rx = state.reload_tx.subscribe();
ws.protocols(["oj-hmr"])
.on_upgrade(move |socket| start_hmr_socket(socket, rx))
}
Err(e) => e.into_response(),
};
}
if req.uri().path() == "/@oj-start/live-reload.js" {
return serve_js(&state.live_reload, "live-reload client").await;
}
if let Some(rest) = req.uri().path().strip_prefix("/@oj-start/fs/") {
return serve_fs_asset(&state, &format!("/{rest}")).await;
}
if let Some(name) = req.uri().path().strip_prefix("/@oj-start/") {
return serve_client_chunk(&state, name).await;
}
if req.uri().path().starts_with("/_serverFn/") {
return forward_with_body(&state, req).await;
}
if oj_server::is_websocket_upgrade(req.headers()) {
return next.run(req).await;
}
match classify(&req, &state.proxy_prefixes) {
Route::Document => {
let raw = path_and_query(&req);
forward_document(&state, raw, req.headers()).await
}
Route::StaticOrDocument => {
let raw = path_and_query(&req);
let headers = req.headers().clone();
let resp = next.run(req).await;
if resp.status() != StatusCode::NOT_FOUND {
return resp;
}
forward_document(&state, raw, &headers).await
}
Route::Api => forward_with_body(&state, req).await,
Route::Pass => next.run(req).await,
}
}
async fn start_hmr_socket(mut socket: WebSocket, mut rx: broadcast::Receiver<()>) {
loop {
tokio::select! {
reload = rx.recv() => match reload {
Err(broadcast::error::RecvError::Closed) => break,
Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {
if socket.send(Message::Text("reload".into())).await.is_err() {
break;
}
}
},
incoming = socket.recv() => match incoming {
None | Some(Err(_)) => break,
Some(Ok(Message::Close(_))) => {
let _ = socket.close().await;
break;
}
Some(Ok(_)) => {}
},
}
}
}
fn path_and_query(req: &Request) -> String {
req.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string()
}
async fn forward_document(
state: &Arc<StartState>,
raw: String,
headers: &header::HeaderMap,
) -> Response {
if let Some(port) = state.plugin_serve.mw_port() {
if let Some(resp) = oj_server::forward_get_to_plugin_mw(port, &raw, headers).await {
return inject_reload_client(resp);
}
}
ensure_runner_fresh(state).await;
forward(
&state.engine,
"GET".into(),
document_url(&raw),
headers,
None,
)
.await
}
fn start_max_body_bytes() -> usize {
static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
*CAP.get_or_init(|| {
std::env::var("OJ_START_MAX_BODY")
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(128 * 1024 * 1024)
})
}
async fn read_body_capped(body: axum::body::Body, cap: usize) -> Result<Option<Vec<u8>>, Response> {
use tokio_stream::StreamExt;
let mut stream = body.into_data_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(frame) = stream.next().await {
match frame {
Ok(bytes) => {
if buf.len().saturating_add(bytes.len()) > cap {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
format!(
"oj start: request body exceeds the {cap}-byte buffer cap; raise OJ_START_MAX_BODY to accept larger bodies"
),
)
.into_response());
}
buf.extend_from_slice(&bytes);
}
Err(e) => {
return Err((
StatusCode::BAD_REQUEST,
format!("oj start: request body read failed: {e}"),
)
.into_response())
}
}
}
Ok(if buf.is_empty() { None } else { Some(buf) })
}
async fn forward_with_body(state: &Arc<StartState>, req: Request) -> Response {
if state.lazy_runner() && state.runner_dirty.load(std::sync::atomic::Ordering::SeqCst) {
let st = Arc::clone(state);
tokio::spawn(async move { ensure_runner_fresh(&st).await });
}
let method = req.method().to_string();
let url = req
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/")
.to_string();
let mut headers = req.headers().clone();
let Some(port) = state.plugin_serve.mw_port() else {
let body = match read_body_capped(req.into_body(), start_max_body_bytes()).await {
Ok(body) => body,
Err(resp) => return resp,
};
return forward(&state.engine, method, url, &headers, body).await;
};
if let Ok(v) = header::HeaderValue::from_str(&state.loopback_port.to_string()) {
headers.insert("x-oj-forward-to", v);
}
match oj_server::proxy_to_loopback_streaming(
port,
&method,
&url,
&headers,
Some(req.into_body()),
)
.await
{
Ok(resp) if resp.headers().contains_key("x-oj-fallthrough") => (
StatusCode::INTERNAL_SERVER_ERROR,
"oj start: request fell through the plugin middleware without reaching the app",
)
.into_response(),
Ok(resp) => inject_reload_client(resp),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("oj start: {e}")).into_response(),
}
}
async fn serve_client_chunk(state: &StartState, name: &str) -> Response {
let name = percent_decode(name);
let bundle = state
.bundle
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let Some(chunk) = bundle.chunk(&name) else {
return (
StatusCode::NOT_FOUND,
format!("oj start: {name} is not in the client bundle manifest"),
)
.into_response();
};
let mode = match &chunk.hash {
Some(_) => state.verify,
None => oj_cache::integrity::VerifyMode::Standard,
};
let expected = oj_cache::integrity::ExpectedFile {
size: chunk.size,
hash: chunk.hash.clone().unwrap_or_default(),
};
let path = chunk.path.clone();
let read = tokio::task::spawn_blocking(move || {
oj_cache::integrity::verified_read(&path, &expected, mode)
})
.await;
match read {
Ok(Ok(bytes)) => {
let ext = name.rsplit('.').next().unwrap_or("");
(
[
(header::CONTENT_TYPE, asset_mime(ext)),
(header::CACHE_CONTROL, "no-cache"),
],
bytes,
)
.into_response()
}
Ok(Err(e)) => {
eprintln!("oj start: chunk {name} failed verification ({e}); refusing to serve");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("oj start: chunk {name}: {e}"),
)
.into_response()
}
Err(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"oj start: chunk read task failed".to_string(),
)
.into_response(),
}
}
async fn serve_js(path: &Path, what: &str) -> Response {
match tokio::fs::read(path).await {
Ok(bytes) => (
[
(header::CONTENT_TYPE, "text/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
bytes,
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("oj start: {what}: {e}"),
)
.into_response(),
}
}
async fn serve_fs_asset(state: &StartState, abs: &str) -> Response {
let decoded = percent_decode(abs);
let path = PathBuf::from(&decoded);
let canon = match tokio::fs::canonicalize(&path).await {
Ok(c) => c,
Err(e) => return (StatusCode::NOT_FOUND, format!("oj start: asset: {e}")).into_response(),
};
if !canon.starts_with(&state.workspace_root) {
return (StatusCode::FORBIDDEN, "oj start: asset outside workspace").into_response();
}
if canon.extension().and_then(|e| e.to_str()) == Some("css") {
if let Some(host) = &state.css_host {
if let Ok(src) = tokio::fs::read_to_string(&canon).await {
if needs_css_compile(&src) {
if let Some(css) = compile_css(host, &src, &canon).await {
return (
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
css,
)
.into_response();
}
}
}
}
}
match tokio::fs::read(&canon).await {
Ok(bytes) => {
let ext = canon.extension().and_then(|e| e.to_str()).unwrap_or("");
(
[
(header::CONTENT_TYPE, asset_mime(ext)),
(header::CACHE_CONTROL, "no-cache"),
],
bytes,
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("oj start: asset: {e}"),
)
.into_response(),
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push(h << 4 | l);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn engine_request(
method: String,
url: String,
req_headers: &header::HeaderMap,
body: Option<Vec<u8>>,
from_loopback: bool,
) -> StartRequest {
let first_value = |name: &str| {
req_headers
.get(name)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let host = from_loopback
.then(|| first_value("x-oj-host"))
.flatten()
.or_else(|| first_value("host"))
.unwrap_or_else(|| "localhost".to_string());
let headers = req_headers
.iter()
.filter(|(name, _)| {
let n = name.as_str();
n != "host" && n != "x-oj-host"
})
.filter_map(|(name, value)| {
Some((name.as_str().to_string(), value.to_str().ok()?.to_string()))
})
.collect();
StartRequest {
method,
url,
host,
headers,
body,
}
}
fn engine_response(resp: StartResponse) -> Response {
let mut out = Response::new(axum::body::Body::from(resp.body));
*out.status_mut() =
StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let headers = out.headers_mut();
for (name, value) in resp.headers {
if let (Ok(n), Ok(v)) = (
header::HeaderName::from_bytes(name.as_bytes()),
header::HeaderValue::from_str(&value),
) {
headers.append(n, v);
}
}
for cookie in resp.set_cookies {
if let Ok(v) = header::HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, v);
}
}
out
}
async fn forward(
engine: &StartEngine,
method: String,
url: String,
req_headers: &header::HeaderMap,
body: Option<Vec<u8>>,
) -> Response {
match engine
.handle(engine_request(method, url, req_headers, body, false))
.await
{
Ok(resp) => inject_reload_client(engine_response(resp)),
Err(e) => {
let esc = |s: &str| {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
};
(
StatusCode::INTERNAL_SERVER_ERROR,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
format!(
"<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"UTF-8\" /><title>Error</title></head>\n<body><h1>Internal Server Error</h1><pre>{}</pre></body></html>\n",
esc(&e)
),
)
.into_response()
}
}
}
async fn spawn_engine_loopback(engine: Arc<StartEngine>) -> anyhow::Result<u16> {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?;
let port = listener.local_addr()?.port();
let app = axum::Router::new()
.fallback(axum::routing::any(
|State(engine): State<Arc<StartEngine>>, req: Request| async move {
let method = req.method().to_string();
let url = path_and_query(&req);
let headers = req.headers().clone();
let body = match read_body_capped(req.into_body(), start_max_body_bytes()).await {
Ok(body) => body,
Err(resp) => return resp,
};
match engine
.handle(engine_request(method, url, &headers, body, true))
.await
{
Ok(resp) => engine_response(resp),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("oj start: {e}"))
.into_response(),
}
},
))
.with_state(engine);
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
Ok(port)
}
fn inject_reload_client(resp: Response) -> Response {
use tokio_stream::StreamExt;
let is_html = resp
.headers()
.get(header::CONTENT_TYPE)
.and_then(|c| c.to_str().ok())
.is_some_and(|c| c.contains("text/html"));
let encoded = resp
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|c| c.to_str().ok())
.is_some_and(|c| !c.eq_ignore_ascii_case("identity"));
if !is_html || encoded {
return resp;
}
let (parts, body) = resp.into_parts();
let tail = tokio_stream::once(Ok::<_, axum::Error>(axum::body::Bytes::from_static(
RELOAD_CLIENT.as_bytes(),
)));
let stream = body.into_data_stream().chain(tail);
Response::from_parts(parts, axum::body::Body::from_stream(stream))
}
type ServeInfoUpdates = tokio::sync::watch::Receiver<Option<oj_server::plugins::ServeInfo>>;
struct PrewarmHold {
updates: ServeInfoUpdates,
host_gone: tokio::sync::watch::Receiver<bool>,
init_failed: tokio::sync::watch::Receiver<bool>,
init_deadline: tokio::time::Instant,
confirm: std::time::Duration,
}
async fn hold_prewarm_for_serve_info(
hold: &mut PrewarmHold,
) -> Option<oj_server::plugins::ServeInfo> {
loop {
if let Some(info) = *hold.updates.borrow_and_update() {
return Some(info);
}
if *hold.host_gone.borrow_and_update() {
return None;
}
if *hold.init_failed.borrow_and_update() {
let confirm = std::cmp::min(
hold.init_deadline,
tokio::time::Instant::now() + hold.confirm,
);
loop {
tokio::select! {
changed = hold.updates.changed() => {
if changed.is_err() {
return None;
}
if let Some(info) = *hold.updates.borrow_and_update() {
return Some(info);
}
}
changed = hold.host_gone.changed() => {
if changed.is_err() || *hold.host_gone.borrow_and_update() {
return None;
}
}
changed = hold.init_failed.changed() => {
if changed.is_err() {
return None;
}
if !*hold.init_failed.borrow_and_update() {
break; }
}
_ = tokio::time::sleep_until(confirm) => {
return None; }
}
}
continue;
}
tokio::select! {
changed = hold.updates.changed() => {
if changed.is_err() {
return None;
}
}
changed = hold.host_gone.changed() => {
if changed.is_err() {
return None;
}
}
changed = hold.init_failed.changed() => {
if changed.is_err() {
return None;
}
}
_ = tokio::time::sleep_until(hold.init_deadline) => {
return None;
}
}
}
}
#[cfg(test)]
fn collect_headers(headers: &header::HeaderMap) -> Vec<(String, String)> {
headers
.iter()
.filter_map(|(k, v)| Some((k.as_str().to_owned(), v.to_str().ok()?.to_owned())))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::{connect_async, tungstenite::Message as ClientMessage};
struct HmrTestServer {
url: String,
reload_tx: broadcast::Sender<()>,
task: tokio::task::JoinHandle<()>,
}
impl Drop for HmrTestServer {
fn drop(&mut self) {
self.task.abort();
}
}
impl HmrTestServer {
async fn start() -> Self {
Self::start_with_gate(None).await
}
async fn start_with_gate(gate: Option<Arc<tokio::sync::Notify>>) -> Self {
let (reload_tx, _) = broadcast::channel(16);
let app = axum::Router::new()
.route(
"/@oj-start/hmr",
axum::routing::get(
|ws: WebSocketUpgrade,
State((tx, gate)): State<(
broadcast::Sender<()>,
Option<Arc<tokio::sync::Notify>>,
)>| async move {
let rx = tx.subscribe();
ws.on_upgrade(move |socket| async move {
if let Some(gate) = gate {
gate.notified().await;
}
start_hmr_socket(socket, rx).await;
})
},
),
)
.with_state((reload_tx.clone(), gate));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}/@oj-start/hmr", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Self {
url,
reload_tx,
task,
}
}
async fn wait_for_no_subscribers(&self) {
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while self.reload_tx.receiver_count() != 0 {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
})
.await
.expect("disconnected HMR client retained its reload subscription");
}
}
#[tokio::test]
async fn start_hmr_releases_closed_clients_without_a_reload() {
let server = HmrTestServer::start().await;
for _ in 0..5 {
let (mut client, _) = connect_async(&server.url).await.unwrap();
assert_eq!(server.reload_tx.receiver_count(), 1);
client.close(None).await.unwrap();
server.wait_for_no_subscribers().await;
let response = tokio::time::timeout(std::time::Duration::from_secs(2), client.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert!(matches!(response, ClientMessage::Close(_)));
}
}
#[tokio::test]
async fn start_hmr_completes_close_handshakes_while_reloads_arrive() {
use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame};
let gate = Arc::new(tokio::sync::Notify::new());
let server = HmrTestServer::start_with_gate(Some(gate.clone())).await;
let close = CloseFrame {
code: CloseCode::Normal,
reason: "leaving the preview".into(),
};
for attempt in 0..64 {
let (mut client, _) = connect_async(&server.url).await.unwrap();
client.close(Some(close.clone())).await.unwrap();
assert_eq!(server.reload_tx.send(()).unwrap(), 1);
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
gate.notify_one();
let reply = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
match client.next().await {
Some(Ok(ClientMessage::Close(frame))) => break frame,
Some(Ok(ClientMessage::Text(text))) => assert_eq!(text, "reload"),
other => panic!("close handshake failed on attempt {attempt}: {other:?}"),
}
}
})
.await
.expect("close handshake did not finish while a reload was pending");
assert_eq!(reply, Some(close.clone()));
server.wait_for_no_subscribers().await;
}
}
#[tokio::test]
async fn start_hmr_releases_dropped_clients_without_a_reload() {
let server = HmrTestServer::start().await;
for _ in 0..5 {
let (client, _) = connect_async(&server.url).await.unwrap();
assert_eq!(server.reload_tx.receiver_count(), 1);
drop(client);
server.wait_for_no_subscribers().await;
}
}
#[tokio::test]
async fn start_hmr_reloads_lagged_clients_instead_of_dropping_them() {
let gate = Arc::new(tokio::sync::Notify::new());
let server = HmrTestServer::start_with_gate(Some(gate.clone())).await;
let (mut client, _) = connect_async(&server.url).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while server.reload_tx.receiver_count() != 1 {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
})
.await
.expect("client subscription never registered");
for _ in 0..32 {
assert_eq!(server.reload_tx.send(()).unwrap(), 1);
}
gate.notify_one();
let first = tokio::time::timeout(std::time::Duration::from_secs(2), client.next())
.await
.expect("lagged client received nothing")
.unwrap()
.unwrap();
assert_eq!(
first,
ClientMessage::Text("reload".into()),
"a lagged client must be reloaded, not dropped"
);
client.close(None).await.unwrap();
server.wait_for_no_subscribers().await;
}
#[tokio::test]
async fn start_hmr_keeps_live_clients_subscribed_after_incoming_messages() {
let server = HmrTestServer::start().await;
let (mut client, _) = connect_async(&server.url).await.unwrap();
client
.send(ClientMessage::Text("ignored".into()))
.await
.unwrap();
client
.send(ClientMessage::Ping(vec![1, 2, 3].into()))
.await
.unwrap();
let pong = tokio::time::timeout(std::time::Duration::from_secs(2), client.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(pong, ClientMessage::Pong(vec![1, 2, 3].into()));
for _ in 0..2 {
assert_eq!(server.reload_tx.send(()).unwrap(), 1);
let reload = tokio::time::timeout(std::time::Duration::from_secs(2), client.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(reload, ClientMessage::Text("reload".into()));
}
client.close(None).await.unwrap();
server.wait_for_no_subscribers().await;
}
fn tmp(label: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("oj-startdev-{}-{label}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn req(method: &str, path: &str) -> Request {
axum::http::Request::builder()
.method(method)
.uri(path)
.body(axum::body::Body::empty())
.unwrap()
}
fn prewarm_channels() -> (
tokio::sync::watch::Sender<Option<oj_server::plugins::ServeInfo>>,
tokio::sync::watch::Sender<bool>,
tokio::sync::watch::Sender<bool>,
PrewarmHold,
) {
let (info_tx, updates) = tokio::sync::watch::channel(None);
let (gone_tx, host_gone) = tokio::sync::watch::channel(false);
let (fail_tx, init_failed) = tokio::sync::watch::channel(false);
let hold = PrewarmHold {
updates,
host_gone,
init_failed,
init_deadline: tokio::time::Instant::now() + std::time::Duration::from_secs(300),
confirm: std::time::Duration::from_secs(20),
};
(info_tx, gone_tx, fail_tx, hold)
}
#[tokio::test(start_paused = true)]
async fn prewarm_hold_waits_out_a_healthy_slow_boot_for_the_serve_info() {
let (info_tx, _gone_tx, _fail_tx, mut hold) = prewarm_channels();
let start = tokio::time::Instant::now();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(120)).await;
let _ = info_tx.send(Some(oj_server::plugins::ServeInfo::default()));
});
let known = hold_prewarm_for_serve_info(&mut hold).await;
assert!(known.is_some(), "the arriving serve info decides");
let held = start.elapsed();
assert!(
held >= std::time::Duration::from_secs(120),
"held past the old RPC-scale bound on a healthy slow boot: {held:?}"
);
assert!(
held < std::time::Duration::from_secs(300),
"released by the info, not the deadline: {held:?}"
);
}
#[tokio::test(start_paused = true)]
async fn prewarm_hold_releases_at_the_init_deadline_when_nothing_decides() {
let (_info_tx, _gone_tx, _fail_tx, mut hold) = prewarm_channels();
let start = tokio::time::Instant::now();
let known = hold_prewarm_for_serve_info(&mut hold).await;
assert!(known.is_none(), "not known: the caller prewarms");
assert!(
start.elapsed() >= std::time::Duration::from_secs(300),
"the outer bound is the init deadline, got {:?}",
start.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn prewarm_hold_releases_on_wedge_evidence() {
let (_info_tx, _gone_tx, fail_tx, mut hold) = prewarm_channels();
let start = tokio::time::Instant::now();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(40)).await;
let _ = fail_tx.send(true);
std::mem::forget(fail_tx);
});
assert!(hold_prewarm_for_serve_info(&mut hold).await.is_none());
let held = start.elapsed();
assert!(
held >= std::time::Duration::from_secs(60)
&& held < std::time::Duration::from_secs(300),
"standing evidence released the hold after its confirmation window: {held:?}"
);
let (_info_tx, gone_tx, _fail_tx, mut hold) = prewarm_channels();
let start = tokio::time::Instant::now();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let _ = gone_tx.send(true);
});
assert!(hold_prewarm_for_serve_info(&mut hold).await.is_none());
assert!(
start.elapsed() < std::time::Duration::from_secs(300),
"the host dying released the hold: {:?}",
start.elapsed()
);
}
#[tokio::test(start_paused = true)]
async fn prewarm_hold_re_arms_when_evidence_clears_before_the_confirm_window() {
let (info_tx, _gone_tx, fail_tx, mut hold) = prewarm_channels();
let start = tokio::time::Instant::now();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
let _ = fail_tx.send(true);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = fail_tx.send(false);
tokio::time::sleep(std::time::Duration::from_secs(85)).await;
let _ = info_tx.send(Some(oj_server::plugins::ServeInfo::default()));
std::mem::forget(fail_tx);
std::mem::forget(info_tx);
});
let known = hold_prewarm_for_serve_info(&mut hold).await;
assert!(
known.is_some(),
"the cleared evidence re-armed the hold; the info decided"
);
let held = start.elapsed();
assert!(
held >= std::time::Duration::from_secs(120),
"held through the false positive to the serve info: {held:?}"
);
assert!(
held < std::time::Duration::from_secs(300),
"released by the info, not the deadline: {held:?}"
);
}
#[test]
fn changed_regen_outputs_detects_rewrites_and_creations() {
let dir = tmp("regen-outputs");
let tree = dir.join("routeTree.gen.ts");
let resolver = dir.join("server-fn-resolver.mjs");
let missing = dir.join("never-written.mjs");
std::fs::write(&tree, "tree v1").unwrap();
let files = [tree.clone(), resolver.clone(), missing.clone()];
let mut seen: std::collections::HashMap<PathBuf, Option<u64>> = files
.iter()
.map(|p| (p.clone(), file_content_hash(p)))
.collect();
assert!(changed_regen_outputs(&files, &mut seen).is_empty());
std::fs::write(&tree, "tree v2").unwrap();
std::fs::write(&resolver, "resolver v1").unwrap();
let changed = changed_regen_outputs(&files, &mut seen);
let paths: Vec<&str> = changed.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![tree.to_str().unwrap(), resolver.to_str().unwrap()]
);
assert!(changed.iter().all(|(_, t)| *t == "update"));
std::fs::write(&tree, "tree v2").unwrap();
assert!(changed_regen_outputs(&files, &mut seen).is_empty());
std::fs::write(&tree, "tree v3").unwrap();
let changed = changed_regen_outputs(&files, &mut seen);
assert_eq!(changed.len(), 1);
assert!(changed[0].0.ends_with("routeTree.gen.ts"));
}
#[test]
fn start_script_env_honors_env_prefix_and_environment_defines() {
let root = tmp("script-env");
std::fs::write(
root.join("oj.config.json"),
r#"{
"envPrefix": ["VITE_", "APP_"],
"resolve": { "conditions": ["custom", "development|production"] },
"define": { "__SHARED__": "1" },
"environments": {
"client": { "define": { "__SIDE__": "\"client\"" } },
"ssr": { "define": { "__SIDE__": "\"server\"", "__ONLY_SSR__": "true" } }
}
}"#,
)
.unwrap();
std::fs::write(root.join(".env.staging"), "VITE_X=1\nAPP_Y=2\nSECRET_Z=3\n").unwrap();
let vars = start_script_env(&root, "serve", "staging").unwrap();
let get = |k: &str| vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
assert_eq!(get("VITE_X").as_deref(), Some("1"));
assert_eq!(get("APP_Y").as_deref(), Some("2"));
assert_eq!(get("SECRET_Z"), None);
assert_eq!(get("OJ_ENV_PREFIX").as_deref(), Some(r#"["VITE_","APP_"]"#));
assert_eq!(get("OJ_DEFINE").as_deref(), Some(r#"{"__SHARED__":"1"}"#));
assert_eq!(
get("OJ_DEFINE_CLIENT").as_deref(),
Some(r#"{"__SIDE__":"\"client\""}"#)
);
assert_eq!(
get("OJ_RESOLVE_CONDITIONS").as_deref(),
Some(r#"["custom","development"]"#)
);
{
let runner_backed = tmp("script-env-runner-backed");
std::fs::write(
runner_backed.join("oj.config.json"),
r#"{ "ssr": { "runnerBacked": true, "resolve": { "conditions": ["workerd", "worker", "module", "browser", "development|production"], "externalConditions": ["workerd", "development|production"] } } }"#,
)
.unwrap();
let vars = start_script_env(&runner_backed, "serve", "development").unwrap();
let var = |k: &str| vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
assert_eq!(
var("OJ_RESOLVE_CONDITIONS").as_deref(),
Some(r#"["module","node","development","import","default"]"#),
"a runner-backed environment's workerd list must not steer the Node loader; node defaults apply"
);
assert_eq!(
var("OJ_EXTERNAL_CONDITIONS").as_deref(),
Some(r#"["node","module-sync"]"#),
"the workerd externalConditions never cross either; Vite's DEFAULT_EXTERNAL_CONDITIONS apply"
);
let with_raw = tmp("script-env-runner-raw");
std::fs::write(
with_raw.join("oj.config.json"),
r#"{ "ssr": { "runnerBacked": true, "resolve": { "conditions": ["workerd", "module", "browser"] } },
"rawResolve": { "conditions": ["custom", "module"], "externalConditions": ["custom-ext", "development|production"] },
"resolve": { "conditions": ["module", "browser", "development|production"] } }"#,
)
.unwrap();
let vars = start_script_env(&with_raw, "serve", "development").unwrap();
let var = |k: &str| vars.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
assert_eq!(
var("OJ_RESOLVE_CONDITIONS").as_deref(),
Some(r#"["module","node","development","custom","import","default"]"#),
"raw top-level user conditions join the node defaults; the resolved client list never does"
);
assert_eq!(
var("OJ_EXTERNAL_CONDITIONS").as_deref(),
Some(r#"["custom-ext","development"]"#),
"raw top-level externalConditions replace the default, as a user list does in Vite"
);
let plain_browser = tmp("script-env-browser-honored");
std::fs::write(
plain_browser.join("oj.config.json"),
r#"{ "environments": { "ssr": { "resolve": { "conditions": ["browser", "module"] } } } }"#,
)
.unwrap();
let vars = start_script_env(&plain_browser, "serve", "development").unwrap();
let cond = vars
.iter()
.find(|(n, _)| n == "OJ_RESOLVE_CONDITIONS")
.map(|(_, v)| v.clone());
assert_eq!(
cond.as_deref(),
Some(r#"["browser","module"]"#),
"a user browser condition on a non-runner-backed environment is honored verbatim"
);
let cf_shape = tmp("script-env-cf-shape");
std::fs::write(
cf_shape.join("oj.config.json"),
r#"{ "ssr": { "runnerBacked": true, "noExternal": true, "target": "node", "resolve": { "conditions": ["workerd", "worker", "module", "browser", "development|production"] } },
"resolve": { "conditions": ["module", "browser", "development|production"] } }"#,
)
.unwrap();
let vars = start_script_env(&cf_shape, "serve", "development").unwrap();
let cond = vars
.iter()
.find(|(n, _)| n == "OJ_RESOLVE_CONDITIONS")
.map(|(_, v)| v.clone());
assert_eq!(
cond.as_deref(),
Some(r#"["module","node","development","import","default"]"#),
"neither the workerd sugar nor the client top-level list crosses into the Node loader"
);
}
let ssr: serde_json::Value = serde_json::from_str(&get("OJ_DEFINE_SSR").unwrap()).unwrap();
assert_eq!(ssr["__SIDE__"], "\"server\"");
assert_eq!(ssr["__ONLY_SSR__"], "true");
let plain = tmp("script-env-plain");
std::fs::write(plain.join(".env"), "VITE_X=1\nAPP_Y=2\n").unwrap();
let vars = start_script_env(&plain, "serve", "development").unwrap();
let names: Vec<&str> = vars.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"VITE_X"));
assert!(!names.contains(&"APP_Y"));
assert!(!names.contains(&"OJ_ENV_PREFIX"));
assert!(!names.contains(&"OJ_DEFINE_CLIENT"));
assert!(!names.contains(&"OJ_DEFINE_SSR"));
assert!(!names.contains(&"OJ_RESOLVE_CONDITIONS"));
}
#[test]
fn cloudflare_hint_reads_the_config_file() {
let root = tmp("cf-hint");
assert!(!config_mentions_cloudflare_plugin(&root, &None));
std::fs::write(
root.join("vite.config.ts"),
"import { cloudflare } from \"@cloudflare/vite-plugin\";\nexport default {};\n",
)
.unwrap();
assert!(config_mentions_cloudflare_plugin(&root, &None));
std::fs::write(root.join("other.config.ts"), "export default {};\n").unwrap();
assert!(!config_mentions_cloudflare_plugin(
&root,
&Some(root.join("other.config.ts"))
));
}
#[test]
fn reload_client_injection_gates_on_html_and_encoding() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let body_of = |resp: Response| {
rt.block_on(async {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8_lossy(&bytes).into_owned()
})
};
let resp = |ct: &str, enc: Option<&str>| {
let mut b = Response::builder().header(header::CONTENT_TYPE, ct);
if let Some(e) = enc {
b = b.header(header::CONTENT_ENCODING, e);
}
b.body(axum::body::Body::from("<html></html>")).unwrap()
};
assert!(body_of(inject_reload_client(resp("text/html", None))).contains(RELOAD_CLIENT));
assert!(
!body_of(inject_reload_client(resp("text/html", Some("gzip")))).contains(RELOAD_CLIENT)
);
assert!(
body_of(inject_reload_client(resp("text/html", Some("identity"))))
.contains(RELOAD_CLIENT)
);
assert!(
!body_of(inject_reload_client(resp("text/javascript", None))).contains(RELOAD_CLIENT)
);
}
#[test]
fn dev_node_env_follows_env_files_when_shell_is_silent() {
if std::env::var_os("NODE_ENV").is_some_and(|v| !v.is_empty()) {
return;
}
let root = tmp("node-env");
assert_eq!(dev_node_env(&root, "development"), "development");
std::fs::write(root.join(".env.staging"), "NODE_ENV=development\n").unwrap();
assert_eq!(dev_node_env(&root, "staging"), "development");
std::fs::write(root.join(".env.prodlike"), "NODE_ENV=production\n").unwrap();
assert_eq!(dev_node_env(&root, "prodlike"), "development");
}
#[test]
fn start_script_env_carries_the_client_resolve_conditions() {
let root = tmp("client-conds");
std::fs::write(root.join("package.json"), r#"{"name":"app"}"#).unwrap();
let conds = |command: &str| -> Vec<String> {
let vars = start_script_env(&root, command, "development").unwrap();
let raw = &vars
.iter()
.find(|(k, _)| k == "OJ_CLIENT_CONDITIONS")
.expect("var")
.1;
serde_json::from_str(raw).unwrap()
};
let dev = conds("serve");
assert!(
dev.iter().any(|c| c == "browser") && dev.iter().any(|c| c == "development"),
"{dev:?}"
);
let build = conds("build");
assert!(
build.iter().any(|c| c == "production") && !build.iter().any(|c| c == "development"),
"{build:?}"
);
std::fs::write(
root.join("oj.config.json"),
r#"{"environments":{"client":{"resolve":{"conditions":["custom","development|production"]}}}}"#,
)
.unwrap();
let custom = conds("serve");
assert!(custom.iter().any(|c| c == "custom"), "{custom:?}");
assert!(
custom.iter().any(|c| c == "development"),
"placeholder mapped: {custom:?}"
);
assert!(
!custom.iter().any(|c| c == "browser"),
"user list replaces the defaults: {custom:?}"
);
}
#[test]
fn app_uses_tailwind_detects_postcss_vite_and_bare() {
let base = tmp("tw");
let cases = [
(r#"{"devDependencies":{"@tailwindcss/postcss":"4"}}"#, true),
(r#"{"devDependencies":{"@tailwindcss/vite":"4"}}"#, true),
(r#"{"dependencies":{"tailwindcss":"3"}}"#, true),
(r#"{"dependencies":{"react":"19"}}"#, false),
];
for (i, (pkg, expected)) in cases.iter().enumerate() {
let app = base.join(format!("app{i}"));
std::fs::create_dir_all(&app).unwrap();
std::fs::write(app.join("package.json"), pkg).unwrap();
assert_eq!(app_uses_tailwind(&app), *expected, "case {i}: {pkg}");
}
}
#[tokio::test]
async fn body_reads_cap_at_the_knob_and_fail_on_stream_errors() {
let body = axum::body::Body::from(vec![7u8; 1024]);
assert_eq!(
read_body_capped(body, 4096).await.unwrap().unwrap().len(),
1024
);
assert!(read_body_capped(axum::body::Body::empty(), 4096)
.await
.unwrap()
.is_none());
let resp = read_body_capped(axum::body::Body::from(vec![7u8; 5000]), 4096)
.await
.expect_err("an oversized body is refused");
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
assert!(
String::from_utf8_lossy(&bytes).contains("OJ_START_MAX_BODY"),
"the 413 names the knob"
);
let broken = axum::body::Body::from_stream(tokio_stream::iter(vec![
Ok::<_, std::io::Error>(axum::body::Bytes::from_static(b"partial")),
Err(std::io::Error::new(
std::io::ErrorKind::ConnectionReset,
"client went away",
)),
]));
let resp = read_body_capped(broken, 4096)
.await
.expect_err("a read failure is a response, not an empty body");
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn document_url_rewrites_index_html_to_directory() {
assert_eq!(document_url("/"), "/");
assert_eq!(document_url("/index.html"), "/");
assert_eq!(document_url("/guides/index.html"), "/guides/");
assert_eq!(document_url("/a/b/index.html"), "/a/b/");
assert_eq!(document_url("/index.html?tab=1"), "/?tab=1");
assert_eq!(document_url("/a/index.html?x=y&z=1"), "/a/?x=y&z=1");
assert_eq!(document_url("/about"), "/about");
assert_eq!(document_url("/guides/foo"), "/guides/foo");
assert_eq!(document_url("/notindex.html"), "/notindex.html");
}
#[test]
fn percent_decode_handles_escapes_and_partials() {
assert_eq!(percent_decode("/plain/path.css"), "/plain/path.css");
assert_eq!(percent_decode("/a%20b"), "/a b");
assert_eq!(percent_decode("%2Fx"), "/x");
assert_eq!(percent_decode("%41%42"), "AB");
assert_eq!(percent_decode("a%"), "a%");
assert_eq!(percent_decode("a%2"), "a%2");
assert_eq!(percent_decode("a%zz"), "a%zz");
}
#[test]
fn asset_mime_maps_types() {
assert_eq!(asset_mime("css"), "text/css; charset=utf-8");
assert_eq!(asset_mime("js"), "text/javascript");
assert_eq!(asset_mime("svg"), "image/svg+xml");
assert_eq!(asset_mime("webp"), "image/webp");
assert_eq!(asset_mime("woff2"), "font/woff2");
assert_eq!(asset_mime("json"), "application/json");
assert_eq!(asset_mime("weirdext"), "application/octet-stream");
}
#[test]
fn needs_css_compile_detects_tailwind_markers() {
assert!(needs_css_compile("@import \"tailwindcss\";"));
assert!(needs_css_compile("@tailwind base;"));
assert!(needs_css_compile("@plugin \"@tailwindcss/typography\";"));
assert!(needs_css_compile(".btn { @apply px-2; }"));
assert!(!needs_css_compile(".a { color: red }"));
assert!(!needs_css_compile("@font-face { src: url(x.woff2) }"));
}
#[test]
fn workspace_root_finds_farthest_node_modules() {
let base = tmp("ws");
std::fs::create_dir_all(base.join("node_modules")).unwrap();
std::fs::create_dir_all(base.join("web").join("src")).unwrap();
std::fs::create_dir_all(base.join("web").join("node_modules")).unwrap();
assert_eq!(workspace_root(&base.join("web")), base);
}
#[test]
fn workspace_root_defaults_to_app_without_ancestor_modules() {
let base = tmp("ws2");
let app = base.join("solo");
std::fs::create_dir_all(&app).unwrap();
assert_eq!(workspace_root(&app), app);
}
#[test]
fn app_uses_tailwind_reads_package_json() {
let base = tmp("tw-pkg");
std::fs::write(
base.join("package.json"),
r#"{"devDependencies":{"@tailwindcss/postcss":"4"}}"#,
)
.unwrap();
assert!(app_uses_tailwind(&base));
std::fs::write(
base.join("package.json"),
r#"{"dependencies":{"react":"19"}}"#,
)
.unwrap();
assert!(!app_uses_tailwind(&base));
let none = tmp("tw-none");
assert!(!app_uses_tailwind(&none));
}
#[test]
fn classify_documents_vs_passes() {
let no_proxy: Vec<String> = vec![];
assert!(matches!(
classify(&req("GET", "/"), &no_proxy),
Route::Document
));
assert!(matches!(
classify(&req("GET", "/about"), &no_proxy),
Route::Document
));
assert!(matches!(
classify(&req("GET", "/index.html"), &no_proxy),
Route::Document
));
assert!(matches!(
classify(&req("GET", "/guides/index.html"), &no_proxy),
Route::Document
));
assert!(matches!(
classify(&req("GET", "/main.js"), &no_proxy),
Route::StaticOrDocument
));
assert!(matches!(
classify(&req("GET", "/styles.css"), &no_proxy),
Route::StaticOrDocument
));
assert!(matches!(
classify(&req("GET", "/robots.txt"), &no_proxy),
Route::StaticOrDocument
));
assert!(matches!(
classify(&req("GET", "/users/john.doe"), &no_proxy),
Route::StaticOrDocument
));
assert!(matches!(
classify(&req("GET", "/api/v1.2/x"), &["/api".to_string()]),
Route::Pass
));
assert!(matches!(
classify(&req("GET", "/@oj-start/hmr"), &no_proxy),
Route::Pass
));
assert!(matches!(
classify(&req("GET", "/__health"), &no_proxy),
Route::Pass
));
assert!(matches!(
classify(&req("POST", "/api/auth/session-v2"), &no_proxy),
Route::Api
));
assert!(matches!(
classify(&req("PUT", "/api/thing"), &no_proxy),
Route::Api
));
assert!(matches!(
classify(&req("DELETE", "/api/thing"), &no_proxy),
Route::Api
));
assert!(matches!(
classify(&req("POST", "/main.js"), &no_proxy),
Route::Api
));
assert!(matches!(
classify(&req("POST", "/api/export.csv"), &no_proxy),
Route::Api
));
assert!(matches!(
classify(&req("PUT", "/files/a.b"), &no_proxy),
Route::Api
));
let proxy = vec!["/api".to_string()];
assert!(matches!(
classify(&req("GET", "/api/users"), &proxy),
Route::Pass
));
assert!(matches!(
classify(&req("POST", "/api/users"), &proxy),
Route::Pass
));
}
#[test]
fn list_route_files_collects_ts_tsx_recursively() {
let root = tmp("routes");
let routes = root.join("src").join("routes");
std::fs::create_dir_all(routes.join("nested")).unwrap();
std::fs::write(routes.join("index.tsx"), "").unwrap();
std::fs::write(routes.join("about.ts"), "").unwrap();
std::fs::write(routes.join("styles.css"), "").unwrap();
std::fs::write(routes.join("data.json"), "").unwrap();
std::fs::write(routes.join("nested").join("deep.tsx"), "").unwrap();
let found = list_route_files(&root);
let names: Vec<String> = found
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect();
assert_eq!(found.len(), 3, "only ts/tsx counted: {names:?}");
assert!(names.contains(&"index.tsx".to_string()));
assert!(names.contains(&"about.ts".to_string()));
assert!(
names.contains(&"deep.tsx".to_string()),
"recursion into subdirs: {names:?}"
);
assert!(!names
.iter()
.any(|n| n.ends_with(".css") || n.ends_with(".json")));
}
#[test]
fn list_src_ts_files_walks_all_of_src() {
let root = tmp("srcwalk");
let src = root.join("src");
std::fs::create_dir_all(src.join("routes")).unwrap();
std::fs::create_dir_all(src.join("lib")).unwrap();
std::fs::write(src.join("routes").join("index.tsx"), "").unwrap();
std::fs::write(src.join("lib").join("fns.ts"), "").unwrap();
std::fs::write(src.join("lib").join("types.d.ts"), "").unwrap();
std::fs::write(src.join("styles.css"), "").unwrap();
let found = list_src_ts_files(&root);
assert_eq!(found.len(), 3, "ts/tsx (incl. .d.ts) only: {found:?}");
}
#[test]
fn list_route_files_missing_dir_is_empty() {
let root = tmp("noroutes");
assert!(list_route_files(&root).is_empty());
}
#[test]
fn collect_headers_serializes_utf8_and_skips_non_utf8() {
let mut h = header::HeaderMap::new();
h.insert(
"content-type",
header::HeaderValue::from_static("text/html"),
);
h.insert("x-custom", header::HeaderValue::from_static("hello"));
h.insert(
"x-bin",
header::HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(),
);
let out = collect_headers(&h);
assert!(
out.contains(&("content-type".to_string(), "text/html".to_string())),
"{out:?}"
);
assert!(
out.contains(&("x-custom".to_string(), "hello".to_string())),
"{out:?}"
);
assert!(
!out.iter().any(|(k, _)| k == "x-bin"),
"non-utf8 value dropped: {out:?}"
);
}
#[test]
fn a_relative_config_path_resolves_against_the_app_root() {
let root = Path::new("/srv/app");
assert_eq!(
config_path(root, Some(PathBuf::from("build/vite.config.mjs"))),
Some(PathBuf::from("/srv/app/build/vite.config.mjs")),
);
assert_eq!(
config_path(root, Some(PathBuf::from("/out/vite.config.mjs"))),
Some(PathBuf::from("/out/vite.config.mjs")),
);
assert_eq!(config_path(root, None), None);
}
#[test]
fn configured_start_server_entry_reads_the_start_alias_for_app_files_only() {
let root = std::env::temp_dir().join(format!("oj-start-entry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("node_modules/@tanstack/react-start/dist")).unwrap();
std::fs::write(root.join("src/ssr-entry.ts"), "export default {};").unwrap();
std::fs::write(
root.join("node_modules/@tanstack/react-start/dist/server-entry.js"),
"",
)
.unwrap();
let cfg = |target: &str| -> oj_config::OjConfig {
serde_json::from_str(&format!(
r#"{{"resolve":{{"alias":{{"virtual:tanstack-start-server-entry":"{}"}}}}}}"#,
target.replace('\\', "/")
))
.unwrap()
};
let app = root.join("src/ssr-entry.ts");
assert_eq!(
configured_start_server_entry(&cfg(&app.to_string_lossy()), &root),
Some(app.canonicalize().unwrap()),
"an app file named by the Start alias is the server entry"
);
assert!(
configured_start_server_entry(&cfg("src/ssr-entry.ts"), &root).is_some(),
"root-relative works too"
);
let pkg = root.join("node_modules/@tanstack/react-start/dist/server-entry.js");
assert_eq!(
configured_start_server_entry(&cfg(&pkg.to_string_lossy()), &root),
None,
"Start's own default entry is not an app entry"
);
assert_eq!(
configured_start_server_entry(&cfg("src/missing.ts"), &root),
None
);
let none: oj_config::OjConfig = serde_json::from_str("{}").unwrap();
assert_eq!(configured_start_server_entry(&none, &root), None);
let env_scoped: oj_config::OjConfig = serde_json::from_str(&format!(
r#"{{"environments":{{"ssr":{{"resolve":{{"alias":{{"virtual:tanstack-start-server-entry":"{}"}}}}}}}}}}"#,
app.to_string_lossy().replace('\\', "/")
))
.unwrap();
assert!(
configured_start_server_entry(&env_scoped, &root).is_some(),
"environments.ssr.resolve.alias counts"
);
}
}