TypeScriptで文字列をモジュールとして実行する

TypeScript にて、任意の文字列を Node.js のモジュールに変換して実行してみます。

例えば module.exports = 1 という文字列が存在するとき、この文字列が変換されたモジュールを実行すると 1 が得られる、そんな動作をする関数についてです。

変換コード

以下のような関数に文字列を渡すと、Node.js で実行できるモジュールに変換されます。ビルドインモジュールの型の拡張方法がわからないので、一部 @ts-ignore でエラーを無視します。

import { dirname } from 'path';

const requireFromString = (code: string, filename: string = '') => {
  const parent = module.parent || undefined;
  const m = new Module(filename, parent);

  m.filename = filename;

  // @ts-ignore
  const paths: string[] = Module._nodeModulePaths(dirname(filename));
  m.paths = paths;

  // @ts-ignore
  m._compile(code, filename);

  return m.exports;
}

使うときはこんな感じ。

const code = `
module.exports = function add(n, m) {
  return n + m;
};
`;

const add = requireFromString(code);

console.log(add(1, 2)); // 3

以下は、Module システムの流れとコードの解説です。

Node.js の Module システム

Node.js の Module について。

// @types/node/globals.d.ts
class Module {
  static runMain(): void;
  static wrap(code: string): string;
  static createRequireFromPath(path: string): (path: string) => any;
  static builtinModules: string[];

  static Module: typeof Module;

  exports: any;
  require: NodeRequireFunction;
  id: string;
  filename: string;
  loaded: boolean;
  parent: Module | null;
  children: Module[];
  paths: string[];

  constructor(id: string, parent?: Module);
}

Module._load

node filename.js が実行されるときや、require('filename') が呼ばれるときになどに実行されます。

モジュールを読み込むとき、読み込み先がキャッシュされていればキャッシュされた Module オブジェクトのモジュールを、そうでなければ新規に作成した Module オブジェクトのモジュールを返します。モジュールは Module オブジェクトの exports からアクセスできます。

// キャッシュされている場合
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
  updateChildren(parent, cachedModule, true);
  return cachedModule.exports;
}

// Module オブジェクト作成
const module = new Module(filename, parent);

// load を実行
module.load(filename)

return module.exports;

Module.prototype.load

ファイル名やディレクトリ名、拡張子などの情報を Module オブジェクトに追加します。また、拡張子ごとに Module._extensions を実行します。

this.filename = filename;
this.paths = Module._nodeModulePaths(path.dirname(filename));

const extension = findLongestRegisteredExtension(filename);
Module._extensions[extension](this, filename);

Module._extensions

拡張子が .js のとき、fs.readFileSync でファイルを読み込み、module._compile でコンパイルします。

Module._extensions['.js'] = function(module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module._compile(content, filename);
};

.json のときは、読み込んだファイルを JSON.parse で JSON オブジェクトに変換して Module オブジェクトの exports に保存します。

Module._extensions['.json'] = function(module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module.exports = JSON.parse(stripBOM(content));
};

変換コードの詳細

Module システムの流れの雰囲気を理解したところで、今回の変換関数を詳しくみていきます。

まず、Module オブジェクト parentfilename を Module に渡して Module オブジェクトを初期化します。

const parent = module.parent || undefined;
const m = new Module(filename, parent);

つぎに、Module オブジェクトのパスを追加します。パスは _nodeModulePaths から取得することができます。このパスはモジュールを探す際に使われます。

const paths: string[] = Module._nodeModulePaths(dirname(filename)); // ['/app/node_modules', '/node_modules'];
m.paths = paths;

そのため、パスの設定をしないまま node_modules のライブラリを読み込もうとするとエラーが発生します。

const code = `
const express = require('express');
const app = express();
module.exports = app;
`;

try {
  const app = requireFromString(code);
 app('*', (_, res) => res.send('ok!'))
} catch (error) {
  console.log(error);
/*
{ Error: Cannot find module 'express'
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:582:15)
    at Function.Module._load (internal/modules/cjs/loader.js:508:25)
    at Module.require (internal/modules/cjs/loader.js:637:17)
    at require (internal/modules/cjs/helpers.js:22:18)
    at Object.<anonymous> (<anonymous>:2:17)
    at Module._compile (internal/modules/cjs/loader.js:701:30)
    at requireFromString (/app/src/sandbox/index.ts:36:5)
    at Object.<anonymous> (/app/src/sandbox/index.ts:52:15)
    at Module._compile (internal/modules/cjs/loader.js:701:30)
    at Module.m._compile (/usr/local/share/.config/yarn/global/node_modules/ts-node/src/index.ts:473:23) code: 'MODULE_NOT_FOUND'
}
 */
}

さいごに、Module オブジェクトのコンパイルメソッドを実行して、文字列からモジュールに変換します。

m._compile(code, filename);

return m.exports;

おしまい。