create-fluxstack 1.20.1 → 1.21.0
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.
- package/LLMD/resources/live-components.md +103 -57
- package/LLMD/resources/live-rooms.md +187 -88
- package/README.md +27 -25
- package/app/client/.live-stubs/LiveCounter.js +4 -4
- package/app/client/src/App.tsx +11 -12
- package/app/client/src/components/AppLayout.tsx +290 -252
- package/app/client/src/components/BackButton.tsx +16 -13
- package/app/client/src/components/DemoPage.tsx +135 -22
- package/app/client/src/index.css +21 -11
- package/app/client/src/live/AuthDemo.tsx +270 -333
- package/app/client/src/live/CounterDemo.tsx +151 -206
- package/app/client/src/live/FormDemo.tsx +140 -119
- package/app/client/src/live/PingPongDemo.tsx +180 -202
- package/app/client/src/live/RoomChatDemo.tsx +397 -374
- package/app/client/src/pages/HomePage.tsx +170 -104
- package/app/server/live/LiveCounter.ts +71 -68
- package/app/server/live/LiveSharedCounter.ts +18 -12
- package/app/server/live/auto-generated-components.ts +1 -3
- package/app/server/live/rooms/CounterRoom.ts +15 -10
- package/core/client/index.ts +0 -3
- package/core/client/state/createStore.ts +88 -88
- package/core/client/state/index.ts +5 -5
- package/core/server/live/auto-generated-components.ts +1 -3
- package/core/utils/version.ts +1 -1
- package/package.json +1 -1
- package/tsconfig.json +7 -6
- package/app/client/src/components/LiveUploadWidget.tsx +0 -200
- package/app/client/src/live/UploadDemo.tsx +0 -21
- package/app/server/live/LiveUpload.ts +0 -96
- package/core/client/hooks/useLiveUpload.ts +0 -70
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Live Room System
|
|
2
2
|
|
|
3
|
-
**Version:** 0.
|
|
3
|
+
**Version:** 0.8.x | **Updated:** 2026-04-15
|
|
4
4
|
|
|
5
5
|
## Quick Facts
|
|
6
6
|
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
- **Untyped rooms** still work for simple pub/sub (backward compatible)
|
|
10
10
|
- Server-side room management — client cannot join typed rooms directly
|
|
11
11
|
- Events propagate to all room members automatically
|
|
12
|
-
- HTTP API integration for
|
|
12
|
+
- HTTP API integration for generic room events (webhooks, bots)
|
|
13
|
+
- Current app examples: `ChatRoom`, `DirectoryRoom`, `CounterRoom`, and `PingRoom`
|
|
13
14
|
- Powered by `@fluxstack/live` package
|
|
14
15
|
|
|
15
16
|
## Overview
|
|
@@ -34,6 +35,7 @@ Room classes live in `app/server/live/rooms/` and are auto-discovered on startup
|
|
|
34
35
|
// app/server/live/rooms/ChatRoom.ts
|
|
35
36
|
import { LiveRoom } from '@fluxstack/live'
|
|
36
37
|
import type { RoomJoinContext, RoomLeaveContext } from '@fluxstack/live'
|
|
38
|
+
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
|
37
39
|
|
|
38
40
|
export interface ChatMessage {
|
|
39
41
|
id: string
|
|
@@ -51,6 +53,7 @@ interface ChatState {
|
|
|
51
53
|
|
|
52
54
|
// Private metadata — NEVER leaves the server
|
|
53
55
|
interface ChatMeta {
|
|
56
|
+
/** Server-only password hash in "salt:hash" format. Never sent to clients. */
|
|
54
57
|
password: string | null
|
|
55
58
|
createdBy: string | null
|
|
56
59
|
}
|
|
@@ -71,12 +74,28 @@ export class ChatRoom extends LiveRoom<ChatState, ChatMeta, ChatEvents> {
|
|
|
71
74
|
// Room options
|
|
72
75
|
static $options = { maxMembers: 100 }
|
|
73
76
|
|
|
77
|
+
private static hashPassword(password: string): string {
|
|
78
|
+
const salt = randomBytes(16).toString('hex')
|
|
79
|
+
const hash = createHash('sha256').update(salt + password).digest('hex')
|
|
80
|
+
return salt + ':' + hash
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private static verifyPassword(password: string, stored: string): boolean {
|
|
84
|
+
const [salt, hash] = stored.split(':')
|
|
85
|
+
if (!salt || !hash) return false
|
|
86
|
+
const computed = createHash('sha256').update(salt + password).digest('hex')
|
|
87
|
+
const bufA = Buffer.from(computed, 'hex')
|
|
88
|
+
const bufB = Buffer.from(hash, 'hex')
|
|
89
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB)
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
// === Lifecycle Hooks ===
|
|
75
93
|
|
|
76
94
|
onJoin(ctx: RoomJoinContext) {
|
|
77
95
|
// Validate password if room is protected
|
|
78
96
|
if (this.meta.password) {
|
|
79
|
-
|
|
97
|
+
const provided = ctx.payload?.password
|
|
98
|
+
if (!provided || !ChatRoom.verifyPassword(provided, this.meta.password)) {
|
|
80
99
|
return false // Reject join
|
|
81
100
|
}
|
|
82
101
|
}
|
|
@@ -90,7 +109,7 @@ export class ChatRoom extends LiveRoom<ChatState, ChatMeta, ChatEvents> {
|
|
|
90
109
|
// === Custom Methods ===
|
|
91
110
|
|
|
92
111
|
setPassword(password: string | null) {
|
|
93
|
-
this.meta.password = password
|
|
112
|
+
this.meta.password = password ? ChatRoom.hashPassword(password) : null
|
|
94
113
|
this.setState({ isPrivate: password !== null })
|
|
95
114
|
}
|
|
96
115
|
|
|
@@ -126,14 +145,15 @@ abstract class LiveRoom<TState, TMeta, TEvents> {
|
|
|
126
145
|
// === Framework Methods ===
|
|
127
146
|
setState(updates: Partial<TState>): void // Update & broadcast state
|
|
128
147
|
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): number // Emit typed event
|
|
148
|
+
emitWithState<K extends keyof TEvents>(event: K, data: TEvents[K], updates: Partial<TState>): number
|
|
129
149
|
get memberCount(): number // Current member count
|
|
130
150
|
|
|
131
151
|
// === Lifecycle Hooks (override in subclass) ===
|
|
132
|
-
onJoin(ctx: RoomJoinContext): void | false
|
|
133
|
-
onLeave(ctx: RoomLeaveContext): void
|
|
134
|
-
onEvent(event: string, data: any, ctx: RoomEventContext): void
|
|
135
|
-
onCreate(): void
|
|
136
|
-
onDestroy(): void | false
|
|
152
|
+
onJoin(ctx: RoomJoinContext): void | false | Promise<void | false>
|
|
153
|
+
onLeave(ctx: RoomLeaveContext): void | Promise<void>
|
|
154
|
+
onEvent(event: string, data: any, ctx: RoomEventContext): void | Promise<void>
|
|
155
|
+
onCreate(): void | Promise<void> // First member joined
|
|
156
|
+
onDestroy(): void | false | Promise<void | false>
|
|
137
157
|
}
|
|
138
158
|
```
|
|
139
159
|
|
|
@@ -167,6 +187,73 @@ interface RoomEventContext {
|
|
|
167
187
|
| **Use for** | Messages, counts, flags | Passwords, secrets, internal data |
|
|
168
188
|
| **Broadcast** | Yes, via deep diff | Never |
|
|
169
189
|
|
|
190
|
+
### Other Typed Room Examples
|
|
191
|
+
|
|
192
|
+
The app currently ships additional typed rooms beyond chat:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// app/server/live/rooms/CounterRoom.ts
|
|
196
|
+
interface CounterState {
|
|
197
|
+
count: number
|
|
198
|
+
lastUpdatedBy: string | null
|
|
199
|
+
onlineCount: number
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface CounterEvents {
|
|
203
|
+
'counter:updated': { count: number; updatedBy: string }
|
|
204
|
+
'presence:changed': { onlineCount: number }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export class CounterRoom extends LiveRoom<CounterState, {}, CounterEvents> {
|
|
208
|
+
static roomName = 'counter'
|
|
209
|
+
static defaultState: CounterState = { count: 0, lastUpdatedBy: null, onlineCount: 0 }
|
|
210
|
+
static defaultMeta = {}
|
|
211
|
+
|
|
212
|
+
onJoin() {
|
|
213
|
+
const onlineCount = this.state.onlineCount + 1
|
|
214
|
+
this.setState({ onlineCount })
|
|
215
|
+
this.emit('presence:changed', { onlineCount })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
onLeave() {
|
|
219
|
+
const onlineCount = Math.max(0, this.state.onlineCount - 1)
|
|
220
|
+
this.setState({ onlineCount })
|
|
221
|
+
this.emit('presence:changed', { onlineCount })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
increment(username: string) {
|
|
225
|
+
const count = this.state.count + 1
|
|
226
|
+
this.setState({ count, lastUpdatedBy: username })
|
|
227
|
+
this.emit('counter:updated', { count, updatedBy: username })
|
|
228
|
+
return count
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// app/server/live/rooms/PingRoom.ts
|
|
235
|
+
interface PingEvents {
|
|
236
|
+
'ping': { from: string; timestamp: number; seq: number }
|
|
237
|
+
'pong': { from: string; timestamp: number; seq: number; serverTime: number }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export class PingRoom extends LiveRoom<PingState, {}, PingEvents> {
|
|
241
|
+
static roomName = 'ping'
|
|
242
|
+
static defaultState = { onlineCount: 0, totalPings: 0, lastPingBy: null }
|
|
243
|
+
static defaultMeta = {}
|
|
244
|
+
|
|
245
|
+
// Typed rooms default to msgpack unless $options.codec overrides it.
|
|
246
|
+
ping(username: string, seq: number) {
|
|
247
|
+
const total = this.state.totalPings + 1
|
|
248
|
+
this.setState({ totalPings: total, lastPingBy: username })
|
|
249
|
+
this.emit('pong', { from: username, timestamp: Date.now(), seq, serverTime: Date.now() })
|
|
250
|
+
return total
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Presence counts should live in room state (`onlineCount`) and be updated from `onJoin` / `onLeave`. Do not calculate presence from each component instance's local state.
|
|
256
|
+
|
|
170
257
|
### Using Typed Rooms in Components
|
|
171
258
|
|
|
172
259
|
```typescript
|
|
@@ -204,8 +291,8 @@ export class LiveRoomChat extends LiveComponent<typeof LiveRoomChat.defaultState
|
|
|
204
291
|
this.setState({ messages: [...this.state.messages, msg] })
|
|
205
292
|
})
|
|
206
293
|
|
|
207
|
-
//
|
|
208
|
-
room.emit('chat:message', { id: '1', user: 'Bot', text: 'Hi', timestamp: Date.now() })
|
|
294
|
+
// Usually custom methods emit events. Direct emit is available for custom events.
|
|
295
|
+
// room.emit('chat:message', { id: '1', user: 'Bot', text: 'Hi', timestamp: Date.now() })
|
|
209
296
|
|
|
210
297
|
// Framework properties
|
|
211
298
|
console.log(room.id) // 'chat:lobby'
|
|
@@ -219,13 +306,13 @@ export class LiveRoomChat extends LiveComponent<typeof LiveRoomChat.defaultState
|
|
|
219
306
|
|
|
220
307
|
### Compound Room IDs
|
|
221
308
|
|
|
222
|
-
Typed rooms use compound IDs: `${roomName}:${instanceId}`
|
|
223
|
-
|
|
224
|
-
```
|
|
225
|
-
ChatRoom + 'lobby'
|
|
226
|
-
ChatRoom + 'vip'
|
|
227
|
-
|
|
228
|
-
```
|
|
309
|
+
Typed rooms use compound IDs: `${roomName}:${instanceId}`
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
ChatRoom + 'lobby' -> 'chat:lobby'
|
|
313
|
+
ChatRoom + 'vip' -> 'chat:vip'
|
|
314
|
+
CounterRoom + 'global' -> 'counter:global'
|
|
315
|
+
```
|
|
229
316
|
|
|
230
317
|
This allows multiple instances of the same room type. The `RoomRegistry` resolves the class from the compound ID automatically.
|
|
231
318
|
|
|
@@ -233,12 +320,13 @@ This allows multiple instances of the same room type. The `RoomRegistry` resolve
|
|
|
233
320
|
|
|
234
321
|
Room classes in `app/server/live/rooms/` are auto-discovered on startup. Any exported class that extends `LiveRoom` is registered automatically.
|
|
235
322
|
|
|
236
|
-
```
|
|
237
|
-
app/server/live/rooms/
|
|
238
|
-
ChatRoom.ts
|
|
239
|
-
DirectoryRoom.ts
|
|
240
|
-
|
|
241
|
-
|
|
323
|
+
```
|
|
324
|
+
app/server/live/rooms/
|
|
325
|
+
ChatRoom.ts -> registered as 'chat'
|
|
326
|
+
DirectoryRoom.ts -> registered as 'directory'
|
|
327
|
+
CounterRoom.ts -> registered as 'counter'
|
|
328
|
+
PingRoom.ts -> registered as 'ping'
|
|
329
|
+
```
|
|
242
330
|
|
|
243
331
|
No manual registration needed. The `websocket-plugin.ts` scans the directory and passes discovered rooms to `LiveServer`.
|
|
244
332
|
|
|
@@ -251,10 +339,10 @@ class VIPRoom extends LiveRoom<State, Meta, Events> {
|
|
|
251
339
|
static roomName = 'vip'
|
|
252
340
|
|
|
253
341
|
onJoin(ctx: RoomJoinContext) {
|
|
254
|
-
// Reject if
|
|
255
|
-
if (this.meta.
|
|
256
|
-
return false
|
|
257
|
-
}
|
|
342
|
+
// Reject if caller does not satisfy room-specific rules
|
|
343
|
+
if (this.meta.requiredToken && ctx.payload?.token !== this.meta.requiredToken) {
|
|
344
|
+
return false
|
|
345
|
+
}
|
|
258
346
|
|
|
259
347
|
// Reject if room is full (also handled automatically by maxMembers)
|
|
260
348
|
if (this.memberCount >= 10) {
|
|
@@ -390,23 +478,29 @@ Complete implementation using `meta` (server-only) and `onJoin` validation:
|
|
|
390
478
|
### 1. Room Class
|
|
391
479
|
|
|
392
480
|
```typescript
|
|
393
|
-
// ChatMeta.password is NEVER sent to clients
|
|
481
|
+
// ChatMeta.password is NEVER sent to clients.
|
|
482
|
+
// Store a hash such as "salt:hash", not the plaintext password.
|
|
394
483
|
interface ChatMeta {
|
|
395
484
|
password: string | null
|
|
396
485
|
createdBy: string | null
|
|
397
486
|
}
|
|
398
487
|
|
|
399
|
-
class ChatRoom extends LiveRoom<ChatState, ChatMeta, ChatEvents> {
|
|
400
|
-
static defaultMeta: ChatMeta = { password: null, createdBy: null }
|
|
488
|
+
class ChatRoom extends LiveRoom<ChatState, ChatMeta, ChatEvents> {
|
|
489
|
+
static defaultMeta: ChatMeta = { password: null, createdBy: null }
|
|
490
|
+
|
|
491
|
+
// Implement these with a salted hash and constant-time comparison
|
|
492
|
+
// (see full ChatRoom example above).
|
|
493
|
+
private static hashPassword(password: string): string { throw new Error('see full example') }
|
|
494
|
+
private static verifyPassword(password: string, stored: string): boolean { throw new Error('see full example') }
|
|
401
495
|
|
|
402
496
|
setPassword(password: string | null) {
|
|
403
|
-
this.meta.password = password
|
|
497
|
+
this.meta.password = password ? ChatRoom.hashPassword(password) : null
|
|
404
498
|
this.setState({ isPrivate: password !== null }) // Visible to clients
|
|
405
499
|
}
|
|
406
500
|
|
|
407
501
|
onJoin(ctx: RoomJoinContext) {
|
|
408
502
|
if (this.meta.password) {
|
|
409
|
-
if (ctx.payload?.password
|
|
503
|
+
if (!ctx.payload?.password || !ChatRoom.verifyPassword(ctx.payload.password, this.meta.password)) {
|
|
410
504
|
return false // Wrong password → rejected
|
|
411
505
|
}
|
|
412
506
|
}
|
|
@@ -424,7 +518,7 @@ async joinRoom(payload: { roomId: string; password?: string }) {
|
|
|
424
518
|
// ^^^^^^^^^ passed to onJoin ctx.payload
|
|
425
519
|
|
|
426
520
|
if ('rejected' in result && result.rejected) {
|
|
427
|
-
return { success: false, error: '
|
|
521
|
+
return { success: false, error: 'Invalid password' }
|
|
428
522
|
}
|
|
429
523
|
return { success: true }
|
|
430
524
|
}
|
|
@@ -464,7 +558,7 @@ const handlePasswordSubmit = async () => {
|
|
|
464
558
|
password: passwordInput
|
|
465
559
|
})
|
|
466
560
|
if (result && !result.success) {
|
|
467
|
-
showError('
|
|
561
|
+
showError('Invalid password')
|
|
468
562
|
}
|
|
469
563
|
}
|
|
470
564
|
```
|
|
@@ -560,7 +654,7 @@ export class LiveRoomChat extends LiveComponent<typeof LiveRoomChat.defaultState
|
|
|
560
654
|
private roomListeners = new Map<string, (() => void)[]>()
|
|
561
655
|
private directoryUnsubs: (() => void)[] = []
|
|
562
656
|
|
|
563
|
-
constructor(initialState: Partial<typeof LiveRoomChat.defaultState
|
|
657
|
+
constructor(initialState: Partial<typeof LiveRoomChat.defaultState> = {}, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
|
|
564
658
|
super(initialState, ws, options)
|
|
565
659
|
|
|
566
660
|
// Auto-join directory for room discovery
|
|
@@ -606,15 +700,15 @@ export class LiveRoomChat extends LiveComponent<typeof LiveRoomChat.defaultState
|
|
|
606
700
|
return { success: true, roomId: payload.roomId }
|
|
607
701
|
}
|
|
608
702
|
|
|
609
|
-
async joinRoom(payload: { roomId: string; roomName?: string; password?: string }) {
|
|
610
|
-
if (this.roomListeners.has(payload.roomId)) {
|
|
611
|
-
this.
|
|
612
|
-
return { success: true, roomId: payload.roomId }
|
|
613
|
-
}
|
|
703
|
+
async joinRoom(payload: { roomId: string; roomName?: string; password?: string }) {
|
|
704
|
+
if (this.roomListeners.has(payload.roomId)) {
|
|
705
|
+
this.setState({ activeRoom: payload.roomId })
|
|
706
|
+
return { success: true, roomId: payload.roomId }
|
|
707
|
+
}
|
|
614
708
|
|
|
615
|
-
const room = this.$room(ChatRoom, payload.roomId)
|
|
616
|
-
const result = room.join({ password: payload.password })
|
|
617
|
-
if ('rejected' in result && result.rejected) return { success: false, error: '
|
|
709
|
+
const room = this.$room(ChatRoom, payload.roomId)
|
|
710
|
+
const result = room.join({ password: payload.password })
|
|
711
|
+
if ('rejected' in result && result.rejected) return { success: false, error: 'Invalid password' }
|
|
618
712
|
|
|
619
713
|
const unsub = room.on('chat:message', (msg: ChatMessage) => {
|
|
620
714
|
const msgs = this.state.messages[payload.roomId] || []
|
|
@@ -640,13 +734,14 @@ export class LiveRoomChat extends LiveComponent<typeof LiveRoomChat.defaultState
|
|
|
640
734
|
return { success: true, message }
|
|
641
735
|
}
|
|
642
736
|
|
|
643
|
-
destroy() {
|
|
644
|
-
for (const fns of this.roomListeners.values()) fns.forEach(fn => fn())
|
|
645
|
-
this.roomListeners.clear()
|
|
646
|
-
this.directoryUnsubs.forEach(fn => fn())
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
737
|
+
destroy() {
|
|
738
|
+
for (const fns of this.roomListeners.values()) fns.forEach(fn => fn())
|
|
739
|
+
this.roomListeners.clear()
|
|
740
|
+
this.directoryUnsubs.forEach(fn => fn())
|
|
741
|
+
this.directoryUnsubs = []
|
|
742
|
+
super.destroy()
|
|
743
|
+
}
|
|
744
|
+
}
|
|
650
745
|
```
|
|
651
746
|
|
|
652
747
|
### Frontend
|
|
@@ -768,23 +863,25 @@ export function RoomChatDemo() {
|
|
|
768
863
|
|
|
769
864
|
---
|
|
770
865
|
|
|
771
|
-
## HTTP API Integration
|
|
772
|
-
|
|
773
|
-
Send
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
866
|
+
## HTTP API Integration
|
|
867
|
+
|
|
868
|
+
Send generic room events from external systems via REST API. These endpoints use `liveServer.roomManager.emitToRoom(roomId, event, data)` and therefore target the exact room id you pass.
|
|
869
|
+
|
|
870
|
+
For typed rooms, use the compound id (`chat:general`, `counter:global-counter`, `ping:global`). Also make sure the emitted event name matches what components are listening for. The demo `LiveRoomChat` listens for `chat:message` through `ChatRoom.addMessage()`; the convenience `POST /api/rooms/:roomId/messages` endpoint emits `message:new`, so it is best treated as a generic/untyped-room example unless you add a bridge listener for that event.
|
|
871
|
+
|
|
872
|
+
```bash
|
|
873
|
+
# Send a generic message event to an untyped room
|
|
874
|
+
curl -X POST http://localhost:3000/api/rooms/notifications/messages \
|
|
875
|
+
-H "Content-Type: application/json" \
|
|
876
|
+
-d '{"user": "Webhook Bot", "text": "New deployment completed!"}'
|
|
877
|
+
|
|
878
|
+
# Emit a custom event to a typed room by compound id
|
|
879
|
+
curl -X POST http://localhost:3000/api/rooms/chat:general/emit \
|
|
880
|
+
-H "Content-Type: application/json" \
|
|
881
|
+
-d '{"event": "chat:message", "data": {"id":"api-1","user":"Webhook Bot","text":"Hello","timestamp":1710000000000}}'
|
|
882
|
+
|
|
883
|
+
# Get room stats
|
|
884
|
+
curl http://localhost:3000/api/rooms/stats
|
|
788
885
|
```
|
|
789
886
|
|
|
790
887
|
---
|
|
@@ -793,26 +890,28 @@ curl http://localhost:3000/api/rooms/stats
|
|
|
793
890
|
|
|
794
891
|
Direct access for advanced use cases:
|
|
795
892
|
|
|
796
|
-
```typescript
|
|
797
|
-
import {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
893
|
+
```typescript
|
|
894
|
+
import { liveServer } from '@core/server/live'
|
|
895
|
+
|
|
896
|
+
const roomManager = liveServer!.roomManager
|
|
897
|
+
|
|
898
|
+
// Membership
|
|
899
|
+
roomManager.joinRoom(componentId, roomId, ws, initialState, options, joinContext)
|
|
900
|
+
roomManager.leaveRoom(componentId, roomId, leaveReason)
|
|
901
|
+
roomManager.cleanupComponent(componentId)
|
|
902
|
+
|
|
903
|
+
// Events & State
|
|
904
|
+
roomManager.emitToRoom(roomId, event, data, excludeComponentId)
|
|
905
|
+
roomManager.setRoomState(roomId, updates, excludeComponentId)
|
|
906
|
+
roomManager.getRoomState(roomId)
|
|
907
|
+
|
|
908
|
+
// Queries
|
|
909
|
+
roomManager.isInRoom(componentId, roomId)
|
|
910
|
+
roomManager.getComponentRooms(componentId)
|
|
911
|
+
roomManager.getMemberCount(roomId)
|
|
912
|
+
roomManager.getRoomInstance(roomId) // Get LiveRoom instance (typed rooms only)
|
|
913
|
+
roomManager.getStats()
|
|
914
|
+
```
|
|
816
915
|
|
|
817
916
|
---
|
|
818
917
|
|
package/README.md
CHANGED
|
@@ -248,31 +248,33 @@ Real-time WebSocket components with **automatic state synchronization** between
|
|
|
248
248
|
|
|
249
249
|
### 🖥️ Server Side
|
|
250
250
|
|
|
251
|
-
```typescript
|
|
252
|
-
// app/server/live/LiveCounter.ts
|
|
253
|
-
import { LiveComponent } from '
|
|
254
|
-
|
|
255
|
-
export class LiveCounter extends LiveComponent<{
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
static defaultState = { count: 0 }
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
275
|
-
|
|
251
|
+
```typescript
|
|
252
|
+
// app/server/live/LiveCounter.ts
|
|
253
|
+
import { LiveComponent } from '@core/types/types'
|
|
254
|
+
|
|
255
|
+
export class LiveCounter extends LiveComponent<typeof LiveCounter.defaultState> {
|
|
256
|
+
static componentName = 'LiveCounter'
|
|
257
|
+
static publicActions = ['increment', 'decrement', 'reset'] as const
|
|
258
|
+
static defaultState = { count: 0 }
|
|
259
|
+
|
|
260
|
+
declare count: number
|
|
261
|
+
|
|
262
|
+
async increment() {
|
|
263
|
+
this.count++ // auto-syncs via Proxy
|
|
264
|
+
return { success: true, count: this.count }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async decrement() {
|
|
268
|
+
this.count--
|
|
269
|
+
return { success: true, count: this.count }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async reset() {
|
|
273
|
+
this.count = 0
|
|
274
|
+
return { success: true, count: 0 }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
276
278
|
|
|
277
279
|
</td>
|
|
278
280
|
<td width="50%">
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export class LiveCounter {
|
|
2
2
|
static componentName = 'LiveCounter'
|
|
3
|
-
static defaultState = {
|
|
4
|
-
count: 0,
|
|
5
|
-
lastUpdatedBy: null,
|
|
6
|
-
connectedUsers: 0
|
|
3
|
+
static defaultState = {
|
|
4
|
+
count: 0,
|
|
5
|
+
lastUpdatedBy: null,
|
|
6
|
+
connectedUsers: 0,
|
|
7
7
|
}
|
|
8
8
|
static publicActions = ['increment', 'decrement', 'reset']
|
|
9
9
|
}
|
package/app/client/src/App.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { LiveComponentsProvider, useLiveComponents } from '@/core/client'
|
|
|
5
5
|
import { executeHook } from './lib/plugin-hooks'
|
|
6
6
|
import { FormDemo } from './live/FormDemo'
|
|
7
7
|
import { CounterDemo } from './live/CounterDemo'
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
import { RoomChatDemo } from './live/RoomChatDemo'
|
|
10
10
|
import { SharedCounterDemo } from './live/SharedCounterDemo'
|
|
11
11
|
import { AuthDemo } from './live/AuthDemo'
|
|
@@ -129,7 +129,9 @@ function AppContent() {
|
|
|
129
129
|
path="/form"
|
|
130
130
|
element={
|
|
131
131
|
<DemoPage
|
|
132
|
-
|
|
132
|
+
title="Live Form"
|
|
133
|
+
description="Formulario com campos sincronizados pelo servidor, debounce e estado compartilhado pelo proxy Live."
|
|
134
|
+
note={<>Este formulario usa <code className="text-theme">Live.use()</code> - cada campo sincroniza automaticamente com o servidor.</>}
|
|
133
135
|
>
|
|
134
136
|
<FormDemo />
|
|
135
137
|
</DemoPage>
|
|
@@ -138,24 +140,21 @@ function AppContent() {
|
|
|
138
140
|
<Route
|
|
139
141
|
path="/counter"
|
|
140
142
|
element={
|
|
141
|
-
<DemoPage
|
|
143
|
+
<DemoPage
|
|
144
|
+
title="Counters"
|
|
145
|
+
description="Compare estado local, estado isolado por sala e estado compartilhado em tempo real."
|
|
146
|
+
>
|
|
142
147
|
<CounterDemo />
|
|
143
148
|
</DemoPage>
|
|
144
149
|
}
|
|
145
150
|
/>
|
|
146
|
-
<Route
|
|
147
|
-
path="/upload"
|
|
148
|
-
element={
|
|
149
|
-
<DemoPage>
|
|
150
|
-
<UploadDemo />
|
|
151
|
-
</DemoPage>
|
|
152
|
-
}
|
|
153
|
-
/>
|
|
154
151
|
<Route
|
|
155
152
|
path="/shared-counter"
|
|
156
153
|
element={
|
|
157
154
|
<DemoPage
|
|
158
|
-
|
|
155
|
+
title="Shared Counter"
|
|
156
|
+
description="Uma sala global sincroniza usuarios, eventos e estado entre abas abertas ao mesmo tempo."
|
|
157
|
+
note={<>Contador compartilhado usando <code className="text-theme">LiveRoom</code> - abra em varias abas.</>}
|
|
159
158
|
>
|
|
160
159
|
<SharedCounterDemo />
|
|
161
160
|
</DemoPage>
|