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
| Export | Purpose |
|---|---|
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
| Option | Type | Default | Description |
|---|---|---|---|
sseEndpoint | string | (required) | URL the iframe connects to for SSE streaming |
sseEventName | string | "message" | SSE event name to listen for |
swapStrategy | string | "innerHTML" | HTMX swap strategy for incoming fragments |
themeVars | Record<string, string> | {} | Initial CSS variables injected into :root |
extraHead | string | "" | Additional <head> content (styles, meta tags) |
htmxUrl | string | unpkg v2 | CDN URL for HTMX |
htmxSseUrl | string | unpkg v2 | CDN URL for the HTMX SSE extension |
hostOrigin | string | "*" | Restrict postMessage targets (set in production) |
Swap Strategies
The swapStrategy option controls how HTMX inserts streamed fragments:
| Strategy | Behavior |
|---|---|
"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:
| Header | Value | Purpose |
|---|---|---|
Content-Type | text/event-stream | Required for SSE |
Cache-Control | no-cache, no-transform | Prevent caching and proxy transformation |
Connection | keep-alive | Keep the connection open |
X-Accel-Buffering | no | Disable 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: noCORS
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.