create-fluxstack 1.19.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LLMD/INDEX.md +1 -1
  2. package/LLMD/MAINTENANCE.md +197 -197
  3. package/LLMD/MIGRATION.md +44 -1
  4. package/LLMD/agent.md +20 -7
  5. package/LLMD/config/declarative-system.md +268 -268
  6. package/LLMD/config/environment-vars.md +3 -6
  7. package/LLMD/config/runtime-reload.md +401 -401
  8. package/LLMD/core/build-system.md +599 -599
  9. package/LLMD/core/framework-lifecycle.md +249 -229
  10. package/LLMD/core/plugin-system.md +154 -100
  11. package/LLMD/patterns/anti-patterns.md +397 -397
  12. package/LLMD/patterns/project-structure.md +264 -264
  13. package/LLMD/patterns/type-safety.md +61 -5
  14. package/LLMD/reference/cli-commands.md +31 -7
  15. package/LLMD/reference/plugin-hooks.md +4 -2
  16. package/LLMD/reference/troubleshooting.md +364 -364
  17. package/LLMD/resources/controllers.md +465 -465
  18. package/LLMD/resources/live-auth.md +178 -1
  19. package/LLMD/resources/live-binary-delta.md +3 -1
  20. package/LLMD/resources/live-components.md +1192 -1041
  21. package/LLMD/resources/live-logging.md +3 -1
  22. package/LLMD/resources/live-rooms.md +1 -1
  23. package/LLMD/resources/live-upload.md +228 -181
  24. package/LLMD/resources/plugins-external.md +8 -7
  25. package/LLMD/resources/rest-auth.md +290 -290
  26. package/LLMD/resources/routes-eden.md +254 -254
  27. package/app/client/.live-stubs/LiveAdminPanel.js +15 -0
  28. package/app/client/.live-stubs/LiveCounter.js +9 -0
  29. package/app/client/.live-stubs/LiveForm.js +11 -0
  30. package/app/client/.live-stubs/LiveLocalCounter.js +8 -0
  31. package/app/client/.live-stubs/LivePingPong.js +10 -0
  32. package/app/client/.live-stubs/LiveRoomChat.js +11 -0
  33. package/app/client/.live-stubs/LiveSharedCounter.js +10 -0
  34. package/app/client/.live-stubs/LiveUpload.js +15 -0
  35. package/app/server/live/auto-generated-components.ts +1 -1
  36. package/core/utils/version.ts +6 -6
  37. package/package.json +108 -108
