use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::LazyLock;
use oj_stylex::plugin::{
DevMode, StylexPlugin, CSS_UPDATE_EVENT, DEV_CSS_PATH, DEV_RUNTIME_JS, DEV_RUNTIME_PATH,
RUNTIME_ID,
};
use super::*;
const UPDATE_COALESCE: std::time::Duration = std::time::Duration::from_millis(150);
static SHARED: LazyLock<Mutex<HashMap<String, std::sync::Weak<StylexPlugin>>>> =
LazyLock::new(Default::default);
pub fn plugin_for(
root: &Path,
config: &oj_config::OjConfig,
command: &str,
) -> Result<Option<Arc<StylexPlugin>>, String> {
let Some(stylex) = &config.vite_stylex else {
return Ok(None);
};
let options = stylex.get("options").unwrap_or(&serde_json::Value::Null);
let build = command == "build";
let knobs = &oj_env::get().knobs;
let node_env =
knobs
.node_env
.as_deref()
.unwrap_or(if build { "production" } else { "development" });
let dev = node_env == "development" || knobs.babel_env.as_deref() == Some("development");
let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let key = format!("{command}\0{dev}\0{}\0{options}", canonical.display());
let mut shared = SHARED.lock().unwrap();
shared.retain(|_, plugin| plugin.strong_count() > 0);
let plugin = match shared.get(&key).and_then(std::sync::Weak::upgrade) {
Some(plugin) => plugin,
None => {
let plugin = Arc::new(StylexPlugin::new(options, root, dev, !build)?);
for warning in plugin.warnings() {
eprintln!("{warning}");
}
shared.insert(key, Arc::downgrade(&plugin));
plugin
}
};
if !build && plugin.dev_mode() == DevMode::Off {
return Ok(None);
}
Ok(Some(plugin))
}
pub(crate) fn notify_clients(plugin: &StylexPlugin, tx: broadcast::Sender<String>) {
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
return;
};
let pending = Arc::new(AtomicBool::new(false));
plugin.set_change_listener(Arc::new(move || {
if pending.swap(true, Ordering::AcqRel) {
return;
}
let (pending, tx) = (Arc::clone(&pending), tx.clone());
runtime.spawn(async move {
tokio::time::sleep(UPDATE_COALESCE).await;
pending.store(false, Ordering::Release);
let event = serde_json::json!({ "type": "custom", "event": CSS_UPDATE_EVENT });
let _ = tx.send(event.to_string());
});
}));
}
pub(crate) struct PreTransformed {
pub code: String,
pub map: Option<String>,
pub rules: Vec<oj_stylex::rules::StylexRule>,
}
impl PreTransformed {
pub(crate) fn unchanged(code: String) -> Self {
Self {
code,
map: None,
rules: Vec::new(),
}
}
}
pub(crate) fn pre_transform(
state: &ServerState,
id: &str,
source: &str,
) -> Result<Option<PreTransformed>, String> {
let Some(plugin) = &state.stylex else {
return Ok(None);
};
if !plugin.handles(id, source) {
return Ok(None);
}
let file = Path::new(id.split('?').next().unwrap_or(id));
Ok(Some(match plugin.transform_source(file, source)? {
Some(out) => PreTransformed {
code: out.code,
map: out.map,
rules: out.rules,
},
None => PreTransformed {
code: source.to_string(),
map: None,
rules: Vec::new(),
},
}))
}
pub(crate) fn record(state: &ServerState, url: &str, rules: &[oj_stylex::rules::StylexRule]) {
record_id(state, &module_id(&state.root, url), rules);
}
pub(crate) fn record_id(state: &ServerState, id: &str, rules: &[oj_stylex::rules::StylexRule]) {
if let Some(plugin) = &state.stylex {
plugin.set_module_rules(id, rules.to_vec());
}
}
fn module_id(root: &Path, url: &str) -> String {
let url = strip_hmr_timestamp(url);
let url = match url.split_once('?') {
Some((path, query)) => {
let dep_version = |kv: &&str| {
kv.strip_prefix("v=")
.is_some_and(|v| v.len() == 8 && v.bytes().all(|b| b.is_ascii_hexdigit()))
};
let kept: Vec<&str> = query.split('&').filter(|kv| !dep_version(kv)).collect();
if kept.is_empty() {
path.to_string()
} else {
format!("{path}?{}", kept.join("&"))
}
}
None => url,
};
if let Some(path) = url.strip_prefix("/@fs/") {
return format!("/{path}");
}
if url.starts_with("/@") {
return url;
}
format!("{}{url}", root.display())
}
pub(crate) fn serve_route(state: &ServerState, path: &str) -> Option<Response> {
let plugin = state.stylex.as_ref()?;
if path == DEV_CSS_PATH {
let css = match dev_stylesheet(state, plugin) {
Ok(css) => css,
Err(e) => {
return Some(
(StatusCode::INTERNAL_SERVER_ERROR, format!("oj: {e}")).into_response(),
)
}
};
return Some(
(
[
(header::CONTENT_TYPE, "text/css"),
(header::CACHE_CONTROL, "no-store"),
],
css,
)
.into_response(),
);
}
if path == DEV_RUNTIME_PATH && plugin.dev_mode() == DevMode::Full {
return Some(js(DEV_RUNTIME_JS));
}
None
}
const BROWSERSLIST_FILES: [&str; 3] = ["package.json", ".browserslistrc", "browserslist"];
pub(crate) type SheetStamp = (u64, [Option<std::time::SystemTime>; 3]);
fn dev_stylesheet(state: &ServerState, plugin: &StylexPlugin) -> Result<String, String> {
let stamp: SheetStamp = (
plugin.generation(),
BROWSERSLIST_FILES.map(|f| {
std::fs::metadata(state.root.join(f))
.and_then(|m| m.modified())
.ok()
}),
);
if let Some((built, css)) = &*state.stylex_css.lock().unwrap() {
if *built == stamp {
return Ok(css.clone());
}
}
let css = match plugin.stylesheet()? {
Some(sheet) => oj_css::process_for_browserslist(
"stylex.css",
&sheet,
&state.root,
"development",
plugin.lightningcss_options(),
)?,
None => String::new(),
};
*state.stylex_css.lock().unwrap() = Some((stamp, css.clone()));
Ok(css)
}
pub(crate) fn virtual_module(state: &ServerState, id: &str) -> Option<&'static str> {
state.stylex.as_ref()?.virtual_module(id)
}
pub(crate) fn html_tags(
state: &ServerState,
base: &str,
) -> Vec<(&'static str, Vec<(&'static str, String)>)> {
match state.stylex.as_ref().map(|plugin| plugin.dev_mode()) {
Some(DevMode::Full) => vec![
(
"script",
vec![
("type", "module".to_string()),
("src", dev_html::posix_join(&[base, "/@id/", RUNTIME_ID])),
],
),
(
"link",
vec![
("rel", "stylesheet".to_string()),
("href", dev_html::posix_join(&[base, DEV_CSS_PATH])),
],
),
],
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::module_id;
use std::path::Path;
#[test]
fn module_ids_are_the_files_path_with_its_variant_query() {
let root = Path::new("/app");
assert_eq!(module_id(root, "/src/a.tsx"), "/app/src/a.tsx");
assert_eq!(
module_id(root, "/src/a.tsx?t=1700000000000"),
"/app/src/a.tsx"
);
assert_eq!(
module_id(root, "/src/a.tsx?alt&t=1700000000000"),
"/app/src/a.tsx?alt"
);
assert_eq!(
module_id(root, "/node_modules/ui/index.js?v=e80770d4"),
"/app/node_modules/ui/index.js"
);
assert_eq!(module_id(root, "/src/a.tsx?t=1"), "/app/src/a.tsx?t=1");
assert_eq!(module_id(root, "/src/a.tsx?v=2"), "/app/src/a.tsx?v=2");
assert_eq!(module_id(root, "/@fs/opt/lib/x.ts"), "/opt/lib/x.ts");
assert_eq!(module_id(root, "/@id/virtual:x"), "/@id/virtual:x");
}
}