Skip to content

Commit 5fed2cc

Browse files
committed
Support source-phase imports in the acorn/terser optimizer pipeline
When -sSOURCE_PHASE_IMPORTS=1, emcc emits ```js import source wasmModule from './foo.wasm'; ``` in its JS runtime. At -O2/-O3/-Os/-Oz the emitted JS is run through tools/acorn-optimizer.mjs which currently fails with a SyntaxError at parse time because acorn 8.x does not yet understand the source-phase imports proposal (https://github.com/tc39/proposal-source-phase-imports). Wire in the acorn-import-phases plugin so acorn can parse the syntax, and pull in the matching terser support (downstream of emscripten-core/terser#1) so the round-trip through from_mozilla_ast / to_mozilla_ast preserves the `phase` keyword on the way back out. Without the terser side, terser would silently drop the keyword and the host would return the module's exports namespace instead of a WebAssembly.Module, changing runtime semantics. * package.json: add acorn-import-phases dependency. * tools/acorn-optimizer.mjs: extend acorn with the plugin and use the extended parser at the parse site. * third_party/terser/terser.js: rebuilt from the emscripten-core/terser branch with source-phase imports support (PR emscripten-core/terser#1, the v5.18.2 downstream port of upstream terser PR terser/terser#1682). * test/js_optimizer/sourcePhaseImports{,-output}.js: new fixture that feeds two `import source` declarations through the JSDCE pass and checks the keyword survives. * test/test_other.py: register the fixture in test_js_optimizer, and parametrize test_esm_source_phase_imports across no-args and -O2 to exercise the optimizer pipeline.
1 parent 85ee810 commit 5fed2cc

7 files changed

Lines changed: 166 additions & 27 deletions

File tree

package-lock.json

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
},
1919
"dependencies": {
2020
"acorn": "^8.15.0",
21+
"acorn-import-phases": "^1.0.4",
2122
"google-closure-compiler": "20260429.0.0",
2223
"html-minifier-terser": "7.2.0"
2324
},
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import source wasmModule from "./foo.wasm";
2+
3+
import source otherModule from "./bar.wasm";
4+
5+
function use() {
6+
return [ wasmModule, otherModule ];
7+
}
8+
9+
use();
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Source-phase imports (https://github.com/tc39/proposal-source-phase-imports).
2+
// The acorn optimizer must parse these via the acorn-import-phases plugin and
3+
// preserve the `source` phase keyword through the terser from_mozilla_ast ->
4+
// print round-trip used at -O2+.
5+
import source wasmModule from './foo.wasm';
6+
import source otherModule from './bar.wasm';
7+
8+
function use() {
9+
return [wasmModule, otherModule];
10+
}
11+
use();

test/test_other.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,11 +421,15 @@ def test_esm(self, args):
421421
self.assertContained('Hello, world!', self.run_js('hello_world.mjs'))
422422

423423
@requires_node_25
424-
def test_esm_source_phase_imports(self):
424+
@parameterized({
425+
'': ([],),
426+
'O2': (['-O2'],),
427+
})
428+
def test_esm_source_phase_imports(self, args):
425429
self.node_args += ['--experimental-wasm-modules', '--no-warnings']
426430
self.run_process([EMCC, '-o', 'hello_world.mjs', '-sSOURCE_PHASE_IMPORTS',
427431
'--extern-post-js', test_file('modularize_post_js.js'),
428-
test_file('hello_world.c')])
432+
test_file('hello_world.c')] + args)
429433
self.assertContained('import source wasmModule from', read_file('hello_world.mjs'))
430434
self.assertContained('Hello, world!', self.run_js('hello_world.mjs'))
431435

@@ -3012,6 +3016,7 @@ def test_extern_prepost(self):
30123016
'minifyGlobals': (['minifyGlobals'],),
30133017
'minifyLocals': (['minifyLocals'],),
30143018
'JSDCE': (['JSDCE', '--export-es6'],),
3019+
'sourcePhaseImports': (['JSDCE', '--export-es6'],),
30153020
'JSDCE-hasOwnProperty': (['JSDCE'],),
30163021
'JSDCE-defaultArg': (['JSDCE'],),
30173022
'JSDCE-fors': (['JSDCE'],),

