├── .gitignore
├── test
├── dependency.js
├── dependency-with-import.js
├── dependency-with-import-npm.js
├── test-utils.js
├── manual-deno-test-cli.js
├── walk-code.test.js
├── manual-node-test.js
└── import-module-string.test.js
├── .npmignore
├── src
├── parse-code.js
├── supports.js
├── stringify-data.js
├── url.js
├── resolve.js
├── walk-code.js
└── preprocess-imports.js
├── vitest.browser.config.js
├── demo.html
├── .github
└── workflows
│ └── ci.yml
├── package.json
├── README.md
└── import-module-string.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | deno.lock
--------------------------------------------------------------------------------
/test/dependency.js:
--------------------------------------------------------------------------------
1 | export default 2;
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | test/
3 | vitest.*
4 | deno.lock
5 | demo.html
6 | .github
--------------------------------------------------------------------------------
/test/dependency-with-import.js:
--------------------------------------------------------------------------------
1 | import num from "./dependency.js";
2 |
3 | export {num};
--------------------------------------------------------------------------------
/test/dependency-with-import-npm.js:
--------------------------------------------------------------------------------
1 | import { noop } from "@zachleat/noop";
2 |
3 | export { noop };
--------------------------------------------------------------------------------
/test/test-utils.js:
--------------------------------------------------------------------------------
1 | import { assert } from "vitest"
2 |
3 | export async function expectError(fn) {
4 | let error;
5 | try {
6 | await fn();
7 | } catch(e) {
8 | error = e;
9 | }
10 | assert.isOk(error);
11 | return error;
12 | }
--------------------------------------------------------------------------------
/src/parse-code.js:
--------------------------------------------------------------------------------
1 | import { parse } from "acorn";
2 |
3 | export function parseCode(code, parseOptions = {}) {
4 |
5 | parseOptions.sourceType ??= "module";
6 | parseOptions.ecmaVersion ??= "latest";
7 |
8 | return parse(code, parseOptions);
9 | }
--------------------------------------------------------------------------------
/src/supports.js:
--------------------------------------------------------------------------------
1 | export function importFromBlob() {
2 | if(typeof Blob === "undefined") {
3 | return false;
4 | }
5 |
6 | let b = new Blob(['/* import-from-string Blob Feature Test */'], { type: "text/javascript" });
7 | let u = URL.createObjectURL(b);
8 |
9 | return import(/* @vite-ignore */u).then(mod => {
10 | URL.revokeObjectURL(u);
11 | return true;
12 | }, error => {
13 | URL.revokeObjectURL(u);
14 | return false;
15 | });
16 | }
--------------------------------------------------------------------------------
/test/manual-deno-test-cli.js:
--------------------------------------------------------------------------------
1 | import { importFromString } from "../import-module-string.js";
2 |
3 | let result = await importFromString(`
4 | import num from "./test/dependency.js";
5 | export { num };
6 | export var a = 1;
7 | export const c = 3;
8 | export let b = 2;`);
9 |
10 | // We don’t want any Deno security request prompts:
11 | // console.log( process.env.NODE );
12 |
13 | console.log("Output", result, "should equal", { a: 1, c: 3, b: 2, num: 2 });
--------------------------------------------------------------------------------
/src/stringify-data.js:
--------------------------------------------------------------------------------
1 | export function stringifyData(data = {}) {
2 | return Object.entries(data).map(([varName, varValue]) => {
3 | // JSON
4 | return `const ${varName} = ${JSON.stringify(varValue, function replacer(key, value) {
5 | if(typeof value === "function") {
6 | throw new Error(`Data passed to 'import-module-string' needs to be JSON.stringify friendly. The '${key || varName}' property was a \`function\`.`);
7 | }
8 | return value;
9 | })};`;
10 | }).join("\n");
11 | }
--------------------------------------------------------------------------------
/vitest.browser.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 | import os from "node:os";
3 |
4 | let instances = [{ browser: "chromium" }, { browser: "firefox" }];
5 |
6 | if (os.type() === "Darwin") {
7 | instances.push({ browser: "webkit" });
8 | }
9 |
10 | export default defineConfig({
11 | test: {
12 | browser: {
13 | enabled: true,
14 | headless: true,
15 | screenshotFailures: false,
16 | provider: 'playwright',
17 | // https://vitest.dev/guide/browser/playwright
18 | instances,
19 | },
20 | },
21 | })
22 |
--------------------------------------------------------------------------------
/src/url.js:
--------------------------------------------------------------------------------
1 | import { importFromBlob } from "./supports.js";
2 |
3 | // async but we await for it below (no top-level await for wider compat)
4 | const SUPPORTS_BLOB_IMPORT = importFromBlob();
5 |
6 | export async function getTarget(codeStr) {
7 | if(await SUPPORTS_BLOB_IMPORT) {
8 | // Node 15.7+
9 | return new Blob([codeStr], { type: "text/javascript" });
10 | }
11 |
12 | return getTargetDataUri(codeStr);
13 | }
14 |
15 | // Node can’t do import(Blob) yet https://github.com/nodejs/node/issues/47573
16 | // This is also used in-browser to inline via `fetch`
17 | export function getTargetDataUri(codeStr) {
18 | return `data:text/javascript;charset=utf-8,${encodeURIComponent(codeStr)}`;
19 | }
--------------------------------------------------------------------------------
/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Eleventy in a Browser
6 |
16 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches-ignore:
4 | - "gh-pages"
5 | jobs:
6 | testnode:
7 | runs-on: ${{ matrix.os }}
8 | strategy:
9 | matrix:
10 | os: ["ubuntu-latest", "macos-latest", "windows-latest"]
11 | node: ["18", "20", "22", "24"]
12 | name: Vitest Node ${{ matrix.node }} on ${{ matrix.os }}
13 | steps:
14 | - uses: actions/checkout@v3
15 | - name: Setup node
16 | uses: actions/setup-node@v3
17 | with:
18 | node-version: ${{ matrix.node }}
19 | cache: npm
20 | - run: npm ci
21 | - run: npm run test:node-manual && npm run test:node
22 | testbrowser:
23 | runs-on: ${{ matrix.os }}
24 | strategy:
25 | matrix:
26 | os: ["ubuntu-latest", "macos-latest", "windows-latest"]
27 | node: ["22"]
28 | name: Vitest Browser Mode ${{ matrix.node }} on ${{ matrix.os }}
29 | steps:
30 | - uses: actions/checkout@v3
31 | - name: Setup node
32 | uses: actions/setup-node@v3
33 | with:
34 | node-version: ${{ matrix.node }}
35 | cache: npm
36 | - run: npm ci
37 | - run: npx playwright install
38 | - run: npm run test:browser
39 | env:
40 | YARN_GPG: no
41 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "import-module-string",
3 | "version": "2.0.3",
4 | "description": "Use import('data:') and import(Blob) to execute arbitrary JavaScript strings",
5 | "main": "import-module-string.js",
6 | "scripts": {
7 | "test:browser": "vitest --config vitest.browser.config.js",
8 | "test:node-manual": "node --test test/manual-node-test.js",
9 | "test:node": "vitest --environment=node",
10 | "test:deno-cli": "echo '** Manual test for Deno is temporary until Vitest runs in Deno **\n' && deno test/manual-deno-test-cli.js",
11 | "test": "npm run test:node-manual && CI=true npm run test:node && CI=true npm run test:browser",
12 | "demo": "npx http-server ."
13 | },
14 | "type": "module",
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/zachleat/import-module-string.git"
18 | },
19 | "author": {
20 | "name": "Zach Leatherman",
21 | "email": "zachleatherman@gmail.com",
22 | "url": "https://zachleat.com/"
23 | },
24 | "license": "MIT",
25 | "devDependencies": {
26 | "@11ty/eleventy-utils": "^2.0.7",
27 | "@vitest/browser": "^3.2.4",
28 | "@zachleat/noop": "^1.0.6",
29 | "http-server": "^14.1.1",
30 | "playwright": "^1.54.2",
31 | "serialize-to-js": "^3.1.2",
32 | "vitest": "^3.2.4"
33 | },
34 | "dependencies": {
35 | "acorn": "^8.15.0",
36 | "acorn-walk": "^8.3.4",
37 | "esm-import-transformer": "^3.0.5"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/resolve.js:
--------------------------------------------------------------------------------
1 | import { resolveModule } from "../import-module-string.js";
2 |
3 | function isValidUrl(ref) {
4 | // Use URL.canParse some day
5 | try {
6 | new URL(ref);
7 | return true;
8 | } catch(e) {
9 | return false;
10 | }
11 | }
12 |
13 | function isRelativeRef(ref) {
14 | return ref.startsWith("/") || ref.startsWith("./") || ref.startsWith("../");
15 | }
16 |
17 | function isAbsolute(ref) {
18 | return ref.startsWith("file:///") || isValidUrl(ref);
19 | }
20 |
21 | function getModuleReferenceMode(ref) {
22 | if(ref.startsWith("data:")) {
23 | return "data";
24 | }
25 |
26 | if(isAbsolute(ref)) {
27 | return "absolute";
28 | }
29 |
30 | if(isRelativeRef(ref)) {
31 | return "relative";
32 | }
33 |
34 | // unknown, probably a bare specifier
35 | return "bare";
36 | }
37 |
38 | function resolveLocalPaths(ref, root) {
39 | if(!root) {
40 | throw new Error("Missing `root` to resolve import reference");
41 | }
42 |
43 | // Unresolved relative urls
44 | if(root.startsWith("file:///")) {
45 | let u = new URL(ref, root);
46 | return u.href;
47 | }
48 |
49 | let rootUrl = new URL(root, `file:`);
50 | let {href, pathname} = new URL(ref, rootUrl);
51 |
52 | // `fs` mode
53 | if(href.startsWith("file:///")) {
54 | return "./" + href.slice(`file:///`.length);
55 | }
56 |
57 | // `url` mode
58 | return pathname;
59 | }
60 |
61 | export function getModuleInfo(name, root) {
62 | let mode = getModuleReferenceMode(name);
63 | let info = {
64 | name,
65 | mode,
66 | original: {
67 | path: name,
68 | mode,
69 | }
70 | };
71 |
72 | if(mode === "relative" && root) {
73 | // resolve relative paths to the virtual or real file path of the script
74 | try {
75 | root = resolveModule(root);
76 | } catch(e) {
77 | // Unresolvable `filePath`, recover gracefully
78 | }
79 |
80 | name = resolveLocalPaths(name, root);
81 | }
82 |
83 | try {
84 | let u = resolveModule(name);
85 | info.path = u;
86 | info.mode = getModuleReferenceMode(u);
87 | info.isMetaResolved = true;
88 | } catch(e) {
89 | // unresolvable name, recover gracefully
90 | info.path = name;
91 | info.isMetaResolved = false;
92 | // console.error( e );
93 | }
94 |
95 | return info;
96 | }
97 |
98 |
--------------------------------------------------------------------------------
/test/walk-code.test.js:
--------------------------------------------------------------------------------
1 | import { assert, test } from "vitest"
2 | import { expectError } from "./test-utils.js";
3 |
4 | import { importFromString, walkCode, parseCode } from "../import-module-string.js"
5 |
6 | const isNodeMode = typeof process !== "undefined" && process?.env?.NODE;
7 |
8 | test("Get import targets", t => {
9 | let code = `import { noop } from '@zachleat/noop';
10 | import fs from "node:fs"`;
11 | let ast = parseCode(code);
12 | let { imports } = walkCode(ast);
13 | assert.deepEqual(imports, new Set(["@zachleat/noop", "node:fs"]));
14 | });
15 |
16 | test("export anonymous function", async t => {
17 | let code = "export default function() {}";
18 | let ast = parseCode(code);
19 | let { imports } = walkCode(ast);
20 | // imports ie empty
21 | assert.deepEqual(imports, new Set());
22 | });
23 |
24 | test("Walk, then import", async t => {
25 | let code = `import fs from 'node:fs';`;
26 | let ast = parseCode(code);
27 | let { imports } = walkCode(ast);
28 | assert.deepEqual(imports, new Set(["node:fs"]));
29 |
30 | if(isNodeMode) {
31 | let res = await importFromString(code, { ast });
32 | assert.isOk(res.fs);
33 | } else {
34 | // Browsers throw an error
35 | try {
36 | await importFromString(code, { ast });
37 | } catch(e) {
38 | let messages = [
39 | "Failed to fetch dynamically imported module:", // Chrome
40 | "error loading dynamically imported module:", // Firefox
41 | "Importing a module script failed.", // Safari
42 | ]
43 | assert.isOk(messages.find(msg => e.message.startsWith(msg)), e.message);
44 | }
45 | }
46 | });
47 |
48 | test.skipIf(!isNodeMode)("Walk, then import a non-built-in", async t => {
49 | const { isBuiltin } = await import("node:module");
50 |
51 | let code = `import { noop } from '@zachleat/noop';`;
52 | let ast = parseCode(code);
53 | let { imports } = walkCode(ast);
54 |
55 | let nonBuiltinImports = Array.from(imports).filter(name => !isBuiltin(name));
56 | if(nonBuiltinImports.length > 0) {
57 | // In Node this *could* throw an error but some day this may be supported?
58 | // In Browsers this may work if an Import Map is correctly configured.
59 | // Upstream scripts can escape to node-retreieve-globals in this case
60 | // throw new Error("Cannot import non-built-in modules via import-module-string: " + nonBuiltinImports.join(", "))
61 | }
62 |
63 | let error = await expectError(async () => {
64 | await importFromString(code, { ast });
65 | });
66 |
67 | assert.isOk(error.message.startsWith("Failed to resolve module specifier") || error.message === "Invalid URL", error.message);
68 | });
69 |
70 |
--------------------------------------------------------------------------------
/src/walk-code.js:
--------------------------------------------------------------------------------
1 | import * as walk from "acorn-walk";
2 |
3 | export function walkCode(ast) {
4 | let globals = new Set();
5 | let imports = new Set();
6 | let references = new Set();
7 |
8 | let features = {
9 | export: false,
10 | require: false,
11 | importMetaUrl: false
12 | };
13 |
14 | let types = {
15 | Identifier(node) {
16 | // variables used, must not be an existing global or host object
17 | if(node?.name && !(node?.name in globalThis)) {
18 | references.add(node?.name)
19 | }
20 | },
21 | MetaProperty(node) {
22 | // This script uses `import.meta.url`
23 | features.importMetaUrl = true;
24 | },
25 | CallExpression(node) {
26 | if(node?.callee?.name === "require") {
27 | features.require = true;
28 | }
29 | // function used
30 | if(node?.callee?.name && !(node?.callee?.name in globalThis)) {
31 | references.add(node.callee.name);
32 | }
33 | },
34 | // e.g. var b = function() {}
35 | // FunctionExpression is already handled by VariableDeclarator
36 | // FunctionExpression(node) {},
37 | FunctionDeclaration(node) {
38 | if(node?.id?.name) {
39 | globals.add(node.id.name);
40 | }
41 | },
42 | VariableDeclarator(node) {
43 | // destructuring assignment Array
44 | if(node?.id?.type === "ArrayPattern") {
45 | for(let prop of node.id.elements) {
46 | if(prop?.type === "Identifier") {
47 | globals.add(prop.name);
48 | }
49 | }
50 | } else if(node?.id?.type === "ObjectPattern") {
51 | // destructuring assignment Object
52 | for(let prop of node.id.properties) {
53 | if(prop?.type === "Property") {
54 | globals.add(prop.value.name);
55 | }
56 | }
57 | } else if(node?.id?.name) {
58 | globals.add(node.id.name);
59 | }
60 | },
61 | // if imports aren’t being transformed to variables assignment, we need those too
62 | ImportSpecifier(node) {
63 | // `name` in `import { name } from 'package'`
64 | globals.add(node.imported.name);
65 | },
66 | ImportDeclaration(node) {
67 | imports.add(node.source.value);
68 | },
69 | ImportDefaultSpecifier(node) {
70 | // `name` in `import name from 'package'`
71 | globals.add(node.local.name);
72 | },
73 | ImportNamespaceSpecifier(node) {
74 | globals.add(node.local.name);
75 | },
76 | ExportSpecifier(node) {
77 | features.export = true;
78 | },
79 | ExportNamedDeclaration(node) {
80 | features.export = true;
81 | },
82 | ExportAllDeclaration(node) {
83 | features.export = true;
84 | }
85 | };
86 |
87 | walk.simple(ast, types);
88 |
89 | // remove declarations from used
90 | for(let name of globals) {
91 | references.delete(name);
92 | }
93 |
94 | return {
95 | ast,
96 | globals,
97 | imports,
98 | features,
99 | used: references,
100 | };
101 | }
--------------------------------------------------------------------------------
/src/preprocess-imports.js:
--------------------------------------------------------------------------------
1 | import { ImportTransformer } from "esm-import-transformer";
2 |
3 | // in-browser `emulateImportMap` *could* be a dynamically inserted
4 | // Import Map some day (though not yet supported in Firefox):
5 | // https://github.com/mdn/mdn/issues/672
6 |
7 | class TransformerManager {
8 | constructor(ast) {
9 | this.ast = ast;
10 | }
11 |
12 | getTransformer(code) {
13 | if(!this.transformer) {
14 | this.transformer = new ImportTransformer(code, this.ast);
15 | } else {
16 | // first one is free, subsequent calls create a new transformer (AST is dirty)
17 | this.transformer = new ImportTransformer(code);
18 | }
19 |
20 | return this.transformer;
21 | }
22 | }
23 |
24 | function getArgumentString(names) {
25 | let argString = "";
26 | if(!Array.isArray(names)) {
27 | names = Array.from(names)
28 | }
29 | names = names.filter(Boolean);
30 |
31 | if(names.length > 0) {
32 | argString = `{ ${names.join(", ")} }`;
33 | }
34 | return argString;
35 | }
36 |
37 | export async function preprocess(codeStr, { resolved, ast, used, compileAsFunction }) {
38 | let importMap = {
39 | imports: {}
40 | };
41 |
42 | for(let {path, name, target, isMetaResolved} of resolved) {
43 | if(target) { // from `resolveImportContent` when overriding how content is fetched (preferred to meta resolved targets)
44 | importMap.imports[name] = target;
45 | } else if(isMetaResolved) { // resolved path
46 | importMap.imports[name] = path;
47 | }
48 | }
49 |
50 | // Warning: if you use both of these features, it will re-parse between them
51 | // Could improve this in `esm-import-transformer` dep
52 | if(Object.keys(importMap?.imports || {}).length > 0 || compileAsFunction) {
53 | let code = codeStr;
54 | let transformerManager = new TransformerManager(ast);
55 |
56 | // Emulate Import Maps
57 | if(Object.keys(importMap?.imports || {}).length > 0) {
58 | let transformer = transformerManager.getTransformer(code);
59 | code = transformer.transformWithImportMap(importMap);
60 | }
61 |
62 | if(compileAsFunction) {
63 | let transformer = transformerManager.getTransformer(code);
64 | let stripped = transformer.transformRemoveImportExports();
65 | let { imports, namedExports } = transformer.getImportsAndExports();
66 |
67 | // TODO we could just use the unprocessed code here if we detect a default export?
68 | if(namedExports.has("default")) {
69 | throw new Error("`export default` is not (yet) supported by the `compileAsFunction` option.");
70 | }
71 |
72 | code = `// import-module-string modified JavaScript
73 | // Boost top-level imports:
74 | ${Array.from(imports).join("\n") || "// No imports found"}
75 |
76 | // Wrapper function:
77 | export default function(${getArgumentString(used)}) {
78 | ${stripped}
79 |
80 | // Returns named exports:
81 | return ${getArgumentString(namedExports) || '{}'}
82 | };`;
83 | }
84 |
85 | return code;
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/test/manual-node-test.js:
--------------------------------------------------------------------------------
1 | import test from "node:test";
2 | import assert from "node:assert/strict";
3 | import { importFromString } from "../import-module-string.js";
4 |
5 | // This test only exists because of a Vitest issue with import.meta.resolve https://github.com/vitest-dev/vitest/issues/6953
6 | test("import from npmpackage (inlined)", async (t) => {
7 | let res = await importFromString("import { noop } from '@zachleat/noop';");
8 |
9 | assert.equal(typeof res.noop, "function");
10 | });
11 |
12 | test("import from local script with import (inline), sanity check on importing data uris", async t => {
13 | let res = await importFromString(`import num from "data:text/javascript;charset=utf-8,export%20default%202%3B";`, {
14 | });
15 | assert.equal(res.num, 2);
16 | });
17 |
18 | test("import from local script", async t => {
19 | let res = await importFromString("import num from './test/dependency.js';");
20 |
21 | assert.equal(res.num, 2);
22 | });
23 |
24 | test("import from local script (with file path)", async t => {
25 | let res = await importFromString("import num from './dependency.js';", {
26 | filePath: "./test/DOES_NOT_EXIST.js",
27 | });
28 |
29 | assert.equal(res.num, 2);
30 | });
31 |
32 | test("import from local script with import local script", async t => {
33 | let res = await importFromString("import {num} from './test/dependency-with-import.js';");
34 |
35 | assert.equal(res.num, 2);
36 | });
37 |
38 | test("import from local script with import local script (with file path)", async t => {
39 | let res = await importFromString("import {num} from './dependency-with-import.js';", {
40 | filePath: "./test/DOES_NOT_EXIST.js",
41 | });
42 |
43 | assert.equal(res.num, 2);
44 | });
45 |
46 | test("import from local script with import npm package", async t => {
47 | let res = await importFromString("import {noop} from './test/dependency-with-import-npm.js';");
48 |
49 | assert.equal(typeof res.noop, "function");
50 | });
51 |
52 | test("import from local script with import npm package", async t => {
53 | let res = await importFromString("import {noop} from './dependency-with-import-npm.js';", {
54 | filePath: "./test/DOES_NOT_EXIST.js",
55 | });
56 |
57 | assert.equal(typeof res.noop, "function");
58 | });
59 |
60 | test("Use compileAsFunction to return function wrapper (with an import)", async t => {
61 | let mod = await importFromString(`import {num} from './test/dependency-with-import.js';
62 | export { num };
63 | export const ret = fn();`, {
64 | compileAsFunction: true,
65 | });
66 |
67 | // This avoids data serialization altogether and brings the code back into your current scope
68 | let res = await mod.default({
69 | fn: function() { return 1 }
70 | });
71 |
72 | assert.equal(res.num, 2);
73 | assert.equal(res.ret, 1);
74 | });
75 |
76 | test("Use compileAsFunction to return function wrapper (with a package import)", async t => {
77 | let mod = await importFromString(`import { noopSync } from '@zachleat/noop';
78 |
79 | export function getNoop() {
80 | // important to use the import here
81 | return noopSync() + "1";
82 | };`, {
83 | compileAsFunction: true,
84 | });
85 |
86 | // This avoids data serialization altogether and brings the code back into your current scope
87 | let res = await mod.default();
88 |
89 | assert.equal(typeof res.getNoop, "function");
90 | assert.equal(res.getNoop(), "undefined1");
91 | });
92 |
93 | test("Use compileAsFunction with data", async t => {
94 | let mod = await importFromString(`export const b = myVar;`, {
95 | compileAsFunction: true,
96 | });
97 |
98 | // This avoids data serialization altogether and brings the code back into your current scope
99 | let res = await mod.default({ myVar: 999 });
100 |
101 | assert.equal(res.b, 999);
102 | });
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # `import-module-string`
2 |
3 | Use `import('data:')` and `import(Blob)` to execute arbitrary JavaScript strings. A simpler alternative to [`node-retrieve-globals`](https://github.com/zachleat/node-retrieve-globals/) that works in more runtimes.
4 |
5 | ## Installation
6 |
7 | Available on `npm` as [`import-module-string`](https://www.npmjs.com/package/import-module-string).
8 |
9 | ```
10 | npm install import-module-string
11 | ```
12 |
13 | ## Features
14 |
15 | - Multi-runtime: tested with Node (18+), Deno (limited), Chromium, Firefox, and WebKit.
16 | - Defers to `export` when used, otherwise implicitly `export` all globals (via `var`, `let`, `const`, `function`, `Array` or `Object` destructuring assignment, `import` specifiers, etc)
17 | - Supports top-level async/await (as expected for ES modules)
18 | - Emulates `import.meta.url` when `filePath` option is supplied
19 | - `addRequire` option adds support for `require()` (in Node)
20 | - Extremely limited dependency footprint (`acorn` for JS parsing only)
21 | - Supports data object to pass in data (must be JSON.stringify friendly, more serialization options may be added later)
22 | - Subject to URL content [size maximums](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data#length_limitations): Chrome `512MB`, Safari `2048MB`, Firefox `512MB`, Firefox prior to v137 `32MB`
23 |
24 | |Feature|Server|Browser|
25 | |---|---|---|
26 | |`import('./file.js')`|✅|✅ (Import Map-friendly)|
27 | |`import('bare')`|✅|✅ (Import Map-friendly)|
28 | |`import('built-in')`|✅|_N/A_|
29 | |`require()`|✅ with `addRequire` option|❌|
30 | |`import.meta.url`|✅ with `filePath` option|✅ with `filePath` option|
31 |
32 | Notes:
33 |
34 | - [built-in](https://nodejs.org/api/module.html#moduleisbuiltinmodulename) modules are provided by the JavaScript runtime. `node:fs` is one example.
35 | - `bare` specifiers are packages referenced by their bare name. In Node this might be a package installed from npm.
36 |
37 | ## Usage
38 |
39 | Import the script first!
40 |
41 | ```js
42 | import { importFromString } from "import-module-string";
43 | ```
44 |
45 | View the [test suite file](https://github.com/zachleat/import-module-string/blob/main/test/import-module-string.test.js) for more examples.
46 |
47 | ### Export
48 |
49 | ```js
50 | await importFromString(`export var a = 1;
51 | export const c = 3;
52 | export let b = 2;`);
53 |
54 | // Returns
55 | { a: 1, c: 3, b: 2 }
56 | ```
57 |
58 | ### No export
59 |
60 | ```js
61 | import { importFromString } from "import-module-string";
62 |
63 | await importFromString(`var a = 1;
64 | const c = 3;
65 | let b = 2;`);
66 |
67 | // Returns
68 | { a: 1, c: 3, b: 2 }
69 | ```
70 |
71 | ### Pass in data
72 |
73 | ```js
74 | await importFromString("const a = b;", { data: { b: 2 } });
75 |
76 | // Returns
77 | { a: 2 }
78 | ```
79 |
80 | ### Pass in filePath
81 |
82 | ```js
83 | await importFromString("const a = import.meta.url;", { filePath: import.meta.url });
84 |
85 | // Returns value for import.meta.url, example shown
86 | { a: `file:///…` }
87 | ```
88 |
89 | ### Imports (experimental feature)
90 |
91 | #### Relative references
92 |
93 | ```js
94 | // `dependency.js` has the content `export default 2;`
95 | await importFromString("import dep from './dependency.js';");
96 |
97 | // Returns
98 | { dep: 2 }
99 | ```
100 |
101 | #### Bare references
102 |
103 | Uses `import.meta.resolve` to resolve paths, which will also resolve using Import Maps (where available).
104 |
105 | ```js
106 | // maps with `import.meta.resolve("@zachleat/noop"))` in-browser (Import Map friendly)
107 | await importFromString("import {noop} from '@zachleat/noop';");
108 |
109 | // Returns
110 | { noop: function() {} }
111 | ```
112 |
113 | #### Builtins
114 |
115 | ```js
116 | await importFromString("import fs from 'node:fs';");
117 |
118 | // Returns (where available: `node:fs` is not typically available in browser)
119 | { fs: { /* … */ } }
120 | ```
121 |
122 | As a side note, you _can_ shim `fs` into the browser with [`memfs`](https://github.com/streamich/memfs).
123 |
124 | ## Changelog
125 |
126 | - `v2.0.0` removes `adapter` (no longer necessary!)
127 | - `v1.0.5` bug fixes
128 | - `v1.0.4` add `adapter` option (add `adapter: "fs"` or `adapter: "fetch"`) to resolve imports in various environments.
--------------------------------------------------------------------------------
/import-module-string.js:
--------------------------------------------------------------------------------
1 | import { parseCode } from "./src/parse-code.js";
2 | import { walkCode } from "./src/walk-code.js";
3 | import { stringifyData } from "./src/stringify-data.js";
4 | import { getModuleInfo } from "./src/resolve.js";
5 | import { getTarget, getTargetDataUri } from "./src/url.js";
6 | import { preprocess } from "./src/preprocess-imports.js";
7 |
8 | export { parseCode, walkCode, getTarget, getTargetDataUri, getModuleInfo };
9 |
10 | // Keep this function in root (not `src/resolve.js`) to maintain for-free root relative import.meta.url
11 | export function resolveModule(ref) {
12 | // Supported in Node v20.6.0+, v18.19.0+, Chrome 105, Safari 16.4, Firefox 106
13 | if(!("resolve" in import.meta)) {
14 | // We *could* return Boolean when import.meta.resolve is not supported
15 | // return true would mean that a browser with an Import Map *may* still resolve the module correctly.
16 | // return false would mean that this module would be skipped
17 |
18 | // Supports `import.meta.resolve` vs Import Maps
19 | // Chrome 105 vs 89
20 | // Safari 16.4 vs 16.4
21 | // Firefox 106 vs 108
22 |
23 | // Vitest issue with import.meta.resolve https://github.com/vitest-dev/vitest/issues/6953
24 | throw new Error(`\`import.meta.resolve\` is not available.`);
25 | }
26 |
27 | // Notes about Node:
28 | // - `fs` resolves to `node:fs`
29 | // - `resolves` with all Node rules about node_modules
30 | // Works with import maps when supported
31 | return import.meta.resolve(ref);
32 | }
33 |
34 | export async function getCode(codeStr, options = {}) {
35 | let { ast, acornOptions, data, filePath, implicitExports, addRequire, resolveImportContent, serializeData: stringifyDataOptionCallback, compileAsFunction } = Object.assign({
36 | data: {},
37 | filePath: undefined,
38 | implicitExports: true, // add `export` if no `export` is included in code
39 | addRequire: false, // add polyfill for `require()` (Node-only)
40 |
41 | resolveImportContent: undefined,
42 | serializeData: undefined,
43 | // TODO add explicit importMap object option
44 |
45 | // Internal
46 | ast: undefined,
47 | acornOptions: {}, // see defaults in walk-code.js
48 |
49 | // Returns a default export function wrapped around the code for execution later (with your own custom context).
50 | // `import` and `export`-friendly and avoids the need for any data serialization!
51 | compileAsFunction: false,
52 | }, options);
53 |
54 | ast ??= parseCode(codeStr, acornOptions);
55 |
56 | let { globals, features, imports, used } = walkCode(ast);
57 |
58 | let resolved = Array.from(imports).map(u => getModuleInfo(u, filePath));
59 |
60 | // Important: Node supports importing builtins here, this adds support for resolving non-builtins
61 | // This allows the use of an `fs` adapter in-browser!
62 | if(typeof resolveImportContent === "function") {
63 | for(let moduleInfo of resolved) {
64 | // { path, mode, resolved }
65 | let moduleInfoArg = {
66 | ...moduleInfo.original,
67 | ...({ resolved: moduleInfo.isMetaResolved ? moduleInfo.path : undefined }),
68 | }
69 | let content = await resolveImportContent(moduleInfoArg);
70 | if(content) {
71 | let code = await getCode(content, {
72 | filePath: moduleInfo.path,
73 | resolveImportContent,
74 | });
75 |
76 | if(code?.trim()) {
77 | // This needs to be `getTargetDataUri` in-browser (even though it supports Blob urls).
78 | moduleInfo.target = await getTargetDataUri(code);
79 | }
80 | }
81 | }
82 | }
83 |
84 | // exports are returned as globals
85 | if(compileAsFunction) {
86 | implicitExports = false;
87 | }
88 |
89 | let result = await preprocess(codeStr, { globals, features, imports, used, resolved, ast, compileAsFunction });
90 | if(typeof result === "string") {
91 | codeStr = result;
92 | }
93 |
94 | let pre = [];
95 | let post = [];
96 |
97 | // When filePath is specified, we supply import.meta.url
98 | if(filePath && features.importMetaUrl) {
99 | codeStr = codeStr.replaceAll("import.meta.url", "__importmetaurl"); // same length as import.meta.url
100 | data.__importmetaurl = filePath;
101 | }
102 |
103 | pre.push(typeof stringifyDataOptionCallback === "function" ? await stringifyDataOptionCallback(data) : stringifyData(data));
104 |
105 | if(addRequire) {
106 | pre.push(`import { createRequire } from "node:module";\nconst require = createRequire("${filePath || "/"}");\n`);
107 | }
108 |
109 | // add `export { ...globals }` but only if the code is *NOT* already using `export`
110 | if(implicitExports && !features.export && globals.size > 0) {
111 | post.push(`export { ${Array.from(globals).join(", ")} }`);
112 | }
113 |
114 | let transformedCode = pre.join("\n") + codeStr + (post.length > 0 ? `\n${post.join("\n")}` : "");
115 | return transformedCode;
116 | };
117 |
118 | // Thanks https://stackoverflow.com/questions/57121467/import-a-module-from-string-variable
119 | export async function importFromString(codeStr, options = {}) {
120 | let code = await getCode(codeStr, options);
121 | let target = await getTarget(code);
122 |
123 | // createObjectURL and revokeObjectURL are Node 16+
124 | // https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL_static
125 |
126 | // Needed for Vitest in browser :(
127 | if(Boolean(globalThis['__vitest_browser__'])) {
128 | return import(/* @vite-ignore */URL.createObjectURL(target));
129 | }
130 |
131 | if(target instanceof Blob) {
132 | let url = URL.createObjectURL(target);
133 | return import(/* @vite-ignore */url).then(mod => {
134 | URL.revokeObjectURL(url);
135 | return mod;
136 | });
137 | }
138 |
139 | // Promise
140 | return import(/* @vite-ignore */target);
141 | }
142 |
--------------------------------------------------------------------------------
/test/import-module-string.test.js:
--------------------------------------------------------------------------------
1 | import { assert, test } from "vitest"
2 | import { isPlainObject } from "@11ty/eleventy-utils";
3 | import serialize from "serialize-to-js";
4 |
5 | import { expectError } from "./test-utils.js";
6 | // import { emulateImportMap } from "../src/emulate-importmap.js";
7 | import { importFromString } from "../import-module-string.js"
8 |
9 | const isNodeMode = typeof process !== "undefined" && Boolean(process?.env?.NODE);
10 | const isVitestBrowserMode = Boolean(globalThis['__vitest_browser__']);
11 |
12 | test("Using export", async () => {
13 | let res = await importFromString(`export var a = 1;
14 | export const c = 3;
15 | export var b = 2;`);
16 | assert.containsSubset(res, {a: 1, b: 2, c: 3});
17 | });
18 |
19 | test("Without export", async () => {
20 | let res = await importFromString(`var a = 1;
21 | var b = 2;`);
22 | assert.containsSubset(res, {a: 1, b: 2});
23 | })
24 |
25 | test("Using date", async () => {
26 | let res = await importFromString(`const mydate = new Date();`);
27 | assert.instanceOf(res.mydate, Date);
28 | });
29 |
30 | test("isPlainObject", async () => {
31 | let res = await importFromString(`const mydate = new Date();`);
32 | assert.isTrue(isPlainObject(res));
33 | })
34 |
35 | test("isPlainObject (deep)", async () => {
36 | let res = await importFromString(`var a = { b: 1, c: { d: {} } };`);
37 | assert.isTrue(isPlainObject(res.a));
38 | assert.isTrue(isPlainObject(res.a.c.d));
39 | })
40 |
41 | test("isPlainObject (circular)", async () => {
42 | let res = await importFromString(`
43 | var a = { a: 1 };
44 | var b = { b: a };
45 | a.b = b;`);
46 |
47 | assert.isTrue(isPlainObject(res.a.b));
48 | assert.isTrue(isPlainObject(res.b.b));
49 | })
50 |
51 | test("var using passed-in data", async t => {
52 | let res = await importFromString("var a = b;", { data: { b: 2 } });
53 | assert.containsSubset(res, { a: 2 });
54 | });
55 |
56 | test("let using passed-in data", async t => {
57 | let res = await importFromString("let a = b;", { data: { b: 2 } });
58 | assert.containsSubset(res, { a: 2 });
59 | });
60 |
61 | test("const using passed-in data", async t => {
62 | let res = await importFromString("const a = b;", { data: { b: 2 } });
63 | assert.containsSubset(res, { a: 2 });
64 | });
65 |
66 | test("function", async t => {
67 | let res = await importFromString("function testFunction() {}");
68 | assert.instanceOf(res.testFunction, Function);
69 | });
70 |
71 | test("function expression", async t => {
72 | let res = await importFromString("const functionExpression = function() {};");
73 | assert.instanceOf(res.functionExpression, Function);
74 | });
75 |
76 | test("async let", async t => {
77 | let res = await importFromString("let b = await Promise.resolve(1);");
78 | assert.containsSubset(res, { b: 1 });
79 | });
80 |
81 | test("Destructured assignment via object", async t => {
82 | let res = await importFromString(`const { a, b } = { a: 1, b: 2 };`);
83 | assert.typeOf(res.a, "number");
84 | assert.typeOf(res.b, "number");
85 | assert.equal(res.a, 1);
86 | assert.equal(res.b, 2);
87 | });
88 |
89 | test("Destructured assignment via Array", async t => {
90 | let res = await importFromString(`const [a, b] = [1, 2];`);
91 | assert.typeOf(res.a, "number");
92 | assert.typeOf(res.b, "number");
93 | assert.equal(res.a, 1);
94 | assert.equal(res.b, 2);
95 | });
96 |
97 | test("Same console.log", async t => {
98 | let res = await importFromString(`const b = console.log`);
99 | assert.equal(res.b, console.log);
100 | });
101 |
102 | test("Same URL", async t => {
103 | let res = await importFromString(`const b = URL`);
104 | assert.equal(res.b, URL);
105 | });
106 |
107 | test("Return array", async t => {
108 | let res = await importFromString(`let b = [1,2,3];`);
109 | assert.deepEqual(res.b, [1,2,3]);
110 | });
111 |
112 | test("JSON unfriendly data throws error", async t => {
113 | let error = await expectError(() => importFromString(`const b = fn;`, {
114 | data: {
115 | fn: function() {}
116 | }
117 | }))
118 |
119 | assert.isOk(error.message.startsWith("Data passed to 'import-module-string' needs to be JSON.stringify friendly."), error.message);
120 | });
121 |
122 | test("Use custom serializeData callback function", async t => {
123 | let res = await importFromString("const ret = fn()", {
124 | data: {
125 | fn: function() { return 1 }
126 | },
127 | serializeData: function(data) {
128 | return Object.entries(data).map(([varName, varValue]) => {
129 | return `const ${varName} = ${serialize(varValue)};`;
130 | }).join("\n");
131 | }
132 | });
133 | assert.typeOf(res.ret, "number");
134 | assert.equal(res.ret, 1);
135 | });
136 |
137 | test("export anonymous function", async t => {
138 | let res = await importFromString("export default function() {}");
139 | assert.typeOf(res.default, "function");
140 | });
141 |
142 | test("import.meta.url (no filePath)", async t => {
143 | let res = await importFromString("const b = import.meta.url;");
144 | assert.isTrue(res.b.startsWith("data:text/javascript;") || res.b.startsWith("blob:"));
145 | });
146 |
147 | test("import.meta.url (filePath override)", async t => {
148 | let res = await importFromString("const b = import.meta.url;", {
149 | filePath: import.meta.url
150 | });
151 |
152 | assert.equal(res.b, import.meta.url);
153 | });
154 |
155 | /*
156 | * Node-only tests
157 | */
158 |
159 | test.skipIf(!isNodeMode || process.version.startsWith("v18."))("import.meta.url used in createRequire (with filePath)", async t => {
160 | let res = await importFromString("const { default: dep } = require('../test/dependency.js');", {
161 | addRequire: true,
162 | filePath: import.meta.url,
163 | });
164 |
165 | assert.typeOf(res.dep, "number");
166 | });
167 |
168 | test.skipIf(!isNodeMode)("import from node:fs (builtin)", async t => {
169 | let res = await importFromString("import fs from 'node:fs'; export { fs };");
170 | assert.isOk(res.fs);
171 | });
172 |
173 | test.skipIf(!isNodeMode)("import from node:fs (builtin, no export)", async t => {
174 | let res = await importFromString("import fs from 'node:fs';");
175 | assert.isOk(res.fs);
176 | });
177 |
178 | test.skipIf(!isNodeMode)("import from node:module (builtin)", async t => {
179 | let res = await importFromString("import module from 'node:module'; export { module };");
180 | assert.isOk(res.module);
181 | });
182 |
183 | test.skipIf(!isNodeMode)("import from node:module (builtin, no export)", async t => {
184 | let res = await importFromString("import module from 'node:module';");
185 | assert.isOk(res.module);
186 | });
187 |
188 | test.skipIf(!isNodeMode)("import * from node:module (builtin)", async t => {
189 | let res = await importFromString("import * as module from 'node:module'; export { module }");
190 | assert.isOk(res.module);
191 | });
192 |
193 | test.skipIf(!isNodeMode)("import * from node:module (builtin, no export)", async t => {
194 | let res = await importFromString("import * as module from 'node:module';");
195 | assert.isOk(res.module);
196 | });
197 |
198 | test.skipIf(!isNodeMode)("error: import from npmpackage", async t => {
199 | let error = await expectError(async () => {
200 | await importFromString("import { noop } from '@zachleat/noop';");
201 | });
202 | assert.isOk(error.message.startsWith(`Failed to resolve module specifier "@zachleat/noop"`) || error.message === "Invalid URL", error.message);
203 | });
204 |
205 | test.skipIf(!isNodeMode)("require(builtin)", async t => {
206 | let res = await importFromString("const fs = require('node:fs'); export { fs };", {
207 | addRequire: true
208 | });
209 | assert.isOk(res.fs);
210 | assert.isNotOk(res.require);
211 | });
212 |
213 | test.skipIf(!isNodeMode)("require(builtin), no export", async t => {
214 | let res = await importFromString("const fs = require('node:fs');", {
215 | addRequire: true
216 | });
217 | assert.isOk(res.fs);
218 | assert.isNotOk(res.require);
219 | });
220 |
221 | test.skipIf(!isNodeMode)("error: require(npm package)", async t => {
222 | let error = await expectError(async () => {
223 | await importFromString("const { noop } = require('@zachleat/noop'); export { noop };", {
224 | addRequire: true
225 | });
226 | });
227 | assert.isOk(error.message.startsWith("Cannot find module '@zachleat/noop'"), error.message);
228 | });
229 |
230 | test.skipIf(!isNodeMode)("error: require(npm package), no export", async t => {
231 | let error = await expectError(async () => {
232 | await importFromString("const { noop } = require('@zachleat/noop');", {
233 | addRequire: true
234 | });
235 | });
236 | assert.isOk(error.message.startsWith("Cannot find module '@zachleat/noop'"), error.message);
237 | });
238 |
239 | test.skipIf(!isNodeMode)("dynamic import(builtin)", async t => {
240 | let res = await importFromString(`const { default: fs } = await import("node:fs");`);
241 | assert.isOk(res.fs);
242 | });
243 |
244 | test.skipIf(!isNodeMode)("error: dynamic import(npm package)", async t => {
245 | let error = await expectError(async () => {
246 | await importFromString(`const { noop } = await import("@zachleat/noop");`);
247 | });
248 |
249 | assert.isOk(error.message.startsWith("Failed to resolve module specifier") || error.message === "Invalid URL", error.message);
250 | });
251 |
252 | /*
253 | * Combo Node and Browser tests may not work in Vitest in Node (if the code path relies on import.meta.resolve)
254 | */
255 |
256 | test("resolveImportContent", async t => {
257 | let res = await importFromString(`import dep from './test/dep1.js';
258 | import dep2 from './test/dep2.js';`, {
259 | resolveImportContent: function(moduleInfo) {
260 | assert.isOk(moduleInfo.path === "./test/dep1.js" || moduleInfo.path === "./test/dep2.js")
261 | assert.isOk(moduleInfo.mode === "relative")
262 | // assert.isOk(moduleInfo.resolved) // Not in Vite
263 |
264 | // This allows us to write our own adapters based on module information
265 | // In this test we just simply always return `2`
266 | return `export default 2;`
267 | }
268 | });
269 |
270 | assert.equal(res.dep, 2);
271 | assert.equal(res.dep2, 2);
272 | });
273 |
274 | // Tests that import from relative references *WORK* but are not supported in Node + Vitest https://github.com/vitest-dev/vitest/issues/6953
275 | // We run these tests separately using Node’s Test Runner: see test/manual-node-test.js
276 | test.skipIf(isNodeMode)("import from local script (inline)", async t => {
277 | let res = await importFromString("import dep from './test/dependency.js';");
278 |
279 | assert.typeOf(res.dep, "number");
280 | });
281 |
282 | // Tests that import from relative references *WORK* but are not supported in Vitest https://github.com/vitest-dev/vitest/issues/6953
283 | // We run these tests separately using Node’s Test Runner: see test/manual-node-test.js
284 | test.skipIf(isNodeMode)("import from local script (inline) with import local script", async t => {
285 | let res = await importFromString("import {num} from './test/dependency-with-import.js';");
286 |
287 | assert.equal(res.num, 2);
288 | });
289 |
290 | // Tests that import from npm packages *WORK* but are not supported in Vitest https://github.com/vitest-dev/vitest/issues/6953
291 | // We run these tests separately using Node’s Test Runner: see test/manual-node-test.js
292 | test.skip("import from npmpackage (inlined)", async t => { /* .skipIf(!isNodeMode) */
293 | let res = await importFromString("import { noop } from '@zachleat/noop';");
294 | assert.typeOf(res.noop, "number");
295 | });
296 |
297 | test("Use compileAsFunction to return function wrapper", async t => {
298 | let mod = await importFromString(`export const ret = fn();`, {
299 | compileAsFunction: true,
300 | });
301 |
302 | // This avoids data serialization altogether and brings the code back into your current scope
303 | let res = await mod.default({
304 | fn: function() { return 1 }
305 | });
306 |
307 | assert.typeOf(res.ret, "number");
308 | assert.equal(res.ret, 1);
309 | });
310 |
311 | // Tests that import from npm packages *WORK* but are not supported in Vitest https://github.com/vitest-dev/vitest/issues/6953
312 | // We run these tests separately using Node’s Test Runner: see test/manual-node-test.js
313 | test.skipIf(isNodeMode)("Use compileAsFunction to return function wrapper (with an import)", async t => {
314 | let mod = await importFromString(`import {num} from './test/dependency-with-import.js';
315 | export { num };
316 | export const ret = fn();`, {
317 | compileAsFunction: true,
318 | });
319 |
320 | // This avoids data serialization altogether and brings the code back into your current scope
321 | let res = await mod.default({
322 | fn: function() { return 1 }
323 | });
324 |
325 | assert.typeOf(res.num, "number");
326 | assert.equal(res.num, 2);
327 | assert.typeOf(res.ret, "number");
328 | assert.equal(res.ret, 1);
329 | });
--------------------------------------------------------------------------------