From b5d752667aabab59478d6f82cc6c1a27d37a8b44 Mon Sep 17 00:00:00 2001 From: Leah Date: Fri, 8 Sep 2023 20:25:13 +0200 Subject: [PATCH 1/2] feat(turbopack): add dynamic metadata support (#54995) Closes NEXT-1435 Closes WEB-1435 --- Cargo.lock | 170 +++++-- Cargo.toml | 3 - .../next-swc/crates/napi/src/app_structure.rs | 147 ++++-- packages/next-swc/crates/next-api/src/app.rs | 35 +- .../next-build/src/next_app/app_entries.rs | 29 +- packages/next-swc/crates/next-core/Cargo.toml | 1 + .../crates/next-core/src/app_source.rs | 180 ++++---- .../crates/next-core/src/app_structure.rs | 420 ++++++++++++------ .../crates/next-core/src/loader_tree.rs | 239 ++++++---- .../next-swc/crates/next-core/src/mode.rs | 20 +- .../next-core/src/next_app/app_page_entry.rs | 34 +- .../next-core/src/next_app/app_route_entry.rs | 14 +- .../next-core/src/next_app/metadata/image.rs | 148 ++++++ .../next-core/src/next_app/metadata/mod.rs | 341 ++++++++++++++ .../next-core/src/next_app/metadata/route.rs | 364 +++++++++++++++ .../crates/next-core/src/next_app/mod.rs | 56 ++- .../unsupported_dynamic_metadata_issue.rs | 54 --- .../crates/next-core/src/next_import_map.rs | 21 +- .../next-dev-tests/tests/integration.rs | 1 + .../app/implicit-metadata/input/app/test.tsx | 15 +- .../src/server/lib/router-utils/setup-dev.ts | 17 +- 21 files changed, 1771 insertions(+), 538 deletions(-) create mode 100644 packages/next-swc/crates/next-core/src/next_app/metadata/image.rs create mode 100644 packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs create mode 100644 packages/next-swc/crates/next-core/src/next_app/metadata/route.rs delete mode 100644 packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs diff --git a/Cargo.lock b/Cargo.lock index 5a64dc231155a..44ad805955fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,54 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "anstream" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea" + +[[package]] +name = "anstyle-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "anstyle-wincon" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + [[package]] name = "any_ascii" version = "0.1.7" @@ -925,30 +973,36 @@ dependencies = [ [[package]] name = "clap" -version = "4.1.11" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42dfd32784433290c51d92c438bb72ea5063797fc3cc9a21a8c4346bebbb2098" +checksum = "6a13b88d2c62ff462f88e4a121f17a82c1af05693a2f192b5c38d14de73c19f6" dependencies = [ - "bitflags 2.3.3", + "clap_builder", "clap_derive", - "clap_lex 0.3.3", - "is-terminal", - "once_cell", +] + +[[package]] +name = "clap_builder" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb9faaa7c2ef94b2743a21f5a29e6f0010dff4caa69ac8e9d6cf8b6fa74da08" +dependencies = [ + "anstream", + "anstyle", + "clap_lex 0.5.1", "strsim", - "termcolor", ] [[package]] name = "clap_derive" -version = "4.1.9" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fddf67631444a3a3e3e5ac51c36a5e01335302de677bd78759eaa90ab1f46644" +checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873" dependencies = [ "heck 0.4.1", - "proc-macro-error", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.25", ] [[package]] @@ -962,12 +1016,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.3.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "033f6b7a4acb1f358c742aaca805c939ee73b4c6209ae4318ec7aca81c42e646" -dependencies = [ - "os_str_bytes", -] +checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" [[package]] name = "cloudabi" @@ -1000,6 +1051,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "combine" version = "4.6.6" @@ -1824,12 +1881,12 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.9.3" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0" dependencies = [ - "atty", "humantime", + "is-terminal", "log", "regex", "termcolor", @@ -2431,15 +2488,15 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "httpmock" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6b56b6265f15908780cbee987912c1e98dbca675361f748291605a8a3a1df09" +checksum = "4b02e044d3b4c2f94936fb05f9649efa658ca788f44eb6b87554e2033fc8ce93" dependencies = [ "assert-json-diff", "async-object-pool", "async-trait", - "base64 0.13.1", - "clap 4.1.11", + "base64 0.21.0", + "clap 4.4.2", "crossbeam-utils", "env_logger", "form_urlencoded", @@ -2869,6 +2926,29 @@ dependencies = [ "log", ] +[[package]] +name = "lazy-regex" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57451d19ad5e289ff6c3d69c2a2424652995c42b79dafa11e9c4d5508c913c01" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a1d9139f0ee2e862e08a9c5d0ba0470f2aa21cd1e1aa1b1562f83116c725f" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.25", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -3171,7 +3251,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" dependencies = [ - "regex-automata", + "regex-automata 0.1.10", ] [[package]] @@ -3547,7 +3627,7 @@ dependencies = [ "anyhow", "async-recursion", "base64 0.21.0", - "clap 4.1.11", + "clap 4.4.2", "console-subscriber", "dunce", "indexmap 1.9.3", @@ -3577,6 +3657,7 @@ dependencies = [ "futures", "indexmap 1.9.3", "indoc", + "lazy-regex", "lazy_static", "mime", "mime_guess", @@ -3601,7 +3682,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chromiumoxide", - "clap 4.1.11", + "clap 4.4.2", "console-subscriber", "criterion", "dunce", @@ -4747,13 +4828,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.8.3" +version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81ca098a9821bd52d6b24fd8b10bd081f47d39c22778cafaa75a2857a62c6390" +checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.2", + "regex-automata 0.3.8", + "regex-syntax 0.7.5", ] [[package]] @@ -4765,6 +4847,17 @@ dependencies = [ "regex-syntax 0.6.29", ] +[[package]] +name = "regex-automata" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.5", +] + [[package]] name = "regex-syntax" version = "0.6.29" @@ -4773,9 +4866,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.7.2" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "region" @@ -7168,11 +7261,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.28.2" +version = "1.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94d7b1cfd2aa4011f2de74c2c4c63665e27a71006b0a192dcd2710272e73dfa2" +checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da" dependencies = [ "autocfg", + "backtrace", "bytes", "libc", "mio 0.8.6", @@ -7819,7 +7913,7 @@ version = "0.1.0" source = "git+https://github.com/vercel/turbo.git?tag=turbopack-230901.3#d76ad75b0402cd9f9583ffba8885fc688e012a03" dependencies = [ "anyhow", - "clap 4.1.11", + "clap 4.4.2", "crossbeam-channel", "crossterm", "once_cell", @@ -7872,7 +7966,7 @@ version = "0.1.0" source = "git+https://github.com/vercel/turbo.git?tag=turbopack-230901.3#d76ad75b0402cd9f9583ffba8885fc688e012a03" dependencies = [ "anyhow", - "clap 4.1.11", + "clap 4.4.2", "indoc", "pathdiff", "serde_json", @@ -8387,6 +8481,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "uuid" version = "1.3.3" diff --git a/Cargo.toml b/Cargo.toml index 5c3972b864e39..c7b8764175097 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,6 @@ members = [ "packages/next-swc/crates/next-transform-strip-page-exports", ] -[profile.dev.package.swc_css_prefixer] -opt-level = 2 - # This is a workaround for wasm timeout issue [profile.dev.package."*"] debug-assertions = false diff --git a/packages/next-swc/crates/napi/src/app_structure.rs b/packages/next-swc/crates/napi/src/app_structure.rs index 90e3458fa5e8c..0b0d79828472a 100644 --- a/packages/next-swc/crates/napi/src/app_structure.rs +++ b/packages/next-swc/crates/napi/src/app_structure.rs @@ -8,7 +8,7 @@ use napi::{ }; use next_core::app_structure::{ find_app_dir, get_entrypoints as get_entrypoints_impl, Components, Entrypoint, Entrypoints, - LoaderTree, MetadataWithAltItem, + LoaderTree, MetadataItem, MetadataWithAltItem, }; use serde::{Deserialize, Serialize}; use turbo_tasks::{ReadRef, Vc}; @@ -41,6 +41,8 @@ struct LoaderTreeForJs { parallel_routes: HashMap>, #[turbo_tasks(trace_ignore)] components: ComponentsForJs, + #[turbo_tasks(trace_ignore)] + global_metadata: GlobalMetadataForJs, } #[derive(PartialEq, Eq, Serialize, Deserialize, ValueDebugFormat, TraceRawVcs)] @@ -101,20 +103,31 @@ struct ComponentsForJs { #[serde(rename_all = "camelCase")] struct MetadataForJs { #[serde(skip_serializing_if = "Vec::is_empty")] - icon: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - apple: Vec, + icon: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] - twitter: Vec, + apple: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] - open_graph: Vec, + twitter: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] - favicon: Vec, + open_graph: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + sitemap: Option, +} + +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)] +#[serde(rename_all = "camelCase")] +struct GlobalMetadataForJs { + #[serde(skip_serializing_if = "Option::is_none")] + favicon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + robots: Option, + #[serde(skip_serializing_if = "Option::is_none")] + manifest: Option, } #[derive(Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)] #[serde(tag = "type", rename_all = "camelCase")] -enum MetadataForJsItem { +enum MetadataWithAltItemForJs { Static { path: String, alt_path: Option, @@ -124,6 +137,13 @@ enum MetadataForJsItem { }, } +#[derive(Deserialize, Serialize, PartialEq, Eq, ValueDebugFormat)] +#[serde(tag = "type", rename_all = "camelCase")] +enum MetadataItemForJs { + Static { path: String }, + Dynamic { path: String }, +} + async fn prepare_components_for_js( project_path: Vc, components: Vc, @@ -158,60 +178,88 @@ async fn prepare_components_for_js( add(&mut result.not_found, project_path, not_found).await?; add(&mut result.default, project_path, default).await?; add(&mut result.route, project_path, route).await?; - async fn add_meta<'a>( - meta: &mut Vec, - project_path: Vc, - value: impl Iterator, - ) -> Result<()> { - let mut value = value.peekable(); - if value.peek().is_some() { - *meta = value - .map(|value| async move { - Ok(match value { - MetadataWithAltItem::Static { path, alt_path } => { - let path = fs_path_to_path(project_path, *path).await?; - let alt_path = if let Some(alt_path) = alt_path { - Some(fs_path_to_path(project_path, *alt_path).await?) - } else { - None - }; - MetadataForJsItem::Static { path, alt_path } - } - MetadataWithAltItem::Dynamic { path } => { - let path = fs_path_to_path(project_path, *path).await?; - MetadataForJsItem::Dynamic { path } - } - }) - }) - .try_join() - .await?; - } - Ok::<_, anyhow::Error>(()) - } + let meta = &mut result.metadata; - add_meta(&mut meta.icon, project_path, metadata.icon.iter()).await?; - add_meta(&mut meta.apple, project_path, metadata.apple.iter()).await?; - add_meta(&mut meta.twitter, project_path, metadata.twitter.iter()).await?; - add_meta( + add_meta_vec(&mut meta.icon, project_path, metadata.icon.iter()).await?; + add_meta_vec(&mut meta.apple, project_path, metadata.apple.iter()).await?; + add_meta_vec(&mut meta.twitter, project_path, metadata.twitter.iter()).await?; + add_meta_vec( &mut meta.open_graph, project_path, metadata.open_graph.iter(), ) .await?; - add_meta(&mut meta.favicon, project_path, metadata.favicon.iter()).await?; + add_meta(&mut meta.sitemap, project_path, metadata.sitemap).await?; Ok(result) } +async fn add_meta_vec<'a>( + meta: &mut Vec, + project_path: Vc, + value: impl Iterator, +) -> Result<()> { + let mut value = value.peekable(); + if value.peek().is_some() { + *meta = value + .map(|value| async move { + Ok(match value { + MetadataWithAltItem::Static { path, alt_path } => { + let path = fs_path_to_path(project_path, *path).await?; + let alt_path = if let Some(alt_path) = alt_path { + Some(fs_path_to_path(project_path, *alt_path).await?) + } else { + None + }; + MetadataWithAltItemForJs::Static { path, alt_path } + } + MetadataWithAltItem::Dynamic { path } => { + let path = fs_path_to_path(project_path, *path).await?; + MetadataWithAltItemForJs::Dynamic { path } + } + }) + }) + .try_join() + .await?; + } + + Ok(()) +} + +async fn add_meta<'a>( + meta: &mut Option, + project_path: Vc, + value: Option, +) -> Result<()> { + if value.is_some() { + *meta = match value { + Some(MetadataItem::Static { path }) => { + let path = fs_path_to_path(project_path, path).await?; + Some(MetadataItemForJs::Static { path }) + } + Some(MetadataItem::Dynamic { path }) => { + let path = fs_path_to_path(project_path, path).await?; + Some(MetadataItemForJs::Dynamic { path }) + } + None => None, + }; + } + + Ok(()) +} + #[turbo_tasks::function] async fn prepare_loader_tree_for_js( project_path: Vc, loader_tree: Vc, ) -> Result> { let LoaderTree { + page: _, segment, parallel_routes, components, + global_metadata, } = &*loader_tree.await?; + let parallel_routes = parallel_routes .iter() .map(|(key, &value)| async move { @@ -224,11 +272,21 @@ async fn prepare_loader_tree_for_js( .await? .into_iter() .collect(); + let components = prepare_components_for_js(project_path, *components).await?; + + let global_metadata = global_metadata.await?; + + let mut meta = GlobalMetadataForJs::default(); + add_meta(&mut meta.favicon, project_path, global_metadata.favicon).await?; + add_meta(&mut meta.manifest, project_path, global_metadata.manifest).await?; + add_meta(&mut meta.robots, project_path, global_metadata.robots).await?; + Ok(LoaderTreeForJs { segment: segment.clone(), parallel_routes, components, + global_metadata: meta, } .cell()) } @@ -251,6 +309,9 @@ async fn prepare_entrypoints_for_js( Entrypoint::AppRoute { path, .. } => EntrypointForJs::AppRoute { path: fs_path_to_path(project_path, path).await?, }, + Entrypoint::AppMetadata { metadata, .. } => EntrypointForJs::AppRoute { + path: fs_path_to_path(project_path, metadata.into_path()).await?, + }, }; Ok((key, value)) } diff --git a/packages/next-swc/crates/next-api/src/app.rs b/packages/next-swc/crates/next-api/src/app.rs index 295b57369a6d3..9fe4e8c66a018 100644 --- a/packages/next-swc/crates/next-api/src/app.rs +++ b/packages/next-swc/crates/next-api/src/app.rs @@ -3,12 +3,13 @@ use next_core::{ all_server_paths, app_structure::{ get_entrypoints, Entrypoint as AppEntrypoint, Entrypoints as AppEntrypoints, LoaderTree, + MetadataItem, }, get_edge_resolve_options_context, mode::NextMode, next_app::{ get_app_client_references_chunks, get_app_client_shared_chunks, get_app_page_entry, - get_app_route_entry, AppEntry, AppPage, + get_app_route_entry, metadata::route::get_app_metadata_route_entry, AppEntry, AppPage, }, next_client::{ get_client_module_options_context, get_client_resolve_options_context, @@ -113,6 +114,11 @@ impl AppProject { self.app_dir } + #[turbo_tasks::function] + fn mode(&self) -> Vc { + self.mode.cell() + } + #[turbo_tasks::function] fn app_entrypoints(&self) -> Vc { get_entrypoints(self.app_dir, self.project.next_config().page_extensions()) @@ -395,6 +401,16 @@ pub async fn app_entry_point_to_route( .cell(), ), }, + AppEntrypoint::AppMetadata { page, metadata } => Route::AppRoute { + endpoint: Vc::upcast( + AppEndpoint { + ty: AppEndpointType::Metadata { metadata }, + app_project, + page, + } + .cell(), + ), + }, } .cell() } @@ -414,6 +430,9 @@ enum AppEndpointType { Route { path: Vc, }, + Metadata { + metadata: MetadataItem, + }, } #[turbo_tasks::value] @@ -431,7 +450,6 @@ impl AppEndpoint { self.app_project.rsc_module_context(), self.app_project.edge_rsc_module_context(), loader_tree, - self.app_project.app_dir(), self.page.clone(), self.app_project.project().project_path(), ) @@ -448,6 +466,18 @@ impl AppEndpoint { ) } + #[turbo_tasks::function] + async fn app_metadata_entry(&self, metadata: MetadataItem) -> Result> { + Ok(get_app_metadata_route_entry( + self.app_project.rsc_module_context(), + self.app_project.edge_rsc_module_context(), + self.app_project.project().project_path(), + self.page.clone(), + *self.app_project.mode().await?, + metadata, + )) + } + #[turbo_tasks::function] fn output_assets(self: Vc) -> Vc { self.output().output_assets() @@ -465,6 +495,7 @@ impl AppEndpoint { // as we know we won't have any client references. However, for now, for simplicity's // sake, we just do the same thing as for pages. AppEndpointType::Route { path } => (self.app_route_entry(path), "route"), + AppEndpointType::Metadata { metadata } => (self.app_metadata_entry(metadata), "route"), }; let node_root = this.app_project.project().node_root(); diff --git a/packages/next-swc/crates/next-build/src/next_app/app_entries.rs b/packages/next-swc/crates/next-build/src/next_app/app_entries.rs index 9c7271ecdd66d..b9d3f38217436 100644 --- a/packages/next-swc/crates/next-build/src/next_app/app_entries.rs +++ b/packages/next-swc/crates/next-build/src/next_app/app_entries.rs @@ -2,11 +2,11 @@ use std::collections::HashMap; use anyhow::Result; use next_core::{ - app_structure::{find_app_dir_if_enabled, get_entrypoints, get_global_metadata, Entrypoint}, + app_structure::{find_app_dir_if_enabled, get_entrypoints, Entrypoint}, mode::NextMode, next_app::{ get_app_client_shared_chunks, get_app_page_entry, get_app_route_entry, - get_app_route_favicon_entry, AppEntry, ClientReferencesChunks, + metadata::route::get_app_metadata_route_entry, AppEntry, ClientReferencesChunks, }, next_client::{ get_client_module_options_context, get_client_resolve_options_context, @@ -184,7 +184,7 @@ pub async fn get_app_entries( rsc_resolve_options_context, ); - let mut entries = entrypoints + let entries = entrypoints .await? .iter() .map(|(_, entrypoint)| async move { @@ -194,7 +194,6 @@ pub async fn get_app_entries( // TODO add edge support rsc_context, *loader_tree, - app_dir, page.clone(), project_root, ), @@ -206,24 +205,20 @@ pub async fn get_app_entries( page.clone(), project_root, ), + Entrypoint::AppMetadata { page, metadata } => get_app_metadata_route_entry( + rsc_context, + // TODO add edge support + rsc_context, + project_root, + page.clone(), + mode, + *metadata, + ), }) }) .try_join() .await?; - let global_metadata = get_global_metadata(app_dir, next_config.page_extensions()); - let global_metadata = global_metadata.await?; - - if let Some(favicon) = global_metadata.favicon { - entries.push(get_app_route_favicon_entry( - rsc_context, - // TODO add edge support - rsc_context, - favicon, - project_root, - )); - } - let client_context = ModuleAssetContext::new( Vc::cell(Default::default()), client_compile_time_info, diff --git a/packages/next-swc/crates/next-core/Cargo.toml b/packages/next-swc/crates/next-core/Cargo.toml index 38bcb02a3cda6..1347b942a12fc 100644 --- a/packages/next-swc/crates/next-core/Cargo.toml +++ b/packages/next-swc/crates/next-core/Cargo.toml @@ -14,6 +14,7 @@ async-recursion = { workspace = true } async-trait = { workspace = true } base64 = "0.21.0" const_format = "0.2.30" +lazy-regex = "3.0.1" once_cell = { workspace = true } qstring = { workspace = true } regex = { workspace = true } diff --git a/packages/next-swc/crates/next-core/src/app_source.rs b/packages/next-swc/crates/next-core/src/app_source.rs index 4265f52139b4b..24cba8b73e00c 100644 --- a/packages/next-swc/crates/next-core/src/app_source.rs +++ b/packages/next-swc/crates/next-core/src/app_source.rs @@ -1,10 +1,11 @@ -use std::{collections::HashMap, io::Write as _, iter::once}; +use std::{collections::HashMap, io::Write as _}; use anyhow::{bail, Result}; use indexmap::indexmap; use indoc::formatdoc; +use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; -use turbo_tasks::Vc; +use turbo_tasks::{trace::TraceRawVcs, TaskInput, Vc}; use turbopack_binding::{ turbo::{ tasks::Value, @@ -19,7 +20,6 @@ use turbopack_binding::{ context::AssetContext, environment::ServerAddr, file_source::FileSource, - issue::IssueExt, reference_type::{ EcmaScriptModulesReferenceSubType, EntryReferenceSubType, ReferenceType, }, @@ -30,7 +30,6 @@ use turbopack_binding::{ dev_server::{ html::DevHtmlAsset, source::{ - asset_graph::AssetGraphContentSource, combined::CombinedContentSource, route_tree::{BaseSegment, RouteType}, ContentSource, ContentSourceData, ContentSourceExt, NoContentSource, @@ -47,7 +46,6 @@ use turbopack_binding::{ }, NodeEntry, NodeRenderingEntry, }, - r#static::fixed::FixedStaticAsset, turbopack::{transition::Transition, ModuleAssetContext}, }, }; @@ -55,17 +53,14 @@ use turbopack_binding::{ use crate::{ app_render::next_server_component_transition::NextServerComponentTransition, app_segment_config::{parse_segment_config_from_loader_tree, parse_segment_config_from_source}, - app_structure::{ - get_entrypoints, get_global_metadata, Entrypoint, GlobalMetadata, LoaderTree, MetadataItem, - OptionAppDir, - }, + app_structure::{get_entrypoints, Entrypoint, LoaderTree, MetadataItem, OptionAppDir}, bootstrap::{route_bootstrap, BootstrapConfig}, embed_js::{next_asset, next_js_file_path}, env::env_for_js, fallback::get_fallback_page, loader_tree::{LoaderTreeModule, ServerComponentTransition}, mode::NextMode, - next_app::{AppPage, AppPath, PathSegment, UnsupportedDynamicMetadataIssue}, + next_app::{metadata::route::get_app_metadata_route_source, AppPage, AppPath, PathSegment}, next_client::{ context::{ get_client_assets_path, get_client_module_options_context, @@ -599,7 +594,8 @@ pub async fn create_app_source( return Ok(Vc::upcast(NoContentSource::new())); }; let entrypoints = get_entrypoints(app_dir, next_config.page_extensions()); - let metadata = get_global_metadata(app_dir, next_config.page_extensions()); + + let mode = NextMode::DevServer; let context_ssr = app_context( project_path, @@ -610,7 +606,7 @@ pub async fn create_app_source( client_chunking_context, client_compile_time_info, true, - NextMode::DevServer, + mode, next_config, server_addr, output_path, @@ -624,7 +620,7 @@ pub async fn create_app_source( client_chunking_context, client_compile_time_info, false, - NextMode::DevServer, + mode, next_config, server_addr, output_path, @@ -671,6 +667,7 @@ pub async fn create_app_source( ), Entrypoint::AppRoute { ref page, path } => create_app_route_source_for_route( page.clone(), + mode, path, context_ssr, project_path, @@ -681,12 +678,20 @@ pub async fn create_app_source( output_path, render_data, ), + Entrypoint::AppMetadata { ref page, metadata } => create_app_route_source_for_metadata( + page.clone(), + mode, + context_ssr, + project_path, + app_dir, + env, + server_root, + server_runtime_entries, + output_path, + render_data, + metadata, + ), }) - .chain(once(create_global_metadata_source( - app_dir, - metadata, - server_root, - ))) .collect(); if let Some(&Entrypoint::AppPage { @@ -717,50 +722,6 @@ pub async fn create_app_source( Ok(Vc::upcast(CombinedContentSource { sources }.cell())) } -#[turbo_tasks::function] -async fn create_global_metadata_source( - app_dir: Vc, - metadata: Vc, - server_root: Vc, -) -> Result>> { - let metadata = metadata.await?; - let mut unsupported_metadata = Vec::new(); - let mut sources = Vec::new(); - for (server_path, item) in [ - ("robots.txt", metadata.robots), - ("favicon.ico", metadata.favicon), - ("sitemap.xml", metadata.sitemap), - ] { - let Some(item) = item else { - continue; - }; - match item { - MetadataItem::Static { path } => { - let asset = FixedStaticAsset::new( - server_root.join(server_path.to_string()), - Vc::upcast(FileSource::new(path)), - ); - sources.push(Vc::upcast(AssetGraphContentSource::new_eager( - server_root, - Vc::upcast(asset), - ))) - } - MetadataItem::Dynamic { path } => { - unsupported_metadata.push(path); - } - } - } - if !unsupported_metadata.is_empty() { - UnsupportedDynamicMetadataIssue { - app_dir, - files: unsupported_metadata, - } - .cell() - .emit(); - } - Ok(Vc::upcast(CombinedContentSource { sources }.cell())) -} - #[turbo_tasks::function] async fn create_app_page_source_for_route( page: AppPage, @@ -794,7 +755,6 @@ async fn create_app_page_source_for_route( Vc::upcast( AppRenderer { runtime_entries, - app_dir, context_ssr, context, server_root, @@ -839,7 +799,6 @@ async fn create_app_not_found_page_source( Vc::upcast( AppRenderer { runtime_entries, - app_dir, context_ssr, context, server_root, @@ -860,6 +819,7 @@ async fn create_app_not_found_page_source( #[turbo_tasks::function] async fn create_app_route_source_for_route( page: AppPage, + mode: NextMode, entry_path: Vc, context_ssr: Vc, project_path: Vc, @@ -890,7 +850,58 @@ async fn create_app_route_source_for_route( context: context_ssr, runtime_entries, server_root, - entry_path, + entry: AppRouteEntry::Path(entry_path), + mode, + project_path, + intermediate_output_path: intermediate_output_path_root, + output_root: intermediate_output_path_root, + app_dir, + } + .cell(), + ), + render_data, + should_debug("app_source"), + ); + + Ok(source.issue_file_path(app_dir, format!("Next.js App Route {app_path}"))) +} + +#[turbo_tasks::function] +async fn create_app_route_source_for_metadata( + page: AppPage, + mode: NextMode, + context_ssr: Vc, + project_path: Vc, + app_dir: Vc, + env: Vc>, + server_root: Vc, + runtime_entries: Vc, + intermediate_output_path_root: Vc, + render_data: Vc, + metadata: MetadataItem, +) -> Result>> { + let app_path = AppPath::from(page.clone()); + let pathname_vc = Vc::cell(app_path.to_string()); + + let params_matcher = NextParamsMatcher::new(pathname_vc); + + let (base_segments, route_type) = app_path_to_segments(&app_path)?; + + let source = create_node_api_source( + project_path, + env, + base_segments, + route_type, + server_root, + Vc::upcast(params_matcher), + pathname_vc, + Vc::upcast( + AppRoute { + context: context_ssr, + runtime_entries, + server_root, + entry: AppRouteEntry::Metadata { metadata, page }, + mode, project_path, intermediate_output_path: intermediate_output_path_root, output_root: intermediate_output_path_root, @@ -909,7 +920,6 @@ async fn create_app_route_source_for_route( #[turbo_tasks::value] struct AppRenderer { runtime_entries: Vc, - app_dir: Vc, context_ssr: Vc, context: Vc, project_path: Vc, @@ -924,7 +934,6 @@ impl AppRenderer { async fn entry(self: Vc, with_ssr: bool) -> Result> { let AppRenderer { runtime_entries, - app_dir, context_ssr, context, project_path, @@ -955,15 +964,6 @@ impl AppRenderer { ) .await?; - if !loader_tree_module.unsupported_metadata.is_empty() { - UnsupportedDynamicMetadataIssue { - app_dir, - files: loader_tree_module.unsupported_metadata, - } - .cell() - .emit(); - } - let mut result = RopeBuilder::from( formatdoc!( " @@ -1078,12 +1078,22 @@ impl NodeEntry for AppRenderer { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TaskInput, TraceRawVcs)] +pub enum AppRouteEntry { + Path(Vc), + Metadata { + metadata: MetadataItem, + page: AppPage, + }, +} + /// The node.js renderer api routes in the app directory #[turbo_tasks::value] struct AppRoute { runtime_entries: Vc, context: Vc, - entry_path: Vc, + entry: AppRouteEntry, + mode: NextMode, intermediate_output_path: Vc, project_path: Vc, server_root: Vc, @@ -1110,13 +1120,19 @@ impl AppRoute { .build(), ); - let entry_file_source = FileSource::new(this.entry_path); + let entry_file_source = match this.entry { + AppRouteEntry::Path(path) => Vc::upcast(FileSource::new(path)), + AppRouteEntry::Metadata { metadata, ref page } => { + get_app_metadata_route_source(page.clone(), this.mode, metadata) + } + }; + let entry_asset = this.context.process( - Vc::upcast(entry_file_source), + entry_file_source, Value::new(ReferenceType::Entry(EntryReferenceSubType::AppRoute)), ); - let config = parse_segment_config_from_source(entry_asset, Vc::upcast(entry_file_source)); + let config = parse_segment_config_from_source(entry_asset, entry_file_source); let module = match config.await?.runtime { Some(NextRuntime::NodeJs) | None => { let bootstrap_asset = next_asset("entry/app/route.ts".to_string()); @@ -1125,7 +1141,7 @@ impl AppRoute { .context .with_transition("next-route".to_string()) .process( - Vc::upcast(entry_file_source), + entry_file_source, Value::new(ReferenceType::Entry(EntryReferenceSubType::AppRoute)), ); @@ -1144,7 +1160,7 @@ impl AppRoute { .context .with_transition("next-edge-route".to_string()) .process( - Vc::upcast(entry_file_source), + entry_file_source, Value::new(ReferenceType::Entry(EntryReferenceSubType::AppRoute)), ); diff --git a/packages/next-swc/crates/next-core/src/app_structure.rs b/packages/next-swc/crates/next-core/src/app_structure.rs index a3597079af6c0..c0cf554cc2525 100644 --- a/packages/next-swc/crates/next-core/src/app_structure.rs +++ b/packages/next-swc/crates/next-core/src/app_structure.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use anyhow::{bail, Result}; use indexmap::{ @@ -6,8 +6,6 @@ use indexmap::{ map::{Entry, OccupiedEntry}, IndexMap, }; -use once_cell::sync::Lazy; -use regex::Regex; use serde::{Deserialize, Serialize}; use turbo_tasks::{ debug::ValueDebugFormat, trace::TraceRawVcs, Completion, Completions, TaskInput, ValueToString, @@ -19,7 +17,13 @@ use turbopack_binding::{ }; use crate::{ - next_app::{AppPage, AppPath}, + next_app::{ + metadata::{ + match_global_metadata_file, match_local_metadata_file, normalize_metadata_route, + GlobalMetadataFileMatch, MetadataFileMatch, + }, + AppPage, AppPath, PageType, + }, next_config::NextConfig, next_import_map::get_next_package, }; @@ -108,6 +112,49 @@ pub enum MetadataItem { Dynamic { path: Vc }, } +#[turbo_tasks::function] +pub async fn get_metadata_route_name(meta: MetadataItem) -> Result> { + Ok(match meta { + MetadataItem::Static { path } => { + let path_value = path.await?; + Vc::cell(path_value.file_name().to_string()) + } + MetadataItem::Dynamic { path } => { + let Some(stem) = &*path.file_stem().await? else { + bail!( + "unable to resolve file stem for metadata item at {}", + path.to_string().await? + ); + }; + + match stem.as_str() { + "robots" => Vc::cell("robots.txt".to_string()), + "manifest" => Vc::cell("manifest.webmanifest".to_string()), + "sitemap" => Vc::cell("sitemap.xml".to_string()), + _ => Vc::cell(stem.clone()), + } + } + }) +} + +impl MetadataItem { + pub fn into_path(self) -> Vc { + match self { + MetadataItem::Static { path } => path, + MetadataItem::Dynamic { path } => path, + } + } +} + +impl From for MetadataItem { + fn from(value: MetadataWithAltItem) -> Self { + match value { + MetadataWithAltItem::Static { path, .. } => MetadataItem::Static { path }, + MetadataWithAltItem::Dynamic { path } => MetadataItem::Dynamic { path }, + } + } +} + /// Metadata file that can be placed in any segment of the app directory. #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, TraceRawVcs)] pub struct Metadata { @@ -119,10 +166,8 @@ pub struct Metadata { pub twitter: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub open_graph: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub favicon: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub manifest: Option, + pub sitemap: Option, } impl Metadata { @@ -132,15 +177,13 @@ impl Metadata { apple, twitter, open_graph, - favicon, - manifest, + sitemap, } = self; icon.is_empty() && apple.is_empty() && twitter.is_empty() && open_graph.is_empty() - && favicon.is_empty() - && manifest.is_none() + && sitemap.is_none() } fn merge(a: &Self, b: &Self) -> Self { @@ -154,8 +197,7 @@ impl Metadata { .chain(b.open_graph.iter()) .copied() .collect(), - favicon: a.favicon.iter().chain(b.favicon.iter()).copied().collect(), - manifest: a.manifest.or(b.manifest), + sitemap: a.sitemap.or(b.sitemap), } } } @@ -169,7 +211,7 @@ pub struct GlobalMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub robots: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub sitemap: Option, + pub manifest: Option, } impl GlobalMetadata { @@ -177,9 +219,9 @@ impl GlobalMetadata { let GlobalMetadata { favicon, robots, - sitemap, + manifest, } = self; - favicon.is_none() && robots.is_none() && sitemap.is_none() + favicon.is_none() && robots.is_none() && manifest.is_none() } } @@ -255,46 +297,6 @@ pub async fn find_app_dir_if_enabled(project_path: Vc) -> Result Ok(find_app_dir(project_path)) } -static STATIC_LOCAL_METADATA: Lazy> = - Lazy::new(|| { - HashMap::from([ - ( - "icon", - &["ico", "jpg", "jpeg", "png", "svg"] as &'static [&'static str], - ), - ("apple-icon", &["jpg", "jpeg", "png"]), - ("opengraph-image", &["jpg", "jpeg", "png", "gif"]), - ("twitter-image", &["jpg", "jpeg", "png", "gif"]), - ("favicon", &["ico"]), - ("manifest", &["webmanifest", "json"]), - ]) - }); - -static STATIC_GLOBAL_METADATA: Lazy> = - Lazy::new(|| { - HashMap::from([ - ("favicon", &["ico"] as &'static [&'static str]), - ("robots", &["txt"]), - ("sitemap", &["xml"]), - ]) - }); - -fn match_metadata_file<'a>( - basename: &'a str, - page_extensions: &[String], -) -> Option<(&'a str, i32, bool)> { - let (stem, ext) = basename.split_once('.')?; - static REGEX: Lazy = Lazy::new(|| Regex::new("^(.*?)(\\d*)$").unwrap()); - let captures = REGEX.captures(stem).expect("the regex will always match"); - let stem = captures.get(1).unwrap().as_str(); - let num: i32 = captures.get(2).unwrap().as_str().parse().unwrap_or(-1); - if page_extensions.iter().any(|e| e == ext) { - return Some((stem, num, true)); - } - let exts = STATIC_LOCAL_METADATA.get(stem)?; - exts.contains(&ext).then_some((stem, num, false)) -} - #[turbo_tasks::function] async fn get_directory_tree( dir: Vc, @@ -312,7 +314,6 @@ async fn get_directory_tree( let mut metadata_apple = Vec::new(); let mut metadata_open_graph = Vec::new(); let mut metadata_twitter = Vec::new(); - let mut metadata_favicon = Vec::new(); for (basename, entry) in entries { match *entry { @@ -328,59 +329,58 @@ async fn get_directory_tree( "not-found" => components.not_found = Some(file), "default" => components.default = Some(file), "route" => components.route = Some(file), - "manifest" => { - components.metadata.manifest = - Some(MetadataItem::Dynamic { path: file }); - continue; - } _ => {} } } } - if let Some((metadata_type, num, dynamic)) = - match_metadata_file(basename.as_str(), &page_extensions_value) - { - if metadata_type == "manifest" { - if num == -1 { - components.metadata.manifest = - Some(MetadataItem::Static { path: file }); - } - continue; - } - - let entry = match metadata_type { - "icon" => Some(&mut metadata_icon), - "apple-icon" => Some(&mut metadata_apple), - "twitter-image" => Some(&mut metadata_twitter), - "opengraph-image" => Some(&mut metadata_open_graph), - "favicon" => Some(&mut metadata_favicon), - _ => None, - }; + let Some(MetadataFileMatch { + metadata_type, + number, + dynamic, + }) = match_local_metadata_file(basename.as_str(), &page_extensions_value) + else { + continue; + }; - if let Some(entry) = entry { + let entry = match metadata_type { + "icon" => &mut metadata_icon, + "apple-icon" => &mut metadata_apple, + "twitter-image" => &mut metadata_twitter, + "opengraph-image" => &mut metadata_open_graph, + "sitemap" => { if dynamic { - entry.push((num, MetadataWithAltItem::Dynamic { path: file })); + components.metadata.sitemap = + Some(MetadataItem::Dynamic { path: file }); } else { - let file_value = file.await?; - let file_name = file_value.file_name(); - let basename = file_name - .rsplit_once('.') - .map_or(file_name, |(basename, _)| basename); - let alt_path = file.parent().join(format!("{}.alt.txt", basename)); - let alt_path = - matches!(&*alt_path.get_type().await?, FileSystemEntryType::File) - .then_some(alt_path); - entry.push(( - num, - MetadataWithAltItem::Static { - path: file, - alt_path, - }, - )); + components.metadata.sitemap = Some(MetadataItem::Static { path: file }); } + continue; } + _ => continue, + }; + + if dynamic { + entry.push((number, MetadataWithAltItem::Dynamic { path: file })); + continue; } + + let file_value = file.await?; + let file_name = file_value.file_name(); + let basename = file_name + .rsplit_once('.') + .map_or(file_name, |(basename, _)| basename); + let alt_path = file.parent().join(format!("{}.alt.txt", basename)); + let alt_path = matches!(&*alt_path.get_type().await?, FileSystemEntryType::File) + .then_some(alt_path); + + entry.push(( + number, + MetadataWithAltItem::Static { + path: file, + alt_path, + }, + )); } DirectoryEntry::Directory(dir) => { // appDir ignores paths starting with an underscore @@ -394,7 +394,7 @@ async fn get_directory_tree( } } - fn sort(mut list: Vec<(i32, T)>) -> Vec { + fn sort(mut list: Vec<(Option, T)>) -> Vec { list.sort_by_key(|(num, _)| *num); list.into_iter().map(|(_, item)| item).collect() } @@ -403,7 +403,6 @@ async fn get_directory_tree( components.metadata.apple = sort(metadata_apple); components.metadata.twitter = sort(metadata_twitter); components.metadata.open_graph = sort(metadata_open_graph); - components.metadata.favicon = sort(metadata_favicon); Ok(DirectoryTree { subdirectories, @@ -415,9 +414,11 @@ async fn get_directory_tree( #[turbo_tasks::value] #[derive(Debug, Clone)] pub struct LoaderTree { + pub page: AppPage, pub segment: String, pub parallel_routes: IndexMap>, pub components: Vc, + pub global_metadata: Vc, } #[turbo_tasks::function] @@ -443,9 +444,12 @@ async fn merge_loader_trees( let components = Components::merge(&*tree1.components.await?, &*tree2.components.await?).cell(); Ok(LoaderTree { + page: tree1.page.clone(), segment, parallel_routes, components, + // this is always the same, no need to merge it + global_metadata: tree1.global_metadata, } .cell()) } @@ -462,6 +466,10 @@ pub enum Entrypoint { page: AppPage, path: Vc, }, + AppMetadata { + page: AppPage, + metadata: MetadataItem, + }, } #[turbo_tasks::value(transparent)] @@ -568,6 +576,12 @@ async fn add_app_page( } => { conflict("route", existing_page); } + Entrypoint::AppMetadata { + page: existing_page, + .. + } => { + conflict("metadata", existing_page); + } } Ok(()) @@ -607,6 +621,55 @@ fn add_app_route( } => { conflict("route", existing_page); } + Entrypoint::AppMetadata { + page: existing_page, + .. + } => { + conflict("metadata", existing_page); + } + } +} + +fn add_app_metadata_route( + app_dir: Vc, + result: &mut IndexMap, + page: AppPage, + metadata: MetadataItem, +) { + let pathname = AppPath::from(page.clone()); + + let e = match result.entry(format!("{pathname}")) { + Entry::Occupied(e) => e, + Entry::Vacant(e) => { + e.insert(Entrypoint::AppMetadata { page, metadata }); + return; + } + }; + + let conflict = |existing_name: &str, existing_page: &AppPage| { + conflict_issue(app_dir, &e, "metadata", existing_name, &page, existing_page); + }; + + let value = e.get(); + match value { + Entrypoint::AppPage { + page: existing_page, + .. + } => { + conflict("page", existing_page); + } + Entrypoint::AppRoute { + page: existing_page, + .. + } => { + conflict("route", existing_page); + } + Entrypoint::AppMetadata { + page: existing_page, + .. + } => { + conflict("metadata", existing_page); + } } } @@ -615,20 +678,32 @@ pub fn get_entrypoints( app_dir: Vc, page_extensions: Vc>, ) -> Vc { - directory_tree_to_entrypoints(app_dir, get_directory_tree(app_dir, page_extensions)) + directory_tree_to_entrypoints( + app_dir, + get_directory_tree(app_dir, page_extensions), + get_global_metadata(app_dir, page_extensions), + ) } #[turbo_tasks::function] fn directory_tree_to_entrypoints( app_dir: Vc, directory_tree: Vc, + global_metadata: Vc, ) -> Vc { - directory_tree_to_entrypoints_internal(app_dir, "".to_string(), directory_tree, AppPage::new()) + directory_tree_to_entrypoints_internal( + app_dir, + global_metadata, + "".to_string(), + directory_tree, + AppPage::new(), + ) } #[turbo_tasks::function] async fn directory_tree_to_entrypoints_internal( app_dir: Vc, + global_metadata: Vc, directory_name: String, directory_tree: Vc, app_page: AppPage, @@ -646,9 +721,10 @@ async fn directory_tree_to_entrypoints_internal( add_app_page( app_dir, &mut result, - app_page.clone(), + app_page.clone().complete(PageType::Page)?, if current_level_is_parallel_route { LoaderTree { + page: app_page.clone(), segment: "__PAGE__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -656,13 +732,16 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell() } else { LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { "children".to_string() => LoaderTree { + page: app_page.clone(), segment: "__PAGE__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -670,10 +749,12 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell() }, @@ -685,9 +766,10 @@ async fn directory_tree_to_entrypoints_internal( add_app_page( app_dir, &mut result, - app_page.clone(), + app_page.clone().complete(PageType::Page)?, if current_level_is_parallel_route { LoaderTree { + page: app_page.clone(), segment: "__DEFAULT__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -695,24 +777,29 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell() } else { LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { - "children".to_string() => LoaderTree { - segment: "__DEFAULT__".to_string(), - parallel_routes: IndexMap::new(), - components: Components { - default: Some(default), - ..Default::default() - } - .cell(), + "children".to_string() => LoaderTree { + page: app_page.clone(), + segment: "__DEFAULT__".to_string(), + parallel_routes: IndexMap::new(), + components: Components { + default: Some(default), + ..Default::default() } .cell(), + global_metadata, + } + .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell() }, @@ -721,18 +808,67 @@ async fn directory_tree_to_entrypoints_internal( } if let Some(route) = components.route { - add_app_route(app_dir, &mut result, app_page.clone(), route); + add_app_route( + app_dir, + &mut result, + app_page.clone().complete(PageType::Route)?, + route, + ); + } + + let Metadata { + icon, + apple, + twitter, + open_graph, + sitemap, + } = &components.metadata; + + for meta in sitemap + .iter() + .copied() + .chain(icon.iter().copied().map(MetadataItem::from)) + .chain(apple.iter().copied().map(MetadataItem::from)) + .chain(twitter.iter().copied().map(MetadataItem::from)) + .chain(open_graph.iter().copied().map(MetadataItem::from)) + { + let app_page = app_page.clone_push_str(&get_metadata_route_name(meta).await?)?; + + add_app_metadata_route( + app_dir, + &mut result, + normalize_metadata_route(app_page)?, + meta, + ); } // root path: / - if app_page.len() == 0 { + if app_page.is_root() { + let GlobalMetadata { + favicon, + robots, + manifest, + } = &*global_metadata.await?; + + for meta in favicon.iter().chain(robots.iter()).chain(manifest.iter()) { + let app_page = app_page.clone_push_str(&get_metadata_route_name(*meta).await?)?; + + add_app_metadata_route( + app_dir, + &mut result, + normalize_metadata_route(app_page)?, + *meta, + ); + } // Next.js has this logic in "collect-app-paths", where the root not-found page // is considered as its own entry point. if let Some(_not_found) = components.not_found { let dev_not_found_tree = LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { "children".to_string() => LoaderTree { + page: app_page.clone(), segment: "__DEFAULT__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -740,10 +876,12 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell(); @@ -759,9 +897,11 @@ async fn directory_tree_to_entrypoints_internal( // Create default not-found page for production if there's no customized // not-found let prod_not_found_tree = LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { "children".to_string() => LoaderTree { + page: app_page.clone(), segment: "__PAGE__".to_string(), parallel_routes: IndexMap::new(), components: Components { @@ -769,10 +909,12 @@ async fn directory_tree_to_entrypoints_internal( ..Default::default() } .cell(), + global_metadata, } .cell(), }, components: components.without_leafs().cell(), + global_metadata, } .cell(); @@ -791,9 +933,10 @@ async fn directory_tree_to_entrypoints_internal( let map = directory_tree_to_entrypoints_internal( app_dir, + global_metadata, subdir_name.to_string(), subdirectory, - app_page, + app_page.clone(), ) .await?; @@ -808,11 +951,13 @@ async fn directory_tree_to_entrypoints_internal( } else { let key = parallel_route_key.unwrap_or("children").to_string(); let child_loader_tree = LoaderTree { + page: app_page.clone(), segment: directory_name.to_string(), parallel_routes: indexmap! { key => loader_tree, }, components: components.without_leafs().cell(), + global_metadata, } .cell(); add_app_page(app_dir, &mut result, page.clone(), child_loader_tree).await?; @@ -821,6 +966,9 @@ async fn directory_tree_to_entrypoints_internal( Entrypoint::AppRoute { ref page, path } => { add_app_route(app_dir, &mut result, page.clone(), path); } + Entrypoint::AppMetadata { ref page, metadata } => { + add_app_metadata_route(app_dir, &mut result, page.clone(), metadata); + } } } } @@ -845,23 +993,29 @@ pub async fn get_global_metadata( let mut metadata = GlobalMetadata::default(); for (basename, entry) in entries { - if let DirectoryEntry::File(file) = *entry { - if let Some((stem, ext)) = basename.split_once('.') { - let list = match stem { - "favicon" => Some(&mut metadata.favicon), - "sitemap" => Some(&mut metadata.sitemap), - "robots" => Some(&mut metadata.robots), - _ => None, - }; - if let Some(list) = list { - if page_extensions.await?.iter().any(|e| e == ext) { - *list = Some(MetadataItem::Dynamic { path: file }); - } - if STATIC_GLOBAL_METADATA.get(stem).unwrap().contains(&ext) { - *list = Some(MetadataItem::Static { path: file }); - } - } - } + let DirectoryEntry::File(file) = *entry else { + continue; + }; + + let Some(GlobalMetadataFileMatch { + metadata_type, + dynamic, + }) = match_global_metadata_file(basename, &page_extensions.await?) + else { + continue; + }; + + let entry = match metadata_type { + "favicon" => &mut metadata.favicon, + "manifest" => &mut metadata.manifest, + "robots" => &mut metadata.robots, + _ => continue, + }; + + if dynamic { + *entry = Some(MetadataItem::Dynamic { path: file }); + } else { + *entry = Some(MetadataItem::Static { path: file }); } // TODO(WEB-952) handle symlinks in app dir } diff --git a/packages/next-swc/crates/next-core/src/loader_tree.rs b/packages/next-swc/crates/next-core/src/loader_tree.rs index 2b4ce0f65b3ed..fc10077995bcf 100644 --- a/packages/next-swc/crates/next-core/src/loader_tree.rs +++ b/packages/next-swc/crates/next-core/src/loader_tree.rs @@ -1,3 +1,5 @@ +use std::fmt::Write; + use anyhow::Result; use async_recursion::async_recursion; use indexmap::IndexMap; @@ -12,13 +14,16 @@ use turbopack_binding::turbopack::{ reference_type::{EcmaScriptModulesReferenceSubType, InnerAssets, ReferenceType}, }, ecmascript::{magic_identifier, text::TextContentFileSource, utils::StringifyJs}, - r#static::StaticModuleAsset, turbopack::{transition::Transition, ModuleAssetContext}, }; use crate::{ - app_structure::{Components, LoaderTree, Metadata, MetadataItem, MetadataWithAltItem}, + app_structure::{ + get_metadata_route_name, Components, GlobalMetadata, LoaderTree, Metadata, MetadataItem, + MetadataWithAltItem, + }, mode::NextMode, + next_app::{metadata::image::dynamic_image_metadata_source, AppPage}, next_image::module::{BlurPlaceholderMode, StructuredImageModuleType}, }; @@ -28,7 +33,6 @@ pub struct LoaderTreeBuilder { imports: Vec, loader_tree_code: String, context: Vc, - unsupported_metadata: Vec>, mode: NextMode, server_component_transition: ServerComponentTransition, pages: Vec>, @@ -77,7 +81,6 @@ impl LoaderTreeBuilder { imports: Vec::new(), loader_tree_code: String::new(), context, - unsupported_metadata: Vec::new(), server_component_transition, mode, pages: Vec::new(), @@ -95,8 +98,6 @@ impl LoaderTreeBuilder { ty: ComponentType, component: Option>, ) -> Result<()> { - use std::fmt::Write; - if let Some(component) = component { if matches!(ty, ComponentType::Page) { self.pages.push(component); @@ -164,7 +165,12 @@ impl LoaderTreeBuilder { Ok(()) } - fn write_metadata(&mut self, metadata: &Metadata) -> Result<()> { + async fn write_metadata( + &mut self, + app_page: &AppPage, + metadata: &Metadata, + global_metadata: &GlobalMetadata, + ) -> Result<()> { if metadata.is_empty() { return Ok(()); } @@ -173,129 +179,189 @@ impl LoaderTreeBuilder { apple, twitter, open_graph, - favicon, - manifest, + sitemap: _, } = metadata; + let GlobalMetadata { + favicon: _, + manifest, + robots: _, + } = global_metadata; + self.loader_tree_code += " metadata: {"; - self.write_metadata_items("icon", favicon.iter().chain(icon.iter()))?; - self.write_metadata_items("apple", apple.iter())?; - self.write_metadata_items("twitter", twitter.iter())?; - self.write_metadata_items("openGraph", open_graph.iter())?; - self.write_metadata_manifest(*manifest)?; + self.write_metadata_items(app_page, "icon", icon.iter()) + .await?; + self.write_metadata_items(app_page, "apple", apple.iter()) + .await?; + self.write_metadata_items(app_page, "twitter", twitter.iter()) + .await?; + self.write_metadata_items(app_page, "openGraph", open_graph.iter()) + .await?; + self.write_metadata_manifest(*manifest).await?; self.loader_tree_code += " },"; Ok(()) } - fn write_metadata_manifest(&mut self, manifest: Option) -> Result<()> { + async fn write_metadata_manifest(&mut self, manifest: Option) -> Result<()> { let Some(manifest) = manifest else { return Ok(()); }; - match manifest { - MetadataItem::Static { path } => { - use std::fmt::Write; - let i = self.unique_number(); - let identifier = magic_identifier::mangle(&format!("manifest #{i}")); - let inner_module_id = format!("METADATA_{i}"); - self.imports - .push(format!("import {identifier} from \"{inner_module_id}\";")); - self.inner_assets.insert( - inner_module_id, - Vc::upcast(StaticModuleAsset::new( - Vc::upcast(FileSource::new(path)), - Vc::upcast(self.context), - )), - ); - writeln!(self.loader_tree_code, " manifest: {identifier},")?; - } - MetadataItem::Dynamic { path } => { - self.unsupported_metadata.push(path); - } - } + + let manifest_route = &format!("/{}", get_metadata_route_name(manifest).await?); + writeln!( + self.loader_tree_code, + " manifest: {},", + StringifyJs(manifest_route) + )?; Ok(()) } - fn write_metadata_items<'a>( + async fn write_metadata_items<'a>( &mut self, + app_page: &AppPage, name: &str, it: impl Iterator, ) -> Result<()> { - use std::fmt::Write; let mut it = it.peekable(); if it.peek().is_none() { return Ok(()); } writeln!(self.loader_tree_code, " {name}: [")?; for item in it { - self.write_metadata_item(name, item)?; + self.write_metadata_item(app_page, name, item).await?; } writeln!(self.loader_tree_code, " ],")?; Ok(()) } - fn write_metadata_item(&mut self, name: &str, item: &MetadataWithAltItem) -> Result<()> { - use std::fmt::Write; - let i = self.unique_number(); - let identifier = magic_identifier::mangle(&format!("{name} #{i}")); - let inner_module_id = format!("METADATA_{i}"); - self.imports - .push(format!("import {identifier} from \"{inner_module_id}\";")); - let s = " "; + async fn write_metadata_item( + &mut self, + app_page: &AppPage, + name: &str, + item: &MetadataWithAltItem, + ) -> Result<()> { match item { MetadataWithAltItem::Static { path, alt_path } => { + self.write_static_metadata_item(app_page, name, item, *path, *alt_path) + .await?; + } + MetadataWithAltItem::Dynamic { path, .. } => { + let i = self.unique_number(); + let identifier = magic_identifier::mangle(&format!("{name} #{i}")); + let inner_module_id = format!("METADATA_{i}"); + + self.imports + .push(format!("import {identifier} from \"{inner_module_id}\";")); + + let source = dynamic_image_metadata_source( + Vc::upcast(self.context), + *path, + name.to_string(), + app_page.clone(), + ); + self.inner_assets.insert( inner_module_id, - Vc::upcast(StructuredImageModuleType::create_module( - Vc::upcast(FileSource::new(*path)), - BlurPlaceholderMode::None, - self.context, - )), + self.context.process( + source, + Value::new(ReferenceType::EcmaScriptModules( + EcmaScriptModulesReferenceSubType::Undefined, + )), + ), ); - writeln!(self.loader_tree_code, "{s}(async (props) => [{{")?; - writeln!(self.loader_tree_code, "{s} url: {identifier}.src,")?; - let numeric_sizes = name == "twitter" || name == "openGraph"; - if numeric_sizes { - writeln!(self.loader_tree_code, "{s} width: {identifier}.width,")?; - writeln!(self.loader_tree_code, "{s} height: {identifier}.height,")?; - } else { - writeln!( - self.loader_tree_code, - "{s} sizes: `${{{identifier}.width}}x${{{identifier}.height}}`," - )?; - } - if let Some(alt_path) = alt_path { - let identifier = magic_identifier::mangle(&format!("{name} alt text #{i}")); - let inner_module_id = format!("METADATA_ALT_{i}"); - self.imports - .push(format!("import {identifier} from \"{inner_module_id}\";")); - self.inner_assets.insert( - inner_module_id, - self.context.process( - Vc::upcast(TextContentFileSource::new(Vc::upcast(FileSource::new( - *alt_path, - )))), - Value::new(ReferenceType::Internal(InnerAssets::empty())), - ), - ); - writeln!(self.loader_tree_code, "{s} alt: {identifier},")?; - } - writeln!(self.loader_tree_code, "{s}}}]),")?; - } - MetadataWithAltItem::Dynamic { path, .. } => { - self.unsupported_metadata.push(*path); + + let s = " "; + writeln!(self.loader_tree_code, "{s}{identifier},")?; } } Ok(()) } + async fn write_static_metadata_item( + &mut self, + app_page: &AppPage, + name: &str, + item: &MetadataWithAltItem, + path: Vc, + alt_path: Option>, + ) -> Result<()> { + let i = self.unique_number(); + let identifier = magic_identifier::mangle(&format!("{name} #{i}")); + let inner_module_id = format!("METADATA_{i}"); + let helper_import = "import { fillMetadataSegment } from \ + \"next/dist/lib/metadata/get-metadata-route\"" + .to_string(); + if !self.imports.contains(&helper_import) { + self.imports.push(helper_import); + } + + self.imports + .push(format!("import {identifier} from \"{inner_module_id}\";")); + self.inner_assets.insert( + inner_module_id, + Vc::upcast(StructuredImageModuleType::create_module( + Vc::upcast(FileSource::new(path)), + BlurPlaceholderMode::None, + self.context, + )), + ); + + let s = " "; + writeln!(self.loader_tree_code, "{s}(async (props) => [{{")?; + + let metadata_route = &*get_metadata_route_name((*item).into()).await?; + writeln!( + self.loader_tree_code, + "{s} url: fillMetadataSegment({}, props.params, {}) + \ + `?${{{identifier}.src.split(\"/\").splice(-1)[0]}}`,", + StringifyJs(&app_page.to_string()), + StringifyJs(metadata_route), + )?; + + let numeric_sizes = name == "twitter" || name == "openGraph"; + if numeric_sizes { + writeln!(self.loader_tree_code, "{s} width: {identifier}.width,")?; + writeln!(self.loader_tree_code, "{s} height: {identifier}.height,")?; + } else { + writeln!( + self.loader_tree_code, + "{s} sizes: `${{{identifier}.width}}x${{{identifier}.height}}`," + )?; + } + + if let Some(alt_path) = alt_path { + let identifier = magic_identifier::mangle(&format!("{name} alt text #{i}")); + let inner_module_id = format!("METADATA_ALT_{i}"); + self.imports + .push(format!("import {identifier} from \"{inner_module_id}\";")); + self.inner_assets.insert( + inner_module_id, + self.context.process( + Vc::upcast(TextContentFileSource::new(Vc::upcast(FileSource::new( + alt_path, + )))), + Value::new(ReferenceType::Internal(InnerAssets::empty())), + ), + ); + + writeln!(self.loader_tree_code, "{s} alt: {identifier},")?; + } + + writeln!(self.loader_tree_code, "{s}}}]),")?; + + Ok(()) + } + #[async_recursion] async fn walk_tree(&mut self, loader_tree: Vc) -> Result<()> { use std::fmt::Write; let LoaderTree { + page: app_page, segment, parallel_routes, components, + global_metadata, } = &*loader_tree.await?; writeln!( @@ -333,7 +399,8 @@ impl LoaderTreeBuilder { .await?; self.write_component(ComponentType::NotFound, *not_found) .await?; - self.write_metadata(metadata)?; + self.write_metadata(app_page, metadata, &*global_metadata.await?) + .await?; write!(self.loader_tree_code, "}}]")?; Ok(()) } @@ -344,7 +411,6 @@ impl LoaderTreeBuilder { imports: self.imports, loader_tree_code: self.loader_tree_code, inner_assets: self.inner_assets, - unsupported_metadata: self.unsupported_metadata, pages: self.pages, }) } @@ -354,7 +420,6 @@ pub struct LoaderTreeModule { pub imports: Vec, pub loader_tree_code: String, pub inner_assets: IndexMap>>, - pub unsupported_metadata: Vec>, pub pages: Vec>, } diff --git a/packages/next-swc/crates/next-core/src/mode.rs b/packages/next-swc/crates/next-core/src/mode.rs index ab54af0391f05..db3c94a6c5542 100644 --- a/packages/next-swc/crates/next-core/src/mode.rs +++ b/packages/next-swc/crates/next-core/src/mode.rs @@ -1,22 +1,8 @@ -use serde::{Deserialize, Serialize}; -use turbo_tasks::{debug::ValueDebugFormat, trace::TraceRawVcs, TaskInput}; +use turbo_tasks::TaskInput; /// The mode in which Next.js is running. -#[derive( - Debug, - Copy, - Clone, - TaskInput, - Eq, - PartialEq, - Ord, - PartialOrd, - Hash, - Serialize, - Deserialize, - TraceRawVcs, - ValueDebugFormat, -)] +#[turbo_tasks::value(shared)] +#[derive(Debug, Copy, Clone, TaskInput, Ord, PartialOrd, Hash)] pub enum NextMode { /// `next dev --turbo` DevServer, diff --git a/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs b/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs index fd99d25664678..e41c15bd1169e 100644 --- a/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs +++ b/packages/next-swc/crates/next-core/src/next_app/app_page_entry.rs @@ -6,8 +6,8 @@ use turbopack_binding::{ turbo::tasks_fs::{rope::RopeBuilder, File, FileSystemPath}, turbopack::{ core::{ - asset::AssetContent, context::AssetContext, issue::IssueExt, - reference_type::ReferenceType, virtual_source::VirtualSource, + asset::AssetContent, context::AssetContext, reference_type::ReferenceType, + virtual_source::VirtualSource, }, ecmascript::{chunk::EcmascriptChunkPlaceable, utils::StringifyJs}, turbopack::ModuleAssetContext, @@ -19,7 +19,7 @@ use crate::{ app_structure::LoaderTree, loader_tree::{LoaderTreeModule, ServerComponentTransition}, mode::NextMode, - next_app::{AppPage, AppPath, UnsupportedDynamicMetadataIssue}, + next_app::{AppPage, AppPath}, next_server_component::NextServerComponentTransition, parse_segment_config_from_loader_tree, util::{load_next_js_template, virtual_next_js_template_path, NextRuntime}, @@ -31,7 +31,6 @@ pub async fn get_app_page_entry( nodejs_context: Vc, edge_context: Vc, loader_tree: Vc, - app_dir: Vc, page: AppPage, project_root: Vc, ) -> Result> { @@ -57,19 +56,9 @@ pub async fn get_app_page_entry( inner_assets, imports, loader_tree_code, - unsupported_metadata, pages, } = loader_tree; - if !unsupported_metadata.is_empty() { - UnsupportedDynamicMetadataIssue { - app_dir, - files: unsupported_metadata, - } - .cell() - .emit(); - } - let mut result = RopeBuilder::default(); for import in imports { @@ -81,8 +70,6 @@ pub async fn get_app_page_entry( let original_name = page.to_string(); let pathname = AppPath::from(page.clone()).to_string(); - let original_page_name = get_original_page_name(&original_name); - let template_file = "build/templates/app-page.js"; // Load the file from the next.js codebase. @@ -100,7 +87,7 @@ pub async fn get_app_page_entry( ) .replace( "\"VAR_ORIGINAL_PATHNAME\"", - &StringifyJs(&original_page_name).to_string(), + &StringifyJs(&original_name).to_string(), ) // TODO(alexkirsz) Support custom global error. .replace( @@ -154,20 +141,9 @@ pub async fn get_app_page_entry( Ok(AppEntry { pathname: pathname.to_string(), - original_name: original_page_name, + original_name, rsc_entry, config, } .cell()) } - -// TODO(alexkirsz) This shouldn't be necessary. The loader tree should keep -// track of this instead. -fn get_original_page_name(pathname: &str) -> String { - match pathname { - "/" => "/page".to_string(), - "/_not-found" => "/_not-found".to_string(), - "/not-found" => "/not-found".to_string(), - _ => format!("{}/page", pathname), - } -} diff --git a/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs b/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs index e60c963e0ece0..838d92dc90036 100644 --- a/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs +++ b/packages/next-swc/crates/next-core/src/next_app/app_route_entry.rs @@ -54,7 +54,6 @@ pub async fn get_app_route_entry( let original_name = page.to_string(); let pathname = AppPath::from(page.clone()).to_string(); - let original_page_name = get_original_route_name(&original_name); let path = source.ident().path(); let template_file = "build/templates/app-route.js"; @@ -83,7 +82,7 @@ pub async fn get_app_route_entry( ) .replace( "\"VAR_ORIGINAL_PATHNAME\"", - &StringifyJs(&original_page_name).to_string(), + &StringifyJs(&original_name).to_string(), ) .replace( "\"VAR_RESOLVED_PAGE_PATH\"", @@ -132,8 +131,8 @@ pub async fn get_app_route_entry( }; Ok(AppEntry { - pathname: pathname.to_string(), - original_name: original_page_name, + pathname, + original_name, rsc_entry, config, } @@ -177,10 +176,3 @@ pub async fn wrap_edge_entry( Value::new(ReferenceType::Internal(Vc::cell(inner_assets))), )) } - -fn get_original_route_name(pathname: &str) -> String { - match pathname { - "/" => "/route".to_string(), - _ => format!("{}/route", pathname), - } -} diff --git a/packages/next-swc/crates/next-core/src/next_app/metadata/image.rs b/packages/next-swc/crates/next-core/src/next_app/metadata/image.rs new file mode 100644 index 0000000000000..68e8a7c6677dd --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_app/metadata/image.rs @@ -0,0 +1,148 @@ +//! (partial) Rust port of the `next-metadata-image-loader` +//! +//! See `next/src/build/webpack/loaders/next-metadata-image-loader` + +use anyhow::{bail, Result}; +use indoc::formatdoc; +use turbo_tasks::{ValueToString, Vc}; +use turbo_tasks_fs::{File, FileContent, FileSystemPath}; +use turbopack_binding::{ + turbo::tasks_hash::hash_xxh3_hash64, + turbopack::{ + core::{ + asset::AssetContent, + context::AssetContext, + file_source::FileSource, + module::Module, + reference_type::{EcmaScriptModulesReferenceSubType, ReferenceType}, + source::Source, + virtual_source::VirtualSource, + }, + ecmascript::{ + chunk::{EcmascriptChunkPlaceable, EcmascriptExports}, + utils::StringifyJs, + EcmascriptModuleAsset, + }, + }, +}; + +use crate::next_app::AppPage; + +async fn hash_file_content(path: Vc) -> Result { + let original_file_content = path.read().await?; + + Ok(match &*original_file_content { + FileContent::Content(content) => { + let content = content.content().to_bytes()?; + hash_xxh3_hash64(&*content) + } + FileContent::NotFound => { + bail!("metadata file not found: {}", &path.to_string().await?); + } + }) +} + +#[turbo_tasks::function] +pub async fn dynamic_image_metadata_source( + asset_context: Vc>, + path: Vc, + ty: String, + page: AppPage, +) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let hash_query = format!("?{:x}", hash_file_content(path).await?); + + let use_numeric_sizes = ty == "twitter" || ty == "openGraph"; + let sizes = if use_numeric_sizes { + "data.width = size.width; data.height = size.height;" + } else { + "data.sizes = size.width + \"x\" + size.height;" + }; + + let source = Vc::upcast(FileSource::new(path)); + let exports = &*collect_direct_exports(asset_context.process( + source, + turbo_tasks::Value::new(ReferenceType::EcmaScriptModules( + EcmaScriptModulesReferenceSubType::Undefined, + )), + )) + .await?; + let exported_fields_excluding_default = exports + .iter() + .filter(|e| *e != "default") + .cloned() + .collect::>() + .join(", "); + + let code = formatdoc! { + r#" + import {{ {exported_fields_excluding_default} }} from {resource_path} + import {{ fillMetadataSegment }} from 'next/dist/lib/metadata/get-metadata-route' + + const imageModule = {{ {exported_fields_excluding_default} }} + + export default async function (props) {{ + const {{ __metadata_id__: _, ...params }} = props.params + const imageUrl = fillMetadataSegment({pathname_prefix}, params, {page_segment}) + + const {{ generateImageMetadata }} = imageModule + + function getImageMetadata(imageMetadata, idParam) {{ + const data = {{ + alt: imageMetadata.alt, + type: imageMetadata.contentType || 'image/png', + url: imageUrl + (idParam ? ('/' + idParam) : '') + {hash_query}, + }} + const {{ size }} = imageMetadata + if (size) {{ + {sizes} + }} + return data + }} + + if (generateImageMetadata) {{ + const imageMetadataArray = await generateImageMetadata({{ params }}) + return imageMetadataArray.map((imageMetadata, index) => {{ + const idParam = (imageMetadata.id || index) + '' + return getImageMetadata(imageMetadata, idParam) + }}) + }} else {{ + return [getImageMetadata(imageModule, '')] + }} + }} + "#, + exported_fields_excluding_default = exported_fields_excluding_default, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + pathname_prefix = StringifyJs(&page.to_string()), + page_segment = StringifyJs(stem), + sizes = sizes, + hash_query = StringifyJs(&hash_query), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--metadata.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn collect_direct_exports(module: Vc>) -> Result>> { + let Some(ecmascript_asset) = + Vc::try_resolve_downcast_type::(module).await? + else { + return Ok(Default::default()); + }; + + if let EcmascriptExports::EsmExports(exports) = &*ecmascript_asset.get_exports().await? { + let exports = &*exports.await?; + return Ok(Vc::cell(exports.exports.keys().cloned().collect())); + } + + Ok(Vc::cell(Vec::new())) +} diff --git a/packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs b/packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs new file mode 100644 index 0000000000000..e0c86c172f543 --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_app/metadata/mod.rs @@ -0,0 +1,341 @@ +use std::{collections::HashMap, ops::Deref}; + +use anyhow::Result; +use once_cell::sync::Lazy; + +use crate::next_app::{AppPage, PageSegment, PageType}; + +pub mod image; +pub mod route; + +pub static STATIC_LOCAL_METADATA: Lazy> = + Lazy::new(|| { + HashMap::from([ + ( + "icon", + &["ico", "jpg", "jpeg", "png", "svg"] as &'static [&'static str], + ), + ("apple-icon", &["jpg", "jpeg", "png"]), + ("opengraph-image", &["jpg", "jpeg", "png", "gif"]), + ("twitter-image", &["jpg", "jpeg", "png", "gif"]), + ("sitemap", &["xml"]), + ]) + }); + +pub static STATIC_GLOBAL_METADATA: Lazy> = + Lazy::new(|| { + HashMap::from([ + ("favicon", &["ico"] as &'static [&'static str]), + ("manifest", &["webmanifest", "json"]), + ("robots", &["txt"]), + ]) + }); + +pub struct MetadataFileMatch<'a> { + pub metadata_type: &'a str, + pub number: Option, + pub dynamic: bool, +} + +fn match_numbered_metadata(stem: &str) -> Option<(&str, &str)> { + let (_whole, stem, number) = lazy_regex::regex_captures!( + "^(icon|apple-icon|opengraph-image|twitter-image)(\\d+)$", + stem + )?; + + Some((stem, number)) +} + +fn match_metadata_file<'a>( + filename: &'a str, + page_extensions: &[String], + metadata: &HashMap<&str, &[&str]>, +) -> Option> { + let (stem, ext) = filename.split_once('.')?; + + let (stem, number) = match match_numbered_metadata(stem) { + Some((stem, number)) => { + let number: u32 = number.parse().ok()?; + (stem, Some(number)) + } + _ => (stem, None), + }; + + let exts = metadata.get(stem)?; + + // favicon can't be dynamic + if stem != "favicon" && page_extensions.iter().any(|e| e == ext) { + return Some(MetadataFileMatch { + metadata_type: stem, + number, + dynamic: true, + }); + } + + exts.contains(&ext).then_some(MetadataFileMatch { + metadata_type: stem, + number, + dynamic: false, + }) +} + +pub fn match_local_metadata_file<'a>( + basename: &'a str, + page_extensions: &[String], +) -> Option> { + match_metadata_file(basename, page_extensions, STATIC_LOCAL_METADATA.deref()) +} + +pub struct GlobalMetadataFileMatch<'a> { + pub metadata_type: &'a str, + pub dynamic: bool, +} + +pub fn match_global_metadata_file<'a>( + basename: &'a str, + page_extensions: &[String], +) -> Option> { + match_metadata_file(basename, page_extensions, STATIC_GLOBAL_METADATA.deref()).map(|m| { + GlobalMetadataFileMatch { + metadata_type: m.metadata_type, + dynamic: m.dynamic, + } + }) +} + +fn split_directory(path: &str) -> (Option<&str>, &str) { + if let Some((dir, basename)) = path.rsplit_once('/') { + if dir.is_empty() { + return (Some("/"), basename); + } + + (Some(dir), basename) + } else { + (None, path) + } +} + +fn filename(path: &str) -> &str { + split_directory(path).1 +} + +fn split_extension(path: &str) -> (&str, Option<&str>) { + let filename = filename(path); + if let Some((filename_before_extension, ext)) = filename.rsplit_once('.') { + if filename_before_extension.is_empty() { + return (filename, None); + } + + (filename_before_extension, Some(ext)) + } else { + (filename, None) + } +} + +fn file_stem(path: &str) -> &str { + split_extension(path).0 +} + +/// When you only pass the file extension as `[]`, it will only match the static +/// convention files e.g. `/robots.txt`, `/sitemap.xml`, `/favicon.ico`, +/// `/manifest.json`. +/// +/// When you pass the file extension as `['js', 'jsx', 'ts', +/// 'tsx']`, it will also match the dynamic convention files e.g. /robots.js, +/// /sitemap.tsx, /favicon.jsx, /manifest.ts. +/// +/// When `withExtension` is false, it will match the static convention files +/// without the extension, by default it's true e.g. /robots, /sitemap, +/// /favicon, /manifest, use to match dynamic API routes like app/robots.ts. +pub fn is_metadata_route_file( + app_dir_relative_path: &str, + page_extensions: &[String], + with_extension: bool, +) -> bool { + let (dir, filename) = split_directory(app_dir_relative_path); + + if with_extension { + if match_local_metadata_file(filename, page_extensions).is_some() { + return true; + } + } else { + let stem = file_stem(filename); + let stem = match_numbered_metadata(stem) + .map(|(stem, _)| stem) + .unwrap_or(stem); + + if STATIC_LOCAL_METADATA.contains_key(stem) { + return true; + } + } + + if dir != Some("/") { + return false; + } + + if with_extension { + if match_global_metadata_file(filename, page_extensions).is_some() { + return true; + } + } else { + let base_name = file_stem(filename); + if STATIC_GLOBAL_METADATA.contains_key(base_name) { + return true; + } + } + + false +} + +pub fn is_static_metadata_route_file(app_dir_relative_path: &str) -> bool { + is_metadata_route_file(app_dir_relative_path, &[], true) +} + +/// Remove the 'app' prefix or '/route' suffix, only check the route name since +/// they're only allowed in root app directory +/// +/// e.g. +/// - /app/robots -> /robots +/// - app/robots -> /robots +/// - /robots -> /robots +pub fn is_metadata_route(mut route: &str) -> bool { + if let Some(stripped) = route.strip_prefix("/app/") { + route = stripped; + } else if let Some(stripped) = route.strip_prefix("app/") { + route = stripped; + } + + if let Some(stripped) = route.strip_suffix("/route") { + route = stripped; + } + + let mut page = route.to_string(); + if !page.starts_with('/') { + page = format!("/{}", page); + } + + !page.ends_with("/page") && is_metadata_route_file(&page, &[], false) +} + +/// http://www.cse.yorku.ca/~oz/hash.html +fn djb2_hash(str: &str) -> u32 { + str.chars().fold(5381, |hash, c| { + ((hash << 5).wrapping_add(hash)).wrapping_add(c as u32) // hash * 33 + c + }) +} + +// this is here to mirror next.js behaviour. +fn format_radix(mut x: u32, radix: u32) -> String { + let mut result = vec![]; + + loop { + let m = x % radix; + x /= radix; + + // will panic if you use a bad radix (< 2 or > 36). + result.push(std::char::from_digit(m, radix).unwrap()); + if x == 0 { + break; + } + } + + result.into_iter().rev().collect() +} + +/// If there's special convention like (...) or @ in the page path, +/// Give it a unique hash suffix to avoid conflicts +/// +/// e.g. +/// /app/open-graph.tsx -> /open-graph/route +/// /app/(post)/open-graph.tsx -> /open-graph/route-[0-9a-z]{6} +fn get_metadata_route_suffix(page: &str) -> Option { + if (page.contains('(') && page.contains(')')) || page.contains('@') { + Some(format_radix(djb2_hash(page), 36)) + } else { + None + } +} + +/// Map metadata page key to the corresponding route +/// +/// static file page key: /app/robots.txt -> /robots.txt -> /robots.txt/route +/// dynamic route page key: /app/robots.tsx -> /robots -> /robots.txt/route +pub fn normalize_metadata_route(mut page: AppPage) -> Result { + if !is_metadata_route(&format!("{page}")) { + return Ok(page); + } + + let mut route = page.to_string(); + let mut suffix: Option = None; + if route == "/robots" { + route += ".txt" + } else if route == "/manifest" { + route += ".webmanifest" + } else if route.ends_with("/sitemap") { + route += ".xml" + } else { + // Remove the file extension, e.g. /route-path/robots.txt -> /route-path + let pathname_prefix = split_directory(&route).0.unwrap_or_default(); + suffix = get_metadata_route_suffix(pathname_prefix); + } + + // Support both / and custom routes + // //route.ts. If it's a metadata file route, we need to + // append /[id]/route to the page. + if !route.ends_with("/route") { + let is_static_metadata_file = is_static_metadata_route_file(&route); + let (base_name, ext) = split_extension(&route); + + let is_static_route = route.starts_with("/robots") + || route.starts_with("/manifest") + || is_static_metadata_file; + + page.0.pop(); + + page.push(PageSegment::Static(format!( + "{}{}{}", + base_name, + suffix + .map(|suffix| format!("-{suffix}")) + .unwrap_or_default(), + ext.map(|ext| format!(".{ext}")).unwrap_or_default(), + )))?; + + if !is_static_route { + page.push(PageSegment::OptionalCatchAll("__metadata_id__".to_string()))?; + } + + page.push(PageSegment::PageType(PageType::Route))?; + } + + Ok(page) +} + +#[cfg(test)] +mod test { + use super::normalize_metadata_route; + use crate::next_app::AppPage; + + #[test] + fn test_normalize_metadata_route() { + let cases = vec![ + [ + "/client/(meme)/more-route/twitter-image", + "/client/(meme)/more-route/twitter-image-769mad/[[...__metadata_id__]]/route", + ], + [ + "/client/(meme)/more-route/twitter-image2", + "/client/(meme)/more-route/twitter-image2-769mad/[[...__metadata_id__]]/route", + ], + ["/robots.txt", "/robots.txt/route"], + ["/manifest.webmanifest", "/manifest.webmanifest/route"], + ]; + + for [input, expected] in cases { + let page = AppPage::parse(input).unwrap(); + let normalized = normalize_metadata_route(page).unwrap(); + + assert_eq!(&normalized.to_string(), expected); + } + } +} diff --git a/packages/next-swc/crates/next-core/src/next_app/metadata/route.rs b/packages/next-swc/crates/next-core/src/next_app/metadata/route.rs new file mode 100644 index 0000000000000..b3782ad60cbc2 --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_app/metadata/route.rs @@ -0,0 +1,364 @@ +//! Rust port of the `next-metadata-route-loader` +//! +//! See `next/src/build/webpack/loaders/next-metadata-route-loader` + +use anyhow::{bail, Result}; +use base64::{display::Base64Display, engine::general_purpose::STANDARD}; +use indoc::{formatdoc, indoc}; +use turbo_tasks::{ValueToString, Vc}; +use turbopack_binding::{ + turbo::tasks_fs::{File, FileContent, FileSystemPath}, + turbopack::{ + core::{asset::AssetContent, source::Source, virtual_source::VirtualSource}, + ecmascript::utils::StringifyJs, + turbopack::ModuleAssetContext, + }, +}; + +use crate::{ + app_structure::MetadataItem, + mode::NextMode, + next_app::{app_entry::AppEntry, app_route_entry::get_app_route_entry, AppPage, PageSegment}, +}; + +/// Computes the route source for a Next.js metadata file. +#[turbo_tasks::function] +pub async fn get_app_metadata_route_source( + page: AppPage, + mode: NextMode, + metadata: MetadataItem, +) -> Result>> { + Ok(match metadata { + MetadataItem::Static { path } => static_route_source(mode, path), + MetadataItem::Dynamic { path } => { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + + if stem == "robots" || stem == "manifest" { + dynamic_text_route_source(path) + } else if stem == "sitemap" { + dynamic_site_map_route_source(mode, path, page) + } else { + dynamic_image_route_source(path) + } + } + }) +} + +#[turbo_tasks::function] +pub fn get_app_metadata_route_entry( + nodejs_context: Vc, + edge_context: Vc, + project_root: Vc, + page: AppPage, + mode: NextMode, + metadata: MetadataItem, +) -> Vc { + get_app_route_entry( + nodejs_context, + edge_context, + get_app_metadata_route_source(page.clone(), mode, metadata), + page, + project_root, + ) +} + +async fn get_content_type(path: Vc) -> Result { + let stem = &*path.file_stem().await?; + let ext = &*path.extension().await?; + + let name = stem.as_deref().unwrap_or_default(); + let mut ext = ext.as_str(); + if ext == "jpg" { + ext = "jpeg" + } + + if name == "favicon" && ext == "ico" { + return Ok("image/x-icon".to_string()); + } + if name == "sitemap" { + return Ok("application/xml".to_string()); + } + if name == "robots" { + return Ok("text/plain".to_string()); + } + if name == "manifest" { + return Ok("application/manifest+json".to_string()); + } + + if ext == "png" || ext == "jpeg" || ext == "ico" || ext == "svg" { + return Ok(mime_guess::from_ext(ext) + .first_or_octet_stream() + .to_string()); + } + + Ok("text/plain".to_string()) +} + +const CACHE_HEADER_NONE: &str = "no-cache, no-store"; +const CACHE_HEADER_LONG_CACHE: &str = "public, immutable, no-transform, max-age=31536000"; +const CACHE_HEADER_REVALIDATE: &str = "public, max-age=0, must-revalidate"; + +async fn get_base64_file_content(path: Vc) -> Result { + let original_file_content = path.read().await?; + + Ok(match &*original_file_content { + FileContent::Content(content) => { + let content = content.content().to_bytes()?; + Base64Display::new(&content, &STANDARD).to_string() + } + FileContent::NotFound => { + bail!("metadata file not found: {}", &path.to_string().await?); + } + }) +} + +#[turbo_tasks::function] +async fn static_route_source( + mode: NextMode, + path: Vc, +) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + + let content_type = get_content_type(path).await?; + + let cache_control = if stem == "favicon" { + CACHE_HEADER_REVALIDATE + } else if mode == NextMode::Build { + CACHE_HEADER_LONG_CACHE + } else { + CACHE_HEADER_NONE + }; + + let original_file_content_b64 = get_base64_file_content(path).await?; + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + + const contentType = {content_type} + const cacheControl = {cache_control} + const buffer = Buffer.from({original_file_content_b64}, 'base64') + + export function GET() {{ + return new NextResponse(buffer, {{ + headers: {{ + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + }}, + }}) + }} + + export const dynamic = 'force-static' + "#, + content_type = StringifyJs(&content_type), + cache_control = StringifyJs(cache_control), + original_file_content_b64 = StringifyJs(&original_file_content_b64), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn dynamic_text_route_source(path: Vc) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let content_type = get_content_type(path).await?; + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + import handler from {resource_path} + import {{ resolveRouteData }} from +'next/dist/build/webpack/loaders/metadata/resolve-route-data' + + const contentType = {content_type} + const cacheControl = {cache_control} + const fileType = {file_type} + + export async function GET() {{ + const data = await handler() + const content = resolveRouteData(data, fileType) + + return new NextResponse(content, {{ + headers: {{ + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + }}, + }}) + }} + "#, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + content_type = StringifyJs(&content_type), + file_type = StringifyJs(&stem), + cache_control = StringifyJs(CACHE_HEADER_REVALIDATE), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn dynamic_site_map_route_source( + mode: NextMode, + path: Vc, + page: AppPage, +) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let content_type = get_content_type(path).await?; + + let mut static_generation_code = ""; + + if mode == NextMode::Build + && page.contains(&PageSegment::Dynamic("[__metadata_id__]".to_string())) + { + static_generation_code = indoc! { + r#" + export async function generateStaticParams() { + const sitemaps = await generateSitemaps() + const params = [] + + for (const item of sitemaps) { + params.push({ __metadata_id__: item.id.toString() + '.xml' }) + } + return params + } + "#, + }; + } + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + import * as _sitemapModule from {resource_path} + import {{ resolveRouteData }} from 'next/dist/build/webpack/loaders/metadata/resolve-route-data' + + const sitemapModule = {{ ..._sitemapModule }} + const handler = sitemapModule.default + const generateSitemaps = sitemapModule.generateSitemaps + const contentType = {content_type} + const cacheControl = {cache_control} + const fileType = {file_type} + + export async function GET(_, ctx) {{ + const {{ __metadata_id__ = [], ...params }} = ctx.params || {{}} + const targetId = __metadata_id__[0] + let id = undefined + const sitemaps = generateSitemaps ? await generateSitemaps() : null + + if (sitemaps) {{ + id = sitemaps.find((item) => {{ + if (process.env.NODE_ENV !== 'production') {{ + if (item?.id == null) {{ + throw new Error('id property is required for every item returned from generateSitemaps') + }} + }} + return item.id.toString() === targetId + }})?.id + + if (id == null) {{ + return new NextResponse('Not Found', {{ + status: 404, + }}) + }} + }} + + const data = await handler({{ id }}) + const content = resolveRouteData(data, fileType) + + return new NextResponse(content, {{ + headers: {{ + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + }}, + }}) + }} + + {static_generation_code} + "#, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + content_type = StringifyJs(&content_type), + file_type = StringifyJs(&stem), + cache_control = StringifyJs(CACHE_HEADER_REVALIDATE), + static_generation_code = static_generation_code, + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} + +#[turbo_tasks::function] +async fn dynamic_image_route_source(path: Vc) -> Result>> { + let stem = path.file_stem().await?; + let stem = stem.as_deref().unwrap_or_default(); + let ext = &*path.extension().await?; + + let code = formatdoc! { + r#" + import {{ NextResponse }} from 'next/server' + import * as _imageModule from {resource_path} + + const imageModule = {{ ..._imageModule }} + + const handler = imageModule.default + const generateImageMetadata = imageModule.generateImageMetadata + + export async function GET(_, ctx) {{ + const {{ __metadata_id__ = [], ...params }} = ctx.params || {{}} + const targetId = __metadata_id__[0] + let id = undefined + const imageMetadata = generateImageMetadata ? await generateImageMetadata({{ params }}) : null + + if (imageMetadata) {{ + id = imageMetadata.find((item) => {{ + if (process.env.NODE_ENV !== 'production') {{ + if (item?.id == null) {{ + throw new Error('id property is required for every item returned from generateImageMetadata') + }} + }} + return item.id.toString() === targetId + }})?.id + + if (id == null) {{ + return new NextResponse('Not Found', {{ + status: 404, + }}) + }} + }} + + return handler({{ params: ctx.params ? params : undefined, id }}) + }} + "#, + resource_path = StringifyJs(&format!("./{}.{}", stem, ext)), + }; + + let file = File::from(code); + let source = VirtualSource::new( + path.parent().join(format!("{stem}--route-entry.js")), + AssetContent::file(file.into()), + ); + + Ok(Vc::upcast(source)) +} diff --git a/packages/next-swc/crates/next-core/src/next_app/mod.rs b/packages/next-swc/crates/next-core/src/next_app/mod.rs index 885ecdf4cd715..e43e5fa35e80e 100644 --- a/packages/next-swc/crates/next-core/src/next_app/mod.rs +++ b/packages/next-swc/crates/next-core/src/next_app/mod.rs @@ -1,10 +1,9 @@ -pub(crate) mod app_client_references_chunks; -pub(crate) mod app_client_shared_chunks; -pub(crate) mod app_entry; -pub(crate) mod app_favicon_entry; -pub(crate) mod app_page_entry; -pub(crate) mod app_route_entry; -pub(crate) mod unsupported_dynamic_metadata_issue; +pub mod app_client_references_chunks; +pub mod app_client_shared_chunks; +pub mod app_entry; +pub mod app_page_entry; +pub mod app_route_entry; +pub mod metadata; use std::{ fmt::{Display, Formatter, Write}, @@ -21,20 +20,27 @@ pub use crate::next_app::{ }, app_client_shared_chunks::get_app_client_shared_chunks, app_entry::AppEntry, - app_favicon_entry::get_app_route_favicon_entry, app_page_entry::get_app_page_entry, app_route_entry::get_app_route_entry, - unsupported_dynamic_metadata_issue::UnsupportedDynamicMetadataIssue, }; +/// See [AppPage]. #[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq, TaskInput, TraceRawVcs)] pub enum PageSegment { + /// e.g. `/dashboard` Static(String), + /// e.g. `/[id]` Dynamic(String), + /// e.g. `/[...slug]` CatchAll(String), + /// e.g. `/[[...slug]]` OptionalCatchAll(String), + /// e.g. `/(shop)` Group(String), + /// e.g. `/@auth` Parallel(String), + /// The final page type appended. (e.g. `/dashboard/page`, + /// `/api/hello/route`) PageType(PageType), } @@ -151,6 +157,13 @@ impl AppPage { ) } + if self.is_complete() { + bail!( + "Invalid segment {}, this page path already has the final PageType appended", + segment + ) + } + self.0.push(segment); Ok(()) } @@ -184,6 +197,18 @@ impl AppPage { Ok(app_page) } + + pub fn is_root(&self) -> bool { + self.0.is_empty() + } + + pub fn is_complete(&self) -> bool { + matches!(self.0.last(), Some(PageSegment::PageType(..))) + } + + pub fn complete(self, page_type: PageType) -> Result { + self.clone_push(PageSegment::PageType(page_type)) + } } impl Display for AppPage { @@ -209,11 +234,18 @@ impl Deref for AppPage { } } +/// Path segments for a router path (not including parallel routes and groups). +/// +/// Also see [AppPath]. #[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq, TaskInput, TraceRawVcs)] pub enum PathSegment { + /// e.g. `/dashboard` Static(String), + /// e.g. `/[id]` Dynamic(String), + /// e.g. `/[...slug]` CatchAll(String), + /// e.g. `/[[...slug]]` OptionalCatchAll(String), } @@ -240,7 +272,11 @@ impl Display for PathSegment { } } -/// The pathname (including dynamic placeholders) for a route to resolve. +/// The pathname (including dynamic placeholders) for the next.js router to +/// resolve. +/// +/// Does not include internal modifiers as it's the equivalent of the http +/// request path. #[derive( Clone, Debug, Hash, PartialEq, Eq, Default, Serialize, Deserialize, TaskInput, TraceRawVcs, )] diff --git a/packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs b/packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs deleted file mode 100644 index 3c5fc2977c4f7..0000000000000 --- a/packages/next-swc/crates/next-core/src/next_app/unsupported_dynamic_metadata_issue.rs +++ /dev/null @@ -1,54 +0,0 @@ -use anyhow::Result; -use turbo_tasks::{TryJoinIterExt, ValueToString, Vc}; -use turbo_tasks_fs::FileSystemPath; -use turbopack_binding::turbopack::{ - core::issue::{Issue, IssueSeverity}, - ecmascript::utils::FormatIter, -}; - -#[turbo_tasks::value(shared)] -pub struct UnsupportedDynamicMetadataIssue { - pub app_dir: Vc, - pub files: Vec>, -} - -#[turbo_tasks::value_impl] -impl Issue for UnsupportedDynamicMetadataIssue { - #[turbo_tasks::function] - fn severity(&self) -> Vc { - IssueSeverity::Warning.into() - } - - #[turbo_tasks::function] - fn category(&self) -> Vc { - Vc::cell("unsupported".to_string()) - } - - #[turbo_tasks::function] - fn file_path(&self) -> Vc { - self.app_dir - } - - #[turbo_tasks::function] - fn title(&self) -> Vc { - Vc::cell( - "Dynamic metadata from filesystem is currently not supported in Turbopack".to_string(), - ) - } - - #[turbo_tasks::function] - async fn description(&self) -> Result> { - let mut files = self - .files - .iter() - .map(|file| file.to_string()) - .try_join() - .await?; - files.sort(); - Ok(Vc::cell(format!( - "The following files were found in the app directory, but are not supported by \ - Turbopack. They are ignored:\n{}", - FormatIter(|| files.iter().flat_map(|file| vec!["\n- ", file])) - ))) - } -} diff --git a/packages/next-swc/crates/next-core/src/next_import_map.rs b/packages/next-swc/crates/next-core/src/next_import_map.rs index c530f3bcb9290..87ef801b78415 100644 --- a/packages/next-swc/crates/next-core/src/next_import_map.rs +++ b/packages/next-swc/crates/next-core/src/next_import_map.rs @@ -215,7 +215,14 @@ pub async fn get_next_server_import_map( let ty = ty.into_value(); - insert_next_server_special_aliases(&mut import_map, ty, mode, NextRuntime::NodeJs).await?; + insert_next_server_special_aliases( + &mut import_map, + project_path, + ty, + mode, + NextRuntime::NodeJs, + ) + .await?; let external: Vc = ImportMapping::External(None).cell(); import_map.insert_exact_alias("next/dist/server/require-hook", external); @@ -290,7 +297,8 @@ pub async fn get_next_edge_import_map( let ty = ty.into_value(); - insert_next_server_special_aliases(&mut import_map, ty, mode, NextRuntime::Edge).await?; + insert_next_server_special_aliases(&mut import_map, project_path, ty, mode, NextRuntime::Edge) + .await?; match ty { ServerContextType::Pages { .. } | ServerContextType::PagesData { .. } => {} @@ -371,6 +379,7 @@ static NEXT_ALIASES: [(&str, &str); 23] = [ async fn insert_next_server_special_aliases( import_map: &mut ImportMap, + project_path: Vc, ty: ServerContextType, mode: NextMode, runtime: NextRuntime, @@ -553,6 +562,14 @@ async fn insert_next_server_special_aliases( (_, ServerContextType::Middleware) => {} } + import_map.insert_exact_alias( + "@vercel/og", + external_if_node( + project_path, + "next/dist/server/web/spec-extension/image-response", + ), + ); + Ok(()) } diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration.rs b/packages/next-swc/crates/next-dev-tests/tests/integration.rs index d67270678c61e..5c195dc22c016 100644 --- a/packages/next-swc/crates/next-dev-tests/tests/integration.rs +++ b/packages/next-swc/crates/next-dev-tests/tests/integration.rs @@ -552,6 +552,7 @@ async fn get_mock_server_future(mock_dir: &Path) -> Result<(), String> { Some(mock_dir.to_path_buf()), false, 0, + std::future::pending(), ) .await } else { diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx b/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx index 054f136e10f65..1b84fceedc0b9 100644 --- a/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx +++ b/packages/next-swc/crates/next-dev-tests/tests/integration/next/app/implicit-metadata/input/app/test.tsx @@ -17,27 +17,22 @@ export default function Test() { ).toEqual([ expect.objectContaining({ rel: 'manifest', - href: expect.stringMatching(/^\/_next\/static\/.+\.webmanifest$/), + href: expect.stringMatching(/^\/manifest\.webmanifest$/), sizes: null, }), expect.objectContaining({ rel: 'icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.ico$/), - sizes: '48x48', - }), - expect.objectContaining({ - rel: 'icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.png$/), + href: expect.stringMatching(/^\/icon\d+\.png\?.+$/), sizes: '32x32', }), expect.objectContaining({ rel: 'icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.png$/), + href: expect.stringMatching(/^\/icon\d+\.png\?.+$/), sizes: '64x64', }), expect.objectContaining({ rel: 'apple-touch-icon', - href: expect.stringMatching(/^\/_next\/static\/.+\.png$/), + href: expect.stringMatching(/^\/apple-icon\.png\?.+$/), sizes: '114x114', }), ]) @@ -51,7 +46,7 @@ export default function Test() { .map((l) => [l.getAttribute('property'), l.getAttribute('content')]) ) expect(metaObject).toEqual({ - 'og:image': expect.stringMatching(/^.+\/_next\/static\/.+\.png$/), + 'og:image': expect.stringMatching(/^.+\/opengraph-image\.png\?.+$/), 'og:image:width': '114', 'og:image:height': '114', 'og:image:alt': 'This is an alt text.', diff --git a/packages/next/src/server/lib/router-utils/setup-dev.ts b/packages/next/src/server/lib/router-utils/setup-dev.ts index cf17fb2d7d9e6..5af0d92c3a2d8 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev.ts @@ -103,6 +103,7 @@ import { TurboPackConnectedAction, } from '../../dev/hot-reloader-types' import { debounce } from '../../utils' +import { normalizeMetadataRoute } from '../../../lib/metadata/get-metadata-route' const wsServer = new ws.Server({ noServer: true }) @@ -230,7 +231,7 @@ async function startWatcher(opts: SetupOpts) { } function formatIssue(issue: Issue) { - const { filePath, title, description, source } = issue + const { filePath, title, description, source, detail } = issue let formattedTitle = title.replace(/\n/g, '\n ') let message = '' @@ -265,6 +266,9 @@ async function startWatcher(opts: SetupOpts) { if (description) { message += `\n${description.replace(/\n/g, '\n ')}` } + if (detail) { + message += `\n${detail.replace(/\n/g, '\n ')}` + } return message } @@ -659,7 +663,7 @@ async function startWatcher(opts: SetupOpts) { async function writeBuildManifest(): Promise { const buildManifest = mergeBuildManifests(buildManifests.values()) - const buildManifestPath = path.join(distDir, 'build-manifest.json') + const buildManifestPath = path.join(distDir, BUILD_MANIFEST) await clearCache(buildManifestPath) await writeFile( buildManifestPath, @@ -896,6 +900,7 @@ async function startWatcher(opts: SetupOpts) { case 'client-success': // { clientId } case 'server-component-reload-page': // { clientId } case 'client-reload-page': // { clientId } + case 'client-removed-page': // { page } case 'client-full-reload': // { stackTrace, hadRuntimeError } // TODO break @@ -1012,7 +1017,13 @@ async function startWatcher(opts: SetupOpts) { } await currentEntriesHandling - const route = curEntries.get(page) + const route = + curEntries.get(page) ?? + curEntries.get( + normalizeAppPath( + normalizeMetadataRoute(match?.definition?.page ?? inputPage) + ) + ) if (!route) { // TODO: why is this entry missing in turbopack? From 6e5b935fd7a61497f6854a81aec7df3a5dbf61ac Mon Sep 17 00:00:00 2001 From: Jimmy Lai Date: Fri, 8 Sep 2023 21:02:17 +0200 Subject: [PATCH 2/2] server: require hook hotfix (#55146) This PR fixes a bug in the require hook implementation where the variable used might not exist depending on the environment. In order to fix that, we can use the variable used to define the version of React used instead as it is already present and a proxy of the same information. Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com> --- packages/next/src/build/index.ts | 3 +-- packages/next/src/server/lib/router-server.ts | 5 +++++ .../next/src/server/lib/server-ipc/index.ts | 7 +++++-- packages/next/src/server/require-hook.ts | 18 +++++++----------- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index b0e0870ca1392..e7d525373c3f5 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -1248,7 +1248,6 @@ export default async function build( forkOptions: { env: { ...process.env, - __NEXT_PRIVATE_RENDER_RUNTIME: type, __NEXT_INCREMENTAL_CACHE_IPC_PORT: ipcPort + '', __NEXT_INCREMENTAL_CACHE_IPC_KEY: ipcValidationKey, __NEXT_PRIVATE_PREBUNDLED_REACT: @@ -1256,7 +1255,7 @@ export default async function build( ? config.experimental.serverActions ? 'experimental' : 'next' - : '', + : undefined, }, }, enableWorkerThreads: config.experimental.workerThreads, diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 90d23955195c1..9a2df080b2926 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -6,6 +6,11 @@ import type { WorkerUpgradeHandler, } from './setup-server-worker' +// This is required before other imports to ensure the require hook is setup. +import '../node-polyfill-fetch' +import '../node-environment' +import '../require-hook' + import url from 'url' import path from 'path' import loadConfig from '../config' diff --git a/packages/next/src/server/lib/server-ipc/index.ts b/packages/next/src/server/lib/server-ipc/index.ts index 023cbde2d25d8..0dba447ab3cae 100644 --- a/packages/next/src/server/lib/server-ipc/index.ts +++ b/packages/next/src/server/lib/server-ipc/index.ts @@ -115,9 +115,12 @@ export const createWorker = async ( __NEXT_PRIVATE_STANDALONE_CONFIG: process.env.__NEXT_PRIVATE_STANDALONE_CONFIG, NODE_ENV: process.env.NODE_ENV, - __NEXT_PRIVATE_RENDER_RUNTIME: type, __NEXT_PRIVATE_PREBUNDLED_REACT: - type === 'app' ? (useServerActions ? 'experimental' : 'next') : '', + type === 'app' + ? useServerActions + ? 'experimental' + : 'next' + : undefined, ...(process.env.NEXT_CPU_PROF ? { __NEXT_PRIVATE_CPU_PROFILE: `CPU.${type}-renderer` } : {}), diff --git a/packages/next/src/server/require-hook.ts b/packages/next/src/server/require-hook.ts index 9b2e1526eec0e..1f8688d25dcf2 100644 --- a/packages/next/src/server/require-hook.ts +++ b/packages/next/src/server/require-hook.ts @@ -122,19 +122,15 @@ mod._resolveFilename = function ( // that needs to point to the rendering runtime version, it will point to the correct one. // This can happen on `pages` when a user requires a dependency that uses next/image for example. // This is only needed in production as in development we fallback to the external version. -if ( - process.env.NODE_ENV !== 'development' && - process.env.__NEXT_PRIVATE_RENDER_RUNTIME && - !process.env.TURBOPACK -) { - const currentRuntime = `${ - process.env.__NEXT_PRIVATE_RENDER_RUNTIME === 'pages' - ? 'next/dist/compiled/next-server/pages.runtime' - : 'next/dist/compiled/next-server/app-page.runtime' - }.prod` - +if (process.env.NODE_ENV !== 'development' && !process.env.TURBOPACK) { mod.prototype.require = function (request: string) { if (request.endsWith('.shared-runtime')) { + const currentRuntime = `${ + // this env var is only set in app router + !!process.env.__NEXT_PRIVATE_PREBUNDLED_REACT + ? 'next/dist/compiled/next-server/app-page.runtime' + : 'next/dist/compiled/next-server/pages.runtime' + }.prod` const base = path.basename(request, '.shared-runtime') const camelized = base.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) const instance = originalRequire.call(this, currentRuntime)