ランタイム実装ガイド
ランタイム実装者向けに、コンパイルパイプラインと実行モデルを定義する。
10.1 コンパイルパイプライン
[CRDT graph store]
↓ project (selector)
[strand source (text view)]
↓ parse
[AST]
↓ name resolution
[resolved AST] ←─── error: undef-ref, dangling
↓ type check
[typed AST] ←─── error: type-mismatch, refinement
↓ effect analysis
[effect-annotated AST] ←── error: cap-missing, direct-call
↓ purity check
[verified AST] ←── error: reducer-side-effect, tile-mutation
↓ lower
[IR (Strand Intermediate Representation)]
↓ codegen
[runtime artifacts]:
• signal graph (JS or WASM)
• effect dispatcher table
• episode logger
• dev-tool trace UI各フェーズは独立した検査を行う。エラーは ./ai-edit.md の構造化エラーで返す。
10.2 IR
中間表現は Typed Dataflow Graph。ノードは次のいずれか:
| ノード種 | 役割 |
|---|---|
slot-read | slot からの読み取り |
slot-write | slot への書き込み(reducer のみ) |
field-access, index | record/collection 要素アクセス |
op, call | 演算・関数呼び出し(fn 定義済み関数も含む) |
fn-body | fn レイヤの本体(純粋計算、引数のみ依存) |
match | union 分岐 |
if, when, for | 制御 |
emit | effect 放出 |
event-source | event の入口 |
dom-node | DOM 出力ノード |
dom-bind | DOM ノードへの slot 紐付け |
エッジは依存関係(dataflow)。
10.2.1 IR シリアライズ形式
JSON でデバッグ可能、本番は CBOR(バイナリ):
{
"version": "0.1",
"slots": [
{"name": "todos", "type": "...", "init": "...", "hash": "..."},
{"name": "draft", "type": "Text", "init": {"text": ""}, "hash": "..."}
],
"effects": [
{"name": "persist", "cap": "storage.write", "in": "...", "out": "Unit", "policy": "debounce:300"}
],
"reducers": [
{
"name": "addTodo",
"on": {"kind": "ui.submit", "selector": {"tile": "NewTodoForm"}},
"do": [
{"op": "let", "name": "id", "value": {"op": "call", "fn": "TodoId.fresh"}},
{"op": "slot-write", "lhs": {"slot": "todos", "key": {"var": "id"}}, "rhs": "..."},
{"op": "slot-write", "lhs": {"slot": "draft"}, "rhs": {"text": ""}},
{"op": "emit", "name": "persist", "args": [{"slot-read": "todos"}]}
]
}
],
"tiles": [
{
"name": "App",
"body": {"kind": "page", "children": [...]},
"deps": ["slot:todos", "slot:draft", "tile:TodoList", "fn:matchFilter"]
}
],
"fns": [
{
"name": "matchFilter",
"params": [{"name": "t", "type": "Todo"}, {"name": "f", "type": "Filter"}],
"ret": "Bool",
"body": {"op": "match", ...},
"hash": "..."
}
],
"app": {
"name": "TodoApp",
"caps": ["storage.read", "storage.write"],
"routes": {"/": "App", "/404": "NotFound"},
"init": [{"emit": "loadTodos", "args": []}],
"theme": "DefaultTheme"
}
}10.3 Signal Graph
ランタイムは IR から 静的 signal graph を生成する。Solid 風の fine-grained reactivity だが、Strand ではコンパイル時にグラフ構造が完全に決まる(実行時にシグナル追跡しない)。
10.3.1 ノード種
| ノード | 入力 | 出力 |
|---|---|---|
SlotNode | – | slot 値 |
ComputeNode | 上流ノードの値 | 派生値 |
BindNode | 上流ノードの値 | DOM 操作 |
EventNode | DOM event | reducer 呼び出し |
10.3.2 更新アルゴリズム
on reducer execution:
collect modified slots into Set<SlotId>
for each modified slot:
for each downstream ComputeNode/BindNode (precomputed):
mark dirty
process dirty queue in topological order:
recompute ComputeNode
apply BindNode → DOM mutation依存関係はコンパイル時に静的に解析されているので、実行時の追跡コストは 0。
10.3.3 batching
1 つの reducer 実行内のすべての slot 変更は 1 つのバッチとして扱う。for ループ内の連続変更も同一バッチ。バッチ確定後に signal graph を 1 度だけ更新する。
10.3.4 DOM レンダリングの不変条件
- null/undefined 子ノードは skip される。
when(false, X)のような偽分岐はnullを子に渡すが、renderTileはそれを無視して兄弟だけを描画する column/row/card/box/panel/stack/region/scroll/fieldsetはすべて<div>ベースのコンテナ。stackはcolumn相当(vertical stack)gridはdisplay: grid+colsprop でgrid-template-columns: repeat(N, 1fr)(数値)または直接 CSS 値(文字列)dividerは<hr>単独要素(children なし)- timer reducer は
setIntervalで発火、app のdispose時にclearIntervalで停止
10.3.5 input/textarea/select の bind path
bind=draft.title のように nested lvalue path に bind できる。ランタイムは:
- 表示:
_live[root][...path]を辿って初期値を読む - 変更: 入力イベントで
_setPathを使い root slot を immutable に更新 - focus 復元:
data-strand-bind属性に full path 文字列 ("draft.title") を入れて識別
10.3.6 動的 theme switching
app theme = themeName のように slot 名で theme を指定できる。ランタイムは:
app.themeNameがapp.themesに存在しなければ、_live[app.themeName]を読んで theme 名を解決- 各
render()の冒頭でapplyThemeDefaultsを再実行 → slot 値の変更が body スタイルに反映
slot themeName : Text = "Light"
theme Light = { colors: {bg: "#fff", fg: "#222"}, ... }
theme Dark = { colors: {bg: "#222", fg: "#eee"}, ... }
reducer toggle on=ui.click(ThemeBtn) do= themeName := if themeName == "Light" then "Dark" else "Light"
app App ... theme = themeName ; ← slot 名を渡す10.3.7 polymorphic collection methods
.filter / .map / .get-or などはランタイムで型 dispatch:
.filter(pred): Array ならArray.prototype.filter、Object ならmapFilter.map(fn): Array なら要素 map、Option/Result なら Some/Ok の中身に map (mapOver).flat-map(fn): Option/Result の Some/Ok を f に渡し、None/Err は素通り (flatMapOption).get-or(default)(Option) /.get-or(key, default)(Map): 引数数で判別m.entriesは[[k, v], ...]で返り、後続の list ops の lambda は$1=k, $2=vに自動 destructure される
10.3.8 select の値マッチング
select(value=v, options=[...]) は option の選択状態を 構造的キーで判定する:
- variant は
_tag+ payload を再帰的にシリアライズしてキー化する。Some(Backlog)とSome(InProgress)は別キーになる(フラットな_tag比較だと両者が"Some"で衝突するため、payload まで含めることが必須) Option(Status)のような「variant でラップした variant」を option 値にできる
10.3.9 focus 復元
再レンダリング後も入力中の input/textarea の focus とカーソル位置を維持する:
bind=がある要素:data-strand-bind属性(nested path は full path 文字列)で再特定id=がある要素: id で再特定- どちらもない(
value=のみの検索ボックス等): DOM child-index path で位置ベースに再特定
10.4 Effect Dispatcher
reducer から emit された effect を実行する責務。
10.4.1 受付
reducer が完了すると、emit された effect 集合がディスパッチャに渡される:
[{name: "persist", args: {...}, key: <derived>, policy: "debounce:300"}, ...]10.4.2 capability check
各 effect の cap が app.caps に含まれるか検査。違反は実行せず app.error に通知。
10.4.3 policy 処理
| policy | 実装 |
|---|---|
| 並列 (default) | 即時 dispatch |
latest | 同名の走行中 effect を cancel、新規を開始 |
latest-per-key(k) | (effect-name, key) 単位で同上 |
queue | FIFO で逐次実行 |
debounce(d) | 同名の呼び出しを d ms 待って最後だけ実行 |
throttle(d) | 同名で d ms 以内の追加呼び出しを破棄 |
once | 同 in の呼び出しを破棄 |
10.4.4 retry
retry=... 指定がある場合、Err 結果かつ 5xx/network エラーで再試行。指数バックオフは jitter ±20% を加える。
10.4.5 結果の配送
effect 完了時、結果を <effect-name>.ok($value, $key) / <effect-name>.err($error, $key) イベントとしてランタイムに通知。マッチする reducer が実行される。
10.4.6 標準 capability の実装
| capability | 実装 |
|---|---|
http.* | fetch() |
storage.* | window.localStorage |
session.* | window.sessionStorage |
indexed.* | IndexedDB API |
nav.* | History API |
clipboard.* | Clipboard API |
notification.show | 組み込み tile (toast/confirm/modal) |
analytics.* | hook (アプリ起動時に app.analytics で実装注入) |
log.* | console.* + 任意 hook |
crypto.* | Web Crypto API |
media.* | MediaDevices API |
geo.* | Geolocation API |
socket.* | WebSocket |
10.5 Episode Loop
1 つのトリガから派生する因果列を 1 つの episode として記録する。
10.5.1 episode の構造
{
"id": "ep_01JC...",
"trigger": {"kind": "ui.click", "target": "AddBtn", "payload": {...}, "ts": ...},
"steps": [
{"kind": "reducer", "name": "addTodo", "slot-diffs": [...], "emits": ["persist"], "ts": ...},
{"kind": "effect-start", "name": "persist", "args": {...}, "ts": ...},
{"kind": "effect-end", "name": "persist", "result": "ok", "value": "()", "ts": ...},
{"kind": "signal-update", "dirty-slots": ["todos"], "binds-updated": ["TodoList.row.0", ...], "ts": ...}
],
"status": "completed" | "panic" | "cancelled" | "ongoing"
}10.5.2 episode store
- メモリに直近 N 件(デフォルト 100)
- localStorage に直近 M 件(デフォルト 20、サイズ上限 5MB)
- 開発時は
--episode-log /path/to/log.jsonlでファイル書き出し
10.5.3 replay
strand replay <episode-id> # signal graph を初期状態から再生
strand replay --from-log <file> # ファイルから読み込んで再生
strand replay --mock 'loadUser: from-log' # effect mock 指定
strand replay --until-step 5 # 途中まで10.6 SSR / Edge / Client 分割
10.6.1 SSR
- HTML 生成は server-side で初期 route の tile を 1 回描画
- slot 初期値は
app.initで emit した effect の結果を含めても良い(hydration 時に再実行しない) - レスポンス bundle 構成:
- HTML(初期 tile 描画結果)
- JSON(初期 slot snapshot)
- JS(signal graph + effect dispatcher)
10.6.2 Hydration
- クライアント JS が起動
- 初期 slot snapshot を読み込んで signal graph に反映
- event handler を DOM に attach
app.startreducer を発火(注意:SSR 中は実行しない、hydration 後のみ)
10.6.3 Edge
Cloudflare Workers / Vercel Edge 等での SSR:
- effect dispatcher の一部(
http.*,storage.kv.*)を edge 側で実行 - 残りはクライアントに deferred
- bundle サイズ予算:runtime 30KB + app code(ターゲット)
10.7 開発サーバ
strand dev # 開発サーバ起動
strand dev --port 5173
strand dev --episode-log ./eps.log
strand dev --strict-a11y機能:
- ホットリロード(コード変更時、slot は維持)
- error overlay(panic 時に詳細表示)
- episode timeline panel(最近の episode を視覚化)
- inspector(slot 値、tile ツリー、依存グラフ)
10.8 ビルド
strand build # 本番ビルド
strand build --target=spa # SPA only
strand build --target=ssr # Node.js SSR
strand build --target=edge # Edge runtime
strand build --target=static # 静的サイト
strand build --analyze # bundle 分析出力構成:
dist/
├── index.html
├── assets/
│ ├── app-<hash>.js
│ ├── app-<hash>.css ← reset + theme トークン展開のみ
│ └── icons-<hash>.svg
├── server/ ← SSR/Edge 時のみ
│ └── entry.js
└── manifest.json10.9 ランタイム API(埋め込み用)
ホストアプリから Strand アプリを埋め込む場合:
import { mount } from "strand/runtime"
const app = mount({
target: document.getElementById("app"),
bundle: "/assets/app.js",
initialSlots: { /* ... */ },
effectHandlers: {
"analytics.send": (event, props) => myAnalytics.track(event, props)
}
})
app.dispatch({ kind: "ui.click", target: "AddBtn", payload: {} })
app.slots.todos // read-only
app.episodes // 最近の episode
app.unmount()10.10 標準ライブラリの実装責務
./stdlib.md で列挙したビルトインは、ランタイム実装が次の挙動を保証する:
| 機能 | 保証 |
|---|---|
Map, Set, List | 純粋(in-place mutation なし) |
Option, Result | パターンマッチ網羅検査 |
Time.now, math.random | reducer 内のみ呼び出し可、episode log に記録 |
*.fresh() | UUIDv7 を生成 |
panic() | episode を panic 状態にして slot をロールバック |
10.11 パフォーマンス予算
| 項目 | 予算 |
|---|---|
| ランタイム本体 | ~30KB gzip |
| 1 reducer 実行時間 | < 1ms (typical) |
| signal graph 更新 | < 16ms (60fps) |
| effect dispatch overhead | < 0.1ms |
| episode log 書き込み | < 0.5ms (memory) |
これらを満たすため、ランタイムは Rust → WASM(オプション)または手書き JS(デフォルト)。
10.12 設計上の判断記録
| 判断 | 理由 |
|---|---|
| signal graph は静的 | 実行時依存追跡を排除、性能と予測可能性 |
| バッチ更新 | 連続変更で 60fps を超えないよう |
| effect は dispatcher 経由 | capability ガードとログを構造で担保 |
| episode = trigger 単位 | デバッグ・テスト・audit を一つの単位に統合 |
| SSR と CSR は同じ IR を食う | ターゲット差は dispatcher の実装差のみ |
| ランタイム 30KB 目標 | モバイル / Edge での実用性 |
10.13 次
- 完全例 → examples/