【WASI】ちょっとデータを整えたい！ブラウザでワンライナーが使えるエディタを作ってみた

この記事は「[富士通クラウドテクノロジーズ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/fjct)」の12日目の記事です。
前日は @ks2022 さんの「[アラート対応の自動化](https://qiita.com/ks2022/items/0355310013c84dcb2ebb)」でした。
トラブル時にはどうしても情報整備まで気が回らないので、自動でストック情報にまとめる仕組みがあるのは便利ですね。属人化を防ぐのにも役立ちそうです。

# TL; DR
データ整形のワンライナー、UIで使えたら便利では？

![linerduper_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/e93e1a37-c422-529d-b9df-ec77d74e80df.gif)

# はじめに
手元にあるファイルから欲しいデータを掬いとる、一度限りのプログラミング。ワンライナーには儚いロマンが詰まっています。

...とはいえ、パイプを流れる中間データを想像するのは大変です。

```bash:中間データの形式は？（これくらいならまだ分かるけど）
cat users.json | jq -r .users[].name | awk '{print $1}' | sort | uniq -c > first_name_count.csv
```

工程ごとに都度出力しながら見ればイメージは付きますが、データが大きいとスクロールが流れて見づらいです。headで絞れば各行の内容は見やすくなりますが、今度は都度ワンライナーを書き換えるのが手間です。

ワンライナーのライブ感はそのままに、UI上でデータを見ながら変換ができたらいいのにな...

# つくったもの
**というわけで作りました**。ブラウザ上で動きます。

https://syuparn.github.io/linerduper/

まず、下のテキストエリアにデータを入れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/668c58bd-ccfa-772d-b3e0-c929b4dcc1ba.png)

次に、コマンド（今はプログラミング言語しかありません）を選び、ワンライナーを入れます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/5a506f7a-94a2-dbf9-cf34-047d0271f7a3.png)

適用すると、テキストエリアが出力結果に書き変わります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/e2543f99-2891-1840-e545-978873dec2c9.png)

後は別のワンライナーを適用し、気に入るまで変換し続けましょう（気に入らなかったら元に戻せます）。
将来的には `head`, `tail`, `wc`, `sort` あたりも入れたいです [^commands]。

どさくさに紛れて「Pangaea」というコマンドが入っていますが、これは自作言語です。 ~~抱き合わせは売名の常套手段~~

https://qiita.com/Syuparn/items/87cafc7fd206016a0f8d

## デザイン
Vite + React + TypeScriptで作成しています。
CSSは苦手ですが、Chakra UIで事なきを得ました。

https://chakra-ui.com/

ロゴには英字フォント「Line」を使用しています。直線的でおしゃれ

https://booth.pm/ja/items/4144426

# 仕組み
## UI上でのコマンドの実行
なるべく構成をシンプルにしたかった（デプロイの手間やコマンド実行によるセキュリティを考えるとバックエンドも持ちたくない）ので、WASIを使用しました。
WASIバイナリのダウンロードを除き、**オフラインで動作が完結します**。

フロントエンド上でのWASIコマンド実行にはRunnoを使用しています。

https://runno.dev/

WASMなので実行環境がサンドボックス化されており、ファイル読み込みや通信等は無効化されています[^wasm]。

Runnoでは、デフォルトで以下のコマンドを使用可能です。

- `python`
- `ruby`
- `quickjs` (JavaScript)
- `sqlite`
- `clang`
- `clangpp`
- `php-cgi`

実行も1関数で完結します。

```typescript:
headlessRunCode("python", sourceCode, stdin).then(value => {
  // 予期せぬクラッシュ
  if (value.resultType !== "complete") {
    console.log(`failed to run: ${JSON.stringify(value)}`)
    return
  }
  // エラー発生
  if (value.stderr !== "") {
    alert(value.stderr)
    return
  }
  // 正常終了
  console.log(value.stdout)
})
```

