HyperGen

Server Setup

Detailed guide to setting up the HyperGen server — bootstrap documents, SSE streaming, response helpers, and framework-specific examples.

The server side of HyperGen has two responsibilities: serving the iframe bootstrap document and streaming HTML fragments over SSE. Everything you need is in a single file: hypergen-server.ts.

API Overview

ExportPurpose
bootstrapHtml(options)Generate the iframe's HTML document
createSSEStream(source, options?)Create a ReadableStream of SSE events from an async generator
streamHtml(source, eventName?)Low-level async generator yielding SSE-formatted strings
HyperGenResponse(stream, extraHeaders?)Wrap a stream in a Response with correct SSE headers
formatSSEEvent(event)Format a single SSEEvent object into an SSE string

bootstrapHtml()

Generates the complete HTML document loaded inside the sandboxed iframe. This document includes HTMX + SSE extension from CDN, the SSE connection root element (#hg-root), a postMessage bridge for host-iframe communication, a ResizeObserver for auto-resize, and base styles with --hg-* CSS variable support.

import { bootstrapHtml } from "./hypergen-server";

const html = bootstrapHtml({
  sseEndpoint: "/api/stream",
});

Options

OptionTypeDefaultDescription
sseEndpointstring(required)URL the iframe connects to for SSE streaming
sseEventNamestring"message"SSE event name to listen for
swapStrategystring"innerHTML"HTMX swap strategy for incoming fragments
themeVarsRecord<string, string>{}Initial CSS variables injected into :root
extraHeadstring""Additional <head> content (styles, meta tags)
htmxUrlstringunpkg v2CDN URL for HTMX
htmxSseUrlstringunpkg v2CDN URL for the HTMX SSE extension
hostOriginstring"*"Restrict postMessage targets (set in production)

Swap Strategies

The swapStrategy option controls how HTMX inserts streamed fragments:

StrategyBehavior
"innerHTML"Replace the content of #hg-root (default -- good for single-view UI)
"beforeend"Append after the last child (good for chat, logs, sequential content)
"outerHTML"Replace the entire target element
"afterend"Insert after the target element
"beforebegin"Insert before the target element
"afterbegin"Insert as the first child
"none"Do not swap (use for side-effect-only events)

Adding Custom Styles

Use extraHead to inject widget-specific CSS into the iframe:

const html = bootstrapHtml({
  sseEndpoint: "/api/stream",
  swapStrategy: "beforeend",
  extraHead: `<style>
    body { padding: 16px; }
    .card {
      background: var(--hg-surface-elevated);
      border: 1px solid var(--hg-border);
      border-radius: var(--hg-radius);
      padding: var(--hg-space-4);
      margin-bottom: var(--hg-space-2);
    }
  </style>`,
});

createSSEStream()

The primary API for creating SSE streams. Pass it an async generator (or any async iterable) that yields HTML strings, and get back a ReadableStream suitable for an HTTP response body.

import { createSSEStream } from "./hypergen-server";

const stream = createSSEStream(async function* () {
  yield "<h1>Hello</h1>";
  await someAsyncWork();
  yield "<p>Done!</p>";
});

Source Argument

The first argument can be:

  • An async generator function (async function* () { ... }) -- most common
  • An async iterable (any object with [Symbol.asyncIterator])
  • A sync iterable (e.g., an array of strings for testing)
  • A factory function returning any of the above (() => myIterable)

Options

The second argument accepts an SSEStreamOptions object or a plain string (treated as eventName):

// Object form
const stream = createSSEStream(source, {
  eventName: "fragment",       // SSE event name (default: "message")
  keepAliveInterval: 30_000,   // Keep-alive ping interval in ms (default: 15000)
});

// String shorthand
const stream = createSSEStream(source, "fragment");

Keep-alive pings

A comment line (: keepalive) is sent at the configured interval to prevent proxies and load balancers from closing idle connections. Set keepAliveInterval: 0 to disable.

HyperGenResponse()

Creates a standard Response with the correct SSE headers. Works with any runtime that supports the Web Response API.

import { createSSEStream, HyperGenResponse } from "./hypergen-server";

const stream = createSSEStream(myGenerator());
return HyperGenResponse(stream);

The response includes these headers:

HeaderValuePurpose
Content-Typetext/event-streamRequired for SSE
Cache-Controlno-cache, no-transformPrevent caching and proxy transformation
Connectionkeep-aliveKeep the connection open
X-Accel-BufferingnoDisable nginx buffering

Add custom headers with the second argument:

return HyperGenResponse(stream, {
  "Access-Control-Allow-Origin": "https://myapp.com",
});

streamHtml()

Low-level async generator for when you need manual control over the stream. Each yielded string from the source becomes one SSE event.

import { streamHtml } from "./hypergen-server";

for await (const chunk of streamHtml(myFragments)) {
  writer.write(chunk);
}

This is the building block that createSSEStream() uses internally. Prefer createSSEStream() unless you need to integrate with a custom streaming mechanism.

formatSSEEvent()

Formats a single SSE event object into a spec-compliant string. Useful when you need to manually construct SSE payloads.

import { formatSSEEvent } from "./hypergen-server";

const sseString = formatSSEEvent({
  event: "fragment",
  data: "<h1>Hello</h1>",
  id: "msg-1",
});
// "event: fragment\nid: msg-1\ndata: <h1>Hello</h1>\n\n"

Framework Examples

Hono

import { Hono } from "hono";
import { bootstrapHtml, createSSEStream, HyperGenResponse } from "./hypergen-server";

const app = new Hono();

app.get("/bootstrap", (c) => {
  return c.html(bootstrapHtml({ sseEndpoint: "/api/stream" }));
});

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      yield "<p>Hello from Hono!</p>";
    }),
  );
});

