Skip to content

Repository files navigation

Hyperstar

Hyperstar

Discord

Caution

Very Beta - This is experimental software. The API changes frequently and will break your code. Don't use this for anything production-critical. Great for prototypes, internal tools, and fun real-time multiplayer apps.

Server-driven UI for real-time web apps. No client code. No state sync. Just TypeScript + JSX.


Demo: Multiplayer Grid Game

Grid Game Demo

Try it live | View source

A multiplayer territory control game in ~300 lines of code. Click cells to claim them for your team - all players see changes in real-time.


Inspired by Phoenix LiveView | Datastar | HTMX

Tip

Built for Vibe Coding - JSX that feels like React, but there's no client bundle, no hydration, no state sync bugs. The server owns everything. Live, realtime UI, directly from the server. When you use bunx hyperstar-cli create, your project includes a Claude Code skill that teaches Claude how to build Hyperstar apps. If you're not using the CLI, you can copy the skill from packages/cli/skill/SKILL.md into your project's .claude/skills/ directory.

Quick Start

bunx hyperstar-cli create my-app
cd my-app
bun install
bun run dev

Open http://localhost:8080 - you have a working app.

Now edit app.tsx:

import{createHyperstar,hs}from"hyperstar"interfaceStore{count: number}constapp=createHyperstar<Store>()constincrement=app.action("increment",(ctx)=>{ctx.update((s)=>({ ...s,count: s.count+1}))})app.app({store: {count: 0},view: (ctx)=>(<divid="app"><h1>{ctx.store.count}</h1><button$={hs.action(increment)}>+1</button></div>),}).serve({port: 8080})

Open multiple tabs - they all sync in real-time.

Ready to ship? Deploy to Fly.io Sprites:

bunx hyperstar-cli deploy --managed

Your app is live. No Docker, no config, no CI/CD setup.


Why Hyperstar?

Zero client code. Your entire app lives on the server. No React components, no client-side state management, no API routes to wire up. Just TypeScript functions that update state and JSX that renders it.

Real-time by default. Every state change automatically syncs to all connected clients. User A clicks a button, User B sees it instantly. No WebSocket setup, no pub/sub configuration, no cache invalidation.

One mental model. Server state is the source of truth. No wondering if the client is out of sync, no optimistic updates gone wrong, no race conditions between tabs.

Ship faster. Internal tools, prototypes, multiplayer games, live dashboards - build them in seconds instead of hours. When the server owns everything, there's just less to think about.


Installation

bun add hyperstar

How It Works

  1. Server renders HTML from your view function
  2. Clients connect via SSE (Server-Sent Events)
  3. When state changes, server re-renders and streams HTML
  4. Clients morph the DOM - no full page reload

Real-time means all clients see the same state. User A clicks a button, User B sees it instantly.


Understanding State

Hyperstar has three types of state, each serving a different purpose:

┌─────────────────────────────────────────────────────────────────┐
│ STORE (Global Server State) │
│ • Shared across ALL connected clients │
│ • User A adds item → User B sees it instantly │
│ • Use for: shared data, chat messages, game state │
├─────────────────────────────────────────────────────────────────┤
│ USERSTORE (Per-Session Server State) │
│ • Private to each browser session │
│ • Persists on server across page reloads │
│ • Use for: user preferences, theme, auth state │
├─────────────────────────────────────────────────────────────────┤
│ SIGNALS (Client State) │
│ • Lives in browser memory only │
│ • Instant updates, no server roundtrip │
│ • Server can update via ctx.patchSignals() │
│ • Use for: form inputs, UI tabs, modals, hover state │
└─────────────────────────────────────────────────────────────────┘
// All three in actionconstapp=createHyperstar<Store,UserStore,Signals>()// Global: everyone sees thisconstaddMessage=app.action("addMessage",{text: Schema.String},(ctx,{ text })=>{ctx.update((s)=>({ ...s,messages: [...s.messages,text]}))})// Per-user: only this session sees thisconstsetTheme=app.action("setTheme",{theme: Schema.String},(ctx,{ theme })=>{ctx.updateUserStore((u)=>({ ...u, theme }))})// Client-side: instant UI state (server can also update via ctx.patchSignals)const{ tab }=app.signals<buttonhs-on:click="$tab.value = 'settings'">Settings</button>

