104
70

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Node.jsでゼロインストールを実現する

Posted at

Node.jsのパッケージマネージャはnpmおよびその代替となるyarnが有名です。共に、次期バージョンアップ(npm tinkおよびyarn v2であるberry)ではゼロインストールを目指しています。

今回は、モジュールハックを使ってゼロインストールを実装してみました。

ゼロインストールとは

npm iyarnを実行するとnode_modules/以下にnpmが展開されます。node_modulesはブラックホールよりも深いと言われています。

node_modulesはブラックホールよりも深い

この反省点をもとにパッケージシステムそのものを否定したプロジェクトを、Node.jsのオリジナル作者であるryがdenoとして立ち上げております。

実際node_modulesはプロジェクトによっては簡単に数百MBytes以上の容量を食い散らかします。手元で様々なJavaScriptプロジェクトを動かしていると、128GBや256GB程度のSSDではいとも簡単にディスクフルを招きます。

そこで実行時にパッケージをfetchするかキャッシュからのみ読み込むことで、オンメモリでパッケージを解決するというアプローチがゼロインストールです。

ゼロインストールのためにパッケージをオンメモリに展開する

npmパッケージは、npmpkg.comやyarnpkg.comなどでホスティングされています(最近GitHubでもnpmやgemなどのホスティングサービスのbetaが始まっています)。

さて、npmpkg.com や yarnpkg.com では、パッケージ情報の取得やダウンロードだけならとても簡単です。所定のURLにGETでアクセスするだけで、jsonもしくはgzip/tarされたアーカイブを取得できます。

import fetch from 'node-fetch'

export const fetchPkg = async (name: string, version?: string) => {
  const url = `https://registry.yarnpkg.com/${name}`
  const json = await fetch(url).then(res => res.json())

  let pkgs: { [props: string]: { info: any; data: any } } = {}

  if (!version) {
    const { latest } = json['dist-tags']
    version = latest
  }

  const info = json.versions[version!]
  if (info.dependencies) {
    await Promise.all(
      Object.keys(info.dependencies).map(async name => {
        pkgs = {
          ...pkgs,
          ...(await fetchPkg(name, info.dependencies[name])),
        }
      }),
    )
  }

  const tarball = await fetch(info.dist.tarball).then(res => res.body)
  const data = await extractTar(tarball)
  pkgs[name] = { info, data }

  return pkgs
}

この TypeScript のコードでは register.yarnpkg.com から、情報(JSON)を取得してバージョンなどの情報を取得し、バージョンを指定していなければ最新版(latest)をダウンロードします。またdependencies も同様にダウンロードします。

やっていることは全体的には、async/await および Promise.all を使って、並列でパッケージダウンロードしているだけです。

最後のほうのconst data = await extractTar(tarball)では、取得した.tgzのtarballを展開しています。

この関数が返すデータは、パッケージ名をキーとして、infoプロパティにJSONデータ、dataプロパティにtarballを展開したものを格納しています。

tarballの展開

import zlib from 'zlib'

import tar from 'tar-stream'

