Turbopack: Implement Server Actions (#53890)

### What?

This implements Server Actions inside the new Turbopack next-api bundles.

### How?

Server Actions requires:
1. A `.next/server/server-reference-manifest.json` manifest describing what loader module to import to invoke a server action
2. A "loader" entry point that then imports server actions from our internal chunk items
3. Importing the bundled `react-experimental` module instead of regular `react`
4. A little 🪄 pixie dust
5. A small change in the magic comment generated in modules that export server actions

I had to change the magic `__next_internal_action_entry_do_not_use__` comment generated by the server actions transformer. When I traverse the module graph to find all exported actions _after chunking_ has been performed, we no longer have access to the original file name needed to generate the server action's id hash. Adding the filename to comment allows me to recover this without overcomplicating our output pipeline.

Closes WEB-1279
Depends on https://github.com/vercel/turbo/pull/5705

Co-authored-by: Tim Neutkens <6324199+timneutkens@users.noreply.github.com>
Co-authored-by: Jiachi Liu <4800338+huozhi@users.noreply.github.com>
This commit is contained in:
Justin Ridgewell 2023-10-04 19:33:21 -04:00 committed by GitHub
parent ad42b610c2
commit feca3ce21c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 805 additions and 180 deletions

3
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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<String, String>;
pub fn server_actions<C: Comments>(
file_name: &FileName,
config: Config,
@ -33,7 +39,7 @@ pub fn server_actions<C: Comments>(
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<C: Comments>(
})
}
/// 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<C: Comments>(program: &Program, comments: C) -> Option<ActionsMap> {
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<C: Comments> {
#[allow(unused)]
config: Config,
file_name: FileName,
file_name: String,
comments: C,
start_pos: BytePos,
@ -216,7 +252,7 @@ impl<C: Comments> ServerActions<C> {
.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<C: Comments> ServerActions<C> {
.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<C: Comments> VisitMut for ServerActions<C> {
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<C: Comments> VisitMut for ServerActions<C> {
&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<C: Comments> VisitMut for ServerActions<C> {
}
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::<ActionsMap>();
// 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::<Vec<_>>()
.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<Id>) {
}
}
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<Stmt>,
ident: Ident,
bound: Vec<Option<ExprOrSpread>>,
file_name: String,
file_name: &str,
export_name: String,
maybe_orig_action_ident: Option<Ident>,
) {
@ -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.

View file

@ -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"] }

View file

@ -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"] }

View file

@ -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)
}
}

View file

@ -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;

View file

@ -476,6 +476,7 @@ impl Project {
self.env(),
self.server_addr(),
this.define_env.nodejs(),
self.next_config(),
))
}

View file

@ -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<Box<dyn EcmascriptChunkPlaceable>>,
node_root: Vc<FileSystemPath>,
pathname: &str,
page_name: &str,
runtime: NextRuntime,
asset_context: Vc<Box<dyn AssetContext>>,
chunking_context: Vc<Box<dyn EcmascriptChunkingContext>>,
enable_server_actions: Vc<bool>,
) -> Result<(
Option<Vc<Box<dyn EvaluatableAsset>>>,
Vc<Box<dyn OutputAsset>>,
)> {
// 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::<Box<dyn EvaluatableAsset>>(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<FileSystemPath>,
page_name: &str,
actions: Vc<ModuleActionMap>,
asset_context: Vc<Box<dyn AssetContext>>,
) -> Result<Vc<Box<dyn EcmascriptChunkPlaceable>>> {
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::<Box<dyn EcmascriptChunkPlaceable>>(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<FileSystemPath>,
pathname: &str,
page_name: &str,
runtime: NextRuntime,
actions: Vc<ModuleActionMap>,
loader_id: Vc<String>,
) -> Result<Vc<Box<dyn OutputAsset>>> {
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<Box<dyn Module>>) -> Result<Vc<ModuleActionMap>> {
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::<IndexMap<_, _>>();
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<Box<dyn Module>>,
) -> Result<impl Iterator<Item = Vc<Box<dyn Module>>> + 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<Box<dyn Module>>) -> Result<Vc<OptionActionMap>> {
let Some(ecmascript_asset) =
Vc::try_resolve_downcast_type::<EcmascriptModuleAsset>(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<Box<dyn Module>>,
) -> Result<Option<(Vc<Box<dyn Module>>, Vc<ActionMap>)>> {
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<Box<dyn Module>>, Vc<ActionMap>>);
#[turbo_tasks::value_impl]
impl ModuleActionMap {
#[turbo_tasks::function]
pub fn empty() -> Vc<Self> {
Vc::cell(IndexMap::new())
}
}
/// Maps the hashed action id to the action's exported function name.
#[turbo_tasks::value(transparent)]
struct ActionMap(IndexMap<String, String>);
/// An Option wrapper around [ActionMap].
#[turbo_tasks::value(transparent)]
struct OptionActionMap(Option<Vc<ActionMap>>);
#[turbo_tasks::value_impl]
impl OptionActionMap {
#[turbo_tasks::function]
pub fn none() -> Vc<Self> {
Vc::cell(None)
}
}

View file

@ -138,8 +138,13 @@ pub(crate) async fn next_build(options: TransientInstance<BuildOptions>) -> 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::<Box<dyn FileSystem>>(VirtualFileSystem::new());

View file

@ -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 }

