うならぼ

どうも。

Node.jsがC:hoge.txt形式の相対パスをサポートしてたけど扱いが難しいって話

C:hoge.txt絶対パスでしょうか。答えはNOです。

これはCドライブのカレントディレクトリに対する相対パスを表します。絶対パスならドライブ文字、コロンに続いてバックスラッシュを付けなければなりません。

Naming Files, Paths, and Namespaces (Windows)

大抵はドライブごとのカレントディレクトリなんて保持していないでしょうから、これが真面目に活躍するのはコマンドプロンプトぐらいのものです。と言いつつ、実はWinAPIのGetFullPathName関数やそれを使った.NETのPath.GetFullPathメソッドがサポートしています。

Node.js の path.resolve() はそれに加えて相対パスの基準となるパスを複数指定できるので、

という仕様です。ソースコードはこの辺

で、本題に入ります。

ルート直下に入れた gulp を直接起動できない

つまりこういう package.jsonR:\ 直下に置いてあって、npm start で gulp を起動しようとしたのです。PATHの通ったコマンド増やさなくて済むので私は好きな方法です。

{
  "name": "hoge",
  "version": "1.0.0",
  "description": "",
  "dependencies": {
    "gulp": "^3.9.1",
  },
  "devDependencies": {},
  "scripts": {
    "start": "gulp"
  },
  "author": "",
  "license": "ISC"
}

そしたらこんなエラー。

R:\>npm start

> hoge@1.0.0 start R:\
> gulp

[22:41:55] Using gulpfile R:\gulpfile.js
module.js:472
    throw err;
    ^

Error: Cannot find module 'R:node_modules\gulp\index.js'
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.Module._load (module.js:418:25)
    at Module.require (module.js:498:17)
    at require (internal/module.js:20:19)
    at Liftoff.handleArguments (R:\node_modules\gulp\bin\gulp.js:121:18)
    at Liftoff.<anonymous> (R:\node_modules\liftoff\index.js:199:16)
    at module.exports (R:\node_modules\flagged-respawn\index.js:17:3)
    at Liftoff.<anonymous> (R:\node_modules\liftoff\index.js:191:9)
    at R:\node_modules\liftoff\index.js:165:9
    at R:\node_modules\v8flags\index.js:108:14

なんのこっちゃ。でも R:node_modules\gulp\index.js が怪しいですね。

不思議なことに、グローバルに入れた gulp コマンドをこのディレクトリで叩けば問題なく動きました。

「何故この形式のパスが生まれたのか」「これはrequireできるのか」を探ります。

R:node_modules\gulp\index.js ができるまで

gulpが使っている Liftoff というライブラリは、Node.js本体のモジュール検索処理を真似た resolve というライブラリを使っています。

ディレクトリをさかのぼって検索パスを構築する処理に問題があるのですが、まあまずはコードを見てみましょう。

https://github.com/substack/node-resolve/blob/v1.2.0/lib/node-modules-paths.js

var splitRe = process.platform === 'win32' ? /[\/\\]/ : /\/+/;

var parts = start.split(splitRe);

var dirs = [];
for (var i = parts.length - 1; i >= 0; i--) {
    if (modules.indexOf(parts[i]) !== -1) continue;
    dirs = dirs.concat(modules.map(function(module_dir) {
        return prefix + path.join(
            path.join.apply(path, parts.slice(0, i + 1)),
            module_dir
        );
    }));
}
if (process.platform === 'win32'){
    dirs[dirs.length-1] = dirs[dirs.length-1].replace(":", ":\\");
}

区切り文字 \ or / で分割しておき、そのうちn個を連結して node_modules を連結する、というのを繰り返しています。R:\hoge を分割すると R:hoge になるわけですが、path.join() を使うことで区切り文字を挟んでくれるので安心ですね。

ところで、次の二つの結果は同じでしょうか?

  • path.join(path.join('R:', 'hoge'), 'node_modules')
  • path.join('R:', 'hoge', 'node_modules')

どちらも R:\hoge\node_modules になります。では次は?

  • path.join(path.join('R:'), 'node_modules')
  • path.join('R:', 'node_modules')

上は R:node_modules、下は R:\node_modules になります。

