From ace9b721a7ec8e0d665895db82b5c6b9ae6aefa2 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 5 Jun 2024 16:41:08 +0200 Subject: [PATCH] feat: basic top-level await support (#239) --- bin/jiti.js | 5 +- eslint.config.mjs | 3 +- src/jiti.ts | 59 ++++++++++++++++++------ src/types.ts | 3 ++ src/utils.ts | 7 +++ test/__snapshots__/fixtures.test.ts.snap | 34 ++++++++++---- test/bun.test.ts | 13 ++++-- test/fixtures.test.ts | 39 ++++++++++------ test/fixtures/async/index.js | 1 - test/fixtures/json/index.ts | 2 +- test/fixtures/native/index.js | 1 - test/fixtures/top-level-await/async.mjs | 1 + test/fixtures/top-level-await/index.ts | 4 ++ test/fixtures/top-level-await/sub.ts | 3 ++ 14 files changed, 126 insertions(+), 49 deletions(-) create mode 100644 test/fixtures/top-level-await/async.mjs create mode 100644 test/fixtures/top-level-await/index.ts create mode 100644 test/fixtures/top-level-await/sub.ts diff --git a/bin/jiti.js b/bin/jiti.js index 2867c646..29a44ed7 100755 --- a/bin/jiti.js +++ b/bin/jiti.js @@ -5,7 +5,6 @@ const { resolve } = require("node:path"); const script = process.argv.splice(2, 1)[0]; if (!script) { - console.error("Usage: jiti [...arguments]"); process.exit(1); } @@ -13,4 +12,6 @@ if (!script) { const pwd = process.cwd(); const jiti = require("..")(pwd); const resolved = (process.argv[1] = jiti.resolve(resolve(pwd, script))); -jiti(resolved); + + +jiti.import(resolved).catch(console.error); diff --git a/eslint.config.mjs b/eslint.config.mjs index 69f45dc5..44aaf400 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,7 @@ export default unjs({ "test/fixtures/error-*" ], rules: { - "unicorn/no-null": 0 + "unicorn/no-null": 0, + "unicorn/prefer-top-level-await": 0 }, }); diff --git a/src/jiti.ts b/src/jiti.ts index 1968e760..493452bf 100644 --- a/src/jiti.ts +++ b/src/jiti.ts @@ -17,6 +17,7 @@ import { md5, detectLegacySyntax, readNearestPackageJSON, + wrapModule, } from "./utils"; import { resolveJitiOptions } from "./options"; import type { TransformOptions, JITIOptions, JITIImportOptions } from "./types"; @@ -35,6 +36,7 @@ export type EvalModuleOptions = Partial<{ filename: string; ext: string; cache: ModuleCache; + async: boolean; }>; export interface JITI extends Require { @@ -53,6 +55,7 @@ export default function createJITI( userOptions: JITIOptions = {}, parentModule?: Module, parentCache?: ModuleCache, + parentImportOptions?: JITIImportOptions, ): JITI { const opts = resolveJitiOptions(userOptions); @@ -111,6 +114,14 @@ export default function createJITI( : _filename, ); + let _dynamicImport: (id: string) => Promise; + const nativeImport = (id: string) => { + const resolvedId = _resolve(id, { paths: [dirname(_filename)] }); + // TODO: use subpath to avoid webpack transform instead + _dynamicImport ??= new Function("url", "return import(url)") as any; + return _dynamicImport(resolvedId); + }; + const tryResolve = (id: string, options?: { paths?: string[] }) => { try { return nativeRequire.resolve(id, options); @@ -246,7 +257,7 @@ export default function createJITI( return opts.interopDefault ? interopDefault(mod) : mod; } - function jiti(id: string, _importOptions?: JITIImportOptions) { + function jiti(id: string, importOptions?: JITIImportOptions) { const cache = parentCache || {}; // Check for node: and file: protocol @@ -265,11 +276,20 @@ export default function createJITI( if (opts.experimentalBun && !opts.transformOptions) { try { debug(`[bun] [native] ${id}`); - const _mod = nativeRequire(id); - if (opts.requireCache === false) { - delete nativeRequire.cache[id]; + if (importOptions?._async) { + return nativeImport(id).then((m: any) => { + if (opts.requireCache === false) { + delete nativeRequire.cache[id]; + } + return _interopDefault(m); + }); + } else { + const _mod = nativeRequire(id); + if (opts.requireCache === false) { + delete nativeRequire.cache[id]; + } + return _interopDefault(_mod); } - return _interopDefault(_mod); } catch (error: any) { debug(`[bun] Using fallback for ${id} because of an error:`, error); } @@ -311,7 +331,13 @@ export default function createJITI( const source = readFileSync(filename, "utf8"); // Evaluate module - return evalModule(source, { id, filename, ext, cache }); + return evalModule(source, { + id, + filename, + ext, + cache, + async: importOptions?._async ?? parentImportOptions?._async, + }); } function evalModule(source: string, evalOptions: EvalModuleOptions = {}) { @@ -372,7 +398,9 @@ export default function createJITI( } } - mod.require = createJITI(filename, opts, mod, cache); + mod.require = createJITI(filename, opts, mod, cache, { + _async: evalOptions.async, + }); // @ts-ignore mod.path = dirname(filename); @@ -389,13 +417,14 @@ export default function createJITI( // Compile wrapped script let compiled; try { - // @ts-ignore - // mod._compile wraps require and require.resolve to global function - compiled = vm.runInThisContext(Module.wrap(source), { - filename, - lineOffset: 0, - displayErrors: false, - }); + compiled = vm.runInThisContext( + wrapModule(source, { async: evalOptions.async }), + { + filename, + lineOffset: 0, + displayErrors: false, + }, + ); } catch (error: any) { if (opts.requireCache) { delete nativeRequire.cache[filename]; @@ -455,7 +484,7 @@ export default function createJITI( jiti.register = register; jiti.evalModule = evalModule; jiti.import = async (id: string, importOptions?: JITIImportOptions) => - await jiti(id, importOptions); + await jiti(id, { _async: true, ...importOptions }); return jiti; } diff --git a/src/types.ts b/src/types.ts index 3d970746..4554c3e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,4 +35,7 @@ export type JITIOptions = { export interface JITIImportOptions { /** @internal */ _import?: () => Promise; + + /** @internal */ + _async?: boolean; } diff --git a/src/utils.ts b/src/utils.ts index 9a7cb49c..d1ce7af1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -72,3 +72,10 @@ export function readNearestPackageJSON(path: string): PackageJson | undefined { } } } + +export function wrapModule(source: string, opts?: { async?: boolean }) { + if (opts?.async) { + source = source.replace(/(\s*=\s*)require\(/g, "$1await require("); + } + return `(${opts?.async ? "async " : ""}function (exports, require, module, __filename, __dirname) { ${source}\n});`; +} diff --git a/test/__snapshots__/fixtures.test.ts.snap b/test/__snapshots__/fixtures.test.ts.snap index 30815bcf..5e73b9f1 100644 --- a/test/__snapshots__/fixtures.test.ts.snap +++ b/test/__snapshots__/fixtures.test.ts.snap @@ -14,28 +14,28 @@ import.meta.env?.TEST true" `; exports[`fixtures > error-parse > stderr 1`] = ` -"/lib/index.js:2 - throw err; /* ↓ Check stack trace ↓ */ - ^ - -Error: ParseError: \`import\` can only be used in \`import +"Error: ParseError: \`import\` can only be used in \`import /index.ts + at /dist/jiti.js + at Generator.next () + at /dist/jiti.js + at new Promise () + at __awaiter (/dist/jiti) + at jiti.import (/dist/jiti) at Object. (/bin/jiti) at Module._compile (internal/modules/cjs/loader) at Module._extensions..js (internal/modules/cjs/loader) at Module.load (internal/modules/cjs/loader) at Module._load (internal/modules/cjs/loader) at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main) - at internal/main/run_main_module - -Node.js v" + at internal/main/run_main_module" `; exports[`fixtures > error-parse > stdout 1`] = `""`; exports[`fixtures > error-runtime > stderr 1`] = ` -"/lib/index.js:2 - throw err; /* ↓ Check stack trace ↓ */ +"events:276 + validateFunction(listener, 'listener'); ^ TypeError: The "listener" argument must be of type function. Received undefined @@ -45,6 +45,12 @@ TypeError: The "listener" argument must be of type function. Received undefined at /index.ts at evalModule (/dist/jiti) at jiti (/dist/jiti) + at /dist/jiti.js + at Generator.next () + at /dist/jiti.js + at new Promise () + at __awaiter (/dist/jiti) + at jiti.import (/dist/jiti) at Object. (/bin/jiti) at Module._compile (internal/modules/cjs/loader) at Module._extensions..js (internal/modules/cjs/loader) @@ -72,6 +78,12 @@ exports[`fixtures > esm > stdout 1`] = ` 'at /index.js', 'at evalModule (/dist/jiti)', 'at jiti (/dist/jiti)', + 'at /dist/jiti.js', + 'at Generator.next ()', + 'at /dist/jiti.js', + 'at new Promise ()', + 'at __awaiter (/dist/jiti)', + 'at jiti.import (/dist/jiti)', 'at Object. (/bin/jiti)', 'at Module._compile (internal/modules/cjs/loader)', 'at Module._extensions..js (internal/modules/cjs/loader)', @@ -120,6 +132,8 @@ Logical or assignment: 50 title is empty. Logical nullish assignment: 50 20" `; +exports[`fixtures > top-level-await > stdout 1`] = `"async value works from sub module"`; + exports[`fixtures > typescript > stdout 1`] = ` "Decorator metadata keys: design:type Decorator called with 3 arguments. diff --git a/test/bun.test.ts b/test/bun.test.ts index 526a0ce8..c2636d71 100644 --- a/test/bun.test.ts +++ b/test/bun.test.ts @@ -21,9 +21,16 @@ for (const fixture of fixtures) { if (fixture.startsWith("error-")) { continue; } - test("fixtures/" + fixture, () => { - _jiti("./" + fixture); - }); + if (!fixture.includes("await")) { + test("fixtures/" + fixture + " (CJS)", () => { + _jiti("./" + fixture); + }); + } + if (!fixture.includes("typescript")) { + test("fixtures/" + fixture + " (ESM)", async () => { + await _jiti.import("./" + fixture); + }); + } } test("hmr", async () => { diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 822f155f..e0a77717 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -18,21 +18,30 @@ describe("fixtures", async () => { // Clean up absolute paths and sourcemap locations for stable snapshots function cleanUpSnap(str: string) { - return (str + "\n") - .replace(/\n\t/g, "\n") - .replace(/\\+/g, "/") - .split(cwd.replace(/\\/g, "/")) - .join("") // workaround for replaceAll in Node 14 - .split(root.replace(/\\/g, "/")) - .join("") // workaround for replaceAll in Node 14 - .replace(/:\d+:\d+([\s')])/g, "$1") // remove line numbers in stacktrace - .replace(/node:(internal|events)/g, "$1") // in Node 16 internal will be presented as node:internal - .replace(/\.js\)/g, ")") - .replace(/file:\/{3}/g, "file://") - .replace(/Node.js v[\d.]+/, "Node.js v") - .replace(/ParseError: \w:\/:\s+/, "ParseError: ") // Unknown chars in Windows - .replace("TypeError [ERR_INVALID_ARG_TYPE]:", "TypeError:") - .trim(); + return ( + (str + "\n") + .replace(/\n\t/g, "\n") + .replace(/\\+/g, "/") + .split(cwd.replace(/\\/g, "/")) + .join("") // workaround for replaceAll in Node 14 + .split(root.replace(/\\/g, "/")) + .join("") // workaround for replaceAll in Node 14 + .replace(/:\d+:\d+([\s')])/g, "$1") // remove line numbers in stacktrace + .replace(/node:(internal|events)/g, "$1") // in Node 16 internal will be presented as node:internal + .replace(/\.js\)/g, ")") + .replace(/file:\/{3}/g, "file://") + .replace(/Node.js v[\d.]+/, "Node.js v") + .replace(/ParseError: \w:\/:\s+/, "ParseError: ") // Unknown chars in Windows + .replace("TypeError [ERR_INVALID_ARG_TYPE]:", "TypeError:") + // Node 18 + .replace( + " ErrorCaptureStackTrace(err);", + "validateFunction(listener, 'listener');", + ) + .replace("internal/errors:496", "events:276") + .replace(" ^", " ^") + .trim() + ); } const { stdout, stderr } = await execa("node", [jitiPath, fixtureEntry], { diff --git a/test/fixtures/async/index.js b/test/fixtures/async/index.js index dac371a9..b9b8e5ef 100644 --- a/test/fixtures/async/index.js +++ b/test/fixtures/async/index.js @@ -2,5 +2,4 @@ async function main() { await import("./async.mjs").then((m) => console.log(m.async)); } -// eslint-disable-next-line unicorn/prefer-top-level-await main().catch(console.error); diff --git a/test/fixtures/json/index.ts b/test/fixtures/json/index.ts index cc225106..3c103aca 100644 --- a/test/fixtures/json/index.ts +++ b/test/fixtures/json/index.ts @@ -10,5 +10,5 @@ const debug = (label: string, value) => debug("Imported", imported); debug("Imported with assertion", importedWithAssertion); debug("Required", required); -// eslint-disable-next-line unicorn/prefer-top-level-await + import("./file.json").then((r) => debug("Dynamic Imported", r)); diff --git a/test/fixtures/native/index.js b/test/fixtures/native/index.js index d206ada6..f432d1ba 100644 --- a/test/fixtures/native/index.js +++ b/test/fixtures/native/index.js @@ -1,2 +1 @@ -// eslint-disable-next-line unicorn/prefer-top-level-await import("./test.mjs").then(console.log); diff --git a/test/fixtures/top-level-await/async.mjs b/test/fixtures/top-level-await/async.mjs new file mode 100644 index 00000000..7ffeb76a --- /dev/null +++ b/test/fixtures/top-level-await/async.mjs @@ -0,0 +1 @@ +export const asyncValue = await Promise.resolve("async value works"); diff --git a/test/fixtures/top-level-await/index.ts b/test/fixtures/top-level-await/index.ts new file mode 100644 index 00000000..2ee0142a --- /dev/null +++ b/test/fixtures/top-level-await/index.ts @@ -0,0 +1,4 @@ +await import("./sub").then((m) => console.log(m.test)); + +// Mark file as module for typescript +export default {}; diff --git a/test/fixtures/top-level-await/sub.ts b/test/fixtures/top-level-await/sub.ts new file mode 100644 index 00000000..045a20a7 --- /dev/null +++ b/test/fixtures/top-level-await/sub.ts @@ -0,0 +1,3 @@ +import { asyncValue } from "./async.mjs"; + +export const test = asyncValue + " from sub module";