今、すべてを作り直すなら

Luna/Sol の SSR と Island Architecture

自己紹介

最近やってること (すべて AI と共同開発)

  • mizchi/crater — 自作ブラウザ (WPT css-* 系ほぼ通過)
  • mizchi/vibe-lang — 自作言語 (WASM セルフホスト)
  • mizchi/kagura — 2D/3D ゲームエンジン (WebGPU)
  • bit-vcs/bit — Git 互換 (JS 60k / WASM 340k)
  • mizchi/actrun — GitHub Actions 互換タスクランナー

せっかく AI 使うんなら、でかいことをしようぜ

モチベーション

  • 理想の最適化: Qwik/Astro の Island Architecture
  • 理想のフレームワーク: Solid
  • React — エコシステムは充実、しかしランタイムコストが多すぎる
  • SSR を完璧に制御するなら 垂直統合するしかない

→ 設計はゼロから、良い実装は移植して活用

Why MoonBit

Rust 風の型システムを持つ関数型言語。WASM ファースト設計。

  • 1つのコードから JS / WASM / Native にビルドできる
    • TypeScript: JS のみ。Rust: フロントエンド統合が辛い
  • JS バックエンドは minify フレンドリーなフラット名前空間 → バンドルサイズが肥大化しない

構文メモ: @pkg = モジュール参照, pub fn = 公開関数

作ったもの

graph LR
    Signals["mizchi/signals<br/>(alien-signals 移植)"] --> Luna
    Luna["Luna<br/>Signal UI ライブラリ"] --> Sol
    Mars["Mars<br/>Hono クローン"] --> Sol
    Sol["Sol<br/>SSR フレームワーク"]
名前 役割 概要
signals リアクティブ基盤 alien-signals (Vue 3.6 採用) の MoonBit 移植
Luna UI ライブラリ Solid 風 API、9.4KB gzip
Mars HTTP サーバー Hono クローン (MoonBit 統一のため自作)
Sol フルスタック SSR Luna + Mars + Island Architecture

Luna — Signal ベースの UI

Signal の変更に追従して View を差分アップデート (トップダウン再描画ではない)

pub fn counter(props : CounterProps) -> DomNode {
  let count = @signal.signal(props.initial_count)
  div(class="counter", [
    span(class="count-display", [text_of(count)]),
    button(on=events().click(_ => count.update(n => n - 1)), [text("-")]),
    button(on=events().click(_ => count.update(n => n + 1)), [text("+")]),
  ])
}
項目 Luna Preact React
Grid 2,500 cells 2,188 ops/s 2,276 ops/s 227 ops/s
Bundle size 9.4KB 11KB 58KB

Preact と同等、React の約 10x 高速

Sol — フルスタック SSR フレームワーク

対応ランタイム: Node.js / Cloudflare Workers / Native

pub fn routes() -> Array[@sol.SolRoutes] {
  [
    @sol.with_mw([@mw.logger()], [             // ミドルウェア
      @sol.wrap("", root_layout, [             // レイアウト
        @sol.route("/", home, title="Home"),    // ページ
        @sol.route("/docs/[...slug]", docs_page, title="Docs"),
      ]),
      @sol.api_get("/api/health", api_health), // API
    ]),
  ]
}

Mars (Hono クローン) の上に構築

Next.js の問題

  • 2025末: Next.js RSC にリモートコード実行 (RCE) 脆弱性
  • "use client" / "use server" — ディレクティブの書き忘れが脆弱性に直結
  • クライアントコードを部分的にサーバーでレンダリング → 境界が曖昧

Sol の解答: 型でサーバー/クライアントを分離する

graph LR
    subgraph Server
        SN["Node[Unit, String]<br/>イベントハンドラ不可"]
    end
    subgraph JSON境界
        CR["ComponentRef[Props]"]
    end
    subgraph Client
        DN["DomNode<br/>Signal + イベント"]
    end
    SN --> CR --> DN

型によるセキュリティ境界: サーバー側

戻り値 ServerNode = Node[Unit, String]イベントハンドラが型的に書けない

