エフアンダーバー

個人開発の記録

【Webpack】 css-loader周りのSourceMap関連バグと戦う

css-loaderを使っていてSourceMapのパスが壊れるバグにハマったのでその際調べたことのまとめです。

長くなるのでタイトルに入れていませんが、 css-loaderpostcss-loadersass-loader辺りのバグです。

問題

Windows環境で次のようにローダーを構成してSourceMapを生成したところ、SourceMapのパスが壊れる現象が発生しました。

[
        {
            loader: 'css-loader',
            options: {
                url: false,
                importLoaders: 2,
                sourceMap: true
            }
        },
        {
            loader: 'postcss-loader',
            options: {
                sourceMap: true
            }
        },
        {
            loader: 'sass-loader',
            options: {
                includePaths: [
                    path.resolve(__dirname, "src/scss")
                ],
                sourceMap: true
            }
        }
]

壊れ方はだいたい三通りで、

  • C:/foo/bar/C:/foo/barのように謎の繰り返しが起きる
  • ./foo/C:/foo/barのように本来のパスの前に余計なパスが挟まる
  • C:/baz/barのようにカレントディレクトリ(C:/baz)直下にファイルが移動する

といった感じでした。

原因

いろいろと調べた結果、どうも原因がひとつではなかったようなのですべて列挙します。

原因1:パスとURLの混同

おそらくWindowsを使っている場合に発生するバグの大部分がこれです。

そもそもSourceMapにおいてソースの指定にはURLを用いるそうです。 PostCSS(css-loaderおよびpostcss-loaderが内部で使用)やその他多くのライブラリがSourceMapの生成に用いる source-mapパッケージでもURLしか受け入れないようになっています。

しかし、それにも関わらず多くのライブラリがNode.jsのpathモジュールで処理した結果をそのままsource-mapに渡してしまっています。 というのも、Windows以外(POSIX)では多くの場合にファイルシステムのパスをURLとしても解釈できるので問題が起こりづらいためです。

一方、Windowsのパスは基本的にURLとして想定通りには解釈されません。 そもそも区切り文字が違いますし、ドライブレターの問題もあります。 C:/foo/barはWindows的には絶対パスでも、URL的には相対パスです。 絶対パスのURLにするためには/C:/foo/barとする必要があります。

さらに面倒くさいことにせっかく/C:/foo/barというようにURLに変換しても、 これにpath.resolveを適用するとC:\C:\foo\barという壊れたパスに変換されます。 パスをURLとして扱うことも、URLをパスとして扱うこともできないわけです。

ちなみにWindowsだと被害が大きいというだけで、これは特にWindows限定のバグというわけではないと思います。 Windows以外でもURLに使えない文字(スペースや日本語など)があれば、何らかのバグが発生すると思われます。 PostCSS内でも<no source><input css 1>といったデフォルト値が見つかりますが、少し怪しい感じがします。

自分のケースでこれが問題となったのはsass-loaderの出力したSourceMapです。 sass-loaderが内部で用いるnode-sass(あるいはlibsass)の出力がURLに変換されていないため、 ドライブレターを含む絶対パスが相対パスとみなされ、 後のローダーの大元のソースを探す処理でおかしな連結が行われていました (コード)。

また、直接の影響はないみたいですが、 libsassの時点では/だったパスの区切りがsass-loaderのpath.normalizeによって\に変えられています。 sass-loaderもパスとURLを区別していないという証拠なのでちょっと怖いところ。

原因2:相対パスから絶対パスへの誤変換

PostCSSは前述のWindowsの絶対パスの問題を避けるためか、やたらと絶対パスを相対パスに変換しようとします。 sass-loaderから繋いだ場合など、前のSourceMapが存在する場合には元のsourceの値が使われるためあまり相対パスが返ってくることもないのですが、 変換元のsourceが見つからない場合には新しく生成されたものとして処理元のファイルへの相対パスを返します。

このとき返されるパスはSourceMapの生成位置からの相対パスなのですが、 postcss-loaderは何故か返されたパスのみを引数にpath.resolveを呼び出します (コード)。 path.resolveの最初の引数に相対パスを指定した場合、それはカレントディレクトリからの相対パスとみなされるため、 おかしな場所にソースが存在することになってしまいます。 また、path.resolveの結果をそのままSourceMapに設定しているので、postcss-loaderもパスとURLを区別していないことがわかります。

自分のケースでこれが問題となったのは、 font-familyの指定に日本語を指定した結果、libsassによって自動的に@charsetが挿入され、 この行のソースが存在しないことでPostCSSが相対パスを返したためでした。

原因3:sourceRootの消失

css-loaderでは何故か入力のSourceMapのsourceRootをクリアします (コード)。 Issueを少し眺めたところ、css-loaderでのsourceRootのサポートに関しては揺れているようです。

webpackでは最終的にsourceRootを空にしなければならないらしく(参照)、 css-loaderではそのためにsourceRootをクリアしているのだと思われます。 それにしてもsourcesに結合するなり、空じゃないなら例外を投げるなり、もう少しいい方法がありそうな気がしますが。

この処理に関してはソースコードを読んでいる際にたまたま見つけただけで、特に自分が踏み抜いたわけではないのですが、 sass-loader辺りはsourceRootに値を入れたSourceMapを返すので、直接css-loaderに繋げるとバグりそうな気がします。 ちなみにpostcss-loaderを間に挟むと処理中にsourcesに結合されて、sourceRootは空になるので問題は起きません。

