Skip to content

Commit 0bfae2f

Browse files
authored
feat(rslib): handle hashbang / react directives natively (#12168)
* feat(rslib): handle hashbang / react directives natively support react directive * chmod * clean * add test * fix non-unix chmod * cr
1 parent b96b353 commit 0bfae2f

File tree

18 files changed

+554
-10
lines changed

18 files changed

+554
-10
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/rspack_binding_api/src/fs_node/node.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type Write = ThreadsafeFunction<FnArgs<(i32, Buffer, u32)>, Promise<Either<u32,
1111
type Read = ThreadsafeFunction<FnArgs<(i32, u32, u32)>, Promise<Either<Buffer, ()>>>;
1212
type ReadUtil = ThreadsafeFunction<FnArgs<(i32, u8, u32)>, Promise<Either<Buffer, ()>>>;
1313
type ReadToEnd = ThreadsafeFunction<FnArgs<(i32, u32)>, Promise<Either<Buffer, ()>>>;
14+
type Chmod = ThreadsafeFunction<FnArgs<(String, u32)>, Promise<()>>;
1415

1516
#[derive(Debug)]
1617
#[napi(object, object_to_js = false, js_name = "ThreadsafeNodeFS")]
@@ -53,7 +54,7 @@ pub struct ThreadsafeNodeFS {
5354
pub read_to_end: ReadToEnd,
5455
// The following functions are not supported by webpack, so they are optional
5556
#[napi(ts_type = "(name: string, mode: number) => Promise<void>")]
56-
pub chmod: Option<ThreadsafeFunction<(String, u32), Promise<()>>>,
57+
pub chmod: Option<Chmod>,
5758
}
5859

5960
#[napi(object, object_to_js = false)]

crates/rspack_binding_api/src/fs_node/write.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ impl WritableFileSystem for NodeFileSystem {
134134
&& let Some(chmod) = &self.0.chmod
135135
{
136136
let file = path.as_str().to_string();
137-
return chmod.call_with_promise((file, mode)).await.to_fs_result();
137+
return chmod
138+
.call_with_promise((file, mode).into())
139+
.await
140+
.to_fs_result();
138141
}
139142
Ok(())
140143
}

crates/rspack_core/src/utils/mod.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ use rspack_collections::Identifier;
44
use rspack_util::comparators::{compare_ids, compare_numbers};
55

66
use crate::{
7-
BoxModule, ChunkGraph, ChunkGroupByUkey, ChunkGroupUkey, ChunkUkey, Compilation, ModuleGraph,
7+
BoxModule, ChunkGraph, ChunkGroupByUkey, ChunkGroupUkey, ChunkUkey, Compilation,
8+
ConcatenatedModule, ModuleGraph, ModuleIdentifier,
89
};
910
mod comment;
1011
mod compile_boolean_matcher;
@@ -145,6 +146,65 @@ pub fn compare_modules_by_identifier(a: &BoxModule, b: &BoxModule) -> std::cmp::
145146
compare_ids(&a.identifier(), &b.identifier())
146147
}
147148

