Skip to content

Commit b902a9f

Browse files
committed
feat(rsc): validate server action exports with runtime global
1 parent dabb7d0 commit b902a9f

13 files changed

Lines changed: 296 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
@@ -1704,38 +1704,27 @@ impl<'a, C: Comments> VisitMut for ServerActions<'a, C> {
17041704

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

17151714
if !server_reference_exports.is_empty() {
1716-
let ensure_ident = private_ident!("ensureServerActions");
1717-
new.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl {
1718-
span: DUMMY_SP,
1719-
specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier {
1720-
span: DUMMY_SP,
1721-
local: ensure_ident.clone(),
1722-
imported: None,
1723-
is_type_only: false,
1724-
})],
1725-
src: Box::new(Str {
1726-
span: DUMMY_SP,
1727-
value: atom!("react-server-dom-rspack/server").into(),
1728-
raw: None,
1729-
}),
1730-
type_only: false,
1731-
with: None,
1732-
phase: Default::default(),
1733-
})));
17341715
new.push(ModuleItem::Stmt(Stmt::Expr(ExprStmt {
17351716
span: DUMMY_SP,
17361717
expr: Box::new(Expr::Call(CallExpr {
17371718
span: DUMMY_SP,
1738-
callee: Callee::Expr(Box::new(Expr::Ident(ensure_ident))),
1719+
callee: Callee::Expr(Box::new(Expr::Member(MemberExpr {
1720+
span: DUMMY_SP,
1721+
obj: Box::new(Expr::Ident(Ident::new(
1722+
atom!("__webpack_require__"),
1723+
DUMMY_SP,
1724+
SyntaxContext::empty(),
1725+
))),
1726+
prop: MemberProp::Ident(quote_ident!("rscA")),
1727+
}))),
17391728
args: vec![ExprOrSpread {
17401729
spread: None,
17411730
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};
@@ -23,6 +24,7 @@ use crate::{
2324
},
2425
constants::{CSS_REGEX, LAYERS_NAMES},
2526
coordinator::Coordinator,
27+
ensure_server_actions_runtime_module::RscEnsureServerActionsRuntimeModule,
2628
hot_reloader::track_server_component_changes,
2729
loaders::action_entry_loader::ACTION_ENTRY_LOADER_IDENTIFIER,
2830
manifest_runtime_module::RscManifestRuntimeModule,
@@ -184,9 +186,77 @@ async fn runtime_requirements_in_tree(
184186
Box::new(RscManifestRuntimeModule::new(&compilation.runtime_template)),
185187
));
186188
}
189+
if runtime_requirements.contains(RuntimeGlobals::RSC_ENSURE_SERVER_ACTIONS) {
190+
runtime_modules_to_add.push((
191+
*chunk_ukey,
192+
Box::new(RscEnsureServerActionsRuntimeModule::new(
193+
&compilation.runtime_template,
194+
)),
195+
));
196+
}
187197
Ok(None)
188198
}
189199

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

231305
ctx.compilation_hooks.chunk_ids.tap(chunk_ids::new(self));
232306

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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
for (const actionId of actionIds) {
28+
loadServerAction(actionId);
29+
}
30+
}).toThrow(RSC_ACTION_ERROR);
31+
});

0 commit comments

Comments
 (0)