Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]

ERR_UNSUPPORTED_ESM_URL_SCHEME の直し方(Windows で絶対パスを動的 import して落ちるとき)

FIX SUMMARY verified
Applies when
node:20Node core (node:url pathToFileURL)dynamic import() of a Windows drive-letter absolute path parsed as URL scheme 'c:'

Verified: reproduced in node:20, then the ERR_UNSUPPORTED_ESM_URL_SCHEME signature was gone after the fix (exit 0).

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.mjsfile:///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 datanode が並びから外れる)と表示されます。ドライブレターのパス('c:')については、確認した範囲では版をまたいで file, data, and node のままでした。

検索やログの突き合わせでは、この可変な文言の部分ではなく、コード名 ERR_UNSUPPORTED_ESM_URL_SCHEMEReceived 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:20node:18 / node:22 でも同じシグネチャを確認)、Linux コンテナ、ネットワーク無し
  • 再現:await import('C:\\app\\plugins\\hello.mjs')ERR_UNSUPPORTED_ESM_URL_SCHEMEReceived protocol 'c:')で終了コード 1
  • 修正:実在するモジュールを await import(pathToFileURL(pluginPath).href) で読み込み、終了コード 0

再現から修正までは errfix の検証ハーネスが機械的に確認しています。Windows のドライブレターパスが 'c:' スキームとして弾かれること、pathToFileURL 経由の file URL なら読み込めることを、Linux コンテナ内で再現→修正→シグネチャ消滅として通しています。macOS / Linux の絶対パスがそのまま読めることも同じ環境で実測しました。Windows 上で pathToFileURLC:\...file:///C:/... へ変換する往復は、Node 公式ドキュメントの url.pathToFileURL に拠ります。

検証(machine-verified)

この修正は node:20 のバージョン固定コンテナ内で再現し、修正後に ERR_UNSUPPORTED_ESM_URL_SCHEME のシグネチャが消えることを機械で確認しています。

verify — run-case.mjs
$ node run-case.mjs node/esm-url-scheme-windows-path
● reproduce ERR_UNSUPPORTED_ESM_URL_SCHEME present ✓
● apply fix exit 0
● re-run ERR_UNSUPPORTED_ESM_URL_SCHEME gone ✓
PASS verified · node:20 · signature gone