diff --git a/crates/pack-api/src/app.rs b/crates/pack-api/src/app.rs index 1fd3af0a6a..5a3443d757 100644 --- a/crates/pack-api/src/app.rs +++ b/crates/pack-api/src/app.rs @@ -39,7 +39,7 @@ use turbopack_core::{ }; use crate::{ - endpoint::{Endpoint, EndpointOutput, EndpointOutputPaths}, + endpoint::{Endpoint, EndpointOutput, EndpointOutputPaths, Endpoints}, paths::initial_paths_in_root, project::Project, }; @@ -48,6 +48,9 @@ use turbopack_resolve::resolve_options_context::ResolveOptionsContext; #[turbo_tasks::value(transparent)] pub struct AppEntrypoints(pub Vec); +#[turbo_tasks::value(transparent)] +pub struct ResolvedAppEntrypoints(pub Vec>); + #[turbo_tasks::value] pub struct AppProject { pub project: ResolvedVc, @@ -70,31 +73,187 @@ impl AppProject { } #[turbo_tasks::function] - pub async fn get_app_endpoint(self: Vc) -> Result> { - let this = self.await?; + pub fn project(&self) -> Vc { + *self.project + } - let project = this.project; + #[turbo_tasks::function] + pub async fn app_runtime_entries(self: Vc) -> Result> { + let project = self.project(); + match &*project.platform().await? { + Platform::Node => Ok(EvaluatableAssets::empty()), + Platform::Web => { + let watch = project.await?.watch.enable; + Ok(get_client_runtime_entries( + project.project_path().owned().await?, + project.mode(), + project.config(), + project.execution_context(), + project.pack_path().owned().await?, + Vc::cell(watch), + project.client_hmr_enabled(), + ) + .resolve_entries(Vc::upcast(self.app_module_context()))) + } + } + } + + #[turbo_tasks::function] + pub async fn app_module_context(self: Vc) -> Result> { + let project = self.project(); + let platform = &*project.platform().await?; + + let layer = match platform { + Platform::Node => { + Layer::new_with_user_friendly_name(rcstr!("server"), rcstr!("Nodejs")) + } + Platform::Web => { + Layer::new_with_user_friendly_name(rcstr!("client"), rcstr!("Browser")) + } + }; + + // Build transition options, registering "server-reference" when configured + let mut named_transitions: FxHashMap< + RcStr, + ResolvedVc>, + > = FxHashMap::default(); + let server_config = project.config().server().await?; + if server_config.function.is_some() { + let server_module_options_context = get_server_module_options_context( + project.project_path().owned().await?, + project.execution_context(), + project.server_compile_time_info().environment(), + project.mode(), + project.config(), + ) + .to_resolved() + .await?; + let server_resolve_options_context = get_server_resolve_options_context( + project.project_path().owned().await?, + project.mode(), + project.config(), + project.config().server_externals_config(), + project.execution_context(), + project.pack_path().owned().await?, + ) + .to_resolved() + .await?; + let transition = ServerReferenceTransition::new( + *project.server_compile_time_info().to_resolved().await?, + *server_module_options_context, + *server_resolve_options_context, + ) + .to_resolved() + .await?; + named_transitions.insert(rcstr!("server-reference"), ResolvedVc::upcast(transition)); + } + + let transition_options = TransitionOptions { + named_transitions, + ..Default::default() + } + .cell(); + Ok(ModuleAssetContext::new( + transition_options, + project.compile_time_info_for_platform(), + self.app_module_options_context(), + self.app_resolve_options_context(), + layer, + )) + } + + #[turbo_tasks::function] + async fn app_module_options_context(self: Vc) -> Result> { + let project = self.project(); + match &*project.platform().await? { + Platform::Node => Ok(get_server_module_options_context( + project.project_path().owned().await?, + project.execution_context(), + project.server_compile_time_info().environment(), + project.mode(), + project.config(), + )), + Platform::Web => Ok(get_client_module_options_context( + project.project_path().owned().await?, + project.execution_context(), + project.client_compile_time_info().environment(), + project.mode(), + project.config(), + Vc::cell(project.await?.watch.enable), + project.pack_path().owned().await?, + )), + } + } + + #[turbo_tasks::function] + async fn app_resolve_options_context(self: Vc) -> Result> { + let project = self.project(); + match &*project.platform().await? { + Platform::Node => Ok(get_server_resolve_options_context( + project.project_path().owned().await?, + project.mode(), + project.config(), + project.config().externals_config(), + project.execution_context(), + project.pack_path().owned().await?, + )), + Platform::Web => Ok(get_client_resolve_options_context( + project.project_path().owned().await?, + project.mode(), + project.config(), + project.execution_context(), + project.pack_path().owned().await?, + )), + } + } + + #[turbo_tasks::function] + pub async fn resolved_entrypoints(self: Vc) -> Result> { + let this = self.await?; + let project = this.project; let entrypoints = this .apps .await? .iter() - .map(|a| async move { + .map(|entrypoint| async move { AppEntrypoint { project, - name: a.name.clone(), - import: a.import.clone(), + name: entrypoint.name.clone(), + import: entrypoint.import.clone(), } .resolved_cell() }) .join() .await; - Ok(AppEndpoint { - project, - entrypoints, - } - .cell()) + Ok(ResolvedAppEntrypoints(entrypoints).cell()) + } + + #[turbo_tasks::function] + pub async fn get_app_endpoints(self: Vc) -> Result> { + let app_project = self.to_resolved().await?; + let this = self.await?; + let project = this.project; + let entrypoints = self.resolved_entrypoints().await?.to_vec(); + + let endpoints = entrypoints + .iter() + .map(|entrypoint| async move { + let endpoint: Vc> = Vc::upcast( + AppEndpoint { + app_project, + project, + entrypoints: vec![*entrypoint], + } + .cell(), + ); + endpoint.to_resolved().await + }) + .try_join() + .await?; + + Ok(Endpoints(endpoints).cell()) } } @@ -305,6 +464,7 @@ impl AppEntrypoint { #[turbo_tasks::value] pub struct AppEndpoint { + app_project: ResolvedVc, project: ResolvedVc, pub entrypoints: Vec>, } @@ -315,137 +475,6 @@ impl AppEndpoint { pub fn project(&self) -> Vc { *self.project } - - #[turbo_tasks::function] - pub async fn app_runtime_entries(self: Vc) -> Result> { - let project = self.project(); - match &*project.platform().await? { - Platform::Node => Ok(EvaluatableAssets::empty()), - Platform::Web => { - let watch = project.await?.watch.enable; - Ok(get_client_runtime_entries( - project.project_path().owned().await?, - project.mode(), - project.config(), - project.execution_context(), - project.pack_path().owned().await?, - Vc::cell(watch), - project.client_hmr_enabled(), - ) - .resolve_entries(Vc::upcast(self.app_module_context()))) - } - } - } - - #[turbo_tasks::function] - pub async fn app_module_context(self: Vc) -> Result> { - let project = self.project(); - let platform = &*project.platform().await?; - - let layer = match platform { - Platform::Node => { - Layer::new_with_user_friendly_name(rcstr!("server"), rcstr!("Nodejs")) - } - Platform::Web => { - Layer::new_with_user_friendly_name(rcstr!("client"), rcstr!("Browser")) - } - }; - - // Build transition options, registering "server-reference" when configured - let mut named_transitions: FxHashMap< - RcStr, - ResolvedVc>, - > = FxHashMap::default(); - let server_config = project.config().server().await?; - if server_config.function.is_some() { - let server_module_options_context = get_server_module_options_context( - project.project_path().owned().await?, - project.execution_context(), - project.server_compile_time_info().environment(), - project.mode(), - project.config(), - ) - .to_resolved() - .await?; - let server_resolve_options_context = get_server_resolve_options_context( - project.project_path().owned().await?, - project.mode(), - project.config(), - project.config().server_externals_config(), - project.execution_context(), - project.pack_path().owned().await?, - ) - .to_resolved() - .await?; - let transition = ServerReferenceTransition::new( - *project.server_compile_time_info().to_resolved().await?, - *server_module_options_context, - *server_resolve_options_context, - ) - .to_resolved() - .await?; - named_transitions.insert(rcstr!("server-reference"), ResolvedVc::upcast(transition)); - } - - let transition_options = TransitionOptions { - named_transitions, - ..Default::default() - } - .cell(); - - Ok(ModuleAssetContext::new( - transition_options, - project.compile_time_info_for_platform(), - self.app_module_options_context(), - self.app_resolve_options_context(), - layer, - )) - } - - #[turbo_tasks::function] - async fn app_module_options_context(self: Vc) -> Result> { - let project = self.project(); - match &*project.platform().await? { - Platform::Node => Ok(get_server_module_options_context( - project.project_path().owned().await?, - project.execution_context(), - project.server_compile_time_info().environment(), - project.mode(), - project.config(), - )), - Platform::Web => Ok(get_client_module_options_context( - project.project_path().owned().await?, - project.execution_context(), - project.client_compile_time_info().environment(), - project.mode(), - project.config(), - Vc::cell(project.await?.watch.enable), - project.pack_path().owned().await?, - )), - } - } - - #[turbo_tasks::function] - async fn app_resolve_options_context(self: Vc) -> Result> { - let project = self.project(); - match &*project.platform().await? { - Platform::Node => Ok(get_server_resolve_options_context( - project.project_path().owned().await?, - project.mode(), - project.config(), - project.config().externals_config(), - project.execution_context(), - project.pack_path().owned().await?, - )), - Platform::Web => Ok(get_client_resolve_options_context( - project.project_path().owned().await?, - project.mode(), - project.config(), - project.execution_context(), - project.pack_path().owned().await?, - )), - } - } } #[turbo_tasks::value_impl] @@ -453,8 +482,8 @@ impl Endpoint for AppEndpoint { #[turbo_tasks::function] async fn entries(self: Vc) -> Result> { let this = self.await?; - let asset_context = self.app_module_context(); - let runtime_entries = self.app_runtime_entries(); + let asset_context = this.app_project.app_module_context(); + let runtime_entries = this.app_project.app_runtime_entries(); let entries = this .entrypoints @@ -482,17 +511,18 @@ impl Endpoint for AppEndpoint { #[turbo_tasks::function] async fn output(self: Vc) -> Result> { async move { - let asset_context = self.app_module_context(); - - let runtime_entries = self.app_runtime_entries(); - let this = self.await?; - let output_assets = { + let asset_context = this.app_project.app_module_context(); + let runtime_entries = this.app_project.app_runtime_entries(); + let client_output_assets = { let mut vcs = this .entrypoints .iter() .map(|e| e.output_assets_for_entry(Vc::upcast(asset_context), runtime_entries)) .collect::>(); + // Copy assets are project-level output. Include the shared task in every + // endpoint so writing any endpoint independently preserves the historical + // aggregated-endpoint behavior. The all-endpoints path deduplicates assets. vcs.push(this.project.copy_output_assets()); OutputAssets::concat(vcs) }; @@ -505,20 +535,36 @@ impl Endpoint for AppEndpoint { .as_ref() .is_some_and(|entry| entry.has_entries()) { - Some( - self.server_reference_output_assets(Vc::upcast(asset_context), runtime_entries), - ) + Some(this.app_project.server_output_assets()) } else { None }; let dist_root_vc = this.project.dist_root(); + let (client_paths, server_output) = futures::future::try_join( + async { + Ok::<_, anyhow::Error>( + initial_paths_in_root(client_output_assets, dist_root_vc) + .await? + .iter() + .cloned() + .collect(), + ) + }, + async { + match server_output { + Some(server_output) => { + // Drive the independent Server build concurrently with Client path + // discovery while preserving the Vc for the final asset union. + server_output.await?; + Ok(Some(server_output)) + } + None => Ok(None), + } + }, + ) + .await?; let dist_root = dist_root_vc.await?; - let client_paths = initial_paths_in_root(output_assets, dist_root_vc) - .await? - .iter() - .cloned() - .collect(); let written_endpoint = EndpointOutputPaths::NodeJs { server_entry_path: dist_root.path.clone(), @@ -526,7 +572,7 @@ impl Endpoint for AppEndpoint { client_paths, }; - let mut output_assets = output_assets; + let mut output_assets = client_output_assets; if let Some(server_output) = server_output { output_assets = output_assets.concatenate(server_output); @@ -545,6 +591,17 @@ impl Endpoint for AppEndpoint { #[turbo_tasks::function] async fn server_changed(self: Vc) -> Result> { + let this = self.await?; + let server_config = this.project.config().server().await?; + if *this.project.platform().await? == Platform::Web + && server_config.function.is_none() + && !server_config + .entry + .as_ref() + .is_some_and(|entry| entry.has_entries()) + { + return Ok(Completion::new()); + } let EndpointOutput { output_assets, project, @@ -555,6 +612,10 @@ impl Endpoint for AppEndpoint { #[turbo_tasks::function] async fn client_changed(self: Vc) -> Result> { + let this = self.await?; + if *this.project.platform().await? == Platform::Node { + return Ok(Completion::new()); + } let EndpointOutput { output_assets, project, @@ -566,60 +627,65 @@ impl Endpoint for AppEndpoint { /// Server function build support #[turbo_tasks::value_impl] -impl AppEndpoint { +impl AppProject { /// Discovers `ServerReferenceModule`s in the client module graph and builds /// their inner server modules as Node.js chunks. #[turbo_tasks::function] - async fn server_reference_output_assets( - self: Vc, - asset_context: Vc>, - runtime_entries: Vc, - ) -> Result> { + async fn server_output_assets(self: Vc) -> Result> { let this = self.await?; let project = *this.project; + let entrypoints = self.resolved_entrypoints().await?.to_vec(); + let asset_context = Vc::upcast(self.app_module_context()); + let runtime_entries = self.app_runtime_entries(); + let server_config = project.config().server().await?; - // Await all graphs simultaneously for better parallelization - let resolved_graphs = this - .entrypoints - .iter() - .map(|e| async { - e.module_graph_for_entry(asset_context, runtime_entries) - .await - }) - .try_join() - .await?; + let server_function_assets: Vec>> = + if server_config.function.is_some() { + // Await all graphs simultaneously for better parallelization. + let resolved_graphs = entrypoints + .iter() + .map(|e| async { + e.module_graph_for_entry(asset_context, runtime_entries) + .await + }) + .try_join() + .await?; - // Walk all graphs to find ServerReferenceModule instances - let mut unique_server_modules = turbo_tasks::FxIndexSet::default(); - for graph in &resolved_graphs { - for module in graph.iter_nodes() { - if let Some(server_ref) = - ResolvedVc::try_downcast_type::(module) - { - let inner = server_ref.await?; - unique_server_modules.insert(inner.server_module); + // Walk all graphs to find ServerReferenceModule instances. + let mut unique_server_modules = turbo_tasks::FxIndexSet::default(); + for graph in &resolved_graphs { + for module in graph.iter_nodes() { + if let Some(server_ref) = + ResolvedVc::try_downcast_type::(module) + { + let inner = server_ref.await?; + unique_server_modules.insert(inner.server_module); + } + } } - } - } - // Resolving VCs to strings for a deterministic sorting pass guarantees our - // AST chunk hashes remain tightly identical between runs, following Next.js's - // FxIndexMap/IndexSet pattern for chunking server routines. - let mut pairs = unique_server_modules - .into_iter() - .map(|m| async move { Ok((m.ident().to_string().await?, m)) }) - .try_join() - .await?; + // Resolving VCs to strings for a deterministic sorting pass guarantees our + // AST chunk hashes remain tightly identical between runs, following Next.js's + // FxIndexMap/IndexSet pattern for chunking server routines. + let mut pairs = unique_server_modules + .into_iter() + .map(|m| async move { Ok((m.ident().to_string().await?, m)) }) + .try_join() + .await?; - pairs.sort_by(|a, b| a.0.cmp(&b.0)); - let server_modules: Vec<_> = pairs.into_iter().map(|(_, m)| m).collect(); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + let server_modules: Vec<_> = pairs.into_iter().map(|(_, m)| m).collect(); - let server_function_assets: Vec>> = server_modules - .iter() - .filter_map(|m| ResolvedVc::try_sidecast::>(*m)) - .collect(); + server_modules + .iter() + .filter_map(|m| ResolvedVc::try_sidecast::>(*m)) + .collect() + } else { + // A plain server entry does not use the server-reference transition, so its + // build can start without waiting for every client module graph. + Vec::new() + }; - let server_config = project.config().server().await?; let mut entry_specs = Vec::new(); if let Some(entry) = &server_config.entry { match entry { diff --git a/crates/pack-api/src/entrypoint.rs b/crates/pack-api/src/entrypoint.rs index 0c611e7998..0129e1df14 100644 --- a/crates/pack-api/src/entrypoint.rs +++ b/crates/pack-api/src/entrypoint.rs @@ -14,7 +14,7 @@ use crate::{ operation::EntrypointsOperation, project::ProjectContainer, utils::get_issues, - webpack_stats::generate_webpack_stats, + webpack_stats::{OutputAssetGroups, generate_webpack_stats}, }; #[turbo_tasks::value(shared)] @@ -59,15 +59,19 @@ pub async fn all_output_assets_operation( ) -> Result> { let project = container.project(); - let endpoint_assets = project + let endpoint_asset_groups = project .get_all_endpoints() .await? .iter() - .map(|endpoint| async move { endpoint.output().await?.output_assets.await }) + .map(|endpoint| async move { Ok(endpoint.output().await?.output_assets) }) .try_join() .await?; - let output_assets: FxIndexSet>> = endpoint_assets + let output_assets: FxIndexSet>> = endpoint_asset_groups + .iter() + .map(|assets| async move { assets.await }) + .try_join() + .await? .iter() .flat_map(|assets| assets.iter().copied()) .collect(); @@ -89,12 +93,31 @@ pub async fn all_output_assets_operation( let mut stats_outputs: Vec>> = Vec::new(); if !has_server { - stats_outputs.push(make_stats_output(output_assets, dist_root).await?); + stats_outputs.push( + make_stats_output( + output_assets, + Vc::::cell(endpoint_asset_groups), + dist_root, + ) + .await?, + ); } else { let server_dist_root_vc = container.project().server_dist_root(); let server_dist_root_read = server_dist_root_vc.await?; let mut client: Vec>> = Vec::new(); let mut server: Vec>> = Vec::new(); + let mut client_groups = Vec::with_capacity(endpoint_asset_groups.len()); + for assets in endpoint_asset_groups { + let mut group = Vec::new(); + for asset in assets.await?.iter().copied() { + if !asset.path().await?.is_inside_ref(&server_dist_root_read) { + group.push(asset); + } + } + if !group.is_empty() { + client_groups.push(ResolvedVc::cell(group)); + } + } for asset in output_assets.await?.iter().copied() { if asset.path().await?.is_inside_ref(&server_dist_root_read) { server.push(asset); @@ -102,9 +125,24 @@ pub async fn all_output_assets_operation( client.push(asset); } } - stats_outputs.push(make_stats_output(Vc::cell(client), dist_root).await?); + stats_outputs.push( + make_stats_output( + Vc::cell(client), + Vc::::cell(client_groups), + dist_root, + ) + .await?, + ); if !server.is_empty() { - stats_outputs.push(make_stats_output(Vc::cell(server), server_dist_root_vc).await?); + let server_assets = ResolvedVc::cell(server); + stats_outputs.push( + make_stats_output( + *server_assets, + Vc::::cell(vec![server_assets]), + server_dist_root_vc, + ) + .await?, + ); } } @@ -113,9 +151,10 @@ pub async fn all_output_assets_operation( async fn make_stats_output( assets: Vc, + asset_groups: Vc, dist_root: Vc, ) -> Result>> { - let webpack_stats = generate_webpack_stats(assets, dist_root).await?; + let webpack_stats = generate_webpack_stats(assets, asset_groups, dist_root).await?; let stats_json = serde_json::to_string_pretty(&*webpack_stats)?; let dist_root_owned = dist_root.owned().await?; let stats_output = VirtualOutputAsset::new( diff --git a/crates/pack-api/src/project.rs b/crates/pack-api/src/project.rs index fe5909b98f..4b33be5832 100644 --- a/crates/pack-api/src/project.rs +++ b/crates/pack-api/src/project.rs @@ -1489,12 +1489,7 @@ impl Project { let app_project = self.app_project().to_resolved().await?.await?; Ok(Entrypoints { apps: match *app_project { - Some(app) => Some( - Endpoints(vec![ResolvedVc::upcast( - app.get_app_endpoint().to_resolved().await?, - )]) - .resolved_cell(), - ), + Some(app) => Some(app.get_app_endpoints().to_resolved().await?), None => None, }, libraries: match *library_project { diff --git a/crates/pack-api/src/webpack_stats.rs b/crates/pack-api/src/webpack_stats.rs index 73a01a0184..d93fd91e32 100644 --- a/crates/pack-api/src/webpack_stats.rs +++ b/crates/pack-api/src/webpack_stats.rs @@ -31,6 +31,9 @@ pub struct AssetIntermediateInfo { pub dev_chunk_list: Option, } +#[turbo_tasks::value(transparent)] +pub struct OutputAssetGroups(pub Vec>); + fn normalize_stats_path(path: RcStr) -> RcStr { path.strip_prefix("./").map(Into::into).unwrap_or(path) } @@ -381,6 +384,7 @@ pub async fn get_asset_intermediate_info( #[turbo_tasks::function] pub async fn generate_webpack_stats( entry_assets: Vc, + entry_asset_groups: Vc, dist_root: Vc, ) -> Result> { let mut assets = vec![]; @@ -402,10 +406,13 @@ pub async fn generate_webpack_stats( }) .try_join() .await?; + let asset_info_by_asset: FxHashMap<_, _> = all_assets + .iter() + .copied() + .zip(asset_results.iter()) + .collect(); - let mut dev_chunk_lists: Vec = vec![]; - for info in asset_results { - let info = info; + for info in &asset_results { if seen_asset_paths.insert(info.asset.name.clone()) { assets.push(info.asset.clone()); } @@ -424,17 +431,39 @@ pub async fn generate_webpack_stats( modules.insert(module.id.clone(), module.clone()); } } - if let Some(dev_chunk_list) = &info.dev_chunk_list { - dev_chunk_lists.push(dev_chunk_list.clone()); - } } - for dev_chunk_list in dev_chunk_lists { - for entrypoint in entrypoints.values_mut() { - entrypoint.chunks.push(dev_chunk_list.clone()); - entrypoint.assets.push(WebpackStatsEntrypointAssets { - name: dev_chunk_list.clone(), - }); + // Endpoint output groups preserve which evaluate entry owns each development chunk list. + // Associating these lists after flattening all output assets made every entrypoint include + // every other page's HMR bootstrap in multi-page builds. + for group in entry_asset_groups.await?.iter().copied() { + let group = group.await?; + let group_entrypoints: FxIndexMap<_, _> = group + .iter() + .filter_map(|asset| asset_info_by_asset.get(asset)) + .flat_map(|info| info.entrypoints.iter()) + .map(|(name, _)| (name.clone(), ())) + .collect(); + let group_chunk_lists: FxIndexMap<_, _> = group + .iter() + .filter_map(|asset| asset_info_by_asset.get(asset)) + .filter_map(|info| info.dev_chunk_list.as_ref()) + .map(|name| (name.clone(), ())) + .collect(); + + for entrypoint_name in group_entrypoints.keys() { + let Some(entrypoint) = entrypoints.get_mut(entrypoint_name) else { + continue; + }; + for dev_chunk_list in group_chunk_lists.keys() { + if entrypoint.chunks.contains(dev_chunk_list) { + continue; + } + entrypoint.chunks.push(dev_chunk_list.clone()); + entrypoint.assets.push(WebpackStatsEntrypointAssets { + name: dev_chunk_list.clone(), + }); + } } } diff --git a/crates/pack-cli/src/serve/source.rs b/crates/pack-cli/src/serve/source.rs index 24a6fd447f..0a94bda6c2 100644 --- a/crates/pack-cli/src/serve/source.rs +++ b/crates/pack-cli/src/serve/source.rs @@ -23,21 +23,17 @@ pub async fn create_web_entry_source( ) -> Result>> { let entries = match &*project.app_project().await? { Some(app_project) => { - let app_endpoint = app_project.get_app_endpoint(); - - let asset_context = Vc::upcast(app_endpoint.app_module_context()); - - let runtime_entries = app_endpoint.app_runtime_entries(); - - let chunking_context = app_endpoint + let asset_context = Vc::upcast(app_project.app_module_context()); + let runtime_entries = app_project.app_runtime_entries(); + let chunking_context = app_project .project() .client_chunking_context() .to_resolved() .await?; - app_endpoint + app_project + .resolved_entrypoints() .await? - .entrypoints .iter() .map(async |app| { let module_graph = app diff --git a/crates/pack-tests/tests/snapshot.rs b/crates/pack-tests/tests/snapshot.rs index 2a763b2e15..85e9b1f6bd 100644 --- a/crates/pack-tests/tests/snapshot.rs +++ b/crates/pack-tests/tests/snapshot.rs @@ -7,11 +7,12 @@ mod util; use anyhow::{Context, Result}; use dunce::canonicalize; use pack_api::{ - endpoint::get_written_endpoint_with_issues_operation, + endpoint::{Endpoint, OptionEndpoint, get_written_endpoint_with_issues_operation}, entrypoint::{ EntrypointsWithIssues, all_output_assets_operation, get_all_written_entrypoints_with_issues_operation, get_entrypoints_with_issues_operation, }, + paths::all_paths_in_root, project::{ProjectContainer, ProjectOptions, WatchOptions}, }; use rustc_hash::FxHashSet; @@ -43,6 +44,36 @@ static SNAPSHOT_TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(()) #[turbo_tasks::value(transparent)] struct SnapshotIgnorePrefixes(Vec); +struct SnapshotProjectOptions { + project: ProjectOptions, + write_endpoints_individually: bool, + expected_app_endpoints: Option, + expected_individual_app_output_paths: Option>, +} + +struct SnapshotConfig { + user_config: serde_json::Value, + runtime_type_override: Option, + watch_enabled: bool, + write_endpoints_individually: bool, + expected_app_endpoints: Option, + expected_individual_app_output_paths: Option>, +} + +#[turbo_tasks::function(operation, root)] +async fn endpoint_all_output_paths_operation( + endpoint: OperationVc, +) -> Result>> { + let Some(endpoint) = *endpoint.connect().await? else { + return Ok(Vc::cell(Vec::new())); + }; + let output = endpoint.output().await?; + Ok(all_paths_in_root( + *output.output_assets, + output.project.dist_root(), + )) +} + fn snapshot_ignore_prefixes(resource: &Path) -> Result> { let ignore_path = resource.join(".snapshotignore"); if !ignore_path.try_exists()? { @@ -172,12 +203,19 @@ async fn run(resource: PathBuf) -> Result<()> { noop_backing_storage(), )); tt.run_once(async move { - let (project_options, write_endpoints_individually) = - project_options_from_resource(&resource)?; + let SnapshotProjectOptions { + project: project_options, + write_endpoints_individually, + expected_app_endpoints, + expected_individual_app_output_paths, + } = project_options_from_resource(&resource)?; let container_op = ProjectContainer::new_operation(rcstr!("project"), project_options.dev); ProjectContainer::initialize(container_op, project_options).await?; - if write_endpoints_individually { + if write_endpoints_individually + || expected_app_endpoints.is_some() + || expected_individual_app_output_paths.is_some() + { let project_container = container_op.resolve().strongly_consistent().await?; let entrypoints_with_issues = read_strongly_consistent_and_apply_effects( get_entrypoints_with_issues_operation(project_container), @@ -185,27 +223,68 @@ async fn run(resource: PathBuf) -> Result<()> { ) .await?; - for endpoint in entrypoints_with_issues - .entrypoints - .apps - .iter() - .chain(&entrypoints_with_issues.entrypoints.libraries) - { - let written_endpoint = get_written_endpoint_with_issues_operation(*endpoint) - .read_strongly_consistent() - .await?; - let error_count = written_endpoint - .issues - .iter() - .filter(|issue| issue.severity <= IssueSeverity::Error) - .count(); + if let Some(expected) = expected_app_endpoints { anyhow::ensure!( - error_count == 0, - "writing an endpoint to disk produced {error_count} error issue(s)" + entrypoints_with_issues.entrypoints.apps.len() == expected, + "expected {expected} app endpoint(s), got {}", + entrypoints_with_issues.entrypoints.apps.len() ); } - return Ok(()); + if write_endpoints_individually || expected_individual_app_output_paths.is_some() { + for (index, endpoint) in entrypoints_with_issues.entrypoints.apps.iter().enumerate() + { + let written_endpoint = get_written_endpoint_with_issues_operation(*endpoint) + .read_strongly_consistent() + .await?; + let error_count = written_endpoint + .issues + .iter() + .filter(|issue| issue.severity <= IssueSeverity::Error) + .count(); + anyhow::ensure!( + error_count == 0, + "writing an endpoint to disk produced {error_count} error issue(s)" + ); + + if let Some(expected_paths) = &expected_individual_app_output_paths { + let output_paths = endpoint_all_output_paths_operation(*endpoint) + .read_strongly_consistent() + .await?; + for expected_path in expected_paths { + anyhow::ensure!( + output_paths.iter().any(|path| path == expected_path), + "app endpoint {index} is missing expected path `{expected_path}`; \ + got {output_paths:?}" + ); + } + } + } + + if write_endpoints_individually { + for endpoint in &entrypoints_with_issues.entrypoints.libraries { + let written_endpoint = + get_written_endpoint_with_issues_operation(*endpoint) + .read_strongly_consistent() + .await?; + let error_count = written_endpoint + .issues + .iter() + .filter(|issue| issue.severity <= IssueSeverity::Error) + .count(); + anyhow::ensure!( + error_count == 0, + "writing an endpoint to disk produced {error_count} error issue(s)" + ); + } + } + + if write_endpoints_individually { + return Ok(()); + } + // Keep running the normal snapshot assertion after checking the + // output of each app endpoint in isolation. + } } #[turbo_tasks::function(operation, root)] @@ -258,7 +337,7 @@ async fn run(resource: PathBuf) -> Result<()> { Ok(()) } -fn project_options_from_resource(resource: &Path) -> Result<(ProjectOptions, bool)> { +fn project_options_from_resource(resource: &Path) -> Result { let test_path = canonicalize(resource)?; assert!(test_path.exists(), "{} does not exist", resource.display()); assert!( @@ -288,13 +367,22 @@ fn project_options_from_resource(resource: &Path) -> Result<(ProjectOptions, boo }; // Parse config content and determine if it's in development or production mode - let (mut user_config, runtime_type_override, watch_enabled, write_endpoints_individually): ( - serde_json::Value, - Option, - bool, - bool, - ) = if config_content.trim().is_empty() { - (serde_json::from_str(&default_config())?, None, false, false) + let SnapshotConfig { + mut user_config, + runtime_type_override, + watch_enabled, + write_endpoints_individually, + expected_app_endpoints, + expected_individual_app_output_paths, + } = if config_content.trim().is_empty() { + SnapshotConfig { + user_config: serde_json::from_str(&default_config())?, + runtime_type_override: None, + watch_enabled: false, + write_endpoints_individually: false, + expected_app_endpoints: None, + expected_individual_app_output_paths: None, + } } else { let raw_root: serde_json::Value = serde_json::from_str(&config_content)?; let runtime_type_from_root = raw_root @@ -309,16 +397,36 @@ fn project_options_from_resource(resource: &Path) -> Result<(ProjectOptions, boo .get("writeEndpointsIndividually") .and_then(|v| v.as_bool()) .unwrap_or(false); + let expected_app_endpoints = raw_root + .get("expectedAppEndpoints") + .and_then(|v| v.as_u64()) + .map(|value| value as usize); + let expected_individual_app_output_paths = raw_root + .get("expectedIndividualAppOutputPaths") + .and_then(|value| value.as_array()) + .map(|paths| { + paths + .iter() + .map(|path| { + path.as_str() + .context("expectedIndividualAppOutputPaths must contain strings") + .map(str::to_owned) + }) + .collect::>>() + }) + .transpose()?; let user_cfg = raw_root .get("config") .cloned() .expect("config.json must contain a top-level `config` object"); - ( - user_cfg, - runtime_type_from_root, + SnapshotConfig { + user_config: user_cfg, + runtime_type_override: runtime_type_from_root, watch_enabled, write_endpoints_individually, - ) + expected_app_endpoints, + expected_individual_app_output_paths, + } }; // Ensure default output configuration is present @@ -403,7 +511,12 @@ fn project_options_from_resource(resource: &Path) -> Result<(ProjectOptions, boo .into(), }; - Ok((project_options, write_endpoints_individually)) + Ok(SnapshotProjectOptions { + project: project_options, + write_endpoints_individually, + expected_app_endpoints, + expected_individual_app_output_paths, + }) } #[turbo_tasks::function(operation, root)] diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/config.json b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/config.json new file mode 100644 index 0000000000..e30570747d --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/config.json @@ -0,0 +1,37 @@ +{ + "expectedAppEndpoints": 2, + "expectedIndividualAppOutputPaths": ["global.css", "server/index.js"], + "config": { + "entry": [ + { + "import": "input/a.js", + "name": "a" + }, + { + "import": "input/b.js", + "name": "b" + } + ], + "mode": "production", + "optimization": { + "minify": false, + "moduleIds": "named" + }, + "output": { + "copy": [ + { + "from": "input/global.css", + "to": "global.css" + } + ], + "path": "output" + }, + "server": { + "entry": "input/server.js", + "function": { + "clientProxy": "./input/transport.js", + "serverRegister": "./input/register.js" + } + } + } +} diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/a.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/a.js new file mode 100644 index 0000000000..cfdc512f8f --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/a.js @@ -0,0 +1 @@ +globalThis.pageA = true; diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/actions.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/actions.js new file mode 100644 index 0000000000..2d56862aae --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/actions.js @@ -0,0 +1,5 @@ +"use server"; + +export async function pageBAction() { + return "page-b"; +} diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/b.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/b.js new file mode 100644 index 0000000000..3aa55a8940 --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/b.js @@ -0,0 +1,4 @@ +import { pageBAction } from "./actions"; + +globalThis.pageB = true; +pageBAction(); diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/global.css b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/global.css new file mode 100644 index 0000000000..9eefda15a1 --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/global.css @@ -0,0 +1,3 @@ +:root { + color: green; +} diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/register.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/register.js new file mode 100644 index 0000000000..aba0b4998f --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/register.js @@ -0,0 +1,4 @@ +export function registerServerReference(action, id, name) { + globalThis.serverActions ??= new Map(); + globalThis.serverActions.set(`${id}:${name}`, action); +} diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/server.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/server.js new file mode 100644 index 0000000000..407814cf7f --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/server.js @@ -0,0 +1 @@ +console.log("server"); diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/transport.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/transport.js new file mode 100644 index 0000000000..736236229c --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/input/transport.js @@ -0,0 +1,3 @@ +export function createServerReference(id, name) { + return (...args) => ({ args, id, name }); +} diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/a.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/a.js new file mode 100644 index 0000000000..b44eb92a11 --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/a.js @@ -0,0 +1,5 @@ +(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push([ + typeof document === "object" ? document.currentScript : undefined, + {"otherChunks":["input_a_d9d53fff.js"],"runtimeModuleIds":["[project]/basic/multi_page_shared_outputs/input/a.js [client] (ecmascript)"]} +]); +// Dummy runtime \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/a.js.map b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/a.js.map new file mode 100644 index 0000000000..c15d7ec003 --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/a.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/b.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/b.js new file mode 100644 index 0000000000..d4fb9d099f --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/b.js @@ -0,0 +1,5 @@ +(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push([ + typeof document === "object" ? document.currentScript : undefined, + {"otherChunks":["input_50d742e5.js"],"runtimeModuleIds":["[project]/basic/multi_page_shared_outputs/input/b.js [client] (ecmascript)"]} +]); +// Dummy runtime \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/b.js.map b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/b.js.map new file mode 100644 index 0000000000..c15d7ec003 --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/b.js.map @@ -0,0 +1,5 @@ +{ + "version": 3, + "sources": [], + "sections": [] +} \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/global.css b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/global.css new file mode 100644 index 0000000000..4a62c3ae5a --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/global.css @@ -0,0 +1,3 @@ +:root { + color: green; +} \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_50d742e5.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_50d742e5.js new file mode 100644 index 0000000000..83e118c26b --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_50d742e5.js @@ -0,0 +1,46 @@ +(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push([typeof document === "object" ? document.currentScript : undefined, +"[project]/basic/multi_page_shared_outputs/input/actions.js [server] (ecmascript, server reference)", (function(__turbopack_context__){ + +}), +"[project]/basic/multi_page_shared_outputs/input/transport.js [client] (ecmascript)", ((__turbopack_context__) => { +"use strict"; + +function createServerReference(id, name) { + return (...args)=>({ + args, + id, + name + }); +} +__turbopack_context__.s([ + "createServerReference", + 0, + createServerReference +]); +}), +"[project]/basic/multi_page_shared_outputs/input/actions.js [client] (ecmascript)", ((__turbopack_context__) => { +"use strict"; + +var __TURBOPACK__imported__module__$5b$project$5d2f$basic$2f$multi_page_shared_outputs$2f$input$2f$actions$2e$js__$5b$server$5d$__$28$ecmascript$2c$__server__reference$29$__ = __turbopack_context__.i("[project]/basic/multi_page_shared_outputs/input/actions.js [server] (ecmascript, server reference)"); +var __TURBOPACK__imported__module__$5b$project$5d2f$basic$2f$multi_page_shared_outputs$2f$input$2f$transport$2e$js__$5b$client$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/basic/multi_page_shared_outputs/input/transport.js [client] (ecmascript)"); +; +; +const pageBAction = (0, __TURBOPACK__imported__module__$5b$project$5d2f$basic$2f$multi_page_shared_outputs$2f$input$2f$transport$2e$js__$5b$client$5d$__$28$ecmascript$29$__["createServerReference"])("ebfb242f4750b77b", "pageBAction"); +__turbopack_context__.s([ + "pageBAction", + 0, + pageBAction +]); +}), +"[project]/basic/multi_page_shared_outputs/input/b.js [client] (ecmascript)", ((__turbopack_context__) => { +"use strict"; + +var __TURBOPACK__imported__module__$5b$project$5d2f$basic$2f$multi_page_shared_outputs$2f$input$2f$actions$2e$js__$5b$client$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/basic/multi_page_shared_outputs/input/actions.js [client] (ecmascript)"); +; +globalThis.pageB = true; +(0, __TURBOPACK__imported__module__$5b$project$5d2f$basic$2f$multi_page_shared_outputs$2f$input$2f$actions$2e$js__$5b$client$5d$__$28$ecmascript$29$__["pageBAction"])(); +__turbopack_context__.s([]); +}), +]); + +//# sourceMappingURL=input_50d742e5.js.map \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_50d742e5.js.map b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_50d742e5.js.map new file mode 100644 index 0000000000..60d104ed1b --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_50d742e5.js.map @@ -0,0 +1,8 @@ +{ + "version": 3, + "sources": [], + "sections": [ + {"offset": {"line": 7, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/basic/multi_page_shared_outputs/input/transport.js"],"sourcesContent":["export function createServerReference(id, name) {\n return (...args) => ({ args, id, name });\n}\n"],"names":["createServerReference","id","name","args"],"mappings":"AAAO,SAASA,sBAAsBC,EAAE,EAAEC,IAAI;IAC5C,OAAO,CAAC,GAAGC,OAAS,CAAC;YAAEA;YAAMF;YAAIC;QAAK,CAAC;AACzC"}}, + {"offset": {"line": 23, "column": 0}, "map": {"version":3,"sources":[],"names":[],"mappings":""}}, + {"offset": {"line": 37, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/basic/multi_page_shared_outputs/input/b.js"],"sourcesContent":["import { pageBAction } from \"./actions\";\n\nglobalThis.pageB = true;\npageBAction();\n"],"names":["globalThis","pageB"],"mappings":"AAAA;;AAEAA,WAAWC,KAAK,GAAG;AACnB,IAAA,iKAAW"}}] +} \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_a_d9d53fff.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_a_d9d53fff.js new file mode 100644 index 0000000000..ba35c92f6f --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_a_d9d53fff.js @@ -0,0 +1,8 @@ +(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push([typeof document === "object" ? document.currentScript : undefined, +"[project]/basic/multi_page_shared_outputs/input/a.js [client] (ecmascript)", ((__turbopack_context__, module, exports) => { + +globalThis.pageA = true; +}), +]); + +//# sourceMappingURL=input_a_d9d53fff.js.map \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_a_d9d53fff.js.map b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_a_d9d53fff.js.map new file mode 100644 index 0000000000..deb600c27c --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/input_a_d9d53fff.js.map @@ -0,0 +1,6 @@ +{ + "version": 3, + "sources": [], + "sections": [ + {"offset": {"line": 3, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/basic/multi_page_shared_outputs/input/a.js"],"sourcesContent":["globalThis.pageA = true;\n"],"names":["globalThis","pageA"],"mappings":"AAAAA,WAAWC,KAAK,GAAG"}}] +} \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/server/index.js b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/server/index.js new file mode 100644 index 0000000000..728f379644 --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/server/index.js @@ -0,0 +1,42 @@ +((__UTOOPACK__) => { +// Dummy runtime +})([ +["index.js", + +"[project]/basic/multi_page_shared_outputs/input/register.js [server] (ecmascript)", ((__turbopack_context__) => { +"use strict"; + +__turbopack_context__.s([ + "registerServerReference", + ()=>registerServerReference +]); +function registerServerReference(action, id, name) { + globalThis.serverActions ??= new Map(); + globalThis.serverActions.set(`${id}:${name}`, action); +} +}), +"[project]/basic/multi_page_shared_outputs/input/actions.js [server] (ecmascript)", ((__turbopack_context__) => { +"use strict"; + +__turbopack_context__.s([ + "pageBAction", + ()=>pageBAction +]); +var __TURBOPACK__imported__module__$5b$project$5d2f$basic$2f$multi_page_shared_outputs$2f$input$2f$register$2e$js__$5b$server$5d$__$28$ecmascript$29$__ = __turbopack_context__.i("[project]/basic/multi_page_shared_outputs/input/register.js [server] (ecmascript)"); +"use server"; +async function pageBAction() { + return "page-b"; +} +; +(0, __TURBOPACK__imported__module__$5b$project$5d2f$basic$2f$multi_page_shared_outputs$2f$input$2f$register$2e$js__$5b$server$5d$__$28$ecmascript$29$__["registerServerReference"])(pageBAction, "ebfb242f4750b77b", "pageBAction"); +}), +"[project]/basic/multi_page_shared_outputs/input/server.js [server] (ecmascript)", ((__turbopack_context__, module, exports) => { + +console.log("server"); +}), +], +["index.js", {"otherChunks":[],"runtimeModuleIds":["[project]/basic/multi_page_shared_outputs/input/actions.js [server] (ecmascript)","[project]/basic/multi_page_shared_outputs/input/server.js [server] (ecmascript)"]}], +]); + + +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/server/index.js.map b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/server/index.js.map new file mode 100644 index 0000000000..b6ded7c022 --- /dev/null +++ b/crates/pack-tests/tests/snapshot/basic/multi_page_shared_outputs/output/server/index.js.map @@ -0,0 +1,8 @@ +{ + "version": 3, + "sources": [], + "sections": [ + {"offset": {"line": 8, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/basic/multi_page_shared_outputs/input/register.js"],"sourcesContent":["export function registerServerReference(action, id, name) {\n globalThis.serverActions ??= new Map();\n globalThis.serverActions.set(`${id}:${name}`, action);\n}\n"],"names":["registerServerReference","action","id","name","globalThis","serverActions","Map","set"],"mappings":";;;;AAAO,SAASA,wBAAwBC,MAAM,EAAEC,EAAE,EAAEC,IAAI;IACtDC,WAAWC,aAAa,KAAK,IAAIC;IACjCF,WAAWC,aAAa,CAACE,GAAG,CAAC,GAAGL,GAAG,CAAC,EAAEC,MAAM,EAAEF;AAChD"}}, + {"offset": {"line": 20, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/basic/multi_page_shared_outputs/input/actions.js"],"sourcesContent":["\"use server\";\n\nexport async function pageBAction() {\n return \"page-b\";\n}\n"],"names":["pageBAction"],"mappings":";;;;;AAAA;AAEO,eAAeA;IACpB,OAAO;AACT;;oLAFsBA"}}, + {"offset": {"line": 34, "column": 0}, "map": {"version":3,"sources":["turbopack:///[project]/basic/multi_page_shared_outputs/input/server.js"],"sourcesContent":["console.log(\"server\");\n"],"names":["console","log"],"mappings":"AAAAA,QAAQC,GAAG,CAAC"}}] +} \ No newline at end of file diff --git a/crates/pack-tests/tests/snapshot/style/css_module_global_leak_graph/config.json b/crates/pack-tests/tests/snapshot/style/css_module_global_leak_graph/config.json index 42d52f243d..b87425f501 100644 --- a/crates/pack-tests/tests/snapshot/style/css_module_global_leak_graph/config.json +++ b/crates/pack-tests/tests/snapshot/style/css_module_global_leak_graph/config.json @@ -1,4 +1,5 @@ { + "expectedAppEndpoints": 2, "config": { "entry": [ { diff --git a/packages/pack/package.json b/packages/pack/package.json index 9bdcdceba1..c950c3d53f 100644 --- a/packages/pack/package.json +++ b/packages/pack/package.json @@ -42,6 +42,7 @@ "@hono/node-ws": "^1.3.0", "@swc/helpers": "0.5.15", "@utoo/pack-shared": "*", + "browserslist": "^4.28.5", "domparser-rs": "^0.0.7", "find-up": "4.1.0", "get-port": "5.1.1", diff --git a/packages/pack/src/__test__/HtmlGenerationManager.test.ts b/packages/pack/src/__test__/HtmlGenerationManager.test.ts new file mode 100644 index 0000000000..c0f69847af --- /dev/null +++ b/packages/pack/src/__test__/HtmlGenerationManager.test.ts @@ -0,0 +1,234 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { NapiWrittenEndpoint } from "../binding"; +import type { ConfigComplete, HtmlConfig } from "../config/types"; +import type { Endpoint } from "../core/types"; +import { HtmlGenerationManager } from "../utils/HtmlGenerationManager"; + +const tempDirs: string[] = []; + +function createTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "utoo-html-manager-")); + tempDirs.push(dir); + return dir; +} + +function writtenEndpoint(...clientPaths: string[]): NapiWrittenEndpoint { + return { + type: "nodejs", + entryPath: "dist", + clientPaths, + serverPaths: [], + config: {}, + }; +} + +function readHtml(outputDir: string, filename: string) { + return fs.readFileSync(path.join(outputDir, filename), "utf8"); +} + +function createFixture(outputDir: string) { + const appA = {} as Endpoint; + const appB = {} as Endpoint; + const library = {} as Endpoint; + const config: ConfigComplete & { html: HtmlConfig } = { + entry: [ + { + name: "a", + import: "a.js", + html: { filename: "a.html", title: "A" }, + }, + { + name: "b", + import: "b.js", + html: { filename: "b.html", title: "B" }, + }, + { + name: "library", + import: "library.js", + library: { name: "Library" }, + html: { filename: "library.html", title: "Library" }, + }, + ], + output: { path: outputDir }, + html: { filename: "all.html", title: "All" }, + }; + const manager = new HtmlGenerationManager(config, outputDir); + manager.setEntrypoints({ + apps: [appA, appB], + libraries: [library], + appPaths: [writtenEndpoint("a.js", "a.css"), writtenEndpoint("b.js")], + libraryPaths: [writtenEndpoint("library.js")], + }); + return { appA, appB, library, manager }; +} + +afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { force: true, recursive: true }); + } + tempDirs.length = 0; +}); + +describe("HtmlGenerationManager", () => { + it("injects only the owning endpoint assets into entry HTML", async () => { + const outputDir = createTempDir(); + const { manager } = createFixture(outputDir); + + await manager.generateAll(); + + const appAHtml = readHtml(outputDir, "a.html"); + expect(appAHtml).toContain('src="a.js"'); + expect(appAHtml).toContain('href="a.css"'); + expect(appAHtml).not.toContain('src="b.js"'); + expect(appAHtml).not.toContain('src="library.js"'); + + const libraryHtml = readHtml(outputDir, "library.html"); + expect(libraryHtml).toContain('src="library.js"'); + expect(libraryHtml).not.toContain('src="a.js"'); + + const globalHtml = readHtml(outputDir, "all.html"); + expect(globalHtml).toContain('src="a.js"'); + expect(globalHtml).toContain('src="b.js"'); + expect(globalHtml).toContain('src="library.js"'); + }); + + it("regenerates only global and owning entry HTML after an update", async () => { + const outputDir = createTempDir(); + const { appA, manager } = createFixture(outputDir); + await manager.generateAll(); + fs.writeFileSync(path.join(outputDir, "b.html"), "unchanged"); + + manager.setWrittenEndpointPath(appA, writtenEndpoint("a-updated.js")); + await manager.generateForEndpoint(appA); + + const appAHtml = readHtml(outputDir, "a.html"); + expect(appAHtml).toContain('src="a-updated.js"'); + expect(appAHtml).not.toContain('src="a.js"'); + expect(readHtml(outputDir, "b.html")).toBe("unchanged"); + + const globalHtml = readHtml(outputDir, "all.html"); + expect(globalHtml).toContain('src="a-updated.js"'); + expect(globalHtml).toContain('src="b.js"'); + }); + + it("preserves config order in global HTML regardless of write order", async () => { + const outputDir = createTempDir(); + const { appA, appB, manager } = createFixture(outputDir); + + manager.setEntrypoints({ + apps: [appA, appB], + }); + manager.setWrittenEndpointPath(appB, writtenEndpoint("b.js")); + manager.setWrittenEndpointPath(appA, writtenEndpoint("a.js")); + await manager.generateAll(); + + const globalHtml = readHtml(outputDir, "all.html"); + expect(globalHtml.indexOf('src="a.js"')).toBeLessThan( + globalHtml.indexOf('src="b.js"'), + ); + }); + + it("rejects endpoint/config cardinality mismatches", () => { + const outputDir = createTempDir(); + const manager = new HtmlGenerationManager( + { + entry: [ + { name: "first", import: "first.js" }, + { name: "second", import: "second.js" }, + ], + output: { path: outputDir }, + } as ConfigComplete, + outputDir, + ); + + expect(() => manager.setEntrypoints({ apps: [{} as Endpoint] })).toThrow( + "Expected 2 app endpoint(s), received 1", + ); + }); + + it("combines endpoints that generate the same HTML file", async () => { + const outputDir = createTempDir(); + const firstScript = {} as Endpoint; + const secondScript = {} as Endpoint; + const sharedHtml = { + filename: "page.html", + templateContent: "", + }; + const config = { + entry: [ + { name: "first", import: "first.js", html: sharedHtml }, + { name: "second", import: "second.js", html: sharedHtml }, + ], + output: { path: outputDir }, + } as ConfigComplete; + const manager = new HtmlGenerationManager(config, outputDir); + + manager.setEntrypoints({ + apps: [firstScript, secondScript], + appPaths: [writtenEndpoint("first.js"), writtenEndpoint("second.js")], + }); + await manager.generateAll(); + + const html = readHtml(outputDir, "page.html"); + expect(html).toContain('src="first.js"'); + expect(html).toContain('src="second.js"'); + }); + + it("serializes concurrent writes to global HTML with the latest paths", async () => { + const outputDir = createTempDir(); + const { appA, appB, manager } = createFixture(outputDir); + await manager.generateAll(); + + manager.setWrittenEndpointPath(appA, writtenEndpoint("a-updated.js")); + const firstWrite = manager.generateForEndpoint(appA); + manager.setWrittenEndpointPath(appB, writtenEndpoint("b-updated.js")); + const secondWrite = manager.generateForEndpoint(appB); + await Promise.all([firstWrite, secondWrite]); + + const globalHtml = readHtml(outputDir, "all.html"); + expect(globalHtml).toContain('src="a-updated.js"'); + expect(globalHtml).toContain('src="b-updated.js"'); + expect(globalHtml).not.toContain('src="a.js"'); + expect(globalHtml).not.toContain('src="b.js"'); + }); + + it("coalesces concurrent updates for the same HTML file", async () => { + const outputDir = createTempDir(); + const firstScript = {} as Endpoint; + const secondScript = {} as Endpoint; + const sharedHtml = { filename: "page.html" }; + const manager = new HtmlGenerationManager( + { + entry: [ + { name: "first", import: "first.js", html: sharedHtml }, + { name: "second", import: "second.js", html: sharedHtml }, + ], + output: { path: outputDir }, + } as ConfigComplete, + outputDir, + ); + manager.setEntrypoints({ + apps: [firstScript, secondScript], + appPaths: [writtenEndpoint("first.js"), writtenEndpoint("second.js")], + }); + + manager.setWrittenEndpointPath( + firstScript, + writtenEndpoint("first-new.js"), + ); + const firstWrite = manager.generateForEndpoint(firstScript); + manager.setWrittenEndpointPath( + secondScript, + writtenEndpoint("second-new.js"), + ); + const secondWrite = manager.generateForEndpoint(secondScript); + await Promise.all([firstWrite, secondWrite]); + + const html = readHtml(outputDir, "page.html"); + expect(html).toContain('src="first-new.js"'); + expect(html).toContain('src="second-new.js"'); + }); +}); diff --git a/packages/pack/src/__test__/projectEndpointWatch.test.ts b/packages/pack/src/__test__/projectEndpointWatch.test.ts new file mode 100644 index 0000000000..5ef850fe7b --- /dev/null +++ b/packages/pack/src/__test__/projectEndpointWatch.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const bindingMocks = vi.hoisted(() => ({ + endpointClientChangedSubscribe: vi.fn(), + endpointServerChangedSubscribe: vi.fn(), + projectEntrypointsSubscribe: vi.fn(), + projectNew: vi.fn(), + registerWorkerScheduler: undefined, + rootTaskDispose: vi.fn(), +})); + +vi.mock("../binding", () => bindingMocks); + +import { projectFactory } from "../core/project"; + +function emitOnce( + callback: (error: Error | undefined, value: unknown) => void, +) { + callback(undefined, { issues: [] }); + return Promise.resolve(); +} + +async function createProject(hasServerOutput: boolean, nodeTarget: boolean) { + const nativeProject = { __napiType: "Project" as const }; + bindingMocks.projectNew.mockResolvedValue(nativeProject); + const project = await projectFactory({ hasServerOutput, nodeTarget })( + { + config: { entry: [], output: {} }, + dev: true, + watch: { enable: true }, + } as never, + {} as never, + ); + const first = { __napiType: "Endpoint" as const }; + const second = { __napiType: "Endpoint" as const }; + bindingMocks.projectEntrypointsSubscribe.mockImplementationOnce( + (_project, callback) => { + callback(undefined, { apps: [first, second], issues: [] }); + return Promise.resolve(); + }, + ); + const entrypoints = await project.entrypointsSubscribe().next(); + return entrypoints.value.apps!; +} + +beforeEach(() => { + vi.clearAllMocks(); + bindingMocks.endpointClientChangedSubscribe.mockImplementation( + (_endpoint, callback) => emitOnce(callback), + ); + bindingMocks.endpointServerChangedSubscribe.mockImplementation( + (_endpoint, _issues, callback) => emitOnce(callback), + ); +}); + +describe("endpoint watch subscriptions", () => { + it("creates only client subscriptions for browser apps without server output", async () => { + const endpoints = await createProject(false, false); + + await Promise.all( + endpoints.flatMap((endpoint) => [ + endpoint.clientChanged(), + endpoint.serverChanged(true), + ]), + ); + + expect(bindingMocks.endpointClientChangedSubscribe).toHaveBeenCalledTimes( + 2, + ); + expect(bindingMocks.endpointServerChangedSubscribe).not.toHaveBeenCalled(); + }); + + it("shares one server subscription across browser app endpoints", async () => { + const endpoints = await createProject(true, false); + + await Promise.all( + endpoints.map((endpoint) => endpoint.serverChanged(true)), + ); + + expect(bindingMocks.endpointServerChangedSubscribe).toHaveBeenCalledTimes( + 1, + ); + }); + + it("creates only server subscriptions for Node endpoints", async () => { + const endpoints = await createProject(false, true); + + await Promise.all( + endpoints.flatMap((endpoint) => [ + endpoint.clientChanged(), + endpoint.serverChanged(true), + ]), + ); + + expect(bindingMocks.endpointClientChangedSubscribe).not.toHaveBeenCalled(); + expect(bindingMocks.endpointServerChangedSubscribe).toHaveBeenCalledTimes( + 2, + ); + }); +}); diff --git a/packages/pack/src/__test__/serveMultiClientStatsChild.ts b/packages/pack/src/__test__/serveMultiClientStatsChild.ts new file mode 100644 index 0000000000..df75eb97a5 --- /dev/null +++ b/packages/pack/src/__test__/serveMultiClientStatsChild.ts @@ -0,0 +1,110 @@ +import fs from "fs"; +import path from "path"; +import { serve } from "../commands/dev"; + +const [, , projectPath, portArg] = process.argv; + +if (!projectPath || !portArg) { + throw new Error("Usage: serveMultiClientStatsChild "); +} + +const port = Number(portArg); +const srcDir = path.join(projectPath, "src"); +const statsPath = path.join(projectPath, "dist", "stats.json"); + +function entrypointAssets(stats: any, name: string): string[] { + return (stats.entrypoints?.[name]?.assets ?? []).map((asset: any) => + typeof asset === "string" ? asset : (asset?.name ?? ""), + ); +} + +function chunkLists(assets: string[]): string[] { + return assets.filter( + (asset) => asset.includes("src_alpha_") || asset.includes("src_beta_"), + ); +} + +async function waitForStats() { + const deadline = Date.now() + 20_000; + + while (true) { + if (fs.existsSync(statsPath)) { + const stats = JSON.parse(fs.readFileSync(statsPath, "utf8")); + if (stats.entrypoints?.alpha && stats.entrypoints?.beta) { + return stats; + } + } + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${statsPath}`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +async function main() { + fs.rmSync(projectPath, { recursive: true, force: true }); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync( + path.join(srcDir, "alpha.js"), + 'import("./alpha-lazy.js").then(({ default: value }) => console.log(value));\n', + ); + fs.writeFileSync( + path.join(srcDir, "alpha-lazy.js"), + 'export default "alpha";\n', + ); + fs.writeFileSync( + path.join(srcDir, "beta.js"), + 'import("./beta-lazy.js").then(({ default: value }) => console.log(value));\n', + ); + fs.writeFileSync( + path.join(srcDir, "beta-lazy.js"), + 'export default "beta";\n', + ); + + await serve( + { + config: { + entry: [ + { import: "./src/alpha.js", name: "alpha" }, + { import: "./src/beta.js", name: "beta" }, + ], + output: { path: "./dist", clean: true }, + stats: true, + }, + }, + projectPath, + projectPath, + { + hostname: "127.0.0.1", + logServerInfo: false, + port, + }, + ); + + const stats = await waitForStats(); + const alphaChunkLists = chunkLists(entrypointAssets(stats, "alpha")); + const betaChunkLists = chunkLists(entrypointAssets(stats, "beta")); + + console.log( + `__STATS_SNAPSHOT__${JSON.stringify({ + alphaHasOwnChunkLists: alphaChunkLists.some((asset) => + asset.includes("src_alpha_"), + ), + alphaHasOnlyOwnChunkLists: alphaChunkLists.every((asset) => + asset.includes("src_alpha_"), + ), + betaHasOwnChunkLists: betaChunkLists.some((asset) => + asset.includes("src_beta_"), + ), + betaHasOnlyOwnChunkLists: betaChunkLists.every((asset) => + asset.includes("src_beta_"), + ), + })}`, + ); + process.kill(process.pid, "SIGTERM"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/pack/src/__test__/serveServerOutputsHmrChild.ts b/packages/pack/src/__test__/serveServerOutputsHmrChild.ts index 8faba1ed46..01881bb75c 100644 --- a/packages/pack/src/__test__/serveServerOutputsHmrChild.ts +++ b/packages/pack/src/__test__/serveServerOutputsHmrChild.ts @@ -94,7 +94,7 @@ async function main() { scenario === "dist-root" ? { entry: [{ import: "./src/index.js", name: "main" }], - target: "node", + target: "current node", output: { path: "./dist/node", clean: true }, } : { diff --git a/packages/pack/src/__test__/serveStats.test.ts b/packages/pack/src/__test__/serveStats.test.ts index 191cd2b7e0..d62691b149 100644 --- a/packages/pack/src/__test__/serveStats.test.ts +++ b/packages/pack/src/__test__/serveStats.test.ts @@ -135,6 +135,17 @@ describe("serve stats", () => { `); }, 30_000); + it("keeps dev chunk lists scoped to their owning entrypoint", async () => { + await expect( + runServeStatsFixture("serveMultiClientStatsChild.ts"), + ).resolves.toEqual({ + alphaHasOwnChunkLists: true, + alphaHasOnlyOwnChunkLists: true, + betaHasOwnChunkLists: true, + betaHasOnlyOwnChunkLists: true, + }); + }, 30_000); + it("keeps all named server entries after rebuilding one entry", async () => { await expect( runServeStatsFixture("serveMultiServerStatsChild.ts"), diff --git a/packages/pack/src/commands/build.ts b/packages/pack/src/commands/build.ts index 44b71afe1a..35a1daa26d 100644 --- a/packages/pack/src/commands/build.ts +++ b/packages/pack/src/commands/build.ts @@ -1,4 +1,4 @@ -import { EntryOptions, handleIssues } from "@utoo/pack-shared"; +import { handleIssues } from "@utoo/pack-shared"; import { spawn } from "child_process"; import fs from "fs"; import { nanoid } from "nanoid"; @@ -6,7 +6,6 @@ import path from "path"; import { BundleOptions } from "../config/types"; import { resolveBundleOptions, WebpackConfig } from "../config/webpackCompat"; import { projectFactory } from "../core/project"; -import { HtmlPlugin } from "../plugins/HtmlPlugin"; import { cleanOutput, getOutputPath } from "../utils/cleanOutput"; import { blockStdout, getPackPath } from "../utils/common"; import { @@ -15,7 +14,7 @@ import { normalizeTurbopackMemoryEviction, } from "../utils/env"; import { findRootDir } from "../utils/findRoot"; -import { getInitialAssetsFromEndpointPaths } from "../utils/getInitialAssets"; +import { HtmlGenerationManager } from "../utils/HtmlGenerationManager"; import { processHtmlEntry } from "../utils/htmlEntry"; import { acquirePersistentCacheLock } from "../utils/lockfile"; import { normalizePath } from "../utils/normalizePath"; @@ -111,36 +110,13 @@ async function buildInternal( const entrypoints = await project.writeAllEntrypointsToDisk(); handleIssues(entrypoints.issues); - - const htmlConfigs = [ - ...(Array.isArray((bundleOptions.config as any).html) - ? (bundleOptions.config as any).html - : (bundleOptions.config as any).html - ? [(bundleOptions.config as any).html] - : []), - ...bundleOptions.config.entry - .filter((e: EntryOptions) => !!e.html) - .map((e: EntryOptions) => e.html!), - ]; - - if (htmlConfigs.length > 0) { - const assets = getInitialAssetsFromEndpointPaths([ - ...(entrypoints.appPaths ?? []), - ...(entrypoints.libraryPaths ?? []), - ]); - - const outputDir = getOutputPath( - bundleOptions.config, - resolvedProjectPath, - ); - - const publicPath = bundleOptions.config.output?.publicPath; - - for (const config of htmlConfigs) { - const plugin = new HtmlPlugin(config); - await plugin.generate(outputDir, assets, publicPath); - } - } + const htmlGenerationManager = new HtmlGenerationManager( + bundleOptions.config, + getOutputPath(bundleOptions.config, resolvedProjectPath), + bundleOptions.config.output?.publicPath, + ); + htmlGenerationManager.setEntrypoints(entrypoints); + await htmlGenerationManager.generateAll(); if (process.env.ANALYZE) { await analyzeBundle(bundleOptions.config.output?.path || "dist"); diff --git a/packages/pack/src/core/hmr.ts b/packages/pack/src/core/hmr.ts index 190d643af7..812c31640b 100644 --- a/packages/pack/src/core/hmr.ts +++ b/packages/pack/src/core/hmr.ts @@ -1,7 +1,6 @@ import { type CompilationError, type EntryIssuesMap, - type EntryOptions, formatIssue, type HMR_ACTION_TYPES, HMR_ACTIONS_SENT_TO_BROWSER, @@ -16,7 +15,6 @@ import { Duplex } from "stream"; import { WebSocketServer } from "ws"; import type { MemoryEvictionMode, NapiWrittenEndpoint } from "../binding"; import { BundleOptions } from "../config/types"; -import { HtmlPlugin } from "../plugins/HtmlPlugin"; import { cleanOutput, getOutputPath } from "../utils/cleanOutput"; import { debounce, getPackPath, processIssues } from "../utils/common"; import { @@ -24,15 +22,21 @@ import { isTruthyEnv, normalizeTurbopackMemoryEviction, } from "../utils/env"; -import { getInitialAssetsFromEndpointPaths } from "../utils/getInitialAssets"; +import { HtmlGenerationManager } from "../utils/HtmlGenerationManager"; import { processHtmlEntry } from "../utils/htmlEntry"; import { acquirePersistentCacheLock } from "../utils/lockfile"; import { normalizePath } from "../utils/normalizePath"; import { useWorkerThreads } from "../utils/runtimePluginStratety"; +import { isNodeTarget } from "../utils/target"; import { validateEntryPaths } from "../utils/validateEntry"; import { consumeHmrSubscription } from "./hmrSubscription"; import { projectFactory } from "./project"; -import { Endpoint, Project, Update as TurbopackUpdate } from "./types"; +import { + Endpoint, + Project, + RawEntrypoints, + Update as TurbopackUpdate, +} from "./types"; const wsServer = new WebSocketServer({ noServer: true }); @@ -170,7 +174,13 @@ export async function createHotReloader( validateEntryPaths(bundleOptions.config, resolvedProjectPath); await cleanOutput(bundleOptions.config, resolvedProjectPath); - const createProject = projectFactory(); + const createProject = projectFactory({ + hasServerOutput: Boolean( + bundleOptions.config.server?.entry || + bundleOptions.config.server?.function, + ), + nodeTarget: isNodeTarget(bundleOptions.config.target), + }); const persistentCaching = isPersistentCachingEnabled( bundleOptions.config.persistentCaching, ); @@ -186,16 +196,11 @@ export async function createHotReloader( persistentCaching, ); - const htmlConfigs = [ - ...(Array.isArray((bundleOptions.config as any).html) - ? (bundleOptions.config as any).html - : (bundleOptions.config as any).html - ? [(bundleOptions.config as any).html] - : []), - ...bundleOptions.config.entry - .filter((e: EntryOptions) => !!e.html) - .map((e: EntryOptions) => e.html!), - ]; + const htmlGenerationManager = new HtmlGenerationManager( + bundleOptions.config, + getOutputPath(bundleOptions.config, resolvedProjectPath), + bundleOptions.config.output?.publicPath, + ); const shouldCreateWebpackStats = Boolean(process.env.ANALYZE) || Boolean(bundleOptions.config.stats); @@ -334,40 +339,36 @@ export async function createHotReloader( const written = paths[index]; if (written) { writtenEndpointPaths.set(endpoint, written); + htmlGenerationManager.setWrittenEndpointPath(endpoint, written); } }); } - async function regenerateHtml() { - if (htmlConfigs.length === 0) { - return; - } - - const outputDir = getOutputPath(bundleOptions.config, resolvedProjectPath); - const publicPath = bundleOptions.config.output?.publicPath; - const assets = getInitialAssetsFromEndpointPaths([ - ...writtenEndpointPaths.values(), - ]); - - for (const config of htmlConfigs) { - const plugin = new HtmlPlugin(config); - await plugin.generate(outputDir, assets, publicPath); - } + function setEntrypoints(entrypoints: RawEntrypoints) { + writtenEndpointPaths.clear(); + htmlGenerationManager.setEntrypoints(entrypoints); + updateWrittenEndpointPaths(entrypoints.apps, entrypoints.appPaths); + updateWrittenEndpointPaths(entrypoints.libraries, entrypoints.libraryPaths); } async function writeAllEntrypointsToDisk() { const result = await project.writeAllEntrypointsToDisk(); processIssues(result, true, true); - updateWrittenEndpointPaths(result.apps, result.appPaths); - updateWrittenEndpointPaths(result.libraries, result.libraryPaths); - await regenerateHtml(); + setEntrypoints(result); + await htmlGenerationManager.generateAll(); } - async function writeEntrypointToDisk(entrypoint: Endpoint) { + async function writeEntrypointToDisk( + entrypoint: Endpoint, + generateHtml = true, + ) { const result = await entrypoint.writeToDisk(); processIssues(result, true, true); writtenEndpointPaths.set(entrypoint, result); - await regenerateHtml(); + htmlGenerationManager.setWrittenEndpointPath(entrypoint, result); + if (generateHtml) { + await htmlGenerationManager.generateForEndpoint(entrypoint); + } } async function writeOutputToDisk(entrypoint: Endpoint) { @@ -560,14 +561,16 @@ export async function createHotReloader( ...(entrypoints.apps ?? []), ...(entrypoints.libraries ?? []), ]; + setEntrypoints(entrypoints); if (shouldCreateWebpackStats) { await writeAllEntrypointsToDisk(); } else { await Promise.all( currentWatchedEntrypoints.map((entrypoint) => - writeEntrypointToDisk(entrypoint), + writeEntrypointToDisk(entrypoint, false), ), ); + await htmlGenerationManager.generateAll(); } if (backgroundWatchersStarted) { diff --git a/packages/pack/src/core/project.ts b/packages/pack/src/core/project.ts index 4f4db7649a..048557622b 100644 --- a/packages/pack/src/core/project.ts +++ b/packages/pack/src/core/project.ts @@ -422,7 +422,10 @@ async function rustifyProjectOptions( }; } -export function projectFactory() { +export function projectFactory(endpointWatchOptions?: { + hasServerOutput: boolean; + nodeTarget: boolean; +}) { const cancel = new (class Cancel extends Error {})(); function subscribe( @@ -618,9 +621,12 @@ export function projectFactory() { class EndpointImpl implements Endpoint { readonly _nativeEndpoint: { __napiType: "Endpoint" }; + /** Undefined for library endpoints. */ + readonly _appIndex: number | undefined; - constructor(nativeEndpoint: { __napiType: "Endpoint" }) { + constructor(nativeEndpoint: { __napiType: "Endpoint" }, appIndex?: number) { this._nativeEndpoint = nativeEndpoint; + this._appIndex = appIndex; } async writeToDisk(): Promise> { @@ -633,6 +639,12 @@ export function projectFactory() { } async clientChanged(): Promise>> { + // Preserve projectFactory()'s public behavior for callers outside dev HMR. + // The dev server supplies endpointWatchOptions so it can avoid creating + // NAPI root tasks for output directions that cannot change. + if (endpointWatchOptions?.nodeTarget) { + return emptySubscription(); + } const clientSubscription = subscribe( false, async (callback) => @@ -648,6 +660,13 @@ export function projectFactory() { async serverChanged( includeIssues: boolean, ): Promise>> { + if ( + endpointWatchOptions && + !endpointWatchOptions.nodeTarget && + (!endpointWatchOptions.hasServerOutput || this._appIndex !== 0) + ) { + return emptySubscription(); + } const serverSubscription = subscribe( false, async (callback) => @@ -662,6 +681,10 @@ export function projectFactory() { } } + async function* emptySubscription(): AsyncIterableIterator< + TurbopackResult<{}> + > {} + function napiEntrypointsToRawEntrypoints( entrypoints: TurbopackResult<{ apps?: { __napiType: "Endpoint" }[]; @@ -671,7 +694,9 @@ export function projectFactory() { }>, ) { return { - apps: (entrypoints.apps || []).map((e) => new EndpointImpl(e)), + apps: (entrypoints.apps || []).map( + (e, index) => new EndpointImpl(e, index), + ), libraries: (entrypoints.libraries || []).map((e) => new EndpointImpl(e)), appPaths: entrypoints.appPaths, libraryPaths: entrypoints.libraryPaths, diff --git a/packages/pack/src/utils/HtmlGenerationManager.ts b/packages/pack/src/utils/HtmlGenerationManager.ts new file mode 100644 index 0000000000..5a8d6e6d5b --- /dev/null +++ b/packages/pack/src/utils/HtmlGenerationManager.ts @@ -0,0 +1,218 @@ +import type { EntryOptions, HtmlConfig } from "@utoo/pack-shared"; +import path from "path"; +import type { NapiWrittenEndpoint } from "../binding"; +import type { ConfigComplete } from "../config/types"; +import type { Endpoint, RawEntrypoints } from "../core/types"; +import { HtmlPlugin } from "../plugins/HtmlPlugin"; +import { getInitialAssetsFromEndpointPaths } from "./getInitialAssets"; + +type ConfigWithGlobalHtml = ConfigComplete & { + html?: HtmlConfig | HtmlConfig[]; +}; + +interface EndpointHtmlGroup { + config: HtmlConfig; + endpoints: Endpoint[]; +} + +export class HtmlGenerationManager { + private readonly globalConfigs: HtmlConfig[]; + private readonly appEntries: EntryOptions[]; + private readonly libraryEntries: EntryOptions[]; + private readonly endpointOrder: Endpoint[] = []; + private readonly endpointGroups = new Map(); + private readonly endpointGroupByEndpoint = new Map< + Endpoint, + EndpointHtmlGroup + >(); + private readonly writtenEndpointPaths = new Map< + Endpoint, + NapiWrittenEndpoint + >(); + private pendingGenerateAll = false; + private readonly pendingEndpoints = new Set(); + private generationQueue = Promise.resolve(); + + constructor( + config: ConfigComplete, + private readonly outputDir: string, + private readonly publicPath?: string, + ) { + const globalHtml = (config as ConfigWithGlobalHtml).html; + this.globalConfigs = Array.isArray(globalHtml) + ? globalHtml + : globalHtml + ? [globalHtml] + : []; + this.appEntries = config.entry.filter((entry) => !entry.library); + this.libraryEntries = config.entry.filter((entry) => !!entry.library); + } + + get enabled() { + return ( + this.globalConfigs.length > 0 || + this.appEntries.some((entry) => !!entry.html) || + this.libraryEntries.some((entry) => !!entry.html) + ); + } + + setEntrypoints(entrypoints: RawEntrypoints) { + this.endpointOrder.length = 0; + this.endpointGroups.clear(); + this.endpointGroupByEndpoint.clear(); + this.writtenEndpointPaths.clear(); + this.addEntrypoints( + entrypoints.apps, + entrypoints.appPaths, + this.appEntries, + "app", + ); + this.addEntrypoints( + entrypoints.libraries, + entrypoints.libraryPaths, + this.libraryEntries, + "library", + ); + } + + setWrittenEndpointPath( + endpoint: Endpoint, + writtenEndpointPath: NapiWrittenEndpoint, + ) { + this.writtenEndpointPaths.set(endpoint, writtenEndpointPath); + } + + async generateAll() { + if (!this.enabled) { + return; + } + + this.pendingGenerateAll = true; + await this.scheduleGeneration(); + } + + async generateForEndpoint(endpoint: Endpoint) { + if (!this.enabled) { + return; + } + + this.pendingEndpoints.add(endpoint); + await this.scheduleGeneration(); + } + + private addEntrypoints( + endpoints: Endpoint[] | undefined, + paths: NapiWrittenEndpoint[] | undefined, + entries: EntryOptions[], + kind: "app" | "library", + ) { + if (endpoints && endpoints.length !== entries.length) { + throw new Error( + `Expected ${entries.length} ${kind} endpoint(s), received ${endpoints.length}`, + ); + } + if (endpoints && paths && paths.length !== endpoints.length) { + throw new Error( + `Expected ${endpoints.length} written ${kind} endpoint path(s), received ${paths.length}`, + ); + } + + endpoints?.forEach((endpoint, index) => { + this.endpointOrder.push(endpoint); + + const config = entries[index]?.html; + if (config) { + const key = this.getHtmlOutputKey(config); + let group = this.endpointGroups.get(key); + if (group) { + // Multiple module scripts extracted from one HTML entry intentionally + // share an output file. Keep all owning endpoints in the same group so + // generating that file cannot overwrite all but the last script. + group.config = config; + group.endpoints.push(endpoint); + } else { + group = { config, endpoints: [endpoint] }; + this.endpointGroups.set(key, group); + } + this.endpointGroupByEndpoint.set(endpoint, group); + } + + const writtenEndpointPath = paths?.[index]; + if (writtenEndpointPath) { + this.writtenEndpointPaths.set(endpoint, writtenEndpointPath); + } + }); + } + + private async generateGlobalHtml() { + if (this.globalConfigs.length === 0) { + return; + } + + const assets = getInitialAssetsFromEndpointPaths( + this.getWrittenEndpointPaths(this.endpointOrder), + ); + for (const config of this.globalConfigs) { + await new HtmlPlugin(config).generate( + this.outputDir, + assets, + this.publicPath, + ); + } + } + + private async generateEndpointHtml(group: EndpointHtmlGroup) { + const writtenEndpointPaths = this.getWrittenEndpointPaths(group.endpoints); + if (writtenEndpointPaths.length === 0) { + return; + } + + const assets = getInitialAssetsFromEndpointPaths(writtenEndpointPaths); + await new HtmlPlugin(group.config).generate( + this.outputDir, + assets, + this.publicPath, + ); + } + + private getWrittenEndpointPaths(endpoints: Endpoint[]) { + return endpoints.flatMap((endpoint) => { + const written = this.writtenEndpointPaths.get(endpoint); + return written ? [written] : []; + }); + } + + private getHtmlOutputKey(config: HtmlConfig) { + const outputDir = path.resolve(config.output?.path ?? this.outputDir); + return path.join(outputDir, config.filename ?? "index.html"); + } + + private scheduleGeneration() { + const drain = async () => { + while (this.pendingGenerateAll || this.pendingEndpoints.size > 0) { + const generateAll = this.pendingGenerateAll; + this.pendingGenerateAll = false; + const endpoints = [...this.pendingEndpoints]; + this.pendingEndpoints.clear(); + + await this.generateGlobalHtml(); + const groups = generateAll + ? [...this.endpointGroups.values()] + : [ + ...new Set( + endpoints.flatMap((endpoint) => { + const group = this.endpointGroupByEndpoint.get(endpoint); + return group ? [group] : []; + }), + ), + ]; + for (const group of groups) { + await this.generateEndpointHtml(group); + } + } + }; + const queued = this.generationQueue.then(drain, drain); + this.generationQueue = queued.catch(() => {}); + return queued; + } +} diff --git a/packages/pack/src/utils/target.test.ts b/packages/pack/src/utils/target.test.ts new file mode 100644 index 0000000000..dd1204eaa4 --- /dev/null +++ b/packages/pack/src/utils/target.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isNodeTarget } from "./target"; + +describe("isNodeTarget", () => { + it.each(["node", "current node", "maintained node versions", "node >= 20"])( + "recognizes the Node target %s", + (target) => { + expect(isNodeTarget(target)).toBe(true); + }, + ); + + it.each([undefined, "web", "last 1 Chrome versions"])( + "keeps the web target %s on the client watcher", + (target) => { + expect(isNodeTarget(target)).toBe(false); + }, + ); + + it("matches the first resolved distribution for mixed queries", () => { + expect(isNodeTarget("last 1 Chrome versions, current node")).toBe(false); + }); +}); diff --git a/packages/pack/src/utils/target.ts b/packages/pack/src/utils/target.ts new file mode 100644 index 0000000000..9dd0a73d12 --- /dev/null +++ b/packages/pack/src/utils/target.ts @@ -0,0 +1,16 @@ +import browserslist from "browserslist"; + +export function isNodeTarget(target?: string) { + if (target === undefined) return false; + + try { + const [distribution] = browserslist(target.split(","), { + ignoreUnknownVersions: true, + }); + return distribution?.startsWith("node ") ?? false; + } catch { + // Match Config::platform(): `node` is also the explicit fallback when the + // target isn't a valid Browserslist query. + return target === "node"; + } +}