BIKKURI / INTERNALS 私用 ·

bikkuriの内部構造

状態変更がどのように画面の差分になり、同じ要素の木がブラウザとサーバーでどう描画されるかを実装からたどる。

全体像

view!構文をTautoの式へ展開
Tree<K, P>番号付きノードと Patch
HTML / DOM文書の生成、または既存DOMの更新
Signalからの更新も同じPatch列に加わる。要素の種類 K と属性 P は描画先が選ぶ。

bikkuri が反応性と描画先に依存しない木を持つ。bikkuri-html はHTML用の語彙、マクロ、HTMLライター、ブラウザのDOMホストを提供する。bikkuri-web がこれらをルート、静的生成、サーバー配信、RPCにつなぐ。

bikkuri-html/src/html-view.taut · 510–521
pub macro fn view(v: view::View): SynExpr ! {macro_error, Div, overflow} {  let d := HtmlView::html()  match v.tree_input {    Some(input) => View::child(d, input, v.child)    // The tree is taken from `ui` once, so the closures the view makes (of `@if`, `@for`,    // events) hold the tree and do not perform `ui` themselves.    None => {      let build := HtmlView::from_ui(d, v.child, v.span)      quote { view_in($build) }    }  }}

HTML側のマクロは共通の view::View を受け取る。入力に木が明示されていればその木を渡し、省略時は view_in を使う式を生成する。

Signalと所有

get(signal)実行中のwatcherに読み取りを登録
set(signal, value)値を更新し、読んだwatcherをキューへ
flushキューを処理して購読を更新
peek は購読せずに読む。set_eq は値が等しければ更新を省く。
bikkuri/src/signal.taut · 374–389
// Writes the signal and runs again, once each, the watchers that read it.pub fn set<T: Shared>(s: bikkuri::src::signal::Signal(T), v: T): Unit ! {rt, Div} {  let r := perform access_runtime()  Ref::set(s.cell, v)  queue(r, live_readers(r, s.id))  if Ref::get(r.flushing) { #() } else {    Ref::set(r.flushing, true)    flush(r)    end_flush(r)  }}// Writes only when the value changes, without subscribing the current watcher.pub fn set_eq<T: Shared>(s: bikkuri::src::signal::Signal(T), v: T): Unit ! {rt, Div} where DecEq(T) {  if peek(s) == v { #() } else { set(s, v) }}

reactive が実行単位ごとのRuntimeを作り、scope がその中にownerを作る。dispose は子を先に終了し、cleanupを実行して購読を解除する。条件分岐やキー付きの行の寿命もこの仕組みで管理する。

要素の木と差分

text_dyn は値をメモ化し、初回は CreateText を出す。最初のwatcher実行は購読だけを行い、その後の変更で SetText を出す。レンダラーはその番号のノードを更新する。

bikkuri/src/tree.taut · 342–354
// Text that follows the signals `f` reads.pub fn text_dyn<K: Shared, P: Shared>(t: Tree(K, P), f: () -> Text ! {rt, Div}): Node ! {rt, Div, overflow} {  let n := node_of(t)  let v := memo_eq(() => f())  emit(t, CreateText(n, peek(v)))  // The first run only subscribes: the text was created with the value.  let first := Ref::new(true)  watch(() => {    let s := get(v)    if Ref::get(first) { Ref::set(first, false) } else { emit(t, SetText(n, s)) }  })  n}

キー付きの並べ替え

keyed_each は既存の行をキーで再利用し、消えた行を終了する。残る行の中で順序を維持できる部分を求め、必要な移動だけを発行する。行をwatcherとは別のownerに置くので、一覧の再計算をまたいで行の状態を保てる。

bikkuri/src/tree.taut · 678–707
): Node ! {rt, Div, overflow} {  let g := node_of(t)  emit(t, CreateGroup(g))  let rows := Ref::new(Nil)  // The rows belong to the owner `keyed_each` was made in, not to the watcher, so a change of  // `items` keeps the rows it keeps.  let home := scope(() => #())  watch(() => {    let xs := items()    let old := Ref::get(rows)    var keys: HashMap(u64, u64) := HashMap::new()    keys_of(&mut keys, xs, key)    end_missing(t, old, &keys)    let fresh := reverse_rows(in_owner(home.owner, () => {      var by_key: HashMap(u64, KeyedRow) := HashMap::new()      rows_by_key(&mut by_key, old)      var seen: HashMap(u64, u64) := HashMap::new()      rows_for(&by_key, xs, key, row, t.duplicate_keys, &mut seen, Nil)    }), Nil)    Ref::set(rows, fresh)    match old {      Nil => append_rows(t, g, fresh)      Cons(o, more) => {        let keep := staying(old, fresh)        place_rows(t, g, reverse_indexed(fresh, 0, Nil), &keep, None)      }    }  })  on_cleanup(() => dispose(home.owner))  g

HTMLとDOM

Patch同じ差分の語彙
HTMLライター要素を組み立て、ポリシーを通してバイト列へ
DOMの採用マーカーに対応する既存ノードをhydrationで再利用
HTMLを経由しないマウントでは、DOMホストがPatchから新しいノードを作る。
bikkuri-html/src/write.taut · 269–275
// Whatever the tree holds, `html_safe` accepts what is written.theorem write_safe(t: HTree): html_safe(write(t)) = true {  Eq::congr((s: Scan) => Scan::done(s), write_keeps_text(t))}// The tree as trusted HTML.fn written(t: HTree): TrustedHtml { TrustedHtml::Mk(write(t), write_safe(t)) }

write_safe の結論はライターの出力を html_safe が受理すること。HTTPヘッダーやDOMホストの振る舞いは別の検証対象になる。

bikkuri-html/dom/host/dom.mjs · 188–190
export const setText = (d, n, s) => {  node(d, n).data = s;};

DOMホストでは SetText に対応してTextノードの data を置き換える。アンマウント時の release はリスナーとタイマーを外し、進行中のロードを中断する。

ResourceとSuspense

resource は Loading のSignalとリクエストを作る。レンダラーが take_wants から取り出し、結果を resolve または reject で返す。Suspense境界は配下の待機数を持ち、最初はfallbackを表示し、待機数がゼロになってから本文を差し込む。

bikkuri/src/resource.taut · 58–74
// while another of its resources still loads.fn settle(w: Want, l: Load): Unit ! {rt, Div} {  if Ref::get(w.settled) { #() } else {    Ref::set(w.settled, true)    batch(() => {      set(w.state, l)      match w.boundary {        None => #()        Some(c) => update(c, (n: u64) => handle { n - 1 } with { overflow() => 0 })      }    })  }}pub fn resolve(w: Want, v: Text): Unit ! {rt, Div} { settle(w, Loaded(v)) }pub fn reject(w: Want, why: Text): Unit ! {rt, Div} { settle(w, LoadFailed(why)) }

同じリクエストの完了は一度だけ処理される。reload は前のリクエストを先に完了扱いにするため、遅い古い応答で新しい値が上書きされるのを防ぐ。

ルーティングとHTTP

Router(R)パスを型付きのルートへ
Pageタイトル、head、view
SSG / SSRファイル出力、またはHTTP応答
ブラウザの通常の遷移では、条件を満たすページをfallbackからストリームする。通常の取得やクライアント側の画面遷移には完成した文書を返す。
bikkuri-web/src/app.taut · 42–47
pub fn page_at<R: Shared>(router: Router(R), target: Text): Option(Page) ! {overflow, Div} {  match route_of(router, target) {    None => None    Some(r) => Some((router.page)(r))  }}

ResourceのGETとサーバー関数のPOST

ブラウザ側のloaderは一時的な通信切断や一部のプロキシ応答でGETを再試行する。POSTはサーバー側で変更が完了してから応答だけ失われる場合があるため自動再試行しない。RPCのスキーマハッシュでフレームの互換性を見て、業務ルールや認可はアプリ側で扱う。

bikkuri-web/host/loader.mjs · 40–51
    let request;    try { request = requestOf(key); } catch { return failure('decode'); }    if (request.failed) return request.failed;    let delay = retryDelayMs;    for (let attempt = 0; ; attempt++) {      const { result, retry } = await once(request, signal);      if (request.rpc || !retry || attempt >= retries || signal?.aborted) return result;      await pause(delay, signal);      if (signal?.aborted) return failure('cancelled');      delay *= 2;    }  };

本番運用の判定

現時点では本番投入を保留

コアとHTMLの型検査は通った。現行環境でコアのテストが検査段階で失敗し、Webの型検査は依存解決で止まった。対象211ファイルの全行レビューは未完了。

確認項目今回の結果
コアとHTMLtauto check は両方成功。コアの tauto test は既存 show 宣言の診断でファイル9件が失敗。
Webの型検査http が参照するローカルの url/Tauto.eure が見つからず、型検査まで到達しなかった。
JavaScriptの独立テストloader 11件とTodoのWALアダプター4件が成功。共有Todoのサーバーテストは生成済みモジュールがなく起動できなかった。
Rustと実ブラウザ今回は未検証。既存READMEは serve_answers・serve_with_state の動作実績と、通常の serve のRust生成コードがコンパイルを通らなかった記録を分けている。
再確認が必要な運用上の論点
  • 長寿命のRuntimeについて、画面の開閉や一覧の差し替えを繰り返した後のメモリを確認する。dispose_owner はwatcherの関数を外すが、watcherのキーは None として表に残す。購読の組も drop_reader で状態を書き換える実装なので、保有件数の推移を計測する。signal.taut
  • serve_streamed の接続上限は512。リソース処理は別タスクに分かれ、期限を超えた処理は待機を終了してもタスク側で続く。接続数とバックグラウンド処理数をそれぞれ計測する必要がある。stream.taut
  • Todo例のWALはappendごとに全ファイルを読み直してから書く。ログが伸びる環境では書き込み遅延と再起動時のリプレイ時間を測る必要がある。ledger-disk.mjs
  • verify.mjs --expect fixed は1/13一致。旧 show を使うケースが宣言エラーとなり、waitの公開関数はビルドできたがテストは 42 と比較して失敗した。Node向けの直接呼び出しでは bigint 42n を返した。verify.mjs
Tautoの診断と最小再現

コアのテストから check.taut だけを抜き出した、依存のないtoteでも再現した。旧テストの show 宣言を現行の言語仕様で受理するべきかは未確認。既存のマクロ名前解決の報告と同じ原因かどうかも未確認。

Tauto.eure の全文

package {
  name = "tests"
  version = "0.1.0"
}

src/check.taut の全文

pub fn check(name: Text, ok: Bool): Unit ! {∀ A. show(A)} {
  if ok { perform show(name, "ok") } else { perform show(name, "FAILED") }
}

上記のtoteルートで tauto check . を実行する。期待は旧テストが前提としていた宣言の検査成功。今回の終了コードは1で、診断全文は以下。

error: `∀ A. show(A)`: every instance of `show` binds each type argument in order: `∀ . show()`
 --> src/check.taut:1:1
  |
1 | pub fn check(name: Text, ok: Bool): Unit ! {∀ A. show(A)} {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2 |   if ok { perform show(name, "ok") } else { perform show(name, "FAILED") }
3 | }
  | ^ in `macro`

実行ファイルは /Users/ryo/.cargo/bin/tauto。バージョン表示は tauto 0.1.0。SHA-256は ec2ebd921ab3f0b113dbff96a0d223b961f954cbc3acb4472b5826498ef0dd58。

Tauto 0.1.0 · Node v25.2.1 · ソース 25d3af9f019f。引用の行番号はこのソース時点。