プレイグラウンド¶
Overview¶
プレイグラウンドは、訪問者がブラウザの中でコードと SQL を動かすツールを、play.fukuemon.dev で提供するアプリである。
ツールは、Node の実行環境、SQL だけを動かすツール、Go を動かすツールの 3 つである。
記事は、ツールのページを iframe で埋め込む。訪問者は、同じページをトップレベルでも開ける。
全体の構成とホスト名は fukuemon.dev Design Doc にある。
実行の方式は、次の ADR が決めている。
- ADR-0033: Node と npm の実行に WebContainers を採り、実行環境と画面の間をインターフェースで分ける
- ADR-0037: Go のコードを、Yaegi のインタプリタの WASM で動かす
- ADR-0053: DB の記事は PGlite を使い、Node のコードからは Unix ドメインソケットで
pgから接続し、SQL は DB のプロセスの標準入出力で送る
Scope¶
持つものは次のとおり。
- アプリの構成、パス、ディレクトリと import の向き
- Node の実行環境と画面の間のインターフェースの型と、画面が記録を読む形
- ツールのページの React のツリーと、状態の置き場
- プロジェクトの JSON の形、訪問者のファイルの保存の形
- 起動から実行までの流れ、動かせないブラウザでの表示
- SQL を送る形式と、結果の表示
持たないものは次のとおり。
- AI のエージェントからの操作の登録。登録する位置(トップレベルで開いたページ)だけを書く。操作の名前、許可のスイッチ、確認、記録は、AI からの操作の Design Doc が持つ。
- コンソールの式を評価する入力欄。
- キャンバスとスライドのエディタの中の構成。ディレクトリを並べる位置だけを書く。
- 記事の本文で、埋め込みを置く書き方。記事の形式の設計が持つ。
- 画面のコンポーネントの見た目。packages/ui/DESIGN.md が持つ。
Design¶
アプリとパス¶
apps/playground は、Vite の React のアプリである。
ツールごとに、HTML のエントリーポイントを持ち、1 つのツールのページが、1 つの React のツリーになる。
ADR-0046: プレイグラウンドを Vite の React のアプリにし、ツールごとに HTML のエントリーポイントとパスを分ける
| パス | ツール | cross-origin isolation のヘッダ |
|---|---|---|
/node/ |
Node の実行環境 | 返す |
/sql/ |
SQL だけを動かすツール | 返さない |
/go/ |
Go を動かすツール | 返さない |
/は、https://fukuemon.dev/playgroundへリダイレクトする。- 開くプロジェクトは、クエリの
projectで指定する(/node/?project=<埋め込みの ID>)。projectが無いときは、デフォルトのプロジェクトを開く。 - 記事の iframe と、トップレベルで開くページは、同じ URL を使う。ページは、
window.self !== window.topで埋め込みかどうかを判定し、埋め込みのときは、ページのヘッダを出さず、AI のエージェントからの操作を登録しない。 - ペインの並べ方は、ページの幅と高さで決める。
layout/のuseLayoutModeが、matchMediaで配置をwide、narrow、shortのどれかに決める。高さが 500px 未満なら、幅にかかわらずshortにする。高さが 500px 以上で幅が 48rem 未満ならnarrow、ほかはwideにする。画面を回したときと、ウィンドウの大きさを変えたときに替える。wideは、ペインをSplitPaneで並べる。narrowは、ファイルのツリーをFileDrawerで出し、ほかのペインをPaneTabsの下の帯で 1 つずつ出す。shortは、PaneTabsの帯を左の縦の帯にする。記事に埋め込んだnarrowの配置は、帯を枠の上に置く。配置ごとの見た目は、コードを動かす画面の配置のパターンにある。- ADR-0072: コードを動かす画面を、幅が 48rem 未満か高さが 500px 未満のときに狭い配置にし、高さが 500px 未満ではペインを左の縦のタブで切り替える
- 3 つのエントリーポイントの HTML は、読み込みの初めに
@fukuemon/ui/ios-viewportのlimitIosInputZoomを呼ぶ。この関数は、iOS と iPadOS のときだけ、viewport の指定にmaximum-scale=1を足す。13px のエディタと入力欄にフォーカスしたときの拡大を止めるためである。
ビルドは、vp build で行う。
React Compiler を、apps/playground のビルドにだけ入れる。apps/playground のコードに、memo、useMemo、useCallback を書かない。書くのは、計測して遅いと分かったときだけにする。
ADR-0047: React Compiler を、apps/playground にだけ入れる
ディレクトリ¶
apps/playground/src/ は、関心ごとのディレクトリで分ける。実装の種類のディレクトリ(components/、hooks/、utils/)を作らない。
ADR-0048: Node の実行環境のモジュールを package にし、記録を store で、ファイルの書き込みとプロセスの起動を関数で公開する
apps/playground/
node/index.html Node の実行環境のエントリーポイント
sql/index.html SQL だけを動かすツールのエントリーポイント
go/index.html Go を動かすツールのエントリーポイント
src/
node/ Node の実行環境のページ。レイアウト、ペインのコンポーネント、Context、選択の状態、起動と保存を進める React の外のモジュール
sql/ SQL だけを動かすツールのページ。入力欄の SQL、実行中かどうか、読み込みと実行の関数を持つ React の外のモジュール
go/ Go を動かすツールのページ。ファイル、実行中かどうか、出力、実行とテストの関数を持つ React の外のモジュール
sql-console/ SQL の入力欄、結果の表、実行計画のツリー
layout/ 3 つのツールのページが共有する配置の判定(useLayoutMode)、狭い配置のフォーカスの移り先(usePaneFocus)、タブとドロワーのボタンの印
code-editor/ CodeMirror 6 の拡張の組み立てと、その拡張で作るエディタのコンポーネント
project/ 埋め込みの ID、プロジェクトの JSON の読み込み、デフォルトのプロジェクト
workspace/ 訪問者のファイルの保存
ai-operations/ AI のエージェントからの操作の許可のスイッチ、登録、確認、記録
packages/
sql-runtime/src/ SqlDatabase の型、結果とエラーの形、PGlite で SQL を実行して結果の形にする関数、PGlite を Web Worker で動かす実装
node-runtime/src/ NodeRuntime の型、bootNodeRuntime、store、記録の行を抜き出す処理、標準入出力で SqlDatabase を実装する処理
webcontainer/ @webcontainer/api を import する実装。WebContainers に依る処理
guest/ 実行環境の中の Node が動かすファイル(preload、DB のプロセスのスクリプト、DB のプロセスの依存を書く db-package.json、標準入出力の形式)
go-runtime/src/ GoRunner の型、import のパスと、テストを持つ package を求める関数
worker/ Yaegi を Web Worker で動かす GoRunner の実装、Web Worker の fetch を差し替える処理
go-runtime/go/ WASM にする Go のコード。Yaegi で訪問者のファイルを実行する処理
- キャンバスとスライドのエディタは、追加するときに、
apps/playground/src/の同じ階層に並べる。- キャンバスのディレクトリ(
drawio/、excalidraw/、documents/、figures/)の間と、@fukuemon/figuresへの import の向きは、キャンバスの Design Doc にある。Oxlint は、drawio/とexcalidraw/も、ツールのページの間の import を止める対象にする。 - スライドのエディタのディレクトリ(
slides/)と、@fukuemon/slidesへの import の向きは、スライドの Design Doc にある。Oxlint は、slides/も、ツールのページの間の import を止める対象にする。
- キャンバスのディレクトリ(
code-editor/は、Node の実行環境のページ(src/node/)、スライドのエディタのページ(src/slides/)、Go を動かすツールのページ(src/go/)、sql-console/が共有する。code-editor/は、ツールのページのディレクトリと、documents/を import しない。layout/は、3 つのツールのページのディレクトリとsql-console/が共有する。layout/は、React のほかに何も import しない。- 実行環境の処理は、実行環境ごとの 3 つの package に置く。package は、型、store、関数だけを持つ。React のコンポーネントと、store を React で読む処理は、
apps/playgroundに置く。@fukuemon/sql-runtime(packages/sql-runtime): PGlite で SQL を実行する処理。@fukuemon/node-runtime(packages/node-runtime): Node の実行環境の処理。@fukuemon/sql-runtimeに依存する。@fukuemon/go-runtime(packages/go-runtime): Go のコードを実行する処理。- 3 つの package は、
@fukuemon/ui、@fukuemon/playground-project、React に依存しない。@fukuemon/sql-runtimeと@fukuemon/go-runtimeは、workspace のどの package にも依存しない。 - package は、ほかの package と同じく、
exportsでソースのファイルを指す。ビルドは、apps/playgroundの Vite が行う。@fukuemon/go-runtimeの WASM だけは、package のbuildの script がビルドする(Go)。
- この文書では、
node-runtime/はpackages/node-runtime/src/を、webcontainer/とguest/はその下のディレクトリを指す。 @fukuemon/sql-runtimeは、エントリーポイントを 2 つに分ける。- 型と、PGlite で SQL を実行して結果の形にする関数。このエントリーポイントは、PGlite から型だけを import する。関数はブラウザと Node の両方で動くので、このエントリーポイントは DOM と Worker の API を使わない。
Storeの型も、このエントリーポイントに置く。 - PGlite を Web Worker で動かす、
SqlDatabaseの実装。SQL だけを動かすツールが使う。
- 型と、PGlite で SQL を実行して結果の形にする関数。このエントリーポイントは、PGlite から型だけを import する。関数はブラウザと Node の両方で動くので、このエントリーポイントは DOM と Worker の API を使わない。
- プロジェクトの JSON と、
playground.jsonの型と schema は、packages/playground-project(@fukuemon/playground-project)に置く。apps/webのビルドと、project/が使う。 guest/のファイルは、ブラウザではなく、実行環境の中の Node で動く。webcontainer/が、ファイルの中身を文字列として読み込み、実行環境に書く。tsconfig と lint の対象を、ブラウザのコードと分ける。guest/のdb-package.jsonは、DB のプロセスの依存(PGlite、pglite-socket)のバージョンを、範囲ではなく 1 つの値で書く。webcontainer/は、このファイルを実行環境の中のpackage.jsonとして書く。PGlite のバージョンは、@fukuemon/sql-runtimeの依存と同じにする。@fukuemon/node-runtimeのテストで、2 つのファイルの PGlite のバージョンを比べ、同じことを確認する。node-runtime/の中の import の向きは、次のとおり。node-runtime/の直下は、webcontainer/のRuntimeBackendの実装を、bootNodeRuntimeの 1 か所だけで import する。webcontainer/がnode-runtime/の直下から import するのは、型と、backend.tsが定義するエラークラス(PathNotFoundError)だけである。guest/は、node-runtime/の直下とwebcontainer/を import しない。node-runtime/の直下とwebcontainer/がguest/からモジュールとして値を import するのは、標準入出力の形式を書くdb-protocol.tsだけである。このファイルはブラウザと Node の両方で読むので、どちらの API も使わない。webcontainer/は、ほかのファイルを文字列(?raw、?bundle)として import する。
- Vite の plugin は、package の直下の
vite.tsに置く。guest/とvite.tsの型は、Node の型を読むtsconfig.node.jsonで確認する。
次の図は、apps/playground/src/ のディレクトリと package の、互いの間で許す import の向きを示す。矢印の無い組は、import しない。
flowchart TB
node["src/node"]
sql["src/sql"]
go["src/go"]
console["sql-console"]
codeeditor["code-editor"]
layout["layout"]
runtime["@fukuemon/node-runtime"]
database["@fukuemon/sql-runtime"]
runner["@fukuemon/go-runtime"]
project["project"]
workspace["workspace"]
ui["@fukuemon/ui"]
ai["ai-operations"]
pp["@fukuemon/playground-project"]
node --> runtime
node --> console
node --> codeeditor
node --> project
node --> workspace
node --> database
node --> ui
node --> ai
node --> layout
ai --> ui
sql --> console
sql --> database
sql --> project
sql --> workspace
sql --> ui
sql --> ai
sql --> layout
go --> runner
go --> codeeditor
go --> project
go --> workspace
go --> ui
go --> ai
go --> layout
console --> database
console --> codeeditor
console --> ui
console --> layout
runtime --> database
workspace --> project
project --> pp
src/node/、sql-console/、node-runtime/が@fukuemon/sql-runtimeから import するのは、型だけである。workspace/がproject/から import するのも、型だけである。sql-console/のテストだけは、toJsonResponseを値で import する。DB のプロセスが JSON で送ったセルと、Web Worker の実装のセルが、同じ文字列で表に出ることを確認するためである。guest/の DB のプロセスのスクリプトだけは、@fukuemon/sql-runtimeの、PGlite で SQL を実行して結果の形にする関数を import する。guest/は、PGlite を Web Worker で動かすSqlDatabaseの実装のエントリーポイントを import しない。
node-runtime/は、project/を import しない。BootOptionsは、プロジェクトの型を使わない。workspace/とnode-runtime/は、互いを import しない。2 つをつなぐ処理は、src/node/が持つ。sql-console/は、workspace/を import しない。入力欄の値と、値の変更は、props で受ける。値を持ち、保存するのは、src/node/とsrc/sql/である。@webcontainer/apiに依存するのは、@fukuemon/node-runtimeだけである。apps/playgroundのpackage.jsonには、@webcontainer/apiを依存として書かない。- Oxlint の
no-restricted-importsが止める import は、次の 4 つ。packages/node-runtime/src/webcontainer/のほかからの、@webcontainer/apiの importapps/playground/src/のnode/、sql/、go/、drawio/、excalidraw/、slides/の間の importapps/playground/src/ai-operations/からの、ツールのディレクトリの importpackages/node-runtime/src/guest/からの、node-runtime/の直下、webcontainer/、@fukuemon/sql-runtime/workerの import
NodeRuntime¶
Node の実行環境と画面の間は、NodeRuntime で分ける。store と関数が渡すデータは、シリアライズできる素の値だけにする。型に、@webcontainer/api の型を含めない。
type Store<T> = {
subscribe: (onChange: () => void) => () => void;
getSnapshot: () => T;
};
type BootOptions = {
files: Readonly<Record<string, string>>;
database?: boolean;
initialSql?: string;
};
declare function bootNodeRuntime(options: BootOptions): Promise<NodeRuntime>;
type NodeRuntime = {
files: {
tree: Store<readonly FileEntry[]>;
changes: { subscribe: (onChange: (paths: readonly string[]) => void) => () => void };
readAll: (options: ReadAllOptions) => Promise<readonly FileSnapshot[]>;
exists: (path: string) => Promise<boolean>;
read: (path: string) => Promise<string>;
write: (path: string, contents: string) => Promise<void>;
createDirectory: (path: string) => Promise<void>;
rename: (from: string, to: string) => Promise<void>;
remove: (path: string) => Promise<void>;
};
processes: {
list: Store<readonly ProcessInfo[]>;
get: (id: string) => ProcessHandle | undefined;
start: (
command: string,
args: readonly string[],
options?: { name?: string; terminal?: TerminalSize },
) => Promise<ProcessHandle>;
startShell: (options?: { name?: string; terminal?: TerminalSize }) => Promise<ProcessHandle>;
};
ports: Store<readonly OpenPort[]>;
httpExchanges: Store<readonly HttpRecord[]>;
consoleEntries: Store<readonly ConsoleRecord[]>;
database?: SqlDatabase;
dispose: () => Promise<void>;
};
type ReadAllOptions = {
exclude: readonly string[];
maxBytes: number;
limit?: { files: number; bytes: number };
};
type FileSnapshot = { path: string; kind: "file" | "other"; size: number; text: string | null };
type ProcessHandle = {
id: string;
output: OutputLog;
write: (data: string) => void;
resize: (size: TerminalSize) => void;
kill: () => void;
exit: Promise<number>;
};
type OutputLog = {
read: () => string;
subscribe: (onChunk: (chunk: string) => void) => () => void;
};
名前は、実装のときに変えてよい。
- 起動は、
bootNodeRuntimeが返す Promise で待つ。起動の失敗は、Promise の失敗で受け取る。失敗は、理由(crossOriginIsolatedがfalse、30 秒で終わらない、そのほか)を持つ。 - 30 秒の判定と、作りかけのインスタンスの破棄は、
bootNodeRuntimeの中で行う。 node-runtime/が公開する起動の関数は、bootNodeRuntimeだけである。Promise を埋め込みの ID ごとに持つ処理は、src/node/に置く。webcontainer/のspawnが、接続先の環境変数とNODE_OPTIONSを組み立てて、プロセスに渡す。画面は、環境変数を渡さない。渡す値は、実行環境が追加するファイルとプロセス にある。- 実行環境が自分で起動するプロセス(DB のプロセス、DB の依存の
npm install)は、processes.listに入れない。 - 端末のシェルは、
processes.startShellで起動する。起動するシェルのプログラムは、webcontainer/が決める。画面は、シェルのプログラムの名前を持たない。 startを呼ばないコンポーネントは、processes.getで、プロセスの出力を読む。files.changesは、ファイルの中身が変わったことの通知である。保存と、エディタで開いているファイルの更新に使う。- ツリーを変えるファイルの操作(新しいファイルへの
files.write、files.createDirectory、files.rename、files.remove)は、操作の後にツリーを読み直してから終わる。読み直しが失敗したときは、ログに出し、操作は成功として終える。操作の成否は、操作そのものの成否で決める。 files.existsは、同じパスのファイルかディレクトリが実行環境にあるかを、親のディレクトリの一覧で確認する。ツリーに出さないnode_modulesのパッケージの中身も確認できる。- ファイルのツリーで項目を作るときと名前を変えるときは、ツリーの一覧で同じパスを確認した後、
files.existsで実行環境も確認する。同じパスがあるときと、確認できないときは、確定せず、FileTreeの欄を開いたまま理由を出す。確認する間、欄は確定を重ねて受けない。 files.readAllは、作業ディレクトリのファイルを、パス、サイズ、テキストの中身で返す。excludeに当たるパスは読まない。maxBytesを超えるファイルと、テキストとして読めないファイルは、textがnullになる。excludeに当たるかの判定、maxBytesを超えるかの判定、テキストかどうかの判定は、node-runtime/の直下が持つ。- ファイルでもディレクトリでもないもの(シンボリックリンク)は、
kindをotherにして、指す先の中身を返す。読めないもの(指す先の無いリンクと、互いを指すリンク)は、エラーの理由を問わず返さない。リンクは保存にも起動し直しにも使わないためである。 - 一覧を読んだ後に消えたファイルとディレクトリは、スキップする。作業ディレクトリそのものが無いときと、スキップしないエラーで、ファイルの中身かディレクトリの一覧を読めないときは、
files.readAllが失敗する。読めなかった作業ディレクトリを、読めたものとして扱わないためである。 - 同時に読むファイルとディレクトリの一覧は、ツリーの全体で合わせて 32 個までにする。同時に始めた読み込みは、
limitを超えても取り消せないためである。 limitを渡すと、読めたファイルの数か、読んだ大きさの合計がlimitの値を超えた時点で、残りを読まずにReadLimitErrorで失敗する。超えたプロジェクトでも、保存のたびに全部を読まないようにするためである。
- ファイルでもディレクトリでもないもの(シンボリックリンク)は、
FileEntryは、パスを持つ。画面は、パスを、FileTreeの項目のidに使う。ProcessInfoは、ID、種別、名前、状態を持つ。種別は、processes.startShellで起動したプロセスがshell、processes.startで起動したプロセスがcommandである。画面は、種別でシェルの数を数え、2 つ目からのシェルの名前に番号を付ける。OpenPortは、ポート、URL、プレビューの iframe に付ける属性(sandbox、allow)を持つ。属性の値は、webcontainer/が決める。- プレビューに入れるスクリプトから
postMessageで送られるコンソールの記録を受けて、consoleEntriesに入れる処理は、node-runtime/が持つ。スクリプトの中身と、メッセージの形は、node-runtime/の直下が持つ。node-runtime/の直下は、スクリプトの中身を、BackendBootOptionsのpreviewScriptでwebcontainer/に渡す。メッセージの origin の確認は、webcontainer/が行う。 databaseは、DB を使うプロジェクトのときだけある。DB のプロセスの状態は、databaseのstatusで公開し、NodeRuntimeには持たせない。- 実行環境を起動し直すときは、
src/node/のモジュールが、古いインスタンスのfiles.readAllの結果を受け取ってから、disposeを呼ぶ。その結果をBootOptionsのfilesに渡して、bootNodeRuntimeで新しいインスタンスを作る。files.readAllには、excludeにnode_modulesを、maxBytesに保存のときと同じ 1 MB を渡し、limitは渡さない。textがnullのファイルは、新しいインスタンスに渡さない。- 保存しないファイル(
.env、ビルドの出力、.git)も、新しいインスタンスに渡す。テキストとして読めないものは渡さないので、新しいインスタンスの.gitとビルドの出力には、テキストのファイルだけが残る。 - プロジェクトの JSON の
filesのキーの規則(isSafeFilePath)に合わないパスは、新しいインスタンスに渡さない。 - ファイルでもディレクトリでもないもの(
kindがother、シンボリックリンク)は、新しいインスタンスに渡さない。渡すと、指す先の中身を持つ通常のファイルになり、その後の保存で IndexedDB に入るためである。 src/node/のモジュールは、起動し直すときのfiles.readAllに、5 秒の上限を付ける。失敗したときと、上限までに終わらないときは、保存のためか起動し直すときに最後に読めたfiles.readAllの結果で起動する。- その結果も無いときは、前に起動したときのファイル(保存したファイルか、初期ファイル)で起動する。
- 作業ディレクトリを読めずに起動し直したときは、「作業ディレクトリを読めませんでした」の案内を出す。
- 案内は、起動し直しを始めるときと、初期の状態に戻したときに消す。起動し直しで続けて読めなかったときは、消した後に出し直す。
- 起動に失敗した実行環境を起動し直すときは、作業ディレクトリを読まないので、案内を出しも消しもしない。
- 保存のために読んだ結果には、保存しないディレクトリの下のファイルが入らないので、保存のために読んだ結果で起動すると、そのファイルは作業ディレクトリに無い。
- 作業ディレクトリを読めたときは、ページを開いたときに IndexedDB から読んだファイルを使わない。作業ディレクトリのファイルで起動するので、IndexedDB を読めないときも、訪問者が変えたファイルが残る。
- ADR-0076: Node の実行環境は、ページを開いたときに起動し、シェルを制限なしで使わせ、訪問者が変えたコードは操作の後に動かし、起動し直すときに作業ディレクトリを読めなければ、前に起動したときのファイルで起動する
SQL だけを動かすツールと、Go を動かすツールは、NodeRuntime を実装しない。それぞれ、SqlDatabase(@fukuemon/sql-runtime)と GoRunner(@fukuemon/go-runtime)を持つ。
webcontainer/ が実装する型¶
node-runtime/ の直下と、webcontainer/ の間は、次の型で分ける。
直下のコードは、この型だけを使い、WebContainers に依る処理を持たない。
type RuntimeBackend = {
isSupported: () => boolean;
boot: (options: BackendBootOptions) => Promise<BackendInstance>;
};
type BackendBootOptions = {
files: Readonly<Record<string, string>>;
database: boolean;
previewScript: string;
};
type BackendInstance = {
files: BackendFiles;
spawn: (
command: string,
args: readonly string[],
options?: { terminal?: TerminalSize; internal?: boolean },
) => Promise<BackendProcess>;
spawnShell: (options?: { terminal?: TerminalSize }) => Promise<BackendProcess>;
ports: { subscribe: (onChange: (ports: readonly OpenPort[]) => void) => () => void };
previewMessages: { subscribe: (onMessage: (message: unknown) => void) => () => void };
guestDirectory: string;
teardown: () => Promise<void>;
};
type BackendProcess = {
output: { subscribe: (onChunk: (chunk: string) => void) => () => void };
write: (data: string) => void;
resize: (size: TerminalSize) => void;
kill: () => void;
exit: Promise<number>;
};
名前は、実装のときに変えてよい。
BackendFilesは、項目ごとに、ファイル、ディレクトリ、そのどちらでもないもの(シンボリックリンク)の種別を持つ一覧、バイト列での読み込み、書き込み、ディレクトリの作成、名前の変更、削除、変更の通知を持つ。store は持たない。files.treeの store、files.readAll、files.existsは、node-runtime/の直下が、この操作から組み立てる。- 一覧と読み込みは、パスが存在しないときに
PathNotFoundErrorで失敗する。WebContainers のfsのエラーにはcodeが無いので、webcontainer/の実装は、パスが存在しないことを、messageの先頭のENOENT:で見分ける。
- 一覧と読み込みは、パスが存在しないときに
webcontainer/が持つ処理は、次のとおり。isSupported:crossOriginIsolatedの判定。boot: WebContainers の起動、初期ファイルの書き込み、guest/のファイルを実行環境のディレクトリに置く処理、プレビューに入れるスクリプトの登録。spawn:NODE_OPTIONSと環境変数の組み立て。NODE_OPTIONSは、HTTP を記録する preload を読ませ、DB を使うときは終了させない preload を足す。internalがtrueのプロセスには、環境変数を渡さず、端末も付けない。node-runtime/の直下は、DB の依存をnpm install --prefix <guestDirectory>で入れ、DB のプロセスをguestDirectoryの下のスクリプトの絶対パスで起動する。spawnShell: 起動するシェルのプログラムの決定と、シェルの起動。環境変数は、spawnが、internalを指定しないプロセスに渡すのと同じ値を渡す。ports: 待ち受けたポートの通知、OpenPortのsandboxとallowの値、ポートの URL を書くファイルの更新。previewMessages: プレビューからのpostMessageの受信と、origin の確認。
node-runtime/の直下が持つ処理は、store、記録の行を抜き出す処理、30 秒の判定、プレビューの URL の確認、DB のプロセスの起動と、標準入出力でのSqlDatabaseの実装である。node-runtime/の直下は、webcontainer/が知らせたポートのうち、URL がhttps:で、ツールのページと別の origin のものだけを、portsの store に入れる。 合わないポートは、プレビューのタブに出さない。 ツールのページと同じ origin のページを、allow-same-originを付けた iframe で開くと、プレビューのスクリプトがツールのページを操作できるためである。webcontainer/は、ポートの URL を書くファイルに、確認で除く前の、知らせたすべてのポートの URL を書く。guest/のファイルと、標準入出力でのSqlDatabaseの実装は、実行環境が Node のプロセスを動かすことを前提にしている。Node のプロセスを持たない実行環境に差し替えるときは、webcontainer/に加えて、この 2 つも直す。
記録を読む形¶
- 記録(ファイルのツリー、プロセスの一覧、ポート、HTTP の記録、コンソールの記録)は、種類ごとの store で公開する。画面は、
useSyncExternalStoreで読む。Effect の中でsetStateを呼んで、記録を写さない。 getSnapshotは、記録が変わっていない間、同じ参照を返す。- プロセスの出力は、
OutputLogで公開する。端末のペインは、Effect の中で、readの結果を端末のエンジンに書いてから、subscribeで追加の出力を書く。 - preload は、記録の行の行頭に、固定のプレフィックスを付ける。
node-runtime/の直下(webcontainer/の外)の処理が、BackendProcessのoutputから受けたそのままの出力から、プレフィックスの付いた行を抜き出して、store に入れる。OutputLogには、抜き出した後の出力を渡す。 - コンソールの記録の、出力したプロセスの名前は、行を抜き出す処理が付ける。名前は、行を出力したプロセスの、
startのnameである。シェルで起動したプロセスの行には、シェルの名前が付く。 - プレフィックスは、HTTP の記録が
@@fd:http、コンソールの記録が@@fd:console、DB のプロセスの応答が@@fd:sqlである。見分ける処理は、node-runtime/の直下のrecord-lines.tsに置く。端末に出すプロセスの記録の行(HTTP とコンソール)と、DB のプロセスの応答の行は、別のプロセスの出力に出るので、別の処理で読む。 - 出力のチャンクは、記録の行の途中で切れて届くことがある。行を抜き出す処理(
record-lines.ts)は、プレフィックスから改行までがそろうまで、その部分を端末に渡さない。チャンクの末尾がプレフィックスの先頭の数文字で終わるときは、続きを 50 ms 待ち、続きが来なければ端末に渡す。端末で打った@のような文字が、次の入力まで出なくならないようにするためである。プレフィックスから 4 MB を超えても改行が来ないときは、記録の行でないとみなして端末に渡す。プロセスが終わったときは、手元の出力を端末に渡す。プロセスの終わりが、最後の出力より先に届くかは確認していない。 - DB のプロセスの出力は、端末に出さない。
record-lines.tsは、端末に出さないプロセスのための、もう 1 つの行を抜き出す処理を持ち、DB のプロセスの応答の行は、こちらで読む。応答の行は 10 MB を超えることがあるので、長さの上限と 50 ms の保留を置かず、届いたチャンクだけで改行を探す。頭の無い行は捨てる。 - HTTP の記録を取る preload(
guest/http-trace.cjs)は、次のことを行う。http.Serverのemitを包み、requestのたびに、メソッド、URL(パスとクエリ)、リクエストとレスポンスのヘッダ、本文、ステータスコード、受けた時刻、所要時間を 1 行に書く。時刻はDate.now()で取る。WebContainers では、performance.timeOriginがプロセスごとにずれ、プロセスをまたいで比べられないためである。同じミリ秒に受けた記録は、届いた順に並ぶ。訪問者のコードが同じリクエストでrequestを出し直したときは、2 回記録しない。listeningのときは、待ち受けたポートを 1 行に書く。- リクエストの本文は、
dataの購読を足さずに、emitを包んで、訪問者の処理が読んだ分を写す。購読を足すと、訪問者の処理より先に本文が流れ出るためである。 - レスポンスのヘッダは、送った頭の文字列から読む。
writeHeadに渡したヘッダは、getHeadersに入らないことがあるためである。 - 本文は、リクエストとレスポンスのそれぞれで、先頭の 64 KB だけを記録する。NUL を含むものと、UTF-8 として読めないものは、テキストとして記録しない。
- 実行環境の中のサーバー(
localhost、127.0.0.1、[::1]、0.0.0.0)へのfetchに、送信元を示すヘッダx-playground-fromを足す。- 値は、リクエストを処理している間はそのリクエストを受けたサーバーのポート、ほかのときは待ち受けた最初のポートで、待ち受けていないプロセスは
pid-<プロセスの ID>である。 - 訪問者が同じ名前のヘッダを付けたときは、書き換えない。この
fetchは Node のfetchに任せるので、origin が変わるリダイレクトの先にもそのヘッダが届く。 - 外のサーバーへの
fetchには足さない。 - リダイレクトを追う
fetchは、preload がリダイレクトを 1 つずつ追う。Node のfetchは、origin が変わるリダイレクトでも、足したヘッダを送り直すためである。origin が変わる時点で、足したヘッダを外し、その先を元のfetchに任せる。そのとき、Node のfetchが origin の変わるリダイレクトで外すヘッダ(authorization、proxy-authorization、cookie、host)も外す。 - メソッドを変えずに送り直す本文(307 と 308、POST でない 301 と 302)は、元の本文を読み切ったバイト列にする。
initにReadableStreamか async iterable を渡したfetchは、Node のfetchと同じく、送り直さずに失敗にする。 - 解釈できない
Location、http と https のほかへのリダイレクト、20 回を超えたリダイレクトは、失敗にする。 - リダイレクトを追ったレスポンスの
redirectedは、cloneしたレスポンスには付かない。
- 値は、リクエストを処理している間はそのリクエストを受けたサーバーのポート、ほかのときは待ち受けた最初のポートで、待ち受けていないプロセスは
http.requestで送った通信には、ヘッダを足さない。送信元は、ブラウザになる。
node-runtime/の直下は、記録の行をHttpRecordにして、httpExchangesの store に入れる。HttpRecordは、送った側と受けた側(idと名前)、メソッド、URL、受けた時刻、所要時間、リクエスト、レスポンスを持つ。レスポンスを返す前に切れた通信は、レスポンスを持たない。本文は、テキスト(読めないものはnull)、大きさ、64 KB で切ったかを持つ。- 送った側と受けた側の
idは、ブラウザがbrowser、サーバーがport:<番号>、待ち受けていないプロセスがprocessである。待ち受けていないプロセスは、区別の色が足りなくならないよう、まとめて 1 つの側にする。訪問者が送信元のヘッダに数字でもpid-でもない値を付けたときは、header:<値>とし、値を名前にする。送信元を示すヘッダが無い通信は、ブラウザからの通信とする。 - サーバーの名前は、待ち受けたポートの行を出したプロセスの、
startのnameである。シェルで起動したサーバーと、待ち受けたポートの行がまだ届いていないサーバーは、<番号> 番のサーバーと呼ぶ。行が後から届いたときは、記録の名前を直す。 - 訪問者のプログラムも記録の行を出せるので、1 行の大きさに上限を置き、超えた分を切る。
- 本文は 65,536 文字(preload が記録する 64 KB を UTF-8 で読んだ文字数を超えない値)、ヘッダはリクエストとレスポンスのそれぞれ 100 個、URL は 8,192 文字までにする。ヘッダの名前は 256 文字、値は 8,192 文字までにする。
- メソッドが 32 文字、送信元のヘッダの値が 256 文字を超える行は捨てる。大きさと時刻が 0 以上の安全な整数でない行、時刻が今より 1 日を超えて先の行、所要時間が 0 から 1 日でない行、ステータスコードが 100 から 999 でない行も捨てる。
- 切ったヘッダの名前と値は、末尾に「…」を付ける。切った本文は
truncated、捨てたヘッダの数はomittedHeaders、切った URL はurlTruncatedで示し、画面はそのことを文で添える。 - 名前を保持するサーバーは 100 個までにし、超えた後に待ち受けたサーバーは、ポートの番号で呼ぶ。
- 記録は、受けた時刻の順に並べる。記録の行はプロセスごとの出力から届くので、届いた順は、受けた順と違うことがある。認可コードフローでは、クライアントが
/callbackの処理の中で送った/tokenの記録が、/callbackの記録より先に届く。
- store は、記録の件数に上限を置き、超えた古い記録を消す。上限は、プロセスの出力が 1 つのプロセスにつき 1 MB、コンソールの記録が 1000 件、HTTP の記録が 500 件である。HTTP の記録は、1 件が本文を最大 64 KB ずつ 2 つ持つので、500 件で最大 64 MB 前後の文字を持つ。この値は見積もりで、測っていない。訪問者のプログラムが出す偽の記録の行は、ヘッダの名前に 256 文字、値に 8,192 文字を、100 個ずつ持てるので、1 件が最大 2 MB 前後の文字になる。偽の行で上限まで埋めたときに重くなるのは、訪問者自身のタブだけである。値は、実装のタスクで測って変えてよい。
React のツリーと状態¶
次の図は、Node の実行環境のページのツリーを示す。
flowchart TB
page["NodePage<br/>プロジェクトと保存したファイルを読み、起動する"]
ctx["2 つの Context<br/>実行環境のインスタンス(起動の Promise)、画面の選択の状態"]
layout["レイアウト"]
files["FilesPane"]
editor["EditorPane"]
terminal["TerminalPane"]
preview["PreviewPane"]
http["HttpListPane と HttpDetailPane"]
cons["ConsolePane"]
sqlp["SqlPane<br/>DB を使うプロジェクトのときだけ"]
ui["@fukuemon/ui の Case"]
sc["sql-console"]
page --> ctx --> layout
layout --> files --> ui
layout --> editor --> ui
layout --> terminal --> ui
layout --> preview --> ui
layout --> http --> ui
layout --> cons --> ui
layout --> sqlp --> sc
- Context は 2 つで、
src/node/に定義する。- 実行環境のインスタンスを、起動の Promise で渡す Context。開いたプロジェクトと、「実行環境を起動し直す」の関数と、起動し直している間かどうかの値(
busyの store)も一緒に渡す。ペインは、Promise をuseで読み、ペインごとのSuspenseで起動を待つ。値が変わるのは、実行環境を起動し直すときだけである。 - 画面の選択の状態を渡す Context。選んだファイル、開いているタブ、選んだ端末のタブ、選んだプレビューのポート、選んだ HTTP のやり取り、下のペインのタブを持つ。状態は、
NodePageがプロジェクトを読んだ後に描くNodeLayoutが、useReducerで持つ。
- 実行環境のインスタンスを、起動の Promise で渡す Context。開いたプロジェクトと、「実行環境を起動し直す」の関数と、起動し直している間かどうかの値(
- Context は、
<Context>を provider として書き、use(Context)で読む。 - ツリーをまたぐ通知(
CustomEvent)と、状態管理のライブラリは使わない。 - ペインのコンポーネント(
FilesPaneなど)が、実行環境を読み、@fukuemon/uiの Case に、素の値の props を渡す。Case と、sql-console/のコンポーネントは、NodeRuntimeの型を import しない。HttpListPaneとHttpDetailPaneは、httpExchangesの記録の、送った側と受けた側から、HttpExchangeListとHttpExchangeDetailのactorsを組み立てる。並びは、ブラウザを先にし、記録に初めて出た順にする。processes.listは使わない。シェルで起動したサーバーは、processes.listに入らないためである。PreviewPaneは、portsの URL と属性から、iframe を作る。sql-console/のコンポーネントは、SqlDatabaseのstatus、SQL を実行する関数、実行中かどうかの値、入力欄の値、入力欄のファイルの名前、DB が止まったときに出す案内の文を、props で受ける。statusは、useSyncExternalStoreで読む。- DB が止まった後の戻り方は
SqlDatabaseの実装ごとに違うので、案内の文は呼ぶ側が渡す。 - 実行の結果は、
sql-console/のコンポーネントが持ち、表に出す。 SqlPaneは、DB を使うプロジェクトのときだけ置く。wideの配置では右の列に、narrowとshortの配置ではプレビューの後のタブに置く。wideの配置の右の列は、プレビューと SQL のペインをタブで切り替え、どちらのペインも列の高さを全て使う。SQL の入力欄と結果を、スクロールせずに同時に見るためである。選んでいないタブのペインはActivityで隠し、入力欄と結果を残す。- SQL のペインの入力欄は、どの配置でもペインの面の縁まで広げる。
shortの配置では、結果の領域を残すために、入力欄を 160px から 128px に低くする。 - 配置が
wideと狭い配置の間で替わると、SqlPaneを作り直す。narrowとshortの間では作り直さない。- 入力欄の SQL と、元に戻す履歴とカーソルは残る。SQL は
src/node/のモジュールが、履歴とカーソルはNodeLayoutの入れ物が持つためである。 - 実行の結果は消える。
sql-console/のコンポーネントが持つためである。訪問者は、もう一度実行して結果を出す。
- 入力欄の SQL と、元に戻す履歴とカーソルは残る。SQL は
SqlPaneは、NodeRuntimeのdatabaseのstatusとrun、useTransitionから得た実行中かどうかの値、入力欄の値を、sql-console/のコンポーネントに渡す。- SQL だけを動かすツールは、
src/sql/の React の外のモジュールが持つ、SQL を実行する関数と、実行中かどうかの値を渡す。画面の実行のボタンと、AI からの操作のsql_run_queryは、同じ関数を呼ぶ。 - SQL だけを動かすツールは、
sql-console/のコンポーネントの省略できる props で、もう 1 つの値を渡す。AI からの操作が、時間の上限で DB を初期の状態に戻したかどうかを表す値である。値は、src/sql/のモジュールが持つ。sql-console/のコンポーネントは、値が戻したことを示すときに、DB を初期の状態に戻したことを出す。SqlPaneは、渡さない。 - 「実行環境を起動し直す」の関数は、起動の Promise を持つ
src/node/のモジュールが公開する。SqlPaneは、この関数と、起動し直している間かどうかを、sql-console/のコンポーネントの、省略できる props で渡す。SQL だけを動かすツールは、渡さない。
Suspenseの境界は、ペインごとに置く。fallback は、同じ Case に、stateのpendingを渡したものにする。待ちの印は、ペインの外のSuspenseがPageLoadingで出す。プロジェクトの読み込みと起動の待ちは続けて起きるので、境界が別の 2 つのSuspenseにも、PageLoadingに同じ文を渡す。useに渡す Promise は、React の外のモジュールが、埋め込みの ID ごとに 1 つ作って持つ。プロジェクトの JSON の Promise はproject/が持つ。src/node/とsrc/sql/のモジュールは、プロジェクトの JSON と、workspace/から読んだ、保存してあるファイルを、1 つにまとめた Promise を持つ。ページは、まとめた Promise をuseで読む。起動の Promise はsrc/node/が持つ。Go を動かすツールでは、src/go/のモジュールが、プロジェクトの JSON と保存したファイルを読んだ後のモジュールの Promise を、1 つにまとめて持つ。実行環境を起動し直すときは、src/node/が、起動の Promise を新しいものに替える。- タブで切り替えるペイン(端末、コンソール、HTTP の一覧、プレビュー)は、
Activityで包む。隠している間の記録は、store が持つ。 - 端末のペインの中のプロセスのタブは、選んだプロセスの端末だけを描く。プロセスのタブを替えたときは、端末を作り直し、プロセスの出力の記録を端末に書き込み直す。xterm.js の端末は、
Activityで隠すと Effect のクリーンアップで破棄されるためである。 - Node の実行環境では、実行のボタンの、実行中かどうかの値を、
useTransitionで持つ。 - SQL だけを動かすツールと、Go を動かすツールでは、
src/sql/とsrc/go/の React の外のモジュールが、実行中かどうかの値を持つ。コンポーネントは、この値をuseSyncExternalStoreで読む。AI からの操作で実行している間も、ボタンを実行中の表示にするためである。モジュールは、PGlite と Yaegi を読み込んでいる間も、値を実行中にする。 - SQL だけを動かすツールの PGlite と、Go を動かすツールの Yaegi は、訪問者が実行のボタンを押したときと、AI からの操作の
sql_run_query、go_run、go_testが呼ばれたときに読み込む。ページを開いたときには、読み込まない。
SQL だけを動かすツールのページは、次のとおり組む。
src/sql/の React の外のモジュールが、埋め込みの ID ごとに 1 つのSqlSessionを作る。SqlSessionは、次のものを持つ。- 入力欄の SQL を持つ store と、値を変える関数。初期値は、プロジェクトの
filesの、openのファイルの中身である。 - 実行中かどうかの store。
SqlDatabaseを Web Worker で動かす実装(@fukuemon/sql-runtime)と、PGlite の読み込みを始める関数。- 実行する関数。
SqlDatabaseのrunを呼び、SqlResponseを返す。呼んでから返すまで、実行中かどうかの値を実行中にする。 - 実行する関数を実行中に呼んだときは、実行せずに
errorを持つ値を返す。
- 入力欄の SQL を持つ store と、値を変える関数。初期値は、プロジェクトの
- 同じモジュールが、ページの URL のクエリからプロジェクトを読み、埋め込みの ID の
SqlSessionを返す関数を持つ。ページは、この関数の Promise をuseで読む。 - ページは、このモジュールの値を
useSyncExternalStoreで読み、sql-console/のコンポーネントに渡す。 - 入力欄のエディタは、CodeMirror 6 にする。Ctrl か ⌘ と Enter で実行する。
- CodeMirror 6 の拡張の組み立ては、
src/code-editor/の 1 か所に置く。Node の実行環境、Go を動かすツール、スライドのエディタも、同じ組み立てを使い、言語の拡張と、ページに固有の keymap を足す。Node の実行環境は、Ctrl か ⌘ と S のキーで何もしない keymap を足す。入力のたびに書き込むので、ブラウザがページを保存するのを止めるためである。- 行番号、アクティブ行のハイライト、インデントガイド、対応する括弧のハイライトを出す。折りたたみのガターは出さない。
- Tab キーでインデントする。補完の一覧が開いているときは、Tab キーで補完を確定する。
- Esc の後 2 秒の間の Tab キーで、エディタの外へ移る。このことを、実行のボタンの横に書く。Node の実行環境のページでは、エディタのペインの下端に書く。
- 選択があるときは、アクティブ行のハイライトを消す。ハイライトは選択の範囲より上に描かれ、選択を隠すためである。
- Ctrl か ⌘ と F で、検索と置換のパネルをエディタの上端に出す。パネルの文言は日本語にする。
- 置換の行は、最初は畳んで出し、パネルのボタンで開く。狭い幅でも、エディタの行が見えるようにするためである。
- 補完の一覧の行は、44px の高さにする。
- CodeMirror は、ビルドで
code-editorのチャンクに分け、エディタを持つページの間で共有する。 - 色は、
packages/uiのテーマ変数を読む。CodeMirror のデフォルトの色は、ダークのテーマで読めないので残さない。 - フォーカスの表示は、エディタを囲む要素に付ける。CodeMirror が作る要素には
focus-ringのユーティリティを付けられないので、検索パネルの入力欄とボタンには、同じ値を書く。
- 保存した SQL があるときだけ、「実行する」の行の右端に「初期の状態に戻す」の操作を出す。
- 行は常にあるので、操作を出したときと消したときに、入力欄の位置は動かない。
- 実行している間は、操作を押せない。
- 押すと
ConfirmDialogで確認する。 - 確認の後、待っている保存を捨て、保存した SQL を消す。入力欄は、初期の SQL(
openのファイルの中身)に戻す。 - 戻した後に入力欄を変えて保存するレコードは、プロジェクトの初期ファイルのバージョンを持つ。
- 戻した後は、「初期の状態に戻す」が消えるので、フォーカスを「実行する」へ移す。確認で取り消したときは、フォーカスが「初期の状態に戻す」へ戻る。
- 保存した SQL を消している間の入力欄の変更は、無視する。
- 今の値と同じ値の変更の通知は、保存しない。保存すると、戻した直後に消したレコードが作り直されるためである。
- 初期の状態に戻すときは、入力欄が既に初期の SQL と同じでも、入力欄の元に戻す履歴を消す。確認のダイアログが、消した SQL を戻せないと出すためである。
- 保存した SQL を消せないときは、入力欄を変えずに、戻せなかったことを出す。
- 保存した SQL があるかは、
SqlSessionの store が持つ。保存に成功したときに true に、消したときに false にする。IndexedDB を読めないときは、書き込まないので false のままである。
- 実行のボタンを押してから結果が出るまで、待っている段階を出す。PGlite の読み込みと初期データの投入の間は、
PageLoadingで画面を覆って出す。SQL の実行の間は、結果の欄にLoadingで出す。 wideの配置では、地の上の、サイトのページと同じ幅(max-w-page)の列に、入力欄、実行の行、結果の欄を縦に並べる。結果の欄は、出すものがあるときだけ紙の面に置く。narrowとshortの配置では、入力と結果をPaneTabsのタブで 1 つずつ出す。実行すると結果のタブに替え、隠れた入力欄にあったフォーカスを選んだタブへ移す。
Node の実行環境のページは、次のとおり組む。
wideの配置では、ペインは、左にファイルのツリー、中央にエディタ、右にプレビュー(DB を使うプロジェクトでは、プレビューと SQL のタブ)、下に端末と HTTP の流れを置く。下のペインは、端末と HTTP の流れをタブで切り替え、最初は端末を出す。HTTP の流れのタブの文には、記録の件数を添える。端末を見ている間も、記録が届いたことが分かるようにするためである。HTTP の流れのタブは、一覧と、選んだ通信の詳細を、SplitPaneで左右に並べる。ペインの間はSplitPaneで分け、訪問者が幅と高さを変えられる。narrowとshortの配置では、エディタ、端末、プレビュー、通信(HTTP の流れ)をPaneTabsのタブで 1 つずつ出す。DB を使うプロジェクトでは、プレビューの後に SQL のタブを足す。SQL のペインは、狭い配置でも入力と結果をタブに分けず、入力欄と操作を上に置いたまま結果の領域だけを送る。通信のタブは、一覧と詳細を、narrowでは上下に、shortでは左右に並べる。ファイルのツリーは、タブの帯の先頭の「ファイル」のボタンでFileDrawerに開き、ファイルを選ぶと、ドロワーを閉じてエディタのタブに替え、エディタにフォーカスを移す。選んでいないタブのペインも隠して残し、タブを切り替えても、端末の出力とプレビューのページの状態を消さない。NodePageは、プロジェクトと保存したファイルをまとめた Promise をuseで読んでから、ペインを並べる。起動の Promise は、RuntimeContextで渡し、ペインがuseで読む。起動を待つ間は、ペインごとのSuspenseが、同じ Case にstateのpendingを渡した空のペインを出す。プロジェクトと保存したファイルを読む間はNodePageのSuspenseが、起動を待つ間はNodeLayoutのSuspenseが、PageLoadingで画面を覆う。どちらのSuspenseも、messages.tsのBOOT_LABELを渡す。src/node/の React の外のモジュールが、埋め込みの ID ごとに 1 つ、起動の Promise を持つ。起動した後に、シェル、npm install、起動のコマンドの順にプロセスを起動する。Promise は、プロセスの起動を待たずに解決する。- ファイルのツリーは、
node_modulesの下を、パッケージのディレクトリ(node_modules/<名前>とnode_modules/@scope/<名前>)までにし、その中身を出さない。中身まで出すと、項目が数千になるためである。この規則は、node-runtime/の直下が、ツリーの store を組むときに持つ。 - ファイルのツリーは、シンボリックリンクをファイルとして出す。リンクの先を開けるかは、一覧の時点で分けられないためである。
- ファイルとディレクトリを消す前に、
ConfirmDialogで確認する。 - エディタは、開いたファイルをタブで並べる。入力のたびに、実行環境のファイルに書き込む。コマンドがファイルを書き換えたときは、
files.changesの通知を受けて、エディタの中身を置き換える。自分が書き込んでいる間の通知では、置き換えない。 - エディタの言語は、拡張子で決める。
.js、.mjs、.cjs、.jsx、.ts、.mts、.cts、.tsxは JavaScript と TypeScript、.jsonは JSON にする。ほかの拡張子には、色を付けない。 - 端末のエンジンは、xterm.js(
@xterm/xtermと@xterm/addon-fit)にする。色は、packages/uiのtoVsCodeThemeの値を写して渡す。xterm.js は CSS の変数を読まないためである。ライトとダークは、OS の設定で決める。 - 端末は、
processes.listのプロセスをタブで並べる。終了したプロセスは、タブの文に終了コードを添える。端末の題の右の「新しい端末」のボタンで、シェルを足す。2 つ目からのシェルの名前には、番号を付ける。 - プレビューは、待ち受けたポートをタブで並べる。選んでいないポートの iframe も
Activityで隠して残し、タブを切り替えても、ページの状態を消さない。再読み込みのボタンは、選んだポートの iframe を作り直す。 - 実行環境を起動できなかったときは、ファイルのツリーとエディタに初期ファイルを読み取り専用で出す。端末とプレビューのペインと、HTTP の一覧には、理由と、対応するブラウザ(デスクトップの Chrome か Edge の最新版)を出す。HTTP の詳細は、通信を選ぶ案内のままにする。同じ文が左右に 2 つ並ばないようにするためである。
projectを付けずに開いたときのプロジェクトは、package.jsonと、3000 番で待ち受けるindex.jsの 2 つのファイルを持つ。起動のコマンドは、node index.jsの 1 つである。
プロジェクト¶
1 つの埋め込みが持つ、初期ファイルの一式と設定を、プロジェクトと呼ぶ。
ADR-0049: 記事の埋め込みの初期ファイルと設定を、apps/web のビルドが JSON にし、ツールのページが埋め込みの ID で読む
- 埋め込みの ID は、記事の
contentIdと、記事の中の埋め込みの名前の組である(<contentId>/<埋め込みの名前>)。 - 運営者は、プロジェクトを、記事のディレクトリの下に置く(
apps/web/src/content/articles/<記事>/playground/<埋め込みの名前>/)。設定は、同じディレクトリのplayground.jsonに書く。 apps/webのビルドが、プロジェクトごとに、1 つの JSON を、https://fukuemon.dev/playground-projects/<埋め込みの ID>.jsonに出力する。fukuemon.devは、/playground-projects/*に、play.fukuemon.devを許すAccess-Control-Allow-Originを返す。- ツールのページは、埋め込みの ID から JSON の URL を組み立てて、
fetchする。fukuemon.devの origin は、apps/playgroundのビルドのときの環境変数で渡す。project/と、キャンバスのfigures/が、同じ環境変数を読む。
- JSON を読めないときと、埋め込みの ID に当たるプロジェクトが無いときは、ツールのページに、エラーと、
https://fukuemon.dev/playgroundへのリンクを出す。デフォルトのプロジェクトは開かない。 - デフォルトのプロジェクトは、
apps/playgroundが、ツールごとに 1 つ持つ。置き場は、project/である。 - JSON と
playground.jsonの型と schema は、@fukuemon/playground-projectが持つ。apps/webのビルドは、この schema でplayground.jsonを検証する。project/は、読んだ JSON を、同じ schema で検証する。schema に合わないときは、JSON を読めないときと同じ表示にする。 - schema は、
filesのキーのうち、/で区切った部分に空、.、..、__proto__があるものと、\か NUL を含むものを拒否する。 作業ディレクトリの外と、オブジェクトの prototype を指すキーを、実行環境とエディタに渡さないためである。 - 埋め込みの ID が
<英小文字、数字、ハイフン>/<英小文字、数字、ハイフン>の形式に合わないときと、JSON のtoolが開いたページのツールと違うときも、JSON を読めないときと同じ表示にする。- 形式に合わない ID では、JSON を取得せず、エラーの文に ID を出さない。URL を作った人の任意の文を、ページに出さないためである。
JSON は、次の値を持つ。
| キー | 中身 | 使うツール |
|---|---|---|
tool |
ツールの種類。node、sql、go のどれか |
全て |
version |
初期ファイルの中身から求めたハッシュ | 全て |
files |
ファイルのパスと中身 | 全て |
open |
最初に開くファイルのパス | 全て |
start |
起動のコマンドの一覧 | node |
database |
true のときに、実行環境が DB のプロセスを起動する |
node |
initialSql |
初期データの SQL | node、sql |
startは、{ command, args, name }の配列である。src/node/が、1 つの要素を、1 回のprocesses.start(command, args, { name })に渡す。コマンドの文字列を、引数に分ける処理は持たない。
保存¶
訪問者のファイルは、play.fukuemon.dev の IndexedDB に保存する。
ADR-0077: 訪問者のファイルを、埋め込みの ID ごとに IndexedDB に保存し、Node の実行環境ではシークレットのファイル、ビルドの出力、.git、リンクを除き、上限を付ける
- キーは、ツールの名前と、埋め込みの ID の組である。デフォルトのプロジェクトは、固定のキーを使う。
- SQL だけを動かすツールは、入力欄の SQL を保存する。Go を動かすツールは、ファイルを保存する。
- SQL だけを動かすツールのレコードは、入力欄の SQL、保存した時刻、初期ファイルのバージョンを持つ。入力欄の SQL を変えると、レコードを丸ごと置き換える。
workspace/のcreateSaverは、保存の途中に来たレコードのうち、最新の 1 件だけを、保存が終わった後に書く。Node の実行環境と SQL だけを動かすツールは、createSaverで保存する。workspace/は、保存してあるレコードを消すclearを持つ。createSaverのclearは、待っている保存を捨て、書いている途中の保存が終わってから消す。消す前に待っていた保存が、消した後にレコードを書き戻さないようにするためである。- IndexedDB から読んだ値がレコードの形に合わないときは、保存したファイルが無いときと同じに扱う。Node の実行環境では、次のレコードも形に合わないとみなす。
- パスが、プロジェクトの JSON の
filesのキーと同じ規則(isSafeFilePath)に合わないもの。 - あるファイルのパスが、別のファイルのパスの途中のディレクトリになっているもの(
aとa/b)。
- パスが、プロジェクトの JSON の
- Node の実行環境のレコードに、SQL のペインの入力欄の SQL(
sql)が無くても、形に合うとみなす。SQL を保存する前に保存したレコードも開くためである。sqlが文字列でないか 1 MB を超えるときは、SQL だけを捨て、ファイルは使う。捨てた後のレコードは、すぐには書き直さず、次の保存で上書きする。入力欄は、最初の SQL で始める。 - Go を動かすツールのレコードは、ファイルのパスと中身、訪問者が作ったディレクトリ、保存した時刻、初期ファイルのバージョンを持つ。ファイルを変えると、レコードを丸ごと置き換える。保存の途中に来た変更は、最新の 1 件だけを後で書く。
Node の実行環境は、次のとおり保存する。
- レコードは、ファイルのパスと中身の一覧、保存した時刻、初期ファイルのバージョンを持つ。DB を使うプロジェクトでは、SQL のペインの入力欄の SQL も持つ。
src/node/が、files.changesの通知を受けて、files.readAllの結果をworkspace/に渡す。src/node/は、files.readAllの結果と、SQL のペインの入力欄の SQL を合わせて、1 つのレコードとしてworkspace/に渡す。workspace/は、保存してあるレコードを、受け取ったレコードで丸ごと置き換える。src/node/は、files.changesの通知を受けたときと、SQL のペインの入力欄の SQL が変わったときだけ保存する。files.changesは、シェルのコマンドが作ったファイルとディレクトリ、追記、名前の変更、削除も通知するためである。- 入力欄の SQL が変わったときも、ファイルの変更の通知と同じ 500 ms の待ちに入れ、作業ディレクトリを読んで、ファイルと SQL を 1 つのレコードで書く。Node の実行環境のレコードは、ファイルの一覧を必ず持つためである。
- 入力欄の SQL の値は、
src/node/のモジュールが持つ。起動し直すときは残し、初期の状態に戻すときは、最初の SQL に戻す。初期の状態に戻すときは、入力欄が既に最初の SQL と同じでも、入力欄の元に戻す履歴を消す。確認のダイアログが「入力欄の SQL は戻せません」と出すためである。 - 保存したレコードがあるときは、入力欄の最初の SQL(保存した SQL か、無ければ最初の SQL)を保存済みとみなす。SQL の無いレコードを、訪問者が何も変えないうちに書き直さないためである。
- 起動し直している間に入力欄の SQL を替えたときは、前の実行環境の保存が止まっているので、新しい実行環境が起動した後に保存する。初期の状態に戻す間に替えた SQL は、最初の SQL で上書きするので保存しない。
src/node/は、通知を受けてから 500 ms 待ってfiles.readAllを呼ぶ。- 待つ間に来た通知は 1 回の保存にまとめ、500 ms を待ち直す。通知が続くときも、最初の通知から 2 秒のうちに読む。
- 結果を使う
files.readAllは、同時に 1 回だけ呼ぶ。読んでいる途中に次の読む時刻が来たときは、読み終えてから 1 回だけ読み直す。 - 5 秒の上限で諦めた
files.readAllは止めずに、結果を捨てる。その間に次のfiles.readAllを呼ぶので、実行環境の応答が遅いと、読み込みが重なる。 - 保存のための
files.readAllにも、起動し直すときと同じ 5 秒の上限を付ける。上限までに終わらないときは、その回の保存をやめ、次の通知を待つ。 - 保存しないパスだけの通知と、前に保存したものと同じ中身(ファイルと SQL)では、保存しない。
- 訪問者が操作する前の変更は保存しない。操作は、エディタの入力、ファイルのツリーの操作、端末への入力、実行のボタン、SQL のペインの入力欄の入力である。SQL だけを変えたときも、ファイルと合わせて保存し、保存したファイルがある扱いにする。保存したファイルがあるときは、開いたときから保存する。
- 自動の
npm installは、訪問者が何もしなくてもpackage-lock.jsonを書く。これを保存すると、何も変えていない訪問者も、次に開いたときに保存したファイルがある扱いになり、起動のコマンドが自動で動かなくなるためである。 src/node/は、画面に渡すNodeRuntimeの、ファイルの操作とプロセスの入力の関数を包む。これらの関数が呼ばれたら、訪問者が操作したとみなす。操作したかは実行環境ごとに持ち、起動し直すときは引き継ぎ、初期の状態に戻すときは引き継がない。
- 自動の
src/node/は、起動し直すときに作業ディレクトリのファイルを読む。訪問者が操作した後なら、読んだファイルを保存してから起動し直す。- どのレコードを書くか、いつ書き直すか、保存の失敗の案内をいつ出して消すかは、
src/node/の保存のモジュール(record-saver.ts)に置く。起動と実行を持つモジュールは、読んだ作業ディレクトリを渡し、書き直しを促し、消す操作を呼ぶだけである。 - 保存が失敗したときは、次の変更で、前と同じ中身でも書き直す。
- 保存しないファイルの規則は、
src/node/の 1 か所に置く。変更の通知の絞り込みと、files.readAllの結果の絞り込みは、同じ規則を使う。- どの階層でも、
node_modules、.git、dist、.next、.nuxt、.cache、.turboという名前のディレクトリの下のファイルと、これらの名前のファイルは保存しない。保存のためのfiles.readAllのexcludeに渡し、読まない。buildとoutは、ソースのディレクトリにも使う名前なので入れない。 - どの階層でも、
.env、.env.で始まる名前、.envrc、.npmrc、.netrc、.yarnrc、.yarnrc.yml、.git-credentialsのファイルは保存しない。シークレットを置くことが多いファイルである。同じ origin のほかのツールのページが、IndexedDB を読めるためである。 - ファイルでもディレクトリでもないもの(
kindがother、シンボリックリンク)は保存しない。作業ディレクトリの外のファイルの中身を保存しないためである。 - プロジェクトの JSON の
filesのキーの規則(isSafeFilePath)に合わないパスは保存しない。保存すると、次に開いたときに、レコードの全体を形に合わないものとして扱うためである。 - 1 つのファイルが 1 MB を超えるものと、テキストとして読めないもの(
textがnullのもの)は、保存しない。 - ページを開いたときは、IndexedDB から読んだレコードから、保存しないファイルを除いてから起動する。
- 除いたファイルがあれば、作業ディレクトリが保存の上限を超えていても、除いた後のレコードを書き直す。
- 書き直しが失敗したときは、除いた後のレコードをメモリに持ち、書けるまで書き直す。次に保存のために作業ディレクトリを読んで上限を超えていなければ、読んだファイルのレコードを書く。上限を超えたとき、保存のための読み込みか起動し直す前の読み込みが失敗したとき、起動に失敗した実行環境を起動し直すとき、保存しないパスだけの変更の通知が来たときは、除いた後のレコードを書く。新しい保存がキューにあるか成功したときは、除いた後のレコードを書かない。持っているレコードは、保存が成功したときと、初期の状態に戻したときに捨てる。
- 書き直しがキューにある間と、書き込みが終わるまでの間は、新しい書き直しを足さない。
- 訪問者の変更の保存が失敗して出た「ファイルを保存できませんでした」の案内は、書き直しが成功しても消さない。訪問者の変更の保存が成功するか、初期の状態に戻すまで残る。
- 書き直しの失敗だけで出た案内は、書き直しが成功したら消す。保存の失敗の案内は、
src/node/の保存のモジュールが、訪問者の変更の保存の失敗を書き直しの失敗と分けて持って決める。workspace/の保存の列(createSaver)は、書き直しかどうかを持たない。 - 保存しないファイルの規則を入れる前に保存したレコードに、シークレットのファイルを残さないためである。
- どの階層でも、
- 保存しないディレクトリの外のファイルが 1000 個を超えるか、合計が 10 MB を超えたら、保存しない。前に保存したレコードは残す。
- 保存のための
files.readAllにlimitを渡し、超えた時点で読むのをやめる。起動し直すときに読んだ結果も、同じ上限で数える。 - テキストとして読めないファイルとリンクも数える。読む時間を抑えるための上限なので、読めたものを全て数える。一覧を読んだ後に消えたファイルと、読めないシンボリックリンクは数えない。
- 超えている間は、「保存の上限を超えたため、保存していません」の案内を出す。案内の本文には、ページを閉じたときに、前に保存したファイルに戻るか、変えたファイルが消えるかを書く。DB を使うプロジェクトでは、SQL も保存しないことを書く。
- SQL のペインの入力欄の SQL は、上限に数えない。超えている間は、SQL も保存しない。
- 超えている間は、「ファイルを保存できませんでした」の案内を出さない。上限の案内は、上限を下回って読めたときと、初期の状態に戻したときに消す。
- 値の根拠は、ADR-0077 の Verification にある。
- 保存のための
- 保存したファイルがあるときは、ページのヘッダに「初期の状態に戻す」の操作を出す。保存したバージョンと、プロジェクトの JSON のバージョンが違うときは、保存したファイルを開き、初期ファイルが変わったことの案内も出す。バージョンは初期ファイルから求め、SQL のペインの入力欄の SQL は含めない。
- 埋め込みの ID が無いページで開くデフォルトのプロジェクトでは、初期ファイルが変わったことの案内を出さない。デフォルトのプロジェクトの初期ファイルは、記事の初期ファイルではないためである。
- 「初期の状態に戻す」は、
ConfirmDialogで確認してから、保存したファイルを消し、初期ファイルで実行環境を起動し直す。DB を使うプロジェクトでは、SQL のペインの入力欄も最初の SQL に戻すことを、確認の文に書く。初めて開いたときと同じく、npm installと起動のコマンドを自動で実行する。保存したファイルを消せないときは、起動し直さずに、戻せなかったことを出す。 workspace/は、読み込みの結果を、保存したファイルが無い、保存したファイルがある、IndexedDB を読めない、の 3 つの種類で返す。IndexedDB を読めないときは、src/node/のモジュールが、保存したファイルが無いときと同じに扱って、初期ファイルで起動する。NodePageは、保存できないことを画面に出す。workspace/は、IndexedDB を読めなかったことを、ページを開いている間だけ持ち、その間は IndexedDB に書き込まない。SQL だけを動かすツールと、Go を動かすツールでも、workspace/は同じように書き込まない。- 実行環境が追加するファイルは、作業ディレクトリの外にあるので、保存の対象に入らない。
起動から実行まで¶
次の図は、Node の実行環境のページを開いてから、サーバーが動くまでの流れを示す。
ADR-0076: Node の実行環境は、ページを開いたときに起動し、シェルを制限なしで使わせ、訪問者が変えたコードは操作の後に動かし、起動し直すときに作業ディレクトリを読めなければ、前に起動したときのファイルで起動する
sequenceDiagram
participant V as 訪問者
participant P as NodePage
participant W as fukuemon.dev
participant S as workspace(IndexedDB)
participant R as node-runtime
V->>P: ページを開く
par
P->>W: プロジェクトの JSON を fetch する
W-->>P: 初期ファイルと設定
and
P->>S: 保存したファイルを読む
S-->>P: 保存したファイル、無し、または読めない
end
opt IndexedDB を読めない
P-->>V: 保存できないことを表示する
end
alt JSON を読めない、埋め込みの ID に当たるプロジェクトが無い
P-->>V: エラーと、一覧へのリンクを表示する
else 読めた
P->>R: bootNodeRuntime(ファイル、DB を使うか、初期データの SQL)
alt 起動が失敗する、30 秒で終わらない、crossOriginIsolated が false
R-->>P: 失敗
P-->>V: 初期ファイルを読み取り専用で出し、理由を表示する
else 起動する
R-->>P: NodeRuntime
P->>R: npm install を実行する
alt npm install が失敗する
R-->>V: 端末に npm install の出力を出す。起動のコマンドは実行しない
else 成功する
alt 保存したファイルが無い、または IndexedDB を読めない
P->>R: 起動のコマンドを実行する
R-->>V: 端末の出力、プレビュー、HTTP の記録
else 保存したファイルがある
P-->>V: 実行のボタンを出す
V->>P: 実行のボタンを押す
P->>R: 起動のコマンドを実行する
R-->>V: 端末の出力、プレビュー、HTTP の記録
end
end
end
end
- 図の
NodePageの手順のうち、bootNodeRuntimeの呼び出し、npm install、起動のコマンドの実行は、起動の Promise を持つsrc/node/のモジュールが進める。React のコンポーネントは、Effect の中でプロセスを起動しない。 bootNodeRuntimeは、DB を使うプロジェクトのときに、DB のプロセスの依存を入れ、DB のプロセスを起動し、初期データの SQL を実行してから、Promise を解決する。30 秒は、bootNodeRuntimeを呼んでから、Promise が解決するまでの時間で判定する。- 30 秒で終わらないときは、
bootNodeRuntimeが、作りかけのインスタンスを破棄してから、Promise を失敗させる。 - DB の依存の
npm installが失敗したときは、Promise を失敗させる。BootErrorのcauseの Error のcauseに、npm installの出力から端末の制御の文字(\r、CSI、OSC、ほかの C0 の制御の文字)とスピナーの文字を除き、末尾の 4096 文字を入れる。画面の文は、ほかの起動の失敗と同じにする。原因は、ブラウザのコンソールで読む。 - 起動が失敗したときは、端末とプレビューのペインに、動かせない理由と、対応するブラウザを出す。
npm installが 0 でない終了コードで終了したときは、起動のコマンドを実行しない。端末に、npm installの出力が残る。- 起動のコマンドが複数あるときは、
startの順に起動する。前のコマンドの終了を待たない。 - 保存したバージョンと、プロジェクトの JSON のバージョンが違うときも、保存したファイルで起動し、保存したファイルがあるときと同じに扱う。
- プレビューの iframe が 30 秒で読み込まれないときは、
PreviewPaneが、プレビューのペインにだけ、動かせない理由を出す。- WebContainers のプレビューの iframe は、サーバーが応答しないときも
loadを起こす。この上限が働くのは、プレビューのページそのものが読み込まれないときだけである。応答しないサーバーの判定は、プレビューに入れるスクリプトを足すときに、スクリプトが送るメッセージで行う形に替える。
- WebContainers のプレビューの iframe は、サーバーが応答しないときも
- 訪問者は、端末で、WebContainers のシェルを制限なしで使う。
- 「実行環境を起動し直す」の操作は、いつでも使える。起動し直した後は、保存したファイルがあるときと同じに扱う。
npm installだけを自動で実行し、起動のコマンドは、訪問者が実行のボタンを押してから実行する。 - 実行のボタンは、起動と
npm installの終了を待ってから、起動のコマンドを実行する。前に実行した起動のコマンドが動いていれば、止めてから実行する。止めるときは、ポートが空くよう、プロセスの終了を 2 秒まで待つ。 src/node/は、自動で実行する起動のコマンドと、実行のボタンで実行する起動のコマンドを、1 つのキューに並べて順に実行する。npm installの途中に押しても、動いている起動のコマンドは 1 組である。- 起動のコマンドを起動できないときは、ペインの上に、実行できなかったことと、起動し直す操作を出す。
- 実行のボタンと、「起動し直す」と「初期の状態に戻す」のボタンは、Go を動かすツールと同じく、ページのヘッダに並べる。ボタンは右に寄せ、実行のボタンを右端に固定する。ヘッダが折り返したときも右に寄せる。「初期の状態に戻す」は、保存したファイルがあるときに左に出す。「初期の状態に戻す」が出たときに、ほかのボタンの位置がずれないようにするためである。起動し直している間は、ボタンを押せず、「起動し直す」に待ちの印を出す。
- ボタンは、Go を動かすツールと同じく
Buttonのsize="S"にする。ページのヘッダの要素は 2px の角のままにする(ADR-0073)。埋め込みのときは、ヘッダに見出しを出さず、ボタンだけを出す。 - DB のプロセスが終了したとき以外で、起動の後に実行環境が動かなくなったときは、訪問者が、「実行環境を起動し直す」の操作で起動し直す。画面は、実行環境の全体の状態を表示しない。
実行環境が追加するファイルとプロセス¶
- 実行環境は、preload、DB のプロセスのスクリプトと依存、待ち受けたポートの URL を書くファイルを、
/tmpの下の、実行環境のディレクトリに置く。 - ファイルを置く処理と、環境変数の組み立ては、
webcontainer/が持つ。 webcontainer/は、startで起動するプロセスと、シェルの環境変数に、次の値を渡す。実行環境の外にファイルを書くためにwebcontainer/がspawnを通さずに起動するnodeには、これらの値を渡さない。NODE_OPTIONS: preload ごとの--require <絶対パス>。HTTP の流れとコンソールを記録する preload は、どのプロジェクトでも追加する。Unix ドメインソケットに接続している間はプロセスを終了させない preload は、DB を使うプロジェクトのときだけ追加する。PLAYGROUND_PORTS_FILE: 待ち受けたポートの URL を書くファイルのパス。- DB を使うプロジェクトのときは、
PGHOST、PGUSER、PGDATABASE、DATABASE_URL。
- 待ち受けたポートの URL を書くファイルは、
/tmp/fukuemon-playground/ports.jsonである。中身は、ポートの番号をキー、URL を値にした 1 行の JSON({"3000":"https://..."})で、ポートが開くか閉じるたびに書き直す。ポートが 1 つも無いときは{}である。webcontainer/は、このファイルを、実行環境の中の Node で書く。書く中身を環境変数で渡す。WebContainers は、改行を含む環境変数の値を最初の行で切るので、中身を base64 の 1 行にして渡す。
public/_headersは、Cloudflare でだけ働く。apps/playgroundのvite.configは、vp devとvp previewでも、/node/に同じ isolation のヘッダを返す。
SQL¶
SQL の画面(sql-console/)は、SQL を実行する関数を props で受ける。
この関数に SQL を渡し、結果を表示する。
SQL を実行する関数は、SqlDatabase の run を呼ぶ。
SqlDatabase の実装は 2 つである。
- SQL だけを動かすツール: Web Worker で動かす PGlite(
@fukuemon/sql-runtime)。ツールのページのメインスレッドでは、SQL を実行しない。 - DB を使うプロジェクトの Node の実行環境: DB のプロセスの標準入出力(
node-runtime/)。
type SqlDatabase = {
status: Store<DatabaseStatus>;
run: (sql: string) => Promise<SqlResponse>;
};
type DatabaseStatus = { kind: "ready" } | { kind: "stopped"; reason: string };
type SqlResponse =
| { results: readonly SqlResult[]; ms: number }
| { error: { message: string; code?: string; position?: number }; ms: number };
type SqlResult = {
fields: readonly string[];
rows: readonly (readonly unknown[])[];
affectedRows: number;
totalRows: number;
truncated: boolean;
};
- 複数の文を 1 回で渡したときは、
resultsに、文ごとの結果が入る。SQL の画面は、最後の文の結果を表示する。- Web Worker で動かす実装と DB のプロセスは、最後の文のほかの結果の
rowsを空にしてから送る。fields、affectedRows、totalRowsは残す。
- Web Worker で動かす実装と DB のプロセスは、最後の文のほかの結果の
- 1 つの文が失敗したときは、全部を取り消す。SQL の画面は、エラーと、全部を取り消したことを表示する。
- 失敗したときは、
ROLLBACKを流す。SQL の中のBEGINで始めたトランザクションが失敗したまま残ると、次の SQL が全て失敗するためである。 - 前の実行で
BEGINを流し、開いたままのトランザクションも、このROLLBACKで取り消す。ROLLBACKも失敗したときは、元のエラーを返す。 - SQL の画面が、全部を取り消したことを表示するのは、
errorがcode(SQLSTATE)を持つときだけである。codeを持たないerror(PGlite の読み込みの失敗、Web Worker の異常終了)では、取り消した文は無いので、メッセージだけを出す。
- 失敗したときは、
- 1 つの結果が 100,000 行を超えるときは、
rowsに先頭の 100,000 行を入れ、totalRowsに全体の行の数を、truncatedにtrueを入れる。SQL の画面は、切ったことと、全体の行の数を表示する。 - SQL の画面は、結果をページ送りで表示する。1 ページの行の数は、訪問者が 10、50、100、500 から選ぶ。デフォルトは 100 である。
- 結果の列が
QUERY PLANの 1 つだけのときは、実行計画として表示する。- 1 行で JSON の値のときは、
FORMAT JSONの出力として、ノードのツリー(TreeView)と、Planning TimeとExecution Timeを出す。 - それ以外は、行をつないだテキストで出す。
- SQL の画面は、訪問者が書いた文を書き換えて実行し直さない。
EXPLAIN ANALYZEは文を実際に実行するので、書き換えて 2 回実行すると、INSERTのような文も 2 回実行されるためである。 - 実行計画のツリーは、使うのが
sql-console/だけなので、packages/uiに置かずsql-console/に置く。
- 1 行で JSON の値のときは、
- 結果のセルは、
NULLをNULLと出し、空の文字列と見分けられるようにする。日時は ISO 8601、byteaは\xで始まる 16 進数、JSON とそのほかのオブジェクトは JSON の文字列で出す。 - SQL だけを動かすツールの DB の中身は、保存しない。ページを開き直すと、初期データの SQL を実行した状態に戻る。
runは、SQL の失敗でも、DB が止まっているときでも、Promise を失敗させず、errorを持つ値を返す。- SQL の実行、100,000 行で切る処理、
SqlResponseを組み立てる処理は、@fukuemon/sql-runtimeの 1 つの関数が持つ。関数は、PGlite のインスタンスと SQL を受け取る。2 つの実装は、同じ関数を呼ぶ。guest/の DB のプロセスのスクリプトは、この関数を import し、ビルドで 1 つのファイルに束ねる。PGlite とpglite-socketは束ねず、実行環境の中でnpm installする。- 束ねる処理は、
@fukuemon/node-runtime/viteが export する Vite の plugin(guestBundle)が持つ。plugin は、<パス>?bundleの import を、パスのファイルを 1 つの ES module に束ねた文字列にする。apps/playgroundのvite.configとvitest.configは、plugin を登録するだけである。束ねた結果は、コミットしない。 webcontainer/は、plugin が束ねた結果を、文字列として import する。@fukuemon/node-runtimeは、npm に公開しない。plugin を使うのは、このリポジトリの中だけである。
DB のプロセスの標準入出力では、次の形式で送る。
- リクエスト: 1 行の JSON(
{"id": "<連番>", "sql": "<SQL>"})。 - レスポンス: 行頭にプレフィックスを付けた、1 行の JSON。
idと、SqlResponseの値を持つ。 node-runtime/は、idでリクエストとレスポンスを対応づける。プレフィックスの無い行(DB のプロセスのログと、入力のエコー)は捨てる。node-runtime/は、レスポンスの値がSqlResponseの形かを確認する。実行環境の中は訪問者が触れるので、DB のプロセスの出力も信頼しない。idがあり、形に合わない行には、待っているリクエストにerrorを持つ値を返す。- リクエストの JSON の U+2028 と U+2029 は、
\u2028と\u2029にエスケープして書く。DB のプロセスのreadlineが、この 2 つを行の区切りとして読むためである。 - DB のプロセスは、ソケットで待ち受けた後に、プレフィックスを付けた
{"ready": true}の行を出す。node-runtime/は、この行を受けてから、初期データの SQL を送る。 - DB のプロセスは、結果のセルの bigint、日時、
bytea、NaN、Infinityを、表に出す文字列にしてから JSON にする。JSON では型が残らないためである。変える関数(toJsonResponse)は、@fukuemon/sql-runtimeに置く。表に出る文字列は、Web Worker で動かす実装と同じになる。 - DB の依存の
npm installが失敗したとき、readyの行の前に DB のプロセスが終了したとき、初期データの SQL が失敗したときは、bootNodeRuntimeが失敗する。初期データの SQL が失敗したときのメッセージは、初期データの SQL が失敗しました:で始める。 disposeの後は、待っているリクエストと、その後のrunに、errorを持つ値を返す。databaseのstatusは変えず、その後に DB のプロセスが終了してもstoppedにしない。訪問者が起動し直すときに、SQL のペインに DB が止まったことを出さないためである。- DB のプロセスが終了したときは、
node-runtime/が、databaseのstatusをstoppedにし、待っているリクエストに、errorを持つ値を返す。その後のrunも、同じ値を返す。 statusがstoppedのときは、SQL のペインにだけ、DB が止まったことをエラーとして出す。エラーの隣に、「実行環境を起動し直す」の操作を出す。起動し直している間は、ヘッダの「起動し直す」と同じく、待ちの表示にして押せなくする。端末と、Node のコードのプロセスは、動かし続ける。- 実行環境を起動し直すと、DB のプロセスも作り直し、初期データの SQL から DB を作り直す。SQL で変えた行は残らない。「初期の状態に戻す」も同じである。
- SQL だけを動かすツールでは、Web Worker が異常終了したときに、
@fukuemon/sql-runtimeが、statusをstoppedにし、待っているリクエストに、errorを持つ値を返す。SQL の画面は、エラーを出す。次にrunを呼んだときに、Web Worker を作り直し、statusをreadyに戻す。DB の中身は、初期の状態に戻る。 - Web Worker で動かす実装は、
SqlDatabaseに加えて、読み込みの段階の store(idle、loading、seeding、ready、failed)と、読み込みを始める関数を持つ。Web Worker は、最初に読み込みを始める関数かrunを呼んだときに作る。 - PGlite を読み込めないとき、初期データの SQL が失敗したとき、Web Worker を作れないときは、
runがerrorを持つ値を返し、Web Worker を終了する。次のrunで、Web Worker を作り直す。- 初期データの SQL が失敗したときのメッセージは、
初期データの SQL が失敗しました:で始める。
- 初期データの SQL が失敗したときのメッセージは、
- Web Worker を終了し、待っている
runにerrorを返す処理は、@fukuemon/sql-runtimeの中の 1 つの関数に置く。
Go¶
Go を動かすツールは、Yaegi のインタプリタの WASM で、訪問者の Go のコードを動かす。
ADR-0037: Go のコードを、Yaegi のインタプリタの WASM で動かす
ファイルの形と、実行する範囲は、次のとおり。
- ファイルは、ディレクトリに分けて持つ。訪問者が作った空のディレクトリも、一覧に出して保存する。
- ルートのディレクトリの import のパスは、ルートの
go.modのmoduleの行から読む。go.modが無いときと、moduleの行が無いときは、playにする。- ディレクトリの package は、
<モジュールの名前>/<ディレクトリ>で import する。ローカルの Go と同じ書き方にして、訪問者がファイルをコピーすればgo runで動くようにするためである。
- ディレクトリの package は、
- 実行のボタンは、ルートのディレクトリの直下にある
mainの package を動かす。テストのボタンは、テストのファイル(_test.go)を持つ全ての package のテストを、package の名前の順に動かす。 - テストは、package ごとに、Web Worker と WASM のインスタンスを作り直して動かす。
testing.Mainがos.Exitで終わるためである。package ごとに、go testと同じokかFAILの行を、出力に足す。 - 終了コードは、正常に終わると 0、コンパイルのエラーとテストの失敗は 1、panic は 2、
os.Exitを呼んだときは渡した値である。テストは、どれかの package が失敗すると 1 にする。
GoRunner(@fukuemon/go-runtime)の形は、次のとおり。
type GoRunner = {
phase: Store<LoadPhase>;
load: () => Promise<void>;
run: (files: GoFiles, onOutput: (chunk: string) => void) => GoExecution;
test: (files: GoFiles, onOutput: (chunk: string) => void) => GoExecution;
};
type GoExecution = { done: Promise<GoExit>; stop: () => void };
type GoExit =
| { kind: "exited"; exitCode: number }
| { kind: "stopped" }
| { kind: "failed"; message: string };
loadは、Yaegi の WASM を取得して、ページのメインスレッドで 1 度だけコンパイルする。実行のたびに、コンパイルしたWebAssembly.Moduleを、新しい Web Worker に渡す。stopは、Web Worker を終了させる。無限ループでも止まり、次の実行は新しい Web Worker で動く。- Yaegi に渡す symbol は、
stdlib.Symbolsだけである。Yaegi がソースを読むファイルシステムには、訪問者のファイルだけを、src/<モジュールの名前>/に置く。 - Web Worker は、Yaegi を起動する前に、Web Worker の
fetchを、同じ origin へのリクエストだけを通すものに差し替える。 - Yaegi の標準出力と標準エラーは、1 つの出力として、出た順に
onOutputに渡す。エラーの中のファイルのパスは、ツールのルートからのパスにする。
WASM のビルドと配信は、次のとおり。
@fukuemon/go-runtimeのbuildの script が、go/のコードをGOOS=js GOARCH=wasmでビルドする。WASM はコミットしない。apps/playgroundのdevとbuildは、先にこの script を動かす。- WASM は、2 つのファイルに分けて配る。WASM の全体が、Cloudflare Workers の静的アセットの 1 ファイルの上限(25 MiB)を超えるためである。ブラウザは、2 つを順につないで、取得しながらコンパイルする。
- Go の
lib/wasm/wasm_exec.jsは、go-runtime/src/worker/vendor/にコミットする。ビルドの script は、mise で入れた Go のものと中身を比べ、違うときは失敗する。
ページは、次のとおり組む。
src/go/の React の外のモジュールが、埋め込みの ID ごとに 1 つ、次のものを持つ。- ファイルとディレクトリの一式の store、実行中かどうかの store、出力の store。
- ファイルの一覧、読み込み、書き込み、ディレクトリの作成、名前の変更、削除の関数。
- 実行とテストの関数。時間の上限を受け取り、実行を始めたときのファイルのコピーを
GoRunnerに渡す。実行中に呼んだときは、実行せずに、実行中であることを返す。
- 出力の store は、1,000,000 文字を上限にし、上限に達した後の出力を捨てる。
wideの配置では、画面は、左にファイルの一覧(FileTree)、右上にエディタ(EditorFrame)、右下に出力(TerminalFrame)を置く。SplitPaneで、幅と高さを変えられる。ヘッダに、実行のボタン、テストのボタン、停止のボタンを置く。narrowとshortの配置では、エディタと出力をPaneTabsのタブで 1 つずつ出す。ファイルの一覧は、タブの帯の先頭の「ファイル」のボタンでFileDrawerに開き、ファイルを選ぶと、ドロワーを閉じてエディタのタブに替え、エディタにフォーカスを移す。実行するかテストすると、出力のタブに替え、隠れたエディタにあったフォーカスを選んだタブへ移す。- エディタのファイルごとの
EditorStateは、ページが持つ。タブを切り替えても、元に戻す履歴とカーソルの位置を残すためである。 - 出力は、
--- PASSとokの行、--- FAILとFAILの行、コンパイルのエラーと panic の行を、色で分ける。 - 出力の欄の下に、Yaegi で動かしていることと、
iterと一部のジェネリックな関数を使えないことを、常に出す。 - 実行のボタンを押してから終わるまで、待っている段階を
Loadingで出す。段階は、Yaegi の読み込み、実行、テストの実行の 3 つである。 - ファイルとディレクトリを消すときは、
ConfirmDialogで確認する。
Interface¶
置くファイル¶
apps/playground/: アプリのコード。Worker の設定と、レスポンスヘッダ(/node/*の cross-origin isolation のヘッダ、/のリダイレクト)を持つ。apps/web/src/content/articles/<記事>/playground/<埋め込みの名前>/: 運営者が書くプロジェクト。playground.jsonと、初期ファイルを置く。packages/playground-project/: プロジェクトの JSON と、playground.jsonの型と schema。packages/sql-runtime/、packages/node-runtime/、packages/go-runtime/: 実行環境ごとの処理。
読む値¶
- URL のクエリの
project: 埋め込みの ID。 - IndexedDB: 訪問者のファイルと、SQL の入力欄の値。キーは、ツールの名前と、埋め込みの ID の組。
動くチェック¶
- Oxlint の
no-restricted-imports: 次の 3 つの import を止める。packages/node-runtime/src/webcontainer/のほかからの、@webcontainer/apiの importapps/playground/src/のnode/、sql/、go/、drawio/、excalidraw/、slides/の間の importapps/playground/src/ai-operations/からの、ツールのディレクトリの import
- package の依存の宣言と knip: 次の 3 つを確認する。
apps/playgroundが、@webcontainer/apiを import しないこと- 実行環境の 3 つの package が、React、
@fukuemon/ui、@fukuemon/playground-projectに依存しないこと @fukuemon/node-runtimeが、workspace の package では、@fukuemon/sql-runtimeだけに依存すること
apps/webのビルド:@fukuemon/playground-projectの schema でplayground.jsonを検証する。toolがnodeのプロジェクトを埋め込む記事の URL を、_headersに書く。