Core API

Actions

Actions modify state. Changes broadcast to all clients.

// Simple actionconstreset=app.action("reset",(ctx)=>{ctx.update((s)=>({ ...s,count: 0}))})// Action with validated argumentsconstadd=app.action("add",{amount: Schema.Number},(ctx,{ amount })=>{ctx.update((s)=>({ ...s,count: s.count+amount}))})

The hs Namespace

Use hs.* helpers with the $ prop for reactive behavior:

// Trigger action on click<button$={hs.action(increment)}>+1</button>// Trigger action on a specific event (with modifiers)<input$={hs.actionOn("input",search,{q: query},{debounce: 200})}/>// Form submission<form$={hs.form(addTodo)}><inputname="text"$={hs.bind(text)}/><buttontype="submit">Add</button></form>// Conditional visibility<div$={hs.show(isVisible)}>Onlyshownwhenvisible</div>// Dynamic classes<div$={hs.class("active",isActive)}>...</div>

Signals (Client State)

Signals are client-side state - private to each browser tab, instant updates, no server roundtrip.

interfaceSignals{tab: "home"|"settings"text: string}constapp=createHyperstar<Store,{},Signals>()const{ tab, text }=app.signalsapp.app({store: { ... },signals: {tab: "home",text: ""},view: (ctx)=>(<divid="app">{/* Instant tab switching - no server call */}<buttonhs-on:click="$tab.value = 'home'">Home</button><buttonhs-on:click="$tab.value = 'settings'">Settings</button>{/* Show/hide based on signal */}<divhs-show={tab.is("home")}>Home content</div><divhs-show={tab.is("settings")}>Settings content</div></div>),})

Examples

git clone https://github.com/StreamUI/hyperstar
cd hyperstar && bun install
bun --hot examples/counter.tsx # Basic counter
bun --hot examples/todos.tsx # Full todo app
bun --hot examples/chat-room.tsx # Multi-user chat
bun --hot examples/dsl-showcase.tsx # DSL helpers showcase
bun --hot examples/dashboard.tsx # Live metrics dashboard
bun --hot examples/sqlite-notes.tsx # SQLite persistence
bun --hot examples/state-types.tsx # Store vs UserStore vs Signals
bun --hot examples/fps.tsx # 60fps game loop
bun --hot examples/tron/app.tsx # Multiplayer Tron

Deployment

Hyperstar apps are just Bun servers - deploy anywhere you can run bun run app.tsx. The CLI has built-in support for Fly.io Sprites, lightweight VMs that hibernate when idle.

Create a hyperstar.json in your project root:

{
"name": "my-app",
"entrypoint": "app.tsx"
}

Managed Hosting (Quick Start)

Warning

Managed hosting is for quick testing only. It may be taken down at any time without notice. Do not deploy anything critical. Use your own Sprites token for persistent deployments.

bunx hyperstar-cli deploy --managed

No account needed. Deploy instantly.

Self-Deploy (Recommended)

For full control, use your own Fly.io Sprites token:

export SPRITE_TOKEN=your_token
bunx hyperstar-cli deploy

Note

Sprites and Background Tasks - Sprites hibernate when idle to save costs. When a sprite sleeps, app.repeat() and app.cron() timers pause. They resume when a user reconnects. For apps that need always-on timers (like polling external APIs), deploy to a traditional always-on server instead.

Other Platforms

Since Hyperstar apps are standard Bun servers, deploy anywhere:

# Any VM/VPS
bun install && bun run app.tsx
# Docker
FROM oven/bun
COPY ..
RUN bun install
CMD ["bun", "run", "app.tsx"]

Advanced Features

Async Actions

Actions can be async for API calls, streaming, etc:

constfetchData=app.action("fetchData",async(ctx)=>{ctx.update((s)=>({ ...s,loading: true}))constdata=awaitfetch("/api/data").then((r)=>r.json())ctx.update((s)=>({ ...s, data,loading: false}))})

