コンテンツにスキップ

ADR-0076: Node の実行環境は、ページを開いたときに起動し、シェルを制限なしで使わせ、訪問者が変えたコードは操作の後に動かし、起動し直すときに作業ディレクトリを読めなければ、前に起動したときのファイルで起動する

Status

承認

Context

Node の実行環境を、いつ起動し、どのコマンドを訪問者の操作なしに実行し、訪問者にどのコマンドを使わせるかを決める必要がある。
「実行環境を起動し直す」の操作で、どのファイルで起動するかと、作業ディレクトリを読めなかったことを訪問者に知らせるかも決める必要がある。

Decision

1. 起動の時期

Node の実行環境は、ツールのページを開いたときに起動する。
記事の iframe の中でも、トップレベルで開いたときも、同じにする。

  • 記事の iframe は、訪問者がボタンを押してから作るので、起動は、訪問者の操作の後になる。
  • トップレベルで開いたときは、訪問者がツールのページを開いたことを、操作とみなす。

2. 訪問者の操作なしに実行するコマンド

  • 保存したファイルが無いとき(初期ファイルのまま)は、起動の後に、npm install と、プロジェクトの設定の起動のコマンドを、自動で実行する。
  • 保存したファイルがあるとき(再訪)は、npm install だけを自動で実行する。起動のコマンドは、訪問者が実行のボタンを押してから実行する。
  • npm install が失敗したときは、起動のコマンドを実行しない。端末に、npm install の出力が残る。
  • 起動のコマンドが複数あるときは、設定に書いた順に起動する。前のコマンドの終了を待たない。サーバーのように、終了しないコマンドを並べられるようにするためである。
  • 「実行環境を起動し直す」の操作の後は、再訪と同じに扱う。npm install だけを自動で実行する。
    • 起動し直すときにどのファイルで起動するかは、Decision 3 にある。
    • DB のプロセスが終了したとき以外で、起動の後に実行環境が動かなくなったときは、訪問者が、この操作で起動し直す。

再訪のときに起動のコマンドを自動で実行しない理由は、訪問者が書きかけたコードを、開いただけで動かさないためである。

3. 起動し直すときのファイル

  • 起動し直すときは、破棄する前の実行環境から読んだ、作業ディレクトリのファイルで起動する。訪問者が変えたファイルは、IndexedDB を読めないときも残る。
    • シンボリックリンクを、新しい実行環境に渡さない。渡すと、指す先の中身を持つ通常のファイルになり、その後の保存で IndexedDB に入るためである。
    • プロジェクトの JSON の files のキーと同じ規則に合わないパス(区切りに __proto__ があるもの、\ を含むもの)も、新しい実行環境に渡さない。
  • 作業ディレクトリを読む処理には、時間の上限を付ける。
  • 起動し直すときに作業ディレクトリを読めないときは、保存のためか起動し直すときに最後に読めたファイルで起動する。それも無いときは、前に起動したときのファイル(保存したファイルか、初期ファイル)で起動する。
  • 作業ディレクトリを読むときは、次の 2 つのエラーだけをスキップする。
    • パスが存在しないエラー(ENOENT。一覧を読んだ後に消えたファイルとディレクトリ)
    • ファイルでもディレクトリでもない項目(シンボリックリンク)を読むときのエラー。理由を問わずスキップする。指す先の無いリンクと、互いを指すリンクも含む。
  • 次のときは、作業ディレクトリを読めないとみなす。
    • 作業ディレクトリそのものの一覧を読めないときは、パスが存在しないエラーでも、作業ディレクトリを読めないとみなす。
    • スキップしないエラーで、ファイルの中身かディレクトリの一覧を読めないときも、作業ディレクトリを読めないとみなす。
    • 作業ディレクトリを読む処理が、時間の上限までに終わらないときも、作業ディレクトリを読めないとみなす。
  • 作業ディレクトリを読めずに起動し直したときは、読めなかったことの案内を画面に出す。
    • 保存のためか起動し直すときに最後に読めたファイルで起動したときも、案内を出す。
    • 案内は、起動し直しを始めるときと、初期の状態に戻したときに消す。起動し直しで続けて読めなかったときは、消した後に出し直す。
    • 起動に失敗した実行環境を起動し直すときは、作業ディレクトリを読まないので、案内を出しも消しもしない。

