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