Background Jobs: Repeat vs Cron

Hyperstar has two ways to run background tasks. Choose based on your use case:

TypeBest ForKey Feature
RepeatGames, animations, heartbeats, pollingConditional execution + FPS tracking
CronScheduled jobsCron expressions + per-user handlers

Repeat - Time-based repeating tasks (replaces timer + interval):

// Game loop with FPS trackingapp.repeat("gameLoop",{every: 16,// ~60fps in millisecondswhen: (s)=>s.running,// Only run when true (pause/resume)trackFps: true,// Enables ctx.fpshandler: (ctx)=>{ctx.update((s)=>({ ...s,frame: s.frame+1,fps: ctx.fps}))},})// Simple heartbeatapp.repeat("heartbeat",{every: "5 seconds",// Duration stringhandler: (ctx)=>{ctx.update((s)=>({ ...s,lastPing: Date.now()}))},})// Duration formats:app.repeat("fast",{every: 100, ... })// 100ms (number)app.repeat("poll",{every: "500 millis", ... })// 500msapp.repeat("sync",{every: "30 seconds", ... })// 30sapp.repeat("refresh",{every: "5 minutes", ... })// 5mapp.repeat("report",{every: "1 hour", ... })// 1h

Cron - Scheduled jobs with calendar-based timing:

