Single executable applicationsを使ってnodeアプリケーションを簡単にビルドできるようにする

# はじめに
こんにちは、Never3924です。
NodeのSingle executable applicationsを使って簡単にビルドができるようにしよう、という記事です。

# 環境
NodeJSのv20.1.2
Windows 10
で動作確認。

# 本題
早速やりましょう。ていうか以下のコードをコピペです。
```build.bat
@echo off
setlocal enabledelayedexpansion
chcp 65001

mkdir dist
cd dist

set CALLPOWERSHELL_RETURN="";

echo Collecting information...
call :CALLPOWERSHELL "$text = Get-Content -Path "../package.json" -Raw; $pattern = '\s+\"main\"\s?:\s?\"(.*)\"'; if($text -match $pattern) { $filename = $matches[1] };echo $filename"
set FILENAME=%CALLPOWERSHELL_RETURN%

call :CALLPOWERSHELL "$text = Get-Content -Path "../package.json" -Raw; $pattern = '\s+\"main\"\s?:\s?\"(.*)\..+\"'; if($text -match $pattern) { $filename = $matches[1] }; echo $filename"
set FILENAMEONLY=%CALLPOWERSHELL_RETURN%

call :CALLPOWERSHELL "$signtoolPath = Get-Command signtool -ErrorAction SilentlyContinue; if ($signtoolPath) {echo True} else {echo False}"
set ISINSTALL_SIGNTOOL=%CALLPOWERSHELL_RETURN%

call :CALLPOWERSHELL "$npm = npm list --depth=0; $foundEsbuild = $false; foreach ($line in $npm.Split(\"`n\")) {if ($line -match \"esbuild\") {$foundEsbuild = $true; break}}; echo $foundEsbuild"
set ISINSTALL_ESBUILD=%CALLPOWERSHELL_RETURN%

if not "%~1"=="" (
    set FILENAME=%~1
    call :CALLPOWERSHELL "[System.IO.Path]::GetFileNameWithoutExtension(\"!FILENAME!\")"
    set FILENAMEONLY=!CALLPOWERSHELL_RETURN!

    if "%2"=="debug" (
        echo !CALLPOWERSHELL_RETURN!
    )
)

set OUTPUTNAME=!FILENAMEONLY!.exe

if "%2"=="debug" (
    echo !FILENAME!
    echo !FILENAMEONLY!
    echo %ISINSTALL_SIGNTOOL%
    echo %ISINSTALL_ESBUILD%
    echo !OUTPUTNAME!
)

echo Collection has been completed.
if %ISINSTALL_SIGNTOOL%==False (
    echo The signature tool is not installed.
    echo Skip the signature processing.
)

if %ISINSTALL_ESBUILD%==False (
    echo esbuild is not installed.
    echo installing...
    echo npm i -D esbuild > tmp.bat
    call tmp.bat
)

echo Bundle the js file.
echo @echo off > tmp.bat
echo %~dp0node_modules\.bin\esbuild !FILENAME! --bundle --outfile=./dist/out.js --platform=node --minify=true >> tmp.bat
echo exit /b >> tmp.bat

cd ../
call ./dist/tmp.bat
cd dist

echo Prepare for the build.
echo { "main": "out.js", "output": "sea-prep.blob" } > sea-config.json
node --experimental-sea-config sea-config.json
echo Copy the node.
node -e "require('fs').copyFileSync(process.execPath, '!OUTPUTNAME!')"
if %ISINSTALL_SIGNTOOL%==True (
    echo I will verify the signature.
    echo Temporarily remove the signature.
    signtool remove /s !OUTPUTNAME!
    echo The signature is complete.
)
echo Starting the build.
echo npx postject !OUTPUTNAME! NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 > tmp.bat
call tmp.bat
if %ISINSTALL_SIGNTOOL%==True (
    echo I will sign.
    signtool sign /fd SHA256 !OUTPUTNAME!
    echo The signature is complete.
)
echo Build has finished.
echo Delete the generated file.
del sea-config.json > nul
del sea-prep.blob > nul
del out.js > nul
if %ISINSTALL_ESBUILD%==False (
    echo uninstall esbuild
    echo npm uninstall esbuild > tmp.bat
    call tmp.bat
)
del tmp.bat > nul
if "%2"=="debug" pause
goto :EOF

:CALLPOWERSHELL
echo @echo off > tmp.bat
echo powershell -Command %1 >> tmp.bat
echo exit /b >> tmp.bat
for /f "usebackq" %%A in (`call tmp.bat`) do set CALLPOWERSHELL_RETURN=%%A
exit /b

:EOF
```
ながーい。予定の2倍ぐらいデカくなった。
なんかハイライトが変ですが気にしないでください

(2024/11/12 18:24　コード変更　npmでesbuildをインストール後、終了してしまう問題の修正)
(2025/06/15 18:50　コード変更　実行終了後にtmp.batが残ってしまう問題の修正)

package.jsonにbuildコマンドを追加しておくと楽ですね。
```json:package.json
{
    ...
    "scripts": {
        "build": "build.bat",
    },
    ...
}
```

# 使い方
基本`build.bat`をコマンドラインで実行すればメインファイルを読み出してビルドしてくれます。
事前にインストールしておくべきものはありません。自動で判断してインストールしてくれます。
インストールしたものは最後にアンインストールされます。
また、JSファイルをD&Dでもビルドしてくれます。

# 解説
```bat
@echo off
setlocal enabledelayedexpansion
chcp 65001
```
言うまでもないですね。ログを出さない、変数をローカルに、UTF-8を使うの三点セットです。
しかし遅延変数が使えるようにしてあります

```bat
mkdir dist
cd dist
```
出力場所を作成しています

```bat
set CALLPOWERSHELL_RETURN="";

