await import(...) に絶対パスの文字列を渡すコードが、macOS や Linux では動くのに、Windows でだけ次のエラーで落ちることがあります。
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. Received protocol 'c:'
注目するのは末尾の Received protocol 'c:' です。あなたは URL のつもりでパスを渡していないのに、Node は C:\... の先頭の C: を URL のスキーム(http: や file: と同じ位置)だと解釈しています。原因は、Windows の絶対パスがドライブレターで始まることと、動的 import() が受け取るのがパスではなく URLであることのすれ違いです。
直し方は、パスを pathToFileURL() で file:// URL に変換してから渡すことです。
なぜ手元(Mac/Linux)では出ないのか
動的 import() の引数は、相対指定子(./foo.js)かパッケージ名か、そうでなければ URL として解釈されます。ここに OS ネイティブの絶対パスをそのまま渡すと、OS によって結果が分かれます。
- macOS / Linux の絶対パス(
/home/app/plugin.mjs) … 先頭が/なので、Node はこれをfile://URL 相当に解決できます。そのまま読めてしまうため、問題に気づきません。 - Windows の絶対パス(
C:\app\plugin.mjs) … 先頭がC:です。URL の文法では「スキーム名 +:」の形(c:)に見えるため、Node はfileでもdataでもnodeでもない未対応スキームとして弾きます。
同じ await import(absolutePath) というコードが、開発機(Mac/Linux)では通り、Windows の同僚や Windows の CI でだけ落ちる、というすれ違いになります。path.join() や path.resolve()、__dirname から組み立てた絶対パスを、pathToFileURL を挟まずに import() へ渡している箇所が疑わしいところです。
// 落ちる書き方:OS ネイティブの絶対パスをそのまま import に渡す
import path from 'node:path';
const pluginPath = path.resolve(dir, 'plugin.mjs');
// Windows では pluginPath は "C:\app\plugin.mjs" → protocol 'c:' で弾かれる
const plugin = await import(pluginPath);
require() にはこの制約はありません。CommonJS の require() はファイルパスを受け取るので、対象が CommonJS として読めるモジュールなら require('C:\\app\\plugin.js') は動きます。ESM の import() に移したときに顕在化するのが、この違いです。
直し方:pathToFileURL で file URL にしてから import する
パスを URL に変換するのは推測でやる作業ではなく、標準ライブラリの node:url に専用の関数があります。pathToFileURL() は、OS ネイティブの絶対パスを正しい file:// URL に変換します。
import { pathToFileURL } from 'node:url';
import path from 'node:path';
const pluginPath = path.resolve(dir, 'plugin.mjs');
const plugin = await import(pathToFileURL(pluginPath).href);
pathToFileURL('C:\\app\\plugin.mjs') は Windows では file:///C:/app/plugin.mjs を返します。ドライブレターは URL のパスの一部に収まり、区切りのバックスラッシュもスラッシュに直され、file: スキームが付きます。これで import() は URL として受け取れます。macOS / Linux でも同じコードが file:///home/app/plugin.mjs を返すので、OS を分岐せずにそのまま両方で動きます(Windows での C:\app\plugin.mjs → file:///C:/app/plugin.mjs の変換は Node 公式ドキュメントの url.pathToFileURL に拠る動作で、errfix の機械検証は Linux コンテナで行っています。詳しくは検証環境の節)。手元で問題が出ていなかった環境でも、pathToFileURL を通しておくのが安全です。
.href を付けているのは、import() に渡すのが文字列だからです。pathToFileURL が返すのは URL オブジェクトなので、その文字列表現(.href)を渡します。URL オブジェクトのまま渡しても実行時には解決されますが、TypeScript では import() の型が文字列を要求するため、.href を付けておくほうが無難です。ログや連結でも文字列のほうが扱いやすくなります。
版・経路によってメッセージの文言が少し変わる
エラーの ERR_UNSUPPORTED_ESM_URL_SCHEME というコードは変わりませんが、Only URLs with a scheme in: ... の許可スキームの並びは、Node の版や弾かれ方で少し違います。たとえば http:// を import した場合、Node 18 / 20 では file, data, and node、Node 22 では file and data(node が並びから外れる)と表示されます。ドライブレターのパス('c:')については、確認した範囲では版をまたいで file, data, and node のままでした。
検索やログの突き合わせでは、この可変な文言の部分ではなく、コード名 ERR_UNSUPPORTED_ESM_URL_SCHEME と Received protocol '...' の中身で見ると版によらず一致します。'c:' ならドライブレター、'http:' なら後述の URL 読み込みです。
切り分け(うまくいかないとき)
Received protocol 'http:'(またはhttps:)が出る:これはドライブレターではなく、http(s)://の URL をそのままimport()した場合です。Node の既定のローダはリモート URL を読み込みません。ローカルに取得してから file URL として読むか、採用している Node の版がサポートするローダを使うかを検討してください。ドライブレターの件とは原因が別です。ERR_INVALID_MODULE_SPECIFIER(encoded"\"を含む、というメッセージ)に変わった:pathToFileURLに渡すパスが、実行している OS のネイティブ形式になっていない可能性があります。たとえば macOS / Linux 上で Windows 形式の文字列(C:\...)をpathToFileURLに渡すと、バックスラッシュが URL エンコードされて壊れた URL になります。pathToFileURLには、いま動いている OS が生成した絶対パス(path.resolveなどの出力)を渡してください。data:の URL は読めている:data:は許可スキームなので、import('data:text/javascript,export default 1')は落ちません。エラーが出るのはfile/data/node以外のスキームに解決されたときです。- 相対指定子に変えたら別のエラーになった:
./plugin.mjsのような相対指定子は、絶対パスとは別扱いで、呼び出し元のファイルからの相対で解決されます。動的に組み立てた場所を読みたいのに相対指定子へ変えると、今度は基準位置がずれてERR_MODULE_NOT_FOUNDになりがちです。絶対的な場所を読みたいなら、相対化ではなくpathToFileURLで URL 化するのが筋です。 importではなくrequireでは動いていた:CommonJS のrequire()はファイルパスを受け取るので、同じ絶対パスでも通っていました。ESM へ移行してimport()にした箇所が、この差でだけ落ちます。requireに戻すのではなく、pathToFileURLで URL 化して ESM のまま直します。
検証環境
node:20(node:18/node:22でも同じシグネチャを確認)、Linux コンテナ、ネットワーク無し- 再現:
await import('C:\\app\\plugins\\hello.mjs')→ERR_UNSUPPORTED_ESM_URL_SCHEME(Received protocol 'c:')で終了コード 1 - 修正:実在するモジュールを
await import(pathToFileURL(pluginPath).href)で読み込み、終了コード 0
再現から修正までは errfix の検証ハーネスが機械的に確認しています。Windows のドライブレターパスが 'c:' スキームとして弾かれること、pathToFileURL 経由の file URL なら読み込めることを、Linux コンテナ内で再現→修正→シグネチャ消滅として通しています。macOS / Linux の絶対パスがそのまま読めることも同じ環境で実測しました。Windows 上で pathToFileURL が C:\... を file:///C:/... へ変換する往復は、Node 公式ドキュメントの url.pathToFileURL に拠ります。