app.cron("cleanup",{every: "0 * * * *",// Every hour (cron syntax)handler: (ctx)=>{ctx.update((s)=>({ ...s,messages: s.messages.slice(-100)}))},})// Or run per-user (great for session cleanup):app.cron("sessionSync",{every: "30 seconds",// Also accepts duration stringsforEachUser: (ctx)=>{ctx.updateUser((u)=>({ ...u,lastSeen: Date.now()}))},})

Lifecycle Hooks

app.app({store: {online: 0},onStart: (ctx)=>{// Called once when server startsconsole.log("Server started")},onConnect: (ctx)=>{// Called when a client connectsctx.update((s)=>({ ...s,online: s.online+1}))},onDisconnect: (ctx)=>{// Called when a client disconnectsctx.update((s)=>({ ...s,online: s.online-1}))},view: (ctx)=> ...
})

Persistence

Auto-save store to JSON:

app.app({store: {todos: []},persist: "./data/todos.json",view: (ctx)=> ...
})

SQLite (Direct Disk Access)

Since Hyperstar runs on a single server (perfect for lightweight VMs like Sprites), you can read/write directly to disk. No external database needed:

import{Database}from"bun:sqlite"import{createHyperstar,hs,Schema}from"hyperstar"constdb=newDatabase("./data/notes.db")db.run(`CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')))`)interfaceStore{refresh: number}constapp=createHyperstar<Store>()constaddNote=app.action("addNote",{title: Schema.String},(ctx,{ title })=>{db.run("INSERT INTO notes (id, title) VALUES (?, ?)",[crypto.randomUUID(),title])ctx.update((s)=>({ ...s,refresh: s.refresh+1}))// Trigger re-render})app.app({store: {refresh: 0},view: (ctx)=>{constnotes=db.query("SELECT * FROM notes ORDER BY created_at DESC").all()return(<divid="app"><ul>{notes.map((n: any)=><liid={n.id}>{n.title}</li>)}</ul></div>)},}).serve({port: 8080})

No ORM, no connection pooling, no Redis - just bun:sqlite. This works because:

  • Hyperstar apps run on a single server instance
  • Bun's SQLite is synchronous and fast

Dynamic Title and Favicon

app.app({store: {unreadCount: 0,status: "idle"},// Dynamic title with notification badgetitle: ({ store })=>store.unreadCount>0 ? `(${store.unreadCount}) My App` : "My App",// Dynamic favicon based on statusfavicon: ({ 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: (ctx)=> ...
})// Or update programmatically in actions:constnotify=app.action("notify",(ctx)=>{ctx.head.setTitle("New message!")ctx.head.setFavicon("/alert.ico")})

Signal Expression Composition

Signals have typed methods that compose:

const{ filter, count, isOpen }=app.signals// Methods based on typefilter.is("active")// "$filter.value === 'active'"count.gt(5)// "$count.value > 5"isOpen.toggle()// "$isOpen.value = !$isOpen.value"// Compositionfilter.is("active").and(count.gt(0))filter.is("all").or(isOpen)isOpen.not()

UserStore (Per-Session Server State)

State that's private to each session but persists on the server:

interfaceUserStore{theme: "light"|"dark"}constapp=createHyperstar<Store,UserStore,Signals>()constsetTheme=app.action("setTheme",{theme: Schema.String},(ctx,{ theme })=>{ctx.updateUserStore((u)=>({ ...u, theme }))})app.app({store: { ... },userStore: {theme: "light"},view: (ctx)=>(<divclass={ctx.userStore.theme==="dark" ? "dark" : ""}>...</div>),})

Patterns

Countdown Timer

A timer that counts down and stops at zero:

import{createHyperstar,hs}from"hyperstar"interfaceStore{timeLeft: numberrunning: boolean}constapp=createHyperstar<Store>()conststart=app.action("start",(ctx)=>{ctx.update((s)=>({ ...s,running: true,timeLeft: 60}))})conststop=app.action("stop",(ctx)=>{ctx.update((s)=>({ ...s,running: false}))})app.repeat("countdown",{every: "1 second",when: (s)=>s.running&&s.timeLeft>0,handler: (ctx)=>{ctx.update((s)=>{constnewTime=s.timeLeft-1return{ ...s,timeLeft: newTime,running: newTime>0}})},})app.app({store: {timeLeft: 60,running: false},view: (ctx)=>(<divid="app"class="p-8 text-center"><divclass="text-6xl font-mono mb-4">{ctx.store.timeLeft}s</div>{!ctx.store.running ? (<button$={hs.action(start)}class="px-4 py-2 bg-green-500 text-white rounded">
Start
</button>) : (<button$={hs.action(stop)}class="px-4 py-2 bg-red-500 text-white rounded">
Stop
</button>)}</div>),}).serve({port: 8080})

Live API Polling

Fetch data from an external API on an interval:

import{createHyperstar}from"hyperstar"interfaceStore{price: number|nulllastUpdated: string|nullerror: string|null}constapp=createHyperstar<Store>()app.repeat("fetchPrice",{every: "10 seconds",handler: async(ctx)=>{try{constres=awaitfetch("https://api.example.com/price")const{ price }=awaitres.json()ctx.update((s)=>({
...s,
price,lastUpdated: newDate().toISOString(),error: null,}))}catch(e){ctx.update((s)=>({ ...s,error: "Failed to fetch price"}))}},})app.app({store: {price: null,lastUpdated: null,error: null},view: (ctx)=>(<divid="app"class="p-8">{ctx.store.error ? (<divclass="text-red-500">{ctx.store.error}</div>) : (<><divclass="text-4xl">${ctx.store.price??"..."}</div><divclass="text-gray-500 text-sm">
Updated: {ctx.store.lastUpdated??"never"}</div></>)}</div>),}).serve({port: 8080})

Typing Indicator

Show who's typing with auto-expiry:

import{createHyperstar,hs,Schema}from"hyperstar"interfaceTypingUser{name: stringtimestamp: number}interfaceStore{messages: {id: string;name: string;text: string}[]typing: TypingUser[]}interfaceSignals{name: stringtext: string}constapp=createHyperstar<Store,{},Signals>()const{ name, text }=app.signalsconstsendMessage=app.action("send",{name: Schema.String,text: Schema.String},(ctx,args)=>{ctx.update((s)=>({
...s,messages: [...s.messages,{id: crypto.randomUUID(), ...args}],typing: s.typing.filter((t)=>t.name!==args.name),}))ctx.patchSignals({text: ""})})constsetTyping=app.action("typing",{name: Schema.String},(ctx,{ name })=>{ctx.update((s)=>({
...s,typing: [
...s.typing.filter((t)=>t.name!==name),{ name,timestamp: Date.now()},],}))})// Auto-clear stale typing indicatorsapp.repeat("clearTyping",{every: "1 second",handler: (ctx)=>{constnow=Date.now()ctx.update((s)=>({
...s,typing: s.typing.filter((t)=>now-t.timestamp<3000),}))},})app.app({store: {messages: [],typing: []},signals: {name: "",text: ""},view: (ctx)=>(<divid="app"class="p-4 max-w-md mx-auto"><divclass="space-y-2 mb-4">{ctx.store.messages.map((m)=>(<divid={m.id}><b>{m.name}:</b>{m.text}</div>))}</div>{ctx.store.typing.length>0&&(<divclass="text-gray-500 text-sm italic mb-2">{ctx.store.typing.map((t)=>t.name).join(", ")} typing...
</div>)}<form$={hs.form(sendMessage)}class="flex gap-2"><inputname="name"$={hs.bind(name)}placeholder="Name"class="border p-2 w-24"/><inputname="text"$={hs.bind(text)}placeholder="Message"class="border p-2 flex-1"hs-on:input={`Hyperstar.dispatch('typing', { name: $name.value })`}/><buttontype="submit"class="bg-blue-500 text-white px-4">Send</button></form></div>),}).serve({port: 8080})

