Skip to content

Commit

Permalink
feat: add experimental esm loader support (#266)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Jul 1, 2024
1 parent 711ea45 commit c6e7b12
Show file tree
Hide file tree
Showing 10 changed files with 186 additions and 52 deletions.
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Just-in-Time Typescript and ESM support for Node.js.
- Seamless interoperability between ESM and CommonJS
- Synchronous API to replace `require()`
- Asynchronous API to replace `import()`
- ESM Loader support
- Super slim and zero dependency
- Smart syntax detection to avoid extra transforms
- Node.js native require cache integration
Expand Down Expand Up @@ -88,17 +89,20 @@ You can also pass options as second argument:
const jiti = createJiti(import.meta.url, { debug: true });
```
### Register require hook
### Register global ESM loader
```bash
node -r jiti/register index.ts
```
You can globally register jiti using [global hooks](https://nodejs.org/api/module.html#initialize).
Alternatively, you can register `jiti` as a require hook programmatically:
**Note:** This is an experimental approach and is not recommended unless you have to, please prefer explicit method.
```js
const jiti = require("jiti")();
const unregister = jiti.register();
import "jiti/register";
```
Or:
```bash
node --import jiti/register index.ts
```
## ⚙️ Options
Expand Down
115 changes: 115 additions & 0 deletions lib/jiti-hooks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import { createJiti } from "./jiti.mjs";

let jiti;

// https://nodejs.org/api/module.html#initialize
export async function initialize() {
jiti = createJiti();
}

// https://nodejs.org/api/module.html#resolvespecifier-context-nextresolve
export async function resolve(specifier, context, nextResolve) {
if (_shouldSkip(specifier)) {
return nextResolve(specifier, context);
}
const resolvedPath = jiti.importResolve(specifier, context?.parentURL, {
conditions: context?.conditions,
});
return {
url: pathToFileURL(resolvedPath).href,
shortCircuit: true,
};
}

// https://nodejs.org/api/module.html#loadurl-context-nextload
export async function load(url, context, nextLoad) {
if (_shouldSkip(url)) {
return nextLoad(url, context);
}

const filename = fileURLToPath(url);

if (url.endsWith(".js")) {
const pkg = await _findClosestPackageJson(dirname(filename));
if (pkg && pkg.type === "module") {
return nextLoad(url, context);
}
}

const rawSource = await readFile(filename, "utf8");

if (url.endsWith(".json")) {
return {
source: `export default ${rawSource}`,
format: "module",
shortCircuit: true,
};
}

const transpiledSource = jiti.transform({
source: rawSource,
filename: filename,
ts: url.endsWith("ts"),
retainLines: true,
async: true,
});

if (url.endsWith(".js") && !transpiledSource.includes("jitiImport")) {
return {
source: transpiledSource,
format: "commonjs",
shortCircuit: true,
};
}

return {
source: _wrapSource(transpiledSource, filename),
format: "module",
shortCircuit: true,
};
}

function _wrapSource(source, filename) {
const _jitiPath = new URL("jiti.mjs", import.meta.url).href;
return /*js*/ `import { createJiti as __createJiti__ } from ${JSON.stringify(_jitiPath)};async function _module(exports, require, module, __filename, __dirname, jitiImport) { ${source}\n};
// GENERATED BY JITI ESM LOADER
const filename = ${JSON.stringify(filename)};
const dirname = ${JSON.stringify(dirname(filename))};
const jiti = __createJiti__(filename);
const module = { exports: Object.create(null) };
await _module(module.exports, jiti, module, filename, dirname, jiti.import);
if (module.exports && module.exports.__JITI_ERROR__) {
const { filename, line, column, code, message } =
module.exports.__JITI_ERROR__;
const loc = [filename, line, column].join(':');
const err = new Error(code + ": " + message + " " + loc);
Error.captureStackTrace(err, _module);
throw err;
}
export default module.exports;
`;
}

function _shouldSkip(url) {
return (
!jiti ||
url.endsWith(".mjs") ||
url.endsWith(".cjs") ||
(!url.startsWith("./") && !url.startsWith("file://")) ||
isBuiltin(url)
);
}

async function _findClosestPackageJson(dir) {
if (dir === "/") return null;
const packageJsonPath = join(dir, "package.json");
if (existsSync(packageJsonPath)) {
return JSON.parse(await readFile(packageJsonPath, "utf8"));
}
return _findClosestPackageJson(dirname(dir));
}
4 changes: 4 additions & 0 deletions lib/jiti-register.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options
import { register } from "node:module";

register("./jiti-hooks.mjs", import.meta.url, {});
10 changes: 5 additions & 5 deletions lib/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ export interface Jiti extends NodeRequire {
/**
* Resolve with ESM import conditions.
*/
importResolve: (id: string) => string;
importResolve: (
id: string,
parentURL?: string,
opts?: { conditions?: string[] },
) => string;
/**
* Transform source code
*/
transform: (opts: TransformOptions) => string;
/**
* Register global (CommonJS) require hook
*/
register: () => () => void;
/**
* Evaluate transformed code as a module
*/
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"default": "./lib/jiti.cjs"
}
},
"./register": {
"import": "./lib/jiti-register.mjs"
},
"./package.json": "./package.json"
},
"main": "./lib/jiti.cjs",
Expand All @@ -37,7 +40,8 @@
"lint": "eslint . && prettier -c src lib test stubs",
"lint:fix": "eslint --fix . && prettier -w src lib test stubs",
"release": "pnpm build && pnpm test && changelogen --release --prerelease --push --publish --publishTag 2x",
"test": "pnpm lint && vitest run --coverage && pnpm test:bun",
"test": "pnpm lint && vitest run --coverage && pnpm test:register && pnpm test:bun",
"test:register": "node ./test/register-test.mjs",
"test:bun": "bun --bun test test/bun"
},
"devDependencies": {
Expand Down Expand Up @@ -76,7 +80,6 @@
"mlly": "^1.7.1",
"object-hash": "^3.0.0",
"pathe": "^1.1.2",
"pirates": "^4.0.6",
"pkg-types": "^1.1.1",
"prettier": "^3.3.2",
"reflect-metadata": "^0.2.1",
Expand Down
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions register.cjs

This file was deleted.

21 changes: 6 additions & 15 deletions src/jiti.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { join } from "pathe";
import escapeStringRegexp from "escape-string-regexp";
import createRequire from "create-require";
import { normalizeAliases } from "pathe/utils";
import { addHook } from "pirates";
import { isDir } from "./utils";
import { resolveJitiOptions } from "./options";
import { jitiResolve } from "./resolve";
Expand Down Expand Up @@ -118,26 +117,18 @@ export default function createJiti(
transform(opts: TransformOptions) {
return transform(ctx, opts);
},
register() {
return addHook(
(source: string, filename: string) =>
transform(ctx, {
source,
filename,
ts: !!/\.[cm]?ts$/.test(filename),
async: false,
}),
{ exts: ctx.opts.extensions },
);
},
evalModule(source: string, options?: EvalModuleOptions) {
return evalModule(ctx, source, options);
},
async import(id: string) {
return await jitiRequire(ctx, id, true /* async */);
},
importResolve(id: string) {
return jitiResolve(ctx, id, { async: true });
importResolve(
id: string,
parentURL?: string,
opts?: { conditions?: string[] },
) {
return jitiResolve(ctx, id, { ...opts, async: true, parentURL });
},
},
);
Expand Down
28 changes: 17 additions & 11 deletions src/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,35 @@ const TS_EXT_RE = /\.(c|m)?t(sx?)$/;
export function jitiResolve(
ctx: Context,
id: string,
options?: { paths?: string[]; async?: boolean },
options?: {
paths?: string[];
async?: boolean;
parentURL?: string;
conditions?: string[];
},
) {
let resolved, err;

if (ctx.isNativeRe.test(id)) {
console.log("$$$$$", id);
return id;
}

// Resolve alias
if (ctx.alias) {
id = resolveAlias(id, ctx.alias);
}

// Try resolving with ESM compatible Node.js resolution in async context
const conditionSets = options?.async
? [
["node", "import"],
["node", "require"],
]
: [
["node", "require"],
["node", "import"],
];
const conditionSets = (
options?.async
? [options?.conditions, ["node", "import"], ["node", "require"]]
: [options?.conditions, ["node", "require"], ["node", "import"]]
).filter(Boolean);
for (const conditions of conditionSets) {
try {
resolved = resolvePathSync(id, {
url: ctx.url,
url: options?.parentURL || ctx.url,
conditions,
extensions: ctx.opts.extensions,
});
Expand Down
23 changes: 23 additions & 0 deletions test/register-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import "jiti/register";
import { fileURLToPath } from "node:url";
import { readdir } from "node:fs/promises";
import { test } from "node:test";
import assert from "node:assert";

const fixturesDir = fileURLToPath(new URL("fixtures", import.meta.url));

const fixtures = await readdir(fixturesDir);

for (const fixture of fixtures) {
if (fixture === "typescript") {
continue; // .mts support
}
test("fixtures/" + fixture + " (ESM)", async () => {
const promise = import(`./fixtures/${fixture}`);
const shouldReject =
fixture === "error-parse" || fixture === "error-runtime";
(await shouldReject)
? assert.rejects(promise)
: assert.doesNotReject(promise);
});
}

0 comments on commit c6e7b12

Please sign in to comment.