Skip to content

Commit dc75dd2

Browse files
MarcosBrendonDePaulaclaude
andcommitted
feat(live): add Room Event Bus for server-side pub/sub
- Add RoomEventBus for internal server-side events between components - Add RoomStateManager for typed in-memory room state storage - Add room event helpers to LiveComponent (onRoomEvent, emitRoomEvent) - Add typed broadcasts support with discriminated unions - Add LiveCounter demo component using Room Events - Add CounterDemo frontend component - Export new modules from core/server/live/index.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0acf43d commit dc75dd2

File tree

10 files changed

+847
-28
lines changed

10 files changed

+847
-28
lines changed

app/client/src/App.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { api } from './lib/eden-api'
33
import { FaFire, FaBook, FaGithub } from 'react-icons/fa'
44
import { LiveComponentsProvider } from '@/core/client'
55
import { FormDemo } from './live/FormDemo'
6+
import { CounterDemo } from './live/CounterDemo'
67

78
function AppContent() {
89
const [apiStatus, setApiStatus] = useState<'checking' | 'online' | 'offline'>('checking')
910
const [showForm, setShowForm] = useState(false)
11+
const [showCounter, setShowCounter] = useState(false)
1012
const [showApiTest, setShowApiTest] = useState(false)
1113
const [apiResponse, setApiResponse] = useState<string>('')
1214
const [isLoading, setIsLoading] = useState(false)
@@ -163,6 +165,23 @@ function AppContent() {
163165
)
164166
}
165167