原因4:css-loaderのハック

css-loaderでは次のハックによって、PostCSSにパスを書き換えられるのを防いでいます。

pipeline.process(inputSource, {
    // we need a prefix to avoid path rewriting of PostCSS
    from: "/css-loader!" + options.from,
    to: options.to,
    ...

map.sources = map.sources.map(function(source) {
    return source.split("!").pop().replace(/\\/g, '/');
}, this);

どうもこれが原因でパスが壊れるケースがあるみたいです。

また、奇妙なことにこのハックを無効にすると、 前述のURL問題に起因するバグが直ることがあるようです。 そのため自分も最初はこれが原因だと考えていたのですが、 URL問題を解決したらこれがあってもバグが発生しなくなったので関係なかったようです。

原因5:webpackの仕様

ここまでの原因をすべて取り除いて、css-loaderの出力するSourceMapも問題なし、 さあこれで完璧だ、と結果を確認したところ未だにSourceMapのパスが壊れていました。

どうもwebpack自体がドライブレターから始まるWindowsのパスを表すURLである/C:/foo/barという指定を解釈してくれないようです。 結局、先頭の/を取り除いてC:/foo/barに戻したところ正しく表示されました。

さすがに疲れたのでソースコードまでは確認していませんが、原因1のパスURL問題と同じような原因だと思います。

対処

本来はそれぞれの原因に対して修正を加えるべきなのでしょうが、 パッケージ外からそれをしようとするとかなり複雑になるので別の方法で対処することにします。

また、原因3、4については自分の環境で特に問題が発生していないので特に何もしません。

対処1:source-mapのパス対応

原因1は本来source-mapモジュールにファイルシステムのパスを渡してしまう方に問題があるのですが、 あまりに間違っている方が多すぎるので、source-mapモジュール自体をパスに対応させてしまうことで対処します。

幸いにもsource-mapではURL関係の処理をいくつかのユーティリティ関数のみで処理しているようなので、そこに手を加えます。 次のコードをwebpack.config.jsあたりに書いておくと、source-mapが(ある程度)パスを扱ってくれます。

const path = require('path');

if (process.platform === 'win32') {
    const utils = require('source-map/lib/util');
    const { urlParse, normalize, join, isAbsolute, relative } = utils;

    utils.normalize = function (aPath) { return toPath(normalize.call(this, (toUrl(aPath)))); }
    utils.join = function (aRoot, aPath) { return toPath(join.call(this, toUrl(aRoot), toUrl(aPath))); }
    utils.isAbsolute = function (aPath) { return isAbsolute.call(this, aPath) || path.isAbsolute(aPath); }
    utils.relative = function (aRoot, aPath) { return toPath(relative.call(this, toUrl(aRoot), toUrl(aPath))); }

    function toUrl(p) {
        if (!isAbsolute(p)) {
            p = encodeURI(p.replace(/\\/g, "/"));
            return (path.isAbsolute(p) ? "/" + p : p);
        }
        return p;
    }

    function toPath(url) {
        if (!urlParse(url)) {
            url = decodeURI(url).replace(/\//g, "\\");
            return (url.charAt(0) === "\\" ? url.slice(1) : url);
        }
    }
}

URLに変換して処理をした後、再度パスへと戻しているのは、 結果のSourceMapのsourcesやsourceRootにpathモジュールを適用されても問題が発生しないようにするためです。 また、原因5の対処にもなっています。

対処2:PostCSSに相対パスを返させない

原因2はもちろんカレントディレクトリで相対パスをpath.resolveしてしまうことが問題ですが、 postcss-loaderの問題コードの位置が悪くパッチを当てづらいので、相対パスが返ってこないようにすることで対処します。

PostCSSで相対パスが返ってくるのは、対象コードのソースが入力のSourceMapに見つからないことでした。 そこで、sass-loaderとpostcss-loaderの間に独自のローダーを挟んで、SourceMapを加工することにしました。

const { SourceMapConsumer, SourceMapGenerator } = require('source-map');

const CHARSET_CODE = "@charset \"UTF-8\";";

module.exports = function (content, map, meta) {
    if (map) {
        if (content.startsWith(CHARSET_CODE) && map.sourcesContent.every(content => !content.startsWith(CHARSET_CODE))) {
            const consumer = new SourceMapConsumer(map);
            const generator = SourceMapGenerator.fromSourceMap(consumer);
            generator.addMapping({
                source: map.sources[0],
                original: { line: 1, column: 0 },
                generated: { line: 1, column: 0 },
            });
            map = generator.toJSON();
        }
    }

    this.callback(null, content, map, meta);
}

自分の場合は@charset "UTF-8";というコードが挿入されることが原因だったため、 それを探して適当なソースを指定しています。

ローダーの指定はこんな感じ。

{
    loader: 'postcss-loader',
    options: {
        sourceMap: true
    }
},
{
    loader: require.resolve("./my-loader")
},
{
    loader: 'sass-loader',
    options: {
        includePaths: [
            path.resolve(__dirname, "src/scss")
        ],
        sourceMap: true
    }
}

おわりに

最近Webpackに手を出してコードを書くのは非常に快適になったのですが、その分構成周りでかなり苦労しています…。 Web関係は変化が速いせいか定番ツールにも関わらず、結構バグが残っていたりするみたいですね。 特にWindowsは見捨てられているような気がします。



執筆時のバージョン
webpack: 3.8.1
css-loader: 0.28.7
postcss-loader: 2.0.9
sass-loader: 6.0.6