diff --git a/Cargo.lock b/Cargo.lock index 55551165ed..51e076a643 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1904,6 +1904,7 @@ dependencies = [ "notify", "puffin_egui", "regex", + "swc_ecma_transforms_typescript", ] [[package]] diff --git a/crates/compiler/src/update/handle_update_modules.rs b/crates/compiler/src/update/handle_update_modules.rs index bcd55fea98..917a946d19 100644 --- a/crates/compiler/src/update/handle_update_modules.rs +++ b/crates/compiler/src/update/handle_update_modules.rs @@ -16,6 +16,7 @@ pub fn handle_update_modules( context: &Arc, update_result: &mut UpdateResult, ) -> farmfe_core::error::Result> { + let paths = sanitize_update_paths(paths); let (before_paths, start_time) = if context.config.record { ( paths.clone(), @@ -27,8 +28,12 @@ pub fn handle_update_modules( } else { (vec![], 0) }; - let paths = resolve_watch_graph_paths(paths, context); - let paths = resolve_last_failed_module_paths(paths, last_fail_module_ids, context); + let paths = sanitize_update_paths(resolve_watch_graph_paths(paths, context)); + let paths = sanitize_update_paths(resolve_last_failed_module_paths( + paths, + last_fail_module_ids, + context, + )); if context.config.record { let end_time = std::time::SystemTime::now() @@ -66,7 +71,7 @@ pub fn handle_update_modules( } else { ("".to_string(), 0) }; - let paths = plugin_update_modules_hook_params.paths; + let paths = sanitize_update_paths(plugin_update_modules_hook_params.paths); let mut module_graph = context.module_graph.write(); let mut additional_paths = vec![]; @@ -120,16 +125,22 @@ pub fn handle_update_modules( // if /root/index.vue and /root/index.vue?foo=bar are both in paths, we should remove /root/index.vue?foo=bar if path != resolved_path { let child_module_id: ModuleId = relative(&context.config.root, &path).into(); + + // Child may already be absent after previous updates/plugins. Skip instead of panicking. + if !module_graph.has_module(&child_module_id) { + result.push(path); + continue; + } + let dependents = module_graph.dependents_ids(&child_module_id); if dependents.contains(&module_id) && dependents.len() == 1 { let removed_module = module_graph.remove_module(&child_module_id); for module_group_id in removed_module.module_groups { - let module_group = module_group_graph - .module_group_mut(&module_group_id) - .unwrap(); - module_group.remove_module(&child_module_id); + if let Some(module_group) = module_group_graph.module_group_mut(&module_group_id) { + module_group.remove_module(&child_module_id); + } } update_result.removed_module_ids.push(child_module_id) @@ -188,6 +199,21 @@ struct UpdateModulesStatsResult { pub update_result: UpdateResult, } +fn sanitize_update_paths(paths: Vec<(String, UpdateType)>) -> Vec<(String, UpdateType)> { + paths + .into_iter() + .filter_map(|(path, ty)| { + let trimmed = path.trim(); + + if trimmed.is_empty() { + None + } else { + Some((trimmed.to_string(), ty)) + } + }) + .collect() +} + fn resolve_watch_graph_paths( paths: Vec<(String, UpdateType)>, context: &Arc, @@ -248,3 +274,74 @@ fn resolve_last_failed_module_paths( paths } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use farmfe_core::{ + config::Config, + context::CompilationContext, + module::{Module, ModuleId}, + plugin::{UpdateResult, UpdateType}, + }; + + use super::handle_update_modules; + + #[test] + fn should_filter_empty_update_paths() { + let root = std::env::temp_dir() + .join("farm-handle-update-modules-empty-path") + .to_string_lossy() + .to_string(); + let mut config = Config::default(); + config.root = root.clone(); + let context = Arc::new(CompilationContext::new(config, vec![]).unwrap()); + let mut update_result = UpdateResult::default(); + + let result = handle_update_modules( + vec![ + ("".to_string(), UpdateType::Updated), + (" ".to_string(), UpdateType::Updated), + ], + &[], + &context, + &mut update_result, + ) + .unwrap(); + + assert!(result.is_empty()); + } + + #[test] + fn should_not_panic_when_query_child_module_missing() { + let root = std::env::temp_dir() + .join("farm-handle-update-modules-missing-child") + .to_string_lossy() + .to_string(); + let mut config = Config::default(); + config.root = root.clone(); + let context = Arc::new(CompilationContext::new(config, vec![]).unwrap()); + + let base_path = format!("{root}/src/entry.ts"); + let base_module_id = ModuleId::new(&base_path, "", &root); + context + .module_graph + .write() + .add_module(Module::new(base_module_id)); + + let mut update_result = UpdateResult::default(); + let result = handle_update_modules( + vec![ + (base_path.clone(), UpdateType::Updated), + (format!("{base_path}?inline"), UpdateType::Updated), + ], + &[], + &context, + &mut update_result, + ) + .unwrap(); + + assert!(result.iter().any(|(path, _)| path == &base_path)); + } +} diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 77ba2ae710..1dfe26e7a4 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -18,6 +18,7 @@ farmfe_core = { path = "../core" } farmfe_toolkit = { path = "../toolkit" } farmfe_toolkit_plugin_types = { path = "../toolkit_plugin_types" } farmfe_plugin_resolve = { path = "../plugin_resolve" } +swc_ecma_transforms_typescript = "27.0.0" regex = "1" libloading = "0.8.6" eframe = { version = "0.24.1", default-features = false, features = [ diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 15debced87..190d641804 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -2,15 +2,15 @@ #![deny(clippy::all)] #![allow(clippy::redundant_allocation)] #![allow(clippy::blocks_in_conditions)] -#[cfg(feature = "file_watcher")] -use std::path::PathBuf; use std::{ + fs, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, }; use farmfe_compiler::{trace_module_graph::TracedModuleGraph, Compiler}; +pub mod module_runner_transform; pub mod plugin_adapters; #[cfg(feature = "profile")] pub mod profile_gui; @@ -25,12 +25,14 @@ use farmfe_core::{ }; use farmfe_plugin_resolve::resolver::{ResolveOptions, Resolver}; +use farmfe_toolkit::sourcemap::{collapse_sourcemap_chain, CollapseSourcemapOptions, SourceMap}; use napi::{ bindgen_prelude::{Buffer, FromNapiValue, JsObjectValue, Object, ObjectRef, Undefined}, threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, Env, JsValue, Status, Unknown, }; +use module_runner_transform::transform_script_module_to_runner_code; use plugin_adapters::{js_plugin_adapter::JsPluginAdapter, rust_plugin_adapter::RustPluginAdapter}; #[macro_use] @@ -94,9 +96,496 @@ pub struct JsUpdateResult { pub extra_watch_result: WatchDiffResult, } +#[napi(object)] +pub struct JsFetchModuleOptions { + pub cached: Option, + pub start_offset: Option, +} + +#[napi(object)] +pub struct JsFetchModuleResult { + pub cache: Option, + pub externalize: Option, + pub bailout_reason: Option, + pub r#type: Option, + pub code: Option, + pub file: Option, + pub id: Option, + pub url: Option, + pub invalidate: Option, + pub map: Option, +} + #[napi(js_name = "Compiler")] pub struct JsCompiler { compiler: Arc, + module_runner_transform_cache: RunnerTransformCacheStore, +} + +type RunnerTransformCacheStore = Mutex>; + +#[derive(Clone)] +struct RunnerTransformCacheEntry { + content_hash: String, + code: String, + map: Option, +} + +fn clean_url(value: &str) -> &str { + let without_query = value + .split_once('?') + .map(|(before, _)| before) + .unwrap_or(value); + + without_query + .split_once('#') + .map(|(before, _)| before) + .unwrap_or(without_query) +} + +fn path_to_file_url(path: &Path) -> String { + let normalized = path.to_string_lossy().replace('\\', "/"); + + if cfg!(windows) { + if normalized.starts_with('/') { + format!("file://{normalized}") + } else { + format!("file:///{normalized}") + } + } else { + format!("file://{normalized}") + } +} + +fn normalize_importer_path(importer: Option<&str>, root: &str) -> Option { + let importer = clean_url(importer?); + + if importer.starts_with("file://") { + let raw = importer.trim_start_matches("file://"); + #[cfg(windows)] + { + if raw.len() >= 3 && raw.starts_with('/') && raw.as_bytes()[2] == b':' { + return Some(PathBuf::from(&raw[1..])); + } + } + return Some(PathBuf::from(raw)); + } + + if importer.starts_with('/') { + return Some(Path::new(root).join(importer.trim_start_matches('/'))); + } + + Some(PathBuf::from(importer)) +} + +fn try_resolve_file_path(id: &str, importer: Option<&str>, root: &str) -> Option { + let id = clean_url(id); + + if id.starts_with("file://") { + return normalize_importer_path(Some(id), root); + } + + if Path::new(id).is_absolute() { + return Some(PathBuf::from(id)); + } + + if id.starts_with('/') { + return Some(Path::new(root).join(id.trim_start_matches('/'))); + } + + if id.starts_with("./") || id.starts_with("../") { + let importer_path = normalize_importer_path(importer, root)?; + let base_dir = if importer_path.is_file() { + importer_path.parent().unwrap_or(importer_path.as_path()) + } else { + importer_path.as_path() + }; + return Some(base_dir.join(id)); + } + + None +} + +fn module_type_by_path(path: &Path) -> String { + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("cjs")) + { + return "commonjs".to_string(); + } + + "module".to_string() +} + +fn external_fetch_result( + externalize: String, + r#type: String, + bailout_reason: Option, +) -> JsFetchModuleResult { + JsFetchModuleResult { + cache: None, + externalize: Some(externalize), + bailout_reason, + r#type: Some(r#type), + code: None, + file: None, + id: None, + url: None, + invalidate: None, + map: None, + } +} + +fn extract_query_string(value: &str) -> String { + let without_hash = value + .split_once('#') + .map(|(before, _)| before) + .unwrap_or(value); + without_hash + .split_once('?') + .map(|(_, query)| format!("?{query}")) + .unwrap_or_default() +} + +fn stringify_query_pairs(query: &[(String, String)]) -> String { + if query.is_empty() { + return String::new(); + } + + format!( + "?{}", + query + .iter() + .map(|(key, value)| { + if value.is_empty() { + key.clone() + } else { + format!("{key}={value}") + } + }) + .collect::>() + .join("&") + ) +} + +fn module_id_from_resolved_path_and_query( + resolved_path: &str, + query_string: &str, + root: &str, +) -> ModuleId { + if resolved_path.contains('?') { + return ModuleId::from_resolved_path_with_query(resolved_path, root); + } + + if query_string.is_empty() { + return ModuleId::new(resolved_path, "", root); + } + + ModuleId::from_resolved_path_with_query(&format!("{resolved_path}{query_string}"), root) +} + +fn is_runner_ready_inlined_code(code: &str) -> bool { + code.contains("__farm_ssr_export_name__") + || code.contains("__farm_ssr_import__") + || code.contains("__farm_ssr_dynamic_import__") +} + +fn collapse_module_source_map( + module: &farmfe_core::module::Module, + transformed_map: Option, +) -> Option { + let mut chain = module + .source_map_chain + .iter() + .filter_map(|item| SourceMap::from_slice(item.as_bytes()).ok()) + .collect::>(); + + if let Some(map) = transformed_map { + if let Ok(parsed) = SourceMap::from_slice(map.as_bytes()) { + chain.push(parsed); + } + } + + if chain.is_empty() { + return None; + } + + let collapsed = collapse_sourcemap_chain(chain, CollapseSourcemapOptions::default()); + let mut buffer = vec![]; + collapsed.to_writer(&mut buffer).ok()?; + + String::from_utf8(buffer).ok() +} + +fn inlined_fetch_result( + module_id: &ModuleId, + code: String, + file: Option, + url: String, + map: Option, +) -> JsFetchModuleResult { + JsFetchModuleResult { + cache: None, + externalize: None, + bailout_reason: None, + r#type: None, + code: Some(code), + file, + id: Some(module_id.to_string()), + url: Some(url), + invalidate: Some(false), + map, + } +} + +enum InlinedFetchAttempt { + Inlined(JsFetchModuleResult), + Bailout(String), + NotApplicable, +} + +fn get_cached_runner_transform( + cache: &RunnerTransformCacheStore, + module_key: &str, + content_hash: &str, +) -> Option<(String, Option)> { + let cache = cache.lock().ok()?; + let entry = cache.get(module_key)?; + + if entry.content_hash != content_hash { + return None; + } + + Some((entry.code.clone(), entry.map.clone())) +} + +fn set_cached_runner_transform( + cache: &RunnerTransformCacheStore, + module_key: String, + content_hash: String, + code: String, + map: Option, +) { + if let Ok(mut cache) = cache.lock() { + cache.insert( + module_key, + RunnerTransformCacheEntry { + content_hash, + code, + map, + }, + ); + } +} + +fn invalidate_cached_runner_transform(cache: &RunnerTransformCacheStore, module_key: &str) { + if let Ok(mut cache) = cache.lock() { + cache.remove(module_key); + } +} + +fn try_inlined_fetch_result( + context: &Arc, + module_id: &ModuleId, + transform_cache: Option<&RunnerTransformCacheStore>, +) -> InlinedFetchAttempt { + let module_graph = context.module_graph.read(); + let Some(module) = module_graph.module(module_id) else { + return InlinedFetchAttempt::NotApplicable; + }; + + if module.external { + return InlinedFetchAttempt::NotApplicable; + } + + if !module.module_type.is_script() { + return InlinedFetchAttempt::Bailout("not-script".to_string()); + } + + if module.content.is_empty() { + return InlinedFetchAttempt::Bailout("empty-content".to_string()); + } + + let code = module.content.to_string(); + + // Keep current runner behavior stable: only inline code that is already transformed + // to farm runner runtime calls. + if !is_runner_ready_inlined_code(&code) { + let module_key = module_id.to_string(); + if let Some(cache) = transform_cache { + if let Some((cached_code, cached_map)) = + get_cached_runner_transform(cache, &module_key, &module.content_hash) + { + let map = collapse_module_source_map(module, cached_map); + let file = module_id.resolved_path(&context.config.root); + let url = module_id.resolved_path_with_query(&context.config.root); + + return InlinedFetchAttempt::Inlined(inlined_fetch_result( + module_id, + cached_code, + Some(file), + url, + map, + )); + } + } + + match transform_script_module_to_runner_code(module, module_id, context) { + Ok((transformed_code, transformed_map)) => { + if let Some(cache) = transform_cache { + set_cached_runner_transform( + cache, + module_key, + module.content_hash.clone(), + transformed_code.clone(), + transformed_map.clone(), + ); + } + + let map = collapse_module_source_map(module, transformed_map); + let file = module_id.resolved_path(&context.config.root); + let url = module_id.resolved_path_with_query(&context.config.root); + + return InlinedFetchAttempt::Inlined(inlined_fetch_result( + module_id, + transformed_code, + Some(file), + url, + map, + )); + } + Err(reason) => { + if let Some(cache) = transform_cache { + invalidate_cached_runner_transform(cache, &module_key); + } + return InlinedFetchAttempt::Bailout(reason.as_str().to_string()); + } + } + } + + let map = collapse_module_source_map(module, None); + let file = module_id.resolved_path(&context.config.root); + let url = module_id.resolved_path_with_query(&context.config.root); + + InlinedFetchAttempt::Inlined(inlined_fetch_result(module_id, code, Some(file), url, map)) +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, sync::Mutex}; + + use farmfe_core::module::{Module, ModuleId}; + use farmfe_core::HashMap; + use farmfe_toolkit::sourcemap::SourceMap; + + use super::{ + collapse_module_source_map, extract_query_string, get_cached_runner_transform, + is_runner_ready_inlined_code, set_cached_runner_transform, stringify_query_pairs, + }; + + #[test] + fn should_extract_query_without_hash() { + assert_eq!(extract_query_string("/src/entry.ts?t=1#hash"), "?t=1"); + assert_eq!(extract_query_string("/src/entry.ts"), ""); + } + + #[test] + fn should_stringify_query_pairs() { + assert_eq!( + stringify_query_pairs(&vec![ + ("t".to_string(), "1".to_string()), + ("raw".to_string(), "".to_string()) + ]), + "?t=1&raw" + ); + } + + #[test] + fn should_detect_runner_ready_code() { + assert!(is_runner_ready_inlined_code( + "__farm_ssr_export_name__(\"value\", () => 1);" + )); + assert!(!is_runner_ready_inlined_code("export const value = 1;")); + } + + #[test] + fn should_hit_runner_transform_cache_when_hash_matches() { + let cache = Mutex::new(HashMap::default()); + set_cached_runner_transform( + &cache, + "/src/entry.ts".to_string(), + "hash-a".to_string(), + "code-a".to_string(), + Some("map-a".to_string()), + ); + + let hit = get_cached_runner_transform(&cache, "/src/entry.ts", "hash-a"); + assert_eq!(hit, Some(("code-a".to_string(), Some("map-a".to_string())))); + + let miss = get_cached_runner_transform(&cache, "/src/entry.ts", "hash-b"); + assert!(miss.is_none()); + } + + #[test] + fn should_replace_runner_transform_cache_when_hash_changes() { + let cache = Mutex::new(HashMap::default()); + set_cached_runner_transform( + &cache, + "/src/entry.ts".to_string(), + "hash-a".to_string(), + "code-a".to_string(), + Some("map-a".to_string()), + ); + set_cached_runner_transform( + &cache, + "/src/entry.ts".to_string(), + "hash-b".to_string(), + "code-b".to_string(), + Some("map-b".to_string()), + ); + + assert!(get_cached_runner_transform(&cache, "/src/entry.ts", "hash-a").is_none()); + assert_eq!( + get_cached_runner_transform(&cache, "/src/entry.ts", "hash-b"), + Some(("code-b".to_string(), Some("map-b".to_string()))) + ); + } + + #[test] + fn should_collapse_transformed_map_after_existing_chain() { + let mut module = Module::new(ModuleId::from("entry.ts")); + module.source_map_chain = vec![Arc::new( + r#"{ + "version": 3, + "file": "b.js", + "sources": ["a.ts"], + "names": [], + "mappings": "AAAA" +}"# + .to_string(), + )]; + + let transformed_map = Some( + r#"{ + "version": 3, + "file": "c.js", + "sources": ["b.js"], + "names": [], + "mappings": "AAAA" +}"# + .to_string(), + ); + + let collapsed = collapse_module_source_map(&module, transformed_map).expect("collapsed map"); + let parsed = SourceMap::from_slice(collapsed.as_bytes()).expect("valid source map"); + let sources = parsed + .tokens() + .filter_map(|token| token.get_source().map(|s| s.to_string())) + .collect::>(); + + assert!(sources.contains("a.ts"), "{sources:?}"); + assert!(!sources.contains("b.js"), "{sources:?}"); + } } #[napi] @@ -159,6 +648,7 @@ impl JsCompiler { Compiler::new(config, plugins_adapters) .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?, ), + module_runner_transform_cache: Mutex::new(HashMap::default()), }) } @@ -472,6 +962,161 @@ impl JsCompiler { resources.get(&name).map(|r| r.bytes.clone().into()) } + #[napi] + pub fn fetch_module( + &self, + id: String, + importer: Option, + _options: Option, + ) -> Option { + let raw_id = id; + let clean_id = clean_url(&raw_id).to_string(); + let has_query = raw_id.contains('?'); + + if clean_id.starts_with("data:") { + return Some(external_fetch_result(clean_id, "builtin".to_string(), None)); + } + + if clean_id.starts_with("node:") { + return Some(external_fetch_result(clean_id, "builtin".to_string(), None)); + } + + if clean_id.starts_with("http://") || clean_id.starts_with("https://") { + return Some(external_fetch_result(clean_id, "network".to_string(), None)); + } + + let context = self.compiler.context().clone(); + + let mut direct_file_fallback: Option = None; + + if let Some(path) = try_resolve_file_path(&raw_id, importer.as_deref(), &context.config.root) { + let resolved = fs::canonicalize(&path).unwrap_or(path); + if resolved.is_file() { + let query = extract_query_string(&raw_id); + let module_id = module_id_from_resolved_path_and_query( + resolved.to_string_lossy().as_ref(), + query.as_str(), + &context.config.root, + ); + + match try_inlined_fetch_result( + &context, + &module_id, + Some(&self.module_runner_transform_cache), + ) { + InlinedFetchAttempt::Inlined(inlined) => return Some(inlined), + InlinedFetchAttempt::Bailout(reason) => { + return Some(external_fetch_result( + path_to_file_url(&resolved), + module_type_by_path(&resolved), + Some(reason), + )); + } + InlinedFetchAttempt::NotApplicable => {} + } + + if !has_query { + return Some(external_fetch_result( + path_to_file_url(&resolved), + module_type_by_path(&resolved), + None, + )); + } + + direct_file_fallback = Some(resolved); + } + } + + let resolver = Resolver::new(); + let resolve_base_dir = normalize_importer_path(importer.as_deref(), &context.config.root) + .map(|importer_path| { + if importer_path.is_file() { + importer_path + .parent() + .unwrap_or(importer_path.as_path()) + .to_path_buf() + } else { + importer_path + } + }) + .unwrap_or_else(|| PathBuf::from(&context.config.root)); + + let resolve_source = raw_id + .split_once('#') + .map(|(before, _)| before.to_string()) + .unwrap_or_else(|| raw_id.clone()); + + if let Some(resolved) = resolver.resolve( + &resolve_source, + resolve_base_dir, + &ResolveKind::Import, + &ResolveOptions::default(), + &context, + ) { + let resolved_path = clean_url(&resolved.resolved_path).to_string(); + + if resolved.external { + let resolved_type = if resolved_path.starts_with("node:") { + "builtin".to_string() + } else if resolved_path.starts_with("http://") || resolved_path.starts_with("https://") { + "network".to_string() + } else if resolved_path.ends_with(".cjs") { + "commonjs".to_string() + } else { + "module".to_string() + }; + + return Some(external_fetch_result(resolved_path, resolved_type, None)); + } + + let query = stringify_query_pairs(&resolved.query); + let module_id = module_id_from_resolved_path_and_query( + &resolved.resolved_path, + query.as_str(), + &context.config.root, + ); + + match try_inlined_fetch_result( + &context, + &module_id, + Some(&self.module_runner_transform_cache), + ) { + InlinedFetchAttempt::Inlined(inlined) => return Some(inlined), + InlinedFetchAttempt::Bailout(reason) => { + let resolved_file = + fs::canonicalize(&resolved_path).unwrap_or(PathBuf::from(&resolved_path)); + if resolved_file.is_file() { + return Some(external_fetch_result( + path_to_file_url(&resolved_file), + module_type_by_path(&resolved_file), + Some(reason), + )); + } + } + InlinedFetchAttempt::NotApplicable => {} + } + + let resolved_file = fs::canonicalize(&resolved_path).unwrap_or(PathBuf::from(&resolved_path)); + if resolved_file.is_file() { + return Some(external_fetch_result( + path_to_file_url(&resolved_file), + module_type_by_path(&resolved_file), + None, + )); + } + } + + if let Some(fallback_file) = direct_file_fallback { + return Some(external_fetch_result( + path_to_file_url(&fallback_file), + module_type_by_path(&fallback_file), + None, + )); + } + + None + } + #[napi] pub fn stats(&self) -> String { let context = self.compiler.context(); @@ -544,10 +1189,19 @@ fn read_metadata_by_scope(js_compiler: &JsCompiler, scope: String) -> Vec &'static str { + match self { + RunnerTransformBailoutReason::UnsupportedTs => "unsupported-ts", + RunnerTransformBailoutReason::ImportMutation => "import-mutation", + RunnerTransformBailoutReason::UnhandledModuleDecl => "unhandled-module-decl", + } + } +} + +pub fn transform_script_module_to_runner_code( + module: &Module, + module_id: &ModuleId, + context: &Arc, +) -> Result<(String, Option), RunnerTransformBailoutReason> { + let mut ast = module.meta.as_script().ast.clone(); + let mut cm = context.meta.get_module_source_map(module_id); + + if ast.body.is_empty() { + let syntax = syntax_from_module_type(&module.module_type, context.config.script.parser.clone()) + .ok_or(RunnerTransformBailoutReason::UnhandledModuleDecl)?; + let parsed = parse_module( + module_id, + Arc::new(module.content.to_string()), + syntax, + context.config.script.target, + ) + .map_err(|_| RunnerTransformBailoutReason::UnhandledModuleDecl)?; + ast = parsed.ast; + cm = parsed.source_map; + } + + transform_module_ast_to_runner(&mut ast, module.module_type.is_typescript())?; + ast.visit_mut_with(&mut RunnerExprRewriter); + + let mut src_map = vec![]; + let code = codegen_module( + &ast, + cm.clone(), + Some(&mut src_map), + create_codegen_config(context), + None, + ) + .map_err(|_| RunnerTransformBailoutReason::UnhandledModuleDecl)?; + + let source_map = if context.config.sourcemap.is_false() { + None + } else { + let map = cm.build_source_map(&src_map, None, DefaultSourceMapGenConfig); + let mut serialized = vec![]; + map + .to_writer(&mut serialized) + .map_err(|_| RunnerTransformBailoutReason::UnhandledModuleDecl)?; + Some( + String::from_utf8(serialized) + .map_err(|_| RunnerTransformBailoutReason::UnhandledModuleDecl)?, + ) + }; + + Ok((String::from_utf8_lossy(&code).to_string(), source_map)) +} + +fn resolve_module_scope(ast: &mut SwcModule, is_typescript: bool) { + let globals = Globals::new(); + resolve_module_mark(ast, is_typescript, &globals); +} + +fn prune_type_only_import_decls(ast: &mut SwcModule) { + ast.body.retain(|item| match item { + ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) => { + if import_decl.type_only { + return false; + } + + if import_decl.specifiers.is_empty() { + return true; + } + + import_decl + .specifiers + .iter() + .any(|specifier| match specifier { + ImportSpecifier::Named(named_specifier) => !named_specifier.is_type_only, + _ => true, + }) + } + _ => true, + }); +} + +fn strip_typescript_module_syntax(ast: &mut SwcModule) { + prune_type_only_import_decls(ast); + ast.visit_mut_with(&mut strip_type()); +} + +fn transform_module_ast_to_runner( + ast: &mut SwcModule, + is_typescript: bool, +) -> Result<(), RunnerTransformBailoutReason> { + if is_typescript { + strip_typescript_module_syntax(ast); + } + + let eligibility = analyze_transform_eligibility(ast); + if !eligibility.should_transform() { + return if eligibility.has_unsupported_ts_syntax { + Err(RunnerTransformBailoutReason::UnsupportedTs) + } else { + Err(RunnerTransformBailoutReason::UnhandledModuleDecl) + }; + } + + resolve_module_scope(ast, is_typescript); + let import_binding_ids = collect_import_binding_ids(ast); + let mutated_import_bindings = collect_import_binding_mutations(ast, &import_binding_ids); + + let mut transformer = RunnerModuleTransformer::new(mutated_import_bindings); + let original = std::mem::take(&mut ast.body); + let mut transformed = Vec::with_capacity(original.len()); + + for item in original { + transformer.transform_module_item(item, &mut transformed)?; + } + + ast.body = transformed; + ast.visit_mut_with(&mut ImportBindingRewriter::new( + transformer.import_bindings_map, + )); + Ok(()) +} + +struct RunnerModuleTransformer { + import_counter: usize, + reexport_counter: usize, + import_bindings_map: HashMap, + mutated_import_bindings: HashSet, +} + +impl RunnerModuleTransformer { + fn new(mutated_import_bindings: HashSet) -> Self { + Self { + import_counter: 0, + reexport_counter: 0, + import_bindings_map: HashMap::default(), + mutated_import_bindings, + } + } + + fn transform_module_item( + &mut self, + item: ModuleItem, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + match item { + ModuleItem::Stmt(stmt) => { + out.push(ModuleItem::Stmt(stmt)); + } + ModuleItem::ModuleDecl(decl) => self.transform_module_decl(decl, out)?, + } + + Ok(()) + } + + fn transform_module_decl( + &mut self, + decl: ModuleDecl, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + match decl { + ModuleDecl::Import(import_decl) => self.transform_import_decl(import_decl, out), + ModuleDecl::ExportDecl(export_decl) => self.transform_export_decl(export_decl, out), + ModuleDecl::ExportDefaultDecl(default_decl) => { + self.transform_export_default_decl(default_decl, out) + } + ModuleDecl::ExportDefaultExpr(default_expr) => { + self.transform_export_default_expr(default_expr, out) + } + ModuleDecl::ExportNamed(named_export) => self.transform_named_export(named_export, out), + ModuleDecl::ExportAll(export_all) => self.transform_export_all(export_all, out), + ModuleDecl::TsExportAssignment(export_assignment) => { + self.transform_ts_export_assignment(export_assignment, out) + } + ModuleDecl::TsNamespaceExport(_) => Ok(()), + ModuleDecl::TsImportEquals(import_equals) => { + self.transform_ts_import_equals(*import_equals, out) + } + } + } + + fn transform_import_decl( + &mut self, + import_decl: ImportDecl, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + let ImportDecl { + specifiers, + src, + type_only, + with, + .. + } = import_decl; + + if type_only { + return Ok(()); + } + + let source = src.value.to_string(); + let import_with = with.map(|with_obj| Expr::Object(*with_obj)); + + if specifiers.is_empty() { + out.push(ModuleItem::Stmt(create_import_stmt( + None, + &source, + import_with, + ))); + return Ok(()); + } + + let runtime_specifiers = specifiers + .into_iter() + .filter(|specifier| match specifier { + ImportSpecifier::Named(named_specifier) => !named_specifier.is_type_only, + _ => true, + }) + .collect::>(); + + if runtime_specifiers.is_empty() { + return Ok(()); + } + + let ns_ident = self.next_import_ident(); + out.push(ModuleItem::Stmt(create_import_stmt( + Some(ns_ident.clone()), + &source, + import_with, + ))); + + for specifier in runtime_specifiers { + match specifier { + ImportSpecifier::Default(default_specifier) => { + let local = default_specifier.local; + let local_id = local.to_id(); + let replacement = create_member_expr(Expr::Ident(ns_ident.clone()), "default"); + if self.mutated_import_bindings.contains(&local_id) { + out.push(ModuleItem::Stmt(create_const_from_expr(local, replacement))); + } else { + self.import_bindings_map.insert(local_id, replacement); + } + } + ImportSpecifier::Namespace(namespace_specifier) => { + let local = namespace_specifier.local; + let local_id = local.to_id(); + let replacement = Expr::Ident(ns_ident.clone()); + if self.mutated_import_bindings.contains(&local_id) { + out.push(ModuleItem::Stmt(create_const_from_expr(local, replacement))); + } else { + self.import_bindings_map.insert(local_id, replacement); + } + } + ImportSpecifier::Named(named_specifier) => { + let imported = named_specifier + .imported + .as_ref() + .map(module_export_name_to_string) + .unwrap_or_else(|| named_specifier.local.sym.to_string()); + let local = named_specifier.local; + let local_id = local.to_id(); + let replacement = create_member_expr(Expr::Ident(ns_ident.clone()), &imported); + if self.mutated_import_bindings.contains(&local_id) { + out.push(ModuleItem::Stmt(create_const_from_expr(local, replacement))); + } else { + self.import_bindings_map.insert(local_id, replacement); + } + } + } + } + + Ok(()) + } + + fn transform_export_decl( + &mut self, + export_decl: ExportDecl, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + match export_decl.decl { + Decl::Class(class_decl) => { + let export_ident = class_decl.ident.clone(); + let export_name = export_ident.sym.to_string(); + out.push(ModuleItem::Stmt(Stmt::Decl(Decl::Class(class_decl)))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + &export_name, + Expr::Ident(export_ident), + ))); + } + Decl::Fn(fn_decl) => { + let export_ident = fn_decl.ident.clone(); + let export_name = export_ident.sym.to_string(); + out.push(ModuleItem::Stmt(Stmt::Decl(Decl::Fn(fn_decl)))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + &export_name, + Expr::Ident(export_ident), + ))); + } + Decl::Var(var_decl) => { + let exported_idents = collect_var_decl_idents(&var_decl); + out.push(ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl)))); + + for ident in exported_idents { + let export_name = ident.sym.to_string(); + out.push(ModuleItem::Stmt(create_export_name_stmt( + &export_name, + Expr::Ident(ident), + ))); + } + } + Decl::TsInterface(_) | Decl::TsTypeAlias(_) | Decl::TsEnum(_) | Decl::TsModule(_) => { + // Type-only exports do not have runtime side effects. + } + _ => return Err(RunnerTransformBailoutReason::UnhandledModuleDecl), + } + + Ok(()) + } + + fn transform_export_default_decl( + &mut self, + default_decl: ExportDefaultDecl, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + match default_decl.decl { + DefaultDecl::Fn(fn_expr) => { + if let Some(ident) = fn_expr.ident { + out.push(ModuleItem::Stmt(Stmt::Decl(Decl::Fn(FnDecl { + ident: ident.clone(), + declare: false, + function: fn_expr.function, + })))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + "default", + Expr::Ident(ident), + ))); + return Ok(()); + } + + out.push(ModuleItem::Stmt(create_const_from_expr( + create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT), + Expr::Fn(fn_expr), + ))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + "default", + Expr::Ident(create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT)), + ))); + } + DefaultDecl::Class(class_expr) => { + if let Some(ident) = class_expr.ident { + out.push(ModuleItem::Stmt(Stmt::Decl(Decl::Class(ClassDecl { + ident: ident.clone(), + declare: false, + class: class_expr.class, + })))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + "default", + Expr::Ident(ident), + ))); + return Ok(()); + } + + out.push(ModuleItem::Stmt(create_const_from_expr( + create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT), + Expr::Class(class_expr), + ))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + "default", + Expr::Ident(create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT)), + ))); + } + DefaultDecl::TsInterfaceDecl(_) => { + // Type-only export + } + } + + Ok(()) + } + + fn transform_export_default_expr( + &mut self, + default_expr: ExportDefaultExpr, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + out.push(ModuleItem::Stmt(create_const_from_expr( + create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT), + *default_expr.expr, + ))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + "default", + Expr::Ident(create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT)), + ))); + Ok(()) + } + + fn transform_named_export( + &mut self, + named_export: NamedExport, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + let NamedExport { + specifiers, + src, + type_only, + with, + .. + } = named_export; + + if type_only { + return Ok(()); + } + + let export_with = with.map(|with_obj| Expr::Object(*with_obj)); + + if let Some(source) = src { + let runtime_specifiers = specifiers + .into_iter() + .filter(|specifier| match specifier { + ExportSpecifier::Named(named_specifier) => !named_specifier.is_type_only, + _ => true, + }) + .collect::>(); + + if runtime_specifiers.is_empty() { + return Ok(()); + } + + let reexport_namespace = self.next_reexport_ident(); + out.push(ModuleItem::Stmt(create_import_stmt( + Some(reexport_namespace.clone()), + source.value.as_ref(), + export_with, + ))); + + for specifier in runtime_specifiers { + match specifier { + ExportSpecifier::Namespace(namespace_specifier) => { + let exported_name = module_export_name_to_string(&namespace_specifier.name); + out.push(ModuleItem::Stmt(create_export_name_stmt( + &exported_name, + Expr::Ident(reexport_namespace.clone()), + ))); + } + ExportSpecifier::Default(default_specifier) => { + let exported_name = default_specifier.exported.sym.to_string(); + out.push(ModuleItem::Stmt(create_export_name_stmt( + &exported_name, + create_member_expr(Expr::Ident(reexport_namespace.clone()), "default"), + ))); + } + ExportSpecifier::Named(named_specifier) => { + let imported_name = module_export_name_to_string(&named_specifier.orig); + let exported_name = named_specifier + .exported + .as_ref() + .map(module_export_name_to_string) + .unwrap_or_else(|| imported_name.clone()); + + out.push(ModuleItem::Stmt(create_export_name_stmt( + &exported_name, + create_member_expr(Expr::Ident(reexport_namespace.clone()), &imported_name), + ))); + } + } + } + + return Ok(()); + } + + for specifier in specifiers { + match specifier { + ExportSpecifier::Named(named_specifier) => { + if named_specifier.is_type_only { + continue; + } + + let local_ident = module_export_name_to_ident(&named_specifier.orig) + .ok_or(RunnerTransformBailoutReason::UnhandledModuleDecl)?; + let exported_name = named_specifier + .exported + .as_ref() + .map(module_export_name_to_string) + .unwrap_or_else(|| local_ident.sym.to_string()); + + out.push(ModuleItem::Stmt(create_export_name_stmt( + &exported_name, + Expr::Ident(local_ident), + ))); + } + ExportSpecifier::Default(ExportDefaultSpecifier { exported, .. }) => { + out.push(ModuleItem::Stmt(create_export_name_stmt( + exported.sym.as_ref(), + Expr::Ident(exported.clone()), + ))); + } + ExportSpecifier::Namespace(namespace_specifier) => { + let local_ident = module_export_name_to_ident(&namespace_specifier.name) + .ok_or(RunnerTransformBailoutReason::UnhandledModuleDecl)?; + out.push(ModuleItem::Stmt(create_export_name_stmt( + local_ident.sym.as_ref(), + Expr::Ident(local_ident.clone()), + ))); + } + } + } + + Ok(()) + } + + fn transform_export_all( + &mut self, + export_all: ExportAll, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + let ExportAll { + src, + type_only, + with, + .. + } = export_all; + + if type_only { + return Ok(()); + } + + let export_with = with.map(|with_obj| Expr::Object(*with_obj)); + let reexport_namespace = self.next_reexport_ident(); + out.push(ModuleItem::Stmt(create_import_stmt( + Some(reexport_namespace.clone()), + src.value.as_ref(), + export_with, + ))); + + out.push(ModuleItem::Stmt(create_export_all_stmt(Expr::Ident( + reexport_namespace, + )))); + + Ok(()) + } + + fn transform_ts_export_assignment( + &mut self, + export_assignment: TsExportAssignment, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + out.push(ModuleItem::Stmt(create_const_from_expr( + create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT), + *export_assignment.expr, + ))); + out.push(ModuleItem::Stmt(create_export_name_stmt( + "default", + Expr::Ident(create_runtime_ident(FARM_SSR_DEFAULT_EXPORT_IDENT)), + ))); + + Ok(()) + } + + fn transform_ts_import_equals( + &mut self, + import_equals: TsImportEqualsDecl, + out: &mut Vec, + ) -> Result<(), RunnerTransformBailoutReason> { + let TsImportEqualsDecl { + id, + module_ref, + is_export, + is_type_only, + .. + } = import_equals; + + if is_type_only { + return Ok(()); + } + + let source = match module_ref { + TsModuleRef::TsExternalModuleRef(external_module_ref) => { + external_module_ref.expr.value.to_string() + } + TsModuleRef::TsEntityName(entity_name) => { + let resolved_expr = create_expr_from_ts_entity_name(&entity_name); + out.push(ModuleItem::Stmt(create_const_from_expr( + id.clone(), + resolved_expr, + ))); + + if is_export { + let export_name = id.sym.to_string(); + out.push(ModuleItem::Stmt(create_export_name_stmt( + &export_name, + Expr::Ident(id), + ))); + } + + return Ok(()); + } + }; + + out.push(ModuleItem::Stmt(create_import_stmt( + Some(id.clone()), + &source, + None, + ))); + + if is_export { + let export_name = id.sym.to_string(); + out.push(ModuleItem::Stmt(create_export_name_stmt( + &export_name, + Expr::Ident(id), + ))); + } + + Ok(()) + } + + fn next_import_ident(&mut self) -> Ident { + let ident = create_runtime_ident(&format!("{FARM_SSR_IMPORT_PREFIX}{}", self.import_counter)); + self.import_counter += 1; + ident + } + + fn next_reexport_ident(&mut self) -> Ident { + let ident = create_runtime_ident(&format!( + "{FARM_SSR_REEXPORT_PREFIX}{}", + self.reexport_counter + )); + self.reexport_counter += 1; + ident + } +} + +fn create_expr_from_ts_entity_name(entity_name: &TsEntityName) -> Expr { + match entity_name { + TsEntityName::Ident(ident) => Expr::Ident(ident.clone()), + TsEntityName::TsQualifiedName(qualified_name) => create_member_expr( + create_expr_from_ts_entity_name(&qualified_name.left), + qualified_name.right.sym.as_ref(), + ), + } +} + +struct ImportBindingRewriter { + import_bindings_map: HashMap, +} + +impl ImportBindingRewriter { + fn new(import_bindings_map: HashMap) -> Self { + Self { + import_bindings_map, + } + } +} + +impl VisitMut for ImportBindingRewriter { + noop_visit_mut_type!(); + + fn visit_mut_prop(&mut self, prop: &mut Prop) { + if let Prop::Shorthand(shorthand) = prop { + if let Some(replacement) = self.import_bindings_map.get(&shorthand.to_id()) { + *prop = Prop::KeyValue(KeyValueProp { + key: shorthand.clone().into(), + value: Box::new(replacement.clone()), + }); + } + return; + } + + prop.visit_mut_children_with(self); + } + + fn visit_mut_expr(&mut self, expr: &mut Expr) { + if let Expr::Ident(ident) = expr { + if let Some(replacement) = self.import_bindings_map.get(&ident.to_id()) { + *expr = replacement.clone(); + } + return; + } + + expr.visit_mut_children_with(self); + } +} + +struct RunnerExprRewriter; + +impl VisitMut for RunnerExprRewriter { + fn visit_mut_expr(&mut self, expr: &mut Expr) { + expr.visit_mut_children_with(self); + + match expr { + Expr::Call(call_expr) => { + if matches!(call_expr.callee, Callee::Import(_)) { + call_expr.callee = Callee::Expr(Box::new(Expr::Ident(create_runtime_ident( + FARM_SSR_DYNAMIC_IMPORT, + )))); + return; + } + + if let Callee::Expr(callee_expr) = &mut call_expr.callee { + if let Expr::Ident(ident) = callee_expr.as_ref() { + if ident.sym == "import" { + *callee_expr = Box::new(Expr::Ident(create_runtime_ident(FARM_SSR_DYNAMIC_IMPORT))); + } + } + } + } + Expr::MetaProp(MetaPropExpr { + kind: MetaPropKind::ImportMeta, + .. + }) => { + *expr = Expr::Ident(create_runtime_ident(FARM_SSR_IMPORT_META)); + } + Expr::Member(member_expr) => { + if is_import_meta_member(member_expr) { + *expr = Expr::Ident(create_runtime_ident(FARM_SSR_IMPORT_META)); + } + } + _ => {} + } + } +} + +fn collect_import_binding_ids(ast: &SwcModule) -> HashSet { + let mut ids = HashSet::default(); + + for item in &ast.body { + let ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) = item else { + continue; + }; + + if import_decl.type_only { + continue; + } + + for specifier in &import_decl.specifiers { + match specifier { + ImportSpecifier::Default(default_specifier) => { + ids.insert(default_specifier.local.to_id()); + } + ImportSpecifier::Namespace(namespace_specifier) => { + ids.insert(namespace_specifier.local.to_id()); + } + ImportSpecifier::Named(named_specifier) => { + if !named_specifier.is_type_only { + ids.insert(named_specifier.local.to_id()); + } + } + } + } + } + + ids +} + +struct ImportBindingMutationCollector<'a> { + import_binding_ids: &'a HashSet, + mutated_ids: HashSet, +} + +impl Visit for ImportBindingMutationCollector<'_> { + fn visit_update_expr(&mut self, update_expr: &UpdateExpr) { + if let Some(id) = extract_ident_id_from_expr(update_expr.arg.as_ref()) { + if self.import_binding_ids.contains(&id) { + self.mutated_ids.insert(id); + } + } + + update_expr.visit_children_with(self); + } + + fn visit_assign_expr(&mut self, assign_expr: &AssignExpr) { + collect_import_binding_assign_target( + &assign_expr.left, + self.import_binding_ids, + &mut self.mutated_ids, + ); + assign_expr.visit_children_with(self); + } + + fn visit_for_of_stmt(&mut self, for_of_stmt: &ForOfStmt) { + collect_import_binding_for_head( + &for_of_stmt.left, + self.import_binding_ids, + &mut self.mutated_ids, + ); + for_of_stmt.visit_children_with(self); + } + + fn visit_for_in_stmt(&mut self, for_in_stmt: &ForInStmt) { + collect_import_binding_for_head( + &for_in_stmt.left, + self.import_binding_ids, + &mut self.mutated_ids, + ); + for_in_stmt.visit_children_with(self); + } +} + +fn collect_import_binding_assign_target( + target: &AssignTarget, + import_binding_ids: &HashSet, + mutated_ids: &mut HashSet, +) { + match target { + AssignTarget::Simple(simple_target) => { + collect_import_binding_simple_assign_target(simple_target, import_binding_ids, mutated_ids) + } + AssignTarget::Pat(assign_target_pat) => { + collect_import_binding_assign_target_pat(assign_target_pat, import_binding_ids, mutated_ids) + } + } +} + +fn collect_import_binding_simple_assign_target( + target: &SimpleAssignTarget, + import_binding_ids: &HashSet, + mutated_ids: &mut HashSet, +) { + match target { + SimpleAssignTarget::Ident(binding_ident) => { + let id = binding_ident.id.to_id(); + if import_binding_ids.contains(&id) { + mutated_ids.insert(id); + } + } + SimpleAssignTarget::Paren(paren_expr) => { + if let Some(id) = extract_ident_id_from_expr(paren_expr.expr.as_ref()) { + if import_binding_ids.contains(&id) { + mutated_ids.insert(id); + } + } + } + _ => {} + } +} + +fn collect_import_binding_assign_target_pat( + target: &AssignTargetPat, + import_binding_ids: &HashSet, + mutated_ids: &mut HashSet, +) { + let pat: Pat = target.clone().into(); + collect_import_binding_pat(&pat, import_binding_ids, mutated_ids); +} + +fn collect_import_binding_for_head( + head: &ForHead, + import_binding_ids: &HashSet, + mutated_ids: &mut HashSet, +) { + if let ForHead::Pat(pat) = head { + collect_import_binding_pat(pat.as_ref(), import_binding_ids, mutated_ids); + } +} + +fn collect_import_binding_pat( + pat: &Pat, + import_binding_ids: &HashSet, + mutated_ids: &mut HashSet, +) { + let mut idents = vec![]; + collect_pat_idents(pat, &mut idents); + for ident in idents { + let id = ident.to_id(); + if import_binding_ids.contains(&id) { + mutated_ids.insert(id); + } + } +} + +fn collect_import_binding_mutations( + ast: &SwcModule, + import_binding_ids: &HashSet, +) -> HashSet { + if import_binding_ids.is_empty() { + return HashSet::default(); + } + + let mut collector = ImportBindingMutationCollector { + import_binding_ids, + mutated_ids: HashSet::default(), + }; + ast.visit_with(&mut collector); + collector.mutated_ids +} + +fn extract_ident_id_from_expr(expr: &Expr) -> Option { + match expr { + Expr::Ident(ident) => Some(ident.to_id()), + Expr::Paren(paren_expr) => extract_ident_id_from_expr(paren_expr.expr.as_ref()), + _ => None, + } +} + +fn is_import_meta_member(member_expr: &MemberExpr) -> bool { + let prop_is_meta = match &member_expr.prop { + MemberProp::Ident(ident_name) => ident_name.sym == "meta", + MemberProp::Computed(computed) => { + if let Expr::Lit(Lit::Str(str)) = computed.expr.as_ref() { + str.value == "meta" + } else { + false + } + } + _ => false, + }; + + if !prop_is_meta { + return false; + } + + if let Expr::Ident(ident) = member_expr.obj.as_ref() { + return ident.sym == "import"; + } + + false +} + +fn is_dynamic_import_call(call_expr: &farmfe_core::swc_ecma_ast::CallExpr) -> bool { + if matches!(call_expr.callee, Callee::Import(_)) { + return true; + } + + if let Callee::Expr(callee_expr) = &call_expr.callee { + if let Expr::Ident(ident) = callee_expr.as_ref() { + return ident.sym == "import"; + } + } + + false +} + +struct RunnerTransformEligibility { + has_module_decl: bool, + has_dynamic_import: bool, + has_import_meta: bool, + has_unsupported_ts_syntax: bool, +} + +impl RunnerTransformEligibility { + fn should_transform(&self) -> bool { + !self.has_unsupported_ts_syntax + && (self.has_module_decl || self.has_dynamic_import || self.has_import_meta) + } +} + +impl Visit for RunnerTransformEligibility { + fn visit_module_decl(&mut self, decl: &ModuleDecl) { + self.has_module_decl = true; + decl.visit_children_with(self); + } + + fn visit_decl(&mut self, decl: &Decl) { + match decl { + Decl::TsInterface(_) | Decl::TsTypeAlias(_) => { + self.has_unsupported_ts_syntax = true; + return; + } + Decl::TsEnum(enum_decl) => { + if !enum_decl.declare { + self.has_unsupported_ts_syntax = true; + return; + } + } + Decl::TsModule(module_decl) => { + if !module_decl.declare { + self.has_unsupported_ts_syntax = true; + return; + } + } + _ => {} + } + + decl.visit_children_with(self); + } + + fn visit_ts_type_ann(&mut self, _: &farmfe_core::swc_ecma_ast::TsTypeAnn) { + self.has_unsupported_ts_syntax = true; + } + + fn visit_ts_type_param_decl(&mut self, _: &farmfe_core::swc_ecma_ast::TsTypeParamDecl) { + self.has_unsupported_ts_syntax = true; + } + + fn visit_ts_type_param_instantiation( + &mut self, + _: &farmfe_core::swc_ecma_ast::TsTypeParamInstantiation, + ) { + self.has_unsupported_ts_syntax = true; + } + + fn visit_expr(&mut self, expr: &Expr) { + if matches!( + expr, + Expr::TsTypeAssertion(_) + | Expr::TsConstAssertion(_) + | Expr::TsNonNull(_) + | Expr::TsAs(_) + | Expr::TsInstantiation(_) + | Expr::TsSatisfies(_) + ) { + self.has_unsupported_ts_syntax = true; + return; + } + + expr.visit_children_with(self); + } + + fn visit_simple_assign_target(&mut self, target: &SimpleAssignTarget) { + if matches!( + target, + SimpleAssignTarget::TsAs(_) + | SimpleAssignTarget::TsSatisfies(_) + | SimpleAssignTarget::TsNonNull(_) + | SimpleAssignTarget::TsTypeAssertion(_) + | SimpleAssignTarget::TsInstantiation(_) + ) { + self.has_unsupported_ts_syntax = true; + return; + } + + target.visit_children_with(self); + } + + fn visit_call_expr(&mut self, call_expr: &farmfe_core::swc_ecma_ast::CallExpr) { + if is_dynamic_import_call(call_expr) { + self.has_dynamic_import = true; + } + + call_expr.visit_children_with(self); + } + + fn visit_meta_prop_expr(&mut self, meta_prop_expr: &MetaPropExpr) { + if matches!(meta_prop_expr.kind, MetaPropKind::ImportMeta) { + self.has_import_meta = true; + } + } + + fn visit_member_expr(&mut self, member_expr: &MemberExpr) { + if is_import_meta_member(member_expr) { + self.has_import_meta = true; + } + + member_expr.visit_children_with(self); + } +} + +#[allow(dead_code)] +fn can_transform_to_runner(ast: &SwcModule) -> bool { + analyze_transform_eligibility(ast).should_transform() +} + +fn analyze_transform_eligibility(ast: &SwcModule) -> RunnerTransformEligibility { + let mut eligibility = RunnerTransformEligibility { + has_module_decl: false, + has_dynamic_import: false, + has_import_meta: false, + has_unsupported_ts_syntax: false, + }; + ast.visit_with(&mut eligibility); + eligibility +} + +fn create_runtime_ident(value: &str) -> Ident { + Ident::new(value.into(), DUMMY_SP, SyntaxContext::empty()) +} + +fn create_string_lit(value: &str) -> Str { + Str { + span: DUMMY_SP, + value: value.into(), + raw: None, + } +} + +fn create_helper_call_expr(name: &str, args: Vec) -> Expr { + Expr::Call(farmfe_core::swc_ecma_ast::CallExpr { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + callee: Callee::Expr(Box::new(Expr::Ident(create_runtime_ident(name)))), + args: args + .into_iter() + .map(|expr| ExprOrSpread { + spread: None, + expr: Box::new(expr), + }) + .collect(), + type_args: None, + }) +} + +fn create_import_stmt(binding: Option, source: &str, import_with: Option) -> Stmt { + let mut import_args = vec![Expr::Lit(Lit::Str(create_string_lit(source)))]; + if let Some(import_with) = import_with { + import_args.push(import_with); + } + + let awaited_import = Expr::Await(AwaitExpr { + span: DUMMY_SP, + arg: Box::new(create_helper_call_expr(FARM_SSR_IMPORT, import_args)), + }); + + if let Some(binding) = binding { + return create_const_from_expr(binding, awaited_import); + } + + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(awaited_import), + }) +} + +fn create_member_expr(target: Expr, prop: &str) -> Expr { + Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(target), + prop: MemberProp::Computed(ComputedPropName { + span: DUMMY_SP, + expr: Box::new(Expr::Lit(Lit::Str(create_string_lit(prop)))), + }), + }) +} + +fn create_const_from_expr(binding: Ident, expr: Expr) -> Stmt { + Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(binding.into()), + init: Some(Box::new(expr)), + definite: false, + }], + declare: false, + ctxt: SyntaxContext::empty(), + }))) +} + +fn create_export_name_stmt(exported_name: &str, value: Expr) -> Stmt { + let getter = Expr::Arrow(ArrowExpr { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + params: vec![], + body: Box::new(BlockStmtOrExpr::Expr(Box::new(value))), + is_async: false, + is_generator: false, + type_params: None, + return_type: None, + }); + + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(create_helper_call_expr( + FARM_SSR_EXPORT_NAME, + vec![ + Expr::Lit(Lit::Str(create_string_lit(exported_name))), + getter, + ], + )), + }) +} + +fn create_export_all_stmt(namespace: Expr) -> Stmt { + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(create_helper_call_expr( + FARM_SSR_EXPORT_ALL, + vec![namespace], + )), + }) +} + +fn collect_var_decl_idents(var_decl: &VarDecl) -> Vec { + let mut idents = vec![]; + + for declarator in &var_decl.decls { + collect_pat_idents(&declarator.name, &mut idents); + } + + idents +} + +fn collect_pat_idents(pat: &Pat, idents: &mut Vec) { + match pat { + Pat::Ident(binding_ident) => { + idents.push(binding_ident.id.clone()); + } + Pat::Array(array_pat) => { + for pat in array_pat.elems.iter().flatten() { + collect_pat_idents(pat, idents); + } + } + Pat::Rest(rest_pat) => { + collect_pat_idents(&rest_pat.arg, idents); + } + Pat::Object(object_pat) => { + for property in &object_pat.props { + match property { + farmfe_core::swc_ecma_ast::ObjectPatProp::Assign(assign_prop) => { + idents.push(assign_prop.key.id.clone()); + } + farmfe_core::swc_ecma_ast::ObjectPatProp::KeyValue(KeyValuePatProp { value, .. }) => { + collect_pat_idents(value, idents); + } + farmfe_core::swc_ecma_ast::ObjectPatProp::Rest(rest) => { + collect_pat_idents(&rest.arg, idents); + } + } + } + } + Pat::Assign(assign_pat) => { + collect_pat_idents(&assign_pat.left, idents); + } + _ => {} + } +} + +fn module_export_name_to_string(name: &ModuleExportName) -> String { + match name { + ModuleExportName::Ident(ident) => ident.sym.to_string(), + ModuleExportName::Str(str) => str.value.to_string(), + } +} + +fn module_export_name_to_ident(name: &ModuleExportName) -> Option { + match name { + ModuleExportName::Ident(ident) => Some(ident.clone()), + ModuleExportName::Str(_) => None, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use farmfe_core::{ + module::ModuleId, + swc_ecma_parser::{EsSyntax, Syntax, TsSyntax}, + }; + use farmfe_toolkit::script::{codegen_module, parse_module}; + + use super::{ + can_transform_to_runner, transform_module_ast_to_runner, RunnerExprRewriter, + RunnerTransformBailoutReason, + }; + use farmfe_toolkit::swc_ecma_visit::VisitMutWith; + + #[test] + fn should_transform_esm_to_runner_helpers() { + let module_id = ModuleId::from("index.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { foo as localFoo } from './dep'; +export const value = localFoo + 1; +export { value as renamed }; +export * from './dep2'; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("__farm_ssr_import__")); + assert!(output.contains("__farm_ssr_export_name__")); + assert!(output.contains("__farm_ssr_export_all__")); + assert!(!output.contains("import ")); + assert!(!output.contains("export ")); + } + + #[test] + fn should_rewrite_dynamic_import_and_import_meta() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +const mod = await import('./dep'); +export const metaUrl = import.meta.url; +export default mod; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("__farm_ssr_dynamic_import__"), "{output}"); + assert!(output.contains("__farm_ssr_import_meta__"), "{output}"); + } + + #[test] + fn should_transform_reexport_namespace_and_named_from() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +export { foo as bar } from './dep'; +export * as depNs from './dep2'; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"bar\""), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"depNs\""), + "{output}" + ); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_transform_export_var_destructuring() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +const dep = { a: 1, b: 2 }; +export const { a, b } = dep; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("__farm_ssr_export_name__(\"a\""), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"b\""), + "{output}" + ); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_ignore_type_only_import_and_export() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import type { Foo } from './dep'; +export type { Foo } from './dep'; +export const value = 1; +"# + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_not_transform_plain_cjs_like_script() { + let module_id = ModuleId::from("entry.js"); + let parsed = parse_module( + &module_id, + Arc::new("const dep = require('./dep'); module.exports = dep;".to_string()), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + assert!(can_transform_to_runner(&ast).eq(&false)); + assert!(matches!( + transform_module_ast_to_runner(&mut ast, false), + Err(RunnerTransformBailoutReason::UnhandledModuleDecl) + )); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!(!output.contains("__farm_ssr_export_name__"), "{output}"); + } + + #[test] + fn should_ignore_inline_type_only_import_specifier() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import { type Foo } from './dep'; export const value = 1;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_ignore_default_type_only_import_declaration() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import type Foo from './dep'; export const value = 1;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_ignore_namespace_type_only_import_declaration() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import type * as dep from './dep'; export const value = 1;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_keep_runtime_binding_for_mixed_type_and_value_import() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import { type Foo, bar } from './dep'; export const value = bar;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + assert!(!output.contains("Foo"), "{output}"); + } + + #[test] + fn should_rewrite_import_binding_references_to_namespace_members() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { counter as localCounter } from './dep'; +const read = () => localCounter + 1; +export { localCounter, read }; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("__farm_ssr_import__"), "{output}"); + assert!(!output.contains("const localCounter ="), "{output}"); + assert!( + output.contains("__farm_ssr_imported__0[\"counter\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"localCounter\""), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"read\""), + "{output}" + ); + } + + #[test] + fn should_rewrite_shorthand_props_using_import_bindings() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "import { foo } from './dep'; const obj = { foo }; export const value = obj.foo;" + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("foo: __farm_ssr_imported__0[\"foo\"]") + || output.contains("foo:__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_not_treat_shadowed_binding_update_as_import_mutation() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { foo } from './dep'; +function mutate(foo) { + foo++; + return foo; +} +export const value = foo; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("function mutate(foo)"), "{output}"); + assert!( + output.contains("foo++") || output.contains("foo ++"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_not_treat_shadowed_parenthesized_binding_update_as_import_mutation() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { foo } from './dep'; +function mutate(foo) { + ((foo))++; + return foo; +} +export const value = foo; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("function mutate(foo)"), "{output}"); + assert!( + output.contains("((foo))++") + || output.contains("((foo)) ++") + || output.contains("(foo)++") + || output.contains("(foo) ++"), + "{output}" + ); + assert!( + !output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + && !output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_not_treat_shadowed_parenthesized_binding_assignment_as_import_mutation() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { foo } from './dep'; +function mutate(foo) { + ((foo)) = 1; + return foo; +} +export const value = foo; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("function mutate(foo)"), "{output}"); + assert!( + output.contains("((foo)) = 1") + || output.contains("((foo))=1") + || output.contains("(foo) = 1") + || output.contains("(foo)=1"), + "{output}" + ); + assert!( + !output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + && !output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_not_treat_block_shadowed_parenthesized_binding_update_as_import_mutation() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { foo } from './dep'; +{ + let foo = 1; + ((foo))++; +} +export const value = foo; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("let foo = 1") || output.contains("let foo=1"), + "{output}" + ); + assert!( + !output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + && !output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_preserve_ordering_with_parenthesized_import_mutation_and_reexport() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { foo } from './dep'; +((foo))++; +export { bar as reBar } from './dep2'; +export const value = foo; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + let dep_import_idx = output.find("\"./dep\"").expect("dep import"); + let foo_alias_idx = output + .find("const foo = __farm_ssr_imported__0[\"foo\"]") + .or_else(|| output.find("const foo=__farm_ssr_imported__0[\"foo\"]")) + .expect("foo alias"); + let dep2_import_idx = output.find("\"./dep2\"").expect("dep2 import"); + let reexport_idx = output + .find("__farm_ssr_export_name__(\"reBar\"") + .expect("reexport call"); + let value_export_idx = output + .find("__farm_ssr_export_name__(\"value\"") + .expect("value export call"); + + assert!(dep_import_idx < foo_alias_idx, "{output}"); + assert!(foo_alias_idx < dep2_import_idx, "{output}"); + assert!(dep2_import_idx < reexport_idx, "{output}"); + assert!(reexport_idx < value_export_idx, "{output}"); + } + + #[test] + fn should_preserve_ordering_with_import_attributes_and_parenthesized_mutation() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { foo } from './dep'; +import data from './data.json' with { type: 'json' }; +((foo))++; +export { bar as reBar } from './dep2.json' with { type: 'json' }; +export const value = data; +"# + .to_string(), + ), + Syntax::Es(EsSyntax { + import_attributes: true, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + let dep_import_idx = output.find("\"./dep\"").expect("dep import"); + let foo_alias_idx = output + .find("const foo = __farm_ssr_imported__0[\"foo\"]") + .or_else(|| output.find("const foo=__farm_ssr_imported__0[\"foo\"]")) + .expect("foo alias"); + let data_import_idx = output.find("\"./data.json\"").expect("data import"); + let dep2_import_idx = output.find("\"./dep2.json\"").expect("dep2 import"); + let reexport_idx = output + .find("__farm_ssr_export_name__(\"reBar\"") + .expect("reexport call"); + let value_export_idx = output + .find("__farm_ssr_export_name__(\"value\"") + .expect("value export call"); + + assert!(dep_import_idx < foo_alias_idx, "{output}"); + assert!(foo_alias_idx < data_import_idx, "{output}"); + assert!(data_import_idx < dep2_import_idx, "{output}"); + assert!(dep2_import_idx < reexport_idx, "{output}"); + assert!(reexport_idx < value_export_idx, "{output}"); + assert!( + output.contains("__farm_ssr_import__(\"./data.json\","), + "{output}" + ); + assert!( + output.contains("__farm_ssr_import__(\"./dep2.json\","), + "{output}" + ); + } + + #[test] + fn should_transform_when_import_binding_is_updated() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import { foo } from './dep'; foo++; export const value = 1;".to_string()), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_when_parenthesized_import_binding_is_updated() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import { foo } from './dep'; ((foo))++; export const value = foo;".to_string()), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("((foo))++") + || output.contains("((foo)) ++") + || output.contains("(foo)++") + || output.contains("(foo) ++"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_when_import_binding_is_assigned() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import { foo } from './dep'; foo = 1; export const value = 1;".to_string()), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_when_parenthesized_import_binding_is_assigned() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import { foo } from './dep'; ((foo)) = 1; export const value = foo;".to_string()), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("((foo)) = 1") + || output.contains("((foo))=1") + || output.contains("(foo) = 1") + || output.contains("(foo)=1"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_when_import_binding_is_destructured_assigned() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "import { foo } from './dep'; ({ foo } = { foo: 1 }); export const value = 1;".to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_when_import_binding_is_for_of_target() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "import { foo } from './dep'; for (foo of [1, 2]) {} export const value = 1;".to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_when_import_binding_is_for_in_target() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "import { foo } from './dep'; for (foo in { a: 1 }) {} export const value = 1;".to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_keep_non_mutated_import_binding_live_when_another_binding_is_mutated() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import { foo, bar } from './dep'; foo++; export const value = bar;".to_string()), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_imported__0[\"bar\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_keep_non_mutated_import_binding_live_when_parenthesized_binding_is_mutated() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "import { foo, bar } from './dep'; ((foo))++; export const value = bar;".to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("const foo = __farm_ssr_imported__0[\"foo\"]") + || output.contains("const foo=__farm_ssr_imported__0[\"foo\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_imported__0[\"bar\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_dynamic_import_with_options() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "const mod = await import('./dep', { with: { type: 'json' } }); export default mod;" + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + assert!(can_transform_to_runner(&ast)); + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("__farm_ssr_dynamic_import__"), "{output}"); + assert!(!output.contains("import("), "{output}"); + assert!( + output.contains("__farm_ssr_dynamic_import__(\"./dep\",") + || output.contains("__farm_ssr_dynamic_import__('./dep',"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + } + + #[test] + fn should_ignore_type_only_reexport_from() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("export { type Foo } from './dep'; export const value = 1;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_ignore_export_all_type_only() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("export type * from './dep'; export const value = 1;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!(!output.contains("__farm_ssr_export_all__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_keep_runtime_binding_for_mixed_type_and_value_reexport_from() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("export { type Foo, bar as baz } from './dep';".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"baz\""), + "{output}" + ); + assert!(!output.contains("Foo"), "{output}"); + } + + #[test] + fn should_keep_runtime_binding_for_mixed_type_and_value_local_export() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "import type { Foo } from './dep'; const bar = 1; export { type Foo, bar as baz };" + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(!output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"baz\""), + "{output}" + ); + assert!( + !output.contains("__farm_ssr_export_name__(\"Foo\""), + "{output}" + ); + } + + #[test] + fn should_not_transform_module_with_ts_decl_syntax() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("type Foo = number; export const value = 1;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + assert!(!can_transform_to_runner(&parsed.ast)); + } + + #[test] + fn should_transform_ts_export_assignment_as_default_export() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("const value = 1; export = value;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("const __farm_ssr_default_export__ = value") + || output.contains("const __farm_ssr_default_export__=value"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + } + + #[test] + fn should_transform_ts_import_equals_external_module_ref() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import foo = require('./dep'); export const value = foo;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("const foo = await __farm_ssr_import__(\"./dep\")") + || output.contains("const foo=await __farm_ssr_import__(\"./dep\")"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_exported_ts_import_equals_external_module_ref() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("export import foo = require('./dep');".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("const foo = await __farm_ssr_import__(\"./dep\")") + || output.contains("const foo=await __farm_ssr_import__(\"./dep\")"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"foo\""), + "{output}" + ); + } + + #[test] + fn should_transform_ts_import_equals_entity_name_module_ref() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import foo = Bar.Baz; export const value = foo;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = Bar[\"Baz\"]") || output.contains("const foo=Bar[\"Baz\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_transform_ts_import_equals_nested_entity_name_module_ref() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("import foo = A.B.C; export default foo;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("const foo = A[\"B\"][\"C\"]") + || output.contains("const foo=A[\"B\"][\"C\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + } + + #[test] + fn should_transform_declare_enum_and_module_decls() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +declare enum Color { + Red +} +declare namespace Foo { + const value: number; +} +export const value = 1; +"# + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + assert!(!output.contains("declare enum"), "{output}"); + assert!(!output.contains("declare namespace"), "{output}"); + } + + #[test] + fn should_bailout_non_declare_ts_enum_decl() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("enum Color { Red } export const value = Color.Red;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + let transformed = transform_module_ast_to_runner(&mut ast, true); + assert!(matches!( + transformed, + Err(RunnerTransformBailoutReason::UnsupportedTs) + )); + } + + #[test] + fn should_bailout_non_declare_ts_module_decl() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("namespace Foo { export const a = 1; } export const value = Foo.a;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + let transformed = transform_module_ast_to_runner(&mut ast, true); + assert!(matches!( + transformed, + Err(RunnerTransformBailoutReason::UnsupportedTs) + )); + } + + #[test] + fn should_transform_declare_interface_and_type_alias_decls() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +declare interface Foo { + value: number; +} +declare type Bar = string; +export const value = 1; +"# + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + assert!(!output.contains("declare interface"), "{output}"); + assert!(!output.contains("declare type"), "{output}"); + } + + #[test] + fn should_preserve_ordering_with_ts_entity_import_equals_and_reexport() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import dep = A.B; +export { foo as reFoo } from './dep2'; +export const value = dep.foo; +"# + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + let dep_assign_idx = output + .find("const dep = A[\"B\"]") + .or_else(|| output.find("const dep=A[\"B\"]")) + .expect("dep assign"); + let dep2_import_idx = output.find("\"./dep2\"").expect("dep2 import"); + let reexport_idx = output + .find("__farm_ssr_export_name__(\"reFoo\"") + .expect("reexport call"); + let value_export_idx = output + .find("__farm_ssr_export_name__(\"value\"") + .expect("value export call"); + + assert!(dep_assign_idx < dep2_import_idx, "{output}"); + assert!(dep2_import_idx < reexport_idx, "{output}"); + assert!(reexport_idx < value_export_idx, "{output}"); + } + + #[test] + fn should_transform_import_attributes() { + let module_id = ModuleId::from("entry.js"); + let parsed = parse_module( + &module_id, + Arc::new( + "import data from './foo.json' with { type: 'json' }; export default data;".to_string(), + ), + Syntax::Es(EsSyntax { + import_attributes: true, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("__farm_ssr_import__"), "{output}"); + assert!( + output.contains("__farm_ssr_import__(\"./foo.json\",") + || output.contains("__farm_ssr_import__('./foo.json',"), + "{output}" + ); + assert!(output.contains("type"), "{output}"); + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + assert!(!output.contains("import "), "{output}"); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_rewrite_destructure_default_initializer_with_import_binding() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { fallback } from './dep'; +const source = {}; +const { value = fallback } = source; +export { value }; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("value = __farm_ssr_imported__0[\"fallback\"]") + || output.contains("value=__farm_ssr_imported__0[\"fallback\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + } + + #[test] + fn should_rewrite_class_computed_key_with_import_binding() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { key } from './dep'; +class Box { + [key]() { + return 1; + } +} +export default Box; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("[__farm_ssr_imported__0[\"key\"]]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + } + + #[test] + fn should_preserve_import_export_ordering_for_reexport() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { before } from './before'; +export { value as reexported } from './dep'; +export const tail = before; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + let before_import_idx = output.find("\"./before\"").expect("before import"); + let dep_import_idx = output.find("\"./dep\"").expect("dep import"); + let reexport_idx = output + .find("__farm_ssr_export_name__(\"reexported\"") + .expect("reexport call"); + let tail_export_idx = output + .find("__farm_ssr_export_name__(\"tail\"") + .expect("tail export call"); + + assert!(before_import_idx < dep_import_idx, "{output}"); + assert!(dep_import_idx < reexport_idx, "{output}"); + assert!(reexport_idx < tail_export_idx, "{output}"); + } + + #[test] + fn should_not_rewrite_shadowed_binding_in_class_computed_key() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { key } from './dep'; +function create(key) { + class Box { + [key]() { + return key; + } + } + return Box; +} +export const box = create('local'); +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("function create(key)"), "{output}"); + assert!(output.contains("[key]"), "{output}"); + assert!( + !output.contains("__farm_ssr_imported__0[\"key\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"box\""), + "{output}" + ); + } + + #[test] + fn should_preserve_ordering_with_import_attributes_and_reexport() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import base from './base.json' with { type: 'json' }; +export { dep as alias } from './dep.json' with { type: 'json' }; +const local = base; +export { local }; +"# + .to_string(), + ), + Syntax::Es(EsSyntax { + import_attributes: true, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + let base_import_idx = output.find("\"./base.json\"").expect("base import"); + let dep_import_idx = output.find("\"./dep.json\"").expect("dep import"); + let alias_export_idx = output + .find("__farm_ssr_export_name__(\"alias\"") + .expect("alias export"); + let local_export_idx = output + .find("__farm_ssr_export_name__(\"local\"") + .expect("local export"); + + assert!(base_import_idx < dep_import_idx, "{output}"); + assert!(dep_import_idx < alias_export_idx, "{output}"); + assert!(alias_export_idx < local_export_idx, "{output}"); + assert!( + output.contains("__farm_ssr_import__(\"./base.json\","), + "{output}" + ); + assert!( + output.contains("__farm_ssr_import__(\"./dep.json\","), + "{output}" + ); + assert!(output.contains("type"), "{output}"); + } + + #[test] + fn should_rewrite_nested_destructure_default_with_scope_shadow() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import { fallback } from './dep'; +const source = { nested: {} }; +const fallbackValue = 'local'; +const { + nested: { value = fallback } +} = source; +export { value, fallbackValue }; +"# + .to_string(), + ), + Syntax::Es(Default::default()), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, false).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("value = __farm_ssr_imported__0[\"fallback\"]") + || output.contains("value=__farm_ssr_imported__0[\"fallback\"]"), + "{output}" + ); + assert!( + output.contains("__farm_ssr_export_name__(\"fallbackValue\""), + "{output}" + ); + } + + #[test] + fn should_preserve_ordering_with_ts_import_equals_and_reexport() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + r#" +import dep = require('./dep'); +export { foo as reFoo } from './dep2'; +export const value = dep.foo; +"# + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + let dep_import_idx = output.find("\"./dep\"").expect("dep import"); + let dep2_import_idx = output.find("\"./dep2\"").expect("dep2 import"); + let reexport_idx = output + .find("__farm_ssr_export_name__(\"reFoo\"") + .expect("reexport call"); + let value_export_idx = output + .find("__farm_ssr_export_name__(\"value\"") + .expect("value export call"); + + assert!(dep_import_idx < dep2_import_idx, "{output}"); + assert!(dep2_import_idx < reexport_idx, "{output}"); + assert!(reexport_idx < value_export_idx, "{output}"); + } + + #[test] + fn should_skip_transform_for_ts_type_annotation_syntax() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("const value: number = 1; export { value };".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + assert!(!can_transform_to_runner(&parsed.ast)); + let mut ast = parsed.ast; + let transformed = transform_module_ast_to_runner(&mut ast, false); + assert!(matches!( + transformed, + Err(RunnerTransformBailoutReason::UnsupportedTs) + )); + } + + #[test] + fn should_skip_transform_for_ts_as_expression_syntax() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("const value = 1 as number; export { value };".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + assert!(!can_transform_to_runner(&parsed.ast)); + } + + #[test] + fn should_transform_ts_type_annotation_after_strip() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("const value: number = 1; export { value };".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + assert!(!output.contains(": number"), "{output}"); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_transform_ts_as_expression_after_strip() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("const value = 1 as number; export default value;".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + assert!(!output.contains(" as number"), "{output}"); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_transform_ts_satisfies_expression_after_strip() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("const value = { a: 1 } satisfies { a: number }; export { value };".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + assert!(!output.contains("satisfies"), "{output}"); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_transform_ts_non_null_expression_after_strip() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "const maybe: string | undefined = 'ok'; const value = maybe!; export default value;" + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + assert!(!output.contains("maybe!"), "{output}"); + assert!(!output.contains(": string | undefined"), "{output}"); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_transform_ts_const_assertion_after_strip() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new("const value = { a: 1 } as const; export { value };".to_string()), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("__farm_ssr_export_name__(\"value\""), + "{output}" + ); + assert!(!output.contains("as const"), "{output}"); + assert!(!output.contains("export "), "{output}"); + } + + #[test] + fn should_transform_ts_instantiation_expression_after_strip() { + let module_id = ModuleId::from("entry.ts"); + let parsed = parse_module( + &module_id, + Arc::new( + "function id(v: T) { return v; } const value = id('ok'); export default value;" + .to_string(), + ), + Syntax::Typescript(TsSyntax { + tsx: false, + ..Default::default() + }), + Default::default(), + ) + .unwrap(); + + let mut ast = parsed.ast; + transform_module_ast_to_runner(&mut ast, true).unwrap(); + ast.visit_mut_with(&mut RunnerExprRewriter); + + let output = codegen_module( + &ast, + parsed.source_map, + None, + farmfe_toolkit::swc_ecma_codegen::Config::default(), + None, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!( + output.contains("__farm_ssr_export_name__(\"default\""), + "{output}" + ); + assert!(!output.contains(""), "{output}"); + assert!(!output.contains("function id"), "{output}"); + assert!(!output.contains("export "), "{output}"); + } +} diff --git a/crates/node/src/plugin_adapters/js_plugin_adapter/mod.rs b/crates/node/src/plugin_adapters/js_plugin_adapter/mod.rs index 296d9bc34b..8189f73583 100644 --- a/crates/node/src/plugin_adapters/js_plugin_adapter/mod.rs +++ b/crates/node/src/plugin_adapters/js_plugin_adapter/mod.rs @@ -317,6 +317,10 @@ impl Plugin for JsPluginAdapter { if let Some(result) = update_result { for (item, update_type) in result { + if item.trim().is_empty() { + continue; + } + if !updating_modules.contains(&item) { params.paths.push((item.clone(), update_type.into())); updating_modules.insert(item); diff --git a/crates/plugin_library/src/formats/umd.rs b/crates/plugin_library/src/formats/umd.rs index 4041d73874..fa24a48b2d 100644 --- a/crates/plugin_library/src/formats/umd.rs +++ b/crates/plugin_library/src/formats/umd.rs @@ -88,7 +88,7 @@ pub fn emit_umd_resources( } /// Example: -/// ``` +/// ```text /// // before /// console.log(require('node:fs')); /// diff --git a/examples/middleware-mode/farm.config.ts b/examples/middleware-mode/farm.config.ts new file mode 100644 index 0000000000..23bfe5cd7a --- /dev/null +++ b/examples/middleware-mode/farm.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@farmfe/core'; + +export default defineConfig({ + server: { + middlewareMode: true, + hmr: { + port: 9801 + }, + middlewares + } +}); diff --git a/examples/middleware-mode/index.html b/examples/middleware-mode/index.html new file mode 100644 index 0000000000..0e05d9abcd --- /dev/null +++ b/examples/middleware-mode/index.html @@ -0,0 +1,13 @@ + + + + + + Farm Middleware Mode + + +

Farm Middleware Mode

+
+ + + diff --git a/examples/middleware-mode/package.json b/examples/middleware-mode/package.json new file mode 100644 index 0000000000..6554b670a5 --- /dev/null +++ b/examples/middleware-mode/package.json @@ -0,0 +1,16 @@ +{ + "name": "@farmfe-examples/middleware-mode", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "dev": "node server.mjs" + }, + "devDependencies": { + "@farmfe/core": "workspace:*" + }, + "dependencies": { + "express": "^4.21.2" + } +} diff --git a/examples/middleware-mode/server.mjs b/examples/middleware-mode/server.mjs new file mode 100644 index 0000000000..898427ea01 --- /dev/null +++ b/examples/middleware-mode/server.mjs @@ -0,0 +1,56 @@ +import express from 'express'; +import { Server } from '@farmfe/core'; + +const app = express(); +const port = 3000; + +const farmServer = await Server.createServer({ + configPath: './farm.config.ts' +}); +const moduleRunner = await farmServer.createModuleRunner({ hmr: true }); + +app.get('/api/ping', (_, res) => { + res.json({ ok: true, from: 'express-api' }); +}); + +app.get('/api/runner', async (_, res) => { + try { + const mod = await moduleRunner.import('/src/entry-server.mjs'); + res.json({ + ok: true, + from: 'module-runner', + value: mod.value, + at: mod.now() + }); + } catch (error) { + res.status(500).json({ + ok: false, + message: error instanceof Error ? error.message : String(error) + }); + } +}); + +app.use(farmServer.middlewares); + +const httpServer = app.listen(port, () => { + console.log(`middleware host: http://localhost:${port}`); + console.log('hmr ws port: 9801'); + console.log('try GET /api/ping, GET /api/runner and open /'); +}); + +const close = async () => { + await farmServer.close(); + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve(undefined))); + }); +}; + +process.on('SIGINT', async () => { + await close(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + await close(); + process.exit(0); +}); diff --git a/examples/middleware-mode/src/entry-server.mjs b/examples/middleware-mode/src/entry-server.mjs new file mode 100644 index 0000000000..a462c2d9ed --- /dev/null +++ b/examples/middleware-mode/src/entry-server.mjs @@ -0,0 +1,5 @@ +export const value = 'hello-from-runner'; + +export function now() { + return new Date().toISOString(); +} diff --git a/examples/middleware-mode/src/main.ts b/examples/middleware-mode/src/main.ts new file mode 100644 index 0000000000..85cd641ecd --- /dev/null +++ b/examples/middleware-mode/src/main.ts @@ -0,0 +1,23 @@ +const app = document.querySelector('#app'); + +if (app) { + app.innerHTML = ` +

123 Farm middlewares are mounted on an external Express app.

+

Open /api/ping to verify custom routes still work.

+

Open /api/runner to verify ModuleRunner SSR import.

+

Edit src/main.ts and check HMR update.

+ +

+  `;
+
+  const button = document.querySelector('#runner-check');
+  const result = document.querySelector('#runner-result');
+
+  button?.addEventListener('click', async () => {
+    const response = await fetch('/api/runner');
+    const payload = await response.json();
+    if (result) {
+      result.textContent = JSON.stringify(payload, null, 2);
+    }
+  });
+}
diff --git a/examples/ssr-toolkit/.gitignore b/examples/ssr-toolkit/.gitignore
new file mode 100644
index 0000000000..b1866059e6
--- /dev/null
+++ b/examples/ssr-toolkit/.gitignore
@@ -0,0 +1,3 @@
+dist
+node_modules
+.DS_Store
diff --git a/examples/ssr-toolkit/README.md b/examples/ssr-toolkit/README.md
new file mode 100644
index 0000000000..fca1295887
--- /dev/null
+++ b/examples/ssr-toolkit/README.md
@@ -0,0 +1,124 @@
+# SSR Toolkit Example
+
+这个 example 用一个完整的 Vue SSR 项目演示 `@farmfe/ssr` 的三种接入方式:
+
+1. 原生 Farm CLI(`farm ssr dev/build/preview`)
+2. 用户自定义 Node Host(把 Farm 当 middleware 挂进去)
+3. 用户已有模板体系(示例里用 `ejs` 风格模板)
+
+## Tech Stack
+
+1. Vue 3 + Vue Router
+2. Vue SFC (`src/pages/*.vue`)
+3. Less (`src/styles/app.less`)
+4. TypeScript 入口(`src/main.ts` / `src/entry-server.ts`)
+5. Farm 配置收敛为单文件:`farm.config.ts`
+
+当前 example 为保证 preview 懒加载路由稳定,client 构建使用了 `compilation.minify=false`(已记录为 Farm 核心已知问题,后续可在核心修复后恢复)。
+
+## Usage A: Farm CLI
+
+先在仓库根目录构建 CLI:
+
+```bash
+scripts/pnpm-node22.sh --filter @farmfe/cli build
+```
+
+然后在 `examples/ssr-toolkit` 目录执行:
+
+```bash
+pnpm run cli:prepare
+pnpm run cli:dev
+pnpm run cli:build
+pnpm run cli:preview
+```
+
+`cli:preview` 已内置 `cli:build` 前置步骤,避免产物缺失导致 preview 失败。
+
+## Usage B: Custom Node Host + Middleware
+
+`examples/ssr-toolkit/server.mjs` 展示了“外部 host 持有自己的 API,Farm 只处理 SSR + assets”的模式,使用 `createSsrRuntime` 创建运行时实例。该示例采用 **D05 模式 B**:Host 决定哪些请求进入 `runtime.render`,其他资源请求交给 `runtime.middlewares`。
+
+```bash
+pnpm run host:dev
+```
+
+说明:  
+host 路径本身就是“用户自有 Node server 启动”,所以入口命令是 `node server.mjs`,不是 `farm ssr dev`。  
+`farm` 在这个路径里负责构建与中间件能力,不接管宿主服务进程。
+
+如果要指定 HMR 端口:
+
+```bash
+SSR_HMR_PORT=9821 pnpm run host:dev
+```
+
+如果要指定 host 监听端口:
+
+```bash
+SSR_HOST_PORT=3011 pnpm run host:dev
+```
+
+关键点:
+
+1. `/api/ping` 由 host 控制
+2. `ssrServer.middlewares(req, res, next)` 处理页面与静态资源
+
+为了便于参考,host 逻辑已拆分到 `server/` 目录:
+
+1. `server/runtime-config.mjs`:环境变量与 command/mode/template 解析
+2. `server/ports.mjs`:host/HMR 端口策略
+3. `server/template.mjs`:dev/preview 模板策略
+4. `server/ssr-options.mjs`:SSR options 与启动信息拼装
+
+## Usage C: EJS Template Integration
+
+同一个 host,切到 ejs 模板模式:
+
+```bash
+pnpm run host:dev:ejs
+```
+
+Host preview 路径会先执行 `farm ssr build` 再启动宿主服务:
+
+```bash
+pnpm run host:preview
+```
+
+注意:当前 example 的 preview 仅支持 `html` 模板;`SSR_TEMPLATE_MODE=ejs` 仅用于 dev 场景。
+
+当前示例支持两种模板:
+
+1. `index.html`(默认)
+2. `index.ejs`(通过 `template.load + template.transform`)
+
+## Route Contract
+
+1. `/` -> `route:home`
+2. `/about` -> `route:about`
+3. `/products` -> `route:products`
+4. unknown path -> `route:not-found` + `#route-not-found`(业务 404 页面)
+
+路由元数据集中在 `src/routes.mjs`,供应用复用,避免示例数据漂移。
+
+## HMR Contract
+
+1. Server HMR:修改 `src/entry-server.ts`,SSR HTML 会更新
+2. Client HMR:修改 `src/pages/HomePage.vue`,浏览器已打开页面会热更新
+
+## 端口占用处理
+
+常见报错:
+
+1. `WebSocket server error: Port is already in use`
+2. `listen EADDRINUSE: address already in use 127.0.0.1:3011`
+
+处理策略(本 example 已实现):
+
+1. 默认自动探测可用 HMR 端口(从 `9811` 开始向后探测)。
+2. 默认自动探测可用 host 端口(从 `3011` 开始向后探测)。
+3. 允许用户显式指定:`SSR_HMR_PORT=` / `SSR_HOST_PORT=`。
+4. 若显式端口被占用,直接失败并提示,避免静默漂移。
+
+这和 Vite/Nuxt 的经验一致:  
+优先“自动选择可用端口 + 保留手动固定能力”,并在端口冲突时给出明确错误信息,方便排查僵尸进程或多实例并行启动。
diff --git a/examples/ssr-toolkit/farm.config.ts b/examples/ssr-toolkit/farm.config.ts
new file mode 100644
index 0000000000..05581a5d33
--- /dev/null
+++ b/examples/ssr-toolkit/farm.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from '@farmfe/core';
+import farmLessPlugin from '@farmfe/js-plugin-less';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+  plugins: [farmLessPlugin()],
+  vitePlugins: [vue()],
+  server: {
+    middlewareMode: true
+  }
+});
diff --git a/examples/ssr-toolkit/index.ejs b/examples/ssr-toolkit/index.ejs
new file mode 100644
index 0000000000..3dfc24611e
--- /dev/null
+++ b/examples/ssr-toolkit/index.ejs
@@ -0,0 +1,13 @@
+
+
+  
+    
+    
+    <%= pageTitle %>
+  
+  
+    
+
<%- appHtml %>
+ + + diff --git a/examples/ssr-toolkit/index.html b/examples/ssr-toolkit/index.html new file mode 100644 index 0000000000..1ec5c5bc0a --- /dev/null +++ b/examples/ssr-toolkit/index.html @@ -0,0 +1,12 @@ + + + + + + Farm SSR Toolkit Example + + +
+ + + diff --git a/examples/ssr-toolkit/package.json b/examples/ssr-toolkit/package.json new file mode 100644 index 0000000000..dde23abb32 --- /dev/null +++ b/examples/ssr-toolkit/package.json @@ -0,0 +1,29 @@ +{ + "name": "@farmfe-examples/ssr-toolkit", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-vue": "^6.0.4", + "vue": "^3.3.12", + "vue-router": "^4.2.1" + }, + "scripts": { + "start": "node server.mjs", + "dev": "pnpm run host:dev", + "dev:ejs": "pnpm run host:dev:ejs", + "host:dev": "node server.mjs", + "host:dev:ejs": "pnpm --filter @farmfe/ssr build && SSR_TEMPLATE_MODE=ejs node server.mjs", + "host:preview": "pnpm run cli:build && SSR_COMMAND=preview node server.mjs", + "cli:prepare": "pnpm --filter @farmfe/cli build && pnpm --filter @farmfe/ssr build", + "cli:dev": "farm ssr dev . --config farm.config.ts", + "cli:build": "farm ssr build . --config farm.config.ts", + "cli:preview": "pnpm run cli:build && farm ssr preview . --config farm.config.ts" + }, + "devDependencies": { + "@farmfe/core": "workspace:*", + "@farmfe/js-plugin-less": "workspace:*", + "@farmfe/ssr": "workspace:*", + "less": "^4.1.3" + } +} diff --git a/examples/ssr-toolkit/server.mjs b/examples/ssr-toolkit/server.mjs new file mode 100644 index 0000000000..f605e61b3f --- /dev/null +++ b/examples/ssr-toolkit/server.mjs @@ -0,0 +1,140 @@ +import { createServer as createNodeServer } from 'node:http'; +import { createSsrRuntime } from '../../packages/ssr/dist/index.js'; +import { resolveHostPort, resolveOptionalDevHmrPort } from './server/ports.mjs'; +import { createRuntimeConfig } from './server/runtime-config.mjs'; +import { + createPingPayload, + createSsrServerOptions, + createStartupMessage +} from './server/ssr-options.mjs'; + +const runtime = createRuntimeConfig(); +const hmrPort = await resolveOptionalDevHmrPort({ + command: runtime.command, + host: runtime.host, + explicitHmrPort: runtime.explicitHmrPort +}); +const hostPort = await resolveHostPort({ + host: runtime.host, + explicitHostPort: runtime.explicitHostPort +}); + +const ssrRuntime = await createSsrRuntime( + createSsrServerOptions({ + runtime, + hmrPort + }) +); + +function isHtmlRequest(req) { + const method = req.method?.toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + return false; + } + + const url = req.url ?? '/'; + if (url.includes('/@') || url.includes('/__')) { + return false; + } + + const queryIndex = url.indexOf('?'); + const pathname = queryIndex >= 0 ? url.slice(0, queryIndex) : url; + if (/\.[a-zA-Z0-9]+$/.test(pathname)) { + return false; + } + + const accept = req.headers.accept; + if (!accept) { + return true; + } + + return accept.includes('text/html') || accept.includes('*/*'); +} + +function shouldHandleSsr(req) { + if (!isHtmlRequest(req)) { + return false; + } + + const url = req.url ?? '/'; + if (url.startsWith('/api/')) { + return false; + } + + return true; +} + +const hostServer = createNodeServer((req, res) => { + if (req.url === '/api/ping') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(createPingPayload(runtime))); + return; + } + + if (shouldHandleSsr(req)) { + ssrRuntime + .render(req.url ?? '/', req, res) + .then((html) => { + if (res.writableEnded) { + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + }) + .catch((error) => { + const stack = error instanceof Error ? error.stack ?? error.message : String(error); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(stack); + }); + return; + } + + ssrRuntime.middlewares(req, res, (error) => { + if (error) { + const stack = error instanceof Error ? error.stack ?? error.message : String(error); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(stack); + return; + } + + if (!res.writableEnded) { + res.statusCode = 404; + res.end('Not Found'); + } + }); +}); + +await new Promise((resolve, reject) => { + hostServer.once('listening', resolve); + hostServer.once('error', reject); + hostServer.listen(hostPort, runtime.host); +}); + +console.log(createStartupMessage({ runtime, hostPort, hmrPort })); +console.log('try GET /api/ping and open /'); + +const close = async () => { + await new Promise((resolve, reject) => { + hostServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await ssrRuntime.close(); +}; + +process.on('SIGINT', async () => { + await close(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + await close(); + process.exit(0); +}); diff --git a/examples/ssr-toolkit/server/ports.mjs b/examples/ssr-toolkit/server/ports.mjs new file mode 100644 index 0000000000..c3a78dc0f1 --- /dev/null +++ b/examples/ssr-toolkit/server/ports.mjs @@ -0,0 +1,106 @@ +import { createServer as createNetServer } from 'node:net'; + +const DEFAULT_MAX_PROBE = 30; +const DEFAULT_HMR_START_PORT = 9811; +const DEFAULT_HOST_START_PORT = 3011; + +export function isPositiveInteger(value) { + return Number.isInteger(value) && value > 0; +} + +export async function isPortAvailable(portToCheck, hostname) { + return new Promise((resolve) => { + const server = createNetServer(); + server.unref(); + server.once('error', () => { + resolve(false); + }); + server.listen({ port: portToCheck, host: hostname }, () => { + server.close(() => resolve(true)); + }); + }); +} + +async function resolvePortWithFallback(params) { + const { + label, + envName, + explicitPort, + host, + startPort, + maxProbe, + isPortAvailableFn + } = params; + + if (explicitPort != null) { + if (!isPositiveInteger(explicitPort)) { + throw new Error( + `[ssr-toolkit] invalid ${envName}="${process.env[envName]}", expected a positive integer.` + ); + } + + const available = await isPortAvailableFn(explicitPort, host); + if (!available) { + throw new Error(`[ssr-toolkit] ${envName}=${explicitPort} is already in use.`); + } + + return explicitPort; + } + + for (let offset = 0; offset < maxProbe; offset++) { + const candidate = startPort + offset; + if (await isPortAvailableFn(candidate, host)) { + return candidate; + } + } + + throw new Error( + `[ssr-toolkit] failed to find an available ${label} port in range ${startPort}-${startPort + maxProbe - 1}.` + ); +} + +export async function resolveOptionalDevHmrPort(params) { + const { + command, + host, + explicitHmrPort, + startPort = DEFAULT_HMR_START_PORT, + maxProbe = DEFAULT_MAX_PROBE, + isPortAvailableFn = isPortAvailable + } = params; + + if (command !== 'dev') { + return undefined; + } + + return resolvePortWithFallback({ + label: 'HMR', + envName: 'SSR_HMR_PORT', + explicitPort: explicitHmrPort, + host, + startPort, + maxProbe, + isPortAvailableFn + }); +} + +export async function resolveHostPort(params) { + const { + host, + explicitHostPort, + startPort = DEFAULT_HOST_START_PORT, + maxProbe = DEFAULT_MAX_PROBE, + isPortAvailableFn = isPortAvailable + } = params; + + return resolvePortWithFallback({ + label: 'host', + envName: 'SSR_HOST_PORT', + explicitPort: explicitHostPort, + host, + startPort, + maxProbe, + isPortAvailableFn + }); +} + diff --git a/examples/ssr-toolkit/server/runtime-config.mjs b/examples/ssr-toolkit/server/runtime-config.mjs new file mode 100644 index 0000000000..bcf028139c --- /dev/null +++ b/examples/ssr-toolkit/server/runtime-config.mjs @@ -0,0 +1,38 @@ +const SSR_HOST = '127.0.0.1'; + +function parseOptionalNumber(raw) { + if (raw == null || raw === '') { + return undefined; + } + + return Number(raw); +} + +export function resolveSsrCommand(env = process.env) { + if (env.SSR_COMMAND === 'preview' || env.SSR_MODE === 'preview') { + return 'preview'; + } + + return 'dev'; +} + +export function resolveSsrEnvMode(command, env = process.env) { + return env.SSR_ENV_MODE ?? (command === 'preview' ? 'production' : 'development'); +} + +export function resolveTemplateMode(env = process.env) { + return env.SSR_TEMPLATE_MODE === 'ejs' ? 'ejs' : 'html'; +} + +export function createRuntimeConfig(env = process.env) { + const command = resolveSsrCommand(env); + return { + host: SSR_HOST, + command, + mode: resolveSsrEnvMode(command, env), + templateMode: resolveTemplateMode(env), + explicitHmrPort: parseOptionalNumber(env.SSR_HMR_PORT), + explicitHostPort: parseOptionalNumber(env.SSR_HOST_PORT) + }; +} + diff --git a/examples/ssr-toolkit/server/ssr-options.mjs b/examples/ssr-toolkit/server/ssr-options.mjs new file mode 100644 index 0000000000..2c29241c13 --- /dev/null +++ b/examples/ssr-toolkit/server/ssr-options.mjs @@ -0,0 +1,70 @@ +import { createSsrRenderConfig } from './template.mjs'; + +export function createSsrServerOptions(params) { + const { runtime, hmrPort } = params; + const configFile = './farm.config.ts'; + + return { + command: runtime.command, + mode: runtime.mode, + client: { + configFile, + server: { + preview: { + distDir: 'dist/client' + }, + ...(hmrPort + ? { + hmr: { + port: hmrPort, + host: runtime.host + } + } + : {}) + }, + compilation: { + minify: false, + input: { + index: './index.html' + }, + output: { + path: 'dist/client' + } + } + }, + server: { + configFile, + compilation: { + input: { + index: './src/entry-server.ts' + }, + output: { + path: 'dist/server', + targetEnv: 'node', + format: 'esm' + }, + minify: false + } + }, + ssr: createSsrRenderConfig({ + command: runtime.command, + templateMode: runtime.templateMode + }) + }; +} + +export function createPingPayload(runtime) { + return { + ok: true, + from: 'ssr-toolkit', + command: runtime.command, + mode: runtime.mode, + templateMode: runtime.templateMode + }; +} + +export function createStartupMessage(params) { + const { runtime, hostPort, hmrPort } = params; + + return `ssr toolkit host: http://${runtime.host}:${hostPort} [command=${runtime.command}, mode=${runtime.mode}, template=${runtime.templateMode}${hmrPort ? `, hmr=${hmrPort}` : ''}]`; +} diff --git a/examples/ssr-toolkit/server/template.mjs b/examples/ssr-toolkit/server/template.mjs new file mode 100644 index 0000000000..e7b5c3a827 --- /dev/null +++ b/examples/ssr-toolkit/server/template.mjs @@ -0,0 +1,86 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +function escapeHtml(raw) { + return String(raw) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function renderEjsLike(template, locals) { + return template.replace(/<%([=-])\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*%>/g, (_all, mode, key) => { + const value = locals[key] ?? ''; + + if (mode === '=') { + return escapeHtml(value); + } + + return String(value); + }); +} + +export function injectAppHtmlIntoBuiltTemplate(template, appHtml) { + const rootContainerPattern = /]*>[\s\S]*?<\/div>/; + + if (!rootContainerPattern.test(template)) { + throw new Error('[ssr-toolkit] failed to locate root container in built html template.'); + } + + return template.replace(rootContainerPattern, `
${appHtml}
`); +} + +export function createTemplateConfig(params) { + const { command, templateMode } = params; + + if (command === 'preview' && templateMode === 'ejs') { + throw new Error( + '[ssr-toolkit] preview mode does not support SSR_TEMPLATE_MODE=ejs yet. Use default html template for host:preview.' + ); + } + + if (templateMode === 'ejs') { + return { + file: './index.ejs', + async load({ root }) { + return fs.readFile(path.join(root, 'index.ejs'), 'utf-8'); + }, + async transform({ template, appHtml }) { + return renderEjsLike(template, { + appHtml, + templateMode, + pageTitle: 'Farm SSR Toolkit Example (ejs)' + }); + } + }; + } + + if (command === 'preview') { + return { + file: './dist/client/index.html', + async transform({ template, appHtml }) { + return injectAppHtmlIntoBuiltTemplate(template, appHtml); + } + }; + } + + return { + resource: 'index.html', + placeholder: '' + }; +} + +export function createSsrRenderConfig(params) { + const { command, templateMode } = params; + + return { + ...(command === 'dev' ? { entry: '/src/entry-server.ts' } : {}), + template: createTemplateConfig({ + command, + templateMode + }) + }; +} + diff --git a/examples/ssr-toolkit/src/App.vue b/examples/ssr-toolkit/src/App.vue new file mode 100644 index 0000000000..0b88c7f5b6 --- /dev/null +++ b/examples/ssr-toolkit/src/App.vue @@ -0,0 +1,46 @@ + + + diff --git a/examples/ssr-toolkit/src/app.ts b/examples/ssr-toolkit/src/app.ts new file mode 100644 index 0000000000..4ea52dfec0 --- /dev/null +++ b/examples/ssr-toolkit/src/app.ts @@ -0,0 +1,22 @@ +import { createSSRApp } from 'vue'; +import type { RouterHistory } from 'vue-router'; +import AppRoot from './App.vue'; +import { createAppRouter } from './router'; + +export interface CreateFarmSsrVueAppOptions { + history: RouterHistory; + homeBanner: string; +} + +export function createFarmSsrVueApp(options: CreateFarmSsrVueAppOptions) { + const app = createSSRApp(AppRoot); + const router = createAppRouter(options.history); + + app.provide('homeBanner', options.homeBanner); + app.use(router); + + return { + app, + router + }; +} diff --git a/examples/ssr-toolkit/src/entry-server.ts b/examples/ssr-toolkit/src/entry-server.ts new file mode 100644 index 0000000000..bfad870a74 --- /dev/null +++ b/examples/ssr-toolkit/src/entry-server.ts @@ -0,0 +1,17 @@ +import { renderToString } from 'vue/server-renderer'; +import { createMemoryHistory } from 'vue-router'; +import { createFarmSsrVueApp } from './app'; + +const HOME_BANNER = 'Farm SSR Toolkit'; + +export default async function render(url) { + const { app, router } = createFarmSsrVueApp({ + history: createMemoryHistory(), + homeBanner: HOME_BANNER + }); + + await router.push(url); + await router.isReady(); + + return renderToString(app); +} diff --git a/examples/ssr-toolkit/src/main.ts b/examples/ssr-toolkit/src/main.ts new file mode 100644 index 0000000000..46e225b6ac --- /dev/null +++ b/examples/ssr-toolkit/src/main.ts @@ -0,0 +1,18 @@ +import { createWebHistory } from 'vue-router'; +import { createFarmSsrVueApp } from './app'; +import './styles/app.less'; + +const HOME_BANNER = 'Farm SSR Toolkit'; + +const { app, router } = createFarmSsrVueApp({ + history: createWebHistory(), + homeBanner: HOME_BANNER +}); + +router.isReady().then(() => { + app.mount('#root'); +}); + +if (import.meta.hot) { + import.meta.hot.accept(); +} diff --git a/examples/ssr-toolkit/src/pages/AboutPage.vue b/examples/ssr-toolkit/src/pages/AboutPage.vue new file mode 100644 index 0000000000..27aa56973a --- /dev/null +++ b/examples/ssr-toolkit/src/pages/AboutPage.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/examples/ssr-toolkit/src/pages/HomePage.vue b/examples/ssr-toolkit/src/pages/HomePage.vue new file mode 100644 index 0000000000..4b97e4984f --- /dev/null +++ b/examples/ssr-toolkit/src/pages/HomePage.vue @@ -0,0 +1,16 @@ + + + diff --git a/examples/ssr-toolkit/src/pages/NotFoundPage.vue b/examples/ssr-toolkit/src/pages/NotFoundPage.vue new file mode 100644 index 0000000000..4024488aed --- /dev/null +++ b/examples/ssr-toolkit/src/pages/NotFoundPage.vue @@ -0,0 +1,19 @@ + + + diff --git a/examples/ssr-toolkit/src/pages/ProductsPage.vue b/examples/ssr-toolkit/src/pages/ProductsPage.vue new file mode 100644 index 0000000000..eeca24adec --- /dev/null +++ b/examples/ssr-toolkit/src/pages/ProductsPage.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/ssr-toolkit/src/router.ts b/examples/ssr-toolkit/src/router.ts new file mode 100644 index 0000000000..0ba11a0c76 --- /dev/null +++ b/examples/ssr-toolkit/src/router.ts @@ -0,0 +1,55 @@ +import { createRouter, type RouteRecordRaw, type RouterHistory } from 'vue-router'; +import HomePage from './pages/HomePage.vue'; +import NotFoundPage from './pages/NotFoundPage.vue'; +import ProductsPage from './pages/ProductsPage.vue'; +import { notFoundRoute, routes } from './routes.mjs'; + +const routeRecords: RouteRecordRaw[] = [ + { + path: '/', + name: 'home', + component: HomePage, + meta: { + key: 'home', + heading: routes[0].heading, + status: 'ok' + } + }, + { + path: '/about', + name: 'about', + component: () => import('./pages/AboutPage.vue'), + meta: { + key: 'about', + heading: routes[1].heading, + status: 'ok' + } + }, + { + path: '/products', + name: 'products', + component: ProductsPage, + meta: { + key: 'products', + heading: routes[2].heading, + status: 'ok' + } + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: NotFoundPage, + meta: { + key: 'not-found', + heading: notFoundRoute.heading, + status: 'not-found' + } + } +]; + +export function createAppRouter(history: RouterHistory) { + return createRouter({ + history, + routes: routeRecords + }); +} diff --git a/examples/ssr-toolkit/src/routes.mjs b/examples/ssr-toolkit/src/routes.mjs new file mode 100644 index 0000000000..fe20f5826b --- /dev/null +++ b/examples/ssr-toolkit/src/routes.mjs @@ -0,0 +1,39 @@ +export const message = 'hello-from-ssr-toolkit'; + +export const routes = [ + { + key: 'home', + path: '/', + title: 'Farm SSR Toolkit', + heading: 'Home', + content: `${message} | home route` + }, + { + key: 'about', + path: '/about', + title: 'About - Farm SSR Toolkit', + heading: 'About Farm SSR Toolkit', + content: 'about-page-from-ssr-toolkit' + }, + { + key: 'products', + path: '/products', + title: 'Products - Farm SSR Toolkit', + heading: 'Products', + content: 'products-page-from-ssr-toolkit' + } +]; + +export const notFoundRoute = { + key: 'not-found', + title: 'Route Not Found - Farm SSR Toolkit', + heading: 'Route Not Found' +}; + +export function getRouteByKey(key) { + return routes.find((item) => item.key === key); +} + +export function getRouteByPath(path) { + return routes.find((item) => item.path === path); +} diff --git a/examples/ssr-toolkit/src/styles/app.less b/examples/ssr-toolkit/src/styles/app.less new file mode 100644 index 0000000000..c64a56ed0b --- /dev/null +++ b/examples/ssr-toolkit/src/styles/app.less @@ -0,0 +1,50 @@ +@app-bg: #f3f8f4; +@app-border: #a7c4af; +@app-heading: #14532d; +@app-text: #24362a; + +#ssr-app { + margin: 1.5rem auto; + max-width: 860px; + padding: 1rem 1.25rem; + border: 1px solid @app-border; + border-radius: 12px; + background: linear-gradient(165deg, @app-bg 0%, #ffffff 100%); + color: @app-text; +} + +.app-header { + h1 { + margin: 0 0 0.75rem; + color: @app-heading; + font-size: 1.8rem; + } +} + +.app-nav { + display: flex; + gap: 0.75rem; + margin-bottom: 1rem; + + a { + color: #0f5132; + text-decoration: none; + font-weight: 600; + } +} + +.route-shell { + background: #ffffff; + border: 1px solid #d7e7da; + border-radius: 10px; + padding: 1rem; +} + +.route-page { + line-height: 1.6; +} + +.less-theme-active { + color: #0f766e; + font-weight: 700; +} diff --git a/examples/ssr-toolkit/tsconfig.json b/examples/ssr-toolkit/tsconfig.json new file mode 100644 index 0000000000..ff87e9faf3 --- /dev/null +++ b/examples/ssr-toolkit/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "nodenext", + "noEmit": true, + "jsx": "react" + }, + "include": ["src", "farm.config.ts"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 04fc0d4e6c..d781a54164 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,6 +39,7 @@ "node": ">= 16" }, "dependencies": { + "@farmfe/ssr": "workspace:*", "cac": "^6.7.14" } } diff --git a/packages/cli/src/argv.spec.ts b/packages/cli/src/argv.spec.ts new file mode 100644 index 0000000000..8842a8da58 --- /dev/null +++ b/packages/cli/src/argv.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeCliArgv } from './argv.js'; + +describe('farm cli argv normalizer', () => { + it('normalizes ssr subcommands from multi-token argv', () => { + expect( + normalizeCliArgv(['node', 'farm', 'ssr', 'build', 'examples/ssr-toolkit']) + ).toEqual(['node', 'farm', 'ssr build', 'examples/ssr-toolkit']); + expect( + normalizeCliArgv([ + 'node', + 'farm', + 'ssr', + 'preview', + 'examples/ssr-toolkit' + ]) + ).toEqual(['node', 'farm', 'ssr preview', 'examples/ssr-toolkit']); + expect(normalizeCliArgv(['node', 'farm', 'ssr', 'dev'])).toEqual([ + 'node', + 'farm', + 'ssr dev' + ]); + }); + + it('keeps argv unchanged for non-ssr commands', () => { + expect(normalizeCliArgv(['node', 'farm', 'build'])).toEqual([ + 'node', + 'farm', + 'build' + ]); + expect(normalizeCliArgv(['node', 'farm', 'ssr', 'unknown'])).toEqual([ + 'node', + 'farm', + 'ssr', + 'unknown' + ]); + expect(normalizeCliArgv(['node', 'farm', 'ssr build'])).toEqual([ + 'node', + 'farm', + 'ssr build' + ]); + }); +}); diff --git a/packages/cli/src/argv.ts b/packages/cli/src/argv.ts new file mode 100644 index 0000000000..39f7490dba --- /dev/null +++ b/packages/cli/src/argv.ts @@ -0,0 +1,20 @@ +const SSR_SUBCOMMANDS = new Set(['dev', 'build', 'preview']); + +export function normalizeCliArgv(argv: string[]) { + if (argv.length < 4) { + return argv; + } + + if (argv[2] !== 'ssr') { + return argv; + } + + const subcommand = argv[3]; + if (!SSR_SUBCOMMANDS.has(subcommand)) { + return argv; + } + + const normalized = [...argv]; + normalized.splice(2, 2, `ssr ${subcommand}`); + return normalized; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index db3504588a..7cf3d54089 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,11 +4,14 @@ import { UserConfig } from '@farmfe/core'; import { cac } from 'cac'; +import { normalizeCliArgv } from './argv.js'; +import { resolveSsrRunOptions } from './ssr.js'; import type { CleanOptions, CliBuildOptions, CliPreviewOptions, CliServerOptions, + CliSsrOptions, GlobalCliOptions } from './types.js'; import { @@ -16,6 +19,7 @@ import { resolveCliConfig, resolveCommandOptions, resolveCore, + resolveSsr, VERSION } from './utils.js'; @@ -232,6 +236,75 @@ cli } ); +function createSsrAction(command: 'dev' | 'build' | 'preview') { + return async (root = '', options: CliSsrOptions & GlobalCliOptions) => { + const resolvedOptions = resolveSsrRunOptions({ + command, + root, + options + }); + const { runSsrCommand } = await resolveSsr(); + + handleAsyncOperationErrors( + runSsrCommand(resolvedOptions), + `Failed to run "farm ssr ${command}"` + ); + }; +} + +cli + .command('ssr dev [root]', 'run SSR toolkit in dev mode') + .option('--client-config ', '[string] client config file') + .option('--server-config ', '[string] server config file') + .option('--entry ', '[string] server render entry file') + .option('--export-name ', '[string] render export name') + .option('--template-file ', '[string] html template file') + .option('--template-resource ', '[string] template resource name') + .option('--placeholder ', '[string] html placeholder') + .option('--host ', '[string] specify host') + .option('--port ', '[number] specify port') + .option('--open', '[boolean] open browser on server start') + .option('--strictPort', '[boolean] exit if specified port is already in use') + .option('-o, --outDir ', '[string] output directory') + .option('--base ', '[string] public base path') + .action(createSsrAction('dev')); + +cli + .command('ssr build [root]', 'build SSR toolkit client and server bundles') + .option('--client-config ', '[string] client config file') + .option('--server-config ', '[string] server config file') + .option('-o, --outDir ', '[string] output directory') + .option('-i, --input ', '[string] input file path') + .option( + '--target ', + '[string] transpile targetEnv node, browser, library' + ) + .option('--format ', '[string] transpile format esm, commonjs') + .option('--sourcemap', '[boolean] output source maps for build') + .option( + '--treeShaking', + '[boolean] Eliminate useless code without side effects' + ) + .option('--minify', '[boolean] code compression at build time') + .option('--base ', '[string] public base path') + .action(createSsrAction('build')); + +cli + .command('ssr preview [root]', 'preview SSR toolkit server from build output') + .option('--client-config ', '[string] client config file') + .option('--server-config ', '[string] server config file') + .option('--entry ', '[string] server render entry file') + .option('--export-name ', '[string] render export name') + .option('--template-file ', '[string] html template file') + .option('--placeholder ', '[string] html placeholder') + .option('--host ', '[string] specify host') + .option('--port ', '[number] specify port') + .option('--open', '[boolean] open browser on preview server start') + .option('--strictPort', '[boolean] exit if specified port is already in use') + .option('-o, --outDir ', '[string] output directory') + .option('--base ', '[string] public base path') + .action(createSsrAction('preview')); + cli .command('clean [path]', 'Clean up the cache built incrementally') .option( @@ -254,4 +327,4 @@ cli.version( `@farmfe/cli ${VERSION ?? 'unknown'} @farmfe/core ${CORE_VERSION ?? 'unknown'}` ); -cli.parse(); +cli.parse(normalizeCliArgv(process.argv)); diff --git a/packages/cli/src/ssr.spec.ts b/packages/cli/src/ssr.spec.ts new file mode 100644 index 0000000000..8a130d2cc2 --- /dev/null +++ b/packages/cli/src/ssr.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSsrRunOptions } from './ssr.js'; + +describe('farm cli ssr option resolver', () => { + it('resolves build command with default config files', () => { + const result = resolveSsrRunOptions({ + command: 'build', + root: '/repo/app', + options: {} + }); + + expect(result.command).toBe('build'); + expect(result.client.configFile).toBe('farm.config.client.ts'); + expect(result.server.configFile).toBe('farm.config.server.ts'); + expect(result.client.root).toBe('/repo/app'); + expect(result.server.root).toBe('/repo/app'); + expect(result.client.compilation?.input).toEqual({ + index: './index.html' + }); + expect(result.server.compilation?.input).toEqual({ + index: '/src/entry-server.mjs' + }); + expect(result.client.compilation?.output).toMatchObject({ + path: 'dist/client' + }); + expect(result.server.compilation?.output).toMatchObject({ + path: 'dist/server', + targetEnv: 'node', + format: 'esm' + }); + }); + + it('uses convention fallback entry when dev/preview entry is omitted', () => { + const devResult = resolveSsrRunOptions({ + command: 'dev', + root: '/repo/app', + options: {} + }); + const previewResult = resolveSsrRunOptions({ + command: 'preview', + root: '/repo/app', + options: {} + }); + + expect(devResult.ssr?.entry).toBe('/src/entry-server.mjs'); + expect(previewResult.ssr?.entry).toBe('index.js'); + }); + + it('maps dev options to run command payload', () => { + const result = resolveSsrRunOptions({ + command: 'dev', + root: '/repo/app', + options: { + entry: '/src/entry-server.ts', + host: '127.0.0.1', + port: '3011' as unknown as number, + mode: 'development', + placeholder: '' + } + }); + + expect(result.command).toBe('dev'); + expect(result.mode).toBe('development'); + expect(result.host).toEqual({ + hostname: '127.0.0.1', + port: 3011 + }); + expect(result.ssr).toEqual({ + entry: '/src/entry-server.ts', + template: { + resource: 'index.html', + placeholder: '' + } + }); + }); + + it('rejects template-resource for preview command', () => { + expect(() => + resolveSsrRunOptions({ + command: 'preview', + root: '/repo/app', + options: { + entry: 'index.js', + templateResource: 'index.html' + } + }) + ).toThrow('--template-resource is only supported'); + }); + + it('uses --config as shared fallback for client/server config', () => { + const result = resolveSsrRunOptions({ + command: 'build', + root: '/repo/app', + options: { + config: './farm.config.ts' + } + }); + + expect(result.client.configFile).toBe('./farm.config.ts'); + expect(result.server.configFile).toBe('./farm.config.ts'); + }); + + it('uses dedicated config over shared config', () => { + const result = resolveSsrRunOptions({ + command: 'build', + root: '/repo/app', + options: { + config: './farm.config.ts', + clientConfig: './farm.config.client.ts', + serverConfig: './farm.config.server.ts' + } + }); + + expect(result.client.configFile).toBe('./farm.config.client.ts'); + expect(result.server.configFile).toBe('./farm.config.server.ts'); + }); + + it('respects entry precedence: cli > env > convention', () => { + const previousEnvEntry = process.env.FARM_SSR_ENTRY; + const previousDevEnvEntry = process.env.FARM_SSR_DEV_ENTRY; + process.env.FARM_SSR_ENTRY = '/from/env/shared.ts'; + process.env.FARM_SSR_DEV_ENTRY = '/from/env/dev.ts'; + + try { + const envResult = resolveSsrRunOptions({ + command: 'dev', + root: '/repo/app', + options: {} + }); + const cliResult = resolveSsrRunOptions({ + command: 'dev', + root: '/repo/app', + options: { + entry: '/from/cli.ts' + } + }); + + expect(envResult.ssr?.entry).toBe('/from/env/dev.ts'); + expect(cliResult.ssr?.entry).toBe('/from/cli.ts'); + } finally { + if (previousEnvEntry == null) { + delete process.env.FARM_SSR_ENTRY; + } else { + process.env.FARM_SSR_ENTRY = previousEnvEntry; + } + + if (previousDevEnvEntry == null) { + delete process.env.FARM_SSR_DEV_ENTRY; + } else { + process.env.FARM_SSR_DEV_ENTRY = previousDevEnvEntry; + } + } + }); +}); diff --git a/packages/cli/src/ssr.ts b/packages/cli/src/ssr.ts new file mode 100644 index 0000000000..7e9d235634 --- /dev/null +++ b/packages/cli/src/ssr.ts @@ -0,0 +1,400 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type { FarmCliOptions, UserConfig } from '@farmfe/core'; +import type { CliSsrOptions, GlobalCliOptions } from './types.js'; +import { resolveRootPath } from './utils.js'; + +export type SsrCliCommand = 'dev' | 'build' | 'preview'; + +type SsrHostOptions = { + hostname?: string; + port?: number; +}; + +type SsrTemplateOptions = { + file?: string; + resource?: string; + placeholder?: string; +}; + +type SsrRunOptions = { + command: SsrCliCommand; + mode?: string; + client: FarmCliOptions & UserConfig; + server: FarmCliOptions & UserConfig; + host?: SsrHostOptions; + ssr?: { + entry: string; + exportName?: string; + template?: SsrTemplateOptions; + }; +}; + +const DEFAULT_DEV_ENTRY_CANDIDATES = [ + '/src/entry-server.mjs', + '/src/entry-server.ts', + '/src/entry-server.js', + '/src/entry-server.tsx', + '/src/entry-server.jsx' +] as const; + +const DEFAULT_PREVIEW_ENTRY = 'index.js'; +const DEFAULT_CLIENT_OUTPUT_PATH = 'dist/client'; +const DEFAULT_SERVER_OUTPUT_PATH = 'dist/server'; + +function resolveConfigFile(params: { + sharedConfigFile?: string; + dedicatedConfigFile?: string; + fallback: string; +}) { + return ( + params.dedicatedConfigFile ?? params.sharedConfigFile ?? params.fallback + ); +} + +function parsePort(rawPort: unknown): number | undefined { + if (rawPort == null) { + return undefined; + } + + const parsed = Number(rawPort); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `[farm ssr] invalid --port value "${String(rawPort)}", expected a positive number.` + ); + } + + return parsed; +} + +function resolveHost(options: CliSsrOptions): SsrHostOptions | undefined { + const port = parsePort(options.port); + const hostname = options.host; + + if (!hostname && port == null) { + return undefined; + } + + return { + hostname, + ...(port == null ? {} : { port }) + }; +} + +function resolveTemplate( + command: SsrCliCommand, + options: CliSsrOptions +): SsrTemplateOptions | undefined { + if (command === 'build') { + return undefined; + } + + if (command === 'preview' && options.templateResource) { + throw new Error( + '[farm ssr] --template-resource is only supported by "farm ssr dev".' + ); + } + + const placeholder = options.placeholder; + + if (command === 'preview') { + if (!options.templateFile && !placeholder) { + return undefined; + } + + return { + ...(options.templateFile ? { file: options.templateFile } : {}), + ...(placeholder ? { placeholder } : {}) + }; + } + + if (options.templateFile) { + return { + file: options.templateFile, + ...(placeholder ? { placeholder } : {}) + }; + } + + return { + resource: options.templateResource ?? 'index.html', + ...(placeholder ? { placeholder } : {}) + }; +} + +function toAbsolutePath(root: string, file: string) { + if (path.isAbsolute(file)) { + return file; + } + + return path.join(root, file.replace(/^\//, '')); +} + +function resolveEntry(params: { + command: SsrCliCommand; + root: string; + options: GlobalCliOptions & CliSsrOptions; +}) { + if (params.options.entry) { + return params.options.entry; + } + + const envEntry = + (params.command === 'dev' + ? process.env.FARM_SSR_DEV_ENTRY + : process.env.FARM_SSR_PREVIEW_ENTRY) ?? process.env.FARM_SSR_ENTRY; + + if (envEntry) { + return envEntry; + } + + if (params.command === 'preview') { + return DEFAULT_PREVIEW_ENTRY; + } + + for (const candidate of DEFAULT_DEV_ENTRY_CANDIDATES) { + if (existsSync(toAbsolutePath(params.root, candidate))) { + return candidate; + } + } + + return DEFAULT_DEV_ENTRY_CANDIDATES[0]; +} + +function resolveBuildEntry(params: { + root: string; + options: GlobalCliOptions & CliSsrOptions; +}) { + if (params.options.entry) { + return params.options.entry; + } + + if (process.env.FARM_SSR_ENTRY) { + return process.env.FARM_SSR_ENTRY; + } + + for (const candidate of DEFAULT_DEV_ENTRY_CANDIDATES) { + if (existsSync(toAbsolutePath(params.root, candidate))) { + return candidate; + } + } + + return DEFAULT_DEV_ENTRY_CANDIDATES[0]; +} + +function ensureCompilation( + config: FarmCliOptions & UserConfig +): NonNullable { + if (!config.compilation) { + config.compilation = {}; + } + return config.compilation; +} + +function applyBuildDefaults(params: { + root: string; + options: GlobalCliOptions & CliSsrOptions; + client: FarmCliOptions & UserConfig; + server: FarmCliOptions & UserConfig; +}) { + const clientCompilation = ensureCompilation(params.client); + const serverCompilation = ensureCompilation(params.server); + + if ( + !clientCompilation.input || + Object.keys(clientCompilation.input).length === 0 + ) { + clientCompilation.input = { + index: './index.html' + }; + } + + if ( + !serverCompilation.input || + Object.keys(serverCompilation.input).length === 0 + ) { + serverCompilation.input = { + index: resolveBuildEntry({ + root: params.root, + options: params.options + }) + }; + } + + clientCompilation.output = { + path: DEFAULT_CLIENT_OUTPUT_PATH, + ...(clientCompilation.output ?? {}) + }; + serverCompilation.output = { + path: DEFAULT_SERVER_OUTPUT_PATH, + targetEnv: 'node', + format: 'esm', + ...(serverCompilation.output ?? {}) + }; +} + +function buildCompilationOutput(options: GlobalCliOptions & CliSsrOptions) { + const output: Record = {}; + + if (options.base) { + output.publicPath = options.base; + } + if (options.outDir) { + output.path = options.outDir; + } + if (options.target) { + output.targetEnv = options.target; + } + if (options.format) { + output.format = options.format; + } + + if (Object.keys(output).length === 0) { + return undefined; + } + + return output; +} + +function buildFarmConfig(params: { + root: string; + mode?: string; + clearScreen?: boolean; + configFile: string; + options: GlobalCliOptions & CliSsrOptions; +}) { + const output = buildCompilationOutput(params.options); + const serverConfig: Record = {}; + + if (params.options.host) { + serverConfig.host = params.options.host; + } + if (params.options.port != null) { + serverConfig.port = parsePort(params.options.port); + } + if (params.options.open != null) { + serverConfig.open = params.options.open; + } + if (params.options.strictPort != null) { + serverConfig.strictPort = params.options.strictPort; + } + const hasPreviewServerOverride = + params.options.host != null || + params.options.port != null || + params.options.open != null || + params.options.strictPort != null || + params.options.outDir != null; + + if (hasPreviewServerOverride) { + serverConfig.preview = { + ...(params.options.outDir ? { distDir: params.options.outDir } : {}), + ...(params.options.host ? { host: params.options.host } : {}), + ...(params.options.port != null + ? { port: parsePort(params.options.port) } + : {}), + ...(params.options.open != null ? { open: params.options.open } : {}), + ...(params.options.strictPort != null + ? { strictPort: params.options.strictPort } + : {}) + }; + } + + return { + root: params.root, + configFile: params.configFile, + mode: params.mode, + clearScreen: params.clearScreen, + ...(Object.keys(serverConfig).length > 0 ? { server: serverConfig } : {}), + ...(params.options.sourcemap != null || + params.options.minify != null || + params.options.treeShaking != null || + params.options.input != null || + output + ? { + compilation: { + ...(output ? { output } : {}), + ...(params.options.input + ? { + input: { + index: params.options.input + } + } + : {}), + ...(params.options.sourcemap != null + ? { sourcemap: params.options.sourcemap } + : {}), + ...(params.options.minify != null + ? { minify: params.options.minify } + : {}), + ...(params.options.treeShaking != null + ? { treeShaking: params.options.treeShaking } + : {}) + } + } + : {}) + } as FarmCliOptions & UserConfig; +} + +export function resolveSsrRunOptions(params: { + command: SsrCliCommand; + root?: string; + options: GlobalCliOptions & CliSsrOptions; +}): SsrRunOptions { + const root = resolveRootPath(params.root); + const clientConfigFile = resolveConfigFile({ + sharedConfigFile: params.options.config, + dedicatedConfigFile: params.options.clientConfig, + fallback: 'farm.config.client.ts' + }); + const serverConfigFile = resolveConfigFile({ + sharedConfigFile: params.options.config, + dedicatedConfigFile: params.options.serverConfig, + fallback: 'farm.config.server.ts' + }); + + const resolved: SsrRunOptions = { + command: params.command, + mode: params.options.mode, + client: buildFarmConfig({ + root, + mode: params.options.mode, + clearScreen: params.options.clearScreen, + configFile: clientConfigFile, + options: params.options + }), + server: buildFarmConfig({ + root, + mode: params.options.mode, + clearScreen: params.options.clearScreen, + configFile: serverConfigFile, + options: params.options + }), + host: resolveHost(params.options) + }; + + if (params.command === 'build') { + applyBuildDefaults({ + root, + options: params.options, + client: resolved.client, + server: resolved.server + }); + return resolved; + } + + const template = resolveTemplate(params.command, params.options); + const entry = resolveEntry({ + command: params.command, + root, + options: params.options + }); + + resolved.ssr = { + entry, + ...(params.options.exportName + ? { exportName: params.options.exportName } + : {}), + ...(template ? { template } : {}) + }; + + return resolved; +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 4275182c21..83b8868be0 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -51,3 +51,13 @@ export interface CliBuildOptions { export interface CliPreviewOptions extends BaseServerOptions { outDir?: string; } + +export interface CliSsrOptions extends BaseServerOptions, CliBuildOptions { + clientConfig?: string; + serverConfig?: string; + entry?: string; + exportName?: string; + templateFile?: string; + templateResource?: string; + placeholder?: string; +} diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 0889efb6df..2f2fc9c984 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -38,6 +38,32 @@ export async function resolveCore(): Promise<{ } } +export async function resolveSsr(): Promise<{ + runSsrCommand: (options: Record) => Promise; +}> { + try { + const ssrPackageName = '@farmfe/ssr'; + return (await import(ssrPackageName)) as { + runSsrCommand: (options: Record) => Promise; + }; + } catch (err) { + try { + // Monorepo fallback for local integration runs where workspace package + // may not be linked in node_modules. + return (await import( + new URL('../../ssr/dist/index.js', import.meta.url).href + )) as { + runSsrCommand: (options: Record) => Promise; + }; + } catch { + logger.error( + `Cannot find @farmfe/ssr module, Did you successfully install: \n${err.stack},`, + { exit: true } + ); + } + } +} + /** * filter duplicate item in options */ diff --git a/packages/core/binding/binding.d.ts b/packages/core/binding/binding.d.ts index 9503897621..725f07cef8 100644 --- a/packages/core/binding/binding.d.ts +++ b/packages/core/binding/binding.d.ts @@ -19,6 +19,7 @@ export declare class Compiler { watchModules(): Array relativeModulePaths(): Array resource(name: string): Buffer | null + fetchModule(id: string, importer?: string | undefined | null, options?: JsFetchModuleOptions | undefined | null): JsFetchModuleResult | null stats(): string invalidateModule(moduleId: string): void /** Write cache with name and data */ @@ -43,6 +44,24 @@ export interface JsApiMetadata { refer?: Array } +export interface JsFetchModuleOptions { + cached?: boolean + startOffset?: number +} + +export interface JsFetchModuleResult { + cache?: boolean + externalize?: string + bailoutReason?: string + type?: string + code?: string + file?: string + id?: string + url?: string + invalidate?: boolean + map?: string +} + export interface JsModuleHookFilters { moduleTypes?: Array resolvedPaths?: Array diff --git a/packages/core/binding/index.js b/packages/core/binding/index.js index 1f616a41ed..71a907055c 100644 --- a/packages/core/binding/index.js +++ b/packages/core/binding/index.js @@ -2,8 +2,10 @@ * binding.cjs and binding.d.ts are generated by the Rust. Please do not modify them manually. * If you want to modify the binding, please modify the Rust code or manually wrap the generated code in index.js or index.d.ts. */ -import binding from "./binding.cjs"; +import { createRequire } from 'node:module'; import bindingPath from "./resolve-binding.cjs"; +const require = createRequire(import.meta.url); +const binding = require(bindingPath); process.env.FARM_LIB_CORE_PATH = bindingPath; // Copied from https://github.com/vercel/next.js/pull/53889/files by @BrightWu diff --git a/packages/core/package.json b/packages/core/package.json index ea4a6f1597..ff11dfa68b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,10 @@ "./types/*": { "types": "./types/*" }, + "./module-runner/serverInvoke": { + "import": "./dist/module-runner/serverInvoke.js", + "types": "./dist/module-runner/serverInvoke.d.ts" + }, "./package.json": "./package.json" }, "napi": { @@ -124,4 +128,4 @@ "client.d.ts", "types/**/*.d.ts" ] -} \ No newline at end of file +} diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index 65e16cec26..220572246f 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -11,6 +11,8 @@ import type { import type { CompilerUpdateItem, JsApiMetadata, + JsFetchModuleOptions, + JsFetchModuleResult, JsUpdateResult } from '../types/binding.js'; import { cleanUrl } from '../utils/index.js'; @@ -171,6 +173,30 @@ export class Compiler { ); } + fetchModule( + id: string, + importer?: string, + options?: JsFetchModuleOptions + ): JsFetchModuleResult | null { + const bindingCompiler = this._bindingCompiler as BindingCompiler & { + fetchModule?: ( + id: string, + importer?: string | null, + options?: JsFetchModuleOptions | null + ) => JsFetchModuleResult | null; + }; + + if (typeof bindingCompiler.fetchModule !== 'function') { + return null; + } + + return (bindingCompiler.fetchModule( + id, + importer ?? null, + options ?? null + ) ?? null) as JsFetchModuleResult | null; + } + resourcesMap(): Record { return this._bindingCompiler.resourcesMap() as Record; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 664f5e48dd..133956e8f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export { Resolver } from '../binding/index.js'; export * from './compiler/index.js'; export * from './config/index.js'; export { defineFarmConfig as defineConfig } from './config/index.js'; +export * from './module-runner/index.js'; export * from './plugin/index.js'; export * from './plugin/type.js'; export * from './server/index.js'; diff --git a/packages/core/src/module-runner/constants.ts b/packages/core/src/module-runner/constants.ts new file mode 100644 index 0000000000..f79d8d2e9a --- /dev/null +++ b/packages/core/src/module-runner/constants.ts @@ -0,0 +1,6 @@ +export const farmSsrModuleExportsKey = '__farm_ssr_exports__'; +export const farmSsrImportKey = '__farm_ssr_import__'; +export const farmSsrDynamicImportKey = '__farm_ssr_dynamic_import__'; +export const farmSsrExportAllKey = '__farm_ssr_export_all__'; +export const farmSsrExportNameKey = '__farm_ssr_export_name__'; +export const farmSsrImportMetaKey = '__farm_ssr_import_meta__'; diff --git a/packages/core/src/module-runner/createImportMeta.ts b/packages/core/src/module-runner/createImportMeta.ts new file mode 100644 index 0000000000..52265fe666 --- /dev/null +++ b/packages/core/src/module-runner/createImportMeta.ts @@ -0,0 +1,120 @@ +import type { FarmRunnerImportMeta } from './types.js'; + +const envProxy = new Proxy({} as Record, { + get(_, key) { + throw new Error( + `[farm module runner] Dynamic access of "import.meta.env" is not supported. Use "import.meta.env.${String( + key + )}" instead.` + ); + } +}); + +export function createDefaultImportMeta( + modulePath: string +): FarmRunnerImportMeta { + const normalized = toFileUrl(modulePath); + + return { + url: normalized, + env: envProxy, + dirname: dirname(modulePath), + filename: modulePath, + resolve() { + throw new Error( + '[farm module runner] "import.meta.resolve" is not supported.' + ); + } + } as FarmRunnerImportMeta; +} + +function toFileUrl(modulePath: string): string { + const normalized = modulePath.replace(/\\/g, '/'); + + if (/^[a-zA-Z]:\//.test(normalized)) { + return `file:///${encodeURI(normalized)}`; + } + + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(modulePath)) { + return modulePath; + } + + if (normalized.startsWith('//')) { + return `file:${encodeURI(normalized)}`; + } + + if (normalized.startsWith('/')) { + return `file://${encodeURI(normalized)}`; + } + + const cwd = getRuntimeCwd(); + + if (!cwd) { + return modulePath; + } + + const baseUrl = toAbsoluteDirectoryFileUrl(cwd); + + if (!baseUrl) { + return modulePath; + } + + try { + return new URL(normalized, baseUrl).toString(); + } catch { + return modulePath; + } +} + +function getRuntimeCwd(): string | null { + const processLike = globalThis.process as + | { + cwd?: () => string; + } + | undefined; + + if (!processLike || typeof processLike.cwd !== 'function') { + return null; + } + + try { + const cwd = processLike.cwd(); + + if (typeof cwd !== 'string' || !cwd) { + return null; + } + + return cwd.replace(/\\/g, '/'); + } catch { + return null; + } +} + +function toAbsoluteDirectoryFileUrl(dirPath: string): string | null { + const normalized = dirPath.replace(/\\/g, '/').replace(/\/+$/, ''); + + if (/^[a-zA-Z]:\//.test(normalized)) { + return `file:///${encodeURI(normalized)}/`; + } + + if (normalized.startsWith('/')) { + return `file://${encodeURI(normalized)}/`; + } + + return null; +} + +function dirname(modulePath: string): string { + const normalized = modulePath.replace(/\\/g, '/').replace(/\/+$/, ''); + const index = normalized.lastIndexOf('/'); + + if (index < 0) { + return '.'; + } + + if (index === 0) { + return normalized.slice(0, 1); + } + + return normalized.slice(0, index); +} diff --git a/packages/core/src/module-runner/diagnostics.ts b/packages/core/src/module-runner/diagnostics.ts new file mode 100644 index 0000000000..85b371bbf1 --- /dev/null +++ b/packages/core/src/module-runner/diagnostics.ts @@ -0,0 +1,35 @@ +import type { + ModuleRunnerDiagnosticsEmitter, + ModuleRunnerDiagnosticsEvent +} from './types.js'; + +export class ModuleRunnerDiagnosticsBus + implements ModuleRunnerDiagnosticsEmitter +{ + private readonly listeners = new Set< + (event: ModuleRunnerDiagnosticsEvent) => void + >(); + + emit(event: ModuleRunnerDiagnosticsEvent): void { + for (const listener of this.listeners) { + try { + listener(event); + } catch { + // Diagnostics observers must not break the runner execution flow. + } + } + } + + subscribe( + listener: (event: ModuleRunnerDiagnosticsEvent) => void + ): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + clear(): void { + this.listeners.clear(); + } +} diff --git a/packages/core/src/module-runner/evaluatedModules.ts b/packages/core/src/module-runner/evaluatedModules.ts new file mode 100644 index 0000000000..8d58670587 --- /dev/null +++ b/packages/core/src/module-runner/evaluatedModules.ts @@ -0,0 +1,98 @@ +import { cleanUrl } from '../utils/url.js'; +import type { ResolvedFetchResult } from './types.js'; + +export class EvaluatedModuleNode { + importers: Set = new Set(); + imports: Set = new Set(); + evaluated = false; + meta: ResolvedFetchResult | undefined; + promise: Promise | undefined; + exports: unknown; + + constructor( + public id: string, + public url: string + ) {} +} + +export class EvaluatedModules { + readonly idToModuleMap = new Map(); + readonly urlToModuleMap = new Map(); + readonly fileToModulesMap = new Map>(); + + getModuleById(id: string): EvaluatedModuleNode | undefined { + return this.idToModuleMap.get(id); + } + + getModuleByUrl(url: string): EvaluatedModuleNode | undefined { + return this.urlToModuleMap.get(url); + } + + getModulesByFile(file: string): Set | undefined { + return this.fileToModulesMap.get(cleanUrl(file)); + } + + ensureModule(id: string, url: string): EvaluatedModuleNode { + const existing = this.idToModuleMap.get(id); + + if (existing) { + this.urlToModuleMap.set(url, existing); + return existing; + } + + const mod = new EvaluatedModuleNode(id, url); + this.idToModuleMap.set(id, mod); + this.urlToModuleMap.set(url, mod); + + const fileKey = cleanUrl(id); + const set = + this.fileToModulesMap.get(fileKey) ?? new Set(); + set.add(mod); + this.fileToModulesMap.set(fileKey, set); + + return mod; + } + + invalidateModule(mod: EvaluatedModuleNode): void { + // detach reverse edges so stale dependency links don't accumulate across updates + for (const depId of mod.imports) { + this.idToModuleMap.get(depId)?.importers.delete(mod.id); + } + + for (const importerId of mod.importers) { + this.idToModuleMap.get(importerId)?.imports.delete(mod.id); + } + + mod.evaluated = false; + mod.promise = undefined; + mod.exports = undefined; + mod.meta = undefined; + mod.imports.clear(); + mod.importers.clear(); + } + + removeModule(mod: EvaluatedModuleNode): void { + this.idToModuleMap.delete(mod.id); + + for (const [url, current] of this.urlToModuleMap.entries()) { + if (current === mod) { + this.urlToModuleMap.delete(url); + } + } + + const fileKey = cleanUrl(mod.id); + const fileMatched = this.fileToModulesMap.get(fileKey); + if (fileMatched) { + fileMatched.delete(mod); + if (fileMatched.size === 0) { + this.fileToModulesMap.delete(fileKey); + } + } + } + + clear(): void { + this.idToModuleMap.clear(); + this.urlToModuleMap.clear(); + this.fileToModulesMap.clear(); + } +} diff --git a/packages/core/src/module-runner/evaluator.ts b/packages/core/src/module-runner/evaluator.ts new file mode 100644 index 0000000000..28943a466b --- /dev/null +++ b/packages/core/src/module-runner/evaluator.ts @@ -0,0 +1,605 @@ +import { cleanUrl } from '../utils/url.js'; +import { + farmSsrDynamicImportKey, + farmSsrExportAllKey, + farmSsrExportNameKey, + farmSsrImportKey, + farmSsrImportMetaKey, + farmSsrModuleExportsKey +} from './constants.js'; +import type { EvaluatedModuleNode } from './evaluatedModules.js'; +import type { + ExternalFetchResult, + ExternalModuleResolveResult, + ExternalModuleResolver, + FarmRunnerContext, + ModuleEvaluator, + ModuleRunnerEvaluatorType +} from './types.js'; + +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const AsyncFunction = async function () {}.constructor as FunctionConstructor; + +type NodeRequireFunction = (id: string) => unknown; +type NodeCreateRequire = (url: string) => NodeRequireFunction; +type NodeFileUrlToPath = (url: string) => string; +type ImportErrorLike = { + code?: unknown; + message?: unknown; + cause?: unknown; +}; + +const IMPORT_FALLBACK_ERROR_CODES = new Set([ + 'ERR_MODULE_NOT_FOUND', + 'ERR_UNSUPPORTED_ESM_URL_SCHEME', + 'ERR_INVALID_URL', + 'ERR_INVALID_MODULE_SPECIFIER', + 'MODULE_NOT_FOUND' +]); + +const IMPORT_FALLBACK_TYPED_ERROR_MESSAGE_PATTERNS: RegExp[] = [ + /failed to resolve module specifier/i, + /only urls with a scheme in/i, + /unsupported.+scheme/i, + /invalid url/i, + /cannot load.+https/i +]; + +const IMPORT_FALLBACK_STRICT_ERROR_MESSAGE_PATTERNS: RegExp[] = [ + /^failed to load url\b/i +]; + +function getProcessLike(): Record | undefined { + const processLike = Reflect.get(globalThis as object, 'process'); + + if (!processLike || typeof processLike !== 'object') { + return undefined; + } + + return processLike as Record; +} + +function getNodeBuiltinModule( + moduleName: 'node:module' | 'module' | 'node:url' | 'url' +): Record | undefined { + const processLike = getProcessLike(); + + if (!processLike) { + return undefined; + } + + const getBuiltinModule = Reflect.get(processLike, 'getBuiltinModule'); + + if (typeof getBuiltinModule !== 'function') { + return undefined; + } + + try { + return Reflect.apply( + getBuiltinModule as (...args: unknown[]) => unknown, + processLike, + [moduleName] + ) as Record | undefined; + } catch { + return undefined; + } +} + +function resolveNodeCreateRequire(): NodeCreateRequire | undefined { + for (const name of ['node:module', 'module'] as const) { + const moduleBuiltin = getNodeBuiltinModule(name); + const createRequire = + moduleBuiltin && Reflect.get(moduleBuiltin, 'createRequire'); + + if (typeof createRequire === 'function') { + return createRequire as NodeCreateRequire; + } + } + + return undefined; +} + +function resolveNodeFileURLToPath(): NodeFileUrlToPath | undefined { + for (const name of ['node:url', 'url'] as const) { + const urlBuiltin = getNodeBuiltinModule(name); + const fileURLToPath = + urlBuiltin && Reflect.get(urlBuiltin, 'fileURLToPath'); + + if (typeof fileURLToPath === 'function') { + return fileURLToPath as NodeFileUrlToPath; + } + } + + return undefined; +} + +function fileUrlToPathWithFallback(url: string): string { + const native = resolveNodeFileURLToPath(); + + if (native) { + return native(url); + } + + const parsed = new URL(url); + let pathname = decodeURIComponent(parsed.pathname); + + if (/^\/[a-zA-Z]:\//.test(pathname)) { + pathname = pathname.slice(1); + } + + return pathname; +} + +function encodeBase64Utf8(value: string): string | undefined { + const globalBuffer = Reflect.get(globalThis as object, 'Buffer') as + | { + from?: ( + input: string, + encoding?: string + ) => { toString: (encoding: string) => string }; + } + | undefined; + + if (globalBuffer && typeof globalBuffer.from === 'function') { + return globalBuffer.from(value, 'utf-8').toString('base64'); + } + + if (typeof TextEncoder !== 'function' || typeof btoa !== 'function') { + return undefined; + } + + const bytes = new TextEncoder().encode(value); + let binary = ''; + + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary); +} + +function toInlineSourceMapComment(sourceMap: string): string | undefined { + const encoded = encodeBase64Utf8(sourceMap); + + if (!encoded) { + return undefined; + } + + return `//# sourceMappingURL=data:application/json;base64,${encoded}`; +} + +function getImportErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + + const code = (error as ImportErrorLike).code; + return typeof code === 'string' ? code : undefined; +} + +function getImportErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + + if (!error || typeof error !== 'object') { + return undefined; + } + + const message = (error as ImportErrorLike).message; + return typeof message === 'string' ? message : undefined; +} + +function getImportErrorName(error: unknown): string | undefined { + if (error instanceof Error) { + return error.name; + } + + if (!error || typeof error !== 'object') { + return undefined; + } + + const name = Reflect.get(error as object, 'name'); + return typeof name === 'string' ? name : undefined; +} + +function shouldTryImportFallback(error: unknown): boolean { + const queue: unknown[] = [error]; + const seen = new Set(); + + while (queue.length) { + const current = queue.shift(); + + if (!current || seen.has(current)) { + continue; + } + + seen.add(current); + + const code = getImportErrorCode(current); + if (code && IMPORT_FALLBACK_ERROR_CODES.has(code)) { + return true; + } + + const message = getImportErrorMessage(current); + const name = getImportErrorName(current); + if (message) { + if ( + (name === 'TypeError' || name === 'URIError') && + IMPORT_FALLBACK_TYPED_ERROR_MESSAGE_PATTERNS.some((pattern) => + pattern.test(message) + ) + ) { + return true; + } + + if ( + (!name || name === 'Error') && + IMPORT_FALLBACK_STRICT_ERROR_MESSAGE_PATTERNS.some((pattern) => + pattern.test(message) + ) + ) { + return true; + } + } + + if (typeof current === 'object') { + const cause = (current as ImportErrorLike).cause; + if (cause) { + queue.push(cause); + } + } + } + + return false; +} + +function normalizeResolveResult(result: unknown): ExternalModuleResolveResult { + if (result == null) { + return { resolved: false }; + } + + if (typeof result !== 'object') { + throw new Error( + `[farm module runner] resolveExternalModule must return an object with boolean "resolved", but got ${typeof result}.` + ); + } + + const resolved = Reflect.get(result as object, 'resolved'); + + if (typeof resolved !== 'boolean') { + throw new Error( + '[farm module runner] resolveExternalModule must return an object with boolean "resolved".' + ); + } + + if (resolved) { + return { + resolved: true, + module: Reflect.get(result as object, 'module') + }; + } + + return { resolved: false }; +} + +async function runInlinedModuleWithAsyncFunction( + context: FarmRunnerContext, + code: string, + module: Readonly +): Promise { + const sourceComments: string[] = []; + const sourceId = + module.meta && 'file' in module.meta && module.meta.file + ? module.meta.file + : module.id; + + if (sourceId) { + sourceComments.push(`//# sourceURL=${sourceId}`); + } + + if (module.meta && 'map' in module.meta && module.meta.map) { + const sourceMapComment = toInlineSourceMapComment(module.meta.map); + if (sourceMapComment) { + sourceComments.push(sourceMapComment); + } + } + + const instrumentedCode = ['"use strict";', code, sourceComments.join('\n')] + .filter(Boolean) + .join('\n'); + + const initModule = new AsyncFunction( + farmSsrModuleExportsKey, + farmSsrImportMetaKey, + farmSsrImportKey, + farmSsrDynamicImportKey, + farmSsrExportAllKey, + farmSsrExportNameKey, + instrumentedCode + ); + + await initModule( + context[farmSsrModuleExportsKey], + context[farmSsrImportMetaKey], + context[farmSsrImportKey], + context[farmSsrDynamicImportKey], + context[farmSsrExportAllKey], + context[farmSsrExportNameKey] + ); + + Object.seal(context[farmSsrModuleExportsKey]); +} + +async function importModuleWithOptions( + file: string, + options?: unknown +): Promise { + if (options === undefined) { + return import(/* @vite-ignore */ file); + } + + return import(/* @vite-ignore */ file, options as Record); +} + +class BaseAsyncFunctionEvaluator implements ModuleEvaluator { + readonly startOffset = 2; + private readonly nodeCommonJsLoader: NodeRequireFunction | undefined; + + constructor( + private readonly runtimeName: ModuleRunnerEvaluatorType, + private readonly allowCommonJsRequire: boolean, + private readonly resolveExternalModule?: ExternalModuleResolver + ) { + this.nodeCommonJsLoader = allowCommonJsRequire + ? this.createNodeCommonJsLoader() + : undefined; + } + + async runInlinedModule( + context: FarmRunnerContext, + code: string, + module: Readonly + ): Promise { + await runInlinedModuleWithAsyncFunction(context, code, module); + } + + async runExternalModule( + file: string, + type: ExternalFetchResult['type'] = 'module', + options?: unknown + ): Promise { + if (type === 'builtin' && this.runtimeName !== 'node') { + const unsupportedBuiltinError = new Error( + `[farm module runner] builtin external module is not supported in ${this.runtimeName} evaluator.` + ); + const resolved = await this.resolveUnsupportedExternal(file, type, { + reason: 'unsupported-external', + error: unsupportedBuiltinError, + importOptions: options + }); + if (resolved.resolved) { + return resolved.module; + } + + throw unsupportedBuiltinError; + } + + if (type === 'commonjs') { + if (!this.allowCommonJsRequire) { + const unsupportedCommonJsError = new Error( + `[farm module runner] commonjs external module is not supported in ${this.runtimeName} evaluator.` + ); + const resolved = await this.resolveUnsupportedExternal(file, type, { + reason: 'unsupported-external', + error: unsupportedCommonJsError, + importOptions: options + }); + if (resolved.resolved) { + return resolved.module; + } + + throw unsupportedCommonJsError; + } + + if (!this.nodeCommonJsLoader) { + const missingNodeLoaderError = new Error( + '[farm module runner] commonjs external module requires Node module loader support, but current host does not provide it.' + ); + const resolved = await this.resolveUnsupportedExternal(file, type, { + reason: 'missing-node-loader', + error: missingNodeLoaderError, + importOptions: options + }); + if (resolved.resolved) { + return resolved.module; + } + + throw missingNodeLoaderError; + } + + const normalized = cleanUrl(file); + if (normalized.startsWith('file://')) { + return this.nodeCommonJsLoader(fileUrlToPathWithFallback(normalized)); + } + + return this.nodeCommonJsLoader(normalized); + } + + try { + return await importModuleWithOptions(file, options); + } catch (error) { + if (!shouldTryImportFallback(error)) { + throw error; + } + + const resolved = await this.resolveUnsupportedExternal(file, type, { + reason: 'import-failed', + error, + importOptions: options + }); + if (resolved.resolved) { + return resolved.module; + } + + throw error; + } + } + + private async resolveUnsupportedExternal( + file: string, + type: ExternalFetchResult['type'], + options?: { + reason?: 'import-failed' | 'unsupported-external' | 'missing-node-loader'; + error?: unknown; + importOptions?: unknown; + } + ) { + if (!this.resolveExternalModule) { + return { resolved: false }; + } + + const context = { + id: file, + type, + evaluator: this.runtimeName, + ...(options?.reason != null ? { reason: options.reason } : {}), + ...(options?.error !== undefined ? { error: options.error } : {}), + ...(options?.importOptions !== undefined + ? { importOptions: options.importOptions } + : {}) + }; + + try { + const resolved = await this.resolveExternalModule(context); + return normalizeResolveResult(resolved); + } catch (error) { + const wrapped = new Error( + `[farm module runner] resolveExternalModule failed for "${file}" (type=${type}, evaluator=${this.runtimeName}, reason=${options?.reason ?? 'unspecified'}).` + ); + (wrapped as Error & { cause?: unknown }).cause = error; + throw wrapped; + } + } + + private createNodeCommonJsLoader(): NodeRequireFunction | undefined { + const createRequire = resolveNodeCreateRequire(); + + if (!createRequire) { + return undefined; + } + + try { + return createRequire(import.meta.url); + } catch { + return undefined; + } + } +} + +export class ESModulesEvaluator extends BaseAsyncFunctionEvaluator { + constructor(resolveExternalModule?: ExternalModuleResolver) { + super('node', true, resolveExternalModule); + } +} + +export class WorkerModulesEvaluator extends BaseAsyncFunctionEvaluator { + constructor(resolveExternalModule?: ExternalModuleResolver) { + super('worker', false, resolveExternalModule); + } +} + +export class BunModulesEvaluator extends BaseAsyncFunctionEvaluator { + constructor(resolveExternalModule?: ExternalModuleResolver) { + super('bun', false, resolveExternalModule); + } +} + +export class DenoModulesEvaluator extends BaseAsyncFunctionEvaluator { + constructor(resolveExternalModule?: ExternalModuleResolver) { + super('deno', false, resolveExternalModule); + } +} + +export function detectHostEvaluatorType( + globalLike: Record = globalThis as unknown as Record< + string, + unknown + > +): ModuleRunnerEvaluatorType { + if (globalLike.Bun) { + return 'bun'; + } + + if (globalLike.Deno) { + return 'deno'; + } + + const processLike = Reflect.get(globalLike, 'process'); + const getBuiltinModule = + processLike && typeof processLike === 'object' + ? Reflect.get(processLike as object, 'getBuiltinModule') + : undefined; + + if (typeof getBuiltinModule === 'function') { + for (const moduleName of [ + 'node:worker_threads', + 'worker_threads' + ] as const) { + try { + const workerThreads = Reflect.apply( + getBuiltinModule as (...args: unknown[]) => unknown, + processLike, + [moduleName] + ); + const isMainThread = + workerThreads && typeof workerThreads === 'object' + ? Reflect.get(workerThreads as object, 'isMainThread') + : undefined; + + if (isMainThread === false) { + return 'worker'; + } + } catch { + // ignore and continue fallback detection + } + } + } + + const workerGlobalScope = globalLike.WorkerGlobalScope; + const selfRef = globalLike.self; + + if ( + typeof workerGlobalScope === 'function' && + typeof selfRef === 'object' && + selfRef != null && + selfRef instanceof workerGlobalScope + ) { + return 'worker'; + } + + return 'node'; +} + +export function createModuleEvaluator( + evaluator?: ModuleRunnerEvaluatorType | ModuleEvaluator, + resolveExternalModule?: ExternalModuleResolver +): ModuleEvaluator { + if (evaluator && typeof evaluator === 'object') { + return evaluator; + } + + const evaluatorType = evaluator ?? detectHostEvaluatorType(); + + switch (evaluatorType) { + case 'worker': + return new WorkerModulesEvaluator(resolveExternalModule); + case 'bun': + return new BunModulesEvaluator(resolveExternalModule); + case 'deno': + return new DenoModulesEvaluator(resolveExternalModule); + case 'node': + default: + return new ESModulesEvaluator(resolveExternalModule); + } +} diff --git a/packages/core/src/module-runner/index.ts b/packages/core/src/module-runner/index.ts new file mode 100644 index 0000000000..d9663f6642 --- /dev/null +++ b/packages/core/src/module-runner/index.ts @@ -0,0 +1,51 @@ +export { createDefaultImportMeta } from './createImportMeta.js'; +export { ModuleRunnerDiagnosticsBus } from './diagnostics.js'; +export { EvaluatedModuleNode, EvaluatedModules } from './evaluatedModules.js'; +export { + BunModulesEvaluator, + createModuleEvaluator, + DenoModulesEvaluator, + detectHostEvaluatorType, + ESModulesEvaluator, + WorkerModulesEvaluator +} from './evaluator.js'; +export { FarmModuleRunner } from './runner.js'; +export { + createModuleRunnerTransportFromInvokeHandlers, + createServerModuleRunnerTransport +} from './serverTransport.js'; +export type { + CachedFetchResult, + ExternalFetchResult, + ExternalModuleResolveContext, + ExternalModuleResolveResult, + ExternalModuleResolver, + FarmModuleRunnerOptions, + FarmRunnerContext, + FarmRunnerImportMeta, + FetchFunctionOptions, + FetchResult, + InlinedFetchResult, + InvokeMethods, + ModuleEvaluator, + ModuleRunnerDiagnosticsEmitter, + ModuleRunnerDiagnosticsEvent, + ModuleRunnerEvaluatorType, + ModuleRunnerInvokeCompiler, + ModuleRunnerInvokeContext, + ModuleRunnerInvokeHandlers, + ModuleRunnerTransport, + NonJsPolicyMode, + NonJsPolicyOptions, + ResolvedFetchResult, + RunnerCachePolicyOptions, + RunnerExternalPolicyOptions, + RunnerHotPayload, + RunnerHotUpdate, + RunnerInvokePayload, + RunnerInvokeResponsePayload, + RunnerResolveContext, + RunnerResolveResult, + RunnerResolver, + RunnerSourceMapInterceptorOptions +} from './types.js'; diff --git a/packages/core/src/module-runner/runner.ts b/packages/core/src/module-runner/runner.ts new file mode 100644 index 0000000000..0b24be759d --- /dev/null +++ b/packages/core/src/module-runner/runner.ts @@ -0,0 +1,1492 @@ +import { fileURLToPath } from 'node:url'; +import { cleanUrl } from '../utils/url.js'; +import { + farmSsrDynamicImportKey, + farmSsrExportAllKey, + farmSsrExportNameKey, + farmSsrImportKey, + farmSsrImportMetaKey, + farmSsrModuleExportsKey +} from './constants.js'; +import { createDefaultImportMeta } from './createImportMeta.js'; +import { ModuleRunnerDiagnosticsBus } from './diagnostics.js'; +import { EvaluatedModuleNode, EvaluatedModules } from './evaluatedModules.js'; +import { createModuleEvaluator } from './evaluator.js'; +import { createRunnerSourceMapInterceptor } from './sourceMapInterceptor.js'; +import type { + ExternalFetchResult, + FarmModuleRunnerOptions, + FetchResult, + ModuleEvaluator, + ModuleRunnerDiagnosticsEmitter, + NonJsPolicyMode, + NonJsPolicyOptions, + ResolvedFetchResult, + RunnerExternalPolicyContext, + RunnerExternalPolicyMode, + RunnerExternalPolicyTargetOptions, + RunnerHotPayload, + RunnerResolveContext, + RunnerResolveResult, + RunnerResolver, + RunnerSourceMapHooks +} from './types.js'; + +type RuntimeImportKind = 'import' | 'dynamic-import'; +type NonJsModuleKind = 'style' | 'asset' | 'unknown'; + +const STYLE_EXTENSIONS = new Set([ + '.css', + '.less', + '.sass', + '.scss', + '.styl', + '.stylus', + '.pcss', + '.postcss' +]); + +const ASSET_EXTENSIONS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.svg', + '.webp', + '.avif', + '.ico', + '.bmp', + '.tif', + '.tiff', + '.woff', + '.woff2', + '.ttf', + '.otf', + '.eot', + '.mp4', + '.webm', + '.mp3', + '.wav' +]); + +const CJS_INTEROP_NON_THROW_MISSING_PROPS = new Set(['then']); + +function createTraceId(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +function isObjectLike( + value: unknown +): value is Record { + return ( + (typeof value === 'object' && value !== null) || typeof value === 'function' + ); +} + +function isModuleNamespaceLike( + value: unknown +): value is Record { + return ( + isObjectLike(value) && Reflect.get(value, Symbol.toStringTag) === 'Module' + ); +} + +function createMissingDefaultExportError(params: { + specifier: string; + importer: string; + kind: RuntimeImportKind; +}): Error { + const error = new Error( + `[farm module runner] Missing default export from "${params.specifier}" imported by "${params.importer}" via ${params.kind}.` + ) as Error & { + code?: string; + specifier?: string; + importer?: string; + }; + error.code = 'ERR_MISSING_DEFAULT_EXPORT'; + error.specifier = params.specifier; + error.importer = params.importer; + return error; +} + +function createMissingNamedExportError(params: { + exportName: string; + specifier: string; + importer: string; + kind: RuntimeImportKind; +}): Error { + const error = new Error( + `[farm module runner] Missing named export "${params.exportName}" from "${params.specifier}" imported by "${params.importer}" via ${params.kind}.` + ) as Error & { + code?: string; + specifier?: string; + importer?: string; + exportName?: string; + }; + error.code = 'ERR_MISSING_NAMED_EXPORT'; + error.specifier = params.specifier; + error.importer = params.importer; + error.exportName = params.exportName; + return error; +} + +function isVueStyleQuery(id: string): boolean { + return /(?:\?|&)vue(?:&|$)/.test(id) && /(?:\?|&)type=style(?:&|$)/.test(id); +} + +function getExtensionFromId(id: string): string { + const normalized = cleanUrl(id); + const lastSlash = normalized.lastIndexOf('/'); + const fileName = + lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized; + const dot = fileName.lastIndexOf('.'); + return dot >= 0 ? fileName.slice(dot).toLowerCase() : ''; +} + +function toFilePathFromExternalize(externalize: string): string | null { + const cleaned = cleanUrl(externalize); + + if (!cleaned.startsWith('file://')) { + return null; + } + + try { + return fileURLToPath(cleaned); + } catch { + return null; + } +} + +function classifyNonJsKind(params: { + id: string; + externalize: string; + bailoutReason?: string; +}): NonJsModuleKind | null { + if (isVueStyleQuery(params.id)) { + return 'style'; + } + + const idExt = getExtensionFromId(params.id); + if (idExt && STYLE_EXTENSIONS.has(idExt)) { + return 'style'; + } + + if (idExt && ASSET_EXTENSIONS.has(idExt)) { + return 'asset'; + } + + const externalExt = getExtensionFromId(params.externalize); + if (externalExt && STYLE_EXTENSIONS.has(externalExt)) { + return 'style'; + } + + if (externalExt && ASSET_EXTENSIONS.has(externalExt)) { + return 'asset'; + } + + if (params.bailoutReason === 'not-script') { + return 'unknown'; + } + + return null; +} + +function resolveNonJsPolicyMode( + kind: NonJsModuleKind, + policy?: NonJsPolicyOptions +): NonJsPolicyMode { + if (kind === 'style') { + return policy?.style ?? 'stub'; + } + + if (kind === 'asset') { + return policy?.asset ?? 'stub'; + } + + return policy?.unknown ?? 'externalize'; +} + +export class FarmModuleRunner { + readonly evaluatedModules: EvaluatedModules; + private readonly sourceMapInterceptor: ReturnType< + typeof createRunnerSourceMapInterceptor + >; + private readonly baseEvaluator: ModuleEvaluator; + private readonly evaluator: ModuleEvaluator; + private readonly resolver: RunnerResolver | undefined; + private readonly diagnostics: ModuleRunnerDiagnosticsEmitter; + private readonly namespaceInteropCache = new WeakMap< + Record, + Record + >(); + private readonly cjsInteropCache = new WeakMap< + Record, + Record + >(); + private readonly fetchInflight = new Map>(); + private readonly moduleLastAccess = new Map(); + private readonly gcCandidateQueue: string[] = []; + private readonly gcCandidateSet = new Set(); + private readonly policyEvictedModules = new Set(); + private readonly reportedBailouts = new Set(); + private readonly strictTransform: boolean; + + private closed = false; + + constructor(private readonly options: FarmModuleRunnerOptions) { + this.strictTransform = options.strictTransform === true; + + this.baseEvaluator = createModuleEvaluator( + options.evaluator, + options.resolveExternalModule + ); + this.evaluator = options.disableInterop + ? this.baseEvaluator + : this.createInteropEvaluator(); + this.resolver = options.resolver; + this.diagnostics = + options.diagnostics === false + ? { + emit() {}, + subscribe() { + return () => undefined; + }, + clear() {} + } + : (options.diagnostics ?? new ModuleRunnerDiagnosticsBus()); + + this.sourceMapInterceptor = createRunnerSourceMapInterceptor( + this.resolveSourceMapInterceptorEnabled(options.sourceMapInterceptor), + this.resolveSourceMapInterceptorNative(options.sourceMapInterceptor), + this.resolveSourceMapInterceptorHooks(options.sourceMapInterceptor) + ); + + this.evaluatedModules = options.evaluatedModules ?? new EvaluatedModules(); + + if (options.hmr !== false) { + if (!this.options.transport.connect) { + throw new Error( + '[farm module runner] HMR is enabled but transport.connect is not available.' + ); + } + + void this.options.transport.connect({ + onMessage: (payload) => this.handleHotPayload(payload), + onDisconnection: () => { + // No-op for now. Keep room for future reconnect strategy. + } + }); + } + } + + async import(url: string): Promise { + const traceId = createTraceId('import'); + const resolved = await this.resolveRequest(url); + const start = Date.now(); + this.diagnostics.emit({ + type: 'import:start', + traceId, + request: url, + resolvedId: resolved.id, + timestamp: start + }); + + try { + const mod = await this.cachedModule(resolved.id); + const result = (await this.cachedRequest(resolved.id, mod)) as T; + this.diagnostics.emit({ + type: 'import:end', + traceId, + request: url, + moduleId: resolved.id, + durationMs: Date.now() - start, + timestamp: Date.now() + }); + return result; + } catch (error) { + this.diagnostics.emit({ + type: 'import:error', + traceId, + request: url, + resolvedId: resolved.id, + error, + timestamp: Date.now() + }); + throw error; + } + } + + clearCache(): void { + this.sourceMapInterceptor.clear(); + this.evaluatedModules.clear(); + this.fetchInflight.clear(); + this.moduleLastAccess.clear(); + this.gcCandidateQueue.length = 0; + this.gcCandidateSet.clear(); + this.policyEvictedModules.clear(); + this.reportedBailouts.clear(); + this.diagnostics.clear(); + } + + async close(): Promise { + if (this.closed) { + return; + } + + this.closed = true; + this.clearCache(); + this.sourceMapInterceptor.close(); + await this.options.transport.disconnect?.(); + } + + isClosed(): boolean { + return this.closed; + } + + private async cachedModule(url: string, importer?: string) { + const cached = this.evaluatedModules.getModuleByUrl(url); + const expired = Boolean(cached && this.isModuleExpired(cached)); + if (cached && expired) { + this.invalidateModuleForGc(cached); + } + + const hasUsableMeta = Boolean( + cached && !expired && !this.policyEvictedModules.has(cached.id) + ); + const fetchTraceId = createTraceId('fetch'); + const fetchStart = Date.now(); + this.diagnostics.emit({ + type: 'fetch:start', + traceId: fetchTraceId, + request: url, + ...(importer ? { importer } : {}), + cached: hasUsableMeta, + timestamp: fetchStart + }); + const inflightKey = `${importer ?? ''}::${url}`; + const existing = this.fetchInflight.get(inflightKey); + const fetchPromise = + existing ?? + (this.options.transport.invoke('fetchModule', [ + url, + importer, + { + cached: hasUsableMeta, + startOffset: this.evaluator.startOffset + } + ]) as Promise); + + if (!existing) { + this.fetchInflight.set(inflightKey, fetchPromise); + } + + let fetchedRaw: FetchResult; + try { + fetchedRaw = await fetchPromise; + } catch (error) { + this.diagnostics.emit({ + type: 'fetch:error', + traceId: fetchTraceId, + request: url, + ...(importer ? { importer } : {}), + cached: hasUsableMeta, + error, + timestamp: Date.now() + }); + throw error; + } finally { + if (!existing) { + this.fetchInflight.delete(inflightKey); + } + } + const fetched = this.applyNonJsPolicy(fetchedRaw, url); + this.diagnostics.emit({ + type: 'fetch:end', + traceId: fetchTraceId, + request: url, + ...(importer ? { importer } : {}), + fromCache: 'cache' in fetched, + resultKind: + 'cache' in fetched + ? 'cache' + : 'externalize' in fetched + ? 'external' + : 'inlined', + durationMs: Date.now() - fetchStart, + timestamp: Date.now() + }); + + if ('cache' in fetched) { + if (!cached?.meta) { + throw new Error( + `[farm module runner] Module "${url}" returned cache=true before first load.` + ); + } + + this.runIncrementalGc(); + return cached; + } + + const moduleId = + 'externalize' in fetched ? fetched.externalize : fetched.id; + const moduleUrl = 'url' in fetched ? fetched.url : url; + const mod = this.evaluatedModules.ensureModule(moduleId, moduleUrl); + + if ('invalidate' in fetched && fetched.invalidate) { + this.invalidateModuleForGc(mod); + } + + const normalized: ResolvedFetchResult = { + ...fetched, + id: moduleId, + url: moduleUrl + }; + + mod.meta = normalized; + this.policyEvictedModules.delete(mod.id); + this.markModuleAccess(mod.id); + this.enforceCachePolicy(mod.id); + this.runIncrementalGc(); + + if ('externalize' in normalized && normalized.bailoutReason) { + if (this.strictTransform) { + throw new Error( + `[farm module runner] Module "${normalized.id}" fallback externalized with bailoutReason="${normalized.bailoutReason}" (strictTransform=true).` + ); + } + + const key = `${normalized.id}::${normalized.bailoutReason}`; + if (!this.reportedBailouts.has(key)) { + this.reportedBailouts.add(key); + console.warn( + `[farm module runner] Module "${normalized.id}" fallback externalized with bailoutReason="${normalized.bailoutReason}".` + ); + } + } + + if ('code' in normalized && normalized.map) { + const sourceId = normalized.file || normalized.id || normalized.url; + this.sourceMapInterceptor.register(sourceId, normalized.map); + } + + return mod; + } + + private async cachedRequest( + url: string, + mod: ReturnType, + callstack: string[] = [], + importOptions?: unknown + ): Promise { + const meta = mod.meta; + + if (!meta) { + throw new Error( + `[farm module runner] Missing module metadata for "${url}".` + ); + } + + const moduleId = meta.id; + this.markModuleAccess(moduleId); + + if (mod.evaluated && mod.promise) { + return mod.promise; + } + + if (callstack.includes(moduleId) && mod.exports) { + return mod.exports; + } + + if (mod.promise) { + return mod.promise; + } + + const promise = this.directRequest(url, mod, callstack, importOptions); + mod.promise = promise; + mod.evaluated = false; + + try { + return await promise; + } finally { + mod.evaluated = true; + } + } + + private async directRequest( + url: string, + mod: ReturnType, + callstack: string[], + importOptions?: unknown + ): Promise { + const meta = mod.meta; + + if (!meta) { + throw new Error( + `[farm module runner] Missing module metadata for "${url}".` + ); + } + + const request = async ( + dep: string, + depImportOptions?: unknown + ): Promise => { + dep = String(dep); + const requestBase = meta.url || url; + const resolvedDep = await this.resolveRequest(dep, { + importer: requestBase + }); + + const importer = meta.id; + const depMod = await this.cachedModule(resolvedDep.id, importer); + depMod.importers.add(importer); + mod.imports.add(depMod.id); + + return this.cachedRequest( + resolvedDep.id, + depMod, + [...callstack, importer], + depImportOptions + ); + }; + + const dynamicRequest = async ( + dep: string, + options?: unknown + ): Promise => { + return request(dep, options); + }; + + if ('externalize' in meta) { + const policyResolved = await this.resolveExternalByPolicy( + url, + meta.externalize, + meta.type + ); + if (policyResolved.resolved) { + mod.exports = policyResolved.module; + return policyResolved.module; + } + + const evalTraceId = createTraceId('eval'); + const evalStart = Date.now(); + this.diagnostics.emit({ + type: 'eval:start', + traceId: evalTraceId, + moduleId: meta.id, + mode: 'external', + timestamp: evalStart + }); + try { + const exports = await this.evaluator.runExternalModule( + meta.externalize, + meta.type, + importOptions + ); + mod.exports = exports; + this.diagnostics.emit({ + type: 'eval:end', + traceId: evalTraceId, + moduleId: meta.id, + mode: 'external', + durationMs: Date.now() - evalStart, + timestamp: Date.now() + }); + return exports; + } catch (error) { + this.diagnostics.emit({ + type: 'eval:error', + traceId: evalTraceId, + moduleId: meta.id, + mode: 'external', + error, + timestamp: Date.now() + }); + throw error; + } + } + + if (!meta.code) { + throw new Error(`[farm module runner] Missing code for "${url}".`); + } + + const createImportMeta = + this.options.createImportMeta ?? createDefaultImportMeta; + const modulePath = meta.file || meta.id; + const baseImportMeta = await createImportMeta(modulePath); + const importMeta = { + ...baseImportMeta, + resolve: (request: string) => + this.resolveImportMetaRequestSync(request, modulePath) + }; + const exports: Record = Object.create(null); + + Object.defineProperty(exports, Symbol.toStringTag, { + value: 'Module', + enumerable: false, + configurable: false + }); + + mod.exports = exports; + + const evalTraceId = createTraceId('eval'); + const evalStart = Date.now(); + this.diagnostics.emit({ + type: 'eval:start', + traceId: evalTraceId, + moduleId: meta.id, + mode: 'inlined', + timestamp: evalStart + }); + try { + await this.evaluator.runInlinedModule( + { + [farmSsrModuleExportsKey]: exports, + [farmSsrImportKey]: request, + [farmSsrDynamicImportKey]: dynamicRequest, + [farmSsrExportAllKey]: (source) => this.exportAll(exports, source), + [farmSsrExportNameKey]: (name, getter) => { + Object.defineProperty(exports, name, { + enumerable: true, + configurable: true, + get: getter + }); + }, + [farmSsrImportMetaKey]: importMeta + }, + meta.code, + mod + ); + this.diagnostics.emit({ + type: 'eval:end', + traceId: evalTraceId, + moduleId: meta.id, + mode: 'inlined', + durationMs: Date.now() - evalStart, + timestamp: Date.now() + }); + } catch (error) { + this.diagnostics.emit({ + type: 'eval:error', + traceId: evalTraceId, + moduleId: meta.id, + mode: 'inlined', + error, + timestamp: Date.now() + }); + throw error; + } + + return exports; + } + + private async resolveExternalByPolicy( + requestId: string, + externalize: string, + type: ExternalFetchResult['type'] + ): Promise<{ resolved: boolean; module?: unknown }> { + const policy = this.options.externalPolicy; + const policyTraceId = createTraceId('policy'); + const globals = policy?.globals; + const candidates = [ + requestId, + cleanUrl(requestId), + externalize, + cleanUrl(externalize) + ]; + + if (globals) { + if (typeof globals === 'function') { + for (const id of candidates) { + const resolved = await globals(id); + if (resolved !== undefined) { + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: 'globals', + action: 'resolved', + timestamp: Date.now() + }); + return { resolved: true, module: resolved }; + } + } + } else { + for (const id of candidates) { + if (Object.hasOwn(globals, id)) { + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: 'globals', + action: 'resolved', + timestamp: Date.now() + }); + return { + resolved: true, + module: globals[id] + }; + } + } + } + } + + const context: RunnerExternalPolicyContext = { + requestId, + externalize, + type + }; + + if (policy?.custom) { + const customResolved = await policy.custom(context); + if (customResolved !== undefined) { + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: 'custom', + action: 'resolved', + timestamp: Date.now() + }); + return { resolved: true, module: customResolved }; + } + } + + const targetPolicy = this.resolveExternalTargetPolicy(type, policy); + const targetPolicyName = type === 'builtin' ? 'builtin' : 'network'; + if (!targetPolicy || targetPolicy.mode === 'externalize') { + if (targetPolicy) { + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: targetPolicyName, + action: 'externalize', + timestamp: Date.now() + }); + } + return { resolved: false }; + } + + if (targetPolicy.mode === 'error') { + const messageValue = targetPolicy.message; + const message = + typeof messageValue === 'function' + ? await messageValue(context) + : messageValue; + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: targetPolicyName, + action: 'error', + timestamp: Date.now() + }); + + throw new Error( + message ?? + `[farm module runner] External module "${requestId}" (${type}) is blocked by externalPolicy.${type}=error.` + ); + } + + const stubValue = targetPolicy.stub; + if (typeof stubValue === 'function') { + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: targetPolicyName, + action: 'stub', + timestamp: Date.now() + }); + return { + resolved: true, + module: await stubValue(context) + }; + } + + if (stubValue !== undefined) { + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: targetPolicyName, + action: 'stub', + timestamp: Date.now() + }); + return { resolved: true, module: stubValue }; + } + + this.diagnostics.emit({ + type: 'external:policy', + traceId: policyTraceId, + requestId, + externalize, + externalType: type, + policy: targetPolicyName, + action: 'stub', + timestamp: Date.now() + }); + return { + resolved: true, + module: this.createDefaultExternalPolicyStub(context) + }; + } + + private resolveExternalTargetPolicy( + type: ExternalFetchResult['type'], + policy: FarmModuleRunnerOptions['externalPolicy'] + ): RunnerExternalPolicyTargetOptions | undefined { + const raw = + type === 'builtin' + ? policy?.builtin + : type === 'network' + ? policy?.network + : undefined; + + if (!raw) { + return undefined; + } + + if (typeof raw === 'string') { + return { mode: raw as RunnerExternalPolicyMode }; + } + + return { + mode: raw.mode ?? 'externalize', + ...(raw.stub !== undefined ? { stub: raw.stub } : {}), + ...(raw.message !== undefined ? { message: raw.message } : {}) + }; + } + + private createDefaultExternalPolicyStub( + context: RunnerExternalPolicyContext + ): unknown { + if (context.type === 'network') { + return cleanUrl(context.externalize); + } + + return Object.create(null); + } + + private isModuleExpired( + mod: ReturnType + ): boolean { + const ttlMs = this.options.cachePolicy?.ttlMs; + if (!ttlMs || ttlMs <= 0) { + return false; + } + + if (!mod.meta) { + return false; + } + + const last = this.moduleLastAccess.get(mod.id); + if (last == null) { + return false; + } + + return Date.now() - last > ttlMs; + } + + private markModuleAccess(moduleId: string): void { + this.moduleLastAccess.set(moduleId, Date.now()); + } + + private enforceCachePolicy(currentModuleId: string): void { + const maxEntries = this.options.cachePolicy?.maxEntries; + if (!maxEntries || maxEntries <= 0) { + return; + } + + const active = [...this.evaluatedModules.idToModuleMap.values()].filter( + (mod) => Boolean(mod.meta) + ); + + if (active.length <= maxEntries) { + return; + } + + const candidates = active + .filter( + (mod) => mod.id !== currentModuleId && !(mod.promise && !mod.evaluated) + ) + .sort((a, b) => { + const aTs = this.moduleLastAccess.get(a.id) ?? 0; + const bTs = this.moduleLastAccess.get(b.id) ?? 0; + return aTs - bTs; + }); + + let overflow = active.length - maxEntries; + for (const mod of candidates) { + if (overflow <= 0) { + break; + } + + this.invalidateModuleForGc(mod); + this.moduleLastAccess.delete(mod.id); + this.policyEvictedModules.add(mod.id); + overflow--; + } + } + + private invalidateModuleForGc(mod: EvaluatedModuleNode): void { + this.evaluatedModules.invalidateModule(mod); + this.scheduleGcCandidate(mod.id); + } + + private scheduleGcCandidate(moduleId: string): void { + if (this.gcCandidateSet.has(moduleId)) { + return; + } + + this.gcCandidateSet.add(moduleId); + this.gcCandidateQueue.push(moduleId); + } + + private runIncrementalGc(): void { + const budget = this.options.cachePolicy?.gcSweepPerCycle ?? 16; + if (budget <= 0) { + return; + } + + let remaining = budget; + while (remaining > 0 && this.gcCandidateQueue.length) { + const moduleId = this.gcCandidateQueue.shift(); + if (!moduleId) { + break; + } + this.gcCandidateSet.delete(moduleId); + + const mod = this.evaluatedModules.getModuleById(moduleId); + if (!mod) { + remaining--; + continue; + } + + if ( + !mod.meta && + !mod.promise && + mod.imports.size === 0 && + mod.importers.size === 0 + ) { + this.evaluatedModules.removeModule(mod); + this.moduleLastAccess.delete(moduleId); + this.policyEvictedModules.delete(moduleId); + } else if (!mod.meta) { + this.scheduleGcCandidate(moduleId); + } + + remaining--; + } + } + + private applyNonJsPolicy(result: FetchResult, moduleId: string): FetchResult { + if (!('externalize' in result)) { + return result; + } + + const kind = classifyNonJsKind({ + id: moduleId, + externalize: result.externalize, + bailoutReason: result.bailoutReason + }); + + if (!kind) { + return result; + } + + const mode = resolveNonJsPolicyMode(kind, this.options.nonJsPolicy); + + if (mode === 'externalize') { + return result; + } + + if (mode === 'error') { + throw new Error( + `[farm module runner] Non-js module "${moduleId}" (${kind}) is blocked by nonJsPolicy=${mode}.` + ); + } + + const file = toFilePathFromExternalize(result.externalize); + + if (kind === 'asset') { + const value = JSON.stringify(cleanUrl(result.externalize)); + return { + code: [ + `const __farm_ssr_asset_url__ = ${value};`, + '__farm_ssr_export_name__("default", () => __farm_ssr_asset_url__);' + ].join('\n'), + file, + id: moduleId, + url: moduleId, + invalidate: false, + map: null + }; + } + + return { + code: [ + 'const __farm_ssr_style_noop__ = {};', + '__farm_ssr_export_name__("default", () => __farm_ssr_style_noop__);' + ].join('\n'), + file, + id: moduleId, + url: moduleId, + invalidate: false, + map: null + }; + } + + private createInteropEvaluator(): ModuleEvaluator { + return { + startOffset: this.baseEvaluator.startOffset, + runExternalModule: (file, type, options) => + this.baseEvaluator.runExternalModule(file, type, options), + runInlinedModule: async (context, code, module) => { + const wrapImport = + ( + loader: (id: string, options?: unknown) => Promise, + kind: RuntimeImportKind + ) => + async (id: string, options?: unknown) => { + const loaded = await loader(id, options); + return this.applyInteropForImportedModule(loaded, { + specifier: String(id), + importer: module.id, + kind + }); + }; + + const wrappedContext = { + ...context, + __farm_ssr_import__: wrapImport( + context.__farm_ssr_import__, + 'import' + ), + __farm_ssr_dynamic_import__: wrapImport( + context.__farm_ssr_dynamic_import__, + 'dynamic-import' + ) + }; + + await this.baseEvaluator.runInlinedModule(wrappedContext, code, module); + } + }; + } + + private applyInteropForImportedModule( + loaded: unknown, + info: { + specifier: string; + importer: string; + kind: RuntimeImportKind; + } + ): unknown { + const traceId = createTraceId('interop'); + + if (!isObjectLike(loaded)) { + return loaded; + } + + if (isModuleNamespaceLike(loaded)) { + const cached = this.namespaceInteropCache.get(loaded); + if (cached) { + return cached; + } + + const wrapped = new Proxy(loaded, { + get: (target, prop, receiver) => { + if (prop === 'default' && !Reflect.has(target, 'default')) { + const error = createMissingDefaultExportError(info); + this.diagnostics.emit({ + type: 'interop:error', + traceId, + specifier: info.specifier, + importer: info.importer, + code: 'ERR_MISSING_DEFAULT_EXPORT', + message: error.message, + timestamp: Date.now() + }); + throw error; + } + + return Reflect.get(target, prop, receiver); + } + }); + + this.diagnostics.emit({ + type: 'interop:wrap', + traceId, + specifier: info.specifier, + importer: info.importer, + kind: 'esm-namespace', + timestamp: Date.now() + }); + this.namespaceInteropCache.set(loaded, wrapped); + return wrapped; + } + + const cached = this.cjsInteropCache.get(loaded); + if (cached) { + return cached; + } + + const cjsSource = loaded; + const wrapped = new Proxy(Object.create(null) as Record, { + get: (_target, prop) => { + if (prop === Symbol.toStringTag) { + return 'Module'; + } + + if (prop === '__esModule') { + return true; + } + + if (prop === 'default') { + return cjsSource; + } + + if ( + typeof prop === 'string' && + !CJS_INTEROP_NON_THROW_MISSING_PROPS.has(prop) && + !Reflect.has(cjsSource, prop) + ) { + const error = createMissingNamedExportError({ + exportName: prop, + ...info + }); + this.diagnostics.emit({ + type: 'interop:error', + traceId, + specifier: info.specifier, + importer: info.importer, + code: 'ERR_MISSING_NAMED_EXPORT', + message: error.message, + timestamp: Date.now() + }); + throw error; + } + + return Reflect.get(cjsSource, prop, cjsSource); + }, + has: (_target, prop) => { + if (prop === '__esModule' || prop === 'default') { + return true; + } + return Reflect.has(cjsSource, prop); + }, + ownKeys: () => { + const keys = new Set([ + ...Reflect.ownKeys(cjsSource), + 'default', + '__esModule' + ]); + + return Array.from(keys); + }, + getOwnPropertyDescriptor: (_target, prop) => { + if (prop === 'default') { + return { + configurable: true, + enumerable: true, + writable: false, + value: cjsSource + }; + } + + if (prop === '__esModule') { + return { + configurable: true, + enumerable: false, + writable: false, + value: true + }; + } + + return Reflect.getOwnPropertyDescriptor(cjsSource, prop); + } + }); + + this.diagnostics.emit({ + type: 'interop:wrap', + traceId, + specifier: info.specifier, + importer: info.importer, + kind: 'cjs-value', + timestamp: Date.now() + }); + this.cjsInteropCache.set(loaded, wrapped); + return wrapped; + } + + private async resolveRequest( + request: string, + context?: RunnerResolveContext + ): Promise { + const traceId = createTraceId('resolve'); + const start = Date.now(); + this.diagnostics.emit({ + type: 'resolve:start', + traceId, + request, + ...(context?.importer ? { importer: context.importer } : {}), + timestamp: start + }); + + let resolved: RunnerResolveResult; + + if (this.resolver) { + resolved = await this.resolver(request, context); + } else if (request[0] === '.' && context?.importer) { + resolved = { + id: resolveRelativeRequestUrl(context.importer, request) + }; + } else { + resolved = { id: request }; + } + + const end = Date.now(); + this.diagnostics.emit({ + type: 'resolve:end', + traceId, + request, + ...(context?.importer ? { importer: context.importer } : {}), + resolvedId: resolved.id, + resolvedUrl: resolved.url ?? resolved.id, + durationMs: end - start, + timestamp: end + }); + + return resolved; + } + + private resolveImportMetaRequestSync( + request: string, + importer: string + ): string { + if (this.resolver) { + const resolved = this.resolver(request, { + importer + }); + + if ( + resolved && + typeof resolved === 'object' && + typeof Reflect.get(resolved as object, 'then') === 'function' + ) { + throw new Error( + '[farm module runner] import.meta.resolve requires a synchronous resolver result.' + ); + } + + const normalized = resolved as RunnerResolveResult; + return normalized.url ?? normalized.id; + } + + if (request[0] === '.') { + return resolveRelativeRequestUrl(importer, request); + } + + return request; + } + + private exportAll( + exports: Record, + sourceModule: unknown + ): void { + if ( + !sourceModule || + typeof sourceModule !== 'object' || + Array.isArray(sourceModule) || + sourceModule instanceof Promise || + sourceModule === exports + ) { + return; + } + + for (const key in sourceModule as Record) { + if (key === 'default' || key === '__esModule' || key in exports) { + continue; + } + + Object.defineProperty(exports, key, { + enumerable: true, + configurable: true, + get: () => (sourceModule as Record)[key] + }); + } + } + + private handleHotPayload(payload: RunnerHotPayload): void { + if (this.closed) { + return; + } + + if (payload.type === 'update') { + this.diagnostics.emit({ + type: 'hmr:update', + updates: payload.updates.length, + timestamp: Date.now() + }); + for (const update of payload.updates) { + this.invalidateByUrl(update.path); + this.invalidateByUrl(update.acceptedPath); + } + return; + } + + if (payload.type === 'full-reload') { + this.diagnostics.emit({ + type: 'hmr:full-reload', + ...(payload.path ? { path: payload.path } : {}), + timestamp: Date.now() + }); + this.clearCache(); + return; + } + + if (payload.type === 'prune') { + this.diagnostics.emit({ + type: 'hmr:prune', + paths: payload.paths.length, + timestamp: Date.now() + }); + for (const path of payload.paths) { + this.invalidateByUrl(path); + } + } + } + + private invalidateByUrl(url: string): void { + const normalized = cleanUrl(url); + const roots = new Set(); + + const directByRaw = this.evaluatedModules.getModuleByUrl(url); + if (directByRaw) { + roots.add(directByRaw); + } + + const directByNormalized = this.evaluatedModules.getModuleByUrl(normalized); + if (directByNormalized) { + roots.add(directByNormalized); + } + + const fileMatched = this.evaluatedModules.getModulesByFile(normalized); + if (fileMatched) { + for (const mod of fileMatched) { + roots.add(mod); + } + } + + if (!roots.size) { + return; + } + + const queue = [...roots]; + const visited = new Set(); + + while (queue.length) { + const mod = queue.pop(); + + if (!mod || visited.has(mod.id)) { + continue; + } + + visited.add(mod.id); + const importers = [...mod.importers]; + const meta = mod.meta; + + if (meta && 'code' in meta && meta.map) { + const sourceId = meta.file || meta.id || meta.url; + this.sourceMapInterceptor.unregister(sourceId); + } + + this.invalidateModuleForGc(mod); + + for (const importerId of importers) { + const importer = this.evaluatedModules.getModuleById(importerId); + + if (importer && !visited.has(importer.id)) { + queue.push(importer); + } + } + } + } + + private resolveSourceMapInterceptorEnabled( + option: FarmModuleRunnerOptions['sourceMapInterceptor'] + ): boolean { + if (option == null) { + return true; + } + + if (typeof option === 'boolean') { + return option; + } + + return option.enable !== false; + } + + private resolveSourceMapInterceptorNative( + option: FarmModuleRunnerOptions['sourceMapInterceptor'] + ): boolean { + if (option == null || typeof option === 'boolean') { + return true; + } + + return option.native !== false; + } + + private resolveSourceMapInterceptorHooks( + option: FarmModuleRunnerOptions['sourceMapInterceptor'] + ): RunnerSourceMapHooks | undefined { + if (option == null || typeof option === 'boolean') { + return undefined; + } + + return option.hooks; + } +} + +function resolveRelativeRequestUrl( + importerUrl: string, + request: string +): string { + const base = cleanUrl(importerUrl); + + if ( + base.startsWith('file://') || + base.startsWith('http://') || + base.startsWith('https://') + ) { + try { + return new URL(request, base).toString(); + } catch { + return request; + } + } + + const normalizedBase = base.startsWith('/') ? base : `/${base}`; + + try { + return new URL(request, `http://farm.invalid${normalizedBase}`).pathname; + } catch { + return request; + } +} diff --git a/packages/core/src/module-runner/serverInvoke.ts b/packages/core/src/module-runner/serverInvoke.ts new file mode 100644 index 0000000000..2e72131855 --- /dev/null +++ b/packages/core/src/module-runner/serverInvoke.ts @@ -0,0 +1,666 @@ +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { builtinModules, createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { Server } from '../server/index.js'; +import { cleanUrl } from '../utils/url.js'; +import type { + FetchFunctionOptions, + FetchResult, + InlinedFetchResult, + ModuleRunnerInvokeContext, + ModuleRunnerInvokeHandlers, + RunnerTransformBailoutReason +} from './types.js'; + +type RunnerInvokeState = { + resourceMirrorStamp: number; + resourceMirrorDir: string; + moduleStamps: Map; + moduleFiles: Map; + packageTypeByJsonPath: Map; +}; + +const BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map((id) => `node:${id}`) +]); + +const STATE = new WeakMap(); + +function getState(context: ModuleRunnerInvokeContext): RunnerInvokeState { + const existing = STATE.get(context); + + if (existing) { + return existing; + } + + const created = { + resourceMirrorStamp: -1, + resourceMirrorDir: path.join(context.root, '.farm', 'module-runner'), + moduleStamps: new Map(), + moduleFiles: new Map(), + packageTypeByJsonPath: new Map() + } satisfies RunnerInvokeState; + + STATE.set(context, created); + + return created; +} + +function appendVersion(url: string, version: number | string): string { + const mark = url.includes('?') ? '&' : '?'; + return `${url}${mark}t=${version}`; +} + +async function readMtimeMs(filePath: string): Promise { + try { + const stats = await fs.stat(filePath); + return stats.mtimeMs; + } catch { + return null; + } +} + +async function getModuleTypeByFile( + file: string, + state: RunnerInvokeState +): Promise<'module' | 'commonjs'> { + if (file.endsWith('.cjs')) { + return 'commonjs'; + } + + if (file.endsWith('.mjs')) { + return 'module'; + } + + if (!file.endsWith('.js')) { + return 'module'; + } + + let current = path.dirname(file); + + while (true) { + const packageJsonPath = path.join(current, 'package.json'); + const cached = state.packageTypeByJsonPath.get(packageJsonPath); + + if (cached) { + return cached; + } + + try { + const raw = await fs.readFile(packageJsonPath, 'utf-8'); + const parsed = JSON.parse(raw) as { type?: unknown }; + const type = parsed.type === 'module' ? 'module' : 'commonjs'; + state.packageTypeByJsonPath.set(packageJsonPath, type); + return type; + } catch (error) { + if ((error as { code?: string }).code !== 'ENOENT') { + return 'commonjs'; + } + } + + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return 'commonjs'; +} + +function resolveExternalizedFilePath(externalize: string): string | null { + const normalized = cleanUrl(externalize); + + if (normalized.startsWith('file://')) { + try { + return fileURLToPath(normalized); + } catch { + return null; + } + } + + if (path.isAbsolute(normalized)) { + return normalized; + } + + return null; +} + +async function normalizeExternalizedTypeByPath( + state: RunnerInvokeState, + externalize: string, + type: 'module' | 'commonjs' | 'builtin' | 'network' +): Promise<'module' | 'commonjs' | 'builtin' | 'network'> { + if (type === 'builtin' || type === 'network') { + return type; + } + + const filePath = resolveExternalizedFilePath(externalize); + + if (!filePath || !existsSync(filePath)) { + return type; + } + + return getModuleTypeByFile(filePath, state); +} + +function isExternalType( + value: unknown +): value is 'module' | 'commonjs' | 'builtin' | 'network' { + return ( + value === 'module' || + value === 'commonjs' || + value === 'builtin' || + value === 'network' + ); +} + +function isBailoutReason( + value: unknown +): value is RunnerTransformBailoutReason { + return ( + value === 'unsupported-ts' || + value === 'import-mutation' || + value === 'unhandled-module-decl' || + value === 'not-script' || + value === 'empty-content' + ); +} + +function isFetchResult(value: unknown): value is FetchResult { + if (!value || typeof value !== 'object') { + return false; + } + + if ('cache' in value) { + return (value as { cache?: unknown }).cache === true; + } + + if ('externalize' in value) { + const externalized = value as { + externalize?: unknown; + type?: unknown; + bailoutReason?: unknown; + }; + + return ( + typeof externalized.externalize === 'string' && + isExternalType(externalized.type) && + (externalized.bailoutReason == null || + isBailoutReason(externalized.bailoutReason)) + ); + } + + if ('code' in value) { + const inlined = value as { + code?: unknown; + file?: unknown; + id?: unknown; + url?: unknown; + invalidate?: unknown; + map?: unknown; + }; + + return ( + typeof inlined.code === 'string' && + (inlined.file == null || typeof inlined.file === 'string') && + typeof inlined.id === 'string' && + typeof inlined.url === 'string' && + typeof inlined.invalidate === 'boolean' && + (inlined.map == null || typeof inlined.map === 'string') + ); + } + + return false; +} + +function normalizeInlinedFetchResult( + result: InlinedFetchResult +): InlinedFetchResult { + return { + ...result, + map: result.map ?? null + }; +} + +async function mirrorCompiledResources( + context: ModuleRunnerInvokeContext +): Promise { + const state = getState(context); + const compiler = context.compiler; + + if (!compiler) { + throw new Error('[farm module runner] compiler is not initialized.'); + } + + if (state.resourceMirrorStamp === context.moduleRunnerStamp) { + return state.resourceMirrorDir; + } + + const resources = compiler.resources(); + + await fs.rm(state.resourceMirrorDir, { recursive: true, force: true }); + await fs.mkdir(state.resourceMirrorDir, { recursive: true }); + + await Promise.all( + Object.entries(resources).map(async ([name, content]) => { + const file = path.join(state.resourceMirrorDir, name); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content); + }) + ); + + state.resourceMirrorStamp = context.moduleRunnerStamp; + + return state.resourceMirrorDir; +} + +function normalizeImporter( + importer: string | undefined, + root: string +): string | undefined { + if (!importer) { + return undefined; + } + + const normalized = cleanUrl(importer); + + if (normalized.startsWith('file://')) { + return fileURLToPath(normalized); + } + + if (normalized.startsWith('/')) { + return path.resolve(root, normalized.slice(1)); + } + + const rooted = path.resolve(root, normalized); + if (existsSync(rooted)) { + return rooted; + } + + return normalized; +} + +function resolveResourceNameByUrl(url: string, publicPath: string): string { + let normalized = cleanUrl(url); + + if (publicPath !== '/' && normalized.startsWith(publicPath)) { + normalized = normalized.slice(publicPath.length); + } + + if (normalized.startsWith('/')) { + normalized = normalized.slice(1); + } + + return normalized; +} + +function resolveFilePath( + context: ModuleRunnerInvokeContext, + id: string, + importer?: string +): string | undefined { + const normalized = cleanUrl(id); + + if (normalized.startsWith('file://')) { + return fileURLToPath(normalized); + } + + if (normalized.startsWith('/')) { + return path.resolve(context.root, normalized.slice(1)); + } + + if (normalized.startsWith('.')) { + const importerPath = normalizeImporter(importer, context.root); + + if (!importerPath) { + return path.resolve(context.root, normalized); + } + + return path.resolve(path.dirname(importerPath), normalized); + } + + return undefined; +} + +function resolveViteVirtualFetchResult(id: string): InlinedFetchResult | null { + if (id !== '\0plugin-vue:export-helper') { + return null; + } + + return { + code: [ + 'function exportHelper(sfc, props) {', + ' const target = sfc.__vccOpts || sfc;', + ' for (let i = 0; i < props.length; i++) {', + ' const pair = props[i];', + ' target[pair[0]] = pair[1];', + ' }', + ' return target;', + '}', + '__farm_ssr_export_name__("default", () => exportHelper);' + ].join('\n'), + file: null, + id, + url: id, + invalidate: false, + map: null + }; +} + +function isVueStyleQueryRequest(id: string): boolean { + const cleaned = cleanUrl(id); + return ( + cleaned.endsWith('.vue') && + /(?:\?|&)vue(?:&|$)/.test(id) && + /(?:\?|&)type=style(?:&|$)/.test(id) + ); +} + +function resolveVueStyleNoopFetchResult( + context: ModuleRunnerInvokeContext, + id: string, + importer: string | undefined, + bailoutReason: RunnerTransformBailoutReason | undefined +): InlinedFetchResult | null { + if (bailoutReason !== 'not-script' || !isVueStyleQueryRequest(id)) { + return null; + } + + const file = resolveFilePath(context, id, importer) ?? null; + + return { + code: [ + 'const __farm_ssr_style_noop__ = {};', + '__farm_ssr_export_name__("default", () => __farm_ssr_style_noop__);' + ].join('\n'), + file, + id, + url: id, + invalidate: false, + map: null + }; +} + +function resolveRootRelativeCompilerFetchId( + context: ModuleRunnerInvokeContext, + id: string +): string | null { + const normalized = cleanUrl(id); + + if (!normalized.startsWith('/')) { + return null; + } + + if (existsSync(normalized)) { + return null; + } + + const resourceName = resolveResourceNameByUrl(normalized, context.publicPath); + if (!resourceName) { + return null; + } + + const suffix = id.slice(normalized.length); + return `${path.resolve(context.root, resourceName)}${suffix}`; +} + +function fetchModuleFromCompiler( + context: ModuleRunnerInvokeContext, + id: string, + importer: string | undefined, + options: FetchFunctionOptions | undefined +): unknown { + const compiler = context.compiler; + if (!compiler) { + return null; + } + + const candidates = [id]; + const rootRelativeId = resolveRootRelativeCompilerFetchId(context, id); + if (rootRelativeId && rootRelativeId !== id) { + candidates.push(rootRelativeId); + } + + for (const candidate of candidates) { + const result = compiler.fetchModule(candidate, importer, options); + if (result != null) { + return result; + } + } + + return null; +} + +async function resolveFetchModule( + context: ModuleRunnerInvokeContext, + id: string, + importer?: string, + options?: FetchFunctionOptions +): Promise { + const compiler = context.compiler; + + if (!compiler) { + throw new Error( + '[farm module runner] compiler is not initialized, call createModuleRunner after server compile.' + ); + } + + if (compiler.compiling) { + await compiler.waitForCompileFinish(); + } + + const state = getState(context); + const cacheKey = `${importer ?? ''}::${id}`; + const latestStamp = context.moduleRunnerStamp; + + if (options?.cached && state.moduleStamps.get(cacheKey) === latestStamp) { + const cachedFile = state.moduleFiles.get(cacheKey); + + if (!cachedFile) { + return { cache: true }; + } + + const mtimeMs = await readMtimeMs(cachedFile.path); + + if (mtimeMs != null && mtimeMs === cachedFile.mtimeMs) { + return { cache: true }; + } + + state.moduleFiles.delete(cacheKey); + } + + const compilerFetchResult = fetchModuleFromCompiler( + context, + id, + importer, + options + ); + + if (compilerFetchResult != null) { + if (!isFetchResult(compilerFetchResult)) { + throw new Error( + `[farm module runner] Invalid fetchModule result for "${id}".` + ); + } + + if (!('cache' in compilerFetchResult)) { + state.moduleStamps.set(cacheKey, latestStamp); + state.moduleFiles.delete(cacheKey); + } + + if ('code' in compilerFetchResult) { + return normalizeInlinedFetchResult(compilerFetchResult); + } + + if ('externalize' in compilerFetchResult) { + const vueStyleNoopResult = resolveVueStyleNoopFetchResult( + context, + id, + importer, + compilerFetchResult.bailoutReason + ); + if (vueStyleNoopResult) { + return vueStyleNoopResult; + } + + return { + ...compilerFetchResult, + type: await normalizeExternalizedTypeByPath( + state, + compilerFetchResult.externalize, + compilerFetchResult.type + ) + }; + } + + return compilerFetchResult; + } + + const normalized = cleanUrl(id); + + if (normalized.startsWith('data:')) { + state.moduleFiles.delete(cacheKey); + return { + externalize: normalized, + type: 'builtin' + }; + } + + if (BUILTINS.has(normalized)) { + state.moduleFiles.delete(cacheKey); + return { + externalize: normalized, + type: 'builtin' + }; + } + + if (/^https?:\/\//.test(normalized)) { + state.moduleFiles.delete(cacheKey); + return { + externalize: normalized, + type: 'network' + }; + } + + const viteVirtualResult = resolveViteVirtualFetchResult(normalized); + if (viteVirtualResult) { + state.moduleStamps.set(cacheKey, latestStamp); + state.moduleFiles.delete(cacheKey); + return viteVirtualResult; + } + + const resourceName = resolveResourceNameByUrl(normalized, context.publicPath); + + if (resourceName && compiler.resource(resourceName)) { + const mirrorDir = await mirrorCompiledResources(context); + const filePath = path.join(mirrorDir, resourceName); + const externalize = appendVersion( + pathToFileURL(filePath).toString(), + latestStamp + ); + + state.moduleStamps.set(cacheKey, latestStamp); + state.moduleFiles.delete(cacheKey); + + return { + externalize, + type: await getModuleTypeByFile(filePath, state) + }; + } + + const directFilePath = resolveFilePath(context, normalized, importer); + + if (directFilePath && existsSync(directFilePath)) { + const mtimeMs = await readMtimeMs(directFilePath); + + const url = pathToFileURL(directFilePath).toString(); + const shouldVersion = directFilePath.startsWith(context.root); + const version = + mtimeMs == null ? latestStamp : `${latestStamp}-${Math.floor(mtimeMs)}`; + const externalize = shouldVersion ? appendVersion(url, version) : url; + + state.moduleStamps.set(cacheKey, latestStamp); + if (mtimeMs != null) { + state.moduleFiles.set(cacheKey, { path: directFilePath, mtimeMs }); + } else { + state.moduleFiles.delete(cacheKey); + } + + return { + externalize, + type: await getModuleTypeByFile(directFilePath, state) + }; + } + + const importerFile = normalizeImporter(importer, context.root); + const resolveFrom = + importerFile && existsSync(importerFile) + ? importerFile + : path.join(context.root, 'package.json'); + + try { + const require = createRequire(resolveFrom); + const resolved = require.resolve(normalized); + const filePath = cleanUrl(resolved); + const mtimeMs = await readMtimeMs(filePath); + const shouldVersion = filePath.startsWith(context.root); + const version = + mtimeMs == null ? latestStamp : `${latestStamp}-${Math.floor(mtimeMs)}`; + const externalize = shouldVersion + ? appendVersion(pathToFileURL(filePath).toString(), version) + : pathToFileURL(filePath).toString(); + + state.moduleStamps.set(cacheKey, latestStamp); + if (mtimeMs != null) { + state.moduleFiles.set(cacheKey, { path: filePath, mtimeMs }); + } else { + state.moduleFiles.delete(cacheKey); + } + + return { + externalize, + type: await getModuleTypeByFile(filePath, state) + }; + } catch { + throw new Error( + `[farm module runner] Cannot fetch module "${id}"${ + importer ? ` imported from "${importer}"` : '' + }. Fallback path supports built outputs, local files and externalized dependencies.` + ); + } +} + +export function createModuleRunnerInvokeHandlers( + context: ModuleRunnerInvokeContext +): ModuleRunnerInvokeHandlers { + return { + fetchModule: (id, importer, options) => + resolveFetchModule(context, id, importer, options), + getBuiltins: async () => Array.from(BUILTINS.values()) + }; +} + +export function createServerModuleRunnerInvokeHandlers( + server: Server +): ModuleRunnerInvokeHandlers { + const context = { + get root() { + return server.root; + }, + get publicPath() { + return server.publicPath; + }, + get moduleRunnerStamp() { + return server.moduleRunnerStamp; + }, + get compiler() { + return server.compiler; + } + } as ModuleRunnerInvokeContext; + + return createModuleRunnerInvokeHandlers(context); +} diff --git a/packages/core/src/module-runner/serverTransport.ts b/packages/core/src/module-runner/serverTransport.ts new file mode 100644 index 0000000000..032225d30e --- /dev/null +++ b/packages/core/src/module-runner/serverTransport.ts @@ -0,0 +1,113 @@ +import type { Server } from '../server/index.js'; +import type { JsUpdateResult } from '../types/binding.js'; +import type { + ModuleRunnerInvokeHandlers, + ModuleRunnerTransport, + RunnerHotPayload, + RunnerHotUpdate +} from './types.js'; + +export type ModuleRunnerHotBus = { + subscribe: (cb: (payload: RunnerHotPayload) => void) => () => void; +}; + +export type ModuleRunnerTransportDeps = { + invokeHandlers: ModuleRunnerInvokeHandlers; + hotBus: ModuleRunnerHotBus; +}; + +export function createModuleRunnerTransportFromInvokeHandlers( + deps: ModuleRunnerTransportDeps +): ModuleRunnerTransport { + let disconnectHandler: (() => void) | null = null; + + return { + connect({ onMessage, onDisconnection }) { + onMessage({ type: 'connected' }); + + let active = true; + const unsubscribe = deps.hotBus.subscribe((payload) => { + if (active) { + onMessage(payload); + } + }); + + disconnectHandler = () => { + if (!active) { + return; + } + active = false; + unsubscribe(); + onDisconnection(); + }; + }, + disconnect() { + disconnectHandler?.(); + disconnectHandler = null; + }, + async invoke(name, data) { + const handler = deps.invokeHandlers[name] as ( + ...args: unknown[] + ) => Promise; + return (await handler(...(data as unknown[]))) as never; + } + }; +} + +export function createServerModuleRunnerTransport( + server: Server +): ModuleRunnerTransport { + let disconnectHandler: (() => void) | null = null; + + return { + connect({ onMessage, onDisconnection }) { + onMessage({ type: 'connected' }); + + let active = true; + const updateListener = (result: JsUpdateResult) => { + if (!active) { + return; + } + + const changedModules = [ + ...result.changed, + ...result.added, + ...result.extraWatchResult.add + ]; + + if (changedModules.length > 0) { + const updates: RunnerHotUpdate[] = changedModules.map((item) => ({ + type: 'js-update', + path: item, + acceptedPath: item, + timestamp: Date.now() + })); + + onMessage({ + type: 'update', + updates + } satisfies RunnerHotPayload); + return; + } + + if (result.removed.length > 0) { + onMessage({ type: 'full-reload' }); + } + }; + + server.hmrEngine?.onUpdateFinish(updateListener); + + disconnectHandler = () => { + active = false; + onDisconnection(); + }; + }, + disconnect() { + disconnectHandler?.(); + disconnectHandler = null; + }, + invoke(name, data) { + return server.invokeModuleRunner(name, data as never); + } + }; +} diff --git a/packages/core/src/module-runner/sourceMapInterceptor.ts b/packages/core/src/module-runner/sourceMapInterceptor.ts new file mode 100644 index 0000000000..432b121303 --- /dev/null +++ b/packages/core/src/module-runner/sourceMapInterceptor.ts @@ -0,0 +1,536 @@ +import { cleanUrl } from '../utils/url.js'; +import type { RunnerSourceMapHooks } from './types.js'; + +type StackTraceFormatter = (error: Error, trace: NodeJS.CallSite[]) => unknown; + +type SourceMapEntry = { + originalSource: string | null; + originalLine: number | null; + originalColumn: number | null; +}; + +type SourceMapLike = { + findEntry(lineOffset: number, columnOffset: number): SourceMapEntry; +}; + +type NodeSourceMapConstructor = new (payload: unknown) => SourceMapLike; + +export interface RunnerSourceMapInterceptor { + register(sourceId: string, sourceMap: string): void; + unregister(sourceId: string): void; + clear(): void; + close(): void; +} + +type GlobalCandidateEntry = { + storeId: number; + map: SourceMapLike; +}; + +type HookStoreEntry = { + hooks: RunnerSourceMapHooks; + parsedCache: Map; +}; + +const globalStores = new Map>(); +const globalCandidateEntries = new Map(); +const globalHookStores = new Map(); +const STACK_FRAME_PATTERN = /^(\s*at\s+(?:.+?\s+\()?)(.+):(\d+):(\d+)(\)?)$/; + +let installedCount = 0; +let nativeRequestedCount = 0; +let previousPrepareStackTrace: StackTraceFormatter | undefined; +let previousNativeSourceMapEnabled: boolean | undefined; +let touchedNativeSourceMapSwitch = false; +let nextStoreId = 0; +let nodeSourceMapCtor: NodeSourceMapConstructor | undefined; + +function getProcessLike(): Record | undefined { + const processLike = Reflect.get(globalThis as object, 'process'); + + if (!processLike || typeof processLike !== 'object') { + return undefined; + } + + return processLike as Record; +} + +function resolveNodeSourceMapCtor(): NodeSourceMapConstructor | undefined { + if (nodeSourceMapCtor) { + return nodeSourceMapCtor; + } + const processLike = getProcessLike(); + + if (!processLike) { + return undefined; + } + + const getBuiltinModule = Reflect.get(processLike, 'getBuiltinModule'); + + if (typeof getBuiltinModule !== 'function') { + return undefined; + } + + for (const moduleName of ['node:module', 'module']) { + try { + const nodeModule = Reflect.apply( + getBuiltinModule as (...args: unknown[]) => unknown, + processLike, + [moduleName] + ) as Record | undefined; + const sourceMapCtor = nodeModule && Reflect.get(nodeModule, 'SourceMap'); + + if (typeof sourceMapCtor === 'function') { + nodeSourceMapCtor = sourceMapCtor as NodeSourceMapConstructor; + return nodeSourceMapCtor; + } + } catch { + // ignore and try next candidate + } + } + + return undefined; +} + +function sourceCandidates(source: string): string[] { + const normalized = cleanUrl(source); + return normalized === source ? [source] : [source, normalized]; +} + +function parseSourceMap(raw: string): SourceMapLike | undefined { + if (typeof raw !== 'string' || raw.length === 0) { + return undefined; + } + + const SourceMapCtor = resolveNodeSourceMapCtor(); + + if (!SourceMapCtor) { + return undefined; + } + + try { + const payload = JSON.parse(raw) as unknown; + return new SourceMapCtor(payload); + } catch { + return undefined; + } +} + +function lookupSourceMap(source: string): SourceMapLike | undefined { + const candidates = sourceCandidates(source); + + for (const candidate of candidates) { + const entries = globalCandidateEntries.get(candidate); + const mapped = entries?.[entries.length - 1]?.map; + + if (mapped) { + return mapped; + } + } + + const hookEntries = [...globalHookStores.entries()].reverse(); + for (const [, entry] of hookEntries) { + for (const candidate of candidates) { + const resolved = resolveHookSourceCandidate(entry.hooks, candidate); + for (const hookCandidate of sourceCandidates(resolved)) { + const fromHook = resolveHookSourceMap(entry, hookCandidate); + if (fromHook) { + return fromHook; + } + } + } + } + + return undefined; +} + +function resolveHookSourceCandidate( + hooks: RunnerSourceMapHooks, + source: string +): string { + if (!hooks.retrieveFile) { + return source; + } + + try { + const resolved = hooks.retrieveFile(source); + if (typeof resolved === 'string' && resolved.length > 0) { + return resolved; + } + } catch { + // keep best-effort behavior + } + + return source; +} + +function resolveHookSourceMap( + entry: HookStoreEntry, + source: string +): SourceMapLike | undefined { + if (!entry.hooks.retrieveSourceMap) { + return undefined; + } + + if (entry.parsedCache.has(source)) { + return entry.parsedCache.get(source) ?? undefined; + } + + let raw: string | null | undefined; + try { + raw = entry.hooks.retrieveSourceMap(source); + } catch { + entry.parsedCache.set(source, null); + return undefined; + } + + if (typeof raw !== 'string' || raw.length === 0) { + entry.parsedCache.set(source, null); + return undefined; + } + + const parsed = parseSourceMap(raw) ?? null; + entry.parsedCache.set(source, parsed); + return parsed ?? undefined; +} + +function upsertGlobalCandidate( + storeId: number, + candidate: string, + map: SourceMapLike +): void { + const entries = globalCandidateEntries.get(candidate) ?? []; + const filtered = entries.filter((entry) => entry.storeId !== storeId); + filtered.push({ storeId, map }); + globalCandidateEntries.set(candidate, filtered); +} + +function removeGlobalCandidate(storeId: number, candidate: string): void { + const entries = globalCandidateEntries.get(candidate); + + if (!entries) { + return; + } + + const filtered = entries.filter((entry) => entry.storeId !== storeId); + + if (filtered.length === 0) { + globalCandidateEntries.delete(candidate); + return; + } + + globalCandidateEntries.set(candidate, filtered); +} + +function remapStackFrame(line: string): string { + const matched = line.match(STACK_FRAME_PATTERN); + + if (!matched) { + return line; + } + + const [, prefix, source, lineNumber, columnNumber, suffix] = matched; + const sourceMap = lookupSourceMap(source); + + if (!sourceMap) { + return line; + } + + const lineOffset = Number(lineNumber) - 1; + const columnOffset = Number(columnNumber) - 1; + + if (lineOffset < 0 || columnOffset < 0) { + return line; + } + + const entry = sourceMap.findEntry(lineOffset, columnOffset); + + if (!entry || !entry.originalSource) { + return line; + } + + const mappedLine = (entry.originalLine ?? 0) + 1; + const mappedColumn = (entry.originalColumn ?? 0) + 1; + return `${prefix}${entry.originalSource}:${mappedLine}:${mappedColumn}${suffix}`; +} + +function remapStackTrace(stack: string): string { + return stack + .split('\n') + .map((line, index) => (index === 0 ? line : remapStackFrame(line))) + .join('\n'); +} + +function applyCustomStackFormatter( + error: Error, + remappedStack: string, + trace: NodeJS.CallSite[] +): unknown { + const hookEntries = [...globalHookStores.entries()].reverse(); + for (const [, entry] of hookEntries) { + const formatter = entry.hooks.formatStack; + if (!formatter) { + continue; + } + + try { + const formatted = formatter({ + error, + remappedStack, + trace + }); + if (formatted !== undefined) { + return formatted; + } + } catch { + // Keep default behavior when formatter throws. + } + } + + return remappedStack; +} + +function defaultFormatStackTrace( + error: Error, + trace: NodeJS.CallSite[] +): string { + const errorName = error.name || 'Error'; + const message = error.message || ''; + const header = message ? `${errorName}: ${message}` : errorName; + + if (!trace.length) { + return header; + } + + return `${header}\n${trace.map((callSite) => ` at ${callSite.toString()}`).join('\n')}`; +} + +function tryEnableNativeSourceMaps(): void { + const processLike = getProcessLike(); + + if (!processLike) { + return; + } + + const toggler = Reflect.get(processLike, 'setSourceMapsEnabled'); + + if (typeof toggler !== 'function') { + return; + } + + try { + const previous = Reflect.get(processLike, 'sourceMapsEnabled'); + + if (typeof previous === 'boolean') { + previousNativeSourceMapEnabled = previous; + } + + Reflect.apply(toggler as (enabled: boolean) => void, processLike, [true]); + touchedNativeSourceMapSwitch = true; + } catch { + touchedNativeSourceMapSwitch = false; + } +} + +function restoreNativeSourceMaps(): void { + if (!touchedNativeSourceMapSwitch) { + return; + } + + const processLike = getProcessLike(); + + if (!processLike) { + return; + } + + const toggler = Reflect.get(processLike, 'setSourceMapsEnabled'); + + if (typeof toggler !== 'function') { + return; + } + + if (typeof previousNativeSourceMapEnabled !== 'boolean') { + return; + } + + try { + Reflect.apply(toggler as (enabled: boolean) => void, processLike, [ + previousNativeSourceMapEnabled + ]); + } catch { + // best effort restore + } finally { + previousNativeSourceMapEnabled = undefined; + touchedNativeSourceMapSwitch = false; + } +} + +function ensureInstalled(useNative: boolean): boolean { + if (useNative) { + nativeRequestedCount++; + } + + if (installedCount === 0) { + previousPrepareStackTrace = Error.prepareStackTrace as + | StackTraceFormatter + | undefined; + if (useNative) { + tryEnableNativeSourceMaps(); + } + + try { + Error.prepareStackTrace = ( + error: Error, + trace: NodeJS.CallSite[] + ): unknown => { + const prepared = previousPrepareStackTrace + ? previousPrepareStackTrace(error, trace) + : defaultFormatStackTrace(error, trace); + + if (typeof prepared !== 'string') { + return prepared; + } + + const remapped = remapStackTrace(prepared); + return applyCustomStackFormatter(error, remapped, trace); + }; + } catch { + if (useNative && nativeRequestedCount > 0) { + nativeRequestedCount--; + } + restoreNativeSourceMaps(); + previousPrepareStackTrace = undefined; + return false; + } + } else if (useNative && nativeRequestedCount === 1) { + // A native-enabled interceptor is created after non-native ones. + tryEnableNativeSourceMaps(); + } + + installedCount++; + return true; +} + +function ensureUninstalled(useNative: boolean): void { + if (useNative && nativeRequestedCount > 0) { + nativeRequestedCount--; + } + + if (installedCount <= 0) { + return; + } + + installedCount--; + + if (installedCount > 0) { + if (nativeRequestedCount === 0) { + restoreNativeSourceMaps(); + } + return; + } + + nativeRequestedCount = 0; + + try { + Error.prepareStackTrace = previousPrepareStackTrace as + | ErrorConstructor['prepareStackTrace'] + | undefined; + } catch { + // Host can lock Error.prepareStackTrace. Keep shutdown best-effort. + } finally { + previousPrepareStackTrace = undefined; + restoreNativeSourceMaps(); + } +} + +const noopInterceptor: RunnerSourceMapInterceptor = { + register() {}, + unregister() {}, + clear() {}, + close() {} +}; + +export function createRunnerSourceMapInterceptor( + enabled = true, + useNative = true, + hooks?: RunnerSourceMapHooks +): RunnerSourceMapInterceptor { + if (!enabled) { + return noopInterceptor; + } + + if (!ensureInstalled(useNative)) { + return noopInterceptor; + } + + const storeId = ++nextStoreId; + const store = new Map(); + const hookEntry: HookStoreEntry | undefined = hooks + ? { + hooks, + parsedCache: new Map() + } + : undefined; + let closed = false; + + globalStores.set(storeId, store); + if (hookEntry) { + globalHookStores.set(storeId, hookEntry); + } + + return { + register(sourceId: string, sourceMap: string): void { + if (closed) { + return; + } + + const parsed = parseSourceMap(sourceMap); + + if (!parsed) { + return; + } + + for (const candidate of new Set(sourceCandidates(sourceId))) { + store.set(candidate, parsed); + upsertGlobalCandidate(storeId, candidate, parsed); + } + }, + unregister(sourceId: string): void { + if (closed) { + return; + } + + for (const candidate of sourceCandidates(sourceId)) { + if (store.delete(candidate)) { + removeGlobalCandidate(storeId, candidate); + } + } + hookEntry?.parsedCache.clear(); + }, + clear(): void { + if (closed) { + return; + } + + for (const candidate of store.keys()) { + removeGlobalCandidate(storeId, candidate); + } + store.clear(); + hookEntry?.parsedCache.clear(); + }, + close(): void { + if (closed) { + return; + } + + closed = true; + for (const candidate of store.keys()) { + removeGlobalCandidate(storeId, candidate); + } + store.clear(); + globalStores.delete(storeId); + globalHookStores.delete(storeId); + ensureUninstalled(useNative); + } + }; +} diff --git a/packages/core/src/module-runner/types.ts b/packages/core/src/module-runner/types.ts new file mode 100644 index 0000000000..0ffa20989e --- /dev/null +++ b/packages/core/src/module-runner/types.ts @@ -0,0 +1,431 @@ +import type { + EvaluatedModuleNode, + EvaluatedModules +} from './evaluatedModules.js'; + +export interface FarmRunnerImportMeta extends ImportMeta { + url: string; + env: Record; + [key: string]: unknown; +} + +export interface FarmRunnerContext { + __farm_ssr_exports__: Record; + __farm_ssr_import__: (id: string, options?: unknown) => Promise; + __farm_ssr_dynamic_import__: ( + id: string, + options?: unknown + ) => Promise; + __farm_ssr_export_all__: (obj: unknown) => void; + __farm_ssr_export_name__: (name: string, getter: () => unknown) => void; + __farm_ssr_import_meta__: FarmRunnerImportMeta; +} + +export interface ModuleEvaluator { + startOffset?: number; + runInlinedModule( + context: FarmRunnerContext, + code: string, + module: Readonly + ): Promise; + runExternalModule( + file: string, + type?: ExternalFetchResult['type'], + options?: unknown + ): Promise; +} + +export type ModuleRunnerEvaluatorType = 'node' | 'worker' | 'bun' | 'deno'; + +export interface FetchFunctionOptions { + cached?: boolean; + startOffset?: number; +} + +export interface RunnerResolveContext { + importer?: string; +} + +export interface RunnerResolveResult { + id: string; + url?: string; +} + +export type RunnerResolver = ( + request: string, + context?: RunnerResolveContext +) => RunnerResolveResult | Promise; + +export type RunnerExternalPolicyMode = 'externalize' | 'stub' | 'error'; + +export interface RunnerExternalPolicyContext { + requestId: string; + externalize: string; + type: 'module' | 'commonjs' | 'builtin' | 'network'; +} + +export interface RunnerExternalPolicyTargetOptions { + mode?: RunnerExternalPolicyMode; + stub?: + | unknown + | ((context: RunnerExternalPolicyContext) => unknown | Promise); + message?: + | string + | ((context: RunnerExternalPolicyContext) => string | Promise); +} + +export interface RunnerExternalPolicyOptions { + globals?: + | Record + | ((id: string) => unknown | undefined | Promise); + builtin?: RunnerExternalPolicyMode | RunnerExternalPolicyTargetOptions; + network?: RunnerExternalPolicyMode | RunnerExternalPolicyTargetOptions; + custom?: ( + context: RunnerExternalPolicyContext + ) => unknown | undefined | Promise; +} + +export interface RunnerCachePolicyOptions { + /** + * Max active module entries kept in runner cache. + * Older entries are invalidated in LRU order when exceeded. + */ + maxEntries?: number; + /** + * Time-to-live for cached module metadata. + * Expired entries are invalidated before fetch. + */ + ttlMs?: number; + /** + * Incremental GC sweep budget per cycle. + * Collected only from previously invalidated candidates. + * Default is 16. + */ + gcSweepPerCycle?: number; +} + +export type NonJsPolicyMode = 'stub' | 'externalize' | 'error'; + +export interface NonJsPolicyOptions { + style?: NonJsPolicyMode; + asset?: NonJsPolicyMode; + unknown?: NonJsPolicyMode; +} + +export interface CachedFetchResult { + cache: true; +} + +export interface ExternalFetchResult { + bailoutReason?: RunnerTransformBailoutReason; + externalize: string; + type: 'module' | 'commonjs' | 'builtin' | 'network'; +} + +export type RunnerTransformBailoutReason = + | 'unsupported-ts' + | 'import-mutation' + | 'unhandled-module-decl' + | 'not-script' + | 'empty-content'; + +export interface ExternalModuleResolveContext { + id: string; + type: ExternalFetchResult['type']; + evaluator: ModuleRunnerEvaluatorType; + reason?: 'import-failed' | 'unsupported-external' | 'missing-node-loader'; + error?: unknown; + importOptions?: unknown; +} + +export interface ExternalModuleResolveResult { + resolved: boolean; + module?: unknown; +} + +export type ExternalModuleResolver = ( + context: ExternalModuleResolveContext +) => ExternalModuleResolveResult | Promise; + +export interface InlinedFetchResult { + code: string; + file: string | null; + id: string; + url: string; + invalidate: boolean; + map?: string | null; +} + +export type FetchResult = + | CachedFetchResult + | ExternalFetchResult + | InlinedFetchResult; + +export type InvokeMethods = { + fetchModule: ( + id: string, + importer?: string, + options?: FetchFunctionOptions + ) => Promise; + getBuiltins: () => Promise; +}; + +export interface ModuleRunnerInvokeCompiler { + compiling: boolean; + waitForCompileFinish: () => Promise; + fetchModule: ( + id: string, + importer?: string, + options?: FetchFunctionOptions + ) => unknown; + resource: (name: string) => unknown; + resources: () => Record; +} + +export interface ModuleRunnerInvokeContext { + root: string; + publicPath: string; + moduleRunnerStamp: number; + compiler: ModuleRunnerInvokeCompiler | null | undefined; +} + +export type RunnerInvokePayload = { + id: 'send' | `send:${string}`; + name: keyof InvokeMethods; + data: unknown[]; +}; + +export type RunnerInvokeResponsePayload = { + id: 'response' | `response:${string}`; + name: keyof InvokeMethods; + data: { result: unknown } | { error: unknown }; +}; + +export interface RunnerHotUpdate { + type: 'js-update'; + path: string; + acceptedPath: string; + timestamp: number; +} + +export type RunnerHotPayload = + | { type: 'connected' } + | { type: 'update'; updates: RunnerHotUpdate[] } + | { type: 'full-reload'; path?: string } + | { type: 'custom'; event: string; data?: unknown } + | { + type: 'error'; + err: { + message: string; + stack?: string; + }; + } + | { type: 'prune'; paths: string[] }; + +export interface ModuleRunnerTransport { + connect?(handlers: { + onMessage: (payload: RunnerHotPayload) => void; + onDisconnection: () => void; + }): Promise | void; + disconnect?(): Promise | void; + invoke( + name: T, + data: Parameters + ): Promise>>; +} + +export interface RunnerSourceMapInterceptorOptions { + enable?: boolean; + native?: boolean; + hooks?: RunnerSourceMapHooks; +} + +export interface RunnerSourceMapHooks { + retrieveSourceMap?: (source: string) => string | null | undefined; + retrieveFile?: (source: string) => string | null | undefined; + formatStack?: (params: { + error: Error; + remappedStack: string; + trace: NodeJS.CallSite[]; + }) => unknown; +} + +export type ModuleRunnerDiagnosticsEvent = + | { + type: 'resolve:start'; + traceId: string; + request: string; + importer?: string; + timestamp: number; + } + | { + type: 'resolve:end'; + traceId: string; + request: string; + importer?: string; + resolvedId: string; + resolvedUrl: string; + durationMs: number; + timestamp: number; + } + | { + type: 'import:start'; + traceId: string; + request: string; + resolvedId: string; + timestamp: number; + } + | { + type: 'import:end'; + traceId: string; + request: string; + moduleId: string; + durationMs: number; + timestamp: number; + } + | { + type: 'import:error'; + traceId: string; + request: string; + resolvedId?: string; + error: unknown; + timestamp: number; + } + | { + type: 'fetch:start'; + traceId: string; + request: string; + importer?: string; + cached: boolean; + timestamp: number; + } + | { + type: 'fetch:end'; + traceId: string; + request: string; + importer?: string; + fromCache: boolean; + resultKind: 'cache' | 'external' | 'inlined'; + durationMs: number; + timestamp: number; + } + | { + type: 'fetch:error'; + traceId: string; + request: string; + importer?: string; + cached: boolean; + error: unknown; + timestamp: number; + } + | { + type: 'eval:start'; + traceId: string; + moduleId: string; + mode: 'external' | 'inlined'; + timestamp: number; + } + | { + type: 'eval:end'; + traceId: string; + moduleId: string; + mode: 'external' | 'inlined'; + durationMs: number; + timestamp: number; + } + | { + type: 'eval:error'; + traceId: string; + moduleId: string; + mode: 'external' | 'inlined'; + error: unknown; + timestamp: number; + } + | { + type: 'external:policy'; + traceId: string; + requestId: string; + externalize: string; + externalType: ExternalFetchResult['type']; + policy: 'globals' | 'custom' | 'builtin' | 'network'; + action: 'resolved' | 'stub' | 'error' | 'externalize'; + timestamp: number; + } + | { + type: 'hmr:update'; + updates: number; + timestamp: number; + } + | { + type: 'hmr:prune'; + paths: number; + timestamp: number; + } + | { + type: 'hmr:full-reload'; + path?: string; + timestamp: number; + } + | { + type: 'interop:wrap'; + traceId: string; + specifier: string; + importer: string; + kind: 'esm-namespace' | 'cjs-value'; + timestamp: number; + } + | { + type: 'interop:error'; + traceId: string; + specifier: string; + importer: string; + code: string; + message: string; + timestamp: number; + }; + +export interface ModuleRunnerDiagnosticsEmitter { + emit(event: ModuleRunnerDiagnosticsEvent): void; + subscribe( + listener: (event: ModuleRunnerDiagnosticsEvent) => void + ): () => void; + clear(): void; +} + +export interface FarmModuleRunnerOptions { + transport: ModuleRunnerTransport; + hmr?: boolean; + /** + * Fail fast when transform fallback happens (externalized with bailoutReason), + * instead of silently continuing via externalize path. + */ + strictTransform?: boolean; + evaluator?: ModuleRunnerEvaluatorType | ModuleEvaluator; + resolveExternalModule?: ExternalModuleResolver; + sourceMapInterceptor?: boolean | RunnerSourceMapInterceptorOptions; + createImportMeta?: ( + modulePath: string + ) => FarmRunnerImportMeta | Promise; + evaluatedModules?: EvaluatedModules; + resolver?: RunnerResolver; + nonJsPolicy?: NonJsPolicyOptions; + externalPolicy?: RunnerExternalPolicyOptions; + cachePolicy?: RunnerCachePolicyOptions; + diagnostics?: ModuleRunnerDiagnosticsEmitter | false; + /** + * Internal escape hatch for convergence migration. + * When true, runner skips built-in interop evaluator wrapping. + */ + disableInterop?: boolean; +} + +export type ResolvedFetchResult = (ExternalFetchResult | InlinedFetchResult) & { + id: string; + url: string; +}; + +export type ModuleRunnerInvokeHandlers = { + [K in keyof InvokeMethods]: ( + ...args: Parameters + ) => ReturnType; +}; diff --git a/packages/core/src/plugin/js/vite-plugin-adapter.spec.ts b/packages/core/src/plugin/js/vite-plugin-adapter.spec.ts new file mode 100644 index 0000000000..70e4dd0b6d --- /dev/null +++ b/packages/core/src/plugin/js/vite-plugin-adapter.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import { VitePluginAdapter } from './vite-plugin-adapter.js'; + +function createAdapterStub(params: { + modulesByFile: Record>; + handleHotUpdate?: (ctx: unknown) => Promise | void>; +}) { + const adapter = Object.create( + VitePluginAdapter.prototype + ) as VitePluginAdapter & { + _rawPlugin: { handleHotUpdate?: (ctx: unknown) => Promise }; + _viteDevServer: { + moduleGraph: { + getModulesByFile: ( + file: string + ) => Array<{ id: string; file?: string }>; + }; + }; + wrapExecutor: (executor: T) => T; + wrapRawPluginHook: (_name: string, hook: unknown) => unknown; + }; + + adapter.name = 'vite-plugin-adapter-test'; + adapter._rawPlugin = { + handleHotUpdate: params.handleHotUpdate + }; + adapter._viteDevServer = { + moduleGraph: { + getModulesByFile: vi.fn( + (file: string) => params.modulesByFile[file] ?? [] + ) + } + }; + adapter.wrapExecutor = (executor: T) => executor; + adapter.wrapRawPluginHook = (_name: string, hook: unknown) => hook; + + return adapter; +} + +describe('VitePluginAdapter updateModules', () => { + it('returns update tuples for vite handleHotUpdate results', async () => { + const styleModuleId = + '/src/pages/AboutPage.vue?vue&type=style&index=0&lang.less'; + const adapter = createAdapterStub({ + modulesByFile: { + '/src/pages/AboutPage.vue': [{ id: styleModuleId }] + }, + handleHotUpdate: async (ctx: { modules: Array<{ id: string }> }) => + ctx.modules + }); + + const hook = (adapter as any).viteHandleHotUpdateToFarmUpdateModules(); + const result = await hook.executor( + { + paths: [['/src/pages/AboutPage.vue', 'updated']] + }, + {} + ); + + expect(result).toEqual([[styleModuleId, 'updated']]); + }); + + it('falls back to moduleGraph modules and keeps highest-priority update type', async () => { + const styleModuleId = + '/src/pages/AboutPage.vue?vue&type=style&index=0&lang.less'; + const adapter = createAdapterStub({ + modulesByFile: { + '/src/pages/AboutPage.vue': [{ id: styleModuleId }], + '/src/pages/AboutPage.vue?removed': [{ id: styleModuleId }] + }, + handleHotUpdate: async () => undefined + }); + + const hook = (adapter as any).viteHandleHotUpdateToFarmUpdateModules(); + const result = await hook.executor( + { + paths: [ + ['/src/pages/AboutPage.vue', 'added'], + ['/src/pages/AboutPage.vue?removed', 'removed'] + ] + }, + {} + ); + + expect(result).toEqual([[styleModuleId, 'removed']]); + }); +}); diff --git a/packages/core/src/plugin/js/vite-plugin-adapter.ts b/packages/core/src/plugin/js/vite-plugin-adapter.ts index ac848889dd..5c209c80f0 100644 --- a/packages/core/src/plugin/js/vite-plugin-adapter.ts +++ b/packages/core/src/plugin/js/vite-plugin-adapter.ts @@ -669,9 +669,29 @@ export class VitePluginAdapter implements JsPlugin { moduleGraph.context = ctx; - const result = []; + const mergedResult = new Map(); + const updateTypePriority: Record = { + added: 1, + updated: 2, + removed: 3 + }; + const pushResult = (id: string, type: UpdateType) => { + if (!id) { + return; + } + + const normalizedId = revertNormalizePath(id); + const existingType = mergedResult.get(normalizedId); + + if ( + !existingType || + updateTypePriority[type] > updateTypePriority[existingType] + ) { + mergedResult.set(normalizedId, type); + } + }; - for (const [file, _] of paths) { + for (const [file, updateType] of paths) { const mods = moduleGraph.getModulesByFile( file ) as unknown as ModuleNode[]; @@ -684,8 +704,8 @@ export class VitePluginAdapter implements JsPlugin { (m) => ({ ...m, - id: normalizePath(m.id), - file: normalizePath(m.file) + id: isString(m.id) ? normalizePath(m.id) : m.id, + file: isString(m.file) ? normalizePath(m.file) : m.file }) as ModuleNode ), read: function (): string | Promise { @@ -697,13 +717,17 @@ export class VitePluginAdapter implements JsPlugin { const updateMods: ModuleNode[] = await hook?.(ctx); if (updateMods) { - result.push(...updateMods.map((mod) => mod.id)); + for (const mod of updateMods) { + pushResult(mod.id, updateType); + } } else { - result.push(...mods.map((mod) => mod.id)); + for (const mod of mods) { + pushResult(mod.id, updateType); + } } } - return [...new Set(result)].map((id) => revertNormalizePath(id)); + return [...mergedResult.entries()]; } ) }; diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 67d4c5b654..55a3ed89ed 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -18,6 +18,14 @@ import type { UserConfig } from '../config/types.js'; import { colors, handleLazyCompilation, resolveConfig } from '../index.js'; +import { FarmModuleRunner } from '../module-runner/runner.js'; +import { createServerModuleRunnerInvokeHandlers } from '../module-runner/serverInvoke.js'; +import { createServerModuleRunnerTransport } from '../module-runner/serverTransport.js'; +import type { + FarmModuleRunnerOptions, + InvokeMethods, + ModuleRunnerInvokeHandlers +} from '../module-runner/types.js'; import { getSortedPluginHooksBindThis } from '../plugin/index.js'; import type { CompilerUpdateItem, @@ -90,6 +98,9 @@ export class Server extends httpServer { terminateServerFn: (_: unknown, exitCode?: number) => Promise; postConfigureServerHooks: ((() => void) | void)[] = []; logger: Logger; + moduleRunnerStamp = 0; + moduleRunnerInvokeHandlers: Partial = {}; + moduleRunners = new Set(); /** * Creates an instance of Server. @@ -191,6 +202,9 @@ export class Server extends httpServer { // init websocket server await server.createWebSocketServer(); + // init module runner invoke handlers + server.#initializeModuleRunner(); + // invalidate vite handler server.#invalidateVite(); @@ -202,6 +216,13 @@ export class Server extends httpServer { // init middlewares server.#initializeMiddlewares(); + if (server.serverOptions.middlewareMode) { + // In middleware mode there is no standalone dev HTTP server, so compile + // eagerly here instead of relying on `listen()`. + await server.#startCompile(); + server.watcher?.watchExtraFiles?.(); + } + server.terminateServerFn = async (_: unknown, exitCode?: number) => { try { await server.close(); @@ -289,6 +310,8 @@ export class Server extends httpServer { }); const handleUpdateFinish = (updateResult: JsUpdateResult) => { + this.moduleRunnerStamp++; + const added = [ ...updateResult.added, ...updateResult.extraWatchResult.add @@ -387,7 +410,9 @@ export class Server extends httpServer { return; } await this.watcher.close(); - await newServer.listen(); + if (!newServer.serverOptions.middlewareMode) { + await newServer.listen(); + } return newServer; } @@ -396,12 +421,6 @@ export class Server extends httpServer { * @throws {Error} If the HTTP server is not created. */ async createWebSocketServer() { - if (!this.httpServer) { - throw new Error( - 'Websocket requires a http server. please check the server is created' - ); - } - this.ws = new WsServer(this); await this.ws.createWebSocketServer(); } @@ -411,7 +430,7 @@ export class Server extends httpServer { * @throws {Error} If the HTTP server is not created. */ createHmrEngine() { - if (!this.httpServer) { + if (!this.httpServer && !this.serverOptions.middlewareMode) { throw new Error( 'HmrEngine requires a http server. please check the server is be created' ); @@ -427,6 +446,9 @@ export class Server extends httpServer { */ async listen(): Promise { if (!this.httpServer) { + if (this.serverOptions.middlewareMode) { + return; + } this.logger.warn('HTTP server is not created yet'); return; } @@ -459,6 +481,43 @@ export class Server extends httpServer { } } + async createModuleRunner( + options: Omit = {} + ): Promise { + if (!this.compiler) { + await this.#startCompile(); + this.watcher?.watchExtraFiles?.(); + } + + const transport = createServerModuleRunnerTransport(this); + const runner = new FarmModuleRunner({ + ...options, + transport + }); + + this.moduleRunners.add(runner); + return runner; + } + + async invokeModuleRunner( + name: T, + data: Parameters + ): Promise>> { + const handler = this.moduleRunnerInvokeHandlers[name]; + + if (!handler) { + throw new Error( + `[farm module runner] Invoke method \"${String(name)}\" is not registered.` + ); + } + + return (await ( + handler as ( + ...args: Parameters + ) => ReturnType + )(...data)) as Awaited>; + } + /** * Get current compiler instance in the server * @returns { CompilerType } return current compiler, may be compiler or undefined @@ -506,6 +565,50 @@ export class Server extends httpServer { } } + #initializeModuleRunner() { + this.moduleRunnerInvokeHandlers = + createServerModuleRunnerInvokeHandlers(this); + + this.ws.on( + 'farm:invoke', + async ( + payload: { + id: 'send' | `send:${string}`; + name: keyof InvokeMethods; + data: unknown[]; + }, + client: { send: (event: string, payload: unknown) => void } + ) => { + const responseId = payload.id.replace('send', 'response') as + | 'response' + | `response:${string}`; + + try { + const result = await this.invokeModuleRunner( + payload.name, + payload.data as never + ); + client.send('farm:invoke', { + id: responseId, + name: payload.name, + data: { result } + }); + } catch (error) { + client.send('farm:invoke', { + id: responseId, + name: payload.name, + data: { + error: { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + } + } + }); + } + } + ); + } + /** * resolve and setting server options * @@ -659,6 +762,7 @@ export class Server extends httpServer { const duration = performance.now() - start; bootstrap(duration, this.compiler.config, hasCacheDir); + this.moduleRunnerStamp++; } /** @@ -746,11 +850,18 @@ export class Server extends httpServer { teardownSIGTERMListener(this.terminateServerFn); } + const closingModuleRunners = [...this.moduleRunners].map((runner) => + runner.close() + ); + await Promise.allSettled([ this.watcher.close(), this.ws.close(), - this.closeHttpServerFn() + this.closeHttpServerFn(), + ...closingModuleRunners ]); + + this.moduleRunners.clear(); this.resolvedUrls = null; } diff --git a/packages/core/src/server/ws.ts b/packages/core/src/server/ws.ts index bbd187e1b4..8366b4a52e 100644 --- a/packages/core/src/server/ws.ts +++ b/packages/core/src/server/ws.ts @@ -305,7 +305,7 @@ export class WsServer { * @param {string} event - The name of the event. * @param {Function} fn - The listener function. */ - on(event: string, fn: () => void) { + on(event: string, fn: (...args: any[]) => void) { if (wsServerEvents.includes(event)) { this.wss.on(event, fn); } else { @@ -321,7 +321,7 @@ export class WsServer { * @param {string} event - The name of the event. * @param {Function} fn - The listener function to remove. */ - off(event: string, fn: () => void) { + off(event: string, fn: (...args: any[]) => void) { if (wsServerEvents.includes(event)) { this.wss.off(event, fn); } else { diff --git a/packages/core/src/types/binding.ts b/packages/core/src/types/binding.ts index 0138181d00..b808676f12 100644 --- a/packages/core/src/types/binding.ts +++ b/packages/core/src/types/binding.ts @@ -12,6 +12,40 @@ export type CompilerUpdateItem = { type: UpdateType; }; +export interface JsFetchModuleOptions { + cached?: boolean; + startOffset?: number; +} + +export interface JsCachedFetchResult { + cache: true; +} + +export interface JsExternalizedFetchResult { + externalize: string; + type: 'module' | 'commonjs' | 'builtin' | 'network'; + bailoutReason?: + | 'unsupported-ts' + | 'import-mutation' + | 'unhandled-module-decl' + | 'not-script' + | 'empty-content'; +} + +export interface JsInlinedFetchResult { + code: string; + file: string | null; + id: string; + url: string; + invalidate: boolean; + map?: string | null; +} + +export type JsFetchModuleResult = + | JsCachedFetchResult + | JsExternalizedFetchResult + | JsInlinedFetchResult; + export type ResolveKind = | { entry: string } | LiteralUnion< diff --git a/packages/core/tests/module-runner.benchmark.spec.ts b/packages/core/tests/module-runner.benchmark.spec.ts new file mode 100644 index 0000000000..486fe48c84 --- /dev/null +++ b/packages/core/tests/module-runner.benchmark.spec.ts @@ -0,0 +1,176 @@ +import { performance } from 'node:perf_hooks'; +import { describe, expect, test } from 'vitest'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { + FetchFunctionOptions, + ModuleRunnerTransport +} from '../src/module-runner/types.js'; + +type BenchResult = { + name: string; + iterations: number; + totalMs: number; + avgMs: number; + opsPerSec: number; +}; + +function toBenchResult( + name: string, + iterations: number, + totalMs: number +): BenchResult { + const avgMs = totalMs / iterations; + const opsPerSec = iterations / (totalMs / 1000); + return { + name, + iterations, + totalMs: Number(totalMs.toFixed(3)), + avgMs: Number(avgMs.toFixed(4)), + opsPerSec: Number(opsPerSec.toFixed(2)) + }; +} + +async function measure( + name: string, + iterations: number, + fn: () => Promise +): Promise { + const start = performance.now(); + for (let i = 0; i < iterations; i++) { + await fn(); + } + const end = performance.now(); + return toBenchResult(name, iterations, end - start); +} + +describe('farm module runner benchmark', () => { + test.runIf(Boolean(process.env.FARM_BENCH))( + 'captures module-runner baseline metrics', + async () => { + let fetchCount = 0; + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + fetchCount++; + + if (options?.cached) { + return { cache: true }; + } + + if (id === '/entry.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + return { + code: `__farm_ssr_export_name__('value', () => ${JSON.stringify(id)});`, + file: id, + id, + url: id, + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + cachePolicy: { + maxEntries: 50, + gcSweepPerCycle: 16 + } + }); + + try { + const cold = await measure('cold_import_unique', 200, async () => { + const id = `/cold-${Math.random().toString(36).slice(2, 12)}.mjs`; + await runner.import(id); + }); + + await runner.import('/entry.mjs'); + const warm = await measure('warm_import_same', 2000, async () => { + await runner.import('/entry.mjs'); + }); + + let dedupeFetchCount = 0; + const dedupeTransport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (id !== '/dedupe.mjs') { + throw new Error(`unexpected id: ${String(id)}`); + } + + if (options?.cached) { + return { cache: true }; + } + + dedupeFetchCount++; + await new Promise((resolve) => setTimeout(resolve, 1)); + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: '/dedupe.mjs', + id: '/dedupe.mjs', + url: '/dedupe.mjs', + invalidate: false, + map: null + }; + } + }; + const dedupeRunner = new FarmModuleRunner({ + transport: dedupeTransport, + hmr: false + }); + + const dedupe = await measure('concurrent_dedupe_20x', 100, async () => { + await Promise.all( + Array.from({ length: 20 }, () => dedupeRunner.import('/dedupe.mjs')) + ); + dedupeRunner.clearCache(); + }); + + expect(dedupeFetchCount).toBe(100); + await dedupeRunner.close(); + + const gcChurn = await measure('gc_churn_200_unique', 200, async () => { + const id = `/gc-${Math.random().toString(36).slice(2, 12)}.mjs`; + await runner.import(id); + }); + + const results = [cold, warm, dedupe, gcChurn]; + console.log( + '[module-runner-bench]', + JSON.stringify( + { + timestamp: new Date().toISOString(), + node: process.version, + fetchCount, + results + }, + null, + 2 + ) + ); + } finally { + await runner.close(); + } + } + ); +}); diff --git a/packages/core/tests/module-runner.convergence.spec.ts b/packages/core/tests/module-runner.convergence.spec.ts new file mode 100644 index 0000000000..b56e3408a8 --- /dev/null +++ b/packages/core/tests/module-runner.convergence.spec.ts @@ -0,0 +1,770 @@ +import { describe, expect, test } from 'vitest'; +import { ModuleRunnerDiagnosticsBus } from '../src/module-runner/diagnostics.js'; +import { EvaluatedModules } from '../src/module-runner/evaluatedModules.js'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { + ModuleEvaluator, + ModuleRunnerTransport +} from '../src/module-runner/types.js'; + +describe('farm module runner convergence (main path)', () => { + test('dedupes concurrent fetchModule requests for same entry', async () => { + let fetchCount = 0; + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id !== '/entry.mjs') { + throw new Error(`unexpected id: ${String(id)}`); + } + + fetchCount++; + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + code: "__farm_ssr_export_name__('value', () => 42);", + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + const [a, b] = await Promise.all([ + runner.import<{ value: number }>('/entry.mjs'), + runner.import<{ value: number }>('/entry.mjs') + ]); + expect(a.value).toBe(42); + expect(b.value).toBe(42); + expect(fetchCount).toBe(1); + } finally { + await runner.close(); + } + }); + + test('invalidates cache by ttl and refetches module', async () => { + let value = 1; + const cachedFlags: boolean[] = []; + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, , options] = data as [ + string, + string | undefined, + { cached?: boolean } | undefined + ]; + expect(id).toBe('/entry.mjs'); + cachedFlags.push(Boolean(options?.cached)); + + if (options?.cached) { + return { cache: true }; + } + + return { + code: `const value = ${value++}; __farm_ssr_export_name__('value', () => value);`, + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + cachePolicy: { + ttlMs: 5 + } + }); + + try { + const first = await runner.import<{ value: number }>('/entry.mjs'); + expect(first.value).toBe(1); + await new Promise((resolve) => setTimeout(resolve, 20)); + const second = await runner.import<{ value: number }>('/entry.mjs'); + expect(second.value).toBe(2); + expect(cachedFlags).toEqual([false, false]); + } finally { + await runner.close(); + } + }); + + test('evicts least recently used cache entry when maxEntries is exceeded', async () => { + const cachedFlags: Record = { + '/a.mjs': [], + '/b.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, , options] = data as [ + string, + string | undefined, + { cached?: boolean } | undefined + ]; + + if (id !== '/a.mjs' && id !== '/b.mjs') { + throw new Error(`unexpected id: ${String(id)}`); + } + + cachedFlags[id].push(Boolean(options?.cached)); + + if (options?.cached) { + return { cache: true }; + } + + const value = id === '/a.mjs' ? 1 : 2; + return { + code: `const value = ${value}; __farm_ssr_export_name__('value', () => value);`, + file: id, + id, + url: id, + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + cachePolicy: { + maxEntries: 1 + } + }); + + try { + const a1 = await runner.import<{ value: number }>('/a.mjs'); + expect(a1.value).toBe(1); + + const b = await runner.import<{ value: number }>('/b.mjs'); + expect(b.value).toBe(2); + + const a2 = await runner.import<{ value: number }>('/a.mjs'); + expect(a2.value).toBe(1); + + expect(cachedFlags['/a.mjs']).toEqual([false, false]); + expect(cachedFlags['/b.mjs']).toEqual([false]); + } finally { + await runner.close(); + } + }); + + test('incremental gc prunes invalidated modules from graph maps', async () => { + const evaluatedModules = new EvaluatedModules(); + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, , options] = data as [ + string, + string | undefined, + { cached?: boolean } | undefined + ]; + + if (id !== '/a.mjs' && id !== '/b.mjs') { + throw new Error(`unexpected id: ${String(id)}`); + } + + if (options?.cached) { + return { cache: true }; + } + + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: id, + id, + url: id, + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluatedModules, + cachePolicy: { + maxEntries: 1, + gcSweepPerCycle: 1 + } + }); + + try { + await runner.import('/a.mjs'); + await runner.import('/b.mjs'); + + expect(evaluatedModules.getModuleById('/a.mjs')).toBeUndefined(); + expect(evaluatedModules.getModuleById('/b.mjs')).toBeDefined(); + } finally { + await runner.close(); + } + }); + + test('applies custom resolver for entry import', async () => { + const fetchIds: string[] = []; + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + fetchIds.push(id as string); + + if (id === '/src/entry.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: '/src/entry.mjs', + id: '/src/entry.mjs', + url: '/src/entry.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + resolver: async (request) => { + if (request === '/entry-alias') { + return { id: '/src/entry.mjs' }; + } + return { id: request }; + } + }); + + try { + const mod = await runner.import<{ value: number }>('/entry-alias'); + expect(mod.value).toBe(1); + expect(fetchIds).toEqual(['/src/entry.mjs']); + } finally { + await runner.close(); + } + }); + + test('applies custom resolver for nested dependencies', async () => { + const fetchIds: string[] = []; + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + fetchIds.push(id as string); + + if (id === '/src/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('@/dep.mjs');", + "__farm_ssr_export_name__('value', () => dep.value + 1);" + ].join('\n'), + file: '/src/entry.mjs', + id: '/src/entry.mjs', + url: '/src/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/src/dep.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: '/src/dep.mjs', + id: '/src/dep.mjs', + url: '/src/dep.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + resolver: async (request, context) => { + if (request === '@/dep.mjs') { + expect(context?.importer).toBe('/src/entry.mjs'); + return { id: '/src/dep.mjs' }; + } + return { id: request }; + } + }); + + try { + const mod = await runner.import<{ value: number }>('/src/entry.mjs'); + expect(mod.value).toBe(2); + expect(fetchIds).toEqual(['/src/entry.mjs', '/src/dep.mjs']); + } finally { + await runner.close(); + } + }); + + test('supports cjs interop on main path and throws structured named-missing error', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('/dep.cjs');", + "__farm_ssr_export_name__('defaultValue', () => dep.default.value);", + "__farm_ssr_export_name__('namedValue', () => dep.value);", + "__farm_ssr_export_name__('missing', () => dep.missing);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dep.cjs') { + return { + externalize: '/dep.cjs', + type: 'commonjs' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const customEvaluator: ModuleEvaluator = { + startOffset: 2, + async runExternalModule() { + return { value: 7 }; + }, + async runInlinedModule(context, code) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const AsyncFunction = async function () {} + .constructor as FunctionConstructor; + const init = new AsyncFunction( + '__farm_ssr_exports__', + '__farm_ssr_import_meta__', + '__farm_ssr_import__', + '__farm_ssr_dynamic_import__', + '__farm_ssr_export_all__', + '__farm_ssr_export_name__', + code + ); + + await init( + context.__farm_ssr_exports__, + context.__farm_ssr_import_meta__, + context.__farm_ssr_import__, + context.__farm_ssr_dynamic_import__, + context.__farm_ssr_export_all__, + context.__farm_ssr_export_name__ + ); + } + }; + + const diagnostics = new ModuleRunnerDiagnosticsBus(); + const events: string[] = []; + const off = diagnostics.subscribe((event) => events.push(event.type)); + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: customEvaluator, + diagnostics + }); + + try { + const mod = await runner.import<{ + defaultValue: number; + namedValue: number; + missing: unknown; + }>('/entry.mjs'); + expect(mod.defaultValue).toBe(7); + expect(mod.namedValue).toBe(7); + + const error = (() => { + try { + // trigger missing named getter + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + mod.missing; + return null; + } catch (e) { + return e as Error & { code?: string }; + } + })(); + + expect(error?.code).toBe('ERR_MISSING_NAMED_EXPORT'); + expect(events).toContain('interop:wrap'); + expect(events).toContain('interop:error'); + } finally { + off(); + await runner.close(); + } + }); + + test('supports nonJsPolicy on main path', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/src/entry.mjs') { + return { + code: [ + "const style = await __farm_ssr_import__('./style.css');", + "const logo = await __farm_ssr_import__('./logo.png');", + "__farm_ssr_export_name__('styleType', () => typeof style.default);", + "__farm_ssr_export_name__('logo', () => logo.default);" + ].join('\n'), + file: '/src/entry.mjs', + id: '/src/entry.mjs', + url: '/src/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/src/style.css') { + return { + externalize: 'file:///tmp/style.css?t=1', + type: 'module', + bailoutReason: 'not-script' + }; + } + + if (id === '/src/logo.png') { + return { + externalize: 'file:///tmp/logo.png?t=1', + type: 'module' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + const mod = await runner.import<{ styleType: string; logo: string }>( + '/src/entry.mjs' + ); + expect(mod.styleType).toBe('object'); + expect(mod.logo).toContain('file:///tmp/logo.png'); + } finally { + await runner.close(); + } + }); + + test('supports externalPolicy.globals before evaluator external loading', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const react = await __farm_ssr_import__('react');", + "__farm_ssr_export_name__('v', () => react.version);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'react') { + return { + externalize: 'react', + type: 'module' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + let evaluatorCalled = 0; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: { + async runInlinedModule(context, code) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const AsyncFunction = async function () {} + .constructor as FunctionConstructor; + const init = new AsyncFunction( + '__farm_ssr_exports__', + '__farm_ssr_import_meta__', + '__farm_ssr_import__', + '__farm_ssr_dynamic_import__', + '__farm_ssr_export_all__', + '__farm_ssr_export_name__', + code + ); + await init( + context.__farm_ssr_exports__, + context.__farm_ssr_import_meta__, + context.__farm_ssr_import__, + context.__farm_ssr_dynamic_import__, + context.__farm_ssr_export_all__, + context.__farm_ssr_export_name__ + ); + }, + async runExternalModule() { + evaluatorCalled++; + return { version: 'from-evaluator' }; + } + }, + externalPolicy: { + globals: { + react: { version: 'from-policy' } + } + } + }); + + try { + const mod = await runner.import<{ v: string }>('/entry.mjs'); + expect(mod.v).toBe('from-policy'); + expect(evaluatorCalled).toBe(0); + } finally { + await runner.close(); + } + }); + + test('supports externalPolicy.network stub before evaluator external loading', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const cdn = await __farm_ssr_import__('https://cdn.example.com/pkg.mjs');", + "__farm_ssr_export_name__('v', () => cdn.default.url);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'https://cdn.example.com/pkg.mjs') { + return { + externalize: 'https://cdn.example.com/pkg.mjs?t=1', + type: 'network' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + let evaluatorCalled = 0; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: { + async runInlinedModule(context, code) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const AsyncFunction = async function () {} + .constructor as FunctionConstructor; + const init = new AsyncFunction( + '__farm_ssr_exports__', + '__farm_ssr_import_meta__', + '__farm_ssr_import__', + '__farm_ssr_dynamic_import__', + '__farm_ssr_export_all__', + '__farm_ssr_export_name__', + code + ); + await init( + context.__farm_ssr_exports__, + context.__farm_ssr_import_meta__, + context.__farm_ssr_import__, + context.__farm_ssr_dynamic_import__, + context.__farm_ssr_export_all__, + context.__farm_ssr_export_name__ + ); + }, + async runExternalModule() { + evaluatorCalled++; + return { url: 'from-evaluator' }; + } + }, + externalPolicy: { + network: { + mode: 'stub', + stub: (context) => ({ url: context.externalize }) + } + } + }); + + try { + const mod = await runner.import<{ v: string }>('/entry.mjs'); + expect(mod.v).toBe('https://cdn.example.com/pkg.mjs?t=1'); + expect(evaluatorCalled).toBe(0); + } finally { + await runner.close(); + } + }); + + test('supports externalPolicy.builtin error mode', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const fs = await __farm_ssr_import__('node:fs');", + "__farm_ssr_export_name__('v', () => Boolean(fs));" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'node:fs') { + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + externalPolicy: { + builtin: { + mode: 'error', + message: (context) => + `blocked external ${context.type}:${context.requestId}` + } + } + }); + + try { + await expect(runner.import('/entry.mjs')).rejects.toThrow( + 'blocked external builtin:node:fs' + ); + } finally { + await runner.close(); + } + }); + + test('supports externalPolicy.custom for module externals', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('virtual:custom');", + "__farm_ssr_export_name__('v', () => dep.default.answer);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'virtual:custom') { + return { + externalize: 'virtual:custom', + type: 'module' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + let evaluatorCalled = 0; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: { + async runInlinedModule(context, code) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const AsyncFunction = async function () {} + .constructor as FunctionConstructor; + const init = new AsyncFunction( + '__farm_ssr_exports__', + '__farm_ssr_import_meta__', + '__farm_ssr_import__', + '__farm_ssr_dynamic_import__', + '__farm_ssr_export_all__', + '__farm_ssr_export_name__', + code + ); + await init( + context.__farm_ssr_exports__, + context.__farm_ssr_import_meta__, + context.__farm_ssr_import__, + context.__farm_ssr_dynamic_import__, + context.__farm_ssr_export_all__, + context.__farm_ssr_export_name__ + ); + }, + async runExternalModule() { + evaluatorCalled++; + return { answer: -1 }; + } + }, + externalPolicy: { + custom: (context) => { + if (context.requestId === 'virtual:custom') { + return { answer: 42 }; + } + return undefined; + } + } + }); + + try { + const mod = await runner.import<{ v: number }>('/entry.mjs'); + expect(mod.v).toBe(42); + expect(evaluatorCalled).toBe(0); + } finally { + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.diagnostics.spec.ts b/packages/core/tests/module-runner.diagnostics.spec.ts new file mode 100644 index 0000000000..afe9cbcd5b --- /dev/null +++ b/packages/core/tests/module-runner.diagnostics.spec.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from 'vitest'; +import { ModuleRunnerDiagnosticsBus } from '../src/module-runner/diagnostics.js'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { + FetchFunctionOptions, + ModuleRunnerDiagnosticsEvent, + ModuleRunnerTransport, + RunnerHotPayload +} from '../src/module-runner/types.js'; + +describe('farm module runner diagnostics', () => { + test('emits fetch/eval/hmr lifecycle events', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const initialized = new Set(); + const diagnostics = new ModuleRunnerDiagnosticsBus(); + const events: ModuleRunnerDiagnosticsEvent[] = []; + const off = diagnostics.subscribe((event) => events.push(event)); + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (options?.cached && initialized.has(id)) { + return { cache: true }; + } + initialized.add(id); + + if (id === '/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('/dep.mjs');", + "__farm_ssr_export_name__('value', () => dep.value + 1);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dep.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: '/dep.mjs', + id: '/dep.mjs', + url: '/dep.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: true, + diagnostics + }); + + try { + await runner.import('/entry.mjs'); + await runner.import('/entry.mjs'); + hmrMessageHandler?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/dep.mjs', + acceptedPath: '/dep.mjs', + timestamp: Date.now() + } + ] + }); + + const eventTypes = events.map((event) => event.type); + expect(eventTypes).toContain('fetch:start'); + expect(eventTypes).toContain('fetch:end'); + expect(eventTypes).toContain('eval:start'); + expect(eventTypes).toContain('eval:end'); + expect(eventTypes).toContain('hmr:update'); + } finally { + off(); + await runner.close(); + } + }); + + test('emits external policy diagnostics events for builtin stub mode', async () => { + const diagnostics = new ModuleRunnerDiagnosticsBus(); + const events: ModuleRunnerDiagnosticsEvent[] = []; + const off = diagnostics.subscribe((event) => events.push(event)); + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const fs = await __farm_ssr_import__('node:fs');", + "__farm_ssr_export_name__('ok', () => typeof fs === 'object');" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'node:fs') { + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + diagnostics, + externalPolicy: { + builtin: 'stub' + } + }); + + try { + const mod = await runner.import<{ ok: boolean }>('/entry.mjs'); + expect(mod.ok).toBe(true); + + const policyEvent = events.find( + (event) => event.type === 'external:policy' + ) as + | Extract + | undefined; + expect(policyEvent).toBeDefined(); + expect(policyEvent?.policy).toBe('builtin'); + expect(policyEvent?.action).toBe('stub'); + expect(policyEvent?.requestId).toBe('node:fs'); + } finally { + off(); + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.exports.spec.ts b/packages/core/tests/module-runner.exports.spec.ts new file mode 100644 index 0000000000..2163426327 --- /dev/null +++ b/packages/core/tests/module-runner.exports.spec.ts @@ -0,0 +1,278 @@ +import { describe, expect, test } from 'vitest'; +import { ModuleRunnerDiagnosticsBus } from '../src/module-runner/diagnostics.js'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { + ModuleEvaluator, + ModuleRunnerTransport +} from '../src/module-runner/types.js'; + +describe('module runner exports (main path)', () => { + test('loads inlined module exports', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: "__farm_ssr_export_name__('value', () => 42);", + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + const mod = await runner.import<{ value: number }>('/entry.mjs'); + expect(mod.value).toBe(42); + } finally { + await runner.close(); + } + }); + + test('throws structured error when default import targets module without default export', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('./named-only.mjs');", + "__farm_ssr_export_name__('value', () => dep.default);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/named-only.mjs') { + return { + code: "__farm_ssr_export_name__('named', () => 1);", + file: '/named-only.mjs', + id: '/named-only.mjs', + url: '/named-only.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + const mod = await runner.import<{ value: unknown }>('/entry.mjs'); + const error = (() => { + try { + // Accessing getter triggers default export read. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + mod.value; + return null; + } catch (e) { + return e as Error & { code?: string }; + } + })(); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('Missing default export'); + expect(error?.code).toBe('ERR_MISSING_DEFAULT_EXPORT'); + } finally { + await runner.close(); + } + }); + + test('bridges cjs default import to module.exports value', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('/dep.cjs');", + "__farm_ssr_export_name__('defaultValue', () => dep.default.value);", + "__farm_ssr_export_name__('namedValue', () => dep.value);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dep.cjs') { + return { + externalize: '/dep.cjs', + type: 'commonjs' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const customEvaluator: ModuleEvaluator = { + startOffset: 2, + async runExternalModule(file, type) { + expect(file).toBe('/dep.cjs'); + expect(type).toBe('commonjs'); + return { value: 7 }; + }, + async runInlinedModule(context, code) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const AsyncFunction = async function () {} + .constructor as FunctionConstructor; + const init = new AsyncFunction( + '__farm_ssr_exports__', + '__farm_ssr_import_meta__', + '__farm_ssr_import__', + '__farm_ssr_dynamic_import__', + '__farm_ssr_export_all__', + '__farm_ssr_export_name__', + code + ); + + await init( + context.__farm_ssr_exports__, + context.__farm_ssr_import_meta__, + context.__farm_ssr_import__, + context.__farm_ssr_dynamic_import__, + context.__farm_ssr_export_all__, + context.__farm_ssr_export_name__ + ); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: customEvaluator + }); + + try { + const mod = await runner.import<{ + defaultValue: number; + namedValue: number; + }>('/entry.mjs'); + expect(mod.defaultValue).toBe(7); + expect(mod.namedValue).toBe(7); + } finally { + await runner.close(); + } + }); + + test('throws structured error when cjs named export is missing', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('/dep.cjs');", + "__farm_ssr_export_name__('missing', () => dep.missing);" + ].join('\n'), + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dep.cjs') { + return { + externalize: '/dep.cjs', + type: 'commonjs' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const customEvaluator: ModuleEvaluator = { + startOffset: 2, + async runExternalModule() { + return { value: 7 }; + }, + async runInlinedModule(context, code) { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const AsyncFunction = async function () {} + .constructor as FunctionConstructor; + const init = new AsyncFunction( + '__farm_ssr_exports__', + '__farm_ssr_import_meta__', + '__farm_ssr_import__', + '__farm_ssr_dynamic_import__', + '__farm_ssr_export_all__', + '__farm_ssr_export_name__', + code + ); + + await init( + context.__farm_ssr_exports__, + context.__farm_ssr_import_meta__, + context.__farm_ssr_import__, + context.__farm_ssr_dynamic_import__, + context.__farm_ssr_export_all__, + context.__farm_ssr_export_name__ + ); + } + }; + + const diagnostics = new ModuleRunnerDiagnosticsBus(); + const events: string[] = []; + const off = diagnostics.subscribe((event) => { + events.push(event.type); + }); + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: customEvaluator, + diagnostics + }); + + try { + const mod = await runner.import<{ missing: unknown }>('/entry.mjs'); + const error = (() => { + try { + // trigger getter + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + mod.missing; + return null; + } catch (e) { + return e as Error & { code?: string }; + } + })(); + + expect(error).toBeInstanceOf(Error); + expect(error?.code).toBe('ERR_MISSING_NAMED_EXPORT'); + expect(error?.message).toContain('Missing named export "missing"'); + expect(events).toContain('interop:wrap'); + expect(events).toContain('interop:error'); + } finally { + off(); + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.hmr.spec.ts b/packages/core/tests/module-runner.hmr.spec.ts new file mode 100644 index 0000000000..fec5f91512 --- /dev/null +++ b/packages/core/tests/module-runner.hmr.spec.ts @@ -0,0 +1,500 @@ +import { describe, expect, test } from 'vitest'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { + FetchFunctionOptions, + ModuleRunnerTransport, + RunnerHotPayload +} from '../src/module-runner/types.js'; + +function inlinedModule(id: string, code: string) { + return { + code, + file: id, + id, + url: id, + invalidate: false, + map: null + }; +} + +describe('farm module runner hmr consistency', () => { + test('update invalidates path + acceptedPath while keeping unrelated module warm', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const initialized = new Set(); + const cachedFlagsById: Record = { + '/entry.mjs': [], + '/dep.mjs': [], + '/boundary.mjs': [], + '/keep.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + cachedFlagsById[id].push(Boolean(options?.cached)); + if (options?.cached && initialized.has(id)) { + return { cache: true }; + } + initialized.add(id); + + if (id === '/entry.mjs') { + return inlinedModule( + id, + [ + "const dep = await __farm_ssr_import__('/dep.mjs');", + "const keep = await __farm_ssr_import__('/keep.mjs');", + "__farm_ssr_export_name__('value', () => dep.value + keep.value);" + ].join('\n') + ); + } + + if (id === '/dep.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('value', () => 1);" + ); + } + + if (id === '/keep.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('value', () => 9);" + ); + } + + if (id === '/boundary.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('ok', () => true);" + ); + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + try { + await runner.import('/entry.mjs'); + await runner.import('/boundary.mjs'); + + hmrMessageHandler?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/dep.mjs', + acceptedPath: '/boundary.mjs', + timestamp: Date.now() + } + ] + }); + + expect( + runner.evaluatedModules.getModuleByUrl('/dep.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/entry.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/boundary.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/keep.mjs')?.meta + ).toBeDefined(); + + await runner.import('/keep.mjs'); + expect(cachedFlagsById['/keep.mjs']).toEqual([false, true]); + } finally { + await runner.close(); + } + }); + + test('prune invalidates targeted module and importer chain only', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const initialized = new Set(); + const cachedFlagsById: Record = { + '/entry.mjs': [], + '/a.mjs': [], + '/b.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + cachedFlagsById[id].push(Boolean(options?.cached)); + if (options?.cached && initialized.has(id)) { + return { cache: true }; + } + initialized.add(id); + + if (id === '/entry.mjs') { + return inlinedModule( + id, + [ + "const a = await __farm_ssr_import__('/a.mjs');", + "const b = await __farm_ssr_import__('/b.mjs');", + "__farm_ssr_export_name__('value', () => a.value + b.value);" + ].join('\n') + ); + } + + if (id === '/a.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('value', () => 1);" + ); + } + + if (id === '/b.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('value', () => 2);" + ); + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + try { + await runner.import('/entry.mjs'); + + hmrMessageHandler?.({ + type: 'prune', + paths: ['/a.mjs'] + }); + + expect( + runner.evaluatedModules.getModuleByUrl('/a.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/entry.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/b.mjs')?.meta + ).toBeDefined(); + + await runner.import('/b.mjs'); + expect(cachedFlagsById['/b.mjs']).toEqual([false, true]); + } finally { + await runner.close(); + } + }); + + test('full-reload clears graph maps and next load starts cold', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const initialized = new Set(); + const cachedFlagsById: Record = { + '/entry.mjs': [], + '/dep.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + cachedFlagsById[id].push(Boolean(options?.cached)); + if (options?.cached && initialized.has(id)) { + return { cache: true }; + } + initialized.add(id); + + if (id === '/entry.mjs') { + return inlinedModule( + id, + [ + "const dep = await __farm_ssr_import__('/dep.mjs');", + "__farm_ssr_export_name__('value', () => dep.value);" + ].join('\n') + ); + } + + if (id === '/dep.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('value', () => 3);" + ); + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + try { + await runner.import('/entry.mjs'); + expect(runner.evaluatedModules.idToModuleMap.size).toBeGreaterThan(0); + + hmrMessageHandler?.({ type: 'full-reload' }); + + expect(runner.evaluatedModules.idToModuleMap.size).toBe(0); + expect(runner.evaluatedModules.urlToModuleMap.size).toBe(0); + + await runner.import('/dep.mjs'); + expect(cachedFlagsById['/dep.mjs']).toEqual([false, false]); + } finally { + await runner.close(); + } + }); + + test('update invalidates circular + dynamic branch while keeping sibling dynamic branch warm', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const initialized = new Set(); + const cachedFlagsById: Record = { + '/entry.mjs': [], + '/a.mjs': [], + '/b.mjs': [], + '/c.mjs': [], + '/keep.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + cachedFlagsById[id].push(Boolean(options?.cached)); + if (options?.cached && initialized.has(id)) { + return { cache: true }; + } + initialized.add(id); + + if (id === '/entry.mjs') { + return inlinedModule( + id, + [ + "const a = await __farm_ssr_dynamic_import__('/a.mjs');", + "const keep = await __farm_ssr_dynamic_import__('/keep.mjs');", + "__farm_ssr_export_name__('value', () => a.value + keep.value);" + ].join('\n') + ); + } + + if (id === '/a.mjs') { + return inlinedModule( + id, + [ + "const b = await __farm_ssr_import__('/b.mjs');", + "__farm_ssr_export_name__('value', () => b.value + 1);" + ].join('\n') + ); + } + + if (id === '/b.mjs') { + return inlinedModule( + id, + [ + "const c = await __farm_ssr_dynamic_import__('/c.mjs');", + "__farm_ssr_export_name__('value', () => c.value + 1);" + ].join('\n') + ); + } + + if (id === '/c.mjs') { + return inlinedModule( + id, + [ + "const a = await __farm_ssr_import__('/a.mjs');", + "__farm_ssr_export_name__('touchA', () => Boolean(a));", + "__farm_ssr_export_name__('value', () => 1);" + ].join('\n') + ); + } + + if (id === '/keep.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('value', () => 9);" + ); + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + try { + const first = await runner.import<{ value: number }>('/entry.mjs'); + expect(first.value).toBe(12); + + hmrMessageHandler?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/c.mjs', + acceptedPath: '/c.mjs', + timestamp: Date.now() + } + ] + }); + + expect( + runner.evaluatedModules.getModuleByUrl('/c.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/b.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/a.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/entry.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/keep.mjs')?.meta + ).toBeDefined(); + + await runner.import('/keep.mjs'); + expect(cachedFlagsById['/keep.mjs']).toEqual([false, true]); + } finally { + await runner.close(); + } + }); + + test('prune invalidates circular dynamic branch and preserves sibling branch cache', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const initialized = new Set(); + const cachedFlagsById: Record = { + '/entry.mjs': [], + '/a.mjs': [], + '/b.mjs': [], + '/sibling.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + cachedFlagsById[id].push(Boolean(options?.cached)); + if (options?.cached && initialized.has(id)) { + return { cache: true }; + } + initialized.add(id); + + if (id === '/entry.mjs') { + return inlinedModule( + id, + [ + "const a = await __farm_ssr_dynamic_import__('/a.mjs');", + "const sibling = await __farm_ssr_dynamic_import__('/sibling.mjs');", + "__farm_ssr_export_name__('value', () => a.value + sibling.value);" + ].join('\n') + ); + } + + if (id === '/a.mjs') { + return inlinedModule( + id, + [ + "const b = await __farm_ssr_import__('/b.mjs');", + "__farm_ssr_export_name__('value', () => b.value + 1);" + ].join('\n') + ); + } + + if (id === '/b.mjs') { + return inlinedModule( + id, + [ + "const a = await __farm_ssr_import__('/a.mjs');", + "__farm_ssr_export_name__('touchA', () => Boolean(a));", + "__farm_ssr_export_name__('value', () => 1);" + ].join('\n') + ); + } + + if (id === '/sibling.mjs') { + return inlinedModule( + id, + "__farm_ssr_export_name__('value', () => 10);" + ); + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + try { + const first = await runner.import<{ value: number }>('/entry.mjs'); + expect(first.value).toBe(12); + + hmrMessageHandler?.({ + type: 'prune', + paths: ['/a.mjs'] + }); + + expect( + runner.evaluatedModules.getModuleByUrl('/a.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/b.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/entry.mjs')?.meta + ).toBeUndefined(); + expect( + runner.evaluatedModules.getModuleByUrl('/sibling.mjs')?.meta + ).toBeDefined(); + + await runner.import('/sibling.mjs'); + expect(cachedFlagsById['/sibling.mjs']).toEqual([false, true]); + } finally { + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.import-meta.spec.ts b/packages/core/tests/module-runner.import-meta.spec.ts new file mode 100644 index 0000000000..63becdaad7 --- /dev/null +++ b/packages/core/tests/module-runner.import-meta.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { ModuleRunnerTransport } from '../src/module-runner/types.js'; + +describe('module runner import.meta (main path)', () => { + test('supports import.meta.resolve', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/src/entry.mjs') { + return { + code: [ + "const resolved = await __farm_ssr_import_meta__.resolve('./dep.mjs');", + "__farm_ssr_export_name__('resolved', () => resolved);" + ].join('\n'), + file: '/src/entry.mjs', + id: '/src/entry.mjs', + url: '/src/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/src/dep.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: '/src/dep.mjs', + id: '/src/dep.mjs', + url: '/src/dep.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected module id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + const entry = await runner.import<{ resolved: string }>('/src/entry.mjs'); + expect(entry.resolved).toBe('/src/dep.mjs'); + } finally { + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.non-js.spec.ts b/packages/core/tests/module-runner.non-js.spec.ts new file mode 100644 index 0000000000..8d951f8113 --- /dev/null +++ b/packages/core/tests/module-runner.non-js.spec.ts @@ -0,0 +1,176 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, test } from 'vitest'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import { createServerModuleRunnerInvokeHandlers } from '../src/module-runner/serverInvoke.js'; +import type { ModuleRunnerTransport } from '../src/module-runner/types.js'; + +const tempDirs: string[] = []; + +async function createTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.allSettled( + tempDirs + .splice(0, tempDirs.length) + .map((dir) => fs.rm(dir, { recursive: true, force: true })) + ); +}); + +describe('module runner non-js policy (main path)', () => { + test('keeps vue style-query noop fallback behavior before B3', async () => { + const root = await createTempDir('farm-runner-v2-vue-style-'); + const vueFile = path.join(root, 'src/App.vue'); + await fs.mkdir(path.dirname(vueFile), { recursive: true }); + await fs.writeFile( + vueFile, + '\n' + ); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + externalize: pathToFileURL(vueFile).toString(), + type: 'module', + bailoutReason: 'not-script' + }), + resource: () => null, + resources: () => ({}) + } + } as never); + + const id = '/src/App.vue?vue&type=style&index=0&lang.css'; + const result = await handlers.fetchModule(id); + + expect(result).toMatchObject({ + id, + url: id, + map: null, + invalidate: false + }); + expect('code' in result ? result.code : '').toContain( + '__farm_ssr_style_noop__' + ); + }); + + test('stubs style and asset modules through nonJsPolicy defaults', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/src/entry.mjs') { + return { + code: [ + "const style = await __farm_ssr_import__('./style.css');", + "const logo = await __farm_ssr_import__('./logo.png');", + "__farm_ssr_export_name__('styleDefaultType', () => typeof style.default);", + "__farm_ssr_export_name__('logo', () => logo.default);" + ].join('\n'), + file: '/src/entry.mjs', + id: '/src/entry.mjs', + url: '/src/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/src/style.css') { + return { + externalize: 'file:///tmp/style.css?t=1', + type: 'module', + bailoutReason: 'not-script' + }; + } + + if (id === '/src/logo.png') { + return { + externalize: 'file:///tmp/logo.png?t=2', + type: 'module' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + const mod = await runner.import<{ + styleDefaultType: string; + logo: string; + }>('/src/entry.mjs'); + + expect(mod.styleDefaultType).toBe('object'); + expect(mod.logo).toContain('file:///tmp/logo.png'); + } finally { + await runner.close(); + } + }); + + test('supports nonJsPolicy=error for style modules', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + + if (id === '/entry.mjs') { + return { + code: "const style = await __farm_ssr_import__('./entry.css'); __farm_ssr_export_name__('style', () => style);", + file: '/entry.mjs', + id: '/entry.mjs', + url: '/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/entry.css') { + return { + externalize: 'file:///tmp/entry.css?t=3', + type: 'module', + bailoutReason: 'not-script' + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + nonJsPolicy: { + style: 'error' + } + }); + + try { + const error = await runner + .import('/entry.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('nonJsPolicy=error'); + expect(error?.message).toContain('/entry.css'); + } finally { + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.resolver.spec.ts b/packages/core/tests/module-runner.resolver.spec.ts new file mode 100644 index 0000000000..303c7b06f5 --- /dev/null +++ b/packages/core/tests/module-runner.resolver.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'vitest'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { ModuleRunnerTransport } from '../src/module-runner/types.js'; + +describe('module runner resolver (main path)', () => { + test('applies resolver for nested fetchModule requests', async () => { + const fetchIds: string[] = []; + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data; + fetchIds.push(id as string); + + if (id === '/src/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('@/dep.mjs');", + "__farm_ssr_export_name__('value', () => dep.value + 1);" + ].join('\n'), + file: '/src/entry.mjs', + id: '/src/entry.mjs', + url: '/src/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/src/dep.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 1);", + file: '/src/dep.mjs', + id: '/src/dep.mjs', + url: '/src/dep.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected id: ${String(id)}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + resolver: async (request) => { + if (request === '@/dep.mjs') { + return { id: '/src/dep.mjs' }; + } + return { id: request }; + } + }); + + try { + const mod = await runner.import<{ value: number }>('/src/entry.mjs'); + expect(mod.value).toBe(2); + expect(fetchIds).toEqual(['/src/entry.mjs', '/src/dep.mjs']); + } finally { + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.sourcemap.spec.ts b/packages/core/tests/module-runner.sourcemap.spec.ts new file mode 100644 index 0000000000..426371f26c --- /dev/null +++ b/packages/core/tests/module-runner.sourcemap.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'vitest'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import type { ModuleRunnerTransport } from '../src/module-runner/types.js'; + +describe('farm module runner source map hooks', () => { + test('retrieveSourceMap hook remaps stack without inlined map payload', async () => { + const generatedId = '/src/generated-hook.ts'; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'throw new Error("hook-remap")', + file: generatedId, + id: generatedId, + url: generatedId, + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + sourceMapInterceptor: { + native: false, + hooks: { + retrieveSourceMap(source) { + if (source !== generatedId) { + return undefined; + } + + return JSON.stringify({ + version: 3, + file: generatedId, + sources: ['/src/original-hook.ts'], + sourcesContent: ['throw new Error("hook-remap")'], + names: [], + mappings: 'AAAA' + }); + } + } + } + }); + + try { + const error = await runner + .import(generatedId) + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(String(error?.stack ?? '')).toContain('/src/original-hook.ts'); + } finally { + await runner.close(); + } + }); + + test('retrieveFile hook redirects source lookup before retrieveSourceMap', async () => { + const generatedId = 'virtual:generated-file-hook.ts'; + const redirectedId = '/src/redirected-file-hook.ts'; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'throw new Error("hook-file-remap")', + file: generatedId, + id: generatedId, + url: generatedId, + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + sourceMapInterceptor: { + native: false, + hooks: { + retrieveFile(source) { + if (source === generatedId) { + return redirectedId; + } + return source; + }, + retrieveSourceMap(source) { + if (source !== redirectedId) { + return undefined; + } + + return JSON.stringify({ + version: 3, + file: redirectedId, + sources: ['/src/original-file-hook.ts'], + sourcesContent: ['throw new Error("hook-file-remap")'], + names: [], + mappings: 'AAAA' + }); + } + } + } + }); + + try { + const error = await runner + .import(generatedId) + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(String(error?.stack ?? '')).toContain( + '/src/original-file-hook.ts' + ); + } finally { + await runner.close(); + } + }); + + test('formatStack hook can override remapped stack output', async () => { + const generatedId = '/src/generated-format-hook.ts'; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'throw new Error("hook-format")', + file: generatedId, + id: generatedId, + url: generatedId, + invalidate: false, + map: JSON.stringify({ + version: 3, + file: generatedId, + sources: ['/src/original-format-hook.ts'], + sourcesContent: ['throw new Error("hook-format")'], + names: [], + mappings: 'AAAA' + }) + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + sourceMapInterceptor: { + native: false, + hooks: { + formatStack({ remappedStack }) { + return `HOOK_FORMAT\n${remappedStack}`; + } + } + } + }); + + try { + const error = await runner + .import(generatedId) + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(String(error?.stack ?? '')).toContain('HOOK_FORMAT'); + expect(String(error?.stack ?? '')).toContain( + '/src/original-format-hook.ts' + ); + } finally { + await runner.close(); + } + }); +}); diff --git a/packages/core/tests/module-runner.spec.ts b/packages/core/tests/module-runner.spec.ts new file mode 100644 index 0000000000..4855506a66 --- /dev/null +++ b/packages/core/tests/module-runner.spec.ts @@ -0,0 +1,3871 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { createDefaultImportMeta } from '../src/module-runner/createImportMeta.js'; +import { detectHostEvaluatorType } from '../src/module-runner/evaluator.js'; +import { FarmModuleRunner } from '../src/module-runner/runner.js'; +import { + createModuleRunnerInvokeHandlers, + createServerModuleRunnerInvokeHandlers +} from '../src/module-runner/serverInvoke.js'; +import { createModuleRunnerTransportFromInvokeHandlers } from '../src/module-runner/serverTransport.js'; +import { createRunnerSourceMapInterceptor } from '../src/module-runner/sourceMapInterceptor.js'; +import type { + FetchFunctionOptions, + ModuleEvaluator, + ModuleRunnerTransport, + RunnerHotPayload +} from '../src/module-runner/types.js'; + +const tempDirs: string[] = []; + +async function createTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.allSettled( + tempDirs + .splice(0, tempDirs.length) + .map((dir) => fs.rm(dir, { recursive: true, force: true })) + ); +}); + +describe('farm module runner', () => { + test('detects evaluator runtime type from host globals', () => { + expect(detectHostEvaluatorType({})).toBe('node'); + expect(detectHostEvaluatorType({ Bun: {} })).toBe('bun'); + expect(detectHostEvaluatorType({ Deno: {} })).toBe('deno'); + expect( + detectHostEvaluatorType({ + process: { + getBuiltinModule(name: string) { + if (name === 'node:worker_threads') { + return { isMainThread: false }; + } + return undefined; + } + } + }) + ).toBe('worker'); + expect( + detectHostEvaluatorType({ + process: { + getBuiltinModule() { + return { isMainThread: true }; + } + } + }) + ).toBe('node'); + + class FakeWorkerGlobalScope {} + const fakeWorker = new FakeWorkerGlobalScope(); + expect( + detectHostEvaluatorType({ + WorkerGlobalScope: FakeWorkerGlobalScope, + self: fakeWorker + }) + ).toBe('worker'); + }); + + test('runner logs and stores transform bailout reason on externalized fallback', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin', + bailoutReason: 'unsupported-ts' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + await runner.import('/entry.ts'); + await runner.import('/entry.ts'); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('bailoutReason="unsupported-ts"') + ); + + const meta = runner.evaluatedModules.getModuleById('node:fs')?.meta; + expect( + meta && 'externalize' in meta ? meta.bailoutReason : undefined + ).toBe('unsupported-ts'); + } finally { + warn.mockRestore(); + await runner.close(); + } + }); + + test('transport adapter forwards hot payloads and handles disconnect', async () => { + const messages: RunnerHotPayload[] = []; + const unsubscribe = vi.fn(); + let subscriber: ((payload: RunnerHotPayload) => void) | null = null; + let disconnected = 0; + + const transport = createModuleRunnerTransportFromInvokeHandlers({ + invokeHandlers: { + fetchModule: async () => ({ + externalize: 'node:fs', + type: 'builtin' + }), + getBuiltins: async () => ['node:fs'] + }, + hotBus: { + subscribe(cb) { + subscriber = cb; + return () => { + subscriber = null; + unsubscribe(); + }; + } + } + }); + + transport.connect?.({ + onMessage(payload) { + messages.push(payload); + }, + onDisconnection() { + disconnected++; + } + }); + + expect(messages[0]).toEqual({ type: 'connected' }); + + subscriber?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/entry.ts', + acceptedPath: '/entry.ts', + timestamp: 1 + } + ] + }); + + expect(messages[1]).toEqual({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/entry.ts', + acceptedPath: '/entry.ts', + timestamp: 1 + } + ] + }); + + transport.disconnect?.(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + expect(disconnected).toBe(1); + expect(subscriber).toBeNull(); + }); + + test('transport adapter delegates invoke to invoke handlers', async () => { + const fetchModule = vi.fn(async () => ({ + externalize: 'node:fs', + type: 'builtin' as const + })); + + const getBuiltins = vi.fn(async () => ['node:fs']); + + const transport = createModuleRunnerTransportFromInvokeHandlers({ + invokeHandlers: { + fetchModule, + getBuiltins + }, + hotBus: { + subscribe() { + return () => undefined; + } + } + }); + + await expect( + transport.invoke('fetchModule', [ + '/entry.ts', + undefined, + { cached: true } + ]) + ).resolves.toEqual({ + externalize: 'node:fs', + type: 'builtin' + }); + + await expect(transport.invoke('getBuiltins', [])).resolves.toEqual([ + 'node:fs' + ]); + + expect(fetchModule).toHaveBeenCalledWith('/entry.ts', undefined, { + cached: true + }); + expect(getBuiltins).toHaveBeenCalledWith(); + }); + + test('runner throws on transform bailout externalize when strictTransform is enabled', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin', + bailoutReason: 'unsupported-ts' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + strictTransform: true + }); + + try { + const error = await runner + .import('/entry.ts') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('strictTransform=true'); + expect(error?.message).toContain('bailoutReason="unsupported-ts"'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + await runner.close(); + } + }); + + test('strictTransform does not block normal externalize without bailoutReason', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + strictTransform: true + }); + + try { + const result = await runner.import>('/entry.ts'); + expect(result).toBeTypeOf('object'); + } finally { + await runner.close(); + } + }); + + test('runner resolves relative imports from inlined bare specifier using fetched module url', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (id === '/entry.ts') { + return { + code: [ + "const pkg = await __farm_ssr_import__('virtual-pkg');", + "__farm_ssr_export_name__('value', () => pkg.value);" + ].join('\n'), + file: '/entry.ts', + id: '/entry.ts', + url: '/entry.ts', + invalidate: false, + map: null + }; + } + + if (id === 'virtual-pkg') { + return { + code: [ + "const dep = await __farm_ssr_import__('./dep.mjs');", + "__farm_ssr_export_name__('value', () => dep.value);" + ].join('\n'), + file: '/node_modules/virtual-pkg/index.mjs', + id: 'virtual-pkg', + url: '/node_modules/virtual-pkg/index.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/node_modules/virtual-pkg/dep.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 42);", + file: '/node_modules/virtual-pkg/dep.mjs', + id: '/node_modules/virtual-pkg/dep.mjs', + url: '/node_modules/virtual-pkg/dep.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false + }); + + try { + const result = await runner.import<{ value: number }>('/entry.ts'); + expect(result.value).toBe(42); + } finally { + await runner.close(); + } + }); + + test('worker evaluator rejects commonjs external module', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/dep.cjs', + type: 'commonjs' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker' + }); + + const error = await runner + .import('/entry.cjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('not supported in worker evaluator'); + await runner.close(); + }); + + test('worker evaluator can resolve commonjs via resolveExternalModule hook', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/worker-dep.cjs', + type: 'commonjs' + }; + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 12 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-worker-cjs-hook.mjs' + ); + expect(result.value).toBe(12); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: '/tmp/worker-dep.cjs', + type: 'commonjs', + evaluator: 'worker', + reason: 'unsupported-external' + }); + expect(calls[0].error).toBeInstanceOf(Error); + expect((calls[0].error as Error).message).toContain( + 'commonjs external module is not supported' + ); + await runner.close(); + }); + + test('worker evaluator rejects builtin external module', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker' + }); + + const error = await runner + .import('/entry-builtin.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('not supported in worker evaluator'); + await runner.close(); + }); + + test('worker evaluator can resolve builtin via resolveExternalModule hook', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 11 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-worker-hook.mjs' + ); + expect(result.value).toBe(11); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'node:fs', + type: 'builtin', + evaluator: 'worker', + reason: 'unsupported-external' + }); + expect(calls[0].error).toBeInstanceOf(Error); + expect((calls[0].error as Error).message).toContain( + 'builtin external module is not supported' + ); + await runner.close(); + }); + + test('worker evaluator forwards import options to resolveExternalModule hook on unsupported builtin', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (id === '/entry-worker-builtin-options-hook.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('node:fs', { with: { type: 'json' } });", + "__farm_ssr_export_name__('value', () => dep.value);" + ].join('\n'), + file: '/entry-worker-builtin-options-hook.mjs', + id: '/entry-worker-builtin-options-hook.mjs', + url: '/entry-worker-builtin-options-hook.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'node:fs') { + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + importOptions?: unknown; + }> = []; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 18 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-worker-builtin-options-hook.mjs' + ); + expect(result.value).toBe(18); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'node:fs', + type: 'builtin', + evaluator: 'worker', + reason: 'unsupported-external', + importOptions: { with: { type: 'json' } } + }); + expect(calls[0].importOptions).toEqual({ with: { type: 'json' } }); + await runner.close(); + }); + + test('worker evaluator reports resolveExternalModule hook failure with context', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async () => { + throw new Error('hook-boom'); + } + }); + + const error = await runner + .import('/entry-worker-hook-error.mjs') + .then(() => null) + .catch((e) => e as Error & { cause?: unknown }); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('resolveExternalModule failed'); + expect(error?.message).toContain('node:fs'); + expect(error?.message).toContain('type=builtin'); + expect(error?.message).toContain('evaluator=worker'); + expect(error?.message).toContain('reason=unsupported-external'); + expect(error?.cause).toBeInstanceOf(Error); + expect((error?.cause as Error)?.message).toContain('hook-boom'); + await runner.close(); + }); + + test('worker evaluator reports invalid resolveExternalModule return type with context', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async () => + 'invalid-result' as unknown as { + resolved: boolean; + module?: unknown; + } + }); + + const error = await runner + .import('/entry-worker-hook-invalid-return.mjs') + .then(() => null) + .catch((e) => e as Error & { cause?: unknown }); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('resolveExternalModule failed'); + expect(error?.message).toContain('node:fs'); + expect(error?.cause).toBeInstanceOf(Error); + expect((error?.cause as Error)?.message).toContain( + 'must return an object with boolean "resolved"' + ); + await runner.close(); + }); + + test('worker evaluator reports invalid resolveExternalModule shape with context', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/worker-shape.cjs', + type: 'commonjs' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async () => + ({ module: { value: 1 } }) as unknown as { + resolved: boolean; + module?: unknown; + } + }); + + const error = await runner + .import('/entry-worker-hook-invalid-shape.mjs') + .then(() => null) + .catch((e) => e as Error & { cause?: unknown }); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('resolveExternalModule failed'); + expect(error?.message).toContain('/tmp/worker-shape.cjs'); + expect(error?.cause).toBeInstanceOf(Error); + expect((error?.cause as Error)?.message).toContain( + 'must return an object with boolean "resolved"' + ); + await runner.close(); + }); + + test('worker evaluator treats undefined resolveExternalModule result as unresolved', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async () => undefined + }); + + const error = await runner + .import('/entry-worker-hook-undefined.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain( + 'builtin external module is not supported' + ); + await runner.close(); + }); + + test('worker evaluator keeps import failure when resolveExternalModule returns null', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'unsupported+scheme://null-hook-entry', + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async () => + null as unknown as { + resolved: boolean; + module?: unknown; + } + }); + + const error = await runner + .import('/entry-worker-hook-null.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('unsupported+scheme://null-hook-entry'); + await runner.close(); + }); + + test('worker evaluator can resolve module externalize when native import fails', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'unsupported+scheme://virtual-entry', + type: 'module' + }; + } + }; + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 13 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-worker-module-hook.mjs' + ); + expect(result.value).toBe(13); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'unsupported+scheme://virtual-entry', + type: 'module', + evaluator: 'worker', + reason: 'import-failed' + }); + expect(calls[0].error).toBeDefined(); + await runner.close(); + }); + + test('worker evaluator forwards import options to resolveExternalModule hook on import-fail', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (id === '/entry-worker-import-options-hook.mjs') { + return { + code: [ + "const dep = await __farm_ssr_dynamic_import__('unsupported+scheme://worker-options-entry', { with: { type: 'json' } });", + "__farm_ssr_export_name__('value', () => dep.value);" + ].join('\n'), + file: '/entry-worker-import-options-hook.mjs', + id: '/entry-worker-import-options-hook.mjs', + url: '/entry-worker-import-options-hook.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'unsupported+scheme://worker-options-entry') { + return { + externalize: id, + type: 'module' + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + importOptions?: unknown; + }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 27 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-worker-import-options-hook.mjs' + ); + expect(result.value).toBe(27); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'unsupported+scheme://worker-options-entry', + type: 'module', + evaluator: 'worker', + reason: 'import-failed', + importOptions: { with: { type: 'json' } } + }); + expect(calls[0].importOptions).toEqual({ with: { type: 'json' } }); + await runner.close(); + }); + + test('worker evaluator can resolve network externalize when native import fails', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'https://example.invalid/farm-worker-network.mjs', + type: 'network' + }; + } + }; + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 14 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-worker-network-hook.mjs' + ); + expect(result.value).toBe(14); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'https://example.invalid/farm-worker-network.mjs', + type: 'network', + evaluator: 'worker', + reason: 'import-failed' + }); + expect(calls[0].error).toBeDefined(); + await runner.close(); + }); + + test('worker evaluator keeps native import runtime error without hook fallback', async () => { + const dir = await createTempDir('farm-runner-worker-runtime-error-'); + const entry = path.join(dir, 'entry.mjs'); + await fs.writeFile( + entry, + "throw new Error('external-module-runtime-boom');\n" + ); + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: `${pathToFileURL(entry).toString()}?runtime=boom`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 99 } + }; + } + }); + + const error = await runner + .import('/entry-worker-runtime-error.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('external-module-runtime-boom'); + expect(calls).toEqual([]); + await runner.close(); + }); + + test('worker evaluator does not fallback hook for runtime error with loader-like message', async () => { + const dir = await createTempDir( + 'farm-runner-worker-runtime-loader-like-error-' + ); + const entry = path.join(dir, 'entry.mjs'); + await fs.writeFile( + entry, + "throw new Error('Cannot find module fake-runtime-value');\n" + ); + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: `${pathToFileURL(entry).toString()}?runtime=loader-like`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 1001 } + }; + } + }); + + const error = await runner + .import('/entry-worker-runtime-loader-like-error.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('Cannot find module fake-runtime-value'); + expect(calls).toEqual([]); + await runner.close(); + }); + + test('worker evaluator does not fallback hook for runtime TypeError with loader-like message', async () => { + const dir = await createTempDir( + 'farm-runner-worker-runtime-loader-like-typeerror-' + ); + const entry = path.join(dir, 'entry.mjs'); + await fs.writeFile( + entry, + "throw new TypeError('Cannot find module fake-runtime-typeerror');\n" + ); + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: `${pathToFileURL(entry).toString()}?runtime=loader-like-typeerror`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 1002 } + }; + } + }); + + const error = await runner + .import('/entry-worker-runtime-loader-like-typeerror.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(TypeError); + expect(error?.message).toContain( + 'Cannot find module fake-runtime-typeerror' + ); + expect(calls).toEqual([]); + await runner.close(); + }); + + test('bun evaluator rejects commonjs external module in baseline mode', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/dep.cjs', + type: 'commonjs' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun' + }); + + const error = await runner + .import('/entry-bun.cjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('not supported in bun evaluator'); + await runner.close(); + }); + + test('bun evaluator can resolve commonjs via resolveExternalModule hook', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/dep.cjs', + type: 'commonjs' + }; + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 22 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-bun-hook.mjs' + ); + expect(result.value).toBe(22); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: '/tmp/dep.cjs', + type: 'commonjs', + evaluator: 'bun', + reason: 'unsupported-external' + }); + expect(calls[0].error).toBeInstanceOf(Error); + expect((calls[0].error as Error).message).toContain( + 'commonjs external module is not supported' + ); + await runner.close(); + }); + + test('bun evaluator can resolve module externalize when native import fails', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'unsupported+scheme://bun-virtual-entry', + type: 'module' + }; + } + }; + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 23 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-bun-module-hook.mjs' + ); + expect(result.value).toBe(23); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'unsupported+scheme://bun-virtual-entry', + type: 'module', + evaluator: 'bun', + reason: 'import-failed' + }); + expect(calls[0].error).toBeDefined(); + await runner.close(); + }); + + test('bun evaluator forwards import options to resolveExternalModule hook on import-fail', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (id === '/entry-bun-import-options-hook.mjs') { + return { + code: [ + "const dep = await __farm_ssr_dynamic_import__('unsupported+scheme://bun-options-entry', { with: { type: 'json' } });", + "__farm_ssr_export_name__('value', () => dep.value);" + ].join('\n'), + file: '/entry-bun-import-options-hook.mjs', + id: '/entry-bun-import-options-hook.mjs', + url: '/entry-bun-import-options-hook.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'unsupported+scheme://bun-options-entry') { + return { + externalize: id, + type: 'module' + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + importOptions?: unknown; + }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 28 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-bun-import-options-hook.mjs' + ); + expect(result.value).toBe(28); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'unsupported+scheme://bun-options-entry', + type: 'module', + evaluator: 'bun', + reason: 'import-failed', + importOptions: { with: { type: 'json' } } + }); + expect(calls[0].importOptions).toEqual({ with: { type: 'json' } }); + await runner.close(); + }); + + test('bun evaluator can resolve network externalize when native import fails', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'https://example.invalid/farm-bun-network.mjs', + type: 'network' + }; + } + }; + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 25 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-bun-network-hook.mjs' + ); + expect(result.value).toBe(25); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'https://example.invalid/farm-bun-network.mjs', + type: 'network', + evaluator: 'bun', + reason: 'import-failed' + }); + expect(calls[0].error).toBeDefined(); + await runner.close(); + }); + + test('bun evaluator keeps native import runtime error without hook fallback', async () => { + const dir = await createTempDir('farm-runner-bun-runtime-error-'); + const entry = path.join(dir, 'entry.mjs'); + await fs.writeFile( + entry, + "throw new Error('bun-external-runtime-boom');\n" + ); + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: `${pathToFileURL(entry).toString()}?runtime=boom`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 199 } + }; + } + }); + + const error = await runner + .import('/entry-bun-runtime-error.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('bun-external-runtime-boom'); + expect(calls).toEqual([]); + await runner.close(); + }); + + test('bun evaluator rejects builtin external module in baseline mode', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun' + }); + + const error = await runner + .import('/entry-bun-builtin.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('not supported in bun evaluator'); + await runner.close(); + }); + + test('bun evaluator can resolve builtin via resolveExternalModule hook', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:fs', + type: 'builtin' + }; + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'bun', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 24 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-bun-builtin-hook.mjs' + ); + expect(result.value).toBe(24); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'node:fs', + type: 'builtin', + evaluator: 'bun', + reason: 'unsupported-external' + }); + expect(calls[0].error).toBeInstanceOf(Error); + expect((calls[0].error as Error).message).toContain( + 'builtin external module is not supported' + ); + await runner.close(); + }); + + test('deno evaluator rejects builtin external module in baseline mode', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'node:path', + type: 'builtin' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'deno' + }); + + const error = await runner + .import('/entry-deno-builtin.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('not supported in deno evaluator'); + await runner.close(); + }); + + test('deno evaluator rejects commonjs external module in baseline mode', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/deno-dep.cjs', + type: 'commonjs' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'deno' + }); + + const error = await runner + .import('/entry-deno-cjs.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('not supported in deno evaluator'); + await runner.close(); + }); + + test('deno evaluator can resolve commonjs via resolveExternalModule hook', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/deno-dep-hook.cjs', + type: 'commonjs' + }; + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'deno', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 34 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-deno-cjs-hook.mjs' + ); + expect(result.value).toBe(34); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: '/tmp/deno-dep-hook.cjs', + type: 'commonjs', + evaluator: 'deno', + reason: 'unsupported-external' + }); + expect(calls[0].error).toBeInstanceOf(Error); + expect((calls[0].error as Error).message).toContain( + 'commonjs external module is not supported' + ); + await runner.close(); + }); + + test('deno evaluator can resolve network externalize when native import fails', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'https://example.invalid/farm-deno-network.mjs', + type: 'network' + }; + } + }; + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'deno', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 33 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-deno-network-hook.mjs' + ); + expect(result.value).toBe(33); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'https://example.invalid/farm-deno-network.mjs', + type: 'network', + evaluator: 'deno', + reason: 'import-failed' + }); + expect(calls[0].error).toBeDefined(); + await runner.close(); + }); + + test('deno evaluator forwards import options to resolveExternalModule hook on import-fail', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (id === '/entry-deno-import-options-hook.mjs') { + return { + code: [ + "const dep = await __farm_ssr_dynamic_import__('unsupported+scheme://deno-options-entry', { with: { type: 'json' } });", + "__farm_ssr_export_name__('value', () => dep.value);" + ].join('\n'), + file: '/entry-deno-import-options-hook.mjs', + id: '/entry-deno-import-options-hook.mjs', + url: '/entry-deno-import-options-hook.mjs', + invalidate: false, + map: null + }; + } + + if (id === 'unsupported+scheme://deno-options-entry') { + return { + externalize: id, + type: 'module' + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + importOptions?: unknown; + }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'deno', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 38 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-deno-import-options-hook.mjs' + ); + expect(result.value).toBe(38); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'unsupported+scheme://deno-options-entry', + type: 'module', + evaluator: 'deno', + reason: 'import-failed', + importOptions: { with: { type: 'json' } } + }); + expect(calls[0].importOptions).toEqual({ with: { type: 'json' } }); + await runner.close(); + }); + + test('deno evaluator keeps native import runtime error without hook fallback', async () => { + const dir = await createTempDir('farm-runner-deno-runtime-error-'); + const entry = path.join(dir, 'entry.mjs'); + await fs.writeFile( + entry, + "throw new Error('deno-external-runtime-boom');\n" + ); + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: `${pathToFileURL(entry).toString()}?runtime=boom`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'deno', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 299 } + }; + } + }); + + const error = await runner + .import('/entry-deno-runtime-error.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('deno-external-runtime-boom'); + expect(calls).toEqual([]); + await runner.close(); + }); + + test('worker evaluator runs inlined module in baseline mode', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'const value = 7; __farm_ssr_export_name__("value", () => value);', + file: '/src/worker-inline.ts', + id: '/src/worker-inline.ts', + url: '/src/worker-inline.ts', + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'worker' + }); + + const result = await runner.import<{ value: number }>( + '/src/worker-inline.ts' + ); + expect(result.value).toBe(7); + await runner.close(); + }); + + test('inlined module still runs when Buffer global is unavailable', async () => { + const bufferDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'Buffer' + ); + + if (bufferDescriptor && !bufferDescriptor.configurable) { + return; + } + + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: "const value = 9; __farm_ssr_export_name__('value', () => value);", + file: '/src/no-buffer.ts', + id: '/src/no-buffer.ts', + url: '/src/no-buffer.ts', + invalidate: false, + map: JSON.stringify({ + version: 3, + file: '/src/no-buffer.ts', + sources: ['/src/no-buffer.ts'], + sourcesContent: ['const value = 9; export { value };'], + names: [], + mappings: 'AAAA' + }) + }; + } + }; + + try { + Object.defineProperty(globalThis, 'Buffer', { + configurable: true, + enumerable: false, + writable: true, + value: undefined + }); + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const result = await runner.import<{ value: number }>( + '/src/no-buffer.ts' + ); + expect(result.value).toBe(9); + await runner.close(); + } finally { + if (bufferDescriptor) { + Object.defineProperty(globalThis, 'Buffer', bufferDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'Buffer'); + } + } + }); + + test('node evaluator errors clearly when commonjs loader capability is unavailable', async () => { + const processDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'process' + ); + const processLike = globalThis.process as NodeJS.Process | undefined; + + if (!processLike) { + return; + } + + const builtinDescriptor = Object.getOwnPropertyDescriptor( + processLike, + 'getBuiltinModule' + ); + + if (builtinDescriptor && !builtinDescriptor.configurable) { + return; + } + + try { + Object.defineProperty(processLike, 'getBuiltinModule', { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }); + + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/no-cjs-loader.cjs', + type: 'commonjs' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'node' + }); + + const error = await runner + .import('/entry-no-cjs-loader.cjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('requires Node module loader support'); + await runner.close(); + } finally { + if (builtinDescriptor) { + Object.defineProperty( + processLike, + 'getBuiltinModule', + builtinDescriptor + ); + } else { + Reflect.deleteProperty(processLike, 'getBuiltinModule'); + } + + if (processDescriptor) { + Object.defineProperty(globalThis, 'process', processDescriptor); + } + } + }); + + test('node evaluator can resolve commonjs via hook when loader capability is unavailable', async () => { + const processLike = globalThis.process as NodeJS.Process | undefined; + + if (!processLike) { + return; + } + + const builtinDescriptor = Object.getOwnPropertyDescriptor( + processLike, + 'getBuiltinModule' + ); + + if (builtinDescriptor && !builtinDescriptor.configurable) { + return; + } + + try { + Object.defineProperty(processLike, 'getBuiltinModule', { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }); + + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/no-cjs-loader-hook.cjs', + type: 'commonjs' + }; + } + }; + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'node', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 17 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-no-cjs-loader-hook.cjs' + ); + expect(result.value).toBe(17); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: '/tmp/no-cjs-loader-hook.cjs', + type: 'commonjs', + evaluator: 'node', + reason: 'missing-node-loader' + }); + expect(calls[0].error).toBeInstanceOf(Error); + expect((calls[0].error as Error).message).toContain( + 'requires Node module loader support' + ); + await runner.close(); + } finally { + if (builtinDescriptor) { + Object.defineProperty( + processLike, + 'getBuiltinModule', + builtinDescriptor + ); + } else { + Reflect.deleteProperty(processLike, 'getBuiltinModule'); + } + } + }); + + test('node evaluator forwards import options to resolveExternalModule hook when loader capability is unavailable', async () => { + const processLike = globalThis.process as NodeJS.Process | undefined; + + if (!processLike) { + return; + } + + const builtinDescriptor = Object.getOwnPropertyDescriptor( + processLike, + 'getBuiltinModule' + ); + + if (builtinDescriptor && !builtinDescriptor.configurable) { + return; + } + + try { + Object.defineProperty(processLike, 'getBuiltinModule', { + configurable: true, + enumerable: true, + writable: true, + value: undefined + }); + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (id === '/entry-no-cjs-loader-options-hook.cjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('/tmp/no-cjs-loader-options.cjs', { with: { type: 'commonjs' } });", + "__farm_ssr_export_name__('value', () => dep.value);" + ].join('\n'), + file: '/entry-no-cjs-loader-options-hook.cjs', + id: '/entry-no-cjs-loader-options-hook.cjs', + url: '/entry-no-cjs-loader-options-hook.cjs', + invalidate: false, + map: null + }; + } + + if (id === '/tmp/no-cjs-loader-options.cjs') { + return { + externalize: '/tmp/no-cjs-loader-options.cjs', + type: 'commonjs' + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + importOptions?: unknown; + }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'node', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 19 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-no-cjs-loader-options-hook.cjs' + ); + expect(result.value).toBe(19); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: '/tmp/no-cjs-loader-options.cjs', + type: 'commonjs', + evaluator: 'node', + reason: 'missing-node-loader', + importOptions: { with: { type: 'commonjs' } } + }); + expect(calls[0].importOptions).toEqual({ with: { type: 'commonjs' } }); + await runner.close(); + } finally { + if (builtinDescriptor) { + Object.defineProperty( + processLike, + 'getBuiltinModule', + builtinDescriptor + ); + } else { + Reflect.deleteProperty(processLike, 'getBuiltinModule'); + } + } + }); + + test('node evaluator can resolve module externalize when native import fails', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: 'unsupported+scheme://node-virtual-entry', + type: 'module' + }; + } + }; + + const calls: Array<{ + id: string; + type: string; + evaluator: string; + reason?: string; + error?: unknown; + }> = []; + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'node', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 18 } + }; + } + }); + + const result = await runner.import<{ value: number }>( + '/entry-node-module-hook.mjs' + ); + expect(result.value).toBe(18); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + id: 'unsupported+scheme://node-virtual-entry', + type: 'module', + evaluator: 'node', + reason: 'import-failed' + }); + expect(calls[0].error).toBeDefined(); + await runner.close(); + }); + + test('node evaluator keeps native import runtime error without hook fallback', async () => { + const dir = await createTempDir('farm-runner-node-runtime-error-'); + const entry = path.join(dir, 'entry.mjs'); + await fs.writeFile( + entry, + "throw new Error('node-external-runtime-boom');\n" + ); + + const calls: Array<{ id: string; type: string; evaluator: string }> = []; + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: `${pathToFileURL(entry).toString()}?runtime=boom`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: 'node', + resolveExternalModule: async (context) => { + calls.push(context); + return { + resolved: true, + module: { value: 1018 } + }; + } + }); + + const error = await runner + .import('/entry-node-runtime-error.mjs') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('node-external-runtime-boom'); + expect(calls).toEqual([]); + await runner.close(); + }); + + test('runner accepts custom module evaluator from options', async () => { + let inlinedCalls = 0; + let externalCalls = 0; + const customEvaluator: ModuleEvaluator = { + async runInlinedModule() { + inlinedCalls++; + }, + async runExternalModule() { + externalCalls++; + return { value: 42 }; + } + }; + + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + externalize: '/tmp/custom.mjs', + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + evaluator: customEvaluator + }); + + const result = await runner.import<{ value: number }>('/entry.mjs'); + expect(result.value).toBe(42); + expect(externalCalls).toBe(1); + expect(inlinedCalls).toBe(0); + await runner.close(); + }); + + test('default import.meta generation does not require node:path or node:url', () => { + const posixMeta = createDefaultImportMeta('/src/entry.ts'); + expect(posixMeta.url).toBe('file:///src/entry.ts'); + expect(posixMeta.dirname).toBe('/src'); + expect(posixMeta.filename).toBe('/src/entry.ts'); + + const windowsMeta = createDefaultImportMeta('C:\\repo\\entry.ts'); + expect(windowsMeta.url).toBe('file:///C:/repo/entry.ts'); + expect(windowsMeta.dirname).toBe('C:/repo'); + expect(windowsMeta.filename).toBe('C:\\repo\\entry.ts'); + }); + + test('default import.meta generation resolves relative path via runtime cwd', () => { + const processLike = globalThis.process as NodeJS.Process | undefined; + + if (!processLike) { + return; + } + + const cwdDescriptor = Object.getOwnPropertyDescriptor(processLike, 'cwd'); + + if (cwdDescriptor && !cwdDescriptor.configurable) { + return; + } + + try { + Object.defineProperty(processLike, 'cwd', { + configurable: true, + enumerable: false, + writable: true, + value: () => '/runtime/root' + }); + + const relativeMeta = createDefaultImportMeta('src/entry.ts'); + expect(relativeMeta.url).toBe('file:///runtime/root/src/entry.ts'); + } finally { + if (cwdDescriptor) { + Object.defineProperty(processLike, 'cwd', cwdDescriptor); + } else { + Reflect.deleteProperty(processLike, 'cwd'); + } + } + }); + + test('inlined module resolves relative imports without node:path dependency', async () => { + const fetchIds: string[] = []; + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + fetchIds.push(id); + + if (id === '/dir/entry.mjs') { + return { + code: [ + "const dep = await __farm_ssr_dynamic_import__('./dep.mjs');", + "__farm_ssr_export_name__('value', () => dep.value + 1);" + ].join('\n'), + file: '/dir/entry.mjs', + id: '/dir/entry.mjs', + url: '/dir/entry.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dir/dep.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 2);", + file: '/dir/dep.mjs', + id: '/dir/dep.mjs', + url: '/dir/dep.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const mod = await runner.import<{ value: number }>('/dir/entry.mjs'); + expect(mod.value).toBe(3); + expect(fetchIds).toEqual(['/dir/entry.mjs', '/dir/dep.mjs']); + await runner.close(); + }); + + test('inlined module accepts dynamic import options argument', async () => { + const fetchIds: string[] = []; + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + fetchIds.push(id); + + if (id === '/dir/entry-with-options.mjs') { + return { + code: [ + "const dep = await __farm_ssr_dynamic_import__('./dep.mjs', { with: { type: 'json' } });", + "__farm_ssr_export_name__('value', () => dep.value + 1);" + ].join('\n'), + file: '/dir/entry-with-options.mjs', + id: '/dir/entry-with-options.mjs', + url: '/dir/entry-with-options.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dir/dep.mjs') { + return { + code: "__farm_ssr_export_name__('value', () => 2);", + file: '/dir/dep.mjs', + id: '/dir/dep.mjs', + url: '/dir/dep.mjs', + invalidate: false, + map: null + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const mod = await runner.import<{ value: number }>( + '/dir/entry-with-options.mjs' + ); + expect(mod.value).toBe(3); + expect(fetchIds).toEqual(['/dir/entry-with-options.mjs', '/dir/dep.mjs']); + await runner.close(); + }); + + test('forwards dynamic import options to external module loading', async () => { + const dir = await createTempDir('farm-runner-dynamic-options-external-'); + const depJson = path.join(dir, 'dep.json'); + await fs.writeFile(depJson, JSON.stringify({ value: 2 })); + + const fetchIds: string[] = []; + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + fetchIds.push(id); + + if (id === '/dir/entry-external-options.mjs') { + return { + code: [ + "const dep = await __farm_ssr_dynamic_import__('./dep.json', { with: { type: 'json' } });", + "__farm_ssr_export_name__('value', () => dep.default.value + 1);" + ].join('\n'), + file: '/dir/entry-external-options.mjs', + id: '/dir/entry-external-options.mjs', + url: '/dir/entry-external-options.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dir/dep.json') { + return { + externalize: pathToFileURL(depJson).toString(), + type: 'module' + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const mod = await runner.import<{ value: number }>( + '/dir/entry-external-options.mjs' + ); + expect(mod.value).toBe(3); + expect(fetchIds).toEqual([ + '/dir/entry-external-options.mjs', + '/dir/dep.json' + ]); + await runner.close(); + }); + + test('forwards static import attributes options to external module loading', async () => { + const dir = await createTempDir('farm-runner-static-attributes-external-'); + const depJson = path.join(dir, 'dep.json'); + await fs.writeFile(depJson, JSON.stringify({ value: 2 })); + + const fetchIds: string[] = []; + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + const [id] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + fetchIds.push(id); + + if (id === '/dir/entry-static-attributes.mjs') { + return { + code: [ + "const dep = await __farm_ssr_import__('./dep.json', { with: { type: 'json' } });", + "__farm_ssr_export_name__('value', () => dep.default.value + 1);" + ].join('\n'), + file: '/dir/entry-static-attributes.mjs', + id: '/dir/entry-static-attributes.mjs', + url: '/dir/entry-static-attributes.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/dir/dep.json') { + return { + externalize: pathToFileURL(depJson).toString(), + type: 'module' + }; + } + + throw new Error(`unexpected fetch id: ${id}`); + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const mod = await runner.import<{ value: number }>( + '/dir/entry-static-attributes.mjs' + ); + expect(mod.value).toBe(3); + expect(fetchIds).toEqual([ + '/dir/entry-static-attributes.mjs', + '/dir/dep.json' + ]); + await runner.close(); + }); + + test('reuses local cache when fetchModule returns cache=true', async () => { + const dir = await createTempDir('farm-runner-cache-'); + const entry = path.join(dir, 'entry.mjs'); + + await fs.writeFile(entry, 'export const value = 1;\n'); + + let fetchCount = 0; + let cacheChecks = 0; + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + fetchCount++; + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + expect(id).toBe('/entry.mjs'); + + if (options?.cached) { + cacheChecks++; + return { cache: true }; + } + + return { + externalize: pathToFileURL(entry).toString() + '?t=1', + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + + const first = await runner.import<{ value: number }>('/entry.mjs'); + const second = await runner.import<{ value: number }>('/entry.mjs'); + + expect(first.value).toBe(1); + expect(second.value).toBe(1); + expect(fetchCount).toBe(2); + expect(cacheChecks).toBe(1); + + await runner.close(); + }); + + test('invalidates cached module on hmr update payload', async () => { + const dir = await createTempDir('farm-runner-hmr-'); + const entry = path.join(dir, 'entry.mjs'); + + await fs.writeFile(entry, 'export const value = 1;\n'); + + let currentVersion = 1; + let invalidated = false; + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const cachedRequestFlags: boolean[] = []; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + + const [_id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + cachedRequestFlags.push(Boolean(options?.cached)); + + if (options?.cached && !invalidated) { + return { cache: true }; + } + + invalidated = false; + + return { + externalize: pathToFileURL(entry).toString() + `?t=${currentVersion}`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + const first = await runner.import<{ value: number }>('/entry.mjs'); + expect(first.value).toBe(1); + + await fs.writeFile(entry, 'export const value = 2;\n'); + currentVersion = 2; + invalidated = true; + expect(hmrMessageHandler).toBeTypeOf('function'); + + hmrMessageHandler?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/entry.mjs', + acceptedPath: '/entry.mjs', + timestamp: Date.now() + } + ] + }); + + await runner.import<{ value: number }>('/entry.mjs'); + expect(cachedRequestFlags).toEqual([false, true]); + + await runner.close(); + }); + + test('hmr update keeps unaffected module cache warm', async () => { + const dir = await createTempDir('farm-runner-hmr-targeted-'); + const entryA = path.join(dir, 'entry-a.mjs'); + const entryB = path.join(dir, 'entry-b.mjs'); + + await Promise.all([ + fs.writeFile(entryA, 'export const value = 1;\n'), + fs.writeFile(entryB, 'export const value = 10;\n') + ]); + + let versionA = 1; + let pendingAInvalidation = false; + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const cachedFlagsById: Record = { + '/entry-a.mjs': [], + '/entry-b.mjs': [] + }; + const externalizeLoads: Record = { + '/entry-a.mjs': 0, + '/entry-b.mjs': 0 + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + cachedFlagsById[id].push(Boolean(options?.cached)); + + if (id === '/entry-a.mjs') { + if (options?.cached && !pendingAInvalidation) { + return { cache: true }; + } + + pendingAInvalidation = false; + externalizeLoads[id]++; + return { + externalize: `${pathToFileURL(entryA).toString()}?t=${versionA}`, + type: 'module' + }; + } + + if (options?.cached) { + return { cache: true }; + } + + externalizeLoads[id]++; + return { + externalize: `${pathToFileURL(entryB).toString()}?t=1`, + type: 'module' + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + await runner.import('/entry-a.mjs'); + await runner.import('/entry-b.mjs'); + + versionA = 2; + pendingAInvalidation = true; + hmrMessageHandler?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/entry-a.mjs', + acceptedPath: '/entry-a.mjs', + timestamp: Date.now() + } + ] + }); + + await runner.import('/entry-a.mjs'); + await runner.import('/entry-b.mjs'); + + expect(cachedFlagsById['/entry-a.mjs']).toEqual([false, true]); + expect(cachedFlagsById['/entry-b.mjs']).toEqual([false, true]); + expect(externalizeLoads['/entry-a.mjs']).toBe(2); + expect(externalizeLoads['/entry-b.mjs']).toBe(1); + + await runner.close(); + }); + + test('hmr update invalidates circular importers without infinite recursion', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const cachedFlagsById: Record = { + '/a.mjs': [], + '/b.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + cachedFlagsById[id].push(Boolean(options?.cached)); + + if (options?.cached) { + return { cache: true }; + } + + if (id === '/a.mjs') { + return { + code: [ + "const b = await __farm_ssr_import__('/b.mjs');", + "__farm_ssr_export_name__('value', () => b.value + 1);" + ].join('\n'), + file: '/a.mjs', + id: '/a.mjs', + url: '/a.mjs', + invalidate: false, + map: null + }; + } + + return { + code: [ + "const a = await __farm_ssr_import__('/a.mjs');", + "__farm_ssr_export_name__('touchA', () => Boolean(a));", + "__farm_ssr_export_name__('value', () => 1);" + ].join('\n'), + file: '/b.mjs', + id: '/b.mjs', + url: '/b.mjs', + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + const first = await runner.import<{ value: number }>('/a.mjs'); + expect(first.value).toBe(2); + + const beforeUpdateA = runner.evaluatedModules.getModuleByUrl('/a.mjs'); + const beforeUpdateB = runner.evaluatedModules.getModuleByUrl('/b.mjs'); + expect(beforeUpdateA?.importers.has('/b.mjs')).toBe(true); + expect(beforeUpdateB?.importers.has('/a.mjs')).toBe(true); + + hmrMessageHandler?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/a.mjs', + acceptedPath: '/a.mjs', + timestamp: Date.now() + } + ] + }); + + const modA = runner.evaluatedModules.getModuleByUrl('/a.mjs'); + const modB = runner.evaluatedModules.getModuleByUrl('/b.mjs'); + + expect(modA?.meta).toBeUndefined(); + expect(modB?.meta).toBeUndefined(); + expect(cachedFlagsById['/a.mjs'].length).toBeGreaterThan(0); + expect(cachedFlagsById['/b.mjs'].length).toBeGreaterThan(0); + + await runner.close(); + }); + + test('hmr update invalidates circular group and keeps unrelated module warm', async () => { + let hmrMessageHandler: ((payload: RunnerHotPayload) => void) | undefined; + const initialized = new Set(); + const cachedFlagsById: Record = { + '/a.mjs': [], + '/b.mjs': [], + '/c.mjs': [] + }; + + const transport: ModuleRunnerTransport = { + connect({ onMessage }) { + hmrMessageHandler = onMessage; + onMessage({ type: 'connected' }); + }, + async invoke(name, data) { + expect(name).toBe('fetchModule'); + + const [id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + cachedFlagsById[id].push(Boolean(options?.cached)); + + if (options?.cached && initialized.has(id)) { + return { cache: true }; + } + + initialized.add(id); + + if (id === '/a.mjs') { + return { + code: [ + "const b = await __farm_ssr_import__('/b.mjs');", + "__farm_ssr_export_name__('value', () => b.value + 1);" + ].join('\n'), + file: '/a.mjs', + id: '/a.mjs', + url: '/a.mjs', + invalidate: false, + map: null + }; + } + + if (id === '/b.mjs') { + return { + code: [ + "const a = await __farm_ssr_import__('/a.mjs');", + "__farm_ssr_export_name__('touchA', () => Boolean(a));", + "__farm_ssr_export_name__('value', () => 1);" + ].join('\n'), + file: '/b.mjs', + id: '/b.mjs', + url: '/b.mjs', + invalidate: false, + map: null + }; + } + + return { + code: "__farm_ssr_export_name__('value', () => 3);", + file: '/c.mjs', + id: '/c.mjs', + url: '/c.mjs', + invalidate: false, + map: null + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: true }); + + const firstA = await runner.import<{ value: number }>('/a.mjs'); + const firstC = await runner.import<{ value: number }>('/c.mjs'); + expect(firstA.value).toBe(2); + expect(firstC.value).toBe(3); + + hmrMessageHandler?.({ + type: 'update', + updates: [ + { + type: 'js-update', + path: '/a.mjs', + acceptedPath: '/a.mjs', + timestamp: Date.now() + } + ] + }); + + const modA = runner.evaluatedModules.getModuleByUrl('/a.mjs'); + const modB = runner.evaluatedModules.getModuleByUrl('/b.mjs'); + const modC = runner.evaluatedModules.getModuleByUrl('/c.mjs'); + + expect(modA?.meta).toBeUndefined(); + expect(modB?.meta).toBeUndefined(); + expect(modC?.meta).toBeDefined(); + + const secondC = await runner.import<{ value: number }>('/c.mjs'); + expect(secondC.value).toBe(3); + expect(cachedFlagsById['/c.mjs']).toEqual([false, true]); + + await runner.close(); + }); + + test('loads externalized commonjs modules via require semantics', async () => { + const dir = await createTempDir('farm-runner-cjs-'); + const entry = path.join(dir, 'entry.cjs'); + await fs.writeFile(entry, 'module.exports = { value: 42 };\n'); + + let fetchCount = 0; + let cacheChecks = 0; + + const transport: ModuleRunnerTransport = { + async invoke(name, data) { + expect(name).toBe('fetchModule'); + fetchCount++; + + const [_id, _importer, options] = data as [ + string, + string | undefined, + FetchFunctionOptions | undefined + ]; + + if (options?.cached) { + cacheChecks++; + return { cache: true }; + } + + return { + externalize: `${pathToFileURL(entry).toString()}?t=1`, + type: 'commonjs' + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const first = await runner.import<{ value: number }>('/entry.cjs'); + const second = await runner.import<{ value: number }>('/entry.cjs'); + + expect(first.value).toBe(42); + expect(second.value).toBe(42); + expect(fetchCount).toBe(2); + expect(cacheChecks).toBe(1); + + await runner.close(); + }); + + test('inlined module executes with source map payload', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: '__farm_ssr_export_name__("value", () => 7);', + file: '/src/entry-server.ts', + id: '/src/entry-server.ts', + url: '/src/entry-server.ts', + invalidate: false, + map: JSON.stringify({ + version: 3, + file: '/src/entry-server.ts', + sources: ['/src/entry-server.ts'], + names: [], + mappings: '' + }) + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const mod = await runner.import<{ value: number }>('/src/entry-server.ts'); + expect(mod.value).toBe(7); + + await runner.close(); + }); + + test('inlined module error stack points to source file when map exists', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'throw new Error("runner-boom")', + file: '/src/entry-server.ts', + id: '/src/entry-server.ts', + url: '/src/entry-server.ts', + invalidate: false, + map: JSON.stringify({ + version: 3, + file: '/src/entry-server.ts', + sources: ['/src/entry-server.ts'], + sourcesContent: ['throw new Error("runner-boom")'], + names: [], + mappings: 'AAAA' + }) + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const error = await runner + .import('/src/entry-server.ts') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('runner-boom'); + expect(String(error?.stack ?? '')).toContain('/src/entry-server.ts'); + + await runner.close(); + }); + + test('source map interceptor remaps stack to original source', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'throw new Error("runner-remap-boom")', + file: '/src/generated-entry.ts', + id: '/src/generated-entry.ts', + url: '/src/generated-entry.ts', + invalidate: false, + map: JSON.stringify({ + version: 3, + file: '/src/generated-entry.ts', + sources: ['/src/original-entry.ts'], + sourcesContent: ['throw new Error("runner-remap-boom")'], + names: [], + mappings: 'AAAA' + }) + }; + } + }; + + const runner = new FarmModuleRunner({ transport, hmr: false }); + const error = await runner + .import('/src/generated-entry.ts') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('runner-remap-boom'); + expect(String(error?.stack ?? '')).toContain('/src/original-entry.ts'); + + await runner.close(); + }); + + test('source map interceptor can be disabled', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'throw new Error("runner-remap-disabled")', + file: '/src/generated-disabled.ts', + id: '/src/generated-disabled.ts', + url: '/src/generated-disabled.ts', + invalidate: false, + map: JSON.stringify({ + version: 3, + file: '/src/generated-disabled.ts', + sources: ['/src/original-disabled.ts'], + sourcesContent: ['throw new Error("runner-remap-disabled")'], + names: [], + mappings: 'AAAA' + }) + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + sourceMapInterceptor: false + }); + + const error = await runner + .import('/src/generated-disabled.ts') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('runner-remap-disabled'); + expect(String(error?.stack ?? '')).toContain('/src/generated-disabled.ts'); + expect(String(error?.stack ?? '')).not.toContain( + '/src/original-disabled.ts' + ); + + await runner.close(); + }); + + test('source map interceptor degrades when process global is unavailable', () => { + const processDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'process' + ); + + if (processDescriptor && !processDescriptor.configurable) { + return; + } + + const sourceMap = JSON.stringify({ + version: 3, + file: '/src/no-process.ts', + sources: ['/src/original-no-process.ts'], + sourcesContent: ['throw new Error("runner-no-process")'], + names: [], + mappings: 'AAAA' + }); + + let interceptor: + | ReturnType + | undefined; + + try { + Object.defineProperty(globalThis, 'process', { + configurable: true, + enumerable: false, + writable: true, + value: undefined + }); + + interceptor = createRunnerSourceMapInterceptor(true, true); + interceptor.register('/src/no-process.ts', sourceMap); + interceptor.unregister('/src/no-process.ts'); + interceptor.clear(); + } finally { + interceptor?.close(); + + if (processDescriptor) { + Object.defineProperty(globalThis, 'process', processDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'process'); + } + } + }); + + test('source map interceptor retries SourceMap lookup after process restore', async () => { + const processDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'process' + ); + + if (processDescriptor && !processDescriptor.configurable) { + return; + } + + const realProcess = globalThis.process as NodeJS.Process | undefined; + + if (!realProcess) { + return; + } + + const getBuiltinModuleDescriptor = Object.getOwnPropertyDescriptor( + realProcess, + 'getBuiltinModule' + ); + + if ( + getBuiltinModuleDescriptor && + !getBuiltinModuleDescriptor.configurable + ) { + return; + } + + const originalGetBuiltinModule = + realProcess.getBuiltinModule?.bind(realProcess); + + if (!originalGetBuiltinModule) { + return; + } + + const sourceMap = JSON.stringify({ + version: 3, + file: '/src/retry-process.ts', + sources: ['/src/original-retry-process.ts'], + sourcesContent: ['throw new Error("runner-retry-process")'], + names: [], + mappings: 'AAAA' + }); + + const freshModuleUrl = new URL( + `../src/module-runner/sourceMapInterceptor.js?retry=${Date.now()}`, + import.meta.url + ); + const { createRunnerSourceMapInterceptor: createFreshInterceptor } = + await import(freshModuleUrl.href); + + let lookupCalls = 0; + + try { + Object.defineProperty(realProcess, 'getBuiltinModule', { + configurable: true, + enumerable: true, + writable: true, + value: (id: string) => { + lookupCalls++; + return originalGetBuiltinModule(id); + } + }); + + Object.defineProperty(globalThis, 'process', { + configurable: true, + enumerable: false, + writable: true, + value: undefined + }); + + const first = createFreshInterceptor(true, true); + first.register('/src/retry-process.ts', sourceMap); + first.close(); + + Object.defineProperty(globalThis, 'process', { + configurable: true, + enumerable: false, + writable: true, + value: realProcess + }); + + const second = createFreshInterceptor(true, true); + second.register('/src/retry-process.ts', sourceMap); + second.close(); + + expect(lookupCalls).toBeGreaterThan(0); + } finally { + if (getBuiltinModuleDescriptor) { + Object.defineProperty( + realProcess, + 'getBuiltinModule', + getBuiltinModuleDescriptor + ); + } else { + Reflect.deleteProperty(realProcess, 'getBuiltinModule'); + } + + if (processDescriptor) { + Object.defineProperty(globalThis, 'process', processDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'process'); + } + } + }); + + test('source map interceptor degrades when Error.prepareStackTrace is locked', () => { + const prepareDescriptor = Object.getOwnPropertyDescriptor( + Error, + 'prepareStackTrace' + ); + + if (prepareDescriptor && !prepareDescriptor.configurable) { + return; + } + + const sourceMap = JSON.stringify({ + version: 3, + file: '/src/locked-prepare.ts', + sources: ['/src/original-locked-prepare.ts'], + sourcesContent: ['throw new Error("runner-locked-prepare")'], + names: [], + mappings: 'AAAA' + }); + + let interceptor: + | ReturnType + | undefined; + + try { + Object.defineProperty(Error, 'prepareStackTrace', { + configurable: true, + enumerable: false, + writable: false, + value: prepareDescriptor?.value + }); + + interceptor = createRunnerSourceMapInterceptor(true, true); + interceptor.register('/src/locked-prepare.ts', sourceMap); + interceptor.unregister('/src/locked-prepare.ts'); + interceptor.clear(); + } finally { + interceptor?.close(); + + if (prepareDescriptor) { + Object.defineProperty(Error, 'prepareStackTrace', prepareDescriptor); + } else { + Reflect.deleteProperty(Error, 'prepareStackTrace'); + } + } + }); + + test('source map interceptor close tolerates locked Error.prepareStackTrace', () => { + const prepareDescriptor = Object.getOwnPropertyDescriptor( + Error, + 'prepareStackTrace' + ); + + if (prepareDescriptor && !prepareDescriptor.configurable) { + return; + } + + const interceptor = createRunnerSourceMapInterceptor(true, true); + + try { + Object.defineProperty(Error, 'prepareStackTrace', { + configurable: true, + enumerable: false, + writable: false, + value: Error.prepareStackTrace + }); + + expect(() => interceptor.close()).not.toThrow(); + } finally { + if (prepareDescriptor) { + Object.defineProperty(Error, 'prepareStackTrace', prepareDescriptor); + } else { + Reflect.deleteProperty(Error, 'prepareStackTrace'); + } + } + }); + + test('source map interceptor remaps stack when native toggle is off', async () => { + const transport: ModuleRunnerTransport = { + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: 'throw new Error("runner-remap-native-off")', + file: '/src/generated-native-off.ts', + id: '/src/generated-native-off.ts', + url: '/src/generated-native-off.ts', + invalidate: false, + map: JSON.stringify({ + version: 3, + file: '/src/generated-native-off.ts', + sources: ['/src/original-native-off.ts'], + sourcesContent: ['throw new Error("runner-remap-native-off")'], + names: [], + mappings: 'AAAA' + }) + }; + } + }; + + const runner = new FarmModuleRunner({ + transport, + hmr: false, + sourceMapInterceptor: { + native: false + } + }); + + const error = await runner + .import('/src/generated-native-off.ts') + .then(() => null) + .catch((e) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect(error?.message).toContain('runner-remap-native-off'); + expect(String(error?.stack ?? '')).toContain('/src/original-native-off.ts'); + + await runner.close(); + }); + + test('source map interceptor supports multi-runner override and close fallback', async () => { + const generatedId = '/src/shared-generated.ts'; + + const createThrowingTransport = ( + originalSource: string, + message: string + ): ModuleRunnerTransport => ({ + async invoke(name) { + expect(name).toBe('fetchModule'); + return { + code: `throw new Error(${JSON.stringify(message)})`, + file: generatedId, + id: generatedId, + url: generatedId, + invalidate: true, + map: JSON.stringify({ + version: 3, + file: generatedId, + sources: [originalSource], + sourcesContent: [`throw new Error(${JSON.stringify(message)})`], + names: [], + mappings: 'AAAA' + }) + }; + } + }); + + const runnerA = new FarmModuleRunner({ + transport: createThrowingTransport('/src/original-a.ts', 'runner-a-boom'), + hmr: false + }); + + const errorA = await runnerA + .import(generatedId) + .then(() => null) + .catch((e) => e as Error); + + expect(errorA).toBeInstanceOf(Error); + expect(String(errorA?.stack ?? '')).toContain('/src/original-a.ts'); + + const runnerB = new FarmModuleRunner({ + transport: createThrowingTransport('/src/original-b.ts', 'runner-b-boom'), + hmr: false + }); + + const errorB = await runnerB + .import(generatedId) + .then(() => null) + .catch((e) => e as Error); + + expect(errorB).toBeInstanceOf(Error); + expect(String(errorB?.stack ?? '')).toContain('/src/original-b.ts'); + expect(String(errorB?.stack ?? '')).not.toContain('/src/original-a.ts'); + + await runnerB.close(); + + const errorAAfterClose = await runnerA + .import(generatedId) + .then(() => null) + .catch((e) => e as Error); + + expect(errorAAfterClose).toBeInstanceOf(Error); + expect(String(errorAAfterClose?.stack ?? '')).toContain( + '/src/original-a.ts' + ); + expect(String(errorAAfterClose?.stack ?? '')).not.toContain( + '/src/original-b.ts' + ); + + await runnerA.close(); + }); + + test('server invoke uses compiler.fetchModule result before fallback path', async () => { + let fetchModuleCalls = 0; + let fallbackTouched = false; + + const handlers = createServerModuleRunnerInvokeHandlers({ + root: process.cwd(), + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => { + fetchModuleCalls++; + return { + externalize: 'node:fs', + type: 'builtin' + }; + }, + resource: () => { + fallbackTouched = true; + return null; + }, + resources: () => ({}) + } + } as never); + + const result = await handlers.fetchModule('/entry.ts'); + + expect(result).toEqual({ + externalize: 'node:fs', + type: 'builtin' + }); + expect(fetchModuleCalls).toBe(1); + expect(fallbackTouched).toBe(false); + }); + + test('server invoke retries compiler.fetchModule with root-absolute id for root-relative requests', async () => { + const root = await createTempDir('farm-runner-invoke-root-retry-'); + const absoluteEntry = path.join(root, 'src', 'entry-server.ts'); + const calls: string[] = []; + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: (id: string) => { + calls.push(id); + if (id !== absoluteEntry) { + return null; + } + + return { + code: 'const value = 1; __farm_ssr_export_name__("value", () => value);', + file: absoluteEntry, + id: 'src/entry-server.ts', + url: '/src/entry-server.ts', + invalidate: false, + map: null + }; + }, + resource: () => null, + resources: () => ({}) + } + } as never); + + await expect(handlers.fetchModule('/src/entry-server.ts')).resolves.toEqual( + { + code: 'const value = 1; __farm_ssr_export_name__("value", () => value);', + file: absoluteEntry, + id: 'src/entry-server.ts', + url: '/src/entry-server.ts', + invalidate: false, + map: null + } + ); + + expect(calls).toEqual(['/src/entry-server.ts', absoluteEntry]); + }); + + test('server wrapper and context invoke handlers return consistent result', async () => { + const root = await createTempDir('farm-runner-wrapper-context-'); + const entry = path.join(root, 'entry.js'); + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ type: 'commonjs' }) + ); + await fs.writeFile(entry, 'module.exports = { value: 1 };\n'); + + const compiler = { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + externalize: pathToFileURL(entry).toString(), + type: 'module' as const + }), + resource: () => null, + resources: () => ({}) + }; + + const serverLike = { + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler + }; + + const serverHandlers = createServerModuleRunnerInvokeHandlers( + serverLike as never + ); + const contextHandlers = createModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler + }); + + const byServer = await serverHandlers.fetchModule('/entry.js'); + const byContext = await contextHandlers.fetchModule('/entry.js'); + + expect(byContext).toEqual(byServer); + }); + + test('server invoke passes through compiler.fetchModule bailout reason', async () => { + const handlers = createServerModuleRunnerInvokeHandlers({ + root: process.cwd(), + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + externalize: 'node:fs', + type: 'builtin', + bailoutReason: 'unsupported-ts' + }), + resource: () => null, + resources: () => ({}) + } + } as never); + + await expect(handlers.fetchModule('/entry.ts')).resolves.toEqual({ + externalize: 'node:fs', + type: 'builtin', + bailoutReason: 'unsupported-ts' + }); + }); + + test('server invoke normalizes inlined fetch source map field', async () => { + const handlers = createServerModuleRunnerInvokeHandlers({ + root: process.cwd(), + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + code: 'export const value = 1;', + file: null, + id: '/entry.ts', + url: '/entry.ts', + invalidate: false + }), + resource: () => null, + resources: () => ({}) + } + } as never); + + const result = await handlers.fetchModule('/entry.ts'); + + expect(result).toEqual({ + code: 'export const value = 1;', + file: null, + id: '/entry.ts', + url: '/entry.ts', + invalidate: false, + map: null + }); + }); + + test('server invoke rejects invalid compiler.fetchModule payload', async () => { + const handlers = createServerModuleRunnerInvokeHandlers({ + root: process.cwd(), + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + externalize: 'node:fs', + type: 'invalid' + }), + resource: () => null, + resources: () => ({}) + } + } as never); + + await expect(handlers.fetchModule('/entry.ts')).rejects.toThrow( + 'Invalid fetchModule result' + ); + }); + + test('server invoke rejects invalid compiler.fetchModule bailout reason', async () => { + const handlers = createServerModuleRunnerInvokeHandlers({ + root: process.cwd(), + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + externalize: 'node:fs', + type: 'builtin', + bailoutReason: 'unknown-reason' + }), + resource: () => null, + resources: () => ({}) + } + } as never); + + await expect(handlers.fetchModule('/entry.ts')).rejects.toThrow( + 'Invalid fetchModule result' + ); + }); + + test('server invoke fallback classifies builtin and network requests', async () => { + const handlers = createServerModuleRunnerInvokeHandlers({ + root: process.cwd(), + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + await expect( + handlers.fetchModule('data:text/javascript,export%20default%201') + ).resolves.toEqual({ + externalize: 'data:text/javascript,export%20default%201', + type: 'builtin' + }); + + await expect(handlers.fetchModule('node:fs')).resolves.toEqual({ + externalize: 'node:fs', + type: 'builtin' + }); + + await expect( + handlers.fetchModule('https://example.com/mod.js') + ).resolves.toEqual({ + externalize: 'https://example.com/mod.js', + type: 'network' + }); + }); + + test('server invoke provides inlined fetch result for vite vue export helper virtual module', async () => { + const handlers = createServerModuleRunnerInvokeHandlers({ + root: process.cwd(), + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + const result = await handlers.fetchModule('\0plugin-vue:export-helper'); + expect(result).toMatchObject({ + id: '\0plugin-vue:export-helper', + url: '\0plugin-vue:export-helper', + invalidate: false + }); + expect('code' in result ? result.code : '').toContain( + '__farm_ssr_export_name__("default", () => exportHelper);' + ); + }); + + test('server invoke returns vue style query noop module for not-script bailout', async () => { + const root = await createTempDir('farm-runner-vue-style-noop-'); + const aboutPagePath = path.join(root, 'src', 'pages', 'AboutPage.vue'); + await fs.mkdir(path.dirname(aboutPagePath), { recursive: true }); + await fs.writeFile( + aboutPagePath, + '\n' + ); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + externalize: pathToFileURL(aboutPagePath).toString(), + type: 'module', + bailoutReason: 'not-script' + }), + resource: () => null, + resources: () => ({}) + } + } as never); + + const styleId = '/src/pages/AboutPage.vue?vue&type=style&index=0&lang.css'; + const result = await handlers.fetchModule(styleId); + + expect(result).toMatchObject({ + id: styleId, + url: styleId, + file: aboutPagePath, + invalidate: false, + map: null + }); + expect('code' in result ? result.code : '').toContain( + '__farm_ssr_export_name__("default", () => __farm_ssr_style_noop__);' + ); + }); + + test('server invoke fallback resolves direct files with versioned externalize and cache hit', async () => { + const root = await createTempDir('farm-runner-invoke-'); + const entry = path.join(root, 'entry.cjs'); + await fs.writeFile(entry, 'module.exports = { value: 1 };\n'); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 9, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + const first = await handlers.fetchModule('/entry.cjs'); + expect(first).toMatchObject({ + type: 'commonjs' + }); + expect('externalize' in first ? first.externalize : '').toContain('?t=9-'); + + await expect( + handlers.fetchModule('/entry.cjs', undefined, { cached: true }) + ).resolves.toEqual({ cache: true }); + }); + + test('server invoke fallback invalidates direct file cache when mtime changes', async () => { + const root = await createTempDir('farm-runner-mtime-invalidate-'); + const entry = path.join(root, 'entry.mjs'); + await fs.writeFile(entry, 'export const value = 1;\n'); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 11, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + const first = await handlers.fetchModule('/entry.mjs'); + expect('externalize' in first ? first.externalize : '').toContain('?t=11-'); + + const currentStat = await fs.stat(entry); + const updatedTime = new Date(currentStat.mtimeMs + 5000); + await fs.utimes(entry, updatedTime, updatedTime); + + const second = await handlers.fetchModule('/entry.mjs', undefined, { + cached: true + }); + + expect(second).toMatchObject({ + type: 'module' + }); + expect('cache' in second).toBe(false); + + if ('externalize' in first && 'externalize' in second) { + expect(second.externalize).not.toBe(first.externalize); + } + }); + + test('context invoke handlers keep state isolated across contexts', async () => { + const root = await createTempDir('farm-runner-context-isolate-'); + const entry = path.join(root, 'entry.mjs'); + await fs.writeFile(entry, 'export const value = 1;\n'); + + const compiler = { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + }; + + const handlersA = createModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 13, + compiler + }); + + const handlersB = createModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 13, + compiler + }); + + await handlersA.fetchModule('/entry.mjs'); + + await expect( + handlersA.fetchModule('/entry.mjs', undefined, { cached: true }) + ).resolves.toEqual({ cache: true }); + + const resultFromB = await handlersB.fetchModule('/entry.mjs', undefined, { + cached: true + }); + + expect('cache' in resultFromB).toBe(false); + expect(resultFromB).toMatchObject({ + type: 'module' + }); + }); + + test('server invoke fallback classifies js file as commonjs when nearest package type is commonjs', async () => { + const root = await createTempDir('farm-runner-js-cjs-'); + const entry = path.join(root, 'entry.js'); + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ type: 'commonjs' }) + ); + await fs.writeFile(entry, 'module.exports = { value: 1 };\n'); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + const result = await handlers.fetchModule('/entry.js'); + expect(result).toMatchObject({ + type: 'commonjs' + }); + }); + + test('server invoke fallback classifies js file as module when nearest package type is module', async () => { + const root = await createTempDir('farm-runner-js-esm-'); + const entry = path.join(root, 'entry.js'); + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ type: 'module' }) + ); + await fs.writeFile(entry, 'export const value = 1;\n'); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + const result = await handlers.fetchModule('/entry.js'); + expect(result).toMatchObject({ + type: 'module' + }); + }); + + test('server invoke normalizes compiler externalize js type by nearest package type', async () => { + const root = await createTempDir('farm-runner-compiler-type-normalize-'); + const entry = path.join(root, 'entry.js'); + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify({ type: 'commonjs' }) + ); + await fs.writeFile(entry, 'module.exports = { value: 1 };\n'); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 1, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => ({ + externalize: pathToFileURL(entry).toString(), + type: 'module' + }), + resource: () => null, + resources: () => ({}) + } + } as never); + + await expect(handlers.fetchModule('/entry.js')).resolves.toEqual({ + externalize: pathToFileURL(entry).toString(), + type: 'commonjs' + }); + }); + + test('server invoke fallback resolves root-relative entry for importer-less relative ids', async () => { + const root = await createTempDir('farm-runner-relative-entry-'); + const entry = path.join(root, 'entry.mjs'); + await fs.writeFile(entry, 'export const value = 1;\n'); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 3, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + const result = await handlers.fetchModule('./entry.mjs'); + expect(result).toMatchObject({ + type: 'module' + }); + expect('externalize' in result ? result.externalize : '').toContain( + '/entry.mjs' + ); + }); + + test('server invoke resolves bare specifier from root-resolved relative importer path', async () => { + const root = await createTempDir('farm-runner-relative-importer-base-'); + const entry = path.join(root, 'src', 'entry.mjs'); + const depDir = path.join(root, 'node_modules', 'dep'); + await fs.mkdir(path.dirname(entry), { recursive: true }); + await fs.mkdir(depDir, { recursive: true }); + await fs.writeFile(entry, 'export const value = 1;\n'); + await fs.writeFile( + path.join(depDir, 'package.json'), + JSON.stringify({ name: 'dep', type: 'module', main: './index.mjs' }) + ); + await fs.writeFile( + path.join(depDir, 'index.mjs'), + 'export const dep = "ok";\n' + ); + + const handlers = createServerModuleRunnerInvokeHandlers({ + root, + publicPath: '/', + moduleRunnerStamp: 5, + compiler: { + compiling: false, + waitForCompileFinish: async () => undefined, + fetchModule: () => null, + resource: () => null, + resources: () => ({}) + } + } as never); + + const result = await handlers.fetchModule('dep', 'src/entry.mjs'); + expect(result).toMatchObject({ + type: 'module' + }); + expect('externalize' in result ? result.externalize : '').toContain( + '/node_modules/dep/index.mjs' + ); + }); +}); diff --git a/packages/ssr/package.json b/packages/ssr/package.json new file mode 100644 index 0000000000..4df7983c3f --- /dev/null +++ b/packages/ssr/package.json @@ -0,0 +1,28 @@ +{ + "name": "@farmfe/ssr", + "version": "0.0.0", + "description": "SSR toolkit helpers for Farm", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "type-check": "tsc -p tsconfig.build.json --noEmit" + }, + "dependencies": { + "@farmfe/core": "workspace:*" + }, + "devDependencies": { + "typescript": "5.6.3" + } +} diff --git a/packages/ssr/src/build-preview.ts b/packages/ssr/src/build-preview.ts new file mode 100644 index 0000000000..a2025ecc5b --- /dev/null +++ b/packages/ssr/src/build-preview.ts @@ -0,0 +1,1190 @@ +import { Buffer } from 'node:buffer'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { + createServer as createNodeServer, + type IncomingMessage, + type ServerResponse +} from 'node:http'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + build, + type FarmCliOptions, + PreviewServer, + resolveConfig, + type UserConfig +} from '@farmfe/core'; +import type { + SsrDevHostServerLike, + SsrDevServerListenOptions, + SsrMiddleware, + SsrMiddlewareServer, + SsrNextMiddleware, + SsrTemplateLoadContext, + SsrTemplateRenderContext +} from './dev-server.js'; +import { toSsrError } from './errors.js'; +import { + injectAssetsIntoHtml, + resolveAssetsFromManifest, + type SsrBuildInfo, + type SsrManifest +} from './manifest.js'; +import type { SsrRuntimeHooks } from './runtime-hooks.js'; +import type { + SsrRuntimeAssets, + SsrRuntimeCommand, + SsrRuntimeMeta +} from './runtime-types.js'; + +export interface SsrBuildOptions { + client: FarmCliOptions & UserConfig; + server: FarmCliOptions & UserConfig; + hooks?: SsrRuntimeHooks; + $client?: FarmCliOptions & UserConfig; + $server?: FarmCliOptions & UserConfig; +} + +export interface SsrResolvedBuildOutput { + root: string; + outputPath: string; +} + +export interface SsrPreviewRenderContext { + url: string; + req: IncomingMessage; + res: ServerResponse; + command: SsrRuntimeCommand; + mode: string; + runtime: SsrRuntimeMeta; + assets: SsrRuntimeAssets; + requestId?: string; +} + +export interface SsrPreviewTemplateOptions { + file?: string; + placeholder?: string; + load?: (context: SsrTemplateLoadContext) => string | Promise; + transform?: (context: SsrTemplateRenderContext) => string | Promise; +} + +export interface SsrPreviewRenderOptions { + entry?: string; + exportName?: string; + template?: SsrPreviewTemplateOptions; + shouldHandle?: (ctx: SsrPreviewRenderContext) => boolean; + render?: ( + mod: Record, + ctx: SsrPreviewRenderContext + ) => unknown; + onError?: ( + error: unknown, + ctx: SsrPreviewRenderContext & { next: (error?: unknown) => void } + ) => void | Promise; +} + +export interface SsrPreviewOptions { + client: FarmCliOptions & UserConfig; + server: FarmCliOptions & UserConfig; + host?: SsrDevServerListenOptions; + ssrMiddleware?: SsrNextMiddleware; + ssr?: SsrPreviewRenderOptions; + hooks?: SsrRuntimeHooks; + $client?: FarmCliOptions & UserConfig; + $server?: FarmCliOptions & UserConfig; +} + +export interface SsrResolvedPreviewMetadata { + clientBuildOutput: SsrResolvedBuildOutput; + serverBuildOutput: SsrResolvedBuildOutput | null; + templateFilePath: string | null; + serverEntryFilePath: string | null; + buildInfoPath: string | null; + manifestFilePath: string | null; + manifestFileCandidates: string[]; +} + +export interface SsrPreviewServer { + middlewares: SsrMiddlewareServer; + render( + url: string, + req: IncomingMessage, + res: ServerResponse + ): Promise; + listen(options?: SsrDevServerListenOptions): Promise; + close(): Promise; +} + +export interface SsrPreviewClientServerLike { + middlewares: SsrMiddleware; + close(): Promise; +} + +export interface SsrBuildPreviewFactories { + runBuild(config: FarmCliOptions & UserConfig): Promise; + resolveBuildOutput( + config: FarmCliOptions & UserConfig + ): Promise; + resolveClientEntries( + config: FarmCliOptions & UserConfig + ): Promise<{ entries: string[]; publicPath: string }>; + createClientPreviewServer( + config: FarmCliOptions & UserConfig + ): Promise; + createHostServer(middlewares: SsrMiddleware): SsrDevHostServerLike; + readFile(filePath: string): Promise; + importModule(filePath: string): Promise>; + listFiles(dir: string): Promise; + writeFile(filePath: string, content: string): Promise; +} + +function shouldRetryImportWithoutCss(error: unknown) { + if (!(error instanceof Error)) { + return false; + } + + const nodeError = error as Error & { code?: string }; + return ( + nodeError.code === 'ERR_UNKNOWN_FILE_EXTENSION' && + error.message.includes('.css') + ); +} + +function rewriteModuleImportsForDataUrl(params: { + code: string; + filePath: string; +}) { + const moduleDir = path.dirname(params.filePath); + const originalFileUrl = pathToFileURL(params.filePath).href; + const importStatementRE = + /import\s+(?:[^'"`]*?\s+from\s+)?(['"])([^'"]+)\1\s*;?/g; + const rewrittenImports = params.code.replace( + importStatementRE, + (statement: string, quote: string, specifier: string) => { + if (specifier.endsWith('.css')) { + return ''; + } + + if (!specifier.startsWith('.')) { + return statement; + } + + const absoluteFileUrl = pathToFileURL( + path.resolve(moduleDir, specifier) + ).href; + return statement.replace( + `${quote}${specifier}${quote}`, + `${quote}${absoluteFileUrl}${quote}` + ); + } + ); + + return rewrittenImports.replaceAll( + 'createRequire(import.meta.url)', + `createRequire(${JSON.stringify(originalFileUrl)})` + ); +} + +export async function importModuleWithCssInterop( + filePath: string, + readFile: (filePath: string, encoding: BufferEncoding) => Promise +) { + const fileUrl = pathToFileURL(filePath).href; + + try { + return (await import(fileUrl)) as Record; + } catch (error) { + if (!shouldRetryImportWithoutCss(error)) { + throw error; + } + } + + const source = await readFile(filePath, 'utf-8'); + const rewrittenSource = rewriteModuleImportsForDataUrl({ + code: source, + filePath + }); + const dataUrl = `data:text/javascript;base64,${Buffer.from(rewrittenSource).toString('base64')}`; + + return (await import(dataUrl)) as Record; +} + +const defaultFactories: SsrBuildPreviewFactories = { + runBuild(config) { + return build(config); + }, + async resolveBuildOutput(config) { + const resolved = await resolveConfig(config, 'build', 'production'); + const root = resolved.root; + const rawOutputPath = resolved.compilation.output.path; + const outputPath = path.isAbsolute(rawOutputPath) + ? rawOutputPath + : path.join(root, rawOutputPath); + + return { + root, + outputPath + }; + }, + async resolveClientEntries(config) { + const resolved = await resolveConfig(config, 'build', 'production'); + const inputs = Object.values(resolved.compilation.input ?? {}); + return { + entries: inputs.length ? inputs : [], + publicPath: resolved.compilation.output.publicPath ?? '/' + }; + }, + async createClientPreviewServer(config) { + const previewServer = new PreviewServer(config); + await previewServer.createPreviewServer(); + + return { + middlewares: previewServer.middlewares as unknown as SsrMiddleware, + close: () => previewServer.close() + }; + }, + readFile(filePath) { + return fs.readFile(filePath, 'utf-8'); + }, + importModule(filePath) { + return importModuleWithCssInterop(filePath, fs.readFile); + }, + async listFiles(dir) { + const results: string[] = []; + const walk = async (current: string) => { + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + if (entry.isFile()) { + const relPath = path.relative(dir, fullPath); + results.push(relPath.split(path.sep).join('/')); + } + } + }; + await walk(dir); + return results; + }, + writeFile(filePath, content) { + return fs.writeFile(filePath, content, 'utf-8'); + }, + createHostServer(middlewares) { + return createNodeServer(middlewares) as SsrDevHostServerLike; + } +}; + +const DEFAULT_PREVIEW_ENTRY = 'index.js'; +const PREVIEW_MANIFEST_CANDIDATES = [ + 'manifest.json', + 'ssr-manifest.json' +] as const; +const SSR_MANIFEST_CLIENT = 'manifest.client.json'; +const SSR_MANIFEST_SERVER = 'manifest.server.json'; +const SSR_BUILD_INFO = 'build-info.json'; + +function isHtmlRequest(req: IncomingMessage) { + const method = req.method?.toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + return false; + } + + const url = req.url ?? '/'; + if (url.includes('/@') || url.includes('/__')) { + return false; + } + const queryIndex = url.indexOf('?'); + const pathname = queryIndex >= 0 ? url.slice(0, queryIndex) : url; + if (/\.[a-zA-Z0-9]+$/.test(pathname)) { + return false; + } + + const accept = req.headers.accept; + if (!accept) { + return true; + } + + return accept.includes('text/html') || accept.includes('*/*'); +} + +function createMiddlewareServer() { + const stack: SsrMiddleware[] = []; + + const middlewareServer = (( + req: IncomingMessage, + res: ServerResponse, + next?: (error?: unknown) => void + ) => { + let index = 0; + + const run = (error?: unknown) => { + if (error) { + next?.(error); + return; + } + + const middleware = stack[index++]; + if (!middleware) { + next?.(); + return; + } + + try { + middleware(req, res, run); + } catch (err) { + run(err); + } + }; + + run(); + }) as SsrMiddlewareServer; + + middlewareServer.use = (middleware) => { + stack.push(middleware); + }; + + return middlewareServer; +} + +function resolveDefaultListenOptions( + options: SsrPreviewOptions +): Required { + const previewConfig = options.client.server?.preview ?? {}; + const fallbackHost = + typeof previewConfig.host === 'string' ? previewConfig.host : '127.0.0.1'; + + return { + port: options.host?.port ?? previewConfig.port ?? 1911, + hostname: options.host?.hostname ?? fallbackHost + }; +} + +function listenHostServer( + hostServer: SsrDevHostServerLike, + options: Required +) { + return new Promise((resolve, reject) => { + const onListening = () => { + hostServer.off('error', onError); + resolve(); + }; + const onError = (error: unknown) => { + hostServer.off('listening', onListening); + reject(error); + }; + + hostServer.once('listening', onListening); + hostServer.once('error', onError); + hostServer.listen(options.port, options.hostname); + }); +} + +function closeHostServer(hostServer: SsrDevHostServerLike) { + if (!hostServer.listening) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + hostServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function closeWithOrder(params: { + hostServer: SsrDevHostServerLike; + clientPreviewServer: SsrPreviewClientServerLike; +}) { + const results = await Promise.allSettled([ + closeHostServer(params.hostServer), + params.clientPreviewServer.close() + ]); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + + if (rejected) { + throw rejected.reason; + } +} + +function resolvePreviewEntryFilePath(params: { + entry: string; + serverBuildOutput: SsrResolvedBuildOutput; +}) { + if (path.isAbsolute(params.entry)) { + return params.entry; + } + + return path.join(params.serverBuildOutput.outputPath, params.entry); +} + +function resolvePreviewEntry(entry?: string) { + return entry ?? DEFAULT_PREVIEW_ENTRY; +} + +function resolveManifestFileCandidates( + clientBuildOutput: SsrResolvedBuildOutput +) { + return PREVIEW_MANIFEST_CANDIDATES.map((filename) => + path.join(clientBuildOutput.outputPath, filename) + ); +} + +function resolveBuildInfoPath(clientBuildOutput: SsrResolvedBuildOutput) { + return path.join(clientBuildOutput.outputPath, SSR_BUILD_INFO); +} + +function resolveClientManifestPath(clientBuildOutput: SsrResolvedBuildOutput) { + return path.join(clientBuildOutput.outputPath, SSR_MANIFEST_CLIENT); +} + +function resolveServerManifestPath(serverBuildOutput: SsrResolvedBuildOutput) { + return path.join(serverBuildOutput.outputPath, SSR_MANIFEST_SERVER); +} + +function createManifestFromFiles(params: { + files: string[]; + entries: string[]; +}): SsrManifest { + const jsFiles = params.files.filter( + (file) => + file.endsWith('.js') || file.endsWith('.mjs') || file.endsWith('.cjs') + ); + const cssFiles = params.files.filter((file) => file.endsWith('.css')); + const entries: Record< + string, + { js: string[]; css: string[]; preload: string[] } + > = {}; + + for (const entry of params.entries) { + entries[entry] = { + js: [...jsFiles], + css: [...cssFiles], + preload: [...jsFiles] + }; + } + + if (params.entries.length === 0) { + entries['__all__'] = { + js: [...jsFiles], + css: [...cssFiles], + preload: [...jsFiles] + }; + } + + return { + version: 1, + entries, + modules: {} + }; +} + +async function writeBuildArtifacts(params: { + options: SsrBuildOptions; + factories: SsrBuildPreviewFactories; +}): Promise { + const clientBuildOutput = await params.factories.resolveBuildOutput( + params.options.client + ); + const serverBuildOutput = await params.factories.resolveBuildOutput( + params.options.server + ); + const { entries } = await params.factories.resolveClientEntries( + params.options.client + ); + const outputFiles = await params.factories.listFiles( + clientBuildOutput.outputPath + ); + const manifest = createManifestFromFiles({ + files: outputFiles, + entries + }); + const manifestPath = resolveClientManifestPath(clientBuildOutput); + const serverManifestPath = resolveServerManifestPath(serverBuildOutput); + const serverEntry = path.join( + serverBuildOutput.outputPath, + DEFAULT_PREVIEW_ENTRY + ); + const buildInfo: SsrBuildInfo = { + version: 1, + client: { + outputDir: clientBuildOutput.outputPath, + manifest: manifestPath, + entry: entries[0] ?? undefined + }, + server: { + outputDir: serverBuildOutput.outputPath, + entry: serverEntry + } + }; + + await params.factories.writeFile( + manifestPath, + JSON.stringify(manifest, null, 2) + ); + await params.factories.writeFile( + serverManifestPath, + JSON.stringify({ version: 1, entries: {}, modules: {} }, null, 2) + ); + await params.factories.writeFile( + resolveBuildInfoPath(clientBuildOutput), + JSON.stringify(buildInfo, null, 2) + ); +} + +function resolveTemplateFilePath(params: { + templateFile?: string; + clientBuildOutput: SsrResolvedBuildOutput; +}) { + if (!params.templateFile) { + return path.join(params.clientBuildOutput.outputPath, 'index.html'); + } + + if (path.isAbsolute(params.templateFile)) { + return params.templateFile; + } + + return path.join(params.clientBuildOutput.root, params.templateFile); +} + +function resolveTemplateFileCandidates(params: { + templateFile?: string; + clientBuildOutput: SsrResolvedBuildOutput; +}) { + const defaultFilePath = path.join( + params.clientBuildOutput.outputPath, + 'index.html' + ); + + if (!params.templateFile) { + return [defaultFilePath]; + } + + if (path.isAbsolute(params.templateFile)) { + return [params.templateFile]; + } + + const normalizedTemplateFile = params.templateFile.replace(/^\/+/, ''); + const candidates = [ + path.join(params.clientBuildOutput.outputPath, normalizedTemplateFile), + path.join(params.clientBuildOutput.root, params.templateFile) + ]; + + return [...new Set(candidates)]; +} + +async function loadTemplateContent(params: { + options: SsrPreviewRenderOptions; + clientBuildOutput: SsrResolvedBuildOutput; + req: IncomingMessage; + res: ServerResponse; + url: string; + factories: SsrBuildPreviewFactories; +}) { + const template = params.options.template ?? {}; + const loadContext: SsrTemplateLoadContext = { + url: params.url, + req: params.req, + res: params.res, + root: params.clientBuildOutput.root + }; + + if (template.load) { + return await template.load(loadContext); + } + + const templateFileCandidates = resolveTemplateFileCandidates({ + templateFile: template.file, + clientBuildOutput: params.clientBuildOutput + }); + let lastReadError: unknown; + + for (const filePath of templateFileCandidates) { + try { + return await params.factories.readFile(filePath); + } catch (error) { + lastReadError = error; + const code = (error as { code?: unknown })?.code; + if (code !== 'ENOENT') { + throw error; + } + } + } + + throw lastReadError; +} + +function resolveRenderResult(params: { + options: SsrPreviewRenderOptions; + moduleExports: Record; + context: SsrPreviewRenderContext; +}) { + if (params.options.render) { + return params.options.render(params.moduleExports, params.context); + } + + const exportName = params.options.exportName ?? 'default'; + const render = params.moduleExports[exportName]; + const entry = resolvePreviewEntry(params.options.entry); + + if (typeof render !== 'function') { + throw new Error( + `[farm ssr] export "${exportName}" from "${entry}" must be a function or provide ssr.render().` + ); + } + + return (render as (url: string, ctx: unknown) => unknown)( + params.context.url, + params.context + ); +} + +export async function renderSsrPreviewHtml(params: { + options: SsrPreviewRenderOptions; + clientBuildOutput: SsrResolvedBuildOutput; + loadModule: () => Promise>; + factories: SsrBuildPreviewFactories; + url: string; + req: IncomingMessage; + res: ServerResponse; + mode: string; + runtime: SsrRuntimeMeta; + assets?: SsrRuntimeAssets; + hooks?: SsrRuntimeHooks; + requestId?: string; +}): Promise { + const requestId = params.requestId ?? randomUUID(); + const context: SsrPreviewRenderContext = { + url: params.url, + req: params.req, + res: params.res, + command: 'preview', + mode: params.mode, + runtime: params.runtime, + assets: params.assets ?? { css: [], preload: [], scripts: [] }, + requestId + }; + + const startTime = Date.now(); + params.hooks?.onRenderStart?.({ + requestId, + url: context.url, + command: context.command, + mode: context.mode, + runtime: context.runtime + }); + + try { + const moduleExports = await params.loadModule(); + const renderResult = await resolveRenderResult({ + options: params.options, + moduleExports, + context + }); + const appHtml = + typeof renderResult === 'string' ? renderResult : String(renderResult); + const template = await loadTemplateContent({ + options: params.options, + clientBuildOutput: params.clientBuildOutput, + req: params.req, + res: params.res, + url: context.url, + factories: params.factories + }); + const templateOptions = params.options.template ?? {}; + + if (templateOptions.transform) { + const html = await templateOptions.transform({ + url: context.url, + req: params.req, + res: params.res, + root: params.clientBuildOutput.root, + appHtml, + template + }); + params.hooks?.onRenderEnd?.({ + requestId, + url: context.url, + command: context.command, + mode: context.mode, + runtime: context.runtime, + ms: Date.now() - startTime + }); + return html; + } + + const placeholder = templateOptions.placeholder ?? ''; + + if (!template.includes(placeholder)) { + throw new Error( + `[farm ssr] template placeholder "${placeholder}" was not found.` + ); + } + + const html = template.replace(placeholder, appHtml); + const injected = injectAssetsIntoHtml({ html, assets: context.assets }); + params.hooks?.onAssetInject?.({ + requestId, + url: context.url, + css: context.assets.css.length, + preload: context.assets.preload.length, + scripts: context.assets.scripts.length + }); + params.hooks?.onRenderEnd?.({ + requestId, + url: context.url, + command: context.command, + mode: context.mode, + runtime: context.runtime, + ms: Date.now() - startTime + }); + return injected; + } catch (error) { + const ssrError = toSsrError({ + code: 'SSR_RENDER_FAILED', + error, + debug: params.mode === 'development' + }); + params.hooks?.onRenderEnd?.({ + requestId, + url: context.url, + command: context.command, + mode: context.mode, + runtime: context.runtime, + ms: Date.now() - startTime, + error: ssrError + }); + throw error; + } +} + +async function loadSsrManifest(params: { + metadata: SsrResolvedPreviewMetadata; + factories: SsrBuildPreviewFactories; +}): Promise { + const candidates: string[] = []; + if (params.metadata.manifestFilePath) { + candidates.push(params.metadata.manifestFilePath); + } + candidates.push(...params.metadata.manifestFileCandidates); + + for (const filePath of candidates) { + try { + const content = await params.factories.readFile(filePath); + return JSON.parse(content) as SsrManifest; + } catch (error) { + const code = (error as { code?: unknown })?.code; + if (code !== 'ENOENT') { + throw error; + } + } + } + + return null; +} + +function resolveManifestEntryKey(params: { + buildInfo: SsrBuildInfo | null; + manifest: SsrManifest | null; + renderEntry?: string; +}): string | null { + if (params.buildInfo?.client?.entry) { + return params.buildInfo.client.entry; + } + + if (params.renderEntry) { + return params.renderEntry; + } + + if (params.manifest) { + const keys = Object.keys(params.manifest.entries); + if (keys.length) { + return keys[0]; + } + } + + return null; +} + +async function loadBuildInfo(params: { + metadata: SsrResolvedPreviewMetadata; + factories: SsrBuildPreviewFactories; +}): Promise { + if (!params.metadata.buildInfoPath) { + return null; + } + + try { + const content = await params.factories.readFile( + params.metadata.buildInfoPath + ); + return JSON.parse(content) as SsrBuildInfo; + } catch (error) { + const code = (error as { code?: unknown })?.code; + if (code !== 'ENOENT') { + throw error; + } + } + + return null; +} + +function createSsrPreviewRenderMiddleware(params: { + options: SsrPreviewRenderOptions; + clientBuildOutput: SsrResolvedBuildOutput; + loadModule: () => Promise>; + factories: SsrBuildPreviewFactories; + runtime: SsrRuntimeMeta; + mode: string; + assets?: SsrRuntimeAssets; + hooks?: SsrRuntimeHooks; +}) { + return async ( + req: IncomingMessage, + res: ServerResponse, + next: (error?: unknown) => void + ) => { + const context: SsrPreviewRenderContext = { + url: req.url ?? '/', + req, + res, + command: 'preview', + mode: params.mode, + runtime: params.runtime, + assets: { css: [], preload: [], scripts: [] } + }; + const shouldHandle = + params.options.shouldHandle ?? ((ctx) => isHtmlRequest(ctx.req)); + + if (!shouldHandle(context)) { + next(); + return; + } + + try { + const html = await renderSsrPreviewHtml({ + options: params.options, + clientBuildOutput: params.clientBuildOutput, + loadModule: params.loadModule, + factories: params.factories, + url: context.url, + req, + res, + mode: params.mode, + runtime: params.runtime, + assets: params.assets, + hooks: params.hooks + }); + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + } catch (error) { + if (params.options.onError) { + await params.options.onError(error, { ...context, next }); + return; + } + + const stack = + error instanceof Error ? (error.stack ?? error.message) : String(error); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(stack); + } + }; +} + +export async function buildSsrAppWithFactories( + options: SsrBuildOptions, + factories: SsrBuildPreviewFactories +) { + const resolvedClient = options.$client + ? { ...options.client, ...options.$client } + : options.client; + const resolvedServer = options.$server + ? { ...options.server, ...options.$server } + : options.server; + const clientStart = Date.now(); + options.hooks?.onCompileStart?.({ kind: 'client' }); + await factories.runBuild(resolvedClient); + options.hooks?.onCompileEnd?.({ + kind: 'client', + ms: Date.now() - clientStart + }); + + const serverStart = Date.now(); + options.hooks?.onCompileStart?.({ kind: 'server' }); + await factories.runBuild(resolvedServer); + options.hooks?.onCompileEnd?.({ + kind: 'server', + ms: Date.now() - serverStart + }); + await writeBuildArtifacts({ + options: { + ...options, + client: resolvedClient, + server: resolvedServer + }, + factories + }); +} + +export async function buildSsrApp(options: SsrBuildOptions) { + await buildSsrAppWithFactories(options, defaultFactories); +} + +export async function createSsrPreviewServerWithFactories( + options: SsrPreviewOptions, + factories: SsrBuildPreviewFactories +): Promise { + if (options.ssr && options.ssrMiddleware) { + throw new Error( + '[farm ssr] "ssr" and "ssrMiddleware" cannot be used together.' + ); + } + + const previewMetadata = await resolveSsrPreviewMetadataWithFactories( + options, + factories + ); + const clientBuildOutput = previewMetadata.clientBuildOutput; + const serverBuildOutput = previewMetadata.serverBuildOutput; + const buildInfo = await loadBuildInfo({ + metadata: previewMetadata, + factories + }); + const manifest = await loadSsrManifest({ + metadata: previewMetadata, + factories + }); + const { publicPath } = await factories.resolveClientEntries(options.client); + + let clientPreviewServer: SsrPreviewClientServerLike | null = null; + + try { + clientPreviewServer = await factories.createClientPreviewServer( + options.client + ); + } catch (error) { + await clientPreviewServer?.close().catch(() => undefined); + throw error; + } + + const middlewares = createMiddlewareServer(); + const runtimeMeta: SsrRuntimeMeta = { + root: clientBuildOutput.root, + publicPath + }; + const runtimeMode = options.client.mode ?? 'production'; + const manifestEntryKey = resolveManifestEntryKey({ + buildInfo, + manifest, + renderEntry: options.ssr?.entry + }); + const resolvedAssets = resolveAssetsFromManifest({ + manifest, + entry: manifestEntryKey, + usedModuleIds: [], + publicPath: runtimeMeta.publicPath + }); + + if (options.ssrMiddleware) { + middlewares.use(options.ssrMiddleware); + } else if ( + options.ssr && + previewMetadata.serverEntryFilePath && + serverBuildOutput + ) { + const renderOptions = { + ...options.ssr, + entry: resolvePreviewEntry(options.ssr.entry) + }; + const entryFile = previewMetadata.serverEntryFilePath; + let loadedModulePromise: Promise> | null = null; + + middlewares.use( + createSsrPreviewRenderMiddleware({ + options: renderOptions, + clientBuildOutput, + factories, + runtime: runtimeMeta, + mode: runtimeMode, + assets: resolvedAssets, + hooks: options.hooks, + loadModule() { + if (!loadedModulePromise) { + loadedModulePromise = factories.importModule(entryFile); + } + + return loadedModulePromise; + } + }) + ); + } + + middlewares.use(clientPreviewServer.middlewares); + + const hostServer = factories.createHostServer(middlewares); + const defaultListenOptions = resolveDefaultListenOptions(options); + let closed = false; + + return { + middlewares, + async render(url, req, res) { + if ( + !options.ssr || + !previewMetadata.serverEntryFilePath || + !serverBuildOutput + ) { + throw new Error( + '[farm ssr] ssr options are required to call render() in preview.' + ); + } + const renderOptions = { + ...options.ssr, + entry: resolvePreviewEntry(options.ssr.entry) + }; + const entryFile = previewMetadata.serverEntryFilePath; + let loadedModulePromise: Promise> | null = null; + return renderSsrPreviewHtml({ + options: renderOptions, + clientBuildOutput, + factories, + url, + req, + res, + mode: runtimeMode, + runtime: runtimeMeta, + assets: resolvedAssets, + hooks: options.hooks, + loadModule() { + if (!loadedModulePromise) { + loadedModulePromise = factories.importModule(entryFile); + } + return loadedModulePromise; + } + }); + }, + async listen(listenOptions) { + if (closed) { + throw new Error('[farm ssr] preview server is already closed.'); + } + + if (hostServer.listening) { + return; + } + + await listenHostServer(hostServer, { + ...defaultListenOptions, + ...(listenOptions ?? {}) + }); + }, + async close() { + if (closed) { + return; + } + closed = true; + await closeWithOrder({ + hostServer, + clientPreviewServer + }); + } + }; +} + +export async function createSsrPreviewServer( + options: SsrPreviewOptions +): Promise { + return createSsrPreviewServerWithFactories(options, defaultFactories); +} + +export async function resolveSsrPreviewMetadataWithFactories( + options: SsrPreviewOptions, + factories: SsrBuildPreviewFactories +): Promise { + const clientBuildOutput = await factories.resolveBuildOutput(options.client); + const serverBuildOutput = options.ssr + ? await factories.resolveBuildOutput(options.server) + : null; + const buildInfoPath = resolveBuildInfoPath(clientBuildOutput); + let buildInfo: SsrBuildInfo | null = null; + let manifestFilePath: string | null = null; + + try { + const buildInfoContent = await factories.readFile(buildInfoPath); + buildInfo = JSON.parse(buildInfoContent) as SsrBuildInfo; + if (buildInfo?.client?.manifest) { + manifestFilePath = buildInfo.client.manifest; + } + } catch (error) { + const code = (error as { code?: unknown })?.code; + if (code !== 'ENOENT') { + throw error; + } + } + const templateFilePath = options.ssr + ? resolveTemplateFilePath({ + templateFile: options.ssr.template?.file, + clientBuildOutput + }) + : null; + const serverEntryFilePath = + options.ssr && serverBuildOutput + ? resolvePreviewEntryFilePath({ + entry: resolvePreviewEntry(options.ssr.entry), + serverBuildOutput + }) + : null; + + return { + clientBuildOutput, + serverBuildOutput, + templateFilePath, + serverEntryFilePath: buildInfo?.server?.entry ?? serverEntryFilePath, + buildInfoPath: buildInfo ? buildInfoPath : null, + manifestFilePath, + manifestFileCandidates: resolveManifestFileCandidates(clientBuildOutput) + }; +} + +export async function resolveSsrPreviewMetadata( + options: SsrPreviewOptions +): Promise { + return resolveSsrPreviewMetadataWithFactories(options, defaultFactories); +} + +export async function startSsrPreviewServerWithFactories( + options: SsrPreviewOptions, + factories: SsrBuildPreviewFactories +): Promise { + const previewServer = await createSsrPreviewServerWithFactories( + options, + factories + ); + + try { + await previewServer.listen(options.host); + return previewServer; + } catch (error) { + await previewServer.close().catch(() => undefined); + throw error; + } +} + +export async function startSsrPreviewServer( + options: SsrPreviewOptions +): Promise { + return startSsrPreviewServerWithFactories(options, defaultFactories); +} + +export async function previewSsrApp(options: SsrPreviewOptions) { + return startSsrPreviewServer(options); +} diff --git a/packages/ssr/src/command.ts b/packages/ssr/src/command.ts new file mode 100644 index 0000000000..5ef6822848 --- /dev/null +++ b/packages/ssr/src/command.ts @@ -0,0 +1,77 @@ +import { buildSsrApp, type SsrBuildOptions } from './build-preview.js'; +import { + resolveSsrConfigForCommand, + type SsrConfig +} from './config-resolver.js'; +import { + createSsrRuntime, + type SsrRuntime, + type SsrRuntimeConfig +} from './runtime.js'; + +export type SsrToolkitCommand = 'dev' | 'preview' | 'build'; + +export type SsrRunServerCommandOptions = SsrRuntimeConfig & { + start?: boolean; +}; + +export type SsrRunBuildCommandOptions = SsrBuildOptions & { + command: 'build'; +}; + +export type SsrRunCommandOptions = + | SsrRunServerCommandOptions + | SsrRunBuildCommandOptions + | (SsrConfig & SsrRunBuildCommandOptions); + +export type SsrRunCommandResult = void | SsrRuntime; + +interface SsrRunCommandResolvers { + buildSsrApp(options: SsrBuildOptions): Promise; + createSsrRuntime(options: SsrRuntimeConfig): Promise; +} + +const defaultResolvers: SsrRunCommandResolvers = { + buildSsrApp, + createSsrRuntime +}; + +function isBuildCommand( + options: SsrRunCommandOptions +): options is SsrRunBuildCommandOptions { + return options.command === 'build'; +} + +export async function runSsrCommandWithResolvers( + options: SsrRunCommandOptions, + resolvers: SsrRunCommandResolvers +): Promise { + if (isBuildCommand(options)) { + const resolved = resolveSsrConfigForCommand(options as SsrConfig, 'build'); + await resolvers.buildSsrApp({ + ...(options as SsrBuildOptions), + client: resolved.client, + server: resolved.server ?? (options as SsrBuildOptions).server + }); + return; + } + + const { start: _start, ...runtimeOptions } = + options as SsrRunServerCommandOptions; + const shouldStart = options.start ?? true; + const runtime = await resolvers.createSsrRuntime( + runtimeOptions as SsrRuntimeConfig + ); + + if (shouldStart) { + await runtime.start(); + } + + return runtime; +} + +export async function runSsrCommand( + options: SsrRunCommandOptions +): Promise { + return runSsrCommandWithResolvers(options, defaultResolvers); +} diff --git a/packages/ssr/src/config-resolver.ts b/packages/ssr/src/config-resolver.ts new file mode 100644 index 0000000000..6b6c61b2cf --- /dev/null +++ b/packages/ssr/src/config-resolver.ts @@ -0,0 +1,61 @@ +import type { FarmCliOptions, UserConfig } from '@farmfe/core'; + +export type SsrConfig = { + root?: string; + mode?: string; + command?: 'dev' | 'preview' | 'build'; + ssr?: unknown; + hooks?: unknown; + host?: unknown; + runner?: unknown; + client?: FarmCliOptions & UserConfig; + server?: FarmCliOptions & UserConfig; + $client?: FarmCliOptions & UserConfig; + $server?: FarmCliOptions & UserConfig; +}; + +export function resolveSsrConfigForCommand( + config: T, + command: 'dev' | 'preview' | 'build' +): { + root?: string; + mode?: string; + ssr?: T['ssr']; + hooks?: T['hooks']; + host?: T['host']; + runner?: T['runner']; + client: FarmCliOptions & UserConfig; + server?: FarmCliOptions & UserConfig; +} { + const baseClient = config.client ?? ({} as FarmCliOptions & UserConfig); + const baseServer = config.server ?? ({} as FarmCliOptions & UserConfig); + const overrideClient = config.$client ?? ({} as FarmCliOptions & UserConfig); + const overrideServer = config.$server ?? ({} as FarmCliOptions & UserConfig); + + const resolvedClient = { + ...baseClient, + ...overrideClient + }; + const resolvedServer = config.server + ? { + ...baseServer, + ...overrideServer + } + : config.$server + ? { + ...baseServer, + ...overrideServer + } + : undefined; + + return { + root: config.root, + mode: config.mode, + ssr: config.ssr, + hooks: config.hooks, + host: config.host, + runner: config.runner, + client: resolvedClient, + server: resolvedServer + }; +} diff --git a/packages/ssr/src/dev-server.ts b/packages/ssr/src/dev-server.ts new file mode 100644 index 0000000000..575feb97b1 --- /dev/null +++ b/packages/ssr/src/dev-server.ts @@ -0,0 +1,1176 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { + createServer as createNodeServer, + type IncomingMessage, + type ServerResponse +} from 'node:http'; +import path from 'node:path'; +import { + createCompiler, + createModuleRunnerTransportFromInvokeHandlers, + type FarmCliOptions, + FarmModuleRunner, + type FarmModuleRunnerOptions, + type ModuleRunnerInvokeCompiler, + normalizePath, + type RunnerHotPayload, + resolveConfig, + Server, + type UserConfig +} from '@farmfe/core'; +import { createModuleRunnerInvokeHandlers } from '@farmfe/core/module-runner/serverInvoke'; +import { toSsrError } from './errors.js'; +import type { SsrRuntimeHooks } from './runtime-hooks.js'; +import type { + SsrRuntimeAssets, + SsrRuntimeCommand, + SsrRuntimeMeta +} from './runtime-types.js'; + +type SsrUpdateType = 'added' | 'updated' | 'removed'; + +type SsrUpdateItem = { + path: string; + type: SsrUpdateType; +}; + +type SsrUpdateResult = { + added: string[]; + changed: string[]; + removed: string[]; + extraWatchResult: { + add: string[]; + }; +}; + +export interface SsrDevServerListenOptions { + port?: number; + hostname?: string; +} + +export interface SsrDevServerOptions { + client: FarmCliOptions & UserConfig; + server?: FarmCliOptions & UserConfig; + host?: SsrDevServerListenOptions; + ssrMiddleware?: SsrNextMiddleware; + ssr?: SsrRenderOptions; + hooks?: SsrRuntimeHooks; + runner?: Omit; +} + +export interface SsrDevServer { + middlewares: SsrMiddlewareServer; + runner: FarmModuleRunner; + render( + url: string, + req: IncomingMessage, + res: ServerResponse + ): Promise; + listen(options?: SsrDevServerListenOptions): Promise; + close(): Promise; +} + +export type SsrMiddleware = ( + req: IncomingMessage, + res: ServerResponse, + next?: (error?: unknown) => void +) => void; + +export type SsrNextMiddleware = ( + req: IncomingMessage, + res: ServerResponse, + next: (error?: unknown) => void +) => void; + +export type SsrMiddlewareServer = SsrMiddleware & { + use(middleware: SsrMiddleware): void; +}; + +export interface SsrTemplateLoadContext { + url: string; + req: IncomingMessage; + res: ServerResponse; + root: string; +} + +export interface SsrTemplateRenderContext extends SsrTemplateLoadContext { + appHtml: string; + template: string; +} + +export interface SsrTemplateOptions { + resource?: string; + file?: string; + placeholder?: string; + load?: (context: SsrTemplateLoadContext) => string | Promise; + transform?: (context: SsrTemplateRenderContext) => string | Promise; +} + +export interface SsrRenderContext { + url: string; + req: IncomingMessage; + res: ServerResponse; + command: SsrRuntimeCommand; + mode: string; + runtime: SsrRuntimeMeta; + assets: SsrRuntimeAssets; + runner?: FarmModuleRunner; + requestId?: string; +} + +export interface SsrRenderOptions { + entry: string; + exportName?: string; + template: SsrTemplateOptions; + shouldHandle?: (ctx: SsrRenderContext) => boolean; + render?: (mod: Record, ctx: SsrRenderContext) => unknown; + onError?: ( + error: unknown, + ctx: SsrRenderContext & { next: (error?: unknown) => void } + ) => void | Promise; +} + +interface SsrTemplateResourceReader { + resource: (name: string) => Buffer | string | undefined | null; +} + +export interface SsrDevWatcherLike { + on( + event: 'add' | 'change' | 'unlink', + listener: (path: string) => Promise | void + ): unknown; + add(paths: string | string[]): unknown; + filterWatchFile(file: string, root: string): boolean; +} + +export interface SsrDevFarmServerLike { + middlewares: SsrMiddleware; + root?: string; + getCompiler?: () => SsrTemplateResourceReader | undefined | null; + watcher?: SsrDevWatcherLike; + createModuleRunner( + options?: Omit + ): Promise; + close(): Promise; +} + +export interface SsrDevHostServerLike { + listening: boolean; + listen(port: number, hostname?: string): SsrDevHostServerLike; + close( + callback?: (error?: Error | undefined) => void + ): SsrDevHostServerLike | void; + once( + event: 'listening' | 'error', + listener: (...args: unknown[]) => void + ): this; + off( + event: 'listening' | 'error', + listener: (...args: unknown[]) => void + ): this; +} + +export interface SsrDevCompilerLike extends ModuleRunnerInvokeCompiler { + compile(): Promise; + update(paths: SsrUpdateItem[]): Promise; + hasModule(path: string): boolean; + transformModulePath(root: string, path: string): string; + resolvedModulePaths?(root: string): string[]; + invalidateModule?(moduleId: string): void; +} + +export interface SsrDevServerCompilerCreateResult { + root: string; + publicPath: string; + compiler: SsrDevCompilerLike; +} + +export interface SsrDevServerFactories { + createFarmServer( + config: FarmCliOptions & UserConfig + ): Promise; + createHostServer(middlewares: SsrMiddleware): SsrDevHostServerLike; + createServerCompiler( + config: FarmCliOptions & UserConfig + ): Promise; +} + +const DEFAULT_HMR_PORT_RETRY_BASE = 9811; +const DEFAULT_HMR_PORT_RETRY_MAX_ATTEMPTS = 30; + +const defaultFactories: SsrDevServerFactories = { + createFarmServer(config) { + return Server.createServer(config); + }, + createHostServer(middlewares) { + return createNodeServer(middlewares) as SsrDevHostServerLike; + }, + async createServerCompiler(config) { + const resolved = await resolveConfig(config, 'dev', 'development'); + const compiler = createCompiler(resolved) as unknown as SsrDevCompilerLike; + await compiler.compile(); + return { + root: resolved.root, + publicPath: resolved.compilation.output.publicPath ?? '/', + compiler + }; + } +}; + +function isHtmlRequest(req: IncomingMessage) { + const method = req.method?.toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + return false; + } + + const url = req.url ?? '/'; + if (url.includes('/@') || url.includes('/__')) { + return false; + } + const queryIndex = url.indexOf('?'); + const pathname = queryIndex >= 0 ? url.slice(0, queryIndex) : url; + if (/\.[a-zA-Z0-9]+$/.test(pathname)) { + return false; + } + + const accept = req.headers.accept; + if (!accept) { + return true; + } + + return accept.includes('text/html') || accept.includes('*/*'); +} + +function createMiddlewareServer() { + const stack: SsrMiddleware[] = []; + + const middlewareServer = (( + req: IncomingMessage, + res: ServerResponse, + next?: (error?: unknown) => void + ) => { + let index = 0; + + const run = (error?: unknown) => { + if (error) { + next?.(error); + return; + } + + const middleware = stack[index++]; + if (!middleware) { + next?.(); + return; + } + + try { + middleware(req, res, run); + } catch (err) { + run(err); + } + }; + + run(); + }) as SsrMiddlewareServer; + + middlewareServer.use = (middleware) => { + stack.push(middleware); + }; + + return middlewareServer; +} + +function resolveDefaultListenOptions( + options: SsrDevServerOptions +): Required { + const clientServer = options.client.server ?? {}; + const fallbackHost = + typeof clientServer.host === 'string' ? clientServer.host : '127.0.0.1'; + + return { + port: options.host?.port ?? clientServer.port ?? 3000, + hostname: options.host?.hostname ?? fallbackHost + }; +} + +function resolveClientConfig( + clientConfig: FarmCliOptions & UserConfig +): FarmCliOptions & UserConfig { + const resolvedServerConfig = { + ...(clientConfig.server ?? {}), + middlewareMode: true + }; + + return { + ...clientConfig, + server: resolvedServerConfig + }; +} + +function isHmrPortInUseError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const message = error.stack ?? error.message; + return ( + message.includes('WebSocket server error: Port is already in use') || + message.includes('EADDRINUSE') + ); +} + +function resolveHmrRetryBasePort(config: FarmCliOptions & UserConfig): number { + const userServerConfig = config.server; + + if ( + userServerConfig?.hmr && + typeof userServerConfig.hmr === 'object' && + typeof userServerConfig.hmr.port === 'number' && + Number.isInteger(userServerConfig.hmr.port) && + userServerConfig.hmr.port > 0 + ) { + return userServerConfig.hmr.port; + } + + if ( + typeof userServerConfig?.port === 'number' && + Number.isInteger(userServerConfig.port) && + userServerConfig.port > 0 + ) { + return userServerConfig.port; + } + + return DEFAULT_HMR_PORT_RETRY_BASE; +} + +function applyHmrPortToClientConfig( + config: FarmCliOptions & UserConfig, + port: number +): FarmCliOptions & UserConfig { + const userServerConfig = config.server ?? {}; + + if (userServerConfig.hmr === false) { + return config; + } + + const normalizedHmr = + userServerConfig.hmr && typeof userServerConfig.hmr === 'object' + ? userServerConfig.hmr + : {}; + + return { + ...config, + server: { + ...userServerConfig, + hmr: { + ...normalizedHmr, + port + } + } + }; +} + +async function createFarmServerWithHmrPortRetry(params: { + factories: SsrDevServerFactories; + clientConfig: FarmCliOptions & UserConfig; +}): Promise<{ + farmServer: SsrDevFarmServerLike; + resolvedClientConfig: FarmCliOptions & UserConfig; +}> { + const initialConfig = resolveClientConfig(params.clientConfig); + const serverConfig = initialConfig.server; + + if (serverConfig?.hmr === false) { + const farmServer = await params.factories.createFarmServer(initialConfig); + return { + farmServer, + resolvedClientConfig: initialConfig + }; + } + + const basePort = resolveHmrRetryBasePort(initialConfig); + let lastError: unknown; + + for ( + let attempt = 0; + attempt < DEFAULT_HMR_PORT_RETRY_MAX_ATTEMPTS; + attempt++ + ) { + const candidatePort = basePort + attempt; + const candidateConfig = applyHmrPortToClientConfig( + initialConfig, + candidatePort + ); + + try { + const farmServer = + await params.factories.createFarmServer(candidateConfig); + return { + farmServer, + resolvedClientConfig: candidateConfig + }; + } catch (error) { + if (!isHmrPortInUseError(error)) { + throw error; + } + lastError = error; + } + } + + const message = + lastError instanceof Error ? lastError.message : String(lastError); + throw new Error( + `[farm ssr] failed to allocate an available HMR port after ${DEFAULT_HMR_PORT_RETRY_MAX_ATTEMPTS} attempts from ${basePort}. lastError=${message}` + ); +} + +function listenHostServer( + hostServer: SsrDevHostServerLike, + options: Required +) { + return new Promise((resolve, reject) => { + const onListening = () => { + hostServer.off('error', onError); + resolve(); + }; + const onError = (error: unknown) => { + hostServer.off('listening', onListening); + reject(error); + }; + + hostServer.once('listening', onListening); + hostServer.once('error', onError); + hostServer.listen(options.port, options.hostname); + }); +} + +function closeHostServer(hostServer: SsrDevHostServerLike) { + if (!hostServer.listening) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + hostServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function createHotBus() { + const listeners = new Set<(payload: RunnerHotPayload) => void>(); + + return { + emit(payload: RunnerHotPayload) { + for (const listener of listeners) { + listener(payload); + } + }, + hotBus: { + subscribe(cb: (payload: RunnerHotPayload) => void) { + listeners.add(cb); + return () => listeners.delete(cb); + } + } + }; +} + +function emitRunnerPayload( + emit: (payload: RunnerHotPayload) => void, + result: SsrUpdateResult +) { + const changedModules = [ + ...result.changed, + ...result.added, + ...result.extraWatchResult.add + ]; + + if (changedModules.length > 0) { + emit({ + type: 'update', + updates: changedModules.map((path) => ({ + type: 'js-update', + path, + acceptedPath: path, + timestamp: Date.now() + })) + }); + return; + } + + if (result.removed.length > 0) { + emit({ type: 'full-reload' }); + } +} + +function normalizeServerUpdateResultPaths( + result: SsrUpdateResult, + normalizePathByCompiler: (path: string) => string +): SsrUpdateResult { + return { + added: result.added.map(normalizePathByCompiler), + changed: result.changed.map(normalizePathByCompiler), + removed: result.removed.map(normalizePathByCompiler), + extraWatchResult: { + add: result.extraWatchResult.add.map(normalizePathByCompiler) + } + }; +} + +function watchServerCompilerWithSingleWatcher(params: { + watcher: SsrDevWatcherLike; + compiler: SsrDevCompilerLike; + onUpdateResult: ( + result: SsrUpdateResult, + updateError?: unknown, + updateDurationMs?: number + ) => void | Promise; +}) { + let active = true; + + const runUpdate = async (path: string, type: SsrUpdateType) => { + if (!active) { + return; + } + + let result: SsrUpdateResult; + let updateError: unknown; + const startTime = Date.now(); + try { + result = await params.compiler.update([{ path, type }]); + } catch (error) { + updateError = error; + result = { + added: [], + changed: type === 'removed' ? [] : [path], + removed: type === 'removed' ? [path] : [], + extraWatchResult: { + add: [] + } + }; + } + + if (!active) { + return; + } + + await params.onUpdateResult(result, updateError, Date.now() - startTime); + }; + + params.watcher.on('add', async (file) => { + await runUpdate(normalizePath(file), 'added'); + }); + + params.watcher.on('unlink', async (file) => { + await runUpdate(normalizePath(file), 'removed'); + }); + + params.watcher.on('change', async (file) => { + const normalizedFile = normalizePath(file); + await runUpdate(normalizedFile, 'updated'); + }); + + return () => { + active = false; + }; +} + +async function closeWithOrder(params: { + hostServer: SsrDevHostServerLike; + runner: FarmModuleRunner; + farmServer: SsrDevFarmServerLike; + stopWatcherSync: () => void; +}) { + params.stopWatcherSync(); + + const results = await Promise.allSettled([ + closeHostServer(params.hostServer), + params.runner.close(), + params.farmServer.close() + ]); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + + if (rejected) { + throw rejected.reason; + } +} + +function normalizeTemplateResourcePath(resource: string) { + if (resource.startsWith('/')) { + return resource.slice(1); + } + + return resource; +} + +const DEFAULT_TEMPLATE_PLACEHOLDER = ''; +const ROOT_CONTAINER_PATTERN = + /]*>[\s\S]*?<\/div>/; + +function injectAppHtmlToTemplate(params: { + template: string; + appHtml: string; + placeholder: string; +}) { + if (params.template.includes(params.placeholder)) { + return params.template.replace(params.placeholder, params.appHtml); + } + + if (params.placeholder !== DEFAULT_TEMPLATE_PLACEHOLDER) { + throw new Error( + `[farm ssr] template placeholder "${params.placeholder}" was not found.` + ); + } + + if (!ROOT_CONTAINER_PATTERN.test(params.template)) { + throw new Error( + `[farm ssr] template placeholder "${params.placeholder}" was not found.` + ); + } + + return params.template.replace( + ROOT_CONTAINER_PATTERN, + `
${params.appHtml}
` + ); +} + +async function loadTemplateContent(params: { + options: SsrRenderOptions; + farmServer: SsrDevFarmServerLike; + root: string; + url: string; + req: IncomingMessage; + res: ServerResponse; +}) { + const template = params.options.template; + const loadContext: SsrTemplateLoadContext = { + url: params.url, + req: params.req, + res: params.res, + root: params.root + }; + + if (template.load) { + return await template.load(loadContext); + } + + if (template.file) { + const filePath = path.isAbsolute(template.file) + ? template.file + : path.join(params.root, template.file); + return await fs.readFile(filePath, 'utf-8'); + } + + if (template.resource) { + const compiler = params.farmServer.getCompiler?.(); + const resource = + compiler?.resource(template.resource) ?? + compiler?.resource(normalizeTemplateResourcePath(template.resource)); + + if (resource == null) { + throw new Error( + `[farm ssr] template resource "${template.resource}" is not available in client compiler resources.` + ); + } + + return Buffer.isBuffer(resource) + ? resource.toString('utf-8') + : String(resource); + } + + throw new Error( + '[farm ssr] invalid ssr.template configuration, provide one of template.load/template.file/template.resource.' + ); +} + +function resolveRenderResult(params: { + options: SsrRenderOptions; + moduleExports: Record; + context: SsrRenderContext; +}) { + if (params.options.render) { + return params.options.render(params.moduleExports, params.context); + } + + const exportName = params.options.exportName ?? 'default'; + const render = params.moduleExports[exportName]; + + if (typeof render !== 'function') { + throw new Error( + `[farm ssr] export "${exportName}" from "${params.options.entry}" must be a function or provide ssr.render().` + ); + } + + return (render as (url: string, ctx: unknown) => unknown)( + params.context.url, + params.context + ); +} + +export async function renderSsrHtmlResponse(params: { + options: SsrRenderOptions; + farmServer: SsrDevFarmServerLike; + root: string; + context: SsrRenderContext; +}) { + if (!params.context.runner) { + throw new Error('[farm ssr] runner is required to render HTML in dev.'); + } + const mod = (await params.context.runner.import( + params.options.entry + )) as Record; + + const renderResult = await resolveRenderResult({ + options: params.options, + moduleExports: mod, + context: params.context + }); + const appHtml = + typeof renderResult === 'string' ? renderResult : String(renderResult); + const template = await loadTemplateContent({ + options: params.options, + farmServer: params.farmServer, + root: params.root, + url: params.context.url, + req: params.context.req, + res: params.context.res + }); + + if (params.options.template.transform) { + return await params.options.template.transform({ + url: params.context.url, + req: params.context.req, + res: params.context.res, + root: params.root, + appHtml, + template + }); + } + + const placeholder = + params.options.template.placeholder ?? DEFAULT_TEMPLATE_PLACEHOLDER; + return injectAppHtmlToTemplate({ + template, + appHtml, + placeholder + }); +} + +function createSsrRenderMiddleware(params: { + options: SsrRenderOptions; + root: string; + farmServer: SsrDevFarmServerLike; + runner: FarmModuleRunner; + runtime: SsrRuntimeMeta; + mode: string; + assets?: SsrRuntimeAssets; + hooks?: SsrRuntimeHooks; +}) { + return async ( + req: IncomingMessage, + res: ServerResponse, + next: (error?: unknown) => void + ) => { + const requestId = randomUUID(); + const context: SsrRenderContext = { + url: req.url ?? '/', + req, + res, + runner: params.runner, + command: 'dev', + mode: params.mode, + runtime: params.runtime, + assets: params.assets ?? { css: [], preload: [], scripts: [] }, + requestId + }; + const shouldHandle = + params.options.shouldHandle ?? ((ctx) => isHtmlRequest(ctx.req)); + + if (!shouldHandle(context)) { + next(); + return; + } + + const startTime = Date.now(); + try { + params.hooks?.onRenderStart?.({ + requestId, + url: context.url, + command: context.command, + mode: context.mode, + runtime: context.runtime + }); + const html = await renderSsrHtmlResponse({ + options: params.options, + farmServer: params.farmServer, + root: params.root, + context + }); + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end(html); + params.hooks?.onRenderEnd?.({ + requestId, + url: context.url, + command: context.command, + mode: context.mode, + runtime: context.runtime, + ms: Date.now() - startTime + }); + } catch (error) { + const ssrError = toSsrError({ + code: 'SSR_RENDER_FAILED', + error, + debug: params.mode === 'development' + }); + params.hooks?.onRenderEnd?.({ + requestId, + url: context.url, + command: context.command, + mode: context.mode, + runtime: context.runtime, + ms: Date.now() - startTime, + error: ssrError + }); + if (params.options.onError) { + await params.options.onError(error, { ...context, next }); + return; + } + + const stack = + error instanceof Error ? (error.stack ?? error.message) : String(error); + res.statusCode = 500; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(stack); + } + }; +} + +export async function createSsrDevServerWithFactories( + options: SsrDevServerOptions, + factories: SsrDevServerFactories +): Promise { + if (options.ssr && options.ssrMiddleware) { + throw new Error( + '[farm ssr] "ssr" and "ssrMiddleware" cannot be used together.' + ); + } + + const { farmServer, resolvedClientConfig } = + await createFarmServerWithHmrPortRetry({ + factories, + clientConfig: options.client + }); + const clientRoot = farmServer.root ?? options.client.root ?? process.cwd(); + let stopWatcherSync: () => void = () => {}; + + let runner: FarmModuleRunner | null = null; + + try { + if (!options.server) { + runner = await farmServer.createModuleRunner({ + hmr: true, + ...(options.runner ?? {}) + }); + } else { + const serverCompilerResult = await factories.createServerCompiler( + options.server + ); + const invokeContext = { + root: serverCompilerResult.root, + publicPath: serverCompilerResult.publicPath, + moduleRunnerStamp: 0, + compiler: serverCompilerResult.compiler + }; + const { emit, hotBus } = createHotBus(); + + const invokeHandlers = createModuleRunnerInvokeHandlers(invokeContext); + const transport = createModuleRunnerTransportFromInvokeHandlers({ + invokeHandlers, + hotBus + }); + runner = new FarmModuleRunner({ + ...options.runner, + hmr: true, + transport + }); + + if (!farmServer.watcher) { + throw new Error( + '[farm ssr] client dev watcher is required when server compiler is enabled.' + ); + } + + const initialServerWatchFiles = + serverCompilerResult.compiler.resolvedModulePaths?.( + serverCompilerResult.root + ) ?? []; + const filteredInitialServerWatchFiles = initialServerWatchFiles.filter( + (file) => farmServer.watcher.filterWatchFile(file, clientRoot) + ); + if (filteredInitialServerWatchFiles.length > 0) { + farmServer.watcher.add(filteredInitialServerWatchFiles); + } + + stopWatcherSync = watchServerCompilerWithSingleWatcher({ + watcher: farmServer.watcher, + compiler: serverCompilerResult.compiler, + async onUpdateResult(result, updateError, updateDurationMs) { + const changedCandidates = [ + ...result.changed, + ...result.added, + ...result.removed, + ...result.extraWatchResult.add + ]; + const hasServerImpact = changedCandidates.some((modulePath) => + invokeContext.compiler.hasModule(modulePath) + ); + + if (!hasServerImpact) { + return; + } + + options.hooks?.onInvalidate?.({ + kind: 'server', + reason: updateError ? 'rebuild' : 'update', + added: result.added.length + result.extraWatchResult.add.length, + changed: result.changed.length, + removed: result.removed.length, + ms: updateDurationMs + }); + + if (updateError) { + const recreatedCompilerResult = + await factories.createServerCompiler(options.server); + invokeContext.root = recreatedCompilerResult.root; + invokeContext.publicPath = recreatedCompilerResult.publicPath; + invokeContext.compiler = recreatedCompilerResult.compiler; + + const normalizePathByCompiler = (modulePath: string) => + recreatedCompilerResult.compiler.transformModulePath( + recreatedCompilerResult.root, + modulePath + ); + const normalizedResult = normalizeServerUpdateResultPaths( + result, + normalizePathByCompiler + ); + + invokeContext.moduleRunnerStamp++; + emit({ type: 'full-reload' }); + emitRunnerPayload(emit, normalizedResult); + + const addedWatchFiles = [ + ...normalizedResult.added, + ...normalizedResult.extraWatchResult.add + ]; + const recreatedWatchFiles = + recreatedCompilerResult.compiler.resolvedModulePaths?.( + recreatedCompilerResult.root + ) ?? []; + const mergedWatchFiles = [ + ...addedWatchFiles, + ...recreatedWatchFiles + ].filter((file) => + farmServer.watcher.filterWatchFile(file, clientRoot) + ); + + if (mergedWatchFiles.length > 0) { + farmServer.watcher.add(mergedWatchFiles); + } + return; + } + + const normalizePathByCompiler = (modulePath: string) => + invokeContext.compiler.transformModulePath( + invokeContext.root, + modulePath + ); + const normalizedResult = normalizeServerUpdateResultPaths( + result, + normalizePathByCompiler + ); + + invokeContext.moduleRunnerStamp++; + emitRunnerPayload(emit, normalizedResult); + + const addedWatchFiles = [ + ...normalizedResult.added, + ...normalizedResult.extraWatchResult.add + ].filter((file) => + farmServer.watcher.filterWatchFile(file, clientRoot) + ); + + if (addedWatchFiles.length > 0) { + farmServer.watcher.add(addedWatchFiles); + } + } + }); + } + } catch (error) { + await runner?.close().catch(() => undefined); + await farmServer.close().catch(() => undefined); + throw error; + } + + if (!runner) { + throw new Error('[farm ssr] runner initialization failed.'); + } + + const middlewares = createMiddlewareServer(); + const runtimeMeta: SsrRuntimeMeta = { + root: clientRoot, + publicPath: resolvedClientConfig.compilation?.output?.publicPath ?? '/' + }; + const runtimeMode = resolvedClientConfig.mode ?? 'development'; + + if (options.ssrMiddleware) { + middlewares.use(options.ssrMiddleware); + } else if (options.ssr) { + middlewares.use( + createSsrRenderMiddleware({ + options: options.ssr, + root: clientRoot, + farmServer, + runner, + runtime: runtimeMeta, + mode: runtimeMode, + hooks: options.hooks + }) + ); + } + + middlewares.use(farmServer.middlewares); + + const hostServer = factories.createHostServer(middlewares); + const defaultListenOptions = resolveDefaultListenOptions({ + ...options, + client: resolvedClientConfig + }); + let closed = false; + + return { + middlewares, + runner, + async render(url, req, res) { + if (!options.ssr) { + throw new Error( + '[farm ssr] ssr options are required to call render() in dev.' + ); + } + const requestId = randomUUID(); + const startTime = Date.now(); + options.hooks?.onRenderStart?.({ + requestId, + url, + command: 'dev', + mode: runtimeMode, + runtime: runtimeMeta + }); + return renderSsrHtmlResponse({ + options: options.ssr, + farmServer, + root: clientRoot, + context: { + url, + req, + res, + runner, + command: 'dev', + mode: runtimeMode, + runtime: runtimeMeta, + assets: { css: [], preload: [], scripts: [] }, + requestId + } + }) + .then((html) => { + options.hooks?.onRenderEnd?.({ + requestId, + url, + command: 'dev', + mode: runtimeMode, + runtime: runtimeMeta, + ms: Date.now() - startTime + }); + return html; + }) + .catch((error) => { + const ssrError = toSsrError({ + code: 'SSR_RENDER_FAILED', + error, + debug: runtimeMode === 'development' + }); + options.hooks?.onRenderEnd?.({ + requestId, + url, + command: 'dev', + mode: runtimeMode, + runtime: runtimeMeta, + ms: Date.now() - startTime, + error: ssrError + }); + throw error; + }); + }, + async listen(listenOptions) { + if (closed) { + throw new Error('[farm ssr] dev server is already closed.'); + } + + if (hostServer.listening) { + return; + } + + await listenHostServer(hostServer, { + ...defaultListenOptions, + ...(listenOptions ?? {}) + }); + }, + async close() { + if (closed) { + return; + } + closed = true; + await closeWithOrder({ + hostServer, + runner, + farmServer, + stopWatcherSync + }); + } + }; +} + +export async function createSsrDevServer( + options: SsrDevServerOptions +): Promise { + return createSsrDevServerWithFactories(options, defaultFactories); +} + +export async function startSsrDevServerWithFactories( + options: SsrDevServerOptions, + factories: SsrDevServerFactories +): Promise { + const devServer = await createSsrDevServerWithFactories(options, factories); + + try { + await devServer.listen(options.host); + return devServer; + } catch (error) { + await devServer.close().catch(() => undefined); + throw error; + } +} + +export async function startSsrDevServer( + options: SsrDevServerOptions +): Promise { + return startSsrDevServerWithFactories(options, defaultFactories); +} diff --git a/packages/ssr/src/errors.ts b/packages/ssr/src/errors.ts new file mode 100644 index 0000000000..64710ad488 --- /dev/null +++ b/packages/ssr/src/errors.ts @@ -0,0 +1,44 @@ +export type SsrErrorCode = + | 'SSR_COMPILE_FAILED' + | 'SSR_RENDER_FAILED' + | 'SSR_TEMPLATE_MISSING' + | 'SSR_TEMPLATE_PLACEHOLDER_MISSING' + | 'SSR_ASSET_MANIFEST_MISSING' + | 'SSR_ASSET_INJECT_FAILED' + | 'SSR_RUNTIME_INTERNAL'; + +export type SsrError = { + code: SsrErrorCode; + message: string; + cause?: unknown; + debug?: { + stack?: string; + hint?: string; + }; +}; + +export function toSsrError(params: { + code: SsrErrorCode; + error: unknown; + debug?: boolean; + hint?: string; +}): SsrError { + const message = + params.error instanceof Error ? params.error.message : String(params.error); + const debug = + params.debug && params.error instanceof Error + ? { + stack: params.error.stack, + hint: params.hint + } + : params.hint + ? { hint: params.hint } + : undefined; + + return { + code: params.code, + message, + cause: params.error, + debug + }; +} diff --git a/packages/ssr/src/index.ts b/packages/ssr/src/index.ts new file mode 100644 index 0000000000..28acdea00c --- /dev/null +++ b/packages/ssr/src/index.ts @@ -0,0 +1,85 @@ +export type { + SsrBuildOptions, + SsrBuildPreviewFactories, + SsrPreviewClientServerLike, + SsrPreviewOptions, + SsrPreviewRenderContext, + SsrPreviewRenderOptions, + SsrPreviewServer, + SsrPreviewTemplateOptions, + SsrResolvedBuildOutput, + SsrResolvedPreviewMetadata +} from './build-preview.js'; +export { + buildSsrApp, + buildSsrAppWithFactories, + createSsrPreviewServer, + createSsrPreviewServerWithFactories, + previewSsrApp, + resolveSsrPreviewMetadata, + resolveSsrPreviewMetadataWithFactories, + startSsrPreviewServer, + startSsrPreviewServerWithFactories +} from './build-preview.js'; +export type { + SsrRunBuildCommandOptions, + SsrRunCommandOptions, + SsrRunCommandResult, + SsrRunServerCommandOptions, + SsrToolkitCommand +} from './command.js'; +export { + runSsrCommand, + runSsrCommandWithResolvers +} from './command.js'; +export type { SsrConfig } from './config-resolver.js'; +export type { + SsrDevCompilerLike, + SsrDevFarmServerLike, + SsrDevHostServerLike, + SsrDevServer, + SsrDevServerCompilerCreateResult, + SsrDevServerFactories, + SsrDevServerListenOptions, + SsrDevServerOptions, + SsrDevWatcherLike, + SsrMiddleware, + SsrMiddlewareServer, + SsrNextMiddleware, + SsrRenderContext, + SsrRenderOptions, + SsrTemplateLoadContext, + SsrTemplateOptions, + SsrTemplateRenderContext +} from './dev-server.js'; +export { + createSsrDevServer, + createSsrDevServerWithFactories, + startSsrDevServer, + startSsrDevServerWithFactories +} from './dev-server.js'; +export type { SsrError, SsrErrorCode } from './errors.js'; +export type { SsrBuildInfo, SsrManifest } from './manifest.js'; +export type { + SsrRuntime, + SsrRuntimeConfig +} from './runtime.js'; +export { createSsrRuntime } from './runtime.js'; +export type { SsrRuntimeHooks } from './runtime-hooks.js'; +export type { + SsrRuntimeAssets, + SsrRuntimeCommand, + SsrRuntimeMeta +} from './runtime-types.js'; +export type { + SsrServer, + SsrServerCommand, + SsrServerMode, + SsrServerOptions +} from './server.js'; +export { + createSsrServer, + createSsrServerWithResolvers, + startSsrServer, + startSsrServerWithResolvers +} from './server.js'; diff --git a/packages/ssr/src/manifest.ts b/packages/ssr/src/manifest.ts new file mode 100644 index 0000000000..39b40cf2f0 --- /dev/null +++ b/packages/ssr/src/manifest.ts @@ -0,0 +1,158 @@ +import type { SsrRuntimeAssets } from './runtime-types.js'; + +export type SsrManifestEntry = { + js?: string[]; + css?: string[]; + preload?: string[]; +}; + +export type SsrManifest = { + version: 1; + entries: Record; + modules: Record; + assets?: Record; +}; + +export type SsrBuildInfo = { + version: 1; + client: { + outputDir: string; + manifest: string; + entry?: string; + }; + server: { + outputDir: string; + entry: string; + }; +}; + +type ResolveAssetsParams = { + manifest: SsrManifest | null; + entry?: string | null; + usedModuleIds?: string[]; + publicPath?: string; +}; + +function normalizePublicPath(publicPath: string | undefined): string { + if (!publicPath) { + return '/'; + } + + if (publicPath.endsWith('/')) { + return publicPath; + } + + return `${publicPath}/`; +} + +function normalizeAssetPath(publicPath: string, asset: string): string { + const cleanAsset = asset.replace(/^\/+/, ''); + return `${publicPath}${cleanAsset}`; +} + +function pushUnique(target: string[], source?: string[]) { + if (!source || source.length === 0) { + return; + } + for (const item of source) { + if (!target.includes(item)) { + target.push(item); + } + } +} + +export function resolveAssetsFromManifest( + params: ResolveAssetsParams +): SsrRuntimeAssets { + const assets: SsrRuntimeAssets = { + css: [], + preload: [], + scripts: [] + }; + + if (!params.manifest) { + return assets; + } + + const publicPath = normalizePublicPath(params.publicPath); + const entryKey = params.entry ?? null; + const entryAssets = entryKey ? params.manifest.entries[entryKey] : null; + + if (entryAssets) { + pushUnique( + assets.css, + entryAssets.css?.map((item) => normalizeAssetPath(publicPath, item)) + ); + pushUnique( + assets.preload, + entryAssets.preload?.map((item) => normalizeAssetPath(publicPath, item)) + ); + pushUnique( + assets.scripts, + entryAssets.js?.map((item) => normalizeAssetPath(publicPath, item)) + ); + } + + for (const moduleId of params.usedModuleIds ?? []) { + const moduleAssets = params.manifest.modules[moduleId]; + if (!moduleAssets) { + continue; + } + + pushUnique( + assets.css, + moduleAssets.css?.map((item) => normalizeAssetPath(publicPath, item)) + ); + pushUnique( + assets.preload, + moduleAssets.preload?.map((item) => normalizeAssetPath(publicPath, item)) + ); + pushUnique( + assets.scripts, + moduleAssets.js?.map((item) => normalizeAssetPath(publicPath, item)) + ); + } + + return assets; +} + +export function injectAssetsIntoHtml(params: { + html: string; + assets: SsrRuntimeAssets; +}): string { + const headLinks: string[] = []; + const bodyScripts: string[] = []; + + for (const href of params.assets.css) { + headLinks.push(``); + } + + for (const href of params.assets.preload) { + headLinks.push(``); + } + + for (const src of params.assets.scripts) { + bodyScripts.push(``); + } + + let html = params.html; + if (headLinks.length) { + const injection = `\n${headLinks.join('\n')}\n`; + if (html.includes('')) { + html = html.replace('', `${injection}`); + } else { + html = injection + html; + } + } + + if (bodyScripts.length) { + const injection = `\n${bodyScripts.join('\n')}\n`; + if (html.includes('')) { + html = html.replace('', `${injection}`); + } else { + html = html + injection; + } + } + + return html; +} diff --git a/packages/ssr/src/runtime-hooks.ts b/packages/ssr/src/runtime-hooks.ts new file mode 100644 index 0000000000..f83a9b41b6 --- /dev/null +++ b/packages/ssr/src/runtime-hooks.ts @@ -0,0 +1,38 @@ +import type { SsrError } from './errors.js'; +import type { SsrRuntimeCommand, SsrRuntimeMeta } from './runtime-types.js'; + +export type SsrRuntimeHooks = { + onCompileStart?: (ctx: { kind: 'client' | 'server' }) => void; + onCompileEnd?: (ctx: { kind: 'client' | 'server'; ms: number }) => void; + onInvalidate?: (ctx: { + kind: 'client' | 'server'; + reason: 'update' | 'rebuild'; + added: number; + changed: number; + removed: number; + ms?: number; + }) => void; + onRenderStart?: (ctx: { + requestId: string; + url: string; + command: SsrRuntimeCommand; + mode: string; + runtime: SsrRuntimeMeta; + }) => void; + onRenderEnd?: (ctx: { + requestId: string; + url: string; + command: SsrRuntimeCommand; + mode: string; + runtime: SsrRuntimeMeta; + ms: number; + error?: SsrError; + }) => void; + onAssetInject?: (ctx: { + requestId: string; + url: string; + css: number; + preload: number; + scripts: number; + }) => void; +}; diff --git a/packages/ssr/src/runtime-types.ts b/packages/ssr/src/runtime-types.ts new file mode 100644 index 0000000000..24e4968cbe --- /dev/null +++ b/packages/ssr/src/runtime-types.ts @@ -0,0 +1,12 @@ +export type SsrRuntimeCommand = 'dev' | 'preview'; + +export type SsrRuntimeAssets = { + css: string[]; + preload: string[]; + scripts: string[]; +}; + +export type SsrRuntimeMeta = { + root: string; + publicPath: string; +}; diff --git a/packages/ssr/src/runtime.ts b/packages/ssr/src/runtime.ts new file mode 100644 index 0000000000..2bcbb8a7e7 --- /dev/null +++ b/packages/ssr/src/runtime.ts @@ -0,0 +1,105 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { SsrPreviewOptions } from './build-preview.js'; +import { createSsrPreviewServer } from './build-preview.js'; +import { + resolveSsrConfigForCommand, + type SsrConfig +} from './config-resolver.js'; +import type { SsrDevServerOptions, SsrMiddlewareServer } from './dev-server.js'; +import { createSsrDevServer } from './dev-server.js'; +import type { SsrRuntimeCommand } from './runtime-types.js'; + +export type SsrRuntimeConfig = SsrConfig & + ( + | (SsrDevServerOptions & { command?: 'dev' }) + | (SsrPreviewOptions & { command: 'preview' }) + ); + +export type SsrRuntime = { + start(): Promise; + close(): Promise; + middlewares: SsrMiddlewareServer; + render( + url: string, + req: IncomingMessage, + res: ServerResponse + ): Promise; +}; + +function resolveCommand(options: SsrRuntimeConfig): SsrRuntimeCommand { + return options.command ?? 'dev'; +} + +function resolveMode( + options: SsrRuntimeConfig, + command: SsrRuntimeCommand +): string { + if (options.mode) { + return options.mode; + } + + return command === 'preview' ? 'production' : 'development'; +} + +export async function createSsrRuntime( + options: SsrRuntimeConfig +): Promise { + const command = resolveCommand(options); + const mode = resolveMode(options, command); + + if (command === 'preview') { + const resolved = resolveSsrConfigForCommand(options, command); + const previewOptions: SsrPreviewOptions = { + ...resolved, + client: { + ...resolved.client, + mode: resolved.client.mode ?? mode + }, + server: { + ...(resolved.server ?? {}), + mode: resolved.server?.mode ?? mode + } + }; + const previewServer = await createSsrPreviewServer(previewOptions); + + return { + middlewares: previewServer.middlewares, + render: previewServer.render, + async start() { + await previewServer.listen(previewOptions.host); + }, + async close() { + await previewServer.close(); + } + }; + } + + const resolved = resolveSsrConfigForCommand(options, command); + const devOptions: SsrDevServerOptions = { + ...resolved, + client: { + ...resolved.client, + mode: resolved.client.mode ?? mode + }, + ...(resolved.server + ? { + server: { + ...resolved.server, + mode: resolved.server.mode ?? mode + } + } + : {}) + }; + const devServer = await createSsrDevServer(devOptions); + + return { + middlewares: devServer.middlewares, + render: devServer.render, + async start() { + await devServer.listen(devOptions.host); + }, + async close() { + await devServer.close(); + } + }; +} diff --git a/packages/ssr/src/server.ts b/packages/ssr/src/server.ts new file mode 100644 index 0000000000..f0f82c4020 --- /dev/null +++ b/packages/ssr/src/server.ts @@ -0,0 +1,138 @@ +import { + createSsrPreviewServer, + type SsrPreviewOptions, + type SsrPreviewServer, + startSsrPreviewServer +} from './build-preview.js'; +import { resolveSsrConfigForCommand } from './config-resolver.js'; +import { + createSsrDevServer, + type SsrDevServer, + type SsrDevServerOptions, + startSsrDevServer +} from './dev-server.js'; + +export type SsrServerCommand = 'dev' | 'preview'; +export type SsrServerMode = SsrServerCommand; + +type SsrServerCommonOptions = { + command?: SsrServerCommand; + // mode follows Farm config env semantics, not dispatch semantics. + mode?: string; +}; + +type SsrServerDevOptions = SsrDevServerOptions & + SsrServerCommonOptions & { command?: 'dev' }; +type SsrServerPreviewOptions = SsrPreviewOptions & + SsrServerCommonOptions & { command: 'preview' }; + +export type SsrServerOptions = SsrServerDevOptions | SsrServerPreviewOptions; + +export type SsrServer = SsrDevServer | SsrPreviewServer; + +interface SsrServerResolvers { + createDevServer(options: SsrDevServerOptions): Promise; + createPreviewServer(options: SsrPreviewOptions): Promise; + startDevServer(options: SsrDevServerOptions): Promise; + startPreviewServer(options: SsrPreviewOptions): Promise; +} + +const defaultResolvers: SsrServerResolvers = { + createDevServer: createSsrDevServer, + createPreviewServer: createSsrPreviewServer, + startDevServer: startSsrDevServer, + startPreviewServer: startSsrPreviewServer +}; + +function resolveCommand(options: SsrServerOptions): SsrServerCommand { + return options.command ?? 'dev'; +} + +function resolveMode( + options: SsrServerOptions, + command: SsrServerCommand +): string { + if (options.mode) { + return options.mode; + } + + return command === 'preview' ? 'production' : 'development'; +} + +function toDevServerOptions(options: SsrServerOptions): SsrDevServerOptions { + const command = resolveCommand(options); + const mode = resolveMode(options, command); + const resolved = resolveSsrConfigForCommand(options, command); + + return { + ...resolved, + client: { + ...resolved.client, + mode: resolved.client.mode ?? mode + }, + ...(resolved.server + ? { + server: { + ...resolved.server, + mode: resolved.server.mode ?? mode + } + } + : {}) + }; +} + +function toPreviewServerOptions(options: SsrServerOptions): SsrPreviewOptions { + const command = resolveCommand(options); + const mode = resolveMode(options, command); + const resolved = resolveSsrConfigForCommand(options, command); + + return { + ...resolved, + client: { + ...resolved.client, + mode: resolved.client.mode ?? mode + }, + server: { + ...(resolved.server ?? {}), + mode: resolved.server?.mode ?? mode + } + }; +} + +function isPreviewCommand(options: SsrServerOptions): boolean { + return resolveCommand(options) === 'preview'; +} + +export async function createSsrServerWithResolvers( + options: SsrServerOptions, + resolvers: SsrServerResolvers +): Promise { + if (isPreviewCommand(options)) { + return resolvers.createPreviewServer(toPreviewServerOptions(options)); + } + + return resolvers.createDevServer(toDevServerOptions(options)); +} + +export async function createSsrServer( + options: SsrServerOptions +): Promise { + return createSsrServerWithResolvers(options, defaultResolvers); +} + +export async function startSsrServerWithResolvers( + options: SsrServerOptions, + resolvers: SsrServerResolvers +): Promise { + if (isPreviewCommand(options)) { + return resolvers.startPreviewServer(toPreviewServerOptions(options)); + } + + return resolvers.startDevServer(toDevServerOptions(options)); +} + +export async function startSsrServer( + options: SsrServerOptions +): Promise { + return startSsrServerWithResolvers(options, defaultResolvers); +} diff --git a/packages/ssr/test/build-preview.spec.ts b/packages/ssr/test/build-preview.spec.ts new file mode 100644 index 0000000000..68706a87ba --- /dev/null +++ b/packages/ssr/test/build-preview.spec.ts @@ -0,0 +1,873 @@ +import { EventEmitter } from 'node:events'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { FarmCliOptions, UserConfig } from '@farmfe/core'; +import { describe, expect, it, vi } from 'vitest'; +import { + buildSsrAppWithFactories, + createSsrPreviewServerWithFactories, + importModuleWithCssInterop, + resolveSsrPreviewMetadataWithFactories, + type SsrBuildPreviewFactories, + type SsrPreviewClientServerLike, + startSsrPreviewServerWithFactories +} from '../src/build-preview.js'; +import type { + SsrDevHostServerLike, + SsrMiddleware, + SsrMiddlewareServer +} from '../src/dev-server.js'; + +class FakeHostServer extends EventEmitter implements SsrDevHostServerLike { + listening = false; + listenCalls: Array<{ port: number; hostname?: string }> = []; + closeCalls = 0; + + listen(port: number, hostname?: string) { + this.listenCalls.push({ port, hostname }); + this.listening = true; + queueMicrotask(() => this.emit('listening')); + return this; + } + + close(callback?: (error?: Error) => void) { + this.closeCalls++; + this.listening = false; + callback?.(); + return this; + } +} + +class FailingHostServer extends EventEmitter implements SsrDevHostServerLike { + listening = false; + closeCalls = 0; + + listen(_port: number, _hostname?: string) { + queueMicrotask(() => + this.emit('error', new Error('preview listen failed')) + ); + return this; + } + + close(callback?: (error?: Error) => void) { + this.closeCalls++; + callback?.(); + return this; + } +} + +function createMiddlewareServer(...middlewares: SsrMiddleware[]) { + const stack = [...middlewares]; + const middlewareServer = ((req, res, next) => { + let index = 0; + + const run = (error?: unknown) => { + if (error) { + next?.(error); + return; + } + + const middleware = stack[index++]; + if (!middleware) { + next?.(); + return; + } + middleware(req, res, run); + }; + + run(); + }) as SsrMiddlewareServer; + + middlewareServer.use = (middleware: SsrMiddleware) => { + stack.push(middleware); + }; + + return middlewareServer; +} + +function createFactories(params: { + hostServer: SsrDevHostServerLike; + clientPreviewServer?: SsrPreviewClientServerLike; + readFile?: (filePath: string) => Promise; + importModule?: (filePath: string) => Promise>; + listFiles?: (dir: string) => Promise; + writeFile?: (filePath: string, content: string) => Promise; + resolveClientEntries?: ( + config: FarmCliOptions & UserConfig + ) => Promise<{ entries: string[]; publicPath: string }>; +}) { + const calls: Array = []; + const clientPreviewServer = + params.clientPreviewServer ?? + ({ + middlewares: createMiddlewareServer((_req, _res, next) => next()), + close: vi.fn(async () => undefined) + } satisfies SsrPreviewClientServerLike); + + const factories: SsrBuildPreviewFactories = { + runBuild: vi.fn(async (config) => { + calls.push(config); + }), + resolveBuildOutput: vi.fn(async (config) => ({ + root: config.root ?? '/project', + outputPath: + config.configFile === 'client' + ? '/project/dist/client' + : '/project/dist/server' + })), + resolveClientEntries: + params.resolveClientEntries ?? + vi.fn(async (config) => ({ + entries: Object.values(config.compilation?.input ?? {}), + publicPath: config.compilation?.output?.publicPath ?? '/' + })), + createClientPreviewServer: vi.fn(async () => clientPreviewServer), + createHostServer: vi.fn(() => params.hostServer), + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith('build-info.json')) { + if (params.readFile) { + const content = await params.readFile(filePath); + if (content.trim().startsWith('{')) { + return content; + } + } + const error = new Error('ENOENT'); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + throw error; + } + const fallback = + params.readFile ?? + (async () => ''); + return fallback(filePath); + }), + importModule: + params.importModule ?? + vi.fn(async () => ({ + default(url: string) { + return `
preview:${url}
`; + } + })), + listFiles: params.listFiles ?? vi.fn(async () => []), + writeFile: params.writeFile ?? vi.fn(async () => undefined) + }; + + return { + factories, + calls, + clientPreviewServer + }; +} + +describe('farm ssr build/preview api', () => { + it('buildSsrApp runs client build then server build', async () => { + const hostServer = new FakeHostServer(); + const { factories, calls } = createFactories({ hostServer }); + const clientConfig = { configFile: 'client' }; + const serverConfig = { configFile: 'server' }; + + await buildSsrAppWithFactories( + { + client: clientConfig, + server: serverConfig + }, + factories + ); + + expect(calls).toEqual([clientConfig, serverConfig]); + }); + + it('applies $client/$server overrides during build', async () => { + const hostServer = new FakeHostServer(); + const { factories, calls } = createFactories({ hostServer }); + const clientConfig = { configFile: 'client', root: '/base' }; + const serverConfig = { configFile: 'server', root: '/base' }; + + await buildSsrAppWithFactories( + { + client: clientConfig, + server: serverConfig, + $client: { root: '/client-override' }, + $server: { root: '/server-override' } + }, + factories + ); + + expect(calls).toEqual([ + { configFile: 'client', root: '/client-override' }, + { configFile: 'server', root: '/server-override' } + ]); + }); + + it('fires asset inject hook during preview render', async () => { + const hostServer = new FakeHostServer(); + const buildInfo = JSON.stringify({ + version: 1, + client: { + outputDir: '/project/dist/client', + manifest: '/project/dist/client/manifest.client.json', + entry: '/src/main.ts' + }, + server: { + outputDir: '/project/dist/server', + entry: '/project/dist/server/index.js' + } + }); + const manifest = JSON.stringify({ + version: 1, + entries: { + '/src/main.ts': { + js: ['assets/main.js'], + css: ['assets/main.css'], + preload: ['assets/vendor.js'] + } + }, + modules: {} + }); + const events: Array<{ css: number; preload: number; scripts: number }> = []; + + const { factories } = createFactories({ + hostServer, + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith('build-info.json')) { + return buildInfo; + } + if (filePath.endsWith('manifest.client.json')) { + return manifest; + } + return ''; + }) + }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { entry: 'entry-server.js' }, + hooks: { + onAssetInject: (ctx) => events.push(ctx) + } + }, + factories + ); + + await new Promise((resolve) => { + previewServer.middlewares( + { + method: 'GET', + url: '/asset-hook', + headers: { accept: 'text/html' } + } as never, + { + setHeader() {}, + end() { + resolve(); + } + } as never, + () => resolve() + ); + }); + + expect(events[0]).toEqual({ css: 1, preload: 1, scripts: 1 }); + await previewServer.close(); + }); + + it('fires compile hooks during build', async () => { + const hostServer = new FakeHostServer(); + const events: string[] = []; + const { factories } = createFactories({ hostServer }); + + await buildSsrAppWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + hooks: { + onCompileStart: ({ kind }) => events.push(`start:${kind}`), + onCompileEnd: ({ kind }) => events.push(`end:${kind}`) + } + }, + factories + ); + + expect(events).toEqual([ + 'start:client', + 'end:client', + 'start:server', + 'end:server' + ]); + }); + + it('writes build-info and manifests after build', async () => { + const hostServer = new FakeHostServer(); + const written: Record = {}; + const { factories } = createFactories({ + hostServer, + listFiles: vi.fn(async () => [ + 'assets/index.123.js', + 'assets/index.123.css' + ]), + writeFile: vi.fn(async (filePath, content) => { + written[filePath] = content; + }), + resolveClientEntries: vi.fn(async () => ({ + entries: ['/src/main.ts'], + publicPath: '/' + })) + }); + + await buildSsrAppWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' } + }, + factories + ); + + expect(written['/project/dist/client/manifest.client.json']).toBeTruthy(); + expect(written['/project/dist/server/manifest.server.json']).toBeTruthy(); + expect(written['/project/dist/client/build-info.json']).toBeTruthy(); + + const buildInfo = JSON.parse( + written['/project/dist/client/build-info.json'] + ); + expect(buildInfo.client.manifest).toBe( + '/project/dist/client/manifest.client.json' + ); + expect(buildInfo.server.entry).toBe('/project/dist/server/index.js'); + }); + + it('renders preview html through built server entry and default template', async () => { + const trace: string[] = []; + const hostServer = new FakeHostServer(); + const { factories } = createFactories({ + hostServer, + clientPreviewServer: { + middlewares: createMiddlewareServer((_req, _res, next) => { + trace.push('client'); + next(); + }), + close: vi.fn(async () => undefined) + } + }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { + entry: 'entry-server.js' + } + }, + factories + ); + + const req = { + method: 'GET', + url: '/preview', + headers: { accept: 'text/html' } + }; + const headers: Record = {}; + const body = await new Promise((resolve) => { + const res = { + setHeader(name: string, value: string) { + headers[name] = value; + }, + end(html: string) { + resolve(html); + } + }; + + previewServer.middlewares(req as never, res as never, () => + resolve('next') + ); + }); + + expect(body).toContain('
preview:/preview
'); + expect(headers['Content-Type']).toContain('text/html'); + expect(trace).toEqual([]); + const readFileCalls = (factories.readFile as any).mock.calls.map( + (args: unknown[]) => args[0] + ); + expect(readFileCalls).toContain('/project/dist/client/index.html'); + expect((factories.importModule as any).mock.calls[0]?.[0]).toBe( + '/project/dist/server/entry-server.js' + ); + + await previewServer.close(); + }); + + it('injects manifest assets into preview html', async () => { + const hostServer = new FakeHostServer(); + const buildInfo = JSON.stringify({ + version: 1, + client: { + outputDir: '/project/dist/client', + manifest: '/project/dist/client/manifest.client.json', + entry: '/src/main.ts' + }, + server: { + outputDir: '/project/dist/server', + entry: '/project/dist/server/index.js' + } + }); + const manifest = JSON.stringify({ + version: 1, + entries: { + '/src/main.ts': { + js: ['assets/main.js'], + css: ['assets/main.css'], + preload: ['assets/vendor.js'] + } + }, + modules: {} + }); + + const { factories } = createFactories({ + hostServer, + readFile: vi.fn(async (filePath: string) => { + if (filePath.endsWith('build-info.json')) { + return buildInfo; + } + if (filePath.endsWith('manifest.client.json')) { + return manifest; + } + return ''; + }) + }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { + entry: 'entry-server.js' + } + }, + factories + ); + + const html = await new Promise((resolve) => { + previewServer.middlewares( + { + method: 'GET', + url: '/about', + headers: { accept: 'text/html' } + } as never, + { + setHeader() {}, + end(body: string) { + resolve(body); + } + } as never, + () => resolve('next') + ); + }); + + expect(html).toContain(''); + expect(html).toContain( + '' + ); + expect(html).toContain( + '' + ); + await previewServer.close(); + }); + + it('supports preview template.transform with built html template', async () => { + const hostServer = new FakeHostServer(); + const builtTemplate = + '
'; + const { factories } = createFactories({ + hostServer, + readFile: vi.fn(async () => builtTemplate) + }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { + entry: 'entry-server.js', + template: { + transform({ template, appHtml }) { + return template.replace( + '
', + `
${appHtml}
` + ); + } + } + } + }, + factories + ); + + const body = await new Promise((resolve) => { + previewServer.middlewares( + { + method: 'GET', + url: '/preview-transform', + headers: { accept: 'text/html' } + } as never, + { + setHeader() {}, + end(html: string) { + resolve(html); + } + } as never, + () => resolve('next') + ); + }); + + expect(body).toContain( + '
preview:/preview-transform
' + ); + expect(body).toContain('data-farm-resource=true'); + expect(body).not.toContain('/src/main.ts'); + await previewServer.close(); + }); + + it('prefers built output template when preview template.file is relative', async () => { + const hostServer = new FakeHostServer(); + const readFileMock = vi.fn(async (filePath: string) => { + if (filePath === '/project/dist/client/index.html') { + return '
'; + } + + if (filePath === '/project/index.html') { + return '
'; + } + + throw Object.assign(new Error(`unexpected file: ${filePath}`), { + code: 'ENOENT' + }); + }); + const { factories } = createFactories({ + hostServer, + readFile: readFileMock + }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { + entry: 'entry-server.js', + template: { + file: 'index.html' + } + } + }, + factories + ); + + const body = await new Promise((resolve) => { + previewServer.middlewares( + { + method: 'GET', + url: '/about', + headers: { accept: 'text/html' } + } as never, + { + setHeader() {}, + end(html: string) { + resolve(html); + } + } as never, + () => resolve('next') + ); + }); + + expect(body).toContain('data-farm-resource=true'); + expect(body).not.toContain('/src/main.ts'); + expect(readFileMock.mock.calls[0]?.[0]).toBe( + '/project/dist/client/index.html' + ); + await previewServer.close(); + }); + + it('falls back to root template file when relative preview template is absent in build output', async () => { + const hostServer = new FakeHostServer(); + const readFileMock = vi.fn(async (filePath: string) => { + if (filePath === '/project/dist/client/custom.html') { + throw Object.assign(new Error('missing output template'), { + code: 'ENOENT' + }); + } + + if (filePath === '/project/custom.html') { + return '
'; + } + + throw Object.assign(new Error(`unexpected file: ${filePath}`), { + code: 'ENOENT' + }); + }); + const { factories } = createFactories({ + hostServer, + readFile: readFileMock + }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { + entry: 'entry-server.js', + template: { + file: 'custom.html' + } + } + }, + factories + ); + + const body = await new Promise((resolve) => { + previewServer.middlewares( + { + method: 'GET', + url: '/fallback-template', + headers: { accept: 'text/html' } + } as never, + { + setHeader() {}, + end(html: string) { + resolve(html); + } + } as never, + () => resolve('next') + ); + }); + + expect(body).toContain('data-farm-resource=true'); + expect(readFileMock.mock.calls.map((args) => args[0])).toEqual([ + '/project/dist/client/custom.html', + '/project/custom.html' + ]); + await previewServer.close(); + }); + + it('uses default preview entry when ssr.entry is omitted', async () => { + const hostServer = new FakeHostServer(); + const { factories } = createFactories({ hostServer }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: {} + }, + factories + ); + + await new Promise((resolve) => { + previewServer.middlewares( + { + method: 'GET', + url: '/default-entry', + headers: { accept: 'text/html' } + } as never, + { + setHeader() {}, + end() { + resolve(); + } + } as never, + () => resolve() + ); + }); + + expect((factories.importModule as any).mock.calls[0]?.[0]).toBe( + '/project/dist/server/index.js' + ); + + await previewServer.close(); + }); + + it('resolves preview metadata with entry/template defaults', async () => { + const hostServer = new FakeHostServer(); + const { factories } = createFactories({ hostServer }); + + const metadata = await resolveSsrPreviewMetadataWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: {} + }, + factories + ); + + expect(metadata.clientBuildOutput).toEqual({ + root: '/project', + outputPath: '/project/dist/client' + }); + expect(metadata.serverBuildOutput).toEqual({ + root: '/project', + outputPath: '/project/dist/server' + }); + expect(metadata.templateFilePath).toBe('/project/dist/client/index.html'); + expect(metadata.serverEntryFilePath).toBe('/project/dist/server/index.js'); + expect(metadata.manifestFileCandidates).toEqual([ + '/project/dist/client/manifest.json', + '/project/dist/client/ssr-manifest.json' + ]); + }); + + it('falls through to preview static middlewares for non-html requests', async () => { + const trace: string[] = []; + const hostServer = new FakeHostServer(); + const { factories } = createFactories({ + hostServer, + clientPreviewServer: { + middlewares: createMiddlewareServer((_req, _res, next) => { + trace.push('client'); + next(); + }), + close: vi.fn(async () => undefined) + } + }); + + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { + entry: 'entry-server.js' + } + }, + factories + ); + + const req = { + method: 'GET', + url: '/main.js', + headers: { accept: 'text/javascript' } + }; + await new Promise((resolve, reject) => { + const res = {}; + previewServer.middlewares( + req as never, + res as never, + (error?: unknown) => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); + + expect(trace).toEqual(['client']); + expect((factories.importModule as any).mock.calls.length).toBe(0); + await previewServer.close(); + }); + + it('cleans up preview resources when start fails during listen', async () => { + const hostServer = new FailingHostServer(); + const clientPreviewServer = { + middlewares: createMiddlewareServer((_req, _res, next) => next()), + close: vi.fn(async () => undefined) + } satisfies SsrPreviewClientServerLike; + const { factories } = createFactories({ + hostServer, + clientPreviewServer + }); + + await expect( + startSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: { entry: 'entry-server.js' } + }, + factories + ) + ).rejects.toThrow('preview listen failed'); + + expect((clientPreviewServer.close as any).mock.calls.length).toBe(1); + expect(hostServer.closeCalls).toBe(0); + }); + + it('uses fallback entry name in export error message', async () => { + const hostServer = new FakeHostServer(); + const { factories } = createFactories({ + hostServer, + importModule: vi.fn(async () => ({ + notDefault() { + return '
no-default
'; + } + })) + }); + const previewServer = await createSsrPreviewServerWithFactories( + { + client: { configFile: 'client' }, + server: { configFile: 'server' }, + ssr: {} + }, + factories + ); + + const body = await new Promise((resolve) => { + previewServer.middlewares( + { + method: 'GET', + url: '/missing-export', + headers: { accept: 'text/html' } + } as never, + { + setHeader() {}, + end(content: string) { + resolve(content); + } + } as never, + () => resolve('next') + ); + }); + + expect(body).toContain('from "index.js"'); + await previewServer.close(); + }); + + it('imports server module with css side-effect imports via fallback interop', async () => { + const tmpRoot = await mkdtemp(path.join(tmpdir(), 'farm-ssr-css-interop-')); + const entryPath = path.join(tmpRoot, 'entry.mjs'); + const depPath = path.join(tmpRoot, 'dep.mjs'); + const cssPath = path.join(tmpRoot, 'style.css'); + + try { + await writeFile(depPath, "export const value = 'ok';\n", 'utf-8'); + await writeFile(cssPath, '.page { color: red; }\n', 'utf-8'); + await writeFile( + entryPath, + "import './style.css';\nimport { value } from './dep.mjs';\nexport default () => `interop:${value}`;\n", + 'utf-8' + ); + + const mod = await importModuleWithCssInterop(entryPath, readFile); + expect(typeof mod.default).toBe('function'); + expect((mod.default as () => string)()).toBe('interop:ok'); + } finally { + await rm(tmpRoot, { recursive: true, force: true }); + } + }); + + it('keeps createRequire usable when module is loaded through css fallback interop', async () => { + const tmpRoot = await mkdtemp(path.join(tmpdir(), 'farm-ssr-css-require-')); + const entryPath = path.join(tmpRoot, 'entry.mjs'); + const cssPath = path.join(tmpRoot, 'style.css'); + + try { + await writeFile(cssPath, '.entry { color: blue; }\n', 'utf-8'); + await writeFile( + entryPath, + "import { createRequire } from 'module';\nimport './style.css';\nconst require = createRequire(import.meta.url);\nexport default () => typeof require;\n", + 'utf-8' + ); + + const mod = await importModuleWithCssInterop(entryPath, readFile); + expect((mod.default as () => string)()).toBe('function'); + } finally { + await rm(tmpRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/ssr/test/command.spec.ts b/packages/ssr/test/command.spec.ts new file mode 100644 index 0000000000..c16e8fa0b9 --- /dev/null +++ b/packages/ssr/test/command.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + runSsrCommandWithResolvers, + type SsrRunCommandOptions +} from '../src/command.js'; + +describe('farm ssr command runner', () => { + it('runs build command without starting server', async () => { + const resolvers = { + buildSsrApp: vi.fn(async () => undefined), + createSsrRuntime: vi.fn(async () => ({ start: vi.fn() }) as never) + }; + + const result = await runSsrCommandWithResolvers( + { + command: 'build', + client: {}, + server: {} + } as SsrRunCommandOptions, + resolvers + ); + + expect(result).toBeUndefined(); + expect(resolvers.buildSsrApp).toHaveBeenCalledTimes(1); + expect(resolvers.createSsrRuntime).toHaveBeenCalledTimes(0); + }); + + it('starts server by default for dev/preview commands', async () => { + const runtime = { + start: vi.fn(async () => undefined) + }; + const resolvers = { + buildSsrApp: vi.fn(async () => undefined), + createSsrRuntime: vi.fn(async () => runtime as never) + }; + + const devResult = await runSsrCommandWithResolvers( + { + client: {}, + $client: { mode: 'development' } + } as SsrRunCommandOptions, + resolvers + ); + const previewResult = await runSsrCommandWithResolvers( + { + command: 'preview', + client: {}, + server: {} + } as SsrRunCommandOptions, + resolvers + ); + + expect(devResult).toBe(runtime); + expect(previewResult).toBe(runtime); + expect(resolvers.createSsrRuntime).toHaveBeenCalledTimes(2); + expect(runtime.start).toHaveBeenCalledTimes(2); + expect(resolvers.createSsrRuntime).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + client: {} + }) + ); + expect(resolvers.createSsrRuntime).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: 'preview', + client: {}, + server: {} + }) + ); + }); + + it('uses create server flow when start=false', async () => { + const runtime = { + start: vi.fn(async () => undefined) + }; + const resolvers = { + buildSsrApp: vi.fn(async () => undefined), + createSsrRuntime: vi.fn(async () => runtime as never) + }; + + const result = await runSsrCommandWithResolvers( + { + command: 'preview', + start: false, + client: {}, + server: {} + } as SsrRunCommandOptions, + resolvers + ); + + expect(result).toBe(runtime); + expect(resolvers.createSsrRuntime).toHaveBeenCalledTimes(1); + expect(runtime.start).toHaveBeenCalledTimes(0); + expect(resolvers.createSsrRuntime).toHaveBeenCalledWith({ + command: 'preview', + client: {}, + server: {} + }); + }); +}); diff --git a/packages/ssr/test/dev-server.spec.ts b/packages/ssr/test/dev-server.spec.ts new file mode 100644 index 0000000000..6d7db8145f --- /dev/null +++ b/packages/ssr/test/dev-server.spec.ts @@ -0,0 +1,613 @@ +import { EventEmitter } from 'node:events'; +import type { FarmModuleRunner } from '@farmfe/core'; +import { describe, expect, it, vi } from 'vitest'; +import { + createSsrDevServerWithFactories, + type SsrDevCompilerLike, + type SsrDevFarmServerLike, + type SsrDevHostServerLike, + type SsrDevServerFactories, + type SsrDevWatcherLike, + type SsrMiddleware, + type SsrMiddlewareServer, + startSsrDevServerWithFactories +} from '../src/dev-server.js'; + +class FakeHostServer extends EventEmitter implements SsrDevHostServerLike { + listening = false; + listenCalls: Array<{ port: number; hostname?: string }> = []; + closeCalls = 0; + + listen(port: number, hostname?: string) { + this.listenCalls.push({ port, hostname }); + this.listening = true; + queueMicrotask(() => this.emit('listening')); + return this; + } + + close(callback?: (error?: Error) => void) { + this.closeCalls++; + this.listening = false; + callback?.(); + return this; + } +} + +class FailingHostServer extends EventEmitter implements SsrDevHostServerLike { + listening = false; + closeCalls = 0; + + listen(_port: number, _hostname?: string) { + queueMicrotask(() => this.emit('error', new Error('listen failed'))); + return this; + } + + close(callback?: (error?: Error) => void) { + this.closeCalls++; + callback?.(); + return this; + } +} + +class FakeWatcher implements SsrDevWatcherLike { + private readonly listeners = { + add: [] as Array<(path: string) => Promise | void>, + change: [] as Array<(path: string) => Promise | void>, + unlink: [] as Array<(path: string) => Promise | void> + }; + + addedPaths: string[] = []; + + on( + event: 'add' | 'change' | 'unlink', + listener: (path: string) => Promise | void + ) { + this.listeners[event].push(listener); + return this; + } + + add(paths: string | string[]) { + this.addedPaths.push(...(Array.isArray(paths) ? paths : [paths])); + return this; + } + + filterWatchFile(_file: string, _root: string) { + return true; + } + + async emit(event: 'add' | 'change' | 'unlink', file: string) { + for (const listener of this.listeners[event]) { + await listener(file); + } + } +} + +class FakeTemplateCompiler { + constructor(private readonly templates: Record) {} + + resource(name: string) { + return this.templates[name] ?? this.templates[name.replace(/^\//, '')]; + } +} + +class FakeServerCompiler implements SsrDevCompilerLike { + compiling = false; + value = 1; + updateCalls = 0; + moduleId = '/src/entry-server.ts'; + + async compile() {} + + async update( + _paths: Array<{ path: string; type: 'added' | 'updated' | 'removed' }> + ) { + this.updateCalls++; + this.value += 1; + return { + added: [], + changed: [this.moduleId], + removed: [], + extraWatchResult: { + add: [] + } + }; + } + + hasModule(path: string) { + return path === this.moduleId; + } + + transformModulePath(_root: string, path: string) { + return path; + } + + async waitForCompileFinish() {} + + fetchModule(id: string) { + if (id !== this.moduleId) { + return null; + } + + return { + id, + url: id, + file: id, + invalidate: false, + code: `const value = ${this.value}; __farm_ssr_export_name__("value", () => value);`, + map: null + }; + } + + resource(_name: string) { + return null; + } + + resources() { + return {}; + } +} + +class FakeServerCompilerWithoutHasModule extends FakeServerCompiler { + hasModule(_path: string) { + return false; + } +} + +class FakeServerCompilerWithUpdateError extends FakeServerCompiler { + async update() { + this.updateCalls++; + throw new Error('update failed'); + } +} + +function createMiddlewareServer(...middlewares: SsrMiddleware[]) { + const stack = [...middlewares]; + const middlewareServer = ((req, res, next) => { + let index = 0; + + const run = (error?: unknown) => { + if (error) { + next?.(error); + return; + } + + const middleware = stack[index++]; + if (!middleware) { + next?.(); + return; + } + middleware(req, res, run); + }; + + run(); + }) as SsrMiddlewareServer; + + middlewareServer.use = (middleware: SsrMiddleware) => { + stack.push(middleware); + }; + + return middlewareServer; +} + +function createFarmServerMock( + withWatcher = false, + templateHtml = '' +) { + const farmMiddlewares = createMiddlewareServer(); + const trace: string[] = []; + farmMiddlewares.use((_req, _res, next) => { + trace.push('farm'); + next(); + }); + + const watcher = withWatcher ? new FakeWatcher() : undefined; + const templateCompiler = new FakeTemplateCompiler({ + 'index.html': templateHtml + }); + const runner = { + import: vi.fn(async () => ({ + default(url: string) { + return `
render:${url}
`; + } + })), + close: vi.fn(async () => undefined) + } as unknown as FarmModuleRunner; + + const farmServer: SsrDevFarmServerLike = { + middlewares: farmMiddlewares, + root: '/project', + getCompiler: () => templateCompiler, + watcher, + createModuleRunner: vi.fn(async () => runner), + close: vi.fn(async () => undefined) + }; + + return { farmServer, runner, watcher, trace }; +} + +function createFactories( + farmServer: SsrDevFarmServerLike, + hostServer: SsrDevHostServerLike, + serverCompiler?: FakeServerCompiler +): SsrDevServerFactories { + return { + createFarmServer: vi.fn(async () => farmServer), + createHostServer: vi.fn(() => hostServer), + createServerCompiler: vi.fn(async () => { + if (!serverCompiler) { + throw new Error('server compiler is not configured in test factory'); + } + + return { + root: '/project', + publicPath: '/', + compiler: serverCompiler + }; + }) + }; +} + +describe('farm ssr dev server api shell', () => { + it('renders html with ssr template resource', async () => { + const { farmServer } = createFarmServerMock(); + const hostServer = new FakeHostServer(); + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + ssr: { + entry: '/src/entry-server.ts', + template: { + resource: 'index.html', + placeholder: '' + } + } + }, + createFactories(farmServer, hostServer) + ); + + const req = { + method: 'GET', + url: '/hello', + headers: { accept: 'text/html' } + }; + const headers: Record = {}; + const body = await new Promise((resolve) => { + const res = { + statusCode: 0, + setHeader(name: string, value: string) { + headers[name] = value; + }, + end(html: string) { + resolve(html); + } + }; + + devServer.middlewares(req as never, res as never, () => resolve('next')); + }); + + expect(body).toContain('
render:/hello
'); + expect(headers['Content-Type']).toContain('text/html'); + await devServer.close(); + }); + + it('falls back to root container replacement when default placeholder is missing', async () => { + const { farmServer } = createFarmServerMock( + false, + '
' + ); + const hostServer = new FakeHostServer(); + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + ssr: { + entry: '/src/entry-server.ts', + template: { + resource: 'index.html' + } + } + }, + createFactories(farmServer, hostServer) + ); + + const body = await new Promise((resolve) => { + const res = { + statusCode: 0, + setHeader() {}, + end(html: string) { + resolve(html); + } + }; + + devServer.middlewares( + { + method: 'GET', + url: '/root-fallback', + headers: { accept: 'text/html' } + } as never, + res as never, + () => resolve('next') + ); + }); + + expect(body).toContain( + '
render:/root-fallback
' + ); + await devServer.close(); + }); + + it('supports custom template load/transform for legacy html engines', async () => { + const { farmServer } = createFarmServerMock(); + const hostServer = new FakeHostServer(); + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + ssr: { + entry: '/src/entry-server.ts', + template: { + async load({ url }) { + return `<%- body %>|<%- url %>|${url}`; + }, + transform({ template, appHtml, url }) { + return template + .replace('<%- body %>', appHtml) + .replace('<%- url %>', url); + } + } + } + }, + createFactories(farmServer, hostServer) + ); + + const req = { + method: 'GET', + url: '/legacy', + headers: { accept: 'text/html' } + }; + const body = await new Promise((resolve) => { + const res = { + statusCode: 0, + setHeader() {}, + end(html: string) { + resolve(html); + } + }; + + devServer.middlewares(req as never, res as never, () => resolve('next')); + }); + + expect(body).toContain('
render:/legacy
'); + expect(body).toContain('/legacy'); + await devServer.close(); + }); + + it('composes middleware order and returns contract', async () => { + const { farmServer, runner, trace } = createFarmServerMock(); + const hostServer = new FakeHostServer(); + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + ssrMiddleware: (_req, _res, next) => { + trace.push('ssr'); + next(); + } + }, + createFactories(farmServer, hostServer) + ); + + const req = { method: 'GET', url: '/', headers: {} }; + const res = { + setHeader: vi.fn(), + getHeader: vi.fn(), + removeHeader: vi.fn(), + end: vi.fn() + }; + + await new Promise((resolve, reject) => { + devServer.middlewares(req as never, res as never, (error?: unknown) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + expect(trace).toEqual(['ssr', 'farm']); + expect(devServer.runner).toBe(runner); + expect(typeof devServer.listen).toBe('function'); + expect(typeof devServer.close).toBe('function'); + }); + + it('handles listen idempotency and close idempotency', async () => { + const { farmServer, runner } = createFarmServerMock(); + const hostServer = new FakeHostServer(); + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + host: { + port: 3201, + hostname: '127.0.0.1' + } + }, + createFactories(farmServer, hostServer) + ); + + await devServer.listen(); + await devServer.listen(); + await devServer.close(); + await devServer.close(); + + expect(hostServer.listenCalls).toEqual([ + { port: 3201, hostname: '127.0.0.1' } + ]); + expect(hostServer.closeCalls).toBe(1); + expect((runner.close as any).mock.calls.length).toBe(1); + expect((farmServer.close as any).mock.calls.length).toBe(1); + }); + + it('retries farm dev server creation with next hmr port when current port is in use', async () => { + const { farmServer } = createFarmServerMock(); + const hostServer = new FakeHostServer(); + let call = 0; + const createFarmServer = vi.fn(async (config: any) => { + call++; + if (call === 1) { + throw new Error( + 'WebSocket server error: Port is already in use. Please set a different port using the `server.hmr.port` option.' + ); + } + return farmServer; + }); + + const factories: SsrDevServerFactories = { + createFarmServer, + createHostServer: vi.fn(() => hostServer), + createServerCompiler: vi.fn(async () => { + throw new Error('server compiler should not be created in this case'); + }) + }; + + const devServer = await createSsrDevServerWithFactories( + { + client: { + server: { + hmr: { + port: 9811 + } + } + } + }, + factories + ); + + expect(createFarmServer).toHaveBeenCalledTimes(2); + expect(createFarmServer.mock.calls[0]?.[0]?.server?.hmr?.port).toBe(9811); + expect(createFarmServer.mock.calls[1]?.[0]?.server?.hmr?.port).toBe(9812); + + await devServer.close(); + }); + + it('cleans up resources when start fails during listen', async () => { + const { farmServer, runner } = createFarmServerMock(); + const hostServer = new FailingHostServer(); + + await expect( + startSsrDevServerWithFactories( + { + client: {} + }, + createFactories(farmServer, hostServer) + ) + ).rejects.toThrow('listen failed'); + + expect((runner.close as any).mock.calls.length).toBe(1); + expect((farmServer.close as any).mock.calls.length).toBe(1); + expect(hostServer.closeCalls).toBe(0); + }); + + it('wires server compiler updates through a single watcher into runner hmr', async () => { + const { farmServer, watcher } = createFarmServerMock(true); + const hostServer = new FakeHostServer(); + const serverCompiler = new FakeServerCompiler(); + const invalidations: Array<{ reason: string }> = []; + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + server: {}, + runner: { + hmr: true + }, + hooks: { + onInvalidate(ctx) { + invalidations.push({ reason: ctx.reason }); + } + } + }, + createFactories(farmServer, hostServer, serverCompiler) + ); + + const first = await devServer.runner.import<{ value: number }>( + '/src/entry-server.ts' + ); + expect(first.value).toBe(1); + + await watcher?.emit('change', '/src/entry-server.ts'); + + const second = await devServer.runner.import<{ value: number }>( + '/src/entry-server.ts' + ); + expect(second.value).toBe(2); + expect(serverCompiler.updateCalls).toBe(1); + expect(invalidations[0]?.reason).toBe('update'); + + await devServer.close(); + }); + + it('does not refresh runner cache when update path is outside server module graph', async () => { + const { farmServer, watcher } = createFarmServerMock(true); + const hostServer = new FakeHostServer(); + const serverCompiler = new FakeServerCompilerWithoutHasModule(); + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + server: {}, + runner: { + hmr: true + } + }, + createFactories(farmServer, hostServer, serverCompiler) + ); + + const first = await devServer.runner.import<{ value: number }>( + '/src/entry-server.ts' + ); + expect(first.value).toBe(1); + + await watcher?.emit('change', '/src/entry-server.ts'); + + const second = await devServer.runner.import<{ value: number }>( + '/src/entry-server.ts' + ); + expect(second.value).toBe(1); + expect(serverCompiler.updateCalls).toBe(1); + + await devServer.close(); + }); + + it('falls back to rebuild when server update fails', async () => { + const { farmServer, watcher } = createFarmServerMock(true); + const hostServer = new FakeHostServer(); + const serverCompiler = new FakeServerCompilerWithUpdateError(); + const invalidations: Array<{ reason: string }> = []; + + const devServer = await createSsrDevServerWithFactories( + { + client: {}, + server: {}, + runner: { + hmr: true + }, + hooks: { + onInvalidate(ctx) { + invalidations.push({ reason: ctx.reason }); + } + } + }, + createFactories(farmServer, hostServer, serverCompiler) + ); + + await watcher?.emit('change', '/src/entry-server.ts'); + + expect(serverCompiler.updateCalls).toBe(1); + expect(invalidations[0]?.reason).toBe('rebuild'); + + await devServer.close(); + }); +}); diff --git a/packages/ssr/test/manifest.spec.ts b/packages/ssr/test/manifest.spec.ts new file mode 100644 index 0000000000..8ddc5d5101 --- /dev/null +++ b/packages/ssr/test/manifest.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + injectAssetsIntoHtml, + resolveAssetsFromManifest, + type SsrManifest +} from '../src/manifest.js'; + +describe('ssr manifest helpers', () => { + it('resolves assets from manifest with entry and modules', () => { + const manifest: SsrManifest = { + version: 1, + entries: { + '/src/main.ts': { + js: ['assets/main.js'], + css: ['assets/main.css'], + preload: ['assets/vendor.js'] + } + }, + modules: { + '/src/pages/About.vue': { + css: ['assets/about.css'] + } + } + }; + + const assets = resolveAssetsFromManifest({ + manifest, + entry: '/src/main.ts', + usedModuleIds: ['/src/pages/About.vue'], + publicPath: '/' + }); + + expect(assets.css).toEqual(['/assets/main.css', '/assets/about.css']); + expect(assets.preload).toEqual(['/assets/vendor.js']); + expect(assets.scripts).toEqual(['/assets/main.js']); + }); + + it('injects assets into html', () => { + const html = + 't
'; + const injected = injectAssetsIntoHtml({ + html, + assets: { + css: ['/assets/main.css'], + preload: ['/assets/vendor.js'], + scripts: ['/assets/main.js'] + } + }); + + expect(injected).toContain( + '' + ); + expect(injected).toContain( + '' + ); + expect(injected).toContain( + '' + ); + }); +}); diff --git a/packages/ssr/test/server.spec.ts b/packages/ssr/test/server.spec.ts new file mode 100644 index 0000000000..b670a21091 --- /dev/null +++ b/packages/ssr/test/server.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createSsrServerWithResolvers, + type SsrServerOptions, + startSsrServerWithResolvers +} from '../src/server.js'; + +describe('farm ssr unified server api', () => { + it('dispatches to dev by default', async () => { + const devServer = { type: 'dev' }; + const previewServer = { type: 'preview' }; + const resolvers = { + createDevServer: vi.fn(async () => devServer as never), + createPreviewServer: vi.fn(async () => previewServer as never), + startDevServer: vi.fn(async () => devServer as never), + startPreviewServer: vi.fn(async () => previewServer as never) + }; + + const result = await createSsrServerWithResolvers( + { client: {} } as SsrServerOptions, + resolvers + ); + + expect(result).toBe(devServer); + expect(resolvers.createDevServer).toHaveBeenCalledTimes(1); + expect(resolvers.createPreviewServer).toHaveBeenCalledTimes(0); + }); + + it('dispatches to preview when command=preview', async () => { + const devServer = { type: 'dev' }; + const previewServer = { type: 'preview' }; + const resolvers = { + createDevServer: vi.fn(async () => devServer as never), + createPreviewServer: vi.fn(async () => previewServer as never), + startDevServer: vi.fn(async () => devServer as never), + startPreviewServer: vi.fn(async () => previewServer as never) + }; + + const result = await createSsrServerWithResolvers( + { + command: 'preview', + client: {}, + server: {} + } as SsrServerOptions, + resolvers + ); + + expect(result).toBe(previewServer); + expect(resolvers.createDevServer).toHaveBeenCalledTimes(0); + expect(resolvers.createPreviewServer).toHaveBeenCalledTimes(1); + }); + + it('start api follows the same command dispatching', async () => { + const devServer = { type: 'dev' }; + const previewServer = { type: 'preview' }; + const resolvers = { + createDevServer: vi.fn(async () => devServer as never), + createPreviewServer: vi.fn(async () => previewServer as never), + startDevServer: vi.fn(async () => devServer as never), + startPreviewServer: vi.fn(async () => previewServer as never) + }; + + const previewResult = await startSsrServerWithResolvers( + { + command: 'preview', + client: {}, + server: {} + } as SsrServerOptions, + resolvers + ); + const devResult = await startSsrServerWithResolvers( + { client: {} } as SsrServerOptions, + resolvers + ); + + expect(previewResult).toBe(previewServer); + expect(devResult).toBe(devServer); + expect(resolvers.startPreviewServer).toHaveBeenCalledTimes(1); + expect(resolvers.startDevServer).toHaveBeenCalledTimes(1); + }); + + it('mode does not change dispatch semantics', async () => { + const devServer = { type: 'dev' }; + const previewServer = { type: 'preview' }; + const resolvers = { + createDevServer: vi.fn(async () => devServer as never), + createPreviewServer: vi.fn(async () => previewServer as never), + startDevServer: vi.fn(async () => devServer as never), + startPreviewServer: vi.fn(async () => previewServer as never) + }; + + const result = await createSsrServerWithResolvers( + { + mode: 'production', + client: {} + } as SsrServerOptions, + resolvers + ); + + expect(result).toBe(devServer); + expect(resolvers.createDevServer).toHaveBeenCalledTimes(1); + expect(resolvers.createPreviewServer).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/ssr/test/ssr-toolkit-example.spec.ts b/packages/ssr/test/ssr-toolkit-example.spec.ts new file mode 100644 index 0000000000..41af20ccde --- /dev/null +++ b/packages/ssr/test/ssr-toolkit-example.spec.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + resolveHostPort, + resolveOptionalDevHmrPort +} from '../../../examples/ssr-toolkit/server/ports.mjs'; +import { + createRuntimeConfig, + resolveSsrCommand, + resolveSsrEnvMode, + resolveTemplateMode +} from '../../../examples/ssr-toolkit/server/runtime-config.mjs'; +import { + createPingPayload, + createSsrServerOptions, + createStartupMessage +} from '../../../examples/ssr-toolkit/server/ssr-options.mjs'; +import { + createSsrRenderConfig, + createTemplateConfig, + injectAppHtmlIntoBuiltTemplate, + renderEjsLike +} from '../../../examples/ssr-toolkit/server/template.mjs'; + +describe('ssr toolkit example runtime config', () => { + it('resolves dev defaults from env', () => { + const config = createRuntimeConfig({}); + expect(config).toMatchObject({ + host: '127.0.0.1', + command: 'dev', + mode: 'development', + templateMode: 'html', + explicitHmrPort: undefined, + explicitHostPort: undefined + }); + }); + + it('resolves preview command/mode from env', () => { + const env = { + SSR_COMMAND: 'preview' + }; + + expect(resolveSsrCommand(env)).toBe('preview'); + expect(resolveSsrEnvMode('preview', env)).toBe('production'); + expect(resolveTemplateMode(env)).toBe('html'); + }); + + it('prefers explicit env mode and ejs template', () => { + const env = { + SSR_MODE: 'preview', + SSR_ENV_MODE: 'staging', + SSR_TEMPLATE_MODE: 'ejs', + SSR_HMR_PORT: '9821', + SSR_HOST_PORT: '3022' + }; + + const config = createRuntimeConfig(env); + expect(config).toMatchObject({ + command: 'preview', + mode: 'staging', + templateMode: 'ejs', + explicitHmrPort: 9821, + explicitHostPort: 3022 + }); + }); +}); + +describe('ssr toolkit example port policy', () => { + it('returns undefined hmr port when command is preview', async () => { + const hmrPort = await resolveOptionalDevHmrPort({ + command: 'preview', + host: '127.0.0.1', + explicitHmrPort: undefined + }); + + expect(hmrPort).toBeUndefined(); + }); + + it('rejects invalid explicit hmr port', async () => { + await expect( + resolveOptionalDevHmrPort({ + command: 'dev', + host: '127.0.0.1', + explicitHmrPort: NaN, + isPortAvailableFn: vi.fn(async () => true) + }) + ).rejects.toThrow('invalid SSR_HMR_PORT'); + }); + + it('falls back to next available host port', async () => { + const isPortAvailableFn = vi.fn(async (port: number) => port === 3012); + const hostPort = await resolveHostPort({ + host: '127.0.0.1', + explicitHostPort: undefined, + startPort: 3011, + maxProbe: 3, + isPortAvailableFn + }); + + expect(hostPort).toBe(3012); + expect(isPortAvailableFn).toHaveBeenNthCalledWith(1, 3011, '127.0.0.1'); + expect(isPortAvailableFn).toHaveBeenNthCalledWith(2, 3012, '127.0.0.1'); + }); +}); + +describe('ssr toolkit example template policy', () => { + it('injects app html into built template root container', () => { + const html = injectAppHtmlIntoBuiltTemplate( + '
', + '
app
' + ); + + expect(html).toContain('
app
'); + expect(html).toContain(''); + }); + + it('supports ejs-like transform with escaping and raw html', () => { + const html = renderEjsLike( + '<%= pageTitle %>
<%- appHtml %>
', + { + pageTitle: '', + appHtml: '

ok

' + } + ); + + expect(html).toContain('<unsafe>'); + expect(html).toContain('

ok

'); + }); + + it('creates preview template config using built index html', async () => { + const config = createTemplateConfig({ + command: 'preview', + templateMode: 'html' + }); + + expect(config).toMatchObject({ + file: './dist/client/index.html' + }); + expect(typeof config.transform).toBe('function'); + await expect( + config.transform({ + template: + '
', + appHtml: '
preview
', + url: '/', + req: {} as never, + res: {} as never, + root: '/project' + }) + ).resolves.toContain('data-farm-resource=true'); + }); + + it('rejects preview+ejs template mode', () => { + expect(() => + createTemplateConfig({ + command: 'preview', + templateMode: 'ejs' + }) + ).toThrow('preview mode does not support SSR_TEMPLATE_MODE=ejs'); + }); +}); + +describe('ssr toolkit example option builders', () => { + it('creates ssr server options and startup metadata', () => { + const runtime = createRuntimeConfig({ + SSR_COMMAND: 'preview' + }); + + const options = createSsrServerOptions({ + runtime, + hmrPort: undefined + }); + const renderConfig = createSsrRenderConfig({ + command: runtime.command, + templateMode: runtime.templateMode + }); + const startupMessage = createStartupMessage({ + runtime, + hostPort: 3011, + hmrPort: undefined + }); + const pingPayload = createPingPayload(runtime); + + expect(options.command).toBe('preview'); + expect(options.client.configFile).toBe('./farm.config.ts'); + expect(options.server.configFile).toBe('./farm.config.ts'); + expect(options.client.server?.preview?.distDir).toBe('dist/client'); + expect(options.client.compilation?.output?.path).toBe('dist/client'); + expect(options.server.compilation?.output?.path).toBe('dist/server'); + expect(options.server.compilation?.output?.targetEnv).toBe('node'); + expect(options.server.compilation?.output?.format).toBe('esm'); + expect(options.ssr.entry).toBeUndefined(); + expect(options.ssr.template.file).toBe('./dist/client/index.html'); + expect(typeof options.ssr.template.transform).toBe('function'); + expect(renderConfig.template.file).toBe('./dist/client/index.html'); + expect(startupMessage).toContain('command=preview'); + expect(pingPayload).toMatchObject({ + command: 'preview', + mode: 'production', + templateMode: 'html' + }); + }); + + it('injects hmr port into client config when provided', () => { + const runtime = createRuntimeConfig({}); + const options = createSsrServerOptions({ + runtime, + hmrPort: 9821 + }); + + expect(options.client.server?.hmr).toMatchObject({ + port: 9821, + host: runtime.host + }); + }); +}); diff --git a/packages/ssr/tsconfig.build.json b/packages/ssr/tsconfig.build.json new file mode 100644 index 0000000000..5a65924f2c --- /dev/null +++ b/packages/ssr/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "src/**/*.spec.ts"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + } +} diff --git a/packages/ssr/tsconfig.json b/packages/ssr/tsconfig.json new file mode 100644 index 0000000000..d3ba2eab23 --- /dev/null +++ b/packages/ssr/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04ae424249..4ea886bb77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: version: 3.1.1(vite@5.2.8(@types/node@18.19.67)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1))(vitest@2.0.4(@types/node@18.19.67)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1)) '@commitlint/cli': specifier: ^17.0.3 - version: 17.8.1(@swc/core@1.7.26) + version: 17.8.1(@swc/core@1.7.26(@swc/helpers@0.5.17)) '@commitlint/config-conventional': specifier: ^17.0.3 version: 17.8.1 @@ -99,19 +99,19 @@ importers: version: 1.36.0 '@docusaurus/core': specifier: 3.5.2 - version: 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + version: 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/plugin-ideal-image': specifier: 3.5.2 - version: 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(prop-types@15.8.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + version: 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(prop-types@15.8.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/preset-classic': specifier: 3.5.2 - version: 3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + version: 3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/theme-common': specifier: 3.5.2 - version: 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) + version: 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/theme-search-algolia': specifier: 3.5.2 - version: 3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + version: 3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@mdx-js/react': specifier: ^3.0.0 version: 3.0.1(@types/react@18.2.35)(react@18.2.0) @@ -144,7 +144,7 @@ importers: version: 0.7.0 docusaurus-plugin-sass: specifier: 0.2.5 - version: 0.2.5(@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(sass@1.74.1)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) + version: 0.2.5(@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(sass@1.74.1)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) docusaurus-preset-shiki-twoslash: specifier: ^1.1.41 version: 1.1.41 @@ -802,6 +802,16 @@ importers: specifier: workspace:* version: link:../../packages/core + examples/middleware-mode: + dependencies: + express: + specifier: ^4.21.2 + version: 4.22.1 + devDependencies: + '@farmfe/core': + specifier: workspace:* + version: link:../../packages/core + examples/module-concatenation: dependencies: core-js: @@ -896,13 +906,13 @@ importers: version: link:../../packages/core '@nestjs/cli': specifier: ^10.0.0 - version: 10.4.2(@swc/core@1.7.26) + version: 10.4.2(@swc/core@1.7.26(@swc/helpers@0.5.17)) '@nestjs/schematics': specifier: ^10.0.0 version: 10.1.2(chokidar@3.6.0)(typescript@5.4.5) '@nestjs/testing': specifier: ^10.0.0 - version: 10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10)) + version: 10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10)(@nestjs/platform-express@10.3.10) '@types/express': specifier: ^4.17.17 version: 4.17.21 @@ -935,7 +945,7 @@ importers: version: 0.1.5 jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + version: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) prettier: specifier: ^3.0.0 version: 3.3.2 @@ -947,13 +957,13 @@ importers: version: 6.3.4 ts-jest: specifier: ^29.1.0 - version: 29.1.5(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.3))(jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)))(typescript@5.4.5) + version: 29.1.5(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.3))(jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)))(typescript@5.4.5) ts-loader: specifier: ^9.4.3 - version: 9.5.1(typescript@5.4.5)(webpack@5.92.1(@swc/core@1.7.26)) + version: 9.5.1(typescript@5.4.5)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) ts-node: specifier: ^10.9.1 - version: 10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5) + version: 10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -1059,7 +1069,7 @@ importers: version: 6.0.0(postcss@8.4.31) tailwindcss: specifier: ^3.3.2 - version: 3.3.5(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3)) + version: 3.3.5(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)) examples/preact: dependencies: @@ -1324,7 +1334,7 @@ importers: optionalDependencies: '@tsconfig/create-react-app': specifier: latest - version: 2.0.10 + version: 2.0.11 abort-controller: specifier: latest version: 3.0.0 @@ -1785,6 +1795,31 @@ importers: specifier: ^2.7.0 version: 2.7.2(solid-js@1.8.5)(vite@5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1)) + examples/ssr-toolkit: + dependencies: + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.4(vite@5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1))(vue@3.4.35(typescript@5.6.3)) + vue: + specifier: ^3.3.12 + version: 3.4.35(typescript@5.6.3) + vue-router: + specifier: ^4.2.1 + version: 4.4.5(vue@3.4.35(typescript@5.6.3)) + devDependencies: + '@farmfe/core': + specifier: workspace:* + version: link:../../packages/core + '@farmfe/js-plugin-less': + specifier: workspace:* + version: link:../../js-plugins/less + '@farmfe/ssr': + specifier: workspace:* + version: link:../../packages/ssr + less: + specifier: ^4.1.3 + version: 4.2.2 + examples/static-assets: dependencies: is-plain-object: @@ -1845,7 +1880,7 @@ importers: version: 0.14.0 tailwindcss: specifier: ^3.3.2 - version: 3.3.5(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3)) + version: 3.3.5(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)) examples/tailwind-next: dependencies: @@ -2141,7 +2176,7 @@ importers: dependencies: vite-tsconfig-paths: specifier: ^4.3.1 - version: 4.3.1(typescript@5.6.3)(vite@5.2.8(@types/node@18.19.67)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1)) + version: 4.3.1(typescript@5.6.3)(vite@5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1)) devDependencies: '@farmfe/cli': specifier: workspace:* @@ -2530,7 +2565,7 @@ importers: version: 1.1.1(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(rollup@4.14.1)(sass@1.85.1)(terser@5.31.1) unplugin-auto-import: specifier: ^0.16.7 - version: 0.16.7(@vueuse/core@9.13.0(vue@3.4.35(typescript@5.6.3)))(rollup@4.14.1) + version: 0.16.7(@vueuse/core@9.13.0(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3)))(rollup@4.14.1) unplugin-vue-router: specifier: ^0.7.0 version: 0.7.0(rollup@4.14.1)(vue-router@4.4.5(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3)) @@ -2668,7 +2703,7 @@ importers: version: 16.0.1(postcss@8.4.31) postcss-load-config: specifier: ^4.0.1 - version: 4.0.1(postcss@8.4.31)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3)) + version: 4.0.1(postcss@8.4.31)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)) postcss-url: specifier: ^10.1.3 version: 10.1.3(postcss@8.4.31) @@ -2859,7 +2894,7 @@ importers: version: 8.4.35 tailwindcss: specifier: ^3.3.2 - version: 3.4.13(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@20.14.12)(typescript@5.6.3)) + version: 3.4.13(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)) vue: specifier: ^3.3.4 version: 3.4.35(typescript@5.6.3) @@ -2905,6 +2940,9 @@ importers: packages/cli: dependencies: + '@farmfe/ssr': + specifier: workspace:* + version: link:../ssr cac: specifier: ^6.7.14 version: 6.7.14 @@ -3099,6 +3137,16 @@ importers: specifier: workspace:* version: link:../runtime + packages/ssr: + dependencies: + '@farmfe/core': + specifier: workspace:* + version: link:../core + devDependencies: + typescript: + specifier: 5.6.3 + version: 5.6.3 + packages/utils: {} rust-plugins/dts: @@ -8600,6 +8648,9 @@ packages: '@rolldown/pluginutils@1.0.0-beta.34': resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + '@rollup/pluginutils@4.2.1': resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} engines: {node: '>= 8.0.0'} @@ -9155,8 +9206,8 @@ packages: '@ts-morph/common@0.24.0': resolution: {integrity: sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==} - '@tsconfig/create-react-app@2.0.10': - resolution: {integrity: sha512-cdQ7qpcYokWyAVVTOGR0Mnqss+ei/nOSUU0xrAmXyWl2pnnnDLFGyJ9i5nxEpRBgHxt9W+nNwLmpvVA2yR0hZQ==} + '@tsconfig/create-react-app@2.0.11': + resolution: {integrity: sha512-vvIx+rDshVTVyA0l8OAjt8LCFUX0xhp6I5me1ggoQCg4Lny0xLG2Y1aXC7b76TCAq5hbUwTRhKF1RR33Yl7mkA==} '@tsconfig/node10@1.0.9': resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -10301,6 +10352,13 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 vue: ^3.2.25 + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + '@vitest/coverage-v8@2.0.4': resolution: {integrity: sha512-i4lx/Wpg5zF1h2op7j0wdwuEQxaL/YTwwQaKuKMHYj7MMh8c7I4W7PNfOptZBCSBZI0z1qwn64o0pM/pA8Tz1g==} peerDependencies: @@ -11328,6 +11386,10 @@ packages: resolution: {integrity: sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + bonjour-service@1.2.1: resolution: {integrity: sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==} @@ -11475,10 +11537,18 @@ packages: resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -12369,6 +12439,10 @@ packages: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -13253,6 +13327,10 @@ packages: draco3d@1.5.7: resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} @@ -13374,6 +13452,10 @@ packages: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -13443,6 +13525,10 @@ packages: resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} engines: {node: '>= 0.4'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -13453,6 +13539,10 @@ packages: es-module-lexer@1.5.4: resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + es-set-tostringtag@2.0.2: resolution: {integrity: sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==} engines: {node: '>= 0.4'} @@ -13689,6 +13779,10 @@ packages: resolution: {integrity: sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==} engines: {node: '>= 0.10.0'} + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -13949,6 +14043,10 @@ packages: resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} engines: {node: '>= 0.8'} + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + find-cache-dir@4.0.0: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} engines: {node: '>=14.16'} @@ -14230,6 +14328,10 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} @@ -14245,6 +14347,10 @@ packages: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stdin@9.0.0: resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==} engines: {node: '>=12'} @@ -14393,6 +14499,10 @@ packages: gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + got@11.8.6: resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} engines: {node: '>=10.19.0'} @@ -14469,6 +14579,10 @@ packages: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + has-tostringtag@1.0.0: resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} engines: {node: '>= 0.4'} @@ -14510,6 +14624,10 @@ packages: resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} engines: {node: '>= 0.4'} + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + hast-util-from-dom@4.2.0: resolution: {integrity: sha512-t1RJW/OpJbCAJQeKi3Qrj1cAOLA0+av/iPFori112+0X7R3wng+jxLA+kXec8K4szqPRGI8vPxbbpEYvvpwaeQ==} @@ -14680,6 +14798,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-parser-js@0.5.8: resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==} @@ -16115,6 +16237,10 @@ packages: material-colors@1.2.6: resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-definitions@5.1.2: resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} @@ -16259,6 +16385,9 @@ packages: merge-descriptors@1.0.1: resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-options@1.0.1: resolution: {integrity: sha512-iuPV41VWKWBIOpBsjoxjDZw8/GbSfZ2mk7N1453bwMrfzdrIk7EzBd+8UVR6rkw67th7xnk9Dytl3J+lHPdxvg==} engines: {node: '>=4'} @@ -16975,6 +17104,10 @@ packages: object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + object-is@1.1.5: resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} engines: {node: '>= 0.4'} @@ -17297,6 +17430,9 @@ packages: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} @@ -18153,6 +18289,10 @@ packages: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} engines: {node: '>=0.6'} + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + query-string@4.3.4: resolution: {integrity: sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==} engines: {node: '>=0.10.0'} @@ -18219,6 +18359,10 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + rbush@2.0.2: resolution: {integrity: sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA==} @@ -19503,6 +19647,10 @@ packages: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -19525,6 +19673,10 @@ packages: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'} + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + serve@14.1.2: resolution: {integrity: sha512-luwVfJwbeE7dhCKeRU0vIBpt4bXdbAfzwsWJIQ5eqrIW2e+4nLWXbSlZ0WzelSFHQq+FlueOW6dr90jEewS9zw==} engines: {node: '>= 14'} @@ -19618,9 +19770,25 @@ packages: resolution: {integrity: sha512-tqnoSWWb7NailWJ/72Bgi8Z5O+ul71SJ5EhXbfHprZg67RGwck5eVyv5Uv4pso06ZuzNpUabRTyyFKHN/Ea9Mw==} deprecated: Shikiji is merged back to Shiki v1.0, please migrate over to get the latest updates + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -19901,6 +20069,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} @@ -22132,7 +22304,7 @@ snapshots: yjs: 13.6.20 zustand: 4.5.5(@types/react@18.2.35)(immer@9.0.21)(react@18.2.0) zustand-middleware-yjs: 1.3.1(@types/react@18.2.35)(immer@9.0.21)(react@18.2.0) - zustand-utils: 1.3.2(react@18.2.0)(zustand@4.5.5(@types/react@18.2.35)(immer@9.0.21)(react@18.2.0)) + zustand-utils: 1.3.2(react@18.2.0)(zustand@4.5.5(@types/react@18.2.35)(immer@10.0.3)(react@18.2.0)) transitivePeerDependencies: - '@emotion/react' - '@types/react' @@ -24609,11 +24781,11 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@commitlint/cli@17.8.1(@swc/core@1.7.26)': + '@commitlint/cli@17.8.1(@swc/core@1.7.26(@swc/helpers@0.5.17))': dependencies: '@commitlint/format': 17.8.1 '@commitlint/lint': 17.8.1 - '@commitlint/load': 17.8.1(@swc/core@1.7.26) + '@commitlint/load': 17.8.1(@swc/core@1.7.26(@swc/helpers@0.5.17)) '@commitlint/read': 17.8.1 '@commitlint/types': 17.8.1 execa: 5.1.1 @@ -24662,7 +24834,7 @@ snapshots: '@commitlint/rules': 17.8.1 '@commitlint/types': 17.8.1 - '@commitlint/load@17.8.1(@swc/core@1.7.26)': + '@commitlint/load@17.8.1(@swc/core@1.7.26(@swc/helpers@0.5.17))': dependencies: '@commitlint/config-validator': 17.8.1 '@commitlint/execute-rule': 17.8.1 @@ -24671,12 +24843,12 @@ snapshots: '@types/node': 20.5.1 chalk: 4.1.2 cosmiconfig: 8.3.6(typescript@5.6.3) - cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.6.3))(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@20.5.1)(typescript@5.6.3))(typescript@5.6.3) + cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.6.3))(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.5.1)(typescript@5.6.3))(typescript@5.6.3) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 resolve-from: 5.0.0 - ts-node: 10.9.1(@swc/core@1.7.26)(@types/node@20.5.1)(typescript@5.6.3) + ts-node: 10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.5.1)(typescript@5.6.3) typescript: 5.6.3 transitivePeerDependencies: - '@swc/core' @@ -25262,7 +25434,7 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: '@babel/core': 7.25.2 '@babel/generator': 7.25.0 @@ -25314,7 +25486,7 @@ snapshots: postcss-loader: 7.3.4(postcss@8.4.47)(typescript@5.6.3)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) prompts: 2.4.2 react: 18.2.0 - react-dev-utils: 12.0.1(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) + react-dev-utils: 12.0.1(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) react-dom: 18.2.0(react@18.2.0) react-helmet-async: 1.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.2.0)' @@ -25431,13 +25603,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-content-blog@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/logger': 3.5.2 '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) '@docusaurus/utils-common': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)) @@ -25473,13 +25645,13 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/logger': 3.5.2 '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/module-type-aliases': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) '@docusaurus/utils-common': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)) @@ -25513,9 +25685,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-content-pages@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-content-pages@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) @@ -25544,9 +25716,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-debug@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-debug@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) fs-extra: 11.2.0 @@ -25573,9 +25745,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-google-analytics@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-google-analytics@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) react: 18.2.0 @@ -25600,9 +25772,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-google-gtag@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-google-gtag@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) '@types/gtag.js': 0.0.12 @@ -25628,9 +25800,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-google-tag-manager@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) react: 18.2.0 @@ -25655,9 +25827,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-ideal-image@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(prop-types@15.8.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-ideal-image@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(prop-types@15.8.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/lqip-loader': 3.5.2(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) '@docusaurus/responsive-loader': 1.7.0(sharp@0.32.6) '@docusaurus/theme-translations': 3.5.2 @@ -25690,9 +25862,9 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/plugin-sitemap@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/plugin-sitemap@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/logger': 3.5.2 '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) @@ -25722,20 +25894,20 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/preset-classic@3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': - dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-debug': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-google-analytics': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-google-gtag': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-google-tag-manager': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-sitemap': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/theme-classic': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) - '@docusaurus/theme-search-algolia': 3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/preset-classic@3.5.2(@algolia/client-search@4.24.0)(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': + dependencies: + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-debug': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-google-analytics': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-google-gtag': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-google-tag-manager': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-sitemap': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/theme-classic': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) + '@docusaurus/theme-search-algolia': 3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -25772,15 +25944,15 @@ snapshots: optionalDependencies: sharp: 0.32.6 - '@docusaurus/theme-classic@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/theme-classic@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/module-type-aliases': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) + '@docusaurus/plugin-content-blog': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/plugin-content-pages': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/theme-translations': 3.5.2 '@docusaurus/types': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) @@ -25820,11 +25992,11 @@ snapshots: - vue-template-compiler - webpack-cli - '@docusaurus/theme-common@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)': + '@docusaurus/theme-common@3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)': dependencies: '@docusaurus/mdx-loader': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/module-type-aliases': 3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) '@docusaurus/utils-common': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)) '@types/history': 4.7.11 @@ -25846,13 +26018,13 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-search-algolia@3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)': + '@docusaurus/theme-search-algolia@3.5.2(@algolia/client-search@4.24.0)(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/react@18.2.35)(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))': dependencies: '@docsearch/react': 3.6.2(@algolia/client-search@4.24.0)(@types/react@18.2.35)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) '@docusaurus/logger': 3.5.2 - '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) - '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) + '@docusaurus/plugin-content-docs': 3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) + '@docusaurus/theme-common': 3.5.2(@docusaurus/plugin-content-docs@3.5.2(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3) '@docusaurus/theme-translations': 3.5.2 '@docusaurus/utils': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) '@docusaurus/utils-validation': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(typescript@5.6.3) @@ -27047,7 +27219,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -27061,7 +27233,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -27643,7 +27815,7 @@ snapshots: '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.0 '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.0 - '@nestjs/cli@10.4.2(@swc/core@1.7.26)': + '@nestjs/cli@10.4.2(@swc/core@1.7.26(@swc/helpers@0.5.17))': dependencies: '@angular-devkit/core': 17.3.8(chokidar@3.6.0) '@angular-devkit/schematics': 17.3.8(chokidar@3.6.0) @@ -27653,7 +27825,7 @@ snapshots: chokidar: 3.6.0 cli-table3: 0.6.5 commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.3.3)(webpack@5.92.1(@swc/core@1.7.26)) + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.3.3)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) glob: 10.4.2 inquirer: 8.2.6 node-emoji: 1.11.0 @@ -27662,7 +27834,7 @@ snapshots: tsconfig-paths: 4.2.0 tsconfig-paths-webpack-plugin: 4.1.0 typescript: 5.3.3 - webpack: 5.92.1(@swc/core@1.7.26) + webpack: 5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17)) webpack-node-externals: 3.0.0 optionalDependencies: '@swc/core': 1.7.26(@swc/helpers@0.5.17) @@ -27729,7 +27901,7 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10))': + '@nestjs/testing@10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10)(@nestjs/platform-express@10.3.10)': dependencies: '@nestjs/common': 10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.3.10(@nestjs/common@10.3.10(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -30857,6 +31029,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.34': {} + '@rolldown/pluginutils@1.0.0-rc.2': {} + '@rollup/pluginutils@4.2.1': dependencies: estree-walker: 2.0.2 @@ -31412,7 +31586,7 @@ snapshots: mkdirp: 3.0.1 path-browserify: 1.0.1 - '@tsconfig/create-react-app@2.0.10': + '@tsconfig/create-react-app@2.0.11': optional: true '@tsconfig/node10@1.0.9': {} @@ -33410,6 +33584,12 @@ snapshots: vite: 5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1) vue: 3.4.27(typescript@5.6.3) + '@vitejs/plugin-vue@6.0.4(vite@5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1))(vue@3.4.35(typescript@5.6.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1) + vue: 3.4.35(typescript@5.6.3) + '@vitest/coverage-v8@2.0.4(vitest@2.0.4(@types/node@18.19.67)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1))': dependencies: '@ampproject/remapping': 2.3.0 @@ -34136,11 +34316,11 @@ snapshots: - '@vue/composition-api' - vue - '@vueuse/core@9.13.0(vue@3.4.35(typescript@5.6.3))': + '@vueuse/core@9.13.0(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3))': dependencies: '@types/web-bluetooth': 0.0.16 '@vueuse/metadata': 9.13.0 - '@vueuse/shared': 9.13.0(vue@3.4.35(typescript@5.6.3)) + '@vueuse/shared': 9.13.0(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3)) vue-demi: 0.14.10(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3)) transitivePeerDependencies: - '@vue/composition-api' @@ -34156,7 +34336,7 @@ snapshots: - '@vue/composition-api' - vue - '@vueuse/shared@9.13.0(vue@3.4.35(typescript@5.6.3))': + '@vueuse/shared@9.13.0(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3))': dependencies: vue-demi: 0.14.10(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3)) transitivePeerDependencies: @@ -35231,6 +35411,23 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + bonjour-service@1.2.1: dependencies: fast-deep-equal: 3.1.3 @@ -35460,6 +35657,11 @@ snapshots: normalize-url: 6.1.0 responselike: 2.0.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -35468,6 +35670,11 @@ snapshots: get-intrinsic: 1.2.4 set-function-length: 1.2.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camel-case@4.1.2: @@ -36045,6 +36252,8 @@ snapshots: cookie@0.6.0: {} + cookie@0.7.2: {} + cookiejar@2.1.4: {} copy-anything@2.0.6: @@ -36110,11 +36319,11 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.6.3))(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@20.5.1)(typescript@5.6.3))(typescript@5.6.3): + cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.6.3))(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.5.1)(typescript@5.6.3))(typescript@5.6.3): dependencies: '@types/node': 20.5.1 cosmiconfig: 8.3.6(typescript@5.6.3) - ts-node: 10.9.1(@swc/core@1.7.26)(@types/node@20.5.1)(typescript@5.6.3) + ts-node: 10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.5.1)(typescript@5.6.3) typescript: 5.6.3 cosmiconfig@6.0.0: @@ -36158,13 +36367,13 @@ snapshots: buffer: 5.7.1 optional: true - create-jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)): + create-jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -36889,9 +37098,9 @@ snapshots: dependencies: esutils: 2.0.3 - docusaurus-plugin-sass@0.2.5(@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16))(sass@1.74.1)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): + docusaurus-plugin-sass@0.2.5(@docusaurus/core@3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))))(sass@1.74.1)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): dependencies: - '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16) + '@docusaurus/core': 3.5.2(@docusaurus/types@3.5.2(@swc/core@1.7.26(@swc/helpers@0.5.17))(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mdx-js/react@3.0.1(@types/react@18.2.35)(react@18.2.0))(@swc/core@1.7.26(@swc/helpers@0.5.17))(bufferutil@4.0.8)(eslint@8.57.0)(lightningcss@1.30.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3))) sass: 1.74.1 sass-loader: 10.5.2(sass@1.74.1)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) transitivePeerDependencies: @@ -37013,6 +37222,12 @@ snapshots: draco3d@1.5.7: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer2@0.1.4: dependencies: readable-stream: 2.3.8 @@ -37189,6 +37404,8 @@ snapshots: encodeurl@1.0.2: {} + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -37290,6 +37507,8 @@ snapshots: dependencies: get-intrinsic: 1.2.4 + es-define-property@1.0.1: {} + es-errors@1.3.0: {} es-get-iterator@1.1.3: @@ -37306,6 +37525,10 @@ snapshots: es-module-lexer@1.5.4: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + es-set-tostringtag@2.0.2: dependencies: get-intrinsic: 1.2.4 @@ -37710,6 +37933,42 @@ snapshots: transitivePeerDependencies: - supports-color + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -37981,6 +38240,18 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + find-cache-dir@4.0.0: dependencies: common-path-prefix: 3.0.0 @@ -38089,7 +38360,7 @@ snapshots: cross-spawn: 7.0.3 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@6.5.3(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): + fork-ts-checker-webpack-plugin@6.5.3(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.24.7 '@types/json-schema': 7.0.15 @@ -38108,9 +38379,9 @@ snapshots: webpack: 5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17)) optionalDependencies: eslint: 8.57.0 - vue-template-compiler: 2.7.16(vue@2.7.16) + vue-template-compiler: 2.7.16(vue@3.4.35(typescript@5.6.3)) - fork-ts-checker-webpack-plugin@9.0.2(typescript@5.3.3)(webpack@5.92.1(@swc/core@1.7.26)): + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.3.3)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.24.2 chalk: 4.1.2 @@ -38125,7 +38396,7 @@ snapshots: semver: 7.5.4 tapable: 2.2.1 typescript: 5.3.3 - webpack: 5.92.1(@swc/core@1.7.26) + webpack: 5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17)) form-data-encoder@2.1.4: {} @@ -38285,6 +38556,19 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.0 + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-own-enumerable-property-symbols@3.0.2: {} get-package-info@1.0.0: @@ -38300,6 +38584,11 @@ snapshots: get-port@7.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stdin@9.0.0: {} get-stream@4.1.0: @@ -38486,6 +38775,8 @@ snapshots: dependencies: get-intrinsic: 1.2.4 + gopd@1.2.0: {} + got@11.8.6: dependencies: '@sindresorhus/is': 4.6.0 @@ -38568,6 +38859,8 @@ snapshots: has-symbols@1.0.3: {} + has-symbols@1.1.0: {} + has-tostringtag@1.0.0: dependencies: has-symbols: 1.0.3 @@ -38605,6 +38898,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + hast-util-from-dom@4.2.0: dependencies: hastscript: 7.2.0 @@ -38914,6 +39211,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-parser-js@0.5.8: {} http-proxy-agent@5.0.0: @@ -39521,16 +39826,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)): + jest-cli@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + create-jest: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -39540,7 +39845,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)): dependencies: '@babel/core': 7.25.2 '@jest/test-sequencer': 29.7.0 @@ -39566,7 +39871,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 18.19.67 - ts-node: 10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5) + ts-node: 10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -39792,12 +40097,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)): + jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + jest-cli: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -40469,6 +40774,8 @@ snapshots: material-colors@1.2.6: {} + math-intrinsics@1.1.0: {} + mdast-util-definitions@5.1.2: dependencies: '@types/mdast': 3.0.15 @@ -40828,6 +41135,8 @@ snapshots: merge-descriptors@1.0.1: {} + merge-descriptors@1.0.3: {} + merge-options@1.0.1: dependencies: is-plain-obj: 1.1.0 @@ -41830,6 +42139,8 @@ snapshots: object-inspect@1.13.1: {} + object-inspect@1.13.4: {} + object-is@1.1.5: dependencies: call-bind: 1.0.7 @@ -42150,6 +42461,8 @@ snapshots: lru-cache: 11.0.1 minipass: 7.1.2 + path-to-regexp@0.1.12: {} + path-to-regexp@0.1.7: {} path-to-regexp@1.8.0: @@ -42468,31 +42781,23 @@ snapshots: '@csstools/utilities': 2.0.0(postcss@8.4.47) postcss: 8.4.47 - postcss-load-config@4.0.1(postcss@8.4.31)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3)): + postcss-load-config@4.0.1(postcss@8.4.31)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)): dependencies: lilconfig: 2.1.0 yaml: 2.3.4 optionalDependencies: postcss: 8.4.31 - ts-node: 10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3) + ts-node: 10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3) - postcss-load-config@4.0.1(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3)): + postcss-load-config@4.0.1(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)): dependencies: lilconfig: 2.1.0 yaml: 2.3.4 optionalDependencies: postcss: 8.4.39 - ts-node: 10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3) - - postcss-load-config@4.0.1(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)): - dependencies: - lilconfig: 2.1.0 - yaml: 2.3.4 - optionalDependencies: - postcss: 8.4.47 ts-node: 10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3) - postcss-load-config@4.0.1(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@20.14.12)(typescript@5.6.3)): + postcss-load-config@4.0.1(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)): dependencies: lilconfig: 2.1.0 yaml: 2.3.4 @@ -43072,6 +43377,10 @@ snapshots: dependencies: side-channel: 1.0.4 + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + query-string@4.3.4: dependencies: object-assign: 4.1.1 @@ -43141,6 +43450,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + rbush@2.0.2: dependencies: quickselect: 1.1.1 @@ -43533,7 +43849,7 @@ snapshots: prop-types: 15.8.1 react: 18.2.0 - react-dev-utils@12.0.1(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): + react-dev-utils@12.0.1(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): dependencies: '@babel/code-frame': 7.24.7 address: 1.2.2 @@ -43544,7 +43860,7 @@ snapshots: escape-string-regexp: 4.0.0 filesize: 8.0.7 find-up: 5.0.0 - fork-ts-checker-webpack-plugin: 6.5.3(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) + fork-ts-checker-webpack-plugin: 6.5.3(eslint@8.57.0)(typescript@5.6.3)(vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)))(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))) global-modules: 2.0.0 globby: 11.1.0 gzip-size: 6.0.0 @@ -44690,6 +45006,24 @@ snapshots: transitivePeerDependencies: - supports-color + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -44733,6 +45067,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + serve@14.1.2: dependencies: '@zeit/schemas': 2.21.0 @@ -44863,12 +45206,40 @@ snapshots: dependencies: shikiji-core: 0.9.10 + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + side-channel@1.0.4: dependencies: call-bind: 1.0.7 get-intrinsic: 1.2.4 object-inspect: 1.13.1 + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -45170,6 +45541,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.7.0: {} std-env@3.9.0: {} @@ -45476,7 +45849,7 @@ snapshots: dependencies: tailwindcss: 4.0.0-alpha.26 - tailwindcss@3.3.5(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3)): + tailwindcss@3.3.5(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -45495,7 +45868,7 @@ snapshots: postcss: 8.4.39 postcss-import: 15.1.0(postcss@8.4.39) postcss-js: 4.0.1(postcss@8.4.39) - postcss-load-config: 4.0.1(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3)) + postcss-load-config: 4.0.1(postcss@8.4.39)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3)) postcss-nested: 6.0.1(postcss@8.4.39) postcss-selector-parser: 6.0.13 resolve: 1.22.8 @@ -45530,33 +45903,6 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.13(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@20.14.12)(typescript@5.6.3)): - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.2 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.0 - lilconfig: 2.1.0 - micromatch: 4.0.5 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.0 - postcss: 8.4.47 - postcss-import: 15.1.0(postcss@8.4.47) - postcss-js: 4.0.1(postcss@8.4.47) - postcss-load-config: 4.0.1(postcss@8.4.47)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@20.14.12)(typescript@5.6.3)) - postcss-nested: 6.0.1(postcss@8.4.47) - postcss-selector-parser: 6.1.0 - resolve: 1.22.8 - sucrase: 3.34.0 - transitivePeerDependencies: - - ts-node - tailwindcss@4.0.0-alpha.26: {} tailwindcss@4.1.12: {} @@ -45711,17 +46057,6 @@ snapshots: optionalDependencies: '@swc/core': 1.7.26(@swc/helpers@0.5.17) - terser-webpack-plugin@5.3.10(@swc/core@1.7.26)(webpack@5.92.1(@swc/core@1.7.26)): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.31.1 - webpack: 5.92.1(@swc/core@1.7.26) - optionalDependencies: - '@swc/core': 1.7.26(@swc/helpers@0.5.17) - terser@5.31.1: dependencies: '@jridgewell/source-map': 0.3.6 @@ -45924,11 +46259,11 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.1.5(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.3))(jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)))(typescript@5.4.5): + ts-jest@29.1.5(@babel/core@7.28.3)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.3))(jest@29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)))(typescript@5.4.5): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5)) + jest: 29.7.0(@types/node@18.19.67)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5)) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -45942,7 +46277,7 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.3) - ts-loader@9.5.1(typescript@5.4.5)(webpack@5.92.1(@swc/core@1.7.26)): + ts-loader@9.5.1(typescript@5.4.5)(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): dependencies: chalk: 4.1.2 enhanced-resolve: 5.17.0 @@ -45950,7 +46285,7 @@ snapshots: semver: 7.5.4 source-map: 0.7.4 typescript: 5.4.5 - webpack: 5.92.1(@swc/core@1.7.26) + webpack: 5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17)) ts-morph@18.0.0: dependencies: @@ -45967,27 +46302,7 @@ snapshots: '@ts-morph/common': 0.24.0 code-block-writer: 13.0.1 - ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.14.12 - acorn: 8.12.0 - acorn-walk: 8.3.2 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.6.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.7.26(@swc/helpers@0.5.17) - - ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.4.5): + ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@18.19.67)(typescript@5.4.5): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 @@ -46007,14 +46322,14 @@ snapshots: optionalDependencies: '@swc/core': 1.7.26(@swc/helpers@0.5.17) - ts-node@10.9.1(@swc/core@1.7.26)(@types/node@18.19.67)(typescript@5.6.3): + ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.14.12)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 18.19.67 + '@types/node': 20.14.12 acorn: 8.12.0 acorn-walk: 8.3.2 arg: 4.1.3 @@ -46026,9 +46341,8 @@ snapshots: yn: 3.1.1 optionalDependencies: '@swc/core': 1.7.26(@swc/helpers@0.5.17) - optional: true - ts-node@10.9.1(@swc/core@1.7.26)(@types/node@20.5.1)(typescript@5.6.3): + ts-node@10.9.1(@swc/core@1.7.26(@swc/helpers@0.5.17))(@types/node@20.5.1)(typescript@5.6.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.9 @@ -46459,7 +46773,7 @@ snapshots: transitivePeerDependencies: - rollup - unplugin-auto-import@0.16.7(@vueuse/core@9.13.0(vue@3.4.35(typescript@5.6.3)))(rollup@4.14.1): + unplugin-auto-import@0.16.7(@vueuse/core@9.13.0(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3)))(rollup@4.14.1): dependencies: '@antfu/utils': 0.7.6 '@rollup/pluginutils': 5.0.5(rollup@4.14.1) @@ -46470,7 +46784,7 @@ snapshots: unimport: 3.4.0(rollup@4.14.1) unplugin: 1.5.0 optionalDependencies: - '@vueuse/core': 9.13.0(vue@3.4.35(typescript@5.6.3)) + '@vueuse/core': 9.13.0(@vue/composition-api@1.7.2(vue@3.4.35(typescript@5.6.3)))(vue@3.4.35(typescript@5.6.3)) transitivePeerDependencies: - rollup @@ -47128,13 +47442,13 @@ snapshots: svgo: 3.2.0 vue: 3.3.7(typescript@5.6.3) - vite-tsconfig-paths@4.3.1(typescript@5.6.3)(vite@5.2.8(@types/node@18.19.67)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1)): + vite-tsconfig-paths@4.3.1(typescript@5.6.3)(vite@5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1)): dependencies: debug: 4.3.4 globrex: 0.1.2 tsconfck: 3.0.1(typescript@5.6.3) optionalDependencies: - vite: 5.2.8(@types/node@18.19.67)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1) + vite: 5.2.8(@types/node@20.14.12)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.1)(terser@5.31.1) transitivePeerDependencies: - supports-color - typescript @@ -47328,6 +47642,13 @@ snapshots: he: 1.2.0 vue: 2.7.16 + vue-template-compiler@2.7.16(vue@3.4.35(typescript@5.6.3)): + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + vue: 3.4.35(typescript@5.6.3) + optional: true + vue-template-es2015-compiler@1.9.1: {} vue-types@3.0.2(vue@3.3.7(typescript@5.6.3)): @@ -47480,7 +47801,7 @@ snapshots: compression: 1.7.4 connect-history-api-fallback: 2.0.0 default-gateway: 6.0.3 - express: 4.19.2 + express: 4.22.1 graceful-fs: 4.2.11 html-entities: 2.3.3 http-proxy-middleware: 2.0.7(@types/express@4.17.21) @@ -47551,37 +47872,6 @@ snapshots: - esbuild - uglify-js - webpack@5.92.1(@swc/core@1.7.26): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.5 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.12.0 - acorn-import-attributes: 1.9.5(acorn@8.12.0) - browserslist: 4.23.1 - chrome-trace-event: 1.0.3 - enhanced-resolve: 5.17.0 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.7.26)(webpack@5.92.1(@swc/core@1.7.26)) - watchpack: 2.4.1 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpackbar@5.0.2(webpack@5.92.1(@swc/core@1.7.26(@swc/helpers@0.5.17))): dependencies: chalk: 4.1.2 @@ -47897,13 +48187,6 @@ snapshots: react: 18.2.0 zustand: 4.5.5(@types/react@18.2.35)(immer@10.0.3)(react@18.2.0) - zustand-utils@1.3.2(react@18.2.0)(zustand@4.5.5(@types/react@18.2.35)(immer@9.0.21)(react@18.2.0)): - dependencies: - '@babel/runtime': 7.25.0 - fast-deep-equal: 3.1.3 - react: 18.2.0 - zustand: 4.5.5(@types/react@18.2.35)(immer@9.0.21)(react@18.2.0) - zustand@3.7.2(react@18.2.0): optionalDependencies: react: 18.2.0 diff --git a/vitest.config.ts b/vitest.config.ts index 48c16a7759..bbcd9e2dcb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ }, maxConcurrency: 1, retry: 0, - cache: false + cache: false, + env: { + FORCE_COLOR: '1' + } } });