use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SsrResolution {
Module(String),
External(String),
}
#[derive(Debug)]
pub enum SsrModuleError {
Forbidden(String),
NotFound(String),
Failed(String),
}
impl std::fmt::Display for SsrModuleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SsrModuleError::Forbidden(m)
| SsrModuleError::NotFound(m)
| SsrModuleError::Failed(m) => f.write_str(m),
}
}
}
#[derive(Clone)]
pub struct SsrBridge {
pub(crate) state: Arc<ServerState>,
}
impl SsrBridge {
pub fn engine_registry(&self) -> oj_js::EngineRegistry {
self.state.engine_registry.clone()
}
pub async fn resolve(&self, importer: &str, spec: &str) -> Result<SsrResolution, String> {
ssr_resolve_inner(&self.state, importer, spec).await
}
pub async fn load_module(&self, id: &str) -> Result<String, SsrModuleError> {
ssr_module_inner(&self.state, id, false).await
}
pub async fn resolve_start(
&self,
importer: &str,
spec: &str,
) -> Result<StartResolution, String> {
Ok(match ssr_resolve_raw(&self.state, importer, spec).await? {
RawResolution::Path(p) if p.to_string_lossy().contains("/node_modules/") => {
StartResolution::Dependency(p)
}
RawResolution::Path(p) => StartResolution::Module(p.to_string_lossy().into_owned()),
RawResolution::PluginId(id) => StartResolution::Module(id),
RawResolution::Bare => StartResolution::Bare(spec.to_string()),
})
}
pub async fn transform_module(
&self,
id: &str,
source: String,
from_plugin: bool,
run_plugins: bool,
) -> Result<String, SsrModuleError> {
if run_plugins {
ssr_transform_source(&self.state, id, source, from_plugin, false).await
} else {
ssr_compile_source(&self.state, id, source, from_plugin, false)
}
}
pub async fn plugin_host(&self) -> Option<std::sync::Arc<PluginHost>> {
ssr_plugin_host(&self.state).await
}
pub fn module_allowed(&self, path: &Path) -> bool {
ssr_module_allowed(&self.state, path)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StartResolution {
Module(String),
Dependency(PathBuf),
Bare(String),
}
enum RawResolution {
Path(PathBuf),
PluginId(String),
Bare,
}
async fn ssr_resolve_raw(
state: &Arc<ServerState>,
importer: &str,
spec: &str,
) -> Result<RawResolution, String> {
let importer_dir = Path::new(importer).parent().unwrap_or(&state.root);
let err = match state.ssr_resolver.resolve(importer_dir, spec) {
Ok(p) => return Ok(RawResolution::Path(p)),
Err(e) => e,
};
if let Some(host) = ssr_plugin_host(state).await {
if let Ok(Some(id)) = host.resolve_id(spec, importer).await {
return Ok(RawResolution::PluginId(id));
}
}
if !spec.starts_with('.') && !spec.starts_with('/') {
return Ok(RawResolution::Bare);
}
Err(format!("cannot resolve {spec}: {}", err.reason))
}
pub(crate) async fn ssr_resolve_inner(
state: &Arc<ServerState>,
importer: &str,
spec: &str,
) -> Result<SsrResolution, String> {
Ok(match ssr_resolve_raw(state, importer, spec).await? {
RawResolution::Path(p) => {
let s = p.to_string_lossy();
if s.contains("/node_modules/") {
SsrResolution::External(spec.to_string())
} else {
SsrResolution::Module(s.into_owned())
}
}
RawResolution::PluginId(id) => SsrResolution::Module(id),
RawResolution::Bare => SsrResolution::External(spec.to_string()),
})
}
pub(crate) 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();
};
match ssr_resolve_inner(&state, importer, spec).await {
Ok(SsrResolution::Module(id)) => js_response_json(serde_json::json!({ "id": id })),
Ok(SsrResolution::External(spec)) => {
js_response_json(serde_json::json!({ "external": true, "spec": spec }))
}
Err(e) => (StatusCode::NOT_FOUND, e).into_response(),
}
}
pub(crate) fn js_response_json(v: serde_json::Value) -> Response {
([(header::CONTENT_TYPE, "application/json")], v.to_string()).into_response()
}
pub(crate) fn module_read_allowed(
root: &Path,
allow: &std::collections::HashSet<PathBuf>,
path: &Path,
) -> bool {
let Ok(candidate) = std::fs::canonicalize(path) else {
return true;
};
let real = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
if candidate.starts_with(real(root)) {
return true;
}
if candidate
.components()
.any(|c| c.as_os_str() == "node_modules")
{
return true;
}
allow
.iter()
.any(|allowed| candidate.starts_with(real(allowed)))
}
pub(crate) fn ssr_module_allowed(state: &ServerState, path: &Path) -> bool {
let allow = state.fs_allow.lock().unwrap().clone();
module_read_allowed(&state.root, &allow, path)
}
pub(crate) async fn ssr_module_inner(
state: &Arc<ServerState>,
id: &str,
runner: bool,
) -> Result<String, SsrModuleError> {
let path = PathBuf::from(id);
if !ssr_module_allowed(state, &path) {
return Err(SsrModuleError::Forbidden(
"oj: module not allow-listed".into(),
));
}
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) if host.hook_wants_load(id) => match host.load(id).await {
Ok(Some(code)) => (code, true),
_ => return Err(SsrModuleError::NotFound(format!("{id}: {read_err}"))),
},
Some(_) | None => return Err(SsrModuleError::NotFound(format!("{id}: {read_err}"))),
},
};
let ext = path.extension().and_then(|e| e.to_str());
if !from_plugin && ext.is_some_and(is_style_ext) {
let source = if is_preprocessor(id) {
run_preprocess_engine(state, id, &source, serde_json::Value::Null)
.await
.map_err(SsrModuleError::Failed)?
} else {
source
};
return ssr_css_module(&state.root, &path, &source).map_err(SsrModuleError::Failed);
}
if !from_plugin && ext == Some("json") {
return oj_compiler::json::to_esm(&source, id)
.map_err(|e| SsrModuleError::Failed(format!("{e}")));
}
ssr_transform_source(state, id, source, from_plugin, runner).await
}
pub(crate) async fn ssr_transform_source(
state: &Arc<ServerState>,
id: &str,
source: String,
from_plugin: bool,
runner: bool,
) -> Result<String, SsrModuleError> {
let source = match ssr_plugin_host(state).await {
Some(host) if host.hook_wants_transform(id, &source) => {
let resolved =
resolved_imports_json(&state.resolver, &state.fs_allow, &source, Path::new(id));
match host.transform(&source, id, &resolved).await {
Ok((code, _, _, _)) => code,
Err(e) => {
return Err(SsrModuleError::Failed(format!(
"oj: plugin transform error for {id}:\n{e}"
)));
}
}
}
Some(_) => {
if plugins::hook_gate_debug() {
eprintln!("oj: hook gate skipped ssr transform for {id}");
}
source
}
None => source,
};
ssr_compile_source(state, id, source, from_plugin, runner)
}
pub(crate) fn ssr_compile_source(
state: &Arc<ServerState>,
id: &str,
source: String,
from_plugin: bool,
runner: bool,
) -> Result<String, SsrModuleError> {
let compile_path: PathBuf = if from_plugin {
PathBuf::from("virtual.tsx")
} else {
PathBuf::from(id)
};
let mut opts = dev_compile_opts(state);
opts.refresh = false;
opts.ssr = true;
if runner {
return match oj_compiler::ssr::ssr_transform_module_with_map(&compile_path, &source, &opts)
{
Ok((code, map)) => Ok(with_inline_map(code, map)),
Err(e) => Err(SsrModuleError::Failed(format!("{e}"))),
};
}
match oj_compiler::compile(&compile_path, &source, &opts) {
Ok(out) => Ok(with_inline_map(out.code, out.map_json)),
Err(e) => Err(SsrModuleError::Failed(format!("{e}"))),
}
}
pub(crate) 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 runner = q.get("runner").map(|v| v == "1").unwrap_or(false);
match ssr_module_inner(&state, id, runner).await {
Ok(code) => js(code),
Err(SsrModuleError::Forbidden(m)) => (StatusCode::FORBIDDEN, m).into_response(),
Err(SsrModuleError::NotFound(m)) => (StatusCode::NOT_FOUND, m).into_response(),
Err(SsrModuleError::Failed(m)) => (StatusCode::INTERNAL_SERVER_ERROR, m).into_response(),
}
}
pub(crate) async fn ssr_plugin_host(
state: &Arc<ServerState>,
) -> Option<std::sync::Arc<PluginHost>> {
let host = state
.plugins_ssr
.get_or_init(|| async {
let file = match plugins::plugin_source(&state.root, state.config_file.as_deref())? {
plugins::PluginSource::OjPlugins(p) | plugins::PluginSource::ViteConfig(p) => p,
};
match PluginHost::spawn_lazy(
&state.root,
&file,
&state.ssr_plugin_config,
Some(state.engine_registry.clone()),
)
.await
{
Ok(host) => {
host.set_import_meta_env(Arc::clone(&state.import_meta_env));
eprintln!("oj ssr: plugins (ssr environment) from {}", file.display());
spawn_ssr_watch_catch_up(
std::sync::Arc::clone(&host),
Arc::clone(&state.ssr_watch),
);
let ssr_defines = host.config_defines().await;
if !ssr_defines.is_empty() {
state.import_meta_env.merge_ssr(ssr_defines);
}
Some(host)
}
Err(e) => {
eprintln!("oj ssr: plugin host failed to start: {e}");
None
}
}
})
.await
.clone();
if let Some(h) = &host {
h.prime_hook_plan().await;
}
host
}
#[derive(Default)]
pub(crate) struct SsrWatchQueue {
pub(crate) backlog: Mutex<Vec<(String, String)>>,
order: tokio::sync::Mutex<()>,
logged: std::sync::atomic::AtomicBool,
}
pub(crate) fn note_ssr_watch_skip(queue: &SsrWatchQueue, file: &str, change_type: &str) {
{
let mut b = queue.backlog.lock().unwrap();
if let Some(entry) = b.iter_mut().find(|(f, _)| f == file) {
entry.1 = change_type.to_string();
} else {
b.push((file.to_string(), change_type.to_string()));
}
}
if !queue.logged.swap(true, std::sync::atomic::Ordering::SeqCst) {
println!(
"oj: ssr plugin host still initializing; queuing file changes for a catch-up replay at its init"
);
}
}
pub(crate) async fn replay_ssr_watch_backlog(host: &PluginHost, queue: &SsrWatchQueue) {
let _order = queue.order.lock().await;
loop {
let batch: Vec<(String, String)> = {
let mut b = queue.backlog.lock().unwrap();
b.drain(..).collect()
};
if batch.is_empty() {
return;
}
println!(
"oj: ssr plugin host caught up: replaying {} file change(s) missed during its init",
batch.len()
);
for (file, ev) in batch {
if let Err(e) = host.watch_change(&file, &ev).await {
eprintln!("oj: watchChange (ssr catch-up) failed for {file}: {e}");
}
}
}
}
pub(crate) fn spawn_ssr_watch_catch_up(
host: std::sync::Arc<PluginHost>,
queue: Arc<SsrWatchQueue>,
) {
tokio::spawn(async move {
let mut init = host.initialized_updates();
let mut gone = host.host_gone_updates();
loop {
if *init.borrow_and_update() {
break;
}
if *gone.borrow_and_update() && !host.can_revive() {
return;
}
tokio::select! {
changed = init.changed() => { if changed.is_err() { return; } }
changed = gone.changed() => { if changed.is_err() { return; } }
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {}
}
}
replay_ssr_watch_backlog(&host, &queue).await;
});
}
pub(crate) fn sass_additional_data_for(state: &ServerState, url: &str) -> Option<String> {
if !oj_css::is_sass(url) {
return None;
}
let indented = url.split('?').next().unwrap_or(url).ends_with(".sass");
if indented {
state.sass_additional_data.clone()
} else {
state.scss_additional_data.clone()
}
}
pub(crate) fn sass_load_paths_for(state: &ServerState, url: &str) -> Vec<PathBuf> {
if !oj_css::is_sass(url) {
return Vec::new();
}
let Some(css) = &state.css_config else {
return Vec::new();
};
let cfg = oj_config::OjConfig {
css: Some(css.clone()),
..Default::default()
};
let lang = if url.split('?').next().unwrap_or(url).ends_with(".sass") {
"sass"
} else {
"scss"
};
oj_config::css_load_paths(&cfg, lang)
.into_iter()
.map(|p| state.root.join(p))
.collect()
}
pub(crate) 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) => oj_css::css_modules_esm(&exports),
None => "export default {};".to_string(),
})
}
pub fn css_modules_options(config: &oj_config::OjConfig) -> oj_css::CssModulesOptions {
let m = oj_config::css_modules(config);
oj_css::CssModulesOptions {
locals_convention: m.locals_convention,
generate_scoped_name: m.generate_scoped_name,
global_scope: m.global_scope,
global_module_paths: m.global_module_paths,
}
}