Skip to content

Commit

Permalink
feat: basic top-level await support (#239)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Jun 5, 2024
1 parent 7746080 commit ace9b72
Show file tree
Hide file tree
Showing 14 changed files with 126 additions and 49 deletions.
5 changes: 3 additions & 2 deletions bin/jiti.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ const { resolve } = require("node:path");
const script = process.argv.splice(2, 1)[0];

if (!script) {

console.error("Usage: jiti <path> [...arguments]");
process.exit(1);
}

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);
3 changes: 2 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
});
59 changes: 44 additions & 15 deletions src/jiti.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
md5,
detectLegacySyntax,
readNearestPackageJSON,
wrapModule,
} from "./utils";
import { resolveJitiOptions } from "./options";
import type { TransformOptions, JITIOptions, JITIImportOptions } from "./types";
Expand All @@ -35,6 +36,7 @@ export type EvalModuleOptions = Partial<{
filename: string;
ext: string;
cache: ModuleCache;
async: boolean;
}>;

export interface JITI extends Require {
Expand All @@ -53,6 +55,7 @@ export default function createJITI(
userOptions: JITIOptions = {},
parentModule?: Module,
parentCache?: ModuleCache,
parentImportOptions?: JITIImportOptions,
): JITI {
const opts = resolveJitiOptions(userOptions);

Expand Down Expand Up @@ -111,6 +114,14 @@ export default function createJITI(
: _filename,
);

let _dynamicImport: (id: string) => Promise<any>;
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);
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -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);
Expand All @@ -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];
Expand Down Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,7 @@ export type JITIOptions = {
export interface JITIImportOptions {
/** @internal */
_import?: () => Promise<any>;

/** @internal */
_async?: boolean;
}
7 changes: 7 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});`;
}
34 changes: 24 additions & 10 deletions test/__snapshots__/fixtures.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,28 @@ import.meta.env?.TEST true"
`;

exports[`fixtures > error-parse > stderr 1`] = `
"<root>/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
<cwd>/index.ts
at <root>/dist/jiti.js
at Generator.next (<anonymous>)
at <root>/dist/jiti.js
at new Promise (<anonymous>)
at __awaiter (<root>/dist/jiti)
at jiti.import (<root>/dist/jiti)
at Object.<anonymous> (<root>/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<version>"
at internal/main/run_main_module"
`;
exports[`fixtures > error-parse > stdout 1`] = `""`;
exports[`fixtures > error-runtime > stderr 1`] = `
"<root>/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
Expand All @@ -45,6 +45,12 @@ TypeError: The "listener" argument must be of type function. Received undefined
at <cwd>/index.ts
at evalModule (<root>/dist/jiti)
at jiti (<root>/dist/jiti)
at <root>/dist/jiti.js
at Generator.next (<anonymous>)
at <root>/dist/jiti.js
at new Promise (<anonymous>)
at __awaiter (<root>/dist/jiti)
at jiti.import (<root>/dist/jiti)
at Object.<anonymous> (<root>/bin/jiti)
at Module._compile (internal/modules/cjs/loader)
at Module._extensions..js (internal/modules/cjs/loader)
Expand Down Expand Up @@ -72,6 +78,12 @@ exports[`fixtures > esm > stdout 1`] = `
'at <cwd>/index.js',
'at evalModule (<root>/dist/jiti)',
'at jiti (<root>/dist/jiti)',
'at <root>/dist/jiti.js',
'at Generator.next (<anonymous>)',
'at <root>/dist/jiti.js',
'at new Promise (<anonymous>)',
'at __awaiter (<root>/dist/jiti)',
'at jiti.import (<root>/dist/jiti)',
'at Object.<anonymous> (<root>/bin/jiti)',
'at Module._compile (internal/modules/cjs/loader)',
'at Module._extensions..js (internal/modules/cjs/loader)',
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 10 additions & 3 deletions test/bun.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
39 changes: 24 additions & 15 deletions test/fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<cwd>") // workaround for replaceAll in Node 14
.split(root.replace(/\\/g, "/"))
.join("<root>") // 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<version>")
.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("<cwd>") // workaround for replaceAll in Node 14
.split(root.replace(/\\/g, "/"))
.join("<root>") // 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<version>")
.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], {
Expand Down
1 change: 0 additions & 1 deletion test/fixtures/async/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
2 changes: 1 addition & 1 deletion test/fixtures/json/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
1 change: 0 additions & 1 deletion test/fixtures/native/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
// eslint-disable-next-line unicorn/prefer-top-level-await
import("./test.mjs").then(console.log);
1 change: 1 addition & 0 deletions test/fixtures/top-level-await/async.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const asyncValue = await Promise.resolve("async value works");
4 changes: 4 additions & 0 deletions test/fixtures/top-level-await/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
await import("./sub").then((m) => console.log(m.test));

// Mark file as module for typescript
export default {};
3 changes: 3 additions & 0 deletions test/fixtures/top-level-await/sub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { asyncValue } from "./async.mjs";

export const test = asyncValue + " from sub module";

0 comments on commit ace9b72

Please sign in to comment.