third_party/terser/terser.js

Lines changed: 124 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2404,7 +2404,7 @@ function parse($TEXT, options) {
24042404
return new_(allow_calls);
24052405
}
24062406
if (is("name", "import") && is_token(peek(), "punc", ".")) {
2407-
return import_meta(allow_calls);
2407+
return parse_import_expr(allow_calls);
24082408
}
24092409
var start = S.token;
24102410
var peeked;
@@ -2864,6 +2864,20 @@ function parse($TEXT, options) {
28642864
function import_statement() {
28652865
var start = prev();
28662866

2867+
// Source-phase imports proposal: `import source NAME from "..."` and
2868+
// `import defer * as NS from "..."`. The phase keyword is a contextual
2869+
// identifier; disambiguate from a legitimate default-import named
2870+
// `source`/`defer` by peeking past it: if the next token is `from` or
2871+
// `,`, the identifier IS the default-imported binding, not a phase.
2872+
var phase = null;
2873+
if (is("name", "source") || is("name", "defer")) {
2874+
var peeked = peek();
2875+
if (!is_token(peeked, "name", "from") && !is_token(peeked, "punc", ",")) {
2876+
phase = S.token.value;
2877+
next();
2878+
}
2879+
}
2880+
28672881
var imported_name;
28682882
var imported_names;
28692883
if (is("name")) {
@@ -2898,14 +2912,38 @@ function parse($TEXT, options) {
28982912
end: mod_str,
28992913
}),
29002914
assert_clause,
2915+
phase,
29012916
end: S.token,
29022917
});
29032918
}
29042919

