Skip to content

Commit 68e6130

Browse files
committed
feat(rsc): validate server action exports with runtime global
1 parent 2d81c6e commit 68e6130

13 files changed

Lines changed: 294 additions & 26 deletions

File tree

crates/rspack_core/src/runtime_globals.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ define_runtime_globals! {
300300

301301
// react server component
302302
const RSC_MANIFEST;
303+
304+
const RSC_ENSURE_SERVER_ACTIONS;
303305
}
304306

305307
impl Default for RuntimeGlobals {
@@ -373,6 +375,7 @@ pub static REQUIRE_SCOPE_GLOBALS: LazyLock<RuntimeGlobals> = LazyLock::new(|| {
373375
| RuntimeGlobals::RSPACK_UNIQUE_ID
374376
| RuntimeGlobals::ASYNC_STARTUP
375377
| RuntimeGlobals::RSC_MANIFEST
378+
| RuntimeGlobals::RSC_ENSURE_SERVER_ACTIONS
376379
| RuntimeGlobals::TO_BINARY
377380
| RuntimeGlobals::DEFERRED_MODULES_ASYNC_TRANSITIVE_DEPENDENCIES
378381
| RuntimeGlobals::DEFERRED_MODULES_ASYNC_TRANSITIVE_DEPENDENCIES_SYMBOL
@@ -473,6 +476,7 @@ pub fn runtime_globals_to_string(
473476
RuntimeGlobals::HAS_FETCH_PRIORITY => "has fetch priority",
474477

475478
RuntimeGlobals::RSC_MANIFEST => "rscM",
479+
RuntimeGlobals::RSC_ENSURE_SERVER_ACTIONS => "rscA",
476480
RuntimeGlobals::TO_BINARY => "tb",
477481
_ => unreachable!(),
478482
};

crates/rspack_loader_swc/src/rsc_transforms/server_actions.rs

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,38 +1703,27 @@ impl<'a, C: Comments> VisitMut for ServerActions<'a, C> {
17031703

17041704
// Ensure that the exports are functions by appending a runtime check:
17051705
//
1706-
// import { ensureServerActions } from 'react-server-dom-rspack/server'
1707-
// ensureServerActions([action1, action2, ...])
1706+
// __webpack_require__.rscA([action1, action2, ...])
17081707
//
17091708
// But it's only needed for the server layer, because on the client
17101709
// layer they're transformed into references already.
17111710
if self.has_action && self.config.is_react_server_layer {
17121711
new.append(&mut self.extra_items);
17131712

17141713
if !server_reference_exports.is_empty() {
1715-
let ensure_ident = private_ident!("ensureServerActions");
1716-
new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl {
1717-
span: DUMMY_SP,
1718-
specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier {
1719-
span: DUMMY_SP,
1720-
local: ensure_ident.clone(),
1721-
imported: None,
1722-
is_type_only: false,
1723-
})],
1724-
src: Box::new(Str {
1725-
span: DUMMY_SP,
1726-
value: atom!("react-server-dom-rspack/server").into(),
1727-
raw: None,
1728-
}),
1729-
type_only: false,
1730-
with: None,
1731-
phase: Default::default(),
1732-
})));
17331714
new.push(ModuleItem::Stmt(Stmt::Expr(ExprStmt {
17341715
span: DUMMY_SP,
17351716
expr: Box::new(Expr::Call(CallExpr {
17361717
span: DUMMY_SP,
1737-
callee: Callee::Expr(Box::new(Expr::Ident(ensure_ident))),
1718+
callee: Callee::Expr(Box::new(Expr::Member(MemberExpr {
1719+
span: DUMMY_SP,
1720+
obj: Box::new(Expr::Ident(Ident::new(
1721+
atom!("__webpack_require__"),
1722+
DUMMY_SP,
1723+
SyntaxContext::empty(),
1724+
))),
1725+
prop: MemberProp::Ident(quote_ident!("rscA")),
1726+
}))),
17381727
args: vec![ExprOrSpread {
17391728
spread: None,
17401729
expr: Box::new(Expr::Array(ArrayLit {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use indoc::formatdoc;
2+
use rspack_core::{
3+
RuntimeGlobals, RuntimeModule, RuntimeModuleGenerateContext, RuntimeModuleStage, RuntimeTemplate,
4+
impl_runtime_module,
5+
};
6+
7+
#[impl_runtime_module]
8+
#[derive(Debug)]
9+
pub struct RscEnsureServerActionsRuntimeModule {}
10+
11+
impl RscEnsureServerActionsRuntimeModule {
12+
pub fn new(runtime_template: &RuntimeTemplate) -> Self {
13+
Self::with_default(runtime_template)
14+
}
15+
}
16+
17+
#[async_trait::async_trait]
18+
impl RuntimeModule for RscEnsureServerActionsRuntimeModule {
19+
fn stage(&self) -> RuntimeModuleStage {
20+
RuntimeModuleStage::Attach
21+
}
22+
23+
async fn generate(
24+
&self,
25+
context: &RuntimeModuleGenerateContext<'_>,
26+
) -> rspack_error::Result<String> {
27+
Ok(formatdoc! {
28+
r#"
29+
{ensure_server_actions} = function(actions) {{
30+
for (var i = 0; i < actions.length; i++) {{
31+
var action = actions[i];
32+
if (typeof action !== "function") {{
33+
throw new Error('A "use server" file can only export async functions, found ' + typeof action + ".");
34+
}}
35+
}}
36+
}};
37+
"#,
38+
ensure_server_actions = context
39+
.runtime_template
40+
.render_runtime_globals(&RuntimeGlobals::RSC_ENSURE_SERVER_ACTIONS),
41+
})
42+
}
43+
}

crates/rspack_plugin_rsc/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod client_reference_dependency;
33
mod component_info;
44
mod constants;
55
mod coordinator;
6+
mod ensure_server_actions_runtime_module;
67
mod hot_reloader;
78
mod loaders;
89
mod manifest_runtime_module;

crates/rspack_plugin_rsc/src/server_plugin.rs

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ use derive_more::Debug;
77
use futures::future::BoxFuture;
88
use rspack_collections::{Identifiable, IdentifierMap};
99
use rspack_core::{
10-
BoxDependency, ChunkByUkey, ChunkNamedIdArtifact, ChunkUkey, Compilation, CompilationChunkIds,
11-
CompilationParams, CompilationRuntimeRequirementInTree, CompilerCompilation, CompilerDone,
12-
CompilerFailed, CompilerFinishMake, CompilerThisCompilation, Dependency, DependencyId,
13-
DependencyType, EntryDependency, EntryOptions, Logger, Plugin, RuntimeGlobals, RuntimeModule,
14-
RuntimeSpec, get_entry_runtime,
10+
BoxDependency, ChunkByUkey, ChunkNamedIdArtifact, ChunkUkey, Compilation,
11+
CompilationAdditionalModuleRuntimeRequirements, CompilationChunkIds, CompilationParams,
12+
CompilationRuntimeRequirementInTree, CompilerCompilation, CompilerDone, CompilerFailed,
13+
CompilerFinishMake, CompilerThisCompilation, Dependency, DependencyId, DependencyType,
14+
EntryDependency, EntryOptions, Logger, Module, ModuleIdentifier, Plugin, RuntimeGlobals,
15+
RuntimeModule, RuntimeSpec, get_entry_runtime,
1516
};
1617
use rspack_error::{Diagnostic, Result, ToStringResultToRspackResultExt};
1718
use rspack_hook::{plugin, plugin_hook};
@@ -21,6 +22,7 @@ use crate::{
2122
component_info::{ClientComponentImports, collect_component_info_from_entry_dependency},
2223
constants::{CSS_REGEX, LAYERS_NAMES},
2324
coordinator::Coordinator,
25+
ensure_server_actions_runtime_module::RscEnsureServerActionsRuntimeModule,
2426
hot_reloader::track_server_component_changes,
2527
loaders::action_entry_loader::ACTION_ENTRY_LOADER_IDENTIFIER,
2628
manifest_runtime_module::RscManifestRuntimeModule,
@@ -181,9 +183,77 @@ async fn runtime_requirements_in_tree(
181183
Box::new(RscManifestRuntimeModule::new(&compilation.runtime_template)),
182184
));
183185
}
186+
if runtime_requirements.contains(RuntimeGlobals::RSC_ENSURE_SERVER_ACTIONS) {
187+
runtime_modules_to_add.push((
188+
*chunk_ukey,
189+
Box::new(RscEnsureServerActionsRuntimeModule::new(
190+
&compilation.runtime_template,
191+
)),
192+
));
193+
}
184194
Ok(None)
185195
}
186196

197+
#[plugin_hook(CompilationAdditionalModuleRuntimeRequirements for RscServerPlugin,tracing=false)]
198+
async fn additional_module_runtime_requirements(
199+
&self,
200+
compilation: &Compilation,
201+
module_identifier: &ModuleIdentifier,
202+
runtime_requirements: &mut RuntimeGlobals,
203+
) -> Result<()> {
204+
let Some(module) = compilation.module_by_identifier(module_identifier) else {
205+
return Ok(());
206+
};
207+
208+
if module_needs_ensure_server_actions_runtime(module.as_ref(), compilation) {
209+
runtime_requirements
210+
.insert(RuntimeGlobals::REQUIRE | RuntimeGlobals::RSC_ENSURE_SERVER_ACTIONS);
211+
}
212+
213+
Ok(())
214+
}
215+
216+
fn has_server_actions(module: &dyn Module) -> bool {
217+
module
218+
.build_info()
219+
.rsc
220+
.as_ref()
221+
.is_some_and(|rsc| !rsc.action_ids.is_empty())
222+
}
223+
224+
fn is_rsc_layer_module(module: &dyn Module) -> bool {
225+
module
226+
.get_layer()
227+
.is_some_and(|layer| layer == LAYERS_NAMES.react_server_components)
228+
}
229+
230+
fn module_needs_ensure_server_actions_runtime(
231+
module: &dyn Module,
232+
compilation: &Compilation,
233+
) -> bool {
234+
if !is_rsc_layer_module(module) {
235+
return false;
236+
}
237+
238+
if has_server_actions(module) {
239+
return true;
240+
}
241+
242+
let Some(concatenated_module) = module.as_concatenated_module() else {
243+
return false;
244+
};
245+
246+
let module_graph = compilation.get_module_graph();
247+
concatenated_module
248+
.get_modules()
249+
.iter()
250+
.any(|inner_module| {
251+
module_graph
252+
.module_by_identifier(&inner_module.id)
253+
.is_some_and(|module| has_server_actions(module.as_ref()))
254+
})
255+
}
256+
187257
/// Compute server manifest and server_consumer_module_map once per entry. Stored in plugin_state for
188258
/// RscManifestRuntimeModule and onManifest to avoid recomputing.
189259
#[plugin_hook(CompilationChunkIds for RscServerPlugin, stage = -10000)]
@@ -224,6 +294,10 @@ impl Plugin for RscServerPlugin {
224294
.compilation_hooks
225295
.runtime_requirement_in_tree
226296
.tap(runtime_requirements_in_tree::new(self));
297+
ctx
298+
.compilation_hooks
299+
.additional_module_runtime_requirements
300+
.tap(additional_module_runtime_requirements::new(self));
227301

228302
ctx.compilation_hooks.chunk_ids.tap(chunk_ids::new(self));
229303

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const path = require('node:path');
2+
const { experiments } = require('@rspack/core');
3+
4+
const { createPlugins, Layers } = experiments.rsc;
5+
const { ServerPlugin, ClientPlugin } = createPlugins();
6+
7+
const ssrEntry = path.join(__dirname, 'src/framework/entry.ssr.js');
8+
const rscEntry = path.join(__dirname, 'src/framework/entry.rsc.js');
9+
10+
const swcLoaderRule = {
11+
test: /\.jsx?$/,
12+
use: [
13+
{
14+
loader: 'builtin:swc-loader',
15+
options: {
16+
detectSyntax: 'auto',
17+
jsc: {
18+
transform: {
19+
react: {
20+
runtime: 'automatic',
21+
},
22+
},
23+
},
24+
rspackExperiments: {
25+
reactServerComponents: true,
26+
},
27+
},
28+
},
29+
],
30+
};
31+
32+
module.exports = [
33+
{
34+
mode: 'production',
35+
target: 'node',
36+
entry: {
37+
main: {
38+
import: ssrEntry,
39+
},
40+
},
41+
resolve: {
42+
extensions: ['...', '.jsx'],
43+
},
44+
module: {
45+
rules: [
46+
swcLoaderRule,
47+
{
48+
resource: ssrEntry,
49+
layer: Layers.ssr,
50+
},
51+
{
52+
resource: rscEntry,
53+
layer: Layers.rsc,
54+
resolve: {
55+
conditionNames: ['react-server', '...'],
56+
},
57+
},
58+
{
59+
issuerLayer: Layers.rsc,
60+
resolve: {
61+
conditionNames: ['react-server', '...'],
62+
},
63+
},
64+
],
65+
},
66+
plugins: [new ServerPlugin()],
67+
},
68+
{
69+
mode: 'production',
70+
target: 'web',
71+
entry: {
72+
main: {
73+
import: './src/framework/entry.client.js',
74+
},
75+
},
76+
resolve: {
77+
extensions: ['...', '.jsx'],
78+
},
79+
module: {
80+
rules: [swcLoaderRule],
81+
},
82+
plugins: [new ClientPlugin()],
83+
},
84+
];
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"use server-entry";
2+
3+
import { Client } from "./Client";
4+
5+
export const App = () => {
6+
return <Client />;
7+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"use client";
2+
3+
import { invalidAction } from "./server-actions";
4+
5+
export const Client = () => {
6+
async function onClick() {
7+
await invalidAction();
8+
}
9+
10+
return (
11+
<button type="button" onClick={onClick}>
12+
Run actions
13+
</button>
14+
);
15+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// In a real app this entry would consume the RSC payload and hydrate.
2+
// This file exists mainly to mirror the typical split of RSC/SSR/client entries.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
loadServerAction,
3+
renderToReadableStream
4+
} from "react-server-dom-rspack/server";
5+
import { App } from "../App";
6+
7+
const RSC_ACTION_ERROR =
8+
'A "use server" file can only export async functions, found number.';
9+
10+
export const renderRscStream = () => {
11+
return renderToReadableStream(<App />);
12+
};
13+
14+
const getActionIds = () => {
15+
const manifest = __rspack_rsc_manifest__;
16+
expect(manifest).toBeDefined();
17+
expect(manifest.serverManifest).toBeDefined();
18+
19+
return Object.keys(manifest.serverManifest);
20+
};
21+
22+
it("should reject non-function server action exports with rscA at runtime", () => {
23+
const actionIds = getActionIds();
24+
25+
expect(actionIds).toHaveLength(2);
26+
expect(() => {
27+
loadServerAction(actionIds[0]);
28+
}).toThrow(RSC_ACTION_ERROR);
29+
});

0 commit comments

Comments
 (0)