create-fluxstack 1.8.3 → 1.10.1
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/LIVE_COMPONENTS_REVIEW.md +781 -0
- package/README.md +653 -275
- package/app/client/src/App.tsx +39 -43
- package/app/client/src/lib/eden-api.ts +2 -7
- package/app/client/src/live/FileUploadExample.tsx +359 -0
- package/app/client/src/live/MinimalLiveClock.tsx +47 -0
- package/app/client/src/live/QuickUploadTest.tsx +193 -0
- package/app/client/src/main.tsx +10 -10
- package/app/client/src/vite-env.d.ts +1 -1
- package/app/client/tsconfig.app.json +45 -44
- package/app/client/tsconfig.node.json +25 -25
- package/app/server/index.ts +30 -103
- package/app/server/live/LiveFileUploadComponent.ts +77 -0
- package/app/server/live/register-components.ts +19 -19
- package/core/build/bundler.ts +202 -55
- package/core/build/index.ts +126 -2
- package/core/build/live-components-generator.ts +68 -1
- package/core/cli/generators/plugin.ts +6 -6
- package/core/cli/index.ts +232 -4
- package/core/client/LiveComponentsProvider.tsx +3 -9
- package/core/client/hooks/AdaptiveChunkSizer.ts +215 -0
- package/core/client/hooks/useChunkedUpload.ts +112 -61
- package/core/client/hooks/useHybridLiveComponent.ts +80 -26
- package/core/client/hooks/useTypedLiveComponent.ts +133 -0
- package/core/client/hooks/useWebSocket.ts +4 -16
- package/core/client/index.ts +20 -2
- package/core/framework/server.ts +181 -8
- package/core/live/ComponentRegistry.ts +5 -1
- package/core/plugins/built-in/index.ts +8 -5
- package/core/plugins/built-in/live-components/commands/create-live-component.ts +55 -63
- package/core/plugins/built-in/vite/index.ts +75 -187
- package/core/plugins/built-in/vite/vite-dev.ts +88 -0
- package/core/plugins/registry.ts +54 -2
- package/core/plugins/types.ts +86 -2
- package/core/server/index.ts +1 -2
- package/core/server/live/ComponentRegistry.ts +14 -5
- package/core/server/live/FileUploadManager.ts +22 -25
- package/core/server/live/auto-generated-components.ts +29 -26
- package/core/server/live/websocket-plugin.ts +19 -5
- package/core/server/plugins/static-files-plugin.ts +49 -240
- package/core/server/plugins/swagger.ts +33 -33
- package/core/types/build.ts +22 -0
- package/core/types/plugin.ts +9 -1
- package/core/types/types.ts +137 -0
- package/core/utils/logger/startup-banner.ts +20 -4
- package/core/utils/version.ts +6 -6
- package/create-fluxstack.ts +7 -7
- package/eslint.config.js +23 -23
- package/package.json +3 -2
- package/plugins/crypto-auth/server/middlewares.ts +19 -19
- package/tsconfig.json +52 -51
- package/workspace.json +5 -5
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from 'react'
|
|
2
|
-
import type
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { AdaptiveChunkSizer, type AdaptiveChunkConfig } from './AdaptiveChunkSizer'
|
|
3
|
+
import type {
|
|
4
|
+
FileUploadStartMessage,
|
|
5
|
+
FileUploadChunkMessage,
|
|
5
6
|
FileUploadCompleteMessage,
|
|
6
7
|
FileUploadProgressResponse,
|
|
7
8
|
FileUploadCompleteResponse
|
|
8
9
|
} from '@/core/types/types'
|
|
9
10
|
|
|
10
11
|
export interface ChunkedUploadOptions {
|
|
11
|
-
chunkSize?: number // Default 64KB
|
|
12
|
+
chunkSize?: number // Default 64KB (used as initial if adaptive is enabled)
|
|
12
13
|
maxFileSize?: number // Default 50MB
|
|
13
14
|
allowedTypes?: string[]
|
|
14
15
|
sendMessageAndWait?: (message: any, timeout?: number) => Promise<any> // WebSocket send function
|
|
15
16
|
onProgress?: (progress: number, bytesUploaded: number, totalBytes: number) => void
|
|
16
17
|
onComplete?: (response: FileUploadCompleteResponse) => void
|
|
17
18
|
onError?: (error: string) => void
|
|
19
|
+
// Adaptive chunking options
|
|
20
|
+
adaptiveChunking?: boolean // Enable adaptive chunk sizing (default: false)
|
|
21
|
+
adaptiveConfig?: Partial<AdaptiveChunkConfig> // Adaptive chunking configuration
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export interface ChunkedUploadState {
|
|
@@ -27,7 +31,7 @@ export interface ChunkedUploadState {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function useChunkedUpload(componentId: string, options: ChunkedUploadOptions = {}) {
|
|
30
|
-
|
|
34
|
+
|
|
31
35
|
const [state, setState] = useState<ChunkedUploadState>({
|
|
32
36
|
uploading: false,
|
|
33
37
|
progress: 0,
|
|
@@ -44,42 +48,23 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti
|
|
|
44
48
|
sendMessageAndWait,
|
|
45
49
|
onProgress,
|
|
46
50
|
onComplete,
|
|
47
|
-
onError
|
|
51
|
+
onError,
|
|
52
|
+
adaptiveChunking = false,
|
|
53
|
+
adaptiveConfig
|
|
48
54
|
} = options
|
|
49
55
|
|
|
50
56
|
const abortControllerRef = useRef<AbortController | null>(null)
|
|
57
|
+
const adaptiveSizerRef = useRef<AdaptiveChunkSizer | null>(null)
|
|
51
58
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const uint8Array = new Uint8Array(arrayBuffer)
|
|
60
|
-
|
|
61
|
-
// Split binary data into chunks first, then convert each chunk to base64
|
|
62
|
-
const chunks: string[] = []
|
|
63
|
-
for (let i = 0; i < uint8Array.length; i += chunkSize) {
|
|
64
|
-
const chunkEnd = Math.min(i + chunkSize, uint8Array.length)
|
|
65
|
-
const chunkBytes = uint8Array.slice(i, chunkEnd)
|
|
66
|
-
|
|
67
|
-
// Convert chunk to base64
|
|
68
|
-
let binary = ''
|
|
69
|
-
for (let j = 0; j < chunkBytes.length; j++) {
|
|
70
|
-
binary += String.fromCharCode(chunkBytes[j])
|
|
71
|
-
}
|
|
72
|
-
const base64Chunk = btoa(binary)
|
|
73
|
-
chunks.push(base64Chunk)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
resolve(chunks)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
reader.onerror = () => reject(new Error('Failed to read file'))
|
|
80
|
-
reader.readAsArrayBuffer(file)
|
|
59
|
+
// Initialize adaptive chunk sizer if enabled
|
|
60
|
+
if (adaptiveChunking && !adaptiveSizerRef.current) {
|
|
61
|
+
adaptiveSizerRef.current = new AdaptiveChunkSizer({
|
|
62
|
+
initialChunkSize: chunkSize,
|
|
63
|
+
minChunkSize: 16 * 1024, // 16KB min
|
|
64
|
+
maxChunkSize: 1024 * 1024, // 1MB max
|
|
65
|
+
...adaptiveConfig
|
|
81
66
|
})
|
|
82
|
-
}
|
|
67
|
+
}
|
|
83
68
|
|
|
84
69
|
// Start chunked upload
|
|
85
70
|
const uploadFile = useCallback(async (file: File) => {
|
|
@@ -90,8 +75,8 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti
|
|
|
90
75
|
return
|
|
91
76
|
}
|
|
92
77
|
|
|
93
|
-
// Validate file
|
|
94
|
-
if (!allowedTypes.includes(file.type)) {
|
|
78
|
+
// Validate file type (skip if allowedTypes is empty = accept all)
|
|
79
|
+
if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
|
|
95
80
|
const error = `Invalid file type: ${file.type}. Allowed: ${allowedTypes.join(', ')}`
|
|
96
81
|
setState(prev => ({ ...prev, error }))
|
|
97
82
|
onError?.(error)
|
|
@@ -120,13 +105,22 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti
|
|
|
120
105
|
totalBytes: file.size
|
|
121
106
|
})
|
|
122
107
|
|
|
123
|
-
console.log('🚀 Starting chunked upload:', {
|
|
108
|
+
console.log('🚀 Starting chunked upload:', {
|
|
109
|
+
uploadId,
|
|
110
|
+
filename: file.name,
|
|
111
|
+
size: file.size,
|
|
112
|
+
adaptiveChunking
|
|
113
|
+
})
|
|
124
114
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
115
|
+
// Reset adaptive sizer for new upload
|
|
116
|
+
if (adaptiveSizerRef.current) {
|
|
117
|
+
adaptiveSizerRef.current.reset()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get initial chunk size (adaptive or fixed)
|
|
121
|
+
const initialChunkSize = adaptiveSizerRef.current?.getChunkSize() ?? chunkSize
|
|
128
122
|
|
|
129
|
-
console.log(`📦
|
|
123
|
+
console.log(`📦 Initial chunk size: ${initialChunkSize} bytes${adaptiveChunking ? ' (adaptive)' : ''}`)
|
|
130
124
|
|
|
131
125
|
// Start upload
|
|
132
126
|
const startMessage: FileUploadStartMessage = {
|
|
@@ -147,35 +141,92 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti
|
|
|
147
141
|
|
|
148
142
|
console.log('✅ Upload started successfully')
|
|
149
143
|
|
|
150
|
-
//
|
|
151
|
-
|
|
144
|
+
// Read file as ArrayBuffer for dynamic chunking
|
|
145
|
+
const fileArrayBuffer = await file.arrayBuffer()
|
|
146
|
+
const fileData = new Uint8Array(fileArrayBuffer)
|
|
147
|
+
|
|
148
|
+
let offset = 0
|
|
149
|
+
let chunkIndex = 0
|
|
150
|
+
const estimatedTotalChunks = Math.ceil(file.size / initialChunkSize)
|
|
151
|
+
|
|
152
|
+
// Send chunks dynamically with adaptive sizing
|
|
153
|
+
while (offset < fileData.length) {
|
|
152
154
|
if (abortControllerRef.current?.signal.aborted) {
|
|
153
155
|
throw new Error('Upload cancelled')
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
// Get current chunk size (adaptive or fixed)
|
|
159
|
+
const currentChunkSize = adaptiveSizerRef.current?.getChunkSize() ?? chunkSize
|
|
160
|
+
const chunkEnd = Math.min(offset + currentChunkSize, fileData.length)
|
|
161
|
+
const chunkBytes = fileData.slice(offset, chunkEnd)
|
|
162
|
+
|
|
163
|
+
// Convert chunk to base64
|
|
164
|
+
let binary = ''
|
|
165
|
+
for (let j = 0; j < chunkBytes.length; j++) {
|
|
166
|
+
binary += String.fromCharCode(chunkBytes[j])
|
|
167
|
+
}
|
|
168
|
+
const base64Chunk = btoa(binary)
|
|
169
|
+
|
|
170
|
+
// Record chunk start time for adaptive sizing
|
|
171
|
+
const chunkStartTime = adaptiveSizerRef.current?.recordChunkStart(chunkIndex) ?? 0
|
|
172
|
+
|
|
156
173
|
const chunkMessage: FileUploadChunkMessage = {
|
|
157
174
|
type: 'FILE_UPLOAD_CHUNK',
|
|
158
175
|
componentId,
|
|
159
176
|
uploadId,
|
|
160
|
-
chunkIndex
|
|
161
|
-
totalChunks,
|
|
162
|
-
data:
|
|
163
|
-
requestId: `chunk-${uploadId}-${
|
|
177
|
+
chunkIndex,
|
|
178
|
+
totalChunks: estimatedTotalChunks, // Approximate, will be recalculated
|
|
179
|
+
data: base64Chunk,
|
|
180
|
+
requestId: `chunk-${uploadId}-${chunkIndex}`
|
|
164
181
|
}
|
|
165
182
|
|
|
166
|
-
console.log(`📤 Sending chunk ${
|
|
183
|
+
console.log(`📤 Sending chunk ${chunkIndex + 1} (size: ${chunkBytes.length} bytes)`)
|
|
167
184
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
185
|
+
try {
|
|
186
|
+
// Send chunk and wait for progress response
|
|
187
|
+
const progressResponse = await sendMessageAndWait(chunkMessage, 10000) as FileUploadProgressResponse
|
|
188
|
+
|
|
189
|
+
if (progressResponse) {
|
|
190
|
+
const { progress, bytesUploaded } = progressResponse
|
|
191
|
+
setState(prev => ({ ...prev, progress, bytesUploaded }))
|
|
192
|
+
onProgress?.(progress, bytesUploaded, file.size)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Record successful chunk upload for adaptive sizing
|
|
196
|
+
if (adaptiveSizerRef.current) {
|
|
197
|
+
adaptiveSizerRef.current.recordChunkComplete(
|
|
198
|
+
chunkIndex,
|
|
199
|
+
chunkBytes.length,
|
|
200
|
+
chunkStartTime,
|
|
201
|
+
true
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// Record failed chunk for adaptive sizing
|
|
206
|
+
if (adaptiveSizerRef.current) {
|
|
207
|
+
adaptiveSizerRef.current.recordChunkComplete(
|
|
208
|
+
chunkIndex,
|
|
209
|
+
chunkBytes.length,
|
|
210
|
+
chunkStartTime,
|
|
211
|
+
false
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
throw error
|
|
175
215
|
}
|
|
176
216
|
|
|
177
|
-
|
|
178
|
-
|
|
217
|
+
offset += chunkBytes.length
|
|
218
|
+
chunkIndex++
|
|
219
|
+
|
|
220
|
+
// Small delay to prevent overwhelming the server (only for fixed chunking)
|
|
221
|
+
if (!adaptiveChunking) {
|
|
222
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Log final adaptive stats
|
|
227
|
+
if (adaptiveSizerRef.current) {
|
|
228
|
+
const stats = adaptiveSizerRef.current.getStats()
|
|
229
|
+
console.log('📊 Final Adaptive Chunking Stats:', stats)
|
|
179
230
|
}
|
|
180
231
|
|
|
181
232
|
// Complete upload
|
|
@@ -219,10 +270,10 @@ export function useChunkedUpload(componentId: string, options: ChunkedUploadOpti
|
|
|
219
270
|
maxFileSize,
|
|
220
271
|
chunkSize,
|
|
221
272
|
sendMessageAndWait,
|
|
222
|
-
fileToChunks,
|
|
223
273
|
onProgress,
|
|
224
274
|
onComplete,
|
|
225
|
-
onError
|
|
275
|
+
onError,
|
|
276
|
+
adaptiveChunking
|
|
226
277
|
])
|
|
227
278
|
|
|
228
279
|
// Cancel upload
|
|
@@ -165,7 +165,13 @@ export function useHybridLiveComponent<T = any>(
|
|
|
165
165
|
room,
|
|
166
166
|
userId,
|
|
167
167
|
autoMount = true,
|
|
168
|
-
debug = false
|
|
168
|
+
debug = false,
|
|
169
|
+
onConnect,
|
|
170
|
+
onMount,
|
|
171
|
+
onDisconnect,
|
|
172
|
+
onRehydrate,
|
|
173
|
+
onError,
|
|
174
|
+
onStateChange
|
|
169
175
|
} = options
|
|
170
176
|
|
|
171
177
|
// Use Live Components context (singleton WebSocket connection)
|
|
@@ -226,9 +232,13 @@ export function useHybridLiveComponent<T = any>(
|
|
|
226
232
|
case 'STATE_UPDATE':
|
|
227
233
|
if (message.payload?.state) {
|
|
228
234
|
const newState = message.payload.state
|
|
235
|
+
const oldState = stateData
|
|
229
236
|
updateState(newState, 'server')
|
|
230
237
|
setLastServerState(newState)
|
|
231
238
|
|
|
239
|
+
// Call onStateChange callback
|
|
240
|
+
onStateChange?.(newState, oldState)
|
|
241
|
+
|
|
232
242
|
if (message.payload?.signedState) {
|
|
233
243
|
setCurrentSignedState(message.payload.signedState)
|
|
234
244
|
persistComponentState(componentName, message.payload.signedState, room, userId)
|
|
@@ -255,6 +265,9 @@ export function useHybridLiveComponent<T = any>(
|
|
|
255
265
|
|
|
256
266
|
setRehydrating(false)
|
|
257
267
|
setError(null)
|
|
268
|
+
|
|
269
|
+
// Call onRehydrate callback
|
|
270
|
+
onRehydrate?.()
|
|
258
271
|
}
|
|
259
272
|
break
|
|
260
273
|
|
|
@@ -266,10 +279,17 @@ export function useHybridLiveComponent<T = any>(
|
|
|
266
279
|
lastKnownComponentIdRef.current = message.result.newComponentId
|
|
267
280
|
setRehydrating(false)
|
|
268
281
|
setError(null)
|
|
282
|
+
|
|
283
|
+
// Call onRehydrate callback
|
|
284
|
+
onRehydrate?.()
|
|
269
285
|
} else if (!message.success) {
|
|
270
286
|
log('❌ Re-hydration failed', message.error)
|
|
271
287
|
setRehydrating(false)
|
|
272
|
-
|
|
288
|
+
const errorMessage = message.error || 'Re-hydration failed'
|
|
289
|
+
setError(errorMessage)
|
|
290
|
+
|
|
291
|
+
// Call onError callback
|
|
292
|
+
onError?.(errorMessage)
|
|
273
293
|
}
|
|
274
294
|
break
|
|
275
295
|
|
|
@@ -283,14 +303,16 @@ export function useHybridLiveComponent<T = any>(
|
|
|
283
303
|
break
|
|
284
304
|
|
|
285
305
|
case 'ERROR':
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
306
|
+
const errorMsg = message.payload?.error || 'Unknown error'
|
|
307
|
+
if (errorMsg.includes('COMPONENT_REHYDRATION_REQUIRED')) {
|
|
288
308
|
log('🔄 Component re-hydration required from ERROR')
|
|
289
309
|
if (!rehydrating) {
|
|
290
310
|
attemptRehydration()
|
|
291
311
|
}
|
|
292
312
|
} else {
|
|
293
|
-
setError(
|
|
313
|
+
setError(errorMsg)
|
|
314
|
+
// Call onError callback
|
|
315
|
+
onError?.(errorMsg)
|
|
294
316
|
}
|
|
295
317
|
break
|
|
296
318
|
|
|
@@ -305,7 +327,7 @@ export function useHybridLiveComponent<T = any>(
|
|
|
305
327
|
log('🗑️ Unregistering component from WebSocket context')
|
|
306
328
|
unregister()
|
|
307
329
|
}
|
|
308
|
-
}, [componentId, registerComponent, unregisterComponent, log, updateState, componentName, room, userId, rehydrating])
|
|
330
|
+
}, [componentId, registerComponent, unregisterComponent, log, updateState, componentName, room, userId, rehydrating, stateData, onStateChange, onRehydrate, onError])
|
|
309
331
|
|
|
310
332
|
// Automatic re-hydration on reconnection
|
|
311
333
|
const attemptRehydration = useCallback(async () => {
|
|
@@ -361,10 +383,20 @@ export function useHybridLiveComponent<T = any>(
|
|
|
361
383
|
if (response?.success && response?.result?.newComponentId) {
|
|
362
384
|
setComponentId(response.result.newComponentId)
|
|
363
385
|
lastKnownComponentIdRef.current = response.result.newComponentId
|
|
386
|
+
mountedRef.current = true
|
|
387
|
+
|
|
388
|
+
// Call onRehydrate callback after React has processed the state update
|
|
389
|
+
// This ensures the component is registered to receive messages before the callback runs
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
onRehydrate?.()
|
|
392
|
+
}, 0)
|
|
393
|
+
|
|
364
394
|
return true
|
|
365
395
|
} else {
|
|
366
396
|
clearPersistedState(componentName)
|
|
367
|
-
|
|
397
|
+
const errorMsg = response?.error || 'Re-hydration failed'
|
|
398
|
+
setError(errorMsg)
|
|
399
|
+
onError?.(errorMsg)
|
|
368
400
|
return false
|
|
369
401
|
}
|
|
370
402
|
|
|
@@ -383,7 +415,7 @@ export function useHybridLiveComponent<T = any>(
|
|
|
383
415
|
globalRehydrationAttempts.set(componentName, rehydrationPromise)
|
|
384
416
|
|
|
385
417
|
return await rehydrationPromise
|
|
386
|
-
}, [connected, rehydrating, componentName, contextSendMessageAndWait, log])
|
|
418
|
+
}, [connected, rehydrating, componentName, contextSendMessageAndWait, log, onRehydrate, onError])
|
|
387
419
|
|
|
388
420
|
// Mount component
|
|
389
421
|
const mount = useCallback(async () => {
|
|
@@ -412,6 +444,7 @@ export function useHybridLiveComponent<T = any>(
|
|
|
412
444
|
if (response?.success && response?.result?.componentId) {
|
|
413
445
|
const newComponentId = response.result.componentId
|
|
414
446
|
setComponentId(newComponentId)
|
|
447
|
+
lastKnownComponentIdRef.current = newComponentId
|
|
415
448
|
mountedRef.current = true
|
|
416
449
|
|
|
417
450
|
if (response.result.signedState) {
|
|
@@ -425,6 +458,12 @@ export function useHybridLiveComponent<T = any>(
|
|
|
425
458
|
}
|
|
426
459
|
|
|
427
460
|
log('✅ Component mounted successfully', { componentId: newComponentId })
|
|
461
|
+
|
|
462
|
+
// Call onMount callback after React has processed the state update
|
|
463
|
+
// This ensures the component is registered to receive messages before the callback runs
|
|
464
|
+
setTimeout(() => {
|
|
465
|
+
onMount?.()
|
|
466
|
+
}, 0)
|
|
428
467
|
} else {
|
|
429
468
|
throw new Error(response?.error || 'No component ID returned from server')
|
|
430
469
|
}
|
|
@@ -433,6 +472,9 @@ export function useHybridLiveComponent<T = any>(
|
|
|
433
472
|
setError(errorMessage)
|
|
434
473
|
log('❌ Mount failed', err)
|
|
435
474
|
|
|
475
|
+
// Call onError callback
|
|
476
|
+
onError?.(errorMessage)
|
|
477
|
+
|
|
436
478
|
if (!fallbackToLocal) {
|
|
437
479
|
throw err
|
|
438
480
|
}
|
|
@@ -440,7 +482,7 @@ export function useHybridLiveComponent<T = any>(
|
|
|
440
482
|
setMountLoading(false)
|
|
441
483
|
mountingRef.current = false
|
|
442
484
|
}
|
|
443
|
-
}, [connected, componentName, initialState, room, userId, contextSendMessageAndWait, log, fallbackToLocal, updateState])
|
|
485
|
+
}, [connected, componentName, initialState, room, userId, contextSendMessageAndWait, log, fallbackToLocal, updateState, onMount, onError])
|
|
444
486
|
|
|
445
487
|
// Unmount component
|
|
446
488
|
const unmount = useCallback(async () => {
|
|
@@ -465,14 +507,16 @@ export function useHybridLiveComponent<T = any>(
|
|
|
465
507
|
|
|
466
508
|
// Server-only actions
|
|
467
509
|
const call = useCallback(async (action: string, payload?: any): Promise<void> => {
|
|
468
|
-
|
|
510
|
+
// Use ref as fallback for componentId (handles timing issues after rehydration)
|
|
511
|
+
const currentComponentId = componentId || lastKnownComponentIdRef.current
|
|
512
|
+
if (!currentComponentId || !connected) {
|
|
469
513
|
throw new Error('Component not mounted or WebSocket not connected')
|
|
470
514
|
}
|
|
471
515
|
|
|
472
516
|
try {
|
|
473
517
|
const message: WebSocketMessage = {
|
|
474
518
|
type: 'CALL_ACTION',
|
|
475
|
-
componentId,
|
|
519
|
+
componentId: currentComponentId,
|
|
476
520
|
action,
|
|
477
521
|
payload
|
|
478
522
|
}
|
|
@@ -482,10 +526,11 @@ export function useHybridLiveComponent<T = any>(
|
|
|
482
526
|
if (!response.success && response.error?.includes?.('COMPONENT_REHYDRATION_REQUIRED')) {
|
|
483
527
|
const rehydrated = await attemptRehydration()
|
|
484
528
|
if (rehydrated) {
|
|
485
|
-
//
|
|
529
|
+
// Use updated ref for retry
|
|
530
|
+
const retryComponentId = lastKnownComponentIdRef.current || currentComponentId
|
|
486
531
|
const retryMessage: WebSocketMessage = {
|
|
487
532
|
type: 'CALL_ACTION',
|
|
488
|
-
componentId,
|
|
533
|
+
componentId: retryComponentId,
|
|
489
534
|
action,
|
|
490
535
|
payload
|
|
491
536
|
}
|
|
@@ -505,14 +550,16 @@ export function useHybridLiveComponent<T = any>(
|
|
|
505
550
|
|
|
506
551
|
// Call action and wait for specific return value
|
|
507
552
|
const callAndWait = useCallback(async (action: string, payload?: any, timeout?: number): Promise<any> => {
|
|
508
|
-
|
|
553
|
+
// Use ref as fallback for componentId (handles timing issues after rehydration)
|
|
554
|
+
const currentComponentId = componentId || lastKnownComponentIdRef.current
|
|
555
|
+
if (!currentComponentId || !connected) {
|
|
509
556
|
throw new Error('Component not mounted or WebSocket not connected')
|
|
510
557
|
}
|
|
511
558
|
|
|
512
559
|
try {
|
|
513
560
|
const message: WebSocketMessage = {
|
|
514
561
|
type: 'CALL_ACTION',
|
|
515
|
-
componentId,
|
|
562
|
+
componentId: currentComponentId,
|
|
516
563
|
action,
|
|
517
564
|
payload
|
|
518
565
|
}
|
|
@@ -550,24 +597,31 @@ export function useHybridLiveComponent<T = any>(
|
|
|
550
597
|
if (wasConnected && !isConnected && mountedRef.current) {
|
|
551
598
|
mountedRef.current = false
|
|
552
599
|
setComponentId(null)
|
|
600
|
+
// Call onDisconnect callback
|
|
601
|
+
onDisconnect?.()
|
|
553
602
|
}
|
|
554
603
|
|
|
555
|
-
if (!wasConnected && isConnected
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const persistedState = getPersistedState(componentName)
|
|
604
|
+
if (!wasConnected && isConnected) {
|
|
605
|
+
// Call onConnect callback when WebSocket connects
|
|
606
|
+
onConnect?.()
|
|
559
607
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
608
|
+
if (!mountedRef.current && !mountingRef.current && !rehydrating) {
|
|
609
|
+
setTimeout(() => {
|
|
610
|
+
if (!mountedRef.current && !mountingRef.current && !rehydrating) {
|
|
611
|
+
const persistedState = getPersistedState(componentName)
|
|
612
|
+
|
|
613
|
+
if (persistedState?.signedState) {
|
|
614
|
+
attemptRehydration()
|
|
615
|
+
} else {
|
|
616
|
+
mount()
|
|
617
|
+
}
|
|
564
618
|
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
619
|
+
}, 100)
|
|
620
|
+
}
|
|
567
621
|
}
|
|
568
622
|
|
|
569
623
|
prevConnectedRef.current = connected
|
|
570
|
-
}, [connected, mount, componentId, attemptRehydration, componentName, rehydrating])
|
|
624
|
+
}, [connected, mount, componentId, attemptRehydration, componentName, rehydrating, onDisconnect, onConnect])
|
|
571
625
|
|
|
572
626
|
// Unmount on cleanup
|
|
573
627
|
useEffect(() => {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 🔥 Typed Live Component Hook - Full Type Inference for Actions
|
|
2
|
+
// Similar to Eden Treaty - automatic type inference from backend components
|
|
3
|
+
|
|
4
|
+
import { useHybridLiveComponent } from './useHybridLiveComponent'
|
|
5
|
+
import type { UseHybridLiveComponentReturn } from './useHybridLiveComponent'
|
|
6
|
+
import type {
|
|
7
|
+
LiveComponent,
|
|
8
|
+
InferComponentState,
|
|
9
|
+
HybridComponentOptions,
|
|
10
|
+
UseTypedLiveComponentReturn,
|
|
11
|
+
ActionNames,
|
|
12
|
+
ActionPayload,
|
|
13
|
+
ActionReturn
|
|
14
|
+
} from '@/core/types/types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Type-safe Live Component hook with automatic action inference
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // Backend component definition
|
|
21
|
+
* class LiveClockComponent extends LiveComponent<LiveClockState> {
|
|
22
|
+
* async setTimeFormat(payload: { format: '12h' | '24h' }) { ... }
|
|
23
|
+
* async toggleSeconds(payload?: { showSeconds?: boolean }) { ... }
|
|
24
|
+
* async getServerInfo() { ... }
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* // Frontend usage with full type inference
|
|
28
|
+
* const { state, call, callAndWait } = useTypedLiveComponent<LiveClockComponent>(
|
|
29
|
+
* 'LiveClock',
|
|
30
|
+
* initialState
|
|
31
|
+
* )
|
|
32
|
+
*
|
|
33
|
+
* // ✅ Autocomplete for action names
|
|
34
|
+
* await call('setTimeFormat', { format: '12h' })
|
|
35
|
+
*
|
|
36
|
+
* // ✅ Type error if wrong payload
|
|
37
|
+
* await call('setTimeFormat', { format: 'invalid' }) // Error!
|
|
38
|
+
*
|
|
39
|
+
* // ✅ Return type is inferred
|
|
40
|
+
* const result = await callAndWait('getServerInfo')
|
|
41
|
+
* // result is: { success: boolean; info: { serverTime: string; ... } }
|
|
42
|
+
*/
|
|
43
|
+
export function useTypedLiveComponent<T extends LiveComponent<any>>(
|
|
44
|
+
componentName: string,
|
|
45
|
+
initialState: InferComponentState<T>,
|
|
46
|
+
options: HybridComponentOptions = {}
|
|
47
|
+
): UseTypedLiveComponentReturn<T> {
|
|
48
|
+
// Use the original hook
|
|
49
|
+
const result = useHybridLiveComponent<InferComponentState<T>>(
|
|
50
|
+
componentName,
|
|
51
|
+
initialState,
|
|
52
|
+
options
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// Create convenience setValue helper
|
|
56
|
+
const setValue = async <K extends keyof InferComponentState<T>>(
|
|
57
|
+
key: K,
|
|
58
|
+
value: InferComponentState<T>[K]
|
|
59
|
+
): Promise<void> => {
|
|
60
|
+
await result.call('setValue', { key, value })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Return with typed call functions and setValue helper
|
|
64
|
+
// The types are enforced at compile time, runtime behavior is the same
|
|
65
|
+
return {
|
|
66
|
+
...result,
|
|
67
|
+
setValue
|
|
68
|
+
} as unknown as UseTypedLiveComponentReturn<T>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Helper type to create a component registry map
|
|
73
|
+
* Maps component names to their class types for even better DX
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* // Define your component map
|
|
77
|
+
* type MyComponents = {
|
|
78
|
+
* LiveClock: LiveClockComponent
|
|
79
|
+
* LiveCounter: LiveCounterComponent
|
|
80
|
+
* LiveChat: LiveChatComponent
|
|
81
|
+
* }
|
|
82
|
+
*
|
|
83
|
+
* // Create a typed hook for your app
|
|
84
|
+
* function useMyComponent<K extends keyof MyComponents>(
|
|
85
|
+
* name: K,
|
|
86
|
+
* initialState: ComponentState<MyComponents[K]>,
|
|
87
|
+
* options?: HybridComponentOptions
|
|
88
|
+
* ) {
|
|
89
|
+
* return useTypedLiveComponent<MyComponents[K]>(name, initialState, options)
|
|
90
|
+
* }
|
|
91
|
+
*
|
|
92
|
+
* // Usage
|
|
93
|
+
* const clock = useMyComponent('LiveClock', { ... })
|
|
94
|
+
* // TypeScript knows exactly which actions are available!
|
|
95
|
+
*/
|
|
96
|
+
export type ComponentRegistry<T extends Record<string, LiveComponent<any>>> = {
|
|
97
|
+
[K in keyof T]: T[K]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a factory for typed live component hooks
|
|
102
|
+
* Useful when you have many components and want simpler imports
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* // In your app/client/src/lib/live.ts
|
|
106
|
+
* import { createTypedLiveComponentHook } from '@/core/client/hooks/useTypedLiveComponent'
|
|
107
|
+
* import type { LiveClockComponent } from '@/app/server/live/LiveClockComponent'
|
|
108
|
+
*
|
|
109
|
+
* export const useLiveClock = createTypedLiveComponentHook<LiveClockComponent>('LiveClock')
|
|
110
|
+
*
|
|
111
|
+
* // Usage in component
|
|
112
|
+
* const { state, call } = useLiveClock({ currentTime: '', ... })
|
|
113
|
+
*/
|
|
114
|
+
export function createTypedLiveComponentHook<T extends LiveComponent<any>>(
|
|
115
|
+
componentName: string
|
|
116
|
+
) {
|
|
117
|
+
return function useComponent(
|
|
118
|
+
initialState: InferComponentState<T>,
|
|
119
|
+
options: HybridComponentOptions = {}
|
|
120
|
+
): UseTypedLiveComponentReturn<T> {
|
|
121
|
+
return useTypedLiveComponent<T>(componentName, initialState, options)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Re-export types for convenience
|
|
126
|
+
export type {
|
|
127
|
+
InferComponentState,
|
|
128
|
+
ActionNames,
|
|
129
|
+
ActionPayload,
|
|
130
|
+
ActionReturn,
|
|
131
|
+
UseTypedLiveComponentReturn,
|
|
132
|
+
HybridComponentOptions
|
|
133
|
+
}
|
|
@@ -32,22 +32,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
|
|
32
32
|
// Get WebSocket URL dynamically based on current environment
|
|
33
33
|
const getWebSocketUrl = () => {
|
|
34
34
|
if (typeof window === 'undefined') return 'ws://localhost:3000/api/live/ws'
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
// In production, use current origin with ws/wss protocol
|
|
40
|
-
if (!isLocalhost) {
|
|
41
|
-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
42
|
-
const url = `${protocol}//${window.location.host}/api/live/ws`
|
|
43
|
-
console.log('🔗 [WebSocket] Production URL:', url)
|
|
44
|
-
return url
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// In development, use backend server (port 3000 for integrated mode)
|
|
48
|
-
const url = 'ws://localhost:3000/api/live/ws'
|
|
49
|
-
console.log('🔗 [WebSocket] Development URL:', url)
|
|
50
|
-
return url
|
|
35
|
+
|
|
36
|
+
// Always use current host - works for both dev and production
|
|
37
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
38
|
+
return `${protocol}//${window.location.host}/api/live/ws`
|
|
51
39
|
}
|
|
52
40
|
|
|
53
41
|
const {
|