ブラウザをターミナル代わりにするrttyを作った

## 初めに
以前にブラウザからターミナルを操作したくて、周りの方に聞いたら[Secure Shell App](https://chrome.google.com/webstore/detail/secure-shell-app/pnhechapfaindjhompbnflcldabbghjo?hl=ja)を教えていただいたんですが、なぜか自分の環境ではうまく動作しませんでした。
他にも似たようなことができるツールはありましたが、メンテナンスがされていなかったりビルドもインストールできなかったりして使えなかったので、自作しました。

https://github.com/skanehira/rtty

しくみ自体はイメージできていたので、そんなに難しくないだろうと思っていましたが、意外と罠があってそれを解決するのに時間がかかってしまいました。

## `rtty`の概要と使い方
`rtty`はプロセスの入出力を`websocket`を通して、ブラウザでターミナルのような操作を可能にするCLIです。
簡単にいうとブラウザがターミナル代わりになる、という感じです。

```
+---------+     http     +------+   stdin   +------+
| browser | <==========> | rtty | <=======> | bash |
+---------+   websocket  +------+   stdout  +------+
```

普段、ぼくはVivaldiというブラウザを使用しています。このブラウザはタブタイリングという機能があり、複数のタブを1画面にまとめられます。次のスクショのようにコーディングしつつブラウジングするにはもってこいです。

![](https://i.gyazo.com/bc8a484cdbffbf8fd1d6a574f181cb24.png)

使い方はシンプルで`rtty run {実行したいコマンド} -v`するだけです。詳細なオプションはREADMEを参照していただければと思います。

## 実装
もう少し詳細を説明すると`rtty`は`PTY`を使って、プロセス入出力を読み書きしています。

```
+---------+     http     +------+       +------+       +-----+    stdin   +------+
| browser | <==========> | rtty | <===> | ptmx | <===> | pts |  <=======> | bash |
+---------+   websocket  +------+       +------+       +-----+    stdout  +------+
```

`PTY`や`ptmx`などについては[こちらの記事](https://yakout.io/blog/terminal-under-the-hood/)がわかりやすいので、わからない方は一度そちらを読むと良いと思います。
簡単に言うと、`rtty`はターミナル（`ptmx`と`pts`）の入出力を`websocket`でブラウザとつないでいるというイメージです。

`ptmx`を作成する部分は[github.com/creack/pty]("https://github.com/creack/pty")というパッケージを使用しています。2行で`ptmx`取れるので便利です。

```go
// Create arbitrary command.
c := exec.Command("bash")

// Start the command with a pty.
ptmx, err := pty.Start(c)
```

あとは`io.Copy`を使って、入出力をよしなに処理しています。

```go
	// Make sure to close the pty at the end.
	defer func() {
		_ = ptmx.Close()
		_ = c.Process.Kill()
		_, _ = c.Process.Wait()
	}() // Best effort.

	go func() {
		_, _ = io.Copy(ptmx, ws)
	}()

	w := &wsConn{
		conn: ws,
	}

	_, _ = io.Copy(w, ptmx)
```

ブラウザ側は[xterm.js](https://github.com/xtermjs/xterm.js)を使用しています。VSCodeのターミナル機能もこれを使って実装されています。
これだけでブラウザ側でいつもの黒い画面になり、入力を受け取りWebsocketに送信でき、受信したデータをDOMに書き込めます。エスケープシーケンスもすべて`xterm.js`が処理して変換してくれるので、ほぼ何もしていないといって良いレベルです。

```ts
const terminal = new Terminal(option);
const fitAddon = new FitAddon.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(document.getElementById('terminal'))
fitAddon.fit();

terminal.onData(data => {
  socket.send(data);
})

socket.onmessage = (e) => {
  terminal.write(e.data);
}
```

## 問題
こんな感じで処理自体はとてもシンプルですが、実は`Vim`などのTUIを使うとたまにメッセージをデコードできなくて、Websocketがcloseされてしまうという問題がありました。

https://twitter.com/gorilla0513/status/1391413068744122370?s=20

メッセージを読むとUTF-8として正しくないデータなのでデコードできない、と言っています。
最初は検討もつかなかったんですが、デバッグしていくうちに`io.Copy`の部分の実装が良くないことに気付きました。

先程載せたコードは修正版ですが、初期の実装は以下になっていました。

```go
_, _ = io.Copy(ws, ptmx)
```

これだと、プロセスの出力が`io.Copy`のバッファサイズを越えた場合、データが途中Websocketに送られてしまいます。それによって、UTF-8として正しくないテキストデータが送られてしまい、ブラウザ側でデコード失敗してしまいます。
簡単にいうと、`あ`は切断されるとinvalidなUTF-8になります。このようなことが発生していました。

```go
a := []byte{0xe3, 0x81, 0x82} // あ
utf8.Valid(a[:2]) // false
```

そのため、読み取ったデータがUTF-8としてinvalidならバッファリングして、バッファの中身がvalidならWebsocketに送るという処理が必要になります。
`rtty`は`x/net/websocket`を使用していますが、残念ながら`Write`はそれを考慮した実装になっていないため、今回の問題が起きました。

対策として`websocket.Conn`とバッファを持った構造体を用意して、それに`io.Writer`を実装して問題を解決しました。

```go
func (ws *wsConn) Write(b []byte) (i int, err error) {
	if !utf8.Valid(b) {
		buflen := len(ws.buf)
		blen := len(b)
		ws.buf = append(ws.buf, b...)[:buflen+blen]
		if utf8.Valid(ws.buf) {
			_, e := ws.conn.Write(ws.buf)
			ws.buf = ws.buf[:0]
			return blen, e
		}
		return blen, nil
	}

	if len(ws.buf) > 0 {
		n, err := ws.conn.Write(ws.buf)
		ws.buf = ws.buf[:0]
		if err != nil {
			return n, err
		}
	}
	n, e := ws.conn.Write(b)
	return n, e
}
```

## 最後に
ひとまず最低限使えるレベルになってきたので、使いながら改善していこうと思っています。
ちなみに、この記事は`rtty`を使ってプレビューしながら書きました。便利やなって思いました。
みなさんも良ければ使ってみてください。

![](https://pbs.twimg.com/media/E6_s92NVkAERIgg?format=jpg&name=4096x4096)