@@ -1,1041 +1,1192 @@
1
- # Live Components
2
-
3
- **Version:** 1.14.0 | **Updated:** 2025-02-27
4
-
5
- ## Quick Facts
6
-
7
- - Server-side state management with WebSocket sync
8
- - **Direct state access** - `this.count++` auto-syncs (v1.13.0)
9
- - **Lifecycle hooks** - `onMount()` / `onDestroy()` for proper initialization and cleanup (v1.14.0)
10
- - **HMR persistence** - `static persistent` + `this.$persistent` survives hot reloads (v1.14.0)
11
- - **Singleton components** - `static singleton = true` for shared server-side instances (v1.14.0)
12
- - **Mandatory `publicActions`** - Only whitelisted methods are callable from client (secure by default)
13
- - **Helpful error messages** - Forgotten `publicActions` entries show exactly what to fix (v1.14.0)
14
- - Automatic state persistence and re-hydration (with anti-replay nonces)
15
- - Room-based event system for multi-user sync
16
- - Type-safe client-server communication (FluxStackWebSocket)
17
- - Built-in connection management and recovery
18
- - **Client component links** - Ctrl+Click navigation
19
-
20
- ## LiveComponent Class Structure (v1.13.0)
21
-
22
- Server-side component extends `LiveComponent` with **static defaultState**:
23
-
24
- ```typescript
25
- // app/server/live/LiveCounter.ts
26
- import { LiveComponent } from '@core/types/types'
27
-
28
- // Componente Cliente (Ctrl+Click para navegar)
29
- import type { CounterDemo as _Client } from '@client/src/live/CounterDemo'
30
-
31
- export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> {
32
- static componentName = 'LiveCounter'
33
- static publicActions = ['increment', 'decrement', 'reset'] as const // 🔒 REQUIRED
34
- // static logging = ['lifecycle', 'messages'] as const // Console logging (optional, prefer DEBUG_LIVE)
35
- static defaultState = {
36
- count: 0
37
- }
38
-
39
- // Declarar propriedades do estado (TypeScript)
40
- declare count: number
41
-
42
- // Direct state access - auto-syncs with frontend
43
- async increment() {
44
- this.count++
45
- return { success: true, count: this.count }
46
- }
47
-
48
- async decrement() {
49
- this.count--
50
- return { success: true, count: this.count }
51
- }
52
-
53
- async reset() {
54
- this.count = 0
55
- return { success: true }
56
- }
57
- }
58
- ```
59
-
60
- ### Key Changes in v1.14.0
61
-
62
- 1. **Lifecycle hooks** - `onMount()` (async) and `onDestroy()` (sync) replace constructor/destroy workarounds
63
- 2. **HMR persistence** - `static persistent` + `this.$persistent` for data that survives hot module reloads
64
- 3. **Singleton components** - `static singleton = true` for shared state across all connected clients
65
- 4. **Better publicActions errors** - Clear message when a method exists but is missing from `publicActions`
66
-
67
- ### Key Changes in v1.13.0
68
-
69
- 1. **Direct state access** - `this.count++` instead of `this.state.count++`
70
- 2. **declare keyword** - TypeScript hint for dynamic properties
71
- 3. **Cleaner code** - No need to prefix with `this.state.`
72
- 4. **Mandatory `publicActions`** - Components without it deny ALL remote actions (secure by default)
73
-
74
- ### Key Changes in v1.12.0
75
-
76
- 1. **Static defaultState inside class** - No external export needed
77
- 2. **Reactive Proxy** - `this.state.count++` triggers sync automatically
78
- 3. **No constructor needed** - Base class handles defaultState merge
79
- 4. **Client link** - `import type { Demo as _Client }` enables Ctrl+Click
80
- 5. **Type-safe WebSocket** - `FluxStackWebSocket` interface
81
-
82
- ### With Room Events (Advanced)
83
-
84
- ```typescript
85
- import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
86
-
87
- export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> {
88
- static componentName = 'LiveCounter'
89
- static publicActions = ['increment'] as const // 🔒 REQUIRED
90
- static defaultState = {
91
- count: 0,
92
- lastUpdatedBy: null as string | null,
93
- connectedUsers: 0
94
- }
95
- protected roomType = 'counter'
96
-
97
- // Constructor only needed for room event subscriptions
98
- constructor(
99
- initialState: Partial<typeof LiveCounter.defaultState>,
100
- ws: FluxStackWebSocket,
101
- options?: { room?: string; userId?: string }
102
- ) {
103
- super(initialState, ws, options)
104
-
105
- this.onRoomEvent<{ count: number }>('COUNT_CHANGED', (data) => {
106
- this.setState({ count: data.count })
107
- })
108
- }
109
-
110
- async increment() {
111
- this.state.count++
112
- this.emitRoomEventWithState('COUNT_CHANGED',
113
- { count: this.state.count },
114
- { count: this.state.count }
115
- )
116
- return { success: true, count: this.state.count }
117
- }
118
-
119
- destroy() {
120
- super.destroy()
121
- }
122
- }
123
- ```
124
-
125
- ## Lifecycle Hooks (v1.14.0)
126
-
127
- Full lifecycle hook system — no more constructor workarounds:
128
-
129
- ```typescript
130
- export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
131
- static componentName = 'MyComponent'
132
- static publicActions = ['doWork'] as const
133
- static defaultState = { users: [] as string[], ready: false, currentRoom: '' }
134
-
135
- private _pollTimer?: NodeJS.Timeout
136
-
137
- // 1️⃣ Called when WebSocket connection is established (before onMount)
138
- protected onConnect() {
139
- console.log('WebSocket connected for this component')
140
- }
141
-
142
- // 2️⃣ Called AFTER component is fully mounted (rooms, auth, injections ready)
143
- // Can be async!
144
- protected async onMount() {
145
- this.$room.join()
146
- this.$room.on('user:joined', (user) => {
147
- this.state.users = [...this.state.users, user]
148
- })
149
- const data = await fetchInitialData(this.$auth.user?.id)
150
- this.state.ready = true
151
- this._pollTimer = setInterval(() => this.poll(), 5000)
152
- }
153
-
154
- // Called after state is restored from localStorage (rehydration)
155
- protected onRehydrate(previousState: typeof MyComponent.defaultState) {
156
- if (!previousState.ready) {
157
- this.state.ready = false // Re-validate stale state
158
- }
159
- }
160
-
161
- // Called after any state mutation (proxy or setState)
162
- protected onStateChange(changes: Partial<typeof MyComponent.defaultState>) {
163
- if ('users' in changes) {
164
- console.log(`User count: ${this.state.users.length}`)
165
- }
166
- }
167
-
168
- // Called when joining a room
169
- protected onRoomJoin(roomId: string) {
170
- this.state.currentRoom = roomId
171
- }
172
-
173
- // Called when leaving a room
174
- protected onRoomLeave(roomId: string) {
175
- if (this.state.currentRoom === roomId) this.state.currentRoom = ''
176
- }
177
-
178
- // Called before each action return false to cancel
179
- protected onAction(action: string, payload: any) {
180
- console.log(`[${this.id}] ${action}`, payload)
181
- // return false // would cancel the action
182
- }
183
-
184
- // Called when WebSocket drops (NOT on intentional unmount)
185
- protected onDisconnect() {
186
- console.log('Connection lost saving recovery data')
187
- }
188
-
189
- // Called BEFORE internal cleanup (sync only)
190
- protected onDestroy() {
191
- clearInterval(this._pollTimer)
192
- }
193
-
194
- async doWork() { /* ... */ }
195
- private poll() { /* ... */ }
196
- }
197
- ```
198
-
199
- ### Lifecycle Order
200
-
201
- ```
202
- WebSocket connects
203
- └→ onConnect()
204
- └→ onMount() ← async, rooms/auth ready
205
- └→ [component active]
206
- ├→ onAction(action, payload) ← before each action (return false to cancel)
207
- ├→ onStateChange(changes) ← after each state mutation
208
- ├→ onRoomJoin(roomId) ← when joining a room
209
- └→ onRoomLeave(roomId) ← when leaving a room
210
-
211
- Connection drops:
212
- └→ onDisconnect() ← only on unexpected disconnect
213
- └→ onDestroy() ← sync, before internal cleanup
214
-
215
- Rehydration (reconnect with saved state):
216
- └→ onConnect()
217
- └→ onRehydrate(previousState)
218
- └→ onMount()
219
- ```
220
-
221
- ### Rules
222
-
223
- | Hook | Async? | When |
224
- |------|--------|------|
225
- | `onConnect()` | No | WebSocket established, before mount |
226
- | `onMount()` | **Yes** | After all setup (rooms, auth, DI) |
227
- | `onRehydrate(prevState)` | No | After state restored from localStorage |
228
- | `onStateChange(changes)` | No | After every state mutation |
229
- | `onRoomJoin(roomId)` | No | After `$room.join()` |
230
- | `onRoomLeave(roomId)` | No | After `$room.leave()` |
231
- | `onAction(action, payload)` | **Yes** | Before action execution (return `false` to cancel) |
232
- | `onDisconnect()` | No | Connection lost (NOT intentional unmount) |
233
- | `onDestroy()` | No | Before internal cleanup |
234
-
235
- - All hooks are optional — override only what you need
236
- - All hook errors are caught and logged — they never break the system
237
- - Constructor is still needed ONLY for `this.onRoomEvent()` subscriptions
238
- - All hooks are in BLOCKED_ACTIONS — clients cannot call them remotely
239
-
240
- ## HMR Persistence (v1.14.0)
241
-
242
- Data in `static persistent` survives Hot Module Replacement reloads via `globalThis`:
243
-
244
- ```typescript
245
- export class LiveMigration extends LiveComponent<typeof LiveMigration.defaultState> {
246
- static componentName = 'LiveMigration'
247
- static publicActions = ['runMigration'] as const
248
- static defaultState = { status: 'idle', lastResult: '' }
249
-
250
- // Define shape and defaults for persistent data
251
- static persistent = {
252
- cache: {} as Record<string, any>,
253
- runCount: 0
254
- }
255
-
256
- protected onMount() {
257
- this.$persistent.runCount++
258
- console.log(`Mount #${this.$persistent.runCount}`) // Survives HMR!
259
- }
260
-
261
- async runMigration(payload: { key: string }) {
262
- // Check HMR-safe cache
263
- if (this.$persistent.cache[payload.key]) {
264
- return { cached: true, result: this.$persistent.cache[payload.key] }
265
- }
266
-
267
- const result = await expensiveComputation(payload.key)
268
- this.$persistent.cache[payload.key] = result
269
- this.state.lastResult = result
270
- return { cached: false, result }
271
- }
272
- }
273
- ```
274
-
275
- **Key facts:**
276
- - `this.$persistent` reads from `globalThis.__fluxstack_persistent_{ComponentName}`
277
- - Each component class has its own namespace
278
- - Defaults come from `static persistent` initialized once, then persisted
279
- - Not sent to client — server-only
280
- - `$persistent` is in BLOCKED_ACTIONS (can't be called from client)
281
-
282
- ## Singleton Components (v1.14.0)
283
-
284
- When `static singleton = true`, only ONE server-side instance exists. All clients share the same state:
285
-
286
- ```typescript
287
- export class LiveDashboard extends LiveComponent<typeof LiveDashboard.defaultState> {
288
- static componentName = 'LiveDashboard'
289
- static singleton = true // All clients share this instance
290
- static publicActions = ['refresh', 'addAlert'] as const
291
- static defaultState = {
292
- visitors: 0,
293
- alerts: [] as string[],
294
- lastRefresh: ''
295
- }
296
-
297
- protected async onMount() {
298
- this.state.visitors++
299
- this.state.lastRefresh = new Date().toISOString()
300
- }
301
-
302
- async refresh() {
303
- const data = await fetchDashboardData()
304
- this.setState(data) // Broadcasts to ALL connected clients
305
- return { success: true }
306
- }
307
-
308
- async addAlert(payload: { message: string }) {
309
- this.state.alerts = [...this.state.alerts, payload.message]
310
- // All clients see the new alert instantly
311
- return { success: true }
312
- }
313
- }
314
- ```
315
-
316
- **How it works:**
317
- - First client to mount creates the singleton instance
318
- - Subsequent clients join the existing instance and receive current state
319
- - `emit` / `setState` / `this.state.x = y` broadcast to ALL connected WebSockets
320
- - When a client disconnects, it's removed from the singleton's connections
321
- - When the LAST client disconnects, the singleton is destroyed
322
- - Stats visible at `/api/live/stats` (shows singleton connection counts)
323
-
324
- **Use cases:** Shared dashboards, global migration state, admin panels, live counters
325
-
326
- ## State Management
327
-
328
- ### Reactive State Proxy (How It Works)
329
-
330
- State mutations auto-sync with the frontend via two layers:
331
-
332
- **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.
333
-
334
- **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`.
335
-
336
- ```
337
- this.count++ → accessor setter → proxy set → STATE_DELTA
338
- this.state.count++ → proxy set → STATE_DELTA
339
- this.setState({count: 1}) → Object.assign + single STATE_DELTA (batch)
340
- ```
341
-
342
- ### Direct State Access (v1.13.0) ✨
343
-
344
- State properties are accessible directly on `this`:
345
-
346
- ```typescript
347
- // Declare properties for TypeScript
348
- declare count: number
349
- declare message: string
350
-
351
- // ✅ Direct access - auto-syncs via proxy!
352
- this.count++
353
- this.message = 'Hello'
354
-
355
- // Also works (v1.12.0 style) - same proxy underneath
356
- this.state.count++
357
- ```
358
-
359
- > **Performance note:** Each direct assignment emits one `STATE_DELTA`. For multiple properties at once, use `setState` (single emit).
360
-
361
- ### setState (Batch Updates)
362
-
363
- Use `setState` for multiple properties at once (single emit):
364
-
365
- ```typescript
366
- // ✅ Batch update - one STATE_DELTA event
367
- this.setState({
368
- count: newCount,
369
- lastUpdatedBy: userId,
370
- updatedAt: new Date().toISOString()
371
- })
372
-
373
- // Function updater (access previous state)
374
- this.setState(prev => ({
375
- count: prev.count + 1,
376
- lastUpdatedBy: userId
377
- }))
378
- ```
379
-
380
- > `setState` writes directly to `_state` (bypasses proxy) and emits a single `STATE_DELTA` with all changed keys. More efficient than N individual assignments.
381
-
382
- ### setValue (Generic Action)
383
-
384
- Built-in action to set any state key from the client. **Must be explicitly included in `publicActions` to be callable:**
385
-
386
- ```typescript
387
- // Server: opt-in to setValue
388
- static publicActions = ['increment', 'setValue'] as const // Must include 'setValue'
389
-
390
- // Client can then call:
391
- await component.setValue({ key: 'count', value: 42 })
392
- ```
393
-
394
- > **Security note:** `setValue` is powerful - it allows the client to set any state key. Only add it to `publicActions` if you trust the client to modify any state field.
395
-
396
- ### $private — Server-Only State
397
-
398
- `$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()`.
399
-
400
- Use it for sensitive data like tokens, API keys, internal IDs, or any server-side bookkeeping:
401
-
402
- ```typescript
403
- export class Chat extends LiveComponent<typeof Chat.defaultState> {
404
- static componentName = 'Chat'
405
- static publicActions = ['connect', 'sendMessage'] as const
406
- static defaultState = { messages: [] as string[] }
407
-
408
- async connect(payload: { token: string }) {
409
- // 🔒 Stays on server — never sent to client
410
- this.$private.token = payload.token
411
- this.$private.apiKey = await getApiKey()
412
-
413
- // Only UI data goes to state (synced with client)
414
- this.state.messages = await fetchMessages(this.$private.token)
415
- return { success: true }
416
- }
417
-
418
- async sendMessage(payload: { text: string }) {
419
- // Use $private data for server-side logic
420
- await postToAPI(this.$private.apiKey, payload.text)
421
- this.state.messages = [...this.state.messages, payload.text]
422
- return { success: true }
423
- }
424
- }
425
- ```
426
-
427
- #### Typed $private (optional)
428
-
429
- Pass a second generic to get full autocomplete and type checking:
430
-
431
- ```typescript
432
- interface ChatPrivate {
433
- token: string
434
- apiKey: string
435
- retryCount: number
436
- }
437
-
438
- export class Chat extends LiveComponent<typeof Chat.defaultState, ChatPrivate> {
439
- static componentName = 'Chat'
440
- static publicActions = ['connect'] as const
441
- static defaultState = { messages: [] as string[] }
442
-
443
- async connect(payload: { token: string }) {
444
- this.$private.token = payload.token // ✅ autocomplete
445
- this.$private.retryCount = 0 // ✅ must be number
446
- this.$private.tokkken = 'x' // TypeScript error (typo)
447
- }
448
- }
449
- ```
450
-
451
- The second generic defaults to `Record<string, any>`, so existing components work without changes.
452
-
453
- **Key facts:**
454
- - Starts as an empty `{}` no static default needed
455
- - Mutations do NOT trigger any WebSocket messages
456
- - Cleared automatically on `destroy()`
457
- - Lost on rehydration (re-populate in your action handlers)
458
- - Blocked from remote access (`$private` and `_privateState` are in BLOCKED_ACTIONS)
459
- - Optional `TPrivate` generic for full type safety
460
-
461
- ### getSerializableState
462
-
463
- Get current state for serialization (does NOT include `$private`):
464
-
465
- ```typescript
466
- const currentState = this.getSerializableState()
467
- ```
468
-
469
- ### State Persistence
470
-
471
- State is automatically signed and persisted on client. On reconnection, state is re-hydrated:
472
-
473
- ```typescript
474
- // Automatic - no code needed
475
- // Client stores signed state in localStorage
476
- // On reconnect, sends signed state to server
477
- // Server validates signature and restores component
478
- ```
479
-
480
- ## Room Events System
481
-
482
- ### Subscribe to Room Events
483
-
484
- ```typescript
485
- constructor(initialState, ws, options) {
486
- super(initialState, ws, options)
487
-
488
- // Listen for room events
489
- this.onRoomEvent<{ count: number }>('COUNT_CHANGED', (data) => {
490
- this.setState({ count: data.count })
491
- })
492
-
493
- this.onRoomEvent<{ message: string }>('MESSAGE_SENT', (data) => {
494
- // Handle message
495
- })
496
- }
497
- ```
498
-
499
- ### Emit Room Events
500
-
501
- ```typescript
502
- // Emit event to all room members
503
- this.emitRoomEvent('MESSAGE_SENT', {
504
- message: 'Hello',
505
- userId: this.userId
506
- })
507
-
508
- // Emit event AND update local state
509
- this.emitRoomEventWithState('COUNT_CHANGED',
510
- { count: newCount }, // Event data
511
- { count: newCount } // State update
512
- )
513
- ```
514
-
515
- ### Room Subscription
516
-
517
- Components automatically join rooms specified in options:
518
-
519
- ```typescript
520
- // Client-side
521
- const counter = Live.use(LiveCounter, {
522
- room: 'global-counter' // All instances in this room sync
523
- })
524
- ```
525
-
526
- ## Actions
527
-
528
- Actions are methods callable from the client. **Only methods listed in `publicActions` can be called remotely.** Components without `publicActions` deny ALL remote actions.
529
-
530
- ```typescript
531
- // Server-side
532
- export class LiveForm extends LiveComponent<FormState> {
533
- static publicActions = ['submit', 'validate'] as const // 🔒 REQUIRED
534
-
535
- async submit() {
536
- const { name, email } = this.state
537
-
538
- if (!name || !email) {
539
- throw new Error('Name and email required')
540
- }
541
-
542
- // Process submission
543
- this.setState({ submitted: true })
544
-
545
- return { success: true, data: { name, email } }
546
- }
547
-
548
- async validate() {
549
- const errors: Record<string, string> = {}
550
-
551
- if (!this.state.name) errors.name = 'Name required'
552
- if (!this.state.email) errors.email = 'Email required'
553
-
554
- return { valid: Object.keys(errors).length === 0, errors }
555
- }
556
- }
557
- ```
558
-
559
- ## Client-Side Integration
560
-
561
- ### Provider Setup
562
-
563
- Wrap app with LiveComponentsProvider:
564
-
565
- ```typescript
566
- // app/client/src/App.tsx
567
- import { LiveComponentsProvider } from '@/core/client'
568
-
569
- function App() {
570
- return (
571
- <LiveComponentsProvider
572
- url="ws://localhost:3000"
573
- autoConnect={true}
574
- reconnectInterval={1000}
575
- debug={true}
576
- >
577
- <AppContent />
578
- </LiveComponentsProvider>
579
- )
580
- }
581
- ```
582
-
583
- ### Using Components
584
-
585
- ```typescript
586
- import { Live } from '@/core/client'
587
- import { LiveCounter } from '@server/live/LiveCounter'
588
-
589
- export function CounterDemo() {
590
- // Mount component with options
591
- const counter = Live.use(LiveCounter, {
592
- room: 'global-counter',
593
- initialState: LiveCounter.defaultState // ✅ Use static defaultState
594
- })
595
-
596
- // Access state
597
- const count = counter.$state.count
598
-
599
- // Check connection status
600
- const isConnected = counter.$connected
601
-
602
- // Check loading state
603
- const isLoading = counter.$loading
604
-
605
- // Call actions
606
- const handleIncrement = async () => {
607
- await counter.increment()
608
- }
609
-
610
- return (
611
- <div>
612
- <p>Count: {count}</p>
613
- <p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
614
- <button onClick={handleIncrement} disabled={isLoading}>
615
- Increment
616
- </button>
617
- </div>
618
- )
619
- }
620
- ```
621
-
622
- ### Field Binding
623
-
624
- For form components, use `$field` helper:
625
-
626
- ```typescript
627
- const form = Live.use(LiveForm)
628
-
629
- // Sync on blur
630
- <input {...form.$field('name', { syncOn: 'blur' })} />
631
-
632
- // Sync on change with debounce
633
- <input {...form.$field('email', { syncOn: 'change', debounce: 500 })} />
634
-
635
- // Manual sync
636
- await form.$sync()
637
- ```
638
-
639
- ### Client API
640
-
641
- ```typescript
642
- // State access
643
- counter.$state.count
644
-
645
- // Connection status
646
- counter.$connected
647
-
648
- // Loading state
649
- counter.$loading
650
-
651
- // Call action
652
- await counter.increment()
653
-
654
- // Field binding (forms)
655
- form.$field('fieldName', options)
656
-
657
- // Manual sync
658
- await form.$sync()
659
- ```
660
-
661
- ## Component Registry
662
-
663
- Components are auto-discovered from `app/server/live/`:
664
-
665
- ```typescript
666
- // app/server/live/register-components.ts
667
- import { componentRegistry } from '@core/server/live'
668
-
669
- // Auto-discover all components in directory
670
- await componentRegistry.autoDiscoverComponents('./app/server/live')
671
-
672
- // Or manually register
673
- componentRegistry.registerComponent({
674
- name: 'MyComponent',
675
- component: MyComponent,
676
- initialState: defaultState
677
- }, '1.0.0')
678
- ```
679
-
680
- ## WebSocket Connection Handling
681
-
682
- ### Automatic Reconnection
683
-
684
- Client automatically reconnects on disconnect:
685
-
686
- ```typescript
687
- <LiveComponentsProvider
688
- reconnectInterval={1000} // Retry every 1 second
689
- autoConnect={true}
690
- >
691
- ```
692
-
693
- ### State Re-hydration
694
-
695
- On reconnect, components restore previous state:
696
-
697
- 1. Client stores signed state in localStorage
698
- 2. On reconnect, sends signed state to server
699
- 3. Server validates signature (HMAC-SHA256) and **anti-replay nonce**
700
- 4. Component re-hydrated with previous state
701
- 5. State expires after 24 hours (configurable)
702
-
703
- No manual code needed - automatic. Each signed state includes a cryptographic nonce that is consumed on validation, preventing replay attacks.
704
-
705
- ## Multi-User Synchronization
706
-
707
- ### Room-Based Sync
708
-
709
- All components in same room receive events:
710
-
711
- ```typescript
712
- // User A increments
713
- await counter.increment()
714
- // Emits COUNT_CHANGED to room
715
-
716
- // User B's component receives event
717
- this.onRoomEvent('COUNT_CHANGED', (data) => {
718
- this.setState({ count: data.count })
719
- })
720
- // User B sees updated count
721
- ```
722
-
723
- ### User Tracking
724
-
725
- Track connected users in room:
726
-
727
- ```typescript
728
- constructor(initialState, ws, options) {
729
- super(initialState, ws, options)
730
-
731
- // Notify room of new user
732
- const newCount = this.state.connectedUsers + 1
733
- this.emitRoomEventWithState('USER_COUNT_CHANGED',
734
- { connectedUsers: newCount },
735
- { connectedUsers: newCount }
736
- )
737
- }
738
-
739
- destroy() {
740
- // Notify room of user leaving
741
- const newCount = Math.max(0, this.state.connectedUsers - 1)
742
- this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount })
743
- super.destroy()
744
- }
745
- ```
746
-
747
- ## Error Handling
748
-
749
- ```typescript
750
- // Server-side - throw errors
751
- async submit() {
752
- if (!this.state.email) {
753
- throw new Error('Email required')
754
- }
755
- // Process...
756
- }
757
-
758
- // Client-side - catch errors
759
- try {
760
- await form.submit()
761
- } catch (error) {
762
- alert(error.message)
763
- }
764
- ```
765
-
766
- ## Performance Monitoring
767
-
768
- Built-in performance tracking:
769
-
770
- ```typescript
771
- // Automatic metrics collection
772
- // - Render times
773
- // - Action execution times
774
- // - Error counts
775
- // - Memory usage
776
-
777
- // Access via registry
778
- const health = componentRegistry.getComponentHealth(componentId)
779
- // { status: 'healthy', metrics: {...} }
780
- ```
781
-
782
- ## Component Organization
783
-
784
- ```
785
- app/server/live/
786
- ├── LiveCounter.ts # Counter component
787
- ├── LiveForm.ts # Form component
788
- ├── LiveChat.ts # Chat component
789
- ├── LiveLocalCounter.ts # Local counter (no room)
790
- └── register-components.ts # Registration
791
-
792
- app/client/src/live/
793
- ├── CounterDemo.tsx # Counter UI
794
- ├── FormDemo.tsx # Form UI
795
- ├── ChatDemo.tsx # Chat UI
796
- └── ...
797
- ```
798
-
799
- Each server file contains:
800
- - `static componentName` - Component identifier
801
- - `static publicActions` - **REQUIRED** whitelist of client-callable methods
802
- - `static defaultState` - Initial state object
803
- - `static logging` - Per-component console log control (optional, prefer `DEBUG_LIVE=true` for debug panel — see [Live Logging](./live-logging.md))
804
- - Component class extending `LiveComponent`
805
- - Client link via `import type { Demo as _Client }`
806
-
807
- ## Testing Components
808
-
809
- ```typescript
810
- // tests/unit/live/LiveCounter.test.ts
811
- import { describe, it, expect } from 'vitest'
812
- import { LiveCounter, defaultState } from '@app/server/live/LiveCounter'
813
-
814
- describe('LiveCounter', () => {
815
- it('should increment count', async () => {
816
- const mockWs = { send: vi.fn() }
817
- const counter = new LiveCounter(defaultState, mockWs)
818
-
819
- const result = await counter.increment()
820
-
821
- expect(result.success).toBe(true)
822
- expect(result.count).toBe(1)
823
- expect(counter.state.count).toBe(1)
824
- })
825
- })
826
- ```
827
-
828
- ## Advanced: Dependencies
829
-
830
- Register services for dependency injection:
831
-
832
- ```typescript
833
- // Register service
834
- componentRegistry.registerService('database', () => db)
835
-
836
- // Register dependencies
837
- componentRegistry.registerDependencies('MyComponent', [
838
- { name: 'database', version: '1.0.0', required: true, factory: () => db }
839
- ])
840
-
841
- // Component receives service
842
- export class MyComponent extends LiveComponent<State> {
843
- private database: any
844
-
845
- setDatabase(db: any) {
846
- this.database = db
847
- }
848
- }
849
- ```
850
-
851
- ## Critical Rules
852
-
853
- **ALWAYS:**
854
- - Define `static componentName` matching class name
855
- - Define `static publicActions` listing ALL client-callable methods (MANDATORY)
856
- - Define `static defaultState` inside the class
857
- - Use `typeof ClassName.defaultState` for type parameter
858
- - Use `declare` for each state property (TypeScript type hint)
859
- - Use `onMount()` for async initialization (rooms, auth, data fetching)
860
- - Use `onDestroy()` for cleanup (timers, connections) sync only
861
- - Use `emitRoomEventWithState` for state changes in rooms
862
- - Handle errors in actions (throw Error)
863
- - Add client link: `import type { Demo as _Client } from '@client/...'`
864
- - Use `$persistent` for data that should survive HMR reloads
865
- - Use `static singleton = true` for shared cross-client state
866
-
867
- **NEVER:**
868
- - Omit `static publicActions` (component will deny ALL remote actions)
869
- - Export separate `defaultState` constant (use static)
870
- - Create constructor just to call super() (not needed)
871
- - Forget `static componentName` (breaks minification)
872
- - Override `destroy()` directly — use `onDestroy()` instead (v1.14.0)
873
- - Emit room events without subscribing first
874
- - Store non-serializable data in state
875
- - Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, $persistent, broadcastToRoom, roomType)
876
- - Include `setValue` in `publicActions` unless you trust clients to modify any state key
877
- - Store sensitive data (tokens, API keys, secrets) in `state` — use `$private` instead
878
-
879
- **STATE UPDATES (v1.13.0) all auto-sync via Proxy:**
880
- ```typescript
881
- // Direct access (1 prop 1 STATE_DELTA)
882
- declare count: number
883
- this.count++
884
-
885
- // Also works (same proxy underneath)
886
- this.state.count++
887
-
888
- // ✅ Multiple properties → use setState (1 STATE_DELTA for all)
889
- this.setState({ a: 1, b: 2, c: 3 })
890
-
891
- // Don't use setState for single property (unnecessary)
892
- this.setState({ count: this.count + 1 })
893
- ```
894
-
895
- ---
896
-
897
- ## Live Upload (Chunked Upload via WebSocket)
898
-
899
- This project includes a Live Component-based upload system that streams file chunks
900
- over the Live Components WebSocket. The client uses a chunked upload hook; the server
901
- tracks progress and assembles the file in `uploads/`.
902
-
903
- ### Server: LiveUpload Component
904
-
905
- Create server-side upload actions inside a Live Component. This example is the base
906
- implementation used by the demos:
907
-
908
- ```typescript
909
- // app/server/live/LiveUpload.ts
910
- import { LiveComponent } from '@core/types/types'
911
- import { liveUploadDefaultState, type LiveUploadState } from '@app/shared'
912
-
913
- export const defaultState: LiveUploadState = liveUploadDefaultState
914
-
915
- export class LiveUpload extends LiveComponent<LiveUploadState> {
916
- static componentName = 'LiveUpload'
917
- static publicActions = ['startUpload', 'updateProgress', 'completeUpload', 'failUpload', 'reset'] as const
918
- static defaultState = defaultState
919
-
920
- constructor(initialState: Partial<typeof defaultState>, ws: any, options?: { room?: string; userId?: string }) {
921
- super({ ...defaultState, ...initialState }, ws, options)
922
- }
923
-
924
- async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) {
925
- // Basic validation (example)
926
- const normalized = payload.fileName.toLowerCase()
927
- if (normalized.includes('..') || normalized.includes('/') || normalized.includes('\\')) {
928
- throw new Error('Invalid file name')
929
- }
930
-
931
- const ext = normalized.includes('.') ? normalized.split('.').pop() || '' : ''
932
- const blocked = ['exe', 'bat', 'cmd', 'sh', 'ps1', 'msi', 'jar']
933
- if (ext && blocked.includes(ext)) {
934
- throw new Error(`File extension not allowed: .${ext}`)
935
- }
936
-
937
- this.setState({
938
- status: 'uploading',
939
- progress: 0,
940
- fileName: payload.fileName,
941
- fileSize: payload.fileSize,
942
- fileType: payload.fileType,
943
- fileUrl: '',
944
- bytesUploaded: 0,
945
- totalBytes: payload.fileSize,
946
- error: null
947
- })
948
-
949
- return { success: true }
950
- }
951
-
952
- async updateProgress(payload: { progress: number; bytesUploaded: number; totalBytes: number }) {
953
- const progress = Math.max(0, Math.min(100, payload.progress))
954
- this.setState({
955
- progress,
956
- bytesUploaded: payload.bytesUploaded,
957
- totalBytes: payload.totalBytes
958
- })
959
-
960
- return { success: true, progress }
961
- }
962
-
963
- async completeUpload(payload: { fileUrl: string }) {
964
- this.setState({
965
- status: 'complete',
966
- progress: 100,
967
- fileUrl: payload.fileUrl,
968
- error: null
969
- })
970
-
971
- return { success: true }
972
- }
973
-
974
- async failUpload(payload: { error: string }) {
975
- this.setState({
976
- status: 'error',
977
- error: payload.error || 'Upload failed'
978
- })
979
-
980
- return { success: true }
981
- }
982
-
983
- async reset() {
984
- this.setState({ ...defaultState })
985
- return { success: true }
986
- }
987
- }
988
- ```
989
-
990
- ### Client: useLiveUpload + Widget
991
-
992
- Use the client hook and UI widget to wire the upload to the Live Component:
993
-
994
- ```typescript
995
- // app/client/src/live/UploadDemo.tsx
996
- import { useLiveUpload } from './useLiveUpload'
997
- import { LiveUploadWidget } from '../components/LiveUploadWidget'
998
-
999
- export function UploadDemo() {
1000
- const { live } = useLiveUpload()
1001
-
1002
- return (
1003
- <LiveUploadWidget live={live} />
1004
- )
1005
- }
1006
- ```
1007
-
1008
- ### Chunked Upload Flow
1009
-
1010
- 1. Client calls `startUpload()` (Live Component action).
1011
- 2. Client streams file chunks over WebSocket with `useChunkedUpload`.
1012
- 3. Server assembles file in `uploads/` and returns `/uploads/...`.
1013
- 4. Client maps to `/api/uploads/...` for access.
1014
-
1015
- ### Error Handling
1016
-
1017
- - If an action throws, the error surfaces in `live.$error` on the client.
1018
- - The widget shows `localError || state.error || $error`.
1019
-
1020
- ### Files Involved
1021
-
1022
- **Server**
1023
- - `app/server/live/LiveUpload.ts`
1024
- - `core/server/live/FileUploadManager.ts` (chunk handling + file assembly)
1025
- - `core/server/live/websocket-plugin.ts` (upload message routing)
1026
-
1027
- **Client**
1028
- - `core/client/hooks/useChunkedUpload.ts` (streaming chunks)
1029
- - `core/client/hooks/useLiveUpload.ts` (Live Component wrapper)
1030
- - `app/client/src/components/LiveUploadWidget.tsx` (UI)
1031
-
1032
- ## Related
1033
-
1034
- - [Live Auth](./live-auth.md) - Authentication for Live Components
1035
- - [Live Logging](./live-logging.md) - Per-component logging control
1036
- - [Live Rooms](./live-rooms.md) - Multi-room real-time communication
1037
- - [Live Upload](./live-upload.md) - Chunked file upload
1038
- - [Live Binary Delta](./live-binary-delta.md) - High-frequency binary state sync
1039
- - [Project Structure](../patterns/project-structure.md)
1040
- - [Type Safety Patterns](../patterns/type-safety.md)
1041
- - [WebSocket Plugin](../core/plugin-system.md)
1
+ # Live Components
2
+
3
+ **Version:** @fluxstack/live 0.7.2 | **Updated:** 2026-04-14
4
+
5
+ ## Quick Facts
6
+
7
+ - Server-side state management with WebSocket sync
8
+ - **Direct state access** - `this.count++` auto-syncs via reactive proxy
9
+ - **Lifecycle hooks** - `onMount()`, `onDestroy()`, `onConnect()`, `onDisconnect()`, and more (all optional)
10
+ - **HMR persistence** - `static persistent` + `this.$persistent` survives hot reloads
11
+ - **Singleton components** - `static singleton = true` for shared server-side instances
12
+ - **Mandatory `publicActions`** - Only whitelisted methods are callable from client (secure by default)
13
+ - **Helpful error messages** - Forgotten `publicActions` entries show exactly what to fix
14
+ - **Custom ID generator** - `LiveServerOptions.generateId` replaces default ID generation
15
+ - Automatic state persistence and re-hydration (with anti-replay nonces)
16
+ - Room-based event system for multi-user sync (typed `LiveRoom` support)
17
+ - Type-safe client-server communication (`FluxStackWebSocket`)
18
+ - Built-in connection management and recovery
19
+ - **Client component links** - Ctrl+Click navigation via `import type`
20
+
21
+ ## LiveComponent Class Structure
22
+
23
+ Server-side component extends `LiveComponent` from `@fluxstack/live` with **static defaultState**:
24
+
25
+ ```typescript
26
+ // app/server/live/LiveLocalCounter.ts
27
+ import { LiveComponent } from '@core/types/types'
28
+
29
+ // Componente Cliente (Ctrl+Click para navegar)
30
+ import type { CounterDemo as _Client } from '@client/src/live/CounterDemo'
31
+
32
+ export class LiveLocalCounter extends LiveComponent<typeof LiveLocalCounter.defaultState> {
33
+ static componentName = 'LiveLocalCounter'
34
+ static publicActions = ['increment', 'decrement', 'reset'] as const // REQUIRED
35
+ static defaultState = {
36
+ count: 0,
37
+ clicks: 0
38
+ }
39
+
40
+ // Declarar propriedades do estado (TypeScript)
41
+ declare count: number
42
+ declare clicks: number
43
+
44
+ // Direct state access - auto-syncs with frontend
45
+ async increment() {
46
+ this.count++
47
+ this.clicks++
48
+ return { success: true, count: this.count }
49
+ }
50
+
51
+ async decrement() {
52
+ this.count--
53
+ this.clicks++
54
+ return { success: true, count: this.count }
55
+ }
56
+
57
+ async reset() {
58
+ this.count = 0
59
+ this.clicks++
60
+ return { success: true, count: 0 }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### Key Patterns
66
+
67
+ 1. **Direct state access** - `this.count++` instead of `this.state.count++`
68
+ 2. **`declare` keyword** - TypeScript hint for dynamic state properties
69
+ 3. **Static `defaultState`** inside the class - no external export needed
70
+ 4. **Reactive Proxy** - `this.state.count++` or `this.count++` triggers sync automatically
71
+ 5. **No constructor needed** - Base class handles `defaultState` merge (constructor only needed for room event subscriptions)
72
+ 6. **Mandatory `publicActions`** - Components without it deny ALL remote actions (secure by default)
73
+ 7. **Client link** - `import type { Demo as _Client }` enables Ctrl+Click in IDE
74
+
75
+ ### With Room Events (Advanced)
76
+
77
+ ```typescript
78
+ // app/server/live/LiveCounter.ts
79
+ import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
80
+
81
+ export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> {
82
+ static componentName = 'LiveCounter'
83
+ static publicActions = ['increment', 'decrement', 'reset'] as const
84
+ static defaultState = {
85
+ count: 0,
86
+ lastUpdatedBy: null as string | null,
87
+ connectedUsers: 0
88
+ }
89
+ protected roomType = 'counter'
90
+
91
+ // Constructor needed for room event subscriptions
92
+ constructor(
93
+ initialState: Partial<typeof LiveCounter.defaultState> = {},
94
+ ws: FluxStackWebSocket,
95
+ options?: { room?: string; userId?: string }
96
+ ) {
97
+ super(initialState, ws, options)
98
+
99
+ this.onRoomEvent<{ count: number; userId: string }>('COUNT_CHANGED', (data) => {
100
+ this.setState({ count: data.count, lastUpdatedBy: data.userId })
101
+ })
102
+
103
+ this.onRoomEvent<{ connectedUsers: number }>('USER_COUNT_CHANGED', (data) => {
104
+ this.setState({ connectedUsers: data.connectedUsers })
105
+ })
106
+
107
+ this.notifyUserJoined()
108
+ }
109
+
110
+ private notifyUserJoined() {
111
+ const newCount = this.state.connectedUsers + 1
112
+ this.emitRoomEventWithState('USER_COUNT_CHANGED',
113
+ { connectedUsers: newCount },
114
+ { connectedUsers: newCount }
115
+ )
116
+ }
117
+
118
+ async increment() {
119
+ const newCount = this.state.count + 1
120
+ this.emitRoomEventWithState('COUNT_CHANGED',
121
+ { count: newCount, userId: this.userId || 'anonymous' },
122
+ { count: newCount, lastUpdatedBy: this.userId || 'anonymous' }
123
+ )
124
+ return { success: true, count: newCount }
125
+ }
126
+
127
+ async decrement() {
128
+ const newCount = this.state.count - 1
129
+ this.emitRoomEventWithState('COUNT_CHANGED',
130
+ { count: newCount, userId: this.userId || 'anonymous' },
131
+ { count: newCount, lastUpdatedBy: this.userId || 'anonymous' }
132
+ )
133
+ return { success: true, count: newCount }
134
+ }
135
+
136
+ async reset() {
137
+ this.emitRoomEventWithState('COUNT_CHANGED',
138
+ { count: 0, userId: this.userId || 'anonymous' },
139
+ { count: 0, lastUpdatedBy: this.userId || 'anonymous' }
140
+ )
141
+ return { success: true, count: 0 }
142
+ }
143
+
144
+ destroy() {
145
+ const newCount = Math.max(0, this.state.connectedUsers - 1)
146
+ this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount })
147
+ super.destroy()
148
+ }
149
+ }
150
+ ```
151
+
152
+ ## Lifecycle Hooks
153
+
154
+ 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.
155
+
156
+ ```typescript
157
+ export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
158
+ static componentName = 'MyComponent'
159
+ static publicActions = ['doWork'] as const
160
+ static defaultState = { users: [] as string[], ready: false, currentRoom: '' }
161
+
162
+ private _pollTimer?: NodeJS.Timeout
163
+
164
+ // 1. Called when WebSocket connection is established (before onMount)
165
+ protected onConnect() {
166
+ console.log('WebSocket connected for this component')
167
+ }
168
+
169
+ // 2. Called AFTER component is fully mounted (rooms, auth, injections ready)
170
+ // Can be async!
171
+ protected async onMount() {
172
+ this.$room('main').join()
173
+ const data = await fetchInitialData(this.$auth.session?.id)
174
+ this.state.ready = true
175
+ this._pollTimer = setInterval(() => this.poll(), 5000)
176
+ }
177
+
178
+ // Called after state is restored from localStorage (rehydration)
179
+ protected onRehydrate(previousState: typeof MyComponent.defaultState) {
180
+ if (!previousState.ready) {
181
+ this.state.ready = false // Re-validate stale state
182
+ }
183
+ }
184
+
185
+ // Called after any state mutation (proxy or setState)
186
+ protected onStateChange(changes: Partial<typeof MyComponent.defaultState>) {
187
+ if ('users' in changes) {
188
+ console.log(`User count: ${this.state.users.length}`)
189
+ }
190
+ }
191
+
192
+ // Called when joining a room
193
+ protected onRoomJoin(roomId: string) {
194
+ this.state.currentRoom = roomId
195
+ }
196
+
197
+ // Called when leaving a room
198
+ protected onRoomLeave(roomId: string) {
199
+ if (this.state.currentRoom === roomId) this.state.currentRoom = ''
200
+ }
201
+
202
+ // Called before each action -- return false to cancel
203
+ protected onAction(action: string, payload: any) {
204
+ console.log(`[${this.id}] ${action}`, payload)
205
+ // return false // would cancel the action
206
+ }
207
+
208
+ // Called when a new client joins a singleton component
209
+ protected onClientJoin(connectionId: string, connectionCount: number) {
210
+ console.log(`Client ${connectionId} joined, total: ${connectionCount}`)
211
+ }
212
+
213
+ // Called when a client leaves a singleton component
214
+ protected onClientLeave(connectionId: string, connectionCount: number) {
215
+ console.log(`Client ${connectionId} left, total: ${connectionCount}`)
216
+ }
217
+
218
+ // Called when WebSocket drops (NOT on intentional unmount)
219
+ protected onDisconnect() {
220
+ console.log('Connection lost -- saving recovery data')
221
+ }
222
+
223
+ // Called BEFORE internal cleanup (sync only)
224
+ protected onDestroy() {
225
+ clearInterval(this._pollTimer)
226
+ }
227
+
228
+ async doWork() { /* ... */ }
229
+ private poll() { /* ... */ }
230
+ }
231
+ ```
232
+
233
+ > **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/live` framework API and can be used in any LiveComponent subclass.
234
+
235
+ ### Lifecycle Order
236
+
237
+ ```
238
+ WebSocket connects
239
+ -> onConnect()
240
+ -> onMount() <- async, rooms/auth ready
241
+ -> [component active]
242
+ |-> onAction(action, payload) <- before each action (return false to cancel)
243
+ |-> onStateChange(changes) <- after each state mutation
244
+ |-> onRoomJoin(roomId) <- when joining a room
245
+ |-> onRoomLeave(roomId) <- when leaving a room
246
+ |-> onClientJoin(connId, count) <- singleton: new client joined
247
+ -> onClientLeave(connId, count) <- singleton: client left
248
+
249
+ Connection drops:
250
+ -> onDisconnect() <- only on unexpected disconnect
251
+ -> onDestroy() <- sync, before internal cleanup
252
+
253
+ Rehydration (reconnect with saved state):
254
+ -> onConnect()
255
+ -> onRehydrate(previousState)
256
+ -> onMount()
257
+ ```
258
+
259
+ ### Rules
260
+
261
+ | Hook | Async? | When |
262
+ |------|--------|------|
263
+ | `onConnect()` | No | WebSocket established, before mount |
264
+ | `onMount()` | **Yes** | After all setup (rooms, auth, DI) |
265
+ | `onRehydrate(prevState)` | No | After state restored from localStorage |
266
+ | `onStateChange(changes)` | No | After every state mutation |
267
+ | `onRoomJoin(roomId)` | No | After `$room.join()` |
268
+ | `onRoomLeave(roomId)` | No | After `$room.leave()` |
269
+ | `onAction(action, payload)` | **Yes** | Before action execution (return `false` to cancel) |
270
+ | `onClientJoin(connId, count)` | No | Singleton: new client connected |
271
+ | `onClientLeave(connId, count)` | No | Singleton: client disconnected |
272
+ | `onDisconnect()` | No | Connection lost (NOT intentional unmount) |
273
+ | `onDestroy()` | No | Before internal cleanup |
274
+
275
+ - All hooks are optional -- override only what you need
276
+ - All hook errors are caught and logged -- they never break the system
277
+ - Constructor is still needed ONLY for `this.onRoomEvent()` subscriptions
278
+ - All hooks are in BLOCKED_ACTIONS -- clients cannot call them remotely
279
+
280
+ ## Custom ID Generator
281
+
282
+ The `LiveServer` accepts a `generateId` option that replaces the default ID generation for component IDs, connection IDs, and cluster singleton IDs:
283
+
284
+ ```typescript
285
+ import { LiveServer } from '@fluxstack/live'
286
+ import { nanoid } from 'nanoid'
287
+
288
+ const server = new LiveServer({
289
+ transport: elysiaAdapter,
290
+ generateId: () => nanoid(), // Custom ID generator
291
+ })
292
+ ```
293
+
294
+ 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).
295
+
296
+ ## HMR Persistence
297
+
298
+ Data in `static persistent` survives Hot Module Replacement reloads via `globalThis`:
299
+
300
+ ```typescript
301
+ export class LiveMigration extends LiveComponent<typeof LiveMigration.defaultState> {
302
+ static componentName = 'LiveMigration'
303
+ static publicActions = ['runMigration'] as const
304
+ static defaultState = { status: 'idle', lastResult: '' }
305
+
306
+ // Define shape and defaults for persistent data
307
+ static persistent = {
308
+ cache: {} as Record<string, any>,
309
+ runCount: 0
310
+ }
311
+
312
+ protected onMount() {
313
+ this.$persistent.runCount++
314
+ console.log(`Mount #${this.$persistent.runCount}`) // Survives HMR!
315
+ }
316
+
317
+ async runMigration(payload: { key: string }) {
318
+ // Check HMR-safe cache
319
+ if (this.$persistent.cache[payload.key]) {
320
+ return { cached: true, result: this.$persistent.cache[payload.key] }
321
+ }
322
+
323
+ const result = await expensiveComputation(payload.key)
324
+ this.$persistent.cache[payload.key] = result
325
+ this.state.lastResult = result
326
+ return { cached: false, result }
327
+ }
328
+ }
329
+ ```
330
+
331
+ **Key facts:**
332
+ - `this.$persistent` reads from `globalThis.__fluxstack_persistent_{ComponentName}`
333
+ - Each component class has its own namespace
334
+ - Defaults come from `static persistent` -- initialized once, then persisted
335
+ - Not sent to client -- server-only
336
+ - `$persistent` is in BLOCKED_ACTIONS (can't be called from client)
337
+
338
+ ## Singleton Components
339
+
340
+ 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.
341
+
342
+ ```typescript
343
+ export class LiveDashboard extends LiveComponent<typeof LiveDashboard.defaultState> {
344
+ static componentName = 'LiveDashboard'
345
+ static singleton = true // All clients share this instance
346
+ static publicActions = ['refresh', 'addAlert'] as const
347
+ static defaultState = {
348
+ visitors: 0,
349
+ alerts: [] as string[],
350
+ lastRefresh: ''
351
+ }
352
+
353
+ protected async onMount() {
354
+ this.state.visitors++
355
+ this.state.lastRefresh = new Date().toISOString()
356
+ }
357
+
358
+ // Singleton-specific hooks (optional)
359
+ protected onClientJoin(connectionId: string, connectionCount: number) {
360
+ this.state.visitors = connectionCount
361
+ }
362
+
363
+ protected onClientLeave(connectionId: string, connectionCount: number) {
364
+ this.state.visitors = connectionCount
365
+ }
366
+
367
+ async refresh() {
368
+ const data = await fetchDashboardData()
369
+ this.setState(data) // Broadcasts to ALL connected clients
370
+ return { success: true }
371
+ }
372
+
373
+ async addAlert(payload: { message: string }) {
374
+ this.state.alerts = [...this.state.alerts, payload.message]
375
+ // All clients see the new alert instantly
376
+ return { success: true }
377
+ }
378
+ }
379
+ ```
380
+
381
+ **How it works:**
382
+ - First client to mount creates the singleton instance
383
+ - Subsequent clients join the existing instance and receive current state
384
+ - `emit` / `setState` / `this.state.x = y` broadcast to ALL connected WebSockets
385
+ - When a client disconnects, it's removed from the singleton's connections
386
+ - When the LAST client disconnects, the singleton is destroyed
387
+ - Stats visible at `/api/live/stats` (shows singleton connection counts)
388
+ - Cluster support: coordinated across server instances via `IClusterAdapter` (Redis)
389
+
390
+ **Use cases:** Shared dashboards, global migration state, admin panels, live counters
391
+
392
+ ## State Management
393
+
394
+ ### Reactive State Proxy (How It Works)
395
+
396
+ State mutations auto-sync with the frontend via two layers:
397
+
398
+ **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.
399
+
400
+ **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`.
401
+
402
+ ```
403
+ this.count++ -> accessor setter -> proxy set -> STATE_DELTA
404
+ this.state.count++ -> proxy set -> STATE_DELTA
405
+ this.setState({count: 1}) -> Object.assign + single STATE_DELTA (batch)
406
+ ```
407
+
408
+ ### Direct State Access
409
+
410
+ State properties are accessible directly on `this`:
411
+
412
+ ```typescript
413
+ // Declare properties for TypeScript
414
+ declare count: number
415
+ declare message: string
416
+
417
+ // Direct access - auto-syncs via proxy!
418
+ this.count++
419
+ this.message = 'Hello'
420
+
421
+ // Also works - same proxy underneath
422
+ this.state.count++
423
+ ```
424
+
425
+ > **Performance note:** Each direct assignment emits one `STATE_DELTA`. For multiple properties at once, use `setState` (single emit).
426
+
427
+ ### setState (Batch Updates)
428
+
429
+ Use `setState` for multiple properties at once (single emit):
430
+
431
+ ```typescript
432
+ // Batch update - one STATE_DELTA event
433
+ this.setState({
434
+ count: newCount,
435
+ lastUpdatedBy: userId,
436
+ updatedAt: new Date().toISOString()
437
+ })
438
+
439
+ // Function updater (access previous state)
440
+ this.setState(prev => ({
441
+ count: prev.count + 1,
442
+ lastUpdatedBy: userId
443
+ }))
444
+ ```
445
+
446
+ > `setState` writes directly to `_state` (bypasses proxy) and emits a single `STATE_DELTA` with all changed keys. More efficient than N individual assignments.
447
+
448
+ ### setValue (Generic Action)
449
+
450
+ Built-in action to set any state key from the client. **Must be explicitly included in `publicActions` to be callable:**
451
+
452
+ ```typescript
453
+ // Server: opt-in to setValue
454
+ static publicActions = ['increment', 'setValue'] as const // Must include 'setValue'
455
+
456
+ // Client can then call:
457
+ await component.setValue({ key: 'count', value: 42 })
458
+ ```
459
+
460
+ > **Security note:** `setValue` is powerful - it allows the client to set any state key. Only add it to `publicActions` if you trust the client to modify any state field.
461
+
462
+ ### $private -- Server-Only State
463
+
464
+ `$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()`.
465
+
466
+ Use it for sensitive data like tokens, API keys, internal IDs, or any server-side bookkeeping:
467
+
468
+ ```typescript
469
+ export class Chat extends LiveComponent<typeof Chat.defaultState> {
470
+ static componentName = 'Chat'
471
+ static publicActions = ['connect', 'sendMessage'] as const
472
+ static defaultState = { messages: [] as string[] }
473
+
474
+ async connect(payload: { token: string }) {
475
+ // Stays on server -- never sent to client
476
+ this.$private.token = payload.token
477
+ this.$private.apiKey = await getApiKey()
478
+
479
+ // Only UI data goes to state (synced with client)
480
+ this.state.messages = await fetchMessages(this.$private.token)
481
+ return { success: true }
482
+ }
483
+
484
+ async sendMessage(payload: { text: string }) {
485
+ // Use $private data for server-side logic
486
+ await postToAPI(this.$private.apiKey, payload.text)
487
+ this.state.messages = [...this.state.messages, payload.text]
488
+ return { success: true }
489
+ }
490
+ }
491
+ ```
492
+
493
+ #### Typed $private (optional)
494
+
495
+ Pass a second generic to get full autocomplete and type checking:
496
+
497
+ ```typescript
498
+ interface ChatPrivate {
499
+ token: string
500
+ apiKey: string
501
+ retryCount: number
502
+ }
503
+
504
+ export class Chat extends LiveComponent<typeof Chat.defaultState, ChatPrivate> {
505
+ static componentName = 'Chat'
506
+ static publicActions = ['connect'] as const
507
+ static defaultState = { messages: [] as string[] }
508
+
509
+ async connect(payload: { token: string }) {
510
+ this.$private.token = payload.token // autocomplete
511
+ this.$private.retryCount = 0 // must be number
512
+ // this.$private.tokkken = 'x' // TypeScript error (typo)
513
+ }
514
+ }
515
+ ```
516
+
517
+ The second generic defaults to `Record<string, any>`, so existing components work without changes.
518
+
519
+ **Key facts:**
520
+ - Starts as an empty `{}` -- no static default needed
521
+ - Mutations do NOT trigger any WebSocket messages
522
+ - Cleared automatically on `destroy()`
523
+ - Lost on rehydration (re-populate in your action handlers)
524
+ - Blocked from remote access (`$private` and `_privateState` are in BLOCKED_ACTIONS)
525
+ - Optional `TPrivate` generic for full type safety
526
+
527
+ ### getSerializableState
528
+
529
+ Get current state for serialization (does NOT include `$private`):
530
+
531
+ ```typescript
532
+ const currentState = this.getSerializableState()
533
+ ```
534
+
535
+ ### State Persistence
536
+
537
+ State is automatically signed and persisted on client. On reconnection, state is re-hydrated:
538
+
539
+ ```typescript
540
+ // Automatic - no code needed
541
+ // Client stores signed state in localStorage
542
+ // On reconnect, sends signed state to server
543
+ // Server validates signature and restores component
544
+ ```
545
+
546
+ ## Room Events System
547
+
548
+ ### Subscribe to Room Events
549
+
550
+ ```typescript
551
+ constructor(initialState, ws, options) {
552
+ super(initialState, ws, options)
553
+
554
+ // Listen for room events
555
+ this.onRoomEvent<{ count: number }>('COUNT_CHANGED', (data) => {
556
+ this.setState({ count: data.count })
557
+ })
558
+
559
+ this.onRoomEvent<{ message: string }>('MESSAGE_SENT', (data) => {
560
+ // Handle message
561
+ })
562
+ }
563
+ ```
564
+
565
+ ### Emit Room Events
566
+
567
+ ```typescript
568
+ // Emit event to all room members
569
+ this.emitRoomEvent('MESSAGE_SENT', {
570
+ message: 'Hello',
571
+ userId: this.userId
572
+ })
573
+
574
+ // Emit event AND update local state
575
+ this.emitRoomEventWithState('COUNT_CHANGED',
576
+ { count: newCount }, // Event data
577
+ { count: newCount } // State update
578
+ )
579
+ ```
580
+
581
+ ### Typed Rooms ($room)
582
+
583
+ Components can use typed `LiveRoom` classes for structured room interactions:
584
+
585
+ ```typescript
586
+ import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
587
+ import { CounterRoom } from './rooms/CounterRoom'
588
+
589
+ export class LiveSharedCounter extends LiveComponent<typeof LiveSharedCounter.defaultState> {
590
+ static componentName = 'LiveSharedCounter'
591
+ static publicActions = ['increment', 'decrement', 'reset'] as const
592
+ static defaultState = {
593
+ username: '',
594
+ count: 0,
595
+ lastUpdatedBy: null as string | null,
596
+ onlineCount: 0
597
+ }
598
+
599
+ private counterUnsub: (() => void) | null = null
600
+
601
+ constructor(initialState: Partial<typeof LiveSharedCounter.defaultState> = {}, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
602
+ super(initialState, ws, options)
603
+
604
+ const room = this.$room(CounterRoom, 'global')
605
+ room.join()
606
+
607
+ // Load current state from room
608
+ this.setState({
609
+ count: room.state.count,
610
+ lastUpdatedBy: room.state.lastUpdatedBy,
611
+ onlineCount: room.state.onlineCount
612
+ })
613
+
614
+ // Listen for updates from other users
615
+ this.counterUnsub = room.on('counter:updated', (data) => {
616
+ this.setState({ count: data.count, lastUpdatedBy: data.updatedBy })
617
+ })
618
+ }
619
+
620
+ async increment() {
621
+ const room = this.$room(CounterRoom, 'global')
622
+ const count = room.increment(this.state.username || 'Anonymous')
623
+ return { success: true, count }
624
+ }
625
+
626
+ destroy() {
627
+ this.counterUnsub?.()
628
+ super.destroy()
629
+ }
630
+ }
631
+ ```
632
+
633
+ **$room API:**
634
+ - `this.$room(RoomClass, instanceId)` -- typed room handle with custom methods
635
+ - `this.$room('roomId')` -- untyped room handle (legacy)
636
+ - `this.$rooms` -- list of room IDs this component participates in
637
+
638
+ ## Actions
639
+
640
+ Actions are methods callable from the client. **Only methods listed in `publicActions` can be called remotely.** Components without `publicActions` deny ALL remote actions.
641
+
642
+ ```typescript
643
+ // Server-side
644
+ export class LiveForm extends LiveComponent<typeof LiveForm.defaultState> {
645
+ static publicActions = ['submit', 'validate', 'reset', 'setValue'] as const
646
+
647
+ static defaultState = {
648
+ name: '',
649
+ email: '',
650
+ message: '',
651
+ submitted: false,
652
+ submittedAt: null as string | null
653
+ }
654
+
655
+ async submit() {
656
+ const { name, email, message } = this.state
657
+
658
+ if (!name || !email) {
659
+ throw new Error('Nome e email sao obrigatorios')
660
+ }
661
+
662
+ this.setState({
663
+ submitted: true,
664
+ submittedAt: new Date().toISOString()
665
+ })
666
+
667
+ return {
668
+ success: true,
669
+ data: { name, email, message },
670
+ submittedAt: this.state.submittedAt
671
+ }
672
+ }
673
+
674
+ async validate() {
675
+ const errors: Record<string, string> = {}
676
+
677
+ if (!this.state.name) errors.name = 'Nome e obrigatorio'
678
+ if (!this.state.email) errors.email = 'Email e obrigatorio'
679
+ else if (!this.state.email.includes('@')) errors.email = 'Email invalido'
680
+
681
+ return { valid: Object.keys(errors).length === 0, errors }
682
+ }
683
+ }
684
+ ```
685
+
686
+ ### Action Security Features (framework)
687
+
688
+ The `@fluxstack/live` framework provides additional action security features:
689
+
690
+ - **Zod validation** -- `static actionSchemas` for automatic payload validation before action execution
691
+ - **Rate limiting** -- `static actionRateLimit` to prevent clients from spamming actions
692
+ - **Per-action auth** -- `static actionAuth` with roles/permissions per action
693
+
694
+ ```typescript
695
+ static actionSchemas = {
696
+ sendMessage: z.object({ text: z.string().max(500) }),
697
+ }
698
+
699
+ static actionRateLimit = { maxCalls: 10, windowMs: 1000, perAction: true }
700
+ ```
701
+
702
+ ## Authentication
703
+
704
+ Components can require authentication and define per-action permissions:
705
+
706
+ ```typescript
707
+ export class LiveAdminPanel extends LiveComponent<AdminPanelState> {
708
+ static componentName = 'LiveAdminPanel'
709
+ static publicActions = ['getAuthInfo', 'init', 'listUsers', 'addUser', 'deleteUser', 'clearAudit'] as const
710
+
711
+ // Component-level: requires auth + admin role
712
+ static auth: LiveComponentAuth = {
713
+ required: true,
714
+ roles: ['admin'],
715
+ }
716
+
717
+ // Per-action: granular permissions
718
+ static actionAuth: LiveActionAuthMap = {
719
+ deleteUser: { permissions: ['users.delete'] },
720
+ clearAudit: { roles: ['admin'] },
721
+ }
722
+
723
+ async getAuthInfo() {
724
+ return {
725
+ authenticated: this.$auth.authenticated,
726
+ userId: this.$auth.session?.id,
727
+ roles: this.$auth.session?.roles || [],
728
+ isAdmin: this.$auth.hasRole('admin'),
729
+ }
730
+ }
731
+ }
732
+ ```
733
+
734
+ **Auth levels:**
735
+ - `this.state` -- client reads AND writes (bidirectional)
736
+ - `this.$private` -- client NEVER sees (server-only)
737
+ - `this.$auth` -- set by framework, immutable (read-only)
738
+
739
+ ## Client-Side Integration
740
+
741
+ ### Provider Setup
742
+
743
+ Wrap app with LiveComponentsProvider:
744
+
745
+ ```typescript
746
+ // app/client/src/App.tsx
747
+ import { LiveComponentsProvider } from '@/core/client'
748
+
749
+ function App() {
750
+ return (
751
+ <LiveComponentsProvider
752
+ url="ws://localhost:3000"
753
+ autoConnect={true}
754
+ reconnectInterval={1000}
755
+ debug={true}
756
+ >
757
+ <AppContent />
758
+ </LiveComponentsProvider>
759
+ )
760
+ }
761
+ ```
762
+
763
+ ### Using Components
764
+
765
+ ```typescript
766
+ import { Live } from '@/core/client'
767
+ import { LiveCounter } from '@server/live/LiveCounter'
768
+
769
+ export function CounterDemo() {
770
+ // Mount component with options
771
+ const counter = Live.use(LiveCounter, {
772
+ room: 'global-counter',
773
+ initialState: LiveCounter.defaultState
774
+ })
775
+
776
+ // Access state
777
+ const count = counter.$state.count
778
+
779
+ // Check connection status
780
+ const isConnected = counter.$connected
781
+
782
+ // Check loading state
783
+ const isLoading = counter.$loading
784
+
785
+ // Call actions
786
+ const handleIncrement = async () => {
787
+ await counter.increment()
788
+ }
789
+
790
+ return (
791
+ <div>
792
+ <p>Count: {count}</p>
793
+ <p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
794
+ <button onClick={handleIncrement} disabled={isLoading}>
795
+ Increment
796
+ </button>
797
+ </div>
798
+ )
799
+ }
800
+ ```
801
+
802
+ ### Field Binding
803
+
804
+ For form components, use `$field` helper:
805
+
806
+ ```typescript
807
+ const form = Live.use(LiveForm)
808
+
809
+ // Sync on blur
810
+ <input {...form.$field('name', { syncOn: 'blur' })} />
811
+
812
+ // Sync on change with debounce
813
+ <input {...form.$field('email', { syncOn: 'change', debounce: 500 })} />
814
+
815
+ // Manual sync
816
+ await form.$sync()
817
+ ```
818
+
819
+ ### Client API
820
+
821
+ ```typescript
822
+ // State access
823
+ counter.$state.count
824
+
825
+ // Connection status
826
+ counter.$connected
827
+
828
+ // Loading state
829
+ counter.$loading
830
+
831
+ // Call action
832
+ await counter.increment()
833
+
834
+ // Field binding (forms)
835
+ form.$field('fieldName', options)
836
+
837
+ // Manual sync
838
+ await form.$sync()
839
+ ```
840
+
841
+ ## Component Registry
842
+
843
+ Components are auto-discovered from `app/server/live/`:
844
+
845
+ ```typescript
846
+ // app/server/live/auto-generated-components.ts (auto-generated by @fluxstack/live)
847
+ import { LiveAdminPanel } from "./LiveAdminPanel"
848
+ import { LiveCounter } from "./LiveCounter"
849
+ import { LiveForm } from "./LiveForm"
850
+ // ... etc
851
+
852
+ export const liveComponentClasses = [
853
+ LiveAdminPanel,
854
+ LiveCounter,
855
+ LiveForm,
856
+ // ...
857
+ ]
858
+ ```
859
+
860
+ The `LiveServer` auto-discovers components via `componentsPath` option and generates this file. For production builds, pass `components: liveComponentClasses` to avoid dynamic imports.
861
+
862
+ ## WebSocket Connection Handling
863
+
864
+ ### Automatic Reconnection
865
+
866
+ Client automatically reconnects on disconnect:
867
+
868
+ ```typescript
869
+ <LiveComponentsProvider
870
+ reconnectInterval={1000} // Retry every 1 second
871
+ autoConnect={true}
872
+ >
873
+ ```
874
+
875
+ ### State Re-hydration
876
+
877
+ On reconnect, components restore previous state:
878
+
879
+ 1. Client stores signed state in localStorage
880
+ 2. On reconnect, sends signed state to server
881
+ 3. Server validates signature (HMAC-SHA256) and **anti-replay nonce**
882
+ 4. Component re-hydrated with previous state
883
+ 5. State expires after 24 hours (configurable)
884
+
885
+ No manual code needed - automatic. Each signed state includes a cryptographic nonce that is consumed on validation, preventing replay attacks.
886
+
887
+ ## Multi-User Synchronization
888
+
889
+ ### Room-Based Sync
890
+
891
+ All components in same room receive events:
892
+
893
+ ```typescript
894
+ // User A increments
895
+ await counter.increment()
896
+ // Emits COUNT_CHANGED to room
897
+
898
+ // User B's component receives event
899
+ this.onRoomEvent('COUNT_CHANGED', (data) => {
900
+ this.setState({ count: data.count })
901
+ })
902
+ // User B sees updated count
903
+ ```
904
+
905
+ ### User Tracking
906
+
907
+ Track connected users in room:
908
+
909
+ ```typescript
910
+ constructor(initialState, ws, options) {
911
+ super(initialState, ws, options)
912
+
913
+ // Notify room of new user
914
+ const newCount = this.state.connectedUsers + 1
915
+ this.emitRoomEventWithState('USER_COUNT_CHANGED',
916
+ { connectedUsers: newCount },
917
+ { connectedUsers: newCount }
918
+ )
919
+ }
920
+
921
+ destroy() {
922
+ // Notify room of user leaving
923
+ const newCount = Math.max(0, this.state.connectedUsers - 1)
924
+ this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount })
925
+ super.destroy()
926
+ }
927
+ ```
928
+
929
+ ## Error Handling
930
+
931
+ ```typescript
932
+ // Server-side - throw errors
933
+ async submit() {
934
+ if (!this.state.email) {
935
+ throw new Error('Email required')
936
+ }
937
+ // Process...
938
+ }
939
+
940
+ // Client-side - catch errors
941
+ try {
942
+ await form.submit()
943
+ } catch (error) {
944
+ alert(error.message)
945
+ }
946
+ ```
947
+
948
+ ## Performance Monitoring
949
+
950
+ Built-in performance tracking:
951
+
952
+ ```typescript
953
+ // Automatic metrics collection
954
+ // - Render times
955
+ // - Action execution times
956
+ // - Error counts
957
+ // - Memory usage
958
+
959
+ // Access via registry
960
+ const health = componentRegistry.getComponentHealth(componentId)
961
+ // { status: 'healthy', metrics: {...} }
962
+ ```
963
+
964
+ ## Existing Components
965
+
966
+ The app includes these live components in `app/server/live/`:
967
+
968
+ | Component | Description | Features |
969
+ |-----------|-------------|----------|
970
+ | `LiveLocalCounter` | Simple counter, no room events | Direct state access, `declare` |
971
+ | `LiveCounter` | Shared counter with room events | `onRoomEvent`, `emitRoomEventWithState` |
972
+ | `LiveSharedCounter` | Shared counter using typed `CounterRoom` | `$room(CounterRoom, 'global')` |
973
+ | `LiveForm` | Reactive form with server validation | `setValue`, `validate`, `submit` |
974
+ | `LivePingPong` | Binary codec demo (msgpack) | Typed `PingRoom`, round-trip timing |
975
+ | `LiveRoomChat` | Multi-room chat with directory | `ChatRoom`, `DirectoryRoom`, password rooms |
976
+ | `LiveProtectedChat` | Auth-required chat | `static auth`, `static actionAuth`, roles |
977
+ | `LiveAdminPanel` | Admin panel with RBAC | Component + per-action auth, audit trail |
978
+ | `LiveUpload` | Chunked file upload via WebSocket | Filename validation, progress tracking |
979
+
980
+ ## Component Organization
981
+
982
+ ```
983
+ app/server/live/
984
+ ├── LiveCounter.ts # Shared counter with room events
985
+ ├── LiveLocalCounter.ts # Local counter (no room)
986
+ ├── LiveForm.ts # Reactive form
987
+ ├── LivePingPong.ts # Binary codec demo
988
+ ├── LiveSharedCounter.ts # Typed room counter
989
+ ├── LiveRoomChat.ts # Multi-room chat
990
+ ├── LiveProtectedChat.ts # Auth-required chat
991
+ ├── LiveAdminPanel.ts # Admin panel with RBAC
992
+ ├── LiveUpload.ts # Chunked file upload
993
+ ├── rooms/ # Typed LiveRoom definitions
994
+ │ ├── ChatRoom.ts
995
+ │ ├── CounterRoom.ts
996
+ │ ├── DirectoryRoom.ts
997
+ │ └── PingRoom.ts
998
+ └── auto-generated-components.ts # Auto-generated registration
999
+
1000
+ app/client/src/live/
1001
+ ├── CounterDemo.tsx
1002
+ ├── FormDemo.tsx
1003
+ ├── RoomChatDemo.tsx
1004
+ ├── SharedCounterDemo.tsx
1005
+ ├── PingPongDemo.tsx
1006
+ ├── UploadDemo.tsx
1007
+ └── ...
1008
+ ```
1009
+
1010
+ Each server file contains:
1011
+ - `static componentName` - Component identifier
1012
+ - `static publicActions` - **REQUIRED** whitelist of client-callable methods
1013
+ - `static defaultState` - Initial state object
1014
+ - `static logging` - Per-component console log control (optional)
1015
+ - Component class extending `LiveComponent`
1016
+ - Client link via `import type { Demo as _Client }`
1017
+
1018
+ ## Advanced: Component Options
1019
+
1020
+ ```typescript
1021
+ export class MyComponent extends LiveComponent<typeof MyComponent.defaultState> {
1022
+ static $options = {
1023
+ deepDiff: true, // Enable deep diff for plain objects (default: true)
1024
+ roomDeepDiff: true, // Enable deep diff for room state (default: true)
1025
+ deepDiffDepth: 3, // Max recursion depth (default: 3)
1026
+ serverOnlyRoomState: false, // When true, client ROOM_STATE_SET is rejected
1027
+ }
1028
+ }
1029
+ ```
1030
+
1031
+ ## Critical Rules
1032
+
1033
+ **ALWAYS:**
1034
+ - Define `static componentName` matching class name
1035
+ - Define `static publicActions` listing ALL client-callable methods (MANDATORY)
1036
+ - Define `static defaultState` inside the class
1037
+ - Use `typeof ClassName.defaultState` for type parameter
1038
+ - Use `declare` for each state property (TypeScript type hint)
1039
+ - Use `onMount()` for async initialization (rooms, auth, data fetching)
1040
+ - Use `onDestroy()` for cleanup (timers, connections) -- sync only
1041
+ - Use `emitRoomEventWithState` for state changes in rooms
1042
+ - Handle errors in actions (throw Error)
1043
+ - Add client link: `import type { Demo as _Client } from '@client/...'`
1044
+ - Use `$persistent` for data that should survive HMR reloads
1045
+ - Use `static singleton = true` for shared cross-client state
1046
+
1047
+ **NEVER:**
1048
+ - Omit `static publicActions` (component will deny ALL remote actions)
1049
+ - Export separate `defaultState` constant (use static)
1050
+ - Create constructor just to call super() (not needed)
1051
+ - Forget `static componentName` (breaks minification)
1052
+ - Override `destroy()` directly -- use `onDestroy()` instead (prefer lifecycle hooks)
1053
+ - Emit room events without subscribing first
1054
+ - Store non-serializable data in state
1055
+ - Use reserved names for state properties (id, state, ws, room, userId, $room, $rooms, $private, $persistent, broadcastToRoom, roomType)
1056
+ - Include `setValue` in `publicActions` unless you trust clients to modify any state key
1057
+ - Store sensitive data (tokens, API keys, secrets) in `state` -- use `$private` instead
1058
+
1059
+ **STATE UPDATES -- all auto-sync via Proxy:**
1060
+ ```typescript
1061
+ // Direct access (1 prop -> 1 STATE_DELTA)
1062
+ declare count: number
1063
+ this.count++
1064
+
1065
+ // Also works (same proxy underneath)
1066
+ this.state.count++
1067
+
1068
+ // Multiple properties -> use setState (1 STATE_DELTA for all)
1069
+ this.setState({ a: 1, b: 2, c: 3 })
1070
+
1071
+ // Don't use setState for single property (unnecessary)
1072
+ // this.setState({ count: this.count + 1 })
1073
+ ```
1074
+
1075
+ ---
1076
+
1077
+ ## Live Upload (Chunked Upload via WebSocket)
1078
+
1079
+ This project includes a Live Component-based upload system that streams file chunks
1080
+ over the Live Components WebSocket. The client uses a chunked upload hook; the server
1081
+ tracks progress and assembles the file in `uploads/`.
1082
+
1083
+ ### Server: LiveUpload Component
1084
+
1085
+ ```typescript
1086
+ // app/server/live/LiveUpload.ts
1087
+ import { LiveComponent } from '@core/types/types'
1088
+
1089
+ export class LiveUpload extends LiveComponent<typeof LiveUpload.defaultState> {
1090
+ static componentName = 'LiveUpload'
1091
+ static publicActions = ['startUpload', 'updateProgress', 'completeUpload', 'failUpload', 'reset'] as const
1092
+ static defaultState = {
1093
+ status: 'idle' as 'idle' | 'uploading' | 'complete' | 'error',
1094
+ progress: 0,
1095
+ fileName: '',
1096
+ fileSize: 0,
1097
+ fileType: '',
1098
+ fileUrl: '',
1099
+ bytesUploaded: 0,
1100
+ totalBytes: 0,
1101
+ error: null as string | null
1102
+ }
1103
+
1104
+ async startUpload(payload: { fileName: string; fileSize: number; fileType: string }) {
1105
+ const fileName = payload.fileName
1106
+
1107
+ // Validate filename length
1108
+ if (!fileName || fileName.length > 255) {
1109
+ throw new Error('Invalid file name: must be 1-255 characters')
1110
+ }
1111
+
1112
+ // Block path traversal, null bytes, and control characters
1113
+ if (/[\x00-\x1f]/.test(fileName) || fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) {
1114
+ throw new Error('Invalid file name: contains forbidden characters')
1115
+ }
1116
+
1117
+ // Block Windows reserved names
1118
+ const baseName = fileName.split('.')[0].toUpperCase()
1119
+ const reserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1', 'LPT2', 'LPT3']
1120
+ if (reserved.includes(baseName)) {
1121
+ throw new Error('Invalid file name: reserved name')
1122
+ }
1123
+
1124
+ this.setState({
1125
+ status: 'uploading',
1126
+ progress: 0,
1127
+ fileName: payload.fileName,
1128
+ fileSize: payload.fileSize,
1129
+ fileType: payload.fileType,
1130
+ fileUrl: '',
1131
+ bytesUploaded: 0,
1132
+ totalBytes: payload.fileSize,
1133
+ error: null
1134
+ })
1135
+
1136
+ return { success: true }
1137
+ }
1138
+
1139
+ // ... updateProgress, completeUpload, failUpload, reset
1140
+ }
1141
+ ```
1142
+
1143
+ ### Client: useLiveUpload + Widget
1144
+
1145
+ ```typescript
1146
+ // app/client/src/live/UploadDemo.tsx
1147
+ import { useLiveUpload } from './useLiveUpload'
1148
+ import { LiveUploadWidget } from '../components/LiveUploadWidget'
1149
+
1150
+ export function UploadDemo() {
1151
+ const { live } = useLiveUpload()
1152
+
1153
+ return (
1154
+ <LiveUploadWidget live={live} />
1155
+ )
1156
+ }
1157
+ ```
1158
+
1159
+ ### Chunked Upload Flow
1160
+
1161
+ 1. Client calls `startUpload()` (Live Component action).
1162
+ 2. Client streams file chunks over WebSocket with `useChunkedUpload`.
1163
+ 3. Server assembles file in `uploads/` and returns `/uploads/...`.
1164
+ 4. Client maps to `/api/uploads/...` for access.
1165
+
1166
+ ### Error Handling
1167
+
1168
+ - If an action throws, the error surfaces in `live.$error` on the client.
1169
+ - The widget shows `localError || state.error || $error`.
1170
+
1171
+ ### Files Involved
1172
+
1173
+ **Server**
1174
+ - `app/server/live/LiveUpload.ts`
1175
+ - `core/server/live/FileUploadManager.ts` (chunk handling + file assembly)
1176
+ - `core/server/live/websocket-plugin.ts` (upload message routing)
1177
+
1178
+ **Client**
1179
+ - `core/client/hooks/useChunkedUpload.ts` (streaming chunks)
1180
+ - `core/client/hooks/useLiveUpload.ts` (Live Component wrapper)
1181
+ - `app/client/src/components/LiveUploadWidget.tsx` (UI)
1182
+
1183
+ ## Related
1184
+
1185
+ - [Live Auth](./live-auth.md) - Authentication for Live Components
1186
+ - [Live Logging](./live-logging.md) - Per-component logging control
1187
+ - [Live Rooms](./live-rooms.md) - Multi-room real-time communication
1188
+ - [Live Upload](./live-upload.md) - Chunked file upload
1189
+ - [Live Binary Delta](./live-binary-delta.md) - High-frequency binary state sync
1190
+ - [Project Structure](../patterns/project-structure.md)
1191
+ - [Type Safety Patterns](../patterns/type-safety.md)
1192
+ - [WebSocket Plugin](../core/plugin-system.md)