Skip to content

Commit ce4c405

Browse files
authored
Add support for -sEXPORT_ES6/*.mjs on Node.js (#17915)
As described in #11792, `require()` and `__dirname` doesn't exist in an ES6 module. Emscripten uses this to import built-in core Node.js modules. For example, the `node:fs` module is used for synchronously importing the `*.wasm` binary, when not linking with `-sSINGLE_FILE`. To work around this, ES6 modules on Node.js may import `createRequire()` from `node:module` to construct the `require()` function, allowing modules to be imported in a CommonJS manner. Emscripten targets a variety of environments, which can be categorized as: 1. Multi-environment builds, which is the default when `-sENVIRONMENT=*` is not specified at link time. 2. Single-environment, e.g. only web or Node.js as target. For use case (1), this commit ensures that an `async` function is emitted, allowing Node.js modules to be dynamically imported. This is necessary given that static import declarations cannot be put in conditionals. Inside the module, for Node.js only, it's using the above-mentioned `createRequire()`-construction. For use case (2), when only Node.js is targeted, a static import declaration utilize the same `createRequire()`-construction. For both use cases, `-sUSE_ES6_IMPORT_META=0` is not allowed, when Node.js is among the targets, since it is not possible to mimic `__dirname` when `import.meta` support is not available. This commit does not change anything for use case (2), when only the web is targeted (`-sENVIRONMENT=web`). Resolves: #11792.
1 parent afbc149 commit ce4c405

File tree

10 files changed

+145
-54
lines changed

10 files changed

+145
-54
lines changed

ChangeLog.md

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ See docs/process.md for more on how version tagging works.
4343
overflow will trap rather corrupting global data first). This should not
4444
be a user-visible change (unless your program does something very odd such
4545
depending on the specific location of stack data in memory). (#18154)
46+
- Add support for `-sEXPORT_ES6`/`*.mjs` on Node.js. (#17915)
4647

4748
3.1.25 - 11/08/22
4849
-----------------

emcc.py

+46-12
Original file line numberDiff line numberDiff line change
@@ -2330,11 +2330,17 @@ def check_memory_setting(setting):
23302330
if 'MAXIMUM_MEMORY' in user_settings and not settings.ALLOW_MEMORY_GROWTH:
23312331
diagnostics.warning('unused-command-line-argument', 'MAXIMUM_MEMORY is only meaningful with ALLOW_MEMORY_GROWTH')
23322332

2333-
if settings.EXPORT_ES6 and not settings.MODULARIZE:
2334-
# EXPORT_ES6 requires output to be a module
2335-
if 'MODULARIZE' in user_settings:
2336-
exit_with_error('EXPORT_ES6 requires MODULARIZE to be set')
2337-
settings.MODULARIZE = 1
2333+
if settings.EXPORT_ES6:
2334+
if not settings.MODULARIZE:
2335+
# EXPORT_ES6 requires output to be a module
2336+
if 'MODULARIZE' in user_settings:
2337+
exit_with_error('EXPORT_ES6 requires MODULARIZE to be set')
2338+
settings.MODULARIZE = 1
2339+
if shared.target_environment_may_be('node') and not settings.USE_ES6_IMPORT_META:
2340+
# EXPORT_ES6 + ENVIRONMENT=*node* requires the use of import.meta.url
2341+
if 'USE_ES6_IMPORT_META' in user_settings:
2342+
exit_with_error('EXPORT_ES6 and ENVIRONMENT=*node* requires USE_ES6_IMPORT_META to be set')
2343+
settings.USE_ES6_IMPORT_META = 1
23382344

23392345
if settings.MODULARIZE and not settings.DECLARE_ASM_MODULE_EXPORTS:
23402346
# When MODULARIZE option is used, currently requires declaring all module exports
@@ -3094,13 +3100,17 @@ def phase_final_emitting(options, state, target, wasm_target, memfile):
30943100
# mode)
30953101
final_js = building.closure_compiler(final_js, pretty=False, advanced=False, extra_closure_args=options.closure_args)
30963102

3097-
# Unmangle previously mangled `import.meta` references in both main code and libraries.
3103+
# Unmangle previously mangled `import.meta` and `await import` references in
3104+
# both main code and libraries.
30983105
# See also: `preprocess` in parseTools.js.
30993106
if settings.EXPORT_ES6 and settings.USE_ES6_IMPORT_META:
31003107
src = read_file(final_js)
31013108
final_js += '.esmeta.js'
3102-
write_file(final_js, src.replace('EMSCRIPTEN$IMPORT$META', 'import.meta'))
3103-
save_intermediate('es6-import-meta')
3109+
write_file(final_js, src
3110+
.replace('EMSCRIPTEN$IMPORT$META', 'import.meta')
3111+
.replace('EMSCRIPTEN$AWAIT$IMPORT', 'await import'))
3112+
shared.get_temp_files().note(final_js)
3113+
save_intermediate('es6-module')
31043114

31053115
# Apply pre and postjs files
31063116
if options.extern_pre_js or options.extern_post_js:
@@ -3684,26 +3694,49 @@ def preprocess_wasm2js_script():
36843694
write_file(final_js, js)
36853695

36863696

3697+
def node_es6_imports():
3698+
if not settings.EXPORT_ES6 or not shared.target_environment_may_be('node'):
3699+
return ''
3700+
3701+
# Multi-environment builds uses `await import` in `shell.js`
3702+
if shared.target_environment_may_be('web'):
3703+
return ''
3704+
3705+
# Use static import declaration if we only target Node.js
3706+
return '''
3707+
import { createRequire } from 'module';
3708+
const require = createRequire(import.meta.url);
3709+
'''
3710+
3711+
36873712
def modularize():
36883713
global final_js
36893714
logger.debug(f'Modularizing, assigning to var {settings.EXPORT_NAME}')
36903715
src = read_file(final_js)
36913716

3717+
# Multi-environment ES6 builds require an async function
3718+
async_emit = ''
3719+
if settings.EXPORT_ES6 and \
3720+
shared.target_environment_may_be('node') and \
3721+
shared.target_environment_may_be('web'):
3722+
async_emit = 'async '
3723+
36923724
return_value = settings.EXPORT_NAME
36933725
if settings.WASM_ASYNC_COMPILATION:
36943726
return_value += '.ready'
36953727
if not settings.EXPORT_READY_PROMISE:
36963728
return_value = '{}'
36973729

36983730
src = '''
3699-
function(%(EXPORT_NAME)s) {
3731+
%(maybe_async)sfunction(%(EXPORT_NAME)s) {
37003732
%(EXPORT_NAME)s = %(EXPORT_NAME)s || {};
37013733
37023734
%(src)s
37033735
37043736
return %(return_value)s
37053737
}
37063738
''' % {
3739+
'maybe_async': async_emit,
37073740
'EXPORT_NAME': settings.EXPORT_NAME,
37083741
'src': src,
37093742
'return_value': return_value
@@ -3714,24 +3747,25 @@ def modularize():
37143747
# document.currentScript, so a simple export declaration is enough.
37153748
src = 'var %s=%s' % (settings.EXPORT_NAME, src)
37163749
else:
3717-
script_url_node = ""
3750+
script_url_node = ''
37183751
# When MODULARIZE this JS may be executed later,
37193752
# after document.currentScript is gone, so we save it.
37203753
# In EXPORT_ES6 + USE_PTHREADS the 'thread' is actually an ES6 module webworker running in strict mode,
37213754
# so doesn't have access to 'document'. In this case use 'import.meta' instead.
37223755
if settings.EXPORT_ES6 and settings.USE_ES6_IMPORT_META:
3723-
script_url = "import.meta.url"
3756+
script_url = 'import.meta.url'
37243757
else:
37253758
script_url = "typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined"
37263759
if shared.target_environment_may_be('node'):
37273760
script_url_node = "if (typeof __filename !== 'undefined') _scriptDir = _scriptDir || __filename;"
3728-
src = '''
3761+
src = '''%(node_imports)s
37293762
var %(EXPORT_NAME)s = (() => {
37303763
var _scriptDir = %(script_url)s;
37313764
%(script_url_node)s
37323765
return (%(src)s);
37333766
})();
37343767
''' % {
3768+
'node_imports': node_es6_imports(),
37353769
'EXPORT_NAME': settings.EXPORT_NAME,
37363770
'script_url': script_url,
37373771
'script_url_node': script_url_node,

src/closure-externs/closure-externs.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@
1111
* The closure_compiler() method in tools/shared.py refers to this file when calling closure.
1212
*/
1313

