Skip to content

Commit 9c2cabb

Browse files
committed
feat: enhance CI workflow to include Rust setup and tests, update rewriter logic for improved handling of export declarations
1 parent cdf04be commit 9c2cabb

5 files changed

Lines changed: 187 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ concurrency:
1515

1616
jobs:
1717
test:
18-
name: Go, JavaScript, Puppeteer, and build checks
18+
name: Go, Rust, JavaScript, Puppeteer, and build checks
1919
runs-on: ubuntu-24.04
2020
timeout-minutes: 30
2121

@@ -28,22 +28,34 @@ jobs:
2828
with:
2929
go-version-file: go.mod
3030
cache: true
31+
- name: Set up Rust
32+
uses: dtolnay/rust-toolchain@stable
33+
with:
34+
targets: wasm32-unknown-unknown
35+
3136

3237
- name: Set up Node.js
3338
uses: actions/setup-node@v4
3439
with:
3540
node-version: lts/*
3641
cache: npm
37-
3842
- name: Print toolchain versions
3943
run: |
4044
go version
45+
rustc --version
46+
cargo --version
4147
node --version
4248
npm --version
4349
50+
- name: Install wasm-bindgen CLI
51+
run: cargo install wasm-bindgen-cli --version 0.2.122 --locked
52+
4453
- name: Install Node dependencies
4554
run: npm ci
4655

56+
- name: Run Rust rewriter tests
57+
run: cargo test --manifest-path rewriter-rs/Cargo.toml
58+
4759
- name: Run Go tests
4860
run: go test ./...
4961

rewriter-rs/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ edition = "2021"
55
publish = false
66

77
[lib]
8-
crate-type = ["cdylib"]
8+
crate-type = ["cdylib", "rlib"]
99

1010
[dependencies]
1111
oxc_allocator = "0.60"

rewriter-rs/src/lib.rs

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ struct Replacement {
5959
#[derive(Clone, Copy, PartialEq, Eq)]
6060
enum ScopeMode {
6161
FunctionRoot,
62-
Function,
6362
Block,
6463
}
6564

@@ -216,12 +215,41 @@ impl<'a> Rewriter<'a> {
216215
Statement::ImportDeclaration(decl) => self.add_replacement(decl.source.span, format!("{:?}", self.module_specifier(decl.source.value.as_str())), 95),
217216
Statement::ExportNamedDeclaration(decl) => {
218217
if let Some(source) = &decl.source { self.add_replacement(source.span, format!("{:?}", self.module_specifier(source.value.as_str())), 95); }
218+
if let Some(inner) = &decl.declaration { self.walk_declaration(inner); }
219219
}
220220
Statement::ExportAllDeclaration(decl) => self.add_replacement(decl.source.span, format!("{:?}", self.module_specifier(decl.source.value.as_str())), 95),
221+
Statement::ExportDefaultDeclaration(decl) => self.walk_export_default(decl),
221222
_ => {}
222223
}
223224
}
224-
225+
fn walk_declaration(&mut self, decl: &Declaration<'a>) {
226+
match decl {
227+
Declaration::VariableDeclaration(decl) => self.walk_variable_declaration(decl),
228+
Declaration::FunctionDeclaration(func) => self.walk_function(func),
229+
Declaration::ClassDeclaration(class) => {
230+
if let Some(id) = &class.id { self.declare(id.name.as_str()); }
231+
for elem in &class.body.body {
232+
match elem {
233+
ClassElement::PropertyDefinition(prop) => if let Some(value) = &prop.value { self.walk_expression(value); },
234+
ClassElement::AccessorProperty(prop) => if let Some(value) = &prop.value { self.walk_expression(value); },
235+
_ => {}
236+
}
237+
}
238+
}
239+
_ => {}
240+
}
241+
}
242+
fn walk_export_default(&mut self, decl: &ExportDefaultDeclaration<'a>) {
243+
match &decl.declaration {
244+
ExportDefaultDeclarationKind::FunctionDeclaration(func) => self.walk_function(func),
245+
ExportDefaultDeclarationKind::ClassDeclaration(class) => {
246+
if let Some(id) = &class.id { self.declare(id.name.as_str()); }
247+
}
248+
other => {
249+
if let Some(expr) = other.as_expression() { self.walk_expression(expr); }
250+
}
251+
}
252+
}
225253
fn walk_function(&mut self, func: &Function<'a>) {
226254
if let Some(id) = &func.id { self.declare(id.name.as_str()); }
227255
let mut scope = HashSet::new();
@@ -614,34 +642,34 @@ impl<'a> Rewriter<'a> {
614642
Statement::FunctionDeclaration(func) => { if let Some(id) = &func.id { names.insert(id.name.to_string()); } }
615643
Statement::ClassDeclaration(class) => { if let Some(id) = &class.id { names.insert(id.name.to_string()); } }
616644
Statement::VariableDeclaration(decl) => {
617-
if mode == ScopeMode::FunctionRoot {
618-
if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } }
619-
} else if decl.kind != VariableDeclarationKind::Var {
645+
if mode == ScopeMode::Block {
646+
if decl.kind != VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } }
647+
} else if decl.kind == VariableDeclarationKind::Var {
620648
for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); }
621649
}
622650
}
623-
Statement::BlockStatement(block) if mode == ScopeMode::FunctionRoot => for stmt in &block.body { self.collect_statement_bindings(stmt, ScopeMode::Function, names); },
624-
Statement::IfStatement(stmt) if mode == ScopeMode::FunctionRoot => { self.collect_statement_bindings(&stmt.consequent, ScopeMode::Function, names); if let Some(alt) = &stmt.alternate { self.collect_statement_bindings(alt, ScopeMode::Function, names); } }
625-
Statement::ForStatement(stmt) if mode == ScopeMode::FunctionRoot => {
651+
Statement::BlockStatement(block) if mode != ScopeMode::Block => for stmt in &block.body { self.collect_statement_bindings(stmt, mode, names); },
652+
Statement::IfStatement(stmt) if mode != ScopeMode::Block => { self.collect_statement_bindings(&stmt.consequent, mode, names); if let Some(alt) = &stmt.alternate { self.collect_statement_bindings(alt, mode, names); } }
653+
Statement::ForStatement(stmt) if mode != ScopeMode::Block => {
626654
if let Some(ForStatementInit::VariableDeclaration(decl)) = &stmt.init { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } }
627-
self.collect_statement_bindings(&stmt.body, ScopeMode::Function, names);
628-
}
629-
Statement::ForInStatement(stmt) if mode == ScopeMode::FunctionRoot => { if let ForStatementLeft::VariableDeclaration(decl) = &stmt.left { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } } self.collect_statement_bindings(&stmt.body, ScopeMode::Function, names); }
630-
Statement::ForOfStatement(stmt) if mode == ScopeMode::FunctionRoot => { if let ForStatementLeft::VariableDeclaration(decl) = &stmt.left { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } } self.collect_statement_bindings(&stmt.body, ScopeMode::Function, names); }
631-
Statement::WhileStatement(stmt) if mode == ScopeMode::FunctionRoot => self.collect_statement_bindings(&stmt.body, ScopeMode::Function, names),
632-
Statement::DoWhileStatement(stmt) if mode == ScopeMode::FunctionRoot => self.collect_statement_bindings(&stmt.body, ScopeMode::Function, names),
633-
Statement::LabeledStatement(stmt) if mode == ScopeMode::FunctionRoot => self.collect_statement_bindings(&stmt.body, ScopeMode::Function, names),
634-
Statement::SwitchStatement(stmt) if mode == ScopeMode::FunctionRoot => for case in &stmt.cases { for child in &case.consequent { self.collect_statement_bindings(child, ScopeMode::Function, names); } },
635-
Statement::TryStatement(stmt) if mode == ScopeMode::FunctionRoot => {
636-
self.collect_block_bindings(&stmt.block, names);
637-
if let Some(handler) = &stmt.handler { self.collect_block_bindings(&handler.body, names); }
638-
if let Some(finalizer) = &stmt.finalizer { self.collect_block_bindings(finalizer, names); }
655+
self.collect_statement_bindings(&stmt.body, mode, names);
656+
}
657+
Statement::ForInStatement(stmt) if mode != ScopeMode::Block => { if let ForStatementLeft::VariableDeclaration(decl) = &stmt.left { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } } self.collect_statement_bindings(&stmt.body, mode, names); }
658+
Statement::ForOfStatement(stmt) if mode != ScopeMode::Block => { if let ForStatementLeft::VariableDeclaration(decl) = &stmt.left { if decl.kind == VariableDeclarationKind::Var { for d in &decl.declarations { self.collect_binding_pattern(&d.id, names); } } } self.collect_statement_bindings(&stmt.body, mode, names); }
659+
Statement::WhileStatement(stmt) if mode != ScopeMode::Block => self.collect_statement_bindings(&stmt.body, mode, names),
660+
Statement::DoWhileStatement(stmt) if mode != ScopeMode::Block => self.collect_statement_bindings(&stmt.body, mode, names),
661+
Statement::LabeledStatement(stmt) if mode != ScopeMode::Block => self.collect_statement_bindings(&stmt.body, mode, names),
662+
Statement::SwitchStatement(stmt) if mode != ScopeMode::Block => for case in &stmt.cases { for child in &case.consequent { self.collect_statement_bindings(child, mode, names); } },
663+
Statement::TryStatement(stmt) if mode != ScopeMode::Block => {
664+
self.collect_block_bindings(&stmt.block, names, mode);
665+
if let Some(handler) = &stmt.handler { self.collect_block_bindings(&handler.body, names, mode); }
666+
if let Some(finalizer) = &stmt.finalizer { self.collect_block_bindings(finalizer, names, mode); }
639667
}
640668
_ => {}
641669
}
642670
}
643-
fn collect_block_bindings(&self, block: &BlockStatement<'a>, names: &mut HashSet<String>) {
644-
for stmt in &block.body { self.collect_statement_bindings(stmt, ScopeMode::Function, names); }
671+
fn collect_block_bindings(&self, block: &BlockStatement<'a>, names: &mut HashSet<String>, mode: ScopeMode) {
672+
for stmt in &block.body { self.collect_statement_bindings(stmt, mode, names); }
645673
}
646674

647675
fn collect_binding_pattern(&self, pattern: &BindingPattern<'a>, names: &mut HashSet<String>) {
@@ -801,6 +829,9 @@ fn join_url(base: &str, raw: &str) -> String {
801829
}
802830
let prefix = match base.rfind('/') { Some(i) => &base[..=i], None => base };
803831
let mut parts: Vec<&str> = prefix.split('/').collect();
832+
if parts.last() == Some(&"") {
833+
parts.pop();
834+
}
804835
for part in raw.split('/') {
805836
match part {
806837
"." => {}
@@ -832,3 +863,58 @@ fn hex(v: u8) -> char {
832863
_ => (b'A' + (v - 10)) as char,
833864
}
834865
}
866+
867+
#[cfg(test)]
868+
mod tests {
869+
use super::*;
870+
871+
fn rewrite_ok(source: &str, kind: &str, target_url: &str) -> String {
872+
let out = rewrite_script(source, kind, target_url, "/zp/");
873+
assert!(out.ok, "rewrite failed: {}", out.error);
874+
out.code
875+
}
876+
877+
#[test]
878+
fn rewrites_virtualized_globals_and_shadowing() {
879+
let code = rewrite_ok(
880+
"function f(x) { if (x) { var location = { href: 'local' }; } return location.href; }\nwindow.location.hash += '-tail';\ndocument.defaultView.location.href;",
881+
"classic",
882+
"https://example.com/app.js",
883+
);
884+
assert!(code.contains("return location.href;"));
885+
assert!(code.contains("__zp_assign(__zp_get(__zp_get(globalThis,\"window\"),\"location\"),\"hash\""));
886+
assert!(code.contains("__zp_get(__zp_get(globalThis,\"document\"),\"defaultView\")"));
887+
assert!(!code.contains("return __zp_get(globalThis,\"location\")"));
888+
}
889+
890+
#[test]
891+
fn rewrites_module_urls_and_dynamic_imports() {
892+
let code = rewrite_ok(
893+
"import './dep.js'; export async function load(name) { await import('./chunks/' + name + '.js'); return new URL('/worker-fixture.js', import.meta.url).href; }",
894+
"module",
895+
"https://example.com/assets/main.js",
896+
);
897+
assert!(code.contains("import \"/zp/api/script?kind=module&u=https%3A%2F%2Fexample.com%2Fassets%2Fdep.js\";"));
898+
assert!(code.contains("__zp_module_url('./chunks/' + name + '.js',\"https://example.com/assets/main.js\")"));
899+
assert!(code.contains("\"https://example.com/assets/main.js\""));
900+
}
901+
902+
#[test]
903+
fn rewrites_calls_and_constructors() {
904+
let code = rewrite_ok(
905+
"window.location = '/next'; const ws = new WebSocket('/ws', ['chat']); Object.getOwnPropertyDescriptor(window, 'location');",
906+
"classic",
907+
"https://example.com/app.js",
908+
);
909+
assert!(code.contains("__zp_set(__zp_get(globalThis,\"window\"),\"location\",'/next')"));
910+
assert!(code.contains("__zp_construct(__zp_get(globalThis,\"WebSocket\"),['/ws',['chat']])"));
911+
assert!(code.contains("__zp_call(Object,\"getOwnPropertyDescriptor\",[__zp_get(globalThis,\"window\"),'location'])"));
912+
}
913+
914+
#[test]
915+
fn parse_failures_return_error() {
916+
let out = rewrite_script("if (", "classic", "https://example.com/app.js", "/zp/");
917+
assert!(!out.ok);
918+
assert_eq!(out.error, "PARSE_FAILED");
919+
}
920+
}

test/js/rewriter.test.js

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,34 @@ const path = require('node:path');
77
const vm = require('node:vm');
88
const oxc = require('@oxc-parser/wasm');
99

10+
let builtRustAssetPromise = null;
11+
1012
async function loadRewriter() {
1113
if (!globalThis.ZPRewriter) vm.runInThisContext(fs.readFileSync('web/js-rewriter.js', 'utf8'), { filename: 'web/js-rewriter.js' });
1214
await globalThis.ZPRewriter.init({ parser: oxc });
1315
return globalThis.ZPRewriter;
1416
}
17+
18+
function loadBuiltRustContext() {
19+
if (!builtRustAssetPromise) {
20+
builtRustAssetPromise = (async () => {
21+
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zp-rust-unit-'));
22+
const result = childProcess.spawnSync('node', ['scripts/build.mjs', '--web-only', '--out', outDir], {
23+
cwd: path.resolve(__dirname, '../..'),
24+
encoding: 'utf8',
25+
});
26+
if (result.status !== 0) {
27+
throw new Error(`node scripts/build.mjs --web-only --out ${outDir} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
28+
}
29+
const ctx = { console, atob, btoa, TextEncoder, TextDecoder, Uint8Array, WebAssembly, FinalizationRegistry, URL, globalThis: null };
30+
ctx.globalThis = ctx;
31+
vm.createContext(ctx);
32+
vm.runInContext(fs.readFileSync(path.join(outDir, 'web', 'rust-rewriter.js'), 'utf8'), ctx, { filename: 'rust-rewriter.js' });
33+
return ctx;
34+
})();
35+
}
36+
return builtRustAssetPromise;
37+
}
1538
test('rewriter prefers Rust engine when available', async () => {
1639
vm.runInThisContext(fs.readFileSync('web/js-rewriter.js', 'utf8'), { filename: 'web/js-rewriter.js' });
1740
globalThis.ZPRustRewriter = { rewriteScript(source, kind, targetUrl, controlPrefix) { return { ok: true, code: `/*rust:${kind}:${targetUrl}:${controlPrefix}*/`, error: '' }; } };
@@ -20,19 +43,46 @@ test('rewriter prefers Rust engine when available', async () => {
2043
assert.equal(out.ok, true);
2144
assert.equal(out.code, '/*rust:classic:https://example.com/app.js:/zp/*/');
2245
});
23-
test('built Rust rewriter asset rewrites live code paths', () => {
24-
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zp-rust-unit-'));
25-
childProcess.execFileSync('node', ['scripts/build.mjs', '--web-only', '--out', outDir], { cwd: path.resolve(__dirname, '../..'), stdio: 'ignore' });
26-
const ctx = { console, atob, btoa, TextEncoder, TextDecoder, Uint8Array, WebAssembly, FinalizationRegistry, URL, globalThis: null };
27-
ctx.globalThis = ctx;
28-
vm.createContext(ctx);
29-
vm.runInContext(fs.readFileSync(path.join(outDir, 'web', 'rust-rewriter.js'), 'utf8'), ctx, { filename: 'rust-rewriter.js' });
30-
const assign = ctx.ZPRustRewriter.rewriteScript('window.location.hash += \"-tail\";', 'classic', 'https://example.com/app.js', '/zp/');
31-
const meta = ctx.ZPRustRewriter.rewriteScript('new URL(\"/worker-fixture.js\", import.meta.url).href;', 'module', 'https://example.com/module-worker.js', '/zp/');
46+
test('built Rust rewriter asset rewrites live code paths', async () => {
47+
const ctx = await loadBuiltRustContext();
48+
const assign = ctx.ZPRustRewriter.rewriteScript('window.location.hash += "-tail"; document.defaultView.location.href;', 'classic', 'https://example.com/app.js', '/zp/');
49+
const meta = ctx.ZPRustRewriter.rewriteScript('new URL("/worker-fixture.js", import.meta.url).href;', 'module', 'https://example.com/module-worker.js', '/zp/');
50+
const dynamic = ctx.ZPRustRewriter.rewriteScript('export async function load(name) { return import("./chunks/" + name + ".js"); }', 'module', 'https://example.com/assets/main.js', '/zp/');
3251
assert.equal(assign.ok, true);
33-
assert.ok(assign.code.includes('__zp_assign(__zp_get(__zp_get(globalThis,\"window\"),\"location\"),\"hash\"'));
52+
assert.ok(assign.code.includes('__zp_assign(__zp_get(__zp_get(globalThis,"window"),"location"),"hash"'));
53+
assert.ok(assign.code.includes('__zp_get(__zp_get(globalThis,"document"),"defaultView")'));
3454
assert.equal(meta.ok, true);
35-
assert.ok(meta.code.includes('\"https://example.com/module-worker.js\"'));
55+
assert.ok(meta.code.includes('https://example.com/module-worker.js'));
56+
assert.equal(dynamic.ok, true);
57+
assert.ok(dynamic.code.includes('__zp_module_url('));
58+
assert.ok(dynamic.code.includes('https://example.com/assets/main.js'));
59+
});
60+
61+
test('built Rust rewriter asset reports parse failures', async () => {
62+
const ctx = await loadBuiltRustContext();
63+
const out = ctx.ZPRustRewriter.rewriteScript('if (', 'classic', 'https://example.com/app.js', '/zp/');
64+
assert.equal(out.ok, false);
65+
assert.equal(out.error, 'PARSE_FAILED');
66+
});
67+
68+
test('JS wrapper falls back to JS engine for import-map and event-handler paths', async () => {
69+
const ctx = await loadBuiltRustContext();
70+
globalThis.ZPRustRewriter = ctx.ZPRustRewriter;
71+
const rewriter = await loadRewriter();
72+
const handler = rewriter.rewriteScript('return location.href', { kind: 'event-handler', targetUrl: 'https://example.com/' });
73+
assert.equal(handler.ok, true);
74+
assert.match(handler.code, /__zp_runEvent/);
75+
const fnBody = rewriter.rewriteScript('return location.href;', { kind: 'function', targetUrl: 'https://example.com/' });
76+
assert.equal(fnBody.ok, true);
77+
assert.ok(fnBody.code.includes('__zp_get(globalThis,"location")'));
78+
const importMap = rewriter.rewriteScript('import React from \"react\";', {
79+
kind: 'module',
80+
targetUrl: 'https://example.com/app.js',
81+
importMap: { imports: { react: 'https://cdn.example/react.js' } },
82+
});
83+
delete globalThis.ZPRustRewriter;
84+
assert.equal(importMap.ok, true);
85+
assert.ok(importMap.code.includes('https%3A%2F%2Fcdn.example%2Freact.js'));
3686
});
3787

3888
test('OXC rewriter virtualizes dangerous globals without rewriting local bindings', async () => {

web/js-rewriter.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@
5757
source = String(source || '');
5858
const kind = normalizeKind(options.scriptKind || options.kind);
5959
const rust = globalThis.ZPRustRewriter;
60-
if (rust && typeof rust.rewriteScript === 'function') {
60+
const rustEligible = kind !== 'event-handler' && kind !== 'function' && !options.importMap;
61+
if (rustEligible && rust && typeof rust.rewriteScript === 'function') {
6162
try {
6263
const out = rust.rewriteScript(source, kind, options.url || options.targetUrl || '', options.controlPrefix || globalThis.ZP && globalThis.ZP.CONTROL_PREFIX || '/zp/');
6364
if (out && out.ok && typeof out.code === 'string') return ok(out.code, []);
@@ -69,7 +70,7 @@
6970
if (kind === 'function') return rewriteFunctionBody(source, options);
7071
const parsed = parse(source, kind === 'module' ? 'module' : 'script', options.url || options.targetUrl || 'target.js');
7172
if (!parsed.ok) return parsed;
72-
const rewritten = rewriteProgram(source, parsed.program, { module: kind === 'module', targetUrl: options.url || options.targetUrl || '' });
73+
const rewritten = rewriteProgram(source, parsed.program, { module: kind === 'module', targetUrl: options.url || options.targetUrl || '', importMap: options.importMap, controlPrefix: options.controlPrefix });
7374
return ok(rewritten.code, parsed.diagnostics.concat(rewritten.diagnostics));
7475
}
7576

0 commit comments

Comments
 (0)