アーキテクチャ

Dialup の「1 タブ = 1 プロセス」モデルと、state の設計を解説します。

全体構造

Dialup は Elixir の GenServer(プロセス)を中心に据えた設計です。 ブラウザのタブが一つ開くと、サーバー上に対応する UserSessionProcess が起動します。 以後の操作はすべてこのプロセスを通じて行われます。

【ブラウザ】
  dialup.js
  ├── WebSocket 接続管理(Cookie 経由のセッション ID)
  ├── ws-event / ws-change / ws-submit / ws-href の監視
  ├── 切断検知と指数バックオフ自動再接続
  ├── ブラウザ履歴管理(history.pushState / popstate)
  └── HTML を受け取り idiomorph で DOM に適用

            ↕ WebSocket

【Elixir サーバー】
  Dialup.SessionRegistry(Registry)
  Dialup.SessionSupervisor(DynamicSupervisor)
  └── UserSessionProcess(1 タブ = 1 プロセス)
       ├── session  : layout.mount が設定。ナビゲーション間で持続
       ├── assigns  : page.mount が設定。ページ遷移でリセット
       ├── params   : URL パラメータ(自動設定)
       └── layout + page を合成して HTML を生成

ファイルベースルーティング

Dialup はファイルの配置を読み取り、コンパイル時にルートテーブルを生成します。 router.ex への手書き登録は不要です。 レイアウトとエラーページもディレクトリ階層で継承されます。

app/
├── layout.ex               # 全ページ共通レイアウト
├── error.ex                # 全ページ共通エラーページ
├── page.ex                 # /
├── blog/
│   ├── layout.ex           # /blog/* 共通レイアウト(ネスト可)
│   ├── error.ex            # /blog/* エラーページ(より具体的)
│   └── [slug]/
│       └── page.ex         # /blog/:slug(動的ルーティング)

URL /blog/my-post にアクセスすると、 app/layout.exblog/layout.exblog/[slug]/page.ex の順に組み合わされます。 エラーページはリクエストパスに最も近い error.ex が選択されます。

ライフサイクル

初回接続:
  HTTP GET → Cookie にセッション ID 発行 → WebSocket 接続
  → layout.mount/1(session 確定)
  → page.mount/2(assigns 確定)
  → render → HTML 送信

ページ遷移(ws-href):
  → 新しい layout があればその mount/1 のみ追加(既存 layout は再実行しない)
  → page.mount/2(assigns リセット)→ render → HTML 送信
  → mount が {:redirect, path} ならレンダリング前にそのパスへ(認証ガード等)

イベント(ws-event / ws-submit / ws-change):
  → dialup_action のモードに応じて dispatch
     command → Context.dispatch/1 → remount
     set → レンダリング時 map を assigns にマージ
     navigate → ページ遷移
     action(legacy)→ handle_event/3
  → {:update, _} なら render

切断 → 再接続:
  プロセス生存中  : mount なしで現在の state で再描画
  タイムアウト済み: layout.mount + page.mount からやり直し

session と assigns の分離

Dialup の最も重要なコンセプトは、state を二層に分けることです。

フィールド 設定者 ライフサイクル 用途例
session layout.mount/1 プロセス全体で持続 ログインユーザー、テーマ設定
assigns page.mount/2 ページ遷移でリセット 記事一覧、カウンター
params 自動設定 ナビゲーションごとに更新 URL パラメータ・クエリ文字列

テンプレート内では session + assigns + params がマージされるため、 すべて @field_name でアクセスできます。

def mount(session) do
  # session: 接続全体で持続(ログインユーザーなど)
  user = Repo.get(User, get_user_id_from_cookie())
  {:ok, Map.put(session, :current_user, user)}
end
def mount(%{"id" => id}, assigns) do
  # assigns にはすでに session の内容(current_user 等)が入っている
  post = Posts.get!(id, assigns.current_user)
  {:ok, %{post: post}}
end

コロケーション CSS とスコーピング

