Javascript 再入門

JavaScript (node.js) に再入門する必要があったので、Copilot Chat 先生に教えていただいた内容を忘れないように整理しておきたい。
　多分普通にやっている人には何の必要性も無い情報だと思うが、自分の知識の整理のためにやっている。

 経緯としては、GitHub Actions のカスタム Action を書くにあたって、JavaScript/TypeScript が必要になったので、ひっさびさに触ってみたが、いろいろ進化しているようなので、そのあたりを整理する。

 元々の目的が、Custom GitHub Action の作成だから、[Creating a JavaScript action](https://docs.github.com/en/actions/creating-actions/creating-a-javascript-action)にしたがってNode.js 20を使用してコードを書いている。書きたいコードは単純で、ファイルをダウンロードして、Unzipするという極めて簡単なもの。Copilot Chat も生成してくれたが動かない。

 # http.get vs fetch

 最初に躓いたのが、`http.get` で、ファイルのダウロードなど簡単と思っていたが、Copilot Chat が作ってくれた次のようなプログラムは失敗した。

 ```javascript
 const https = require('https');
const fs = require('fs');
const AdmZip = require('adm-zip');

const url = 'https://github.com/TsuyoshiUshio/BlogBabel/releases/download/v1.0.0-preview.5/BlogBabel-win-x64.zip'; // 'https://github.com/TsuyoshiUshio/BlogBabel/releases/download/v1.0.0-preview.5/BlogBabel-win-x64.zip'; // Replace with your ZIP file's URL
const outputPath = 'downloaded.zip'; // Temporary output path for the downloaded ZIP file
const extractPath = 'extracted/'; // Directory where the contents will be extracted

https.get(url, (response) => {
  if (response.statusCode !== 200) {
    console.error(`Failed to download file: ${response.statusCode}`);
    response.resume();
    return;
  }
    :

}).on('error', (err) => {
  console.error(`Error: ${err.message}`);
});
 ```

 上記のプログラムが動作しない理由は、`github.com` の `Release` からのダウンロードは、最初に302つまりリダイレクトが帰ってくるのだが、`http.get`はそれに対応していないらしい。2024年にそれは衝撃的過ぎるのだが、`http.get`はリダイレクトをサポートしていないっぽい。だからやるとしたら、302 のレスポンスをハンドリングして、自分でリクエストする必要がある。
　代わりに[fetch](https://developer.mozilla.org/ja/docs/Web/API/fetch#redirect)は思いっきりサポートしている。使い味もそんなに変わらないので、書き換えてみよう。

 ```javascript
const fs = require('fs');
const AdmZip = require('adm-zip');
const { pipeline } = require('stream');
const { promisify } = require('util');

const url = 'https://github.com/TsuyoshiUshio/BlogBabel/releases/download/v1.0.0-preview.5/BlogBabel-win-x64.zip';
const outputPath = 'downloaded.zip';
const extractPath = 'extracted/';

const download = async (url, outputPath) => {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`unexpected response ${response.statusText}`);
  await promisify(pipeline)(response.body, fs.createWriteStream(outputPath));
  console.log('Download completed, extracting...');
};

download(url, outputPath)
  .then(() => {
    const zip = new AdmZip(outputPath);
    zip.extractAllTo(extractPath, true);
    console.log(`Extraction completed to "${extractPath}"`);
    fs.unlinkSync(outputPath);
    console.log('Downloaded ZIP file removed.');
  })
  .catch((err) => console.error(`Error: ${err.message}`));
 ```

 素晴らしい。簡単だ。第一部完！という感じだが、JavaScript が久々過ぎてちゃんと理解できていない。
自分でスパイクソリューションを書いて理解していこう。今は Copilot Chat 先生がいるので質問が出来て便利だ。

# fetch と async/await

まずは次のセクションを理解していこう。download 関数の定義で、`async` が定義されている。C#と同様のイメージで使える感じのようだ。これで、非同期のプログラミングが楽になりそう。C#の場合は内部での戻り値は、`Task` とか `ValueTask`だったが、こちらは`Promise`になっている。

```javascript
const download = async () => {
    const response = await fetch("https://developer.mozilla.org/ja/docs/Web/API/fetch#redirect");
    const writable = fs.createWriteStream("redirect.html");
    await promisify(pipeline)(response.body, writable);
```

 [promisify](https://www.geeksforgeeks.org/node-js-util-promisify-method/)はコールバックをPromiseの形式にしてくれるようだ。これは素晴らしい。つまりJavaScript はコールバックの塊だが、これで、promise 化ができるので、async/await と使うことが出来るようになる。

[pipeline](https://www.geeksforgeeks.org/node-js-stream-pipeline-method/)はその名の通り、ストリームを別のストリームにつないでくれるもの。試していないが第三引数にコールバックを渡せる様子。ファイルを読んで書くとか、そういう時にとてもすっきりかけて良い。だから上記のプログラムは、`fetch` でファイルを取得して、ファイルに書き出しているというコードだ。

# Promise と then/catch

次のセクションは上記の download() を実行すると、`async`メソッドで定義されているので、実態としては、`Promise`が帰ってくる。`then` は Continuation で、ダウンロードの実行後に継続して実行するコールバックを書ける。`catch`は例外が発生したときのコールバックだ。

```javascript
download()
.then(() => console.log("Download completed"))
.catch((err) => console.error(`Error: ${err.message}`));
```

ところが、これにはちょっと問題がある。download のあとに、入力プロンプトを表示して、ファイルを消したいとする。すると、問題が発生する。

```javascript: コード
const fs = require('fs');
const { pipeline } = require('stream');
const { promisify } = require('util');

const download = async () => {
    const response = await fetch("https://developer.mozilla.org/ja/docs/Web/API/fetch#redirect");
    const writable = fs.createWriteStream("redirect.html");
    await promisify(pipeline)(response.body, writable);
}

download()
.then(() => console.log("Download completed"))
.catch((err) => console.error(`Error: ${err.message}`));


const readline = require('readline');

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
});

rl.question('Clear redirect.html:', (answer) => {
    fs.unlinkSync("redirect.html");
    rl.close();
});
```
このコードは思った通り動かない。

```powershell:実行結果
> node fetchspike.js
Clear redirect.html:Download completed
```

これは、ダウンロードが完了する前に、入力プロンプトが実行されてしまっている。これを防ぐためには、`then`の中に、入力プロンプトのコードを入れ込む必要がある。

こんなサンプルプログラム程度ならよいが、このコードのあとにこれを実行みたいなケースですべて、`then`の中のコールバックに書いていたらネストがすごいことになるだろう。
　これがいわゆるコールバックヘルという状態だ。そうならない方法を聞くと `async/await`だとCopilot Chat から帰ってきた。早速書き換えてみよう。


 ```javascript
const fs = require('fs');
const { pipeline } = require('stream');
const { promisify } = require('util');

const download = async () => {
    const response = await fetch("https://developer.mozilla.org/ja/docs/Web/API/fetch#redirect");
    const writable = fs.createWriteStream("redirect.html");
    await promisify(pipeline)(response.body, writable);
}

const cleanup = (message) => {
    const readline = require('readline');

    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });
    
    rl.setPrompt('Clear redirect.html? [y/n]> ');
    rl.prompt();
    
    return new Promise(resolve => rl.on('line', (line) => {
        switch (line.trim()) {
            case 'y':
                fs.unlinkSync("redirect.html");
                rl.close();
                break;
            case 'n':
                rl.close();
                break;
            default:
                console.log(`Unknown: ${line.trim()}`);
                break;
        }
        resolve();
    }));
}

const main = async() => {
    try {
        await download();
        console.log("Download completed");
        await cleanup();
    } catch (err) {
        console.error(`Error: ${err.message}`);
    }
}
    
main();


 ```

# promisify での書き換え

 ついでにプロンプトも若干リッチにしておいた。`readline`は`question`の他にも、`setPrompt/prompt`や、`on`のメソッドがあるようだ。`cleanup` 関数がPromise()を返すようにしているのは、`await` できるようにするためだが、もしかすると、`promisify` でできるんちゃう？やってみよう。

 ```javascript
    :
 const cleanup = (message) => {
    const readline = require('readline');

    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });
    
    rl.setPrompt(message);
    rl.prompt();
    
    rl.on('line', (line) => {
        switch (line.trim()) {
            case 'y':
                fs.unlinkSync("redirect.html");
                rl.close();
                break;
            case 'n':
                rl.close();
                break;
            default:
                console.log(`Unknown: ${line.trim()}`);
                break;
        }
    });
}

const main = async() => {
    try {
        await download();
        console.log("Download completed");
        await promisify(cleanup)(`Clear redirect.html? [y/n]> `);
    } catch (err) {
        console.error(`Error: ${err.message}`);
    }
}
    
main();
```

とってもクリアになりました。

```powershell:実行結果
> node fetchspikeasync.js
Download completed
Clear redirect.html? [y/n]> y
```