create-fluxstack 1.20.0 → 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.
@@ -1,6 +1,6 @@
1
1
  # Live Room System
2
2
 
3
- **Version:** 0.7.2 | **Updated:** 2026-04-14
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 external systems (webhooks, bots)
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
- if (ctx.payload?.password !== this.meta.password) {
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 // 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)
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
- // Emit typed events
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' 'chat:lobby'
226
- ChatRoom + 'vip' 'chat:vip'
227
- GameRoom + 'match1' 'game:match1'
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 registered as 'chat'
239
- DirectoryRoom.ts registered as 'directory'
240
- GameRoom.ts registered as 'game'
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 no password
255
- if (this.meta.password && ctx.payload?.password !== this.meta.password) {
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 // Server-only
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 !== this.meta.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: 'Senha incorreta' }
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('Senha incorreta')
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>, ws: FluxStackWebSocket, options?: { room?: string; userId?: string }) {
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.state.activeRoom = payload.roomId
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: 'Senha incorreta' }
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
- super.destroy()
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 messages from external systems via REST API:
774
-
775
- ```bash
776
- # Send message to room
777
- curl -X POST http://localhost:3000/api/rooms/geral/messages \
778
- -H "Content-Type: application/json" \
779
- -d '{"user": "Webhook Bot", "text": "New deployment completed!"}'
780
-
781
- # Emit custom event
782
- curl -X POST http://localhost:3000/api/rooms/tech/emit \
783
- -H "Content-Type: application/json" \
784
- -d '{"event": "notification", "data": {"type": "alert"}}'
785
-
786
- # Get room stats
787
- curl http://localhost:3000/api/rooms/stats
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 { liveRoomManager } from '@core/server/live/LiveRoomManager'
798
-
799
- // Membership
800
- liveRoomManager.joinRoom(componentId, roomId, ws, initialState, options, joinContext)
801
- liveRoomManager.leaveRoom(componentId, roomId, leaveReason)
802
- liveRoomManager.cleanupComponent(componentId)
803
-
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()
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 '@/core/server'
254
-
255
- export class LiveCounter extends LiveComponent<{
256
- count: number
257
- }> {
258
- static defaultState = { count: 0 }
259
-
260
- async increment() {
261
- this.state.count++ // auto-syncs via Proxy
262
- return { success: true }
263
- }
264
-
265
- async decrement() {
266
- this.state.count--
267
- return { success: true }
268
- }
269
-
270
- async reset() {
271
- this.state.count = 0
272
- return { success: true }
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
  }
@@ -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
- import { UploadDemo } from './live/UploadDemo'
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
- note={<>? Este formul?rio usa <code className="text-theme">Live.use()</code> - cada campo sincroniza automaticamente com o servidor!</>}
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
- note={<>Contador compartilhado usando <code className="text-theme">LiveRoom</code> - abra em varias abas!</>}
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>