Node.jsのパッケージマネージャはnpmおよびその代替となるyarnが有名です。共に、次期バージョンアップ(npm tinkおよびyarn v2であるberry)ではゼロインストールを目指しています。
今回は、モジュールハックを使ってゼロインストールを実装してみました。
ゼロインストールとは
npm i
やyarn
を実行するとnode_modules/
以下にnpmが展開されます。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-fetch
でres.body
を使うとbody
をStream
として取得できるので、
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
と新しいパス指定を元に、modname
と modpath
を書き換えます。
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.info
のmain
に書かれたモジュールを新たな名前として採用します。
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])
requireStack
に modname
とfilename
をpush
します。
const code = pkgs[modname].data[filename].toString()
const module = new Module()
module._compile(code, filename)
modname
とfilename
が確定しているためオンメモリしたファイルからソースコードを取り出し、module._compule
により実行をします。
requireStack.pop()
console.log('R', requireStack.length, name, module.exports)
return module.exports || null
})
return unhack
}
あとは、requireStack
をpop
してから、module.exports
を返せばモジュールハックの完了です。
まとめ
パッケージレジストリからパッケージ情報とパッケージのtarballを取得できるので、オンメモリでgzip+tarを展開しておく。モジュールハックを使って対象モジュールを指定された場合にはそのモジュールのソースコードを取り出し、module._compile
でNode.js環境に応じた処理を実行させてから、module.exports
を取り出す。
これらによりゼロインストールは実現可能です。
-
https://github.com/noxtjs/gateway/tree/master/src
- module_hack.ts
- npm/
ソースコードに関しては、これらを見るといいでしょう。
ただし、このやり方が正しいか?というと検証がまだ全然足りていません。おそらくpackage.json
のscripts
でpre-install
だのpost-install
だのをしているもの、ネイティブモジュールなどでは動作しないでしょう。黒魔術めいたコードの場合も動かない可能性は普通に考えられます。
筆者の手元では、uuidv4
npm パッケージのゼロインストール実行はできています。
宣伝
技術書典7にお越しいただいた皆様ありがとうございました。
技術書典7で出した本のPDF電子版を、Pixiv Boothで頒布しております。よろしければどうぞ。