create-fluxstack 1.15.0 → 1.16.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/INDEX.md +4 -3
- package/LLMD/resources/live-binary-delta.md +507 -0
- package/LLMD/resources/live-components.md +1 -0
- package/LLMD/resources/live-rooms.md +731 -333
- package/app/client/.live-stubs/LivePingPong.js +10 -0
- package/app/client/.live-stubs/LiveRoomChat.js +3 -2
- package/app/client/.live-stubs/LiveSharedCounter.js +10 -0
- package/app/client/src/App.tsx +15 -14
- package/app/client/src/components/AppLayout.tsx +4 -4
- package/app/client/src/live/PingPongDemo.tsx +199 -0
- package/app/client/src/live/RoomChatDemo.tsx +187 -22
- package/app/client/src/live/SharedCounterDemo.tsx +142 -0
- package/app/server/live/LivePingPong.ts +61 -0
- package/app/server/live/LiveRoomChat.ts +106 -38
- package/app/server/live/LiveSharedCounter.ts +73 -0
- package/app/server/live/rooms/ChatRoom.ts +68 -0
- package/app/server/live/rooms/CounterRoom.ts +51 -0
- package/app/server/live/rooms/DirectoryRoom.ts +42 -0
- package/app/server/live/rooms/PingRoom.ts +40 -0
- package/core/build/live-components-generator.ts +10 -1
- package/core/client/index.ts +0 -16
- package/core/server/live/auto-generated-components.ts +3 -9
- package/core/server/live/index.ts +0 -5
- package/core/server/live/websocket-plugin.ts +37 -2
- package/core/utils/version.ts +1 -1
- package/package.json +100 -99
- package/tsconfig.json +4 -1
- package/app/client/.live-stubs/LiveChat.js +0 -7
- package/app/client/.live-stubs/LiveTodoList.js +0 -9
- package/app/client/src/live/ChatDemo.tsx +0 -107
- package/app/client/src/live/LiveDebuggerPanel.tsx +0 -779
- package/app/client/src/live/TodoListDemo.tsx +0 -158
- package/app/server/live/LiveChat.ts +0 -78
- package/app/server/live/LiveTodoList.ts +0 -110
- package/core/client/components/LiveDebugger.tsx +0 -1324
|
@@ -1,341 +1,779 @@
|
|
|
1
1
|
# Live Room System
|
|
2
2
|
|
|
3
|
-
**Version:**
|
|
3
|
+
**Version:** 2.0.0 | **Updated:** 2025-03-11
|
|
4
4
|
|
|
5
5
|
## Quick Facts
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
7
|
+
- **Two APIs:** Typed `$room(ChatRoom, 'lobby')` and untyped `$room('room-id')` — coexist
|
|
8
|
+
- **Typed rooms** have lifecycle hooks, private metadata, custom methods, join rejection
|
|
9
|
+
- **Untyped rooms** still work for simple pub/sub (backward compatible)
|
|
10
|
+
- Server-side room management — client cannot join typed rooms directly
|
|
9
11
|
- Events propagate to all room members automatically
|
|
10
12
|
- HTTP API integration for external systems (webhooks, bots)
|
|
11
|
-
-
|
|
13
|
+
- Powered by `@fluxstack/live` package
|
|
12
14
|
|
|
13
15
|
## Overview
|
|
14
16
|
|
|
15
|
-
The Room System enables real-time communication between Live Components.
|
|
17
|
+
The Room System enables real-time communication between Live Components. There are two levels:
|
|
16
18
|
|
|
17
|
-
1.
|
|
18
|
-
2. Each component updates its own client via `setState()`
|
|
19
|
-
3. External systems can emit events via HTTP API
|
|
19
|
+
1. **Typed Rooms (LiveRoom)** — Class-based rooms with lifecycle hooks, private state (`meta`), custom methods, and join validation. Ideal for rooms with business logic (chat with passwords, game rooms with rules).
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
2. **Untyped Rooms** — String-based `$room('id')` for simple pub/sub without a room class. Ideal for notifications, presence, simple event broadcasting.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Both are **server-side first**: the server controls membership, event routing, and state.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Typed Rooms (LiveRoom)
|
|
28
|
+
|
|
29
|
+
### Creating a Room Class
|
|
30
|
+
|
|
31
|
+
Room classes live in `app/server/live/rooms/` and are auto-discovered on startup.
|
|
24
32
|
|
|
25
33
|
```typescript
|
|
26
|
-
// app/server/live/
|
|
27
|
-
import {
|
|
34
|
+
// app/server/live/rooms/ChatRoom.ts
|
|
35
|
+
import { LiveRoom } from '@fluxstack/live'
|
|
36
|
+
import type { RoomJoinContext, RoomLeaveContext } from '@fluxstack/live'
|
|
28
37
|
|
|
29
|
-
export
|
|
38
|
+
export interface ChatMessage {
|
|
39
|
+
id: string
|
|
40
|
+
user: string
|
|
41
|
+
text: string
|
|
42
|
+
timestamp: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Public state — synced to all room members via WebSocket
|
|
46
|
+
interface ChatState {
|
|
47
|
+
messages: ChatMessage[]
|
|
48
|
+
onlineCount: number
|
|
49
|
+
isPrivate: boolean
|
|
50
|
+
}
|
|
30
51
|
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
// Private metadata — NEVER leaves the server
|
|
53
|
+
interface ChatMeta {
|
|
54
|
+
password: string | null
|
|
55
|
+
createdBy: string | null
|
|
56
|
+
}
|
|
33
57
|
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
// Typed events emitted within the room
|
|
59
|
+
interface ChatEvents {
|
|
60
|
+
'chat:message': ChatMessage
|
|
61
|
+
}
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
export class ChatRoom extends LiveRoom<ChatState, ChatMeta, ChatEvents> {
|
|
64
|
+
// Required: unique room type name (used as prefix in compound IDs)
|
|
65
|
+
static roomName = 'chat'
|
|
39
66
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.setState({ ... })
|
|
44
|
-
})
|
|
67
|
+
// Initial state templates (cloned per instance)
|
|
68
|
+
static defaultState: ChatState = { messages: [], onlineCount: 0, isPrivate: false }
|
|
69
|
+
static defaultMeta: ChatMeta = { password: null, createdBy: null }
|
|
45
70
|
|
|
46
|
-
//
|
|
47
|
-
|
|
71
|
+
// Room options
|
|
72
|
+
static $options = { maxMembers: 100 }
|
|
48
73
|
|
|
49
|
-
//
|
|
50
|
-
this.$room('room-id').setState({ key: 'value' })
|
|
74
|
+
// === Lifecycle Hooks ===
|
|
51
75
|
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
onJoin(ctx: RoomJoinContext) {
|
|
77
|
+
// Validate password if room is protected
|
|
78
|
+
if (this.meta.password) {
|
|
79
|
+
if (ctx.payload?.password !== this.meta.password) {
|
|
80
|
+
return false // Reject join
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.setState({ onlineCount: this.state.onlineCount + 1 })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onLeave(_ctx: RoomLeaveContext) {
|
|
87
|
+
this.setState({ onlineCount: Math.max(0, this.state.onlineCount - 1) })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// === Custom Methods ===
|
|
91
|
+
|
|
92
|
+
setPassword(password: string | null) {
|
|
93
|
+
this.meta.password = password
|
|
94
|
+
this.setState({ isPrivate: password !== null })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
addMessage(user: string, text: string) {
|
|
98
|
+
const msg: ChatMessage = {
|
|
99
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
100
|
+
user,
|
|
101
|
+
text,
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
}
|
|
104
|
+
this.setState({ messages: [...this.state.messages.slice(-99), msg] })
|
|
105
|
+
this.emit('chat:message', msg)
|
|
106
|
+
return msg
|
|
107
|
+
}
|
|
54
108
|
}
|
|
55
109
|
```
|
|
56
110
|
|
|
57
|
-
###
|
|
111
|
+
### LiveRoom Base Class API
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
abstract class LiveRoom<TState, TMeta, TEvents> {
|
|
115
|
+
// === Static Fields (required in subclass) ===
|
|
116
|
+
static roomName: string // Unique type name (e.g. 'chat')
|
|
117
|
+
static defaultState: Record<string, any> // Initial public state
|
|
118
|
+
static defaultMeta: Record<string, any> // Initial private metadata
|
|
119
|
+
static $options?: LiveRoomOptions // { maxMembers?, deepDiff?, deepDiffDepth? }
|
|
120
|
+
|
|
121
|
+
// === Instance Properties ===
|
|
122
|
+
readonly id: string // Compound ID (e.g. 'chat:lobby')
|
|
123
|
+
state: TState // Public state — synced to all members
|
|
124
|
+
meta: TMeta // Private metadata — NEVER sent to clients
|
|
125
|
+
|
|
126
|
+
// === Framework Methods ===
|
|
127
|
+
setState(updates: Partial<TState>): void // Update & broadcast state
|
|
128
|
+
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): number // Emit typed event
|
|
129
|
+
get memberCount(): number // Current member count
|
|
130
|
+
|
|
131
|
+
// === Lifecycle Hooks (override in subclass) ===
|
|
132
|
+
onJoin(ctx: RoomJoinContext): void | false // Return false to reject
|
|
133
|
+
onLeave(ctx: RoomLeaveContext): void
|
|
134
|
+
onEvent(event: string, data: any, ctx: RoomEventContext): void
|
|
135
|
+
onCreate(): void // First member joined
|
|
136
|
+
onDestroy(): void | false // Last member left (return false to keep alive)
|
|
137
|
+
}
|
|
138
|
+
```
|
|
58
139
|
|
|
59
|
-
|
|
140
|
+
### Lifecycle Context Types
|
|
60
141
|
|
|
61
142
|
```typescript
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
143
|
+
interface RoomJoinContext {
|
|
144
|
+
componentId: string
|
|
145
|
+
userId?: string
|
|
146
|
+
payload?: any // Arbitrary data passed from room.join({ ... })
|
|
147
|
+
}
|
|
65
148
|
|
|
66
|
-
|
|
67
|
-
|
|
149
|
+
interface RoomLeaveContext {
|
|
150
|
+
componentId: string
|
|
151
|
+
userId?: string
|
|
152
|
+
reason: 'leave' | 'disconnect' | 'cleanup'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface RoomEventContext {
|
|
156
|
+
componentId: string
|
|
157
|
+
userId?: string
|
|
158
|
+
}
|
|
68
159
|
```
|
|
69
160
|
|
|
70
|
-
|
|
161
|
+
### Public State vs Private Meta
|
|
71
162
|
|
|
72
|
-
|
|
163
|
+
| | `state` | `meta` |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| **Visibility** | Synced to all room members | Server-only, never leaves |
|
|
166
|
+
| **Access** | `this.state.x` / `this.setState({})` | `this.meta.x` (direct mutation) |
|
|
167
|
+
| **Use for** | Messages, counts, flags | Passwords, secrets, internal data |
|
|
168
|
+
| **Broadcast** | Yes, via deep diff | Never |
|
|
169
|
+
|
|
170
|
+
### Using Typed Rooms in Components
|
|
73
171
|
|
|
74
172
|
```typescript
|
|
75
173
|
// app/server/live/LiveRoomChat.ts
|
|
76
|
-
import {
|
|
174
|
+
import { ChatRoom } from './rooms/ChatRoom'
|
|
77
175
|
|
|
78
|
-
export
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
176
|
+
export class LiveRoomChat extends LiveComponent<typeof LiveRoomChat.defaultState> {
|
|
177
|
+
static publicActions = ['joinRoom', 'sendMessage'] as const
|
|
178
|
+
|
|
179
|
+
async joinRoom(payload: { roomId: string; password?: string }) {
|
|
180
|
+
// $room(Class, instanceId) — returns typed handle with custom methods
|
|
181
|
+
const room = this.$room(ChatRoom, payload.roomId)
|
|
182
|
+
|
|
183
|
+
// Join with payload (passed to onJoin lifecycle hook)
|
|
184
|
+
const result = room.join({ password: payload.password })
|
|
185
|
+
|
|
186
|
+
// Check rejection
|
|
187
|
+
if ('rejected' in result && result.rejected) {
|
|
188
|
+
return { success: false, error: result.reason }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Access room state (public)
|
|
192
|
+
const messages = room.state.messages
|
|
193
|
+
|
|
194
|
+
// Access room meta (private, server-only)
|
|
195
|
+
const createdBy = room.meta.createdBy
|
|
196
|
+
|
|
197
|
+
// Call custom methods
|
|
198
|
+
room.addMessage('System', 'Welcome!')
|
|
199
|
+
room.setPassword('new-password')
|
|
84
200
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
201
|
+
// Listen for typed events
|
|
202
|
+
room.on('chat:message', (msg) => {
|
|
203
|
+
// Update component state to sync with frontend
|
|
204
|
+
this.setState({ messages: [...this.state.messages, msg] })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Emit typed events
|
|
208
|
+
room.emit('chat:message', { id: '1', user: 'Bot', text: 'Hi', timestamp: Date.now() })
|
|
209
|
+
|
|
210
|
+
// Framework properties
|
|
211
|
+
console.log(room.id) // 'chat:lobby'
|
|
212
|
+
console.log(room.memberCount) // 5
|
|
213
|
+
console.log(room.state) // { messages: [...], onlineCount: 5, isPrivate: true }
|
|
214
|
+
|
|
215
|
+
return { success: true }
|
|
216
|
+
}
|
|
91
217
|
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Compound Room IDs
|
|
221
|
+
|
|
222
|
+
Typed rooms use compound IDs: `${roomName}:${instanceId}`
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
ChatRoom + 'lobby' → 'chat:lobby'
|
|
226
|
+
ChatRoom + 'vip' → 'chat:vip'
|
|
227
|
+
GameRoom + 'match1' → 'game:match1'
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
This allows multiple instances of the same room type. The `RoomRegistry` resolves the class from the compound ID automatically.
|
|
231
|
+
|
|
232
|
+
### Room Auto-Discovery
|
|
233
|
+
|
|
234
|
+
Room classes in `app/server/live/rooms/` are auto-discovered on startup. Any exported class that extends `LiveRoom` is registered automatically.
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
app/server/live/rooms/
|
|
238
|
+
ChatRoom.ts → registered as 'chat'
|
|
239
|
+
DirectoryRoom.ts → registered as 'directory'
|
|
240
|
+
GameRoom.ts → registered as 'game'
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
No manual registration needed. The `websocket-plugin.ts` scans the directory and passes discovered rooms to `LiveServer`.
|
|
92
244
|
|
|
93
|
-
|
|
94
|
-
static defaultState = defaultState
|
|
245
|
+
### Join Rejection
|
|
95
246
|
|
|
96
|
-
|
|
97
|
-
|
|
247
|
+
Typed rooms can reject joins in the `onJoin` hook:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
class VIPRoom extends LiveRoom<State, Meta, Events> {
|
|
251
|
+
static roomName = 'vip'
|
|
252
|
+
|
|
253
|
+
onJoin(ctx: RoomJoinContext) {
|
|
254
|
+
// Reject if no password
|
|
255
|
+
if (this.meta.password && ctx.payload?.password !== this.meta.password) {
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Reject if room is full (also handled automatically by maxMembers)
|
|
260
|
+
if (this.memberCount >= 10) {
|
|
261
|
+
return false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Accept (return void)
|
|
98
265
|
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
On the component side:
|
|
99
270
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
271
|
+
```typescript
|
|
272
|
+
const room = this.$room(VIPRoom, 'exclusive')
|
|
273
|
+
const result = room.join({ password: '1234' })
|
|
103
274
|
|
|
104
|
-
|
|
105
|
-
|
|
275
|
+
if ('rejected' in result && result.rejected) {
|
|
276
|
+
return { success: false, error: result.reason }
|
|
277
|
+
}
|
|
278
|
+
```
|
|
106
279
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
280
|
+
### Server-Only Join Enforcement
|
|
281
|
+
|
|
282
|
+
Clients cannot join typed rooms directly via WebSocket. The join MUST happen through a component action on the server. If a client attempts to send a `ROOM_JOIN` message for a typed room, it receives an error:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
"Room requires server-side join via component action"
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
This ensures all join logic (password validation, authorization, etc.) runs on the server.
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Shared Room Directory Pattern
|
|
293
|
+
|
|
294
|
+
When users can create rooms dynamically, other users need to discover them. The **DirectoryRoom** pattern solves this:
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
// app/server/live/rooms/DirectoryRoom.ts
|
|
298
|
+
import { LiveRoom } from '@fluxstack/live'
|
|
299
|
+
|
|
300
|
+
export interface DirectoryEntry {
|
|
301
|
+
id: string
|
|
302
|
+
name: string
|
|
303
|
+
isPrivate: boolean
|
|
304
|
+
createdBy: string
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
interface DirectoryState {
|
|
308
|
+
rooms: DirectoryEntry[]
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface DirectoryEvents {
|
|
312
|
+
'room:added': DirectoryEntry
|
|
313
|
+
'room:removed': { id: string }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export class DirectoryRoom extends LiveRoom<DirectoryState, {}, DirectoryEvents> {
|
|
317
|
+
static roomName = 'directory'
|
|
318
|
+
static defaultState: DirectoryState = { rooms: [] }
|
|
319
|
+
static defaultMeta = {}
|
|
111
320
|
|
|
112
|
-
|
|
113
|
-
this
|
|
114
|
-
this.
|
|
321
|
+
addRoom(entry: DirectoryEntry) {
|
|
322
|
+
this.setState({
|
|
323
|
+
rooms: [...this.state.rooms.filter(r => r.id !== entry.id), entry]
|
|
115
324
|
})
|
|
325
|
+
this.emit('room:added', entry)
|
|
326
|
+
}
|
|
116
327
|
|
|
117
|
-
|
|
328
|
+
removeRoom(id: string) {
|
|
118
329
|
this.setState({
|
|
119
|
-
|
|
120
|
-
rooms: [...this.state.rooms, { id: roomId, name: roomName || roomId }],
|
|
121
|
-
messages: { ...this.state.messages, [roomId]: [] },
|
|
122
|
-
typingUsers: { ...this.state.typingUsers, [roomId]: [] }
|
|
330
|
+
rooms: this.state.rooms.filter(r => r.id !== id)
|
|
123
331
|
})
|
|
332
|
+
this.emit('room:removed', { id })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
```
|
|
124
336
|
|
|
125
|
-
|
|
337
|
+
**Usage in component:**
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
export class LiveRoomChat extends LiveComponent<...> {
|
|
341
|
+
constructor(initialState, ws, options) {
|
|
342
|
+
super(initialState, ws, options)
|
|
343
|
+
|
|
344
|
+
// All instances auto-join the directory
|
|
345
|
+
const dir = this.$room(DirectoryRoom, 'main')
|
|
346
|
+
dir.join()
|
|
347
|
+
|
|
348
|
+
// Load existing rooms
|
|
349
|
+
this.setState({ customRooms: dir.state.rooms || [] })
|
|
350
|
+
|
|
351
|
+
// Listen for real-time updates
|
|
352
|
+
dir.on('room:added', (entry) => {
|
|
353
|
+
this.setState({ customRooms: [...this.state.customRooms, entry] })
|
|
354
|
+
})
|
|
126
355
|
}
|
|
127
356
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!roomId) throw new Error('No active room')
|
|
357
|
+
async createRoom(payload: { roomId: string; roomName: string; password?: string }) {
|
|
358
|
+
const room = this.$room(ChatRoom, payload.roomId)
|
|
359
|
+
room.join()
|
|
132
360
|
|
|
133
|
-
|
|
134
|
-
id: `msg-${Date.now()}`,
|
|
135
|
-
user: this.state.username,
|
|
136
|
-
text: payload.text,
|
|
137
|
-
timestamp: Date.now()
|
|
138
|
-
}
|
|
361
|
+
if (payload.password) room.setPassword(payload.password)
|
|
139
362
|
|
|
140
|
-
//
|
|
141
|
-
this
|
|
363
|
+
// Register in directory — all connected users see it immediately
|
|
364
|
+
this.$room(DirectoryRoom, 'main').addRoom({
|
|
365
|
+
id: payload.roomId,
|
|
366
|
+
name: payload.roomName,
|
|
367
|
+
isPrivate: !!payload.password,
|
|
368
|
+
createdBy: this.state.username
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
142
373
|
|
|
143
|
-
|
|
144
|
-
this.$room(roomId).emit('message:new', message)
|
|
374
|
+
**Flow:**
|
|
145
375
|
|
|
146
|
-
|
|
376
|
+
```
|
|
377
|
+
User A creates room → DirectoryRoom.addRoom()
|
|
378
|
+
→ emit('room:added', entry)
|
|
379
|
+
→ All LiveRoomChat instances receive event
|
|
380
|
+
→ Each updates customRooms in component state
|
|
381
|
+
→ Each frontend sees the new room in sidebar
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Password-Protected Rooms
|
|
387
|
+
|
|
388
|
+
Complete implementation using `meta` (server-only) and `onJoin` validation:
|
|
389
|
+
|
|
390
|
+
### 1. Room Class
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
// ChatMeta.password is NEVER sent to clients
|
|
394
|
+
interface ChatMeta {
|
|
395
|
+
password: string | null
|
|
396
|
+
createdBy: string | null
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
class ChatRoom extends LiveRoom<ChatState, ChatMeta, ChatEvents> {
|
|
400
|
+
static defaultMeta: ChatMeta = { password: null, createdBy: null }
|
|
401
|
+
|
|
402
|
+
setPassword(password: string | null) {
|
|
403
|
+
this.meta.password = password // Server-only
|
|
404
|
+
this.setState({ isPrivate: password !== null }) // Visible to clients
|
|
147
405
|
}
|
|
148
406
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
messages: {
|
|
154
|
-
...this.state.messages,
|
|
155
|
-
[roomId]: [...messages, msg].slice(-100) // Keep last 100
|
|
407
|
+
onJoin(ctx: RoomJoinContext) {
|
|
408
|
+
if (this.meta.password) {
|
|
409
|
+
if (ctx.payload?.password !== this.meta.password) {
|
|
410
|
+
return false // Wrong password → rejected
|
|
156
411
|
}
|
|
157
|
-
}
|
|
412
|
+
}
|
|
413
|
+
// Password correct or no password → accepted
|
|
158
414
|
}
|
|
415
|
+
}
|
|
416
|
+
```
|
|
159
417
|
|
|
160
|
-
|
|
161
|
-
private updateTypingUsers(roomId: string, user: string, typing: boolean) {
|
|
162
|
-
const current = this.state.typingUsers[roomId] || []
|
|
163
|
-
const updated = typing
|
|
164
|
-
? [...current.filter(u => u !== user), user]
|
|
165
|
-
: current.filter(u => u !== user)
|
|
418
|
+
### 2. Component Action
|
|
166
419
|
|
|
167
|
-
|
|
168
|
-
|
|
420
|
+
```typescript
|
|
421
|
+
async joinRoom(payload: { roomId: string; password?: string }) {
|
|
422
|
+
const room = this.$room(ChatRoom, payload.roomId)
|
|
423
|
+
const result = room.join({ password: payload.password })
|
|
424
|
+
// ^^^^^^^^^ passed to onJoin ctx.payload
|
|
425
|
+
|
|
426
|
+
if ('rejected' in result && result.rejected) {
|
|
427
|
+
return { success: false, error: 'Senha incorreta' }
|
|
428
|
+
}
|
|
429
|
+
return { success: true }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async createRoom(payload: { roomId: string; roomName: string; password?: string }) {
|
|
433
|
+
const room = this.$room(ChatRoom, payload.roomId)
|
|
434
|
+
room.join() // Creator joins without password (first join, no password set yet)
|
|
435
|
+
|
|
436
|
+
if (payload.password) {
|
|
437
|
+
room.setPassword(payload.password) // Now set password for future joiners
|
|
438
|
+
}
|
|
439
|
+
room.meta.createdBy = this.state.username
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### 3. Frontend
|
|
444
|
+
|
|
445
|
+
```tsx
|
|
446
|
+
// Password prompt when clicking a private room
|
|
447
|
+
const handleJoinRoom = async (roomId: string, roomName: string, isPrivate?: boolean) => {
|
|
448
|
+
if (isPrivate) {
|
|
449
|
+
showPasswordModal(roomId, roomName)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
const result = await chat.joinRoom({ roomId, roomName })
|
|
453
|
+
if (result && !result.success) {
|
|
454
|
+
// Rejected — maybe password-protected, show prompt
|
|
455
|
+
showPasswordModal(roomId, roomName)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Submit with password
|
|
460
|
+
const handlePasswordSubmit = async () => {
|
|
461
|
+
const result = await chat.joinRoom({
|
|
462
|
+
roomId: prompt.roomId,
|
|
463
|
+
roomName: prompt.roomName,
|
|
464
|
+
password: passwordInput
|
|
465
|
+
})
|
|
466
|
+
if (result && !result.success) {
|
|
467
|
+
showError('Senha incorreta')
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## Untyped Rooms (Legacy)
|
|
475
|
+
|
|
476
|
+
The string-based API still works for simple pub/sub without a room class:
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// Join/leave
|
|
480
|
+
this.$room('notifications').join()
|
|
481
|
+
this.$room('notifications').leave()
|
|
482
|
+
|
|
483
|
+
// Emit/listen
|
|
484
|
+
this.$room('notifications').emit('alert', { msg: 'hey' })
|
|
485
|
+
this.$room('notifications').on('alert', (data) => {
|
|
486
|
+
this.setState({ alerts: [...this.state.alerts, data] })
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
// Room state
|
|
490
|
+
this.$room('notifications').setState({ lastAlert: Date.now() })
|
|
491
|
+
const state = this.$room('notifications').state
|
|
492
|
+
|
|
493
|
+
// Default room shorthand (via options.room)
|
|
494
|
+
this.$room.emit('event', data)
|
|
495
|
+
this.$room.on('event', handler)
|
|
496
|
+
|
|
497
|
+
// List all joined rooms
|
|
498
|
+
const rooms = this.$rooms // ['notifications', 'chat:lobby']
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### Typed vs Untyped Comparison
|
|
502
|
+
|
|
503
|
+
| Feature | Typed `$room(Class, id)` | Untyped `$room('id')` |
|
|
504
|
+
|---|---|---|
|
|
505
|
+
| Lifecycle hooks | onJoin, onLeave, onCreate, onDestroy | None |
|
|
506
|
+
| Private metadata | `meta` (server-only) | None |
|
|
507
|
+
| Custom methods | addMessage(), setPassword(), etc. | None |
|
|
508
|
+
| Join rejection | `return false` in onJoin | Not possible |
|
|
509
|
+
| Type safety | Full (state, events, methods) | None |
|
|
510
|
+
| Server-only join | Enforced | Client can join directly |
|
|
511
|
+
| Max members | `$options.maxMembers` | No limit |
|
|
512
|
+
| Auto-discovery | Yes (rooms/ directory) | N/A |
|
|
513
|
+
| Use case | Complex room logic | Simple pub/sub |
|
|
514
|
+
|
|
515
|
+
**Both can be used in the same component:**
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
// Typed room for chat
|
|
519
|
+
const chat = this.$room(ChatRoom, 'lobby')
|
|
520
|
+
chat.addMessage('user', 'hello')
|
|
521
|
+
|
|
522
|
+
// Untyped room for presence
|
|
523
|
+
this.$room('presence').join()
|
|
524
|
+
this.$room('presence').emit('online', { user: 'John' })
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## Complete Example: Chat with Password Rooms
|
|
530
|
+
|
|
531
|
+
### Server — Room Classes
|
|
532
|
+
|
|
533
|
+
```
|
|
534
|
+
app/server/live/rooms/
|
|
535
|
+
ChatRoom.ts — Chat room with messages, password, lifecycle
|
|
536
|
+
DirectoryRoom.ts — Shared room directory for room discovery
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Server — Component
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
// app/server/live/LiveRoomChat.ts
|
|
543
|
+
import { LiveComponent, type FluxStackWebSocket } from '@core/types/types'
|
|
544
|
+
import { ChatRoom } from './rooms/ChatRoom'
|
|
545
|
+
import { DirectoryRoom } from './rooms/DirectoryRoom'
|
|
546
|
+
import type { ChatMessage } from './rooms/ChatRoom'
|
|
547
|
+
import type { DirectoryEntry } from './rooms/DirectoryRoom'
|
|
548
|
+
|
|
549
|
+
export class LiveRoomChat extends LiveComponent<typeof LiveRoomChat.defaultState> {
|
|
550
|
+
static componentName = 'LiveRoomChat'
|
|
551
|
+
static publicActions = ['createRoom', 'joinRoom', 'leaveRoom', 'switchRoom', 'sendMessage', 'setUsername'] as const
|
|
552
|
+
static defaultState = {
|
|
553
|
+
username: '',
|
|
554
|
+
activeRoom: null as string | null,
|
|
555
|
+
rooms: [] as { id: string; name: string; isPrivate: boolean }[],
|
|
556
|
+
messages: {} as Record<string, ChatMessage[]>,
|
|
557
|
+
customRooms: [] as DirectoryEntry[]
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private roomListeners = new Map<string, (() => void)[]>()
|
|
561
|
+
private directoryUnsubs: (() => void)[] = []
|
|
562
|
+
|
|
563
|
+
constructor(initialState: Partial<typeof LiveRoomChat.defaultState>, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
|
|
564
|
+
super(initialState, ws, options)
|
|
565
|
+
|
|
566
|
+
// Auto-join directory for room discovery
|
|
567
|
+
const dir = this.$room(DirectoryRoom, 'main')
|
|
568
|
+
dir.join()
|
|
569
|
+
this.setState({ customRooms: dir.state.rooms || [] })
|
|
570
|
+
|
|
571
|
+
const unsubAdd = dir.on('room:added', (entry: DirectoryEntry) => {
|
|
572
|
+
const current = this.state.customRooms.filter(r => r.id !== entry.id)
|
|
573
|
+
this.setState({ customRooms: [...current, entry] })
|
|
574
|
+
})
|
|
575
|
+
const unsubRemove = dir.on('room:removed', (data: { id: string }) => {
|
|
576
|
+
this.setState({ customRooms: this.state.customRooms.filter(r => r.id !== data.id) })
|
|
169
577
|
})
|
|
578
|
+
this.directoryUnsubs = [unsubAdd, unsubRemove]
|
|
170
579
|
}
|
|
171
580
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
581
|
+
async createRoom(payload: { roomId: string; roomName: string; password?: string }) {
|
|
582
|
+
const room = this.$room(ChatRoom, payload.roomId)
|
|
583
|
+
const result = room.join()
|
|
584
|
+
if ('rejected' in result && result.rejected) return { success: false, error: result.reason }
|
|
585
|
+
|
|
586
|
+
if (payload.password) room.setPassword(payload.password)
|
|
587
|
+
room.meta.createdBy = this.state.username || 'Anonymous'
|
|
176
588
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
589
|
+
// Register in directory so all users see it
|
|
590
|
+
this.$room(DirectoryRoom, 'main').addRoom({
|
|
591
|
+
id: payload.roomId, name: payload.roomName,
|
|
592
|
+
isPrivate: !!payload.password, createdBy: this.state.username || 'Anonymous'
|
|
180
593
|
})
|
|
181
594
|
|
|
182
|
-
|
|
595
|
+
const unsub = room.on('chat:message', (msg: ChatMessage) => {
|
|
596
|
+
const msgs = this.state.messages[payload.roomId] || []
|
|
597
|
+
this.setState({ messages: { ...this.state.messages, [payload.roomId]: [...msgs, msg].slice(-100) } })
|
|
598
|
+
})
|
|
599
|
+
this.roomListeners.set(payload.roomId, [unsub])
|
|
600
|
+
|
|
601
|
+
this.setState({
|
|
602
|
+
activeRoom: payload.roomId,
|
|
603
|
+
rooms: [...this.state.rooms.filter(r => r.id !== payload.roomId), { id: payload.roomId, name: payload.roomName, isPrivate: !!payload.password }],
|
|
604
|
+
messages: { ...this.state.messages, [payload.roomId]: [] }
|
|
605
|
+
})
|
|
606
|
+
return { success: true, roomId: payload.roomId }
|
|
183
607
|
}
|
|
184
608
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
609
|
+
async joinRoom(payload: { roomId: string; roomName?: string; password?: string }) {
|
|
610
|
+
if (this.roomListeners.has(payload.roomId)) {
|
|
611
|
+
this.state.activeRoom = payload.roomId
|
|
612
|
+
return { success: true, roomId: payload.roomId }
|
|
613
|
+
}
|
|
188
614
|
|
|
189
|
-
this.$room(roomId)
|
|
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: 'Senha incorreta' }
|
|
190
618
|
|
|
191
|
-
const
|
|
192
|
-
|
|
619
|
+
const unsub = room.on('chat:message', (msg: ChatMessage) => {
|
|
620
|
+
const msgs = this.state.messages[payload.roomId] || []
|
|
621
|
+
this.setState({ messages: { ...this.state.messages, [payload.roomId]: [...msgs, msg].slice(-100) } })
|
|
622
|
+
})
|
|
623
|
+
this.roomListeners.set(payload.roomId, [unsub])
|
|
193
624
|
|
|
194
625
|
this.setState({
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
messages:
|
|
198
|
-
typingUsers: restTyping
|
|
626
|
+
activeRoom: payload.roomId,
|
|
627
|
+
rooms: [...this.state.rooms.filter(r => r.id !== payload.roomId), { id: payload.roomId, name: payload.roomName || payload.roomId, isPrivate: room.state.isPrivate }],
|
|
628
|
+
messages: { ...this.state.messages, [payload.roomId]: room.state.messages || [] }
|
|
199
629
|
})
|
|
630
|
+
return { success: true, roomId: payload.roomId }
|
|
631
|
+
}
|
|
200
632
|
|
|
201
|
-
|
|
633
|
+
async sendMessage(payload: { text: string }) {
|
|
634
|
+
const roomId = this.state.activeRoom
|
|
635
|
+
if (!roomId) throw new Error('No active room')
|
|
636
|
+
// Custom method on ChatRoom — emits 'chat:message' event,
|
|
637
|
+
// which the handler above catches and updates component state
|
|
638
|
+
const room = this.$room(ChatRoom, roomId)
|
|
639
|
+
const message = room.addMessage(this.state.username || 'Anonymous', payload.text.trim())
|
|
640
|
+
return { success: true, message }
|
|
641
|
+
}
|
|
642
|
+
|
|
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
|
+
super.destroy()
|
|
202
648
|
}
|
|
203
649
|
}
|
|
204
650
|
```
|
|
205
651
|
|
|
206
|
-
### Frontend
|
|
652
|
+
### Frontend
|
|
207
653
|
|
|
208
|
-
```
|
|
654
|
+
```tsx
|
|
209
655
|
// app/client/src/live/RoomChatDemo.tsx
|
|
210
656
|
import { Live } from '@/core/client'
|
|
211
|
-
import { LiveRoomChat
|
|
657
|
+
import { LiveRoomChat } from '@server/live/LiveRoomChat'
|
|
212
658
|
|
|
213
659
|
export function RoomChatDemo() {
|
|
214
|
-
const [text, setText] = useState('')
|
|
215
|
-
|
|
216
|
-
// Connect to Live Component
|
|
217
660
|
const chat = Live.use(LiveRoomChat, {
|
|
218
|
-
initialState: { ...defaultState, username: 'User123' }
|
|
661
|
+
initialState: { ...LiveRoomChat.defaultState, username: 'User123' }
|
|
219
662
|
})
|
|
220
663
|
|
|
221
|
-
//
|
|
222
|
-
const
|
|
223
|
-
const messages = activeRoom ? (chat.$state.messages[activeRoom] || []) : []
|
|
224
|
-
const typingUsers = activeRoom ? (chat.$state.typingUsers[activeRoom] || []) : []
|
|
664
|
+
// Joined rooms from component state
|
|
665
|
+
const joinedRoomIds = chat.$state.rooms.map(r => r.id)
|
|
225
666
|
|
|
226
|
-
//
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
667
|
+
// Custom rooms from shared directory (visible to ALL users)
|
|
668
|
+
const customRooms = chat.$state.customRooms || []
|
|
669
|
+
|
|
670
|
+
// Combine default + custom rooms for sidebar
|
|
671
|
+
const allRooms = [
|
|
672
|
+
...DEFAULT_ROOMS,
|
|
673
|
+
...customRooms.filter(r => !DEFAULT_ROOMS.some(d => d.id === r.id))
|
|
674
|
+
]
|
|
230
675
|
|
|
231
|
-
//
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
676
|
+
// Join with password handling
|
|
677
|
+
const handleJoinRoom = async (roomId: string, roomName: string, isPrivate?: boolean) => {
|
|
678
|
+
if (joinedRoomIds.includes(roomId)) {
|
|
679
|
+
await chat.switchRoom({ roomId })
|
|
680
|
+
return
|
|
681
|
+
}
|
|
682
|
+
if (isPrivate) {
|
|
683
|
+
showPasswordPrompt(roomId, roomName)
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
const result = await chat.joinRoom({ roomId, roomName })
|
|
687
|
+
if (result && !result.success) {
|
|
688
|
+
showPasswordPrompt(roomId, roomName) // Might be password-protected
|
|
689
|
+
}
|
|
236
690
|
}
|
|
237
691
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
<button key={roomId} onClick={() => handleJoinRoom(roomId)}>
|
|
244
|
-
{roomId} {chat.$rooms.includes(roomId) && '(joined)'}
|
|
245
|
-
</button>
|
|
246
|
-
))}
|
|
247
|
-
</div>
|
|
248
|
-
|
|
249
|
-
{/* Messages */}
|
|
250
|
-
<div>
|
|
251
|
-
{messages.map(msg => (
|
|
252
|
-
<div key={msg.id}>
|
|
253
|
-
<strong>{msg.user}:</strong> {msg.text}
|
|
254
|
-
</div>
|
|
255
|
-
))}
|
|
256
|
-
</div>
|
|
257
|
-
|
|
258
|
-
{/* Typing indicator */}
|
|
259
|
-
{typingUsers.length > 0 && (
|
|
260
|
-
<div>{typingUsers.join(', ')} typing...</div>
|
|
261
|
-
)}
|
|
262
|
-
|
|
263
|
-
{/* Input */}
|
|
264
|
-
<input
|
|
265
|
-
value={text}
|
|
266
|
-
onChange={e => {
|
|
267
|
-
setText(e.target.value)
|
|
268
|
-
chat.startTyping()
|
|
269
|
-
}}
|
|
270
|
-
onKeyDown={e => e.key === 'Enter' && handleSend()}
|
|
271
|
-
/>
|
|
272
|
-
<button onClick={handleSend}>Send</button>
|
|
273
|
-
</div>
|
|
274
|
-
)
|
|
692
|
+
// Create room with optional password
|
|
693
|
+
const handleCreateRoom = async (name: string, password?: string) => {
|
|
694
|
+
const roomId = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
695
|
+
await chat.createRoom({ roomId, roomName: name, password })
|
|
696
|
+
}
|
|
275
697
|
}
|
|
276
698
|
```
|
|
277
699
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
Send messages from external systems via REST API:
|
|
700
|
+
---
|
|
281
701
|
|
|
282
|
-
|
|
702
|
+
## Event Flow Diagrams
|
|
283
703
|
|
|
284
|
-
|
|
285
|
-
// app/server/routes/room.routes.ts
|
|
286
|
-
import { Elysia, t } from 'elysia'
|
|
287
|
-
import { liveRoomManager } from '@core/server/live/LiveRoomManager'
|
|
704
|
+
### Message Flow (Typed Room)
|
|
288
705
|
|
|
289
|
-
|
|
706
|
+
```
|
|
707
|
+
Frontend A Server Frontend B
|
|
708
|
+
│ │ │
|
|
709
|
+
│ sendMessage() │ │
|
|
710
|
+
│─────────────────────>│ │
|
|
711
|
+
│ │ │
|
|
712
|
+
│ │ ChatRoom.addMessage()
|
|
713
|
+
│ │ ├─ setState(messages) // room state updated
|
|
714
|
+
│ │ └─ emit('chat:message') // event to all members
|
|
715
|
+
│ │ │
|
|
716
|
+
│ │ A's on('chat:message')
|
|
717
|
+
│ │ └─ component.setState()
|
|
718
|
+
│ <state update> │ │
|
|
719
|
+
│<─────────────────────│ │
|
|
720
|
+
│ │ │
|
|
721
|
+
│ │ B's on('chat:message')
|
|
722
|
+
│ │ └─ component.setState()
|
|
723
|
+
│ │ <state update> │
|
|
724
|
+
│ │─────────────────────>│
|
|
725
|
+
```
|
|
290
726
|
|
|
291
|
-
|
|
292
|
-
.post('/:roomId/messages', ({ params, body }) => {
|
|
293
|
-
const message = {
|
|
294
|
-
id: `api-${Date.now()}`,
|
|
295
|
-
user: body.user || 'API Bot',
|
|
296
|
-
text: body.text,
|
|
297
|
-
timestamp: Date.now()
|
|
298
|
-
}
|
|
727
|
+
### Password Join Flow
|
|
299
728
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
})
|
|
729
|
+
```
|
|
730
|
+
Frontend Component ChatRoom
|
|
731
|
+
│ │ │
|
|
732
|
+
│ joinRoom(password) │ │
|
|
733
|
+
│─────────────────────>│ │
|
|
734
|
+
│ │ │
|
|
735
|
+
│ │ room.join({password})
|
|
736
|
+
│ │─────────────────────>│
|
|
737
|
+
│ │ │
|
|
738
|
+
│ │ onJoin(ctx)
|
|
739
|
+
│ │ ctx.payload.password
|
|
740
|
+
│ │ vs meta.password
|
|
741
|
+
│ │ │
|
|
742
|
+
│ │ {rejected: true} │ (wrong password)
|
|
743
|
+
│ │<─────────────────────│
|
|
744
|
+
│ │ │
|
|
745
|
+
│ {success: false} │ │
|
|
746
|
+
│<─────────────────────│ │
|
|
747
|
+
│ │ │
|
|
748
|
+
│ Show error toast │ │
|
|
749
|
+
```
|
|
314
750
|
|
|
315
|
-
|
|
316
|
-
.post('/:roomId/emit', ({ params, body }) => {
|
|
317
|
-
const notified = liveRoomManager.emitToRoom(
|
|
318
|
-
params.roomId,
|
|
319
|
-
body.event,
|
|
320
|
-
body.data
|
|
321
|
-
)
|
|
322
|
-
return { success: true, notified }
|
|
323
|
-
}, {
|
|
324
|
-
params: t.Object({ roomId: t.String() }),
|
|
325
|
-
body: t.Object({
|
|
326
|
-
event: t.String(),
|
|
327
|
-
data: t.Any()
|
|
328
|
-
})
|
|
329
|
-
})
|
|
751
|
+
### Room Discovery Flow
|
|
330
752
|
|
|
331
|
-
// Get stats
|
|
332
|
-
.get('/stats', () => liveRoomManager.getStats())
|
|
333
753
|
```
|
|
754
|
+
User A DirectoryRoom User B
|
|
755
|
+
│ │ │
|
|
756
|
+
│ createRoom() │ │
|
|
757
|
+
│ dir.addRoom(entry) │ │
|
|
758
|
+
│─────────────────────>│ │
|
|
759
|
+
│ │ │
|
|
760
|
+
│ │ emit('room:added') │
|
|
761
|
+
│ │─────────────────────>│
|
|
762
|
+
│ │ │
|
|
763
|
+
│ │ B's on('room:added')│
|
|
764
|
+
│ │ setState(customRooms)
|
|
765
|
+
│ │ │
|
|
766
|
+
│ │ New room appears in B's sidebar
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
## HTTP API Integration
|
|
334
772
|
|
|
335
|
-
|
|
773
|
+
Send messages from external systems via REST API:
|
|
336
774
|
|
|
337
775
|
```bash
|
|
338
|
-
# Send message
|
|
776
|
+
# Send message to room
|
|
339
777
|
curl -X POST http://localhost:3000/api/rooms/geral/messages \
|
|
340
778
|
-H "Content-Type: application/json" \
|
|
341
779
|
-d '{"user": "Webhook Bot", "text": "New deployment completed!"}'
|
|
@@ -343,140 +781,100 @@ curl -X POST http://localhost:3000/api/rooms/geral/messages \
|
|
|
343
781
|
# Emit custom event
|
|
344
782
|
curl -X POST http://localhost:3000/api/rooms/tech/emit \
|
|
345
783
|
-H "Content-Type: application/json" \
|
|
346
|
-
-d '{"event": "notification", "data": {"type": "alert"
|
|
784
|
+
-d '{"event": "notification", "data": {"type": "alert"}}'
|
|
347
785
|
|
|
348
786
|
# Get room stats
|
|
349
787
|
curl http://localhost:3000/api/rooms/stats
|
|
350
788
|
```
|
|
351
789
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
```
|
|
355
|
-
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
356
|
-
│ Frontend A │ │ Server │ │ Frontend B │
|
|
357
|
-
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
|
358
|
-
│ │ │
|
|
359
|
-
│ sendMessage() │ │
|
|
360
|
-
│──────────────────>│ │
|
|
361
|
-
│ │ │
|
|
362
|
-
│ │ 1. setState() │
|
|
363
|
-
│ │ (sync to A) │
|
|
364
|
-
│<──────────────────│ │
|
|
365
|
-
│ │ │
|
|
366
|
-
│ │ 2. $room.emit() │
|
|
367
|
-
│ │ (to others) │
|
|
368
|
-
│ │ │
|
|
369
|
-
│ │ 3. B's handler │
|
|
370
|
-
│ │ receives event │
|
|
371
|
-
│ │ │
|
|
372
|
-
│ │ 4. B's setState() │
|
|
373
|
-
│ │ (sync to B) │
|
|
374
|
-
│ │──────────────────>│
|
|
375
|
-
│ │ │
|
|
376
|
-
```
|
|
790
|
+
---
|
|
377
791
|
|
|
378
792
|
## Room Manager API
|
|
379
793
|
|
|
380
|
-
Direct access
|
|
794
|
+
Direct access for advanced use cases:
|
|
381
795
|
|
|
382
796
|
```typescript
|
|
383
797
|
import { liveRoomManager } from '@core/server/live/LiveRoomManager'
|
|
384
798
|
|
|
385
|
-
//
|
|
386
|
-
liveRoomManager.joinRoom(componentId, roomId, ws, initialState)
|
|
387
|
-
|
|
388
|
-
// Leave room
|
|
389
|
-
liveRoomManager.leaveRoom(componentId, roomId)
|
|
390
|
-
|
|
391
|
-
// Emit event to room
|
|
392
|
-
const count = liveRoomManager.emitToRoom(roomId, event, data, excludeComponentId)
|
|
393
|
-
|
|
394
|
-
// Update room state
|
|
395
|
-
liveRoomManager.setRoomState(roomId, updates, excludeComponentId)
|
|
396
|
-
|
|
397
|
-
// Get room state
|
|
398
|
-
const state = liveRoomManager.getRoomState(roomId)
|
|
399
|
-
|
|
400
|
-
// Check membership
|
|
401
|
-
const isIn = liveRoomManager.isInRoom(componentId, roomId)
|
|
402
|
-
|
|
403
|
-
// Get component's rooms
|
|
404
|
-
const rooms = liveRoomManager.getComponentRooms(componentId)
|
|
405
|
-
|
|
406
|
-
// Cleanup on disconnect
|
|
799
|
+
// Membership
|
|
800
|
+
liveRoomManager.joinRoom(componentId, roomId, ws, initialState, options, joinContext)
|
|
801
|
+
liveRoomManager.leaveRoom(componentId, roomId, leaveReason)
|
|
407
802
|
liveRoomManager.cleanupComponent(componentId)
|
|
408
803
|
|
|
409
|
-
//
|
|
410
|
-
|
|
804
|
+
// Events & State
|
|
805
|
+
liveRoomManager.emitToRoom(roomId, event, data, excludeComponentId)
|
|
806
|
+
liveRoomManager.setRoomState(roomId, updates, excludeComponentId)
|
|
807
|
+
liveRoomManager.getRoomState(roomId)
|
|
808
|
+
|
|
809
|
+
// Queries
|
|
810
|
+
liveRoomManager.isInRoom(componentId, roomId)
|
|
811
|
+
liveRoomManager.getComponentRooms(componentId)
|
|
812
|
+
liveRoomManager.getMemberCount(roomId)
|
|
813
|
+
liveRoomManager.getRoomInstance(roomId) // Get LiveRoom instance (typed rooms only)
|
|
814
|
+
liveRoomManager.getStats()
|
|
411
815
|
```
|
|
412
816
|
|
|
413
|
-
|
|
817
|
+
---
|
|
414
818
|
|
|
415
|
-
|
|
819
|
+
## Best Practices
|
|
416
820
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
componentId, // subscriber
|
|
426
|
-
(data) => { // handler
|
|
427
|
-
console.log('Received:', data)
|
|
428
|
-
}
|
|
429
|
-
)
|
|
821
|
+
**DO:**
|
|
822
|
+
- Use typed rooms for rooms with business logic (auth, validation, custom methods)
|
|
823
|
+
- Use `meta` for sensitive data (passwords, tokens, internal flags)
|
|
824
|
+
- Use `onJoin` to validate/reject joins
|
|
825
|
+
- Register event handlers with `room.on()` in joinRoom actions
|
|
826
|
+
- Use the DirectoryRoom pattern when users create rooms dynamically
|
|
827
|
+
- Clean up listeners in `destroy()`
|
|
828
|
+
- Load existing room state on join: `room.state.messages`
|
|
430
829
|
|
|
431
|
-
|
|
432
|
-
|
|
830
|
+
**DON'T:**
|
|
831
|
+
- Store sensitive data in `state` (it's synced to all clients)
|
|
832
|
+
- Forget to check `result.rejected` after `room.join()`
|
|
833
|
+
- Update component state directly in `sendMessage` when using room events (causes duplicates)
|
|
834
|
+
- Rely on untyped rooms for rooms with security requirements
|
|
835
|
+
- Skip the DirectoryRoom when users need to discover dynamically-created rooms
|
|
433
836
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
837
|
+
**Common Pitfall — Duplicate Messages:**
|
|
838
|
+
```typescript
|
|
839
|
+
// WRONG: event handler + manual setState = duplicate
|
|
840
|
+
async sendMessage(payload: { text: string }) {
|
|
841
|
+
const room = this.$room(ChatRoom, roomId)
|
|
842
|
+
room.addMessage(user, text) // emits 'chat:message' → handler updates state
|
|
843
|
+
// DON'T also do: this.setState({ messages: [...msgs, msg] })
|
|
844
|
+
}
|
|
437
845
|
|
|
438
|
-
//
|
|
439
|
-
|
|
846
|
+
// CORRECT: let the event handler do all the work
|
|
847
|
+
async sendMessage(payload: { text: string }) {
|
|
848
|
+
const room = this.$room(ChatRoom, roomId)
|
|
849
|
+
room.addMessage(user, text) // event handler catches it for ALL members
|
|
850
|
+
}
|
|
440
851
|
```
|
|
441
852
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
| Use Case | Description |
|
|
445
|
-
|----------|-------------|
|
|
446
|
-
| **Chat** | Multi-room chat with typing indicators |
|
|
447
|
-
| **Notifications** | Send alerts to specific groups |
|
|
448
|
-
| **Collaboration** | Real-time document editing |
|
|
449
|
-
| **Gaming** | Multiplayer game state sync |
|
|
450
|
-
| **Dashboards** | Live metrics and alerts |
|
|
451
|
-
| **Webhooks** | External events to rooms |
|
|
452
|
-
| **Presence** | Online/offline status |
|
|
853
|
+
---
|
|
453
854
|
|
|
454
|
-
##
|
|
455
|
-
|
|
456
|
-
**DO:**
|
|
457
|
-
- Use `setState()` to sync state to your own frontend
|
|
458
|
-
- Use `$room.emit()` to notify other components
|
|
459
|
-
- Register handlers with `$room.on()` in `joinRoom`
|
|
460
|
-
- Clean up with `$room.leave()` when leaving
|
|
461
|
-
- Use HTTP API for external integrations
|
|
855
|
+
## Files Reference
|
|
462
856
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
857
|
+
| File | Purpose |
|
|
858
|
+
|------|---------|
|
|
859
|
+
| `app/server/live/rooms/ChatRoom.ts` | Example typed room with password support |
|
|
860
|
+
| `app/server/live/rooms/DirectoryRoom.ts` | Shared room directory for discovery |
|
|
861
|
+
| `app/server/live/LiveRoomChat.ts` | Chat component using typed rooms |
|
|
862
|
+
| `app/client/src/live/RoomChatDemo.tsx` | Frontend React component |
|
|
863
|
+
| `core/server/live/websocket-plugin.ts` | Room auto-discovery and LiveServer setup |
|
|
468
864
|
|
|
469
|
-
|
|
865
|
+
### @fluxstack/live Package Files
|
|
470
866
|
|
|
471
867
|
| File | Purpose |
|
|
472
868
|
|------|---------|
|
|
473
|
-
| `core/
|
|
474
|
-
| `core/
|
|
475
|
-
| `core/
|
|
476
|
-
| `
|
|
869
|
+
| `packages/core/src/rooms/LiveRoom.ts` | LiveRoom base class |
|
|
870
|
+
| `packages/core/src/rooms/RoomRegistry.ts` | Room type → class mapping |
|
|
871
|
+
| `packages/core/src/rooms/LiveRoomManager.ts` | Room membership, broadcasting, lifecycle |
|
|
872
|
+
| `packages/core/src/component/managers/ComponentRoomProxy.ts` | `$room` proxy (typed + untyped) |
|
|
873
|
+
| `packages/core/src/server/LiveServer.ts` | Server-side join enforcement |
|
|
477
874
|
|
|
478
875
|
## Related
|
|
479
876
|
|
|
480
877
|
- [Live Components](./live-components.md) - Base component system
|
|
878
|
+
- [Live Auth](./live-auth.md) - Authentication for Live Components
|
|
481
879
|
- [Routes with Eden Treaty](./routes-eden.md) - HTTP API patterns
|
|
482
880
|
- [Type Safety](../patterns/type-safety.md) - TypeScript patterns
|