168+
// Live Counter Demo
169+
if (showCounter) {
170+
return (
171+
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex flex-col items-center justify-center px-4">
172+
<div className="mb-8">
173+
<button
174+
onClick={() => setShowCounter(false)}
175+
className="px-4 py-2 bg-white/10 backdrop-blur-sm border border-white/20 text-white rounded-lg font-medium hover:bg-white/20 transition-all"
176+
>
177+
← Voltar
178+
</button>
179+
</div>
180+
<CounterDemo />
181+
</div>
182+
)
183+
}
184+
166185
return (
167186
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
168187
<div className="flex flex-col items-center justify-center min-h-screen px-6 text-center">
@@ -223,6 +242,12 @@ function AppContent() {
223242

224243
{/* Action Buttons */}
225244
<div className="flex flex-wrap gap-4 justify-center">
245+
<button
246+
onClick={() => setShowCounter(true)}
247+
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-emerald-500 to-teal-600 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-emerald-500/50 transition-all"
248+
>
249+
🔢 Live Counter
250+
</button>
226251
<button
227252
onClick={() => setShowForm(true)}
228253
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-600 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/50 transition-all"
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// 🔥 CounterDemo - Demonstração do Room Events
2+
// Contador compartilhado entre todos os clientes
3+
4+
import { Live } from '@/core/client'
5+
import { LiveCounter, defaultState } from '@server/live/LiveCounter'
6+
7+
export function CounterDemo() {
8+
const counter = Live.use(LiveCounter, {
9+
room: 'global-counter',
10+
initialState: defaultState
11+
})
12+
13+
const handleIncrement = async () => {
14+
await counter.increment()
15+
}
16+
17+
const handleDecrement = async () => {
18+
await counter.decrement()
19+
}
20+
21+
const handleReset = async () => {
22+
await counter.reset()
23+
}
24+
25+
return (
26+
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-8 max-w-md mx-auto">
27+
<h2 className="text-2xl font-bold text-white mb-2 text-center">
28+
Contador Compartilhado
29+
</h2>
30+
31+
<p className="text-gray-400 text-sm text-center mb-6">
32+
Abra em várias abas - todos veem o mesmo valor!
33+
</p>
34+
35+
{/* Status de conexão */}
36+
<div className="flex justify-center gap-4 mb-6">
37+
<div className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs ${
38+
counter.$connected
39+
? 'bg-emerald-500/20 text-emerald-300'
40+
: 'bg-red-500/20 text-red-300'
41+
}`}>
42+
<div className={`w-2 h-2 rounded-full ${
43+
counter.$connected ? 'bg-emerald-400' : 'bg-red-400'
44+
}`} />
45+
{counter.$connected ? 'Conectado' : 'Desconectado'}
46+
</div>
47+
48+
<div className="flex items-center gap-2 px-3 py-1 rounded-full text-xs bg-blue-500/20 text-blue-300">
49+
<span>👥</span>
50+
{counter.$state.connectedUsers} usuário(s)
51+
</div>
52+
</div>
53+
54+
{/* Contador */}
55+
<div className="text-center mb-8">
56+
<div className="text-8xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
57+
{counter.$state.count}
58+
</div>
59+
60+
{counter.$state.lastUpdatedBy && (
61+
<p className="text-gray-500 text-sm mt-2">
62+
Última atualização: {counter.$state.lastUpdatedBy}
63+
</p>
64+
)}
65+
</div>
66+
67+
{/* Botões */}
68+
<div className="flex gap-4 justify-center">
69+
<button
70+
onClick={handleDecrement}
71+
disabled={counter.$loading}
72+
className="w-14 h-14 flex items-center justify-center text-3xl bg-red-500/20 hover:bg-red-500/30 border border-red-500/30 text-red-300 rounded-xl transition-all disabled:opacity-50"
73+
>
74+
75+
</button>
76+
77+
<button
78+
onClick={handleReset}
79+
disabled={counter.$loading}
80+
className="px-6 h-14 flex items-center justify-center text-sm bg-gray-500/20 hover:bg-gray-500/30 border border-gray-500/30 text-gray-300 rounded-xl transition-all disabled:opacity-50"
81+
>
82+
Reset
83+
</button>
84+
85+
<button
86+
onClick={handleIncrement}
87+
disabled={counter.$loading}
88+
className="w-14 h-14 flex items-center justify-center text-3xl bg-emerald-500/20 hover:bg-emerald-500/30 border border-emerald-500/30 text-emerald-300 rounded-xl transition-all disabled:opacity-50"
89+
>
90+
+
91+
</button>
92+
</div>
93+
94+
{/* Loading indicator */}
95+
{counter.$loading && (
96+
<div className="flex justify-center mt-4">
97+
<div className="w-5 h-5 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
98+
</div>
99+
)}
100+
101+
{/* Info */}
102+
<div className="mt-8 pt-6 border-t border-white/10">
103+
<p className="text-gray-500 text-xs text-center">
104+
✨ Usando <code className="text-purple-400">Room Events</code> -
105+
sincronização server-side sem broadcast manual
106+
</p>
107+
</div>
108+
</div>
109+
)
110+
}

app/server/live/LiveCounter.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// 🔥 LiveCounter - Shared counter using Room Events
2+
3+
import { LiveComponent } from '@core/types/types'
4+
5+
export const defaultState = {
6+
count: 0,
7+
lastUpdatedBy: null as string | null,
8+
connectedUsers: 0
9+
}
10+
11+
export class LiveCounter extends LiveComponent<typeof defaultState> {
12+
static defaultState = defaultState
13+
protected roomType = 'counter'
14+
15+
constructor(initialState: Partial<typeof defaultState>, ws: any, options?: { room?: string; userId?: string }) {
16+
super({ ...defaultState, ...initialState }, ws, options)
17+
18+
this.onRoomEvent<{ count: number; userId: string }>('COUNT_CHANGED', (data) => {
19+
this.setState({ count: data.count, lastUpdatedBy: data.userId })
20+
})
21+
22+
this.onRoomEvent<{ connectedUsers: number }>('USER_COUNT_CHANGED', (data) => {
23+
this.setState({ connectedUsers: data.connectedUsers })
24+
})
25+
26+
this.notifyUserJoined()
27+
}
28+
29+
private notifyUserJoined() {
30+
const newCount = this.state.connectedUsers + 1
31+
this.emitRoomEventWithState('USER_COUNT_CHANGED', { connectedUsers: newCount }, { connectedUsers: newCount })
32+
}
33+
34+
async increment() {
35+
const newCount = this.state.count + 1
36+
this.emitRoomEventWithState('COUNT_CHANGED', { count: newCount, userId: this.userId || 'anonymous' }, {
37+
count: newCount,
38+
lastUpdatedBy: this.userId || 'anonymous'
39+
})
40+
return { success: true, count: newCount }
41+
}
42+
43+
async decrement() {
44+
const newCount = this.state.count - 1
45+
this.emitRoomEventWithState('COUNT_CHANGED', { count: newCount, userId: this.userId || 'anonymous' }, {
46+
count: newCount,
47+
lastUpdatedBy: this.userId || 'anonymous'
48+
})
49+
return { success: true, count: newCount }
50+
}
51+
52+
async reset() {
53+
this.emitRoomEventWithState('COUNT_CHANGED', { count: 0, userId: this.userId || 'anonymous' }, {
54+
count: 0,
55+
lastUpdatedBy: this.userId || 'anonymous'
56+
})
57+
return { success: true, count: 0 }
58+
}
59+
60+
destroy() {
61+
const newCount = Math.max(0, this.state.connectedUsers - 1)
62+
this.emitRoomEvent('USER_COUNT_CHANGED', { connectedUsers: newCount })
63+
super.destroy()
64+
}
65+
}

core/client/LiveComponentsProvider.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,25 @@ export function LiveComponentsProvider({
155155
return
156156
}
157157

158+
// Broadcast messages should go to ALL components (not just sender)
159+
if (response.type === 'BROADCAST') {
160+
// Send to all registered components in the same room
161+
const registeredComponents = Array.from(componentCallbacksRef.current.keys())
162+
log('📡 Broadcast routing:', {
163+
sender: response.componentId,
164+
registeredComponents,
165+
totalRegistered: registeredComponents.length
166+
})
167+
168+
componentCallbacksRef.current.forEach((callback, compId) => {
169+
// Don't send back to the sender component
170+
if (compId !== response.componentId) {
171+
callback(response)
172+
}
173+
})
174+
return
175+
}
176+
158177
// Route message to specific component
159178
if (response.componentId) {
160179
const callback = componentCallbacksRef.current.get(response.componentId)
@@ -165,14 +184,6 @@ export function LiveComponentsProvider({
165184
}
166185
}
167186

168-
// Broadcast messages (no specific componentId)
169-
if (response.type === 'BROADCAST' && !response.componentId) {
170-
// Send to all registered components
171-
componentCallbacksRef.current.forEach(callback => {
172-
callback(response)
173-
})
174-
}
175-
176187
} catch (error) {
177188
log('❌ Failed to parse message', error)
178189
setError('Failed to parse message')

core/client/components/Live.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,31 @@
1414
// <input {...form.$field('name', { syncOn: 'blur' })} />
1515
// <button onClick={() => form.submit()}>Enviar</button>
1616
// )
17+
//
18+
// 🔥 Broadcasts Tipados (Discriminated Union):
19+
// // No servidor, defina a interface de broadcasts:
20+
// export interface LiveFormBroadcasts {
21+
// FORM_SUBMITTED: { formId: string; data: any }
22+
// FIELD_CHANGED: { field: string; value: any }
23+
// }
24+
//
25+
// // No cliente, use com tipagem automática (discriminated union):
26+
// import { LiveForm, type LiveFormBroadcasts } from '@server/live/LiveForm'
27+
//
28+
// const form = Live.use(LiveForm)
29+
// form.$onBroadcast<LiveFormBroadcasts>((event) => {
30+
// switch (event.type) {
31+
// case 'FORM_SUBMITTED':
32+
// console.log(event.data.formId) // ✅ Tipado como string!
33+
// break
34+
// case 'FIELD_CHANGED':
35+
// console.log(event.data.field) // ✅ Tipado como string!
36+
// break
37+
// }
38+
// })
1739

1840
import { useLiveComponent } from '../hooks/useLiveComponent'
19-
import type { UseLiveComponentOptions, LiveProxy } from '../hooks/useLiveComponent'
41+
import type { UseLiveComponentOptions, LiveProxy, LiveProxyWithBroadcasts } from '../hooks/useLiveComponent'
2042

2143
// ===== Tipos para Inferência do Servidor =====
2244

@@ -42,23 +64,33 @@ type ExtractActions<T> = T extends { new(...args: any[]): infer Instance }
4264
}
4365
: Record<string, never>
4466

67+
// ===== Opções do Live.use() =====
68+
69+
interface LiveUseOptions<TState> extends UseLiveComponentOptions {
70+
/** Estado inicial para o componente */
71+
initialState?: Partial<TState>
72+
}
73+
4574
// ===== Hook Principal =====
4675

47-
function useLive<T extends { new(...args: any[]): any; defaultState?: Record<string, any> }>(
76+
function useLive<
77+
T extends { new(...args: any[]): any; defaultState?: Record<string, any> },
78+
TBroadcasts extends Record<string, any> = Record<string, any>
79+
>(
4880
ComponentClass: T,
49-
initialState?: Partial<ExtractState<T>>,
50-
options?: UseLiveComponentOptions
51-
): LiveProxy<ExtractState<T>, ExtractActions<T>> {
81+
options?: LiveUseOptions<ExtractState<T>>
82+
): LiveProxyWithBroadcasts<ExtractState<T>, ExtractActions<T>, TBroadcasts> {
5283
const componentName = ComponentClass.name.replace(/Component$/, '')
5384

5485
// Usa defaultState da classe se não passar initialState
5586
const defaultState = (ComponentClass as any).defaultState || {}
87+
const { initialState, ...restOptions } = options || {}
5688
const mergedState = { ...defaultState, ...initialState } as ExtractState<T>
5789

58-
return useLiveComponent<ExtractState<T>, ExtractActions<T>>(
90+
return useLiveComponent<ExtractState<T>, ExtractActions<T>, TBroadcasts>(
5991
componentName,
6092
mergedState,
61-
options
93+
restOptions
6294
)
6395
}
6496

0 commit comments

Comments
 (0)