const extractTar = (tarball: NodeJS.ReadableStream) => {
  return new Promise((resolve, reject) => {
    const files: { [props: string]: string } = {}
    const gunzip = zlib.createGunzip({})
    tarball
      .pipe(gunzip)
      .on('error', err => reject(err))
      .pipe(tar.extract())
      .on('error', err => reject(err))
      .on('finish', () => resolve(files))
      .on('entry', (headers, stream, next) => {
        let { name } = headers
        name = name.replace(/^package\//, '')
        if (!(name in files)) {
          files[name] = ''
        }
        stream.on('data', data => {
          files[name] += data.toString()
        })
        stream.on('error', err => reject(err))
        stream.on('end', () => next())
      })
  })
}

tarballはまずgzipで圧縮されたバイナリを伸張しなければいけません。それについては、Node.js zlib APIを使います。また Node.js では Node.js Stream API の Stream で大体のエコシステムができあがってしまっているため、全体を Stream 処理で行っています。

node-fetchres.bodyを使うとbodyStreamとして取得できるので、

    const gunzip = zlib.createGunzip({})
    tarball
      .pipe(gunzip)

で、前処理として、gzipの伸張を行います。これをさらにpipeで渡してtar-stream npm パッケージでエントリごとに取り出します。

このtar-streamはかなりクセが強い挙動を示すのでご注意ください。

      .pipe(tar.extract())
      .on('error', err => reject(err))
      .on('finish', () => resolve(files))
      .on('entry', (headers, stream, next) => {
        let { name } = headers
        name = name.replace(/^package\//, '')
        if (!(name in files)) {
          files[name] = ''
        }
        stream.on('data', data => {
          files[name] += data.toString()
        })
        stream.on('error', err => reject(err))
        stream.on('end', () => next())
      })

.pipe(tar.extract())で継続される Stream では通常とは異なりfinishイベントとentryイベントを使います。

entryイベントではヘッダ情報の取得と、データ本体の為の新規Streamを受け取ります。そのため、このようなストリーム処理のネストが発生します。データ本体のストリームは比較的素直なストリームです。

パッケージは、package/のprefixが付いてるため問答無用で剥がしておきます。

データストリームでは、dataイベントでデータをオンメモリに追加しておき、endイベントで、ストリームの次を促すためにnext()を実行します。

finishイベントが流れてくるとこれらの処理が一通り完了です。

今回はまとめて全部を1つのPromise化しているため、finish時に、resolveでPromiseを完了状態に持って行きます。

モジュールハックでオンメモリのパッケージを返すようにする

ここがゼロインストールの本領です。

その前にモジュールハックのコードを

export const Module = require('module') as any

export const originalLoad = Module._load

export const originalExts: { [props: string]: any } = {}

Object.keys(Module._extensions).forEach(ext => {
  originalExts[ext] = Module._extensions[ext]
})

type Hook = (m: any, filename: string) => any
export const hackExt = (ext: string, hook: Hook) => {
  Module._extensions[ext] = function(m: any, filename: string) {
    return hook(m, filename)
  }
}

export const builtinModules: string[] = Module.builtinModules

type Loader = (name: string, parent: any, isMain: boolean) => any

let loaders: Loader[] = []
export const hackLoader = (loader: Loader) => {
  loaders.push(loader)
  Module._load = function(name: string, parent: any, isMain: boolean) {
    for (const l of loaders) {
      const res = l(name, parent, isMain)
      if (res !== undefined) {
        return res
      }
    }
    return originalLoad(name, parent, isMain)
  }

  return () => {
    loaders = loaders.filter(l => l !== loader)
  }
}

モジュールハックのコードを汎用化したものです。今回は hackLoader関数を使います。

export const hackZeroinstall = (pkgs: any) => {
  const requireStack: [string, string][] = []

  const pkgNames = Object.keys(pkgs)
  if (pkgNames.length === 0) {
    return
  }
  console.log(`hackZeroinstall ${pkgNames}`)

ここでexportしているhackZeroinstallを、先ほどのfetchPkgで取ってきたpkgsデータをぶち込むと、オンメモリ展開されたパッケージを読み込むことができる、つまりゼロインストール処理が完成します。

requireStackは、ゼロインストール処理中のモジュール呼び出しスタックです。文字列2つのタプルの配列になっています。後ほど説明しますが、文字列は、モジュール名と、モジュールのパスです。

ここでは pkgs が空なら何もしません。

  const unhack = hackLoader((name, parent, isMain) => {
    let [modname, modpath] = name.split('/', 2)

    const isRelative = name.startsWith('.') || name.startsWith('/')
    if (requireStack.length === 0 && isRelative) {
      return
    }

ここでは、まずimportのソースパスやrequireの引数 name を '/' で区切ることによりモジュール名とパスを切り離します。

./で始まるローカルモジュールの場合でかつ、hack中でなければそのまま何もしません。

    if (isRelative) {
      ;[modname, modpath] = resolveRelative(requireStack, name)
    }

相対パスの場合、requireStack と新しいパス指定を元に、modnamemodpath を書き換えます。

const resolveRelative = (requireStack: [string, string][], name: string) => {
  const prev = requireStack[requireStack.length - 1]
  console.log('prev', prev)
  return [prev[0], path.join(path.dirname(prev[1]), name)]
}

まずスタックの1つ前を読み込みます。前述の通り、タプルの1つめはモジュール名 modname なのでそのまま返します。タプルの2つめはモジュールパスなので、path.dirname でパス名を取り出し、今回のパス指定を結合することで、パス解決を行います。

    if (!pkgNames.includes(modname)) {
      return
    }

ここまでの処理でmodnameは完全に確定しているため、modnameがもしpkgsの中 Object.keys(pkgs) になければ、hack処理をせずに帰ります。

    console.log('load hack:', name, modname, modpath)

    const filename = resolveFile(pkgs[modname], modpath)

次にファイル名を確定します。

const resolveFile = (pkg: any, modpath: string) => {
  let name = modpath || pkg.info.main

modpathが空の場合、package.infomainに書かれたモジュールを新たな名前として採用します。

  const exts = ['.js', '.json', '.node']

  if (exts.find(ext => name.endsWith(ext))) {
    return name
  }

modpathが拡張子で終わる場合はその拡張子をそのまま使えるためここでファイル名としては完成です。

  const files = Object.keys(pkg.data).filter(n =>
    exts.find(ext => n === `${name}${ext}` || n === `${name}/index${ext}`),
  )
  console.log(files)
  if (files.length > 0) {
    return files[0]
  }

  console.log('------------------------')
  console.log(modpath)
  console.log(Object.keys(pkg.data))
  console.log('------------------------')
  throw new Error(`not found ${modpath}`)
}

処理がここまで及ぶ場合は、拡張子無しでファイル名を指定しているか、ディレクトリそのものを指しています。

まず、そこで拡張子を付与したものや、/index + 拡張子を付与したものにヒットするかどうか調べます。

もし複数ヒットすれば、ヒットした一番はじめのファイルを採用します。

※本当はもっと真面目に https://nodejs.org/api/modules.html に従ってファイル名決定をする必要がありそうです。

さてここまでで、モジュール名とファイル名が完全に確定しました。

    requireStack.push([modname, filename])
    console.log('PUSH', [modname, filename])

requireStackmodnamefilenamepushします。

    const code = pkgs[modname].data[filename].toString()
    const module = new Module()
    module._compile(code, filename)

modnamefilenameが確定しているためオンメモリしたファイルからソースコードを取り出し、module._compuleにより実行をします。

    requireStack.pop()
    console.log('R', requireStack.length, name, module.exports)
    return module.exports || null
  })
  return unhack
}

あとは、requireStackpopしてから、module.exportsを返せばモジュールハックの完了です。

まとめ

パッケージレジストリからパッケージ情報とパッケージのtarballを取得できるので、オンメモリでgzip+tarを展開しておく。モジュールハックを使って対象モジュールを指定された場合にはそのモジュールのソースコードを取り出し、module._compileでNode.js環境に応じた処理を実行させてから、module.exportsを取り出す。

これらによりゼロインストールは実現可能です。

ソースコードに関しては、これらを見るといいでしょう。

ただし、このやり方が正しいか?というと検証がまだ全然足りていません。おそらくpackage.jsonscriptspre-installだのpost-installだのをしているもの、ネイティブモジュールなどでは動作しないでしょう。黒魔術めいたコードの場合も動かない可能性は普通に考えられます。

筆者の手元では、uuidv4 npm パッケージのゼロインストール実行はできています。

宣伝

技術書典7にお越しいただいた皆様ありがとうございました。

技術書典7で出した本のPDF電子版を、Pixiv Boothで頒布しております。よろしければどうぞ。

104
70
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
104
70

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?