diff --git a/Cargo.lock b/Cargo.lock index 1c4fc9f1cfe6..fd835d940036 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3641,6 +3641,7 @@ dependencies = [ "rspack_plugin_real_content_hash", "rspack_plugin_remove_duplicate_modules", "rspack_plugin_remove_empty_chunks", + "rspack_plugin_rsc", "rspack_plugin_rsdoctor", "rspack_plugin_rslib", "rspack_plugin_rstest", @@ -4045,6 +4046,10 @@ version = "0.100.0-alpha.0" dependencies = [ "async-trait", "either", + "hex", + "indoc", + "once_cell", + "regex", "rspack_cacheable", "rspack_core", "rspack_error", @@ -4058,6 +4063,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "sha2", "stacker", "sugar_path", "swc", @@ -4755,6 +4761,38 @@ dependencies = [ "tracing", ] +[[package]] +name = "rspack_plugin_rsc" +version = "0.100.0-alpha.0" +dependencies = [ + "async-trait", + "atomic_refcell", + "derive_more", + "form_urlencoded", + "futures", + "indoc", + "once_cell", + "regex", + "rspack-allocative", + "rspack_cacheable", + "rspack_collections", + "rspack_core", + "rspack_error", + "rspack_hash", + "rspack_hook", + "rspack_loader_runner", + "rspack_plugin_javascript", + "rspack_util", + "rustc-hash", + "serde", + "serde_json", + "simd-json", + "swc_core", + "tokio", + "tracing", + "urlencoding", +] + [[package]] name = "rspack_plugin_rsdoctor" version = "0.100.0-alpha.0" diff --git a/Cargo.toml b/Cargo.toml index 4b1684cd24f8..c3c8ea5096e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ dyn-clone = { version = "1.0.20", default-features = false } either = { version = "1.15.0", default-features = false } enum-tag = { version = "0.3.0", default-features = false } fast-glob = { version = "1.0.0", default-features = false } +form_urlencoded = { version = "1.2.2", default-features = false } futures = { version = "0.3.31", default-features = false, features = ["std"] } glob = { version = "0.3.3", default-features = false } hashlink = { version = "0.10.0", default-features = false } @@ -224,6 +225,7 @@ rspack_plugin_progress = { version = "=0.100.0-alpha.0", path = rspack_plugin_real_content_hash = { version = "=0.100.0-alpha.0", path = "crates/rspack_plugin_real_content_hash", default-features = false } rspack_plugin_remove_duplicate_modules = { version = "=0.100.0-alpha.0", path = "crates/rspack_plugin_remove_duplicate_modules", default-features = false } rspack_plugin_remove_empty_chunks = { version = "=0.100.0-alpha.0", path = "crates/rspack_plugin_remove_empty_chunks", default-features = false } +rspack_plugin_rsc = { version = "=0.100.0-alpha.0", path = "crates/rspack_plugin_rsc", default-features = false } rspack_plugin_rsdoctor = { version = "=0.100.0-alpha.0", path = "crates/rspack_plugin_rsdoctor", default-features = false } rspack_plugin_rslib = { version = "=0.100.0-alpha.0", path = "crates/rspack_plugin_rslib", default-features = false } rspack_plugin_rstest = { version = "=0.100.0-alpha.0", path = "crates/rspack_plugin_rstest", default-features = false } diff --git a/crates/node_binding/napi-binding.d.ts b/crates/node_binding/napi-binding.d.ts index 73b1767af2c5..35a3e63a482f 100644 --- a/crates/node_binding/napi-binding.d.ts +++ b/crates/node_binding/napi-binding.d.ts @@ -95,6 +95,8 @@ export interface JsSource { source: string | Buffer map?: string } + +export type CompilerId = void; /* -- banner.d.ts end -- */ /* -- napi-rs generated below -- */ @@ -334,6 +336,7 @@ export declare class JsCompiler { rebuild(changed_files: string[], removed_files: string[], callback: (err: null | Error) => void): void close(): Promise getVirtualFileStore(): VirtualFileStore | null + getCompilerId(): ExternalObject } export declare class JsContextModuleFactoryAfterResolveData { @@ -361,6 +364,10 @@ export declare class JsContextModuleFactoryBeforeResolveData { set recursive(recursive: boolean) } +export declare class JsCoordinator { + constructor(getServerCompilerIdJsFn: () => ExternalObject) +} + export declare class JsDependencies { get fileDependencies(): Array get addedFileDependencies(): Array @@ -602,7 +609,9 @@ export declare enum BuiltinPluginName { LazyCompilationPlugin = 'LazyCompilationPlugin', ModuleInfoHeaderPlugin = 'ModuleInfoHeaderPlugin', HttpUriPlugin = 'HttpUriPlugin', - CssChunkingPlugin = 'CssChunkingPlugin' + CssChunkingPlugin = 'CssChunkingPlugin', + RscServerPlugin = 'RscServerPlugin', + RscClientPlugin = 'RscClientPlugin' } export declare function cleanupGlobalTrace(): void @@ -1028,6 +1037,15 @@ export interface JsResourceData { descriptionFilePath?: string } +export interface JsRscClientPluginOptions { + coordinator: JsCoordinator +} + +export interface JsRscServerPluginOptions { + coordinator: JsCoordinator + onServerComponentChanges?: (() => void) | undefined | null +} + export interface JsRsdoctorAsset { ukey: number path: string diff --git a/crates/node_binding/rspack.wasi-browser.js b/crates/node_binding/rspack.wasi-browser.js index e3e5c0a99d48..fdade4bda847 100644 --- a/crates/node_binding/rspack.wasi-browser.js +++ b/crates/node_binding/rspack.wasi-browser.js @@ -85,6 +85,7 @@ export const JsCompilation = __napiModule.exports.JsCompilation export const JsCompiler = __napiModule.exports.JsCompiler export const JsContextModuleFactoryAfterResolveData = __napiModule.exports.JsContextModuleFactoryAfterResolveData export const JsContextModuleFactoryBeforeResolveData = __napiModule.exports.JsContextModuleFactoryBeforeResolveData +export const JsCoordinator = __napiModule.exports.JsCoordinator export const JsDependencies = __napiModule.exports.JsDependencies export const JsEntries = __napiModule.exports.JsEntries export const JsExportsInfo = __napiModule.exports.JsExportsInfo diff --git a/crates/node_binding/rspack.wasi.cjs b/crates/node_binding/rspack.wasi.cjs index a251ce4d0d7d..59a540c241a8 100644 --- a/crates/node_binding/rspack.wasi.cjs +++ b/crates/node_binding/rspack.wasi.cjs @@ -130,6 +130,7 @@ module.exports.JsCompilation = __napiModule.exports.JsCompilation module.exports.JsCompiler = __napiModule.exports.JsCompiler module.exports.JsContextModuleFactoryAfterResolveData = __napiModule.exports.JsContextModuleFactoryAfterResolveData module.exports.JsContextModuleFactoryBeforeResolveData = __napiModule.exports.JsContextModuleFactoryBeforeResolveData +module.exports.JsCoordinator = __napiModule.exports.JsCoordinator module.exports.JsDependencies = __napiModule.exports.JsDependencies module.exports.JsEntries = __napiModule.exports.JsEntries module.exports.JsExportsInfo = __napiModule.exports.JsExportsInfo diff --git a/crates/node_binding/scripts/banner.d.ts b/crates/node_binding/scripts/banner.d.ts index 0087c41122eb..9534ec755f3b 100644 --- a/crates/node_binding/scripts/banner.d.ts +++ b/crates/node_binding/scripts/banner.d.ts @@ -95,6 +95,8 @@ export interface JsSource { source: string | Buffer map?: string } + +export type CompilerId = void; /* -- banner.d.ts end -- */ /* -- napi-rs generated below -- */ diff --git a/crates/rspack_binding_api/Cargo.toml b/crates/rspack_binding_api/Cargo.toml index c743ac1d04fa..1c4463c0a608 100644 --- a/crates/rspack_binding_api/Cargo.toml +++ b/crates/rspack_binding_api/Cargo.toml @@ -40,6 +40,7 @@ rspack_paths = { workspace = true } rspack_plugin_esm_library = { workspace = true } rspack_plugin_html = { workspace = true } rspack_plugin_javascript = { workspace = true } +rspack_plugin_rsc = { workspace = true } rspack_plugin_rsdoctor = { workspace = true } rspack_plugin_rslib = { workspace = true } rspack_plugin_rstest = { workspace = true } diff --git a/crates/rspack_binding_api/src/lib.rs b/crates/rspack_binding_api/src/lib.rs index 631c836210e8..75033a5288b7 100644 --- a/crates/rspack_binding_api/src/lib.rs +++ b/crates/rspack_binding_api/src/lib.rs @@ -206,6 +206,8 @@ impl JsCompiler { rspack_loader_lightningcss::LightningcssLoaderPlugin::new(), )); plugins.push(Box::new(rspack_loader_swc::SwcLoaderPlugin::new())); + plugins.push(Box::new(rspack_plugin_rsc::ClientEntryLoaderPlugin::new())); + plugins.push(Box::new(rspack_plugin_rsc::ActionEntryLoaderPlugin::new())); plugins.push(Box::new( rspack_loader_react_refresh::ReactRefreshLoaderPlugin::new(), )); @@ -416,6 +418,11 @@ impl JsCompiler { .as_ref() .map(|store| JsVirtualFileStore::new(store.clone())) } + + #[napi] + pub fn get_compiler_id(&self) -> External { + External::new(self.compiler.id()) + } } struct RunGuard { diff --git a/crates/rspack_binding_api/src/plugins/mod.rs b/crates/rspack_binding_api/src/plugins/mod.rs index 41da611cb5c0..25c0fe4a6f67 100644 --- a/crates/rspack_binding_api/src/plugins/mod.rs +++ b/crates/rspack_binding_api/src/plugins/mod.rs @@ -2,9 +2,11 @@ mod interceptor; mod js_cleanup_plugin; mod js_hooks_plugin; mod js_loader; +mod rsc; pub use js_cleanup_plugin::*; pub use js_hooks_plugin::*; pub(super) use js_loader::{JsLoaderItem, JsLoaderRspackPlugin, JsLoaderRunnerGetter}; pub mod buildtime_plugins; pub use interceptor::*; +pub use rsc::{JsCoordinator, JsRscClientPluginOptions, JsRscServerPluginOptions}; diff --git a/crates/rspack_binding_api/src/plugins/rsc.rs b/crates/rspack_binding_api/src/plugins/rsc.rs new file mode 100644 index 000000000000..2938dba0b962 --- /dev/null +++ b/crates/rspack_binding_api/src/plugins/rsc.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use futures::future::BoxFuture; +use napi::{ + Env, Status, + bindgen_prelude::{ + ClassInstance, Either3, External, ExternalRef, FromNapiValue, Function, JsObjectValue, Null, + Object, Promise, Reference, Undefined, WeakReference, + }, + threadsafe_function::ThreadsafeFunction, +}; +use once_cell::unsync::OnceCell; +use rspack_core::{Compiler, CompilerId}; +use rspack_error::ToStringResultToRspackResultExt; +use rspack_plugin_rsc::{Coordinator, RscClientPluginOptions, RscServerPluginOptions}; + +use crate::JsCompiler; + +type InvalidateTsFn = Arc>; + +#[napi] +pub struct JsCoordinator { + i: Arc, +} + +#[napi] +impl JsCoordinator { + #[napi(constructor)] + pub fn new( + get_server_compiler_id_js_fn: Function<'static, (), &'static External>, + ) -> napi::Result { + let get_server_compiler_id = { + let ts_fn = Arc::new( + get_server_compiler_id_js_fn + .build_threadsafe_function::<()>() + .callee_handled::() + .max_queue_size::<0>() + .weak::() + .build()?, + ); + Box::new( + move || -> BoxFuture<'static, rspack_error::Result> { + let ts_fn = ts_fn.clone(); + Box::pin(async move { + let external = ts_fn.call_async(()).await.to_rspack_result()?; + Ok(**external) + }) + }, + ) + }; + + Ok(Self { + i: Arc::new(Coordinator::new(get_server_compiler_id)), + }) + } +} + +impl From<&JsCoordinator> for Arc { + fn from(value: &JsCoordinator) -> Self { + value.i.clone() + } +} + +#[napi(object, object_to_js = false)] +pub struct JsRscClientPluginOptions<'a> { + pub coordinator: ClassInstance<'a, JsCoordinator>, +} + +impl From<&JsRscClientPluginOptions<'_>> for RscClientPluginOptions { + fn from(value: &JsRscClientPluginOptions) -> Self { + Self { + coordinator: value.coordinator.i.clone(), + } + } +} + +#[napi(object, object_to_js = false)] +pub struct JsRscServerPluginOptions<'a> { + pub coordinator: ClassInstance<'a, JsCoordinator>, + pub on_server_component_changes: Option, Undefined, Null>>, +} + +impl TryFrom<&JsRscServerPluginOptions<'_>> for RscServerPluginOptions { + type Error = napi::Error; + + fn try_from(value: &JsRscServerPluginOptions) -> napi::Result { + let on_server_component_changes: Option< + Box BoxFuture<'static, rspack_error::Result<()>> + Sync + Send>, + > = match value.on_server_component_changes { + Some(Either3::A(js_fn)) => { + let ts_fn = Arc::new( + js_fn + .build_threadsafe_function::<()>() + .callee_handled::() + .max_queue_size::<0>() + .weak::() + .build()?, + ); + Some(Box::new( + move || -> BoxFuture<'static, rspack_error::Result<()>> { + let ts_fn = ts_fn.clone(); + Box::pin(async move { ts_fn.call_async(()).await.to_rspack_result() }) + }, + )) + } + _ => None, + }; + + Ok(Self { + coordinator: value.coordinator.i.clone(), + on_server_component_changes, + }) + } +} diff --git a/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs b/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs index 84c0b5819ee6..ee98fe31b7d5 100644 --- a/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs +++ b/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs @@ -26,7 +26,7 @@ use std::cell::RefCell; use napi::{ Either, Env, Unknown, - bindgen_prelude::{FromNapiValue, JsObjectValue, Object}, + bindgen_prelude::{ClassInstance, FromNapiValue, JsObjectValue, Object}, }; use napi_derive::napi; use raw_dll::{RawDllReferenceAgencyPluginOptions, RawFlagAllModulesAsUsedPluginOptions}; @@ -85,6 +85,7 @@ use rspack_plugin_no_emit_on_errors::NoEmitOnErrorsPlugin; use rspack_plugin_real_content_hash::RealContentHashPlugin; use rspack_plugin_remove_duplicate_modules::RemoveDuplicateModulesPlugin; use rspack_plugin_remove_empty_chunks::RemoveEmptyChunksPlugin; +use rspack_plugin_rsc::{RscClientPlugin, RscServerPlugin}; use rspack_plugin_rslib::RslibPlugin; use rspack_plugin_runtime::{ ArrayPushCallbackChunkFormatPlugin, BundlerInfoPlugin, ChunkPrefetchPreloadPlugin, @@ -126,7 +127,10 @@ use self::{ }; use crate::{ options::entry::JsEntryPluginOptions, - plugins::{JsLoaderRspackPlugin, JsLoaderRunnerGetter}, + plugins::{ + JsCoordinator, JsLoaderRspackPlugin, JsLoaderRunnerGetter, JsRscClientPluginOptions, + JsRscServerPluginOptions, + }, raw_options::{ RawDynamicEntryPluginOptions, RawEvalDevToolModulePluginOptions, RawExternalItemWrapper, RawExternalsPluginOptions, RawHttpExternalsRspackPluginOptions, RawSplitChunksOptions, @@ -236,6 +240,10 @@ pub enum BuiltinPluginName { ModuleInfoHeaderPlugin, HttpUriPlugin, CssChunkingPlugin, + + // react server components + RscServerPlugin, + RscClientPlugin, } #[doc(hidden)] @@ -839,6 +847,16 @@ impl<'a> BuiltinPlugin<'a> { .map_err(|report| napi::Error::from_reason(report.to_string()))?; plugins.push(CssChunkingPlugin::new(options.into()).boxed()); } + BuiltinPluginName::RscServerPlugin => { + let options = &downcast_into::(self.options) + .map_err(|report| napi::Error::from_reason(report.to_string()))?; + plugins.push(RscServerPlugin::new(options.try_into()?).boxed()); + } + BuiltinPluginName::RscClientPlugin => { + let options = &downcast_into::(self.options) + .map_err(|report| napi::Error::from_reason(report.to_string()))?; + plugins.push(RscClientPlugin::new(options.into()).boxed()); + } } Ok(()) } diff --git a/crates/rspack_binding_api/src/swc.rs b/crates/rspack_binding_api/src/swc.rs index 6f2b0d8d4e58..39bb5c1e8a04 100644 --- a/crates/rspack_binding_api/src/swc.rs +++ b/crates/rspack_binding_api/src/swc.rs @@ -1,10 +1,14 @@ +use std::{rc::Rc, sync::Arc}; + use napi::bindgen_prelude::within_runtime_if_available; use rspack_javascript_compiler::{ JavaScriptCompiler, TransformOutput as CompilerTransformOutput, minify::JsMinifyOptions, transform::SwcOptions, }; use rspack_util::source_map::SourceMapKind; -use swc_core::{base::config::SourceMapsConfig, ecma::ast::noop_pass}; +use swc_core::{ + base::config::SourceMapsConfig, common::comments::SingleThreadedComments, ecma::ast::noop_pass, +}; #[napi(object)] pub struct TransformOutput { @@ -52,14 +56,16 @@ fn _transform(source: String, options: String) -> napi::Result } let compiler = JavaScriptCompiler::new(); + let comments = Rc::new(SingleThreadedComments::default()); let module_source_map_kind = _to_source_map_kind(options.source_maps.clone()); compiler .transform( source, - Some(swc_core::common::FileName::Real( + Some(Arc::new(swc_core::common::FileName::Real( options.filename.clone().into(), - )), + ))), + comments, options, Some(module_source_map_kind), |_, _| {}, diff --git a/crates/rspack_cacheable/src/with/as_preset/swc.rs b/crates/rspack_cacheable/src/with/as_preset/swc.rs index 9dda30a20992..0b640cf2eabb 100644 --- a/crates/rspack_cacheable/src/with/as_preset/swc.rs +++ b/crates/rspack_cacheable/src/with/as_preset/swc.rs @@ -1,11 +1,12 @@ use rkyv::{ Place, rancor::{Fallible, Source}, - ser::Writer, + ser::{Allocator, Writer}, string::{ArchivedString, StringResolver}, + vec::{ArchivedVec, VecResolver}, with::{ArchiveWith, DeserializeWith, SerializeWith}, }; -use swc_core::ecma::atoms::Atom; +use swc_core::{atoms::Wtf8Atom, ecma::atoms::Atom}; use super::AsPreset; @@ -39,3 +40,34 @@ where Ok(Atom::from(field.as_str())) } } + +impl ArchiveWith for AsPreset { + type Archived = ArchivedVec; + type Resolver = VecResolver; + + #[inline] + fn resolve_with(field: &Wtf8Atom, resolver: Self::Resolver, out: Place) { + ArchivedVec::resolve_from_len(field.as_bytes().len(), resolver, out); + } +} + +impl SerializeWith for AsPreset +where + S: Fallible + Allocator + Writer + ?Sized, + S::Error: Source, +{ + #[inline] + fn serialize_with(field: &Wtf8Atom, serializer: &mut S) -> Result { + ArchivedVec::serialize_from_slice(field.as_bytes(), serializer) + } +} + +impl DeserializeWith, Wtf8Atom, D> for AsPreset +where + D: ?Sized + Fallible, +{ + #[inline] + fn deserialize_with(field: &ArchivedVec, _: &mut D) -> Result { + Ok(unsafe { Wtf8Atom::from_bytes_unchecked(field) }) + } +} diff --git a/crates/rspack_core/src/compiler/mod.rs b/crates/rspack_core/src/compiler/mod.rs index 16accc7b929a..54d216220d6e 100644 --- a/crates/rspack_core/src/compiler/mod.rs +++ b/crates/rspack_core/src/compiler/mod.rs @@ -2,6 +2,7 @@ mod rebuild; use std::sync::{Arc, atomic::AtomicU32}; use futures::future::join_all; +use rspack_cacheable::cacheable; use rspack_error::Result; use rspack_fs::{IntermediateFileSystem, NativeFileSystem, ReadableFileSystem, WritableFileSystem}; use rspack_hook::define_hook; @@ -38,6 +39,8 @@ define_hook!(CompilerEmit: Series(compilation: &mut Compilation)); define_hook!(CompilerAfterEmit: Series(compilation: &mut Compilation)); define_hook!(CompilerAssetEmitted: Series(compilation: &Compilation, filename: &str, info: &AssetEmittedInfo)); define_hook!(CompilerClose: Series(compilation: &Compilation)); +define_hook!(CompilerDone: Series(compilation: &Compilation)); +define_hook!(CompilerFailed: Series(compilation: &Compilation)); #[derive(Debug, Default)] pub struct CompilerHooks { @@ -50,10 +53,13 @@ pub struct CompilerHooks { pub after_emit: CompilerAfterEmitHook, pub asset_emitted: CompilerAssetEmittedHook, pub close: CompilerCloseHook, + pub done: CompilerDoneHook, + pub failed: CompilerFailedHook, } static COMPILER_ID: AtomicU32 = AtomicU32::new(0); +#[cacheable] #[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)] pub struct CompilerId(u32); @@ -200,11 +206,31 @@ impl Compiler { self.build().await?; Ok(()) } + pub async fn build(&mut self) -> Result<()> { let compiler_context = self.compiler_context.clone(); - within_compiler_context(compiler_context, self.build_inner()).await?; - Ok(()) + match within_compiler_context(compiler_context, self.build_inner()).await { + Ok(_) => { + self + .plugin_driver + .compiler_hooks + .done + .call(&self.compilation) + .await?; + Ok(()) + } + Err(e) => { + self + .plugin_driver + .compiler_hooks + .failed + .call(&self.compilation) + .await?; + Err(e) + } + } } + #[instrument("Compiler:build",target=TRACING_BENCH_TARGET, skip_all)] async fn build_inner(&mut self) -> Result<()> { // TODO: clear the outdated cache entries in resolver, diff --git a/crates/rspack_core/src/compiler/rebuild.rs b/crates/rspack_core/src/compiler/rebuild.rs index 74c2eb94199c..7e7da59e9fb0 100644 --- a/crates/rspack_core/src/compiler/rebuild.rs +++ b/crates/rspack_core/src/compiler/rebuild.rs @@ -23,13 +23,33 @@ impl Compiler { changed_files: std::collections::HashSet, deleted_files: std::collections::HashSet, ) -> Result<()> { - within_compiler_context( + match within_compiler_context( self.compiler_context.clone(), self.rebuild_inner(changed_files, deleted_files), ) - .await?; - Ok(()) + .await + { + Ok(_) => { + self + .plugin_driver + .compiler_hooks + .done + .call(&self.compilation) + .await?; + Ok(()) + } + Err(e) => { + self + .plugin_driver + .compiler_hooks + .failed + .call(&self.compilation) + .await?; + Err(e) + } + } } + #[tracing::instrument("Compiler:rebuild", skip_all, fields( compiler.changed_files = ?changed_files.iter().cloned().collect::>(), compiler.deleted_files = ?deleted_files.iter().cloned().collect::>() diff --git a/crates/rspack_core/src/module.rs b/crates/rspack_core/src/module.rs index 691db8f2d3ad..cf4c4b358280 100644 --- a/crates/rspack_core/src/module.rs +++ b/crates/rspack_core/src/module.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use json::JsonValue; use rspack_cacheable::{ cacheable, cacheable_dyn, - with::{AsInner, AsInnerConverter, AsOption, AsPreset, AsVec}, + with::{AsInner, AsInnerConverter, AsMap, AsOption, AsPreset, AsVec}, }; use rspack_collections::{Identifiable, Identifier, IdentifierMap, IdentifierSet}; use rspack_error::{Diagnosable, Result}; @@ -22,10 +22,12 @@ use rspack_sources::BoxSource; use rspack_util::{ atom::Atom, ext::{AsAny, DynHash}, + fx_hash::FxIndexMap, source_map::ModuleSourceMapConfig, }; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use serde::Serialize; +use swc_core::atoms::Wtf8Atom; use crate::{ AsyncDependenciesBlock, BindingCell, BoxDependency, BoxDependencyTemplate, BoxModuleDependency, @@ -49,6 +51,38 @@ pub struct BuildContext { pub fs: Arc, } +#[cacheable] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RscModuleType { + /// Represents a server entry module with "use server-entry" directive. + /// + /// Transformation flow: + /// 1. Original module with "use server-entry" is transformed into a proxy module + /// 2. The proxy module (with `module_type = ServerEntry`) imports the original implementation + /// 3. The original implementation module may resulting in `module_type = Client` + /// + /// Note: "use server" and "use client" directives can coexist in the same file. + ServerEntry, + Server, + Client, +} + +#[cacheable] +#[derive(Debug, Clone)] +pub struct RscMeta { + pub module_type: RscModuleType, + + #[cacheable(with=AsVec)] + pub server_refs: Vec, + + #[cacheable(with=AsVec)] + pub client_refs: Vec, + pub is_cjs: bool, + + #[cacheable(with=AsMap)] + pub action_ids: FxIndexMap, +} + #[cacheable] #[derive(Debug, Clone)] pub struct BuildInfo { @@ -76,6 +110,7 @@ pub struct BuildInfo { pub module: bool, pub inline_exports: bool, pub collected_typescript_info: Option, + pub rsc: Option, /// Stores external fields from the JS side (Record), /// while other properties are stored in KnownBuildInfo. #[cacheable(with=AsPreset)] @@ -105,6 +140,7 @@ impl Default for BuildInfo { module: false, inline_exports: false, collected_typescript_info: None, + rsc: None, extras: Default::default(), } } diff --git a/crates/rspack_core/src/module_graph/mod.rs b/crates/rspack_core/src/module_graph/mod.rs index c030127094f2..c6b76b01a13e 100644 --- a/crates/rspack_core/src/module_graph/mod.rs +++ b/crates/rspack_core/src/module_graph/mod.rs @@ -755,6 +755,13 @@ impl ModuleGraph { self.inner.connections.get(dependency_id) } + pub fn get_resolved_module(&self, dependency_id: &DependencyId) -> Option<&ModuleIdentifier> { + match self.connection_by_dependency_id(dependency_id) { + Some(connection) => Some(&connection.resolved_module), + None => None, + } + } + pub fn connection_by_dependency_id_mut( &mut self, dependency_id: &DependencyId, diff --git a/crates/rspack_core/src/runtime_globals.rs b/crates/rspack_core/src/runtime_globals.rs index c325e1de89ce..86e7b4193aca 100644 --- a/crates/rspack_core/src/runtime_globals.rs +++ b/crates/rspack_core/src/runtime_globals.rs @@ -269,6 +269,9 @@ bitflags! { // rspack only const ASYNC_STARTUP = 1 << 73; + + // react server component + const RSC_MANIFEST = 1 << 74; } } @@ -360,6 +363,8 @@ pub fn runtime_globals_to_string( RuntimeGlobals::HAS_CSS_MODULES => "has css modules".to_string(), RuntimeGlobals::ASYNC_STARTUP => format!("{scope_name}.asyncStartup"), RuntimeGlobals::HAS_FETCH_PRIORITY => "has fetch priority".to_string(), + + RuntimeGlobals::RSC_MANIFEST => format!("{scope_name}.rscM"), RuntimeGlobals::TO_BINARY => format!("{scope_name}.tb"), _ => unreachable!(), } diff --git a/crates/rspack_javascript_compiler/examples/transform.rs b/crates/rspack_javascript_compiler/examples/transform.rs index 867b5ea7ae72..6e884334c8eb 100644 --- a/crates/rspack_javascript_compiler/examples/transform.rs +++ b/crates/rspack_javascript_compiler/examples/transform.rs @@ -1,13 +1,19 @@ +use std::{rc::Rc, sync::Arc}; + use rspack_javascript_compiler::JavaScriptCompiler; -use swc_core::ecma::ast::noop_pass; +use swc_core::{common::comments::SingleThreadedComments, ecma::ast::noop_pass}; fn main() { let source = "const a = 10;"; let compiler = JavaScriptCompiler::new(); + let comments = Rc::new(SingleThreadedComments::default()); let s = compiler.transform( source, - Some(swc_core::common::FileName::Custom("test.js".to_string())), + Some(Arc::new(swc_core::common::FileName::Custom( + "test.js".to_string(), + ))), + comments, Default::default(), None, |_, _| {}, diff --git a/crates/rspack_javascript_compiler/src/compiler/transform.rs b/crates/rspack_javascript_compiler/src/compiler/transform.rs index fcc52143f25a..e32ba6c8187c 100644 --- a/crates/rspack_javascript_compiler/src/compiler/transform.rs +++ b/crates/rspack_javascript_compiler/src/compiler/transform.rs @@ -46,10 +46,12 @@ use super::{ impl JavaScriptCompiler { /// Transforms the given JavaScript source code according to the provided options and source map kind. + #[allow(clippy::too_many_arguments)] pub fn transform<'a, S, P>( &self, source: S, - filename: Option, + filename: Option>, + comments: std::rc::Rc, options: SwcOptions, module_source_map_kind: Option, inspect_parsed_ast: impl FnOnce(&Program, Mark), @@ -61,8 +63,9 @@ impl JavaScriptCompiler { { let fm = self .cm - .new_source_file(filename.unwrap_or(FileName::Anon).into(), source.into()); - let javascript_transformer = JavaScriptTransformer::new(self.cm.clone(), fm, self, options)?; + .new_source_file(filename.unwrap_or(Arc::new(FileName::Anon)), source.into()); + let javascript_transformer = + JavaScriptTransformer::new(self.cm.clone(), fm, comments, self, options)?; javascript_transformer.transform(inspect_parsed_ast, before_pass, module_source_map_kind) } @@ -71,7 +74,7 @@ impl JavaScriptCompiler { struct JavaScriptTransformer<'a> { cm: Arc, fm: Arc, - comments: SingleThreadedComments, + comments: std::rc::Rc, options: SwcOptions, javascript_compiler: &'a JavaScriptCompiler, helpers: Helpers, @@ -84,6 +87,7 @@ impl<'a> JavaScriptTransformer<'a> { pub fn new( cm: Arc, fm: Arc, + comments: std::rc::Rc, compiler: &'a JavaScriptCompiler, mut options: SwcOptions, ) -> Result { @@ -95,7 +99,6 @@ impl<'a> JavaScriptTransformer<'a> { }); let config = get_swc_config_from_file(&fm.name); - let comments = SingleThreadedComments::default(); let helpers = GLOBALS.set(&compiler.globals, || { let mut external_helpers = options.config.jsc.external_helpers; external_helpers.merge(config.jsc.external_helpers); diff --git a/crates/rspack_loader_swc/Cargo.toml b/crates/rspack_loader_swc/Cargo.toml index 49961f89feff..8bf1dfc2f548 100644 --- a/crates/rspack_loader_swc/Cargo.toml +++ b/crates/rspack_loader_swc/Cargo.toml @@ -11,8 +11,6 @@ ignored = ["swc"] [features] default = [] plugin = [ - "rspack_util/plugin", - # plugin_transform_host_native cannot be enabled directly to avoid wasmer dependency "swc_core/__plugin_transform_host", "swc_core/__plugin_transform_host_schema_v1", @@ -22,6 +20,10 @@ plugin = [ [dependencies] async-trait = { workspace = true } either = { workspace = true } +hex = { workspace = true } +indoc = { workspace = true } +once_cell = { workspace = true } +regex = { workspace = true } rspack_cacheable = { workspace = true } rspack_core = { workspace = true } rspack_error = { workspace = true } @@ -30,11 +32,12 @@ rspack_javascript_compiler = { workspace = true } rspack_loader_runner = { workspace = true } rspack_swc_plugin_import = { workspace = true } rspack_swc_plugin_ts_collector = { workspace = true } -rspack_util = { workspace = true, optional = true } +rspack_util = { workspace = true } rspack_workspace = { workspace = true } rustc-hash = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } sugar_path = { workspace = true } swc = { workspace = true, features = ["manual-tokio-runtime"] } swc_config = { workspace = true } diff --git a/crates/rspack_loader_swc/src/lib.rs b/crates/rspack_loader_swc/src/lib.rs index 0a7f45a45f7c..b83495b65dcb 100644 --- a/crates/rspack_loader_swc/src/lib.rs +++ b/crates/rspack_loader_swc/src/lib.rs @@ -1,15 +1,18 @@ +#![feature(box_patterns)] + mod collect_ts_info; mod options; mod plugin; +mod rsc_transforms; mod transformer; -use std::{default::Default, path::Path}; +use std::{cell::RefCell, default::Default, path::Path, rc::Rc, sync::Arc}; use options::SwcCompilerOptionsWithAdditional; pub use options::SwcLoaderJsOptions; pub use plugin::SwcLoaderPlugin; use rspack_cacheable::{cacheable, cacheable_dyn}; -use rspack_core::{COLLECTED_TYPESCRIPT_INFO_PARSE_META_KEY, Mode, RunnerContext}; +use rspack_core::{COLLECTED_TYPESCRIPT_INFO_PARSE_META_KEY, Mode, Module, RscMeta, RunnerContext}; use rspack_error::{Diagnostic, Error, Result}; use rspack_javascript_compiler::{JavaScriptCompiler, TransformOutput}; use rspack_loader_runner::{Identifier, Loader, LoaderContext}; @@ -20,10 +23,14 @@ use sugar_path::SugarPath; use swc_config::{merge::Merge, types::MergingOption}; use swc_core::{ base::config::{InputSourceMap, TransformConfig}, - common::{FileName, SyntaxContext}, + common::{FileName, SyntaxContext, comments::SingleThreadedComments}, + ecma::ast::noop_pass, }; -use crate::collect_ts_info::collect_typescript_info; +use crate::{ + collect_ts_info::collect_typescript_info, + rsc_transforms::{rsc_pass, to_module_ref}, +}; #[cacheable] #[derive(Debug)] @@ -106,12 +113,14 @@ impl SwcLoader { }; let javascript_compiler = JavaScriptCompiler::new(); - let filename = FileName::Real(resource_path.clone().into_std_path_buf()); + let filename = Arc::new(FileName::Real(resource_path.clone().into_std_path_buf())); + let comments = Rc::new(SingleThreadedComments::default()); let source = content.into_string_lossy(); let is_typescript = matches!(swc_options.config.jsc.syntax, Some(syntax) if syntax.typescript()); let mut collected_ts_info = None; + let rsc_meta: RefCell> = Default::default(); let TransformOutput { code, @@ -119,7 +128,8 @@ impl SwcLoader { diagnostics, } = javascript_compiler.transform( source, - Some(filename), + Some(filename.clone()), + comments.clone(), swc_options, Some(loader_context.context.source_map_kind), |program, unresolved_mark| { @@ -135,13 +145,42 @@ impl SwcLoader { options, )); }, - |_| transformer::transform(&self.options_with_additional.rspack_experiments), + |_| { + ( + if self + .options_with_additional + .rspack_experiments + .react_server_components + { + swc_core::common::pass::Either::Left(rsc_pass( + loader_context, + filename, + resource_path.as_str(), + comments, + &rsc_meta, + )) + } else { + swc_core::common::pass::Either::Right(noop_pass()) + }, + transformer::transform(&self.options_with_additional.rspack_experiments), + ) + }, )?; for diagnostic in diagnostics { loader_context.emit_diagnostic(Error::warning(diagnostic).into()); } + if let Some(rsc) = rsc_meta.borrow_mut().take() { + let module = &mut loader_context.context.module; + module.build_info_mut().rsc = Some(rsc); + // TODO: move to_module_ref into rsc transforms + if let Some(code) = to_module_ref(module)? { + loader_context.finish_with(code); + return Ok(()); + } + } + if let Some(collected_ts_info) = collected_ts_info { loader_context.parse_meta.insert( COLLECTED_TYPESCRIPT_INFO_PARSE_META_KEY.to_string(), diff --git a/crates/rspack_loader_swc/src/options.rs b/crates/rspack_loader_swc/src/options.rs index 6ff91f83ecfc..ac853dcea862 100644 --- a/crates/rspack_loader_swc/src/options.rs +++ b/crates/rspack_loader_swc/src/options.rs @@ -14,6 +14,8 @@ use swc_core::base::config::{ #[serde(rename_all = "camelCase", default)] pub struct RawRspackExperiments { pub import: Option>, + #[serde(default)] + pub react_server_components: bool, } #[derive(Default, Deserialize, Debug)] @@ -26,6 +28,7 @@ pub struct RawCollectTypeScriptInfoOptions { #[derive(Default, Debug)] pub(crate) struct RspackExperiments { pub(crate) import: Option>, + pub(crate) react_server_components: bool, } #[derive(Default, Debug)] @@ -47,6 +50,7 @@ impl From for RspackExperiments { import: value .import .map(|i| i.into_iter().map(|v| v.into()).collect()), + react_server_components: value.react_server_components, } } } diff --git a/crates/rspack_loader_swc/src/rsc_transforms/cjs_finder.rs b/crates/rspack_loader_swc/src/rsc_transforms/cjs_finder.rs new file mode 100644 index 000000000000..5035d1a4eb1e --- /dev/null +++ b/crates/rspack_loader_swc/src/rsc_transforms/cjs_finder.rs @@ -0,0 +1,143 @@ +// This file is derived from Next.js +// Copyright (c) 2024 Vercel, Inc. +// Licensed under the MIT License + +use swc_core::ecma::{ + ast::*, + visit::{Visit, VisitWith}, +}; + +pub fn contains_cjs(m: &Module) -> bool { + let mut v = CjsFinder::default(); + m.visit_with(&mut v); + v.found && !v.is_esm +} + +#[derive(Copy, Clone, Default)] +struct CjsFinder { + found: bool, + is_esm: bool, + ignore_module: bool, + ignore_exports: bool, +} + +impl CjsFinder { + /// If the given pattern contains `module` as a parameter, we don't need to + /// recurse into it because `module` is shadowed. + fn adjust_state<'a, I>(&mut self, iter: I) + where + I: Iterator, + { + iter.for_each(|p| { + if let Pat::Ident(i) = p { + if &*i.id.sym == "module" { + self.ignore_module = true; + } + if &*i.id.sym == "exports" { + self.ignore_exports = true; + } + } + }) + } +} + +/// This visitor implementation supports typescript, because the api of `swc` +/// does not support changing configuration based on content of the file. +impl Visit for CjsFinder { + fn visit_arrow_expr(&mut self, n: &ArrowExpr) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.params.iter()); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + // Detect `Object.defineProperty(exports, "__esModule", ...)` + // Note that `Object.defineProperty(module.exports, ...)` will be handled by + // `visit_member_expr`. + fn visit_call_expr(&mut self, e: &CallExpr) { + if !self.ignore_exports + && let Callee::Expr(expr) = &e.callee + && let Expr::Member(member_expr) = &**expr + && let (Expr::Ident(obj), MemberProp::Ident(prop)) = (&*member_expr.obj, &member_expr.prop) + && &*obj.sym == "Object" + && &*prop.sym == "defineProperty" + && let Some(ExprOrSpread { expr: expr0, .. }) = e.args.first() + && let Expr::Ident(arg0) = &**expr0 + && &*arg0.sym == "exports" + && let Some(ExprOrSpread { expr: expr1, .. }) = e.args.get(1) + && let Expr::Lit(Lit::Str(arg1)) = &**expr1 + && &*arg1.value == "__esModule" + { + self.found = true; + return; + } + e.callee.visit_with(self); + } + + fn visit_class_method(&mut self, n: &ClassMethod) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.function.params.iter().map(|v| &v.pat)); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + fn visit_function(&mut self, n: &Function) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.params.iter().map(|v| &v.pat)); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + fn visit_member_expr(&mut self, e: &MemberExpr) { + if let Expr::Ident(obj) = &*e.obj + && let MemberProp::Ident(prop) = &e.prop + { + // Detect `module.exports` and `exports.__esModule` + if (!self.ignore_module && &*obj.sym == "module" && &*prop.sym == "exports") + || (!self.ignore_exports && &*obj.sym == "exports") + { + self.found = true; + return; + } + } + + e.obj.visit_with(self); + e.prop.visit_with(self); + } + + fn visit_method_prop(&mut self, n: &MethodProp) { + let old_ignore_module = self.ignore_module; + let old_ignore_exports = self.ignore_exports; + + self.adjust_state(n.function.params.iter().map(|v| &v.pat)); + + n.visit_children_with(self); + + self.ignore_module = old_ignore_module; + self.ignore_exports = old_ignore_exports; + } + + fn visit_module_decl(&mut self, n: &ModuleDecl) { + match n { + ModuleDecl::Import(_) => {} + _ => { + self.is_esm = true; + } + } + } +} diff --git a/crates/rspack_loader_swc/src/rsc_transforms/import_analyzer.rs b/crates/rspack_loader_swc/src/rsc_transforms/import_analyzer.rs new file mode 100644 index 000000000000..d9e801d4385d --- /dev/null +++ b/crates/rspack_loader_swc/src/rsc_transforms/import_analyzer.rs @@ -0,0 +1,103 @@ +// This file is derived from Next.js +// Copyright (c) 2024 Vercel, Inc. +// Licensed under the MIT License + +use rustc_hash::FxHashMap; +use swc::atoms::Wtf8Atom; +use swc_core::ecma::{ + ast::{ + Expr, Id, ImportDecl, ImportNamedSpecifier, ImportSpecifier, MemberExpr, MemberProp, Module, + ModuleExportName, + }, + visit::{Visit, VisitWith, noop_visit_type}, +}; + +#[derive(Debug, Default)] +pub(crate) struct ImportMap { + /// Map from module name to (module path, exported symbol) + imports: FxHashMap, + namespace_imports: FxHashMap, +} + +#[allow(unused)] +impl ImportMap { + /// Returns true if `e` is an import of `orig_name` from `module`. + pub fn is_import(&self, e: &Expr, module: &str, orig_name: &str) -> bool { + match e { + Expr::Ident(i) => { + if let Some((i_src, i_sym)) = self.imports.get(&i.to_id()) { + i_src == module && i_sym == orig_name + } else { + false + } + } + + Expr::Member(MemberExpr { + obj, + prop: MemberProp::Ident(prop), + .. + }) => { + if let Expr::Ident(obj) = &**obj { + if let Some(obj_src) = self.namespace_imports.get(&obj.to_id()) { + obj_src == module && prop.sym == *orig_name + } else { + false + } + } else { + false + } + } + + _ => false, + } + } + + pub fn analyze(m: &Module) -> Self { + let mut data = ImportMap::default(); + + m.visit_with(&mut Analyzer { data: &mut data }); + + data + } +} + +struct Analyzer<'a> { + data: &'a mut ImportMap, +} + +impl Visit for Analyzer<'_> { + noop_visit_type!(); + + fn visit_import_decl(&mut self, import: &ImportDecl) { + for s in &import.specifiers { + let (local, orig_sym) = match s { + ImportSpecifier::Named(ImportNamedSpecifier { + local, imported, .. + }) => match imported { + Some(imported) => (local.to_id(), orig_name(imported)), + _ => (local.to_id(), Wtf8Atom::from(local.sym.clone())), + }, + ImportSpecifier::Default(s) => (s.local.to_id(), Wtf8Atom::from("default")), + ImportSpecifier::Namespace(s) => { + self + .data + .namespace_imports + .insert(s.local.to_id(), import.src.value.clone()); + continue; + } + }; + + self + .data + .imports + .insert(local, (import.src.value.clone(), orig_sym)); + } + } +} + +fn orig_name(n: &ModuleExportName) -> Wtf8Atom { + match n { + ModuleExportName::Ident(v) => Wtf8Atom::from(v.sym.clone()), + ModuleExportName::Str(v) => v.value.clone(), + } +} diff --git a/crates/rspack_loader_swc/src/rsc_transforms/mod.rs b/crates/rspack_loader_swc/src/rsc_transforms/mod.rs new file mode 100644 index 000000000000..65ab1d273dc5 --- /dev/null +++ b/crates/rspack_loader_swc/src/rsc_transforms/mod.rs @@ -0,0 +1,55 @@ +mod cjs_finder; +mod import_analyzer; +mod react_server_components; +mod server_actions; +mod to_module_ref; + +use std::{cell::RefCell, rc::Rc, sync::Arc}; + +pub use react_server_components::{Config, Options, server_components}; +use rspack_core::{LoaderContext, Module, RscMeta, RunnerContext}; +pub use server_actions::{Config as ServerActionsConfig, server_actions}; +use swc_core::{ + common::{FileName, comments::SingleThreadedComments}, + ecma::ast::Pass, +}; +pub use to_module_ref::to_module_ref; + +pub fn rsc_pass( + loader_context: &mut LoaderContext, + filename: Arc, + resource_path: &str, + comments: Rc, + rsc_meta: &RefCell>, +) -> impl Pass { + let module = &loader_context.context.module; + let is_react_server_layer = module + .get_layer() + .is_some_and(|layer| layer == "react-server-components"); + + // Avoid transforming the redirected server entry module to prevent duplicate RSC metadata generation. + let server_entry_proxy = loader_context + .resource_query() + .is_some_and(|q| q.contains("rsc-server-entry-proxy=true")); + + ( + server_components( + filename, + Config::WithOptions(Options { + is_react_server_layer, + enable_server_entry: !server_entry_proxy, + }), + rsc_meta, + ), + server_actions( + resource_path.to_string(), + ServerActionsConfig { + is_react_server_layer, + is_development: false, + hash_salt: "".to_string(), + }, + comments, + rsc_meta, + ), + ) +} diff --git a/crates/rspack_loader_swc/src/rsc_transforms/react_server_components.rs b/crates/rspack_loader_swc/src/rsc_transforms/react_server_components.rs new file mode 100644 index 000000000000..c3bb6cb29dd6 --- /dev/null +++ b/crates/rspack_loader_swc/src/rsc_transforms/react_server_components.rs @@ -0,0 +1,529 @@ +// This file is derived from Next.js +// Copyright (c) 2024 Vercel, Inc. +// Licensed under the MIT License + +use std::{cell::RefCell, iter::FromIterator, sync::Arc}; + +use once_cell::sync::Lazy; +use regex::Regex; +use rspack_core::{RscMeta, RscModuleType}; +use rustc_hash::FxHashMap; +use serde::Deserialize; +use swc::atoms::Wtf8Atom; +use swc_core::{ + common::{FileName, Span, errors::HANDLER, util::take::Take}, + ecma::{ + ast::*, + visit::{ + Visit, VisitMut, VisitMutWith, VisitWith, noop_visit_mut_type, noop_visit_type, + visit_mut_pass, + }, + }, +}; + +use super::{cjs_finder::contains_cjs, import_analyzer::ImportMap}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum Config { + All, + WithOptions(Options), +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Options { + pub is_react_server_layer: bool, + pub enable_server_entry: bool, +} + +struct DirectiveImportCollection { + pub is_server_entry: bool, + pub is_client_entry: bool, + pub imports: Vec, + pub export_names: Vec, +} + +/// A visitor that transforms given module to use module proxy if it's a React +/// server component. +/// **NOTE** Turbopack uses ClientDirectiveTransformer for the +/// same purpose, so does not run this transform. +struct ReactServerComponents<'a> { + is_react_server_layer: bool, + enable_server_entry: bool, + filepath: String, + rsc_meta: &'a RefCell>, + directive_import_collection: Option, +} + +#[derive(Clone, Debug)] +struct ModuleImports { + source: (Wtf8Atom, Span), + specifiers: Vec<(Wtf8Atom, Span)>, +} + +enum RSCErrorKind { + /// When `use client` and `use server` are in the same file. + /// It's not possible to have both directives in the same file. + RedundantDirectives(Span), + ErrClientDirective(Span), + ErrReactApi((String, Span)), +} + +impl VisitMut for ReactServerComponents<'_> { + noop_visit_mut_type!(); + + fn visit_mut_module(&mut self, module: &mut Module) { + // Run the validator first to assert, collect directives and imports. + let mut validator = + ReactServerComponentValidator::new(self.is_react_server_layer, self.filepath.clone()); + + module.visit_with(&mut validator); + self.directive_import_collection = validator.directive_import_collection; + + #[allow(clippy::unwrap_used)] + let directive_import_collection = self.directive_import_collection.as_ref().unwrap(); + + let is_server_entry = self.enable_server_entry && directive_import_collection.is_server_entry; + let is_client_entry = directive_import_collection.is_client_entry; + + self.remove_top_level_directive(module); + + let is_cjs = contains_cjs(module); + + if self.is_react_server_layer { + if is_server_entry { + self.set_server_entry_metadata(is_cjs); + } else if is_client_entry { + self.set_client_metadata(is_cjs); + } + } + module.visit_mut_children_with(self) + } +} + +impl ReactServerComponents<'_> { + /// removes specific directive from the AST. + fn remove_top_level_directive(&mut self, module: &mut Module) { + module.body.retain(|item| { + if let ModuleItem::Stmt(stmt) = item + && let Some(expr_stmt) = stmt.as_expr() + && let Expr::Lit(Lit::Str(Str { value, .. })) = &*expr_stmt.expr + && &**value == "use client" + { + // Remove the directive. + return false; + } + true + }); + } + + fn set_server_entry_metadata(&mut self, is_cjs: bool) { + #[allow(clippy::unwrap_used)] + let export_names = &self + .directive_import_collection + .as_ref() + .unwrap() + .export_names; + + let mut rsc_meta = self.rsc_meta.borrow_mut(); + match rsc_meta.as_mut() { + Some(rsc_meta) => { + rsc_meta.module_type = RscModuleType::ServerEntry; + rsc_meta.server_refs = export_names.clone(); + rsc_meta.is_cjs = is_cjs; + } + None => { + *rsc_meta = Some(RscMeta { + module_type: RscModuleType::ServerEntry, + server_refs: export_names.clone(), + client_refs: Default::default(), + is_cjs, + action_ids: Default::default(), + }); + } + } + } + + fn set_client_metadata(&mut self, is_cjs: bool) { + #[allow(clippy::unwrap_used)] + let export_names = &self + .directive_import_collection + .as_ref() + .unwrap() + .export_names; + + let mut rsc_meta = self.rsc_meta.borrow_mut(); + match rsc_meta.as_mut() { + Some(rsc_meta) => { + rsc_meta.module_type = RscModuleType::Client; + rsc_meta.client_refs = export_names.clone(); + rsc_meta.is_cjs = is_cjs; + } + None => { + *rsc_meta = Some(RscMeta { + module_type: RscModuleType::Client, + server_refs: Default::default(), + client_refs: export_names.clone(), + is_cjs, + action_ids: Default::default(), + }); + } + } + } +} + +/// Consolidated place to parse, generate error messages for the RSC parsing +/// errors. +fn report_error(error_kind: RSCErrorKind) { + let (msg, spans) = match error_kind { + RSCErrorKind::RedundantDirectives(span) => ( + "It's not possible to have both `use client` and `use server` directives in the \ + same file." + .to_string(), + vec![span], + ), + RSCErrorKind::ErrClientDirective(span) => ( + "The \"use client\" directive must be placed before other expressions. Move it to \ + the top of the file to resolve this issue." + .to_string(), + vec![span], + ), + RSCErrorKind::ErrReactApi((source, span)) => { + let msg = if source == "Component" { + "You’re importing a class component. It only works in a Client Component but none of its parents are marked with \"use client\", so they're Server Components by default.\n\n".to_string() + } else { + format!( + "You're importing a component that needs `{source}`. This React Hook only works in a Client Component. To fix, mark the file (or its parent) with the `\"use client\"` directive.\n\n" + ) + }; + + (msg, vec![span]) + } + }; + + HANDLER.with(|handler| handler.struct_span_err(spans, msg.as_str()).emit()) +} + +/// Collects top level directives and imports +fn collect_top_level_directives_and_imports(module: &Module) -> DirectiveImportCollection { + let mut imports: Vec = vec![]; + let mut finished_directives = false; + let mut is_server_entry = false; + let mut is_client_entry = false; + let mut is_action_file = false; + + let mut export_names: Vec = vec![]; + + let _ = &module.body.iter().for_each(|item| { + match item { + ModuleItem::Stmt(stmt) => { + if !stmt.is_expr() { + // Not an expression. + finished_directives = true; + } + + match stmt.as_expr() { + Some(expr_stmt) => { + match &*expr_stmt.expr { + Expr::Lit(Lit::Str(Str { value, .. })) => { + if &**value == "use server-entry" { + is_server_entry = true; + } else if &**value == "use client" { + if !finished_directives { + is_client_entry = true; + + if is_action_file { + report_error(RSCErrorKind::RedundantDirectives(expr_stmt.span)); + } + } else { + report_error(RSCErrorKind::ErrClientDirective(expr_stmt.span)); + } + } else if &**value == "use server" && !finished_directives { + is_action_file = true; + + if is_client_entry { + report_error(RSCErrorKind::RedundantDirectives(expr_stmt.span)); + } + } + } + // Match `ParenthesisExpression` which is some formatting tools + // usually do: ('use client'). In these case we need to throw + // an exception because they are not valid directives. + Expr::Paren(ParenExpr { expr, .. }) => { + finished_directives = true; + if let Expr::Lit(Lit::Str(Str { value, .. })) = &**expr + && &**value == "use client" + { + report_error(RSCErrorKind::ErrClientDirective(expr_stmt.span)); + } + } + _ => { + // Other expression types. + finished_directives = true; + } + } + } + None => { + // Not an expression. + finished_directives = true; + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::Import( + import @ ImportDecl { + type_only: false, .. + }, + )) => { + let source = import.src.value.clone(); + let specifiers = import + .specifiers + .iter() + .filter(|specifier| { + !matches!( + specifier, + ImportSpecifier::Named(ImportNamedSpecifier { + is_type_only: true, + .. + }) + ) + }) + .map(|specifier| match specifier { + ImportSpecifier::Named(named) => match &named.imported { + Some(imported) => match &imported { + ModuleExportName::Ident(i) => (Wtf8Atom::from(i.to_id().0), i.span), + ModuleExportName::Str(s) => (s.value.clone(), s.span), + }, + None => (Wtf8Atom::from(named.local.to_id().0), named.local.span), + }, + ImportSpecifier::Default(d) => (Wtf8Atom::from(""), d.span), + ImportSpecifier::Namespace(n) => (Wtf8Atom::from("*"), n.span), + }) + .collect(); + + imports.push(ModuleImports { + source: (source, import.span), + specifiers, + }); + + finished_directives = true; + } + // Collect all export names. + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(e)) => { + for specifier in &e.specifiers { + export_names.push(match specifier { + ExportSpecifier::Default(_) => Wtf8Atom::from("default"), + ExportSpecifier::Namespace(_) => Wtf8Atom::from("*"), + ExportSpecifier::Named(named) => match &named.exported { + Some(exported) => match &exported { + ModuleExportName::Ident(i) => Wtf8Atom::from(i.sym.clone()), + ModuleExportName::Str(s) => s.value.clone(), + }, + _ => match &named.orig { + ModuleExportName::Ident(i) => Wtf8Atom::from(i.sym.clone()), + ModuleExportName::Str(s) => s.value.clone(), + }, + }, + }) + } + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { decl, .. })) => { + match decl { + Decl::Class(ClassDecl { ident, .. }) => { + export_names.push(Wtf8Atom::from(ident.sym.clone())); + } + Decl::Fn(FnDecl { ident, .. }) => { + export_names.push(Wtf8Atom::from(ident.sym.clone())); + } + Decl::Var(var) => { + for decl in &var.decls { + if let Pat::Ident(ident) = &decl.name { + export_names.push(Wtf8Atom::from(ident.id.sym.clone())); + } + } + } + _ => {} + } + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(ExportDefaultDecl { + decl: _, .. + })) => { + export_names.push(Wtf8Atom::from("default")); + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(ExportDefaultExpr { + expr: _, .. + })) => { + export_names.push(Wtf8Atom::from("default")); + finished_directives = true; + } + ModuleItem::ModuleDecl(ModuleDecl::ExportAll(_)) => { + export_names.push(Wtf8Atom::from("*")); + } + _ => { + finished_directives = true; + } + } + }); + + DirectiveImportCollection { + is_server_entry, + is_client_entry, + imports, + export_names, + } +} + +/// A visitor to assert given module file is a valid React server component. +struct ReactServerComponentValidator { + is_react_server_layer: bool, + filepath: String, + invalid_server_lib_apis_mapping: FxHashMap<&'static str, Vec<&'static str>>, + pub directive_import_collection: Option, + imports: ImportMap, +} + +impl ReactServerComponentValidator { + pub fn new(is_react_server_layer: bool, filename: String) -> Self { + Self { + is_react_server_layer, + filepath: filename, + directive_import_collection: None, + // react -> [apis] + // react-dom -> [apis] + invalid_server_lib_apis_mapping: FxHashMap::from_iter([ + ( + "react", + vec![ + "Component", + "createContext", + "createFactory", + "PureComponent", + "useDeferredValue", + "useEffect", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useTransition", + "useOptimistic", + "useActionState", + "experimental_useOptimistic", + ], + ), + ( + "react-dom", + vec![ + "flushSync", + "unstable_batchedUpdates", + "useFormStatus", + "useFormState", + ], + ), + ]), + imports: ImportMap::default(), + } + } + + fn is_from_node_modules(&self, filepath: &str) -> bool { + static RE: Lazy = Lazy::new(|| { + #[allow(clippy::unwrap_used)] + Regex::new(r"node_modules[\\/]").unwrap() + }); + RE.is_match(filepath) + } + + // Asserts the server lib apis + // e.g. + // assert_invalid_server_lib_apis("react", import) + // assert_invalid_server_lib_apis("react-dom", import) + fn assert_invalid_server_lib_apis(&self, import_source: &str, import: &ModuleImports) { + let invalid_apis = self.invalid_server_lib_apis_mapping.get(import_source); + if let Some(invalid_apis) = invalid_apis { + for specifier in &import.specifiers { + if let Some(specifier_name) = specifier.0.as_str() + && invalid_apis.contains(&specifier_name) + { + report_error(RSCErrorKind::ErrReactApi(( + specifier_name.to_string(), + specifier.1, + ))); + } + } + } + } + + fn assert_server_graph(&self, imports: &[ModuleImports]) { + if self.is_from_node_modules(&self.filepath) { + return; + } + for import in imports { + let source = import.source.0.clone(); + if let Some(source_str) = source.as_str() { + self.assert_invalid_server_lib_apis(source_str, import); + } + } + } +} + +impl Visit for ReactServerComponentValidator { + noop_visit_type!(); + + // coerce parsed script to run validation for the context, which is still + // required even if file is empty + fn visit_script(&mut self, script: &swc_core::ecma::ast::Script) { + if script.body.is_empty() { + self.visit_module(&Module::dummy()); + } + } + + fn visit_module(&mut self, module: &Module) { + self.imports = ImportMap::analyze(module); + + let directive_import_collection = collect_top_level_directives_and_imports(module); + + if self.is_react_server_layer && !directive_import_collection.is_client_entry { + // Only assert server graph if file's bundle target is "server", e.g. + // * server components pages + // * pages bundles on SSR layer + // * middleware + // * app/pages api routes + self.assert_server_graph(&directive_import_collection.imports) + } + self.directive_import_collection = Some(directive_import_collection); + + module.visit_children_with(self); + } +} + +/// Runs react server component transform for the module proxy, as well as +/// running assertion. +pub fn server_components( + filename: Arc, + config: Config, + rsc_meta: &RefCell>, +) -> impl Pass + VisitMut { + let is_react_server_layer: bool = match &config { + Config::WithOptions(x) => x.is_react_server_layer, + _ => false, + }; + let enable_server_entry = match &config { + Config::WithOptions(x) => x.enable_server_entry, + _ => false, + }; + visit_mut_pass(ReactServerComponents { + is_react_server_layer, + enable_server_entry, + rsc_meta, + filepath: match &*filename { + FileName::Custom(path) => format!("<{path}>"), + _ => filename.to_string(), + }, + directive_import_collection: None, + }) +} diff --git a/crates/rspack_loader_swc/src/rsc_transforms/server_actions.rs b/crates/rspack_loader_swc/src/rsc_transforms/server_actions.rs new file mode 100644 index 000000000000..91c464001f57 --- /dev/null +++ b/crates/rspack_loader_swc/src/rsc_transforms/server_actions.rs @@ -0,0 +1,2669 @@ +// This file is derived from Next.js +// Copyright (c) 2024 Vercel, Inc. +// Licensed under the MIT License + +use std::{ + cell::RefCell, + convert::{TryFrom, TryInto}, + mem::{replace, take}, +}; + +use indoc::formatdoc; +use rspack_core::{RscMeta, RscModuleType}; +use rspack_util::fx_hash::FxIndexMap; +use rustc_hash::FxHashSet; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use swc_core::{ + atoms::{Atom, Wtf8Atom, atom}, + common::{ + BytePos, DUMMY_SP, Mark, Span, SyntaxContext, comments::Comments, errors::HANDLER, + source_map::PURE_SP, util::take::Take, + }, + ecma::{ + ast::*, + utils::{ExprFactory, private_ident, quote_ident}, + visit::{VisitMut, VisitMutWith, noop_visit_mut_type, visit_mut_pass}, + }, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Config { + pub is_react_server_layer: bool, + pub is_development: bool, + pub hash_salt: String, +} + +#[derive(Clone, Debug)] +enum DirectiveLocation { + Module, + FunctionBody, +} + +#[derive(Clone, Debug)] +enum ThisStatus { + Allowed, + Forbidden, +} + +#[derive(Clone)] +struct ServerReferenceExport { + ident: Ident, + export_name: ModuleExportName, + reference_id: Atom, +} + +#[derive(Clone, Debug)] +enum ServerActionsErrorKind { + ExportedSyncFunction { + span: Span, + }, + ForbiddenExpression { + span: Span, + expr: String, + }, + InlineSyncFunction { + span: Span, + }, + InlineUseServerInClassInstanceMethod { + span: Span, + }, + InlineUseServerInClientComponent { + span: Span, + }, + MisplacedDirective { + span: Span, + directive: String, + location: DirectiveLocation, + }, + MisplacedWrappedDirective { + span: Span, + directive: String, + location: DirectiveLocation, + }, + MisspelledDirective { + span: Span, + directive: String, + expected_directive: String, + }, + MultipleDirectives { + span: Span, + location: DirectiveLocation, + }, + WrappedDirective { + span: Span, + directive: String, + }, +} + +pub fn server_actions( + file_name: String, + config: Config, + comments: C, + rsc_meta: &RefCell>, +) -> impl Pass { + visit_mut_pass(ServerActions { + config, + comments, + rsc_meta, + file_name, + start_pos: BytePos(0), + in_action_file: false, + current_export_name: None, + fn_decl_ident: None, + in_callee: false, + has_action: false, + this_status: ThisStatus::Allowed, + + reference_index: 0, + in_module_level: true, + should_track_names: false, + has_server_reference_with_bound_args: false, + + names: Default::default(), + declared_idents: Default::default(), + + // This flag allows us to rewrite `function foo() {}` to `const foo = createProxy(...)`. + rewrite_fn_decl_to_proxy_decl: None, + rewrite_default_fn_expr_to_proxy_expr: None, + rewrite_expr_to_proxy_expr: None, + + annotations: Default::default(), + extra_items: Default::default(), + hoisted_extra_items: Default::default(), + reference_ids_by_export_name: Default::default(), + server_reference_exports: Default::default(), + + private_ctxt: SyntaxContext::empty().apply_mark(Mark::new()), + + arrow_or_fn_expr_ident: None, + export_name_by_local_id: Default::default(), + local_ids_that_need_cache_runtime_wrapper_if_exported: FxHashSet::default(), + }) +} + +struct ServerActions<'a, C: Comments> { + #[allow(unused)] + config: Config, + file_name: String, + comments: C, + rsc_meta: &'a RefCell>, + + start_pos: BytePos, + in_action_file: bool, + current_export_name: Option, + fn_decl_ident: Option, + in_callee: bool, + has_action: bool, + this_status: ThisStatus, + + reference_index: u32, + in_module_level: bool, + should_track_names: bool, + has_server_reference_with_bound_args: bool, + + names: Vec, + declared_idents: Vec, + + // This flag allows us to rewrite `function foo() {}` to `const foo = createProxy(...)`. + rewrite_fn_decl_to_proxy_decl: Option, + rewrite_default_fn_expr_to_proxy_expr: Option>, + rewrite_expr_to_proxy_expr: Option>, + + annotations: Vec, + extra_items: Vec, + hoisted_extra_items: Vec, + + /// A map of all server references (inline + exported): export_name -> reference_id + reference_ids_by_export_name: FxIndexMap, + + /// A list of server references for originally exported server functions only. + server_reference_exports: Vec, + + private_ctxt: SyntaxContext, + + arrow_or_fn_expr_ident: Option, + export_name_by_local_id: FxIndexMap, + + /// Tracks which local IDs need cache runtime wrappers if exported (collected during pre-pass). + /// Includes imports, destructured identifiers, and variables with unknown-type init + /// expressions (calls, identifiers, etc.). Excludes known functions (arrow/fn + /// declarations) and known non-functions (object/array/literals). When these IDs are + /// exported, their exports are stripped and replaced with conditional wrappers. + local_ids_that_need_cache_runtime_wrapper_if_exported: FxHashSet, +} + +impl<'a, C: Comments> ServerActions<'a, C> { + fn generate_server_reference_id( + &self, + export_name: &ModuleExportName, + params: Option<&Vec>, + ) -> Atom { + // Attach a checksum to the action using sha1: + // $$id = special_byte + sha1('hash_salt' + 'file_name' + ':' + 'export_name'); + // Currently encoded as hex. + + let mut hasher = Sha256::new(); + hasher.update(self.config.hash_salt.as_bytes()); + hasher.update(self.file_name.as_bytes()); + hasher.update(b":"); + + let export_name_bytes = match export_name { + ModuleExportName::Ident(ident) => &ident.sym.as_bytes(), + ModuleExportName::Str(s) => &s.value.as_bytes(), + }; + + hasher.update(export_name_bytes); + + let mut result = hasher.finalize().to_vec(); + + // Prepend an extra byte to the ID, with the following format: + // 0 000000 0 + // ^type ^arg mask ^rest args + // + // The type bit represents if the action is a cache function or not. + // For cache functions, the type bit is set to 1. Otherwise, it's 0. + // + // The arg mask bit is used to determine which arguments are used by + // the function itself, up to 6 arguments. The bit is set to 1 if the + // argument is used, or being spread or destructured (so it can be + // indirectly or partially used). The bit is set to 0 otherwise. + // + // The rest args bit is used to determine if there's a ...rest argument + // in the function signature. If there is, the bit is set to 1. + // + // For example: + // + // async function foo(a, foo, b, bar, ...baz) { + // 'use cache'; + // return a + b; + // } + // + // will have it encoded as [1][101011][1]. The first bit is set to 1 + // because it's a cache function. The second part has 1010 because the + // only arguments used are `a` and `b`. The subsequent 11 bits are set + // to 1 because there's a ...rest argument starting from the 5th. The + // last bit is set to 1 as well for the same reason. + let type_bit = 0u8; + let mut arg_mask = 0u8; + let mut rest_args = 0u8; + + if let Some(params) = params { + // TODO: For the current implementation, we don't track if an + // argument ident is actually referenced in the function body. + // Instead, we go with the easy route and assume defined ones are + // used. This can be improved in the future. + for (i, param) in params.iter().enumerate() { + if let Pat::Rest(_) = param.pat { + // If there's a ...rest argument, we set the rest args bit + // to 1 and set the arg mask to 0b111111. + arg_mask = 0b111111; + rest_args = 0b1; + break; + } + if i < 6 { + arg_mask |= 0b1 << (5 - i); + } else { + // More than 6 arguments, we set the rest args bit to 1. + // This is rare for a Server Action, usually. + rest_args = 0b1; + break; + } + } + } else { + // If we can't determine the arguments (e.g. not statically analyzable), + // we assume all arguments are used. + arg_mask = 0b111111; + rest_args = 0b1; + } + + result.push((type_bit << 7) | (arg_mask << 1) | rest_args); + result.rotate_right(1); + + Atom::from(hex::encode(result)) + } + + fn is_default_export(&self) -> bool { + matches!( + self.current_export_name, + Some(ModuleExportName::Ident(ref i)) if i.sym == *"default" + ) + } + + fn gen_action_ident(&mut self) -> Atom { + let id: Atom = format!("$$RSC_SERVER_ACTION_{0}", self.reference_index).into(); + self.reference_index += 1; + id + } + + fn create_bound_action_args_array_pat(&mut self, arg_len: usize) -> Pat { + Pat::Array(ArrayPat { + span: DUMMY_SP, + elems: (0..arg_len) + .map(|i| { + Some(Pat::Ident( + Ident::new( + format!("$$ACTION_ARG_{i}").into(), + DUMMY_SP, + self.private_ctxt, + ) + .into(), + )) + }) + .collect(), + optional: false, + type_ann: None, + }) + } + + // Check if the function or arrow function is an action function, + // and remove any server function directive. + fn has_use_server_for_function(&mut self, maybe_body: Option<&mut BlockStmt>) -> bool { + let mut found_use_server = false; + + // Even if it's a file-level action or cache module, the function body + // might still have directives that override the module-level annotations. + if let Some(body) = maybe_body { + let directive_visitor = &mut DirectiveVisitor { + config: &self.config, + found_use_server: false, + in_action_file: self.in_action_file, + is_allowed_position: true, + location: DirectiveLocation::FunctionBody, + }; + + body.stmts.retain(|stmt| { + let has_directive = directive_visitor.visit_stmt(stmt); + + !has_directive + }); + + found_use_server = directive_visitor.found_use_server; + } + + // All exported functions inherit the file directive if they don't have their own directive. + if self.current_export_name.is_some() && !found_use_server && self.in_action_file { + return true; + } + + found_use_server + } + + fn has_use_server_for_module(&mut self, stmts: &mut Vec) -> bool { + let directive_visitor = &mut DirectiveVisitor { + config: &self.config, + found_use_server: false, + in_action_file: false, + is_allowed_position: true, + location: DirectiveLocation::Module, + }; + + stmts.retain(|item| { + if let ModuleItem::Stmt(stmt) = item { + let has_directive = directive_visitor.visit_stmt(stmt); + + !has_directive + } else { + directive_visitor.is_allowed_position = false; + true + } + }); + + directive_visitor.found_use_server + } + + fn maybe_hoist_and_create_proxy_for_server_action_arrow_expr( + &mut self, + ids_from_closure: Vec, + arrow: &mut ArrowExpr, + ) -> Box { + let mut new_params: Vec = vec![]; + + if !ids_from_closure.is_empty() { + // First param is the encrypted closure variables. + new_params.push(Param { + span: DUMMY_SP, + decorators: vec![], + pat: Pat::Ident(IdentName::new(atom!("$$ACTION_CLOSURE_BOUND"), DUMMY_SP).into()), + }); + } + + for p in arrow.params.iter() { + new_params.push(Param::from(p.clone())); + } + + let action_name = self.gen_action_ident(); + let action_ident = Ident::new(action_name.clone(), arrow.span, self.private_ctxt); + let action_id = self.generate_server_reference_id( + &ModuleExportName::Ident(action_ident.clone()), + Some(&new_params), + ); + + self.has_action = true; + self.reference_ids_by_export_name.insert( + ModuleExportName::Ident(action_ident.clone()), + action_id.clone(), + ); + + // If this is an exported arrow, remove it from export_name_by_local_id so the + // post-pass doesn't register it again (it's already registered above). + if self.current_export_name.is_some() + && let Some(arrow_ident) = &self.arrow_or_fn_expr_ident + { + self + .export_name_by_local_id + .swap_remove(&arrow_ident.to_id()); + } + + if let BlockStmtOrExpr::BlockStmt(block) = &mut *arrow.body { + block.visit_mut_with(&mut ClosureReplacer { + used_ids: &ids_from_closure, + private_ctxt: self.private_ctxt, + }); + } + + let mut new_body: BlockStmtOrExpr = *arrow.body.clone(); + + if !ids_from_closure.is_empty() { + // Prepend the decryption declaration to the body. + // var [arg1, arg2, arg3] = await decryptActionBoundArgs(actionId, + // $$ACTION_CLOSURE_BOUND) + let decryption_decl = VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: self.create_bound_action_args_array_pat(ids_from_closure.len()), + init: Some(Box::new(Expr::Await(AwaitExpr { + span: DUMMY_SP, + arg: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("decryptActionBoundArgs").as_callee(), + args: vec![ + action_id.clone().as_arg(), + quote_ident!("$$ACTION_CLOSURE_BOUND").as_arg(), + ], + ..Default::default() + })), + }))), + definite: Default::default(), + }], + ..Default::default() + }; + + match &mut new_body { + BlockStmtOrExpr::BlockStmt(body) => { + body.stmts.insert(0, decryption_decl.into()); + } + BlockStmtOrExpr::Expr(body_expr) => { + new_body = BlockStmtOrExpr::BlockStmt(BlockStmt { + span: DUMMY_SP, + stmts: vec![ + decryption_decl.into(), + Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(body_expr.take()), + }), + ], + ..Default::default() + }); + } + } + } + + // Create the action export decl from the arrow function + // export const $$RSC_SERVER_ACTION_0 = async function action($$ACTION_CLOSURE_BOUND) {} + self + .hoisted_extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: VarDecl { + kind: VarDeclKind::Const, + span: DUMMY_SP, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(action_ident.clone().into()), + definite: false, + init: Some(Box::new(Expr::Fn(FnExpr { + ident: self.arrow_or_fn_expr_ident.clone(), + function: Box::new(Function { + params: new_params, + body: match new_body { + BlockStmtOrExpr::BlockStmt(body) => Some(body), + BlockStmtOrExpr::Expr(expr) => Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![Stmt::Return(ReturnStmt { + span: DUMMY_SP, + arg: Some(expr), + })], + ..Default::default() + }), + }, + is_async: true, + ..Default::default() + }), + }))), + }], + declare: Default::default(), + ctxt: self.private_ctxt, + } + .into(), + }))); + + self + .hoisted_extra_items + .push(ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(annotate_ident_as_server_reference( + action_ident.clone(), + action_id.clone(), + arrow.span, + )), + }))); + + if ids_from_closure.is_empty() { + Box::new(action_ident.clone().into()) + } else { + self.has_server_reference_with_bound_args = true; + Box::new(bind_args_to_ident( + action_ident.clone(), + ids_from_closure + .iter() + .cloned() + .map(|id| Some(id.as_arg())) + .collect(), + action_id.clone(), + )) + } + } + + fn maybe_hoist_and_create_proxy_for_server_action_function( + &mut self, + ids_from_closure: Vec, + function: &mut Function, + fn_name: Option, + ) -> Box { + let mut new_params: Vec = vec![]; + + if !ids_from_closure.is_empty() { + // First param is the encrypted closure variables. + new_params.push(Param { + span: DUMMY_SP, + decorators: vec![], + pat: Pat::Ident(IdentName::new(atom!("$$ACTION_CLOSURE_BOUND"), DUMMY_SP).into()), + }); + } + + new_params.append(&mut function.params); + + let action_name: Atom = self.gen_action_ident(); + let mut action_ident = Ident::new(action_name.clone(), function.span, self.private_ctxt); + if action_ident.span.lo == self.start_pos { + action_ident.span = Span::dummy_with_cmt(); + } + + let action_id = self.generate_server_reference_id( + &ModuleExportName::Ident(action_ident.clone()), + Some(&new_params), + ); + + self.has_action = true; + self.reference_ids_by_export_name.insert( + ModuleExportName::Ident(action_ident.clone()), + action_id.clone(), + ); + + // If this is an exported function, remove it from export_name_by_local_id so the + // post-pass doesn't register it again (it's already registered above). + if self.current_export_name.is_some() + && let Some(ref fn_name) = fn_name + { + self.export_name_by_local_id.swap_remove(&fn_name.to_id()); + } + + function.body.visit_mut_with(&mut ClosureReplacer { + used_ids: &ids_from_closure, + private_ctxt: self.private_ctxt, + }); + + let mut new_body: Option = function.body.clone(); + + if !ids_from_closure.is_empty() { + // Prepend the decryption declaration to the body. + // var [arg1, arg2, arg3] = await decryptActionBoundArgs(actionId, + // $$ACTION_CLOSURE_BOUND) + let decryption_decl = VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: self.create_bound_action_args_array_pat(ids_from_closure.len()), + init: Some(Box::new(Expr::Await(AwaitExpr { + span: DUMMY_SP, + arg: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("decryptActionBoundArgs").as_callee(), + args: vec![ + action_id.clone().as_arg(), + quote_ident!("$$ACTION_CLOSURE_BOUND").as_arg(), + ], + ..Default::default() + })), + }))), + definite: Default::default(), + }], + ..Default::default() + }; + + if let Some(body) = &mut new_body { + body.stmts.insert(0, decryption_decl.into()); + } else { + new_body = Some(BlockStmt { + span: DUMMY_SP, + stmts: vec![decryption_decl.into()], + ..Default::default() + }); + } + } + + // Create the action export decl from the function + // export const $$RSC_SERVER_ACTION_0 = async function action($$ACTION_CLOSURE_BOUND) {} + self + .hoisted_extra_items + .push(ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { + span: DUMMY_SP, + decl: VarDecl { + kind: VarDeclKind::Const, + span: DUMMY_SP, + decls: vec![VarDeclarator { + span: DUMMY_SP, // TODO: need to map it to the original span? + name: Pat::Ident(action_ident.clone().into()), + definite: false, + init: Some(Box::new(Expr::Fn(FnExpr { + ident: fn_name, + function: Box::new(Function { + params: new_params, + body: new_body, + ..function.take() + }), + }))), + }], + declare: Default::default(), + ctxt: self.private_ctxt, + } + .into(), + }))); + + self + .hoisted_extra_items + .push(ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(annotate_ident_as_server_reference( + action_ident.clone(), + action_id.clone(), + function.span, + )), + }))); + + if ids_from_closure.is_empty() { + Box::new(action_ident.clone().into()) + } else { + self.has_server_reference_with_bound_args = true; + Box::new(bind_args_to_ident( + action_ident.clone(), + ids_from_closure + .iter() + .cloned() + .map(|id| Some(id.as_arg())) + .collect(), + action_id.clone(), + )) + } + } + + /// Validates that a function is async, emitting an error if not. + /// Returns true if async, false otherwise. + fn validate_async_function(&self, is_async: bool, span: Span, fn_name: Option<&Ident>) -> bool { + if is_async { + true + } else { + emit_error(ServerActionsErrorKind::InlineSyncFunction { + span: fn_name.as_ref().map_or(span, |ident| ident.span), + }); + false + } + } + + /// Registers a server action export (for a 'use server' file directive). + fn register_server_action_export( + &mut self, + export_name: &ModuleExportName, + fn_name: Option<&Ident>, + params: Option<&Vec>, + span: Span, + take_fn_or_arrow_expr: &mut dyn FnMut() -> Box, + ) { + if let Some(fn_name) = fn_name { + let reference_id = self.generate_server_reference_id(export_name, params); + + self.has_action = true; + self + .reference_ids_by_export_name + .insert(export_name.clone(), reference_id.clone()); + + self.server_reference_exports.push(ServerReferenceExport { + ident: fn_name.clone(), + export_name: export_name.clone(), + reference_id: reference_id.clone(), + }); + } else if self.is_default_export() { + let action_ident = Ident::new(self.gen_action_ident(), span, self.private_ctxt); + let reference_id = self.generate_server_reference_id(export_name, params); + + self.has_action = true; + self + .reference_ids_by_export_name + .insert(export_name.clone(), reference_id.clone()); + + self.server_reference_exports.push(ServerReferenceExport { + ident: action_ident.clone(), + export_name: export_name.clone(), + reference_id: reference_id.clone(), + }); + + // For the server layer, also hoist the function and rewrite the default export. + if self.config.is_react_server_layer { + self + .hoisted_extra_items + .push(ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { + kind: VarDeclKind::Const, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(action_ident.clone().into()), + init: Some(take_fn_or_arrow_expr()), + definite: false, + }], + ..Default::default() + }))))); + + self + .hoisted_extra_items + .push(ModuleItem::Stmt(assign_name_to_ident( + &action_ident, + "default", + ))); + + self.rewrite_default_fn_expr_to_proxy_expr = Some(Box::new(Expr::Ident(action_ident))); + } + } + } + + fn set_action_ids( + &self, + export_names_ordered_by_reference_id: &FxIndexMap<&Atom, &ModuleExportName>, + ) { + let action_ids = export_names_ordered_by_reference_id + .iter() + .map(|(ref_id, export_name)| ((**ref_id).clone(), export_name.atom().into_owned())) + .collect::>(); + + let mut rsc_meta = self.rsc_meta.borrow_mut(); + match rsc_meta.as_mut() { + Some(rsc_meta) => { + if rsc_meta.module_type != RscModuleType::ServerEntry { + rsc_meta.action_ids = action_ids; + } + } + None => { + *rsc_meta = Some(RscMeta { + module_type: RscModuleType::Server, + server_refs: Default::default(), + client_refs: Default::default(), + is_cjs: false, + action_ids, + }); + } + } + } +} + +impl<'a, C: Comments> VisitMut for ServerActions<'a, C> { + fn visit_mut_export_decl(&mut self, decl: &mut ExportDecl) { + // For inline exports like `export function foo() {}` or `export const bar = ...`, + // the export name is looked up from export_name_by_local_id and set as current_export_name + // in visit_mut_fn_decl or visit_mut_var_declarator. + decl.decl.visit_mut_with(self); + } + + fn visit_mut_export_default_decl(&mut self, decl: &mut ExportDefaultDecl) { + let old_current_export_name = self.current_export_name.take(); + self.current_export_name = Some(ModuleExportName::Ident(atom!("default").into())); + self.rewrite_default_fn_expr_to_proxy_expr = None; + decl.decl.visit_mut_with(self); + self.current_export_name = old_current_export_name; + } + + fn visit_mut_export_default_expr(&mut self, expr: &mut ExportDefaultExpr) { + let old_current_export_name = self.current_export_name.take(); + self.current_export_name = Some(ModuleExportName::Ident(atom!("default").into())); + expr.expr.visit_mut_with(self); + self.current_export_name = old_current_export_name; + + // For 'use server' files with call expressions as default exports, + // hoist the call expression to a const declarator. + if matches!(&*expr.expr, Expr::Call(_)) && self.in_action_file { + let export_name = ModuleExportName::Ident(atom!("default").into()); + let action_ident = Ident::new(self.gen_action_ident(), expr.span, self.private_ctxt); + let action_id = self.generate_server_reference_id(&export_name, None); + + self.has_action = true; + self + .reference_ids_by_export_name + .insert(export_name.clone(), action_id.clone()); + + self.server_reference_exports.push(ServerReferenceExport { + ident: action_ident.clone(), + export_name: export_name.clone(), + reference_id: action_id.clone(), + }); + + self + .hoisted_extra_items + .push(ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { + kind: VarDeclKind::Const, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(action_ident.clone().into()), + init: Some(expr.expr.take()), + definite: false, + }], + ..Default::default() + }))))); + + self.rewrite_default_fn_expr_to_proxy_expr = Some(Box::new(Expr::Ident(action_ident))); + } + } + + fn visit_mut_fn_expr(&mut self, f: &mut FnExpr) { + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + if let Some(ident) = &f.ident { + self.arrow_or_fn_expr_ident = Some(ident.clone()); + } + f.visit_mut_children_with(self); + self.this_status = old_this_status; + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_function(&mut self, f: &mut Function) { + let found_use_server = self.has_use_server_for_function(f.body.as_mut()); + let declared_idents_until = self.declared_idents.len(); + let old_names = take(&mut self.names); + + if found_use_server { + self.this_status = ThisStatus::Forbidden; + } + + // Visit children + { + let old_in_module = replace(&mut self.in_module_level, false); + let should_track_names = found_use_server || self.should_track_names; + let old_should_track_names = replace(&mut self.should_track_names, should_track_names); + let old_current_export_name = self.current_export_name.take(); + let old_fn_decl_ident = self.fn_decl_ident.take(); + f.visit_mut_children_with(self); + self.in_module_level = old_in_module; + self.should_track_names = old_should_track_names; + self.current_export_name = old_current_export_name; + self.fn_decl_ident = old_fn_decl_ident; + } + + let mut child_names = take(&mut self.names); + + if self.should_track_names { + self.names = [old_names, child_names.clone()].concat(); + } + + if found_use_server { + let fn_name = self + .fn_decl_ident + .as_ref() + .or(self.arrow_or_fn_expr_ident.as_ref()) + .cloned(); + + if !self.validate_async_function(f.is_async, f.span, fn_name.as_ref()) { + // If this is an exported function that failed validation, remove it from + // export_name_by_local_id so the post-pass doesn't register it. + if self.current_export_name.is_some() + && let Some(fn_name) = fn_name + { + self.export_name_by_local_id.swap_remove(&fn_name.to_id()); + } + + return; + } + + // If this function is invalid, or any prior errors have been emitted, skip further + // processing. + if HANDLER.with(|handler| handler.has_errors()) { + return; + } + + // For server action files, register exports without hoisting (for both server and + // client layers). + if self.in_action_file + && found_use_server + && let Some(export_name) = self.current_export_name.clone() + { + let params = f.params.clone(); + let span = f.span; + + self.register_server_action_export( + &export_name, + fn_name.as_ref(), + Some(¶ms), + span, + &mut || { + Box::new(Expr::Fn(FnExpr { + ident: fn_name.clone(), + function: Box::new(f.take()), + })) + }, + ); + + return; + } + + // Collect all the identifiers defined inside the closure and used + // in the action function. With deduplication. + retain_names_from_declared_idents( + &mut child_names, + &self.declared_idents[..declared_idents_until], + ); + + let new_expr = + self.maybe_hoist_and_create_proxy_for_server_action_function(child_names, f, fn_name); + + if self.is_default_export() { + // This function expression is also the default export: + // `export default async function() {}` + // This specific case (default export) isn't handled by `visit_mut_expr`. + // Replace the original function expr with a action proxy expr. + self.rewrite_default_fn_expr_to_proxy_expr = Some(new_expr); + } else if let Some(ident) = &self.fn_decl_ident { + // Replace the original function declaration with an action proxy + // declaration expr. + self.rewrite_fn_decl_to_proxy_decl = Some(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Var, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(ident.clone().into()), + init: Some(new_expr), + definite: false, + }], + ..Default::default() + }); + } else { + self.rewrite_expr_to_proxy_expr = Some(new_expr); + } + } + } + + fn visit_mut_decl(&mut self, d: &mut Decl) { + self.rewrite_fn_decl_to_proxy_decl = None; + d.visit_mut_children_with(self); + + if let Some(decl) = &self.rewrite_fn_decl_to_proxy_decl { + *d = (*decl).clone().into(); + } + + self.rewrite_fn_decl_to_proxy_decl = None; + } + + fn visit_mut_fn_decl(&mut self, f: &mut FnDecl) { + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + let old_current_export_name = self.current_export_name.take(); + if self.in_module_level + && let Some(export_name) = self.export_name_by_local_id.get(&f.ident.to_id()) + { + self.current_export_name = Some(export_name.clone()); + } + let old_fn_decl_ident = self.fn_decl_ident.replace(f.ident.clone()); + f.visit_mut_children_with(self); + self.this_status = old_this_status; + self.current_export_name = old_current_export_name; + self.fn_decl_ident = old_fn_decl_ident; + } + + fn visit_mut_arrow_expr(&mut self, a: &mut ArrowExpr) { + // Arrow expressions need to be visited in prepass to determine if it's + // an action function or not. + let found_use_server = + self.has_use_server_for_function(if let BlockStmtOrExpr::BlockStmt(block) = &mut *a.body { + Some(block) + } else { + None + }); + + if found_use_server { + self.this_status = ThisStatus::Forbidden; + } + + let declared_idents_until = self.declared_idents.len(); + let old_names = take(&mut self.names); + + { + // Visit children + let old_in_module = replace(&mut self.in_module_level, false); + let should_track_names = found_use_server || self.should_track_names; + let old_should_track_names = replace(&mut self.should_track_names, should_track_names); + let old_current_export_name = self.current_export_name.take(); + { + for n in &mut a.params { + collect_idents_in_pat(n, &mut self.declared_idents); + } + } + a.visit_mut_children_with(self); + self.in_module_level = old_in_module; + self.should_track_names = old_should_track_names; + self.current_export_name = old_current_export_name; + } + + let mut child_names = take(&mut self.names); + + if self.should_track_names { + self.names = [old_names, child_names.clone()].concat(); + } + + if found_use_server { + let arrow_ident = self.arrow_or_fn_expr_ident.clone(); + + if !self.validate_async_function(a.is_async, a.span, arrow_ident.as_ref()) { + // If this is an exported arrow function that failed validation, remove it from + // export_name_by_local_id so the post-pass doesn't register it. + if self.current_export_name.is_some() + && let Some(arrow_ident) = arrow_ident + { + self + .export_name_by_local_id + .swap_remove(&arrow_ident.to_id()); + } + + return; + } + + // If this function is invalid, or any prior errors have been emitted, skip further + // processing. + if HANDLER.with(|handler| handler.has_errors()) { + return; + } + + // For server action files, register exports without hoisting (for both server and + // client layers). + if self.in_action_file + && found_use_server + && let Some(export_name) = self.current_export_name.clone() + { + let params: Vec = a.params.iter().map(|p| Param::from(p.clone())).collect(); + + self.register_server_action_export( + &export_name, + arrow_ident.as_ref(), + Some(¶ms), + a.span, + &mut || Box::new(Expr::Arrow(a.take())), + ); + + return; + } + + // Collect all the identifiers defined inside the closure and used + // in the action function. With deduplication. + retain_names_from_declared_idents( + &mut child_names, + &self.declared_idents[..declared_idents_until], + ); + + self.rewrite_expr_to_proxy_expr = + Some(self.maybe_hoist_and_create_proxy_for_server_action_arrow_expr(child_names, a)); + } + } + + fn visit_mut_module(&mut self, m: &mut Module) { + self.start_pos = m.span.lo; + m.visit_mut_children_with(self); + } + + fn visit_mut_stmt(&mut self, n: &mut Stmt) { + n.visit_mut_children_with(self); + + if self.in_module_level { + return; + } + + // If it's a closure (not in the module level), we need to collect + // identifiers defined in the closure. + collect_decl_idents_in_stmt(n, &mut self.declared_idents); + } + + fn visit_mut_param(&mut self, n: &mut Param) { + n.visit_mut_children_with(self); + + if self.in_module_level { + return; + } + + collect_idents_in_pat(&n.pat, &mut self.declared_idents); + } + + fn visit_mut_prop_or_spread(&mut self, n: &mut PropOrSpread) { + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + let old_current_export_name = self.current_export_name.take(); + + if let PropOrSpread::Prop(prop) = n { + if let Prop::KeyValue(KeyValueProp { + key: PropName::Ident(ident_name), + value, + .. + }) = &**prop + { + if matches!(**value, Expr::Arrow(_) | Expr::Fn(_)) { + self.current_export_name = None; + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + } else if let Prop::Method(MethodProp { key, .. }) = &**prop { + let key = key.clone(); + + if let PropName::Ident(ident_name) = &key { + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + self.rewrite_expr_to_proxy_expr = None; + self.current_export_name = None; + n.visit_mut_children_with(self); + self.current_export_name = old_current_export_name.clone(); + self.this_status = old_this_status; + + if let Some(expr) = self.rewrite_expr_to_proxy_expr.take() { + *n = PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { key, value: expr }))); + } + + return; + } + } + + if !self.in_module_level + && self.should_track_names + && let PropOrSpread::Prop(prop) = n + && let Prop::Shorthand(i) = &**prop + { + self.names.push(Name::from(i)); + self.should_track_names = false; + n.visit_mut_children_with(self); + self.should_track_names = true; + return; + } + + n.visit_mut_children_with(self); + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + self.current_export_name = old_current_export_name; + } + + fn visit_mut_class(&mut self, n: &mut Class) { + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + n.visit_mut_children_with(self); + self.this_status = old_this_status; + } + + fn visit_mut_class_member(&mut self, n: &mut ClassMember) { + if let ClassMember::Method(ClassMethod { + is_abstract: false, + is_static: true, + kind: MethodKind::Method, + key, + span, + accessibility: None | Some(Accessibility::Public), + .. + }) = n + { + let key = key.clone(); + let span = *span; + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + + if let PropName::Ident(ident_name) = &key { + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + + let old_this_status = replace(&mut self.this_status, ThisStatus::Allowed); + let old_current_export_name = self.current_export_name.take(); + self.rewrite_expr_to_proxy_expr = None; + self.current_export_name = None; + n.visit_mut_children_with(self); + self.this_status = old_this_status; + self.current_export_name = old_current_export_name; + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + + if let Some(expr) = self.rewrite_expr_to_proxy_expr.take() { + *n = ClassMember::ClassProp(ClassProp { + span, + key, + value: Some(expr), + is_static: true, + ..Default::default() + }); + } + } else { + n.visit_mut_children_with(self); + } + } + + fn visit_mut_class_method(&mut self, n: &mut ClassMethod) { + if n.is_static { + n.visit_mut_children_with(self); + } else if is_action_fn(&n.function.body) { + emit_error(ServerActionsErrorKind::InlineUseServerInClassInstanceMethod { span: n.span }); + } else { + n.visit_mut_children_with(self); + } + } + + fn visit_mut_call_expr(&mut self, n: &mut CallExpr) { + if let Callee::Expr(expr) = &mut n.callee + && let Expr::Ident(Ident { sym, .. }) = &**expr + && (sym == "jsxDEV" || sym == "_jsxDEV") + { + // Do not visit the 6th arg in a generated jsxDEV call, which is a `this` + // expression, to avoid emitting an error for using `this` if it's + // inside of a server function. https://github.com/facebook/react/blob/9106107/packages/react/src/jsx/ReactJSXElement.js#L429 + if n.args.len() > 4 { + for arg in &mut n.args[0..4] { + arg.visit_mut_with(self); + } + return; + } + } + + let old_current_export_name = self.current_export_name.take(); + n.visit_mut_children_with(self); + self.current_export_name = old_current_export_name; + } + + fn visit_mut_callee(&mut self, n: &mut Callee) { + let old_in_callee = replace(&mut self.in_callee, true); + n.visit_mut_children_with(self); + self.in_callee = old_in_callee; + } + + fn visit_mut_expr(&mut self, n: &mut Expr) { + if !self.in_module_level + && self.should_track_names + && let Ok(mut name) = Name::try_from(&*n) + { + if self.in_callee { + // This is a callee i.e. `foo.bar()`, + // we need to track the actual value instead of the method name. + if !name.1.is_empty() { + name.1.pop(); + } + } + + self.names.push(name); + self.should_track_names = false; + n.visit_mut_children_with(self); + self.should_track_names = true; + return; + } + + self.rewrite_expr_to_proxy_expr = None; + n.visit_mut_children_with(self); + if let Some(expr) = self.rewrite_expr_to_proxy_expr.take() { + *n = *expr; + } + } + + fn visit_mut_module_items(&mut self, stmts: &mut Vec) { + self.in_action_file = self.has_use_server_for_module(stmts); + + let in_action_file = self.in_action_file; + + let should_track_exports = in_action_file; + + // Pre-pass: Collect a mapping from local identifiers to export names for all exports + // in server boundary files ('use server'). This mapping is used to: + // 1. Set current_export_name when visiting exported functions/variables during the main + // pass. + // 2. Register any remaining exports in the post-pass that weren't handled by the visitor. + if should_track_exports { + for stmt in stmts.iter() { + match stmt { + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(export_default_expr)) => { + if let Expr::Ident(ident) = &*export_default_expr.expr { + self.export_name_by_local_id.insert( + ident.to_id(), + ModuleExportName::Ident(atom!("default").into()), + ); + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(export_default_decl)) => { + // export default function foo() {} + if let DefaultDecl::Fn(f) = &export_default_decl.decl + && let Some(ident) = &f.ident + { + self.export_name_by_local_id.insert( + ident.to_id(), + ModuleExportName::Ident(atom!("default").into()), + ); + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) => { + // export function foo() {} or export const bar = ... + match &export_decl.decl { + Decl::Fn(f) => { + self + .export_name_by_local_id + .insert(f.ident.to_id(), ModuleExportName::Ident(f.ident.clone())); + } + Decl::Var(var) => { + for decl in &var.decls { + // Collect all identifiers from the pattern and track which may + // need cache runtime wrappers. For destructuring patterns, we + // always need wrappers since we can't statically know if the + // destructured values are functions. For simple identifiers, + // check the init expression. + let mut idents = vec![]; + collect_idents_in_pat(&decl.name, &mut idents); + + let is_destructuring = !matches!(&decl.name, Pat::Ident(_)); + let needs_wrapper = if is_destructuring { + true + } else if let Some(init) = &decl.init { + may_need_cache_runtime_wrapper(init) + } else { + false + }; + + for ident in idents { + self + .export_name_by_local_id + .insert(ident.to_id(), ModuleExportName::Ident(ident.clone())); + + if needs_wrapper { + self + .local_ids_that_need_cache_runtime_wrapper_if_exported + .insert(ident.to_id()); + } + } + } + } + _ => {} + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(named_export)) => { + if named_export.src.is_none() { + for spec in &named_export.specifiers { + match spec { + ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(orig), + exported: Some(exported), + is_type_only: false, + .. + }) => { + // export { foo as bar } or export { foo as "📙" } + self + .export_name_by_local_id + .insert(orig.to_id(), exported.clone()); + } + ExportSpecifier::Named(ExportNamedSpecifier { + orig: ModuleExportName::Ident(orig), + exported: None, + is_type_only: false, + .. + }) => { + // export { foo } + self + .export_name_by_local_id + .insert(orig.to_id(), ModuleExportName::Ident(orig.clone())); + } + _ => {} + } + } + } + } + ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl))) => { + // Track which declarations need cache runtime wrappers if exported. + for decl in &var_decl.decls { + if let Pat::Ident(ident_pat) = &decl.name + && let Some(init) = &decl.init + && may_need_cache_runtime_wrapper(init) + { + self + .local_ids_that_need_cache_runtime_wrapper_if_exported + .insert(ident_pat.id.to_id()); + } + } + } + ModuleItem::Stmt(Stmt::Decl(Decl::Fn(_fn_decl))) => { + // Function declarations are known functions and don't need runtime + // wrappers. + } + ModuleItem::ModuleDecl(ModuleDecl::Import(import_decl)) => { + // Track all imports. We don't know if they're functions, so they need + // runtime wrappers if they end up getting re-exported. + for spec in &import_decl.specifiers { + match spec { + ImportSpecifier::Named(named) => { + self + .local_ids_that_need_cache_runtime_wrapper_if_exported + .insert(named.local.to_id()); + } + ImportSpecifier::Default(default) => { + self + .local_ids_that_need_cache_runtime_wrapper_if_exported + .insert(default.local.to_id()); + } + ImportSpecifier::Namespace(ns) => { + self + .local_ids_that_need_cache_runtime_wrapper_if_exported + .insert(ns.local.to_id()); + } + } + } + } + _ => {} + } + } + } + + let old_annotations = self.annotations.take(); + let mut new = Vec::with_capacity(stmts.len()); + + // Main pass: For each statement, validate exports in server boundary files, + // visit and transform it, and add it to the output along with any hoisted items. + for mut stmt in stmts.take() { + if should_track_exports { + let mut disallowed_export_span = DUMMY_SP; + + match &mut stmt { + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { decl, span })) => match decl { + Decl::Var(_) + | Decl::Fn(_) + | Decl::TsInterface(_) + | Decl::TsTypeAlias(_) + | Decl::TsEnum(_) => {} + _ => { + disallowed_export_span = *span; + } + }, + ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(named)) => { + if !named.type_only + && let Some(_) = &named.src + { + // export { x } from './module' + if named.specifiers.iter().any(|s| match s { + ExportSpecifier::Namespace(_) | ExportSpecifier::Default(_) => true, + ExportSpecifier::Named(s) => !s.is_type_only, + }) { + disallowed_export_span = named.span; + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(ExportDefaultDecl { + decl, + span, + })) => match decl { + DefaultDecl::Fn(_) | DefaultDecl::TsInterfaceDecl(_) => {} + _ => { + disallowed_export_span = *span; + } + }, + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(default_expr)) => { + match &mut *default_expr.expr { + Expr::Fn(_) | Expr::Arrow(_) | Expr::Ident(_) | Expr::Call(_) => {} + _ => { + disallowed_export_span = default_expr.span; + } + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportAll(ExportAll { + span, type_only, .. + })) => { + if !*type_only { + disallowed_export_span = *span; + } + } + _ => {} + } + + // Emit validation error if we found a disallowed export + if disallowed_export_span != DUMMY_SP { + emit_error(ServerActionsErrorKind::ExportedSyncFunction { + span: disallowed_export_span, + }); + return; + } + } + + stmt.visit_mut_with(self); + + let new_stmt = if let Some(expr) = self.rewrite_default_fn_expr_to_proxy_expr.take() { + Some(ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr( + ExportDefaultExpr { + span: DUMMY_SP, + expr, + }, + ))) + } else { + Some(stmt) + }; + + if self.config.is_react_server_layer || !in_action_file { + new.append(&mut self.hoisted_extra_items); + if let Some(stmt) = new_stmt { + new.push(stmt); + } + new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); + new.append(&mut self.extra_items); + } + } + + // Post-pass: For server boundary files, register any exports that weren't already + // registered during the main pass. + if should_track_exports { + for (id, export_name) in &self.export_name_by_local_id { + if self.reference_ids_by_export_name.contains_key(export_name) { + continue; + } + + self.server_reference_exports.push(ServerReferenceExport { + ident: Ident::from(id.clone()), + export_name: export_name.clone(), + reference_id: self.generate_server_reference_id(export_name, None), + }); + } + } + + if in_action_file && !self.config.is_react_server_layer { + self.reference_ids_by_export_name.extend( + self + .server_reference_exports + .iter() + .map(|e| (e.export_name.clone(), e.reference_id.clone())), + ); + + if !self.reference_ids_by_export_name.is_empty() { + self.has_action |= in_action_file; + } + }; + + // If it's compiled in the client layer, each export field needs to be + // wrapped by a reference creation call. + let create_ref_ident = private_ident!("createServerReference"); + + let client_layer_import = (self.has_action && !self.config.is_react_server_layer).then(|| { + // import { createServerReference } from 'react-server-dom-rspack/client' + // createServerReference("action_id") + ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: create_ref_ident.clone(), + imported: None, + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + value: atom!("react-server-dom-rspack/client").into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + })) + }); + + let mut client_layer_exports = FxIndexMap::default(); + + // If it's a "use server" file, all exports need to be annotated. + if should_track_exports { + let server_reference_exports = self.server_reference_exports.take(); + + for ServerReferenceExport { + ident, + export_name, + reference_id: ref_id, + } in &server_reference_exports + { + if !self.config.is_react_server_layer { + if matches!(export_name, ModuleExportName::Ident(i) if i.sym == *"default") { + let export_expr = + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(ExportDefaultExpr { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + // In development, we generate these spans for sourcemapping + // with better logs/errors. For production, this is not + // generated because it would leak server code to the browser. + span: if self.config.is_react_server_layer || self.config.is_development { + self.comments.add_pure_comment(ident.span.lo); + ident.span + } else { + PURE_SP + }, + callee: Callee::Expr(Box::new(Expr::Ident(create_ref_ident.clone()))), + args: vec![ref_id.clone().as_arg()], + ..Default::default() + })), + })); + client_layer_exports.insert( + atom!("default"), + ( + vec![export_expr], + ModuleExportName::Ident(atom!("default").into()), + ref_id.clone(), + ), + ); + } else { + let var_name = self.gen_action_ident(); + + let var_ident = Ident::new(var_name.clone(), DUMMY_SP, self.private_ctxt); + + // Determine span for the variable name. In development, we generate these + // spans for sourcemapping with better logs/errors. For production, this is + // not generated because it would leak server code to the browser. + let name_span = if self.config.is_react_server_layer || self.config.is_development { + ident.span + } else { + DUMMY_SP + }; + + let var_decl = ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { + span: DUMMY_SP, + kind: VarDeclKind::Const, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(IdentName::new(var_name.clone(), name_span).into()), + init: Some(Box::new(Expr::Call(CallExpr { + span: PURE_SP, + callee: Callee::Expr(Box::new(Expr::Ident(create_ref_ident.clone()))), + args: vec![ref_id.clone().as_arg()], + ..Default::default() + }))), + definite: false, + }], + ..Default::default() + })))); + + // Determine the export name. In development, we generate these spans for + // sourcemapping with better logs/errors. For production, this is not + // generated because it would leak server code to the browser. + let exported_name = if self.config.is_react_server_layer || self.config.is_development { + export_name.clone() + } else { + strip_export_name_span(export_name) + }; + + let export_named = ModuleItem::ModuleDecl(ModuleDecl::ExportNamed(NamedExport { + span: DUMMY_SP, + specifiers: vec![ExportSpecifier::Named(ExportNamedSpecifier { + span: DUMMY_SP, + orig: ModuleExportName::Ident(var_ident), + exported: Some(exported_name), + is_type_only: false, + })], + src: None, + type_only: false, + with: None, + })); + + client_layer_exports.insert( + var_name, + ( + vec![var_decl, export_named], + export_name.clone(), + ref_id.clone(), + ), + ); + } + } else { + self.annotations.push(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(annotate_ident_as_server_reference( + ident.clone(), + ref_id.clone(), + ident.span, + )), + })); + } + } + + // Ensure that the exports are functions by appending a runtime check: + // + // import { ensureServerActions } from 'react-server-dom-rspack/server' + // ensureServerActions([action1, action2, ...]) + // + // But it's only needed for the server layer, because on the client + // layer they're transformed into references already. + if self.has_action && self.config.is_react_server_layer { + new.append(&mut self.extra_items); + + if !server_reference_exports.is_empty() { + let ensure_ident = private_ident!("ensureServerActions"); + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: ensure_ident.clone(), + imported: None, + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + value: atom!("react-server-dom-rspack/server").into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + new.push(ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Ident(ensure_ident))), + args: vec![ExprOrSpread { + spread: None, + expr: Box::new(Expr::Array(ArrayLit { + span: DUMMY_SP, + elems: server_reference_exports + .iter() + .map(|ServerReferenceExport { ident, .. }| { + Some(ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident.clone())), + }) + }) + .collect(), + })), + }], + ..Default::default() + })), + }))); + } + + // Append annotations to the end of the file. + new.extend(self.annotations.drain(..).map(ModuleItem::Stmt)); + } + } + + if self.has_action && self.config.is_react_server_layer { + // Inlined actions are only allowed on the server layer. + // import { registerServerReference } from 'react-server-dom-rspack/server' + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: quote_ident!("registerServerReference").into(), + imported: None, + is_type_only: false, + })], + src: Box::new(Str { + span: DUMMY_SP, + value: atom!("react-server-dom-rspack/server").into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + + let mut import_count = 1; + + // Encryption and decryption only happens when there are bound arguments. + if self.has_server_reference_with_bound_args { + // import { encryptActionBoundArgs, decryptActionBoundArgs } from + // 'react-server-dom-rspack/server' + new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl { + span: DUMMY_SP, + specifiers: vec![ + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: quote_ident!("encryptActionBoundArgs").into(), + imported: None, + is_type_only: false, + }), + ImportSpecifier::Named(ImportNamedSpecifier { + span: DUMMY_SP, + local: quote_ident!("decryptActionBoundArgs").into(), + imported: None, + is_type_only: false, + }), + ], + src: Box::new(Str { + span: DUMMY_SP, + value: atom!("react-server-dom-rspack/server").into(), + raw: None, + }), + type_only: false, + with: None, + phase: Default::default(), + }))); + import_count += 1; + } + + // Make them the first items + new.rotate_right(import_count); + } + + if self.has_action { + // Flip the map and convert it to a FxIndexMap for deterministic + // ordering in the server references comment. + let export_names_ordered_by_reference_id = self + .reference_ids_by_export_name + .iter() + .map(|(export_name, reference_id)| (reference_id, export_name)) + .collect::>(); + + if self.config.is_react_server_layer { + self.set_action_ids(&export_names_ordered_by_reference_id); + } else { + self.set_action_ids(&export_names_ordered_by_reference_id); + #[allow(clippy::unwrap_used)] + new.push(client_layer_import.unwrap()); + new.rotate_right(1); + new.extend( + client_layer_exports + .into_iter() + .flat_map(|(_, (items, _, _))| items), + ); + } + } + + *stmts = new; + + self.annotations = old_annotations; + } + + fn visit_mut_stmts(&mut self, stmts: &mut Vec) { + let old_annotations = self.annotations.take(); + + let mut new = Vec::with_capacity(stmts.len()); + for mut stmt in stmts.take() { + stmt.visit_mut_with(self); + + new.push(stmt); + new.append(&mut self.annotations); + } + + *stmts = new; + + self.annotations = old_annotations; + } + + fn visit_mut_jsx_attr(&mut self, attr: &mut JSXAttr) { + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.take(); + + if let (Some(JSXAttrValue::JSXExprContainer(container)), JSXAttrName::Ident(ident_name)) = + (&attr.value, &attr.name) + { + match &container.expr { + JSXExpr::Expr(box Expr::Arrow(_)) | JSXExpr::Expr(box Expr::Fn(_)) => { + self.arrow_or_fn_expr_ident = Some(ident_name.clone().into()); + } + _ => {} + } + } + + attr.visit_mut_children_with(self); + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_var_declarator(&mut self, var_declarator: &mut VarDeclarator) { + let old_current_export_name = self.current_export_name.take(); + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.take(); + + if let (Pat::Ident(ident), Some(box Expr::Arrow(_) | box Expr::Fn(_))) = + (&var_declarator.name, &var_declarator.init) + { + if self.in_module_level + && let Some(export_name) = self.export_name_by_local_id.get(&ident.to_id()) + { + self.current_export_name = Some(export_name.clone()); + } + + self.arrow_or_fn_expr_ident = Some(ident.id.clone()); + } + + var_declarator.visit_mut_children_with(self); + + self.current_export_name = old_current_export_name; + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_assign_expr(&mut self, assign_expr: &mut AssignExpr) { + let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.clone(); + + if let ( + AssignTarget::Simple(SimpleAssignTarget::Ident(ident)), + box Expr::Arrow(_) | box Expr::Fn(_), + ) = (&assign_expr.left, &assign_expr.right) + { + self.arrow_or_fn_expr_ident = Some(ident.id.clone()); + } + + assign_expr.visit_mut_children_with(self); + self.arrow_or_fn_expr_ident = old_arrow_or_fn_expr_ident; + } + + fn visit_mut_this_expr(&mut self, n: &mut ThisExpr) { + if matches!(self.this_status, ThisStatus::Forbidden) { + emit_error(ServerActionsErrorKind::ForbiddenExpression { + span: n.span, + expr: "this".into(), + }); + } + } + + fn visit_mut_super(&mut self, n: &mut Super) { + if matches!(self.this_status, ThisStatus::Forbidden) { + emit_error(ServerActionsErrorKind::ForbiddenExpression { + span: n.span, + expr: "super".into(), + }); + } + } + + fn visit_mut_ident(&mut self, n: &mut Ident) { + if n.sym == *"arguments" && matches!(self.this_status, ThisStatus::Forbidden) { + emit_error(ServerActionsErrorKind::ForbiddenExpression { + span: n.span, + expr: "arguments".into(), + }); + } + } + + noop_visit_mut_type!(); +} + +fn retain_names_from_declared_idents( + child_names: &mut Vec, + current_declared_idents: &[Ident], +) { + // Collect the names to retain in a separate vector + let mut retained_names = Vec::new(); + + for name in child_names.iter() { + let mut should_retain = true; + + // Merge child_names. For example if both `foo.bar` and `foo.bar.baz` are used, + // we only need to keep `foo.bar` as it covers the other. + + // Currently this is O(n^2) and we can potentially improve this to O(n log n) + // by sorting or using a hashset. + for another_name in child_names.iter() { + if name != another_name && name.0 == another_name.0 && name.1.len() >= another_name.1.len() { + let mut is_prefix = true; + for i in 0..another_name.1.len() { + if name.1[i] != another_name.1[i] { + is_prefix = false; + break; + } + } + if is_prefix { + should_retain = false; + break; + } + } + } + + if should_retain + && current_declared_idents + .iter() + .any(|ident| ident.to_id() == name.0) + && !retained_names.contains(name) + { + retained_names.push(name.clone()); + } + } + + // Replace the original child_names with the retained names + *child_names = retained_names; +} + +/// Returns true if the expression may need a cache runtime wrapper. +/// Known functions and known non-functions return false. +fn may_need_cache_runtime_wrapper(expr: &Expr) -> bool { + match expr { + // Known functions - don't need wrapper + Expr::Arrow(_) | Expr::Fn(_) => false, + // Known non-functions - don't need wrapper + Expr::Object(_) | Expr::Array(_) | Expr::Lit(_) => false, + // Unknown/might be function - needs runtime check + _ => true, + } +} + +fn assign_name_to_ident(ident: &Ident, name: &str) -> Stmt { + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Callee::Expr(Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(Ident::new( + atom!("Object"), + DUMMY_SP, + SyntaxContext::empty(), + ))), + prop: MemberProp::Computed(ComputedPropName { + span: DUMMY_SP, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: atom!("defineProperty").into(), + raw: None, + }))), + }), + }))), + args: vec![ + // $action + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident.clone())), + }, + // "name" + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: atom!("name").into(), + raw: None, + }))), + }, + // { value: $name } + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Object(ObjectLit { + span: DUMMY_SP, + props: vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(IdentName::new(atom!("value"), DUMMY_SP)), + value: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: Wtf8Atom::from(name), + raw: None, + }))), + })))], + })), + }, + ], + type_args: None, + ctxt: SyntaxContext::default(), + })), + }) +} + +fn annotate_ident_as_server_reference(ident: Ident, action_id: Atom, original_span: Span) -> Expr { + // registerServerReference(reference, id, null) + Expr::Call(CallExpr { + span: original_span, + callee: quote_ident!("registerServerReference").as_callee(), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(ident)), + }, + ExprOrSpread { + spread: None, + expr: Box::new(action_id.clone().into()), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Null(Null { span: DUMMY_SP }))), + }, + ], + ..Default::default() + }) +} + +fn bind_args_to_ident(ident: Ident, bound: Vec>, action_id: Atom) -> Expr { + // ident.bind(null, [encryptActionBoundArgs("id", arg1, arg2, ...)]) + Expr::Call(CallExpr { + span: DUMMY_SP, + callee: Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(ident.into()), + prop: MemberProp::Ident(quote_ident!("bind")), + }) + .as_callee(), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Null(Null { span: DUMMY_SP }))), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + callee: quote_ident!("encryptActionBoundArgs").as_callee(), + args: std::iter::once(ExprOrSpread { + spread: None, + expr: Box::new(action_id.into()), + }) + .chain(bound.into_iter().flatten()) + .collect(), + ..Default::default() + })), + }, + ], + ..Default::default() + }) +} + +// Detects if two strings are similar (but not the same). +// This implementation is fast and simple as it allows only one +// edit (add, remove, edit, swap), instead of using a N^2 Levenshtein algorithm. +// +// Example of similar strings of "use server": +// "use servers", +// "use-server", +// "use sevrer", +// "use srever", +// "use servre", +// "user server", +// +// This avoids accidental typos as there's currently no other static analysis +// tool to help when these mistakes happen. +fn detect_similar_strings(a: &str, b: &str) -> bool { + let mut a = a.chars().collect::>(); + let mut b = b.chars().collect::>(); + + if a.len() < b.len() { + (a, b) = (b, a); + } + + if a.len() == b.len() { + // Same length, get the number of character differences. + let mut diff = 0; + for i in 0..a.len() { + if a[i] != b[i] { + diff += 1; + if diff > 2 { + return false; + } + } + } + + // Should be 1 or 2, but not 0. + diff != 0 + } else { + if a.len() - b.len() > 1 { + return false; + } + + // A has one more character than B. + for i in 0..b.len() { + if a[i] != b[i] { + // This should be the only difference, a[i+1..] should be equal to b[i..]. + // Otherwise, they're not considered similar. + // A: "use srerver" + // B: "use server" + // ^ + return a[i + 1..] == b[i..]; + } + } + + // This happens when the last character of A is an extra character. + true + } +} + +// Check if the function or arrow function has action, +// without mutating the function body or erroring out. +// This is used to quickly determine if we need to use the module-level +// directives for this function or not. +fn is_action_fn(maybe_body: &Option) -> bool { + let mut result = false; + if let Some(body) = maybe_body { + for stmt in body.stmts.iter() { + match stmt { + Stmt::Expr(ExprStmt { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + }) => { + if value == "use server" { + result = true; + break; + } + } + _ => break, + } + } + } + result +} + +fn collect_idents_in_array_pat(elems: &[Option], idents: &mut Vec) { + for elem in elems.iter().flatten() { + match elem { + Pat::Ident(ident) => { + idents.push(ident.id.clone()); + } + Pat::Array(array) => { + collect_idents_in_array_pat(&array.elems, idents); + } + Pat::Object(object) => { + collect_idents_in_object_pat(&object.props, idents); + } + Pat::Rest(rest) => { + if let Pat::Ident(ident) = &*rest.arg { + idents.push(ident.id.clone()); + } + } + Pat::Assign(AssignPat { left, .. }) => { + collect_idents_in_pat(left, idents); + } + Pat::Expr(..) | Pat::Invalid(..) => {} + } + } +} + +fn collect_idents_in_object_pat(props: &[ObjectPatProp], idents: &mut Vec) { + for prop in props { + match prop { + ObjectPatProp::KeyValue(KeyValuePatProp { value, .. }) => { + // For { foo: bar }, only collect 'bar' (the local binding), not 'foo' (the property + // key). + match &**value { + Pat::Ident(ident) => { + idents.push(ident.id.clone()); + } + Pat::Array(array) => { + collect_idents_in_array_pat(&array.elems, idents); + } + Pat::Object(object) => { + collect_idents_in_object_pat(&object.props, idents); + } + _ => {} + } + } + ObjectPatProp::Assign(AssignPatProp { key, .. }) => { + // For { foo }, 'foo' is both the property key and local binding. + idents.push(key.id.clone()); + } + ObjectPatProp::Rest(RestPat { arg, .. }) => { + if let Pat::Ident(ident) = &**arg { + idents.push(ident.id.clone()); + } + } + } + } +} + +fn collect_idents_in_var_decls(decls: &[VarDeclarator], idents: &mut Vec) { + for decl in decls { + collect_idents_in_pat(&decl.name, idents); + } +} + +fn collect_idents_in_pat(pat: &Pat, idents: &mut Vec) { + match pat { + Pat::Ident(ident) => { + idents.push(ident.id.clone()); + } + Pat::Array(array) => { + collect_idents_in_array_pat(&array.elems, idents); + } + Pat::Object(object) => { + collect_idents_in_object_pat(&object.props, idents); + } + Pat::Assign(AssignPat { left, .. }) => { + collect_idents_in_pat(left, idents); + } + Pat::Rest(RestPat { arg, .. }) => { + if let Pat::Ident(ident) = &**arg { + idents.push(ident.id.clone()); + } + } + Pat::Expr(..) | Pat::Invalid(..) => {} + } +} + +fn collect_decl_idents_in_stmt(stmt: &Stmt, idents: &mut Vec) { + if let Stmt::Decl(decl) = stmt { + match decl { + Decl::Var(var) => { + collect_idents_in_var_decls(&var.decls, idents); + } + Decl::Fn(fn_decl) => { + idents.push(fn_decl.ident.clone()); + } + _ => {} + } + } +} + +struct DirectiveVisitor<'a> { + config: &'a Config, + location: DirectiveLocation, + found_use_server: bool, + in_action_file: bool, + is_allowed_position: bool, +} + +impl DirectiveVisitor<'_> { + /** + * Returns `true` if the statement contains a server directive. + * The found directive is assigned to `DirectiveVisitor::directive`. + */ + fn visit_stmt(&mut self, stmt: &Stmt) -> bool { + let in_fn_body = matches!(self.location, DirectiveLocation::FunctionBody); + let allow_inline = self.config.is_react_server_layer || self.in_action_file; + + match stmt { + Stmt::Expr(ExprStmt { + expr: box Expr::Lit(Lit::Str(Str { value, span, .. })), + .. + }) => { + // Match `use server` + if value == "use server" { + if in_fn_body && !allow_inline { + emit_error(ServerActionsErrorKind::InlineUseServerInClientComponent { span: *span }) + } else if self.found_use_server { + emit_error(ServerActionsErrorKind::MultipleDirectives { + span: *span, + location: self.location.clone(), + }); + } else if self.is_allowed_position { + self.found_use_server = true; + return true; + } else { + emit_error(ServerActionsErrorKind::MisplacedDirective { + span: *span, + directive: value.to_string_lossy().into_owned(), + location: self.location.clone(), + }); + } + } else if detect_similar_strings(&value.to_string_lossy(), "use server") { + // Detect typo of "use server" + emit_error(ServerActionsErrorKind::MisspelledDirective { + span: *span, + directive: value.to_string_lossy().into_owned(), + expected_directive: "use server".to_string(), + }); + } + } + Stmt::Expr(ExprStmt { + expr: + box Expr::Paren(ParenExpr { + expr: box Expr::Lit(Lit::Str(Str { value, .. })), + .. + }), + span, + .. + }) => { + // Match `("use server")`. + if value == "use server" || detect_similar_strings(&value.to_string_lossy(), "use server") { + if self.is_allowed_position { + emit_error(ServerActionsErrorKind::WrappedDirective { + span: *span, + directive: "use server".to_string(), + }); + } else { + emit_error(ServerActionsErrorKind::MisplacedWrappedDirective { + span: *span, + directive: "use server".to_string(), + location: self.location.clone(), + }); + } + } + } + _ => { + // Directives must not be placed after other statements. + self.is_allowed_position = false; + } + }; + + false + } +} + +pub(crate) struct ClosureReplacer<'a> { + used_ids: &'a [Name], + private_ctxt: SyntaxContext, +} + +impl ClosureReplacer<'_> { + fn index(&self, e: &Expr) -> Option { + let name = Name::try_from(e).ok()?; + self.used_ids.iter().position(|used_id| *used_id == name) + } +} + +impl VisitMut for ClosureReplacer<'_> { + fn visit_mut_expr(&mut self, e: &mut Expr) { + e.visit_mut_children_with(self); + + if let Some(index) = self.index(e) { + *e = Expr::Ident(Ident::new( + // $$ACTION_ARG_0 + format!("$$ACTION_ARG_{index}").into(), + DUMMY_SP, + self.private_ctxt, + )); + } + } + + fn visit_mut_prop_or_spread(&mut self, n: &mut PropOrSpread) { + n.visit_mut_children_with(self); + + if let PropOrSpread::Prop(box Prop::Shorthand(i)) = n { + let name = Name::from(&*i); + if let Some(index) = self.used_ids.iter().position(|used_id| *used_id == name) { + *n = PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp { + key: PropName::Ident(i.clone().into()), + value: Box::new(Expr::Ident(Ident::new( + // $$ACTION_ARG_0 + format!("$$ACTION_ARG_{index}").into(), + DUMMY_SP, + self.private_ctxt, + ))), + }))); + } + } + } + + noop_visit_mut_type!(); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NamePart { + prop: Atom, + is_member: bool, + optional: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Name(Id, Vec); + +impl From<&'_ Ident> for Name { + fn from(value: &Ident) -> Self { + Name(value.to_id(), vec![]) + } +} + +impl TryFrom<&'_ Expr> for Name { + type Error = (); + + fn try_from(value: &Expr) -> Result { + match value { + Expr::Ident(i) => Ok(Name(i.to_id(), vec![])), + Expr::Member(e) => e.try_into(), + Expr::OptChain(e) => e.try_into(), + _ => Err(()), + } + } +} + +impl TryFrom<&'_ MemberExpr> for Name { + type Error = (); + + fn try_from(value: &MemberExpr) -> Result { + match &value.prop { + MemberProp::Ident(prop) => { + let mut obj: Name = value.obj.as_ref().try_into()?; + obj.1.push(NamePart { + prop: prop.sym.clone(), + is_member: true, + optional: false, + }); + Ok(obj) + } + _ => Err(()), + } + } +} + +impl TryFrom<&'_ OptChainExpr> for Name { + type Error = (); + + fn try_from(value: &OptChainExpr) -> Result { + match &*value.base { + OptChainBase::Member(m) => match &m.prop { + MemberProp::Ident(prop) => { + let mut obj: Name = m.obj.as_ref().try_into()?; + obj.1.push(NamePart { + prop: prop.sym.clone(), + is_member: false, + optional: value.optional, + }); + Ok(obj) + } + _ => Err(()), + }, + OptChainBase::Call(_) => Err(()), + } + } +} + +impl From for Box { + fn from(value: Name) -> Self { + let mut expr = Box::new(Expr::Ident(value.0.into())); + + for NamePart { + prop, + is_member, + optional, + } in value.1.into_iter() + { + #[allow(clippy::replace_box)] + if is_member { + expr = Box::new(Expr::Member(MemberExpr { + span: DUMMY_SP, + obj: expr, + prop: MemberProp::Ident(IdentName::new(prop, DUMMY_SP)), + })); + } else { + expr = Box::new(Expr::OptChain(OptChainExpr { + span: DUMMY_SP, + base: Box::new(OptChainBase::Member(MemberExpr { + span: DUMMY_SP, + obj: expr, + prop: MemberProp::Ident(IdentName::new(prop, DUMMY_SP)), + })), + optional, + })); + } + } + + expr + } +} + +fn emit_error(error_kind: ServerActionsErrorKind) { + let (span, msg) = match error_kind { + ServerActionsErrorKind::ExportedSyncFunction { span } => ( + span, + formatdoc! { + r#" + Only async functions are allowed to be exported in a \"use server\" file. + "#, + }, + ), + ServerActionsErrorKind::ForbiddenExpression { span, expr } => ( + span, + formatdoc! { + r#" + Server Actions cannot use `{expr}`. + "#, + }, + ), + ServerActionsErrorKind::InlineUseServerInClassInstanceMethod { span } => ( + span, + formatdoc! { + r#" + It is not allowed to define inline "use server" annotated class instance methods. + To define Server Actions, use functions, object method properties, or static class methods instead. + "# + }, + ), + ServerActionsErrorKind::InlineUseServerInClientComponent { span } => ( + span, + formatdoc! { + r#" + It is not allowed to define inline "use server" annotated Server Actions in Client Components. + To use Server Actions in a Client Component, you can either export them from a separate file with "use server" at the top, or pass them down through props from a Server Component. + "# + }, + ), + ServerActionsErrorKind::InlineSyncFunction { span } => ( + span, + formatdoc! { + r#" + Server Actions must be async functions. + "#, + }, + ), + ServerActionsErrorKind::MisplacedDirective { + span, + directive, + location, + } => ( + span, + formatdoc! { + r#" + The "{directive}" directive must be at the top of the {location}. + "#, + location = match location { + DirectiveLocation::Module => "file", + DirectiveLocation::FunctionBody => "function body", + } + }, + ), + ServerActionsErrorKind::MisplacedWrappedDirective { + span, + directive, + location, + } => ( + span, + formatdoc! { + r#" + The "{directive}" directive must be at the top of the {location}, and cannot be wrapped in parentheses. + "#, + location = match location { + DirectiveLocation::Module => "file", + DirectiveLocation::FunctionBody => "function body", + } + }, + ), + ServerActionsErrorKind::MisspelledDirective { + span, + directive, + expected_directive, + } => ( + span, + formatdoc! { + r#" + Did you mean "{expected_directive}"? "{directive}" is not a supported directive name." + "# + }, + ), + ServerActionsErrorKind::MultipleDirectives { span, location } => ( + span, + formatdoc! { + r#" + Conflicting directives "use server" found in the same {location}. You cannot place both directives at the top of a {location}. Please remove one of them. + "#, + location = match location { + DirectiveLocation::Module => "file", + DirectiveLocation::FunctionBody => "function body", + } + }, + ), + ServerActionsErrorKind::WrappedDirective { span, directive } => ( + span, + formatdoc! { + r#" + The "{directive}" directive cannot be wrapped in parentheses. + "# + }, + ), + }; + + HANDLER.with(|handler| handler.struct_span_err(span, &msg).emit()); +} + +/// Strips span information from a ModuleExportName, replacing all spans with DUMMY_SP. +/// Used in production builds to prevent leaking source code information in source maps to browsers. +fn strip_export_name_span(export_name: &ModuleExportName) -> ModuleExportName { + match export_name { + ModuleExportName::Ident(i) => { + ModuleExportName::Ident(Ident::new(i.sym.clone(), DUMMY_SP, i.ctxt)) + } + ModuleExportName::Str(s) => ModuleExportName::Str(Str { + span: DUMMY_SP, + value: s.value.clone(), + raw: None, + }), + } +} diff --git a/crates/rspack_loader_swc/src/rsc_transforms/to_module_ref.rs b/crates/rspack_loader_swc/src/rsc_transforms/to_module_ref.rs new file mode 100644 index 000000000000..3eb86a3f5fae --- /dev/null +++ b/crates/rspack_loader_swc/src/rsc_transforms/to_module_ref.rs @@ -0,0 +1,223 @@ +use indoc::formatdoc; +use rspack_core::{Module, NormalModule, RscModuleType}; +use rspack_error::{Result, ToStringResultToRspackResultExt}; +use swc::atoms::Wtf8Atom; + +fn to_cjs_server_entry(resource: &str, server_refs: &[Wtf8Atom]) -> String { + let mut cjs_source = + "const { createServerEntry } = require(\"react-server-dom-rspack/server\");\n".to_string(); + + for server_ref in server_refs { + match server_ref.as_str() { + Some("default") => { + cjs_source.push_str(&formatdoc! { + r#" + const _default = require("{resource}?rsc-server-entry-proxy=true"); + module.exports = createServerEntry( + _default, + "{resource}", + ); + "#, + resource = resource + }); + } + Some(ident) => { + cjs_source.push_str(&formatdoc! { + r#" + const _original_{ident} = require("{resource}?rsc-server-entry-proxy=true").{ident}; + exports.{ident} = createServerEntry( + _original_{ident}, + "{resource}", + ); + "#, + ident = ident, + resource = resource + }); + } + _ => {} + } + } + cjs_source +} + +fn to_esm_server_entry(resource: &str, server_refs: &[Wtf8Atom]) -> String { + let mut esm_source = + "import { createServerEntry } from \"react-server-dom-rspack/server\";\n".to_string(); + + for server_ref in server_refs { + match server_ref.as_str() { + Some("default") => { + esm_source.push_str(&formatdoc! { + r#" + import _default from "{resource}?rsc-server-entry-proxy=true"; + export default createServerEntry( + _default, + "{resource}", + ) + "#, + resource = resource + }); + } + Some(ident) => { + esm_source.push_str(&formatdoc! { + r#" + import {{ {ident} as _original_{ident} }} from "{resource}?rsc-server-entry-proxy=true"; + export const {ident} = createServerEntry( + _original_{ident}, + "{resource}", + ) + "#, + ident = ident, + resource = resource, + }); + } + _ => {} + } + } + esm_source +} + +fn to_esm_client_entry(resource: &str, client_refs: &[Wtf8Atom]) -> Result { + let mut esm_source = + String::from("import { registerClientReference } from \"react-server-dom-rspack/server\"\n"); + + let call_error = format!( + "Attempted to call the default export of {} from \ + the server, but it's on the client. It's not possible to invoke a \ + client function from the server, it can only be rendered as a \ + Component or passed to props of a Client Component.", + serde_json::to_string(resource).to_rspack_result()? + ); + + for client_ref in client_refs { + match client_ref.as_str() { + Some("default") => { + esm_source.push_str(&formatdoc! { + r#" + export default registerClientReference( + function() {{ throw new Error({call_error}) }}, + "{resource}", + "default", + ) + "#, + resource = resource, + call_error = serde_json::to_string(&call_error).to_rspack_result()? + }); + } + Some(ident) => { + esm_source.push_str(&formatdoc! { + r#" + export const {ident} = registerClientReference( + function() {{ throw new Error({call_error}) }}, + "{resource}", + "{ident}", + ) + "#, + ident = ident, + resource = resource, + call_error = serde_json::to_string(&call_error).to_rspack_result()? + }); + } + _ => {} + } + } + Ok(esm_source) +} + +fn to_cjs_client_entry(resource: &str, client_refs: &[Wtf8Atom]) -> Result { + let mut cjs_source = String::from( + "const { registerClientReference } = require(\"react-server-dom-rspack/server\");\n", + ); + + let call_error = format!( + "Attempted to call the default export of {} from \ + the server, but it's on the client. It's not possible to invoke a \ + client function from the server, it can only be rendered as a \ + Component or passed to props of a Client Component.", + serde_json::to_string(resource).to_rspack_result()? + ); + + for client_ref in client_refs { + match client_ref.as_str() { + Some("default") => { + cjs_source.push_str(&formatdoc! { + r#" + module.exports = registerClientReference( + function() {{ throw new Error({call_error}) }}, + "{resource}", + "default", + ); + "#, + resource = resource, + call_error = serde_json::to_string(&call_error).to_rspack_result()? + }); + } + Some(ident) => { + cjs_source.push_str(&formatdoc! { + r#" + exports.{ident} = registerClientReference( + function() {{ throw new Error({call_error}) }}, + "{resource}", + "{ident}", + ); + "#, + ident = ident, + resource = resource, + call_error = serde_json::to_string(&call_error).to_rspack_result()? + }); + } + _ => {} + } + } + Ok(cjs_source) +} + +pub fn to_module_ref(module: &NormalModule) -> Result> { + let is_react_server_layer = module + .get_layer() + .is_some_and(|layer| layer == "react-server-components"); + if !is_react_server_layer { + return Ok(None); + } + + let Some(rsc) = module.build_info().rsc.as_ref() else { + return Ok(None); + }; + + let resource = module.resource_resolved_data().resource(); + if rsc.module_type == RscModuleType::ServerEntry { + if rsc + .server_refs + .iter() + .any(|server_ref| server_ref.as_str() == Some("*")) + { + return Err(rspack_error::error!( + r#"It's currently unsupported to use "export *" in a server entry. Please use named exports instead."# + )); + } + if rsc.is_cjs { + return Ok(Some(to_cjs_server_entry(resource, &rsc.server_refs))); + } else { + return Ok(Some(to_esm_server_entry(resource, &rsc.server_refs))); + } + } + + if rsc.module_type == RscModuleType::Client { + if rsc + .client_refs + .iter() + .any(|client_ref| client_ref.as_str() == Some("*")) + { + return Err(rspack_error::error!( + r#"It's currently unsupported to use "export *" in a client boundary. Please use named exports instead."# + )); + } + if rsc.is_cjs { + return Ok(Some(to_cjs_client_entry(resource, &rsc.client_refs)?)); + } else { + return Ok(Some(to_esm_client_entry(resource, &rsc.client_refs)?)); + } + } + + Ok(None) +} diff --git a/crates/rspack_plugin_javascript/src/parser_plugin/api_plugin.rs b/crates/rspack_plugin_javascript/src/parser_plugin/api_plugin.rs index 62fda48ca154..77fa3448f6d7 100644 --- a/crates/rspack_plugin_javascript/src/parser_plugin/api_plugin.rs +++ b/crates/rspack_plugin_javascript/src/parser_plugin/api_plugin.rs @@ -58,6 +58,7 @@ const API_REQUIRE: &str = "__webpack_require__"; const API_GET_SCRIPT_FILENAME: &str = "__webpack_get_script_filename__"; const API_VERSION: &str = "__rspack_version__"; const API_UNIQUE_ID: &str = "__rspack_unique_id__"; +const API_RSC_MANIFEST: &str = "__rspack_rsc_manifest__"; pub struct APIPluginOptions { module: bool, @@ -93,6 +94,7 @@ fn get_typeof_evaluate_of_api(sym: &str) -> Option<&str> { API_GET_SCRIPT_FILENAME => Some("function"), API_VERSION => Some("string"), API_UNIQUE_ID => Some("string"), + API_RSC_MANIFEST => Some("object"), _ => None, } } @@ -334,6 +336,17 @@ impl JavascriptParserPlugin for APIPlugin { ))); Some(true) } + API_RSC_MANIFEST => { + parser.add_presentational_dependency(Box::new(ConstDependency::new( + ident.span.into(), + parser + .runtime_template + .render_runtime_globals(&RuntimeGlobals::RSC_MANIFEST) + .into(), + Some(RuntimeGlobals::RSC_MANIFEST), + ))); + Some(true) + } _ => None, } } diff --git a/crates/rspack_plugin_rsc/Cargo.toml b/crates/rspack_plugin_rsc/Cargo.toml new file mode 100644 index 000000000000..126de59e2391 --- /dev/null +++ b/crates/rspack_plugin_rsc/Cargo.toml @@ -0,0 +1,43 @@ +[package] +description = "Rspack React Server Component plugin" +edition.workspace = true +license = "MIT" +name = "rspack_plugin_rsc" +repository = "https://github.com/web-infra-dev/rspack" +version.workspace = true +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +allocative = { workspace = true, optional = true } +async-trait = { workspace = true } +atomic_refcell = { workspace = true } +derive_more = { workspace = true } +form_urlencoded = { workspace = true } +futures = { workspace = true } +indoc = { workspace = true } +once_cell = { workspace = true } +regex = { workspace = true } +rspack_cacheable = { workspace = true } +rspack_collections = { workspace = true } +rspack_core = { workspace = true } +rspack_error = { workspace = true } +rspack_hash = { workspace = true } +rspack_hook = { workspace = true } +rspack_loader_runner = { workspace = true } +rspack_plugin_javascript = { workspace = true } +rspack_util = { workspace = true } +rustc-hash = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +simd-json = { workspace = true } +swc_core = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +urlencoding = { workspace = true } + +[package.metadata.cargo-shear] +ignored = ["tracing", "rspack_hash"] + +[lints.rust.unexpected_cfgs] +check-cfg = ['cfg(allocative)'] +level = "warn" diff --git a/crates/rspack_plugin_rsc/LICENSE b/crates/rspack_plugin_rsc/LICENSE new file mode 100644 index 000000000000..46310101ad8a --- /dev/null +++ b/crates/rspack_plugin_rsc/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022-present Bytedance, Inc. and its affiliates. + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/rspack_plugin_rsc/src/client_plugin.rs b/crates/rspack_plugin_rsc/src/client_plugin.rs new file mode 100644 index 000000000000..40a619b8ca70 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/client_plugin.rs @@ -0,0 +1,588 @@ +use std::sync::Arc; + +use atomic_refcell::AtomicRefCell; +use derive_more::Debug; +use rspack_collections::Identifier; +use rspack_core::{ + ChunkGraph, ChunkGroup, ChunkGroupUkey, ChunkUkey, Compilation, CompilationAfterProcessAssets, + CompilerFailed, CompilerId, CompilerMake, CrossOriginLoading, Dependency, DependencyId, + EntryDependency, Logger, ModuleGraph, ModuleId, ModuleIdentifier, Plugin, +}; +use rspack_error::{Diagnostic, Result}; +use rspack_hook::{plugin, plugin_hook}; +use rspack_util::fx_hash::FxIndexSet; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::{ + Coordinator, + loaders::client_entry_loader::{ + CLIENT_ENTRY_LOADER_IDENTIFIER, ParsedClientEntries, parse_client_entries, + }, + plugin_state::{ActionIdNamePair, PLUGIN_STATES, PluginState}, + reference_manifest::{CrossOriginMode, ManifestExport, ModuleLoading}, + utils::{encode_uri_path, get_module_resource, is_css_mod}, +}; + +pub struct RscClientPluginOptions { + pub coordinator: Arc, +} + +#[plugin] +#[derive(Debug)] +pub struct RscClientPlugin { + #[debug(skip)] + coordinator: Arc, + server_compiler_id: AtomicRefCell>, + client_entries_per_entry: AtomicRefCell>>, +} + +fn extend_required_chunks( + chunk_group: &ChunkGroup, + compilation: &Compilation, + required_chunks: &mut Vec, +) { + for chunk_ukey in &chunk_group.chunks { + let Some(chunk) = compilation.chunk_by_ukey.get(chunk_ukey) else { + continue; + }; + let Some(chunk_id) = chunk.id() else { + continue; + }; + for file in chunk.files() { + if let Some(asset) = compilation.assets().get(file) { + let asset_info = asset.get_info(); + if asset_info.hot_module_replacement.unwrap_or(false) + || asset_info.development.unwrap_or(false) + { + continue; + } + }; + required_chunks.push(chunk_id.to_string()); + // We encode the file as a URI because our server (and many other services such as S3) + // expect to receive reserved characters such as `[` and `]` as encoded. This was + // previously done for dynamic chunks by patching the Rspack runtime but we want + // these filenames to be managed by React's Flight runtime instead and so we need + // to implement any special handling of the file name here. + required_chunks.push(encode_uri_path(file)); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn record_module( + entry_name: &str, + module_id: &ModuleId, + module_identifier: &ModuleIdentifier, + chunk_ukey: &ChunkUkey, + compilation: &Compilation, + required_chunks: &[String], + plugin_state: &mut PluginState, +) { + let Some(module) = compilation.module_by_identifier(module_identifier) else { + return; + }; + + let resource = get_module_resource(module.as_ref()); + if resource.is_empty() { + return; + } + + if is_css_mod(module.as_ref()) { + let (Some(chunk), Some(entry_css_imports)) = ( + compilation.chunk_by_ukey.get(chunk_ukey), + plugin_state.entry_css_imports.get(entry_name), + ) else { + return; + }; + + let prefix = &plugin_state + .module_loading + .as_ref() + .expect("module_loading should be initialized in traverse_modules before recording modules") + .prefix; + let css_files: Vec = chunk + .files() + .iter() + .filter(|file| file.ends_with(".css")) + .map(|file| format!("{}{}", prefix, file)) + .collect(); + if css_files.is_empty() { + return; + } + + let entry_css_files = plugin_state + .entry_css_files + .entry(entry_name.to_string()) + .or_default(); + + for (server_entry, imports) in entry_css_imports { + if imports.get(resource.as_ref()).is_some() { + entry_css_files + .entry(server_entry.clone()) + .or_default() + .extend(css_files.iter().cloned()); + } + } + return; + } + + let is_async = ModuleGraph::is_async( + &compilation.async_modules_artifact.borrow(), + module_identifier, + ); + plugin_state.client_modules.insert( + resource.to_string(), + ManifestExport { + id: module_id.to_string(), + name: "*".to_string(), + chunks: required_chunks.to_vec(), + r#async: Some(is_async), + }, + ); +} + +#[allow(clippy::too_many_arguments)] +fn record_chunk_group( + entry_name: &str, + client_entry_modules: &FxHashSet, + chunk_group: &ChunkGroup, + compilation: &Compilation, + required_chunks: &mut Vec, + checked_chunk_groups: &mut FxHashSet, + checked_chunks: &mut FxHashSet, + plugin_state: &mut PluginState, +) { + // Ensure recursion is stopped if we've already checked this chunk group. + if checked_chunk_groups.contains(&chunk_group.ukey) { + return; + } + checked_chunk_groups.insert(chunk_group.ukey); + + let module_graph = compilation.get_module_graph(); + + // Only apply following logic to client module requests from client entry, + // or if the module is marked as client module. That's because other + // client modules don't need to be in the manifest at all as they're + // never be referenced by the server/client boundary. + // This saves a lot of bytes in the manifest. + for chunk_ukey in &chunk_group.chunks { + // Ensure recursion is stopped if we've already checked this chunk. + if checked_chunks.contains(chunk_ukey) { + continue; + } + checked_chunks.insert(*chunk_ukey); + + let chunk_modules = compilation + .chunk_graph + .get_chunk_modules_identifier(chunk_ukey); + for module_identifier in chunk_modules { + if !client_entry_modules.contains(module_identifier) { + continue; + } + let Some(module_id) = + ChunkGraph::get_module_id(&compilation.module_ids_artifact, *module_identifier) + else { + continue; + }; + let Some(module) = module_graph.module_by_identifier(module_identifier) else { + continue; + }; + + if let Some(concatenated_module) = module.as_concatenated_module() { + for inner_module in concatenated_module.get_modules() { + record_module( + entry_name, + module_id, + &inner_module.id, + chunk_ukey, + compilation, + required_chunks, + plugin_state, + ); + } + } else { + record_module( + entry_name, + module_id, + module_identifier, + chunk_ukey, + compilation, + required_chunks, + plugin_state, + ); + } + } + } + + // Walk through all children chunk groups too. + for child_ukey in chunk_group.children_iterable() { + let Some(child) = compilation.chunk_group_by_ukey.get(child_ukey) else { + continue; + }; + let start_len = required_chunks.len(); + extend_required_chunks(child, compilation, required_chunks); + record_chunk_group( + entry_name, + client_entry_modules, + child, + compilation, + required_chunks, + checked_chunk_groups, + checked_chunks, + plugin_state, + ); + required_chunks.truncate(start_len); + } +} + +async fn collect_entry_js_files( + compilation: &Compilation, + plugin_state: &mut PluginState, +) -> Result<()> { + for (entry_name, chunk_group_ukey) in &compilation.entrypoints { + let Some(chunk_group) = compilation.chunk_group_by_ukey.get(chunk_group_ukey) else { + continue; + }; + let entry_js_files = plugin_state + .entry_js_files + .entry(entry_name.to_string()) + .or_default(); + let prefix = &plugin_state + .module_loading + .as_ref() + .expect("module_loading should be initialized in traverse_modules before recording modules") + .prefix; + + *entry_js_files = chunk_group + .get_files(&compilation.chunk_by_ukey) + .into_iter() + .filter(|chunk_file| chunk_file.ends_with(".js")) + .filter(|chunk_file| { + let Some(asset) = compilation.assets().get(chunk_file) else { + return true; + }; + // Prevent hot-module files from being included + let asset_info = asset.get_info(); + !(asset_info.hot_module_replacement.unwrap_or(false) + || asset_info.development.unwrap_or(false)) + }) + .map(|file| format!("{}{}", prefix, file)) + .collect::>(); + } + Ok(()) +} + +fn collect_actions( + module_graph: &ModuleGraph, + module_identifier: &ModuleIdentifier, + collected_actions: &mut FxHashMap>, + visited_modules: &mut FxHashSet, +) { + let module = match module_graph.module_by_identifier(module_identifier) { + Some(m) => m, + None => return, + }; + + let module_resource = get_module_resource(module.as_ref()); + if module_resource.is_empty() { + return; + } + + if visited_modules.contains(module_identifier) { + return; + } + visited_modules.insert(*module_identifier); + + if let Some(action_ids) = module.build_info().rsc.as_ref().map(|rsc| &rsc.action_ids) { + let pairs = action_ids + .into_iter() + .map(|(id, exported_name)| (id.clone(), exported_name.clone())) + .collect::>(); + + collected_actions.insert(module_resource.to_string(), pairs); + } + + // Collect used exported actions transversely. + for dependency_id in module_graph.get_outgoing_deps_in_order(module_identifier) { + let Some(resolved_module) = module_graph.get_resolved_module(dependency_id) else { + continue; + }; + collect_actions( + module_graph, + resolved_module, + collected_actions, + visited_modules, + ); + } +} + +fn collect_client_actions_from_dependencies( + compilation: &Compilation, + entry_dependencies: &FxHashSet, +) -> FxHashMap> { + // action file path -> action names + let mut collected_actions: FxHashMap> = Default::default(); + + // Keep track of checked modules to avoid infinite loops with recursive imports. + let mut visited_modules: FxHashSet = Default::default(); + + let module_graph = compilation.get_module_graph(); + for entry_dependency_id in entry_dependencies { + let Some(entry_module_identifier) = module_graph.get_resolved_module(entry_dependency_id) + else { + continue; + }; + for dependency_id in module_graph.get_outgoing_deps_in_order(entry_module_identifier) { + let Some(module_identifier) = module_graph.get_resolved_module(dependency_id) else { + continue; + }; + collect_actions( + module_graph, + module_identifier, + &mut collected_actions, + &mut visited_modules, + ); + } + } + + collected_actions +} + +impl RscClientPlugin { + pub fn new(options: RscClientPluginOptions) -> Self { + Self::new_inner(options.coordinator, Default::default(), Default::default()) + } + + async fn traverse_modules( + &self, + compilation: &Compilation, + plugin_state: &mut PluginState, + ) -> Result<()> { + let public_path = &compilation.options.output.public_path; + let configured_cross_origin_loading = &compilation.options.output.cross_origin_loading; + + let prefix = match public_path { + rspack_core::PublicPath::Filename(filename) => match filename.template() { + Some(template) => template.to_string(), + None => { + return Err(rspack_error::error!( + "Expected Rspack publicPath to be a string when using React Server Components." + )); + } + }, + rspack_core::PublicPath::Auto => "/".to_string(), + }; + + let cross_origin: Option = match configured_cross_origin_loading { + CrossOriginLoading::Enable(value) => { + if value == "use-credentials" { + Some(CrossOriginMode::UseCredentials) + } else { + Some(CrossOriginMode::Anonymous) + } + } + _ => None, + }; + + plugin_state.module_loading = Some(ModuleLoading { + prefix, + cross_origin, + }); + + let mut client_entry_modules: FxHashSet = Default::default(); + let module_graph = compilation.get_module_graph(); + for entry_data in compilation.entries.values() { + for dependency_id in &entry_data.include_dependencies { + let Some(module_identifier) = + module_graph.module_identifier_by_dependency_id(dependency_id) + else { + continue; + }; + let Some(module) = module_graph.module_by_identifier(module_identifier) else { + continue; + }; + + let is_client_loader = module + .as_normal_module() + .map(|m| m.user_request().starts_with(CLIENT_ENTRY_LOADER_IDENTIFIER)) + .unwrap_or(false); + if !is_client_loader { + continue; + } + for dependency_id in module_graph.get_outgoing_deps_in_order(module_identifier) { + if let Some(conn) = module_graph.connection_by_dependency_id(dependency_id) { + client_entry_modules.insert(*conn.module_identifier()); + } + } + } + } + + let mut required_chunks: Vec = Default::default(); + let mut checked_chunk_groups: FxHashSet = Default::default(); + let mut checked_chunks: FxHashSet = Default::default(); + + for (entry_name, entrypoint_ukey) in &compilation.entrypoints { + let Some(entrypoint) = compilation.chunk_group_by_ukey.get(entrypoint_ukey) else { + continue; + }; + + required_chunks.clear(); + checked_chunk_groups.clear(); + checked_chunks.clear(); + + record_chunk_group( + entry_name, + &client_entry_modules, + entrypoint, + compilation, + &mut required_chunks, + &mut checked_chunk_groups, + &mut checked_chunks, + plugin_state, + ); + } + + Ok(()) + } +} + +impl Plugin for RscClientPlugin { + fn name(&self) -> &'static str { + "RscClientPlugin" + } + + fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> { + ctx.compiler_hooks.make.tap(make::new(self)); + + ctx.compiler_hooks.failed.tap(failed::new(self)); + + ctx + .compilation_hooks + .after_process_assets + .tap(after_process_assets::new(self)); + + Ok(()) + } +} + +// Execution must occur after EntryPlugin to ensure base entries are established +// before injecting client component entries. Stage 100 ensures proper ordering. +#[plugin_hook(CompilerMake for RscClientPlugin, stage = 100)] +async fn make(&self, compilation: &mut Compilation) -> Result<()> { + self.coordinator.start_client_entries_compilation().await?; + + let server_compiler_id = self.coordinator.get_server_compiler_id().await?; + *self.server_compiler_id.borrow_mut() = Some(server_compiler_id); + + let plugin_states = PLUGIN_STATES.borrow_mut(); + let plugin_state = plugin_states.get(&server_compiler_id).ok_or_else(|| { + rspack_error::error!( + "RscClientPlugin: Plugin state not found in make hook for compiler {:#?}.", + compilation.compiler_id() + ) + })?; + + let context = compilation.options.context.clone(); + let mut include_dependencies = vec![]; + for (entry_name, import) in &plugin_state.injected_client_entries { + { + if compilation.entries.get(entry_name).is_none() { + let loader_query = import + .split_once('?') + .map(|x| x.1) + .unwrap_or_default() + .rsplit_once('!') + .map(|x| x.0) + .unwrap_or_default(); + let ParsedClientEntries { modules, .. } = parse_client_entries(loader_query)?; + compilation.push_diagnostic(Diagnostic::error( + "RSC Client Entry Mismatch".to_string(), + format!( + "Entry '{}' not found in the client compiler. Failed to inject the following client modules: {}", + entry_name, + modules + .into_iter() + .map(|m| m.request) + .collect::>() + .join(", ") + ), + )); + continue; + } + + let dependency = Box::new(EntryDependency::new( + import.to_string(), + context.clone(), + None, + false, + )); + self + .client_entries_per_entry + .borrow_mut() + .entry(entry_name.clone()) + .or_default() + .insert(*dependency.id()); + include_dependencies.push(*dependency.id()); + compilation + .get_module_graph_mut() + .add_dependency(dependency); + } + + #[allow(clippy::unwrap_used)] + let entry_data = compilation.entries.get_mut(entry_name).unwrap(); + entry_data + .include_dependencies + .append(&mut include_dependencies); + } + + Ok(()) +} + +#[plugin_hook(CompilationAfterProcessAssets for RscClientPlugin)] +async fn after_process_assets( + &self, + compilation: &Compilation, + _diagnostics: &mut Vec, +) -> Result<()> { + let logger = compilation.get_logger("rspack.RscClientPlugin"); + + let server_compiler_id = self.coordinator.get_server_compiler_id().await?; + + let mut plugin_states = PLUGIN_STATES.borrow_mut(); + let Some(plugin_state) = plugin_states.get_mut(&server_compiler_id) else { + return Err(rspack_error::error!( + "Failed to find plugin state for server compiler (ID: {}). \ + The server compiler may not have properly collected client entry information, \ + or the compiler has not been initialized yet.", + server_compiler_id.as_u32() + )); + }; + + let start = logger.time("create client reference manifest"); + self.traverse_modules(compilation, plugin_state).await?; + logger.time_end(start); + + let start = logger.time("record entry js files"); + collect_entry_js_files(compilation, plugin_state).await?; + logger.time_end(start); + + for (entry_name, client_entries) in self.client_entries_per_entry.borrow().iter() { + let client_actions = collect_client_actions_from_dependencies(compilation, client_entries); + plugin_state + .client_actions_per_entry + .insert(entry_name.clone(), client_actions); + } + + self + .coordinator + .complete_client_entries_compilation() + .await?; + + Ok(()) +} + +#[plugin_hook(CompilerFailed for RscClientPlugin)] +async fn failed(&self, _compilation: &Compilation) -> Result<()> { + self.coordinator.failed().await?; + Ok(()) +} diff --git a/crates/rspack_plugin_rsc/src/component_info.rs b/crates/rspack_plugin_rsc/src/component_info.rs new file mode 100644 index 000000000000..a66f205dd080 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/component_info.rs @@ -0,0 +1,353 @@ +use derive_more::Debug; +use rspack_core::{ + Compilation, DependencyId, ExportsInfoGetter, Module, ModuleIdentifier, PrefetchExportsInfoMode, + RscMeta, RscModuleType, RuntimeSpec, +}; +use rspack_plugin_javascript::dependency::{ + CommonJsExportRequireDependency, ESMExportImportedSpecifierDependency, + ESMImportSpecifierDependency, +}; +use rspack_util::fx_hash::{FxIndexMap, FxIndexSet}; +use rustc_hash::{FxHashMap, FxHashSet}; +use swc_core::atoms::{Atom, Wtf8Atom}; + +use crate::{ + constants::{IMAGE_REGEX, LAYERS_NAMES}, + plugin_state::ActionIdNamePair, + utils::{get_module_resource, is_css_mod}, +}; + +// { [client import path]: [exported names] } +pub type ClientComponentImports = FxHashMap>; +// { [server entry path]: [css imports] } +pub type CssImports = FxHashMap>; + +#[derive(Debug, Default)] +pub struct ComponentInfo { + pub should_inject_ssr_modules: bool, + pub css_imports: CssImports, + pub client_component_imports: ClientComponentImports, + pub action_imports: Vec<(String, Vec)>, +} + +pub fn collect_component_info_from_entry_denendency( + compilation: &Compilation, + runtime: &RuntimeSpec, + dependency_id: &DependencyId, +) -> ComponentInfo { + let mut component_info: ComponentInfo = Default::default(); + + let module_graph = compilation.get_module_graph(); + let Some(resolved_module) = module_graph + .get_resolved_module(dependency_id) + .and_then(|identifier| compilation.module_by_identifier(identifier)) + else { + return component_info; + }; + + // Keep track of checked modules to avoid infinite loops with recursive imports. + let mut visited_of_client_components_traverse: FxHashSet = FxHashSet::default(); + + // Info to collect. + let mut server_entries: Vec = Default::default(); + + // Traverse the module graph to find all client components. + + traverse_with_server_entry_context( + compilation, + resolved_module.as_ref(), + runtime, + &[], + &mut visited_of_client_components_traverse, + &mut server_entries, + &mut component_info, + ); + + component_info +} + +fn traverse_with_server_entry_context( + compilation: &Compilation, + module: &dyn Module, + runtime: &RuntimeSpec, + imported_identifiers: &[String], + visited: &mut FxHashSet, + server_entries: &mut Vec, + component_info: &mut ComponentInfo, +) { + let is_server_entry = { + get_module_rsc_information(module) + .is_some_and(|rsc| rsc.module_type == RscModuleType::ServerEntry) + }; + if is_server_entry { + server_entries.push(get_module_resource(module).to_string()); + } + filter_client_components( + compilation, + module, + runtime, + imported_identifiers, + visited, + server_entries, + component_info, + ); + if is_server_entry { + server_entries.pop(); + } +} + +#[allow(clippy::too_many_arguments)] +fn filter_client_components( + compilation: &Compilation, + module: &dyn Module, + runtime: &RuntimeSpec, + imported_identifiers: &[String], + visited: &mut FxHashSet, + server_entries: &mut Vec, + component_info: &mut ComponentInfo, +) { + let resource = get_module_resource(module); + if resource.is_empty() { + return; + } + + if visited.contains(&module.identifier()) { + if component_info + .client_component_imports + .contains_key(resource.as_ref()) + { + add_client_import( + module, + &resource, + imported_identifiers, + false, + &mut component_info.client_component_imports, + ); + } + return; + } + visited.insert(module.identifier()); + + if !component_info.should_inject_ssr_modules + && module + .get_layer() + .is_some_and(|layer| layer == LAYERS_NAMES.server_side_rendering) + { + component_info.should_inject_ssr_modules = true; + } + + let actions = get_actions_from_build_info(module); + if let Some(actions) = actions { + component_info.action_imports.push(( + resource.to_string(), + actions + .iter() + .map(|(id, exported_name)| (id.clone(), exported_name.clone())) + .collect(), + )); + } + + let module_graph = compilation.get_module_graph(); + if is_css_mod(module) { + let side_effect_free = module + .factory_meta() + .and_then(|meta| meta.side_effect_free) + .unwrap_or(false); + + if side_effect_free { + let exports_info = module_graph.get_exports_info(&module.identifier()); + let prefetched_exports_info = ExportsInfoGetter::prefetch( + &exports_info, + module_graph, + PrefetchExportsInfoMode::Default, + ); + let unused = !prefetched_exports_info.is_module_used(Some(runtime)); + if unused { + return; + } + } + + for server_entry in server_entries.iter() { + component_info + .css_imports + .entry(server_entry.clone()) + .or_default() + .insert(resource.to_string()); + } + } else if is_client_component_entry_module(module) { + if !component_info + .client_component_imports + .contains_key(resource.as_ref()) + { + component_info + .client_component_imports + .insert(resource.to_string(), Default::default()); + } + add_client_import( + module, + resource.as_ref(), + imported_identifiers, + true, + &mut component_info.client_component_imports, + ); + return; + } + + for dependency_id in module_graph.get_outgoing_deps_in_order(&module.identifier()) { + let Some(connection) = module_graph.connection_by_dependency_id(dependency_id) else { + continue; + }; + let mut dependency_ids = Vec::new(); + + // `ids` are the identifiers that are imported from the dependency, + // if it's present, it's an array of strings. + let dependency = module_graph.dependency_by_id(&connection.dependency_id); + let ids = if let Some(dependency) = dependency.downcast_ref::() + { + Some(dependency.get_ids(module_graph)) + } else if let Some(dependency) = + dependency.downcast_ref::() + { + Some(dependency.get_ids(module_graph)) + } else { + dependency + .downcast_ref::() + .map(|dependency| dependency.get_ids(module_graph)) + }; + if let Some(ids) = ids { + for id in ids { + dependency_ids.push(id.to_string()); + } + } else { + dependency_ids.push("*".into()); + } + + let Some(resolved_module) = module_graph.module_by_identifier(&connection.resolved_module) + else { + continue; + }; + traverse_with_server_entry_context( + compilation, + resolved_module.as_ref(), + runtime, + &dependency_ids, + visited, + server_entries, + component_info, + ); + } +} + +fn add_client_import( + module: &dyn Module, + mod_request: &str, + imported_identifiers: &[String], + is_first_visit_module: bool, + client_component_imports: &mut ClientComponentImports, +) { + let rsc = get_module_rsc_information(module); + let is_cjs_module = rsc.as_ref().is_some_and(|rsc| rsc.is_cjs); + let assumed_source_type = + get_assumed_source_type(module, if is_cjs_module { "commonjs" } else { "auto" }); + + let client_imports_set = client_component_imports + .entry(mod_request.to_string()) + .or_default(); + + if imported_identifiers + .first() + .map(|identifier| identifier.as_str()) + == Some("*") + { + // If there's collected import path with named import identifiers, + // or there's nothing in collected imports are empty. + // we should include the whole module. + if !is_first_visit_module && !client_imports_set.contains("*") { + client_component_imports.insert( + mod_request.to_string(), + FxHashSet::from_iter(["*".to_string()]), + ); + } + } else { + let is_auto_module_source_type = assumed_source_type == "auto"; + if is_auto_module_source_type { + client_component_imports.insert( + mod_request.to_string(), + FxHashSet::from_iter(["*".to_string()]), + ); + } else { + // If it's not analyzed as named ESM exports, e.g. if it's mixing `export *` with named exports, + // We'll include all modules since it's not able to do tree-shaking. + for name in imported_identifiers { + // For cjs module default import, we include the whole module since + let is_cjs_default_import = is_cjs_module && name == "default"; + + // Always include __esModule along with cjs module default export, + // to make sure it works with client module proxy from React. + if is_cjs_default_import { + client_imports_set.insert("__esModule".to_string()); + } + + client_imports_set.insert(name.clone()); + } + } + } +} + +// Gives { id: name } record of actions from the build info. +fn get_actions_from_build_info(module: &dyn Module) -> Option<&FxIndexMap> { + let rsc = get_module_rsc_information(module)?; + Some(&rsc.action_ids) +} + +fn get_module_rsc_information(module: &dyn Module) -> Option<&RscMeta> { + module.build_info().rsc.as_ref() +} + +fn is_client_component_entry_module(module: &dyn Module) -> bool { + let rsc = get_module_rsc_information(module); + let has_client_directive = matches!(rsc, Some(rsc) if rsc.module_type == RscModuleType::Client); + let is_action_layer_entry = is_action_client_layer_module(module); + let is_image = if let Some(module) = module.as_normal_module() { + IMAGE_REGEX.is_match(module.resource_resolved_data().resource()) + } else { + false + }; + has_client_directive || is_action_layer_entry || is_image +} + +// Determine if the whole module is client action, 'use server' in nested closure in the client module +fn is_action_client_layer_module(module: &dyn Module) -> bool { + let rsc = get_module_rsc_information(module); + matches!(&rsc, Some(rsc) if !rsc.action_ids.is_empty()) + && matches!(&rsc, Some(rsc) if rsc.module_type == RscModuleType::Client) +} + +fn get_assumed_source_type<'a>(module: &dyn Module, source_type: &'a str) -> &'a str { + let rsc = get_module_rsc_information(module); + let is_cjs = rsc.as_ref().is_some_and(|rsc| rsc.is_cjs); + let client_refs: &[Wtf8Atom] = rsc + .as_ref() + .map(|rsc| rsc.client_refs.as_slice()) + .unwrap_or_default(); + + // It's tricky to detect the type of a client boundary, but we should always + // use the `module` type when we can, to support `export *` and `export from` + // syntax in other modules that import this client boundary. + + if source_type == "auto" { + if is_cjs { + return "commonjs"; + } else if client_refs.is_empty() { + // If there's zero export detected in the client boundary, and it's the + // `auto` type, we can safely assume it's a CJS module because it doesn't + // have ESM exports. + return "commonjs"; + } else if !client_refs.iter().any(|e| e == "*") { + // Otherwise, we assume it's an ESM module. + return "module"; + } + } + + source_type +} diff --git a/crates/rspack_plugin_rsc/src/constants.rs b/crates/rspack_plugin_rsc/src/constants.rs new file mode 100644 index 000000000000..8c3cb399ce62 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/constants.rs @@ -0,0 +1,27 @@ +use std::sync::LazyLock; + +use once_cell::sync::Lazy; +use regex::Regex; + +/// The names of the Rspack layers. These layers are the primitives for the +/// Rspack chunks. +pub const LAYERS_NAMES: LayersNames = LayersNames { + react_server_components: "react-server-components", + server_side_rendering: "server-side-rendering", +}; + +pub struct LayersNames { + pub react_server_components: &'static str, + pub server_side_rendering: &'static str, +} + +pub static CSS_REGEX: Lazy = Lazy::new(|| { + #[allow(clippy::unwrap_used)] + Regex::new(r"\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)$").unwrap() +}); + +pub static IMAGE_REGEX: LazyLock = LazyLock::new(|| { + let image_extensions = ["jpg", "jpeg", "png", "webp", "avif", "ico", "svg"]; + #[allow(clippy::unwrap_used)] + Regex::new(&format!(r"\.({})$", image_extensions.join("|"))).unwrap() +}); diff --git a/crates/rspack_plugin_rsc/src/coordinator.rs b/crates/rspack_plugin_rsc/src/coordinator.rs new file mode 100644 index 000000000000..59ac6c12ab1d --- /dev/null +++ b/crates/rspack_plugin_rsc/src/coordinator.rs @@ -0,0 +1,203 @@ +use atomic_refcell::AtomicRefCell; +use futures::future::BoxFuture; +use rspack_core::CompilerId; +use rspack_error::Result; +use tokio::sync::Notify; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Idle, + ServerEntriesCompiling, + ServerEntriesDone, + ClientEntriesCompiling, + ClientEntriesDone, + ServerActionsCompiling, + ServerActionsDone, + Failed, +} + +type GetServerCompilerId = Box BoxFuture<'static, Result> + Sync + Send>; + +/// Coordinates the compilation sequence between Server Compiler and Client Compiler. +/// +/// Ensures the following compilation order: +/// 1. Server Entries compilation(in Server Compiler) +/// 2. Client Entries compilation(in Client Compiler) +/// 3. Server Actions compilation(in Server Compiler) +/// +/// The coordinator manages state transitions and synchronization between compilers +/// to maintain the correct build sequence for React Server Components. +pub struct Coordinator { + state: AtomicRefCell, + state_notify: Notify, + get_server_compiler_id: GetServerCompilerId, +} + +impl Coordinator { + pub fn new(get_server_compiler_id: GetServerCompilerId) -> Self { + Self { + state: AtomicRefCell::new(State::Idle), + state_notify: Default::default(), + get_server_compiler_id, + } + } + + pub async fn get_server_compiler_id(&self) -> Result { + (self.get_server_compiler_id)().await + } + + async fn wait_for(&self, mut predicate: impl FnMut(State) -> bool) -> Result<()> { + loop { + { + let state = *self.state.borrow(); + if predicate(state) { + return Ok(()); + } + if state == State::Failed { + return Ok(()); + } + } + self.state_notify.notified().await; + } + } + + async fn transition(&self, expected: State, next: State, context: &'static str) -> Result<()> { + let mut state = self.state.borrow_mut(); + if *state == State::Failed { + return Ok(()); + } + + if *state != expected { + return Err(rspack_error::error!( + "Invalid state transition in {}: expected {:?}, got {:?}", + context, + expected, + *state + )); + } + *state = next; + self.state_notify.notify_waiters(); + Ok(()) + } + + async fn set_if_current(&self, current: State, next: State) -> bool { + let mut state = self.state.borrow_mut(); + if *state == current { + *state = next; + self.state_notify.notify_waiters(); + true + } else { + false + } + } + + pub async fn idle(&self) -> Result<()> { + self + .transition(State::ServerActionsDone, State::Idle, "idle") + .await + } + + async fn wait_idle(&self) -> Result<()> { + self.wait_for(|s| s == State::Idle).await + } + + pub async fn start_server_entries_compilation(&self) -> Result<()> { + loop { + if self + .set_if_current(State::Idle, State::ServerEntriesCompiling) + .await + { + return Ok(()); + } + self.wait_idle().await?; + } + } + + pub async fn complete_server_entries_compilation(&self) -> Result<()> { + self + .transition( + State::ServerEntriesCompiling, + State::ServerEntriesDone, + "complete_server_entries_compilation", + ) + .await + } + + async fn wait_server_entries_compiled(&self) -> Result<()> { + self.wait_for(|s| s == State::ServerEntriesDone).await + } + + pub async fn start_client_entries_compilation(&self) -> Result<()> { + loop { + if self + .set_if_current(State::ServerEntriesDone, State::ClientEntriesCompiling) + .await + { + return Ok(()); + } + self.wait_server_entries_compiled().await?; + } + } + + pub async fn complete_client_entries_compilation(&self) -> Result<()> { + self + .transition( + State::ClientEntriesCompiling, + State::ClientEntriesDone, + "complete_client_entries_compilation", + ) + .await + } + + async fn wait_client_entries_compiled(&self) -> Result<()> { + self.wait_for(|s| s == State::ClientEntriesDone).await + } + + pub async fn start_server_actions_compilation(&self) -> Result<()> { + loop { + if self + .set_if_current(State::ClientEntriesDone, State::ServerActionsCompiling) + .await + { + return Ok(()); + } + + { + let state = *self.state.borrow(); + match state { + State::ServerEntriesDone | State::ClientEntriesCompiling => { + // fallthrough to wait below + } + _ => { + return Err(rspack_error::error!( + "Invalid state transition in start_server_actions_compilation: expected {:?}/{:?}/{:?}, got {:?}", + State::ServerEntriesDone, + State::ClientEntriesCompiling, + State::ClientEntriesDone, + state + )); + } + } + } + + self.wait_client_entries_compiled().await?; + } + } + + pub async fn complete_server_actions_compilation(&self) -> Result<()> { + self + .transition( + State::ServerActionsCompiling, + State::ServerActionsDone, + "complete_server_actions_compilation", + ) + .await + } + + pub async fn failed(&self) -> Result<()> { + let mut state = self.state.borrow_mut(); + *state = State::Failed; + self.state_notify.notify_waiters(); + Ok(()) + } +} diff --git a/crates/rspack_plugin_rsc/src/hot_reloader.rs b/crates/rspack_plugin_rsc/src/hot_reloader.rs new file mode 100644 index 000000000000..acac10a827a3 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/hot_reloader.rs @@ -0,0 +1,112 @@ +use std::hash::{Hash, Hasher}; + +use rspack_collections::{IdentifierMap, IdentifierSet}; +use rspack_core::{Compilation, Module, ModuleGraph, RscModuleType}; +use rustc_hash::{FxHashMap, FxHasher}; + +use crate::constants::LAYERS_NAMES; + +pub fn track_server_component_changes( + compilation: &Compilation, + prev_server_component_hashes: &mut IdentifierMap, +) -> FxHashMap { + let module_graph = compilation.get_module_graph(); + + let mut visited_modules: IdentifierSet = Default::default(); + let mut changed_server_components_per_entry: FxHashMap = + Default::default(); + let mut cur_server_component_hashes = Default::default(); + + for (entry_name, entry_data) in &compilation.entries { + visited_modules.clear(); + + let changed_server_components = changed_server_components_per_entry + .entry(entry_name.to_string()) + .or_default(); + + let entry_dependency_id = entry_data.dependencies[0]; + let Some(resolved_module) = module_graph + .get_resolved_module(&entry_dependency_id) + .and_then(|identifier| compilation.module_by_identifier(identifier)) + else { + continue; + }; + + collect_changed_server_components( + compilation, + module_graph, + resolved_module.as_ref(), + prev_server_component_hashes, + &mut visited_modules, + &mut cur_server_component_hashes, + changed_server_components, + ) + } + + *prev_server_component_hashes = cur_server_component_hashes; + changed_server_components_per_entry +} + +#[allow(clippy::too_many_arguments)] +fn collect_changed_server_components( + compilation: &Compilation, + module_graph: &ModuleGraph, + module: &dyn Module, + prev_server_component_hashes: &IdentifierMap, + visited_modules: &mut IdentifierSet, + cur_server_component_hashes: &mut IdentifierMap, + changed_server_components: &mut IdentifierSet, +) { + let module_identifier = module.identifier(); + if visited_modules.contains(&module_identifier) { + return; + } + visited_modules.insert(module_identifier); + + if let Some(rsc) = module.build_info().rsc.as_ref() + && rsc.module_type == RscModuleType::Client + { + return; + } + + if module + .get_layer() + .is_some_and(|layer| layer == LAYERS_NAMES.react_server_components) + { + let Some(module) = compilation.module_by_identifier(&module_identifier) else { + return; + }; + let Some(source) = module.source() else { + return; + }; + let mut hasher = FxHasher::default(); + source.hash(&mut hasher); + let cur_hash = hasher.finish(); + if prev_server_component_hashes + .get(&module_identifier) + .is_some_and(|prev| *prev != cur_hash) + { + changed_server_components.insert(module_identifier); + } + cur_server_component_hashes.insert(module_identifier, cur_hash); + } + + for dependency_id in module_graph.get_outgoing_deps_in_order(&module_identifier) { + let Some(resolved_module) = module_graph + .connection_by_dependency_id(dependency_id) + .and_then(|c| module_graph.module_by_identifier(&c.resolved_module)) + else { + continue; + }; + + collect_changed_server_components( + compilation, + module_graph, + resolved_module.as_ref(), + prev_server_component_hashes, + visited_modules, + cur_server_component_hashes, + changed_server_components, + ); + } +} diff --git a/crates/rspack_plugin_rsc/src/lib.rs b/crates/rspack_plugin_rsc/src/lib.rs new file mode 100644 index 000000000000..5ef0556e722a --- /dev/null +++ b/crates/rspack_plugin_rsc/src/lib.rs @@ -0,0 +1,19 @@ +mod client_plugin; +mod component_info; +mod constants; +mod coordinator; +mod hot_reloader; +mod loaders; +mod manifest_runtime_module; +mod plugin_state; +mod reference_manifest; +mod server_plugin; +mod utils; + +pub use client_plugin::{RscClientPlugin, RscClientPluginOptions}; +pub use coordinator::Coordinator; +pub use loaders::{ + action_entry_loader_plugin::ActionEntryLoaderPlugin, + client_entry_loader_plugin::ClientEntryLoaderPlugin, +}; +pub use server_plugin::{RscServerPlugin, RscServerPluginOptions}; diff --git a/crates/rspack_plugin_rsc/src/loaders/action_entry_loader.rs b/crates/rspack_plugin_rsc/src/loaders/action_entry_loader.rs new file mode 100644 index 000000000000..e91a0743068c --- /dev/null +++ b/crates/rspack_plugin_rsc/src/loaders/action_entry_loader.rs @@ -0,0 +1,125 @@ +use std::sync::Arc; + +use rspack_cacheable::{cacheable, cacheable_dyn}; +use rspack_collections::Identifier; +use rspack_core::RunnerContext; +use rspack_error::{Result, ToStringResultToRspackResultExt}; +use rspack_loader_runner::{Loader, LoaderContext}; +use serde::{Deserialize, Serialize}; +use simd_json::base::{ValueAsArray, ValueAsObject, ValueAsScalar}; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ActionEntry { + pub id: String, + pub path: Arc, + pub exported_name: String, +} + +pub const ACTION_ENTRY_LOADER_IDENTIFIER: &str = "builtin:rsc-action-entry-loader"; + +#[cacheable] +#[derive(Debug)] +#[cfg_attr(allocative, derive(allocative::Allocative))] +pub struct ActionEntryLoader { + identifier: Identifier, +} + +impl ActionEntryLoader { + pub fn new() -> Self { + Self { + identifier: ACTION_ENTRY_LOADER_IDENTIFIER.into(), + } + } + + pub fn with_identifier>(mut self, identifier: T) -> Self { + let identifier = identifier.into(); + assert!(identifier.starts_with(ACTION_ENTRY_LOADER_IDENTIFIER)); + self.identifier = identifier; + self + } +} + +pub fn parse_action_entries(v: String) -> Result>> { + let mut action_entries = vec![]; + + let mut bytes = v.into_bytes(); + let borrowed_value = simd_json::to_borrowed_value(&mut bytes).to_rspack_result()?; + if let Some(object) = borrowed_value.as_object() { + for (path, value) in object.iter() { + let Some(item) = value.as_array() else { + return Ok(None); + }; + let path: Arc = Arc::from(path.to_string()); + for tuple in item { + let Some(tuple) = tuple.as_array() else { + return Ok(None); + }; + let id = match tuple.first().and_then(|v| v.as_str()) { + Some(id) => id, + None => continue, + }; + let exported_name = match tuple.get(1).and_then(|v| v.as_str()) { + Some(v) => v, + None => continue, + }; + action_entries.push(ActionEntry { + id: id.to_string(), + path: path.clone(), + exported_name: exported_name.to_string(), + }); + } + } + } + Ok(Some(action_entries)) +} + +#[cacheable_dyn] +#[async_trait::async_trait] +impl Loader for ActionEntryLoader { + fn identifier(&self) -> Identifier { + self.identifier + } + + #[tracing::instrument("loader:action-entry-loader", skip_all, fields( + perfetto.track_name = "loader:action-entry-loader", + perfetto.process_name = "Loader Analysis", + resource = loader_context.resource(), + ))] + async fn run(&self, loader_context: &mut LoaderContext) -> Result<()> { + let Some(loader_query) = loader_context.current_loader().query() else { + loader_context.finish_with("".to_string()); + return Ok(()); + }; + + let loader_options = form_urlencoded::parse(&loader_query.as_bytes()[1..]); + let mut individual_actions: Vec = vec![]; + for (k, v) in loader_options { + if k == "actions" { + individual_actions = parse_action_entries(v.into_owned())?.unwrap_or_default(); + } + } + + let code = individual_actions + .iter() + .map( + |ActionEntry { + id, + path, + exported_name, + }| { + Ok(format!( + "export {{ {} as \"{}\" }} from {}", + exported_name, + id, + serde_json::to_string(path).to_rspack_result()? + )) + }, + ) + .collect::>>()? + .join("\n"); + + loader_context.finish_with(code); + + Ok(()) + } +} diff --git a/crates/rspack_plugin_rsc/src/loaders/action_entry_loader_plugin.rs b/crates/rspack_plugin_rsc/src/loaders/action_entry_loader_plugin.rs new file mode 100644 index 000000000000..1989fb3c860d --- /dev/null +++ b/crates/rspack_plugin_rsc/src/loaders/action_entry_loader_plugin.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use rspack_core::{ + BoxLoader, Context, ModuleRuleUseLoader, NormalModuleFactoryResolveLoader, Plugin, Resolver, +}; +use rspack_error::Result; +use rspack_hook::{plugin, plugin_hook}; + +use crate::loaders::action_entry_loader::{ACTION_ENTRY_LOADER_IDENTIFIER, ActionEntryLoader}; + +#[plugin] +#[derive(Debug)] +pub struct ActionEntryLoaderPlugin; + +impl ActionEntryLoaderPlugin { + pub fn new() -> Self { + Self::new_inner() + } +} + +impl Default for ActionEntryLoaderPlugin { + fn default() -> Self { + Self::new() + } +} + +impl Plugin for ActionEntryLoaderPlugin { + fn name(&self) -> &'static str { + "ActionEntryLoaderPlugin" + } + + fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> { + ctx + .normal_module_factory_hooks + .resolve_loader + .tap(resolve_loader::new(self)); + Ok(()) + } +} + +#[plugin_hook(NormalModuleFactoryResolveLoader for ActionEntryLoaderPlugin)] +pub(crate) async fn resolve_loader( + &self, + _context: &Context, + _resolver: &Resolver, + l: &ModuleRuleUseLoader, +) -> Result> { + let loader_request = &l.loader; + if loader_request.starts_with(ACTION_ENTRY_LOADER_IDENTIFIER) { + let loader = Arc::new(ActionEntryLoader::new().with_identifier(loader_request.to_string())); + return Ok(Some(loader)); + } + Ok(None) +} diff --git a/crates/rspack_plugin_rsc/src/loaders/client_entry_loader.rs b/crates/rspack_plugin_rsc/src/loaders/client_entry_loader.rs new file mode 100644 index 000000000000..a2c14e0699b9 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/loaders/client_entry_loader.rs @@ -0,0 +1,156 @@ +use rspack_cacheable::{cacheable, cacheable_dyn}; +use rspack_collections::Identifier; +use rspack_core::RunnerContext; +use rspack_error::{Result, ToStringResultToRspackResultExt}; +use rspack_loader_runner::{Loader, LoaderContext}; +use serde::{Deserialize, Serialize}; +use simd_json::{ + BorrowedValue, + base::{ValueAsObject, ValueAsScalar}, + derived::ValueTryAsArray, +}; + +use crate::constants::CSS_REGEX; + +#[derive(Debug, Deserialize, Serialize)] +pub struct ClientEntry { + pub request: String, + pub ids: Vec, +} + +pub const CLIENT_ENTRY_LOADER_IDENTIFIER: &str = "builtin:rsc-client-entry-loader"; + +#[cacheable] +#[derive(Debug)] +#[cfg_attr(allocative, derive(allocative::Allocative))] +pub struct ClientEntryLoader { + identifier: Identifier, +} + +impl ClientEntryLoader { + pub fn new() -> Self { + Self { + identifier: CLIENT_ENTRY_LOADER_IDENTIFIER.into(), + } + } + + pub fn with_identifier>(mut self, identifier: T) -> Self { + let identifier = identifier.into(); + assert!(identifier.starts_with(CLIENT_ENTRY_LOADER_IDENTIFIER)); + self.identifier = identifier; + self + } +} + +fn parse_client_entry_from_value(object: &BorrowedValue) -> Option { + let object = object.as_object()?; + let request = object.get("request")?.as_str()?.to_string(); + let ids_array = object.get("ids")?.try_as_array().ok()?; + let ids = ids_array + .iter() + .filter_map(|id_value| id_value.as_str().map(String::from)) + .collect::>(); + Some(ClientEntry { request, ids }) +} + +#[derive(Debug, Default)] +pub struct ParsedClientEntries { + pub modules: Vec, + pub is_server: bool, +} + +pub fn parse_client_entries(query: &str) -> Result { + let loader_options = form_urlencoded::parse(query.as_bytes()); + let mut modules: Vec = vec![]; + let mut is_server: bool = false; + for (k, v) in loader_options { + if k == "modules" { + let mut bytes = v.to_string().into_bytes(); + let borrowed_value = simd_json::to_borrowed_value(&mut bytes).to_rspack_result()?; + match borrowed_value.try_as_array() { + Ok(array) => { + for item in array.iter() { + if let Some(component) = parse_client_entry_from_value(item) { + modules.push(component); + } + } + } + Err(_) => { + if let Some(component) = parse_client_entry_from_value(&borrowed_value) { + modules.push(component); + } + } + } + } else if k == "server" && v == "true" { + is_server = true; + } + } + Ok(ParsedClientEntries { modules, is_server }) +} + +#[cacheable_dyn] +#[async_trait::async_trait] +impl Loader for ClientEntryLoader { + fn identifier(&self) -> Identifier { + self.identifier + } + + #[tracing::instrument("loader:client-entry-loader", skip_all, fields( + perfetto.track_name = "loader:client-entry-loader", + perfetto.process_name = "Loader Analysis", + resource = loader_context.resource(), + ))] + async fn run(&self, loader_context: &mut LoaderContext) -> Result<()> { + let Some(loader_query) = loader_context.current_loader().query() else { + loader_context.finish_with("".to_string()); + return Ok(()); + }; + + let ParsedClientEntries { modules, is_server } = parse_client_entries(&loader_query[1..])?; + + let code = modules + .iter() + .filter(|client_component| { + if is_server { + !CSS_REGEX.is_match(&client_component.request) + } else { + true + } + }) + .map(|client_component| { + // When we cannot determine the export names, we use eager mode to include the whole module. + // Otherwise, we use eager mode with webpackExports to only include the necessary exports. + // If we have '*' in the ids, we include all the imports + let import_path = simd_json::to_string(&client_component.request).to_rspack_result()?; + Ok( + if client_component.ids.is_empty() || client_component.ids.iter().any(|id| id == "*") { + if is_server { + format!("import(/* webpackMode: \"eager\" */ {});\n", import_path) + } else { + format!("import({});\n", import_path) + } + } else { + let webpack_exports = simd_json::to_string(&client_component.ids).to_rspack_result()?; + + if is_server { + format!( + "import(/* webpackMode: \"eager\" */ /* webpackExports: {} */ {});\n", + webpack_exports, import_path + ) + } else { + format!( + "import(/* webpackExports: {} */ {});\n", + webpack_exports, import_path + ) + } + }, + ) + }) + .collect::>>()? + .join("\n"); + + loader_context.finish_with(code); + + Ok(()) + } +} diff --git a/crates/rspack_plugin_rsc/src/loaders/client_entry_loader_plugin.rs b/crates/rspack_plugin_rsc/src/loaders/client_entry_loader_plugin.rs new file mode 100644 index 000000000000..f3bce8930e46 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/loaders/client_entry_loader_plugin.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use rspack_core::{ + BoxLoader, Context, ModuleRuleUseLoader, NormalModuleFactoryResolveLoader, Plugin, Resolver, +}; +use rspack_error::Result; +use rspack_hook::{plugin, plugin_hook}; + +use crate::loaders::client_entry_loader::{CLIENT_ENTRY_LOADER_IDENTIFIER, ClientEntryLoader}; + +#[plugin] +#[derive(Debug)] +pub struct ClientEntryLoaderPlugin; + +impl ClientEntryLoaderPlugin { + pub fn new() -> Self { + Self::new_inner() + } +} + +impl Default for ClientEntryLoaderPlugin { + fn default() -> Self { + Self::new() + } +} + +impl Plugin for ClientEntryLoaderPlugin { + fn name(&self) -> &'static str { + "ClientEntryLoaderPlugin" + } + + fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> { + ctx + .normal_module_factory_hooks + .resolve_loader + .tap(resolve_loader::new(self)); + Ok(()) + } +} + +#[plugin_hook(NormalModuleFactoryResolveLoader for ClientEntryLoaderPlugin)] +pub(crate) async fn resolve_loader( + &self, + _context: &Context, + _resolver: &Resolver, + l: &ModuleRuleUseLoader, +) -> Result> { + let loader_request = &l.loader; + if loader_request.starts_with(CLIENT_ENTRY_LOADER_IDENTIFIER) { + let loader = Arc::new(ClientEntryLoader::new().with_identifier(loader_request.to_string())); + return Ok(Some(loader)); + } + Ok(None) +} diff --git a/crates/rspack_plugin_rsc/src/loaders/mod.rs b/crates/rspack_plugin_rsc/src/loaders/mod.rs new file mode 100644 index 000000000000..8b6fad235181 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/loaders/mod.rs @@ -0,0 +1,5 @@ +pub mod client_entry_loader; +pub mod client_entry_loader_plugin; + +pub mod action_entry_loader; +pub mod action_entry_loader_plugin; diff --git a/crates/rspack_plugin_rsc/src/manifest_runtime_module.rs b/crates/rspack_plugin_rsc/src/manifest_runtime_module.rs new file mode 100644 index 000000000000..ea076a5b1004 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/manifest_runtime_module.rs @@ -0,0 +1,260 @@ +use indoc::formatdoc; +use rspack_collections::Identifier; +use rspack_core::{ + ChunkGraph, ChunkUkey, Compilation, Module, ModuleGraph, ModuleId, ModuleIdentifier, + RuntimeModule, RuntimeModuleStage, impl_runtime_module, +}; +use rspack_error::{Result, ToStringResultToRspackResultExt}; +use rspack_util::fx_hash::FxIndexSet; +use rustc_hash::FxHashMap; +use serde::{Serialize, Serializer, ser::SerializeMap}; + +use crate::{ + constants::LAYERS_NAMES, + loaders::action_entry_loader::{ACTION_ENTRY_LOADER_IDENTIFIER, parse_action_entries}, + plugin_state::PLUGIN_STATES, + reference_manifest::{ManifestExport, ManifestNode, ModuleLoading, ServerReferenceManifest}, + utils::{ChunkModules, get_module_resource, to_json_string_literal}, +}; + +fn serialize_none_as_empty_object(val: &Option, serializer: S) -> Result +where + S: Serializer, + T: Serialize, +{ + match val { + Some(v) => v.serialize(serializer), + None => { + let map = serializer.serialize_map(Some(0))?; + map.end() + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RscManifest<'a> { + pub server_manifest: &'a FxHashMap, + pub client_manifest: &'a FxHashMap, + pub server_consumer_module_map: &'a FxHashMap, + pub module_loading: &'a ModuleLoading, + + #[serde(serialize_with = "serialize_none_as_empty_object")] + pub entry_css_files: Option<&'a FxHashMap>>, + + #[serde(serialize_with = "serialize_none_as_empty_object")] + pub entry_js_files: Option<&'a FxIndexSet>, +} + +#[impl_runtime_module] +#[derive(Debug)] +pub struct RscManifestRuntimeModule { + id: Identifier, + chunk_ukey: Option, +} + +impl RscManifestRuntimeModule { + pub fn new() -> Self { + Self::with_default(Identifier::from("webpack/runtime/rsc_manifest"), None) + } +} + +#[async_trait::async_trait] +impl RuntimeModule for RscManifestRuntimeModule { + fn name(&self) -> Identifier { + self.id + } + + fn stage(&self) -> RuntimeModuleStage { + RuntimeModuleStage::Attach + } + + async fn generate(&self, compilation: &Compilation) -> rspack_error::Result { + let server_compiler_id = compilation.compiler_id(); + + let Some(entry_name) = self + .chunk_ukey + .as_ref() + .and_then(|chunk_ukey| compilation.chunk_by_ukey.get(chunk_ukey)) + .and_then(|chunk| chunk.get_entry_options(&compilation.chunk_group_by_ukey)) + .and_then(|entry_options| entry_options.name.as_ref()) + else { + return Ok(String::new()); + }; + + let mut plugin_states = PLUGIN_STATES.borrow_mut(); + let plugin_state = plugin_states.get_mut(&server_compiler_id).ok_or_else(|| { + rspack_error::error!( + "Failed to find RSC plugin state for compiler (ID: {}).", + server_compiler_id.as_u32() + ) + })?; + + build_server_manifest(compilation, &mut plugin_state.server_actions)?; + let module_loading = plugin_state.module_loading.as_ref().ok_or_else(|| { + rspack_error::error!( + "Missing RSC moduleLoading config in plugin state. Ensure ClientPlugin is applied." + ) + })?; + let server_consumer_module_map = + build_server_consumer_module_map(compilation, &plugin_state.client_modules); + + let rsc_manifest = RscManifest { + server_manifest: &plugin_state.server_actions, + client_manifest: &plugin_state.client_modules, + server_consumer_module_map: &server_consumer_module_map, + module_loading, + entry_css_files: plugin_state.entry_css_files.get(entry_name), + entry_js_files: plugin_state.entry_js_files.get(entry_name), + }; + + Ok(formatdoc! { + r#" + __webpack_require__.rscM = JSON.parse({}); + "#, + to_json_string_literal(&rsc_manifest).to_rspack_result()?, + }) + } + + fn attach(&mut self, chunk_ukey: ChunkUkey) { + self.chunk_ukey = Some(chunk_ukey); + } +} + +fn build_server_manifest( + compilation: &Compilation, + server_actions: &mut ServerReferenceManifest, +) -> Result<()> { + let module_graph = compilation.get_module_graph(); + + for module in module_graph.modules().values() { + let module_id = + match ChunkGraph::get_module_id(&compilation.module_ids_artifact, module.identifier()) { + Some(id) => id, + None => continue, + }; + + let Some(normal_module) = module.as_normal_module() else { + continue; + }; + + let request = normal_module.request(); + if !request.starts_with(ACTION_ENTRY_LOADER_IDENTIFIER) { + continue; + } + + let loader_query = request + .split_once('?') + .map(|x| x.1) + .unwrap_or_default() + .rsplit_once('!') + .map(|x| x.0) + .unwrap_or_default(); + let loader_options = form_urlencoded::parse(loader_query.as_bytes()); + + for (k, v) in loader_options { + if k == "actions" { + if let Some(actions) = parse_action_entries(v.into_owned())? { + for action in actions { + server_actions.insert( + action.id.to_string(), + ManifestExport { + id: module_id.to_string(), + name: action.id.to_string(), + // Server Action modules serve as endpoints rather than code splitting points, so ensuring chunk loading at runtime is unnecessary. + chunks: vec![], + r#async: Some(ModuleGraph::is_async( + &compilation.async_modules_artifact.borrow(), + &module.identifier(), + )), + }, + ); + } + } + break; + } + } + } + + Ok(()) +} + +fn record_module( + compilation: &Compilation, + client_modules: &FxHashMap, + module_graph: &ModuleGraph, + module_identifier: &ModuleIdentifier, + module_id: &ModuleId, + server_consumer_module_map: &mut FxHashMap, +) { + let Some(module) = module_graph.module_by_identifier(module_identifier) else { + return; + }; + let Some(normal_module) = module.as_normal_module() else { + return; + }; + + if normal_module + .get_layer() + .is_none_or(|layer| layer != LAYERS_NAMES.server_side_rendering) + { + return; + } + + let resource = get_module_resource(module.as_ref()); + if resource.is_empty() { + return; + } + + let manifest_export = ManifestExport { + id: module_id.to_string(), + name: "*".to_string(), + chunks: vec![], + r#async: Some(ModuleGraph::is_async( + &compilation.async_modules_artifact.borrow(), + &module.identifier(), + )), + }; + let mut node = FxHashMap::default(); + node.insert("*".to_string(), manifest_export); + if let Some(export) = client_modules.get(resource.as_ref()) { + server_consumer_module_map.insert(export.id.clone(), node); + } +} + +fn build_server_consumer_module_map( + compilation: &Compilation, + client_modules: &FxHashMap, +) -> FxHashMap { + let mut server_consumer_module_map: FxHashMap = Default::default(); + let module_graph = compilation.get_module_graph(); + let chunk_modules = ChunkModules::new(compilation, module_graph); + for (module_identifier, module_id) in chunk_modules { + let Some(module) = module_graph.module_by_identifier(&module_identifier) else { + continue; + }; + + if let Some(concatenated_module) = module.as_concatenated_module() { + for inner_module in concatenated_module.get_modules() { + record_module( + compilation, + client_modules, + module_graph, + &inner_module.id, + &module_id, + &mut server_consumer_module_map, + ); + } + } else { + record_module( + compilation, + client_modules, + module_graph, + &module_identifier, + &module_id, + &mut server_consumer_module_map, + ); + } + } + server_consumer_module_map +} diff --git a/crates/rspack_plugin_rsc/src/plugin_state.rs b/crates/rspack_plugin_rsc/src/plugin_state.rs new file mode 100644 index 000000000000..9cbac5d4bb41 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/plugin_state.rs @@ -0,0 +1,53 @@ +use atomic_refcell::AtomicRefCell; +use once_cell::sync::Lazy; +use rspack_collections::IdentifierSet; +use rspack_core::CompilerId; +use rspack_util::{atom::Atom, fx_hash::FxIndexSet}; +use rustc_hash::FxHashMap; + +use crate::reference_manifest::{ManifestExport, ModuleLoading, ServerReferenceManifest}; + +pub type ActionIdNamePair = (Atom, Atom); + +#[derive(Debug, Default)] +pub struct PluginState { + pub module_loading: Option, + pub injected_client_entries: FxHashMap, + pub client_modules: FxHashMap, + pub ssr_modules: FxHashMap, + pub client_actions_per_entry: FxHashMap>>, + pub server_actions: ServerReferenceManifest, + pub entry_css_imports: FxHashMap>>, + /// Maps entry names to CSS chunk files organized by server entry resource. + /// + /// This nested structure tracks CSS dependencies for React Server Components: + /// - Outer key: Entry name (e.g., "main", "app") + /// - Inner key: Server entry resource + /// - Inner value: Ordered set of CSS chunk file paths (automatically deduplicated) + pub entry_css_files: FxHashMap>>, + /// Maps entry names to their associated JS chunk files. + /// + /// This structure tracks JavaScript dependencies for React Server Components: + /// - Key: Entry name (e.g., "main", "app") + /// - Value: Ordered set of JS chunk file paths (automatically deduplicated) + pub entry_js_files: FxHashMap>, + pub changed_server_components_per_entry: FxHashMap, +} + +impl PluginState { + pub fn clear(&mut self) { + self.module_loading = None; + self.injected_client_entries.clear(); + self.client_modules.clear(); + self.ssr_modules.clear(); + self.client_actions_per_entry.clear(); + self.server_actions.clear(); + self.entry_css_imports.clear(); + self.entry_css_files.clear(); + self.entry_js_files.clear(); + self.changed_server_components_per_entry.clear(); + } +} + +pub static PLUGIN_STATES: Lazy>> = + Lazy::new(Default::default); diff --git a/crates/rspack_plugin_rsc/src/reference_manifest.rs b/crates/rspack_plugin_rsc/src/reference_manifest.rs new file mode 100644 index 000000000000..7448c78efaaa --- /dev/null +++ b/crates/rspack_plugin_rsc/src/reference_manifest.rs @@ -0,0 +1,36 @@ +use rustc_hash::FxHashMap; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize)] +pub struct ManifestExport { + /// Rspack module id + pub id: String, + /// Export name + pub name: String, + /// Chunks for the module. JS and CSS. + pub chunks: Vec, + /// If chunk contains async module + #[serde(skip_serializing_if = "Option::is_none")] + pub r#async: Option, +} + +pub type ManifestNode = FxHashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CrossOriginMode { + #[serde(rename = "use-credentials")] + UseCredentials, + #[serde(rename = "")] + Anonymous, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleLoading { + pub prefix: String, + #[serde(rename = "crossOrigin")] + #[serde(skip_serializing_if = "Option::is_none")] + pub cross_origin: Option, +} + +pub type ServerReferenceManifest = FxHashMap; diff --git a/crates/rspack_plugin_rsc/src/server_plugin.rs b/crates/rspack_plugin_rsc/src/server_plugin.rs new file mode 100644 index 000000000000..8633941c30cb --- /dev/null +++ b/crates/rspack_plugin_rsc/src/server_plugin.rs @@ -0,0 +1,562 @@ +use std::sync::Arc; + +use atomic_refcell::AtomicRefCell; +use derive_more::Debug; +use futures::future::BoxFuture; +use rspack_collections::{Identifiable, IdentifierMap}; +use rspack_core::{ + BoxDependency, ChunkUkey, Compilation, CompilationParams, CompilationProcessAssets, + CompilationRuntimeRequirementInTree, CompilerDone, CompilerFailed, CompilerFinishMake, + CompilerThisCompilation, Dependency, DependencyId, EntryDependency, EntryOptions, Logger, Plugin, + RuntimeGlobals, RuntimeModule, RuntimeSpec, get_entry_runtime, +}; +use rspack_error::Result; +use rspack_hook::{plugin, plugin_hook}; +use rustc_hash::{FxHashMap, FxHashSet}; +use serde_json::json; + +use crate::{ + component_info::{ + ClientComponentImports, CssImports, collect_component_info_from_entry_denendency, + }, + constants::LAYERS_NAMES, + coordinator::Coordinator, + hot_reloader::track_server_component_changes, + loaders::{ + action_entry_loader::ACTION_ENTRY_LOADER_IDENTIFIER, + client_entry_loader::CLIENT_ENTRY_LOADER_IDENTIFIER, + }, + manifest_runtime_module::RscManifestRuntimeModule, + plugin_state::{ActionIdNamePair, PLUGIN_STATES, PluginState}, +}; + +#[derive(Debug)] +struct ClientEntry { + entry_name: String, + runtime: RuntimeSpec, + client_imports: ClientComponentImports, + css_imports: CssImports, +} + +#[derive(Debug)] +struct InjectedSsrEntry { + runtime: RuntimeSpec, + add_entry: (BoxDependency, EntryOptions), + dependency_id: DependencyId, +} + +struct ActionEntry { + actions: FxHashMap>, + entry_name: String, + runtime: RuntimeSpec, + from_client: bool, +} + +#[derive(Debug)] +struct InjectedActionEntry { + pub runtime: RuntimeSpec, + pub add_entry: (BoxDependency, EntryOptions), +} + +type OnServerComponentChanges = Box BoxFuture<'static, Result<()>> + Sync + Send>; + +pub struct RscServerPluginOptions { + pub coordinator: Arc, + pub on_server_component_changes: Option, +} + +#[plugin] +#[derive(Debug)] +pub struct RscServerPlugin { + #[debug(skip)] + coordinator: Arc, + #[debug(skip)] + on_server_component_changes: Option, + prev_server_component_hashes: AtomicRefCell>, +} + +impl RscServerPlugin { + pub fn new(options: RscServerPluginOptions) -> Self { + Self::new_inner( + options.coordinator, + options.on_server_component_changes, + Default::default(), + ) + } +} + +#[plugin_hook(CompilerThisCompilation for RscServerPlugin)] +async fn this_compilation( + &self, + compilation: &mut Compilation, + _params: &mut CompilationParams, +) -> Result<()> { + // Initialize or reset the plugin state for the current compilation. + // If a state already exists, clear it; otherwise, insert a default state. + let mut plugin_states = PLUGIN_STATES.borrow_mut(); + match plugin_states.entry(compilation.compiler_id()) { + std::collections::hash_map::Entry::Occupied(mut occupied_entry) => { + occupied_entry.get_mut().clear(); + } + std::collections::hash_map::Entry::Vacant(vacant_entry) => { + vacant_entry.insert(Default::default()); + } + }; + + self.coordinator.start_server_entries_compilation().await?; + + Ok(()) +} + +#[plugin_hook(CompilerFinishMake for RscServerPlugin)] +async fn finish_make(&self, compilation: &mut Compilation) -> Result<()> { + let logger = compilation.get_logger("rspack.RscServerPlugin"); + + { + let mut plugin_states = PLUGIN_STATES.borrow_mut(); + let plugin_state = plugin_states.entry(compilation.compiler_id()).or_default(); + + let start = logger.time("track server component changes"); + let mut prev_server_component_hashes = self.prev_server_component_hashes.borrow_mut(); + plugin_state.changed_server_components_per_entry = + track_server_component_changes(compilation, &mut prev_server_component_hashes); + logger.time_end(start); + } + + let start = logger.time("create client entries"); + self.create_client_entries(compilation).await?; + logger.time_end(start); + + Ok(()) +} + +#[plugin_hook(CompilationRuntimeRequirementInTree for RscServerPlugin)] +async fn runtime_requirements_in_tree( + &self, + _compilation: &Compilation, + chunk_ukey: &ChunkUkey, + _all_runtime_requirements: &RuntimeGlobals, + runtime_requirements: &RuntimeGlobals, + _runtime_requirements_mut: &mut RuntimeGlobals, + runtime_modules_to_add: &mut Vec<(ChunkUkey, Box)>, +) -> Result> { + if runtime_requirements.contains(RuntimeGlobals::RSC_MANIFEST) { + runtime_modules_to_add.push((*chunk_ukey, Box::new(RscManifestRuntimeModule::new()))); + } + Ok(None) +} + +#[plugin_hook(CompilationProcessAssets for RscServerPlugin)] +async fn process_assets(&self, _compilation: &mut Compilation) -> Result<()> { + self.coordinator.idle().await?; + Ok(()) +} + +impl Plugin for RscServerPlugin { + fn name(&self) -> &'static str { + "rspack.RscServerPlugin" + } + + fn apply(&self, ctx: &mut rspack_core::ApplyContext) -> Result<()> { + ctx + .compiler_hooks + .this_compilation + .tap(this_compilation::new(self)); + + ctx.compiler_hooks.done.tap(done::new(self)); + ctx.compiler_hooks.failed.tap(failed::new(self)); + + ctx.compiler_hooks.finish_make.tap(finish_make::new(self)); + + ctx + .compilation_hooks + .runtime_requirement_in_tree + .tap(runtime_requirements_in_tree::new(self)); + + ctx + .compilation_hooks + .process_assets + .tap(process_assets::new(self)); + + Ok(()) + } +} + +impl RscServerPlugin { + async fn create_client_entries(&self, compilation: &mut Compilation) -> Result<()> { + let mut add_ssr_modules_list: Vec = Default::default(); + let mut created_ssr_dependencies_per_entry: FxHashMap> = + Default::default(); + let mut add_action_entry_list: Vec = Default::default(); + let mut server_actions_per_entry: FxHashMap>> = + Default::default(); + let mut created_action_ids: FxHashSet = Default::default(); + let mut runtime_per_entry: FxHashMap = Default::default(); + + for (entry_name, entry_data) in &compilation.entries { + let runtime = get_entry_runtime(entry_name, &entry_data.options, &compilation.entries); + runtime_per_entry.insert(entry_name.to_string(), runtime.clone()); + + let mut action_entry_imports: FxHashMap> = Default::default(); + let mut client_entries_to_inject = Vec::new(); + + let entry_dependency = &entry_data.dependencies[0]; + let component_info = + collect_component_info_from_entry_denendency(compilation, &runtime, entry_dependency); + for (dep, actions) in component_info.action_imports { + action_entry_imports.insert(dep, actions); + } + if !component_info.client_component_imports.is_empty() { + client_entries_to_inject.push(ClientEntry { + entry_name: entry_name.to_string(), + runtime: runtime.clone(), + client_imports: component_info.client_component_imports, + css_imports: component_info.css_imports, + }); + } + + { + let mut plugin_states = PLUGIN_STATES.borrow_mut(); + let plugin_state = plugin_states.entry(compilation.compiler_id()).or_default(); + + for client_entry_to_inject in client_entries_to_inject { + let entry_name = client_entry_to_inject.entry_name.to_string(); + let Some(injected) = self + .inject_client_entry_and_ssr_modules( + compilation, + client_entry_to_inject, + component_info.should_inject_ssr_modules, + plugin_state, + ) + .await + else { + continue; + }; + + // Track all created SSR dependencies for each entry from the server layer. + created_ssr_dependencies_per_entry + .entry(entry_name) + .or_default() + .push(injected.dependency_id); + + add_ssr_modules_list.push(injected); + } + } + + if !action_entry_imports.is_empty() { + server_actions_per_entry + .entry(entry_name.to_string()) + .or_default() + .extend(action_entry_imports); + } + } + + for (name, action_entry_imports) in server_actions_per_entry { + let runtime = runtime_per_entry.get(&name).cloned().unwrap_or_default(); + if let Some(injected) = self.inject_action_entry( + compilation, + ActionEntry { + actions: action_entry_imports, + entry_name: name.clone(), + runtime, + from_client: false, + }, + &mut created_action_ids, + ) { + add_action_entry_list.push(injected); + } + } + + // Wait for action entries to be added. + + let included_dependencies: Vec<(DependencyId, RuntimeSpec)> = add_ssr_modules_list + .iter() + .map(|injected| (*injected.add_entry.0.id(), injected.runtime.clone())) + .collect(); + let add_include_args: Vec<(BoxDependency, EntryOptions)> = add_ssr_modules_list + .into_iter() + .map(|injected_ssr_entry| injected_ssr_entry.add_entry) + .chain( + add_action_entry_list + .into_iter() + .map(|add_action_entry| add_action_entry.add_entry), + ) + .collect(); + compilation.add_include(add_include_args).await?; + for (dependency_id, runtime) in included_dependencies { + let mg = compilation.get_module_graph_mut(); + let Some(module) = mg.get_module_by_dependency_id(&dependency_id) else { + continue; + }; + let info = mg.get_exports_info_data_mut(&module.identifier()); + info.set_used_in_unknown_way(Some(&runtime)); + } + + self + .coordinator + .complete_server_entries_compilation() + .await?; + + self.coordinator.start_server_actions_compilation().await?; + + self + .coordinator + .complete_server_actions_compilation() + .await?; + + let mut added_client_action_entry_list: Vec = Vec::new(); + let plugin_states = PLUGIN_STATES.borrow_mut(); + let plugin_state = plugin_states + .get(&compilation.compiler_id()) + .ok_or_else(|| { + rspack_error::error!( + "RscServerPlugin: Plugin state not found in finish_make hook for compiler {:#?}.", + compilation.compiler_id() + ) + })?; + + for (entry_name, action_entry_imports) in &plugin_state.client_actions_per_entry { + // If an action method is already created in the server layer, we don't + // need to create it again in the action layer. + // This is to avoid duplicate action instances and make sure the module + // state is shared. + let mut remaining_client_imported_actions = false; + let mut remaining_action_entry_imports: FxHashMap> = + Default::default(); + let runtime = runtime_per_entry + .get(entry_name) + .cloned() + .unwrap_or_default(); + for (dependency, actions) in action_entry_imports { + let mut remaining_actions: Vec = Vec::new(); + for action in actions { + if !created_action_ids.contains(&format!("{}@{}", entry_name, &action.0)) { + remaining_actions.push(action.clone()); + } + } + if !remaining_actions.is_empty() { + remaining_action_entry_imports.insert(dependency.clone(), remaining_actions); + remaining_client_imported_actions = true; + } + } + + if remaining_client_imported_actions + && let Some(injected) = self.inject_action_entry( + compilation, + ActionEntry { + actions: remaining_action_entry_imports, + entry_name: entry_name.clone(), + runtime, + from_client: true, + }, + &mut created_action_ids, + ) + { + added_client_action_entry_list.push(injected); + } + } + let included_dependencies: Vec<(DependencyId, RuntimeSpec)> = added_client_action_entry_list + .iter() + .map(|action_entry| (*action_entry.add_entry.0.id(), action_entry.runtime.clone())) + .collect(); + let add_include_args: Vec<(BoxDependency, EntryOptions)> = added_client_action_entry_list + .into_iter() + .map(|action_entry| action_entry.add_entry) + .collect(); + compilation.add_include(add_include_args).await?; + for (dependency_id, runtime) in included_dependencies { + let mg = compilation.get_module_graph_mut(); + let Some(m) = mg.get_module_by_dependency_id(&dependency_id) else { + continue; + }; + let info = mg.get_exports_info_data_mut(&m.identifier()); + info.set_used_in_unknown_way(Some(&runtime)); + } + + Ok(()) + } + + async fn inject_client_entry_and_ssr_modules( + &self, + compilation: &Compilation, + client_entry: ClientEntry, + should_inject_ssr_modules: bool, + plugin_state: &mut PluginState, + ) -> Option { + let ClientEntry { + entry_name, + runtime, + client_imports, + css_imports, + } = client_entry; + + let client_browser_loader = { + let mut serializer = form_urlencoded::Serializer::new(String::new()); + let merged_css_imports = css_imports.values().flatten().collect::>(); + for request in merged_css_imports { + #[allow(clippy::unwrap_used)] + let module_json = serde_json::to_string(&json!({ + "request": request, + "ids": [] + })) + .unwrap(); + serializer.append_pair("modules", &module_json); + } + + plugin_state + .entry_css_imports + .entry(entry_name.clone()) + .or_default() + .extend(css_imports.into_iter()); + + for (request, ids) in &client_imports { + #[allow(clippy::unwrap_used)] + let module_json = serde_json::to_string(&json!({ + "request": request, + "ids": ids + })) + .unwrap(); + serializer.append_pair("modules", &module_json); + } + serializer.append_pair("server", "false"); + format!( + "{}?{}!", + CLIENT_ENTRY_LOADER_IDENTIFIER, + serializer.finish() + ) + }; + + let client_server_loader = { + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (request, ids) in &client_imports { + #[allow(clippy::unwrap_used)] + let module_json = serde_json::to_string(&json!({ + "request": request, + "ids": ids + })) + .unwrap(); + serializer.append_pair("modules", &module_json); + } + serializer.append_pair("server", "true"); + format!( + "{}?{}!", + CLIENT_ENTRY_LOADER_IDENTIFIER, + serializer.finish() + ) + }; + + // Add for the client compilation + // Inject the entry to the client compiler. + plugin_state + .injected_client_entries + .insert(entry_name.to_string(), client_browser_loader); + + if !should_inject_ssr_modules { + return None; + } + + let ssr_entry_dependency = EntryDependency::new( + client_server_loader.to_string(), + compilation.options.context.clone(), + Some(LAYERS_NAMES.server_side_rendering.to_string()), + false, + ); + let dependency_id = *(ssr_entry_dependency.id()); + Some(InjectedSsrEntry { + runtime, + add_entry: ( + Box::new(ssr_entry_dependency), + EntryOptions { + name: Some(entry_name.to_string()), + ..Default::default() + }, + ), + dependency_id, + }) + } + + fn inject_action_entry( + &self, + compilation: &Compilation, + action_entry: ActionEntry, + created_action_ids: &mut FxHashSet, + ) -> Option { + let ActionEntry { + actions, + entry_name, + runtime, + from_client, + } = action_entry; + + if actions.is_empty() { + return None; + } + + for actions_from_module in actions.values() { + for (id, _) in actions_from_module { + created_action_ids.insert(format!("{}@{}", entry_name, id)); + } + } + + let mut serializer = form_urlencoded::Serializer::new(String::new()); + #[allow(clippy::unwrap_used)] + serializer.append_pair("actions", &serde_json::to_string(&actions).unwrap()); + serializer.append_pair("from-client", &from_client.to_string()); + let action_entry_loader = format!( + "{}?{}!", + ACTION_ENTRY_LOADER_IDENTIFIER, + serializer.finish() + ); + + // Inject the entry to the server compiler + let layer = LAYERS_NAMES.react_server_components.to_string(); + let action_entry_dep = EntryDependency::new( + action_entry_loader, + compilation.options.context.clone(), + Some(layer.to_string()), + false, + ); + + Some(InjectedActionEntry { + runtime, + add_entry: ( + Box::new(action_entry_dep), + EntryOptions { + name: Some(entry_name.to_string()), + layer: Some(layer), + ..Default::default() + }, + ), + }) + } +} + +#[plugin_hook(CompilerDone for RscServerPlugin)] +async fn done(&self, compilation: &Compilation) -> Result<()> { + if let Some(on_server_component_changes) = self.on_server_component_changes.as_ref() { + let plugin_states = PLUGIN_STATES.borrow(); + let plugin_state = plugin_states + .get(&compilation.compiler_id()) + .ok_or_else(|| { + rspack_error::error!( + "RscServerPlugin: Plugin state not found in done hook for compiler {:#?}.", + compilation.compiler_id() + ) + })?; + let changed_server_components_per_entry = plugin_state + .changed_server_components_per_entry + .iter() + .filter(|(_, changes)| !changes.is_empty()) + .collect::>(); + if !changed_server_components_per_entry.is_empty() { + (on_server_component_changes)().await?; + } + } + Ok(()) +} + +#[plugin_hook(CompilerFailed for RscServerPlugin)] +async fn failed(&self, _compilation: &Compilation) -> Result<()> { + self.coordinator.failed().await?; + Ok(()) +} diff --git a/crates/rspack_plugin_rsc/src/utils.rs b/crates/rspack_plugin_rsc/src/utils.rs new file mode 100644 index 000000000000..94234652cf56 --- /dev/null +++ b/crates/rspack_plugin_rsc/src/utils.rs @@ -0,0 +1,168 @@ +use std::borrow::Cow; + +use rspack_collections::Identifiable; +use rspack_core::{ + ChunkGraph, ChunkGroup, ChunkGroupUkey, ChunkUkey, Compilation, ConcatenatedInnerModule, Module, + ModuleGraph, ModuleId, ModuleIdentifier, ModuleType, +}; +use rspack_error::{Result, ToStringResultToRspackResultExt}; +use serde::Serialize; +use urlencoding::encode; + +use crate::constants::CSS_REGEX; + +pub fn get_module_resource<'a>(module: &'a dyn Module) -> Cow<'a, str> { + if let Some(module) = module.as_normal_module() { + let resource_resolved_data = module.resource_resolved_data(); + let mod_path = resource_resolved_data + .path() + .map(|path| path.as_str()) + .unwrap_or(""); + let mod_query = resource_resolved_data.query().unwrap_or(""); + // We have to always use the resolved request here to make sure the + // server and client are using the same module path (required by RSC), as + // the server compiler and client compiler have different resolve configs. + Cow::Owned(format!("{}{}", mod_path, mod_query)) + } else if let Some(module) = module.as_context_module() { + Cow::Borrowed(module.identifier().as_str()) + } else { + Cow::Borrowed("") + } +} + +pub fn is_css_mod(module: &dyn Module) -> bool { + if matches!( + module.module_type(), + ModuleType::Css | ModuleType::CssModule | ModuleType::CssAuto + ) { + return true; + } + let resource = get_module_resource(module); + CSS_REGEX.is_match(resource.as_ref()) +} + +pub struct ChunkModules<'a> { + compilation: &'a Compilation, + module_graph: &'a ModuleGraph, + chunk_groups_iter: Box + 'a>, + chunks_iter: Option>, + modules_iter: Option>, + concatenated_modules_iter: Option>, + current_chunk: Option, + current_chunk_group: Option<&'a ChunkGroup>, +} + +impl<'a> ChunkModules<'a> { + pub fn new(compilation: &'a Compilation, module_graph: &'a ModuleGraph) -> Self { + let chunk_groups_iter = Box::new(compilation.chunk_group_by_ukey.iter()); + Self { + compilation, + module_graph, + chunk_groups_iter, + chunks_iter: None, + modules_iter: None, + concatenated_modules_iter: None, + current_chunk: None, + current_chunk_group: None, + } + } +} + +impl<'a> Iterator for ChunkModules<'a> { + type Item = (ModuleIdentifier, ModuleId); + + fn next(&mut self) -> Option { + loop { + if let Some(concatenated_modules_iter) = self.concatenated_modules_iter.as_mut() { + if let Some(module) = concatenated_modules_iter.next() { + match ChunkGraph::get_module_id(&self.compilation.module_ids_artifact, module.id) { + Some(module_id) => { + return Some((module.id, module_id.clone())); + } + None => { + continue; + } + } + } else { + self.concatenated_modules_iter = None; + } + } + + if let Some(modules_iter) = self.modules_iter.as_mut() { + if let Some(module_identifier) = modules_iter.next() { + match ChunkGraph::get_module_id(&self.compilation.module_ids_artifact, *module_identifier) + { + Some(module_id) => { + return Some((*module_identifier, module_id.clone())); + } + None => { + let Some(module) = self.module_graph.module_by_identifier(module_identifier) else { + continue; + }; + let Some(concatenated_module) = module.as_concatenated_module() else { + continue; + }; + let concatenated_modules = concatenated_module.get_modules(); + if !concatenated_modules.is_empty() { + self.concatenated_modules_iter = Some(concatenated_module.get_modules().iter()); + continue; + } + continue; + } + } + } else { + self.modules_iter = None; + } + } + + if let Some(ref mut chunks_iter) = self.chunks_iter { + if let Some(chunk_ukey) = chunks_iter.next() { + self.current_chunk = Some(*chunk_ukey); + + let chunk_modules = self + .compilation + .chunk_graph + .get_chunk_modules_identifier(chunk_ukey); + + if !chunk_modules.is_empty() { + self.modules_iter = Some(chunk_modules.iter()); + continue; + } + continue; + } else { + self.chunks_iter = None; + self.current_chunk = None; + self.current_chunk_group = None; + } + } + + if let Some((_, chunk_group)) = self.chunk_groups_iter.next() { + self.current_chunk_group = Some(chunk_group); + if !chunk_group.chunks.is_empty() { + self.chunks_iter = Some(chunk_group.chunks.iter()); + continue; + } + continue; + } + + return None; + } + } +} + +/// Returns a JSON string literal for `value` (i.e. double-encoded), suitable for embedding into JS. +/// +/// Example: +/// - input: `{"a":1}` +/// - output: "\"{\\\"a\\\":1}\"" +pub fn to_json_string_literal(value: &T) -> Result { + serde_json::to_string(&serde_json::to_string(value).to_rspack_result()?).to_rspack_result() +} + +pub fn encode_uri_path(file: &str) -> String { + file + .split('/') + .map(|p| encode(p).into_owned()) + .collect::>() + .join("/") +} diff --git a/crates/swc_plugin_ts_collector/tests/fixture.rs b/crates/swc_plugin_ts_collector/tests/fixture.rs index e521368440b3..f547b7cbe85d 100644 --- a/crates/swc_plugin_ts_collector/tests/fixture.rs +++ b/crates/swc_plugin_ts_collector/tests/fixture.rs @@ -1,4 +1,4 @@ -use std::{fs, path::PathBuf}; +use std::{fs, path::PathBuf, rc::Rc, sync::Arc}; use glob::glob; use rspack_javascript_compiler::{JavaScriptCompiler, transform::SwcOptions}; @@ -6,7 +6,7 @@ use rspack_swc_plugin_ts_collector::TypeExportsCollector; use rustc_hash::FxHashSet; use swc_core::{ atoms::Atom, - common::FileName, + common::{FileName, comments::SingleThreadedComments}, ecma::{ ast::noop_pass, parser::{Syntax, TsSyntax}, @@ -34,10 +34,12 @@ fn type_exports() { let mut options = SwcOptions::default(); options.config.jsc.syntax = Some(Syntax::Typescript(TsSyntax::default())); let mut type_exports_results = FxHashSet::default(); + let comments = Rc::new(SingleThreadedComments::default()); let _ = compiler .transform( source, - Some(FileName::Real(input)), + Some(Arc::new(FileName::Real(input))), + comments, options, None, |program, _| { diff --git a/packages/rspack/etc/core.api.md b/packages/rspack/etc/core.api.md index fe776b5f38dc..4aef18b679ab 100644 --- a/packages/rspack/etc/core.api.md +++ b/packages/rspack/etc/core.api.md @@ -923,6 +923,10 @@ type ClientConfiguration = { webSocketURL?: string | WebSocketURL | undefined; }; +// @public (undocumented) +class ClientPlugin extends RscClientPlugin { +} + // @public (undocumented) class CodeGenerationResult { constructor(result: binding.JsCodegenerationResult); @@ -1709,6 +1713,15 @@ interface ContinueStatement extends Node_4, HasSpan { type: "ContinueStatement"; } +// @public (undocumented) +class Coordinator { + constructor(); + // (undocumented) + applyClientCompiler(clientCompiler: Compiler): void; + // (undocumented) + applyServerCompiler(serverCompiler: Compiler): void; +} + // @public (undocumented) export const CopyRspackPlugin: { new (copy: CopyRspackPluginOptions): { @@ -2568,6 +2581,8 @@ interface Experiments_2 { sync: typeof sync; }; // (undocumented) + rsc: typeof rsc; + // (undocumented) RsdoctorPlugin: typeof RsdoctorPlugin; // (undocumented) RslibPlugin: typeof RslibPlugin; @@ -6669,6 +6684,47 @@ type Rewrite = { // @public (undocumented) type RewriteTo = (context: HistoryContext) => string; +// @public (undocumented) +const rsc: { + createPlugins: () => { + ServerPlugin: new (options?: Omit) => ServerPlugin; + ClientPlugin: new () => ClientPlugin; + }; + Layers: { + readonly rsc: "react-server-components"; + readonly ssr: "server-side-rendering"; + }; +}; + +// @public (undocumented) +class RscClientPlugin extends RspackBuiltinPlugin { + constructor(options: RscClientPluginOptions); + // (undocumented) + name: string; + // (undocumented) + raw(compiler: Compiler): binding.BuiltinPlugin; +} + +// @public (undocumented) +type RscClientPluginOptions = { + coordinator: Coordinator; +}; + +// @public (undocumented) +class RscServerPlugin extends RspackBuiltinPlugin { + constructor(options: RscServerPluginOptions); + // (undocumented) + name: string; + // (undocumented) + raw(compiler: Compiler): binding.BuiltinPlugin; +} + +// @public (undocumented) +type RscServerPluginOptions = { + coordinator: Coordinator; + onServerComponentChanges?: () => Promise; +}; + // @public (undocumented) const RsdoctorPlugin: typeof RsdoctorPluginImpl & { getCompilationHooks: (compilation: Compilation) => RsdoctorPluginHooks; @@ -7501,6 +7557,11 @@ type ServerOptions = ServerOptions_2 & { }; }; +// @public (undocumented) +class ServerPlugin extends RscServerPlugin { + constructor(options?: Omit); +} + // @public (undocumented) type ServerResponse_2 = ServerResponse; @@ -8209,6 +8270,7 @@ export type SwcLoaderOptions = Config_2 & { collectTypeScriptInfo?: CollectTypeScriptInfoOptions; rspackExperiments?: { import?: PluginImportOptions; + reactServerComponents?: boolean; }; }; diff --git a/packages/rspack/scripts/check-documentation-coverage.ts b/packages/rspack/scripts/check-documentation-coverage.ts index ace3008d2d0c..521234a59f42 100644 --- a/packages/rspack/scripts/check-documentation-coverage.ts +++ b/packages/rspack/scripts/check-documentation-coverage.ts @@ -112,6 +112,10 @@ function checkPluginsDocumentationCoverage() { 'RsdoctorPlugin', // This plugin is not stable yet 'RstestPlugin', // This plugin is not stable yet 'RslibPlugin', // This plugin is not stable yet + 'RscClientPlugin', + 'RscServerPlugin', + 'ClientPlugin', + 'ServerPlugin', ]; const removedPlugins = [ diff --git a/packages/rspack/src/Compiler.ts b/packages/rspack/src/Compiler.ts index b92bab8a47b0..278bd3d9056b 100644 --- a/packages/rspack/src/Compiler.ts +++ b/packages/rspack/src/Compiler.ts @@ -131,6 +131,8 @@ export type CompilerHooks = { additionalPass: liteTapable.AsyncSeriesHook<[]>; }; +export const GET_COMPILER_ID = Symbol('getCompilerId'); + class Compiler { #instance?: binding.JsCompiler; #initial: boolean; @@ -328,6 +330,15 @@ class Compiler { // } // }); // }); + + Object.defineProperty(this, GET_COMPILER_ID, { + writable: false, + configurable: false, + enumerable: false, + value: () => { + return this.#instance!.getCompilerId(); + }, + }); } get recordsInputPath() { diff --git a/packages/rspack/src/MultiCompiler.ts b/packages/rspack/src/MultiCompiler.ts index e132272afb05..de6653cae949 100644 --- a/packages/rspack/src/MultiCompiler.ts +++ b/packages/rspack/src/MultiCompiler.ts @@ -149,11 +149,13 @@ export class MultiCompiler { }); } } + set unsafeFastDrop(value: boolean) { for (const compiler of this.compilers) { compiler.unsafeFastDrop = value; } } + get options() { return Object.assign( this.compilers.map((c) => c.options), diff --git a/packages/rspack/src/Watching.ts b/packages/rspack/src/Watching.ts index 71ad9fec12a8..aae8346877a5 100644 --- a/packages/rspack/src/Watching.ts +++ b/packages/rspack/src/Watching.ts @@ -374,44 +374,6 @@ export class Watching { compilation.endTime = Date.now(); const cbs = this.callbacks; this.callbacks = []; - const fileDependencies = new Set([ - ...compilation.fileDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - fileDependencies.added = new Set( - compilation.__internal__addedFileDependencies, - ); - fileDependencies.removed = new Set( - compilation.__internal__removedFileDependencies, - ); - - const contextDependencies = new Set([ - ...compilation.contextDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - contextDependencies.added = new Set( - compilation.__internal__addedContextDependencies, - ); - contextDependencies.removed = new Set( - compilation.__internal__removedContextDependencies, - ); - - const missingDependencies = new Set([ - ...compilation.missingDependencies, - ]) as unknown as Iterable & { - added?: Iterable; - removed?: Iterable; - }; - missingDependencies.added = new Set( - compilation.__internal__addedMissingDependencies, - ); - missingDependencies.removed = new Set( - compilation.__internal__removedMissingDependencies, - ); this.compiler.hooks.done.callAsync(stats, (err) => { if (err) return handleError(err, cbs); @@ -419,6 +381,45 @@ export class Watching { process.nextTick(() => { if (!this.#closed) { + const fileDependencies = new Set([ + ...compilation.fileDependencies, + ]) as unknown as Iterable & { + added?: Iterable; + removed?: Iterable; + }; + fileDependencies.added = new Set( + compilation.__internal__addedFileDependencies, + ); + fileDependencies.removed = new Set( + compilation.__internal__removedFileDependencies, + ); + + const contextDependencies = new Set([ + ...compilation.contextDependencies, + ]) as unknown as Iterable & { + added?: Iterable; + removed?: Iterable; + }; + contextDependencies.added = new Set( + compilation.__internal__addedContextDependencies, + ); + contextDependencies.removed = new Set( + compilation.__internal__removedContextDependencies, + ); + + const missingDependencies = new Set([ + ...compilation.missingDependencies, + ]) as unknown as Iterable & { + added?: Iterable; + removed?: Iterable; + }; + missingDependencies.added = new Set( + compilation.__internal__addedMissingDependencies, + ); + missingDependencies.removed = new Set( + compilation.__internal__removedMissingDependencies, + ); + this.watch( fileDependencies, contextDependencies, diff --git a/packages/rspack/src/builtin-loader/swc/types.ts b/packages/rspack/src/builtin-loader/swc/types.ts index b1a30ea17180..7f6d04d64447 100644 --- a/packages/rspack/src/builtin-loader/swc/types.ts +++ b/packages/rspack/src/builtin-loader/swc/types.ts @@ -31,6 +31,10 @@ export type SwcLoaderOptions = Config & { */ rspackExperiments?: { import?: PluginImportOptions; + /** + * Enable React Server Components support. + */ + reactServerComponents?: boolean; }; }; diff --git a/packages/rspack/src/builtin-plugin/index.ts b/packages/rspack/src/builtin-plugin/index.ts index d8937ebf9954..b6c9c280ee29 100644 --- a/packages/rspack/src/builtin-plugin/index.ts +++ b/packages/rspack/src/builtin-plugin/index.ts @@ -73,6 +73,7 @@ export * from './RslibPlugin'; export * from './RstestPlugin'; export * from './RuntimeChunkPlugin'; export * from './RuntimePlugin'; +export { rsc } from './rsc'; export * from './SideEffectsFlagPlugin'; export * from './SizeLimitsPlugin'; export * from './SourceMapDevToolPlugin'; diff --git a/packages/rspack/src/builtin-plugin/rsc/Coordinator.ts b/packages/rspack/src/builtin-plugin/rsc/Coordinator.ts new file mode 100644 index 000000000000..08c68757306e --- /dev/null +++ b/packages/rspack/src/builtin-plugin/rsc/Coordinator.ts @@ -0,0 +1,85 @@ +import { JsCoordinator } from '@rspack/binding'; +import { type Compiler, GET_COMPILER_ID } from '../../Compiler'; +import type { Compilation } from '../../exports'; + +const PLUGIN_NAME = 'RscPlugin'; + +export const GET_OR_INIT_BINDING = Symbol('GET_OR_INIT_BINDING'); + +export class Coordinator { + #serverCompiler?: Compiler; + #clientCompiler?: Compiler; + #clientLastCompilation?: Compilation; + #isProxyingClientWatching = false; + + #binding?: JsCoordinator; + + constructor() { + // Make the symbol method non-enumerable (and avoid TS emitting it as a class method). + Object.defineProperty(this, GET_OR_INIT_BINDING, { + enumerable: false, + configurable: false, + writable: false, + value: () => { + if (!this.#binding) { + this.#binding = new JsCoordinator(() => { + if (!this.#serverCompiler) { + throw new Error( + '[RscPlugin] Coordinator.getOrInitBinding() called before the server compiler was attached. ' + + 'Call coordinator.applyServerCompiler(serverCompiler) first.', + ); + } + // @ts-ignore + return this.#serverCompiler[GET_COMPILER_ID](); + }); + } + return this.#binding; + }, + }); + } + + applyServerCompiler(serverCompiler: Compiler) { + this.#serverCompiler = serverCompiler; + + // Make server's watched dependencies include client dependencies (so server watcher stays authoritative). + serverCompiler.hooks.done.tap(PLUGIN_NAME, (stats) => { + this.#isProxyingClientWatching = true; + if (this.#clientLastCompilation) { + stats.compilation.fileDependencies.addAll( + this.#clientLastCompilation.fileDependencies, + ); + stats.compilation.contextDependencies.addAll( + this.#clientLastCompilation.contextDependencies, + ); + stats.compilation.missingDependencies.addAll( + this.#clientLastCompilation.missingDependencies, + ); + } + }); + + // Server owns watch events; on invalid, explicitly invalidate client. + serverCompiler.hooks.watchRun.tap(PLUGIN_NAME, () => { + if (!this.#isProxyingClientWatching) { + return; + } + this.#clientCompiler!.watching!.invalidateWithChangesAndRemovals( + new Set(this.#serverCompiler!.modifiedFiles), + new Set(this.#serverCompiler!.removedFiles), + ); + }); + } + + applyClientCompiler(clientCompiler: Compiler) { + this.#clientCompiler = clientCompiler; + const originalWatch = clientCompiler.watch; + // Ensure client compiler watches nothing. + // This prevents duplicate rebuilds caused by both server & client receiving FS events. + clientCompiler.watch = function watch(watchOptions, handler) { + watchOptions.ignored = () => true; + return originalWatch.call(this, watchOptions, handler); + }; + clientCompiler.hooks.done.tap(PLUGIN_NAME, (stats) => { + this.#clientLastCompilation = stats.compilation; + }); + } +} diff --git a/packages/rspack/src/builtin-plugin/rsc/RscClientPlugin.ts b/packages/rspack/src/builtin-plugin/rsc/RscClientPlugin.ts new file mode 100644 index 000000000000..05451843313d --- /dev/null +++ b/packages/rspack/src/builtin-plugin/rsc/RscClientPlugin.ts @@ -0,0 +1,26 @@ +import type binding from '@rspack/binding'; +import type { Compiler } from '../..'; +import { createBuiltinPlugin, RspackBuiltinPlugin } from '../base'; +import { type Coordinator, GET_OR_INIT_BINDING } from './Coordinator'; + +export type RscClientPluginOptions = { + coordinator: Coordinator; +}; + +export class RscClientPlugin extends RspackBuiltinPlugin { + name = 'RscClientPlugin'; + #options: RscClientPluginOptions; + + constructor(options: RscClientPluginOptions) { + super(); + this.#options = options; + } + raw(compiler: Compiler): binding.BuiltinPlugin { + this.#options.coordinator.applyClientCompiler(compiler); + + return createBuiltinPlugin(this.name, { + // @ts-ignore + coordinator: this.#options.coordinator[GET_OR_INIT_BINDING](), + }); + } +} diff --git a/packages/rspack/src/builtin-plugin/rsc/RscServerPlugin.ts b/packages/rspack/src/builtin-plugin/rsc/RscServerPlugin.ts new file mode 100644 index 000000000000..2b4bcbe38c13 --- /dev/null +++ b/packages/rspack/src/builtin-plugin/rsc/RscServerPlugin.ts @@ -0,0 +1,29 @@ +import type binding from '@rspack/binding'; +import type { Compiler } from '../..'; +import { createBuiltinPlugin, RspackBuiltinPlugin } from '../base'; +import { type Coordinator, GET_OR_INIT_BINDING } from './Coordinator'; + +export type RscServerPluginOptions = { + coordinator: Coordinator; + onServerComponentChanges?: () => Promise; +}; + +export class RscServerPlugin extends RspackBuiltinPlugin { + name = 'RscServerPlugin'; + #options: RscServerPluginOptions; + + constructor(options: RscServerPluginOptions) { + super(); + this.#options = options; + } + + raw(compiler: Compiler): binding.BuiltinPlugin { + this.#options.coordinator.applyServerCompiler(compiler); + + return createBuiltinPlugin(this.name, { + // @ts-ignore + coordinator: this.#options.coordinator[GET_OR_INIT_BINDING](), + onServerComponentChanges: this.#options.onServerComponentChanges, + }); + } +} diff --git a/packages/rspack/src/builtin-plugin/rsc/index.ts b/packages/rspack/src/builtin-plugin/rsc/index.ts new file mode 100644 index 000000000000..e18a47dac2ad --- /dev/null +++ b/packages/rspack/src/builtin-plugin/rsc/index.ts @@ -0,0 +1,47 @@ +import { Coordinator } from './Coordinator'; +import { + RscClientPlugin, + type RscClientPluginOptions, +} from './RscClientPlugin'; +import { RscServerPlugin } from './RscServerPlugin'; + +declare class ServerPlugin extends RscServerPlugin { + constructor(options?: Omit); +} + +declare class ClientPlugin extends RscClientPlugin {} + +export const rsc = { + createPlugins: (): { + ServerPlugin: new ( + options?: Omit, + ) => ServerPlugin; + ClientPlugin: new () => ClientPlugin; + } => { + const coordinator = new Coordinator(); + + return { + ServerPlugin: class ServerPlugin extends RscServerPlugin { + constructor(options: Omit = {}) { + super({ coordinator, ...options }); + } + }, + ClientPlugin: class ClientPlugin extends RscClientPlugin { + constructor() { + super({ coordinator }); + } + }, + }; + }, + + Layers: { + /** + * The layer for server-only runtime and picking up `react-server` export conditions. + */ + rsc: 'react-server-components', + /** + * Server Side Rendering layer for app. + */ + ssr: 'server-side-rendering', + } as const, +}; diff --git a/packages/rspack/src/exports.ts b/packages/rspack/src/exports.ts index 00e0fd811d54..40a501498dd6 100644 --- a/packages/rspack/src/exports.ts +++ b/packages/rspack/src/exports.ts @@ -135,6 +135,7 @@ export type { OutputFileSystem, WatchFileSystem } from './util/fs'; import { FetchCompileAsyncWasmPlugin, lazyCompilationMiddleware, + rsc, SubresourceIntegrityPlugin, } from './builtin-plugin'; @@ -390,6 +391,7 @@ interface Experiments { CssChunkingPlugin: typeof CssChunkingPlugin; createNativePlugin: typeof createNativePlugin; VirtualModulesPlugin: typeof VirtualModulesPlugin; + rsc: typeof rsc; } export const experiments: Experiments = { @@ -440,4 +442,5 @@ export const experiments: Experiments = { CssChunkingPlugin, createNativePlugin, VirtualModulesPlugin, + rsc, };