前に起動したときのファイルで起動する理由は、保存したファイルを、実行環境と IndexedDB のレコードから消さないためである。
案内を出す理由は、保存のために最後に読んだファイルで起動しても、前に起動したときのファイルで起動しても、読めなかった時点より前のファイルに戻るためである。
案内が無いと、訪問者は、直前の変更が消えたことに気づけない。 ほかのエラーをスキップしない理由は、読めなかったファイルをスキップすると、作業ディレクトリを読めたものとして扱うためである。そのときは、読めなかったファイルの無い作業ディレクトリで起動し、案内も出ない。
シンボリックリンクのエラーを理由を問わずスキップする理由は、リンクを保存にも起動し直しにも使わないためである。
リンクを保存に使わない決定は、ADR-0077 にある。 互いを指すリンクの読み込みは ELOOP で失敗し続けるので、スキップしないと、リンクを 1 組作るだけで、その後の変更が案内なしに保存されなくなる。

4. 端末のコマンドの範囲

訪問者に、WebContainers のシェル(jsh)を、制限なしで使わせる。

  • 理由は、訪問者が壊せるものが、自分のブラウザの中の実行環境だけであることである。
  • 「実行環境を起動し直す」の操作を出す。

5. 動かせないブラウザでの表示

ブラウザの名前では分けず、起動を試す。
次のどれかのときは、動かせないと判定する。

  • crossOriginIsolated が false のとき。
  • 起動が失敗したとき。起動が 30 秒で終わらないとき。30 秒で判定する範囲は、WebContainers を起動してから、DB を使うプロジェクトでは初期データの SQL を実行し終えるまでである。
  • プレビューの iframe が、30 秒で読み込まれないとき。このときは、プレビューのペインだけを、動かせないと判定する。

動かせないと判定したときは、エディタに、初期ファイルを読み取り専用で出す。端末とプレビューのペインに、動かせない理由と、対応するブラウザを出す。
モバイルのブラウザも、同じ扱いにする。

Verification

起動にかかる時間と、シェルの環境変数

Playwright 1.65.0 の Chromium、WebKit、Firefox(headless)で測った。マシンは Apple M2、配信は localhost である。実機のブラウザは未確認である。

  • WebContainers 1.6.4 の起動は、Chromium で 3.1 秒、WebKit で 1.2 秒、Firefox で 1.0 秒だった。ADR-0033 の Verification では、Chromium の 1 回目が 3.6 秒だった。
  • DB のプロセスの依存を入れる時間は、3.1〜4.5 秒だった(ADR-0053 の Verification)。
  • 30 秒は、測った時間の合計(最長で約 8 秒)の約 4 倍にした値である。遅い回線と端末で、起動にかかる時間は、測っていない。
  • プレビューの iframe が読み込まれるまでの時間は、測っていない。プレビューの 30 秒は、起動と同じ値にした。
  • 実行環境がシェル(jsh)に渡した環境変数は、シェルの入力に書いたコマンドのプロセスに渡った。3 つのブラウザで同じだった。

起動し直すときのファイル

  • 初期ファイルで起動する実装を、単体テストの fake で確認した。保存した {"index.js": "b"} で起動し、作業ディレクトリを読む処理を止めて起動し直し、package-lock.json の通知を出した。
    • 最後の保存は {"index.js": "a", "package-lock.json": "{}", "package.json": "{}"} になった。
  • 前に起動したときのファイルで起動する実装では、同じ手順の最後の保存が {"index.js": "b", "package-lock.json": "{}"} になった。
  • 案内は、単体テストの fake で確認した。読めずに起動し直すと出て、読めて起動し直したときと、初期の状態に戻したときに消えた。続けて 2 回読めなかったときは、1 度消えてから、もう 1 度出た。
  • Apple M2 の vp dev の /node/ を、Playwright 1.64.1 の Chromium(headless)で開き、WebContainers の fs が投げるエラーを記録した。
    • 無いファイルの readFile、無いディレクトリの readdir、指す先の無いリンクの readFile は、どれも code を持たず、message が ENOENT: で始まる Error で失敗した。
    • . を指すリンク、ディレクトリを指すリンク、ファイルを指すリンクの readFile は、失敗しなかった。
    • 端末で互いを指す 2 つのリンク(a -> b、b -> a)を作ると、readFile は、code を持たず、message が ELOOP: で始まる Error で失敗した。スキップする前の実装では、その後にエディタで変えたファイルが保存されなかった。
  • 単体テストの fake で、次の 3 つを確認した。パスが存在しないエラーをスキップすること。シンボリックリンクを読むときのほかのエラーもスキップすること。作業ディレクトリそのものが無いときと、ファイルをほかのエラーで読めないときに、読み込みが失敗すること。
  • 互いを指すリンクがあるときに保存が続くことは、スキップする実装では、ブラウザで未確認である。
  • 読み込みが失敗したときに、前に起動したときのファイルで起動し、案内を出すことは、単体テストの fake で確認した。
  • 起動し直すときに作業ディレクトリを読めない場合の動きと案内は、ブラウザでは未確認である。WebContainers の読み込みだけを失敗させる手段が無いためである。WebKit と Firefox では、fs のエラーの message も記録していない。
  • 起動し直すときにリンクを渡さないことは、単体テストの fake で確認した。ブラウザでは未確認である。
  • Apple M2 の vp dev の /node/ を、Playwright 1.64.1 の Chromium(headless)で開き、.env、.npmrc、dist/a.js、.git/HEAD を作った。起動し直した後も、.env、.npmrc、dist、.git は作業ディレクトリに残った。