14-
// Special placeholder for `import.meta`.
14+
// Special placeholder for `import.meta` and `await import`.
1515
var EMSCRIPTEN$IMPORT$META;
16+
var EMSCRIPTEN$AWAIT$IMPORT;
17+
18+
// Don't minify createRequire
19+
var createRequire;
1620

1721
// Closure externs used by library_sockfs.js
1822

src/node_shell_read.js

+5-17
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,16 @@
44
* SPDX-License-Identifier: MIT
55
*/
66

7-
// These modules will usually be used on Node.js. Load them eagerly to avoid
8-
// the complexity of lazy-loading. However, for now we must guard on require()
9-
// actually existing: if the JS is put in a .mjs file (ES6 module) and run on
10-
// node, then we'll detect node as the environment and get here, but require()
11-
// does not exist (since ES6 modules should use |import|). If the code actually
12-
// uses the node filesystem then it will crash, of course, but in the case of
13-
// code that never uses it we don't want to crash here, so the guarding if lets
14-
// such code work properly. See discussion in
15-
// https://github.com/emscripten-core/emscripten/pull/17851
16-
var fs, nodePath;
17-
if (typeof require === 'function') {
18-
fs = require('fs');
19-
nodePath = require('path');
20-
}
21-
227
read_ = (filename, binary) => {
238
#if SUPPORT_BASE64_EMBEDDING
249
var ret = tryParseAsDataURI(filename);
2510
if (ret) {
2611
return binary ? ret : ret.toString();
2712
}
2813
#endif
29-
filename = nodePath['normalize'](filename);
14+
// We need to re-wrap `file://` strings to URLs. Normalizing isn't
15+
// necessary in that case, the path should already be absolute.
16+
filename = isFileURI(filename) ? new URL(filename) : nodePath.normalize(filename);
3017
return fs.readFileSync(filename, binary ? undefined : 'utf8');
3118
};
3219

