Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type 'guess' #4

Merged
merged 1 commit into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ fromMem(code: string, options: FromMemOptions): Promise<unknown>

export type FromMemOptions = {
/**
* What format does the code have? Throws an error if the format is not
* "commonjs", "es", "umd", or "bare". Default: "commonjs".
* What format does the code have? "guess" means to read the closest
* package.json file looking for the "type" key. Default: "commonjs".
*/
format?: "amd" | "bare" | "commonjs" | "es" | "globals" | "umd";
format?: "bare" | "commonjs" | "es" | "globals" | "guess";
/**
* What is the fully-qualified synthetic
* filename for the code? Most important is the directory, which is used to
Expand Down
63 changes: 52 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
// Ideas taken from the "module-from-string" and "eval" modules, neither of
// which were situated correctly to be used as-is.

const vm = require("vm");
const { Module } = require("module");
const path = require("path");
const url = require("url");
const fs = require("node:fs/promises");
const vm = require("node:vm");
const { Module } = require("node:module");
const path = require("node:path");
const url = require("node:url");
const semver = require("semver");

// These already exist in a new, blank VM. Date, JSON, NaN, etc.
Expand Down Expand Up @@ -40,9 +41,9 @@ globalContext.console = console;
* Options for how to process code.
*
* @typedef {object} FromMemOptions
* @property {"amd"|"bare"|"commonjs"|"es"|"globals"|"umd"} [format="commonjs"]
* What format does the code have? Throws an error if the format is not
* "commonjs", "es", "umd", or "bare".
* @property {"amd"|"bare"|"commonjs"|"es"|"globals"|"guess"|"umd"} [format="commonjs"]
* What format does the code have? "guess" means to read the closest
* package.json file looking for the "type" key.
* @property {string} filename What is the fully-qualified synthetic
* filename for the code? Most important is the directory, which is used to
* find modules that the code import's or require's.
Expand Down Expand Up @@ -117,7 +118,7 @@ async function importString(code, dirname, options) {
? options.filename
: url.pathToFileURL(options.filename).toString();
const dirUrl = dirname.startsWith("file:")
? dirname
? dirname + "/"
: url.pathToFileURL(dirname).toString() + "/";

const mod = new vm.SourceTextModule(code, {
Expand Down Expand Up @@ -150,6 +151,41 @@ async function importString(code, dirname, options) {
return mod.namespace;
}

/**
* Figure out the module type for the given file. If no package.json is
* found, default to "commonjs".
*
* @param {string} filename Fully-qualified filename to start from.
* @returns {Promise<"commonjs"|"es">}
* @throws On invalid package.json
*/
async function guessModuleType(filename) {
const fp = path.parse(filename);
switch (fp.ext) {
case ".cjs": return "commonjs";
case ".mjs": return "es";
default:
// Fall-through
}
let dir = fp.dir;
let prev = undefined;
while (dir !== prev) {
try {
const pkg = await fs.readFile(path.join(dir, "package.json"), "utf8");
const pkgj = JSON.parse(pkg);
return (pkgj.type === "module") ? "es" : "commonjs";
} catch (err) {
// If the file just didn't exist, keep going.
if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT") {
throw err;
}
}
prev = dir;
dir = path.dirname(dir);
}
return "commonjs";
}

/**
* Import or require the given code from memory. Knows about the different
* Peggy output formats. Returns the exports of the module.
Expand All @@ -158,7 +194,6 @@ async function importString(code, dirname, options) {
* @param {FromMemOptions} options Options. Most important is filename.
* @returns {Promise<unknown>} The evaluated code.
*/
// eslint-disable-next-line require-await -- Always want to return a Promise
module.exports = async function fromMem(code, options) {
options = {
format: "commonjs",
Expand All @@ -180,12 +215,18 @@ module.exports = async function fromMem(code, options) {
// @ts-expect-error Context is always non-null
options.context.globalThis = options.context;

if (!options?.filename) {
if (!options.filename) {
throw new TypeError("filename is required");
}
options.filename = path.resolve(options.filename);
if (!options.filename.startsWith("file:")) {
// File URLs must be already resolved.
options.filename = path.resolve(options.filename);
}
const dirname = path.dirname(options.filename);

if (options.format === "guess") {
options.format = await guessModuleType(options.filename);
}
switch (options.format) {
case "bare":
case "commonjs":
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/bad/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{
6 changes: 6 additions & 0 deletions test/fixtures/example.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"use strict";

exports.foo = function foo() {
return 7;
};

3 changes: 3 additions & 0 deletions test/fixtures/mjs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"type": "module"
}
61 changes: 58 additions & 3 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

const assert = require("node:assert");
const fromMem = require("../index.js");
const { join } = require("node:path");
const { join, parse } = require("node:path");
const { pathToFileURL } = require("node:url");
const test = require("node:test");

test("options", async() => {
Expand All @@ -19,15 +20,62 @@ test("commonjs", async() => {
filename: "test1.js",
});
assert.equal(cjs, 4);
await assert.rejects(() => fromMem("throw new Error('foo')", {

const cjs2 = await fromMem(`
const {foo} = require('./fixtures/example.cjs');
module.exports = foo() + 2`, {
filename: join(__dirname, "test2.js"),
});
assert.equal(cjs2, 9);

await assert.rejects(() => fromMem("throw new Error('foo')", {
filename: join(__dirname, "test3.js"),
format: "bare",
}), (/** @type {Error} */ err) => {
assert(/test2\.js/.test(err.stack), err.stack);
assert(/test3\.js/.test(err.stack), err.stack);
return true;
});
});

test("guess", async() => {
const cjs = await fromMem("module.exports = 4", {
filename: join(__dirname, "test_guess1.cjs"),
format: "guess",
});
assert.equal(cjs, 4);

const cjs2 = await fromMem("module.exports = 4", {
filename: join(__dirname, "test_guess2.js"),
format: "guess",
});
assert.equal(cjs2, 4);

// Hope there is not package.json in your root directory. If there is,
// it better be commonjs. :)
const cjs3 = await fromMem("module.exports = 4", {
filename: join(parse(__dirname).root, "test_guess2.js"),
format: "guess",
});
assert.equal(cjs3, 4);

const mjs = await fromMem("export default 4", {
filename: join(__dirname, "test_guess3.mjs"),
format: "guess",
});
assert.equal(mjs.default, 4);

const mjs2 = await fromMem("export default 4", {
filename: join(__dirname, "fixtures", "mjs", "test_guess4.js"),
format: "guess",
});
assert.equal(mjs2.default, 4);

await assert.rejects(() => fromMem("export default 4", {
filename: join(__dirname, "fixtures", "bad", "test_guess5.js"),
format: "guess",
}));
});

test("esm", async() => {
const mjs4 = await fromMem("export default 5", {
filename: join(__dirname, "test4.js"),
Expand Down Expand Up @@ -57,4 +105,11 @@ export default foo();`, {
format: "es",
});
assert.equal(mjs7.default, 6);

const mjs8 = await fromMem(`
export default 8`, {
filename: pathToFileURL(join(__dirname, "test8.js")).toString(),
format: "es",
});
assert.equal(mjs8.default, 8);
});