Considered Options

起動の時期の選択肢は次のとおり。

選択肢 強み 弱み
採用: ページを開いたときに起動する トップレベルで開いた訪問者が、ボタンを押さずに済む トップレベルで開くと、StackBlitz への通信が発生し、stackblitz.com の Cookie が置かれる
トップレベルでは、ボタンを押してから起動する ボタンを押すまで、外部への通信が発生しない 訪問者は、ツールのページを開いた後に、もう一度ボタンを押す

操作なしに実行するコマンドの選択肢は次のとおり。

選択肢 強み 弱み
採用: 初期ファイルのままのときだけ、起動のコマンドを自動で実行する 初めて開いた訪問者は、待つだけで動く画面を見られる。書きかけたコードを動かさない 再訪のときは、ボタンを 1 回押す
いつも、npm install と起動のコマンドを自動で実行する ボタンを押さずに済む 訪問者が書きかけたコード(無限ループなど)を、開いただけで動かす
いつも、訪問者が実行のボタンを押してから実行する 操作なしに動くものが無い 初めて開いたときも、ボタンを押す

起動し直すときのリンクの選択肢は次のとおり。

選択肢 強み 弱み
採用: 新しい実行環境に渡さない 起動し直した後も、リンクの先の中身が保存されない 訪問者が作ったリンクが、起動し直した後の作業ディレクトリに無い
指す先の中身を持つ通常のファイルとして渡す 起動し直した後も、同じパスでファイルを読める リンクの先の中身が、その後の保存で IndexedDB に入る

作業ディレクトリを読めないときに、どのファイルで起動するかの選択肢は次のとおり。

選択肢 強み 弱み
採用: 前に起動したときのファイル(保存したファイルか初期ファイル)で起動する 保存したファイルが、実行環境からも IndexedDB からも消えない 前に起動した後の変更のうち、まだ保存していないものが消える
初期ファイルで起動する 起動するファイルが、いつも運営者の用意したものになる 保存したファイルが実行環境から消え、次の保存で IndexedDB のレコードも初期ファイルで上書きされる

作業ディレクトリを読むときに、どのエラーをスキップするかの選択肢は次のとおり。

選択肢 強み 弱み
採用: パスが存在しないエラーと、シンボリックリンクを読むときのエラーをスキップする 読んでいる間に消えたファイルがあっても、互いを指すリンクがあっても読める。読めなかったファイルがあれば、案内を出す パスが無いことを、WebContainers のエラーの message で見分ける
パスが存在しないエラーだけをスキップする 読めなかったリンクも、作業ディレクトリを読めない扱いにできる 互いを指すリンクを 1 組作ると、その後の変更が案内なしに保存されなくなる
読めなかったものを全てスキップする 読み込みがほとんど失敗しない 読めなかったファイルの無い作業ディレクトリで起動し、案内も出ない
1 つでも読めなければ失敗する 見分ける処理が要らない 読んでいる間にコマンドがファイルを消すと、起動し直すたびに読めない扱いになる

作業ディレクトリを読めずに起動し直したことを、訪問者に知らせるかどうかの選択肢は次のとおり。

