コンテンツにスキップ

アーキテクチャ

インスタンスサーバーと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保存を意味しない。

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 は知らない。

名前空間 特性 用途
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 つ。


パッケージ 責務 依存禁止
@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 のみ

Worker 内では Ubi(UbiSDK インスタンス)が自動注入される。 modは ECS System として実装し、毎フレーム entities / deltaTime / events を受け取る。

  • Entity: 識別子を持つオブジェクト(ローカル)
  • Component: Entity に付与するデータの断片(型付き)
  • System: 毎フレーム全 Entity を走査して処理を行う関数
  • Query: 特定 Component を持つ Entity を絞り込むキャッシュ付きフィルタ

ECS は @ubichill/ecs で完結し、ネットワーク・DOM を一切知らない。


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)。

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)

EVT_LIFECYCLE_INIT は キューを通さず Worker へ直接 postMessage する。 キュー経由にすると CMD_READY が返ってこないまま Tick がキューに積まれ deadlock になる。

CMD_READY 受信後にキューをフラッシュする。CMD_READY が届かない = 初期化失敗。

Worker→Host の各コマンドは、Host 側の単一ゲート (createCapabilityGate) を通る。capability は 使用 API からビルド時に自動生成され(宣言不要・情報表示用)、実際の許可は危険度ティア別に 実行時ユーザー承認する(on-demand)。safe は常に許可、sensitive は既定許可、dangerous は承認必須。 fetch は接続先ドメインごとに承認する。詳細は API.md の「権限 (Capability)」節。


同期には 2 つのチャネルがある。

チャネル 用途 保存 頻度
Reliable State 位置確定・色変更・ロック インスタンスのメモリ 低(1-10Hz / アクション終了時)
Volatile Stream ドラッグ中・カーソル・描画軌跡 なし(ブロードキャストのみ) 高(30-60Hz)

stream は 30ms(33Hz)を目安にスロットリングする。

楽観的ロック(Lock → Mutate → Release)

Section titled “楽観的ロック(Lock → Mutate → Release)”
  1. ユーザーがオブジェクトに触れる → フロントで即座に lockedBy: me と仮定(レイテンシ隠蔽)
  2. 操作中は stream チャネルで座標・軌跡を配信
  3. 操作終了時に確定データを PATCH・lockedBy: null でロック解除

切断時はサーバーが全 lockedBy を強制 null にリセット(デッドロック防止)。


Worker の中で mod コードを隔離する。詳細と WASM の扱いは MOD_RUNTIME.md。

  1. グローバルの封鎖 — fetch / XMLHttpRequest / WebSocket / WebTransport / importScripts / Worker / BroadcastChannel / indexedDB / caches / navigator などを、self だけでなくプロトタイプチェーンの全段から 取り除く(ブラウザでは多くが WorkerGlobalScope.prototype 側にある)。取り除けない入口が 1 つでもあれば mod を実行しない
  2. コード生成の禁止 — eval / Function / 関数の constructor / 文字列のタイマーは EvalError。SafeFunction (封鎖前に退避した Function)だけが mod コードを評価する
  3. Worker 専用の CSP — 本番は default-src 'none'; script-src 'unsafe-eval' 'wasm-unsafe-eval' を Worker スクリプトの 応答に付ける(BFF・Vite preview)。スクリプトの URL を 1 つも許可しないので、1 を抜けても通信できず、同一オリジンを含めて 他の JS を読み込めない(lock で固定したコードだけが動く)。dev は Worker の依存パッケージのパスだけを許可する
  4. プロトタイプ凍結 — Object.prototype 等をフリーズしてプロトタイプチェーン汚染をブロック
  5. postMessage の一本化 — self.postMessage は警告のみ。Host へ届くのは SDK の経路だけで、Host 側の capability ゲートを必ず通る

WASM は禁止しない。WASM は imports で渡した関数しか呼べないため、JS 層の権限を越えない。 動的なコードが必要な mod は、QuickJS などのインタプリタを WASM として同梱して動かす。

既知の限界: 3 の CSP は BFF と Vite が付ける。それ以外の方法でフロントを配信すると 1・2 だけになり、 import() でのスクリプト読み込みは JS 側では止められない。


詳細は 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
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["安全な実行"]