diff --git a/lib/internal/loader/ModuleJob.js b/lib/internal/loader/ModuleJob.js index a17c501609a426..13f8708e15db31 100644 --- a/lib/internal/loader/ModuleJob.js +++ b/lib/internal/loader/ModuleJob.js @@ -87,20 +87,19 @@ class ModuleJob { // ModuleWrap in this module, but all modules in the graph. dependencyJob.instantiated = resolvedPromise; } - return this.module; } async run() { - const module = await this.instantiate(); + await this.instantiate(); try { - module.evaluate(); + this.module.evaluate(); } catch (e) { e.stack; this.hadError = true; this.error = e; throw e; } - return module; + return this.module; } } Object.setPrototypeOf(ModuleJob.prototype, null); diff --git a/lib/internal/loader/singleton.js b/lib/internal/loader/singleton.js new file mode 100644 index 00000000000000..dff96de07dc1ca --- /dev/null +++ b/lib/internal/loader/singleton.js @@ -0,0 +1,25 @@ +'use strict'; + +const Loader = require('internal/loader/Loader'); + +function newLoader(hooks) { + const loader = new Loader(); + Loader.registerImportDynamicallyCallback(loader); + if (hooks) + loader.hook(hooks); + + return loader; +} + +module.exports = { + loaded: false, + get loader() { + delete this.loader; + this.loader = newLoader(); + this.loaded = true; + return this.loader; + }, + replace(hooks) { + this.loader = newLoader(hooks); + }, +}; diff --git a/lib/internal/repl/import.js b/lib/internal/repl/import.js new file mode 100644 index 00000000000000..51175528faf32e --- /dev/null +++ b/lib/internal/repl/import.js @@ -0,0 +1,79 @@ +'use strict'; + +const acorn = require('internal/deps/acorn/dist/acorn'); +const ESLoader = require('internal/loader/singleton'); + +/* +Performs importing from static import statments + +"a(); import { x, y } from 'some_modules'; x(); y" + +would become: + +"a(); x(); y" + +The symbol is a promise from Promise.all on an array of promises +from Loader#import. When a promise finishes it attaches to +`repl.context` with a getter, so that the imported names behave +like variables not values (live binding). +*/ + +function processTopLevelImport(src, repl) { + let root; + try { + root = acorn.parse(src, { ecmaVersion: 8, sourceType: 'module' }); + } catch (err) { + return null; + } + + const importLength = + root.body.filter((n) => n.type === 'ImportDeclaration').length; + + if (importLength === 0) + return null; + + const importPromises = []; + let newBody = ''; + + for (const index in root.body) { + const node = root.body[index]; + if (node.type === 'ImportDeclaration') { + const lPromise = ESLoader.loader.import(node.source.value) + .then((exports) => { + const { specifiers } = node; + if (specifiers[0].type === 'ImportNamespaceSpecifier') { + Object.defineProperty(repl.context, specifiers[0].local.name, { + enumerable: true, + configurable: true, + get() { return exports; } + }); + } else { + const properties = {}; + for (const { imported, local } of specifiers) { + const imp = imported ? imported.name : 'default'; + properties[local.name] = { + enumerable: true, + configurable: true, + get() { return exports[imp]; } + }; + } + Object.defineProperties(repl.context, properties); + } + }); + + importPromises.push(lPromise); + } else if (node.expression !== undefined) { + const { start, end } = node.expression; + newBody += src.substring(start, end) + ';'; + } else { + newBody += src.substring(node.start, node.end); + } + } + + return { + promise: Promise.all(importPromises), + body: newBody, + }; +} + +module.exports = processTopLevelImport; diff --git a/lib/module.js b/lib/module.js index 4c4ceaf847fba2..0c32ba66036a1a 100644 --- a/lib/module.js +++ b/lib/module.js @@ -43,10 +43,9 @@ const errors = require('internal/errors'); module.exports = Module; // these are below module.exports for the circular reference -const Loader = require('internal/loader/Loader'); const ModuleJob = require('internal/loader/ModuleJob'); const { createDynamicModule } = require('internal/loader/ModuleWrap'); -let ESMLoader; +const ESMLoader = require('internal/loader/singleton'); function stat(filename) { filename = path.toNamespacedPath(filename); @@ -463,17 +462,14 @@ Module._load = function(request, parent, isMain) { if (isMain && experimentalModules) { (async () => { // loader setup - if (!ESMLoader) { - ESMLoader = new Loader(); + if (!ESMLoader.loaded) { const userLoader = process.binding('config').userLoader; if (userLoader) { - const hooks = await ESMLoader.import(userLoader); - ESMLoader = new Loader(); - ESMLoader.hook(hooks); + const hooks = await ESMLoader.loader.import(userLoader); + ESMLoader.replace(hooks); } } - Loader.registerImportDynamicallyCallback(ESMLoader); - await ESMLoader.import(getURLFromFilePath(request).pathname); + await ESMLoader.loader.import(getURLFromFilePath(request).pathname); })() .catch((e) => { decorateErrorStack(e); @@ -577,14 +573,15 @@ Module.prototype.load = function(filename) { Module._extensions[extension](this, filename); this.loaded = true; - if (ESMLoader) { + if (ESMLoader.loaded) { + const { loader } = ESMLoader; const url = getURLFromFilePath(filename); const urlString = `${url}`; const exports = this.exports; - if (ESMLoader.moduleMap.has(urlString) !== true) { - ESMLoader.moduleMap.set( + if (loader.moduleMap.has(urlString) !== true) { + loader.moduleMap.set( urlString, - new ModuleJob(ESMLoader, url, async () => { + new ModuleJob(loader, url, async () => { const ctx = createDynamicModule( ['default'], url); ctx.reflect.exports.default.set(exports); @@ -592,7 +589,7 @@ Module.prototype.load = function(filename) { }) ); } else { - const job = ESMLoader.moduleMap.get(urlString); + const job = loader.moduleMap.get(urlString); if (job.reflect) job.reflect.exports.default.set(exports); } diff --git a/lib/repl.js b/lib/repl.js index e2e716dd66f2da..b5ce8a420406b5 100644 --- a/lib/repl.js +++ b/lib/repl.js @@ -44,6 +44,7 @@ const internalModule = require('internal/module'); const { processTopLevelAwait } = require('internal/repl/await'); +const processTopLevelImport = require('internal/repl/import'); const internalUtil = require('internal/util'); const { isTypedArray } = require('internal/util/types'); const util = require('util'); @@ -60,6 +61,8 @@ const domain = require('domain'); const debug = util.debuglog('repl'); const errors = require('internal/errors'); +const experimentalModules = !!process.binding('config').experimentalModules; + const parentModule = module; const replMap = new WeakMap(); @@ -222,6 +225,16 @@ function REPLServer(prompt, } } + if (!wrappedCmd && experimentalModules && /\bimport\b/.test(code)) { + const processed = processTopLevelImport(code, self); + if (processed !== null) { + processed.promise.then(() => { + defaultEval(processed.body, context, file, cb); + }, (e) => { cb(e); }); + return; + } + } + // first, create the Script object to check the syntax if (code === '\n') diff --git a/node.gyp b/node.gyp index ed28b4a6ce198a..ecd0d2f81884c7 100644 --- a/node.gyp +++ b/node.gyp @@ -107,6 +107,7 @@ 'lib/internal/loader/ModuleWrap.js', 'lib/internal/loader/ModuleRequest.js', 'lib/internal/loader/search.js', + 'lib/internal/loader/singleton.js', 'lib/internal/safe_globals.js', 'lib/internal/net.js', 'lib/internal/module.js', @@ -121,6 +122,7 @@ 'lib/internal/readline.js', 'lib/internal/repl.js', 'lib/internal/repl/await.js', + 'lib/internal/repl/import.js', 'lib/internal/socket_list.js', 'lib/internal/test/unicode.js', 'lib/internal/tls.js', diff --git a/test/fixtures/esm-with-basic-exports.mjs b/test/fixtures/esm-with-basic-exports.mjs new file mode 100644 index 00000000000000..8abd60adc535d1 --- /dev/null +++ b/test/fixtures/esm-with-basic-exports.mjs @@ -0,0 +1,9 @@ +export default 'default'; + +export const six = 6; + +export let i = 0; +export const increment = () => { i++; }; + +const _delete = 'delete'; +export { _delete as delete }; diff --git a/test/parallel/test-repl-top-level-import.js b/test/parallel/test-repl-top-level-import.js new file mode 100644 index 00000000000000..b770e5a74cedaa --- /dev/null +++ b/test/parallel/test-repl-top-level-import.js @@ -0,0 +1,145 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { stripVTControlCharacters } = require('internal/readline'); +const repl = require('repl'); + +common.crashOnUnhandledRejection(); + +// Flags: --expose-internals --experimental-modules + +const PROMPT = 'import repl > '; + +class REPLStream extends common.ArrayStream { + constructor() { + super(); + this.waitingForResponse = false; + this.lines = ['']; + } + + write(chunk, encoding, callback) { + if (Buffer.isBuffer(chunk)) { + chunk = chunk.toString(encoding); + } + const chunkLines = stripVTControlCharacters(chunk).split('\n'); + this.lines[this.lines.length - 1] += chunkLines[0]; + if (chunkLines.length > 1) { + this.lines.push(...chunkLines.slice(1)); + } + this.emit('line'); + if (callback) callback(); + return true; + } + + wait(lookFor = PROMPT) { + if (this.waitingForResponse) { + throw new Error('Currently waiting for response to another command'); + } + this.lines = ['']; + return common.fires(new Promise((resolve, reject) => { + const onError = (err) => { + this.removeListener('line', onLine); + reject(err); + }; + const onLine = () => { + if (this.lines[this.lines.length - 1].includes(lookFor)) { + this.removeListener('error', onError); + this.removeListener('line', onLine); + resolve(this.lines); + } + }; + this.once('error', onError); + this.on('line', onLine); + }), new Error(), 1000); + } +} + +const putIn = new REPLStream(); +const testMe = repl.start({ + prompt: PROMPT, + stream: putIn, + terminal: true, + useColors: false, + breakEvalOnSigint: true +}); + +function runAndWait(cmds, lookFor) { + const promise = putIn.wait(lookFor); + for (const cmd of cmds) { + if (typeof cmd === 'string') { + putIn.run([cmd]); + } else { + testMe.write('', cmd); + } + } + return promise; +} + +async function runEach(cmds) { + const out = []; + for (const cmd of cmds) { + const ret = await runAndWait([cmd]); + out.push(...ret); + } + return out; +} + +const file = './test/fixtures/esm-with-basic-exports.mjs'; +async function main() { + assert.deepStrictEqual(await runEach([ + `/* comment */ import { six } from "${file}"; six + 1;` + ]), [ + `/* comment */ import { six } from "${file}"; six + 1;\r`, + '7', + PROMPT + ]); + + assert.deepStrictEqual(await runEach([ + `import def from "${file}"`, + 'def', + ]), [ + `import def from "${file}"\r`, + 'undefined', + PROMPT, + 'def\r', + '\'default\'', + PROMPT + ]); + + testMe.resetContext(); + + assert.deepStrictEqual(await runEach([ + `import * as test from "${file}"`, + 'test.six', + ]), [ + `import * as test from "${file}"\r`, + 'undefined', + PROMPT, + 'test.six\r', + '6', + PROMPT + ]); + + assert.deepStrictEqual(await runEach([ + `import { i, increment } from "${file}"`, + 'i', + 'increment()', + 'i', + ]), [ + `import { i, increment } from "${file}"\r`, + 'undefined', + PROMPT, + 'i\r', + '0', + PROMPT, + 'increment()\r', + 'undefined', + PROMPT, + 'i\r', + '1', + PROMPT, + ]); +} + +main();