Express

Express does not natively support the Web Response API. Use the stream directly with res.write():

import express from "express";
import { bootstrapHtml, streamHtml } from "./hypergen-server";

const app = express();

app.get("/bootstrap", (req, res) => {
  res.type("html").send(bootstrapHtml({ sseEndpoint: "/api/stream" }));
});

app.get("/api/stream", async (req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache, no-transform",
    "Connection": "keep-alive",
    "X-Accel-Buffering": "no",
  });

  const fragments = async function* () {
    yield "<p>Hello from Express!</p>";
  };

  for await (const chunk of streamHtml(fragments())) {
    res.write(chunk);
  }

  res.end();
});

app.listen(3000);

Express adapter

If you prefer the Response API with Express, consider using an adapter like @hono/node-server or running Express 5+ which has better streaming support.

Next.js Route Handlers

// app/api/bootstrap/route.ts
import { bootstrapHtml } from "@/lib/hypergen-server";

export function GET() {
  return new Response(
    bootstrapHtml({ sseEndpoint: "/api/stream" }),
    { headers: { "Content-Type": "text/html" } },
  );
}
// app/api/stream/route.ts
import { createSSEStream, HyperGenResponse } from "@/lib/hypergen-server";

export function GET() {
  return HyperGenResponse(
    createSSEStream(async function* () {
      yield "<p>Hello from Next.js!</p>";
    }),
  );
}

Deno

import { bootstrapHtml, createSSEStream, HyperGenResponse } from "./hypergen-server.ts";

Deno.serve({ port: 3000 }, (req: Request) => {
  const url = new URL(req.url);

  if (url.pathname === "/bootstrap") {
    return new Response(
      bootstrapHtml({ sseEndpoint: "/api/stream" }),
      { headers: { "Content-Type": "text/html" } },
    );
  }

  if (url.pathname === "/api/stream") {
    return HyperGenResponse(
      createSSEStream(async function* () {
        yield "<p>Hello from Deno!</p>";
      }),
    );
  }

  return new Response("Not found", { status: 404 });
});

SSE Headers and CORS

Required Headers

HyperGenResponse() sets these automatically. If you build responses manually, include all of them:

Content-Type: text/event-stream
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no

CORS

If your agent server runs on a different origin than the host application, you need CORS headers on both the bootstrap and SSE endpoints:

return HyperGenResponse(stream, {
  "Access-Control-Allow-Origin": "https://myapp.com",
  "Access-Control-Allow-Credentials": "true",
});

Security

In production, always set hostOrigin in bootstrapHtml() to your actual host origin instead of "*". This restricts which origins the iframe will accept postMessage events from.

Reverse Proxy Configuration

SSE connections need special treatment from reverse proxies:

Nginx:

location /api/stream {
    proxy_pass http://backend;
    proxy_set_header Connection "";
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
}

Cloudflare: SSE works out of the box. The X-Accel-Buffering: no header (set by HyperGenResponse) prevents premature buffering.

Handling User Interactions

When the agent generates HTML with HTMX attributes like hx-post, user interactions are sent directly from the iframe to your server. Handle them with standard HTTP endpoints:

app.post("/api/action", async (c) => {
  const body = await c.req.parseBody();
  const action = body["action"] as string;

  switch (action) {
    case "approve":
      return c.html('<div class="success">Approved!</div>');
    case "refresh":
      const data = await fetchLatestData();
      return c.html(renderDashboard(data));
    default:
      return c.html('<div class="error">Unknown action</div>');
  }
});

The response from POST endpoints is plain HTML -- HTMX swaps it into the iframe DOM based on hx-target and hx-swap attributes in the triggering element.

On this page