From 3e467e254db82e429898e16ba923774c10106a59 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Sat, 17 Feb 2024 15:29:25 -0700 Subject: [PATCH] Add type 'guess' --- README.md | 6 ++-- index.js | 63 ++++++++++++++++++++++++++++------ test/fixtures/bad/package.json | 1 + test/fixtures/example.cjs | 6 ++++ test/fixtures/mjs/package.json | 3 ++ test/index.test.js | 61 ++++++++++++++++++++++++++++++-- 6 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 test/fixtures/bad/package.json create mode 100644 test/fixtures/example.cjs create mode 100644 test/fixtures/mjs/package.json diff --git a/README.md b/README.md index c57e879..2653676 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ fromMem(code: string, options: FromMemOptions): Promise 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 diff --git a/index.js b/index.js index 4da4617..c7dffc0 100644 --- a/index.js +++ b/index.js @@ -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. @@ -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. @@ -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, { @@ -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. @@ -158,7 +194,6 @@ async function importString(code, dirname, options) { * @param {FromMemOptions} options Options. Most important is filename. * @returns {Promise} 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", @@ -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": diff --git a/test/fixtures/bad/package.json b/test/fixtures/bad/package.json new file mode 100644 index 0000000..98232c6 --- /dev/null +++ b/test/fixtures/bad/package.json @@ -0,0 +1 @@ +{ diff --git a/test/fixtures/example.cjs b/test/fixtures/example.cjs new file mode 100644 index 0000000..820d318 --- /dev/null +++ b/test/fixtures/example.cjs @@ -0,0 +1,6 @@ +"use strict"; + +exports.foo = function foo() { + return 7; +}; + diff --git a/test/fixtures/mjs/package.json b/test/fixtures/mjs/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/test/fixtures/mjs/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/index.test.js b/test/index.test.js index a691bac..8f508e4 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -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() => { @@ -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"), @@ -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); });