自前のWASIコマンドも実行可能です。シグネチャが違うので、デフォルトのコマンドと共存させる場合は要注意です（型合わせの実装は [こちら](https://github.com/Syuparn/linerduper/blob/main/src/wasm/handler.ts)）。

```typescript:公式リポジトリのサンプルコードより一部改変
const result = await WASI.start(fetch("/binary.wasm"), {
  args: ["binary-name", "--do-something", "some-file.txt"],
  stdout: (out) => console.log("stdout", out),
  stderr: (err) => console.error("stderr", err),
  stdin: () => prompt("stdin:"),
});
```

## WASIバイナリ作成
続いて、自前で追加したいコマンドのWASIを用意します。ワンライナーといえばやはり `jq` と `awk` は欲しいですね。

ちょうどGo1.21からビルドターゲットにWASIが追加されたので、Go実装のjq, AWKをWASIにビルドして使用します。

https://go.dev/blog/wasi

```bash
GOOS=wasip1 GOARCH=wasm go build -o main.wasm
```

https://github.com/itchyny/gojq

https://github.com/benhoyt/goawk

これで必要コマンドが揃いました。

# ハマったところ
ここまで順風満帆かのように書いてきましたが、実際は**色々な問題にハマり続けていました**...
特にGitHub Pages絡みのトラブルシューティングは骨が折れました :angel: リンク先記事の先人の方々には感謝してもしきれません。

## Chakra UIがレンダリングされない
Chakra UIのコンポーネントにCSSが反映されない問題。`App` を`ChakraProvier` でラップする必要がありました :innocent:

https://chakra-ui.com/getting-started/cra-guide#2-provider-setup

```typescript:src/Main.tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ChakraProvider theme={theme}>
      <App />
    </ChakraProvider>
  </React.StrictMode>,
)
```

## Runnoで標準入力読み込みが無限ループする
WASI実行メソッド `WASI.start` でハマった問題。標準入力を以下のように実装したところ標準入力読み込みが無限ループしてしまいました。

```typescript:src/wasm/handler.ts
  const result = await WASI.start(fetch(path), {
    // ...
    stdin: stdin,
  })
```

こちらは仕様によるもので、`stdin` を対話的に読み込めるよう **stdin関数が `null` を返すまで繰り返し呼び出される**という挙動でした。

正しくは以下のように実装します。クロージャは偉大[^closure]。

```typescript:src/wasm/handler.ts
  const result = await WASI.start(fetch(path), {
    // ...
    stdin: (() => {
      // HACK: stdinを一度だけ返すようにクロージャを使用
      let done = false
      return () => {
        if (done) {
          return null
        }
        done = true
        return stdin
      }
    })(),
  })
```

## Runnoで標準入力読み込みがハングする

:::note info
**`@runno/runtime v0.6.2` で修正されたため現在は以下の対処は不要です！**
:::

PR即リリースいただけてありがたい～

https://github.com/taybenlor/runno/pull/285

ここからはあくまで試行錯誤の記録としてお読みください。

<details><summary>試行錯誤の記録（クリックで展開）</summary><div>

標準入力を末尾まで取得しようとするとハングしてしまいました。

```ruby:ハングするコードの例
# 標準入力を配列形式で全行取得
p readlines
```

結論としては、入力末尾に `EOF` を追加していないのが原因でした。

Runnoとしては、対話的に標準入力を1行ずつ受け取り、終わったら `ctrl + d` を押して終了する想定のようです。

```:サンプルコードの説明文（日本語は拙訳）
This demo writes whatever you type to the file specified in args. When the program finishes you'll see a file in the filesystem with your contents. 
To finish entering text press ctrl+d.

このデモでは、入力内容を引数に指定したファイルに書き込みます。プログラム終了時に、ファイルシステムに入力した内容のファイルが作成されます。テキスト入力を終了するにはctrl + dを押してください。
```

https://github.com/taybenlor/runno/blob/ea5c4542afb5a031b5947d458873f4d9bcb81f07/packages/website/src/demos/wasi-examples.ts#L27

一方、今回使用した `headlessRunCode` では標準入力を文字列の形で事前に渡しているため、`ctrl + d` によりEOFを追記することができません。

実装を追っていくと、Runnoのランタイム内部に手を加えないと解消しないことが分かりました。

```typescript:packages/runtime/lib/headless.ts
export async function headlessRunFS(
  runtime: Runtime,
  entryPath: string,
  fs: WASIFS,
  stdin?: string
): Promise<RunResult> {
  // ...
  if (stdin) {
    workerHost.pushStdin(stdin);
    // 以下の追加が必要！
    workerHost.pushEOF();
  }
  // ...
}
```

Runnoのランタイム自体を変更する必要が出てきたので、その場しのぎですがソースコードにパッチを当てることにしました。幸い、npmにパッチを当てるツール patch-package があったので、手で書き換えた修正をパッチ化、 `npm install` 時に自動適用できるようにしました。

https://www.npmjs.com/package/patch-package

参考記事

https://bagelee.com/programming/javascript-2/patch-package/

できたパッチはこのような内容です。minifyされてたから読むの辛かった... （~~そもそもminifyされたコードを手で直してはいけません~~）

```diff:作成されたパッチ
diff --git a/node_modules/@runno/runtime/dist/runtime.js b/node_modules/@runno/runtime/dist/runtime.js
index 813773f..5645bdf 100644
--- a/node_modules/@runno/runtime/dist/runtime.js
+++ b/node_modules/@runno/runtime/dist/runtime.js
@@ -31445,7 +31445,11 @@ async function ry(i, e, t, r) {
       s.stderr += c, s.tty += c;
     }
   });
-  r && l.pushStdin(r);
+  if (r) {
+    l.pushStdin(r);
+    console.log("HACK: add EOF to avoid hang");
+    l.pushEOF();
+  }
   const O = await l.start();
   return s.fs = { ...t, ...O.fs }, s.exitCode = O.exitCode, s;
 }
```

後から振り返ると、この機能はPR作る前の動作確認としても便利ですね。
</div></details>


## GitHub Pages作成時に、生成物をfeatureブランチへpushできない
GitHub Pagesデプロイにはactions-gh-pagesのGitHub Actionを使用しました。

https://github.com/peaceiris/actions-gh-pages

このactionではビルド生成物featureブランチ `gh-pages` にpushすることで管理していますが、このブランチへのpushが失敗してしまいました。

`GITHUB_TOKEN` の権限が弱くなったのが原因だったため、明示的にpushの権限を与えました。

参考記事

https://qiita.com/Hiroki_lzh/items/3607072052a2de7e8378#push%E3%81%99%E3%82%8B%E3%81%9F%E3%82%81%E3%81%AE%E6%9B%B8%E3%81%8D%E8%BE%BC%E3%81%BF%E6%A8%A9%E9%99%90%E3%82%92%E4%BB%98%E4%B8%8E%E3%81%9B%E3%82%88

## GitHub pagesでNot Foundが発生する
デプロイが成功したと思ったら、今度はscript内のjsファイルやwasmファイル等が404で読み込めません。
これはパスがずれているのが原因でした。

||パス|
|-|-|
|エンドポイント|`https://syuparn.github.io/linerduper/`|
|表記|`/hoge.js`|
|期待するパス|`https://syuparn.github.io/linerduper/hoge.js`|
|実際のパス|`https://syuparn.github.io/hoge.js` |

### index.htmlでのJSファイル読み込み
GitHub Pagesにデプロイするときだけ `defineConfig` の `base` にパスを追加しました。

参考記事

https://zenn.dev/shivase/articles/009-react-vite-githubpages

### WASMファイル
上記でも `fetch` のパスは修正されないため、GitHub Pages上のフルパスを指定しました。
強引ですが、WASI自体を変更することはめったに無いので問題ない想定です。

https://github.com/Syuparn/linerduper/blob/main/src/wasm/handler.ts#L7

## `SharedArrayBuffer` が未定義エラー
WASI実行時に以下のエラーが発生しました。

```
Uncaught ReferenceError: SharedArrayBuffer is not defined
```

これは意図的なもので、セキュリティ対策として `SharedArrayBuffer` が動作できる条件を「同じオリジンからしかオブジェクトを参照できない状態」に限定しているためです。

https://developers-jp.googleblog.com/2021/03/chrome-91-sharedarraybuffer.html

具体的には以下のレスポンスヘッダが有効になっていないと使えません。

```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```

対策として、Service Worker を使用してレスポンスヘッダ追加しました。仕組みが複雑なので、詳細は以下の参考記事をご覧ください。

https://zenn.dev/wok/articles/0035_ffmpeg_wasm_on_github_pages

:::note alert
インターネット上の信頼できないwasmファイルや、利用者が指定したURLに対してこの方法を使うのは非推奨です。
:::

ちなみに、Viteのミドルウェアでヘッダを追加する方法も試しましたが上手くいきませんでした。
GitHub Pagesはカスタムヘッダーに対応するまで無理なようです。

https://github.com/orgs/community/discussions/54257

追加した実装

https://github.com/Syuparn/linerduper/blob/main/vite.config.ts#L8

# おわりに
以上、ブラウザ上で動くワンライナーエディタ（？）の紹介でした。構想2年、実現方法を考えあぐねていたのですが、Runnoとの出逢い、そしてGoのWASIサポートによってついに形になりました。
WASIはまだまだ色々なところに活用できそうです。~~後半はWASIよりもGitHub Pagesの仕様の話になってしまいましたが...~~

この記事は「[富士通クラウドテクノロジーズ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/fjct)」の12日目の記事でした。
明日は、@kato-hiroki-783さんがredfishAPI or vsphere自動化 or homelabについて書いてくださるようです。効率的なインフラ管理のTipsが聞けそうですね。お楽しみに！

[^wasm]: Runnoの設定で明示すれば、WASI上の仮想的なファイルを読み書きすることは可能です。
[^commands]: WASI実装が必要なのでGoやRustの移植版を探すか自作する必要があります...
[^closure]: もちろん、状態変化するオブジェクトで管理することも可能です。