echo Collecting information...
call :CALLPOWERSHELL "$text = Get-Content -Path "../package.json" -Raw; $pattern = '\s+\"main\"\s?:\s?\"(.*)\"'; if($text -match $pattern) { $filename = $matches[1] };echo $filename"
set FILENAME=%CALLPOWERSHELL_RETURN%

call :CALLPOWERSHELL "$text = Get-Content -Path "../package.json" -Raw; $pattern = '\s+\"main\"\s?:\s?\"(.*)\..+\"'; if($text -match $pattern) { $filename = $matches[1] }; echo $filename"
set FILENAMEONLY=%CALLPOWERSHELL_RETURN%

call :CALLPOWERSHELL "$signtoolPath = Get-Command signtool -ErrorAction SilentlyContinue; if ($signtoolPath) {echo True} else {echo False}"
set ISINSTALL_SIGNTOOL=%CALLPOWERSHELL_RETURN%

call :CALLPOWERSHELL "$npm = npm list --depth=0; $foundEsbuild = $false; foreach ($line in $npm.Split(\"`n\")) {if ($line -match \"esbuild\") {$foundEsbuild = $true; break}}; echo $foundEsbuild"
set ISINSTALL_ESBUILD=%CALLPOWERSHELL_RETURN%
```
powershellのコマンドを実行して情報を取得しています
取得情報は
・対象のファイルパス
・対象の拡張子抜きのファイル名
・[signtool](https://learn.microsoft.com/ja-jp/windows/win32/seccrypto/signtool)がインストールされているか
・esbuildがインストールされているか

```bat
if not "%~1"=="" (
    set FILENAME=%~1
    call :CALLPOWERSHELL "[System.IO.Path]::GetFileNameWithoutExtension(\"!FILENAME!\")"
    set FILENAMEONLY=!CALLPOWERSHELL_RETURN!

    if "%2"=="debug" (
        echo !CALLPOWERSHELL_RETURN!
    )
)
```
ファイルを指定する引数があったらファイルパスとファイル名を更新。

```bat
if %ISINSTALL_SIGNTOOL%==False (
    echo The signature tool is not installed.
    echo Skip the signature processing.
)

if %ISINSTALL_ESBUILD%==False (
    echo esbuild is not installed.
    echo installing...
    npm i -D esbuild
)
```
signtoolがなかったら署名をスキップする旨のメッセージを出す。
esbuildがインストールされてなかったらインストールする。

```bat
echo @echo off > tmp.bat
echo %~dp0node_modules\.bin\esbuild !FILENAME! --bundle --outfile=./dist/out.js --platform=node --minify=true >> tmp.bat
echo exit /b >> tmp.bat

cd ../
call ./dist/tmp.bat
cd dist
```
jsをバンドルする。勝手に処理を終了するので、別ファイルに書き込み。

以下ほぼ[チュートリアル](https://nodejs.org/api/single-executable-applications.html)通り。
```bat
echo { "main": "out.js", "output": "sea-prep.blob" } > sea-config.json
node --experimental-sea-config sea-config.json
```
`sea-config.json`を作って`sea-prep.blob`を生成する。
ここらでやってることはよくわからない。

```bat
node -e "require('fs').copyFileSync(process.execPath, '!OUTPUTNAME!')"
```
nodeそのものをコピー。

```bat
if %ISINSTALL_SIGNTOOL%==True (
    echo I will verify the signature.
    echo Temporarily remove the signature.
    signtool remove /s !OUTPUTNAME!
    echo The signature is complete.
)
```
signtoolが入っていたら、署名を削除。入ってなかったら素通り。

```bat
echo npx postject !OUTPUTNAME! NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 > tmp.bat
call tmp.bat
```
バイナリを結合してるっぽい。ようわからん

```bat
if %ISINSTALL_SIGNTOOL%==True (
    echo I will sign.
    signtool sign /fd SHA256 !OUTPUTNAME!
    echo The signature is complete.
)
```
signtoolが入っていたら、署名する。

```bat
del sea-config.json > nul
del sea-prep.blob > nul
del out.js > nul
del tmp.bat > nul
if %ISINSTALL_ESBUILD%==False (
    echo uninstall esbuild
    npm uninstall esbuild
)
```
各種生成されたファイルの削除。また、先ほど入れたesbuildの削除。

以上で、解説終わり!

# おわりに
すげぇbatの分量多くなりました。すいません。
でも汎用性のあるスクリプト作りたかったから良し
~~6時間近く溶かしました~~
ではまた。

[追記、2025/02/21]
いまタイトル見たら横文字ばっかで草生える