diff --git a/packages/next-swc/crates/core/src/lib.rs b/packages/next-swc/crates/core/src/lib.rs index d708b107e4..42c9fb72c0 100644 --- a/packages/next-swc/crates/core/src/lib.rs +++ b/packages/next-swc/crates/core/src/lib.rs @@ -59,6 +59,7 @@ pub mod react_server_components; #[cfg(not(target_arch = "wasm32"))] pub mod relay; pub mod remove_console; +pub mod server_actions; pub mod shake_exports; mod top_level_binding_collector; @@ -122,6 +123,9 @@ pub struct TransformOptions { #[serde(default)] pub font_loaders: Option, + + #[serde(default)] + pub server_actions: Option, } pub fn custom_before_pass<'a, C: Comments + 'a>( @@ -252,6 +256,11 @@ where Some(config) => Either::Left(next_font_loaders::next_font_loaders(config.clone())), None => Either::Right(noop()), }, + match &opts.server_actions { + Some(config) => + Either::Left(server_actions::server_actions(&file.name, config.clone())), + None => Either::Right(noop()), + }, ) } diff --git a/packages/next-swc/crates/core/src/server_actions.rs b/packages/next-swc/crates/core/src/server_actions.rs new file mode 100644 index 0000000000..43f7ae7281 --- /dev/null +++ b/packages/next-swc/crates/core/src/server_actions.rs @@ -0,0 +1,392 @@ +use next_binding::swc::core::{ + common::{errors::HANDLER, util::take::Take, FileName, DUMMY_SP}, + ecma::{ + ast::{ + op, ArrayLit, AssignExpr, BlockStmt, CallExpr, ComputedPropName, Decl, ExportDecl, + Expr, ExprStmt, FnDecl, Function, Id, Ident, KeyValueProp, Lit, MemberExpr, MemberProp, + ModuleDecl, ModuleItem, PatOrExpr, Prop, PropName, ReturnStmt, Stmt, Str, VarDecl, + VarDeclKind, VarDeclarator, + }, + atoms::JsWord, + utils::{find_pat_ids, private_ident, quote_ident, ExprFactory}, + visit::{ + as_folder, noop_visit_mut_type, noop_visit_type, visit_obj_and_computed, Fold, Visit, + VisitMut, VisitMutWith, VisitWith, + }, + }, +}; +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Config {} + +pub fn server_actions(file_name: &FileName, config: Config) -> impl VisitMut + Fold { + as_folder(ServerActions { + config, + file_name: file_name.clone(), + in_action_file: false, + in_export_decl: false, + top_level: false, + closure_candidates: Default::default(), + annotations: Default::default(), + extra_items: Default::default(), + }) +} + +struct ServerActions { + #[allow(unused)] + config: Config, + file_name: FileName, + + in_action_file: bool, + in_export_decl: bool, + top_level: bool, + + closure_candidates: Vec, + + annotations: Vec, + extra_items: Vec, +} + +impl VisitMut for ServerActions { + fn visit_mut_export_decl(&mut self, decl: &mut ExportDecl) { + let old = self.in_export_decl; + self.in_export_decl = true; + decl.decl.visit_mut_with(self); + self.in_export_decl = old; + } + + fn visit_mut_fn_decl(&mut self, f: &mut FnDecl) { + { + let old_len = self.closure_candidates.len(); + self.closure_candidates + .extend(find_pat_ids(&f.function.params)); + + f.visit_mut_children_with(self); + + self.closure_candidates.truncate(old_len); + } + + if !(self.in_action_file && self.in_export_decl) { + // Check if the first item is `"use action"`; + if let Some(body) = &mut f.function.body { + if let Some(Stmt::Expr(first)) = body.stmts.first() { + match &*first.expr { + Expr::Lit(Lit::Str(Str { value, .. })) if value == "use action" => {} + _ => return, + } + } else { + return; + } + + body.stmts.remove(0); + } else { + return; + } + } + + if !f.function.is_async { + HANDLER.with(|handler| { + handler + .struct_span_err(f.ident.span, "Server actions must be async") + .emit(); + }); + } + + let action_name: JsWord = format!("$ACTION_{}", f.ident.sym).into(); + let action_ident = private_ident!(action_name.clone()); + + // myAction.$$typeof = Symbol.for('react.action.reference'); + self.annotations.push(annotate( + &f.ident, + "$$typeof", + CallExpr { + span: DUMMY_SP, + callee: quote_ident!("Symbol") + .make_member(quote_ident!("for")) + .as_callee(), + args: vec!["react.action.reference".as_arg()], + type_args: Default::default(), + } + .into(), + )); + + // myAction.$$filepath = '/app/page.tsx'; + self.annotations.push(annotate( + &f.ident, + "$$filepath", + self.file_name.to_string().into(), + )); + + // myAction.$$name = '$ACTION_myAction'; + self.annotations + .push(annotate(&f.ident, "$$name", action_name.into())); + + if self.top_level { + // export const $ACTION_myAction = myAction; + self.extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + declare: Default::default(), + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: action_ident.into(), + init: Some(f.ident.clone().into()), + definite: Default::default(), + }], + })), + }))); + } else { + // Hoist the function to the top level. + + let mut used_ids = idents_used_by(&f.function.body); + + used_ids.retain(|id| self.closure_candidates.contains(id)); + + let closure_arg = private_ident!("closure"); + + f.function.body.visit_mut_with(&mut ClosureReplacer { + closure_arg: &closure_arg, + used_ids: &used_ids, + }); + + // myAction.$$closure = [id1, id2] + self.annotations.push(annotate( + &f.ident, + "$$closure", + ArrayLit { + span: DUMMY_SP, + elems: used_ids + .iter() + .cloned() + .map(|id| Some(id.as_arg())) + .collect(), + } + .into(), + )); + + let call = CallExpr { + span: DUMMY_SP, + callee: action_ident.clone().as_callee(), + args: vec![f + .ident + .clone() + .make_member(quote_ident!("$$closure")) + .as_arg()], + type_args: Default::default(), + }; + + let new_fn = Box::new(Function { + params: f.function.params.clone(), + decorators: f.function.decorators.take(), + span: f.function.span, + body: Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(call.into()), + })], + }), + is_generator: f.function.is_generator, + is_async: f.function.is_async, + type_params: Default::default(), + return_type: Default::default(), + }); + + self.extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: FnDecl { + ident: action_ident, + function: Box::new(Function { + params: vec![closure_arg.into()], + ..*f.function.take() + }), + declare: Default::default(), + } + .into(), + }))); + + f.function = new_fn; + } + } + + fn visit_mut_module_item(&mut self, s: &mut ModuleItem) { + s.visit_mut_children_with(self); + + if self.in_action_file { + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + decl: decl @ Decl::Fn(..), + .. + })) = s + { + *s = ModuleItem::Stmt(Stmt::Decl(decl.take())); + } + } + } + + fn visit_mut_module_items(&mut self, stmts: &mut Vec) { + if let Some(ModuleItem::Stmt(Stmt::Expr(first))) = stmts.first() { + match &*first.expr { + Expr::Lit(Lit::Str(Str { value, .. })) if value == "use action" => { + self.in_action_file = true; + } + _ => {} + } + } + + if self.in_action_file { + stmts.remove(0); + } + + let old_annotations = self.annotations.take(); + + let mut new = Vec::with_capacity(stmts.len()); + for mut stmt in stmts.take() { + self.top_level = true; + stmt.visit_mut_with(self); + + new.push(stmt); + new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); + new.append(&mut self.extra_items); + } + + *stmts = new; + + self.annotations = old_annotations; + } + + fn visit_mut_stmts(&mut self, stmts: &mut Vec) { + let old_top_level = self.top_level; + let old_annotations = self.annotations.take(); + + let mut new = Vec::with_capacity(stmts.len()); + for mut stmt in stmts.take() { + self.top_level = false; + stmt.visit_mut_with(self); + + new.push(stmt); + new.append(&mut self.annotations); + } + + *stmts = new; + + self.annotations = old_annotations; + self.top_level = old_top_level; + } + + noop_visit_mut_type!(); +} + +fn annotate(fn_name: &Ident, field_name: &str, value: Box) -> Stmt { + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: AssignExpr { + span: DUMMY_SP, + op: op!("="), + left: PatOrExpr::Expr(fn_name.clone().make_member(quote_ident!(field_name)).into()), + right: value, + } + .into(), + }) +} + +fn idents_used_by(n: &N) -> Vec +where + N: VisitWith, +{ + let mut v = IdentUsageCollector { + ..Default::default() + }; + n.visit_with(&mut v); + v.ids +} + +#[derive(Default)] +pub(crate) struct IdentUsageCollector { + ids: Vec, +} + +impl Visit for IdentUsageCollector { + noop_visit_type!(); + + visit_obj_and_computed!(); + + fn visit_ident(&mut self, n: &Ident) { + if self.ids.contains(&n.to_id()) { + return; + } + self.ids.push(n.to_id()); + } + + fn visit_member_prop(&mut self, n: &MemberProp) { + if let MemberProp::Computed(..) = n { + n.visit_children_with(self); + } + } + + fn visit_prop_name(&mut self, n: &PropName) { + if let PropName::Computed(..) = n { + n.visit_children_with(self); + } + } +} + +pub(crate) struct ClosureReplacer<'a> { + closure_arg: &'a Ident, + used_ids: &'a [Id], +} + +impl ClosureReplacer<'_> { + fn index(&self, i: &Ident) -> Option { + self.used_ids + .iter() + .position(|used_id| i.sym == used_id.0 && i.span.ctxt == used_id.1) + } +} + +impl VisitMut for ClosureReplacer<'_> { + fn visit_mut_expr(&mut self, e: &mut Expr) { + e.visit_mut_children_with(self); + + if let Expr::Ident(i) = e { + if let Some(index) = self.index(i) { + *e = Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: self.closure_arg.clone().into(), + prop: MemberProp::Computed(ComputedPropName { + span: DUMMY_SP, + expr: index.into(), + }), + }); + } + } + } + + fn visit_mut_prop(&mut self, p: &mut Prop) { + p.visit_mut_children_with(self); + + if let Prop::Shorthand(i) = p { + if let Some(index) = self.index(i) { + *p = Prop::KeyValue(KeyValueProp { + key: PropName::Ident(i.clone()), + value: MemberExpr { + span: DUMMY_SP, + obj: self.closure_arg.clone().into(), + prop: MemberProp::Computed(ComputedPropName { + span: DUMMY_SP, + expr: index.into(), + }), + } + .into(), + }); + } + } + } + + noop_visit_mut_type!(); +} diff --git a/packages/next-swc/crates/core/tests/fixture.rs b/packages/next-swc/crates/core/tests/fixture.rs index b362230d95..ec1351cd07 100644 --- a/packages/next-swc/crates/core/tests/fixture.rs +++ b/packages/next-swc/crates/core/tests/fixture.rs @@ -17,6 +17,7 @@ use next_swc::{ react_server_components::server_components, relay::{relay, Config as RelayConfig, RelayLanguageConfig}, remove_console::remove_console, + server_actions::{self, server_actions}, shake_exports::{shake_exports, Config as ShakeExportsConfig}, }; use std::path::PathBuf; @@ -295,3 +296,20 @@ fn next_font_loaders_fixture(input: PathBuf) { Default::default(), ); } + +#[fixture("tests/fixture/server-actions/**/input.js")] +fn server_actions_fixture(input: PathBuf) { + let output = input.parent().unwrap().join("output.js"); + test_fixture( + syntax(), + &|_tr| { + server_actions( + &FileName::Real("/app/item.js".into()), + server_actions::Config {}, + ) + }, + &input, + &output, + Default::default(), + ); +} diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/1/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/1/input.js new file mode 100644 index 0000000000..b0b7a7ed44 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/1/input.js @@ -0,0 +1,8 @@ +export function Item({ id1, id2 }) { + async function deleteItem() { + "use action"; + await deleteFromDb(id1); + await deleteFromDb(id2); + } + return ; +} \ No newline at end of file diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/1/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/1/output.js new file mode 100644 index 0000000000..2a8001145e --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/1/output.js @@ -0,0 +1,17 @@ +export function Item({ id1 , id2 }) { + async function deleteItem() { + return $ACTION_deleteItem(deleteItem.$$closure); + } + deleteItem.$$typeof = Symbol.for("react.action.reference"); + deleteItem.$$filepath = "/app/item.js"; + deleteItem.$$name = "$ACTION_deleteItem"; + deleteItem.$$closure = [ + id1, + id2 + ]; + return ; +} +export async function $ACTION_deleteItem(closure) { + await deleteFromDb(closure[0]); + await deleteFromDb(closure[1]); +} diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/2/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/2/input.js new file mode 100644 index 0000000000..b71c4d7ec3 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/2/input.js @@ -0,0 +1,8 @@ +async function myAction(a, b, c) { + "use action"; + console.log('a') +} + +export default function Page() { + return ; +} \ No newline at end of file diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/2/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/2/output.js new file mode 100644 index 0000000000..ef6346f84d --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/2/output.js @@ -0,0 +1,10 @@ +async function myAction(a, b, c) { + console.log('a'); +} +myAction.$$typeof = Symbol.for("react.action.reference"); +myAction.$$filepath = "/app/item.js"; +myAction.$$name = "$ACTION_myAction"; +export const $ACTION_myAction = myAction; +export default function Page() { + return ; +} diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/3/input.js b/packages/next-swc/crates/core/tests/fixture/server-actions/3/input.js new file mode 100644 index 0000000000..433b9cadb1 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/3/input.js @@ -0,0 +1,5 @@ +// app/send.ts +"use action"; +export async function myAction(a, b, c) { + console.log('a') +} \ No newline at end of file diff --git a/packages/next-swc/crates/core/tests/fixture/server-actions/3/output.js b/packages/next-swc/crates/core/tests/fixture/server-actions/3/output.js new file mode 100644 index 0000000000..2c58682402 --- /dev/null +++ b/packages/next-swc/crates/core/tests/fixture/server-actions/3/output.js @@ -0,0 +1,8 @@ +// app/send.ts +async function myAction(a, b, c) { + console.log('a'); +} +myAction.$$typeof = Symbol.for("react.action.reference"); +myAction.$$filepath = "/app/item.js"; +myAction.$$name = "$ACTION_myAction"; +export const $ACTION_myAction = myAction; diff --git a/packages/next-swc/crates/core/tests/full.rs b/packages/next-swc/crates/core/tests/full.rs index d45ffe9cc4..9bde0c87f0 100644 --- a/packages/next-swc/crates/core/tests/full.rs +++ b/packages/next-swc/crates/core/tests/full.rs @@ -70,6 +70,7 @@ fn test(input: &Path, minify: bool) { emotion: Some(assert_json("{}")), modularize_imports: None, font_loaders: None, + server_actions: None, }; let options = options.patch(&fm);