View file

@ -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());

View file

@ -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());

View file

@ -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?);

View file

@ -443,6 +443,9 @@ pub struct ExperimentalConfig {
pub optimize_css: Option<serde_json::Value>,
pub next_script_workers: Option<bool>,
pub web_vitals_attribution: Option<Vec<String>>,
/// 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<bool>,
// ---
// UNSUPPORTED
@ -484,9 +487,6 @@ pub struct ExperimentalConfig {
/// directory.
ppr: Option<bool>,
proxy_timeout: Option<f64>,
/// 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<bool>,
/// Allows adjusting body parser size limit for server actions.
server_actions_body_size_limit: Option<SizeLimit>,
/// 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<Self>) -> Result<Vc<bool>> {
Ok(Vc::cell(
self.await?.experimental.server_actions.unwrap_or(false),
))
}
}
fn next_configs() -> Vc<Vec<String>> {

View file

@ -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> = 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<NextConfig>,
) -> Result<()> {
let external_if_node = move |context_dir: Vc<FileSystemPath>, 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) => {}

View file

@ -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<String, ActionManifestEntry>,
/// A map from hashed action name to the runtime module we that exports it.
pub node: HashMap<String, ActionManifestEntry>,
/// A map from hashed action name to the runtime module we that exports it.
pub edge: HashMap<String, ActionManifestEntry>,
}
#[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<String, ActionManifestWorkerEntry>,
pub layer: HashMap<String, ActionLayer>,
}
#[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 {

View file

@ -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

View file

@ -180,12 +180,17 @@ pub async fn get_server_resolve_options_context(
.cell())
}
fn defines(mode: NextMode, define_env: &IndexMap<String, String>) -> CompileTimeDefines {
fn defines(
mode: NextMode,
define_env: &IndexMap<String, String>,
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<String, String>) -> CompileTime
async fn next_server_defines(
mode: NextMode,
define_env: Vc<EnvMap>,
server_actions: Vc<bool>,
) -> Result<Vc<CompileTimeDefines>> {
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<EnvMap>,
server_actions: Vc<bool>,
) -> Result<Vc<FreeVarReferences>> {
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<Box<dyn ProcessEnv>>,
server_addr: Vc<ServerAddr>,
define_env: Vc<EnvMap>,
next_config: Vc<NextConfig>,
) -> Vc<CompileTimeInfo> {
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()
}

View file

@ -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,

View file

@ -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},

View file

@ -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(())
}
}

View file

@ -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"

View file

@ -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

View file

@ -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) {

View file

@ -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.

View file

@ -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<string, PagesManifest>()
const appPathsManifests = new Map<string, PagesManifest>()
const middlewareManifests = new Map<string, MiddlewareManifest>()
const actionManifests = new Map<string, ActionManifest>()
const clientToHmrSubscription = new Map<
ws,
Map<string, AsyncIterator<any>>
@ -542,6 +545,17 @@ async function startWatcher(opts: SetupOpts) {
)
}
async function loadActionManifest(pageName: string): Promise<void> {
actionManifests.set(
pageName,
await loadPartialManifest(
`${SERVER_REFERENCE_MANIFEST}.json`,
pageName,
'app'
)
)
}
const buildingReported = new Set<string>()
async function changeSubscription(
@ -653,6 +667,32 @@ async function startWatcher(opts: SetupOpts) {
return manifest
}
function mergeActionManifests(manifests: Iterable<ActionManifest>) {
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<void> {
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<void> {
// 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)

View file

@ -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 (
<div>
<p>{isClient ? "client" : "server"}</p>
</div>
);
}
`,
],
[
'index.js',
outdent`
import Component from './component'
export default function Mismatch() {
return (
<main>
<Component />
</main>
);
}
`,
],
])
)
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()
})
})
})
}
)

View file

@ -0,0 +1,8 @@
const isClient = typeof window !== 'undefined'
export default function Component() {
return (
<div>
<p>{isClient ? 'client' : 'server'}</p>
</div>
)
}

View file

@ -0,0 +1,8 @@
import Component from './component'
export default function Mismatch() {
return (
<main>
<Component />
</main>
)
}

View file

@ -0,0 +1 @@
module.exports = {}

View file

@ -0,0 +1 @@
export { default } from '../index'

View file

@ -1037,6 +1037,24 @@ export function getSnapshotTestDescribe(variant: TestVariants) {
return shouldSkip ? describe.skip : describe
}
export async function getRedboxComponentStack(
browser: BrowserInterface
): Promise<string> {
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.
*