main() が 3 を返すスクリプト old.mjs を、名前だけが違う2つのフォルダに置き、cmd から起動しました
(Windows 11 10.0.26200 / Node v24.18.1)。次の5行を .bat に書いて実行した結果です。
@echo off
node "C:\Users\Public\automos-guard-cmd-QTJABN\plain\old.mjs"
echo ERRORLEVEL=%ERRORLEVEL%
node "C:\Users\Public\automos-guard-cmd-QTJABN\v1#2\old.mjs"
echo ERRORLEVEL=%ERRORLEVEL%
main が呼ばれた
ERRORLEVEL=3
ERRORLEVEL=0
plain に置いたほうは1行出して 3 で終わりました。v1#2 に置いたほうは何も出さず、0 で終わりました。
例外も出ません。「走ったが、することが無かった」と見分けがつきません。
判定は、当社が配っていた診断スクリプトの末尾に実際にあった3行です。
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, "/")}`).href) {
process.exit(main());
}
old.mjs は、この3行の前に「1行出して 3 を返す main()」を置いただけのファイルです。
パスによってはこの比較が偽になり、main() が一度も呼ばれません。
再現した結果
同じ中身のファイルを置き場所と起動のしかたを変えて起動し、main() が呼ばれたかを見ました。
表は Node v24.18.1 で、Node から子プロセスとして起動した結果です(フルパス指定・cwd は C:\。cmd は通していません)。
**1 は「1行出して終了コード3」、0 は「出力0行・終了コード0」**です。
**「下の直し方」の列は、この版では import.meta.main を通った結果です。**比較の側の結果は、直し方の節の注意に書きました。
| 置き場所・起動のしかた | 手組みの URL | pathToFileURL(argv[1]) |
fileURLToPath で比較 |
realpathSync してから pathToFileURL
|
import.meta.main |
下の直し方 |
|---|---|---|---|---|---|---|
| ASCII のみ | 1 | 1 | 1 | 1 | 1 | 1 |
| 日本語 | 1 | 1 | 1 | 1 | 1 | 1 |
| 空白 | 1 | 1 | 1 | 1 | 1 | 1 |
é |
1 | 1 | 1 | 1 | 1 | 1 |
#(v1#2) |
0 | 1 | 1 | 1 | 1 | 1 |
~(PROGRA~1 の形) |
0 | 1 | 1 | 1 | 1 | 1 |
[ ]([x]) |
0 | 1 | 1 | 1 | 1 | 1 |
%(100%) |
0 | 1 | 1 | 1 | 1 | 1 |
| 8.3 短縮名を含む %TEMP%(実在するフォルダ) | 0 | 1 | 1 | 1 | 1 | 1 |
| ジャンクション経由(実体は ASCII のフォルダ) | 0 | 0 | 0 | 1 | 1 | 1 |
ジャンクション経由 + --preserve-symlinks-main
|
1 | 1 | 1 | 0 | 1 | 1 |
拡張子なしで起動(type: module の .js) |
0 | 0 | 0 | 例外(終了コード1) | 1 | 1 |
- 手組みの URL で黙ったのは、測った場所のうち
#~[ ]%を含むところ・ジャンクション経由・拡張子なしの起動です。
測っていない文字でも起きうるので、この表を「安全な文字の一覧」として読まないでください - ジャンクション経由では、手組みを直した2つの書き方(
pathToFileURL/fileURLToPath)も黙りました - 「黙る」のは書き方と起動のしかたの組み合わせで決まり、どの比較の書き方にも黙る行がありました。
全部の行で1になったのはimport.meta.mainと、それを使う下の直し方だけです
なぜ一致しないのか(手組みの URL)
Node v24.18.1 で、import.meta.url と手組みの URL を並べて出しました(フォルダ名の部分だけ抜き出しています)。
| フォルダ名 | import.meta.url |
手組み(new URL) |
|---|---|---|
v1#2 |
.../v1%232/probe.mjs |
.../v1#2/probe.mjs |
PROGRA~1 |
.../PROGRA%7E1/probe.mjs |
.../PROGRA~1/probe.mjs |
[x] |
.../%5Bx%5D/probe.mjs |
.../[x]/probe.mjs |
100% |
.../100%25/probe.mjs |
.../100%/probe.mjs |
-
#は URL では「ここから先はフラグメント」の意味です。手組みの URL は
pathnameが/C:/work/v1で切れ、残りの#2/tool.mjsがフラグメントになります(C:\work\v1#2\tool.mjsの場合) -
~[ ]%は、この版のimport.meta.urlでは符号化されていて、new URL()ではそのまま残っていました
文字列として比べているので、1文字違えば偽です。そして偽のときは何も起きません。
ジャンクション経由だと、符号化を揃えても黙る
ASCII だけのフォルダ plain を指すジャンクション junction-to-plain を作り、そちら経由で起動しました。
process.argv[1] : C:\Users\Public\automos-guard-C07cot\junction-to-plain\probe.mjs
import.meta.url : file:///C:/Users/Public/automos-guard-C07cot/plain/probe.mjs
process.argv[1] は呼んだときのパス、import.meta.url は実体のパスになっていました。
符号化の規則をいくら揃えても、指している場所の書き方が違うので一致しません。
そこで argv[1] を fs.realpathSync で実体に直してから比べると、今度は別の起動のしかたで一致しなくなりました。
-
node --preserve-symlinks-mainで起動すると、import.meta.urlもジャンクションのパスのまま
(.../junction-to-plain/probe.mjs)になり、実体に直したargv[1]と一致しません。0行・終了コード0 でした -
拡張子を付けずに起動すると(
node C:\...\noext\probe、中身はpackage.jsonが"type": "module"のフォルダのprobe.js)、
process.argv[1]は...\noext\probe、import.meta.urlは.../noext/probe.jsでした。
実在しないパスなのでrealpathSyncが例外を投げ、終了コード1で終わりました
Node の内部でどう処理されているかは確かめていません。見たのはこれらの値です。
同じファイルでも、呼び方で動いたり黙ったりする
このPCでは、環境変数 %TEMP% に 8.3 短縮名の形(C:\Users\XXXXX~1\AppData\Local\Temp)が入っていました。
そこに置いた1つのファイルを、2通りの名前で起動しました。
| 起動したパス | 手組みの URL で判定 |
|---|---|
短縮名の形(...\XXXXX~1\...) |
0行・終了コード0 |
同じフォルダを長い名前で(...\(元のユーザー名)\...) |
1行・終了コード3 |
ファイルは同じです。process.argv[1] にどういう文字列が入るかで結果が変わります。
cmd で、この短縮名を含むフォルダへ cd してから相対パス(node old.mjs)で起動しても、0行・ERRORLEVEL=0 でした。
cd が表示したカレントディレクトリは、短縮名の形のままでした。相対パスで起動すれば避けられる、というものではありません。
直し方
import.meta.main は、24 系では v24.2.0 以降、22 系では v22.18.0 以降にあります
(公式ドキュメントの Added in: v24.2.0, v22.18.0。Stability: 1.0 - Early development)。
それがある版ではそれを使い、無い版だけ argv[1] を実体のパスに直してから比べます。
元の行の process.exit(main()) はそのまま残します。main() の戻り値が終了コードだからです
(元の行と同じく、main() が終了コードを同期で返す前提の形です)。
import fs from "node:fs";
import { pathToFileURL } from "node:url";
function isEntryPoint() {
if (typeof import.meta.main === "boolean") return import.meta.main;
const a = process.argv[1];
if (a === undefined) return false;
let p = a;
try {
p = fs.realpathSync(a);
} catch {}
return import.meta.url === pathToFileURL(p).href;
}
if (isEntryPoint()) {
process.exit(main());
}
表の「下の直し方」の列が、この形そのものを置いて起動した結果です。注意が4つあります。
-
v24.18.1 では1行目の
import.meta.mainが使われるので、表の列は右側(比較)を通っていません。
右側は、1行目を消した写しで別に測りました。12通りのうち10通りで1行・終了コード3、
--preserve-symlinks-main付きと拡張子なしの起動では 0行・終了コード0 でした。
import.meta.mainが無い版の Node そのものでは走らせていません -
import.meta.mainだけで書くと、それが無い版ではundefinedになり、ifが偽のまま同じ形で黙ります。
上の形はtypeofで boolean かを見てから使っています -
realpathSyncは、拡張子なしの起動のように実在しないパスでは例外を投げます(表の「例外(終了コード1)」)。
上の形はそれをcatchで握って直さずに比べるので、import.meta.mainが無い版で、このファイル自身を拡張子なしで起動すると、
例外ではなく黙るほうになります。黙る側を選んでいます。(type: moduleの.jsの場合です。.mjsのファイルは
拡張子なしでは読み込めず、ここへ来る前に終了コード1で止まりました。)理由は、握らないと、このファイルを import しているだけのプログラムが
拡張子なしで起動されたときに、import の時点で落ちるからです。catchを外した写しを import するapp.jsを
node ...\appで起動すると終了コード1で止まり、catchありの写しではapp.jsが最後まで走りました
(argv[1]は import する側でもメインのパスなので、同じ例外が起きます) -
fs.realpathSync.nativeにすると、8.3 短縮名を含む %TEMP% の置き場所で 0行・終了コード0 でした。
.nativeは短縮名を長い名前へ展開しますが、このときのimport.meta.urlは短縮名(~1)のままだったので一致しません。
使ったのはfs.realpathSync(.nativeなし)です
確かめ方(数分)
手元の判定を、# を含むフォルダと、そこを指すジャンクションの2か所から起動してみる手順です。
次の8行(先頭は @echo off)を .bat に書き、cmd から実行しました。
実際に流した .bat では、@echo off の次に、%TEMP% を空の新しいフォルダへ向け直す set と mkdir、
your-script.mjs のあるフォルダへの cd /d を置いています。
@echo off
mkdir "%TEMP%\v1#2"
copy your-script.mjs "%TEMP%\v1#2\"
node "%TEMP%\v1#2\your-script.mjs"
echo ERRORLEVEL=%ERRORLEVEL%
mklink /J "%TEMP%\link-to-v1" "%TEMP%\v1#2"
node "%TEMP%\link-to-v1\your-script.mjs"
echo ERRORLEVEL=%ERRORLEVEL%
pathToFileURL(argv[1]) で比べる版(main() は 3 を返す)を置いたときの出力です
(mklink が出す1行は省きました。copy の表示の文言は環境によって違います)。
1 file(s) copied.
main が呼ばれた
ERRORLEVEL=3
ERRORLEVEL=0
**2回目(ジャンクション経由)だけ、何も出ずに ERRORLEVEL=0 でした。**上の直し方で書いた版を置くと、2回とも1行出て ERRORLEVEL=3 でした。
- **この手順は、あなたのスクリプトを実際に2回走らせます。**ファイルを書き換える・通信するスクリプトなら、
main()の中身を「1行出して 3 を返す」だけにした写しで試してください -
%ERRORLEVEL%は、node と別の行で読んでください。node ... & echo ERRORLEVEL=%ERRORLEVEL%と1行に書くと、
cmd は node が走る前にその行の%ERRORLEVEL%を展開します。3 で終わるold.mjsで試すと、同じ行ではERRORLEVEL=0、
次の行で読むとERRORLEVEL=3でした。私は一度この測り方でこの記事の証拠を取り、別の目のレビューで指摘されて取り直しました - この手順で分かるのは
#とジャンクションの2つだけです。表の--preserve-symlinks-main・拡張子なし・
短縮名(cmd でcdしたときを含む)は、それぞれの形で起動して確かめる必要があります
なぜ厄介か
落ちるなら気づけます。この形は落ちません。
- 例外が出ない
- 終了コードは 0
- 出力は 0行
タスクスケジューラなどで無人で走らせていると、「走ったが、することが無かった」回と
同じ顔で記録されます。
私はこれを、当社が無料で配っている診断スクリプトで踏みました。
配った翌日に自分でフルパスで起動して手組みの URL の件を見つけ、pathToFileURL に直しました。
その直し方がジャンクション経由では同じ形で黙ることは、あとから別の目のレビューで指摘され、自分で測り直して確かめました。
その次に入れた realpathSync の形も --preserve-symlinks-main 付きでは黙ったので、いまは上の直し方と同じ形にしています。
結果の先頭に見出しを出す作りや、何も確かめられなければ終了コード 1 にする作りは、この壊れ方には効きません。
どちらも main() の中にあり、main() が呼ばれないからです。
いまは、Node から子プロセスとして(フルパス・~・#・[x]・100%・ジャンクション経由・
--preserve-symlinks-main 付きで)起動し、出力が出ることを見るテストを置いています。
cmd は通していません。テストは v24.18.1 で走るので、通っているのは import.meta.main の側で、比較の側は見ていません。
確かめていないこと
- 実測は Windows 11(10.0.26200)/ Node v24.18.1 の1台だけです
- macOS・Linux(シンボリックリンクを含む)では確かめていません
- CommonJS(
require.main === module)の書き方は比べていません - 文字は上の表に書いたものしか測っていません
-
import.meta.mainが無い版の Node では走らせていません(右側は写しで測りました) - PowerShell・Git Bash から起動したときは、この記事では測っていません
この判定を踏んだ診断スクリプトは、Windows で AI を無人で走らせるときの落とし穴を点検する道具です(無料・依存なし・読むだけで書き換えません・通信しません):