HyperGen

クライアントセットアップ

フロントエンドにHyperGen iframeをマウント — React、Vue、Svelte、またはバニラJS。テーマ管理、リサイズ処理、ライフサイクル制御。

クライアントセットアップ

HyperGenのクライアントサイドは、サンドボックス化されたiframeをマウントし、ブートストラップドキュメントを注入し、iframeのライフサイクルを管理するコントローラーを提供します。必要なものは1つのファイルhypergen-client.tsにすべて含まれています。

API概要

エクスポート目的
mountHyperGen(container, options)サンドボックス化されたiframeを作成し、HyperGenControllerを返す

mountHyperGen()

サンドボックス化されたiframeを作成し、ブートストラップドキュメントを読み込み、postMessageリスナーをセットアップし、コントローラーを返します。

import { mountHyperGen } from "./hypergen-client";

const controller = mountHyperGen(document.getElementById("agent-ui")!, {
  bootstrapUrl: "/bootstrap",
});

オプション

オプションデフォルト説明
bootstrapUrlstring--ブートストラップHTMLを提供するURL(iframeのsrc
bootstrapHtmlstring--インラインブートストラップHTML(iframeのsrcdoc
themeRecord<string, string>--iframe読み込み後に送信される初期CSS変数
iframeOriginstring"*"postMessage検証に使用される期待されるiframeオリジン
autoResizebooleantrueコンテンツの高さに基づいてiframeを自動リサイズ
maxHeightnumber2000iframeの最大高さ(ピクセル)
minHeightnumber100iframeの最小高さ(ピクセル)
extraSandboxPermissionsstring[][]追加のsandboxパーミッション
classNamestring--iframe要素のCSSクラス
stylePartial<CSSStyleDeclaration>--iframe要素のインラインスタイル

bootstrapUrlまたはbootstrapHtmlのいずれか一方を指定する必要があります(両方は不可)。

bootstrapUrl — iframeがHTTP経由でブートストラップドキュメントを読み込みます。標準的なアプローチです: iframeのsrc属性にこのURLが設定されます。

bootstrapHtml — ブートストラップHTMLがsrcdoc経由で直接注入されます。サーバーサイドでブートストラップを生成し、追加のHTTPリクエストを避けたい場合に使用します(SSRフレームワークなど)。

// URLアプローチ(追加HTTPリクエストあり、よりシンプル)
const ctrl = mountHyperGen(el, { bootstrapUrl: "/bootstrap" });

// インラインHTMLアプローチ(追加リクエストなし、サーバーサイドレンダリングが必要)
const ctrl = mountHyperGen(el, { bootstrapHtml: serverGeneratedHtml });

HyperGenController

mountHyperGen()が返すオブジェクトです。マウント後のiframeを管理するために使用します。

controller.setTheme(vars)

postMessage経由でiframeにCSS変数を送信します。iframeのブリッジスクリプトがそれらを:rootに適用します。

controller.setTheme({
  "--hg-surface": "#1a1a2e",
  "--hg-text": "#e0e0e0",
  "--hg-accent": "#7c3aed",
});

テーマを更新するためにいつでも呼び出せます(ダークモードの切り替え時など)。指定された変数のみが更新され、既存の変数は削除されません。

controller.onResize(callback)

コンテンツサイズの変更に対するコールバックを登録します。iframeのResizeObserverが高さの変更を検出し、postMessage経由で通知します。

const unsubscribe = controller.onResize((height, width) => {
  console.log(`Content: ${height}px tall, ${width}px wide`);
});

// 後で: リスニングを停止
unsubscribe();

autoResizetrue(デフォルト)の場合、iframe要素の高さはminHeight/maxHeightの範囲内で自動的に調整されます。コールバックはautoResizeの設定に関係なく発火します。

controller.onNavigate(callback)

iframeからのナビゲーションリクエストに対するコールバックを登録します。エージェントはdata-hg-navigate属性を持つ要素を含めることでナビゲーションをトリガーします。

const unsubscribe = controller.onNavigate((url) => {
  // ルーターと統合
  router.push(url);
});

iframe内で、エージェントは以下を生成できます:

<a data-hg-navigate="/dashboard" href="#">Go to Dashboard</a>

クリックすると、iframe内でのナビゲーションの代わりにhg:navigate postMessageが送信されます。

controller.onData(callback)

iframeから送信される任意のデータに対するコールバックを登録します。エージェントはブリッジがホストに転送するカスタムイベントをディスパッチできます。

const unsubscribe = controller.onData((payload) => {
  console.log("Data from agent:", payload);
});

iframe内で、エージェント生成のJavaScriptがデータを送信できます:

<script>
  document.dispatchEvent(new CustomEvent("hg:data", {
    detail: { action: "selected", itemId: 42 }
  }));
</script>

controller.destroy()

iframeを破棄します: SSE接続を閉じるためにhg:destroyを送信し、メッセージリスナーを削除し、すべてのサブスクリプションをクリアし、DOMからiframeを削除します。

controller.destroy();
console.log(controller.destroyed); // true

メモリリークを防ぐため、コンポーネントのアンマウント時には必ずdestroy()を呼び出してください。

controller.iframe

高度なユースケースのために、基になるHTMLIFrameElementに直接アクセスできます。

controller.iframe.style.opacity = "0.5";

controller.destroyed

destroy()が呼び出されたかどうかを示す真偽値です。

フレームワーク統合

React

import { useEffect, useRef } from "react";
import { mountHyperGen, type HyperGenController } from "./hypergen-client";

function AgentUI({ theme }: { theme?: Record<string, string> }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const controllerRef = useRef<HyperGenController | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const controller = mountHyperGen(containerRef.current, {
      bootstrapUrl: "/bootstrap",
      theme,
    });
    controllerRef.current = controller;

    controller.onNavigate((url) => {
      // ルーターを使用: window.location.href = url
    });

    return () => controller.destroy();
  }, []);

  // propsが変更されたらテーマを更新
  useEffect(() => {
    if (theme && controllerRef.current && !controllerRef.current.destroyed) {
      controllerRef.current.setTheme(theme);
    }
  }, [theme]);

  return <div ref={containerRef} />;
}

Vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { mountHyperGen, type HyperGenController } from "./hypergen-client";

const props = defineProps<{
  theme?: Record<string, string>;
}>();

const container = ref<HTMLElement | null>(null);
let controller: HyperGenController | null = null;

onMounted(() => {
  if (!container.value) return;

  controller = mountHyperGen(container.value, {
    bootstrapUrl: "/bootstrap",
    theme: props.theme,
  });

  controller.onNavigate((url) => {
    // useRouter().push(url)
  });
});

watch(() => props.theme, (newTheme) => {
  if (newTheme && controller && !controller.destroyed) {
    controller.setTheme(newTheme);
  }
});

onUnmounted(() => {
  controller?.destroy();
});
</script>

<template>
  <div ref="container" />
</template>

Svelte

<script lang="ts">
  import { onMount, onDestroy } from "svelte";
  import { mountHyperGen, type HyperGenController } from "./hypergen-client";

  export let theme: Record<string, string> | undefined = undefined;

  let container: HTMLElement;
  let controller: HyperGenController | null = null;

  onMount(() => {
    controller = mountHyperGen(container, {
      bootstrapUrl: "/bootstrap",
      theme,
    });

    controller.onNavigate((url) => {
      // goto(url)
    });
  });

  onDestroy(() => {
    controller?.destroy();
  });

  $: if (theme && controller && !controller.destroyed) {
    controller.setTheme(theme);
  }
</script>

<div bind:this={container}></div>

バニラJS

<div id="agent-ui"></div>

<script type="module">
  import { mountHyperGen } from "./hypergen-client.js";

  const controller = mountHyperGen(
    document.getElementById("agent-ui"),
    { bootstrapUrl: "/bootstrap" }
  );

  controller.setTheme({
    "--hg-accent": "#7c3aed",
    "--hg-surface": "#ffffff",
  });

  controller.onResize((height) => {
    console.log("Content height:", height);
  });

  // ページアンロード時にクリーンアップ
  window.addEventListener("beforeunload", () => {
    controller.destroy();
  });
</script>

自動リサイズ動作

デフォルトでは、iframeはコンテンツの高さに合わせて自動的にリサイズされます。フロー:

  1. iframeのブートストラップドキュメントに<body>ResizeObserverが含まれる
  2. コンテンツのサイズが変わると、オブザーバーがホストにhg:resizeメッセージを送信
  3. mountHyperGen()がメッセージを受信し、iframe.style.heightを調整
  4. 高さはminHeightmaxHeightの間にクランプされる

リサイズの遷移はスムーズ(150msイーズアウト)で、視覚的なちらつきを防ぎます。

自動リサイズを無効にして自分で処理する場合:

const controller = mountHyperGen(el, {
  bootstrapUrl: "/bootstrap",
  autoResize: false,
});

controller.onResize((height) => {
  // カスタムリサイズロジック
  el.style.height = `${Math.min(height, 800)}px`;
});

1ページに複数のiframe

複数のHyperGen iframeをマウントでき、それぞれが独自のコントローラーとSSE接続を持ちます:

const chatCtrl = mountHyperGen(
  document.getElementById("chat")!,
  { bootstrapUrl: "/bootstrap?view=chat" },
);

const dashCtrl = mountHyperGen(
  document.getElementById("dashboard")!,
  { bootstrapUrl: "/bootstrap?view=dashboard" },
);

// 各コントローラーは独立
chatCtrl.setTheme({ "--hg-accent": "#10b981" });
dashCtrl.setTheme({ "--hg-accent": "#f59e0b" });

各iframeは独自のSSE接続、テーマ、ライフサイクルを維持します。mountHyperGen()の実装は、受信postMessageイベントが正しいiframeから発信されていることを検証するため(event.source === iframe.contentWindow)、インスタンス間でメッセージが交差することはありません。

Sandboxパーミッション

デフォルトのsandboxはallow-scripts allow-same-origin allow-forms — HTMXが必要とする最小限です。

セキュリティ

sandboxパーミッションを追加すると、セキュリティ境界が弱くなります。実際に必要なパーミッションのみを追加してください。

const controller = mountHyperGen(el, {
  bootstrapUrl: "/bootstrap",
  extraSandboxPermissions: ["allow-popups"],  // 注意して使用
});

完全なセキュリティモデルの理由についてはADR-004を参照してください。

目次