create-fluxstack 1.0.13 → 1.0.14
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/.env.example +29 -29
- package/app/client/README.md +69 -69
- package/app/client/index.html +14 -13
- package/app/client/src/App.tsx +157 -524
- package/app/client/src/components/ErrorBoundary.tsx +107 -0
- package/app/client/src/components/ErrorDisplay.css +365 -0
- package/app/client/src/components/ErrorDisplay.tsx +258 -0
- package/app/client/src/components/FluxStackConfig.tsx +1321 -0
- package/app/client/src/components/HybridLiveCounter.tsx +140 -0
- package/app/client/src/components/LiveClock.tsx +286 -0
- package/app/client/src/components/MainLayout.tsx +390 -0
- package/app/client/src/components/SidebarNavigation.tsx +391 -0
- package/app/client/src/components/StateDemo.tsx +178 -0
- package/app/client/src/components/SystemMonitor.tsx +1038 -0
- package/app/client/src/components/Teste.tsx +104 -0
- package/app/client/src/components/UserProfile.tsx +809 -0
- package/app/client/src/hooks/useAuth.ts +39 -0
- package/app/client/src/hooks/useNotifications.ts +56 -0
- package/app/client/src/lib/eden-api.ts +189 -53
- package/app/client/src/lib/errors.ts +340 -0
- package/app/client/src/lib/hooks/useErrorHandler.ts +258 -0
- package/app/client/src/lib/index.ts +45 -0
- package/app/client/src/main.tsx +3 -2
- package/app/client/src/pages/ApiDocs.tsx +182 -0
- package/app/client/src/pages/Demo.tsx +174 -0
- package/app/client/src/pages/HybridLive.tsx +263 -0
- package/app/client/src/pages/Overview.tsx +155 -0
- package/app/client/src/store/README.md +43 -0
- package/app/client/src/store/index.ts +16 -0
- package/app/client/src/store/slices/uiSlice.ts +151 -0
- package/app/client/src/store/slices/userSlice.ts +161 -0
- package/app/client/src/test/README.md +257 -0
- package/app/client/src/test/setup.ts +70 -0
- package/app/client/src/test/types.ts +12 -0
- package/app/client/src/vite-env.d.ts +1 -1
- package/app/client/tsconfig.app.json +44 -43
- package/app/client/tsconfig.json +7 -7
- package/app/client/tsconfig.node.json +25 -25
- package/app/client/zustand-setup.md +65 -0
- package/app/server/controllers/users.controller.ts +68 -68
- package/app/server/index.ts +9 -1
- package/app/server/live/CounterComponent.ts +191 -0
- package/app/server/live/FluxStackConfig.ts +529 -0
- package/app/server/live/LiveClockComponent.ts +214 -0
- package/app/server/live/SidebarNavigation.ts +156 -0
- package/app/server/live/SystemMonitor.ts +594 -0
- package/app/server/live/SystemMonitorIntegration.ts +151 -0
- package/app/server/live/TesteComponent.ts +87 -0
- package/app/server/live/UserProfileComponent.ts +135 -0
- package/app/server/live/register-components.ts +28 -0
- package/app/server/middleware/auth.ts +136 -0
- package/app/server/middleware/errorHandling.ts +250 -0
- package/app/server/middleware/index.ts +10 -0
- package/app/server/middleware/rateLimit.ts +193 -0
- package/app/server/middleware/requestLogging.ts +215 -0
- package/app/server/middleware/validation.ts +270 -0
- package/app/server/routes/index.ts +14 -2
- package/app/server/routes/upload.ts +92 -0
- package/app/server/routes/users.routes.ts +2 -9
- package/app/server/services/NotificationService.ts +302 -0
- package/app/server/services/UserService.ts +222 -0
- package/app/server/services/index.ts +46 -0
- package/core/cli/commands/plugin-deps.ts +263 -0
- package/core/cli/generators/README.md +339 -0
- package/core/cli/generators/component.ts +770 -0
- package/core/cli/generators/controller.ts +299 -0
- package/core/cli/generators/index.ts +144 -0
- package/core/cli/generators/interactive.ts +228 -0
- package/core/cli/generators/prompts.ts +83 -0
- package/core/cli/generators/route.ts +513 -0
- package/core/cli/generators/service.ts +465 -0
- package/core/cli/generators/template-engine.ts +154 -0
- package/core/cli/generators/types.ts +71 -0
- package/core/cli/generators/utils.ts +192 -0
- package/core/cli/index.ts +69 -0
- package/core/cli/plugin-discovery.ts +16 -85
- package/core/client/fluxstack.ts +17 -0
- package/core/client/hooks/index.ts +7 -0
- package/core/client/hooks/state-validator.ts +130 -0
- package/core/client/hooks/useAuth.ts +49 -0
- package/core/client/hooks/useChunkedUpload.ts +258 -0
- package/core/client/hooks/useHybridLiveComponent.ts +967 -0
- package/core/client/hooks/useWebSocket.ts +373 -0
- package/core/client/index.ts +47 -0
- package/core/client/state/createStore.ts +193 -0
- package/core/client/state/index.ts +15 -0
- package/core/config/env-dynamic.ts +1 -1
- package/core/config/env.ts +2 -1
- package/core/config/runtime-config.ts +3 -3
- package/core/config/schema.ts +84 -49
- package/core/framework/server.ts +30 -0
- package/core/index.ts +25 -0
- package/core/live/ComponentRegistry.ts +399 -0
- package/core/live/types.ts +164 -0
- package/core/plugins/built-in/live-components/commands/create-live-component.ts +1201 -0
- package/core/plugins/built-in/live-components/index.ts +27 -0
- package/core/plugins/built-in/logger/index.ts +1 -1
- package/core/plugins/built-in/monitoring/index.ts +1 -1
- package/core/plugins/built-in/static/index.ts +1 -1
- package/core/plugins/built-in/swagger/index.ts +1 -1
- package/core/plugins/built-in/vite/index.ts +1 -1
- package/core/plugins/dependency-manager.ts +384 -0
- package/core/plugins/index.ts +5 -1
- package/core/plugins/manager.ts +7 -3
- package/core/plugins/registry.ts +88 -10
- package/core/plugins/types.ts +11 -11
- package/core/server/framework.ts +43 -0
- package/core/server/index.ts +11 -1
- package/core/server/live/ComponentRegistry.ts +1017 -0
- package/core/server/live/FileUploadManager.ts +272 -0
- package/core/server/live/LiveComponentPerformanceMonitor.ts +930 -0
- package/core/server/live/SingleConnectionManager.ts +0 -0
- package/core/server/live/StateSignature.ts +644 -0
- package/core/server/live/WebSocketConnectionManager.ts +688 -0
- package/core/server/live/websocket-plugin.ts +435 -0
- package/core/server/middleware/errorHandling.ts +141 -0
- package/core/server/middleware/index.ts +16 -0
- package/core/server/plugins/static-files-plugin.ts +232 -0
- package/core/server/services/BaseService.ts +95 -0
- package/core/server/services/ServiceContainer.ts +144 -0
- package/core/server/services/index.ts +9 -0
- package/core/templates/create-project.ts +46 -2
- package/core/testing/index.ts +10 -0
- package/core/testing/setup.ts +74 -0
- package/core/types/build.ts +38 -14
- package/core/types/types.ts +319 -0
- package/core/utils/env-runtime.ts +7 -0
- package/core/utils/errors/handlers.ts +264 -39
- package/core/utils/errors/index.ts +528 -18
- package/core/utils/errors/middleware.ts +114 -0
- package/core/utils/logger/formatters.ts +222 -0
- package/core/utils/logger/index.ts +167 -48
- package/core/utils/logger/middleware.ts +253 -0
- package/core/utils/logger/performance.ts +384 -0
- package/core/utils/logger/transports.ts +365 -0
- package/create-fluxstack.ts +296 -296
- package/fluxstack.config.ts +17 -1
- package/package-template.json +66 -66
- package/package.json +31 -6
- package/public/README.md +16 -0
- package/vite.config.ts +29 -14
- package/.claude/settings.local.json +0 -74
- package/.github/workflows/ci-build-tests.yml +0 -480
- package/.github/workflows/dependency-management.yml +0 -324
- package/.github/workflows/release-validation.yml +0 -355
- package/.kiro/specs/fluxstack-architecture-optimization/design.md +0 -700
- package/.kiro/specs/fluxstack-architecture-optimization/requirements.md +0 -127
- package/.kiro/specs/fluxstack-architecture-optimization/tasks.md +0 -330
- package/CLAUDE.md +0 -200
- package/Dockerfile +0 -58
- package/Dockerfile.backend +0 -52
- package/Dockerfile.frontend +0 -54
- package/README-Docker.md +0 -85
- package/ai-context/00-QUICK-START.md +0 -86
- package/ai-context/README.md +0 -88
- package/ai-context/development/eden-treaty-guide.md +0 -362
- package/ai-context/development/patterns.md +0 -382
- package/ai-context/development/plugins-guide.md +0 -572
- package/ai-context/examples/crud-complete.md +0 -626
- package/ai-context/project/architecture.md +0 -399
- package/ai-context/project/overview.md +0 -213
- package/ai-context/recent-changes/eden-treaty-refactor.md +0 -281
- package/ai-context/recent-changes/type-inference-fix.md +0 -223
- package/ai-context/reference/environment-vars.md +0 -384
- package/ai-context/reference/troubleshooting.md +0 -407
- package/app/client/src/components/TestPage.tsx +0 -453
- package/bun.lock +0 -1063
- package/bunfig.toml +0 -16
- package/core/__tests__/integration.test.ts +0 -227
- package/core/build/index.ts +0 -186
- package/core/config/__tests__/config-loader.test.ts +0 -554
- package/core/config/__tests__/config-merger.test.ts +0 -657
- package/core/config/__tests__/env-converter.test.ts +0 -372
- package/core/config/__tests__/env-processor.test.ts +0 -431
- package/core/config/__tests__/env.test.ts +0 -452
- package/core/config/__tests__/integration.test.ts +0 -418
- package/core/config/__tests__/loader.test.ts +0 -331
- package/core/config/__tests__/schema.test.ts +0 -129
- package/core/config/__tests__/validator.test.ts +0 -318
- package/core/framework/__tests__/server.test.ts +0 -233
- package/core/plugins/__tests__/built-in.test.ts.disabled +0 -366
- package/core/plugins/__tests__/manager.test.ts +0 -398
- package/core/plugins/__tests__/monitoring.test.ts +0 -401
- package/core/plugins/__tests__/registry.test.ts +0 -335
- package/core/utils/__tests__/errors.test.ts +0 -139
- package/core/utils/__tests__/helpers.test.ts +0 -297
- package/core/utils/__tests__/logger.test.ts +0 -141
- package/create-test-app.ts +0 -156
- package/docker-compose.microservices.yml +0 -75
- package/docker-compose.simple.yml +0 -57
- package/docker-compose.yml +0 -71
- package/eslint.config.js +0 -23
- package/flux-cli.ts +0 -214
- package/nginx-lb.conf +0 -37
- package/publish.sh +0 -63
- package/run-clean.ts +0 -26
- package/run-env-tests.ts +0 -313
- package/tailwind.config.js +0 -34
- package/tests/__mocks__/api.ts +0 -56
- package/tests/fixtures/users.ts +0 -69
- package/tests/integration/api/users.routes.test.ts +0 -221
- package/tests/setup.ts +0 -29
- package/tests/unit/app/client/App-simple.test.tsx +0 -56
- package/tests/unit/app/client/App.test.tsx.skip +0 -237
- package/tests/unit/app/client/eden-api.test.ts +0 -186
- package/tests/unit/app/client/simple.test.tsx +0 -23
- package/tests/unit/app/controllers/users.controller.test.ts +0 -150
- package/tests/unit/core/create-project.test.ts.skip +0 -95
- package/tests/unit/core/framework.test.ts +0 -144
- package/tests/unit/core/plugins/logger.test.ts.skip +0 -268
- package/tests/unit/core/plugins/vite.test.ts.disabled +0 -188
- package/tests/utils/test-helpers.ts +0 -61
- package/vitest.config.ts +0 -50
- package/workspace.json +0 -6
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
// 🔥 Hybrid Live Component Hook - Server-Driven with Zustand
|
|
2
|
+
// Direct WebSocket integration (no dependency on useLiveComponent)
|
|
3
|
+
|
|
4
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
5
|
+
import { create } from 'zustand'
|
|
6
|
+
import { subscribeWithSelector } from 'zustand/middleware'
|
|
7
|
+
import { useWebSocket, type WebSocketMessage, type WebSocketResponse } from './useWebSocket'
|
|
8
|
+
import { StateValidator } from './state-validator'
|
|
9
|
+
import type {
|
|
10
|
+
HybridState,
|
|
11
|
+
StateConflict,
|
|
12
|
+
HybridComponentOptions
|
|
13
|
+
} from '../../types/types'
|
|
14
|
+
|
|
15
|
+
// Client-side state persistence for reconnection
|
|
16
|
+
interface PersistedComponentState {
|
|
17
|
+
componentName: string
|
|
18
|
+
signedState: any
|
|
19
|
+
room?: string
|
|
20
|
+
userId?: string
|
|
21
|
+
lastUpdate: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STORAGE_KEY_PREFIX = 'fluxstack_component_'
|
|
25
|
+
const STATE_MAX_AGE = 24 * 60 * 60 * 1000 // 24 hours
|
|
26
|
+
|
|
27
|
+
// Global re-hydration throttling by component name
|
|
28
|
+
const globalRehydrationAttempts = new Map<string, Promise<boolean>>()
|
|
29
|
+
|
|
30
|
+
// Utility functions for state persistence
|
|
31
|
+
const persistComponentState = (componentName: string, signedState: any, room?: string, userId?: string) => {
|
|
32
|
+
try {
|
|
33
|
+
const persistedState: PersistedComponentState = {
|
|
34
|
+
componentName,
|
|
35
|
+
signedState,
|
|
36
|
+
room,
|
|
37
|
+
userId,
|
|
38
|
+
lastUpdate: Date.now()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const key = `${STORAGE_KEY_PREFIX}${componentName}`
|
|
42
|
+
localStorage.setItem(key, JSON.stringify(persistedState))
|
|
43
|
+
|
|
44
|
+
// State persisted silently to avoid log spam
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.warn('⚠️ Failed to persist component state:', error)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const getPersistedState = (componentName: string): PersistedComponentState | null => {
|
|
51
|
+
try {
|
|
52
|
+
const key = `${STORAGE_KEY_PREFIX}${componentName}`
|
|
53
|
+
console.log('🔍 Getting persisted state', { componentName, key })
|
|
54
|
+
const stored = localStorage.getItem(key)
|
|
55
|
+
|
|
56
|
+
if (!stored) {
|
|
57
|
+
console.log('❌ No localStorage data found', { key })
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log('✅ Found localStorage data', { stored })
|
|
62
|
+
const persistedState: PersistedComponentState = JSON.parse(stored)
|
|
63
|
+
|
|
64
|
+
// Check if state is not too old
|
|
65
|
+
const age = Date.now() - persistedState.lastUpdate
|
|
66
|
+
if (age > STATE_MAX_AGE) {
|
|
67
|
+
localStorage.removeItem(key)
|
|
68
|
+
console.log('🗑️ Expired persisted state removed:', { componentName, age, maxAge: STATE_MAX_AGE })
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('✅ Valid persisted state found', { componentName, age })
|
|
73
|
+
return persistedState
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.warn('⚠️ Failed to retrieve persisted state:', error)
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const clearPersistedState = (componentName: string) => {
|
|
81
|
+
try {
|
|
82
|
+
const key = `${STORAGE_KEY_PREFIX}${componentName}`
|
|
83
|
+
localStorage.removeItem(key)
|
|
84
|
+
console.log('🗑️ Persisted state cleared:', componentName)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.warn('⚠️ Failed to clear persisted state:', error)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface HybridStore<T> {
|
|
91
|
+
hybridState: HybridState<T>
|
|
92
|
+
updateState: (newState: T, source?: 'server' | 'mount') => void
|
|
93
|
+
reset: (initialState: T) => void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UseHybridLiveComponentReturn<T> {
|
|
97
|
+
// Server-driven state (read-only from frontend perspective)
|
|
98
|
+
state: T
|
|
99
|
+
|
|
100
|
+
// Status information
|
|
101
|
+
loading: boolean
|
|
102
|
+
error: string | null
|
|
103
|
+
connected: boolean
|
|
104
|
+
componentId: string | null
|
|
105
|
+
|
|
106
|
+
// Connection status with all possible states
|
|
107
|
+
status: 'synced' | 'disconnected' | 'connecting' | 'reconnecting' | 'loading' | 'mounting' | 'error'
|
|
108
|
+
|
|
109
|
+
// Actions (all go to server)
|
|
110
|
+
call: (action: string, payload?: any) => Promise<void>
|
|
111
|
+
callAndWait: (action: string, payload?: any, timeout?: number) => Promise<any>
|
|
112
|
+
mount: () => Promise<void>
|
|
113
|
+
unmount: () => Promise<void>
|
|
114
|
+
|
|
115
|
+
// WebSocket utilities
|
|
116
|
+
sendMessage: (message: any) => Promise<WebSocketResponse>
|
|
117
|
+
sendMessageAndWait: (message: any, timeout?: number) => Promise<WebSocketResponse>
|
|
118
|
+
|
|
119
|
+
// Helper for temporary input state
|
|
120
|
+
useControlledField: <K extends keyof T>(field: K, action?: string) => {
|
|
121
|
+
value: T[K]
|
|
122
|
+
setValue: (value: T[K]) => void
|
|
123
|
+
commit: (value?: T[K]) => Promise<void>
|
|
124
|
+
isDirty: boolean
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create Zustand store for component instance
|
|
130
|
+
*/
|
|
131
|
+
function createHybridStore<T>(initialState: T) {
|
|
132
|
+
return create<HybridStore<T>>()(
|
|
133
|
+
subscribeWithSelector((set, get) => ({
|
|
134
|
+
hybridState: {
|
|
135
|
+
data: initialState,
|
|
136
|
+
validation: StateValidator.createValidation(initialState, 'mount'),
|
|
137
|
+
conflicts: [],
|
|
138
|
+
status: 'disconnected' as const
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
updateState: (newState: T, source: 'server' | 'mount' = 'server') => {
|
|
142
|
+
console.log('🔄 [Zustand] Server state update', { newState, source })
|
|
143
|
+
set((state) => {
|
|
144
|
+
// Backend is ONLY source of state mutations
|
|
145
|
+
const updatedData = newState
|
|
146
|
+
|
|
147
|
+
console.log('🔄 [Zustand] State replaced from server', {
|
|
148
|
+
from: state.hybridState.data,
|
|
149
|
+
to: updatedData
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
hybridState: {
|
|
154
|
+
data: updatedData,
|
|
155
|
+
validation: StateValidator.createValidation(updatedData, source),
|
|
156
|
+
conflicts: [], // No conflicts - server is source of truth
|
|
157
|
+
status: 'synced'
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
reset: (initialState: T) => {
|
|
164
|
+
set({
|
|
165
|
+
hybridState: {
|
|
166
|
+
data: initialState,
|
|
167
|
+
validation: StateValidator.createValidation(initialState, 'mount'),
|
|
168
|
+
conflicts: [],
|
|
169
|
+
status: 'disconnected'
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}))
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function useHybridLiveComponent<T = any>(
|
|
178
|
+
componentName: string,
|
|
179
|
+
initialState: T,
|
|
180
|
+
options: HybridComponentOptions = {}
|
|
181
|
+
): UseHybridLiveComponentReturn<T> {
|
|
182
|
+
const {
|
|
183
|
+
fallbackToLocal = true,
|
|
184
|
+
room,
|
|
185
|
+
userId,
|
|
186
|
+
autoMount = true,
|
|
187
|
+
debug = false
|
|
188
|
+
} = options
|
|
189
|
+
|
|
190
|
+
// Create unique instance ID to avoid conflicts between multiple instances
|
|
191
|
+
const instanceId = useRef(`${componentName}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`)
|
|
192
|
+
const logPrefix = `${instanceId.current}${room ? `[${room}]` : ''}`
|
|
193
|
+
|
|
194
|
+
// Create Zustand store instance (one per component instance)
|
|
195
|
+
const storeRef = useRef<ReturnType<typeof createHybridStore<T>> | null>(null)
|
|
196
|
+
if (!storeRef.current) {
|
|
197
|
+
storeRef.current = createHybridStore(initialState)
|
|
198
|
+
}
|
|
199
|
+
const store = storeRef.current
|
|
200
|
+
|
|
201
|
+
// Get state from Zustand store with optimized selectors
|
|
202
|
+
const hybridState = store((state) => state.hybridState)
|
|
203
|
+
const stateData = store((state) => state.hybridState.data)
|
|
204
|
+
const updateState = store((state) => state.updateState)
|
|
205
|
+
|
|
206
|
+
// Log state changes (throttled to avoid spam)
|
|
207
|
+
const lastLoggedStateRef = useRef<string>('')
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (debug) {
|
|
210
|
+
const stateString = JSON.stringify(stateData)
|
|
211
|
+
if (stateString !== lastLoggedStateRef.current) {
|
|
212
|
+
console.log('🔍 [Zustand] State data changed:', stateData)
|
|
213
|
+
lastLoggedStateRef.current = stateString
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}, [stateData, debug])
|
|
217
|
+
|
|
218
|
+
// Direct WebSocket integration
|
|
219
|
+
const {
|
|
220
|
+
connected,
|
|
221
|
+
sendMessage,
|
|
222
|
+
sendMessageAndWait,
|
|
223
|
+
onMessage,
|
|
224
|
+
error: wsError
|
|
225
|
+
} = useWebSocket({
|
|
226
|
+
debug
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Component state
|
|
230
|
+
const [componentId, setComponentId] = useState<string | null>(null)
|
|
231
|
+
const [lastServerState, setLastServerState] = useState<T | null>(null)
|
|
232
|
+
const [mountLoading, setMountLoading] = useState(false) // Only for mount/unmount operations
|
|
233
|
+
const [error, setError] = useState<string | null>(null)
|
|
234
|
+
const [rehydrating, setRehydrating] = useState(false)
|
|
235
|
+
const [currentSignedState, setCurrentSignedState] = useState<any>(null)
|
|
236
|
+
const mountedRef = useRef(false)
|
|
237
|
+
const mountingRef = useRef(false)
|
|
238
|
+
const lastKnownComponentIdRef = useRef<string | null>(null)
|
|
239
|
+
|
|
240
|
+
const log = useCallback((message: string, data?: any) => {
|
|
241
|
+
if (debug) {
|
|
242
|
+
console.log(`[${logPrefix}] ${message}`, data)
|
|
243
|
+
}
|
|
244
|
+
}, [debug, logPrefix])
|
|
245
|
+
|
|
246
|
+
// Prevent multiple simultaneous re-hydration attempts
|
|
247
|
+
const rehydrationAttemptRef = useRef<Promise<boolean> | null>(null)
|
|
248
|
+
|
|
249
|
+
// Automatic re-hydration on reconnection
|
|
250
|
+
const attemptRehydration = useCallback(async () => {
|
|
251
|
+
log('🔄 attemptRehydration called', { connected, rehydrating, mounting: mountingRef.current })
|
|
252
|
+
|
|
253
|
+
if (!connected || rehydrating || mountingRef.current) {
|
|
254
|
+
log('❌ Re-hydration blocked', { connected, rehydrating, mounting: mountingRef.current })
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Prevent multiple simultaneous attempts (local)
|
|
259
|
+
if (rehydrationAttemptRef.current) {
|
|
260
|
+
log('⏳ Re-hydration already in progress locally, waiting...')
|
|
261
|
+
return await rehydrationAttemptRef.current
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Prevent multiple simultaneous attempts (global by component name)
|
|
265
|
+
if (globalRehydrationAttempts.has(componentName)) {
|
|
266
|
+
log('⏳ Re-hydration already in progress globally for', componentName)
|
|
267
|
+
return await globalRehydrationAttempts.get(componentName)!
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check for persisted state
|
|
271
|
+
log('🔍 Checking for persisted state', { componentName })
|
|
272
|
+
const persistedState = getPersistedState(componentName)
|
|
273
|
+
if (!persistedState) {
|
|
274
|
+
log('❌ No persisted state found for re-hydration', { componentName })
|
|
275
|
+
return false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
log('✅ Found persisted state', { persistedState })
|
|
279
|
+
|
|
280
|
+
// Create and store the re-hydration promise
|
|
281
|
+
const rehydrationPromise = (async () => {
|
|
282
|
+
setRehydrating(true)
|
|
283
|
+
setError(null)
|
|
284
|
+
log('Attempting automatic re-hydration', {
|
|
285
|
+
componentName,
|
|
286
|
+
persistedState: {
|
|
287
|
+
lastUpdate: persistedState.lastUpdate,
|
|
288
|
+
age: Date.now() - persistedState.lastUpdate
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
// Send re-hydration request with signed state
|
|
294
|
+
const tempComponentId = lastKnownComponentIdRef.current || instanceId.current
|
|
295
|
+
|
|
296
|
+
log('📤 Sending COMPONENT_REHYDRATE request', {
|
|
297
|
+
tempComponentId,
|
|
298
|
+
componentName,
|
|
299
|
+
currentRehydrating: rehydrating,
|
|
300
|
+
persistedState: {
|
|
301
|
+
room: persistedState.room,
|
|
302
|
+
userId: persistedState.userId,
|
|
303
|
+
signedStateVersion: persistedState.signedState?.version
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const response = await sendMessageAndWait({
|
|
308
|
+
type: 'COMPONENT_REHYDRATE',
|
|
309
|
+
componentId: tempComponentId,
|
|
310
|
+
payload: {
|
|
311
|
+
componentName,
|
|
312
|
+
signedState: persistedState.signedState,
|
|
313
|
+
room: persistedState.room,
|
|
314
|
+
userId: persistedState.userId
|
|
315
|
+
},
|
|
316
|
+
expectResponse: true
|
|
317
|
+
}, 10000)
|
|
318
|
+
|
|
319
|
+
log('💫 Re-hydration response received:', {
|
|
320
|
+
success: response?.success,
|
|
321
|
+
newComponentId: response?.result?.newComponentId,
|
|
322
|
+
error: response?.error
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
if (response?.success && response?.result?.newComponentId) {
|
|
326
|
+
log('✅ Re-hydration successful - updating componentId in attemptRehydration', {
|
|
327
|
+
oldComponentId: componentId,
|
|
328
|
+
newComponentId: response.result.newComponentId
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Update componentId immediately to prevent further re-hydration attempts
|
|
332
|
+
setComponentId(response.result.newComponentId)
|
|
333
|
+
lastKnownComponentIdRef.current = response.result.newComponentId
|
|
334
|
+
|
|
335
|
+
return true
|
|
336
|
+
} else {
|
|
337
|
+
log('❌ Re-hydration failed', response?.error || 'Unknown error')
|
|
338
|
+
// Clear invalid persisted state
|
|
339
|
+
clearPersistedState(componentName)
|
|
340
|
+
setError(response?.error || 'Re-hydration failed')
|
|
341
|
+
return false
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
} catch (error: any) {
|
|
345
|
+
log('Re-hydration error', error.message)
|
|
346
|
+
clearPersistedState(componentName)
|
|
347
|
+
setError(error.message)
|
|
348
|
+
return false
|
|
349
|
+
} finally {
|
|
350
|
+
setRehydrating(false)
|
|
351
|
+
rehydrationAttemptRef.current = null // Clear the local reference
|
|
352
|
+
globalRehydrationAttempts.delete(componentName) // Clear the global reference
|
|
353
|
+
}
|
|
354
|
+
})()
|
|
355
|
+
|
|
356
|
+
// Store both locally and globally
|
|
357
|
+
rehydrationAttemptRef.current = rehydrationPromise
|
|
358
|
+
globalRehydrationAttempts.set(componentName, rehydrationPromise)
|
|
359
|
+
|
|
360
|
+
return await rehydrationPromise
|
|
361
|
+
}, [connected, rehydrating, componentName, sendMessageAndWait, log])
|
|
362
|
+
|
|
363
|
+
// Handle incoming WebSocket messages (real-time processing)
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
if (!componentId) {
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Process each message immediately as it arrives
|
|
370
|
+
const unsubscribe = onMessage((message: any) => {
|
|
371
|
+
// Debug: Log all received messages first
|
|
372
|
+
log('🔍 Received WebSocket message', {
|
|
373
|
+
type: message.type,
|
|
374
|
+
messageComponentId: message.componentId,
|
|
375
|
+
currentComponentId: componentId,
|
|
376
|
+
requestId: message.requestId,
|
|
377
|
+
success: message.success
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// Don't filter STATE_REHYDRATED and COMPONENT_REHYDRATED - they may have different componentIds
|
|
381
|
+
if (message.type !== 'STATE_REHYDRATED' && message.type !== 'COMPONENT_REHYDRATED' && message.componentId !== componentId) {
|
|
382
|
+
log('🚫 Filtering out message - componentId mismatch', {
|
|
383
|
+
type: message.type,
|
|
384
|
+
messageComponentId: message.componentId,
|
|
385
|
+
currentComponentId: componentId
|
|
386
|
+
})
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
log('✅ Processing message immediately', { type: message.type, componentId: message.componentId })
|
|
391
|
+
|
|
392
|
+
switch (message.type) {
|
|
393
|
+
case 'STATE_UPDATE':
|
|
394
|
+
log('Processing STATE_UPDATE', message.payload)
|
|
395
|
+
if (message.payload?.state) {
|
|
396
|
+
const newState = message.payload.state
|
|
397
|
+
log('Updating Zustand with server state', newState)
|
|
398
|
+
updateState(newState, 'server')
|
|
399
|
+
setLastServerState(newState)
|
|
400
|
+
|
|
401
|
+
// Debug signed state persistence
|
|
402
|
+
if (message.payload?.signedState) {
|
|
403
|
+
log('Found signedState in STATE_UPDATE - persisting', {
|
|
404
|
+
componentName,
|
|
405
|
+
signedState: message.payload.signedState
|
|
406
|
+
})
|
|
407
|
+
setCurrentSignedState(message.payload.signedState)
|
|
408
|
+
persistComponentState(componentName, message.payload.signedState, room, userId)
|
|
409
|
+
log('State persisted successfully')
|
|
410
|
+
} else {
|
|
411
|
+
log('⚠️ No signedState in STATE_UPDATE payload', message.payload)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
log('State updated from server successfully', newState)
|
|
415
|
+
} else {
|
|
416
|
+
log('STATE_UPDATE has no state payload', message.payload)
|
|
417
|
+
}
|
|
418
|
+
break
|
|
419
|
+
|
|
420
|
+
case 'STATE_REHYDRATED':
|
|
421
|
+
log('Processing STATE_REHYDRATED', message.payload)
|
|
422
|
+
if (message.payload?.state && message.payload?.newComponentId) {
|
|
423
|
+
const newState = message.payload.state
|
|
424
|
+
const newComponentId = message.payload.newComponentId
|
|
425
|
+
const oldComponentId = message.payload.oldComponentId
|
|
426
|
+
|
|
427
|
+
log('Component re-hydrated successfully', {
|
|
428
|
+
oldComponentId,
|
|
429
|
+
newComponentId,
|
|
430
|
+
state: newState
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// Update component ID and state
|
|
434
|
+
setComponentId(newComponentId)
|
|
435
|
+
lastKnownComponentIdRef.current = newComponentId
|
|
436
|
+
updateState(newState, 'server')
|
|
437
|
+
setLastServerState(newState)
|
|
438
|
+
|
|
439
|
+
// Update signed state
|
|
440
|
+
if (message.payload?.signedState) {
|
|
441
|
+
setCurrentSignedState(message.payload.signedState)
|
|
442
|
+
persistComponentState(componentName, message.payload.signedState, room, userId)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
setRehydrating(false)
|
|
446
|
+
setError(null)
|
|
447
|
+
|
|
448
|
+
log('Re-hydration completed successfully')
|
|
449
|
+
}
|
|
450
|
+
break
|
|
451
|
+
|
|
452
|
+
case 'COMPONENT_REHYDRATED':
|
|
453
|
+
log('🎉 Processing COMPONENT_REHYDRATED response', message)
|
|
454
|
+
log('🎉 Response details:', {
|
|
455
|
+
success: message.success,
|
|
456
|
+
newComponentId: message.result?.newComponentId,
|
|
457
|
+
requestId: message.requestId,
|
|
458
|
+
currentRehydrating: rehydrating,
|
|
459
|
+
currentComponentId: componentId
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// Check if this is a successful re-hydration
|
|
463
|
+
if (message.success && message.result?.newComponentId) {
|
|
464
|
+
log('✅ Re-hydration succeeded, updating componentId', {
|
|
465
|
+
from: componentId,
|
|
466
|
+
to: message.result.newComponentId
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// Update componentId immediately to stop the loop
|
|
470
|
+
setComponentId(message.result.newComponentId)
|
|
471
|
+
lastKnownComponentIdRef.current = message.result.newComponentId
|
|
472
|
+
setRehydrating(false)
|
|
473
|
+
setError(null)
|
|
474
|
+
|
|
475
|
+
log('🎯 ComponentId updated, re-hydration completed')
|
|
476
|
+
} else if (!message.success) {
|
|
477
|
+
log('❌ Re-hydration failed', message.error)
|
|
478
|
+
setRehydrating(false)
|
|
479
|
+
setError(message.error || 'Re-hydration failed')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// This is also handled by sendMessageAndWait, but we process it here too for immediate UI updates
|
|
483
|
+
break
|
|
484
|
+
|
|
485
|
+
case 'MESSAGE_RESPONSE':
|
|
486
|
+
if (message.originalType !== 'CALL_ACTION') {
|
|
487
|
+
log('Received response for', message.originalType)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Check for re-hydration required error
|
|
491
|
+
if (!message.success && message.error?.includes?.('COMPONENT_REHYDRATION_REQUIRED')) {
|
|
492
|
+
log('🔄 Component re-hydration required from MESSAGE_RESPONSE - attempting automatic re-hydration', {
|
|
493
|
+
error: message.error,
|
|
494
|
+
currentComponentId: componentId,
|
|
495
|
+
rehydrating
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
if (!rehydrating) {
|
|
499
|
+
attemptRehydration().then(rehydrated => {
|
|
500
|
+
if (rehydrated) {
|
|
501
|
+
log('✅ Re-hydration successful after action error')
|
|
502
|
+
} else {
|
|
503
|
+
log('❌ Re-hydration failed after action error')
|
|
504
|
+
setError('Component lost connection and could not be recovered')
|
|
505
|
+
}
|
|
506
|
+
}).catch(error => {
|
|
507
|
+
log('💥 Re-hydration error after action error', error)
|
|
508
|
+
setError('Component recovery failed')
|
|
509
|
+
})
|
|
510
|
+
} else {
|
|
511
|
+
log('⚠️ Already re-hydrating, skipping duplicate attempt')
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
case 'BROADCAST':
|
|
517
|
+
log('Received broadcast', message.payload)
|
|
518
|
+
break
|
|
519
|
+
|
|
520
|
+
case 'ERROR':
|
|
521
|
+
log('Received error', message.payload)
|
|
522
|
+
const errorMessage = message.payload?.error || 'Unknown error'
|
|
523
|
+
|
|
524
|
+
// Check for re-hydration required error
|
|
525
|
+
if (errorMessage.includes('COMPONENT_REHYDRATION_REQUIRED')) {
|
|
526
|
+
log('🔄 Component re-hydration required from ERROR - attempting automatic re-hydration', {
|
|
527
|
+
errorMessage,
|
|
528
|
+
currentComponentId: componentId,
|
|
529
|
+
rehydrating
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
if (!rehydrating) {
|
|
533
|
+
attemptRehydration().then(rehydrated => {
|
|
534
|
+
if (rehydrated) {
|
|
535
|
+
log('✅ Re-hydration successful after error')
|
|
536
|
+
} else {
|
|
537
|
+
log('❌ Re-hydration failed after error')
|
|
538
|
+
setError('Component lost connection and could not be recovered')
|
|
539
|
+
}
|
|
540
|
+
}).catch(error => {
|
|
541
|
+
log('💥 Re-hydration error after error', error)
|
|
542
|
+
setError('Component recovery failed')
|
|
543
|
+
})
|
|
544
|
+
} else {
|
|
545
|
+
log('⚠️ Already re-hydrating, skipping duplicate attempt from ERROR')
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
setError(errorMessage)
|
|
549
|
+
}
|
|
550
|
+
break
|
|
551
|
+
}
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
// Cleanup callback on unmount
|
|
555
|
+
return unsubscribe
|
|
556
|
+
}, [componentId, updateState, log, onMessage, attemptRehydration])
|
|
557
|
+
|
|
558
|
+
// Mount component
|
|
559
|
+
const mount = useCallback(async () => {
|
|
560
|
+
if (!connected || mountedRef.current || mountingRef.current) {
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
mountingRef.current = true
|
|
565
|
+
setMountLoading(true)
|
|
566
|
+
setError(null)
|
|
567
|
+
log('Mounting component - server will control all state')
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const message: WebSocketMessage = {
|
|
571
|
+
type: 'COMPONENT_MOUNT',
|
|
572
|
+
componentId: instanceId.current,
|
|
573
|
+
payload: {
|
|
574
|
+
component: componentName,
|
|
575
|
+
props: initialState,
|
|
576
|
+
room,
|
|
577
|
+
userId
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const response = await sendMessageAndWait(message, 10000)
|
|
582
|
+
|
|
583
|
+
log('Mount response received', { response, fullResponse: JSON.stringify(response) })
|
|
584
|
+
|
|
585
|
+
if (response?.success && response?.result?.componentId) {
|
|
586
|
+
const newComponentId = response.result.componentId
|
|
587
|
+
setComponentId(newComponentId)
|
|
588
|
+
mountedRef.current = true
|
|
589
|
+
|
|
590
|
+
// Immediately persist signed state from mount response
|
|
591
|
+
if (response.result.signedState) {
|
|
592
|
+
log('Found signedState in mount response - persisting immediately', {
|
|
593
|
+
componentName,
|
|
594
|
+
signedState: response.result.signedState
|
|
595
|
+
})
|
|
596
|
+
setCurrentSignedState(response.result.signedState)
|
|
597
|
+
persistComponentState(componentName, response.result.signedState, room, userId)
|
|
598
|
+
log('Mount state persisted successfully')
|
|
599
|
+
} else {
|
|
600
|
+
log('⚠️ No signedState in mount response', response.result)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Update state if provided
|
|
604
|
+
if (response.result.initialState) {
|
|
605
|
+
log('Updating state from mount response', response.result.initialState)
|
|
606
|
+
updateState(response.result.initialState, 'server')
|
|
607
|
+
setLastServerState(response.result.initialState)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
log('Component mounted successfully', { componentId: newComponentId })
|
|
611
|
+
} else {
|
|
612
|
+
log('Failed to parse response', {
|
|
613
|
+
hasResponse: !!response,
|
|
614
|
+
hasSuccess: response?.success,
|
|
615
|
+
hasResult: !!response?.result,
|
|
616
|
+
hasComponentId: response?.result?.componentId,
|
|
617
|
+
error: response?.error
|
|
618
|
+
})
|
|
619
|
+
throw new Error(response?.error || 'No component ID returned from server')
|
|
620
|
+
}
|
|
621
|
+
} catch (err) {
|
|
622
|
+
const errorMessage = err instanceof Error ? err.message : 'Mount failed'
|
|
623
|
+
setError(errorMessage)
|
|
624
|
+
log('Mount failed', err)
|
|
625
|
+
|
|
626
|
+
if (fallbackToLocal) {
|
|
627
|
+
log('Using local state as fallback until reconnection')
|
|
628
|
+
} else {
|
|
629
|
+
throw err
|
|
630
|
+
}
|
|
631
|
+
} finally {
|
|
632
|
+
setMountLoading(false)
|
|
633
|
+
mountingRef.current = false
|
|
634
|
+
}
|
|
635
|
+
}, [connected, componentName, initialState, room, userId, sendMessage, log, fallbackToLocal])
|
|
636
|
+
|
|
637
|
+
// Unmount component
|
|
638
|
+
const unmount = useCallback(async () => {
|
|
639
|
+
if (!componentId || !connected) {
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
log('Unmounting component', { componentId })
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
await sendMessage({
|
|
647
|
+
type: 'COMPONENT_UNMOUNT',
|
|
648
|
+
componentId
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
setComponentId(null)
|
|
652
|
+
mountedRef.current = false
|
|
653
|
+
mountingRef.current = false
|
|
654
|
+
log('Component unmounted successfully')
|
|
655
|
+
} catch (err) {
|
|
656
|
+
log('Unmount failed', err)
|
|
657
|
+
}
|
|
658
|
+
}, [componentId, connected, sendMessage, log])
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
// Server-only actions (no client-side state mutations)
|
|
662
|
+
const call = useCallback(async (action: string, payload?: any): Promise<void> => {
|
|
663
|
+
if (!componentId || !connected) {
|
|
664
|
+
throw new Error('Component not mounted or WebSocket not connected')
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
log('Calling server action', { action, payload })
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
// Don't set loading for actions to avoid UI flicker
|
|
671
|
+
|
|
672
|
+
const message: WebSocketMessage = {
|
|
673
|
+
type: 'CALL_ACTION',
|
|
674
|
+
componentId,
|
|
675
|
+
action,
|
|
676
|
+
payload,
|
|
677
|
+
expectResponse: true // Always expect response to catch errors like re-hydration required
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Send action - server will update state and send back changes
|
|
681
|
+
try {
|
|
682
|
+
const response = await sendMessageAndWait(message, 5000)
|
|
683
|
+
|
|
684
|
+
// Check for re-hydration required error
|
|
685
|
+
if (!response.success && response.error?.includes?.('COMPONENT_REHYDRATION_REQUIRED')) {
|
|
686
|
+
log('Component re-hydration required - attempting automatic re-hydration')
|
|
687
|
+
const rehydrated = await attemptRehydration()
|
|
688
|
+
if (rehydrated) {
|
|
689
|
+
log('Re-hydration successful - retrying action with new component ID')
|
|
690
|
+
// Use the updated componentId after re-hydration
|
|
691
|
+
const currentComponentId = componentId // This should be updated by re-hydration
|
|
692
|
+
const retryMessage: WebSocketMessage = {
|
|
693
|
+
type: 'CALL_ACTION',
|
|
694
|
+
componentId: currentComponentId,
|
|
695
|
+
action,
|
|
696
|
+
payload,
|
|
697
|
+
expectResponse: true
|
|
698
|
+
}
|
|
699
|
+
await sendMessageAndWait(retryMessage, 5000)
|
|
700
|
+
} else {
|
|
701
|
+
throw new Error('Component lost connection and could not be recovered')
|
|
702
|
+
}
|
|
703
|
+
} else if (!response.success) {
|
|
704
|
+
throw new Error(response.error || 'Action failed')
|
|
705
|
+
}
|
|
706
|
+
} catch (wsError: any) {
|
|
707
|
+
// Check if the WebSocket error is about re-hydration
|
|
708
|
+
if (wsError.message?.includes?.('COMPONENT_REHYDRATION_REQUIRED')) {
|
|
709
|
+
log('Component re-hydration required (from WebSocket error) - attempting automatic re-hydration')
|
|
710
|
+
const rehydrated = await attemptRehydration()
|
|
711
|
+
if (rehydrated) {
|
|
712
|
+
log('Re-hydration successful - retrying action with new component ID')
|
|
713
|
+
// Use the updated componentId after re-hydration
|
|
714
|
+
const currentComponentId = componentId
|
|
715
|
+
const retryMessage: WebSocketMessage = {
|
|
716
|
+
type: 'CALL_ACTION',
|
|
717
|
+
componentId: currentComponentId,
|
|
718
|
+
action,
|
|
719
|
+
payload,
|
|
720
|
+
expectResponse: true
|
|
721
|
+
}
|
|
722
|
+
await sendMessageAndWait(retryMessage, 5000)
|
|
723
|
+
} else {
|
|
724
|
+
throw new Error('Component lost connection and could not be recovered')
|
|
725
|
+
}
|
|
726
|
+
} else {
|
|
727
|
+
// Re-throw other WebSocket errors
|
|
728
|
+
throw wsError
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
log('Action sent to server - waiting for server state update', { action, payload })
|
|
733
|
+
} catch (err) {
|
|
734
|
+
const errorMessage = err instanceof Error ? err.message : 'Action failed'
|
|
735
|
+
setError(errorMessage)
|
|
736
|
+
log('Action failed', { action, error: err })
|
|
737
|
+
throw err
|
|
738
|
+
} finally {
|
|
739
|
+
// No loading state for actions to prevent UI flicker
|
|
740
|
+
}
|
|
741
|
+
}, [componentId, connected, sendMessage, log])
|
|
742
|
+
|
|
743
|
+
// Call action and wait for specific return value
|
|
744
|
+
const callAndWait = useCallback(async (action: string, payload?: any, timeout?: number): Promise<any> => {
|
|
745
|
+
if (!componentId || !connected) {
|
|
746
|
+
throw new Error('Component not mounted or WebSocket not connected')
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
log('Calling server action and waiting for response', { action, payload })
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
// Don't set loading for callAndWait to avoid UI flicker
|
|
753
|
+
|
|
754
|
+
const message: WebSocketMessage = {
|
|
755
|
+
type: 'CALL_ACTION',
|
|
756
|
+
componentId,
|
|
757
|
+
action,
|
|
758
|
+
payload
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Send action and wait for response
|
|
762
|
+
const result = await sendMessageAndWait(message, timeout)
|
|
763
|
+
|
|
764
|
+
log('Action completed with result', { action, payload, result })
|
|
765
|
+
return result
|
|
766
|
+
} catch (err) {
|
|
767
|
+
const errorMessage = err instanceof Error ? err.message : 'Action failed'
|
|
768
|
+
setError(errorMessage)
|
|
769
|
+
log('Action failed', { action, error: err })
|
|
770
|
+
throw err
|
|
771
|
+
} finally {
|
|
772
|
+
// No loading state for actions to prevent UI flicker
|
|
773
|
+
}
|
|
774
|
+
}, [componentId, connected, sendMessageAndWait, log])
|
|
775
|
+
|
|
776
|
+
// Auto-mount with re-hydration attempt
|
|
777
|
+
useEffect(() => {
|
|
778
|
+
if (connected && autoMount && !mountedRef.current && !componentId && !mountingRef.current && !rehydrating) {
|
|
779
|
+
log('Auto-mounting with re-hydration attempt', {
|
|
780
|
+
connected,
|
|
781
|
+
autoMount,
|
|
782
|
+
mounted: mountedRef.current,
|
|
783
|
+
componentId,
|
|
784
|
+
mounting: mountingRef.current,
|
|
785
|
+
rehydrating
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
// First try re-hydration, then fall back to normal mount
|
|
789
|
+
attemptRehydration().then(rehydrated => {
|
|
790
|
+
if (!rehydrated && !mountedRef.current && !componentId && !mountingRef.current) {
|
|
791
|
+
log('Re-hydration failed or not available, proceeding with normal mount')
|
|
792
|
+
mount()
|
|
793
|
+
} else if (rehydrated) {
|
|
794
|
+
log('Re-hydration successful, skipping normal mount')
|
|
795
|
+
}
|
|
796
|
+
}).catch(error => {
|
|
797
|
+
log('Re-hydration attempt failed with error, proceeding with normal mount', error)
|
|
798
|
+
if (!mountedRef.current && !componentId && !mountingRef.current) {
|
|
799
|
+
mount()
|
|
800
|
+
}
|
|
801
|
+
})
|
|
802
|
+
}
|
|
803
|
+
}, [connected, autoMount, mount, componentId, log, rehydrating, attemptRehydration])
|
|
804
|
+
|
|
805
|
+
// Monitor connection status changes and force reconnection
|
|
806
|
+
const prevConnectedRef = useRef(connected)
|
|
807
|
+
useEffect(() => {
|
|
808
|
+
const wasConnected = prevConnectedRef.current
|
|
809
|
+
const isConnected = connected
|
|
810
|
+
|
|
811
|
+
log('🔍 Connection status change detected:', {
|
|
812
|
+
wasConnected,
|
|
813
|
+
isConnected,
|
|
814
|
+
componentMounted: mountedRef.current,
|
|
815
|
+
componentId
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
// If we lost connection and had a component mounted, prepare for reconnection
|
|
819
|
+
if (wasConnected && !isConnected && mountedRef.current) {
|
|
820
|
+
log('🔄 Connection lost - marking component for remount on reconnection')
|
|
821
|
+
mountedRef.current = false
|
|
822
|
+
setComponentId(null)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// If we reconnected and don't have a component mounted, try re-hydration first
|
|
826
|
+
if (!wasConnected && isConnected && !mountedRef.current && !mountingRef.current && !rehydrating) {
|
|
827
|
+
log('🔗 Connection restored - checking for persisted state to re-hydrate')
|
|
828
|
+
|
|
829
|
+
// Small delay to ensure WebSocket is fully established
|
|
830
|
+
setTimeout(() => {
|
|
831
|
+
if (!mountedRef.current && !mountingRef.current && !rehydrating) {
|
|
832
|
+
const persistedState = getPersistedState(componentName)
|
|
833
|
+
|
|
834
|
+
if (persistedState && persistedState.signedState) {
|
|
835
|
+
log('🔄 Found persisted state - attempting re-hydration on reconnection', {
|
|
836
|
+
hasSignedState: !!persistedState.signedState,
|
|
837
|
+
lastUpdate: persistedState.lastUpdate,
|
|
838
|
+
age: Date.now() - persistedState.lastUpdate
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
attemptRehydration().then(success => {
|
|
842
|
+
if (success) {
|
|
843
|
+
log('✅ Re-hydration successful on reconnection')
|
|
844
|
+
} else {
|
|
845
|
+
log('❌ Re-hydration failed on reconnection - falling back to mount')
|
|
846
|
+
mount()
|
|
847
|
+
}
|
|
848
|
+
}).catch(error => {
|
|
849
|
+
log('💥 Re-hydration error on reconnection - falling back to mount', error)
|
|
850
|
+
mount()
|
|
851
|
+
})
|
|
852
|
+
} else {
|
|
853
|
+
log('🚀 No persisted state found - executing fresh mount after reconnection')
|
|
854
|
+
mount()
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}, 100)
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// If connected but no component after some time, force mount (fallback)
|
|
861
|
+
if (isConnected && !mountedRef.current && !mountingRef.current && !rehydrating) {
|
|
862
|
+
log('🔄 Connected but no component - scheduling fallback mount attempt')
|
|
863
|
+
setTimeout(() => {
|
|
864
|
+
if (connected && !mountedRef.current && !mountingRef.current && !rehydrating) {
|
|
865
|
+
log('🚀 Forcing fallback mount for orphaned connection')
|
|
866
|
+
mount()
|
|
867
|
+
}
|
|
868
|
+
}, 500) // Increased timeout to allow for re-hydration attempts
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
prevConnectedRef.current = connected
|
|
872
|
+
}, [connected, mount, componentId, log, attemptRehydration, componentName, rehydrating])
|
|
873
|
+
|
|
874
|
+
// Unmount on cleanup
|
|
875
|
+
useEffect(() => {
|
|
876
|
+
return () => {
|
|
877
|
+
if (mountedRef.current) {
|
|
878
|
+
unmount()
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}, [unmount])
|
|
882
|
+
|
|
883
|
+
// Update error from WebSocket
|
|
884
|
+
useEffect(() => {
|
|
885
|
+
if (wsError) {
|
|
886
|
+
setError(wsError)
|
|
887
|
+
}
|
|
888
|
+
}, [wsError])
|
|
889
|
+
|
|
890
|
+
// Helper for controlled inputs (temporary local state + server commit)
|
|
891
|
+
const useControlledField = useCallback(<K extends keyof T>(
|
|
892
|
+
field: K,
|
|
893
|
+
action: string = 'updateField'
|
|
894
|
+
) => {
|
|
895
|
+
const [tempValue, setTempValue] = useState<T[K]>(stateData[field])
|
|
896
|
+
|
|
897
|
+
// Always sync temp value with server state (server is source of truth)
|
|
898
|
+
useEffect(() => {
|
|
899
|
+
setTempValue(stateData[field])
|
|
900
|
+
}, [stateData[field]])
|
|
901
|
+
|
|
902
|
+
const commitValue = useCallback(async (value?: T[K]) => {
|
|
903
|
+
const valueToCommit = value !== undefined ? value : tempValue
|
|
904
|
+
log('Committing field to server', { field, value: valueToCommit })
|
|
905
|
+
|
|
906
|
+
// Call server action - server will update state and send back changes
|
|
907
|
+
await call(action, { field, value: valueToCommit })
|
|
908
|
+
|
|
909
|
+
// No local state mutation - wait for server response
|
|
910
|
+
}, [tempValue, field, action])
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
value: tempValue,
|
|
914
|
+
setValue: setTempValue,
|
|
915
|
+
commit: commitValue,
|
|
916
|
+
isDirty: JSON.stringify(tempValue) !== JSON.stringify(stateData[field])
|
|
917
|
+
}
|
|
918
|
+
}, [stateData, call, log])
|
|
919
|
+
|
|
920
|
+
// Calculate detailed status
|
|
921
|
+
const getStatus = () => {
|
|
922
|
+
if (!connected) return 'connecting'
|
|
923
|
+
if (rehydrating) return 'reconnecting'
|
|
924
|
+
if (mountLoading) return 'loading' // Only show loading for mount operations
|
|
925
|
+
if (error) return 'error'
|
|
926
|
+
if (!componentId) return 'mounting'
|
|
927
|
+
if (hybridState.status === 'disconnected') return 'disconnected'
|
|
928
|
+
return 'synced'
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const status = getStatus()
|
|
932
|
+
|
|
933
|
+
// Debug log for state return (throttled)
|
|
934
|
+
const lastReturnedStateRef = useRef<string>('')
|
|
935
|
+
if (debug) {
|
|
936
|
+
const currentStateString = JSON.stringify(stateData)
|
|
937
|
+
if (currentStateString !== lastReturnedStateRef.current) {
|
|
938
|
+
console.log('🎯 [Hook] Returning state to component:', stateData)
|
|
939
|
+
lastReturnedStateRef.current = currentStateString
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
// Server-driven state
|
|
945
|
+
state: stateData,
|
|
946
|
+
|
|
947
|
+
// Status
|
|
948
|
+
loading: mountLoading, // Only loading for mount operations
|
|
949
|
+
error,
|
|
950
|
+
connected,
|
|
951
|
+
componentId,
|
|
952
|
+
status,
|
|
953
|
+
|
|
954
|
+
// Actions (all server-driven)
|
|
955
|
+
call,
|
|
956
|
+
callAndWait,
|
|
957
|
+
mount,
|
|
958
|
+
unmount,
|
|
959
|
+
|
|
960
|
+
// WebSocket utilities
|
|
961
|
+
sendMessage,
|
|
962
|
+
sendMessageAndWait,
|
|
963
|
+
|
|
964
|
+
// Helper for forms
|
|
965
|
+
useControlledField
|
|
966
|
+
}
|
|
967
|
+
}
|