page.ex / layout.ex と同じディレクトリに同名の .css を置くと、 コンパイル時にモジュール名由来の一意なクラス(d-xxxxxxx)でスコーピングされます。 layout.css はその配下のディレクトリ全体に適用され、page.css は 同じディレクトリの page.ex のみに適用されます。

<div class="d-layout">           <!-- app/layout.css のスコープ -->
  <header>...</header>
  <div class="d-docs-page">    <!-- docs/page.css のスコープ -->
    <h1>Docs</h1>
  </div>
</div>

@layout false — 全画面ページ

特定のページでレイアウト継承を無効にするには @layout false を指定します。 ログイン画面・エラー画面の全画面表示に使用します。

defmodule Dialup.App.Login.Page do
  use Dialup.Page

  @layout false  # 全画面表示(layout.ex を無効化)

  def render(assigns) do
    ~H"""
    <div class="login-screen">
      <form ws-submit="login">
        <input name="email" />
        <input name="password" type="password" />
        <button type="submit">ログイン</button>
      </form>
    </div>
    """
  end
end

差分適用:なぜクライアント側か

Phoenix LiveView はサーバー側で差分を計算して最小限の指示を送ります。 Dialup はシンプルさを優先し、サーバーは HTML 断片をそのまま送信します。 クライアント側の idiomorph(5KB)が DOM を効率的にモーフィングするため、フォームの入力値が保たれます。

HTTP Agent API(UI から自動生成)

Dialup のもう一つの中心軸は、人間向け UI 宣言からエージェント向け HTTP JSON-RPC API を 自動生成することです。<.dialup_action>declare_action/1tools/list のカタログになり、tools/call は宣言されたモードに応じて command(Commanded dispatch + remount)、set(assigns 更新)、 navigate、または legacy の handle_event/3 に直列化されます。 外部 chat agent は人間が貼った GET /agent?t=<tab_id> から POST /_dialup/agent-connect で pending token を取得し、 POST /agentAuthorization: Bearer で JSON-RPC を送ります。 接続直後、人間のブラウザには WebSocket で承認モーダルが表示されます。

【人間】 dialup.js ──WebSocket──► UserSessionProcess
        │  AI に渡す / 承認モーダル(WS push)
【AI】   POST /agent (Authorization: Bearer) ──► 同じ UserSessionProcess
                │
         declare_action / dialup_action
                │
           tools/list · tools/call
                │
    ┌───────────┼───────────┬──────────┬──────────────┐
    ▼           ▼           ▼          ▼              ▼
 command       set      navigate   action      issue_browser_url
 Context    assigns     path      handle_event   → browser handoff
 dispatch   merge                  /3           (finalize-join で完了)

エージェント先行と browser handoff

人間のタブがまだ無い状態から AI が先に操作を始める場合、 POST /_dialup/agent-session で headless セッションを起動し、 MCP で操作したあと issue_browser_url で参加用 URL を発行します。 人間側は URL を開いただけでは join 完了ではなく、WebSocket attach のあと POST /_dialup/finalize-join で cookie が設定され token が消費されます。

エージェント先行 → 人間参加(browser handoff):

  1. POST /_dialup/agent-session または Dialup.Session.start/2
     → headless UserSessionProcess + MCP token
  2. tools/call issue_browser_url → browserUrl(/path?_join=TOKEN)
  3. 人間が URL を開く(この時点では cookie 未設定)
  4. dialup.js → WebSocket /ws?tab_id=…&join_token=…(ライブ HTML + join_finalize_nonce)
  5. POST /_dialup/finalize-join → dialup_session cookie 設定 + token 消費(単一完了点)
  6. __reconnect で表示同期

ライブデモは /agent_demo 。 詳細は Hex ガイド Chat agent connection を参照してください。

認証と MCP 権限

auth_accounts を設定すると、未ログイン agent セッションは最小 capability に制限され、 ログイン後は agent_grant/1 に従って拡張されます。ログイン前の guest connect トークンは ログイン後に自動昇格しません — POST /_dialup/agent-connect の再実行が必要です。

リファレンスの認証 セクションを参照してください。