use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::Context;
use axum::{
Router,
body::Body,
extract::{Query, State, WebSocketUpgrade, ws::Message},
http::{HeaderMap, StatusCode, Uri, header},
response::{IntoResponse, Response},
routing::get,
};
use oj_cache::{CachedModule, PersistentCache};
pub mod sidecar;
pub mod plugins;
use sidecar::{Sidecar, is_tailwind_css};
use plugins::PluginHost;
use oj_graph::{HmrDecision, ModuleGraph};
use oj_resolver::OjResolver;
use tokio::sync::broadcast;
pub fn cobalt(s: &str) -> String {
use std::io::IsTerminal;
if std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() {
format!("\x1b[1;38;2;42;51;212m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn oj_tag() -> String {
format!("{}:", cobalt("oj"))
}
fn bytes_to_string(bytes: Vec<u8>) -> std::io::Result<String> {
match simdutf8::basic::from_utf8(&bytes) {
Ok(_) => Ok(unsafe { String::from_utf8_unchecked(bytes) }),
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
)),
}
}
const CLIENT_JS: &str = include_str!("assets/client.js");
pub const OJ_ROUTES_JS: &str = include_str!("assets/oj-routes.js");
const SERVER_FN_JS: &str = include_str!("assets/server-fn.js");
const REFRESH_RUNTIME_JS: &str = include_str!("assets/refresh-runtime.js");
const REFRESH_PREAMBLE_JS: &str = include_str!("assets/refresh-preamble.js");
const BUNDLE_RUNTIME_JS: &str = include_str!("assets/bundle-runtime.js");
pub const SSR_RUNNER_JS: &str = include_str!("assets/ssr-runner.mjs");
const COMPILABLE: &[&str] = &["tsx", "ts", "jsx", "js", "mjs"];
const START_ASSETS: &[(&str, &str)] = &[
("resolve-pkg.mjs", include_str!("assets/start/resolve-pkg.mjs")),
("esbuild-assets.mjs", include_str!("assets/start/esbuild-assets.mjs")),
("vite-plugin-bridge.mjs", include_str!("assets/start/vite-plugin-bridge.mjs")),
("glob-transform.mjs", include_str!("assets/start/glob-transform.mjs")),
("cf-server.mjs", include_str!("assets/start/cf-server.mjs")),
("css-host.mjs", include_str!("assets/start/css-host.mjs")),
("loader.mjs", include_str!("assets/start/loader.mjs")),
("loader-util.mjs", include_str!("assets/start/loader-util.mjs")),
("runner.mjs", include_str!("assets/start/runner.mjs")),
("generate.mjs", include_str!("assets/start/generate.mjs")),
("gen-resolver.mjs", include_str!("assets/start/gen-resolver.mjs")),
("fn-stubs.mjs", include_str!("assets/start/fn-stubs.mjs")),
("bundle-client.mjs", include_str!("assets/start/bundle-client.mjs")),
("build.mjs", include_str!("assets/start/build.mjs")),
("live-reload.js", include_str!("assets/start/live-reload.js")),
("server-entry.tsx", include_str!("assets/start/server-entry.tsx")),
("client-entry.tsx", include_str!("assets/start/client-entry.tsx")),
("start-entry.ts", include_str!("assets/start/start-entry.ts")),
("plugin-adapters.ts", include_str!("assets/start/plugin-adapters.ts")),
("manifest.ts", include_str!("assets/start/manifest.ts")),
];
pub fn write_start_assets(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
for (name, content) in START_ASSETS {
std::fs::write(dir.join(name), content)?;
}
Ok(())
}
pub fn is_tanstack_start_app(root: &Path) -> bool {
root.join("src/routes").is_dir()
&& std::fs::read_to_string(root.join("package.json"))
.map(|s| s.contains("@tanstack/react-start"))
.unwrap_or(false)
}
pub struct DevServer {
pub root: PathBuf,
pub port: Option<u16>,
pub bundle: bool,
}
struct ServerState {
root: PathBuf,
public_dir: PathBuf,
bundle: bool,
reload_tx: broadcast::Sender<String>,
graph: Mutex<ModuleGraph>,
resolver: Arc<OjResolver>,
ssr_resolver: Arc<OjResolver>,
cache: PersistentCache,
memory: Mutex<HashMap<String, (String, Arc<CachedModule>)>>,
compile_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
crawl_done: tokio::sync::watch::Receiver<bool>,
fs_allow: Arc<Mutex<std::collections::HashSet<PathBuf>>>,
dir_cache: Arc<Mutex<DirCache>>,
patch_seq: std::sync::atomic::AtomicU64,
chunk_cache: Mutex<Option<(String, Arc<String>)>>,
cache_writes: tokio::sync::mpsc::Sender<(String, Arc<CachedModule>)>,
tailwind: tokio::sync::OnceCell<std::sync::Arc<Sidecar>>,
tailwind_urls: Mutex<std::collections::HashSet<String>>,
has_postcss: bool,
preload_snapshot: Vec<String>,
proxy: Vec<(String, oj_config::ProxyEntry)>,
http: reqwest::Client,
virtual_modules: std::collections::BTreeMap<String, String>,
plugins: Option<std::sync::Arc<PluginHost>>,
plugin_mw_port: Option<u16>,
plugins_ssr: tokio::sync::OnceCell<Option<std::sync::Arc<PluginHost>>>,
ssr_plugin_config: String,
rt: tokio::runtime::Handle,
base: Option<String>,
}
pub struct BuiltApp {
pub router: Router,
pub host: std::net::IpAddr,
pub port: u16,
pub proxy_prefixes: Vec<String>,
pub root: PathBuf,
pub started: Instant,
}
impl DevServer {
pub async fn run(self) -> anyhow::Result<()> {
let built = self.build_app().await?;
let addr = SocketAddr::from((built.host, built.port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("cannot bind {addr}"))?;
println!(" {} dev server", cobalt("oj"));
println!(" root: {}", built.root.display());
println!(" {}", cobalt(&format!("http://localhost:{}/", built.port)));
if !built.proxy_prefixes.is_empty() {
println!(" proxy: {}", built.proxy_prefixes.join(", "));
}
println!(" ready in {:?}", built.started.elapsed());
axum::serve(listener, built.router).await?;
Ok(())
}
pub async fn build_app(self) -> anyhow::Result<BuiltApp> {
let root = self
.root
.canonicalize()
.with_context(|| format!("app root not found: {}", self.root.display()))?;
let mut config = oj_config::load(&root).map_err(|e| anyhow::anyhow!("{e}"))?;
plugins::adopt_vite_config_values(&mut config, &root);
let env_prefix = config.env_prefix.as_deref().unwrap_or("VITE_");
let env_dir = config.env_dir.as_deref().map(|d| root.join(d)).unwrap_or_else(|| root.clone());
let env = oj_env::load(&env_dir, "development");
let mut defines = oj_env::import_meta_env_defines(
&env,
"development",
true,
config.base.as_deref().unwrap_or("/"),
env_prefix,
);
defines.extend(oj_config::config_defines(&config));
defines.extend(oj_config::environment_defines(&config, "client"));
defines.extend(oj_config::environment_defines(&config, "ssr"));
oj_compiler::set_import_meta_env(defines);
let server_cfg = config.server.clone().unwrap_or_default();
let port = self.port.or(server_cfg.port).unwrap_or(5199);
let bundle = self.bundle || config.bundle.unwrap_or(false);
let host: std::net::IpAddr = match server_cfg.host.as_deref() {
Some("0.0.0.0") | Some("true") => [0, 0, 0, 0].into(),
Some(h) => h.parse().unwrap_or([127, 0, 0, 1].into()),
None => [127, 0, 0, 1].into(),
};
let proxy: Vec<(String, oj_config::ProxyEntry)> =
server_cfg.proxy.clone().unwrap_or_default().into_iter().collect();
let plugin_src = if is_tanstack_start_app(&root) {
None
} else {
plugins::plugin_source(&root)
};
let (plugins_path, plugins_format, plugins_label) = match plugin_src {
Some(plugins::PluginSource::OjPlugins(p)) => {
let label = p.file_name().unwrap().to_string_lossy().into_owned();
(Some(p), "oj", label)
}
Some(plugins::PluginSource::ViteConfig(p)) => (Some(p), "vite", "vite.config".to_string()),
None => (None, "oj", String::new()),
};
let mut plugin_cfg = serde_json::json!({
"config": {
"root": root.display().to_string(),
"base": config.base.clone().unwrap_or_else(|| "/".into()),
"mode": "development",
"command": "serve",
"define": config.define,
"server": { "port": port, "host": server_cfg.host },
"environments": config.environments,
},
"env": { "command": "serve", "mode": "development" },
"environment": { "name": "client", "mode": "development" },
"pluginsFormat": plugins_format,
});
let plugin_config = plugin_cfg.to_string();
plugin_cfg["environment"]["name"] = serde_json::json!("ssr");
let ssr_plugin_config = plugin_cfg.to_string();
let plugin_host = match plugins_path {
Some(file) => match PluginHost::spawn(&root, &file, &plugin_config).await {
Ok(host) => {
println!(" plugins: {plugins_label}");
if let Err(e) = host.build_start().await {
eprintln!("oj: plugin buildStart failed: {e}");
}
Some(host)
}
Err(e) => {
eprintln!("oj: plugin host failed to start: {e}");
None
}
},
None => None,
};
let plugin_mw_port = match &plugin_host {
Some(host) => host.middleware_port().await,
None => None,
};
if let Some(p) = plugin_mw_port {
println!(" plugin middleware: forwarding unmatched requests to :{p}");
}
let started = Instant::now();
let (reload_tx, _) = broadcast::channel::<String>(64);
let (crawl_tx, crawl_rx) = tokio::sync::watch::channel(false);
let (write_tx, mut write_rx) =
tokio::sync::mpsc::channel::<(String, Arc<CachedModule>)>(65536);
let public_dir = config
.public_dir
.as_ref()
.map(|p| root.join(p))
.unwrap_or_else(|| root.join("public"));
let state = Arc::new(ServerState {
root: root.clone(),
public_dir,
bundle,
reload_tx,
graph: Mutex::new(ModuleGraph::new()),
resolver: Arc::new(OjResolver::with_options(
&root,
&oj_config::resolve_conditions(&config, "client"),
&oj_config::resolve_alias(&config, "client"),
)),
ssr_resolver: Arc::new(OjResolver::with_options(
&root,
&oj_config::resolve_conditions(&config, "ssr"),
&oj_config::resolve_alias(&config, "ssr"),
)),
cache: PersistentCache::new(
root.join(".oj-cache"),
env!("CARGO_PKG_VERSION"),
),
memory: Mutex::new(HashMap::new()),
compile_locks: Mutex::new(HashMap::new()),
crawl_done: crawl_rx,
tailwind: tokio::sync::OnceCell::new(),
tailwind_urls: Mutex::new(std::collections::HashSet::new()),
has_postcss: has_postcss_config(&root),
fs_allow: Arc::new(Mutex::new(std::collections::HashSet::new())),
dir_cache: Arc::new(Mutex::new(DirCache::new())),
patch_seq: std::sync::atomic::AtomicU64::new(0),
chunk_cache: Mutex::new(None),
cache_writes: write_tx,
preload_snapshot: load_graph_snapshot(&root),
proxy,
http: reqwest::Client::new(),
virtual_modules: config.virtual_modules.clone().unwrap_or_default(),
plugins: plugin_host,
plugin_mw_port,
plugins_ssr: tokio::sync::OnceCell::new(),
ssr_plugin_config,
rt: tokio::runtime::Handle::current(),
base: config.base.clone().filter(|b| b != "/"),
});
{
let state = Arc::clone(&state);
std::thread::spawn(move || {
while let Some((key, module)) = write_rx.blocking_recv() {
state.cache.put(&key, &module);
}
});
}
spawn_watcher(Arc::clone(&state));
spawn_crawl(Arc::clone(&state), crawl_tx);
let mut app = Router::new()
.route("/@oj/client.js", get(|| async { js(CLIENT_JS) }))
.route("/@oj/refresh-runtime.js", get(|| async { js(REFRESH_RUNTIME_JS) }))
.route("/@oj/refresh-preamble.js", get(|| async { js(REFRESH_PREAMBLE_JS) }))
.route("/@oj/bundle-runtime.js", get(|| async { js(BUNDLE_RUNTIME_JS) }))
.route("/@oj/chunk.js", get(serve_chunk))
.route("/@oj/patch.js", get(serve_patch))
.route("/@oj/lazy.js", get(serve_lazy))
.route("/@oj/routes.js", get(serve_oj_routes))
.route("/@oj/server-fn.js", get(|| async { js(SERVER_FN_JS) }))
.route("/@ssr-resolve", get(ssr_resolve))
.route("/@ssr-module", get(ssr_module))
.route("/__ws", get(ws_upgrade))
.fallback(get(serve_path));
let extra_headers: Vec<(header::HeaderName, header::HeaderValue)> = config
.server
.as_ref()
.and_then(|s| s.headers.as_ref())
.map(|h| {
h.iter()
.filter_map(|(k, v)| Some((k.parse().ok()?, v.parse().ok()?)))
.collect()
})
.unwrap_or_default();
if !extra_headers.is_empty() {
app = app.layer(axum::middleware::from_fn_with_state(
Arc::new(extra_headers),
apply_dev_headers,
));
}
if !state.proxy.is_empty() {
app = app.layer(axum::middleware::from_fn_with_state(
Arc::clone(&state),
proxy_middleware,
));
}
let proxy_prefixes: Vec<String> =
state.proxy.iter().map(|(p, _)| p.clone()).collect();
let app = app.with_state(state);
Ok(BuiltApp { router: app, host, port, proxy_prefixes, root, started })
}
}
fn js(body: impl IntoResponse) -> Response {
([(header::CONTENT_TYPE, "text/javascript")], body).into_response()
}
async fn ssr_resolve(
State(state): State<Arc<ServerState>>,
Query(q): Query<HashMap<String, String>>,
) -> Response {
let (Some(importer), Some(spec)) = (q.get("importer"), q.get("spec")) else {
return (StatusCode::BAD_REQUEST, "importer and spec required").into_response();
};
let importer_dir = Path::new(importer).parent().unwrap_or(&state.root);
match state.ssr_resolver.resolve(importer_dir, spec) {
Ok(p) => {
let s = p.to_string_lossy();
let body = if s.contains("/node_modules/") {
serde_json::json!({ "external": true, "spec": spec })
} else {
serde_json::json!({ "id": s })
};
js_response_json(body)
}
Err(e) => {
if let Some(host) = ssr_plugin_host(&state).await {
if let Ok(Some(id)) = host.resolve_id(spec, importer).await {
return js_response_json(serde_json::json!({ "id": id }));
}
}
if !spec.starts_with('.') && !spec.starts_with('/') {
return js_response_json(serde_json::json!({ "external": true, "spec": spec }));
}
(StatusCode::NOT_FOUND, format!("cannot resolve {spec}: {}", e.reason)).into_response()
}
}
}
fn js_response_json(v: serde_json::Value) -> Response {
([(header::CONTENT_TYPE, "application/json")], v.to_string()).into_response()
}
async fn ssr_module(
State(state): State<Arc<ServerState>>,
Query(q): Query<HashMap<String, String>>,
) -> Response {
let Some(id) = q.get("id") else {
return (StatusCode::BAD_REQUEST, "id required").into_response();
};
let path = PathBuf::from(id);
let (source, from_plugin) = match std::fs::read(&path).and_then(bytes_to_string) {
Ok(s) => (s, false),
Err(read_err) => match ssr_plugin_host(&state).await {
Some(host) => match host.load(id).await {
Ok(Some(code)) => (code, true),
_ => return (StatusCode::NOT_FOUND, format!("{id}: {read_err}")).into_response(),
},
None => return (StatusCode::NOT_FOUND, format!("{id}: {read_err}")).into_response(),
},
};
let ext = path.extension().and_then(|e| e.to_str());
if !from_plugin && matches!(ext, Some("css") | Some("scss") | Some("sass")) {
return match ssr_css_module(&state.root, &path, &source) {
Ok(code) => js(code),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
};
}
if !from_plugin && ext == Some("json") {
return match oj_compiler::json::to_esm(&source, id) {
Ok(code) => js(code),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
};
}
let source = match ssr_plugin_host(&state).await {
Some(host) => host.transform(&source, id).await.unwrap_or(source),
None => source,
};
let compile_path: PathBuf =
if from_plugin { PathBuf::from("virtual.tsx") } else { path };
match oj_compiler::compile(&compile_path, &source, &oj_compiler::CompileOptions::prod()) {
Ok(out) => js(out.code),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
}
async fn ssr_plugin_host(state: &Arc<ServerState>) -> Option<std::sync::Arc<PluginHost>> {
state
.plugins_ssr
.get_or_init(|| async {
let file = match plugins::plugin_source(&state.root)? {
plugins::PluginSource::OjPlugins(p) | plugins::PluginSource::ViteConfig(p) => p,
};
match PluginHost::spawn(&state.root, &file, &state.ssr_plugin_config).await {
Ok(host) => {
eprintln!("oj ssr: plugins (ssr environment) from {}", file.display());
Some(host)
}
Err(e) => {
eprintln!("oj ssr: plugin host failed to start: {e}");
None
}
}
})
.await
.clone()
}
fn ssr_css_module(root: &Path, path: &Path, source: &str) -> Result<String, String> {
let css_src = if oj_css::is_sass(&path.to_string_lossy()) {
oj_css::compile_sass(source, path.parent())?
} else {
source.to_string()
};
let css_id = match path.strip_prefix(root) {
Ok(rel) => format!("/{}", rel.display()),
Err(_) => path.to_string_lossy().to_string(),
};
let output = oj_css::compile_css(&css_id, &css_src, true)?;
Ok(match output.exports {
Some(exports) => {
let map: serde_json::Map<String, serde_json::Value> =
exports.into_iter().map(|(k, v)| (k, serde_json::Value::String(v))).collect();
format!("export default {};", serde_json::Value::Object(map))
}
None => "export default {};".to_string(),
})
}
pub async fn preview(
dir: PathBuf,
port: u16,
base: String,
headers: Vec<(String, String)>,
) -> anyhow::Result<()> {
let dir = dir
.canonicalize()
.with_context(|| format!("build dir not found: {} (run `oj build` first)", dir.display()))?;
let headers: Vec<(header::HeaderName, header::HeaderValue)> = headers
.iter()
.filter_map(|(k, v)| Some((k.parse().ok()?, v.parse().ok()?)))
.collect();
let state = Arc::new((dir.clone(), base, headers));
let app = Router::new().fallback(get(preview_serve)).with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("cannot bind {addr}"))?;
println!(" {} preview", cobalt("oj"));
println!(" serving: {}", dir.display());
println!(" {}", cobalt(&format!("http://localhost:{port}/")));
axum::serve(listener, app).await?;
Ok(())
}
fn preview_rel<'a>(path: &'a str, base: &str) -> Option<String> {
let trimmed = path.strip_prefix(base.trim_end_matches('/')).unwrap_or(path);
let rel = trimmed.trim_start_matches('/');
if rel.split('/').any(|seg| seg == "..") {
return None;
}
Some(if rel.is_empty() { "index.html".to_string() } else { rel.to_string() })
}
async fn preview_serve(
State(state): State<Arc<(PathBuf, String, Vec<(header::HeaderName, header::HeaderValue)>)>>,
uri: Uri,
) -> Response {
let (dir, base, extra_headers) = &*state;
let Some(rel) = preview_rel(uri.path(), base) else {
return (StatusCode::FORBIDDEN, "oj: path traversal denied").into_response();
};
let file = dir.join(&rel);
let ext = Path::new(&rel).extension().and_then(|e| e.to_str()).unwrap_or("");
let (target, ctype) = if file.is_file() {
(file, content_type(ext))
} else if ext.is_empty() {
(dir.join("index.html"), "text/html; charset=utf-8")
} else {
return (StatusCode::NOT_FOUND, format!("oj: not found: {rel}")).into_response();
};
match tokio::fs::read(&target).await {
Ok(bytes) => {
let mut resp = ([(header::CONTENT_TYPE, ctype)], bytes).into_response();
let h = resp.headers_mut();
for (name, value) in extra_headers {
h.insert(name.clone(), value.clone());
}
resp
}
Err(_) => (StatusCode::NOT_FOUND, "oj: not found").into_response(),
}
}
async fn ws_upgrade(
State(state): State<Arc<ServerState>>,
upgrade: WebSocketUpgrade,
) -> impl IntoResponse {
upgrade.on_upgrade(move |mut socket| async move {
let mut rx = state.reload_tx.subscribe();
loop {
tokio::select! {
msg = rx.recv() => match msg {
Ok(text) => {
if socket.send(Message::Text(text.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
},
incoming = socket.recv() => match incoming {
None | Some(Err(_)) => break,
Some(Ok(Message::Text(text))) => handle_client_message(&state, &text),
Some(Ok(_)) => {}
},
}
}
})
}
async fn apply_dev_headers(
State(headers): State<Arc<Vec<(header::HeaderName, header::HeaderValue)>>>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let mut resp = next.run(req).await;
let h = resp.headers_mut();
for (name, value) in headers.iter() {
h.insert(name.clone(), value.clone());
}
resp
}
async fn proxy_middleware(
State(state): State<Arc<ServerState>>,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let path = req.uri().path().to_string();
let matched = state
.proxy
.iter()
.filter(|(prefix, _)| path.starts_with(prefix.as_str()))
.max_by_key(|(prefix, _)| prefix.len());
let Some((prefix, entry)) = matched else {
return next.run(req).await;
};
let mut fwd_path = path.clone();
if let Some((from, to)) = entry.rewrite() {
if let Some(stripped) = from.strip_prefix('^') {
if let Some(rest) = fwd_path.strip_prefix(stripped) {
fwd_path = format!("{to}{rest}");
}
} else {
fwd_path = fwd_path.replacen(from, to, 1);
}
}
let query = req.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
let target = format!("{}{}{}", entry.target().trim_end_matches('/'), fwd_path, query);
let method = req.method().clone();
let req_headers = req.headers().clone();
let body_bytes = match axum::body::to_bytes(req.into_body(), 100 * 1024 * 1024).await {
Ok(b) => b,
Err(e) => {
return (StatusCode::BAD_GATEWAY, format!("oj proxy: body read: {e}")).into_response()
}
};
let mut out = state.http.request(method, &target).body(body_bytes.to_vec());
for (name, value) in req_headers.iter() {
if entry.change_origin() && name == header::HOST {
continue;
}
out = out.header(name, value);
}
match out.send().await {
Ok(resp) => {
let status = resp.status();
let headers = resp.headers().clone();
let bytes = resp.bytes().await.unwrap_or_default();
let mut response = Response::new(Body::from(bytes));
*response.status_mut() = status;
for (name, value) in headers.iter() {
if name == header::TRANSFER_ENCODING || name == header::CONTENT_LENGTH {
continue;
}
response.headers_mut().insert(name, value.clone());
}
response
}
Err(e) => {
let via = if entry.ws() { " (ws proxying not yet supported)" } else { "" };
(StatusCode::BAD_GATEWAY, format!("oj proxy to {}{} failed: {e}", prefix, via))
.into_response()
}
}
}
async fn serve_html(state: &ServerState, bytes: Vec<u8>) -> Response {
let mut raw = String::from_utf8_lossy(&bytes).into_owned();
if let Some(host) = &state.plugins {
if let Ok(out) = host.transform_index_html(&raw).await {
raw = out;
}
}
let html = if state.bundle {
inject_bundle_scripts(raw)
} else {
inject_module_preloads(inject_dev_scripts(raw), state)
};
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}
async fn serve_index_html(state: &ServerState) -> Response {
match tokio::fs::read(state.root.join("index.html")).await {
Ok(bytes) => serve_html(state, bytes).await,
Err(_) => (StatusCode::NOT_FOUND, "oj: index.html not found").into_response(),
}
}
fn is_spa_navigation(rel: &str, headers: &HeaderMap) -> bool {
if rel.starts_with('@')
|| rel.starts_with("__")
|| rel.starts_with("src/")
|| rel.starts_with("node_modules/")
{
return false;
}
let last = rel.rsplit('/').next().unwrap_or("");
let no_extension = !last.contains('.');
let accepts_html = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.is_some_and(|a| a.contains("text/html"));
no_extension || accepts_html
}
async fn forward_to_plugin_middleware(
state: &ServerState,
uri: &Uri,
headers: &HeaderMap,
) -> Option<Response> {
let port = state.plugin_mw_port?;
let pq = uri.path_and_query().map(|p| p.as_str()).unwrap_or(uri.path());
let target = format!("http://127.0.0.1:{port}{pq}");
let mut out = state.http.get(&target);
for (name, value) in headers.iter() {
if name == header::HOST {
continue;
}
out = out.header(name, value);
}
let resp = out.send().await.ok()?;
if resp.headers().contains_key("x-oj-fallthrough") {
return None;
}
let status = resp.status();
let resp_headers = resp.headers().clone();
let bytes = resp.bytes().await.unwrap_or_default();
let mut response = Response::new(Body::from(bytes));
*response.status_mut() = status;
for (name, value) in resp_headers.iter() {
if name == header::TRANSFER_ENCODING || name == header::CONTENT_LENGTH {
continue;
}
response.headers_mut().insert(name, value.clone());
}
Some(response)
}
async fn serve_path(
State(state): State<Arc<ServerState>>,
headers: HeaderMap,
uri: Uri,
) -> Response {
let path = state
.base
.as_deref()
.and_then(|b| uri.path().strip_prefix(b.trim_end_matches('/')))
.unwrap_or_else(|| uri.path());
let rel = path.trim_start_matches('/');
let rel = if rel.is_empty() { "index.html" } else { rel };
if let Some(id) = uri.path().strip_prefix("/@virtual/") {
return match state.virtual_modules.get(id) {
Some(code) => (
[(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache")],
code.clone(),
)
.into_response(),
None => (StatusCode::NOT_FOUND, format!("oj: no virtual module {id}")).into_response(),
};
}
if let Some(hex) = uri.path().strip_prefix("/@id/") {
let spec = hex_decode(hex).unwrap_or_default();
let importer = uri
.query()
.and_then(|q| q.strip_prefix("importer="))
.and_then(hex_decode)
.unwrap_or_default();
return serve_plugin_id(&state, &spec, &importer).await;
}
let file = if let Some(abs) = uri.path().strip_prefix("/@fs") {
let candidate = PathBuf::from(abs);
let allowed = {
let allow = state.fs_allow.lock().unwrap();
allow.iter().any(|root| candidate.starts_with(root))
};
if !allowed {
return (StatusCode::FORBIDDEN, "oj: /@fs path not allow-listed").into_response();
}
candidate
} else {
match locate(&state.root, &state.public_dir, rel) {
Some(file) => file,
None => {
if let Some(resp) = forward_to_plugin_middleware(&state, &uri, &headers).await {
return resp;
}
if is_spa_navigation(rel, &headers) {
return serve_index_html(&state).await;
}
return (StatusCode::NOT_FOUND, format!("oj: no such file: /{rel}"))
.into_response();
}
}
};
let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
if let Some(kind) = query_asset_kind(uri.query()) {
let url = url_of(&state.root, &file);
return match asset_module(&file, &url, kind).await {
Ok(js) => (
[(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache")],
js,
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("oj: {e}")).into_response(),
};
}
if matches!(ext, "css" | "scss" | "sass")
&& uri.query().is_some_and(|q| q.contains("import"))
{
let url = url_of(&state.root, &file);
return serve_css_wrapper(&state, &file, &url).await;
}
if COMPILABLE.contains(&ext) {
let url = url_of(&state.root, &file);
return serve_compiled(&state, &file, &url, uri.query(), &headers).await;
}
if ext == "json" && !file.starts_with(&state.public_dir) {
let url = url_of(&state.root, &file);
return serve_compiled(&state, &file, &url, uri.query(), &headers).await;
}
match tokio::fs::read(&file).await {
Ok(bytes) if ext == "html" => serve_html(&state, bytes).await,
Ok(bytes) if ext == "css" => {
let source = String::from_utf8_lossy(&bytes).into_owned();
if is_tailwind_css(&source) {
let url = url_of(&state.root, &file);
return match compile_tailwind(&state, &url, &source).await {
Ok(css) => {
([(header::CONTENT_TYPE, "text/css"), (header::CACHE_CONTROL, "no-cache")], css)
.into_response()
}
Err(err) => {
let _ = state.reload_tx.send(
serde_json::json!({ "type": "error", "message": err.clone() })
.to_string(),
);
(StatusCode::INTERNAL_SERVER_ERROR, format!("oj: {err}")).into_response()
}
};
}
([(header::CONTENT_TYPE, "text/css")], source).into_response()
}
Ok(bytes) => {
let mut response = Response::new(Body::from(bytes));
response
.headers_mut()
.insert(header::CONTENT_TYPE, content_type(ext).parse().unwrap());
response
}
Err(err) => {
(StatusCode::INTERNAL_SERVER_ERROR, format!("oj: read error: {err}"))
.into_response()
}
}
}
fn inject_dev_scripts(html: String) -> String {
let tags = "<script type=\"module\" src=\"/@oj/refresh-preamble.js\"></script>\n\
<script type=\"module\" src=\"/@oj/client.js\"></script>";
match html.find("<head>") {
Some(idx) => {
let insert_at = idx + "<head>".len();
format!("{}\n{}{}", &html[..insert_at], tags, &html[insert_at..])
}
None => format!("{tags}\n{html}"),
}
}
async fn serve_compiled(
state: &Arc<ServerState>,
file: &Path,
url: &str,
query: Option<&str>,
headers: &HeaderMap,
) -> Response {
let (key, module) = match ensure_module(state, file, url).await {
Ok(pair) => pair,
Err(err) => {
let _ = state.reload_tx.send(
serde_json::json!({ "type": "error", "message": err.clone() }).to_string(),
);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("oj: {err}")).into_response();
}
};
let etag = format!("\"{key}\"");
if query.is_none() {
if let Some(inm) = headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) {
if inm == etag {
return (
StatusCode::NOT_MODIFIED,
[(header::ETAG, etag), (header::CACHE_CONTROL, "no-cache".to_string())],
)
.into_response();
}
}
}
let mut body = module.code.clone();
if !state.bundle {
body.push_str(&hot_glue(url, query, module.is_boundary));
}
if let Some(map_url) = &module.map_data_url {
body.push_str(&format!("\n//# sourceMappingURL={map_url}\n"));
}
(
[
(header::CONTENT_TYPE, "text/javascript".to_string()),
(header::CACHE_CONTROL, "no-cache".to_string()),
(header::ETAG, etag),
],
body,
)
.into_response()
}
async fn ensure_module(
state: &Arc<ServerState>,
file: &Path,
url: &str,
) -> Result<(String, Arc<CachedModule>), String> {
let source = bytes_to_string(
tokio::fs::read(file).await.map_err(|err| format!("read error for {url}: {err}"))?,
)
.map_err(|err| format!("read error for {url}: {err}"))?;
if file.extension().and_then(|e| e.to_str()) == Some("css") && is_tailwind_css(&source) {
let css = compile_tailwind(state, url, &source).await?;
let module = Arc::new(CachedModule {
is_boundary: true,
kind: "css".into(),
code: css,
map_data_url: None,
imports: Vec::new(),
require_map: Vec::new(),
css_exports: Vec::new(),
fs_allow: Vec::new(),
});
register_in_graph(state, url, &module);
return Ok((String::new(), module));
}
let is_dep_early = url.contains("/node_modules/") || url.starts_with("/@fs/");
let is_server = is_server_module(file) && !is_dep_early && !state.bundle;
let mode = if state.bundle {
"bundle"
} else if is_server {
"server"
} else {
"dev"
};
let key = state.cache.key(source.as_bytes(), url, mode);
if let Some(module) = memory_get(state, url, &key) {
register_in_graph(state, url, &module);
return Ok((key, module));
}
let lock = {
let mut locks = state.compile_locks.lock().unwrap();
Arc::clone(locks.entry(url.to_string()).or_default())
};
let _guard = lock.lock().await;
if let Some(module) = memory_get(state, url, &key) {
register_in_graph(state, url, &module);
return Ok((key, module));
}
if let Some(module) = state.cache.get(&key) {
let module = Arc::new(module);
memory_put(state, url, &key, &module);
register_in_graph(state, url, &module);
return Ok((key, module));
}
if is_server {
let code = server_fn_stub(&oj_compiler::exports(&source, file), url);
let module = Arc::new(CachedModule {
is_boundary: false,
kind: String::new(),
code,
map_data_url: None,
imports: Vec::new(),
require_map: Vec::new(),
css_exports: Vec::new(),
fs_allow: Vec::new(),
});
let _ = state.cache_writes.try_send((key.clone(), Arc::clone(&module)));
memory_put(state, url, &key, &module);
register_in_graph(state, url, &module);
return Ok((key, module));
}
let is_dep = url.contains("/node_modules/") || url.starts_with("/@fs/");
let source = match &state.plugins {
Some(host) if !is_dep => {
host.transform(&source, &file.to_string_lossy()).await.unwrap_or(source)
}
_ => source,
};
let source = if state.has_postcss && file.extension().and_then(|e| e.to_str()) == Some("css") {
run_css_sidecar(state, url, &source).await.unwrap_or(source)
} else {
source
};
let root = state.root.clone();
let resolver = Arc::clone(&state.resolver);
let fs_allow = Arc::clone(&state.fs_allow);
let dir_cache = Arc::clone(&state.dir_cache);
let virtual_ids: std::collections::BTreeSet<String> =
state.virtual_modules.keys().cloned().collect();
let dir = file.parent().map(Path::to_path_buf).unwrap_or_default();
let file_owned = file.to_path_buf();
let url_owned = url.to_string();
let bundle = state.bundle;
let plugin_fallback = state.plugins.is_some() && !bundle;
let importer_abs = file.to_string_lossy().into_owned();
let ext = file.extension().and_then(|e| e.to_str());
let is_css = matches!(ext, Some("css") | Some("scss") | Some("sass"));
let is_json = ext == Some("json");
let compiled = tokio::task::spawn_blocking(move || -> Result<CachedModule, String> {
if is_json {
let code = if bundle {
oj_compiler::json::to_factory_body(&source, &url_owned)
} else {
oj_compiler::json::to_esm(&source, &url_owned)
}
.map_err(|err| format!("compile error:\n{err}"))?;
return Ok(CachedModule {
is_boundary: false,
kind: if bundle { "esm".into() } else { String::new() },
code,
map_data_url: None,
imports: Vec::new(),
require_map: Vec::new(),
css_exports: Vec::new(),
fs_allow: Vec::new(),
});
}
if is_css {
let css_src = if oj_css::is_sass(&url_owned) {
oj_css::compile_sass(&source, Some(&dir))?
} else {
source.clone()
};
let output = oj_css::compile_css(&url_owned, &css_src, false)?;
return Ok(CachedModule {
is_boundary: true, kind: "css".into(),
code: output.css,
map_data_url: None,
imports: Vec::new(),
require_map: Vec::new(),
css_exports: output.exports.unwrap_or_default(),
fs_allow: Vec::new(),
});
}
let mut rewrite = |spec: &str| {
if spec == "virtual:oj-routes" {
return Some("/@oj/routes.js".to_string());
}
if virtual_ids.contains(spec) {
return Some(format!("/@virtual/{spec}"));
}
if let Some(url) = rewrite_specifier(&root, &dir, &resolver, &fs_allow, &dir_cache, spec, !bundle) {
return Some(url);
}
if plugin_fallback && is_bare_specifier(spec) {
return Some(format!("/@id/{}?importer={}", hex_encode(spec), hex_encode(&importer_abs)));
}
None
};
if bundle {
let factory =
oj_compiler::bundle::compile_factory(&file_owned, &url_owned, &source, &mut rewrite)
.map_err(|err| format!("compile error:\n{err}"))?;
Ok(CachedModule {
is_boundary: factory.is_boundary(),
kind: match factory.kind {
oj_compiler::bundle::FactoryKind::Esm => "esm".into(),
oj_compiler::bundle::FactoryKind::Cjs => "cjs".into(),
},
code: factory.code,
map_data_url: None,
fs_allow: fs_allow_from(&factory.imports),
imports: factory.imports,
require_map: factory.require_map,
css_exports: Vec::new(),
})
} else {
let output = if is_dep {
oj_compiler::cjs::compile_dep(&file_owned, &url_owned, &source, &mut rewrite)
} else {
oj_compiler::compile_module(
&file_owned,
&source,
&oj_compiler::CompileOptions::dev(),
Some(&mut rewrite),
)
}
.map_err(|err| format!("compile error:\n{err}"))?;
Ok(CachedModule {
is_boundary: !is_dep && output.has_refresh_registrations(),
code: output.code,
map_data_url: output.map_data_url,
fs_allow: fs_allow_from(&output.imports),
imports: output.imports,
kind: String::new(),
require_map: Vec::new(),
css_exports: Vec::new(),
})
}
})
.await;
let module = match compiled {
Ok(Ok(module)) => Arc::new(module),
Ok(Err(err)) => return Err(err),
Err(join_err) => return Err(format!("compiler task failed: {join_err}")),
};
let _ = state.cache_writes.try_send((key.clone(), Arc::clone(&module)));
memory_put(state, url, &key, &module);
register_in_graph(state, url, &module);
Ok((key, module))
}
fn memory_get(state: &ServerState, url: &str, key: &str) -> Option<Arc<CachedModule>> {
let memory = state.memory.lock().unwrap();
memory.get(url).filter(|(k, _)| k == key).map(|(_, m)| Arc::clone(m))
}
fn memory_put(state: &ServerState, url: &str, key: &str, module: &Arc<CachedModule>) {
state
.memory
.lock()
.unwrap()
.insert(url.to_string(), (key.to_string(), Arc::clone(module)));
}
fn package_root(path: &Path) -> PathBuf {
let mut dir = path.parent();
while let Some(d) = dir {
if d.join("package.json").is_file() {
return d.to_path_buf();
}
dir = d.parent();
}
path.parent().unwrap_or(path).to_path_buf()
}
fn fs_allow_from(imports: &[String]) -> Vec<String> {
imports
.iter()
.filter_map(|i| i.split('?').next().unwrap_or(i).strip_prefix("/@fs"))
.map(|p| package_root(Path::new(p)).display().to_string())
.collect()
}
fn register_in_graph(state: &ServerState, url: &str, module: &CachedModule) {
if !module.fs_allow.is_empty() {
let mut allow = state.fs_allow.lock().unwrap();
for p in &module.fs_allow {
allow.insert(PathBuf::from(p));
}
}
let mut graph = state.graph.lock().unwrap();
let local_imports: Vec<PathBuf> = module
.imports
.iter()
.filter(|s| s.starts_with('/') && !s.starts_with("/@oj/"))
.map(|s| PathBuf::from(s.split('?').next().unwrap_or(s)))
.collect();
graph.set_imports(Path::new(url), &local_imports);
graph.set_self_accepting(Path::new(url), module.is_boundary);
}
async fn serve_css_wrapper(state: &Arc<ServerState>, file: &Path, url: &str) -> Response {
let (_, module) = match ensure_module(state, file, url).await {
Ok(pair) => pair,
Err(err) => {
let _ = state.reload_tx.send(
serde_json::json!({ "type": "error", "message": err.clone() }).to_string(),
);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("oj: {err}")).into_response();
}
};
let exports = if module.css_exports.is_empty() {
"void 0".to_string()
} else {
let map: serde_json::Map<String, serde_json::Value> = module
.css_exports
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect();
serde_json::Value::Object(map).to_string()
};
let body = format!(
"import {{ createHotContext as __oj_hot, updateStyle as __oj_updateStyle }} from \"/@oj/client.js\";\n\
import.meta.hot = __oj_hot({url:?});\n\
__oj_updateStyle({url:?}, {css});\n\
export default {exports};\n\
import.meta.hot.accept(() => {{}});\n",
css = serde_json::Value::String(module.code.clone()),
);
(
[(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache")],
body,
)
.into_response()
}
pub fn has_postcss_config(root: &Path) -> bool {
["postcss.config.js", "postcss.config.cjs", "postcss.config.mjs"]
.iter()
.any(|f| root.join(f).is_file())
}
async fn run_css_sidecar(state: &Arc<ServerState>, url: &str, source: &str) -> Result<String, String> {
let sidecar = state
.tailwind
.get_or_try_init(|| Sidecar::spawn(&state.root))
.await
.map_err(|e| e.to_string())?;
sidecar.compile(source, url).await
}
async fn compile_tailwind(
state: &Arc<ServerState>,
url: &str,
source: &str,
) -> Result<String, String> {
let css = run_css_sidecar(state, url, source).await?;
state.tailwind_urls.lock().unwrap().insert(url.to_string());
Ok(css)
}
fn handle_client_message(state: &Arc<ServerState>, text: &str) {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
if msg["type"] == "invalidate" {
let Some(path) = msg["path"].as_str() else { return };
let reply = if state.bundle {
match state.graph.lock().unwrap().update_plan_from_importers(Path::new(path)) {
Ok(plan) => {
println!("oj: invalidate {path} -> patch {:?}", plan.boundaries);
let seq =
state.patch_seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
let to_urls =
|v: &[PathBuf]| -> Vec<String> { v.iter().map(|p| p.display().to_string()).collect() };
serde_json::json!({
"type": "patch",
"changed": [],
"dirty": to_urls(&plan.dirty),
"boundaries": to_urls(&plan.boundaries),
"timestamp": now_millis() as u64,
"seq": seq,
})
}
Err(reason) => {
println!("oj: invalidate {path} -> full-reload ({reason})");
serde_json::json!({ "type": "full-reload", "reason": reason })
}
}
} else {
match state.graph.lock().unwrap().propagate_update_from_importers(Path::new(path)) {
HmrDecision::Update { boundaries } => {
println!("oj: invalidate {path} -> update {boundaries:?}");
let timestamp = now_millis() as u64;
let updates: Vec<_> = boundaries
.iter()
.map(|b| {
serde_json::json!({
"path": format!("{}", b.display()),
"timestamp": timestamp,
})
})
.collect();
serde_json::json!({ "type": "update", "updates": updates })
}
HmrDecision::FullReload { reason } => {
println!("oj: invalidate {path} -> full-reload ({reason})");
serde_json::json!({ "type": "full-reload", "reason": reason })
}
}
};
let _ = state.reload_tx.send(reply.to_string());
} else if msg["type"] == "custom" {
if msg["event"].is_string() {
let _ = state.reload_tx.send(
serde_json::json!({
"type": "custom",
"event": msg["event"],
"data": msg["data"],
})
.to_string(),
);
}
}
}
fn hot_glue(url: &str, query: Option<&str>, is_boundary: bool) -> String {
if !is_boundary {
return String::new();
}
let self_specifier = match query {
Some(q) if !q.is_empty() => format!("{url}?{q}"),
_ => url.to_string(),
};
format!(
r#"
import {{ createHotContext as __oj_createHotContext }} from "/@oj/client.js";
import.meta.hot = __oj_createHotContext({url:?});
import * as RefreshRuntime from "/@oj/refresh-runtime.js";
import * as __oj_currentExports from {self_specifier:?};
if (import.meta.hot) {{
if (!window.__oj_refresh_installed__) {{
throw new Error("oj: Fast Refresh preamble missing; was index.html served by oj?");
}}
const currentExports = __oj_currentExports;
// Register synchronously during module evaluation (NOT in a microtask):
// a fast second edit must find the accept callback the instant the first
// edit's dynamic import resolves, or it snapshots an empty list and is
// silently dropped. This mirrors Vite's shared/hmr.ts.
RefreshRuntime.registerExportsForReactRefresh({url:?}, currentExports);
import.meta.hot.accept((nextExports) => {{
if (!nextExports) return;
const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate({url:?}, currentExports, nextExports);
if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
}});
}}
function $RefreshReg$(type, id) {{ return RefreshRuntime.register(type, {url:?} + " " + id); }}
function $RefreshSig$() {{ return RefreshRuntime.createSignatureFunctionForTransform(); }}
"#
)
}
type DirCache = std::collections::HashMap<PathBuf, std::sync::Arc<std::collections::HashMap<std::ffi::OsString, bool>>>;
fn is_file_cached(cache: &Mutex<DirCache>, path: &Path) -> bool {
let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
return path.is_file();
};
if let Some(entries) = cache.lock().unwrap().get(dir) {
return entries.get(name).copied().unwrap_or(false);
}
let mut map = std::collections::HashMap::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let is_file = match e.file_type() {
Ok(ft) if ft.is_file() => true,
Ok(ft) if ft.is_symlink() => e.path().is_file(), _ => false,
};
map.insert(e.file_name(), is_file);
}
}
let arc = std::sync::Arc::new(map);
let result = arc.get(name).copied().unwrap_or(false);
cache.lock().unwrap().insert(dir.to_path_buf(), arc);
result
}
fn rewrite_specifier(
root: &Path,
dir: &Path,
resolver: &OjResolver,
fs_allow: &Mutex<std::collections::HashSet<PathBuf>>,
dir_cache: &Mutex<DirCache>,
spec: &str,
css_import_marker: bool,
) -> Option<String> {
if spec.starts_with('/') || spec.contains("://") {
return None;
}
if let Some((base, query)) = spec.split_once('?') {
if matches!(query, "url" | "raw" | "inline" | "worker" | "sharedworker") {
let resolved = rewrite_specifier(root, dir, resolver, fs_allow, dir_cache, base, false)
.or_else(|| {
resolver.resolve(dir, base).ok().map(|p| {
fs_allow.lock().unwrap().insert(package_root(&p));
url_of(root, &p)
})
})?;
return Some(format!("{resolved}?{query}"));
}
}
if spec.starts_with("./") || spec.starts_with("../") {
let mut joined = normalize(&dir.join(spec));
if !is_file_cached(dir_cache, &joined) {
if let Some(ext) = joined.extension().and_then(|e| e.to_str()) {
if ext == "js" || ext == "jsx" {
for cand in ["ts", "tsx"] {
let alt = joined.with_extension(cand);
if is_file_cached(dir_cache, &alt) {
joined = alt;
break;
}
}
}
}
}
let quick = if is_file_cached(dir_cache, &joined) {
Some(joined)
} else if joined.extension().is_none() {
COMPILABLE.iter().map(|ext| joined.with_extension(ext)).find(|c| is_file_cached(dir_cache, c))
} else {
None
};
if let Some(p) = quick {
let url = url_of(root, &p);
if css_import_marker
&& (url.ends_with(".css") || url.ends_with(".scss") || url.ends_with(".sass"))
{
return Some(format!("{url}?import"));
}
return Some(url);
}
}
match resolver.resolve(dir, spec) {
Ok(resolved) if resolved.starts_with(root) => Some(url_of(root, &resolved)),
Ok(resolved) => {
fs_allow.lock().unwrap().insert(package_root(&resolved));
Some(url_of(root, &resolved))
}
Err(err) => {
if !(spec.starts_with("./") || spec.starts_with("../")) {
eprintln!("oj: cannot resolve '{spec}': {err}");
}
None
}
}
}
fn url_of(root: &Path, file: &Path) -> String {
match file.strip_prefix(root) {
Ok(rel) => format!("/{}", rel.display()),
Err(_) => format!("/@fs{}", file.display()),
}
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
fn locate(root: &Path, public_dir: &Path, rel: &str) -> Option<PathBuf> {
if rel.split('/').any(|seg| seg == "..") {
return None;
}
let base = root.join(rel);
if base.is_file() {
return Some(base);
}
if base.extension().is_none() {
for ext in COMPILABLE {
let candidate = base.with_extension(ext);
if candidate.is_file() {
return Some(candidate);
}
}
}
let public = public_dir.join(rel);
if public.is_file() {
return Some(public);
}
None
}
fn query_asset_kind(query: Option<&str>) -> Option<&'static str> {
let q = query?;
for kind in ["url", "raw", "inline", "worker", "sharedworker"] {
if q.split('&').any(|kv| kv == kind) {
return Some(kind);
}
}
None
}
async fn asset_module(file: &Path, url: &str, kind: &str) -> Result<String, String> {
let clean_url = url.split('?').next().unwrap_or(url);
match kind {
"url" => Ok(format!("export default {clean_url:?};\n")),
"raw" => {
let text = tokio::fs::read_to_string(file)
.await
.map_err(|e| format!("read {}: {e}", file.display()))?;
Ok(format!("export default {};\n", serde_json::Value::String(text)))
}
"inline" => {
let bytes = tokio::fs::read(file).await.map_err(|e| format!("read: {e}"))?;
let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
let mime = content_type(ext).split(';').next().unwrap_or("application/octet-stream");
let data_uri = format!("data:{mime};base64,{}", base64_encode(&bytes));
Ok(format!("export default {data_uri:?};\n"))
}
"worker" | "sharedworker" => {
let ctor = if kind == "sharedworker" { "SharedWorker" } else { "Worker" };
Ok(format!(
"export default function () {{ return new {ctor}({clean_url:?}, {{ type: \"module\" }}); }}\n"
))
}
_ => Err(format!("unknown asset query: {kind}")),
}
}
fn base64_encode(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
out.push(T[(n >> 18 & 63) as usize] as char);
out.push(T[(n >> 12 & 63) as usize] as char);
out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
fn is_server_module(file: &Path) -> bool {
file.file_name()
.and_then(|n| n.to_str())
.map(|n| {
[".server.ts", ".server.tsx", ".server.js", ".server.jsx"]
.iter()
.any(|s| n.ends_with(s))
})
.unwrap_or(false)
}
fn server_fn_stub(exports: &[String], url: &str) -> String {
let mut out = String::from("import { __ojServerCall } from \"/@oj/server-fn.js\";\n");
for name in exports {
if name == "default" {
out.push_str(&format!(
"export default (...a) => __ojServerCall({url:?}, \"default\", a);\n"
));
} else {
out.push_str(&format!(
"export const {name} = (...a) => __ojServerCall({url:?}, {name:?}, a);\n"
));
}
}
out
}
async fn serve_oj_routes(State(state): State<Arc<ServerState>>) -> Response {
let root = state.root.clone();
let resolver = Arc::clone(&state.resolver);
let fs_allow = Arc::clone(&state.fs_allow);
let dir_cache = Arc::clone(&state.dir_cache);
let synthetic = root.join("oj-routes.tsx");
let compiled = tokio::task::spawn_blocking(move || {
let dir = root.clone();
let mut rewrite = |s: &str| rewrite_specifier(&root, &dir, &resolver, &fs_allow, &dir_cache, s, true);
oj_compiler::compile_module(
&synthetic,
OJ_ROUTES_JS,
&oj_compiler::CompileOptions::dev(),
Some(&mut rewrite),
)
.map(|o| o.code_with_inline_map())
.map_err(|e| format!("{e}"))
})
.await;
match compiled {
Ok(Ok(code)) => (
[(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache")],
code,
)
.into_response(),
Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, format!("oj: routes manifest: {e}")).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("compile task failed: {e}")).into_response(),
}
}
async fn serve_plugin_id(state: &Arc<ServerState>, spec: &str, importer: &str) -> Response {
let Some(host) = &state.plugins else {
return (StatusCode::NOT_FOUND, "oj: no plugin host").into_response();
};
let id = match host.resolve_id(spec, importer).await {
Ok(Some(id)) => id,
Ok(None) => {
return (StatusCode::NOT_FOUND, format!("oj: no plugin resolved {spec}")).into_response();
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
};
let source = match host.load(&id).await {
Ok(Some(src)) => src,
Ok(None) => {
return (StatusCode::NOT_FOUND, format!("oj: no plugin loaded {id}")).into_response();
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
};
let root = state.root.clone();
let resolver = Arc::clone(&state.resolver);
let fs_allow = Arc::clone(&state.fs_allow);
let dir_cache = Arc::clone(&state.dir_cache);
let compiled = tokio::task::spawn_blocking(move || {
let mut rewrite = |s: &str| rewrite_specifier(&root, &root, &resolver, &fs_allow, &dir_cache, s, true);
oj_compiler::compile_module(
Path::new("plugin.tsx"),
&source,
&oj_compiler::CompileOptions::dev(),
Some(&mut rewrite),
)
.map(|o| o.code_with_inline_map())
.map_err(|e| format!("{e}"))
})
.await;
match compiled {
Ok(Ok(code)) => (
[(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache")],
code,
)
.into_response(),
Ok(Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("compile task failed: {e}")).into_response(),
}
}
fn is_bare_specifier(spec: &str) -> bool {
!spec.starts_with('.') && !spec.starts_with('/') && !spec.contains("://")
}
fn hex_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for b in s.bytes() {
out.push(char::from_digit((b >> 4) as u32, 16).unwrap());
out.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
out
}
fn hex_decode(s: &str) -> Option<String> {
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
return None;
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks(2) {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out.push((hi * 16 + lo) as u8);
}
String::from_utf8(out).ok()
}
fn content_type(ext: &str) -> &'static str {
match ext {
"html" => "text/html; charset=utf-8",
"js" | "mjs" | "cjs" => "text/javascript",
"css" => "text/css",
"json" | "map" => "application/json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"ico" => "image/x-icon",
"wasm" => "application/wasm",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"eot" => "application/vnd.ms-fontobject",
"webp" => "image/webp",
"gif" => "image/gif",
"txt" | "map2" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn now_millis() -> u128 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()
}
fn inject_module_preloads(html: String, state: &ServerState) -> String {
let paths: Vec<String> = if *state.crawl_done.borrow() {
state
.graph
.lock()
.unwrap()
.module_paths()
.iter()
.map(|p| p.display().to_string())
.collect()
} else {
state.preload_snapshot.clone()
};
if paths.is_empty() {
return html;
}
let links: String = paths
.iter()
.map(|p| {
if p.ends_with(".css") || p.ends_with(".scss") || p.ends_with(".sass") {
format!("<link rel=\"modulepreload\" href=\"{p}?import\" />\n")
} else {
format!("<link rel=\"modulepreload\" href=\"{p}\" />\n")
}
})
.collect();
match html.find("</head>") {
Some(idx) => format!("{}{links}{}", &html[..idx], &html[idx..]),
None => format!("{html}\n{links}"),
}
}
fn inject_bundle_scripts(html: String) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html.as_str();
while let Some(start) = rest.find("<script") {
let Some(tag_close) = rest[start..].find('>') else { break };
let tag = &rest[start..start + tag_close];
if tag.contains("type=\"module\"") && tag.contains("src=\"/") {
out.push_str(&rest[..start]);
let after_tag = &rest[start + tag_close + 1..];
rest = match after_tag.find("</script>") {
Some(end) => &after_tag[end + "</script>".len()..],
None => after_tag,
};
} else {
out.push_str(&rest[..start + tag_close + 1]);
rest = &rest[start + tag_close + 1..];
}
}
out.push_str(rest);
let tags = "<script type=\"module\" src=\"/@oj/bundle-runtime.js\"></script>\n\
<script type=\"module\" src=\"/@oj/chunk.js\"></script>";
match out.find("<head>") {
Some(idx) => {
let insert_at = idx + "<head>".len();
format!("{}\n{}{}", &out[..insert_at], tags, &out[insert_at..])
}
None => format!("{tags}\n{out}"),
}
}
async fn serve_chunk(State(state): State<Arc<ServerState>>, headers: HeaderMap) -> Response {
if let Some((etag, body)) = state.chunk_cache.lock().unwrap().clone() {
return chunk_response(&headers, etag, body);
}
let mut crawl_done = state.crawl_done.clone();
if !*crawl_done.borrow() {
let _ = crawl_done.wait_for(|done| *done).await;
}
let urls: Vec<String> = state
.graph
.lock()
.unwrap()
.module_paths()
.iter()
.map(|p| p.display().to_string())
.collect();
let lock = {
let mut locks = state.compile_locks.lock().unwrap();
Arc::clone(locks.entry("/@oj/chunk.js".into()).or_default())
};
let _guard = lock.lock().await;
if let Some((etag, body)) = state.chunk_cache.lock().unwrap().clone() {
return chunk_response(&headers, etag, body);
}
let mut chunk = String::new();
for url in &urls {
match registration_for(&state, url).await {
Ok(registration) => chunk.push_str(®istration),
Err(err) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("oj: chunk: {err}"))
.into_response();
}
}
}
for entry in html_entries(&state.root) {
chunk.push_str(&format!("__oj_start({entry:?});\n"));
}
let etag = format!("\"{}\"", state.cache.key(chunk.as_bytes(), "/@oj/chunk.js", "chunk"));
let body = Arc::new(chunk);
*state.chunk_cache.lock().unwrap() = Some((etag.clone(), Arc::clone(&body)));
chunk_response(&headers, etag, body)
}
fn chunk_response(headers: &HeaderMap, etag: String, body: Arc<String>) -> Response {
if headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) == Some(etag.as_str()) {
return (
StatusCode::NOT_MODIFIED,
[(header::ETAG, etag), (header::CACHE_CONTROL, "no-cache".to_string())],
)
.into_response();
}
(
[
(header::CONTENT_TYPE, "text/javascript".to_string()),
(header::CACHE_CONTROL, "no-cache".to_string()),
(header::ETAG, etag),
],
body.as_str().to_string(),
)
.into_response()}
async fn serve_patch(State(state): State<Arc<ServerState>>, uri: Uri) -> Response {
let query = uri.query().unwrap_or("");
let modules = query
.split('&')
.find_map(|kv| kv.strip_prefix("m="))
.map(|v| urldecode(v))
.unwrap_or_default();
let mut patch = String::new();
for url in modules.split(',').filter(|u| !u.is_empty()) {
match registration_for(&state, url).await {
Ok(registration) => patch.push_str(®istration),
Err(err) => {
let _ = state.reload_tx.send(
serde_json::json!({ "type": "error", "message": err.clone() }).to_string(),
);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("oj: patch: {err}"))
.into_response();
}
}
}
(
[(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache")],
patch,
)
.into_response()
}
fn locate_url(state: &ServerState, url: &str) -> Result<PathBuf, String> {
if let Some(abs) = url.strip_prefix("/@fs") {
Ok(PathBuf::from(abs))
} else {
let rel = url.trim_start_matches('/');
locate(&state.root, &state.public_dir, rel).ok_or_else(|| format!("no such module: {url}"))
}
}
fn render_registration(url: &str, module: &CachedModule) -> String {
let deps: serde_json::Map<String, serde_json::Value> = module
.require_map
.iter()
.map(|(spec, target)| (spec.clone(), serde_json::Value::String(target.clone())))
.collect();
if module.kind == "css" {
let exports = if module.css_exports.is_empty() {
"void 0".to_string()
} else {
let map: serde_json::Map<String, serde_json::Value> = module
.css_exports
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect();
serde_json::Value::Object(map).to_string()
};
return format!(
"__oj_register({url:?}, \"esm\", {{}}, function(module, __oj_exports, __oj_require) {{\n __oj_esm(__oj_exports, {{ \"default\": () => __oj_css_default }});\n var __oj_css_default = {exports};\n __oj_inject_css({url:?}, {css});\n }});\n",
css = serde_json::Value::String(module.code.clone()),
);
}
let params = if module.kind == "cjs" {
"module, exports, require"
} else {
"module, __oj_exports, __oj_require"
};
format!(
"__oj_register({url:?}, {kind:?}, {deps}, function({params}) {{\n{body}\n}});\n",
kind = module.kind,
deps = serde_json::Value::Object(deps),
body = module.code,
)
}
async fn registration_for(state: &Arc<ServerState>, url: &str) -> Result<String, String> {
let file = locate_url(state, url)?;
let (_, module) = ensure_module(state, &file, url).await?;
Ok(render_registration(url, &module))
}
async fn serve_lazy(State(state): State<Arc<ServerState>>, uri: Uri) -> Response {
let query = uri.query().unwrap_or("");
let field = |k: &str| query.split('&').find_map(|kv| kv.strip_prefix(k)).map(urldecode);
let Some(id) = field("id=").filter(|s| !s.is_empty()) else {
return (StatusCode::BAD_REQUEST, "oj: lazy: id required").into_response();
};
let mut visited: std::collections::HashSet<String> = field("have=")
.map(|v| v.split(',').filter(|s| !s.is_empty()).map(str::to_string).collect())
.unwrap_or_default();
let mut chunk = String::new();
let mut queue = vec![id.split('?').next().unwrap_or(&id).to_string()];
while let Some(url) = queue.pop() {
if url.starts_with("/@oj/") || !visited.insert(url.clone()) {
continue;
}
let Ok(file) = locate_url(&state, &url) else { continue };
let module = match ensure_module(&state, &file, &url).await {
Ok((_, module)) => module,
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("oj: lazy: {err}")).into_response(),
};
chunk.push_str(&render_registration(&url, &module));
for imp in &module.imports {
let clean = imp.split('?').next().unwrap_or(imp);
if clean.starts_with('/') && !clean.starts_with("/@oj/") && !visited.contains(clean) {
queue.push(clean.to_string());
}
}
}
(
[(header::CONTENT_TYPE, "text/javascript"), (header::CACHE_CONTROL, "no-cache")],
chunk,
)
.into_response()
}
fn urldecode(input: &str) -> String {
input.replace("%2F", "/").replace("%2f", "/").replace("%2C", ",").replace("%2c", ",")
}
fn html_entries(root: &Path) -> Vec<String> {
let Ok(html) = std::fs::read_to_string(root.join("index.html")) else {
return Vec::new();
};
let mut entries = Vec::new();
for tag_start in html.match_indices("<script").map(|(i, _)| i) {
let Some(tag_end) = html[tag_start..].find('>') else { continue };
let tag = &html[tag_start..tag_start + tag_end];
if !tag.contains("type=\"module\"") {
continue;
}
if let Some(src_at) = tag.find("src=\"") {
let rest = &tag[src_at + 5..];
if let Some(end) = rest.find('"') {
let src = &rest[..end];
if src.starts_with('/') {
entries.push(src.to_string());
}
}
}
}
entries
}
fn spawn_crawl(state: Arc<ServerState>, done_tx: tokio::sync::watch::Sender<bool>) {
tokio::spawn(async move {
let started = Instant::now();
let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut queue: Vec<String> = html_entries(&state.root);
let mut tasks = tokio::task::JoinSet::new();
loop {
for url in queue.drain(..) {
if !visited.insert(url.clone()) {
continue;
}
let file = if let Some(abs) = url.strip_prefix("/@fs") {
let f = PathBuf::from(abs);
let ok = { let a = state.fs_allow.lock().unwrap();
a.iter().any(|r| f.starts_with(r)) };
if !ok { continue; }
f
} else {
let rel = url.trim_start_matches('/').to_string();
match locate(&state.root, &state.public_dir, &rel) { Some(f) => f, None => continue }
};
let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
if !COMPILABLE.contains(&ext) && !matches!(ext, "css" | "scss" | "sass" | "json") {
continue;
}
let state = Arc::clone(&state);
tasks.spawn(async move {
match ensure_module(&state, &file, &url).await {
Ok((_, module)) => module.imports.clone(),
Err(err) => {
eprintln!("oj: crawl: {err}");
Vec::new()
}
}
});
}
match tasks.join_next().await {
None => break,
Some(imports) => {
for import in imports.unwrap_or_default() {
let import =
import.split('?').next().unwrap_or(&import).to_string();
if import.starts_with('/')
&& !import.starts_with("/@oj/")
&& !visited.contains(&import)
{
queue.push(import);
}
}
}
}
}
let paths = state.graph.lock().unwrap().module_paths();
println!("{} eager graph ready: {} modules in {:?}", oj_tag(), paths.len(), started.elapsed());
save_graph_snapshot(&state.root, &paths);
let _ = done_tx.send(true);
});
}
fn snapshot_path(root: &Path) -> PathBuf {
root.join(".oj-cache").join("graph-snapshot.json")
}
fn load_graph_snapshot(root: &Path) -> Vec<String> {
std::fs::read(snapshot_path(root))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn save_graph_snapshot(root: &Path, paths: &[PathBuf]) {
let urls: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
let path = snapshot_path(root);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, serde_json::to_vec(&urls).unwrap_or_default());
}
fn spawn_watcher(state: Arc<ServerState>) {
std::thread::spawn(move || {
use notify::{RecursiveMode, Watcher};
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = match notify::recommended_watcher(tx) {
Ok(w) => w,
Err(err) => {
eprintln!("oj: file watcher failed to start: {err}");
return;
}
};
if let Err(err) = watcher.watch(&state.root, RecursiveMode::Recursive) {
eprintln!("oj: cannot watch {}: {err}", state.root.display());
return;
}
use std::sync::mpsc::RecvTimeoutError;
let debounce_ms: u64 = std::env::var("OJ_HMR_DEBOUNCE_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10);
loop {
let first = match rx.recv() {
Ok(Ok(ev)) => ev,
Ok(Err(_)) => continue,
Err(_) => break, };
let mut paths: std::collections::HashSet<PathBuf> =
first.paths.into_iter().collect();
loop {
match rx.recv_timeout(Duration::from_millis(debounce_ms)) {
Ok(Ok(ev)) => paths.extend(ev.paths),
Ok(Err(_)) => {}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => return,
}
}
let paths: Vec<PathBuf> = paths.into_iter().collect();
let messages = decide(&state, &paths);
if messages.is_empty() {
continue;
}
*state.chunk_cache.lock().unwrap() = None;
state.dir_cache.lock().unwrap().clear();
for message in messages {
let _ = state.reload_tx.send(message);
}
}
});
}
fn decide(state: &ServerState, paths: &[PathBuf]) -> Vec<String> {
let mut messages: Vec<String> = Vec::new();
let mut updates: Vec<serde_json::Value> = Vec::new();
let plugin_watched: std::collections::HashSet<PathBuf> = match &state.plugins {
Some(host) => state
.rt
.block_on(host.watch_files())
.unwrap_or_default()
.into_iter()
.map(|p| std::fs::canonicalize(&p).unwrap_or_else(|_| PathBuf::from(p)))
.collect(),
None => Default::default(),
};
let source_changed = paths.iter().any(|p| {
!p.components().any(|c| {
let c = c.as_os_str();
c == "node_modules" || c == ".oj-cache" || c == "dist"
})
&& p.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| COMPILABLE.contains(&e))
});
if source_changed {
let timestamp = now_millis() as u64;
for url in state.tailwind_urls.lock().unwrap().iter() {
messages.push(
serde_json::json!({ "type": "css-update", "path": url, "timestamp": timestamp })
.to_string(),
);
}
}
for path in paths {
if path.components().any(|c| {
let c = c.as_os_str();
c == "node_modules" || c == ".oj-cache" || c == "dist"
}) {
continue;
}
if let Some(host) = &state.plugins {
let file = path.display().to_string();
let ts = now_millis() as u64;
let _ = state.rt.block_on(host.watch_change(&file, "update"));
match state.rt.block_on(host.handle_hot_update(&file, ts)) {
Ok(Some(d)) if d == "skip" => {
println!("oj: change {file} -> HMR suppressed by plugin");
continue;
}
Ok(Some(d)) if d == "full-reload" => {
println!("oj: change {file} -> full-reload (plugin)");
messages.push(
serde_json::json!({ "type": "full-reload", "reason": "plugin" }).to_string(),
);
return messages;
}
_ => {}
}
}
if !plugin_watched.is_empty() {
let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
if plugin_watched.contains(&canon) {
println!("oj: change {} -> full-reload (plugin watch)", path.display());
messages.push(
serde_json::json!({ "type": "full-reload", "reason": "plugin-watch" }).to_string(),
);
return messages;
}
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "css" {
let url = url_of(&state.root, path);
if !state.graph.lock().unwrap().contains(Path::new(&url)) {
println!("oj: change {url} -> css-update");
messages.push(
serde_json::json!({
"type": "css-update",
"path": url,
"timestamp": now_millis() as u64,
})
.to_string(),
);
continue;
}
}
if ext == "html" {
println!("oj: change {} -> full-reload", path.display());
messages.push(
serde_json::json!({ "type": "full-reload", "reason": path.display().to_string() })
.to_string(),
);
return messages;
}
if !COMPILABLE.contains(&ext) && !matches!(ext, "css" | "scss" | "sass" | "json") {
continue;
}
let url = url_of(&state.root, path);
if state.bundle {
let plan = state.graph.lock().unwrap().update_plan(Path::new(&url));
match plan {
Ok(plan) => {
println!("oj: change {url} -> patch {:?}", plan.boundaries);
let to_urls = |v: &[PathBuf]| -> Vec<String> {
v.iter().map(|p| p.display().to_string()).collect()
};
let seq = state
.patch_seq
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
messages.push(
serde_json::json!({
"type": "patch",
"changed": [url],
"dirty": to_urls(&plan.dirty),
"boundaries": to_urls(&plan.boundaries),
"timestamp": now_millis() as u64,
"seq": seq,
})
.to_string(),
);
continue;
}
Err(reason) => {
println!("oj: change {url} -> full-reload ({reason})");
messages.push(
serde_json::json!({ "type": "full-reload", "reason": reason }).to_string(),
);
return messages;
}
}
}
let decision = state.graph.lock().unwrap().propagate_update(Path::new(&url));
match decision {
HmrDecision::Update { boundaries } => {
println!("oj: change {url} -> update {boundaries:?}");
let timestamp = now_millis() as u64;
updates.extend(boundaries.iter().map(|b| {
let mut path = format!("{}", b.display());
if path.ends_with(".css") {
path.push_str("?import");
}
serde_json::json!({ "path": path, "timestamp": timestamp })
}));
}
HmrDecision::FullReload { reason } => {
println!("oj: change {url} -> full-reload ({reason})");
messages.push(
serde_json::json!({ "type": "full-reload", "reason": reason }).to_string(),
);
return messages;
}
}
}
if !updates.is_empty() {
messages.push(serde_json::json!({ "type": "update", "updates": updates }).to_string());
}
messages
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_injection_puts_preamble_first_in_head() {
let out = inject_dev_scripts("<html><head><title>x</title></head></html>".into());
let preamble = out.find("refresh-preamble").unwrap();
let title = out.find("<title>").unwrap();
assert!(preamble < title);
}
#[test]
fn glue_only_added_for_boundary_modules() {
assert!(hot_glue("/src/util.ts", None, false).is_empty());
let glue = hot_glue("/src/App.tsx", Some("t=123"), true);
assert!(glue.contains(r#"createHotContext("/src/App.tsx")"#));
assert!(glue.contains(r#"from "/src/App.tsx?t=123""#), "{glue}");
assert!(glue.contains("validateRefreshBoundaryAndEnqueueUpdate"));
assert!(glue.contains("function $RefreshReg$"));
}
#[test]
fn normalize_resolves_parent_components() {
assert_eq!(
normalize(Path::new("/a/b/../c/./d.ts")),
PathBuf::from("/a/c/d.ts")
);
}
#[test]
fn preview_rel_maps_base_and_guards_traversal() {
assert_eq!(preview_rel("/", "/").as_deref(), Some("index.html"));
assert_eq!(preview_rel("/assets/x.js", "/").as_deref(), Some("assets/x.js"));
assert_eq!(preview_rel("/app/assets/x.js", "/app/").as_deref(), Some("assets/x.js"));
assert_eq!(preview_rel("/app/", "/app/").as_deref(), Some("index.html"));
assert_eq!(preview_rel("/../etc/passwd", "/"), None);
}
#[test]
fn spa_navigation_falls_back_only_for_routes() {
let html = {
let mut h = HeaderMap::new();
h.insert(header::ACCEPT, "text/html,application/xhtml+xml".parse().unwrap());
h
};
let empty = HeaderMap::new();
assert!(is_spa_navigation("dashboard", &empty));
assert!(is_spa_navigation("users/123/edit", &empty));
assert!(is_spa_navigation("report.v2", &html));
assert!(!is_spa_navigation("missing.png", &empty));
assert!(!is_spa_navigation("assets/app.js", &empty));
assert!(!is_spa_navigation("@vite/client", &html));
assert!(!is_spa_navigation("src/does-not-exist.tsx", &html));
assert!(!is_spa_navigation("node_modules/react/missing.js", &html));
}
}
#[cfg(test)]
mod adapter_tests {
use super::*;
fn tmp(label: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("oj-srv-{}-{label}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn is_tanstack_start_app_requires_routes_and_dep() {
let base = tmp("ts");
let app = base.join("app");
std::fs::create_dir_all(app.join("src").join("routes")).unwrap();
std::fs::write(app.join("package.json"), r#"{"dependencies":{"react":"19"}}"#).unwrap();
assert!(!is_tanstack_start_app(&app));
std::fs::write(app.join("package.json"), r#"{"dependencies":{"@tanstack/react-start":"1"}}"#).unwrap();
assert!(is_tanstack_start_app(&app));
let app2 = base.join("app2");
std::fs::create_dir_all(app2.join("src")).unwrap();
std::fs::write(app2.join("package.json"), r#"{"dependencies":{"@tanstack/react-start":"1"}}"#).unwrap();
assert!(!is_tanstack_start_app(&app2));
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn locate_prefers_root_then_public_dir() {
let base = tmp("locate");
let root = base.join("root");
let public = base.join("shared-public");
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(public.join("img")).unwrap();
std::fs::write(root.join("src").join("App.tsx"), "x").unwrap();
std::fs::write(public.join("img").join("logo.webp"), "y").unwrap();
assert_eq!(locate(&root, &public, "src/App"), Some(root.join("src/App.tsx")));
assert_eq!(locate(&root, &public, "img/logo.webp"), Some(public.join("img/logo.webp")));
assert_eq!(locate(&root, &public, "img/missing.webp"), None);
assert_eq!(locate(&root, &public, "../secret"), None);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn is_spa_navigation_rules() {
let empty = HeaderMap::new();
assert!(is_spa_navigation("dashboard", &empty));
assert!(is_spa_navigation("projects/abc", &empty));
assert!(!is_spa_navigation("main.js", &empty));
assert!(!is_spa_navigation("@vite/client", &empty));
assert!(!is_spa_navigation("src/App.tsx", &empty));
assert!(!is_spa_navigation("node_modules/react/index.js", &empty));
let mut html = HeaderMap::new();
html.insert(header::ACCEPT, "text/html,*/*".parse().unwrap());
assert!(is_spa_navigation("some.thing", &html));
}
}