選択肢 強み 弱み
採用: 案内を出す 訪問者は、直前の変更が実行環境に無いことに気づける 案内が 1 つ増える
案内を出さない 画面が変わらない 訪問者は、直前の変更が消えたことに、ファイルを開くまで気づけない

端末のコマンドの範囲の選択肢は次のとおり。

選択肢 強み 弱み
採用: シェルを、制限なしで使わせる 訪問者が、npm install <パッケージ>、node <ファイル>、curl を自分で試せる 訪問者は、実行環境のプロセスを kill で止められる
シェルを出さず、記事が決めたコマンドをボタンで実行する 訪問者が、実行環境を壊せない 訪問者が試せる操作が、記事の決めたコマンドだけになる
シェルを出し、打てるコマンドを一覧で絞る 壊しにくい シェルの入力を解釈する処理を作る。node -e で何でも実行できるので、絞る効果が小さい

動かせないブラウザでの表示の選択肢は次のとおり。

選択肢 強み 弱み
採用: 起動を試し、動かせないときは、コードを読み取り専用で出す WebContainers が動くブラウザでは、訪問者がコードを動かせる。動かないときも、コードは読める 動かないブラウザでは、訪問者が、判定まで待つ
ユーザーエージェントで、デスクトップの Chromium 系のほかを止める 待つ時間が無い 動くブラウザも止める。ユーザーエージェントの判定を保守する
ページの全体を、案内の表示に替える 作るものが少ない 記事を読みに来た訪問者が、コードも読めない

Consequences

Positive

  • 初めて開いた訪問者は、待つだけで、動いているサーバーを見られる。
  • 訪問者は、端末で、自分でコマンドを試せる。
  • 動かせないブラウザでも、訪問者はコードを読める。
  • 作業ディレクトリを読めずに起動し直しても、保存したファイルが残る。
  • 作業ディレクトリを読めずに起動し直したことを、訪問者が画面の案内で知る。

Negative

  • トップレベルでツールのページを開くと、訪問者がボタンを押す前に、StackBlitz への通信が発生する。
  • 再訪のときの npm install は、訪問者が書き換えた package.json の install のスクリプトを、操作なしに動かす。
  • 訪問者は、実行環境が起動したプロセスを、端末から止められる。止めたときは、「実行環境を起動し直す」で戻す。
  • 1 MB を超えるファイルと、テキストとして読めないファイルは、「実行環境を起動し直す」の後に無い。そのため、起動し直した後の .git とビルドの出力には、テキストのファイルだけが残る。
  • 訪問者が作ったリンクは、起動し直した後の作業ディレクトリに無い。
  • プロジェクトの JSON の files のキーの規則に合わない名前のファイル(__proto__ という名前のディレクトリの下のファイルなど)は、起動し直した後の作業ディレクトリに無い。
  • 動かないブラウザでは、動かせないと分かるまで、最長で 30 秒かかる。
  • 作業ディレクトリを読めずに起動し直したときは、前に起動した後の変更のうち、まだ保存していないものが実行環境から消える。
  • 起動し直すときに作業ディレクトリを読めず、保存のために読んだ結果で起動したときは、その結果に除いたディレクトリの下のファイルが入らないので、ビルドの出力と .git が作業ディレクトリから無くなる。
  • 上限を超えている間は、保存のために読んだ結果が古いままになる。起動し直すときに作業ディレクトリを読めず、保存のために読んだ結果で起動したときは、超える前のファイルで起動する。
  • 保存のための読み込みが、スキップしないエラーで失敗し続ける間は、保存されず、案内も出ない。読み取り権限の無いファイルで失敗し続けるかは、未確認である。
  • 一覧を読んだ後に、ファイルがディレクトリに替わるか、ディレクトリがファイルに替わると、パスが存在しないエラーにならず、作業ディレクトリを読めないとみなす。この動きは実装を読んで分かったもので、WebContainers がどのエラーを投げるかは未確認である。
  • 作業ディレクトリそのものを消すと、保存されない。その後に起動し直すと、保存のためか起動し直すときに最後に読めたファイルか、それも無ければ前に起動したときのファイルで起動し、案内が出る。
  • パスが存在しないことは、WebContainers のエラーの message が ENOENT: で始まるかで見分ける。WebContainers が message を変えると、一覧を読んだ後に消えたファイルがあるだけで、作業ディレクトリを読めないとみなす。