create-fluxstack 1.14.0 → 1.15.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.
Files changed (62) hide show
  1. package/LLMD/resources/live-components.md +207 -12
  2. package/app/client/.live-stubs/LiveAdminPanel.js +5 -0
  3. package/app/client/.live-stubs/LiveChat.js +7 -0
  4. package/app/client/.live-stubs/LiveCounter.js +9 -0
  5. package/app/client/.live-stubs/LiveForm.js +11 -0
  6. package/app/client/.live-stubs/LiveLocalCounter.js +8 -0
  7. package/app/client/.live-stubs/LiveRoomChat.js +10 -0
  8. package/app/client/.live-stubs/LiveTodoList.js +9 -0
  9. package/app/client/.live-stubs/LiveUpload.js +15 -0
  10. package/app/client/src/App.tsx +11 -0
  11. package/app/client/src/components/AppLayout.tsx +16 -8
  12. package/app/client/src/live/LiveDebuggerPanel.tsx +1 -1
  13. package/app/client/src/live/TodoListDemo.tsx +158 -0
  14. package/app/server/auth/DevAuthProvider.ts +2 -2
  15. package/app/server/auth/JWTAuthProvider.example.ts +2 -2
  16. package/app/server/index.ts +2 -2
  17. package/app/server/live/LiveAdminPanel.ts +1 -1
  18. package/app/server/live/LiveProtectedChat.ts +1 -1
  19. package/app/server/live/LiveTodoList.ts +110 -0
  20. package/app/server/routes/room.routes.ts +1 -2
  21. package/core/build/live-components-generator.ts +1 -1
  22. package/core/build/vite-plugins.ts +28 -0
  23. package/core/client/components/LiveDebugger.tsx +1 -1
  24. package/core/client/hooks/useLiveUpload.ts +3 -4
  25. package/core/client/index.ts +37 -31
  26. package/core/framework/server.ts +1 -1
  27. package/core/server/index.ts +1 -2
  28. package/core/server/live/auto-generated-components.ts +6 -3
  29. package/core/server/live/index.ts +95 -21
  30. package/core/server/live/websocket-plugin.ts +27 -1087
  31. package/core/types/types.ts +76 -1025
  32. package/core/utils/version.ts +1 -1
  33. package/create-fluxstack.ts +1 -1
  34. package/package.json +5 -1
  35. package/plugins/crypto-auth/index.ts +1 -1
  36. package/plugins/crypto-auth/server/CryptoAuthLiveProvider.ts +2 -2
  37. package/vite.config.ts +40 -12
  38. package/core/client/LiveComponentsProvider.tsx +0 -531
  39. package/core/client/components/Live.tsx +0 -111
  40. package/core/client/hooks/AdaptiveChunkSizer.ts +0 -215
  41. package/core/client/hooks/state-validator.ts +0 -130
  42. package/core/client/hooks/useChunkedUpload.ts +0 -359
  43. package/core/client/hooks/useLiveChunkedUpload.ts +0 -87
  44. package/core/client/hooks/useLiveComponent.ts +0 -853
  45. package/core/client/hooks/useLiveDebugger.ts +0 -392
  46. package/core/client/hooks/useRoom.ts +0 -409
  47. package/core/client/hooks/useRoomProxy.ts +0 -382
  48. package/core/server/live/ComponentRegistry.ts +0 -1128
  49. package/core/server/live/FileUploadManager.ts +0 -446
  50. package/core/server/live/LiveComponentPerformanceMonitor.ts +0 -931
  51. package/core/server/live/LiveDebugger.ts +0 -462
  52. package/core/server/live/LiveLogger.ts +0 -144
  53. package/core/server/live/LiveRoomManager.ts +0 -278
  54. package/core/server/live/RoomEventBus.ts +0 -234
  55. package/core/server/live/RoomStateManager.ts +0 -172
  56. package/core/server/live/SingleConnectionManager.ts +0 -0
  57. package/core/server/live/StateSignature.ts +0 -705
  58. package/core/server/live/WebSocketConnectionManager.ts +0 -710
  59. package/core/server/live/auth/LiveAuthContext.ts +0 -71
  60. package/core/server/live/auth/LiveAuthManager.ts +0 -304
  61. package/core/server/live/auth/index.ts +0 -19
  62. package/core/server/live/auth/types.ts +0 -179
