アーキテクチャ
インスタンスサーバーとSNSの境界
Section titled “インスタンスサーバーとSNSの境界”services/instance のGoサーバーが入退室・カーソル・エンティティ・ロック・メディア同期を所有する。
TypeScript backendはSNSのDB、認証、フレンド、ワールド解決と参加チケット発行を担当する。
ブラウザはSNSから接続先と短期チケットを取得し、Goへ直接WebSocket接続する。
ゲストモードではGoと定義ファイルだけで起動し、SNSのセッションを必要としない。
HTTPは protocol/instance/openapi.json からGo/TypeScriptの型を生成する。
リアルタイム通信は protocol/instance/asyncapi.json と protocol/instance/README.md を参照。
状態は現在メモリのみで、再起動時は初期状態へ戻る。確定更新の応答はDB保存を意味しない。
システム全体像
Section titled “システム全体像”Browser (Main Thread) └─ React Host └─ ModHostManager packages/sandbox │ postMessage ▼ Sandbox Worker (Guest) packages/sandbox └─ UbiSDK (Ubi) packages/sdk ├─ Ubi.state entity.data と双方向同期する宣言的ストア ├─ Ubi.entity callable: self / of(id) + query / get / spawn ├─ Ubi.event sendToHost / broadcast / emit (クロス Worker) ├─ Ubi.ui / .media / .canvas / .player └─ Ubi.fetch / .registerSystem / .logロジックはすべて Sandbox Worker 内で処理し、React はレンダリングのみを担う。 WebSocket によるGoインスタンスサーバーとのワールド同期はメインスレッドが担い、Worker は知らない。
SDK 名前空間(mod開発者向け)
Section titled “SDK 名前空間(mod開発者向け)”| 名前空間 | 特性 | 用途 |
|---|---|---|
Ubi.state.* |
宣言的・自動同期 | state.local.x = v で entity.data と双方向同期 (sync の options で挙動切替) |
Ubi.entity() / Ubi.entity(id) |
callable | self/other へ .update / .destroy / .spawn のハンドルを返す |
Ubi.entity.query / get / spawn |
静的メソッド | type 検索 / id 取得 / 自由 spawn |
Ubi.event.* |
トリガー | sendToHost / broadcast (他ユーザー揮発) / emit (同 tab クロス Worker) |
Ubi.ui.* |
Fire & Forget | UI 描画 (render) / トースト |
Ubi.media.* |
Fire & Forget | video / HLS 再生制御 |
Ubi.canvas.* |
Fire & Forget | canvas 描画 |
Ubi.player.* |
プレイヤー | others() / all() / syncCursor() |
Ubi.fetch / .registerSystem / .log |
shortcut | HTTP / System 登録 / ログ |
ECS 用語と命名規約の詳細は design/ecs-migration.md を参照。
Entity == GameObject、Worker 1 つ = Component インスタンス 1 つ。
パッケージ責務
Section titled “パッケージ責務”| パッケージ | 責務 | 依存禁止 |
|---|---|---|
@ubichill/ecs |
純粋 ECS(Entity / Component / System / Query) | React・DOM・Worker・Network |
@ubichill/sandbox |
Worker ライフサイクル・Tick ループ・入力収集 | React |
@ubichill/react |
React Hooks 群(useModWorker / useEntity 等) | — |
@ubichill/sdk |
mod開発者向け公開 API(Ubi) | @ubichill/shared などの内部パッケージ |
@ubichill/shared |
型定義・プロトコル定義 | — |
mods/* |
各mod実装 | @ubichill/sdk のみ |
mod実行モデル(ECS)
Section titled “mod実行モデル(ECS)”Worker 内では Ubi(UbiSDK インスタンス)が自動注入される。
modは ECS System として実装し、毎フレーム entities / deltaTime / events を受け取る。
- Entity: 識別子を持つオブジェクト(ローカル)
- Component: Entity に付与するデータの断片(型付き)
- System: 毎フレーム全 Entity を走査して処理を行う関数
- Query: 特定 Component を持つ Entity を絞り込むキャッシュ付きフィルタ
ECS は @ubichill/ecs で完結し、ネットワーク・DOM を一切知らない。
Host ↔ Guest 通信プロトコル
Section titled “Host ↔ Guest 通信プロトコル”Guest → Host(ModGuestCommand)
Section titled “Guest → Host(ModGuestCommand)”| type | 分類 | 説明 |
|---|---|---|
CMD_READY |
Fire & Forget | Worker 初期化完了通知 |
SCENE_GET_ENTITY |
RPC | エンティティ取得 (Ubi.entity.get) |
SCENE_QUERY_ENTITIES |
RPC | type 検索 (Ubi.entity.query) |
SCENE_CREATE_ENTITY |
RPC | 新規 spawn (Ubi.entity().spawn / Ubi.entity.spawn) |
SCENE_UPDATE_ENTITY |
RPC | エンティティ更新 (Ubi.entity().update / Ubi.entity(id).update / state.sync flush) |
SCENE_DESTROY_ENTITY |
RPC | エンティティ削除 (Ubi.entity().destroy / Ubi.entity(id).destroy) |
NETWORK_SEND_TO_HOST |
Fire & Forget | 自 Host にメッセージ (Ubi.event.sendToHost) |
NETWORK_BROADCAST |
Fire & Forget | 全ユーザーの同 entity Worker に揮発性配信 (Ubi.event.broadcast / state.sync の ephemeral) |
EVENT_EMIT |
Fire & Forget | 同 tab 内の他 Worker (scope + targetType) にイベント (Ubi.event.emit) |
UI_RENDER |
Fire & Forget | VNode を Host に送って DOM 化 (Ubi.ui.render) |
UI_SHOW_TOAST |
Fire & Forget | トースト (Ubi.ui.showToast) |
CANVAS_FRAME / CANVAS_COMMIT_STROKE / CANVAS_REPLACE_STROKES |
Fire & Forget | canvas 描画 (Ubi.canvas.*) |
MEDIA_* |
Fire & Forget | video 制御 (Ubi.media.*) |
NETWORK_FETCH |
RPC | HTTP リクエスト (Ubi.fetch) |
CMD_LOG |
Fire & Forget | ログ (Ubi.log) |
RPC は id フィールドを持ち、Host が EVT_RPC_RESPONSE で応答する(タイムアウト 10s)。
Host → Guest(ModHostEvent)
Section titled “Host → Guest(ModHostEvent)”| type | 説明 |
|---|---|
EVT_LIFECYCLE_INIT |
初期化(code / worldId / myUserId) |
EVT_LIFECYCLE_TICK |
フレーム更新(deltaTime ms) |
EVT_INPUT |
入力イベントバッチ(MOUSE_MOVE / MOUSE_DOWN / KEY_DOWN 等) |
EVT_PLAYER_JOINED |
ユーザー入室 |
EVT_PLAYER_LEFT |
ユーザー退室 |
EVT_PLAYER_CURSOR_MOVED |
他ユーザーのカーソル移動 |
EVT_SCENE_ENTITY_UPDATED |
購読中エンティティ更新 |
EVT_NETWORK_BROADCAST |
他ユーザーからのブロードキャスト受信 |
EVT_RPC_RESPONSE |
RPC 応答 |
EVT_CUSTOM |
mod独自イベント(Host → Worker) |
初期化シーケンスの注意点
Section titled “初期化シーケンスの注意点”EVT_LIFECYCLE_INIT は キューを通さず Worker へ直接 postMessage する。
キュー経由にすると CMD_READY が返ってこないまま Tick がキューに積まれ deadlock になる。
CMD_READY 受信後にキューをフラッシュする。CMD_READY が届かない = 初期化失敗。
Capability ゲート(権限)
Section titled “Capability ゲート(権限)”Worker→Host の各コマンドは、Host 側の単一ゲート (createCapabilityGate) を通る。capability は
使用 API からビルド時に自動生成され(宣言不要・情報表示用)、実際の許可は危険度ティア別に
実行時ユーザー承認する(on-demand)。safe は常に許可、sensitive は既定許可、dangerous は承認必須。
fetch は接続先ドメインごとに承認する。詳細は API.md の「権限 (Capability)」節。
リアルタイム同期(UEP)
Section titled “リアルタイム同期(UEP)”同期には 2 つのチャネルがある。
| チャネル | 用途 | 保存 | 頻度 |
|---|---|---|---|
| Reliable State | 位置確定・色変更・ロック | インスタンスのメモリ | 低(1-10Hz / アクション終了時) |
| Volatile Stream | ドラッグ中・カーソル・描画軌跡 | なし(ブロードキャストのみ) | 高(30-60Hz) |
stream は 30ms(33Hz)を目安にスロットリングする。
楽観的ロック(Lock → Mutate → Release)
Section titled “楽観的ロック(Lock → Mutate → Release)”- ユーザーがオブジェクトに触れる → フロントで即座に
lockedBy: meと仮定(レイテンシ隠蔽) - 操作中は
streamチャネルで座標・軌跡を配信 - 操作終了時に確定データを
PATCH・lockedBy: nullでロック解除
切断時はサーバーが全 lockedBy を強制 null にリセット(デッドロック防止)。
Sandbox セキュリティ
Section titled “Sandbox セキュリティ”Worker の中で mod コードを隔離する。詳細と WASM の扱いは MOD_RUNTIME.md。
- グローバルの封鎖 —
fetch/XMLHttpRequest/WebSocket/WebTransport/importScripts/Worker/BroadcastChannel/indexedDB/caches/navigatorなどを、selfだけでなくプロトタイプチェーンの全段から 取り除く(ブラウザでは多くがWorkerGlobalScope.prototype側にある)。取り除けない入口が 1 つでもあれば mod を実行しない - コード生成の禁止 —
eval/Function/ 関数のconstructor/ 文字列のタイマーはEvalError。SafeFunction(封鎖前に退避したFunction)だけが mod コードを評価する - Worker 専用の CSP — 本番は
default-src 'none'; script-src 'unsafe-eval' 'wasm-unsafe-eval'を Worker スクリプトの 応答に付ける(BFF・Vite preview)。スクリプトの URL を 1 つも許可しないので、1 を抜けても通信できず、同一オリジンを含めて 他の JS を読み込めない(lock で固定したコードだけが動く)。dev は Worker の依存パッケージのパスだけを許可する - プロトタイプ凍結 —
Object.prototype等をフリーズしてプロトタイプチェーン汚染をブロック - postMessage の一本化 —
self.postMessageは警告のみ。Host へ届くのは SDK の経路だけで、Host 側の capability ゲートを必ず通る
WASM は禁止しない。WASM は imports で渡した関数しか呼べないため、JS 層の権限を越えない。
動的なコードが必要な mod は、QuickJS などのインタプリタを WASM として同梱して動かす。
既知の限界: 3 の CSP は BFF と Vite が付ける。それ以外の方法でフロントを配信すると 1・2 だけになり、
import() でのスクリプト読み込みは JS 側では止められない。
パフォーマンス設計
Section titled “パフォーマンス設計”詳細は CLAUDE.md のパフォーマンス設計セクションを参照。
| 最適化 | 場所 | 効果 |
|---|---|---|
| InputCollector MOUSE_MOVE デデュプ | packages/sandbox |
フレームあたり mousemove を 1 件に集約(O(1)) |
| rAF バインドレス arrow field | packages/sandbox |
毎フレームの bind() アロケーション排除 |
| ECS エンティティ配列 dirty flag キャッシュ | packages/ecs |
エンティティ変化なし時の tick を O(1) に |
| useCursorPosition DOM 直接書き込み | packages/react |
カーソル 60fps 更新で React re-render ゼロ |
| useEntity shallow equality | packages/react |
JSON.stringify 比較を O(k) に置き換え |
UI レンダリングの内部フロー (図解)
Section titled “UI レンダリングの内部フロー (図解)”大原則: Worker 内 JS はメインスレッドに触れない
Section titled “大原則: Worker 内 JS はメインスレッドに触れない”modの UI ロジックは Web Worker の中で完結する。
Worker は DOM に直接アクセスできないため、Host への指示は postMessage 経由のシリアライズ可能なデータ(VNode)のみ。
flowchart TB
subgraph Worker["🔒 Sandbox Worker (隔離)"]
SYS["Mod Systems (ECS)"]
JSX["jsx-runtime\n関数 → '__h0' 変換"]
SDK["UbiSDK\npostMessage 送信"]
end
subgraph Host["Host (React / メインスレッド)"]
PHM["ModHostManager\npostMessage 受信"]
UPU["useModUI\nvnodes state 管理"]
PUM["ModUIMount"]
VNR["VNodeRenderer\ncreateElement / sanitize"]
end
SYS -->|"Ubi.ui.render(() => JSX)"| JSX
JSX -->|"VNode\n{onUbiClick: '__h0'}"| SDK
SDK -->|"postMessage\nUI_RENDER"| PHM
PHM -->|"onRender(targetId, vnode)"| UPU
UPU -->|"vnodes Map"| PUM
PUM --> VNR
VNR -->|"createElement()"| DOM["実 DOM"]
DOM -->|"click"| PUM
PUM -->|"sendEvent\nEVT_UI_ACTION {index:0}"| PHM
PHM -->|"postMessage"| SDK
SDK -->|"_callHandler(0)"| SYS
TSX → VNode → postMessage → DOM の変換シーケンス
Section titled “TSX → VNode → postMessage → DOM の変換シーケンス”sequenceDiagram
participant W as Worker (Mod)
participant H as ModHostManager
participant R as useModUI
participant D as VNodeRenderer / DOM
W->>W: Ubi.ui.render(factory) — JSX で button + onUbiClick を返す
Note over W: jsx-runtime が関数をレジストリに登録<br/>VNode は onUbiClick:"__h0" 形式に変換(プリミティブのみ)
W->>H: postMessage type:UI_RENDER, vnode
H->>R: onRender("pen-palette", vnode)
R->>D: renderVNode(vnode, container)
Note over D: createElement — innerHTML 禁止<br/>href/src → sanitizeUrl()<br/>style → setProperty() で cssText 書き換え防止<br/>許可タグ外は無視
D->>H: click イベント発火
H->>W: postMessage type:EVT_UI_ACTION, handlerIndex:0
W->>W: _callHandler("pen-palette", 0, detail)
Note over W: クロージャが実行される<br/>penState.color = c
セキュリティの多層防御
Section titled “セキュリティの多層防御”flowchart LR
code["modコード\n(文字列)"]
code -->|"封鎖に失敗したら実行しない"| sandbox
subgraph sandbox["Sandbox Worker 実行環境"]
freeze["グローバル封鎖(プロトタイプ全段)\nfetch / WebSocket / caches / importScripts\neval / Function は EvalError\n+ Worker 専用 CSP"]
cap["capability ゲート\n危険度ティア別に on-demand 承認\n未承認コマンドは拒否"]
serial["VNode シリアライズ\n関数は '__h0' に変換\npostMessage に関数は乗らない"]
vnode["VNodeRenderer サニタイズ\nタグ許可リスト / URL 検証\ninnerHTML 禁止"]
end
freeze & cap & serial & vnode --> safe["安全な実行"]