| name | hyperstar |
| description | Build real-time web apps with Hyperstar framework. Use when creating new hyperstar apps, adding features, writing actions, views, or working with signals and state management. Uses JSX syntax with the $ prop for reactive attributes. |
Hyperstar Framework
Very Beta - API changes frequently. Great for prototypes and fun real-time multiplayer apps.
Hyperstar is a server-driven UI framework for Bun. Server owns state, clients sync automatically via SSE.
Real-time = all clients see the same store. User A makes a change, User B sees it instantly.
Core Pattern
import { createHyperstar, hs, Schema } from "hyperstar"
interface Store {
count: number
}
interface Signals {
text: string
}
const app = createHyperstar<Store, {}, Signals>()
const { text } = app.signals
const increment = app.action("increment", (ctx) => {
ctx.update((s) => ({ ...s, count: s.count + 1 }))
})
app.app({
store: { count: 0 },
signals: { text: "" },
view: (ctx) => (
<div id="app">
<span>{ctx.store.count}</span>
<button $={hs.action(increment)}>+1</button>
</div>
),
}).serve({ port: 8080 })
Store vs Signals vs UserStore
┌─────────────────────────────────────────────────────────────┐
│ ctx.store (Server State - Shared) │
│ • Shared across ALL connected clients │
│ • Changes broadcast via SSE to everyone │
│ • User A adds item → User B sees it instantly │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ ctx.userStore (Server State - Per-Session) │
│ • Private to each session (browser tab) │
│ • Stored on server, persists across page reloads │
│ • Perfect for: theme, user preferences, voting state │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ signals (Client State) │
│ • Private to each browser tab │
│ • Never broadcast to other users │
│ • Perfect for UI state: tabs, modals, form inputs │
└─────────────────────────────────────────────────────────────┘
Actions
Actions modify store. Changes broadcast to all clients.
const reset = app.action("reset", (ctx) => {
ctx.update((s) => ({ ...s, count: 0 }))
})
const addTodo = app.action("addTodo", { text: Schema.String }, (ctx, { text }) => {
ctx.update((s) => ({
...s,
todos: [...s.todos, { id: crypto.randomUUID(), text, done: false }],
}))
ctx.patchSignals({ text: "" })
})
const fetchData = app.action("fetchData", async (ctx) => {
ctx.update((s) => ({ ...s, loading: true }))
const data = await fetch("/api/data").then((r) => r.json())
ctx.update((s) => ({ ...s, data, loading: false }))
})
Action Context
const myAction = app.action("myAction", (ctx) => {
ctx.update((s) => ({ ...s, ... }))
ctx.getStore()
ctx.updateUserStore((u) => ({ ... }))
ctx.getUserStore()
ctx.patchSignals({ text: "" })
ctx.sessionId
ctx.session.id
ctx.session.connectedAt
ctx.head.setTitle("New Title")
ctx.head.setFavicon("/icon.ico", "image/x-icon")
ctx.broadcast({ type: "custom", data })
})
JSX with the $ Prop
The $ prop takes an hs.* helper for reactive attributes:
<button $={hs.action(increment)}>+1</button>
<button $={hs.action(deleteTodo, { id: todo.id })}>Delete</button>
<form $={hs.form(addTodo)}>
<input name="text" $={hs.bind(text)} />
<button type="submit">Add</button>
</form>
<div $={hs.show(isVisible)}>Shown when visible</div>
<div $={hs.class("active", isActive)}>...</div>
<div $={hs.show(isVisible).class("active", isActive)}>...</div>
hs Namespace
hs.action(action, args?)
hs.actionOn(event, action, args?, mods?)
hs.form(action, args?)
hs.bind(signal)
hs.show(condition)
hs.class(className, condition)
hs.attr(name, condition)
hs.style(property, value)
hs.disabled(condition)
hs.text(signal)
hs.on(event, handler, mods?)
hs.expr(code)
hs.seq(...exprs)
hs.ref(name)
hs.init(code)
hs.html(content)
hs.drag(dataKey, dataValue)
hs.drop(action, keyFrom, keyTo, extraArgs?)
Event Modifiers
$={hs.on("input", signal.set("value"), { debounce: 300 })}
$={hs.actionOn("change", saveAction, { id }, { throttle: 500 })}
Builder Utilities
hs.builder().action(save).class("active", isActive).show(isVisible)
hs.toggle(isDark, "bg-gray-900 text-white", "bg-white text-gray-900")
hs.compose(
hs.action(save),
hs.class("active", isActive),
hs.show(isVisible)
)
Signals
Signals are client-side state with typed methods:
interface Signals {
filter: "all" | "active" | "done"
text: string
count: number
isOpen: boolean
editingId: string | null
}
const app = createHyperstar<Store, {}, Signals>()
const { filter, text, count, isOpen, editingId } = app.signals
signal.expr
signal.set(value)
signal.patch(value)
filter.is("active")
filter.isNot("done")
filter.oneOf(["a", "b"])
filter.noneOf(["x", "y"])
text.isEmpty()
text.isNotEmpty()
count.eq(0)
count.gt(5)
count.gte(5)
count.lt(10)
count.lte(10)
count.oneOf([1, 2, 3])
count.noneOf([0])
isOpen.is(true)
isOpen.toggle()
isOpen.setTrue()
isOpen.setFalse()
editingId.isNull()
editingId.isNotNull()
editingId.is("abc")
editingId.clear()
Expression Composition
filter.is("active").and(count.gt(0))
filter.is("all").or(filter.is("active"))
isOpen.not()
filter.is("active").and(!todo.done)
Direct Attributes
You can use hs-* attributes directly instead of the $ prop:
<button hs-on:click="$count.value++">Increment</button>
<button hs-on:click={count.toggle()}>Toggle</button>
<div hs-show={tab.is("home")}>Home content</div>
<div hs-show="$loading.value">Loading...</div>
<button hs-class:bg-blue-500={filter.is("all")}>All</button>
<div hs-class:opacity-50="$disabled.value">Maybe dim</div>
<button hs-attr:disabled="!$valid.value">Submit</button>
<input hs-attr:readonly={isLocked.is(true)} />
<div hs-style:background-color="$color.value">Colored</div>
<div hs-style:width="$progress.value + '%'">Bar</div>
<span hs-text="$name.value"></span>
<div hs-html="$htmlContent.value"></div>
<input hs-ref="myInput" />
<!-- Access via: $refs.myInput.focus() -->
<div hs-init="console.log('element created')">...</div>
<input hs-bind="text" />
<input $={hs.bind(text)} /> <!-- Equivalent -->
Background Jobs: Repeat vs Cron
| Type | Best For | Key Feature |
|---|
| Repeat | Games, animations, heartbeats, polling | when condition + trackFps |
| Cron | Scheduled jobs | Cron syntax + forEachUser |
Repeat - Time-based repeating tasks (replaces timer + interval):
app.repeat("gameLoop", {
every: 16,
when: (s) => s.running,
trackFps: true,
handler: (ctx) => {
ctx.update((s) => ({ ...s, frame: s.frame + 1, fps: ctx.fps }))
},
})
app.repeat("heartbeat", {
every: "5 seconds",
handler: (ctx) => {
ctx.update((s) => ({ ...s, lastPing: Date.now() }))
},
})
Cron - Scheduled jobs:
app.cron("cleanup", {
every: "0 * * * *",
handler: (ctx) => {
ctx.update((s) => ({ ...s, messages: s.messages.slice(-100) }))
},
})
app.cron("sessionSync", {
every: "30 seconds",
forEachUser: (ctx) => {
ctx.updateUser((u) => ({ ...u, lastSeen: Date.now() }))
},
})
Triggers (Store Change Watchers)
React to specific store value changes:
app.trigger("count-watcher", {
watch: (s) => s.count,
handler: (ctx, { oldValue, newValue }) => {
console.log(`Count changed: ${oldValue} → ${newValue}`)
},
})
app.userTrigger("theme-changed", {
watch: (u) => u.theme,
handler: (ctx, { oldValue, newValue, sessionId }) => {
console.log(`User ${sessionId} changed theme`)
},
})
HTTP Endpoints
Add custom HTTP routes:
app.http("/api/status", (ctx) => {
return Response.json({ status: "ok", count: ctx.getStore().count })
})
app.http("/webhook", {
method: "POST",
handler: async (ctx) => {
const body = await ctx.request.json()
ctx.update((s) => ({ ...s, lastWebhook: body }))
return Response.json({ received: true })
},
})
Lifecycle Hooks
app.app({
store: { online: 0 },
onStart: (ctx) => {
console.log("Server started")
},
onConnect: (ctx) => {
ctx.update((s) => ({ ...s, online: s.online + 1 }))
},
onDisconnect: (ctx) => {
ctx.update((s) => ({ ...s, online: s.online - 1 }))
},
view: (ctx) => ...
})
Persistence
app.app({
store: { todos: [] },
persist: "./data/todos.json",
view: (ctx) => ...
})
app.app({
store: { todos: [] },
persist: {
path: "./data/todos.json",
debounceMs: 200,
},
view: (ctx) => ...
})
Dynamic Title and Favicon
app.app({
store: { unreadCount: 0, status: "idle" },
title: ({ store }) =>
store.unreadCount > 0 ? `(${store.unreadCount}) My App` : "My App",
favicon: ({ store }) =>
store.status === "active"
? "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🟢</text></svg>"
: "/favicon.ico",
view: ...
})
const notify = app.action("notify", (ctx) => {
ctx.head.setTitle("New message!")
ctx.head.setFavicon("/alert.ico")
})
SQLite (Direct Disk Access)
Since Hyperstar runs on a single server, use bun:sqlite directly:
import { Database } from "bun:sqlite"
import { createHyperstar, Schema } from "hyperstar"
const db = new Database("./data/app.db")
db.run(`CREATE TABLE IF NOT EXISTS items (id TEXT PRIMARY KEY, name TEXT)`)
interface Store { refresh: number }
const app = createHyperstar<Store>()
const addItem = app.action("addItem", { name: Schema.String }, (ctx, { name }) => {
db.run("INSERT INTO items (id, name) VALUES (?, ?)", [crypto.randomUUID(), name])
ctx.update((s) => ({ ...s, refresh: s.refresh + 1 }))
})
app.app({
store: { refresh: 0 },
view: (ctx) => {
const items = db.query("SELECT * FROM items").all()
return <div id="app">{items.map((i: any) => <div id={i.id}>{i.name}</div>)}</div>
},
})
App Configuration
app.app({
store: { count: 0 },
userStore: { theme: "light" },
signals: { text: "" },
view: (ctx) => <div id="app">...</div>,
title: "Static" | ({ store, userStore }) => "Dynamic",
favicon: "/icon.ico" | ({ store }) => "/dynamic.ico",
persist: "./data.json" | { path: "...", debounceMs: 100 },
autoPauseWhenIdle: true,
onStart: (ctx) => { },
onConnect: (ctx) => { },
onDisconnect: (ctx) => { },
}).serve({
port: 8080,
hostname: "0.0.0.0",
})
Important Rules
- Root element must have
id="app"
- Use ctx.update() for immutable store updates -
ctx.update((s) => ({ ...s, ... }))
- All clients share store - Changes broadcast to everyone
- Signals are client-only - Perfect for UI state
- Define actions before using in view - Actions are variables
Common Patterns
Tabs (instant, no server roundtrip)
interface Signals { tab: "home" | "settings" }
const app = createHyperstar<{}, {}, Signals>()
const { tab } = app.signals
app.app({
store: {},
signals: { tab: "home" },
view: () => (
<div id="app">
<button hs-on:click="$tab.value = 'home'">Home</button>
<button hs-on:click="$tab.value = 'settings'">Settings</button>
<div hs-show={tab.is("home")}>Home content</div>
<div hs-show={tab.is("settings")}>Settings content</div>
</div>
),
})
Hybrid filtering (server data + client filter)
const { filter } = app.signals
{ctx.store.todos.map((todo) => (
<div
id={`todo-${todo.id}`}
hs-show={filter.is("all")
.or(filter.is("active").and(!todo.done))
.or(filter.is("done").and(todo.done))
}
>
{todo.text}
</div>
))}
Form with signal clearing
const { text } = app.signals
const addItem = app.action("addItem", { text: Schema.String }, (ctx, { text: t }) => {
ctx.update((s) => ({
...s,
items: [...s.items, { id: crypto.randomUUID(), text: t }],
}))
ctx.patchSignals({ text: "" })
})
<form $={hs.form(addItem)}>
<input name="text" $={hs.bind(text)} />
<button type="submit">Add</button>
</form>
Edit mode with nullable signal
interface Signals {
editingId: string | null
editText: string
}
const { editingId, editText } = app.signals
const saveEdit = app.action("saveEdit", { id: Schema.String, editText: Schema.String }, (ctx, { id, editText }) => {
ctx.update((s) => ({
...s,
items: s.items.map((i) => (i.id === id ? { ...i, text: editText } : i)),
}))
ctx.patchSignals({ editingId: null, editText: "" })
})
<div hs-show={editingId.isNot(item.id)}>
{item.text}
<button hs-on:click={`$editingId.value = '${item.id}'; $editText.value = '${item.text}'`}>
Edit
</button>
</div>
<form hs-show={editingId.is(item.id)} $={hs.form(saveEdit, { id: item.id })}>
<input name="editText" $={hs.bind(editText)} />
<button type="submit">Save</button>
<button type="button" hs-on:click="$editingId.value = null">Cancel</button>
</form>
Real-time chat
interface Message { id: string; username: string; text: string }
interface Store { messages: Message[] }
interface Signals { username: string; text: string }
const app = createHyperstar<Store, {}, Signals>()
const { username, text } = app.signals
const sendMessage = app.action("sendMessage", {
username: Schema.String.pipe(Schema.minLength(1)),
text: Schema.String.pipe(Schema.minLength(1)),
}, (ctx, { username: user, text: msg }) => {
ctx.update((s) => ({
...s,
messages: [...s.messages, {
id: crypto.randomUUID(),
username: user,
text: msg,
}].slice(-100),
}))
ctx.patchSignals({ text: "" })
})
<form $={hs.form(sendMessage)}>
<input name="username" $={hs.bind(username)} placeholder="Name" />
<input name="text" $={hs.bind(text)} placeholder="Message" />
<button
type="submit"
hs-show={username.isNotEmpty().and(text.isNotEmpty())}
>
Send
</button>
</form>
Session-based voting
interface Store {
options: { id: string; text: string; votes: number }[]
voters: Record<string, true>
}
const vote = app.action("vote", { optionId: Schema.String }, (ctx, { optionId }) => {
const store = ctx.getStore()
if (ctx.sessionId in store.voters) return
ctx.update((s) => ({
...s,
options: s.options.map((o) =>
o.id === optionId ? { ...o, votes: o.votes + 1 } : o
),
voters: { ...s.voters, [ctx.sessionId]: true },
}))
})
const hasVoted = ctx.session.id in ctx.store.voters
{ctx.store.options.map((option) =>
hasVoted
? <div>{option.text}: {option.votes} votes</div>
: <button $={hs.action(vote, { optionId: option.id })}>{option.text}</button>
)}
Drag and drop (Kanban style)
const moveCard = app.action("moveCard", {
cardId: Schema.String,
targetColumnId: Schema.String,
}, (ctx, { cardId, targetColumnId }) => {
ctx.update((s) => ({
...s,
cards: s.cards.map((c) =>
c.id === cardId ? { ...c, columnId: targetColumnId } : c
),
}))
})
<div $={hs.drag("cardId", card.id)}>
{card.title}
</div>
<div $={hs.drop(moveCard, "cardId", "cardId", { targetColumnId: column.id })}>
{column.cards.map((card) => ...)}
</div>
Debounced search
const { query } = app.signals
const search = app.action("search", { q: Schema.String }, async (ctx, { q }) => {
ctx.update((s) => ({ ...s, loading: true }))
const results = await fetchResults(q)
ctx.update((s) => ({ ...s, results, loading: false }))
})
<input
$={hs.bind(query).actionOn("input", search, { q: query }, { debounce: 300 })}
placeholder="Search..."
/>
Sequencing expressions
const { text, filter } = app.signals
<button
$={hs.on("click", hs.seq(
text.set(""),
filter.set("all"),
hs.expr("$refs.input.focus()")
))}
>
Reset
</button>
<input $={hs.ref("input").bind(text)} />