廣瀬製紙株式会社

Employees' Blog 社員ブログ

Node.js の ESM で import.meta.url からエントリーポイントを判定する書き方と、その壊れ方
ディレクトリ名に空白が1つ入るだけで、エントリーポイントの判定は反転します

公開日: 2026.08.31 更新日: 2026.08.31
同じファイルを指す2枚の荷札が、片方には file:///C:/tmp/entry-demo/cli.mjs という URL の綴り、もう片方には C:tmpentry-democli.mjs というパスの綴りで書かれていて、照合窓口が別人と判断してしまう様子のイラスト

同じ1本のスクリプトが、Linux では狙いどおりに動くのに、Windows では何も出さずに終わります。例外も警告も出ず、終了コードは 0 です。見た目には「正常に終わった」ようにしか見えないので、まず疑うのは自分のロジックのほうでしょう。

問題のコードはこれだけです。ESM で書いたコマンドラインツールが、自分が直接実行されたときにだけ main() を動かし、他のファイルから import されたときは何もしない、という素朴な狙いのものです。

// cli.mjs
export function build() {
  return 'built';
}

function main() {
  console.log(build());
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Linux(今回は Windows 上で Linux を動かす WSL の Ubuntu、Node.js v18.19.1)でファイル名を直接指定して起動すると、この条件は true になり built が表示されます。ところが Windows 11(Node.js v22.12.0)で同じ起動をすると false になり、標準出力は空のままです。どちらの環境でも、実行しているファイルは間違いなくエントリーポイントそのものです。

なぜ同じ式が、環境によって逆の答えを返すのでしょうか。そして、よく引用される「正しい書き方」に直せば本当に解決するのでしょうか。

エントリーポイントとは何か、require.main === module は何をしていたのか

エントリーポイントは、プロセスが最初に読み込むファイルのことです。node cli.mjs と叩いたときの cli.mjs がそれにあたり、そこから import された他のファイルはエントリーポイントではありません。1つのファイルを「そのまま実行できるツール」と「他から呼べる部品」の両方に使いたいとき、この区別が要ります。

CommonJS には、そのための短い書き方がありました。require.main にはエントリーポイントのモジュールオブジェクトが入り、module は自分自身を指すので、この2つが同一なら「いま動いているファイルがエントリーポイント」だと分かります。文字列の比較ではなく、ランタイムが持っているオブジェクトどうしの同一性を見ているのがポイントです。

// CommonJS
if (require.main === module) main();

ESM には module も require もありません。かわりに使えるのが import.meta です。

import.meta と file: URL とパーセントエンコーディング

import.meta は ESM の中でだけ使えるオブジェクトで、そのモジュール自身についての情報が入っています。どの環境にも共通して入っているのは import.meta.url で、公式ドキュメントの定義は「The absolute file: URL of the module.」、つまりそのモジュールの絶対 file: URL です。ファイルパスではなく URL である、という点が今回の話の中心になります。

file: URL は、ローカルのファイルを URL の書式で表したものです。file:// のあとにホスト名(ふつうは空)が続き、そのあとに / で始まるパスが来ます。POSIX(Linux や macOS が従っているインターフェースの規格)のパスはもともと / で始まるので file:///home/me/tools/cli.mjs のようにスラッシュが3本並び、Windows のドライブ文字の場合も file:///C:/tmp/entry-demo/cli.mjs と、区切りがすべて / に揃えられます。

パーセントエンコーディングは、URL にそのまま書けない文字を % と16進2桁で表す仕組みです。空白は %20、# は %23 になり、ASCII 以外の文字は UTF-8 のバイト列を1バイトずつ %XX に置き換えます。# は URL ではフラグメントの始まりという特別な意味を持つので、パスの一部として素通しにはできません。

一方の process.argv[1] は URL ではありません。公式ドキュメントには「If a program entry point was provided, the second element will be the absolute path to it.」とあり、そのプラットフォームの表記による絶対パスが入ります。Windows なら C:\tmp\entry-demo\cli.mjs、Linux なら /home/me/entry-demo/cli.mjs です。

判定式の地図

エントリーポイント判定の書き方は、実質4通りに分かれます。それぞれが何をしているかを先に並べておきます。

書き方していること向いている場面注意点
文字列連結(file:// の後ろに process.argv[1] をつなぐ)。以降の出力では naiveパスの先頭に file:// を足して文字列として比べる短く書けるぶん広まりやすい形ですが、勧められる場面はありませんWindows では常に外れ、POSIX でもパスに空白・#・非 ASCII が入ると外れる
pathToFileURL(process.argv[1]).href と比べるパスを正しく file: URL へ変換してから比べるnode ファイル名 の形でしか起動しないと分かっているときnode . とリンク経由の起動で外れる
realpath とモジュール解決(package.json の main などをたどって、実際に読むファイルを決める処理)を通してから比べるargv[1] をファイルまで解決し、リンクをたどってから URL に揃える配布するツールなど、どう起動されるか分からないもの行数が増える
import.meta.mainランタイムが出した答えを読むだけ新しい Node.js だけを相手にできるときv24.2.0 / v22.18.0 で追加されたので、それ以前では undefined

いちばん短いのは最後の import.meta.main で、これが本命です。ただし公式ドキュメントでは Stability: 1.0 - Early development と付いた早い段階の API で、しかも本命なりの落とし穴もあるので、まずは上の3つがどこで壊れるかを見てから最後に戻ってきます。

比べているのは、そもそも別の層のものです

最初のコードは import.meta.url と process.argv[1] を突き合わせていました。前者は URL、後者はパスです。この2つは同じファイルを指していても表記の決まりが違うので、片方に file:// を足しただけでは同じ綴りになりません。

同じファイルが、上段では file: URL として、下段ではプラットフォーム固有の絶対パスとして表され、両者を直接つなぐ矢印に×が付いている図
図 1: import.meta.url は URL の層、process.argv[1] はパスの層

Windows での実測がいちばん分かりやすい例です。手元の Windows 11 / Node.js v22.12.0 で、起動のしかたを変えながら値をそのまま出しました。出力の naive が上の表の文字列連結、pathToFileURL が変換を通した比較にあたります。

== 1. node cli.mjs ==
argv[1]      : C:\tmp\entry-demo\cli.mjs
meta.url     : file:///C:/tmp/entry-demo/cli.mjs
naive        : false
pathToFileURL: true
meta.main    : undefined

区切り文字が \ と / で違ううえ、file:// を足しただけでは file://C:\tmp\... にしかならず、スラッシュ3本の形にもなりません。Windows で必ず外れるのは、この2点だけでも十分に説明が付きます。

では、区切り文字さえ同じなら POSIX では安全なのでしょうか。ここが本題で、同じ変換を POSIX の表記で並べると、区切り文字ではなくパーセントエンコーディングのほうが本体だと見えてきます。次の出力は同じ Windows 機の上で pathToFileURL(p, { windows: false }) を指定し、POSIX として解釈させて並べたもので、実機の Linux での裏付けは後半の5ケースにあります。

"/home/me/tools/cli.mjs"
  naive  : file:///home/me/tools/cli.mjs
  correct: file:///home/me/tools/cli.mjs
  equal  : true
"/home/me/my tools/cli.mjs"
  naive  : file:///home/me/my tools/cli.mjs
  correct: file:///home/me/my%20tools/cli.mjs
  equal  : false
"/home/me/ツール/cli.mjs"
  naive  : file:///home/me/ツール/cli.mjs
  correct: file:///home/me/%E3%83%84%E3%83%BC%E3%83%AB/cli.mjs
  equal  : false
"/home/me/c#/cli.mjs"
  naive  : file:///home/me/c#/cli.mjs
  correct: file:///home/me/c%23/cli.mjs
  equal  : false
左に生のパス、右に file: URL を置き、空白が %20、# が %23、日本語が UTF-8 のパーセントエンコード列へ変換されることを示す対応図
図 2: 同じパスが file: URL になるとき、空白・#・非 ASCII だけが姿を変える

Windows でも同じことが起きます。C:\tmp\entry demo#2\cli.mjs の import.meta.url は file:///C:/tmp/entry%20demo%232/cli.mjs になり、C:\tmp\entry-デモ\cli.mjs は file:///C:/tmp/entry-%E3%83%87%E3%83%A2/cli.mjs になりました。? もエンコード対象の文字ですが、Windows のファイル名には使えないため今回は測っていません。

そして決定的なのが Linux 側の結果です。冒頭で true になっていたあの判定は、ディレクトリ名に空白を1つ入れて $HOME/my tools/ から実行しただけで false に反転しました。つまり「Linux では動く」のではなく、POSIX の上で、パーセントエンコードの対象になる文字(空白・#・非 ASCII など)を1つも含まないパスを、ファイル名で直接指定して起動したときにだけ、たまたま当たっていたわけです。

ここを「ASCII のパスなら安全」と読み替えたくなりますが、すぐ上の出力がその反証になっています。my tools の空白も c# の # もどちらも ASCII の範囲の文字なのに、equal : false です。条件を決めているのは文字集合ではなくエンコードの対象かどうかで、開発マシンのホームディレクトリにそうした文字が無いあいだは、この欠陥はずっと隠れ続けます。

正しく URL へ変換する関数は標準で用意されています。url.pathToFileURL() のドキュメントには「This function ensures that path is resolved absolutely, and that the URL control characters are correctly encoded when converting into a File URL.」とあり、絶対パス化と制御文字のエンコードをまとめて引き受けてくれます。逆向きの url.fileURLToPath() を使って URL 側をパスに落としてから比べる手もあり、どちらの層に揃えるかの違いです。

import { pathToFileURL } from 'node:url';

if (import.meta.url === pathToFileURL(process.argv[1]).href) main();

「正しい書き方」も、2つの起動で破れます

これで安心かというと、そうはいきません。上の実測の1行目をもう一度見ると pathToFileURL: true でしたが、起動のしかたを変えると同じ列が false に落ちます。

1つ目は node . です。package.json の main に index.mjs を書いたディレクトリで実行すると、こうなりました。

== 4. node . ==
argv[1]      : C:\tmp\entry-demo
meta.url     : file:///C:/tmp/entry-demo/index.mjs
naive        : false
pathToFileURL: false
meta.main    : undefined

process.argv[1] はコマンドラインに書かれたとおりのディレクトリで、実際に読み込まれたファイルではありません。import.meta.url のほうはモジュール解決が済んだあとの index.mjs を指しているので、解決前と解決後を比べていることになります。

2つ目はリンク経由の起動です。真偽値だけでは、どちらがリンク側でどちらが実体側なのかが分かりません。そこで同じ場所に置いた確認用のファイルを、リンク越しのパスで指して起動し、値そのものを出させました。

== C-junction: node C:\tmp\entry-link\values.mjs ==
case         : C-junction
argv[1]      : C:\tmp\entry-link\values.mjs
meta.url     : file:///C:/tmp/entry-demo/values.mjs
pathToFileURL: false

process.argv[1] はコマンドラインに書いたとおりの entry-link(リンク側)のままで、import.meta.url は entry-demo(実体側)を指しています。Node.js には --preserve-symlinks-main という、メインモジュール(つまりエントリーポイント)を解決するときにリンクをそのまま残すためのオプションがあり、原文は「Instructs the module loader to preserve symbolic links when resolving and caching the main module (require.main).」です。わざわざ「残す」ためのオプションが用意されているのは、この既定を前提にしているからだと読めます(このオプションを付けたときの挙動は今回測っていません)。

node . のときは argv[1] がディレクトリのまま、リンク経由のときは argv[1] がリンク側のパスのままで、import.meta.url だけが解決後のファイルの実体パスを指していることを示す図
図 3: node . とリンク経由では、解決前と解決後を比べてしまう

ここまで来ると、必要な処理がはっきりします。argv[1] の側を、ファイルまで解決し、リンクをたどり、そのうえで URL 層へ持ち上げてから比べればよいわけです。

// isentry.mjs
import { realpathSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';

export function isEntryPoint(metaUrl) {
  const argv1 = process.argv[1];
  if (!argv1) return false;
  try {
    const resolved = createRequire(metaUrl).resolve(argv1);
    return metaUrl === pathToFileURL(realpathSync(resolved)).href;
  } catch {
    return false;
  }
}

createRequire(metaUrl).resolve(argv1) が「ディレクトリならその中の main」まで解決し、realpathSync がリンクをたどって実体パス(リンクをすべて解決したあとの本当の置き場所)にし、最後に pathToFileURL が URL 層へ揃えます。argv1 が空になりうるのは対話モードなどで実行されたときで、その場合は素直に false を返します。

ただし catch の1行には注意が要ります。解決の途中で何が起きても「エントリーポイントではない」に倒れるので、たとえばファイルが読めなかったときも main() は黙って飛びます。これはこの記事の後半で扱う無言の空振りとそっくり同じ形なので、握りつぶすのか、ログに残すのか、投げ直すのかは自分で決めてください。

5通りの起動 × 3つの判定式

言葉で「こちらのほうが正しい」と言うだけでは、どのくらい正しいのかが分かりません。そこで起動のしかたを5通り用意し、実行する前に期待値を決めてから測りました。A・B・C は対象ファイル自身がエントリーポイントなので true、D・E は別ファイルから import されているので false です。正解は true が3件、false が2件で固定されているので、判定式ごとに5点満点の正答数を数えられます。

ケース起動のしかた期待値
A-directnode check.mjs(冒頭のツールと同じ形のファイル)true
B-node-dotnode .(package.json の main は index.mjs)true
C-junction / C-symlinkリンク経由のパスで check.mjs を起動true
D-importednode host.mjs(host.mjs が check.mjs を import)false
E-imported-junctionリンク経由で host.mjs を起動false

Windows 11 / Node.js v22.12.0 での結果です。太字にしたのが期待値と食い違った箇所です。

ケース期待値naivepathToFileURLisEntryPoint
A-directtruefalsetruetrue
B-node-dottruefalsefalsetrue
C-junctiontruefalsefalsetrue
D-importedfalsefalsefalsefalse
E-imported-junctionfalsefalsefalsefalse

正答数は naive が 2 / 5、pathToFileURL が 3 / 5、isEntryPoint が 5 / 5 でした。naive の2点は D と E で、Windows では常に false を返す式がたまたま false の期待値に当たっただけです。壊れた判定式でも、期待値が false のケースだけ並べれば満点に見えます。 ここが、この種の欠陥がテストをすり抜ける経路でもあります。

縦に A-direct / B-node-dot / C-junction / D-imported / E-imported-junction の5つの起動ケース、横に naive / pathToFileURL / isEntryPoint の3つの判定式を並べ、期待値と一致したマスと外れたマスを塗り分けたマトリクス図。naive が 2/5、pathToFileURL が 3/5、isEntryPoint が 5/5 であることを示している
図 4: 5通りの起動と3つの判定式のマトリクス

同じ5通りを Linux(WSL 上の Ubuntu、Node.js v18.19.1)で測ると、A-direct の naive だけが true に変わり、正答数は naive 3 / 5、pathToFileURL 3 / 5、isEntryPoint 5 / 5 になりました。Windows との差はこの1マスだけで、しかもそのマスは前述のとおり、ディレクトリ名に空白を1つ足せば false に戻ります。冒頭の「Linux では動くのに Windows では動かない」は、たった1マスの差だったわけです。

なお Windows 側の C・E は、シンボリックリンクではなくディレクトリジャンクション(mklink /J で作る、ディレクトリを別のパスから見せる仕組み)で測っています。管理者権限のない環境ではシンボリックリンクの作成が EPERM: operation not permitted, symlink で失敗したためで、Windows のシンボリックリンクでも同じ結果になるかどうかは確かめていません。macOS、npx 経由やグローバルインストール経由での起動、バンドラを通したあとの挙動も、今回は測っていない範囲です。

本命の import.meta.main と、いちばん気づきにくい壊れ方

ここまでの10行の関数(import を数えると13行)は、本来ランタイムが答えを持っている問いを、外から推測し直しているだけです。それを直接読めるようにしたのが import.meta.main で、公式ドキュメントには「Added in: v24.2.0, v22.18.0」と書かれ、「Equivalent to require.main === module in CommonJS.」と説明されています。CommonJS で使っていたあの1行が、そのまま ESM に戻ってきた形です。

export function build() {
  return 'built';
}

function main() {
  console.log(build());
  process.exitCode = 0;
}

if (import.meta.main) main();

短くて読みやすく、公式ドキュメントの説明どおりなら起動のしかたにもプラットフォームにも左右されません。ただしこれは仕様の記述であって実測ではなく、true が返る実機を今回は用意できていません。同じページには Stability: 1.0 - Early development とも付いていて、まだ早い段階の API だと示されています。

ではこれで話は終わりでしょうか。追加されたのが v24.2.0 と v22.18.0 だ、という一文を思い出してください。

追加前のバージョンでは、import.meta.main はただの未定義プロパティです。エラーにはならず、undefined が返ります。そして undefined は falsy なので、if (import.meta.main) main() は静かに素通りします。実際、v22.12.0 で上のファイルを node tool.mjs として実行した結果がこれです。

--- node tool.mjs ---
exit=0  (stdout above is everything printed)

標準出力は空、終了コードは 0。 警告も例外も出ません。先ほどの5ケースの測定でも import.meta.main の列は全ケースで undefined でした。動かなくなったのに、失敗した形跡がどこにも残らないわけです。

誰にも止められない無人の改札を、荷物がそのまま素通りしていき、出口の表示だけが 0 を示していて、警報も足跡も残っていない様子のイラスト
図 5: 何も起きないまま、出口を通り抜ける

これが、今回のテーマでいちばん気づきにくい壊れ方です。文字列比較の欠陥は、少なくとも「環境によって挙動が違う」という手がかりを残します。ところが import.meta.main への乗り換えは、古いランタイムの上で何も起きないという形で失敗するので、CI が通り、node -v を確認するまで理由が分かりません。なお、v22.18.0 以降や v24.2.0 以降の実機で true が返るところは今回確認できていないので、追加バージョンについては公式ドキュメントの記述をそのまま引いています。

まとめ

エントリーポイントの判定は、同じファイルを指す2つの表現を、どの層で揃えてから比べるかという問題でした。import.meta.url は URL、process.argv[1] はパスで、URL 側だけがパーセントエンコーディングとスラッシュの決まりを持っています。文字列を連結して比べる書き方は、この差を無視しているぶん、パーセントエンコードの対象になる文字を1つも含まないパスを、POSIX でファイル名を直接指定して起動したときにだけ当たります。

サポートする Node.js を v24.2.0 / v22.18.0 以降に絞れるなら、import.meta.main を素直に使うのがいちばんです。Stability: 1.0 - Early development という但し書きは付いていて、Node.js の Stability index はこの段階を「The feature is not subject to semantic versioning rules. Non-backward compatible changes or removal may occur in any future release. Use of the feature is not recommended in production environments.」と定めています。semver の対象外で、将来のリリースで非互換に変わったり無くなったりしうるうえ、本番環境での使用は推奨されていません。判定の中身をランタイムに任せられる利点は大きいので、この但し書きと天秤にかけたうえで選んでください。それより古いバージョンも動かすなら、undefined に落ちたときの逃げ道を用意しておくと、乗り換えの途中でも黙って何も起きない事故を避けられます。

import { isEntryPoint } from './isentry.mjs';

if (import.meta.main ?? isEntryPoint(import.meta.url)) main();

?? は左辺が null か undefined のときだけ右辺を評価するので、import.meta.main が false を返す新しいランタイムでは右辺が動きません。今回測った v22.12.0 では import.meta.main が undefined だったので、右側の判定が使われます。行数は増えますが、今回測った Windows / Linux の5通りの起動では同じ答えになるほうを取る、という選び方です。

最後にもう一度。あなたのツールは、ディレクトリ名に空白が1つ入ったホームディレクトリから起動されても、node . で起動されても、リンク越しに起動されても、同じ判定を返すでしょうか。判定式を書き換える前に、起動のしかたを何通りか並べて期待値を先に決めておくと、どこまで正しいのかが数字で見えます。

参考にした一次情報

この記事を書いた人

情報企画チーム 松村 晶(まつむら あき)

2024年11月廣瀬製紙株式会社入社。

書店・福祉・飲食業などを経験したのち、システム開発畑に転向。

転職をきっかけに生まれの地である高知市に移住し、現在は社内SEとして、社内のDBシステム開発やDX関連のシステム開発を担当している。

この著者の記事を見る →