path.join('R:') の結果は R:. という見慣れないものになり、これに何かを連結すると R:node_modules といった形になります。だったら path.join('R:', 'hoge') もそうすべきだと思うんですが、こっちは利便性を優先したんですかねえ。

最後の要素だけ修正するコードが書かれていますが、今回は ["R:node_modules", "R:node_modules"] と要素が重複してるので解決しません。重複してることが問題か。

そうして R:node_modules という検索パスが生まれました。このあと検索パスから gulp/index.js を探すわけですが、今回の場合カレントディレクトリが R:\ なので、fs.stat()fs.readFile() に渡しても R:\node_modules\gulp\index.js と解釈され、特に問題は起きません。

あとは対象モジュールのpackage.jsonにmainが書かれていれば path.resolve() が呼ばれて(この場で)絶対パスになるようですが、gulpのpackage.jsonには書いてないので、resolve 結果は相対パスのままです。

R:node_modules\gulp\index.js はrequireできるのか

冒頭に書いた通りこれは相対パスなので、./../ で始まっていない相対パスを渡すと node_modules 内で検索されるという話と基本的には同じです。場合によってはカレントディレクトリやルートディレクトリになるという点を除いて。

モジュールの検索にあたっては、現在のモジュールが置かれているディレクトリから順にさかのぼっていき*1、それぞれの node_modules ディレクトリ(が存在する場合に限る)を基準に path.resolve() が行われます。ちなみに node_modules\node_modules は省略されます。

例えば R:\node_modules\gulp\bin\gulp.js なら、

  • R:\node_modules\gulp\bin\node_modules 存在しない
  • R:\node_modules\gulp\node_modules 存在しない
  • R:\node_modules\node_modules node_modulesの入れ子は省略
  • R:\node_modules

とまあ候補は最後のひとつしかないんですが、同じドライブなので相対パス扱いになり、R:\node_modules + R:node_modules\gulp\index.js = R:\node_modules\node_modules\gulp\index.js は存在しないので失敗します。

では、グローバルに入れた gulp コマンド経由では成功したのは何故でしょうか。

gulp コマンドを使った場合は、gulp コマンドの場所が起点になります。

  • C:\Users\unarist\AppData\Roaming\npm\node_modules\gulp\bin\node_modules
  • C:\Users\unarist\AppData\Roaming\npm\node_modules\gulp\node_modules
  • C:\Users\unarist\AppData\Roaming\npm\node_modules\node_modules
  • C:\Users\unarist\AppData\Roaming\npm\node_modules
  • C:\Users\unarist\AppData\Roaming\node_modules
  • C:\Users\unarist\AppData\node_modules
  • C:\Users\unarist\node_modules
  • C:\Users\node_modules
  • C:\node_modules

候補が2つ残りましたが、上から行きましょう。というかどっちでもいいんです。

パスの解決を行いますが、C:\... は別ドライブなので相対パスの基点として連結することはできません。次に process.cwd() を確認すると R:\ なので、これを使って R:\ + R:node_modules\gulp\index.js = R:\node_modules\gulp\index.js となります。

おっと、正解のパスにたどり着いてしまいました。

まとめ

require("R:node_modules\\...") と書いても、

のいずれかを満たせば、R:\node_modules\... からロードすることが可能です。

この手の相対パスは意図せずして .\\ から読み込まれる可能性があると言った方がいいかもしれない。例のresolveライブラリにちょろっと入っていた修正もそれが原因っぽいし*2。ちなみに今回のresolveライブラリのバグは先月末に修正がマージされているのでそのうちリリースされるはず。

おまけ

  • Module._load
    • Module._resolveFilename: ロード・キャッシュに使うファイルパスを確定する
    • キャッシュの確認
    • new Module()
    • Module.prototype.load: モジュールの読み込み
      • Module._nodeModulePaths: そのモジュールを起点とした検索パスを用意しておく
      • Module._extensions[ext]: 拡張子ごとのロード処理

*1:実際にはモジュール読み込み時に列挙しておいた module.paths を使う

*2:あれの場合はpackage.jsonにmain書かれてればその場でresolveしちゃう=検索パスの条件がなくなるし、仮にrequireできなくても正しくない結果が返りうることには違いない