@@ -1,853 +0,0 @@
1
- // 🔥 FluxStack Live Component Hook - Proxy-based State Access
2
- // Acesse estado do servidor como se fossem variáveis locais (estilo Livewire)
3
- //
4
- // Uso:
5
- // const clock = useLiveComponent('LiveClock', { currentTime: '', format: '24h' })
6
- //
7
- // // Lê estado como variável normal
8
- // console.log(clock.currentTime) // "14:30:25"
9
- //
10
- // // Escreve estado - sincroniza automaticamente com servidor
11
- // clock.format = '12h'
12
- //
13
- // // Chama actions diretamente
14
- // await clock.setTimeFormat({ format: '24h' })
15
- //
16
- // // Metadata via $ prefix
17
- // clock.$connected // boolean
18
- // clock.$loading // boolean
19
- // clock.$error // string | null
20
-
21
- import { useRef, useMemo, useState, useEffect, useCallback } from 'react'
22
- import { create } from 'zustand'
23
- import { subscribeWithSelector } from 'zustand/middleware'
24
- import { useLiveComponents } from '../LiveComponentsProvider'
25
- import { StateValidator } from './state-validator'
26
- import { RoomManager } from './useRoomProxy'
27
- import type { RoomProxy, RoomServerMessage } from './useRoomProxy'
28
- import type {
29
- HybridState,
30
- HybridComponentOptions,
31
- WebSocketMessage,
32
- WebSocketResponse
33
- } from '@core/types/types'
34
-
35
- // ===== Tipos =====
36
-
37
- // Opções para $field()
38
- export interface FieldOptions {
39
- /** Quando sincronizar: 'change' (debounced), 'blur' (ao sair), 'manual' (só $sync) */
40
- syncOn?: 'change' | 'blur' | 'manual'
41
- /** Debounce em ms (só para syncOn: 'change'). Default: 150 */
42
- debounce?: number
43
- /** Transformar valor antes de sincronizar */
44
- transform?: (value: any) => any
45
- }
46
-
47
- // Retorno do $field()
48
- export interface FieldBinding {
49
- value: any
50
- onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
51
- onBlur: () => void
52
- name: string
53
- }
54
-
55
- export interface LiveComponentProxy<
56
- TState extends Record<string, any>,
57
- TRoomState = any,
58
- TRoomEvents extends Record<string, any> = Record<string, any>
59
- > {
60
- // Propriedades de estado são acessadas diretamente: proxy.propertyName
61
-
62
- // Metadata ($ prefix)
63
- readonly $state: TState
64
- readonly $connected: boolean
65
- readonly $loading: boolean
66
- readonly $error: string | null
67
- readonly $status: 'synced' | 'disconnected' | 'connecting' | 'reconnecting' | 'loading' | 'mounting' | 'error'
68
- readonly $componentId: string | null
69
- readonly $dirty: boolean
70
- /** Whether the WebSocket connection is authenticated on the server */
71
- readonly $authenticated: boolean
72
-
73
- // Methods
74
- $call: (action: string, payload?: any) => Promise<void>
75
- $callAndWait: <R = any>(action: string, payload?: any, timeout?: number) => Promise<R>
76
- $mount: () => Promise<void>
77
- $unmount: () => Promise<void>
78
- $refresh: () => Promise<void>
79
- $set: <K extends keyof TState>(key: K, value: TState[K]) => Promise<void>
80
-
81
- /** Bind de campo com controle de sincronização */
82
- $field: <K extends keyof TState>(key: K, options?: FieldOptions) => FieldBinding
83
-
84
- /** Sincroniza todos os campos pendentes (para syncOn: 'manual') */
85
- $sync: () => Promise<void>
86
-
87
- /** Registra handler para broadcasts recebidos de outros usuários (sem tipagem) */
88
- $onBroadcast: (handler: (type: string, data: any) => void) => void
89
-
90
- /** Atualiza estado local diretamente (para processar broadcasts) */
91
- $updateLocal: (updates: Partial<TState>) => void
92
-
93
- /**
94
- * Sistema de salas - acessa sala padrão ou específica
95
- * @example
96
- * // Sala padrão (definida em options.room)
97
- * component.$room.emit('typing', { user: 'João' })
98
- * component.$room.on('message:new', handler)
99
- *
100
- * // Sala específica
101
- * component.$room('sala-vip').join()
102
- * component.$room('sala-vip').emit('typing', { user: 'João' })
103
- * component.$room('sala-vip').leave()
104
- */
105
- readonly $room: RoomProxy<TRoomState, TRoomEvents>
106
-
107
- /** Lista de IDs das salas que está participando */
108
- readonly $rooms: string[]
109
- }
110
-
111
- // Helper type para criar union de broadcasts
112
- type BroadcastEvent<T extends Record<string, any>> = {
113
- [K in keyof T]: { type: K; data: T[K] }
114
- }[keyof T]
115
-
116
- // Proxy com broadcasts tipados
117
- export interface LiveComponentProxyWithBroadcasts<
118
- TState extends Record<string, any>,
119
- TBroadcasts extends Record<string, any> = Record<string, any>,
120
- TRoomState = any,
121
- TRoomEvents extends Record<string, any> = Record<string, any>
122
- > extends Omit<LiveComponentProxy<TState, TRoomState, TRoomEvents>, '$onBroadcast'> {
123
- /**
124
- * Registra handler para broadcasts tipados
125
- * @example
126
- * // Uso com tipagem:
127
- * chat.$onBroadcast<LiveChatBroadcasts>((event) => {
128
- * if (event.type === 'NEW_MESSAGE') {
129
- * console.log(event.data.message) // ✅ Tipado como ChatMessage
130
- * }
131
- * })
132
- */
133
- $onBroadcast: <T extends TBroadcasts = TBroadcasts>(
134
- handler: (event: BroadcastEvent<T>) => void
135
- ) => void
136
- }
137
-
138
- // Actions são qualquer método que não existe no state
139
- export type LiveProxy<
140
- TState extends Record<string, any>,
141
- TActions = {},
142
- TRoomState = any,
143
- TRoomEvents extends Record<string, any> = Record<string, any>
144
- > = TState & LiveComponentProxy<TState, TRoomState, TRoomEvents> & TActions
145
-
146
- // Proxy com broadcasts tipados
147
- export type LiveProxyWithBroadcasts<
148
- TState extends Record<string, any>,
149
- TActions = {},
150
- TBroadcasts extends Record<string, any> = Record<string, any>,
151
- TRoomState = any,
152
- TRoomEvents extends Record<string, any> = Record<string, any>
153
- > = TState & LiveComponentProxyWithBroadcasts<TState, TBroadcasts, TRoomState, TRoomEvents> & TActions
154
-
155
- export interface UseLiveComponentOptions extends HybridComponentOptions {
156
- /** Debounce para sets (ms). Default: 150 */
157
- debounce?: number
158
- /** Atualização otimista (UI atualiza antes do servidor confirmar). Default: true */
159
- optimistic?: boolean
160
- /** Modo de sync: 'immediate' | 'debounced' | 'manual'. Default: 'debounced' */
161
- syncMode?: 'immediate' | 'debounced' | 'manual'
162
- /** Persistir estado em localStorage (rehydration). Default: true */
163
- persistState?: boolean
164
- /**
165
- * Label de debug para identificar esta instância no Live Debugger.
166
- * Aparece no lugar do componentId no painel de debug.
167
- * Só tem efeito em development.
168
- *
169
- * @example
170
- * Live.use(LiveCounter, { debugLabel: 'Header Counter' })
171
- * Live.use(LiveChat, { debugLabel: 'Main Chat' })
172
- */
173
- debugLabel?: string
174
- }
175
-
176
- // ===== Propriedades Reservadas =====
177
-
178
- const RESERVED_PROPS = new Set([
179
- '$state', '$connected', '$loading', '$error', '$status', '$componentId', '$dirty', '$authenticated',
180
- '$call', '$callAndWait', '$mount', '$unmount', '$refresh', '$set', '$onBroadcast', '$updateLocal',
181
- '$room', '$rooms', '$field', '$sync',
182
- 'then', 'toJSON', 'valueOf', 'toString',
183
- Symbol.toStringTag, Symbol.iterator,
184
- ])
185
-
186
- // ===== Persistência de Estado =====
187
-
188
- const STORAGE_KEY_PREFIX = 'fluxstack_component_'
189
- const STATE_MAX_AGE = 24 * 60 * 60 * 1000
190
-
191
- interface PersistedState {
192
- componentName: string
193
- signedState: any
194
- room?: string
195
- userId?: string
196
- lastUpdate: number
197
- }
198
-
199
- const persistState = (enabled: boolean, name: string, signedState: any, room?: string, userId?: string) => {
200
- if (!enabled) return
201
- try {
202
- localStorage.setItem(`${STORAGE_KEY_PREFIX}${name}`, JSON.stringify({
203
- componentName: name, signedState, room, userId, lastUpdate: Date.now()
204
- }))
205
- } catch {}
206
- }
207
-
208
- const getPersistedState = (enabled: boolean, name: string): PersistedState | null => {
209
- if (!enabled) return null
210
- try {
211
- const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${name}`)
212
- if (!stored) return null
213
- const state: PersistedState = JSON.parse(stored)
214
- if (Date.now() - state.lastUpdate > STATE_MAX_AGE) {
215
- localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`)
216
- return null
217
- }
218
- return state
219
- } catch { return null }
220
- }
221
-
222
- const clearPersistedState = (enabled: boolean, name: string) => {
223
- if (!enabled) return
224
- try { localStorage.removeItem(`${STORAGE_KEY_PREFIX}${name}`) } catch {}
225
- }
226
-
227
- // ===== Zustand Store =====
228
-
229
- interface Store<T> {
230
- state: T
231
- status: 'synced' | 'disconnected'
232
- updateState: (newState: T) => void
233
- }
234
-
235
- function createStore<T>(initialState: T) {
236
- return create<Store<T>>()(
237
- subscribeWithSelector((set) => ({
238
- state: initialState,
239
- status: 'disconnected',
240
- updateState: (newState: T) => set({ state: newState, status: 'synced' })
241
- }))
242
- )
243
- }
244
-
245
- // ===== Hook Principal =====
246
-
247
- export function useLiveComponent<
248
- TState extends Record<string, any>,
249
- TActions = {},
250
- TBroadcasts extends Record<string, any> = Record<string, any>
251
- >(
252
- componentName: string,
253
- initialState: TState,
254
- options: UseLiveComponentOptions = {}
255
- ): LiveProxyWithBroadcasts<TState, TActions, TBroadcasts> {
256
- const {
257
- debounce = 150,
258
- optimistic = true,
259
- syncMode = 'debounced',
260
- persistState: persistEnabled = true,
261
- fallbackToLocal = true,
262
- room,
263
- userId,
264
- autoMount = true,
265
- debug = false,
266
- onConnect,
267
- onMount,
268
- onDisconnect,
269
- onRehydrate,
270
- onError,
271
- onStateChange
272
- } = options
273
-
274
- // WebSocket context
275
- const {
276
- connected,
277
- authenticated: wsAuthenticated,
278
- sendMessage,
279
- sendMessageAndWait,
280
- registerComponent,
281
- unregisterComponent
282
- } = useLiveComponents()
283
-
284
- // Refs
285
- const instanceId = useRef(`${componentName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`)
286
- const storeRef = useRef<ReturnType<typeof createStore<TState>> | null>(null)
287
- if (!storeRef.current) storeRef.current = createStore(initialState)
288
- const store = storeRef.current
289
-
290
- const pendingChanges = useRef<Map<keyof TState, { value: any; synced: boolean }>>(new Map())
291
- const debounceTimers = useRef<Map<keyof TState, NodeJS.Timeout>>(new Map())
292
- const localFieldValues = useRef<Map<keyof TState, any>>(new Map()) // Valores locais para campos com syncOn: blur/manual
293
- const fieldOptions = useRef<Map<keyof TState, FieldOptions>>(new Map()) // Opções por campo
294
- const [localVersion, setLocalVersion] = useState(0) // Força re-render quando valores locais mudam
295
- const mountedRef = useRef(false)
296
- const mountingRef = useRef(false)
297
- const rehydratingRef = useRef(false) // Previne múltiplas tentativas de rehydrate
298
- const lastComponentIdRef = useRef<string | null>(null)
299
- const broadcastHandlerRef = useRef<((event: { type: string; data: any }) => void) | null>(null)
300
- const roomMessageHandlers = useRef<Set<(msg: RoomServerMessage) => void>>(new Set())
301
- const roomManagerRef = useRef<RoomManager | null>(null)
302
- const mountFnRef = useRef<(() => Promise<void>) | null>(null)
303
-
304
- // State
305
- const stateData = store((s) => s.state)
306
- const updateState = store((s) => s.updateState)
307
- const [componentId, setComponentId] = useState<string | null>(null)
308
- const [loading, setLoading] = useState(false)
309
- const [error, setError] = useState<string | null>(null)
310
- const [rehydrating, setRehydrating] = useState(false)
311
- const [mountFailed, setMountFailed] = useState(false) // Previne loop infinito de mount
312
- const [authDenied, setAuthDenied] = useState(false) // Track if mount failed due to AUTH_DENIED
313
-
314
- const log = useCallback((msg: string, data?: any) => {
315
- if (debug) console.log(`[${componentName}] ${msg}`, data || '')
316
- }, [debug, componentName])
317
-
318
- // ===== Set Property =====
319
- const setProperty = useCallback(async <K extends keyof TState>(key: K, value: TState[K]) => {
320
- // Clear existing timer
321
- const timer = debounceTimers.current.get(key)
322
- if (timer) clearTimeout(timer)
323
-
324
- // Track pending
325
- pendingChanges.current.set(key, { value, synced: false })
326
-
327
- const doSync = async () => {
328
- try {
329
- const id = componentId || lastComponentIdRef.current
330
- if (!id || !connected) return
331
-
332
- await sendMessageAndWait({
333
- type: 'CALL_ACTION',
334
- componentId: id,
335
- action: 'setValue',
336
- payload: { key, value }
337
- }, 5000)
338
-
339
- pendingChanges.current.get(key)!.synced = true
340
- } catch (err: any) {
341
- pendingChanges.current.delete(key)
342
- setError(err.message)
343
- }
344
- }
345
-
346
- if (syncMode === 'immediate') {
347
- await doSync()
348
- } else if (syncMode === 'debounced') {
349
- debounceTimers.current.set(key, setTimeout(doSync, debounce))
350
- }
351
- }, [componentId, connected, sendMessageAndWait, debounce, syncMode])
352
-
353
- // ===== Mount =====
354
- const mount = useCallback(async () => {
355
- // Usa refs para prevenir chamadas duplicadas (React StrictMode)
356
- if (!connected || mountedRef.current || mountingRef.current || rehydratingRef.current || mountFailed) return
357
-
358
- mountingRef.current = true
359
- setLoading(true)
360
- setError(null)
361
-
362
- try {
363
- const response = await sendMessageAndWait({
364
- type: 'COMPONENT_MOUNT',
365
- componentId: instanceId.current,
366
- payload: { component: componentName, props: initialState, room, userId, debugLabel: options.debugLabel }
367
- }, 5000)
368
-
369
- if (response?.success && response?.result?.componentId) {
370
- const newId = response.result.componentId
371
- setComponentId(newId)
372
- lastComponentIdRef.current = newId
373
- mountedRef.current = true
374
-
375
- if (response.result.signedState) {
376
- persistState(persistEnabled, componentName, response.result.signedState, room, userId)
377
- }
378
- if (response.result.initialState) {
379
- updateState(response.result.initialState)
380
- }
381
-
382
- log('Mounted', newId)
383
- setTimeout(() => onMount?.(), 0)
384
- } else {
385
- throw new Error(response?.error || 'Mount failed')
386
- }
387
- } catch (err: any) {
388
- setError(err.message)
389
- // Track if auth was the reason for failure
390
- if (err.message?.includes('AUTH_DENIED')) {
391
- setAuthDenied(true)
392
- }
393
- setMountFailed(true) // Previne loop infinito para TODOS os erros
394
- onError?.(err.message)
395
- if (!fallbackToLocal) throw err
396
- } finally {
397
- setLoading(false)
398
- mountingRef.current = false
399
- }
400
- }, [connected, componentName, initialState, room, userId, sendMessageAndWait, updateState, log, onMount, onError, fallbackToLocal, mountFailed])
401
-
402
- // Keep mount function ref updated
403
- mountFnRef.current = mount
404
-
405
- // ===== Unmount =====
406
- const unmount = useCallback(async () => {
407
- if (!componentId || !connected) return
408
- try {
409
- await sendMessage({ type: 'COMPONENT_UNMOUNT', componentId })
410
- setComponentId(null)
411
- mountedRef.current = false
412
- } catch {}
413
- }, [componentId, connected, sendMessage])
414
-
415
- // ===== Rehydrate =====
416
- const rehydrate = useCallback(async () => {
417
- // Usa ref para prevenir chamadas duplicadas (React StrictMode)
418
- if (!connected || rehydratingRef.current || mountingRef.current || mountedRef.current) return false
419
-
420
- const persisted = getPersistedState(persistEnabled, componentName)
421
- if (!persisted) return false
422
-
423
- // Skip if too old (> 1 hour)
424
- if (Date.now() - persisted.lastUpdate > 60 * 60 * 1000) {
425
- clearPersistedState(persistEnabled, componentName)
426
- return false
427
- }
428
-
429
- rehydratingRef.current = true
430
- setRehydrating(true)
431
- try {
432
- const response = await sendMessageAndWait({
433
- type: 'COMPONENT_REHYDRATE',
434
- componentId: lastComponentIdRef.current || instanceId.current,
435
- payload: {
436
- componentName,
437
- signedState: persisted.signedState,
438
- room: persisted.room,
439
- userId: persisted.userId
440
- }
441
- }, 2000)
442
-
443
- if (response?.success && response?.result?.newComponentId) {
444
- setComponentId(response.result.newComponentId)
445
- lastComponentIdRef.current = response.result.newComponentId
446
- mountedRef.current = true
447
- setTimeout(() => onRehydrate?.(), 0)
448
- return true
449
- }
450
- clearPersistedState(persistEnabled, componentName)
451
- return false
452
- } catch {
453
- clearPersistedState(persistEnabled, componentName)
454
- return false
455
- } finally {
456
- rehydratingRef.current = false
457
- setRehydrating(false)
458
- }
459
- }, [connected, componentName, sendMessageAndWait, onRehydrate])
460
-
461
- // ===== Call Action =====
462
- const call = useCallback(async (action: string, payload?: any) => {
463
- const id = componentId || lastComponentIdRef.current
464
- if (!id || !connected) throw new Error('Not connected')
465
-
466
- const response = await sendMessageAndWait({
467
- type: 'CALL_ACTION',
468
- componentId: id,
469
- action,
470
- payload
471
- }, 5000)
472
-
473
- if (!response.success) throw new Error(response.error || 'Action failed')
474
- }, [componentId, connected, sendMessageAndWait])
475
-
476
- const callAndWait = useCallback(async <R = any>(action: string, payload?: any, timeout = 10000): Promise<R> => {
477
- const id = componentId || lastComponentIdRef.current
478
- if (!id || !connected) throw new Error('Not connected')
479
-
480
- const response = await sendMessageAndWait({
481
- type: 'CALL_ACTION',
482
- componentId: id,
483
- action,
484
- payload
485
- }, timeout)
486
-
487
- return response as R
488
- }, [componentId, connected, sendMessageAndWait])
489
-
490
- // ===== Refresh =====
491
- const refresh = useCallback(async () => {
492
- for (const [key, change] of pendingChanges.current) {
493
- if (!change.synced) {
494
- await setProperty(key, change.value)
495
- }
496
- }
497
- }, [setProperty])
498
-
499
- // ===== Sync (para campos com syncOn: manual) =====
500
- const sync = useCallback(async () => {
501
- const promises: Promise<void>[] = []
502
-
503
- for (const [key, value] of localFieldValues.current) {
504
- const currentServerValue = stateData[key]
505
- if (value !== currentServerValue) {
506
- promises.push(setProperty(key, value))
507
- }
508
- }
509
-
510
- await Promise.all(promises)
511
- }, [stateData, setProperty])
512
-
513
- // ===== Field Binding =====
514
- const createFieldBinding = useCallback(<K extends keyof TState>(
515
- key: K,
516
- options: FieldOptions = {}
517
- ): FieldBinding => {
518
- const {
519
- syncOn = 'change',
520
- debounce: fieldDebounce = debounce,
521
- transform
522
- } = options
523
-
524
- // Salvar opções do campo
525
- fieldOptions.current.set(key, options)
526
-
527
- // Valor atual: local (se existir) ou do servidor
528
- const currentValue = localFieldValues.current.has(key)
529
- ? localFieldValues.current.get(key)
530
- : stateData[key]
531
-
532
- return {
533
- name: String(key),
534
- value: currentValue ?? '',
535
-
536
- onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
537
- let value: any = e.target.value
538
-
539
- // Checkbox support
540
- if (e.target.type === 'checkbox') {
541
- value = (e.target as HTMLInputElement).checked
542
- }
543
-
544
- // Transform
545
- if (transform) {
546
- value = transform(value)
547
- }
548
-
549
- // Sempre salvar localmente primeiro (para UI responsiva)
550
- localFieldValues.current.set(key, value)
551
-
552
- // Forçar re-render
553
- setLocalVersion(v => v + 1)
554
- pendingChanges.current.set(key, { value, synced: false })
555
-
556
- if (syncOn === 'change') {
557
- // Debounced sync
558
- const timer = debounceTimers.current.get(key)
559
- if (timer) clearTimeout(timer)
560
-
561
- debounceTimers.current.set(key, setTimeout(async () => {
562
- await setProperty(key, value)
563
- localFieldValues.current.delete(key) // Limpar valor local após sync
564
- }, fieldDebounce))
565
- }
566
- // blur e manual: não faz nada aqui, espera onBlur ou $sync()
567
- },
568
-
569
- onBlur: () => {
570
- if (syncOn === 'blur') {
571
- const value = localFieldValues.current.get(key)
572
- if (value !== undefined && value !== stateData[key]) {
573
- setProperty(key, value).then(() => {
574
- localFieldValues.current.delete(key)
575
- })
576
- }
577
- }
578
- }
579
- }
580
- }, [stateData, debounce, setProperty, localVersion])
581
-
582
- // ===== Register with WebSocket =====
583
- useEffect(() => {
584
- if (!componentId) return
585
-
586
- const unregister = registerComponent(componentId, (message: WebSocketResponse) => {
587
- switch (message.type) {
588
- case 'STATE_UPDATE':
589
- if (message.payload?.state) {
590
- const oldState = stateData
591
- updateState(message.payload.state)
592
- onStateChange?.(message.payload.state, oldState)
593
- if (message.payload?.signedState) {
594
- persistState(persistEnabled, componentName, message.payload.signedState, room, userId)
595
- }
596
- }
597
- break
598
- case 'STATE_DELTA':
599
- if (message.payload?.delta) {
600
- const oldState = storeRef.current?.getState().state ?? stateData
601
- const mergedState = { ...oldState, ...message.payload.delta } as TState
602
- updateState(mergedState)
603
- onStateChange?.(mergedState, oldState)
604
- }
605
- break
606
- case 'STATE_REHYDRATED':
607
- if (message.payload?.state && message.payload?.newComponentId) {
608
- setComponentId(message.payload.newComponentId)
609
- lastComponentIdRef.current = message.payload.newComponentId
610
- updateState(message.payload.state)
611
- setRehydrating(false)
612
- onRehydrate?.()
613
- }
614
- break
615
- case 'BROADCAST':
616
- // Handle broadcast messages from other users in the same room
617
- if (message.payload?.type) {
618
- // Emit broadcast event for component to handle (as { type, data } object)
619
- broadcastHandlerRef.current?.({ type: message.payload.type, data: message.payload.data })
620
- }
621
- break
622
- case 'ERROR':
623
- setError(message.payload?.error || 'Unknown error')
624
- onError?.(message.payload?.error)
625
- break
626
-
627
- // Room system messages
628
- case 'ROOM_EVENT':
629
- case 'ROOM_STATE':
630
- case 'ROOM_SYSTEM':
631
- case 'ROOM_JOINED':
632
- case 'ROOM_LEFT':
633
- // Forward to room handlers
634
- for (const handler of roomMessageHandlers.current) {
635
- handler(message as unknown as RoomServerMessage)
636
- }
637
- break
638
- }
639
- })
640
-
641
- return () => unregister()
642
- }, [componentId, registerComponent, updateState, stateData, componentName, room, userId, onStateChange, onRehydrate, onError])
643
-
644
- // ===== Auto Mount =====
645
- useEffect(() => {
646
- if (connected && autoMount && !mountedRef.current && !componentId && !mountingRef.current && !rehydrating && !mountFailed) {
647
- rehydrate().then(ok => {
648
- if (!ok && !mountedRef.current && !mountFailed) mount()
649
- })
650
- }
651
- }, [connected, autoMount, mount, componentId, rehydrating, rehydrate, mountFailed])
652
-
653
- // ===== Auto Re-mount on Auth Change =====
654
- // When auth changes from false to true and component failed due to AUTH_DENIED, retry mount
655
- const prevAuthRef = useRef(wsAuthenticated)
656
- useEffect(() => {
657
- const wasNotAuthenticated = !prevAuthRef.current
658
- const isNowAuthenticated = wsAuthenticated
659
- prevAuthRef.current = wsAuthenticated
660
-
661
- // Only retry if: auth changed from false→true AND we had an auth denial
662
- if (wasNotAuthenticated && isNowAuthenticated && authDenied) {
663
- log('Auth changed to authenticated, retrying mount...')
664
- // Reset flags to allow retry
665
- setAuthDenied(false)
666
- setMountFailed(false)
667
- setError(null)
668
- mountedRef.current = false
669
- mountingRef.current = false
670
- // Small delay to ensure state is updated, use ref to avoid stale closure
671
- setTimeout(() => mountFnRef.current?.(), 50)
672
- }
673
- }, [wsAuthenticated, authDenied, log])
674
-
675
- // ===== Connection Changes =====
676
- const prevConnected = useRef(connected)
677
- useEffect(() => {
678
- if (prevConnected.current && !connected && mountedRef.current) {
679
- mountedRef.current = false
680
- setComponentId(null)
681
- onDisconnect?.()
682
- }
683
- if (!prevConnected.current && connected) {
684
- onConnect?.()
685
- if (!mountedRef.current && !mountingRef.current) {
686
- setTimeout(() => {
687
- const persisted = getPersistedState(persistEnabled, componentName)
688
- if (persisted?.signedState) rehydrate()
689
- else mount()
690
- }, 100)
691
- }
692
- }
693
- prevConnected.current = connected
694
- }, [connected, mount, rehydrate, componentName, onConnect, onDisconnect])
695
-
696
- // ===== Room Manager =====
697
- const roomManager = useMemo(() => {
698
- if (roomManagerRef.current) {
699
- roomManagerRef.current.setComponentId(componentId)
700
- return roomManagerRef.current
701
- }
702
-
703
- const manager = new RoomManager({
704
- componentId,
705
- defaultRoom: room,
706
- sendMessage,
707
- sendMessageAndWait,
708
- onMessage: (handler) => {
709
- roomMessageHandlers.current.add(handler)
710
- return () => {
711
- roomMessageHandlers.current.delete(handler)
712
- }
713
- }
714
- })
715
-
716
- roomManagerRef.current = manager
717
- return manager
718
- }, [componentId, room, sendMessage, sendMessageAndWait])
719
-
720
- // Atualizar componentId no RoomManager quando mudar
721
- useEffect(() => {
722
- roomManagerRef.current?.setComponentId(componentId)
723
- }, [componentId])
724
-
725
- // ===== Cleanup =====
726
- useEffect(() => {
727
- return () => {
728
- debounceTimers.current.forEach(t => clearTimeout(t))
729
- roomManagerRef.current?.destroy()
730
- if (mountedRef.current) unmount()
731
- }
732
- }, [unmount])
733
-
734
- // ===== Status =====
735
- const getStatus = () => {
736
- if (!connected) return 'connecting'
737
- if (rehydrating) return 'reconnecting'
738
- if (loading) return 'loading'
739
- if (error) return 'error'
740
- if (!componentId) return 'mounting'
741
- return 'synced'
742
- }
743
-
744
- // ===== Proxy =====
745
- const proxy = useMemo(() => {
746
- return new Proxy({} as LiveProxyWithBroadcasts<TState, TActions, TBroadcasts>, {
747
- get(_, prop: string | symbol) {
748
- if (typeof prop === 'symbol') {
749
- if (prop === Symbol.toStringTag) return 'LiveComponent'
750
- return undefined
751
- }
752
-
753
- // Metadata ($ prefix)
754
- switch (prop) {
755
- // $state returns FRESH state from store (not stale closure)
756
- case '$state': return storeRef.current?.getState().state ?? stateData
757
- case '$connected': return connected
758
- case '$loading': return loading
759
- case '$error': return error
760
- case '$status': return getStatus()
761
- case '$componentId': return componentId
762
- case '$dirty': return pendingChanges.current.size > 0
763
- case '$authenticated': return wsAuthenticated
764
- case '$call': return call
765
- case '$callAndWait': return callAndWait
766
- case '$mount': return mount
767
- case '$unmount': return unmount
768
- case '$refresh': return refresh
769
- case '$set': return setProperty
770
- case '$field': return createFieldBinding
771
- case '$sync': return sync
772
- case '$onBroadcast': return (handler: (event: { type: string; data: any }) => void) => {
773
- broadcastHandlerRef.current = handler
774
- }
775
- case '$updateLocal': return (updates: Partial<TState>) => {
776
- const currentState = storeRef.current?.getState().state
777
- if (currentState) {
778
- updateState({ ...currentState, ...updates } as TState)
779
- }
780
- }
781
- case '$room': return roomManager.createProxy()
782
- case '$rooms': return roomManager.getJoinedRooms()
783
- }
784
-
785
- // Se é propriedade do state → retorna valor
786
- if (prop in stateData) {
787
- // Valor local tem prioridade (para UI responsiva com $field)
788
- if (localFieldValues.current.has(prop as keyof TState)) {
789
- return localFieldValues.current.get(prop as keyof TState)
790
- }
791
- // Optimistic update
792
- if (optimistic) {
793
- const pending = pendingChanges.current.get(prop as keyof TState)
794
- if (pending && !pending.synced) return pending.value
795
- }
796
- return stateData[prop as keyof TState]
797
- }
798
-
799
- // Se NÃO é propriedade do state → é uma action!
800
- // Retorna uma função que chama a action no servidor
801
- return async (payload?: any) => {
802
- const id = componentId || lastComponentIdRef.current
803
- if (!id || !connected) throw new Error('Not connected')
804
-
805
- const response = await sendMessageAndWait({
806
- type: 'CALL_ACTION',
807
- componentId: id,
808
- action: prop,
809
- payload
810
- }, 10000)
811
-
812
- if (!response.success) throw new Error(response.error || 'Action failed')
813
- return response.result
814
- }
815
- },
816
-
817
- set(_, prop: string | symbol, value) {
818
- if (typeof prop === 'symbol' || RESERVED_PROPS.has(prop as string)) return false
819
- setProperty(prop as keyof TState, value)
820
- return true
821
- },
822
-
823
- has(_, prop) {
824
- if (typeof prop === 'symbol') return false
825
- return RESERVED_PROPS.has(prop) || prop in stateData
826
- },
827
-
828
- ownKeys() {
829
- return [...Object.keys(stateData), '$state', '$connected', '$loading', '$error', '$status', '$componentId', '$dirty', '$authenticated', '$call', '$callAndWait', '$mount', '$unmount', '$refresh', '$set', '$field', '$sync', '$onBroadcast', '$updateLocal', '$room', '$rooms']
830
- }
831
- })
832
- }, [stateData, connected, wsAuthenticated, loading, error, componentId, call, callAndWait, mount, unmount, refresh, setProperty, optimistic, sendMessageAndWait, createFieldBinding, sync, localVersion, roomManager])
833
-
834
- return proxy
835
- }
836
-
837
- // ===== Factory =====
838
-
839
- export function createLiveComponent<
840
- TState extends Record<string, any>,
841
- TActions = {},
842
- TBroadcasts extends Record<string, any> = Record<string, any>
843
- >(
844
- componentName: string,
845
- defaultOptions: Omit<UseLiveComponentOptions, keyof HybridComponentOptions> = {}
846
- ) {
847
- return function useComponent(
848
- initialState: TState,
849
- options: UseLiveComponentOptions = {}
850
- ): LiveProxyWithBroadcasts<TState, TActions, TBroadcasts> {
851
- return useLiveComponent<TState, TActions, TBroadcasts>(componentName, initialState, { ...defaultOptions, ...options })
852
- }
853
- }