アーキテクチャ
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.ex → blog/layout.ex → blog/[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/1 が
tools/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 /agent に Authorization: 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 の再実行が必要です。
リファレンスの認証 セクションを参照してください。