149+
/// # Returns
150+
/// - `Some(String)` if a hashbang is found in the module's build_info extras
151+
/// - `None` if no hashbang is present or the module doesn't exist
152+
pub fn get_module_hashbang(
153+
module_graph: &ModuleGraph,
154+
module_id: &ModuleIdentifier,
155+
) -> Option<String> {
156+
let module = module_graph.module_by_identifier(module_id)?;
157+
158+
let build_info =
159+
if let Some(concatenated_module) = module.as_any().downcast_ref::<ConcatenatedModule>() {
160+
// For concatenated modules, get the root module's build_info
161+
let root_module_id = concatenated_module.get_root();
162+
module_graph
163+
.module_by_identifier(&root_module_id)
164+
.map_or_else(|| module.build_info(), |m| m.build_info())
165+
} else {
166+
module.build_info()
167+
};
168+
169+
build_info
170+
.extras
171+
.get("hashbang")
172+
.and_then(|v| v.as_str())
173+
.map(|s| s.to_string())
174+
}
175+
176+
/// # Returns
177+
/// - `Some(Vec<String>)` if directives are found in the module's build_info extras
178+
/// - `None` if no directives are present or the module doesn't exist
179+
pub fn get_module_directives(
180+
module_graph: &ModuleGraph,
181+
module_id: &ModuleIdentifier,
182+
) -> Option<Vec<String>> {
183+
let module = module_graph.module_by_identifier(module_id)?;
184+
185+
let build_info =
186+
if let Some(concatenated_module) = module.as_any().downcast_ref::<ConcatenatedModule>() {
187+
// For concatenated modules, get the root module's build_info
188+
let root_module_id = concatenated_module.get_root();
189+
module_graph
190+
.module_by_identifier(&root_module_id)
191+
.map_or_else(|| module.build_info(), |m| m.build_info())
192+
} else {
193+
module.build_info()
194+
};
195+
196+
build_info
197+
.extras
198+
.get("react_directives")
199+
.and_then(|v| v.as_array())
200+
.map(|arr| {
201+
arr
202+
.iter()
203+
.filter_map(|v| v.as_str().map(|s| s.to_string()))
204+
.collect()
205+
})
206+
}
207+
148208
pub fn compare_module_iterables(modules_a: &[&BoxModule], modules_b: &[&BoxModule]) -> Ordering {
149209
let mut a_iter = modules_a.iter();
150210
let mut b_iter = modules_b.iter();

crates/rspack_plugin_esm_library/src/render.rs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use rspack_collections::{IdentifierIndexSet, UkeyIndexMap, UkeySet};
55
use rspack_core::{
66
AssetInfo, Chunk, ChunkGraph, ChunkRenderContext, ChunkUkey, CodeGenerationDataFilename,
77
Compilation, ConcatenatedModuleInfo, DependencyId, InitFragment, ModuleIdentifier, PathData,
8-
PathInfo, RuntimeGlobals, SourceType, get_js_chunk_filename_template, get_undo_path,
9-
render_init_fragments,
8+
PathInfo, RuntimeGlobals, SourceType, get_js_chunk_filename_template, get_module_directives,
9+
get_module_hashbang, get_undo_path, render_init_fragments,
1010
rspack_sources::{ConcatSource, RawStringSource, ReplaceSource, Source, SourceExt},
1111
};
1212
use rspack_error::Result;
@@ -85,6 +85,49 @@ impl EsmLibraryPlugin {
8585

8686
let mut chunk_init_fragments: Vec<Box<dyn InitFragment<ChunkRenderContext> + 'static>> =
8787
chunk_link.init_fragments.clone();
88+
89+
// NOTE: Similar hashbang and directives handling logic.
90+
// See rspack_plugin_rslib/src/plugin.rs render() for why this duplication is necessary.
91+
let entry_modules = compilation.chunk_graph.get_chunk_entry_modules(chunk_ukey);
92+
for entry_module_id in &entry_modules {
93+
let hashbang = get_module_hashbang(&module_graph, entry_module_id);
94+
let directives = get_module_directives(&module_graph, entry_module_id);
95+
96+
if hashbang.is_none() && directives.is_none() {
97+
continue;
98+
}
99+
100+
if let Some(hashbang) = &hashbang {
101+
chunk_init_fragments.insert(
102+
0,
103+
Box::new(rspack_core::NormalInitFragment::new(
104+
format!("{hashbang}\n"),
105+
rspack_core::InitFragmentStage::StageConstants,
106+
i32::MIN,
107+
rspack_core::InitFragmentKey::unique(),
108+
None,
109+
)),
110+
);
111+
}
112+
113+
if let Some(directives) = directives {
114+
for (idx, directive) in directives.iter().enumerate() {
115+
let insert_pos = if hashbang.is_some() { 1 + idx } else { idx };
116+
chunk_init_fragments.insert(
117+
insert_pos,
118+
Box::new(rspack_core::NormalInitFragment::new(
119+
format!("{directive}\n"),
120+
rspack_core::InitFragmentStage::StageConstants,
121+
i32::MIN + 1 + idx as i32,
122+
rspack_core::InitFragmentKey::unique(),
123+
None,
124+
)),
125+
);
126+
}
127+
}
128+
break; // Only process the first entry module with hashbang/directives
129+
}
130+
88131
let mut replace_auto_public_path = false;
89132
let mut replace_static_url = false;
90133

crates/rspack_plugin_rslib/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ version.workspace = true
1111
rspack_cacheable = { workspace = true }
1212
rspack_core = { workspace = true }
1313
rspack_error = { workspace = true }
14+
rspack_fs = { workspace = true }
1415
rspack_hash = { workspace = true }
1516
rspack_hook = { workspace = true }
1617
rspack_plugin_asset = { workspace = true }
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use rspack_core::ConstDependency;
2+
use rspack_plugin_javascript::{JavascriptParserPlugin, visitors::JavascriptParser};
3+
use swc_core::ecma::ast::Program;
4+
5+
pub struct HashbangParserPlugin;
6+
7+
impl JavascriptParserPlugin for HashbangParserPlugin {
8+
fn program(&self, parser: &mut JavascriptParser, ast: &Program) -> Option<bool> {
9+
let hashbang = ast
10+
.as_module()
11+
.and_then(|m| m.shebang.as_ref())
12+
.or_else(|| ast.as_script().and_then(|s| s.shebang.as_ref()))?;
13+
14+
// Normalize hashbang to always include "#!" prefix
15+
// SWC may omit the leading "#!" in the shebang value
16+
let normalized_hashbang = if hashbang.starts_with("#!") {
17+
hashbang.to_string()
18+
} else {
19+
format!("#!{}", hashbang)
20+
};
21+
22+
// Store hashbang in build_info for later use during rendering
23+
parser.build_info.extras.insert(
24+
"hashbang".to_string(),
25+
serde_json::Value::String(normalized_hashbang),
26+
);
27+
28+
// Remove hashbang from source code
29+
// If SWC omitted "#!", we still need to remove those two characters
30+
let replace_len = if hashbang.starts_with("#!") {
31+
hashbang.len() as u32
32+
} else {
33+
hashbang.len() as u32 + 2 // include "#!"
34+
};
35+
36+
parser.add_presentational_dependency(Box::new(ConstDependency::new(
37+
(0, replace_len).into(),
38+
"".into(),
39+
None,
40+
)));
41+
42+
None
43+
}
44+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mod asset;
2+
mod hashbang_parser_plugin;
23
mod import_dependency;
34
mod import_external;
45
mod parser_plugin;
56
mod plugin;
7+
mod react_directives_parser_plugin;
68
pub use plugin::*;

crates/rspack_plugin_rslib/src/plugin.rs

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,24 @@ use std::{
44
};
55

66
use rspack_core::{
7-
Compilation, CompilationParams, CompilerCompilation, CompilerFinishMake, ModuleType,
8-
NormalModuleFactoryParser, ParserAndGenerator, ParserOptions, Plugin,
7+
AssetEmittedInfo, ChunkUkey, Compilation, CompilationParams, CompilerAssetEmitted,
8+
CompilerCompilation, CompilerFinishMake, ModuleType, NormalModuleFactoryParser,
9+
ParserAndGenerator, ParserOptions, Plugin, get_module_directives, get_module_hashbang,
10+
rspack_sources::{ConcatSource, RawStringSource, Source, SourceExt},
911
};
1012
use rspack_error::Result;
1113
use rspack_hook::{plugin, plugin_hook};
1214
use rspack_plugin_asset::AssetParserAndGenerator;
1315
use rspack_plugin_javascript::{
14-
BoxJavascriptParserPlugin, parser_and_generator::JavaScriptParserAndGenerator,
16+
BoxJavascriptParserPlugin, JavascriptModulesRender, JsPlugin, RenderSource,
17+
parser_and_generator::JavaScriptParserAndGenerator,
1518
};
1619

1720
use crate::{
18-
asset::RslibAssetParserAndGenerator, import_dependency::RslibDependencyTemplate,
21+
asset::RslibAssetParserAndGenerator, hashbang_parser_plugin::HashbangParserPlugin,
22+
import_dependency::RslibDependencyTemplate,
1923
import_external::replace_import_dependencies_for_external_modules,
20-
parser_plugin::RslibParserPlugin,
24+
parser_plugin::RslibParserPlugin, react_directives_parser_plugin::ReactDirectivesParserPlugin,
2125
};
2226

2327
#[derive(Debug)]
@@ -54,6 +58,8 @@ async fn nmf_parser(
5458
) -> Result<()> {
5559
if let Some(parser) = parser.downcast_mut::<JavaScriptParserAndGenerator>() {
5660
if module_type.is_js_like() {
61+
parser.add_parser_plugin(Box::new(HashbangParserPlugin) as BoxJavascriptParserPlugin);
62+
parser.add_parser_plugin(Box::new(ReactDirectivesParserPlugin) as BoxJavascriptParserPlugin);
5763
parser.add_parser_plugin(
5864
Box::new(RslibParserPlugin::new(self.options.intercept_api_plugin))
5965
as BoxJavascriptParserPlugin,
@@ -88,6 +94,71 @@ async fn compilation(
8894
RslibDependencyTemplate::template_type(),
8995
Arc::new(RslibDependencyTemplate::default()),
9096
);
97+
98+
// Register render hook for hashbang and directives handling during chunk generation
99+
let hooks = JsPlugin::get_compilation_hooks_mut(compilation.id());
100+
let mut hooks = hooks.write().await;
101+
hooks.render.tap(render::new(self));
102+
drop(hooks);
103+
104+
Ok(())
105+
}
106+
107+
#[plugin_hook(JavascriptModulesRender for RslibPlugin)]
108+
async fn render(
109+
&self,
110+
compilation: &Compilation,
111+
chunk_ukey: &ChunkUkey,
112+
render_source: &mut RenderSource,
113+
) -> Result<()> {
114+
// NOTE: This function handles hashbang and directives for non new ESM library formats.
115+
// Similar logic exists in rspack_plugin_esm_library/src/render.rs for ESM format,
116+
// as that plugin's render path is used instead when ESM library plugin is enabled.
117+
let entry_modules = compilation.chunk_graph.get_chunk_entry_modules(chunk_ukey);
118+
if entry_modules.is_empty() {
119+
return Ok(());
120+
}
121+
122+
let module_graph = compilation.get_module_graph();
123+
124+
for entry_module_id in &entry_modules {
125+
let hashbang = get_module_hashbang(&module_graph, entry_module_id);
126+
let directives = get_module_directives(&module_graph, entry_module_id);
127+
128+
if hashbang.is_none() && directives.is_none() {
129+
continue;
130+
}
131+
132+
let original_source_str = render_source.source.source().into_string_lossy();
133+
134+
let mut new_source = ConcatSource::default();
135+
136+
if let Some(hashbang) = hashbang {
137+
new_source.add(RawStringSource::from(format!("{}\n", hashbang)));
138+
}
139+
140+
if let Some(directives) = directives {
141+
let use_strict_prefix = "\"use strict\";\n";
142+
if let Some(rest) = original_source_str.strip_prefix(use_strict_prefix) {
143+
new_source.add(RawStringSource::from(use_strict_prefix));
144+
for directive in directives {
145+
new_source.add(RawStringSource::from(format!("{}\n", directive)));
146+
}
147+
new_source.add(RawStringSource::from(rest));
148+
} else {
149+
for directive in directives {
150+
new_source.add(RawStringSource::from(format!("{}\n", directive)));
151+
}
152+
new_source.add(render_source.source.clone());
153+
}
154+
} else {
155+
new_source.add(render_source.source.clone());
156+
}
157+
158+
render_source.source = new_source.boxed();
159+
break;
160+
}
161+
91162
Ok(())
92163
}
93164

@@ -98,6 +169,26 @@ async fn finish_make(&self, compilation: &mut Compilation) -> Result<()> {
98169
Ok(())
99170
}
100171

172+
#[plugin_hook(CompilerAssetEmitted for RslibPlugin)]
173+
async fn asset_emitted(
174+
&self,
175+
compilation: &Compilation,
176+
_filename: &str,
177+
info: &AssetEmittedInfo,
178+
) -> Result<()> {
179+
use rspack_fs::FilePermissions;
180+
181+
let content = info.source.source().into_string_lossy();
182+
if content.starts_with("#!") {
183+
let output_fs = &compilation.output_filesystem;
184+
let permissions = FilePermissions::from_mode(0o755);
185+
output_fs
186+
.set_permissions(&info.target_path, permissions)
187+
.await?;
188+
}
189+
Ok(())
190+
}
191+
101192
impl Plugin for RslibPlugin {
102193
fn name(&self) -> &'static str {
103194
"rslib"
@@ -111,6 +202,10 @@ impl Plugin for RslibPlugin {
111202
.tap(nmf_parser::new(self));
112203

113204
ctx.compiler_hooks.finish_make.tap(finish_make::new(self));
205+
ctx
206+
.compiler_hooks
207+
.asset_emitted
208+
.tap(asset_emitted::new(self));
114209

115210
Ok(())
116211
}

0 commit comments

Comments
 (0)