diff --git a/Cargo.lock b/Cargo.lock index 1f3e243c5c..cbe4f11704 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3359,7 +3359,9 @@ dependencies = [ "anyhow", "futures", "indexmap 1.9.3", + "indoc", "next-core", + "next-swc", "once_cell", "serde", "serde_json", @@ -3411,6 +3413,7 @@ dependencies = [ "lazy_static", "mime", "mime_guess", + "next-swc", "next-transform-dynamic", "next-transform-font", "next-transform-strip-page-exports", diff --git a/Cargo.toml b/Cargo.toml index d753f0989b..5d2d40a9ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ opt-level = 3 next-api = { path = "packages/next-swc/crates/next-api", default-features = false } next-build = { path = "packages/next-swc/crates/next-build", default-features = false } next-core = { path = "packages/next-swc/crates/next-core", default-features = false } +next-swc = { path = "packages/next-swc/crates/core" } next-transform-font = { path = "packages/next-swc/crates/next-transform-font" } next-transform-dynamic = { path = "packages/next-swc/crates/next-transform-dynamic" } next-transform-strip-page-exports = { path = "packages/next-swc/crates/next-transform-strip-page-exports" } diff --git a/packages/next-swc/crates/core/src/server_actions.rs b/packages/next-swc/crates/core/src/server_actions.rs index 0abaa51b8c..b9657a6dac 100644 --- a/packages/next-swc/crates/core/src/server_actions.rs +++ b/packages/next-swc/crates/core/src/server_actions.rs @@ -1,4 +1,7 @@ -use std::convert::{TryFrom, TryInto}; +use std::{ + collections::HashMap, + convert::{TryFrom, TryInto}, +}; use hex::encode as hex_encode; use serde::Deserialize; @@ -25,6 +28,9 @@ pub struct Config { pub enabled: bool, } +/// A mapping of hashed action id to the action's exported function name. +pub type ActionsMap = HashMap; + pub fn server_actions( file_name: &FileName, config: Config, @@ -33,7 +39,7 @@ pub fn server_actions( as_folder(ServerActions { config, comments, - file_name: file_name.clone(), + file_name: file_name.to_string(), start_pos: BytePos(0), in_action_file: false, in_export_decl: false, @@ -55,10 +61,40 @@ pub fn server_actions( }) } +/// Parses the Server Actions comment for all exported action function names. +/// +/// Action names are stored in a leading BlockComment prefixed by +/// `__next_internal_action_entry_do_not_use__`. +pub fn parse_server_actions(program: &Program, comments: C) -> Option { + let byte_pos = match program { + Program::Module(m) => m.span.lo, + Program::Script(s) => s.span.lo, + }; + comments.get_leading(byte_pos).and_then(|comments| { + comments.iter().find_map(|c| { + c.text + .split_once("__next_internal_action_entry_do_not_use__") + .and_then(|(_, actions)| match serde_json::from_str(actions) { + Ok(v) => Some(v), + Err(_) => None, + }) + }) + }) +} + +/// Serializes the Server Actions into a magic comment prefixed by +/// `__next_internal_action_entry_do_not_use__`. +fn generate_server_actions_comment(actions: ActionsMap) -> String { + format!( + " __next_internal_action_entry_do_not_use__ {} ", + serde_json::to_string(&actions).unwrap() + ) +} + struct ServerActions { #[allow(unused)] config: Config, - file_name: FileName, + file_name: String, comments: C, start_pos: BytePos, @@ -216,7 +252,7 @@ impl ServerActions { .cloned() .map(|id| Some(id.as_arg())) .collect(), - self.file_name.to_string(), + &self.file_name, export_name.to_string(), Some(action_ident.clone()), ); @@ -317,7 +353,7 @@ impl ServerActions { .cloned() .map(|id| Some(id.as_arg())) .collect(), - self.file_name.to_string(), + &self.file_name, export_name.to_string(), Some(action_ident.clone()), ); @@ -923,8 +959,7 @@ impl VisitMut for ServerActions { let ident = Ident::new(id.0.clone(), DUMMY_SP.with_ctxt(id.1)); if !self.config.is_server { - let action_id = - generate_action_id(self.file_name.to_string(), export_name.to_string()); + let action_id = generate_action_id(&self.file_name, export_name); if export_name == "default" { let export_expr = ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr( @@ -973,7 +1008,7 @@ impl VisitMut for ServerActions { &mut self.annotations, ident.clone(), Vec::new(), - self.file_name.to_string(), + &self.file_name, export_name.to_string(), None, ); @@ -1037,26 +1072,22 @@ impl VisitMut for ServerActions { } if self.has_action { + let actions = if self.in_action_file { + self.exported_idents.iter().map(|e| e.1.clone()).collect() + } else { + self.export_actions.clone() + }; + let actions = actions + .into_iter() + .map(|name| (generate_action_id(&self.file_name, &name), name)) + .collect::(); // Prepend a special comment to the top of the file. self.comments.add_leading( self.start_pos, Comment { span: DUMMY_SP, kind: CommentKind::Block, - // Append a list of exported actions. - text: format!( - " __next_internal_action_entry_do_not_use__ {} ", - if self.in_action_file { - self.exported_idents - .iter() - .map(|e| e.1.to_string()) - .collect::>() - .join(",") - } else { - self.export_actions.join(",") - } - ) - .into(), + text: generate_server_actions_comment(actions).into(), }, ); @@ -1162,7 +1193,7 @@ fn collect_pat_idents(pat: &Pat, closure_idents: &mut Vec) { } } -fn generate_action_id(file_name: String, export_name: String) -> String { +fn generate_action_id(file_name: &str, export_name: &str) -> String { // Attach a checksum to the action using sha1: // $$id = sha1('file_name' + ':' + 'export_name'); let mut hasher = Sha1::new(); @@ -1178,7 +1209,7 @@ fn annotate_ident_as_action( annotations: &mut Vec, ident: Ident, bound: Vec>, - file_name: String, + file_name: &str, export_name: String, maybe_orig_action_ident: Option, ) { @@ -1188,7 +1219,7 @@ fn annotate_ident_as_action( // $$id ExprOrSpread { spread: None, - expr: Box::new(generate_action_id(file_name, export_name).into()), + expr: Box::new(generate_action_id(file_name, &export_name).into()), }, // myAction.$$bound = [arg1, arg2, arg3]; // or myAction.$$bound = null; if there are no bound values. diff --git a/packages/next-swc/crates/napi/Cargo.toml b/packages/next-swc/crates/napi/Cargo.toml index a92e0953ca..4e2fdcc75b 100644 --- a/packages/next-swc/crates/napi/Cargo.toml +++ b/packages/next-swc/crates/napi/Cargo.toml @@ -13,7 +13,13 @@ default = ["rustls-tls"] # when build (i.e napi --build --features plugin), same for the wasm as well. # this is due to some of transitive dependencies have features cannot be enabled at the same time # (i.e wasmer/default vs wasmer/js-default) while cargo merges all the features at once. -plugin = ["turbopack-binding/__swc_core_binding_napi_plugin", "turbopack-binding/__swc_core_binding_napi_plugin_filesystem_cache", "turbopack-binding/__swc_core_binding_napi_plugin_shared_runtime", "next-swc/plugin", "next-core/plugin"] +plugin = [ + "turbopack-binding/__swc_core_binding_napi_plugin", + "turbopack-binding/__swc_core_binding_napi_plugin_filesystem_cache", + "turbopack-binding/__swc_core_binding_napi_plugin_shared_runtime", + "next-swc/plugin", + "next-core/plugin", +] sentry_native_tls = ["sentry", "sentry/native-tls", "native-tls"] sentry_rustls = ["sentry", "sentry/rustls", "rustls-tls"] @@ -24,9 +30,7 @@ image-avif = ["next-core/image-avif"] # Enable all the available image codec support. # Currently this is identical to `image-webp`, as we are not able to build # other codecs easily yet. -image-extended = [ - "image-webp", -] +image-extended = ["image-webp"] # Enable dhat profiling allocator for heap profiling. __internal_dhat-heap = ["dhat"] @@ -47,7 +51,7 @@ napi = { version = "2", default-features = false, features = [ "error_anyhow", ] } napi-derive = "2" -next-swc = { version = "0.0.0", path = "../core" } +next-swc = { workspace = true } next-api = { workspace = true } next-build = { workspace = true } next-core = { workspace = true } @@ -73,9 +77,7 @@ turbopack-binding = { workspace = true, features = [ ] } [target.'cfg(not(all(target_os = "linux", target_env = "musl", target_arch = "aarch64")))'.dependencies] -turbopack-binding = { workspace = true, features = [ - "__turbo_tasks_malloc" -] } +turbopack-binding = { workspace = true, features = ["__turbo_tasks_malloc"] } # There are few build targets we can't use native-tls which default features rely on, # allow to specify alternative (rustls) instead via features. @@ -94,6 +96,4 @@ serde = "1" serde_json = "1" # It is not a mistake this dependency is specified in dep / build-dep both. shadow-rs = { workspace = true } -turbopack-binding = { workspace = true, features = [ - "__turbo_tasks_build" -]} +turbopack-binding = { workspace = true, features = ["__turbo_tasks_build"] } diff --git a/packages/next-swc/crates/next-api/Cargo.toml b/packages/next-swc/crates/next-api/Cargo.toml index 868f1eaeb9..dbb94efdb3 100644 --- a/packages/next-swc/crates/next-api/Cargo.toml +++ b/packages/next-swc/crates/next-api/Cargo.toml @@ -11,12 +11,17 @@ bench = false [features] default = ["custom_allocator"] -custom_allocator = ["turbopack-binding/__turbo_tasks_malloc", "turbopack-binding/__turbo_tasks_malloc_custom_allocator"] +custom_allocator = [ + "turbopack-binding/__turbo_tasks_malloc", + "turbopack-binding/__turbo_tasks_malloc_custom_allocator", +] [dependencies] anyhow = { workspace = true, features = ["backtrace"] } futures = { workspace = true } +next-swc = { workspace = true } indexmap = { workspace = true } +indoc = { workspace = true } next-core = { workspace = true } once_cell = { workspace = true } serde = { workspace = true } @@ -35,7 +40,7 @@ turbopack-binding = { workspace = true, features = [ "__turbopack_cli_utils", "__turbopack_node", "__turbopack_dev_server", -]} +] } turbo-tasks = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } @@ -43,6 +48,4 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } [build-dependencies] # It is not a mistake this dependency is specified in dep / build-dep both. shadow-rs = { workspace = true } -turbopack-binding = { workspace = true, features = [ - "__turbo_tasks_build" -]} +turbopack-binding = { workspace = true, features = ["__turbo_tasks_build"] } diff --git a/packages/next-swc/crates/next-api/src/app.rs b/packages/next-swc/crates/next-api/src/app.rs index 8224873685..cd9ac1a0e3 100644 --- a/packages/next-swc/crates/next-api/src/app.rs +++ b/packages/next-swc/crates/next-api/src/app.rs @@ -54,6 +54,7 @@ use turbopack_binding::{ use crate::{ project::Project, route::{Endpoint, Route, Routes, WrittenEndpoint}, + server_actions::create_server_actions_manifest, server_paths::all_server_paths, }; @@ -685,6 +686,26 @@ impl AppEndpoint { bail!("Entry module must be evaluatable"); }; evaluatable_assets.push(evaluatable); + + let (loader, manifest) = create_server_actions_manifest( + app_entry.rsc_entry, + node_root, + &app_entry.pathname, + &app_entry.original_name, + NextRuntime::Edge, + Vc::upcast(this.app_project.edge_rsc_module_context()), + Vc::upcast(chunking_context), + this.app_project + .project() + .next_config() + .enable_server_actions(), + ) + .await?; + server_assets.push(manifest); + if let Some(loader) = loader { + evaluatable_assets.push(loader); + } + let files = chunking_context.evaluated_chunk_group( app_entry .rsc_entry @@ -783,6 +804,28 @@ impl AppEndpoint { } } NextRuntime::NodeJs => { + let mut evaluatable_assets = + this.app_project.rsc_runtime_entries().await?.clone_value(); + + let (loader, manifest) = create_server_actions_manifest( + app_entry.rsc_entry, + node_root, + &app_entry.pathname, + &app_entry.original_name, + NextRuntime::NodeJs, + Vc::upcast(this.app_project.rsc_module_context()), + Vc::upcast(this.app_project.project().rsc_chunking_context()), + this.app_project + .project() + .next_config() + .enable_server_actions(), + ) + .await?; + server_assets.push(manifest); + if let Some(loader) = loader { + evaluatable_assets.push(loader); + } + let rsc_chunk = this .app_project .project() @@ -793,7 +836,7 @@ impl AppEndpoint { original_name = app_entry.original_name )), app_entry.rsc_entry, - this.app_project.rsc_runtime_entries(), + Vc::cell(evaluatable_assets), ); server_assets.push(rsc_chunk); @@ -816,8 +859,10 @@ impl AppEndpoint { client_assets: Vc::cell(client_assets), } } - }; - Ok(endpoint_output.cell()) + } + .cell(); + + Ok(endpoint_output) } } diff --git a/packages/next-swc/crates/next-api/src/lib.rs b/packages/next-swc/crates/next-api/src/lib.rs index b7a0ffe5e7..2a83ab2d75 100644 --- a/packages/next-swc/crates/next-api/src/lib.rs +++ b/packages/next-swc/crates/next-api/src/lib.rs @@ -8,6 +8,7 @@ mod middleware; mod pages; pub mod project; pub mod route; +mod server_actions; pub mod server_paths; mod versioned_content_map; diff --git a/packages/next-swc/crates/next-api/src/project.rs b/packages/next-swc/crates/next-api/src/project.rs index 9c2c761b8a..74231fa2d6 100644 --- a/packages/next-swc/crates/next-api/src/project.rs +++ b/packages/next-swc/crates/next-api/src/project.rs @@ -476,6 +476,7 @@ impl Project { self.env(), self.server_addr(), this.define_env.nodejs(), + self.next_config(), )) } diff --git a/packages/next-swc/crates/next-api/src/server_actions.rs b/packages/next-swc/crates/next-api/src/server_actions.rs new file mode 100644 index 0000000000..7b0428d5ad --- /dev/null +++ b/packages/next-swc/crates/next-api/src/server_actions.rs @@ -0,0 +1,279 @@ +use std::io::Write; + +use anyhow::{bail, Result}; +use indexmap::IndexMap; +use indoc::writedoc; +use next_core::{ + next_manifests::{ActionLayer, ActionManifestWorkerEntry, ServerReferenceManifest}, + util::{get_asset_prefix_from_pathname, NextRuntime}, +}; +use next_swc::server_actions::parse_server_actions; +use turbo_tasks::{ + graph::{GraphTraversal, NonDeterministic}, + TryFlatJoinIterExt, Value, ValueToString, Vc, +}; +use turbopack_binding::{ + turbo::tasks_fs::{rope::RopeBuilder, File, FileSystemPath}, + turbopack::{ + core::{ + asset::AssetContent, chunk::EvaluatableAsset, context::AssetContext, module::Module, + output::OutputAsset, reference::primary_referenced_modules, + reference_type::ReferenceType, virtual_output::VirtualOutputAsset, + virtual_source::VirtualSource, + }, + ecmascript::{ + chunk::{EcmascriptChunkItemExt, EcmascriptChunkPlaceable, EcmascriptChunkingContext}, + parse::ParseResult, + EcmascriptModuleAsset, + }, + }, +}; + +/// Scans the RSC entry point's full module graph looking for exported Server +/// Actions (identifiable by a magic comment in the transformed module's +/// output), and constructs a evaluatable "action loader" entry point and +/// manifest describing the found actions. +/// +/// If Server Actions are not enabled, this returns an empty manifest and a None +/// loader. +pub(crate) async fn create_server_actions_manifest( + entry: Vc>, + node_root: Vc, + pathname: &str, + page_name: &str, + runtime: NextRuntime, + asset_context: Vc>, + chunking_context: Vc>, + enable_server_actions: Vc, +) -> Result<( + Option>>, + Vc>, +)> { + // If actions aren't enabled, then there's no need to scan the module graph. We + // still need to generate an empty manifest so that the TS side can merge + // the manifest later on. + if !*enable_server_actions.await? { + let manifest = build_manifest( + node_root, + pathname, + page_name, + runtime, + ModuleActionMap::empty(), + Default::default(), + ) + .await?; + return Ok((None, manifest)); + } + + let actions = get_actions(Vc::upcast(entry)); + let loader = build_server_actions_loader(node_root, page_name, actions, asset_context).await?; + let Some(evaluable) = Vc::try_resolve_sidecast::>(loader).await? + else { + bail!("loader module must be evaluatable"); + }; + + let loader_id = loader.as_chunk_item(chunking_context).id().to_string(); + let manifest = + build_manifest(node_root, pathname, page_name, runtime, actions, loader_id).await?; + Ok((Some(evaluable), manifest)) +} + +/// Builds the "action loader" entry point, which reexports every found action +/// behind a lazy dynamic import. +/// +/// The actions are reexported under a hashed name (comprised of the exporting +/// file's name and the action name). This hash matches the id sent to the +/// client and present inside the paired manifest. +async fn build_server_actions_loader( + node_root: Vc, + page_name: &str, + actions: Vc, + asset_context: Vc>, +) -> Result>> { + let actions = actions.await?; + + let mut contents = RopeBuilder::from("__turbopack_export_value__({\n"); + let mut import_map = IndexMap::with_capacity(actions.len()); + + // Every module which exports an action (that is accessible starting from our + // app page entry point) will be present. We generate a single loader file + // which lazily imports the respective module's chunk_item id and invokes + // the exported action function. + for (i, (module, actions_map)) in actions.iter().enumerate() { + let module_name = format!("ACTIONS_MODULE{i}"); + for (hash_id, name) in &*actions_map.await? { + writedoc!( + contents, + " + \x20 '{hash_id}': (...args) => import('{module_name}') + .then(mod => (0, mod['{name}'])(...args)),\n + ", + )?; + } + import_map.insert(module_name, *module); + } + write!(contents, "}});")?; + + let output_path = node_root.join(format!("server/app{page_name}/actions.js")); + let file = File::from(contents.build()); + let source = VirtualSource::new(output_path, AssetContent::file(file.into())); + let module = asset_context.process( + Vc::upcast(source), + Value::new(ReferenceType::Internal(Vc::cell(import_map))), + ); + + let Some(placeable) = + Vc::try_resolve_sidecast::>(module).await? + else { + bail!("internal module must be evaluatable"); + }; + + Ok(placeable) +} + +/// Builds a manifest containing every action's hashed id, with an internal +/// module id which exports a function using that hashed name. +async fn build_manifest( + node_root: Vc, + pathname: &str, + page_name: &str, + runtime: NextRuntime, + actions: Vc, + loader_id: Vc, +) -> Result>> { + let manifest_path_prefix = get_asset_prefix_from_pathname(pathname); + let manifest_path = node_root.join(format!( + "server/app{manifest_path_prefix}/page/server-reference-manifest.json", + )); + let mut manifest = ServerReferenceManifest { + ..Default::default() + }; + + let actions_value = actions.await?; + let loader_id_value = loader_id.await?; + let mapping = match runtime { + NextRuntime::Edge => &mut manifest.edge, + NextRuntime::NodeJs => &mut manifest.node, + }; + + for value in actions_value.values() { + let value = value.await?; + for hash in value.keys() { + let entry = mapping.entry(hash.clone()).or_default(); + entry.workers.insert( + format!("app{page_name}"), + ActionManifestWorkerEntry::String(loader_id_value.clone_value()), + ); + entry + .layer + .insert(format!("app{page_name}"), ActionLayer::Rsc); + } + } + + Ok(Vc::upcast(VirtualOutputAsset::new( + manifest_path, + AssetContent::file(File::from(serde_json::to_string_pretty(&manifest)?).into()), + ))) +} + +/// Traverses the entire module graph starting from [module], looking for magic +/// comment which identifies server actions. Every found server action will be +/// returned along with the module which exports that action. +#[turbo_tasks::function] +async fn get_actions(module: Vc>) -> Result> { + let mut all_actions = NonDeterministic::new() + .skip_duplicates() + .visit([module], get_referenced_modules) + .await + .completed()? + .into_inner() + .into_iter() + .map(parse_actions_filter_map) + .try_flat_join() + .await? + .into_iter() + .collect::>(); + + all_actions.sort_keys(); + Ok(Vc::cell(all_actions)) +} + +/// Our graph traversal visitor, which finds the primary modules directly +/// referenced by [parent]. +async fn get_referenced_modules( + parent: Vc>, +) -> Result>> + Send> { + primary_referenced_modules(parent) + .await + .map(|modules| modules.clone_value().into_iter()) +} + +/// Inspects the comments inside [module] looking for the magic actions comment. +/// If found, we return the mapping of every action's hashed id to the name of +/// the exported action function. If not, we return a None. +#[turbo_tasks::function] +async fn parse_actions(module: Vc>) -> Result> { + let Some(ecmascript_asset) = + Vc::try_resolve_downcast_type::(module).await? + else { + return Ok(OptionActionMap::none()); + }; + let ParseResult::Ok { + comments, program, .. + } = &*ecmascript_asset.parse().await? + else { + bail!( + "failed to parse action module '{id}'", + id = module.ident().to_string().await? + ); + }; + + let Some(actions) = parse_server_actions(program, comments.clone()) else { + return Ok(OptionActionMap::none()); + }; + + let mut actions = IndexMap::from_iter(actions.into_iter()); + actions.sort_keys(); + Ok(Vc::cell(Some(Vc::cell(actions)))) +} + +/// Converts our cached [parsed_actions] call into a data type suitable for +/// collecting into a flat-mapped [IndexMap]. +async fn parse_actions_filter_map( + module: Vc>, +) -> Result>, Vc)>> { + parse_actions(module).await.map(|option_action_map| { + option_action_map + .clone_value() + .map(|action_map| (module, action_map)) + }) +} + +/// A mapping of every module which exports a Server Action, with the hashed id +/// and exported name of each found action. +#[turbo_tasks::value(transparent)] +struct ModuleActionMap(IndexMap>, Vc>); + +#[turbo_tasks::value_impl] +impl ModuleActionMap { + #[turbo_tasks::function] + pub fn empty() -> Vc { + Vc::cell(IndexMap::new()) + } +} + +/// Maps the hashed action id to the action's exported function name. +#[turbo_tasks::value(transparent)] +struct ActionMap(IndexMap); + +/// An Option wrapper around [ActionMap]. +#[turbo_tasks::value(transparent)] +struct OptionActionMap(Option>); + +#[turbo_tasks::value_impl] +impl OptionActionMap { + #[turbo_tasks::function] + pub fn none() -> Vc { + Vc::cell(None) + } +} diff --git a/packages/next-swc/crates/next-build/src/next_build.rs b/packages/next-swc/crates/next-build/src/next_build.rs index 6fab3bc7ec..4828913866 100644 --- a/packages/next-swc/crates/next-build/src/next_build.rs +++ b/packages/next-swc/crates/next-build/src/next_build.rs @@ -138,8 +138,13 @@ pub(crate) async fn next_build(options: TransientInstance) -> Resu get_client_compile_time_info(mode, browserslist_query, client_define_env); let server_define_env = Vc::cell(options.define_env.nodejs.iter().cloned().collect()); - let server_compile_time_info = - get_server_compile_time_info(mode, env, ServerAddr::empty(), server_define_env); + let server_compile_time_info = get_server_compile_time_info( + mode, + env, + ServerAddr::empty(), + server_define_env, + next_config, + ); // TODO(alexkirsz) Pages should build their own routes, outside of a FS. let next_router_fs = Vc::upcast::>(VirtualFileSystem::new()); diff --git a/packages/next-swc/crates/next-core/Cargo.toml b/packages/next-swc/crates/next-core/Cargo.toml index 70783de4ed..bcd6f3c1eb 100644 --- a/packages/next-swc/crates/next-core/Cargo.toml +++ b/packages/next-swc/crates/next-core/Cargo.toml @@ -15,6 +15,7 @@ async-trait = { workspace = true } base64 = "0.21.0" const_format = "0.2.30" lazy-regex = "3.0.1" +next-swc = { workspace = true } once_cell = { workspace = true } qstring = { workspace = true } regex = { workspace = true } 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 73982bb4c2..1b50862bc0 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 @@ -116,7 +116,7 @@ pub async fn get_app_page_entry( file.push('\n'); } - result.concat(&file.into()); + result.push_bytes(file.as_bytes()); let file = File::from(result.build()); 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 ab7d381f1f..014328c97f 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 @@ -98,7 +98,7 @@ pub async fn get_app_route_entry( file.push('\n'); } - result.concat(&file.into()); + result.push_bytes(file.as_bytes()); let file = File::from(result.build()); diff --git a/packages/next-swc/crates/next-core/src/next_client/transforms.rs b/packages/next-swc/crates/next-core/src/next_client/transforms.rs index d7c4591c6b..15a07842fd 100644 --- a/packages/next-swc/crates/next-core/src/next_client/transforms.rs +++ b/packages/next-swc/crates/next-core/src/next_client/transforms.rs @@ -10,6 +10,7 @@ use crate::{ next_shared::transforms::{ get_next_dynamic_transform_rule, get_next_font_transform_rule, get_next_image_rule, get_next_modularize_imports_rule, get_next_pages_transforms_rule, + get_server_actions_transform_rule, server_actions::ActionsTransform, }, }; @@ -23,6 +24,7 @@ pub async fn get_next_client_transforms_rules( let mut rules = vec![]; let modularize_imports_config = &next_config.await?.modularize_imports; + let enable_server_actions = *next_config.enable_server_actions().await?; if let Some(modularize_imports_config) = modularize_imports_config { rules.push(get_next_modularize_imports_rule(modularize_imports_config)); } @@ -36,9 +38,13 @@ pub async fn get_next_client_transforms_rules( ); Some(pages_dir) } - ClientContextType::App { .. } | ClientContextType::Fallback | ClientContextType::Other => { + ClientContextType::App { .. } => { + if enable_server_actions { + rules.push(get_server_actions_transform_rule(ActionsTransform::Client)); + } None } + ClientContextType::Fallback | ClientContextType::Other => None, }; rules.push(get_next_dynamic_transform_rule(false, false, pages_dir, mode).await?); diff --git a/packages/next-swc/crates/next-core/src/next_config.rs b/packages/next-swc/crates/next-core/src/next_config.rs index 5e876c1f71..5576234070 100644 --- a/packages/next-swc/crates/next-core/src/next_config.rs +++ b/packages/next-swc/crates/next-core/src/next_config.rs @@ -443,6 +443,9 @@ pub struct ExperimentalConfig { pub optimize_css: Option, pub next_script_workers: Option, pub web_vitals_attribution: Option>, + /// Enables server actions. Using this feature will enable the + /// `react@experimental` for the `app` directory. @see https://nextjs.org/docs/app/api-reference/functions/server-actions + server_actions: Option, // --- // UNSUPPORTED @@ -484,9 +487,6 @@ pub struct ExperimentalConfig { /// directory. ppr: Option, proxy_timeout: Option, - /// Enables server actions. Using this feature will enable the - /// `react@experimental` for the `app` directory. @see https://nextjs.org/docs/app/api-reference/functions/server-actions - server_actions: Option, /// Allows adjusting body parser size limit for server actions. server_actions_body_size_limit: Option, /// enables the minification of server code. @@ -735,6 +735,13 @@ impl NextConfig { .trim_end_matches('/') )))) } + + #[turbo_tasks::function] + pub async fn enable_server_actions(self: Vc) -> Result> { + Ok(Vc::cell( + self.await?.experimental.server_actions.unwrap_or(false), + )) + } } fn next_configs() -> Vc> { 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 fc2a29ec84..cec5657980 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 @@ -89,6 +89,11 @@ pub async fn get_next_client_import_map( ); } ClientContextType::App { app_dir } => { + let react_flavor = if *next_config.enable_server_actions().await? { + "-experimental" + } else { + "" + }; import_map.insert_exact_alias( "server-only", request_to_import_mapping(app_dir, "next/dist/compiled/server-only"), @@ -99,25 +104,37 @@ pub async fn get_next_client_import_map( ); import_map.insert_exact_alias( "react", - request_to_import_mapping(app_dir, "next/dist/compiled/react"), + request_to_import_mapping( + app_dir, + &format!("next/dist/compiled/react{react_flavor}"), + ), ); import_map.insert_wildcard_alias( "react/", - request_to_import_mapping(app_dir, "next/dist/compiled/react/*"), + request_to_import_mapping( + app_dir, + &format!("next/dist/compiled/react{react_flavor}/*"), + ), ); import_map.insert_exact_alias( "react-dom", - request_to_import_mapping(app_dir, "next/dist/compiled/react-dom"), + request_to_import_mapping( + app_dir, + &format!("next/dist/compiled/react-dom{react_flavor}"), + ), ); import_map.insert_wildcard_alias( "react-dom/", - request_to_import_mapping(app_dir, "next/dist/compiled/react-dom/*"), + request_to_import_mapping( + app_dir, + &format!("next/dist/compiled/react-dom{react_flavor}/*"), + ), ); import_map.insert_wildcard_alias( "react-server-dom-webpack/", request_to_import_mapping( app_dir, - "next/dist/compiled/react-server-dom-turbopack/*", + &format!("next/dist/compiled/react-server-dom-turbopack{react_flavor}/*"), ), ); import_map.insert_exact_alias( @@ -236,6 +253,7 @@ pub async fn get_next_server_import_map( ty, mode, NextRuntime::NodeJs, + next_config, ) .await?; let external: Vc = ImportMapping::External(None).cell(); @@ -255,6 +273,13 @@ pub async fn get_next_server_import_map( ServerContextType::AppSSR { .. } | ServerContextType::AppRSC { .. } | ServerContextType::AppRoute { .. } => { + import_map.insert_exact_alias( + "private-next-rsc-action-proxy", + request_to_import_mapping( + project_path, + "next/dist/build/webpack/loaders/next-flight-loader/action-proxy", + ), + ); import_map.insert_exact_alias( "next/head", request_to_import_mapping(project_path, "next/dist/client/components/noop-head"), @@ -304,8 +329,15 @@ pub async fn get_next_edge_import_map( let ty = ty.into_value(); - insert_next_server_special_aliases(&mut import_map, project_path, ty, mode, NextRuntime::Edge) - .await?; + insert_next_server_special_aliases( + &mut import_map, + project_path, + ty, + mode, + NextRuntime::Edge, + next_config, + ) + .await?; match ty { ServerContextType::Pages { .. } | ServerContextType::PagesData { .. } => {} @@ -390,6 +422,7 @@ async fn insert_next_server_special_aliases( ty: ServerContextType, mode: NextMode, runtime: NextRuntime, + next_config: Vc, ) -> Result<()> { let external_if_node = move |context_dir: Vc, request: &str| match runtime { NextRuntime::Edge => request_to_import_mapping(context_dir, request), @@ -445,13 +478,18 @@ async fn insert_next_server_special_aliases( "styled-jsx/", request_to_import_mapping(get_next_package(app_dir), "styled-jsx/*"), ); + + let server_actions = *next_config.enable_server_actions().await?; import_map.insert_exact_alias( "react/jsx-runtime", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react/jsx-runtime", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-experimental/jsx-runtime" + } + (NextRuntime::Edge, false) => "next/dist/compiled/react/jsx-runtime", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/ssr/\ react-jsx-runtime" } @@ -462,9 +500,12 @@ async fn insert_next_server_special_aliases( "react/jsx-dev-runtime", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react/jsx-dev-runtime", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-experimental/jsx-dev-runtime" + } + (NextRuntime::Edge, false) => "next/dist/compiled/react/jsx-dev-runtime", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/ssr/\ react-jsx-dev-runtime" } @@ -475,9 +516,10 @@ async fn insert_next_server_special_aliases( "react", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => "next/dist/compiled/react-experimental", + (NextRuntime::Edge, false) => "next/dist/compiled/react", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/ssr/react" } }, @@ -487,9 +529,10 @@ async fn insert_next_server_special_aliases( "react-dom", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react-dom", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => "next/dist/compiled/react-dom-experimental", + (NextRuntime::Edge, false) => "next/dist/compiled/react-dom", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/ssr/react-dom" } }, @@ -499,13 +542,16 @@ async fn insert_next_server_special_aliases( "react-server-dom-webpack/client.edge", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-server-dom-turbopack-experimental/client.edge" + } + (NextRuntime::Edge, false) => { "next/dist/compiled/react-server-dom-turbopack/client.edge" } // When we access the runtime we still use the webpack name. The runtime // itself will substitute in the turbopack variant - NextRuntime::NodeJs => { + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/ssr/\ react-server-dom-turbopack-client-edge" } @@ -519,13 +565,16 @@ async fn insert_next_server_special_aliases( "react-server-dom-webpack/client", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-server-dom-turbopack-experimental/client.edge" + } + (NextRuntime::Edge, false) => { "next/dist/compiled/react-server-dom-turbopack/client.edge" } // When we access the runtime we still use the webpack name. The runtime // itself will substitute in the turbopack variant - NextRuntime::NodeJs => { + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/ssr/\ react-server-dom-turbopack-client-edge" } @@ -537,15 +586,30 @@ async fn insert_next_server_special_aliases( // layer TODO: add the rests import_map.insert_exact_alias( "react-dom/server", - request_to_import_mapping(app_dir, "next/dist/compiled/react-dom/server"), + request_to_import_mapping( + app_dir, + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-dom-experimental/server.edge" + } + (NextRuntime::Edge, false) => "next/dist/compiled/react-dom/server.edge", + (NextRuntime::NodeJs, _) => { + "next/dist/server/future/route-modules/app-page/vendored/ssr/\ + react-dom-server-edge" + } + }, + ), ); import_map.insert_exact_alias( "react-dom/server.edge", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react-dom/server.edge", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-dom-experimental/server.edge" + } + (NextRuntime::Edge, false) => "next/dist/compiled/react-dom/server.edge", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/ssr/\ react-dom-server-edge" } @@ -572,13 +636,17 @@ async fn insert_next_server_special_aliases( request_to_import_mapping(get_next_package(app_dir), "styled-jsx/*"), ); + let server_actions = *next_config.enable_server_actions().await?; import_map.insert_exact_alias( "react/jsx-runtime", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react/jsx-runtime", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-experimental/jsx-runtime" + } + (NextRuntime::Edge, false) => "next/dist/compiled/react/jsx-runtime", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/rsc/\ react-jsx-runtime" } @@ -589,9 +657,12 @@ async fn insert_next_server_special_aliases( "react/jsx-dev-runtime", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react/jsx-dev-runtime", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-experimental/jsx-dev-runtime" + } + (NextRuntime::Edge, false) => "next/dist/compiled/react/jsx-dev-runtime", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/rsc/\ react-jsx-dev-runtime" } @@ -610,9 +681,10 @@ async fn insert_next_server_special_aliases( "react", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => "next/dist/compiled/react-experimental", + (NextRuntime::Edge, false) => "next/dist/compiled/react", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/rsc/react" } }, @@ -622,9 +694,10 @@ async fn insert_next_server_special_aliases( "react-dom", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => "next/dist/compiled/react-dom", - NextRuntime::NodeJs => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => "next/dist/compiled/react-dom-experimental", + (NextRuntime::Edge, false) => "next/dist/compiled/react-dom", + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/rsc/react-dom" } }, @@ -634,13 +707,16 @@ async fn insert_next_server_special_aliases( "react-server-dom-webpack/server.edge", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-server-dom-turbopack-experimental/server.edge" + } + (NextRuntime::Edge, false) => { "next/dist/compiled/react-server-dom-turbopack/server.edge" } // When we access the runtime we still use the webpack name. The runtime // itself will substitute in the turbopack variant - NextRuntime::NodeJs => { + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/rsc/\ react-server-dom-turbopack-server-edge" } @@ -651,13 +727,16 @@ async fn insert_next_server_special_aliases( "react-server-dom-webpack/server.node", request_to_import_mapping( app_dir, - match runtime { - NextRuntime::Edge => { + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-server-dom-turbopack-experimental/server.node" + } + (NextRuntime::Edge, false) => { "next/dist/compiled/react-server-dom-turbopack/server.node" } // When we access the runtime we still use the webpack name. The runtime // itself will substitute in the turbopack variant - NextRuntime::NodeJs => { + (NextRuntime::NodeJs, _) => { "next/dist/server/future/route-modules/app-page/vendored/rsc/\ react-server-dom-turbopack-server-node" } @@ -669,11 +748,19 @@ async fn insert_next_server_special_aliases( // layer TODO: add the rests import_map.insert_exact_alias( "react-dom/server.edge", - request_to_import_mapping(app_dir, "next/dist/compiled/react-dom/server.edge"), - ); - import_map.insert_exact_alias( - "react-dom/server", - request_to_import_mapping(app_dir, "next/dist/compiled/react-dom/server"), + request_to_import_mapping( + app_dir, + match (runtime, server_actions) { + (NextRuntime::Edge, true) => { + "next/dist/compiled/react-dom-experimental/server.edge" + } + (NextRuntime::Edge, false) => "next/dist/compiled/react-dom/server.edge", + (NextRuntime::NodeJs, _) => { + "next/dist/server/future/route-modules/app-page/vendored/ssr/\ + react-dom-server-edge" + } + }, + ), ); } (_, ServerContextType::Middleware) => {} diff --git a/packages/next-swc/crates/next-core/src/next_manifests/mod.rs b/packages/next-swc/crates/next-core/src/next_manifests/mod.rs index dbfccf986d..5d6a452be3 100644 --- a/packages/next-swc/crates/next-core/src/next_manifests/mod.rs +++ b/packages/next-swc/crates/next-core/src/next_manifests/mod.rs @@ -148,23 +148,20 @@ pub struct AppPathsManifest { #[derive(Serialize, Default, Debug)] #[serde(rename_all = "camelCase")] pub struct ServerReferenceManifest { - #[serde(flatten)] - pub server_actions: ActionManifest, - #[serde(flatten)] - pub edge_server_actions: ActionManifest, -} - -#[derive(Serialize, Default, Debug)] -#[serde(rename_all = "camelCase")] -pub struct ActionManifest { - #[serde(flatten)] - pub actions: HashMap, + /// A map from hashed action name to the runtime module we that exports it. + pub node: HashMap, + /// A map from hashed action name to the runtime module we that exports it. + pub edge: HashMap, } #[derive(Serialize, Default, Debug)] #[serde(rename_all = "camelCase")] pub struct ActionManifestEntry { + /// A mapping from the page that uses the server action to the runtime + /// module that exports it. pub workers: HashMap, + + pub layer: HashMap, } #[derive(Serialize, Debug)] @@ -175,6 +172,13 @@ pub enum ActionManifestWorkerEntry { Number(f64), } +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub enum ActionLayer { + Rsc, + ActionBrowser, +} + #[derive(Serialize, Default, Debug)] #[serde(rename_all = "camelCase")] pub struct ClientReferenceManifest { diff --git a/packages/next-swc/crates/next-core/src/next_pages/page_entry.rs b/packages/next-swc/crates/next-core/src/next_pages/page_entry.rs index 4dedec9adc..c05121a79c 100644 --- a/packages/next-swc/crates/next-core/src/next_pages/page_entry.rs +++ b/packages/next-swc/crates/next-core/src/next_pages/page_entry.rs @@ -3,7 +3,7 @@ use std::io::Write; use anyhow::{bail, Result}; use indexmap::indexmap; use turbo_tasks::Vc; -use turbo_tasks_fs::{rope::Rope, FileSystemPath}; +use turbo_tasks_fs::FileSystemPath; use turbopack_binding::{ turbo::{ tasks::Value, @@ -90,9 +90,8 @@ pub async fn create_page_ssr_entry_module( file.push('\n'); } - let file = Rope::from(file); let mut result = RopeBuilder::default(); - result += &file; + result.push_bytes(file.as_bytes()); if reference_type == ReferenceType::Entry(EntryReferenceSubType::Page) { // When we're building the instrumentation page (only when the diff --git a/packages/next-swc/crates/next-core/src/next_server/context.rs b/packages/next-swc/crates/next-core/src/next_server/context.rs index ac8dd43c09..d789721362 100644 --- a/packages/next-swc/crates/next-core/src/next_server/context.rs +++ b/packages/next-swc/crates/next-core/src/next_server/context.rs @@ -180,12 +180,17 @@ pub async fn get_server_resolve_options_context( .cell()) } -fn defines(mode: NextMode, define_env: &IndexMap) -> CompileTimeDefines { +fn defines( + mode: NextMode, + define_env: &IndexMap, + server_actions: bool, +) -> CompileTimeDefines { let mut defines = compile_time_defines!( process.turbopack = true, process.env.NEXT_RUNTIME = "nodejs", process.env.NODE_ENV = mode.node_env(), process.env.TURBOPACK = true, + process.env.__NEXT_EXPERIMENTAL_REACT = server_actions, ); for (k, v) in define_env { @@ -202,30 +207,37 @@ fn defines(mode: NextMode, define_env: &IndexMap) -> CompileTime async fn next_server_defines( mode: NextMode, define_env: Vc, + server_actions: Vc, ) -> Result> { - Ok(defines(mode, &*define_env.await?).cell()) + Ok(defines(mode, &*define_env.await?, *server_actions.await?).cell()) } #[turbo_tasks::function] async fn next_server_free_vars( mode: NextMode, define_env: Vc, + server_actions: Vc, ) -> Result> { - Ok(free_var_references!(..defines(mode, &*define_env.await?).into_iter()).cell()) + Ok(free_var_references!( + ..defines(mode, &*define_env.await?, *server_actions.await?).into_iter() + ) + .cell()) } #[turbo_tasks::function] -pub fn get_server_compile_time_info( +pub async fn get_server_compile_time_info( mode: NextMode, process_env: Vc>, server_addr: Vc, define_env: Vc, + next_config: Vc, ) -> Vc { + let server_actions = next_config.enable_server_actions(); CompileTimeInfo::builder(Environment::new(Value::new( ExecutionEnvironment::NodeJsLambda(NodeJsEnvironment::current(process_env, server_addr)), ))) - .defines(next_server_defines(mode, define_env)) - .free_var_references(next_server_free_vars(mode, define_env)) + .defines(next_server_defines(mode, define_env, server_actions)) + .free_var_references(next_server_free_vars(mode, define_env, server_actions)) .cell() } diff --git a/packages/next-swc/crates/next-core/src/next_server/transforms.rs b/packages/next-swc/crates/next-core/src/next_server/transforms.rs index a948a9b10e..63a58d59a8 100644 --- a/packages/next-swc/crates/next-core/src/next_server/transforms.rs +++ b/packages/next-swc/crates/next-core/src/next_server/transforms.rs @@ -11,6 +11,7 @@ use crate::{ next_shared::transforms::{ get_next_dynamic_transform_rule, get_next_font_transform_rule, get_next_image_rule, get_next_modularize_imports_rule, get_next_pages_transforms_rule, + get_server_actions_transform_rule, server_actions::ActionsTransform, }, }; @@ -24,6 +25,7 @@ pub async fn get_next_server_transforms_rules( let mut rules = vec![]; let modularize_imports_config = &next_config.await?.modularize_imports; + let enable_server_actions = *next_config.enable_server_actions().await?; if let Some(modularize_imports_config) = modularize_imports_config { rules.push(get_next_modularize_imports_rule(modularize_imports_config)); } @@ -37,10 +39,18 @@ pub async fn get_next_server_transforms_rules( ); (false, Some(pages_dir)) } - ServerContextType::AppSSR { .. } => (false, None), + ServerContextType::AppSSR { .. } => { + if enable_server_actions { + rules.push(get_server_actions_transform_rule(ActionsTransform::Server)); + } + (false, None) + } ServerContextType::AppRSC { client_transition, .. } => { + if enable_server_actions { + rules.push(get_server_actions_transform_rule(ActionsTransform::Server)); + } if let Some(client_transition) = client_transition { rules.push(get_next_css_client_reference_transforms_rule( client_transition, diff --git a/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs b/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs index 68cc185939..cbb68ab3d7 100644 --- a/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs +++ b/packages/next-swc/crates/next-core/src/next_shared/transforms/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod next_dynamic; pub(crate) mod next_font; pub(crate) mod next_strip_page_exports; pub(crate) mod relay; +pub(crate) mod server_actions; pub(crate) mod styled_components; pub(crate) mod styled_jsx; pub(crate) mod swc_ecma_transform_plugins; @@ -13,6 +14,7 @@ pub use next_dynamic::get_next_dynamic_transform_rule; pub use next_font::get_next_font_transform_rule; pub use next_strip_page_exports::get_next_pages_transforms_rule; pub use relay::get_relay_transform_plugin; +pub use server_actions::get_server_actions_transform_rule; use turbo_tasks::{Value, Vc}; use turbopack_binding::turbopack::{ core::reference_type::{ReferenceType, UrlReferenceSubType}, diff --git a/packages/next-swc/crates/next-core/src/next_shared/transforms/server_actions.rs b/packages/next-swc/crates/next-core/src/next_shared/transforms/server_actions.rs new file mode 100644 index 0000000000..87c10281c4 --- /dev/null +++ b/packages/next-swc/crates/next-core/src/next_shared/transforms/server_actions.rs @@ -0,0 +1,54 @@ +use anyhow::Result; +use async_trait::async_trait; +use next_swc::server_actions::{server_actions, Config}; +use swc_core::{ + common::FileName, + ecma::{ast::Program, visit::VisitMutWith}, +}; +use turbo_tasks::Vc; +use turbopack_binding::turbopack::{ + ecmascript::{CustomTransformer, EcmascriptInputTransform, TransformContext}, + turbopack::module_options::{ModuleRule, ModuleRuleEffect}, +}; + +use super::module_rule_match_js_no_url; + +#[derive(Debug)] +pub enum ActionsTransform { + Client, + Server, +} + +/// Returns a rule which applies the Next.js Server Actions transform. +pub fn get_server_actions_transform_rule(transform: ActionsTransform) -> ModuleRule { + let transformer = + EcmascriptInputTransform::Plugin(Vc::cell(Box::new(NextServerActions { transform }) as _)); + ModuleRule::new( + module_rule_match_js_no_url(), + vec![ModuleRuleEffect::AddEcmascriptTransforms(Vc::cell(vec![ + transformer, + ]))], + ) +} + +#[derive(Debug)] +struct NextServerActions { + transform: ActionsTransform, +} + +#[async_trait] +impl CustomTransformer for NextServerActions { + async fn transform(&self, program: &mut Program, ctx: &TransformContext<'_>) -> Result<()> { + let mut actions = server_actions( + &FileName::Real(ctx.file_path_str.into()), + Config { + is_server: matches!(self.transform, ActionsTransform::Server), + enabled: true, + }, + ctx.comments.clone(), + ); + + program.visit_mut_with(&mut actions); + Ok(()) + } +} diff --git a/packages/next-swc/crates/wasm/Cargo.toml b/packages/next-swc/crates/wasm/Cargo.toml index 7498bc9e6e..c05f4bd1f4 100644 --- a/packages/next-swc/crates/wasm/Cargo.toml +++ b/packages/next-swc/crates/wasm/Cargo.toml @@ -16,7 +16,7 @@ plugin = ["getrandom/js", "turbopack-binding/__swc_core_binding_wasm_plugin"] [dependencies] anyhow = "1.0.66" console_error_panic_hook = "0.1.6" -next-swc = { version = "0.0.0", path = "../core" } +next-swc = { workspace = true } once_cell = { workspace = true } parking_lot_core = "=0.8.0" path-clean = "0.1" diff --git a/packages/next/src/build/analysis/get-page-static-info.ts b/packages/next/src/build/analysis/get-page-static-info.ts index 9f1d890a05..37440dfe80 100644 --- a/packages/next/src/build/analysis/get-page-static-info.ts +++ b/packages/next/src/build/analysis/get-page-static-info.ts @@ -53,7 +53,7 @@ const CLIENT_MODULE_LABEL = /\/\* __next_internal_client_entry_do_not_use__ ([^ ]*) (cjs|auto) \*\// const ACTION_MODULE_LABEL = - /\/\* __next_internal_action_entry_do_not_use__ ([^ ]+) \*\// + /\/\* __next_internal_action_entry_do_not_use__ (\{[^}]+\}) \*\// const CLIENT_DIRECTIVE = 'use client' const SERVER_ACTION_DIRECTIVE = 'use server' @@ -63,7 +63,10 @@ export function getRSCModuleInformation( source: string, isServerLayer: boolean ): RSCMeta { - const actions = source.match(ACTION_MODULE_LABEL)?.[1]?.split(',') + const actionsJson = source.match(ACTION_MODULE_LABEL) + const actions = actionsJson + ? (Object.values(JSON.parse(actionsJson[1])) as string[]) + : undefined const clientInfoMatch = source.match(CLIENT_MODULE_LABEL) const isClientRef = !!clientInfoMatch diff --git a/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts b/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts index 349a3af2c4..63735c0b78 100644 --- a/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts +++ b/packages/next/src/build/webpack/plugins/nextjs-require-cache-hot-reloader.ts @@ -50,6 +50,11 @@ export function deleteAppClientCache() { deleteFromRequireCache( require.resolve('next/dist/compiled/next-server/app-page.runtime.dev.js') ) + deleteFromRequireCache( + require.resolve( + 'next/dist/compiled/next-server/app-page-experimental.runtime.dev.js' + ) + ) } export function deleteCache(filePath: string) { diff --git a/packages/next/src/lib/turbopack-warning.ts b/packages/next/src/lib/turbopack-warning.ts index 436be820aa..3d0951abab 100644 --- a/packages/next/src/lib/turbopack-warning.ts +++ b/packages/next/src/lib/turbopack-warning.ts @@ -62,6 +62,7 @@ const supportedTurbopackNextConfigOptions = [ 'experimental.useDeploymentId', 'experimental.useDeploymentIdServerActions', 'experimental.deploymentId', + 'experimental.serverActions', // Experimental options that don't affect compilation 'serverRuntimeConfig', @@ -88,7 +89,6 @@ const supportedTurbopackNextConfigOptions = [ 'experimental.trustHostHeader', // Left to be implemented (priority) - // 'experimental.serverActions', // 'experimental.ppr', // Checked in `needs-experimental-react.ts` // clientRouterFilter is `true` by default currently in config-shared.ts, // might be removed as an option altogether. 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 90181b0ab4..f97e14c151 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev.ts @@ -64,6 +64,7 @@ import { NEXT_FONT_MANIFEST, PAGES_MANIFEST, PHASE_DEVELOPMENT_SERVER, + SERVER_REFERENCE_MANIFEST, } from '../../../shared/lib/constants' import { @@ -111,6 +112,7 @@ import { } from '../../../build/webpack/plugins/nextjs-require-cache-hot-reloader' import { normalizeMetadataRoute } from '../../../lib/metadata/get-metadata-route' import { clearModuleContext } from '../render-server' +import type { ActionManifest } from '../../../build/webpack/plugins/flight-client-entry-plugin' const wsServer = new ws.Server({ noServer: true }) @@ -492,6 +494,7 @@ async function startWatcher(opts: SetupOpts) { const pagesManifests = new Map() const appPathsManifests = new Map() const middlewareManifests = new Map() + const actionManifests = new Map() const clientToHmrSubscription = new Map< ws, Map> @@ -542,6 +545,17 @@ async function startWatcher(opts: SetupOpts) { ) } + async function loadActionManifest(pageName: string): Promise { + actionManifests.set( + pageName, + await loadPartialManifest( + `${SERVER_REFERENCE_MANIFEST}.json`, + pageName, + 'app' + ) + ) + } + const buildingReported = new Set() async function changeSubscription( @@ -653,6 +667,32 @@ async function startWatcher(opts: SetupOpts) { return manifest } + function mergeActionManifests(manifests: Iterable) { + type ActionEntries = ActionManifest['edge' | 'node'] + const manifest: ActionManifest = { + node: {}, + edge: {}, + } + + function mergeActionIds( + actionEntries: ActionEntries, + other: ActionEntries + ): void { + for (const key in other) { + const action = (actionEntries[key] ??= { workers: {}, layer: {} }) + Object.assign(action.workers, other[key].workers) + Object.assign(action.layer, other[key].layer) + } + } + + for (const m of manifests) { + mergeActionIds(manifest.node, m.node) + mergeActionIds(manifest.edge, m.edge) + } + + return manifest + } + async function writeFileAtomic( filePath: string, content: string @@ -770,6 +810,29 @@ async function startWatcher(opts: SetupOpts) { ) } + async function writeActionManifest(): Promise { + const actionManifest = mergeActionManifests(actionManifests.values()) + const actionManifestJsonPath = path.join( + distDir, + 'server', + `${SERVER_REFERENCE_MANIFEST}.json` + ) + const actionManifestJsPath = path.join( + distDir, + 'server', + `${SERVER_REFERENCE_MANIFEST}.js` + ) + const json = JSON.stringify(actionManifest, null, 2) + deleteCache(actionManifestJsonPath) + deleteCache(actionManifestJsPath) + await writeFile(actionManifestJsonPath, json, 'utf-8') + await writeFile( + actionManifestJsPath, + `self.__RSC_SERVER_MANIFEST=${JSON.stringify(json)}`, + 'utf-8' + ) + } + async function writeFontManifest(): Promise { // TODO: turbopack should write the correct // version of this @@ -979,6 +1042,7 @@ async function startWatcher(opts: SetupOpts) { await writePagesManifest() await writeAppPathsManifest() await writeMiddlewareManifest() + await writeActionManifest() await writeOtherManifests() await writeFontManifest() @@ -1312,11 +1376,13 @@ async function startWatcher(opts: SetupOpts) { await loadAppBuildManifest(page) await loadBuildManifest(page, 'app') await loadAppPathManifest(page, 'app') + await loadActionManifest(page) await writeAppBuildManifest() await writeBuildManifest() await writeAppPathsManifest() await writeMiddlewareManifest() + await writeActionManifest() await writeOtherManifests() processIssues(page, page, writtenEndpoint, true) diff --git a/test/development/acceptance/component-stack.test.ts b/test/development/acceptance/component-stack.test.ts index ac229c0861..be967046e8 100644 --- a/test/development/acceptance/component-stack.test.ts +++ b/test/development/acceptance/component-stack.test.ts @@ -1,62 +1,26 @@ /* eslint-env jest */ -import { sandbox } from 'development-sandbox' -import { FileRef, nextTestSetup } from 'e2e-utils' -import { outdent } from 'outdent' +import { createNextDescribe } from 'e2e-utils' +import { getRedboxComponentStack, hasRedbox } from 'next-test-utils' import path from 'path' -describe('Component Stack in error overlay', () => { - const { next } = nextTestSetup({ - files: new FileRef(path.join(__dirname, 'fixtures', 'default-template')), - dependencies: { - react: 'latest', - 'react-dom': 'latest', - }, - skipStart: true, - }) +createNextDescribe( + 'Component Stack in error overlay', + { + files: path.join(__dirname, 'fixtures', 'component-stack'), + }, + ({ next }) => { + it('should show a component stack on hydration error', async () => { + const browser = await next.browser('/') - it('should show a component stack on hydration error', async () => { - const { cleanup, session } = await sandbox( - next, - new Map([ - [ - 'component.js', - outdent` - const isClient = typeof window !== 'undefined' - export default function Component() { - return ( -
-

{isClient ? "client" : "server"}

-
- ); - } - `, - ], - [ - 'index.js', - outdent` - import Component from './component' - export default function Mismatch() { - return ( -
- -
- ); - } - `, - ], - ]) - ) + expect(await hasRedbox(browser, true)).toBe(true) - expect(await session.hasRedbox(true)).toBe(true) - - expect(await session.getRedboxComponentStack()).toMatchInlineSnapshot(` + expect(await getRedboxComponentStack(browser)).toMatchInlineSnapshot(` "p div Component main Mismatch" `) - - await cleanup() - }) -}) + }) + } +) diff --git a/test/development/acceptance/fixtures/component-stack/component.js b/test/development/acceptance/fixtures/component-stack/component.js new file mode 100644 index 0000000000..d34a53948c --- /dev/null +++ b/test/development/acceptance/fixtures/component-stack/component.js @@ -0,0 +1,8 @@ +const isClient = typeof window !== 'undefined' +export default function Component() { + return ( +
+

{isClient ? 'client' : 'server'}

+
+ ) +} diff --git a/test/development/acceptance/fixtures/component-stack/index.js b/test/development/acceptance/fixtures/component-stack/index.js new file mode 100644 index 0000000000..74edbac739 --- /dev/null +++ b/test/development/acceptance/fixtures/component-stack/index.js @@ -0,0 +1,8 @@ +import Component from './component' +export default function Mismatch() { + return ( +
+ +
+ ) +} diff --git a/test/development/acceptance/fixtures/component-stack/next.config.js b/test/development/acceptance/fixtures/component-stack/next.config.js new file mode 100644 index 0000000000..4ba52ba2c8 --- /dev/null +++ b/test/development/acceptance/fixtures/component-stack/next.config.js @@ -0,0 +1 @@ +module.exports = {} diff --git a/test/development/acceptance/fixtures/component-stack/pages/index.js b/test/development/acceptance/fixtures/component-stack/pages/index.js new file mode 100644 index 0000000000..88aa8f4788 --- /dev/null +++ b/test/development/acceptance/fixtures/component-stack/pages/index.js @@ -0,0 +1 @@ +export { default } from '../index' diff --git a/test/lib/next-test-utils.ts b/test/lib/next-test-utils.ts index 7cc38b7b07..0ac9a2bed9 100644 --- a/test/lib/next-test-utils.ts +++ b/test/lib/next-test-utils.ts @@ -1037,6 +1037,24 @@ export function getSnapshotTestDescribe(variant: TestVariants) { return shouldSkip ? describe.skip : describe } +export async function getRedboxComponentStack( + browser: BrowserInterface +): Promise { + await browser.waitForElementByCss( + '[data-nextjs-component-stack-frame]', + 30000 + ) + // TODO: the type for elementsByCss is incorrect + const componentStackFrameElements: any = await browser.elementsByCss( + '[data-nextjs-component-stack-frame]' + ) + const componentStackFrameTexts = await Promise.all( + componentStackFrameElements.map((f) => f.innerText()) + ) + + return componentStackFrameTexts.join('\n') +} + /** * For better editor support, pass in the variants this should run on (`default` and/or `turbo`) as cases. *