OpenVessel

アーキテクチャ

OpenVessel は、自分の OS(ファイルシステム・プロセス・シェルを持つカーネル)と、自分のプログラム(Node.js・npm・Astro)と、決まった出入り口(外への通信のネットワークゲートと、プレビューのための仮想ポート)を持っています。仮想マシンと違うのは、CPU を再現しないことです。JavaScript と WebAssembly は、ブラウザのエンジンでそのまま動きます。

システム構成

ブラウザのタブの中に、3つのオリジンを置きます。埋め込む側のページ(H)、ランタイム(R)、プレビュー(P)です。3つは postMessage だけで話します。

OpenVessel の全体の構成 ブラウザのタブの中に、埋め込む側のページ、ランタイム(カーネルの Web Worker と、Node.js のプロセスごとの Web Worker、Service Worker のネットワークゲート)、プレビュー(iframe と Service Worker)がある。ランタイムだけがネットワークゲートを通して npm のレジストリなどに出られる。プレビューは仮想ポートを通して開発サーバーにつながる。 ブラウザのタブ origin H 埋め込む側のページ あなたの画面 エディタ・CMS・教材 OpenVessel API boot · mount · exec spawn · createPreview 通信の方針 決めるのは H だけ origin R(見えない iframe) ランタイム カーネル(Web Worker) VFS · プロセス · パイプ · TTYsh · coreutils · npm/pnpm/yarn/bunsocketpair · 仮想ポート同期の syscall の受け口 node Worker lib/ (Node) bindings (TS) node Worker lib/ (Node) bindings (TS) node Worker lib/ (Node) bindings (TS) 同期の syscall(SharedArrayBuffer + Atomics) Service Worker(R) ES モジュールの配信 外への通信のネットワークゲート(唯一の出口) origin P プレビュー 開発中のサイト iframe(sandbox) Service Worker(P) HTTP · WebSocket postMessage → :4321(仮想ポート) カーネルへ HTTP・WS を橋渡し インターネット registry.npmjs.orgfonts · icons · cdngithub 許可した先だけ(fetch) 手元のマシンの ネットワーク 開くポートは 0

H

埋め込む側のアプリと API。仮想マシンの中のコードは、このページの DOM・クッキー・ストレージに触れられない。

R

カーネル・プロセス・ネットワークゲート。外に出られるのはここだけで、それもネットワークゲートを通るときだけ。

P

開発中のサイト。サイト自身の JavaScript が、ランタイムや H に届かないように分ける。

レイヤー構成(Node.js との対比)

Node.js は、JavaScript で書かれた lib/ が、C++ の internalBinding()・libuv のイベントループ・OS の上に乗っています。OpenVessel は lib/ をそのまま使い、その下の層だけをブラウザの上に作り直しました。

本物の Node.jsLinux / macOS / Windows

あなたのコード・npm のパッケージ
lib/(Node の JavaScript)
internalBinding()(C++、約50のモジュール)
libuv(イベントループ)
OS(ファイル・プロセス・ソケット)

OpenVesselブラウザのタブ

あなたのコード・npm のパッケージ
lib/(Node の JavaScript)書き換えない
internalBinding() を TypeScript で
loop.ts(Node と同じ順番のイベントループ)
カーネル(Web Worker)+ブラウザ

色のついた層が、OpenVessel が作った部分です。上の2段は同じものなので、エラーの文言とコード、引数の検査、node:fs:441:20 のようなスタックトレース、ストリームの背圧、イベントの順番が Node のとおりになります。

syscall

同期システムコール

プロセスは要求を SharedArrayBuffer に書き、カーネルが答えるまで Atomics.wait で眠ります。本物の OS と同じ形なので、fs.readFileSync・spawnSync・同期の require() がブラウザで動きます。

ES モジュール

ネイティブ ES モジュール

ソースをランタイムの Service Worker から配信するので、import・import.meta・最上位の await は、ブラウザのモジュールそのものとして動きます。

WebAssembly

ネイティブアドオンの WebAssembly 化

esbuild・rollup・rolldown・lightningcss・sharp・napi-rs の wasm32-wasi 版を node:wasi で動かします。無いものは互換パッケージ(Pagefind・cn-font-split・@resvg/resvg-js など)で補います。

カーネルの機能

Web Worker 1つが OS の役をします。node のプロセスは、それぞれが1つの Web Worker です。

  • メモリ上のファイルシステム(権限のビット・時刻・監視)
  • プロセス(pid・ファイル記述子・環境変数・終了コード・シグナル)
  • パイプ・端末(TTY)・ソケット(TCP / Unix の socketpair)
  • sh と coreutils(ls・cat・grep・ps など)
  • npm・pnpm・yarn・bun(4つの lockfile を1つのクライアントで)
  • 仮想ポートとネットワークゲート

処理の流れ:npm install && npm run dev

  1. 01コマンドを渡すvessel.exec()

    埋め込む側のページが API を呼ぶと、カーネルが sh を起動し、sh がコマンドを読み解きます。

  2. 02依存を入れるnpm install

    npm と同じように依存の木を解き(持ち上げ・peer 依存・lockfile)、ネットワークゲートを通して tarball を取り、sha512 を確かめてからメモリ上のファイルシステムに展開します。ネイティブのパッケージは WebAssembly 版か互換パッケージに差し替えます。

  3. 03Node.js を起動するnpm run dev

    新しい Web Worker で Node.js が立ち上がります(温めておいた Worker なら約 0.05 秒)。Astro と Vite を ES モジュールとして読み込み、esbuild と rolldown を WebAssembly で動かします。

  4. 04仮想ポートで待ち受けるlisten(4321)

    カーネルが待ち受けを記録し、埋め込む側のページに server-ready を知らせます。ページがプレビューの iframe を作ります。

  5. 05編集が反映されるfs.writeFile()

    ファイルを書くと、カーネルが監視している側に知らせます。Vite の fs.watch が反応し、HMR の知らせが WebSocket でプレビューに届きます。

Node.js のバージョン追従

参照している Node の版はソースの1か所(src/node/version.ts)にあり、新しい版との差分はスクリプトで洗い出せます。lib/ は Node のものをそのまま使うので、追従は「変わった internalBinding を読み直す」作業になります。似せて作った互換の層のように、振る舞いを一つずつ追いかけ直す必要がありません。

バインディングは1つずつ、本物の Node で同じ呼び出しをした結果と突き合わせて試験しています。Node 自身の試験も OpenVessel の中で流しています。

ソースツリー
src/kernel/カーネル:VFS・プロセス・I/O・syscall・ネットワーク・sh・coreutils・npm
src/nodejs/Worker の中の Node.js:起動・イベントループ・internalBinding(1つずつ1ファイル)
src/runtime/ランタイムの iframe と Service Worker(ES モジュールの配信・ネットワークゲート)
src/preview/プレビューの中継と Service Worker(HTTP・WebSocket を仮想ポートへ)
src/host/埋め込む側のページのための API
compat/ネイティブだけのパッケージの互換パッケージ(WebAssembly にコンパイル)