2905-
function import_meta(allow_calls) {
2920+
// Parses an `import.<property>` expression, after `expr_atom` has
2921+
// already confirmed the `import .` lookahead. Handles:
2922+
//
2923+
// import.meta — meta-property
2924+
// import.source(specifier [, options]) — source-phase imports proposal
2925+
// import.defer(specifier [, options]) — "
2926+
//
2927+
// The two source-phase forms (https://github.com/tc39/proposal-source-phase-imports)
2928+
// share the `import.NAME` shape with `import.meta` but are an entirely
2929+
// different proposal: they must be followed by a call and their result
2930+
// is a dynamic import, not a meta-property reference.
2931+
function parse_import_expr(allow_calls) {
29062932
var start = S.token;
29072933
expect_token("name", "import");
29082934
expect_token("punc", ".");
2935+
if (is("name", "source") || is("name", "defer")) {
2936+
var phase = S.token.value;
2937+
next();
2938+
if (!is("punc", "(")) {
2939+
croak("'import." + phase + "' can only be used in a dynamic import");
2940+
}
2941+
return subscripts(new AST_DynamicImport({
2942+
start: start,
2943+
phase: phase,
2944+
end: prev()
2945+
}), allow_calls);
2946+
}
29092947
expect_token("name", "meta");
29102948
return subscripts(new AST_ImportMeta({
29112949
start: start,
@@ -5071,13 +5109,14 @@ var AST_NameMapping = DEFNODE("NameMapping", "foreign_name name", function AST_N
50715109

50725110
var AST_Import = DEFNODE(
50735111
"Import",
5074-
"imported_name imported_names module_name assert_clause",
5112+
"imported_name imported_names module_name assert_clause phase",
50755113
function AST_Import(props) {
50765114
if (props) {
50775115
this.imported_name = props.imported_name;
50785116
this.imported_names = props.imported_names;
50795117
this.module_name = props.module_name;
50805118
this.assert_clause = props.assert_clause;
5119+
this.phase = props.phase;
50815120
this.start = props.start;
50825121
this.end = props.end;
50835122
}
@@ -5090,7 +5129,8 @@ var AST_Import = DEFNODE(
50905129
imported_name: "[AST_SymbolImport] The name of the variable holding the module's default export.",
50915130
imported_names: "[AST_NameMapping*] The names of non-default imported variables",
50925131
module_name: "[AST_String] String literal describing where this module came from",
5093-
assert_clause: "[AST_Object?] The import assertion"
5132+
assert_clause: "[AST_Object?] The import assertion",
5133+
phase: "[string?] Phase keyword for source-phase imports ('source' or 'defer'), or null."
50945134
},
50955135
_walk: function(visitor) {
50965136
return visitor._visit(this, function() {
@@ -5127,6 +5167,26 @@ var AST_ImportMeta = DEFNODE("ImportMeta", null, function AST_ImportMeta(props)
51275167
$documentation: "A reference to import.meta",
51285168
});
51295169

5170+
var AST_DynamicImport = DEFNODE(
5171+
"DynamicImport",
5172+
"phase",
5173+
function AST_DynamicImport(props) {
5174+
if (props) {
5175+
this.phase = props.phase;
5176+
this.start = props.start;
5177+
this.end = props.end;
5178+
}
5179+
5180+
this.flags = 0;
5181+
},
5182+
{
5183+
$documentation: "The callee of a dynamic `import(...)` / `import.source(...)` / `import.defer(...)` expression. Always appears as the `expression` of an AST_Call.",
5184+
$propdoc: {
5185+
phase: "[string?] Phase keyword ('source' or 'defer'), or null for a plain dynamic import."
5186+
}
5187+
}
5188+
);
5189+
51305190
var AST_Export = DEFNODE(
51315191
"Export",
51325192
"exported_definition exported_value is_default exported_names module_name assert_clause",
@@ -6706,7 +6766,7 @@ const _NOINLINE = 0b00000100;
67066766
const _KEY = 0b00001000;
67076767
const _MANGLEPROP = 0b00010000;
67086768

6709-
// XXX Emscripten: export TreeWalker for walking through AST in acorn-optimizer.mjs.
6769+
// XXX Emscripten: export TreeWalker for walking through AST in acorn-optimizer.js.
67106770
exports.TreeWalker = TreeWalker;
67116771

67126772
/***********************************************************************
@@ -7449,7 +7509,8 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) {
74497509
imported_name: imported_name,
74507510
imported_names : imported_names,
74517511
module_name : from_moz(M.source),
7452-
assert_clause: assert_clause_from_moz(M.assertions)
7512+
assert_clause: assert_clause_from_moz(M.assertions),
7513+
phase: M.phase || null
74537514
});
74547515
},
74557516

@@ -7475,6 +7536,39 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) {
74757536
});
74767537
},
74777538

7539+
ImportExpression: function(M) {
7540+
const args = [from_moz(M.source)];
7541+
if (M.options) {
7542+
args.push(from_moz(M.options));
7543+
}
7544+
// Source-phase imports proposal (https://github.com/tc39/proposal-source-phase-imports):
7545+
// `import.source(x)` and `import.defer(x)` arrive as ImportExpression
7546+
// nodes with a `phase` field (per acorn-import-phases). Plain
7547+
// dynamic imports continue to use the synthetic `import` SymbolRef
7548+
// callee for back-compat with downstream code that already groks
7549+
// that pattern.
7550+
var expression;
7551+
if (M.phase) {
7552+
expression = new AST_DynamicImport({
7553+
start: my_start_token(M),
7554+
end: my_end_token(M),
7555+
phase: M.phase
7556+
});
7557+
} else {
7558+
expression = from_moz({
7559+
type: "Identifier",
7560+
name: "import"
7561+
});
7562+
}
7563+
return new AST_Call({
7564+
start: my_start_token(M),
7565+
end: my_end_token(M),
7566+
expression: expression,
7567+
optional: false,
7568+
args
7569+
});
7570+
},
7571+
74787572
ExportAllDeclaration: function(M) {
74797573
var foreign_name = M.exported == null ?
74807574
new AST_SymbolExportForeign({ name: "*" }) :
@@ -7887,19 +7981,6 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) {
78877981
});
78887982
},
78897983

7890-
ImportExpression: function(M) {
7891-
let import_token = my_start_token(M);
7892-
return new AST_Call({
7893-
start : import_token,
7894-
end : my_end_token(M),
7895-
expression : new AST_SymbolRef({
7896-
start : import_token,
7897-
end : import_token,
7898-
name : "import"
7899-
}),
7900-
args : [from_moz(M.source)]
7901-
});
7902-
}
79037984
};
79047985

79057986
MOZ_TO_ME.UpdateExpression =
@@ -8112,6 +8193,16 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) {
81128193
};
81138194
});
81148195
def_to_moz(AST_Call, function To_Moz_CallExpression(M) {
8196+
if (M.expression instanceof AST_DynamicImport) {
8197+
const [source, options] = M.args.map(to_moz);
8198+
const out = {
8199+
type: "ImportExpression",
8200+
source,
8201+
options: options || null
8202+
};
8203+
if (M.expression.phase) out.phase = M.expression.phase;
8204+
return out;
8205+
}
81158206
return {
81168207
type: "CallExpression",
81178208
callee: to_moz(M.expression),
@@ -8347,12 +8438,14 @@ def_transform(AST_PrefixedTemplateString, function(self, tw) {
83478438
});
83488439
}
83498440
}
8350-
return {
8441+
var moz = {
83518442
type: "ImportDeclaration",
83528443
specifiers: specifiers,
83538444
source: to_moz(M.module_name),
83548445
assertions: assert_clause_to_moz(M.assert_clause)
83558446
};
8447+
if (M.phase) moz.phase = M.phase;
8448+
return moz;
83568449
});
83578450

83588451
def_to_moz(AST_ImportMeta, function To_Moz_MetaProperty() {
@@ -10398,6 +10491,10 @@ function OutputStream(options) {
1039810491
DEFPRINT(AST_Import, function(self, output) {
1039910492
output.print("import");
1040010493
output.space();
10494+
if (self.phase) {
10495+
output.print(self.phase);
10496+
output.space();
10497+
}
1040110498
if (self.imported_name) {
1040210499
self.imported_name.print(output);
1040310500
}
@@ -10438,6 +10535,13 @@ function OutputStream(options) {
1043810535
DEFPRINT(AST_ImportMeta, function(self, output) {
1043910536
output.print("import.meta");
1044010537
});
10538+
DEFPRINT(AST_DynamicImport, function(self, output) {
10539+
if (self.phase) {
10540+
output.print("import." + self.phase);
10541+
} else {
10542+
output.print("import");
10543+
}
10544+
});
1044110545

1044210546
DEFPRINT(AST_NameMapping, function(self, output) {
1044310547
var is_import = output.parent() instanceof AST_Import;

tools/acorn-optimizer.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
#!/usr/bin/env node
22

33
import * as acorn from 'acorn';
4+
import importPhases from 'acorn-import-phases';
45
import * as terser from '../third_party/terser/terser.js';
56
import * as fs from 'node:fs';
67
import assert from 'node:assert';
78
import {parseArgs} from 'node:util';
89

10+
// Extend acorn to understand source-phase import syntax
11+
// (`import source foo from './bar.wasm'`) emitted under -sSOURCE_PHASE_IMPORTS.
12+
// The plugin annotates ImportDeclaration nodes with a `phase` field; the
13+
// bundled terser carries this through from_mozilla_ast / to_mozilla_ast and
14+
// emits it back on output.
15+
const parser = acorn.Parser.extend(importPhases());
16+
917
// Utilities
1018

1119
function read(x) {
@@ -1750,7 +1758,7 @@ const registry = {
17501758

17511759
let ast;
17521760
try {
1753-
ast = acorn.parse(input, params);
1761+
ast = parser.parse(input, params);
17541762
for (let pass of passes) {
17551763
const resolvedPass = registry[pass];
17561764
assert(resolvedPass, `unknown optimizer pass: ${pass}`);

0 commit comments

Comments
 (0)