Version: @fluxstack/live 0.7.2 | Updated: 2026-04-14
- Server-side state management with WebSocket sync
- Direct state access -
this.count++auto-syncs via reactive proxy - Lifecycle hooks -
onMount(),onDestroy(),onConnect(),onDisconnect(), and more (all optional) - HMR persistence -
static persistent+this.$persistentsurvives hot reloads - Singleton components -
static singleton = truefor shared server-side instances - Mandatory
publicActions- Only whitelisted methods are callable from client (secure by default) - Helpful error messages - Forgotten
publicActionsentries show exactly what to fix - Custom ID generator -
LiveServerOptions.generateIdreplaces default ID generation - Automatic state persistence and re-hydration (with anti-replay nonces)
- Room-based event system for multi-user sync (typed
LiveRoomsupport) - Type-safe client-server communication (
FluxStackWebSocket) - Built-in connection management and recovery
- Client component links - Ctrl+Click navigation via
import type
Server-side component extends LiveComponent from @fluxstack/live with static defaultState:
// app/server/live/LiveLocalCounter.ts
import { LiveComponent } from '@core/types/types'
// Componente Cliente (Ctrl+Click para navegar)
import type { CounterDemo as _Client } from '@client/src/live/CounterDemo'
export class LiveLocalCounter extends LiveComponent<typeof LiveLocalCounter.defaultState> {
static componentName = 'LiveLocalCounter'
static publicActions = ['increment', 'decrement', 'reset'] as const // REQUIRED
static defaultState = {
count: 0,
clicks: 0
}
// Declarar propriedades do estado (TypeScript)
declare count: number
declare clicks: number
// Direct state access - auto-syncs with frontend
async increment() {
this.count++
this.clicks++
return { success: true, count: this.count }
}
async decrement() {
this.count--
this.clicks++
return { success: true, count: this.count }
}
async reset() {
this.count = 0
this.clicks++
return { success: true, count: 0 }
}
}- Direct state access -
this.count++instead ofthis.state.count++ declarekeyword - TypeScript hint for dynamic state properties- Static
defaultStateinside the class - no external export needed - Reactive Proxy -
this.state.count++orthis.count++triggers sync automatically - No constructor needed - Base class handles
defaultStatemerge (constructor only needed for room event subscriptions) - Mandatory
publicActions- Components without it deny ALL remote actions (secure by default) - Client link -
import type { Demo as _Client }enables Ctrl+Click in IDE
// app/server/live/LiveCounter.ts
import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
import { CounterRoom } from './rooms/CounterRoom'
export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> {
static componentName = 'LiveCounter'
static publicActions = ['increment', 'decrement', 'reset'] as const
static defaultState = {
count: 0,
lastUpdatedBy: null as string | null,
connectedUsers: 0
}
private roomId: string
private unsubscribeCounter: (() => void) | null = null
private unsubscribePresence: (() => void) | null = null
constructor(
initialState: Partial<typeof LiveCounter.defaultState> = {},
ws: FluxStackWebSocket,
options?: { room?: string; userId?: string }
) {
super(initialState, ws, options)
this.roomId = options?.room ?? 'default'
const room = this.$room(CounterRoom, this.roomId)
room.join()
// Load authoritative room state for new joiners.
this.setState({
count: room.state.count,
lastUpdatedBy: room.state.lastUpdatedBy,
connectedUsers: room.state.onlineCount
})
this.unsubscribeCounter = room.on('counter:updated', (data) => {
this.setState({
count: data.count,
lastUpdatedBy: data.updatedBy
})
})
this.unsubscribePresence = room.on('presence:changed', (data) => {
this.setState({ connectedUsers: data.onlineCount })
})
}
async increment() {
const room = this.$room(CounterRoom, this.roomId)
const count = room.increment(this.userId || 'anonymous')
return { success: true, count }
}
async decrement() {
const room = this.$room(CounterRoom, this.roomId)
const count = room.decrement(this.userId || 'anonymous')
return { success: true, count }
}
async reset() {
const room = this.$room(CounterRoom, this.roomId)
const count = room.reset(this.userId || 'anonymous')
return { success: true, count }
}
destroy() {
this.unsubscribeCounter?.()
this.unsubscribePresence?.()
super.destroy()
}
}Presence must be tracked by authoritative room state, not by incrementing each component instance's local connectedUsers. A new tab creates a new component instance with local state starting at 0, so local arithmetic can publish the wrong count. Put membership accounting in the LiveRoom lifecycle:
// app/server/live/rooms/CounterRoom.ts
interface CounterEvents {
'counter:updated': { count: number; updatedBy: string }
'presence:changed': { onlineCount: number }
}
export class CounterRoom extends LiveRoom<CounterState, {}, CounterEvents> {
static roomName = 'counter'
static defaultState = { count: 0, lastUpdatedBy: null, onlineCount: 0 }
static defaultMeta = {}
onJoin() {
const onlineCount = this.state.onlineCount + 1
this.setState({ onlineCount })
this.emit('presence:changed', { onlineCount })
}
onLeave() {
const onlineCount = Math.max(0, this.state.onlineCount - 1)
this.setState({ onlineCount })
this.emit('presence:changed', { onlineCount })
}
increment(username: string) {
const count = this.state.count + 1
this.setState({ count, lastUpdatedBy: username })
this.emit('counter:updated', { count, updatedBy: username })
return count
}
}The @fluxstack/live framework provides a full lifecycle hook system. All hooks are optional -- override only what you need. The example components in app/server/live/ do not use all of them, but they are all available in the framework API.
export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
static componentName = 'MyComponent'
static publicActions = ['doWork'] as const
static defaultState = { users: [] as string[], ready: false, currentRoom: '' }
private _pollTimer?: NodeJS.Timeout
// 1. Called when WebSocket connection is established (before onMount)
protected onConnect() {
console.log('WebSocket connected for this component')
}
// 2. Called AFTER component is fully mounted (rooms, auth, injections ready)
// Can be async!
protected async onMount() {
this.$room('main').join()
const data = await fetchInitialData(this.$auth.session?.id)
this.state.ready = true
this._pollTimer = setInterval(() => this.poll(), 5000)
}
// Called after state is restored from localStorage (rehydration)
protected onRehydrate(previousState: typeof MyComponent.defaultState) {
if (!previousState.ready) {
this.state.ready = false // Re-validate stale state
}
}
// Called after any state mutation (proxy or setState)
protected onStateChange(changes: Partial<typeof MyComponent.defaultState>) {
if ('users' in changes) {
console.log(`User count: ${this.state.users.length}`)
}
}
// Called when joining a room
protected onRoomJoin(roomId: string) {
this.state.currentRoom = roomId
}
// Called when leaving a room
protected onRoomLeave(roomId: string) {
if (this.state.currentRoom === roomId) this.state.currentRoom = ''
}
// Called before each action -- return false to cancel
protected onAction(action: string, payload: any) {
console.log(`[${this.id}] ${action}`, payload)
// return false // would cancel the action
}
// Called when a new client joins a singleton component
protected onClientJoin(connectionId: string, connectionCount: number) {
console.log(`Client ${connectionId} joined, total: ${connectionCount}`)
}
// Called when a client leaves a singleton component
protected onClientLeave(connectionId: string, connectionCount: number) {
console.log(`Client ${connectionId} left, total: ${connectionCount}`)
}
// Called when WebSocket drops (NOT on intentional unmount)
protected onDisconnect() {
console.log('Connection lost -- saving recovery data')
}
// Called BEFORE internal cleanup (sync only)
protected onDestroy() {
clearInterval(this._pollTimer)
}
async doWork() { /* ... */ }
private poll() { /* ... */ }
}Note: The example components in
app/server/live/are intentionally simple and do not use most lifecycle hooks. This does not mean the hooks are unavailable -- they are all part of the@fluxstack/liveframework API and can be used in any LiveComponent subclass.
WebSocket connects
-> onConnect()
-> onMount() <- async, rooms/auth ready
-> [component active]
|-> onAction(action, payload) <- before each action (return false to cancel)
|-> onStateChange(changes) <- after each state mutation
|-> onRoomJoin(roomId) <- when joining a room
|-> onRoomLeave(roomId) <- when leaving a room
|-> onClientJoin(connId, count) <- singleton: new client joined
-> onClientLeave(connId, count) <- singleton: client left
Connection drops:
-> onDisconnect() <- only on unexpected disconnect
-> onDestroy() <- sync, before internal cleanup
Rehydration (reconnect with saved state):
-> onConnect()
-> onRehydrate(previousState)
-> onMount()
| Hook | Async? | When |
|---|---|---|
onConnect() |
No | WebSocket established, before mount |
onMount() |
Yes | After all setup (rooms, auth, DI) |
onRehydrate(prevState) |
No | After state restored from localStorage |
onStateChange(changes) |
No | After every state mutation |
onRoomJoin(roomId) |
No | After $room.join() |
onRoomLeave(roomId) |
No | After $room.leave() |
onAction(action, payload) |
Yes | Before action execution (return false to cancel) |
onClientJoin(connId, count) |
No | Singleton: new client connected |
onClientLeave(connId, count) |
No | Singleton: client disconnected |
onDisconnect() |
No | Connection lost (NOT intentional unmount) |
onDestroy() |
No | Before internal cleanup |
- All hooks are optional -- override only what you need
- All hook errors are caught and logged -- they never break the system
- Constructor is still needed ONLY for room setup/subscriptions (
this.$room(...).join(),room.on(...), or legacythis.onRoomEvent()) - All hooks are in BLOCKED_ACTIONS -- clients cannot call them remotely
The LiveServer accepts a generateId option that replaces the default ID generation for component IDs, connection IDs, and cluster singleton IDs:
import { LiveServer } from '@fluxstack/live'
import { nanoid } from 'nanoid'
const server = new LiveServer({
transport: elysiaAdapter,
generateId: () => nanoid(), // Custom ID generator
})When provided, the custom generator is used via the LiveComponentContext -- every LiveComponent instance calls it during construction. If not provided, the framework uses its built-in generateId() (crypto-based).
Data in static persistent survives Hot Module Replacement reloads via globalThis:
export class LiveMigration extends LiveComponent<typeof LiveMigration.defaultState> {
static componentName = 'LiveMigration'
static publicActions = ['runMigration'] as const
static defaultState = { status: 'idle', lastResult: '' }
// Define shape and defaults for persistent data
static persistent = {
cache: {} as Record<string, any>,
runCount: 0
}
protected onMount() {
this.$persistent.runCount++
console.log(`Mount #${this.$persistent.runCount}`) // Survives HMR!
}
async runMigration(payload: { key: string }) {
// Check HMR-safe cache
if (this.$persistent.cache[payload.key]) {
return { cached: true, result: this.$persistent.cache[payload.key] }
}
const result = await expensiveComputation(payload.key)
this.$persistent.cache[payload.key] = result
this.state.lastResult = result
return { cached: false, result }
}
}Key facts:
this.$persistentreads fromglobalThis.__fluxstack_persistent_{ComponentName}- Each component class has its own namespace
- Defaults come from
static persistent-- initialized once, then persisted - Not sent to client -- server-only
$persistentis in BLOCKED_ACTIONS (can't be called from client)
When static singleton = true, only ONE server-side instance exists. All clients share the same state. This is a real feature of @fluxstack/live with cluster support via Redis.
export class LiveDashboard extends LiveComponent<typeof LiveDashboard.defaultState> {
static componentName = 'LiveDashboard'
static singleton = true // All clients share this instance
static publicActions = ['refresh', 'addAlert'] as const
static defaultState = {
visitors: 0,
alerts: [] as string[],
lastRefresh: ''
}
protected async onMount() {
this.state.visitors++
this.state.lastRefresh = new Date().toISOString()
}
// Singleton-specific hooks (optional)
protected onClientJoin(connectionId: string, connectionCount: number) {
this.state.visitors = connectionCount
}
protected onClientLeave(connectionId: string, connectionCount: number) {
this.state.visitors = connectionCount
}
async refresh() {
const data = await fetchDashboardData()
this.setState(data) // Broadcasts to ALL connected clients
return { success: true }
}
async addAlert(payload: { message: string }) {
this.state.alerts = [...this.state.alerts, payload.message]
// All clients see the new alert instantly
return { success: true }
}
}How it works:
- First client to mount creates the singleton instance
- Subsequent clients join the existing instance and receive current state
emit/setState/this.state.x = ybroadcast to ALL connected WebSockets- When a client disconnects, it's removed from the singleton's connections
- When the LAST client disconnects, the singleton is destroyed
- Stats visible at
/api/live/stats(shows singleton connection counts) - Cluster support: coordinated across server instances via
IClusterAdapter(Redis)
Use cases: Shared dashboards, global migration state, admin panels, live counters
State mutations auto-sync with the frontend via two layers:
Layer 1 -- Proxy (this.state): A Proxy wraps the internal state object. Any set on this.state compares old vs new value and, if changed, emits STATE_DELTA to the client automatically.
Layer 2 -- Direct Accessors (this.count): On construction, createDirectStateAccessors() defines a getter/setter via Object.defineProperty for each key in defaultState. The setter delegates to the proxy, so it also triggers STATE_DELTA.
this.count++ -> accessor setter -> proxy set -> STATE_DELTA
this.state.count++ -> proxy set -> STATE_DELTA
this.setState({count: 1}) -> Object.assign + single STATE_DELTA (batch)
State properties are accessible directly on this:
// Declare properties for TypeScript
declare count: number
declare message: string
// Direct access - auto-syncs via proxy!
this.count++
this.message = 'Hello'
// Also works - same proxy underneath
this.state.count++Performance note: Each direct assignment emits one
STATE_DELTA. For multiple properties at once, usesetState(single emit).
Use setState for multiple properties at once (single emit):
// Batch update - one STATE_DELTA event
this.setState({
count: newCount,
lastUpdatedBy: userId,
updatedAt: new Date().toISOString()
})
// Function updater (access previous state)
this.setState(prev => ({
count: prev.count + 1,
lastUpdatedBy: userId
}))
setStatewrites directly to_state(bypasses proxy) and emits a singleSTATE_DELTAwith all changed keys. More efficient than N individual assignments.
Built-in action to set any state key from the client. Must be explicitly included in publicActions to be callable:
// Server: opt-in to setValue
static publicActions = ['increment', 'setValue'] as const // Must include 'setValue'
// Client can then call:
await component.setValue({ key: 'count', value: 42 })Security note:
setValueis powerful - it allows the client to set any state key. Only add it topublicActionsif you trust the client to modify any state field.
$private is a key-value store that lives exclusively on the server. It is NEVER synchronized with the client -- no STATE_UPDATE, no STATE_DELTA, not included in getSerializableState().
Use it for sensitive data like tokens, API keys, internal IDs, or any server-side bookkeeping:
export class Chat extends LiveComponent<typeof Chat.defaultState> {
static componentName = 'Chat'
static publicActions = ['connect', 'sendMessage'] as const
static defaultState = { messages: [] as string[] }
async connect(payload: { token: string }) {
// Stays on server -- never sent to client
this.$private.token = payload.token
this.$private.apiKey = await getApiKey()
// Only UI data goes to state (synced with client)
this.state.messages = await fetchMessages(this.$private.token)
return { success: true }
}
async sendMessage(payload: { text: string }) {
// Use $private data for server-side logic
await postToAPI(this.$private.apiKey, payload.text)
this.state.messages = [...this.state.messages, payload.text]
return { success: true }
}
}Pass a second generic to get full autocomplete and type checking:
interface ChatPrivate {
token: string
apiKey: string
retryCount: number
}
export class Chat extends LiveComponent<typeof Chat.defaultState, ChatPrivate> {
static componentName = 'Chat'
static publicActions = ['connect'] as const
static defaultState = { messages: [] as string[] }
async connect(payload: { token: string }) {
this.$private.token = payload.token // autocomplete
this.$private.retryCount = 0 // must be number
// this.$private.tokkken = 'x' // TypeScript error (typo)
}
}The second generic defaults to Record<string, any>, so existing components work without changes.
Key facts:
- Starts as an empty
{}-- no static default needed - Mutations do NOT trigger any WebSocket messages
- Cleared automatically on
destroy() - Lost on rehydration (re-populate in your action handlers)
- Blocked from remote access (
$privateand_privateStateare in BLOCKED_ACTIONS) - Optional
TPrivategeneric for full type safety
Get current state for serialization (does NOT include $private):
const currentState = this.getSerializableState()State is automatically signed and persisted on client. On reconnection, state is re-hydrated:
// Automatic - no code needed
// Client stores signed state in localStorage
// On reconnect, sends signed state to server
// Server validates signature and restores componentconstructor(initialState, ws, options) {
super(initialState, ws, options)
// Listen for room events
this.onRoomEvent<{ count: number }>('COUNT_CHANGED', (data) => {
this.setState({ count: data.count })
})
this.onRoomEvent<{ message: string }>('MESSAGE_SENT', (data) => {
// Handle message
})
}// Emit event to all room members
this.emitRoomEvent('MESSAGE_SENT', {
message: 'Hello',
userId: this.userId
})
// Emit event AND update local state
this.emitRoomEventWithState('COUNT_CHANGED',
{ count: newCount }, // Event data
{ count: newCount } // State update
)Components can use typed LiveRoom classes for structured room interactions:
import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
import { CounterRoom } from './rooms/CounterRoom'
export class LiveSharedCounter extends LiveComponent<typeof LiveSharedCounter.defaultState> {
static componentName = 'LiveSharedCounter'
static publicActions = ['increment', 'decrement', 'reset'] as const
static defaultState = {
username: '',
count: 0,
lastUpdatedBy: null as string | null,
onlineCount: 0
}
private counterUnsub: (() => void) | null = null
private presenceUnsub: (() => void) | null = null
constructor(initialState: Partial<typeof LiveSharedCounter.defaultState> = {}, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
super(initialState, ws, options)
const room = this.$room(CounterRoom, 'global')
room.join()
// Load current state from room
this.setState({
count: room.state.count,
lastUpdatedBy: room.state.lastUpdatedBy,
onlineCount: room.state.onlineCount
})
// Listen for updates from other users
this.counterUnsub = room.on('counter:updated', (data) => {
this.setState({ count: data.count, lastUpdatedBy: data.updatedBy })
})
this.presenceUnsub = room.on('presence:changed', (data) => {
this.setState({ onlineCount: data.onlineCount })
})
}
async increment() {
const room = this.$room(CounterRoom, 'global')
const count = room.increment(this.state.username || 'Anonymous')
return { success: true, count }
}
destroy() {
this.counterUnsub?.()
this.presenceUnsub?.()
super.destroy()
}
}$room API:
this.$room(RoomClass, instanceId)-- typed room handle with custom methodsthis.$room('roomId')-- untyped room handle (legacy)this.$rooms-- list of room IDs this component participates in
Actions are methods callable from the client. Only methods listed in publicActions can be called remotely. Components without publicActions deny ALL remote actions.
// Server-side
export class LiveForm extends LiveComponent<typeof LiveForm.defaultState> {
static publicActions = ['submit', 'validate', 'reset', 'setValue'] as const
static defaultState = {
name: '',
email: '',
message: '',
submitted: false,
submittedAt: null as string | null
}
async submit() {
const { name, email, message } = this.state
if (!name || !email) {
throw new Error('Nome e email sao obrigatorios')
}
this.setState({
submitted: true,
submittedAt: new Date().toISOString()
})
return {
success: true,
data: { name, email, message },
submittedAt: this.state.submittedAt
}
}
async validate() {
const errors: Record<string, string> = {}
if (!this.state.name) errors.name = 'Nome e obrigatorio'
if (!this.state.email) errors.email = 'Email e obrigatorio'
else if (!this.state.email.includes('@')) errors.email = 'Email invalido'
return { valid: Object.keys(errors).length === 0, errors }
}
}The @fluxstack/live framework provides additional action security features:
- Zod validation --
static actionSchemasfor automatic payload validation before action execution - Rate limiting --
static actionRateLimitto prevent clients from spamming actions - Per-action auth --
static actionAuthwith roles/permissions per action
static actionSchemas = {
sendMessage: z.object({ text: z.string().max(500) }),
}
static actionRateLimit = { maxCalls: 10, windowMs: 1000, perAction: true }Components can require authentication and define per-action permissions:
export class LiveAdminPanel extends LiveComponent<AdminPanelState> {
static componentName = 'LiveAdminPanel'
static publicActions = ['getAuthInfo', 'init', 'listUsers', 'addUser', 'deleteUser', 'clearAudit'] as const
// Component-level: requires auth + admin role
static auth: LiveComponentAuth = {
required: true,
roles: ['admin'],
}
// Per-action: granular permissions
static actionAuth: LiveActionAuthMap = {
deleteUser: { permissions: ['users.delete'] },
clearAudit: { roles: ['admin'] },
}
async getAuthInfo() {
return {
authenticated: this.$auth.authenticated,
userId: this.$auth.session?.id,
roles: this.$auth.session?.roles || [],
isAdmin: this.$auth.hasRole('admin'),
}
}
}Auth levels:
this.state-- client reads AND writes (bidirectional)this.$private-- client NEVER sees (server-only)this.$auth-- set by framework, immutable (read-only)
Wrap app with LiveComponentsProvider:
// app/client/src/App.tsx
import { LiveComponentsProvider } from '@/core/client'
function App() {
return (
<LiveComponentsProvider
url="ws://localhost:3000"
autoConnect={true}
reconnectInterval={1000}
debug={true}
>
<AppContent />
</LiveComponentsProvider>
)
}import { Live } from '@/core/client'
import { LiveCounter } from '@server/live/LiveCounter'
export function CounterDemo() {
// Mount component with options
const counter = Live.use(LiveCounter, {
room: 'global-counter',
initialState: LiveCounter.defaultState
})
// Access state
const count = counter.$state.count
// Check connection status
const isConnected = counter.$connected
// Check loading state
const isLoading = counter.$loading
// Call actions
const handleIncrement = async () => {
await counter.increment()
}
return (
<div>
<p>Count: {count}</p>
<p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
<button onClick={handleIncrement} disabled={isLoading}>
Increment
</button>
</div>
)
}For form components, use $field helper:
const form = Live.use(LiveForm)
// Sync on blur
<input {...form.$field('name', { syncOn: 'blur' })} />
// Sync on change with debounce
<input {...form.$field('email', { syncOn: 'change', debounce: 500 })} />
// Manual sync
await form.$sync()// State access
counter.$state.count
// Connection status
counter.$connected
// Loading state
counter.$loading
// Call action
await counter.increment()
// Field binding (forms)
form.$field('fieldName', options)
// Manual sync
await form.$sync()Components are auto-discovered from app/server/live/:
// app/server/live/auto-generated-components.ts (auto-generated by @fluxstack/live)
import { LiveAdminPanel } from "./LiveAdminPanel"
import { LiveCounter } from "./LiveCounter"
import { LiveForm } from "./LiveForm"
// ... etc
export const liveComponentClasses = [
LiveAdminPanel,
LiveCounter,
LiveForm,
// ...
]The LiveServer auto-discovers components via componentsPath option and generates this file. For production builds, pass components: liveComponentClasses to avoid dynamic imports.
Client automatically reconnects on disconnect:
<LiveComponentsProvider
reconnectInterval={1000} // Retry every 1 second
autoConnect={true}
>On reconnect, components restore previous state:
- Client stores signed state in localStorage
- On reconnect, sends signed state to server
- Server validates signature (HMAC-SHA256) and anti-replay nonce
- Component re-hydrated with previous state
- State expires after 24 hours (configurable)
No manual code needed - automatic. Each signed state includes a cryptographic nonce that is consumed on validation, preventing replay attacks.
All components in same room receive events:
// User A increments
await counter.increment()
// Calls CounterRoom.increment(), which updates room state and emits counter:updated
// User B's component receives event
const unsub = room.on('counter:updated', (data) => {
this.setState({ count: data.count, lastUpdatedBy: data.updatedBy })
})
// User B sees updated countTrack connected users in room:
// In the typed room class, not in each component instance.
onJoin() {
const onlineCount = this.state.onlineCount + 1
this.setState({ onlineCount })
this.emit('presence:changed', { onlineCount })
}
onLeave() {
const onlineCount = Math.max(0, this.state.onlineCount - 1)
this.setState({ onlineCount })
this.emit('presence:changed', { onlineCount })
}
// In the component constructor.
const room = this.$room(CounterRoom, options?.room ?? 'default')
room.join()
this.setState({ connectedUsers: room.state.onlineCount })
const unsub = room.on('presence:changed', (data) => {
this.setState({ connectedUsers: data.onlineCount })
}Do not calculate presence with this.state.connectedUsers + 1 inside each component. Each browser tab mounts a separate component instance, so local state starts from its own value and can publish stale counts. Use room state (room.state.onlineCount) as the source of truth.
// Server-side - throw errors
async submit() {
if (!this.state.email) {
throw new Error('Email required')
}
// Process...
}
// Client-side - catch errors
try {
await form.submit()
} catch (error) {
alert(error.message)
}Built-in performance tracking:
// Automatic metrics collection
// - Render times
// - Action execution times
// - Error counts
// - Memory usage
// Access via registry
const health = componentRegistry.getComponentHealth(componentId)
// { status: 'healthy', metrics: {...} }The app includes these live components in app/server/live/:
| Component | Description | Features |
|---|---|---|
LiveLocalCounter |
Simple counter, no room events | Direct state access, declare |
LiveCounter |
Shared counter using typed CounterRoom |
$room(CounterRoom, roomId), presence:changed |
LiveSharedCounter |
Shared counter using typed CounterRoom |
$room(CounterRoom, 'global'), presence:changed |
LiveForm |
Reactive form with server validation | setValue, validate, submit |
LivePingPong |
Binary codec demo (msgpack) | Typed PingRoom, round-trip timing |
LiveRoomChat |
Multi-room chat with directory | ChatRoom, DirectoryRoom, password rooms |
LiveProtectedChat |
Auth-required chat | static auth, static actionAuth, roles |
LiveAdminPanel |
Admin panel with RBAC | Component + per-action auth, audit trail |
LiveUpload |
Chunked file upload via WebSocket | Filename validation, progress tracking |
app/server/live/
├── LiveCounter.ts # Shared counter using typed CounterRoom
├── LiveLocalCounter.ts # Local counter (no room)
├── LiveForm.ts # Reactive form
├── LivePingPong.ts # Binary codec demo
├── LiveSharedCounter.ts # Typed room counter
├── LiveRoomChat.ts # Multi-room chat
├── LiveProtectedChat.ts # Auth-required chat
├── LiveAdminPanel.ts # Admin panel with RBAC
├── LiveUpload.ts # Chunked file upload
├── rooms/ # Typed LiveRoom definitions
│ ├── ChatRoom.ts
│ ├── CounterRoom.ts
│ ├── DirectoryRoom.ts
│ └── PingRoom.ts
└── auto-generated-components.ts # Auto-generated registration
app/client/src/live/
├── CounterDemo.tsx
├── FormDemo.tsx
├── RoomChatDemo.tsx
├── SharedCounterDemo.tsx
├── PingPongDemo.tsx
├── UploadDemo.tsx
└── ...
Each server file contains:
static componentName- Component identifierstatic publicActions- REQUIRED whitelist of client-callable methodsstatic defaultState- Initial state objectstatic logging- Per-component console log control (optional)- Component class extending
LiveComponent - Client link via
import type { Demo as _Client }
export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
static $options = {
deepDiff: true, // Enable deep diff for plain objects (default: true)
roomDeepDiff: true, // Enable deep diff for room state (default: true)
deepDiffDepth: 3, // Max recursion depth (default: 3)
serverOnlyRoomState: false, // When true, client ROOM_STATE_SET is rejected
}
}ALWAYS:
- Define
static componentNamematching class name - Define
static publicActionslisting ALL client-callable methods (MANDATORY) - Define
static defaultStateinside the class - Use
typeof ClassName.defaultStatefor type parameter - Use
declarefor each state property (TypeScript type hint) - Use
onMount()for async initialization (rooms, auth, data fetching) - Use
onDestroy()for cleanup (timers, connections) -- sync only - Prefer typed
LiveRoomstate/methods for shared state and presence; useemitRoomEventWithStateonly for simple legacy untyped-room events - Handle errors in actions (throw Error)
- Add client link:
import type { Demo as _Client } from '@client/...' - Use
$persistentfor data that should survive HMR reloads - Use
static singleton = truefor shared cross-client state
NEVER:
- Omit
static publicActions(component will deny ALL remote actions) - Export separate
defaultStateconstant (use static) - Create constructor just to call super() (not needed)
- Forget
static componentName(breaks minification) - Override
destroy()directly -- useonDestroy()instead (prefer lifecycle hooks) - Emit room events without subscribing first
- Store non-serializable data in state
- Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, $persistent, broadcastToRoom, roomType)
- Include
setValueinpublicActionsunless you trust clients to modify any state key - Store sensitive data (tokens, API keys, secrets) in
state-- use$privateinstead
STATE UPDATES -- all auto-sync via Proxy:
// Direct access (1 prop -> 1 STATE_DELTA)
declare count: number
this.count++
// Also works (same proxy underneath)
this.state.count++
// Multiple properties -> use setState (1 STATE_DELTA for all)
this.setState({ a: 1, b: 2, c: 3 })
// Don't use setState for single property (unnecessary)
// this.setState({ count: this.count + 1 })This project includes a Live Component-based upload system that streams file chunks
over the Live Components WebSocket. The client uses a chunked upload hook; the server
tracks progress and assembles the file in uploads/.
// app/server/live/LiveUpload.ts
import { LiveComponent } from '@core/types/types'
export class LiveUpload extends LiveComponent<typeof LiveUpload.defaultState> {
static componentName = 'LiveUpload'
static publicActions = ['startUpload', 'updateProgress', 'completeUpload', 'failUpload', 'reset'] as const
static defaultState = {
status: 'idle' as 'idle' | 'uploading' | 'complete' | 'error',
progress: 0,
fileName: '',
fileSize: 0,
fileType: '',
fileUrl: '',
bytesUploaded: 0,
totalBytes: 0,
error: null as string | null
}
async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) {
const fileName = payload.fileName
// Validate filename length
if (!fileName || fileName.length > 255) {
throw new Error('Invalid file name: must be 1-255 characters')
}
// Block path traversal, null bytes, and control characters
if (/[\x00-\x1f]/.test(fileName) || fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
throw new Error('Invalid file name: contains forbidden characters')
}
// Block Windows reserved names
const baseName = fileName.split('.')[0].toUpperCase()
const reserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1', 'LPT2', 'LPT3']
if (reserved.includes(baseName)) {
throw new Error('Invalid file name: reserved name')
}
this.setState({
status: 'uploading',
progress: 0,
fileName: payload.fileName,
fileSize: payload.fileSize,
fileType: payload.fileType,
fileUrl: '',
bytesUploaded: 0,
totalBytes: payload.fileSize,
error: null
})
return { success: true }
}
// ... updateProgress, completeUpload, failUpload, reset
}// app/client/src/live/UploadDemo.tsx
import { useLiveUpload } from './useLiveUpload'
import { LiveUploadWidget } from '../components/LiveUploadWidget'
export function UploadDemo() {
const { live } = useLiveUpload()
return (
<LiveUploadWidget live={live} />
)
}- Client calls
startUpload()(Live Component action). - Client streams file chunks over WebSocket with
useChunkedUpload. - Server assembles file in
uploads/and returns/uploads/.... - Client maps to
/api/uploads/...for access.
- If an action throws, the error surfaces in
live.$erroron the client. - The widget shows
localError || state.error || $error.
Server
app/server/live/LiveUpload.tscore/server/live/FileUploadManager.ts(chunk handling + file assembly)core/server/live/websocket-plugin.ts(upload message routing)
Client
core/client/hooks/useChunkedUpload.ts(streaming chunks)core/client/hooks/useLiveUpload.ts(Live Component wrapper)app/client/src/components/LiveUploadWidget.tsx(UI)
- Live Auth - Authentication for Live Components
- Live Logging - Per-component logging control
- Live Rooms - Multi-room real-time communication
- Live Upload - Chunked file upload
- Live Binary Delta - High-frequency binary state sync
- Project Structure
- Type Safety Patterns
- WebSocket Plugin