├── .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 | }); --------------------------------------------------------------------------------