@@ -48,7 +35,8 @@ readAsync = (filename, onload, onerror) => {
4835
onload(ret);
4936
}
5037
#endif
51-
filename = nodePath['normalize'](filename);
38+
// See the comment in the `read_` function.
39+
filename = isFileURI(filename) ? new URL(filename) : nodePath.normalize(filename);
5240
fs.readFile(filename, function(err, data) {
5341
if (err) onerror(err);
5442
else onload(data.buffer);

src/parseTools.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ function processMacros(text) {
3737
function preprocess(text, filenameHint) {
3838
if (EXPORT_ES6 && USE_ES6_IMPORT_META) {
3939
// `eval`, Terser and Closure don't support module syntax; to allow it,
40-
// we need to temporarily replace `import.meta` usages with placeholders
41-
// during preprocess phase, and back after all the other ops.
40+
// we need to temporarily replace `import.meta` and `await import` usages
41+
// with placeholders during preprocess phase, and back after all the other ops.
4242
// See also: `phase_final_emitting` in emcc.py.
43-
text = text.replace(/\bimport\.meta\b/g, 'EMSCRIPTEN$IMPORT$META');
43+
text = text
44+
.replace(/\bimport\.meta\b/g, 'EMSCRIPTEN$IMPORT$META')
45+
.replace(/\bawait import\b/g, 'EMSCRIPTEN$AWAIT$IMPORT');
4446
}
4547

4648
const IGNORE = 0;

src/preamble.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,7 @@ if (Module['locateFile']) {
641641
#if EXPORT_ES6 && USE_ES6_IMPORT_META && !SINGLE_FILE // in single-file mode, repeating WASM_BINARY_FILE would emit the contents again
642642
} else {
643643
// Use bundler-friendly `new URL(..., import.meta.url)` pattern; works in browsers too.
644-
wasmBinaryFile = new URL('{{{ WASM_BINARY_FILE }}}', import.meta.url).toString();
644+
wasmBinaryFile = new URL('{{{ WASM_BINARY_FILE }}}', import.meta.url).href;
645645
}
646646
#endif
647647

src/settings.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1236,7 +1236,8 @@ var EXPORT_ES6 = false;
12361236

12371237
// Use the ES6 Module relative import feature 'import.meta.url'
12381238
// to auto-detect WASM Module path.
1239-
// It might not be supported on old browsers / toolchains
1239+
// It might not be supported on old browsers / toolchains. This setting
1240+
// may not be disabled when Node.js is targeted (-sENVIRONMENT=*node*).
12401241
// [link]
12411242
var USE_ES6_IMPORT_META = true;
12421243

src/shell.js

+23-6
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,6 @@ var ENVIRONMENT_IS_WASM_WORKER = Module['$ww'];
133133
#if SHARED_MEMORY && !MODULARIZE
134134
// In MODULARIZE mode _scriptDir needs to be captured already at the very top of the page immediately when the page is parsed, so it is generated there
135135
// before the page load. In non-MODULARIZE modes generate it here.
136-
#if EXPORT_ES6
137-
var _scriptDir = import.meta.url;
138-
#else
139136
var _scriptDir = (typeof document != 'undefined' && document.currentScript) ? document.currentScript.src : undefined;
140137

141138
if (ENVIRONMENT_IS_WORKER) {
@@ -146,8 +143,7 @@ else if (ENVIRONMENT_IS_NODE) {
146143
_scriptDir = __filename;
147144
}
148145
#endif // ENVIRONMENT_MAY_BE_NODE
149-
#endif
150-
#endif
146+
#endif // SHARED_MEMORY && !MODULARIZE
151147

152148
// `/` should be present at the end if `scriptDirectory` is not empty
153149
var scriptDirectory = '';
@@ -193,10 +189,31 @@ if (ENVIRONMENT_IS_NODE) {
193189
if (typeof process == 'undefined' || !process.release || process.release.name !== 'node') throw new Error('not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)');
194190
#endif
195191
#endif
192+
// `require()` is no-op in an ESM module, use `createRequire()` to construct
193+
// the require()` function. This is only necessary for multi-environment
194+
// builds, `-sENVIRONMENT=node` emits a static import declaration instead.
195+
// TODO: Swap all `require()`'s with `import()`'s?
196+
#if EXPORT_ES6 && ENVIRONMENT_MAY_BE_WEB
197+
const { createRequire } = await import('module');
198+
/** @suppress{duplicate} */
199+
var require = createRequire(import.meta.url);
200+
#endif
201+
// These modules will usually be used on Node.js. Load them eagerly to avoid
202+
// the complexity of lazy-loading.
203+
var fs = require('fs');
204+
var nodePath = require('path');
205+
196206
if (ENVIRONMENT_IS_WORKER) {
197-
scriptDirectory = require('path').dirname(scriptDirectory) + '/';
207+
scriptDirectory = nodePath.dirname(scriptDirectory) + '/';
198208
} else {
209+
#if EXPORT_ES6
210+
// EXPORT_ES6 + ENVIRONMENT_IS_NODE always requires use of import.meta.url,
211+
// since there's no way getting the current absolute path of the module when
212+
// support for that is not available.
213+
scriptDirectory = require('url').fileURLToPath(new URL('./', import.meta.url)); // includes trailing slash
214+
#else
199215
scriptDirectory = __dirname + '/';
216+
#endif
200217
}
201218

202219
#include "node_shell_read.js"

test/test_other.py

+45-13
Original file line numberDiff line numberDiff line change
@@ -238,41 +238,68 @@ def test_emcc_generate_config(self):
238238
self.assertContained('LLVM_ROOT', config_contents)
239239
os.remove(config_path)
240240

241-
def test_emcc_output_mjs(self):
242-
self.run_process([EMCC, '-o', 'hello_world.mjs', test_file('hello_world.c')])
243-
output = read_file('hello_world.mjs')
244-
self.assertContained('export default Module;', output)
245-
# TODO(sbc): Test that this is actually runnable. We currently don't have
246-
# any tests for EXPORT_ES6 but once we do this should be enabled.
247-
# self.assertContained('hello, world!', self.run_js('hello_world.mjs'))
241+
@parameterized({
242+
'': ([],),
243+
'node': (['-sENVIRONMENT=node'],),
244+
})
245+
def test_emcc_output_mjs(self, args):
246+
create_file('extern-post.js', 'await Module();')
247+
self.run_process([EMCC, '-o', 'hello_world.mjs',
248+
'--extern-post-js', 'extern-post.js',
249+
test_file('hello_world.c')] + args)
250+
src = read_file('hello_world.mjs')
251+
self.assertContained('export default Module;', src)
252+
self.assertContained('hello, world!', self.run_js('hello_world.mjs'))
248253

249254
@parameterized({
250-
'': (True, [],),
251-
'no_import_meta': (False, ['-sUSE_ES6_IMPORT_META=0'],),
255+
'': ([],),
256+
'node': (['-sENVIRONMENT=node'],),
252257
})
253-
def test_emcc_output_worker_mjs(self, has_import_meta, args):
258+
@node_pthreads
259+
def test_emcc_output_worker_mjs(self, args):
260+
create_file('extern-post.js', 'await Module();')
254261
os.mkdir('subdir')
255-
self.run_process([EMCC, '-o', 'subdir/hello_world.mjs', '-pthread', '-O1',
262+
self.run_process([EMCC, '-o', 'subdir/hello_world.mjs',
263+
'-sEXIT_RUNTIME', '-sPROXY_TO_PTHREAD', '-pthread', '-O1',
264+
'--extern-post-js', 'extern-post.js',
256265
test_file('hello_world.c')] + args)
257266
src = read_file('subdir/hello_world.mjs')
258-
self.assertContainedIf("new URL('hello_world.wasm', import.meta.url)", src, condition=has_import_meta)
259-
self.assertContainedIf("new Worker(new URL('hello_world.worker.js', import.meta.url))", src, condition=has_import_meta)
267+
self.assertContained("new URL('hello_world.wasm', import.meta.url)", src)
268+
self.assertContained("new Worker(new URL('hello_world.worker.js', import.meta.url))", src)
260269
self.assertContained('export default Module;', src)
261270
src = read_file('subdir/hello_world.worker.js')
262271
self.assertContained('import("./hello_world.mjs")', src)
272+
self.assertContained('hello, world!', self.run_js('subdir/hello_world.mjs'))
263273

274+
@node_pthreads
264275
def test_emcc_output_worker_mjs_single_file(self):
276+
create_file('extern-post.js', 'await Module();')
265277
self.run_process([EMCC, '-o', 'hello_world.mjs', '-pthread',
278+
'--extern-post-js', 'extern-post.js',
266279
test_file('hello_world.c'), '-sSINGLE_FILE'])
267280
src = read_file('hello_world.mjs')
268281
self.assertNotContained("new URL('data:", src)
269282
self.assertContained("new Worker(new URL('hello_world.worker.js', import.meta.url))", src)
283+
self.assertContained('hello, world!', self.run_js('hello_world.mjs'))
270284

271285
def test_emcc_output_mjs_closure(self):
286+
create_file('extern-post.js', 'await Module();')
272287
self.run_process([EMCC, '-o', 'hello_world.mjs',
288+
'--extern-post-js', 'extern-post.js',
273289
test_file('hello_world.c'), '--closure=1'])
274290
src = read_file('hello_world.mjs')
275291
self.assertContained('new URL("hello_world.wasm", import.meta.url)', src)
292+
self.assertContained('hello, world!', self.run_js('hello_world.mjs'))
293+
294+
def test_emcc_output_mjs_web_no_import_meta(self):
295+
# Ensure we don't emit import.meta.url at all for:
296+
# ENVIRONMENT=web + EXPORT_ES6 + USE_ES6_IMPORT_META=0
297+
self.run_process([EMCC, '-o', 'hello_world.mjs',
298+
test_file('hello_world.c'),
299+
'-sENVIRONMENT=web', '-sUSE_ES6_IMPORT_META=0'])
300+
src = read_file('hello_world.mjs')
301+
self.assertNotContained('import.meta.url', src)
302+
self.assertContained('export default Module;', src)
276303

277304
def test_export_es6_implies_modularize(self):
278305
self.run_process([EMCC, test_file('hello_world.c'), '-sEXPORT_ES6'])
@@ -283,6 +310,11 @@ def test_export_es6_requires_modularize(self):
283310
err = self.expect_fail([EMCC, test_file('hello_world.c'), '-sEXPORT_ES6', '-sMODULARIZE=0'])
284311
self.assertContained('EXPORT_ES6 requires MODULARIZE to be set', err)
285312

313+
def test_export_es6_node_requires_import_meta(self):
314+
err = self.expect_fail([EMCC, test_file('hello_world.c'),
315+
'-sENVIRONMENT=node', '-sEXPORT_ES6', '-sUSE_ES6_IMPORT_META=0'])
316+
self.assertContained('EXPORT_ES6 and ENVIRONMENT=*node* requires USE_ES6_IMPORT_META to be set', err)
317+
286318
def test_export_es6_allows_export_in_post_js(self):
287319
self.run_process([EMCC, test_file('hello_world.c'), '-O3', '-sEXPORT_ES6', '--post-js', test_file('export_module.js')])
288320
src = read_file('a.out.js')

third_party/closure-compiler/node-externs/url.js

+12
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,15 @@ url.format = function(urlObj) {};
6161
* @nosideeffects
6262
*/
6363
url.resolve = function(from, to) {};
64+
65+
/**
66+
* @param {url.URL|string} url
67+
* @return {string}
68+
*/
69+
url.fileURLToPath = function(url) {};
70+
71+
/**
72+
* @param {string} path
73+
* @return {url.URL}
74+
*/
75+
url.pathToFileURL = function(path) {};

0 commit comments

Comments
 (0)