fix(builder): add OXC semantic to deconflic JS modules using the same global variables/functions
- this solve issues like: Uncaught SyntaxError: Identifier 'GROUP_BY_DEFS' has already been declared (at app.XXXX.js)
- add a protection on build: test to reload bundled app, on failure exit immediately (prevent the build or a broken bundle)
- add final check during CI: node --check on the bundle to brevent going to production too if any failure
example of a bundle:
```
warning: oxicloud@0.6.0: bundle: 50 files in dependency order:
warning: oxicloud@0.6.0: bundle: deconflicting 2 name(s): GROUP_BY_DEFS, LOAD_MORE_ID
warning: oxicloud@0.6.0: bundle: [29] GROUP_BY_DEFS -> GROUP_BY_DEFS_29 (favoritesView.js)
warning: oxicloud@0.6.0: bundle: [29] LOAD_MORE_ID -> LOAD_MORE_ID_29 (favoritesView.js)
warning: oxicloud@0.6.0: bundle: [31] GROUP_BY_DEFS -> GROUP_BY_DEFS_31 (recentView.js)
warning: oxicloud@0.6.0: bundle: [31] LOAD_MORE_ID -> LOAD_MORE_ID_31 (recentView.js)
warning: oxicloud@0.6.0: bundle: [34] GROUP_BY_DEFS -> GROUP_BY_DEFS_34 (sharedWithMeView.js)
warning: oxicloud@0.6.0: bundle: [34] LOAD_MORE_ID -> LOAD_MORE_ID_34 (sharedWithMeView.js)
warning: oxicloud@0.6.0: bundle: [44] GROUP_BY_DEFS -> GROUP_BY_DEFS_44 (filesView.js)
warning: oxicloud@0.6.0: bundle: [44] LOAD_MORE_ID -> LOAD_MORE_ID_44 (filesView.js)
```
This commit is contained in:
@@ -154,6 +154,13 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cargo build --release
|
- run: cargo build --release
|
||||||
|
# build.rs runs the deconflict pass and js_bundle_validate() — any
|
||||||
|
# duplicate declaration or parse error in the JS bundle fails here.
|
||||||
|
- name: Validate JS bundle (node --check)
|
||||||
|
# belt-and-suspenders: node --check parses the bundle without executing it.
|
||||||
|
# Catches SyntaxErrors that OXC's parse check inside build.rs would also
|
||||||
|
# catch, but gives a human-readable error line in the CI log.
|
||||||
|
run: node --check static-dist/js/app.*.js
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: oxicloud-release
|
name: oxicloud-release
|
||||||
|
|||||||
Generated
+1
@@ -3692,6 +3692,7 @@ dependencies = [
|
|||||||
"oxc_codegen",
|
"oxc_codegen",
|
||||||
"oxc_minifier",
|
"oxc_minifier",
|
||||||
"oxc_parser",
|
"oxc_parser",
|
||||||
|
"oxc_semantic",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quick-xml 0.39.2",
|
"quick-xml 0.39.2",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ path = "src/bin/generate-openapi.rs"
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
oxc_allocator = "0.125.0"
|
oxc_allocator = "0.125.0"
|
||||||
oxc_parser = "0.125.0"
|
oxc_parser = "0.125.0"
|
||||||
|
oxc_semantic = "0.125.0"
|
||||||
oxc_span = "0.125.0"
|
oxc_span = "0.125.0"
|
||||||
oxc_codegen = "0.125.0"
|
oxc_codegen = "0.125.0"
|
||||||
oxc_minifier = "0.125.0"
|
oxc_minifier = "0.125.0"
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ fn process_release(manifest_dir: &Path, static_dir: &Path, out_dir: &Path) {
|
|||||||
let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html");
|
let index_html = fs::read_to_string(static_dir.join("index.html")).expect("read index.html");
|
||||||
let module_scripts = extract_module_scripts(&index_html);
|
let module_scripts = extract_module_scripts(&index_html);
|
||||||
let js_raw = build_js_module_bundle(&module_scripts, static_dir);
|
let js_raw = build_js_module_bundle(&module_scripts, static_dir);
|
||||||
|
// Validate the raw bundle with OXC before minifying — catches re-declaration
|
||||||
|
// collisions and other syntax errors that would silently survive minification.
|
||||||
|
js_bundle_validate(&js_raw);
|
||||||
let js_bundle = js_minify_script_safe(&js_raw);
|
let js_bundle = js_minify_script_safe(&js_raw);
|
||||||
let js_hash = fnv_hash(js_bundle.as_bytes());
|
let js_hash = fnv_hash(js_bundle.as_bytes());
|
||||||
let js_name = format!("app.{js_hash}.js");
|
let js_name = format!("app.{js_hash}.js");
|
||||||
@@ -273,8 +276,12 @@ fn extract_module_scripts(html: &str) -> Vec<String> {
|
|||||||
/// 1. DFS from each entry point, following `import … from '…'` edges.
|
/// 1. DFS from each entry point, following `import … from '…'` edges.
|
||||||
/// 2. Post-order traversal ensures every dependency is emitted before its importer.
|
/// 2. Post-order traversal ensures every dependency is emitted before its importer.
|
||||||
/// 3. Cycles are broken by marking files as visited before recursing.
|
/// 3. Cycles are broken by marking files as visited before recursing.
|
||||||
/// 4. Each file has its import/export syntax stripped before being appended.
|
/// 4. **Deconflict pass**: any top-level binding that is private (not exported)
|
||||||
/// 5. The result is wrapped in `(function(){"use strict"; …})();`.
|
/// and shared across two or more modules is renamed `NAME_<idx>` in every
|
||||||
|
/// module that declares it. This prevents `SyntaxError: already declared`
|
||||||
|
/// when all modules land in the same IIFE scope.
|
||||||
|
/// 5. Each file has its import/export syntax stripped before being appended.
|
||||||
|
/// 6. The result is wrapped in `(function(){"use strict"; …})();`.
|
||||||
fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String {
|
fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -290,28 +297,206 @@ fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String
|
|||||||
"cargo:warning=bundle: {} files in dependency order:",
|
"cargo:warning=bundle: {} files in dependency order:",
|
||||||
order.len()
|
order.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Read all sources upfront — the deconflict pass needs the full set.
|
||||||
|
let mut sources: Vec<String> = order
|
||||||
|
.iter()
|
||||||
|
.map(|f| match fs::read_to_string(f) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("cargo:warning=bundle: cannot read {}: {e}", f.display());
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Deconflict: rename private top-level bindings that collide across modules.
|
||||||
|
deconflict_module_sources(&order, &mut sources);
|
||||||
|
|
||||||
|
// Concatenate into a single IIFE.
|
||||||
let mut bundle = String::with_capacity(2 * 1024 * 1024);
|
let mut bundle = String::with_capacity(2 * 1024 * 1024);
|
||||||
bundle.push_str("(function(){\n\"use strict\";\n");
|
bundle.push_str("(function(){\n\"use strict\";\n");
|
||||||
let mut declared_namespaces = std::collections::HashSet::new();
|
let mut declared_namespaces = HashSet::new();
|
||||||
for (i, file) in order.iter().enumerate() {
|
for (i, (file, src)) in order.iter().zip(sources.iter()).enumerate() {
|
||||||
println!(
|
println!(
|
||||||
"cargo:warning=bundle [{:>3}/{}] {}",
|
"cargo:warning=bundle [{:>3}/{}] {}",
|
||||||
i + 1,
|
i + 1,
|
||||||
order.len(),
|
order.len(),
|
||||||
file.display()
|
file.display()
|
||||||
);
|
);
|
||||||
match fs::read_to_string(file) {
|
bundle.push_str(&strip_esm_syntax(src, file, &mut declared_namespaces));
|
||||||
Ok(src) => {
|
bundle.push('\n');
|
||||||
bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces));
|
|
||||||
bundle.push('\n');
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
bundle.push_str("})();\n");
|
bundle.push_str("})();\n");
|
||||||
bundle
|
bundle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Deconflict pass
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Rename private top-level bindings that appear in more than one module so
|
||||||
|
/// they don't collide when all modules are concatenated into one IIFE scope.
|
||||||
|
///
|
||||||
|
/// Only **private** (non-exported) names are renamed. Exported names are the
|
||||||
|
/// public API — other modules reference them directly by name after
|
||||||
|
/// import-stripping and must not be touched.
|
||||||
|
///
|
||||||
|
/// The renamed form is `NAME_<module_index>` where the index is the position
|
||||||
|
/// of the module in the bundle order — guaranteed unique within the bundle.
|
||||||
|
fn deconflict_module_sources(order: &[PathBuf], sources: &mut [String]) {
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
// Per-module: private (non-exported) top-level binding names.
|
||||||
|
let private_bindings: Vec<Vec<String>> = sources
|
||||||
|
.iter()
|
||||||
|
.map(|src| {
|
||||||
|
let all = top_level_bindings(src);
|
||||||
|
let exported: HashSet<String> = extract_exported_names(src).into_iter().collect();
|
||||||
|
all.into_iter().filter(|n| !exported.contains(n)).collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Count how many modules declare each private name.
|
||||||
|
let mut name_count: HashMap<String, usize> = HashMap::new();
|
||||||
|
for bindings in &private_bindings {
|
||||||
|
for name in bindings {
|
||||||
|
*name_count.entry(name.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision set: names declared privately in more than one module.
|
||||||
|
let collisions: HashSet<String> = name_count
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, count)| *count > 1)
|
||||||
|
.map(|(name, _)| name)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if collisions.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sorted: Vec<&str> = collisions.iter().map(String::as_str).collect();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
println!(
|
||||||
|
"cargo:warning=bundle: deconflicting {} name(s): {}",
|
||||||
|
sorted.len(),
|
||||||
|
sorted.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rename each colliding binding within every module that declares it.
|
||||||
|
for (idx, src) in sources.iter_mut().enumerate() {
|
||||||
|
for name in &private_bindings[idx] {
|
||||||
|
if collisions.contains(name) {
|
||||||
|
let new_name = format!("{name}_{idx}");
|
||||||
|
*src = rename_binding(src, name, &new_name);
|
||||||
|
println!(
|
||||||
|
"cargo:warning=bundle: [{idx}] {name} -> {new_name} ({})",
|
||||||
|
order[idx].file_name().unwrap_or_default().to_string_lossy()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the names of all top-level bindings in an ES-module source file that
|
||||||
|
/// are **locally declared AND not exported**, using OXC for accurate analysis.
|
||||||
|
///
|
||||||
|
/// Two categories are excluded so the deconflict pass never touches them:
|
||||||
|
///
|
||||||
|
/// 1. **Import bindings** (`import { ui } from '…'`): after import-stripping,
|
||||||
|
/// these names resolve directly to the exporting module's declaration already
|
||||||
|
/// present in the IIFE scope — renaming them would break those references.
|
||||||
|
///
|
||||||
|
/// 2. **Exported bindings**: other modules import these by name after stripping,
|
||||||
|
/// so they must keep their original names. `ParseReturn::module_record` is
|
||||||
|
/// used instead of the text-based `extract_exported_names` helper because
|
||||||
|
/// multi-line `export { … }` blocks would otherwise be missed.
|
||||||
|
fn top_level_bindings(source: &str) -> Vec<String> {
|
||||||
|
use oxc_allocator::Allocator;
|
||||||
|
use oxc_parser::Parser;
|
||||||
|
use oxc_semantic::SemanticBuilder;
|
||||||
|
use oxc_span::SourceType;
|
||||||
|
|
||||||
|
let allocator = Allocator::default();
|
||||||
|
let ret = Parser::new(&allocator, source, SourceType::mjs()).parse();
|
||||||
|
if !ret.errors.is_empty() {
|
||||||
|
return Vec::new(); // parse failed — skip deconflict for this file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect exported local names from the module record (handles single-line
|
||||||
|
// and multi-line export blocks, re-exports, export-declarations, etc.).
|
||||||
|
let exported: std::collections::HashSet<&str> = ret
|
||||||
|
.module_record
|
||||||
|
.local_export_entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.local_name.name())
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let semantic = SemanticBuilder::new().build(&ret.program).semantic;
|
||||||
|
let scoping = semantic.scoping();
|
||||||
|
let root = scoping.root_scope_id();
|
||||||
|
scoping
|
||||||
|
.get_bindings(root)
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(ident, &symbol_id)| {
|
||||||
|
let name = ident.as_str();
|
||||||
|
// Skip import bindings and exported bindings.
|
||||||
|
let flags = scoping.symbol_flags(symbol_id);
|
||||||
|
if flags.is_import() || exported.contains(name) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace every whole-word occurrence of `old` with `new` in `source`.
|
||||||
|
///
|
||||||
|
/// "Whole word" means the characters immediately before and after the match
|
||||||
|
/// are not JavaScript identifier characters (`[a-zA-Z0-9_$]`). This prevents
|
||||||
|
/// `LOAD_MORE_ID` from being accidentally renamed when the source contains
|
||||||
|
/// `LOAD_MORE_ID_EXTRA`.
|
||||||
|
fn rename_binding(source: &str, old: &str, new: &str) -> String {
|
||||||
|
let old_len = old.len();
|
||||||
|
let mut out = String::with_capacity(source.len());
|
||||||
|
let mut start = 0;
|
||||||
|
|
||||||
|
while let Some(rel) = source[start..].find(old) {
|
||||||
|
let pos = start + rel;
|
||||||
|
let after = pos + old_len;
|
||||||
|
|
||||||
|
let boundary_before = pos == 0
|
||||||
|
|| source[..pos]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_none_or(|c| !is_js_ident_char(c));
|
||||||
|
|
||||||
|
let boundary_after = after >= source.len()
|
||||||
|
|| source[after..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_none_or(|c| !is_js_ident_char(c));
|
||||||
|
|
||||||
|
out.push_str(&source[start..pos]);
|
||||||
|
if boundary_before && boundary_after {
|
||||||
|
out.push_str(new);
|
||||||
|
} else {
|
||||||
|
out.push_str(old);
|
||||||
|
}
|
||||||
|
start = after;
|
||||||
|
}
|
||||||
|
out.push_str(&source[start..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_js_ident_char(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_' || c == '$'
|
||||||
|
}
|
||||||
|
|
||||||
/// DFS post-order: push `file` to `order` after all its imports.
|
/// DFS post-order: push `file` to `order` after all its imports.
|
||||||
/// Marks files as seen before recursing to break circular dependencies.
|
/// Marks files as seen before recursing to break circular dependencies.
|
||||||
fn collect_module_deps(
|
fn collect_module_deps(
|
||||||
@@ -666,6 +851,27 @@ fn collect_import_aliases(stmt: &str, declared: &mut std::collections::HashSet<S
|
|||||||
// JS minification
|
// JS minification
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Parse the JS bundle with OXC and hard-fail the build on any error.
|
||||||
|
///
|
||||||
|
/// Called on the **raw** (pre-minification) bundle so error messages still
|
||||||
|
/// reference readable source. Uses `cargo:error=` so Cargo surfaces the
|
||||||
|
/// problem immediately and stops the build — no silent fallback.
|
||||||
|
fn js_bundle_validate(source: &str) {
|
||||||
|
use oxc_allocator::Allocator;
|
||||||
|
use oxc_parser::Parser;
|
||||||
|
use oxc_span::SourceType;
|
||||||
|
|
||||||
|
let allocator = Allocator::default();
|
||||||
|
// The bundle is a classic IIFE script, not an ES module.
|
||||||
|
let ret = Parser::new(&allocator, source, SourceType::cjs()).parse();
|
||||||
|
if !ret.errors.is_empty() {
|
||||||
|
for e in &ret.errors {
|
||||||
|
println!("cargo:error=JS bundle parse error: {e}");
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Minify an ES-module file (contains import/export) — returns original on failure.
|
/// Minify an ES-module file (contains import/export) — returns original on failure.
|
||||||
fn js_minify_safe(source: &str) -> String {
|
fn js_minify_safe(source: &str) -> String {
|
||||||
js_minify_inner(source, true)
|
js_minify_inner(source, true)
|
||||||
|
|||||||
Reference in New Issue
Block a user