mod の実行環境
mod が各ユーザーのブラウザ内で WASM や外部ライブラリを動かし、バイナリを取得・処理するための仕組み。 Ubichill が提供するのは用途を限定しない実行・通信・データ処理の基盤だけで、特定の mod(動画再生など) のための機能は持たない(→ #202)。
関連: ARCHITECTURE.md(Sandbox)/ CAPABILITIES.md(権限)/
MOD.md(配布・lock)/ API.md(Ubi.*)
1 Component = 1 Worker。Worker の中は 2 層に分かれる。
| 層 | 動くもの | 持つ権限 |
|---|---|---|
| JS 層 | lock で固定した mod のコード(index.<hash>.js)だけ |
Ubi 経由の API のみ。通信・保存・スクリプト読み込みの入口は Worker 起動時に封鎖する |
| WASM 層 | mod が持ち込んだ WASM(Rust / C / Go / Python ランタイムなど) | WebAssembly.instantiate(module, imports) の imports に渡したものだけ |
- JS の動的なコード生成は禁止(
eval・Function・関数のconstructor・文字列のタイマー)。実行時にEvalErrorになる。外部から取ってきたコードを動かしたい場合は、QuickJS などのインタプリタを WASM として 同梱し、その中で動かす(WASM 層は imports 以外に何も触れない)。 - WASM は任意のバイト列からコンパイルできる。WASM は imports で渡した関数しか呼べないため、JS 層の 権限を越えない。
封鎖の 2 段構え
Section titled “封鎖の 2 段構え”| 段 | 何をするか | どこで効くか |
|---|---|---|
Worker 内の封鎖(packages/sandbox/src/worker/lockdown.ts) |
fetch・XMLHttpRequest・WebSocket・WebTransport・EventSource・importScripts・Worker・BroadcastChannel・indexedDB・caches・navigator・FontFace などを self とプロトタイプチェーンの全段から取り除く。コード生成の入口も止める。1 つでも取り除けなければ mod を実行しない |
どの配信方法でも効く |
Worker 専用の CSP(SANDBOX_WORKER_CSP) |
本番は default-src 'none'; script-src 'unsafe-eval' 'wasm-unsafe-eval'。スクリプトの URL を 1 つも許可しないので、封鎖を抜けても通信できず、import() で同一オリジンの JS(他の mod のアセット等)も読めない。dev は Vite が Worker の依存(packages/{sandbox,sdk,shared,ecs,runtime,core-components}/src/ と node_modules/.vite/deps/)をモジュールごとに配信するため、そのパスだけを許可する |
BFF(本番)と Vite(dev / preview)が Worker スクリプトに付ける。それ以外の配信方法では付かない |
以前は
selfの上書きと文字列検査だけだったため、Object.getPrototypeOf(self).fetch.call(self, '/api/v1/…')で本体の API に cookie 付きで届いた。文字列検査は WASM のグルーコード(Emscripten・wasm-bindgen が含むimportScriptsやFunction(という文字列)を誤検知する一方で、上の抜け道は防げなかったので廃止した。 実ブラウザで抜け道が塞がっていることはpackages/sandbox/src/worker/sandbox.browser.test.tsが毎回確かめる。
Ubi.asset — 同梱アセット(権限 asset:read・safe)
Section titled “Ubi.asset — 同梱アセット(権限 asset:read・safe)”const bytes = await Ubi.asset.bytes('data/table.bin'); // ArrayBuffer(integrity 照合済み)const text = await Ubi.asset.text('readme.txt'); // UTF-8const module = await Ubi.asset.wasm('engine.wasm'); // WebAssembly.Module(同じパスは 1 回だけコンパイル)const { exports } = await WebAssembly.instantiate(module, { env: { /* WASM に許すものだけ */ } });- パスは mod の
assets/からの相対。..・絶対パス・%・クエリは拒否する(ASSET_INVALID_PATH)。 - manifest に無いファイルは読めない(
ASSET_NOT_DECLARED)。中身が manifest と違えば渡さない(ASSET_INTEGRITY_MISMATCH)。 { signal }で取り消せる。
Ubi.fetch — バイナリ・上限・取り消し(権限 net:fetch・dangerous)
Section titled “Ubi.fetch — バイナリ・上限・取り消し(権限 net:fetch・dangerous)”const controller = new AbortController();const res = await Ubi.fetch('https://cdn.example.com/model.bin', { responseType: 'arrayBuffer', // 既定は 'text'(従来どおり) maxBytes: 64 * 1024 * 1024, // 本文の上限。超えたら読み込みを止める timeoutMs: 60_000, // ドメイン承認の待ち時間を含む制限時間 signal: controller.signal, // abort で取り消し → FETCH_ABORTED の UbiError});if (!res.ok) console.warn(res.error?.code ?? `HTTP ${res.status}`);else process(new Uint8Array(res.body)); // res.body は ArrayBuffer(コピーせずに Worker へ移る)- 送信本文に
ArrayBuffer/Uint8Arrayを渡せる(Host へはコピーして送る)。 - Host が合成した失敗(ドメイン拒否・制限時間・サイズ超過・通信失敗)は reject ではなく
ok: falseとerror.codeで返る(FETCH_DOMAIN_NOT_ALLOWED/FETCH_TIMEOUT/FETCH_RESPONSE_TOO_LARGE/FETCH_NETWORK_ERROR/FETCH_REDIRECT_BLOCKED)。サーバーの 404 などにはerrorは付かない。 - 取り消し(
signal)だけは reject(FETCH_ABORTED)。標準のfetchと同じ。
Ubi.runtime — 実行環境の能力
Section titled “Ubi.runtime — 実行環境の能力”if (!Ubi.runtime.supports('wasm:jspi')) fallbackToAsyncApi();Ubi.runtime.require('wasm'); // 使えなければ UNSUPPORTED_FEATURE の UbiError| feature | 意味 | 決まり方 |
|---|---|---|
wasm |
WebAssembly | ブラウザ |
wasm:simd |
128bit SIMD | ブラウザ |
wasm:exceptions |
WASM の例外処理(新しい Pyodide などが使う) | ブラウザ |
wasm:threads |
共有メモリのスレッド | 現在は常に false(ページが cross-origin isolated ではないため) |
wasm:jspi |
JS Promise Integration(同期的に書かれた WASM から非同期 API を待つ) | ブラウザ |
fetch:binary / asset |
上記の API | Host のプロトコル版(v4 以上) |
identity |
Ubi.identity |
Host のプロトコル版(v5 以上) |
event:ref |
Ubi.event.emit の { ref } と scope: 'referrers'(古い Host は届けずに捨てる) |
Host のプロトコル版(v6 以上) |
media:surface |
component: 'core:media' の参照を Ubi.media.* の描画先にする(古い Host は描かない) |
Host のプロトコル版(v6 以上) |
古い Host には Ubi.runtime 自体が無い。古い Host でも動かしたい mod は Ubi.runtime?.supports(...) と書く。
制限値(FETCH_LIMITS)
Section titled “制限値(FETCH_LIMITS)”| 項目 | 既定 | 上限 |
|---|---|---|
timeoutMs(承認待ちを含む) |
120 秒 | 300 秒 |
maxBytes(fetch の本文) |
32 MiB | 256 MiB |
| アセット 1 ファイル | — | 256 MiB(制限時間は 120 秒) |
配布と完全性
Section titled “配布と完全性”mods/<id>/assets/** ──ubichill build──▶ v<ver>/<path> (そのままコピー) manifest.json.assetIntegrity[path] = sha256-… lock.json.manifestIntegrity = manifest の sha256world の lock(manifestIntegrity)──▶ loader が manifest を照合 ──▶ Host がアセットを照合- manifest は lock で固定されているので、manifest に載ったアセットの hash も固定される。lock の形式は変えていない。
- アセットの無い mod の manifest は従来とバイト単位で同じ(既存の lock を壊さない)。
manifest.json・lock.json・mod.json・index.jsonと、Component 名と同じディレクトリはassets/に置けない(ビルドが失敗する)。ubichill verifyはアセットも再ハッシュして manifest と突き合わせる。- 古い CLI でビルドした mod(
assetIntegrityが無い)はUbi.assetで読めない。Ubi.modBase配下をUbi.fetchで読むことはできるが、その場合は integrity の照合が無い。
Host が許可しても、ブラウザの制限は解除されない。
| 規則 | 内容 |
|---|---|
自分のアセット(modBase 配下)・自分の名前空間(/mods/<id>/) |
承認不要 |
本体オリジンのそれ以外(/api など) |
禁止 |
| 外部ドメイン | https のみ。ホスト名ごとにユーザーが承認(「今回だけ / 次回以降も / 拒否」) |
| リダイレクト | 追わない(FETCH_REDIRECT_BLOCKED)。ブラウザは行き先を送信前に見せないため、追うと未承認の相手へ送信本文や cookie が届き得る。最終的な URL を直接指定する |
| cookie | 外部オリジンには送らない(credentials: 'omit')。同梱アセットの取得も cookie を付けない |
| 承認待ちの取り消し | signal の取り消し・制限時間で、承認画面を待つ依頼から外れる。同じドメインを待つ依頼がすべて外れたら画面を取り下げる(拒否としては記憶しない) |
| CORS | 相手が Access-Control-Allow-Origin を返さなければ失敗する(FETCH_NETWORK_ERROR) |
| 自分の外部サービスの保護 | Ubi.identity.token(audience) で、ログイン中の利用者からの依頼であることを示す短命の署名を付けられる(→ SERVICE_TOKEN.md) |
| 応答ヘッダー | CORS で公開されたもの(Access-Control-Expose-Headers)しか読めない |
| 禁止ヘッダー | Cookie・Origin・User-Agent などはブラウザが送らせない |
承認はホスト名の完全一致で記憶する。サブドメインが毎回変わる配信元(CDN のエッジなど)では、ホストごとに確認が出る。
実行ライフサイクル
Section titled “実行ライフサイクル”- 終了: 退室・エンティティ削除・mod の差し替えで Worker を
terminateする。WASM のメモリも一緒に消える。 Host 側で実行中の fetch・アセット読み込みは同時に取り消す(応答を捨てるだけでなく通信も止める)。 - 初期化と再利用: Worker の中では module スコープに置いたもの(
Ubi.asset.wasmのキャッシュ、 インスタンス化した WASM)がそのまま使い回せる。 - 重い処理の分離: 重い計算は Component の Worker をその間止める(UI・入力の処理も止まる)。初期化が
数秒かかるランタイム(Pyodide 等)や、複数の Component から共有したい処理は、
singleton: trueの Component に置く。singleton は参加中のクライアントごとに 1 つだけ起動し、mod がワールドで使われている 間だけ生きる。他の Component からはUbi.event.emit(..., { scope: 'world', targetType })で依頼する。 専用の Worker 種別は作っていない(Component の仕組みで足りるため)。
ブラウザだけでできる範囲 / それ以外が必要な範囲
Section titled “ブラウザだけでできる範囲 / それ以外が必要な範囲”| ブラウザだけで可能 | 拡張機能・ネイティブなどが必要 | |
|---|---|---|
| WASM の実行 | ○(SIMD・例外処理・JSPI はブラウザ次第) | — |
| 共有メモリのスレッド | ×(Host が COOP/COEP を出していない) | Host 側の対応(他オリジンの埋め込みとの両立が要る) |
| CORS を許可している API・CDN からの取得 | ○ | — |
| CORS を許可していないサイトからの取得 | × | 拡張機能(host_permissions)・ネイティブ・中継サーバー |
任意の cookie・User-Agent・Origin の送信 |
× | 同上 |
| 他オリジンのページの JS をその場で実行 | ×(JS のコード生成は禁止) | WASM 上のインタプリタ(QuickJS 等)なら可能 |
| 同期的な通信(同期 XHR) | ×(Worker 内の通信手段は非同期の Ubi.fetch だけ) |
JSPI で非同期を待つ |
例: yt-dlp を各クライアントで動かす場合(videoplayer 側の責務)
Section titled “例: yt-dlp を各クライアントで動かす場合(videoplayer 側の責務)”2026-10-07 にブラウザ(Chrome、オリジン http://localhost)から実測した結果:
| 取得先 | 結果 |
|---|---|
www.youtube.com/watch |
CORS で失敗 |
www.youtube.com/youtubei/v1/player |
CORS で失敗 |
www.youtube.com/oembed |
成功(タイトル・サムネイル) |
i.ytimg.com(サムネイル) |
成功 |
cdn.jsdelivr.net/pyodide/… |
成功 |
- この基盤で、Pyodide(WASM・標準ライブラリ)の読み込み、Python の実行、JS challenge を QuickJS 等の WASM で
解くこと、結果の URL を
Ubi.media.loadに渡すことは mod 側で実装できる。 - しかし yt-dlp が最初に叩く YouTube のページと player API はブラウザから CORS で読めない。ブラウザだけで YouTube の URL を解決することはできない。拡張機能・ネイティブの補助か、中継サーバー(=サーバーに URL が 渡る)が必要になる。これは Ubichill 側で解除できる制限ではない。
- タイトル・サムネイルの取得(oEmbed・ytimg)は今でもサーバー無しにできる。
大きなランタイム(Pyodide など)を載せるときの注意
Section titled “大きなランタイム(Pyodide など)を載せるときの注意”- JS のグルーコードは mod のバンドルに含める(
importScripts・import()での読み込みは封鎖・CSP で失敗する)。 - WASM・標準ライブラリの zip・wheel は
Ubi.asset(同梱)かUbi.fetchのresponseType: 'arrayBuffer'(CDN)で取得し、ランタイムの読み込み関数に渡す。ランタイムが内部でfetchを呼ぶ場合は、Ubi.fetchを 使う関数をバンドル内でfetchとして渡す(グローバルのfetchは無い)。 - 実行時に
eval/new Functionを呼ぶ箇所(Emscripten の動的リンクで読み込む拡張モジュールの EM_JS など)はEvalErrorで失敗する。使わない構成を選ぶ。 - 同期的な通信が必要な Python コード(
urllibなど)は、JSPI(wasm:jspi)で非同期のUbi.fetchを待つ形にする。
今後の候補(未実装)
Section titled “今後の候補(未実装)”- 本文のストリーミング(今は
maxBytesまで読んでから一度に渡す。Rangeヘッダーで分割取得はできる)。 - 同じ mod の Worker 間で、コンパイル済みの
WebAssembly.Moduleを共有する。 - 拡張機能・ネイティブの補助を、特定サービス専用ではない「通信手段」として Host に差し込む。
- 承認をサフィックス単位(
*.example.com)で記憶する。 - アセット・lock の署名(今は hash の固定のみ)。