async fn home(_props : @sol.PageProps) -> @server_dom.ServerNode {
  @server_dom.ServerNode::async_(fn() {
    let props : @types.CounterProps = { initial_count: 42 }
    @luna.fragment([
      h1([text("Welcome")]),
      // Props を JSON シリアライズしてクライアント Island を埋め込む
      @server_dom.client(@types.counter(props), [
        div(class="counter", [text("42")]),  // SSR フォールバック
      ]),
    ])
  })
}
// on=events().click(...) を書くとコンパイルエラー!

型によるセキュリティ境界: クライアント側

戻り値 DomNodeSignal とイベントハンドラが使える

pub fn counter(props : CounterProps) -> DomNode {
  let count = @signal.signal(props.initial_count)
  div(class="counter", [
    text_of(count),
    button(on=events().click(_ => count.update(n => n + 1)), [text("+")]),
  ])
}

Next.js との違い:
ディレクティブの書き忘れが発生しない — 型が違うのでコンパイラが強制する

唯一の接点: ComponentRef[T] — Props を JSON シリアライズして橋渡し

Island Architecture: SSR → Hydration

sequenceDiagram
    participant S as Server
    participant B as Browser
    participant L as loader.js

    S->>B: 完全な HTML (SSR済み) + preload ヘッダー
    Note over B: 各 Island に luna:url, luna:trigger 属性
    B->>L: DOMContentLoaded
    L->>L: luna:url 要素をスキャン

    alt load
        L->>B: 即座に import(url)
    else idle
        L->>B: requestIdleCallback
    else visible
        L->>B: IntersectionObserver
    else media:(query)
        L->>B: matchMedia
    end

    B->>B: hydrate — SSR HTML 再利用 + イベント接続

Hydration Trigger

サーバーは SSR + preload ヘッダー注入 だけ
クライアント側で 条件に応じて選択的に hydrate

trigger 発火 ユースケース
load 即座 ファーストビュー必須
idle requestIdleCallback 非優先だが早めに
visible IntersectionObserver スクロールで見えたら
media:(query) matchMedia モバイルのみ等
none 手動 プログラム制御

不要な Island は JS を読み込みすらしない

WebComponents: SSR 対応と限界

Declarative Shadow DOM で WC SSR に対応済み。
しかし 全コンポーネントを WC にするのはアンチパターン:

項目 Plain DOM WC 劣化
イベントバブリング 451K ops/s 39K ops/s (3段) 11.7x
初期化 (100個) 3,425 ops/s 972 ops/s 3.5x
Context (10階層) 10-12x

→ 末端の装飾拡張には有効、データ伝搬レイヤーには使わない
→ Sol では Island 単位で WC/非WC を選択可能

MoonBit: クロスコンパイルの威力

SSR 最難関: サーバー/クライアントで同じ出力の担保 → 今まで JS でしか無理だった

MoonBit: 同一コードから JS/Native にビルドして SSR の一致を型で保証

TypeScript と共存可能 — 段階的に置き換えられる

MoonBit: Native の現状

同一ハンドラコード、トランスポートのみ異なる比較:

Metric Native JS (Node.js) Ratio
Requests/sec 12,376 16,820 73.6%
Avg latency 768µs 556µs 1.38x slower
  • Native コンパイラは発展途上 (参照カウント ~15%, 文字列操作 ~13%)
  • 現時点では V8 + libuv が優秀で JS ターゲットの方が速い
  • コンパイラ最適化の進展で将来的に逆転の可能性

MoonBit + AI

  • コンパイラの警告が優秀 + 公式 AI Skills で補完
  • ライブラリ不足は AI で踏み倒せる
    • 「テストコード移植して出力一致まで実装して」
    • moon bench でベンチマーク書いて改善して」

AI が書きやすい言語 = 型が厳密でコンパイラがガイドしてくれる言語

まとめ

まとめ

  1. SSR を完璧に制御するなら垂直統合 — UI, サーバー, ビルドまで一貫して設計
  2. 型でサーバー/クライアント境界を強制 — ディレクティブ依存ではなくコンパイルエラー
  3. Island Architecture — 必要な部分だけ hydrate、不要な JS は読み込まない
  4. MoonBit のクロスコンパイル — SSR の一致を言語レベルで保証
  5. WebComponents は適材適所 — Island の外殻には有効、入れ子はアンチパターン

リンク

TODO: 名前・所属

TODO: 自己紹介を書く