Smooth Animation

Animate values smoothly with easing:

import{createHyperstar,hs,Schema}from"hyperstar"interfaceStore{current: numbertarget: number}constapp=createHyperstar<Store>()constsetTarget=app.action("setTarget",{value: Schema.Number},(ctx,{ value })=>{ctx.update((s)=>({ ...s,target: value}))})app.repeat("animate",{every: 16,// ~60fpswhen: (s)=>Math.abs(s.current-s.target)>0.5,handler: (ctx)=>{ctx.update((s)=>({
...s,current: s.current+(s.target-s.current)*0.1,// Easing}))},})app.app({store: {current: 0,target: 0},view: (ctx)=>(<divid="app"class="p-8"><divclass="w-16 h-16 bg-blue-500 rounded-lg transition-none"style={`transform: translateX(${ctx.store.current}px)`}/><divclass="mt-8 space-x-2"><button$={hs.action(setTarget,{value: 0})}class="px-4 py-2 bg-gray-200 rounded">
Left
</button><button$={hs.action(setTarget,{value: 200})}class="px-4 py-2 bg-gray-200 rounded">
Right
</button></div><divclass="mt-4 text-gray-500">
Position: {Math.round(ctx.store.current)}px
</div></div>),}).serve({port: 8080})

Session Cleanup with Cron

Clean up inactive sessions periodically:

import{createHyperstar}from"hyperstar"interfaceStore{activeSessions: number}interfaceUserStore{lastActivity: number}constapp=createHyperstar<Store,UserStore>()// Update activity timestamp on any actionconstping=app.action("ping",(ctx)=>{ctx.updateUserStore((u)=>({ ...u,lastActivity: Date.now()}))})// Clean up inactive sessions every minuteapp.cron("sessionCleanup",{every: "1 minute",forEachUser: (ctx)=>{constinactiveFor=Date.now()-ctx.getUserStore().lastActivityif(inactiveFor>5*60*1000){// 5 minutes inactiveconsole.log(`Session ${ctx.sessionId} inactive, cleaning up...`)// Perform cleanup logic here}},})app.app({store: {activeSessions: 0},userStore: {lastActivity: Date.now()},onConnect: (ctx)=>ctx.update((s)=>({ ...s,activeSessions: s.activeSessions+1})),onDisconnect: (ctx)=>ctx.update((s)=>({ ...s,activeSessions: s.activeSessions-1})),view: (ctx)=>(<divid="app"class="p-8"><div>Active sessions: {ctx.store.activeSessions}</div></div>),}).serve({port: 8080})

Philosophy

Hyperstar is for building simple, real-time apps fast. It's not trying to replace Next.js or Rails.

Great for: Internal tools, prototypes, multiplayer games, live dashboards, chat apps.

Not built for: Multi-page SEO sites, offline-first apps, complex client-side interactions.


License

MIT

About

Server-driven UI for real-time web apps. No client code, no state sync, just TypeScript & JSX on the server, and every client sees the same thing

Topics

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages