TypeScriptでWSH JScriptを書き、ES Modulesを利用する

# 概要

https://github.com/mukai1011/how-to-write-jscript-in-a-modern-way

Windows Script Host(WSH)は、Windowsに標準搭載されているスクリプト実行環境です。Visual Basicをベースとした**VBScript**や、ES3相当のJavaScriptをベースとした**JScript**で記述することができる、ちょっちかしこいバッチファイルだと考えてください。Windowsに関わるほとんどなんでも実現することができます。

- ファイル操作
- レジストリ操作
- シェルコマンドの実行
- ExcelをはじめとしたCOMオートメーション
- （がんばれば）Windows APIの呼び出し
- など...

バッチファイルと同程度に、Windowsであればどこでも実行できることが強みです。

ただVisual BasicもES3もひどくレガシーであり、現代のプログラマーであるわれわれにとってこれらを扱うのは本当に苦痛です。なんとかこの強力なスクリプトを、快適に書くことはできないものでしょうか。

# 目標

着目するのはJScriptです。ES3相当とはいえ、JavaScriptであればTypeScriptの恩恵を受けることができるでしょう。モダンWindowsスクリプティングを標榜して、以下を目標に設定します。

- WSH JScriptをTypeScriptで書く
- 実行環境に遠慮せず、新しいJavaScriptで書く
- 最終出力はES3
- ES Modulesを使えるようにする

**TypeScript**で書くことで、型にまつわる不毛なミスを減らすことができます。[型定義](https://www.npmjs.com/search?q=%40types%2Factivex-&page=0&perPage=20)はnpmに揃っています。なければ自分で書きましょう。

仕様の穴を突くような方法でES2015相当のJavaScriptをWSHで実行する方法もありますが、終了コードを返す`WScript.Quit`を利用できないことが痛手です。今回は素直にES3にトランスパイルすることにします。**Babel**を利用し、記法の変換やポリフィルを任せてしまいます。

中規模以上の開発にはモジュール機能が欠かせません。WSHはモジュールに対応していないため、単一ファイルにまとめてくれるモジュールバンドラーが必要です。Webpackなどいくつか選択肢があり、どれでも実現できると思いますが、今回はバンドル後の可読性を評価して**Rollup**を利用します。

# 環境

```
C:\>ver

Microsoft Windows [Version 10.0.22000.675]

C:\>node -v
v17.4.0

```

# 実践

## Node.jsのインストール

JScriptは標準搭載ですが、上に挙げたツールを利用するためには**Node.js**が必要です。最近はなんでもwingetでインストールできて助かります。

```ps1
winget install -e --id OpenJS.NodeJS
```

## プロジェクト作成

hogeフォルダを作り、空のファイルを作成しておきます。

`index.ts`がエントリーポイントになります。各ファイルはUTF-8で編集し、バンドル時にUTF-16 LEに変換されます。

```ps1
mkdir hoge
cd hoge
New-Item -Name src -ItemType Directory
New-Item -Name dist -ItemType Directory
New-Item src/index.ts -ItemType File
New-Item rollup.config.js -ItemType File
New-Item .babelrc -ItemType File
```

## 開発ツールのインストール

開発に使用するツールをインストールします。

```ps1
npm init --yes
npm install --save-dev @babel/core @babel/preset-env @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-typescript rollup tslib typescript
npm install core-js
npx tsc --init
```

### `@babel/core` `@babel/preset-env`

Babel本体と、指定した環境に合わせて自動で適切なポリフィルを導入するプリセットです。

### `@rollup/plugin-babel` `@rollup/plugin-commonjs` `@rollup/plugin-typescript` `rollup`

Rollup本体と、各ツールをRollupと連携させるプラグインです。`@rollup/plugin-commonjs`は、ポリフィルの導入時に追加されるCommonJSモジュールをES Modulesに揃えます。

### `tslib` `typescript`

TypeScript関連です。`tslib`はRollupでTypeScriptをトランスパイルするために[必要です](https://www.npmjs.com/package/@rollup/plugin-typescript)。

### `core-js`

ポリフィルです。上記のツールは`--save-dev`（`package.json`における`"devDependencies"`）にインストールされますが、`core-js`は`"dependencies"`にインストールする[必要があります](https://github.com/rollup/rollup-plugin-babel/issues/313#issuecomment-509079906)。

## `tsconfig.json`の編集

- `"module"`を`"commonjs"`から`"esnext"`に変更します。
- `"compilerOptions"`と同じ階層に`"include"`を追加します。拡張子`*.ts`のファイルがトランスパイル対象になります。

```jsonc
{
    "compilerOptions" {
        // 省略
        "module": "esnext",  
        // 省略
    },
    "include": ["src/**/*.ts"]
}
```

## `.babelrc`の編集

```jsonc
{
    "presets": [
        [
            "@babel/preset-env",
            {
                // モジュールの処理はRollupに任せます。
                "modules": false,

                // 可能な限りObject.definePropertiesを使用しないES3に変換します。
                "targets": {
                    "ie": 8
                },
                "loose": true,

                // この設定によって、core-jsから自動でポリフィルが導入されます。
                "useBuiltIns": "usage",
                "corejs": 3
            }
        ]
    ]
}
```

## `rollup.config.js`の編集

```js
import typescript from "@rollup/plugin-typescript";
import babel from "@rollup/plugin-babel";
import commonjs from "@rollup/plugin-commonjs";

export default [
    {
        input: "src/index.ts",
        output: {
            file: "dist/index.js",
            format: 'es'
        },
        plugins: [
            // tsconfig.jsonに従って、TypeScriptがトランスパイルされます。
            typescript(),

            // .babelrcに従って、ES3へトランスパイルされます。
            babel({
                babelHelpers: 'bundled',
                extensions: ['.js', '.ts'],
            }),

            // CommonJSモジュールをES Modulesに揃えます。
            commonjs()
        ]
    }
];
```

## `package.json`の編集

`"type": "module"`を追加し、`"scripts"`に`"build"`を追加します。

JScriptでは`Object.defineProperty`という重要なメソッドが使えず、おそらくBabelもそれを承知でトランスパイルしてくれるようなのですが、その後Rollupがバンドルに`Object.defineProperty`を利用しようとするため、JScriptで実行できないものを出力してしまうようです。

なので、バンドル結果の`index.js`の先頭に簡易ポリフィルを追加するスクリプトを折り込みました。`npm run build`の正体は最小化されたバッチファイルであり、そもそもWindowsでしか使えないスクリプトなので他OSへの移植性は気にしていません。（それが伝わるように、わざとDOSコマンドを大文字で書いています。）

```jsonc
{
    "type": "module"
    // 省略
    "scripts": {
        // 省略
        "build": "ECHO Object.defineProperty=function(o,p,d){o[p]=d.value}; > _.js && rollup -c && COPY /B _.js+dist\\index.js _.js && powershell \"Get-Content -Encoding UTF8 _.js | Set-Content -Encoding Unicode dist\\index.js\" && DEL /Q _.js"
    }
    // 省略
}
```

```bat
REM 簡易ポリフィルを記載した一時ファイルを用意します。
ECHO Object.defineProperty=function(o,p,d){o[p]=d.value}; > _.js

REM Rollupの結果、dist\index.js が生成されます。
rollup -c

REM 一時ファイルとindex.jsを連結して、一時ファイルに上書きします。
COPY /B _.js+dist\index.js _.js

REM 後述の日本語対応のために、一時ファイルの文字コードをUTF-8からUTF-16 LEに変換して、index.jsを上書きします。
powershell "Get-Content -Encoding UTF8 _.js | Set-Content -Encoding Unicode dist\index.js"

REM 一時ファイルを削除します。
DEL /Q _.js
```

## `index.js`の生成

以下を実行することで、トランスパイルとバンドル及び文字コードの変換が走り、`dist\index.js`が生成されます。

```ps1
npm run build
```

このファイルをwscript/cscriptで実行することができます。

```ps1
wscript .\dist\index.js
```

# 日本語対応

先頭に載せたリポジトリでも説明しているのですが、本来WSHは`ASCII`、`Shift_JIS`、`UTF-16 LE`あたりしか読むことができず、一般的に`UTF-8`を前提とするJavaScriptツールチェインとは相性が悪いです。`UTF-8`で保存されたスクリプトで日本語の文字列を表示すると、WSHでは文字化けします。

ただし今回の方式では、どこかでツールが気を利かせてくれて、UTF-8でもWSHで文字化けしないように変換されています。

```
ふが => \u3075\u304C
```

しかしバンドル後ファイルの可読性も落ちますし、他コンポーネントとの連携に支障があるかもしれません。念のため、`package.json`の`"scripts"`で文字コードを変換しています。

# サンプル

この記事の環境構築を適用したプロジェクトのサンプルとして、WSH JScriptで実行されるバイナリビュアーを作成しました。TypeScriptではアロー関数や`Array.forEach`、テンプレートリテラル、letやconstをふんだんに利用していますが、最終的にはJScriptとして実行可能な形式に変換されます。

https://github.com/mukai1011/binary-viewer

WSH JScriptでは本来バイナリファイルを扱うことは難しいのですが、一工夫することでNumberに落とし込むことができます。この部分をモジュールに切り出し、再利用しやすいようにまとめることができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1550728/f46ee3d0-6854-5f89-d11e-9e4db4a398de.png)

# 余談: Windows APIについて

従来、WSHでWindows APIを利用する方法として、[SFC mini](https://sfcmini.osdn.jp/help/index.html)や[Excel経由](https://support.microsoft.com/ja-jp/office/call-%E9%96%A2%E6%95%B0%E3%81%A8-register-%E9%96%A2%E6%95%B0%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9-06fa83c1-2869-4a89-b665-7e63d188307f)で利用することが提案されていました。しかしせっかくWSHは標準搭載なのに、Windows APIの実行を外部のソフトウェアに頼るのは残念ですよね。

これを解決する手段として、まだ途上なのですが、PowerShellのP/Invoke経由で実行する方法を考えています。

```js
WScript.CreateObject("WScript.Shell").Run("cmd.exe /c powershell -Nologo (Add-Type -Name _ -MemberDefinition '[DllImport(\"\"\"\"user32.dll\"\"\"\")] public static extern int MessageBoxA(IntPtr param0, string param1, string param2, UInt32 param3);' -PassThru)::MessageBoxA(0, \"\"\"\"ほげ\"\"\"\", \"\"\"\"ふが\"\"\"\", 0) 1>stdout 2>stderr", 0, true);
```

一時ファイル`stdout`に戻り値、`stderr`にエラー出力が格納されます。これらを解析することで、成否を判断することができます。個々の単純なAPIに対してラッパーを用意するのは簡単なのですが、戻り値を引数に返してきたりするものへの対応を考えているところです。

# まとめ

WSH JScriptを、かなり現代のJavaScriptに寄せて書くことができるようになりました。書きやすいバッチファイルとしてもよし、Google Apps ScriptやOffice ScriptよろしくExcel VBAをモダンに書くこともできます。