create-fluxstack 1.12.1 → 1.13.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/LLMD/INDEX.md +8 -1
- package/LLMD/agent.md +867 -0
- package/LLMD/config/environment-vars.md +30 -0
- package/LLMD/resources/live-auth.md +447 -0
- package/LLMD/resources/live-components.md +79 -21
- package/LLMD/resources/live-logging.md +158 -0
- package/LLMD/resources/live-upload.md +1 -1
- package/LLMD/resources/rest-auth.md +290 -0
- package/README.md +520 -340
- package/app/client/src/App.tsx +11 -0
- package/app/client/src/components/AppLayout.tsx +1 -0
- package/app/client/src/live/AuthDemo.tsx +332 -0
- package/app/server/auth/AuthManager.ts +213 -0
- package/app/server/auth/DevAuthProvider.ts +66 -0
- package/app/server/auth/HashManager.ts +123 -0
- package/app/server/auth/JWTAuthProvider.example.ts +101 -0
- package/app/server/auth/RateLimiter.ts +106 -0
- package/app/server/auth/contracts.ts +192 -0
- package/app/server/auth/guards/SessionGuard.ts +167 -0
- package/app/server/auth/guards/TokenGuard.ts +202 -0
- package/app/server/auth/index.ts +174 -0
- package/app/server/auth/middleware.ts +163 -0
- package/app/server/auth/providers/InMemoryProvider.ts +162 -0
- package/app/server/auth/sessions/SessionManager.ts +164 -0
- package/app/server/cache/CacheManager.ts +81 -0
- package/app/server/cache/MemoryDriver.ts +112 -0
- package/app/server/cache/contracts.ts +49 -0
- package/app/server/cache/index.ts +42 -0
- package/app/server/index.ts +14 -0
- package/app/server/live/LiveAdminPanel.ts +173 -0
- package/app/server/live/LiveCounter.ts +1 -0
- package/app/server/live/LiveLocalCounter.ts +13 -8
- package/app/server/live/LiveProtectedChat.ts +150 -0
- package/app/server/routes/auth.routes.ts +278 -0
- package/app/server/routes/index.ts +2 -0
- package/config/index.ts +8 -0
- package/config/system/auth.config.ts +49 -0
- package/config/system/session.config.ts +33 -0
- package/core/client/LiveComponentsProvider.tsx +76 -5
- package/core/client/components/Live.tsx +2 -1
- package/core/client/hooks/useLiveComponent.ts +47 -4
- package/core/client/index.ts +2 -1
- package/core/framework/server.ts +36 -4
- package/core/plugins/built-in/live-components/commands/create-live-component.ts +15 -8
- package/core/plugins/built-in/monitoring/index.ts +10 -3
- package/core/plugins/built-in/vite/index.ts +95 -18
- package/core/plugins/config.ts +5 -4
- package/core/plugins/discovery.ts +11 -2
- package/core/plugins/manager.ts +11 -5
- package/core/plugins/module-resolver.ts +1 -1
- package/core/plugins/registry.ts +53 -25
- package/core/server/live/ComponentRegistry.ts +79 -24
- package/core/server/live/LiveComponentPerformanceMonitor.ts +9 -8
- package/core/server/live/LiveLogger.ts +111 -0
- package/core/server/live/LiveRoomManager.ts +5 -4
- package/core/server/live/StateSignature.ts +644 -643
- package/core/server/live/auth/LiveAuthContext.ts +71 -0
- package/core/server/live/auth/LiveAuthManager.ts +304 -0
- package/core/server/live/auth/index.ts +19 -0
- package/core/server/live/auth/types.ts +179 -0
- package/core/server/live/auto-generated-components.ts +8 -2
- package/core/server/live/index.ts +16 -0
- package/core/server/live/websocket-plugin.ts +92 -16
- package/core/templates/create-project.ts +0 -3
- package/core/types/types.ts +133 -13
- package/core/utils/index.ts +17 -17
- package/core/utils/logger/index.ts +5 -2
- package/core/utils/version.ts +1 -1
- package/package.json +1 -8
- package/plugins/crypto-auth/index.ts +6 -0
- package/plugins/crypto-auth/server/CryptoAuthLiveProvider.ts +58 -0
- package/plugins/crypto-auth/server/index.ts +24 -21
- package/rest-tests/README.md +57 -0
- package/rest-tests/auth-token.http +113 -0
- package/rest-tests/auth.http +112 -0
- package/rest-tests/rooms-token.http +69 -0
- package/rest-tests/users-token.http +62 -0
- package/.dockerignore +0 -81
- package/Dockerfile +0 -70
- package/LIVE_COMPONENTS_REVIEW.md +0 -781
|
@@ -4,11 +4,23 @@
|
|
|
4
4
|
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
|
|
5
5
|
import type { WebSocketMessage, WebSocketResponse } from '../types/types'
|
|
6
6
|
|
|
7
|
+
/** Auth credentials to send during WebSocket connection */
|
|
8
|
+
export interface LiveAuthOptions {
|
|
9
|
+
/** JWT or opaque token */
|
|
10
|
+
token?: string
|
|
11
|
+
/** Provider name (if multiple auth providers configured) */
|
|
12
|
+
provider?: string
|
|
13
|
+
/** Additional credentials (publicKey, signature, etc.) */
|
|
14
|
+
[key: string]: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
export interface LiveComponentsContextValue {
|
|
8
18
|
connected: boolean
|
|
9
19
|
connecting: boolean
|
|
10
20
|
error: string | null
|
|
11
21
|
connectionId: string | null
|
|
22
|
+
/** Whether the WebSocket connection is authenticated */
|
|
23
|
+
authenticated: boolean
|
|
12
24
|
|
|
13
25
|
// Send message without waiting for response
|
|
14
26
|
sendMessage: (message: WebSocketMessage) => Promise<void>
|
|
@@ -28,6 +40,9 @@ export interface LiveComponentsContextValue {
|
|
|
28
40
|
// Manual reconnect
|
|
29
41
|
reconnect: () => void
|
|
30
42
|
|
|
43
|
+
// Authenticate (or re-authenticate) the WebSocket connection
|
|
44
|
+
authenticate: (credentials: LiveAuthOptions) => Promise<boolean>
|
|
45
|
+
|
|
31
46
|
// Get current WebSocket instance (for advanced use)
|
|
32
47
|
getWebSocket: () => WebSocket | null
|
|
33
48
|
}
|
|
@@ -37,6 +52,8 @@ const LiveComponentsContext = createContext<LiveComponentsContextValue | null>(n
|
|
|
37
52
|
export interface LiveComponentsProviderProps {
|
|
38
53
|
children: React.ReactNode
|
|
39
54
|
url?: string
|
|
55
|
+
/** Auth credentials to send on connection */
|
|
56
|
+
auth?: LiveAuthOptions
|
|
40
57
|
autoConnect?: boolean
|
|
41
58
|
reconnectInterval?: number
|
|
42
59
|
maxReconnectAttempts?: number
|
|
@@ -47,6 +64,7 @@ export interface LiveComponentsProviderProps {
|
|
|
47
64
|
export function LiveComponentsProvider({
|
|
48
65
|
children,
|
|
49
66
|
url,
|
|
67
|
+
auth,
|
|
50
68
|
autoConnect = true,
|
|
51
69
|
reconnectInterval = 1000,
|
|
52
70
|
maxReconnectAttempts = 5,
|
|
@@ -56,12 +74,23 @@ export function LiveComponentsProvider({
|
|
|
56
74
|
|
|
57
75
|
// Get WebSocket URL dynamically
|
|
58
76
|
const getWebSocketUrl = () => {
|
|
59
|
-
|
|
60
|
-
if (
|
|
77
|
+
let baseUrl: string
|
|
78
|
+
if (url) {
|
|
79
|
+
baseUrl = url
|
|
80
|
+
} else if (typeof window === 'undefined') {
|
|
81
|
+
baseUrl = 'ws://localhost:3000/api/live/ws'
|
|
82
|
+
} else {
|
|
83
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
84
|
+
baseUrl = `${protocol}//${window.location.host}/api/live/ws`
|
|
85
|
+
}
|
|
61
86
|
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
87
|
+
// Append auth token as query param if provided
|
|
88
|
+
if (auth?.token) {
|
|
89
|
+
const separator = baseUrl.includes('?') ? '&' : '?'
|
|
90
|
+
return `${baseUrl}${separator}token=${encodeURIComponent(auth.token)}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return baseUrl
|
|
65
94
|
}
|
|
66
95
|
|
|
67
96
|
const wsUrl = getWebSocketUrl()
|
|
@@ -71,6 +100,7 @@ export function LiveComponentsProvider({
|
|
|
71
100
|
const [connecting, setConnecting] = useState(false)
|
|
72
101
|
const [error, setError] = useState<string | null>(null)
|
|
73
102
|
const [connectionId, setConnectionId] = useState<string | null>(null)
|
|
103
|
+
const [authenticated, setAuthenticated] = useState(false)
|
|
74
104
|
|
|
75
105
|
// Refs
|
|
76
106
|
const wsRef = useRef<WebSocket | null>(null)
|
|
@@ -136,7 +166,30 @@ export function LiveComponentsProvider({
|
|
|
136
166
|
// Handle connection established
|
|
137
167
|
if (response.type === 'CONNECTION_ESTABLISHED') {
|
|
138
168
|
setConnectionId(response.connectionId || null)
|
|
169
|
+
setAuthenticated((response as any).authenticated || false)
|
|
139
170
|
log('🔗 Connection ID:', response.connectionId)
|
|
171
|
+
if ((response as any).authenticated) {
|
|
172
|
+
log('🔒 Authenticated as:', (response as any).userId)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// If auth credentials provided but not yet authenticated via query,
|
|
176
|
+
// send AUTH message with full credentials
|
|
177
|
+
if (auth && !auth.token && Object.keys(auth).some(k => auth[k])) {
|
|
178
|
+
sendMessageAndWait({
|
|
179
|
+
type: 'AUTH',
|
|
180
|
+
payload: auth
|
|
181
|
+
} as any).then(authResp => {
|
|
182
|
+
if ((authResp as any).authenticated) {
|
|
183
|
+
setAuthenticated(true)
|
|
184
|
+
log('🔒 Authenticated via message')
|
|
185
|
+
}
|
|
186
|
+
}).catch(() => {})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Handle auth response
|
|
191
|
+
if (response.type === 'AUTH_RESPONSE') {
|
|
192
|
+
setAuthenticated((response as any).authenticated || false)
|
|
140
193
|
}
|
|
141
194
|
|
|
142
195
|
// Handle pending requests (request-response pattern)
|
|
@@ -403,6 +456,22 @@ export function LiveComponentsProvider({
|
|
|
403
456
|
log('🗑️ Component unregistered', componentId)
|
|
404
457
|
}, [log])
|
|
405
458
|
|
|
459
|
+
// Authenticate (or re-authenticate) the WebSocket connection
|
|
460
|
+
const authenticate = useCallback(async (credentials: LiveAuthOptions): Promise<boolean> => {
|
|
461
|
+
try {
|
|
462
|
+
const response = await sendMessageAndWait({
|
|
463
|
+
type: 'AUTH',
|
|
464
|
+
payload: credentials
|
|
465
|
+
} as any, 5000)
|
|
466
|
+
|
|
467
|
+
const success = (response as any).authenticated || false
|
|
468
|
+
setAuthenticated(success)
|
|
469
|
+
return success
|
|
470
|
+
} catch {
|
|
471
|
+
return false
|
|
472
|
+
}
|
|
473
|
+
}, [sendMessageAndWait])
|
|
474
|
+
|
|
406
475
|
// Get WebSocket instance
|
|
407
476
|
const getWebSocket = useCallback(() => {
|
|
408
477
|
return wsRef.current
|
|
@@ -424,12 +493,14 @@ export function LiveComponentsProvider({
|
|
|
424
493
|
connecting,
|
|
425
494
|
error,
|
|
426
495
|
connectionId,
|
|
496
|
+
authenticated,
|
|
427
497
|
sendMessage,
|
|
428
498
|
sendMessageAndWait,
|
|
429
499
|
sendBinaryAndWait,
|
|
430
500
|
registerComponent,
|
|
431
501
|
unregisterComponent,
|
|
432
502
|
reconnect,
|
|
503
|
+
authenticate,
|
|
433
504
|
getWebSocket
|
|
434
505
|
}
|
|
435
506
|
|
|
@@ -53,10 +53,11 @@ type ExtractState<T> = T extends { new(...args: any[]): { state: infer S } }
|
|
|
53
53
|
: ExtractDefaultState<T>
|
|
54
54
|
|
|
55
55
|
// Extrai as Actions (métodos públicos async) da classe do servidor
|
|
56
|
+
// Filtra métodos internos do framework que não devem ser expostos como actions
|
|
56
57
|
type ExtractActions<T> = T extends { new(...args: any[]): infer Instance }
|
|
57
58
|
? {
|
|
58
59
|
[K in keyof Instance as Instance[K] extends (...args: any[]) => Promise<any>
|
|
59
|
-
? K extends 'setState' | 'getState' | 'getValue' | 'setValue' | 'setValues' | 'getSnapshot'
|
|
60
|
+
? K extends 'setState' | 'getState' | 'getValue' | 'setValue' | 'setValues' | 'getSnapshot' | 'setAuthContext'
|
|
60
61
|
? never
|
|
61
62
|
: K
|
|
62
63
|
: never
|
|
@@ -67,6 +67,8 @@ export interface LiveComponentProxy<
|
|
|
67
67
|
readonly $status: 'synced' | 'disconnected' | 'connecting' | 'reconnecting' | 'loading' | 'mounting' | 'error'
|
|
68
68
|
readonly $componentId: string | null
|
|
69
69
|
readonly $dirty: boolean
|
|
70
|
+
/** Whether the WebSocket connection is authenticated on the server */
|
|
71
|
+
readonly $authenticated: boolean
|
|
70
72
|
|
|
71
73
|
// Methods
|
|
72
74
|
$call: (action: string, payload?: any) => Promise<void>
|
|
@@ -164,7 +166,7 @@ export interface UseLiveComponentOptions extends HybridComponentOptions {
|
|
|
164
166
|
// ===== Propriedades Reservadas =====
|
|
165
167
|
|
|
166
168
|
const RESERVED_PROPS = new Set([
|
|
167
|
-
'$state', '$connected', '$loading', '$error', '$status', '$componentId', '$dirty',
|
|
169
|
+
'$state', '$connected', '$loading', '$error', '$status', '$componentId', '$dirty', '$authenticated',
|
|
168
170
|
'$call', '$callAndWait', '$mount', '$unmount', '$refresh', '$set', '$onBroadcast', '$updateLocal',
|
|
169
171
|
'$room', '$rooms', '$field', '$sync',
|
|
170
172
|
'then', 'toJSON', 'valueOf', 'toString',
|
|
@@ -262,6 +264,7 @@ export function useLiveComponent<
|
|
|
262
264
|
// WebSocket context
|
|
263
265
|
const {
|
|
264
266
|
connected,
|
|
267
|
+
authenticated: wsAuthenticated,
|
|
265
268
|
sendMessage,
|
|
266
269
|
sendMessageAndWait,
|
|
267
270
|
registerComponent,
|
|
@@ -286,6 +289,7 @@ export function useLiveComponent<
|
|
|
286
289
|
const broadcastHandlerRef = useRef<((event: { type: string; data: any }) => void) | null>(null)
|
|
287
290
|
const roomMessageHandlers = useRef<Set<(msg: RoomServerMessage) => void>>(new Set())
|
|
288
291
|
const roomManagerRef = useRef<RoomManager | null>(null)
|
|
292
|
+
const mountFnRef = useRef<(() => Promise<void>) | null>(null)
|
|
289
293
|
|
|
290
294
|
// State
|
|
291
295
|
const stateData = store((s) => s.state)
|
|
@@ -295,6 +299,7 @@ export function useLiveComponent<
|
|
|
295
299
|
const [error, setError] = useState<string | null>(null)
|
|
296
300
|
const [rehydrating, setRehydrating] = useState(false)
|
|
297
301
|
const [mountFailed, setMountFailed] = useState(false) // Previne loop infinito de mount
|
|
302
|
+
const [authDenied, setAuthDenied] = useState(false) // Track if mount failed due to AUTH_DENIED
|
|
298
303
|
|
|
299
304
|
const log = useCallback((msg: string, data?: any) => {
|
|
300
305
|
if (debug) console.log(`[${componentName}] ${msg}`, data || '')
|
|
@@ -371,7 +376,11 @@ export function useLiveComponent<
|
|
|
371
376
|
}
|
|
372
377
|
} catch (err: any) {
|
|
373
378
|
setError(err.message)
|
|
374
|
-
|
|
379
|
+
// Track if auth was the reason for failure
|
|
380
|
+
if (err.message?.includes('AUTH_DENIED')) {
|
|
381
|
+
setAuthDenied(true)
|
|
382
|
+
}
|
|
383
|
+
setMountFailed(true) // Previne loop infinito para TODOS os erros
|
|
375
384
|
onError?.(err.message)
|
|
376
385
|
if (!fallbackToLocal) throw err
|
|
377
386
|
} finally {
|
|
@@ -380,6 +389,9 @@ export function useLiveComponent<
|
|
|
380
389
|
}
|
|
381
390
|
}, [connected, componentName, initialState, room, userId, sendMessageAndWait, updateState, log, onMount, onError, fallbackToLocal, mountFailed])
|
|
382
391
|
|
|
392
|
+
// Keep mount function ref updated
|
|
393
|
+
mountFnRef.current = mount
|
|
394
|
+
|
|
383
395
|
// ===== Unmount =====
|
|
384
396
|
const unmount = useCallback(async () => {
|
|
385
397
|
if (!componentId || !connected) return
|
|
@@ -573,6 +585,14 @@ export function useLiveComponent<
|
|
|
573
585
|
}
|
|
574
586
|
}
|
|
575
587
|
break
|
|
588
|
+
case 'STATE_DELTA':
|
|
589
|
+
if (message.payload?.delta) {
|
|
590
|
+
const oldState = storeRef.current?.getState().state ?? stateData
|
|
591
|
+
const mergedState = { ...oldState, ...message.payload.delta } as TState
|
|
592
|
+
updateState(mergedState)
|
|
593
|
+
onStateChange?.(mergedState, oldState)
|
|
594
|
+
}
|
|
595
|
+
break
|
|
576
596
|
case 'STATE_REHYDRATED':
|
|
577
597
|
if (message.payload?.state && message.payload?.newComponentId) {
|
|
578
598
|
setComponentId(message.payload.newComponentId)
|
|
@@ -620,6 +640,28 @@ export function useLiveComponent<
|
|
|
620
640
|
}
|
|
621
641
|
}, [connected, autoMount, mount, componentId, rehydrating, rehydrate, mountFailed])
|
|
622
642
|
|
|
643
|
+
// ===== Auto Re-mount on Auth Change =====
|
|
644
|
+
// When auth changes from false to true and component failed due to AUTH_DENIED, retry mount
|
|
645
|
+
const prevAuthRef = useRef(wsAuthenticated)
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
const wasNotAuthenticated = !prevAuthRef.current
|
|
648
|
+
const isNowAuthenticated = wsAuthenticated
|
|
649
|
+
prevAuthRef.current = wsAuthenticated
|
|
650
|
+
|
|
651
|
+
// Only retry if: auth changed from false→true AND we had an auth denial
|
|
652
|
+
if (wasNotAuthenticated && isNowAuthenticated && authDenied) {
|
|
653
|
+
log('Auth changed to authenticated, retrying mount...')
|
|
654
|
+
// Reset flags to allow retry
|
|
655
|
+
setAuthDenied(false)
|
|
656
|
+
setMountFailed(false)
|
|
657
|
+
setError(null)
|
|
658
|
+
mountedRef.current = false
|
|
659
|
+
mountingRef.current = false
|
|
660
|
+
// Small delay to ensure state is updated, use ref to avoid stale closure
|
|
661
|
+
setTimeout(() => mountFnRef.current?.(), 50)
|
|
662
|
+
}
|
|
663
|
+
}, [wsAuthenticated, authDenied, log])
|
|
664
|
+
|
|
623
665
|
// ===== Connection Changes =====
|
|
624
666
|
const prevConnected = useRef(connected)
|
|
625
667
|
useEffect(() => {
|
|
@@ -708,6 +750,7 @@ export function useLiveComponent<
|
|
|
708
750
|
case '$status': return getStatus()
|
|
709
751
|
case '$componentId': return componentId
|
|
710
752
|
case '$dirty': return pendingChanges.current.size > 0
|
|
753
|
+
case '$authenticated': return wsAuthenticated
|
|
711
754
|
case '$call': return call
|
|
712
755
|
case '$callAndWait': return callAndWait
|
|
713
756
|
case '$mount': return mount
|
|
@@ -773,10 +816,10 @@ export function useLiveComponent<
|
|
|
773
816
|
},
|
|
774
817
|
|
|
775
818
|
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']
|
|
819
|
+
return [...Object.keys(stateData), '$state', '$connected', '$loading', '$error', '$status', '$componentId', '$dirty', '$authenticated', '$call', '$callAndWait', '$mount', '$unmount', '$refresh', '$set', '$field', '$sync', '$onBroadcast', '$updateLocal', '$room', '$rooms']
|
|
777
820
|
}
|
|
778
821
|
})
|
|
779
|
-
}, [stateData, connected, loading, error, componentId, call, callAndWait, mount, unmount, refresh, setProperty, optimistic, sendMessageAndWait, createFieldBinding, sync, localVersion, roomManager])
|
|
822
|
+
}, [stateData, connected, wsAuthenticated, loading, error, componentId, call, callAndWait, mount, unmount, refresh, setProperty, optimistic, sendMessageAndWait, createFieldBinding, sync, localVersion, roomManager])
|
|
780
823
|
|
|
781
824
|
return proxy
|
|
782
825
|
}
|
package/core/client/index.ts
CHANGED
package/core/framework/server.ts
CHANGED
|
@@ -36,6 +36,37 @@ export class FluxStackFramework {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Extract client IP from request headers (supports proxies)
|
|
41
|
+
*/
|
|
42
|
+
private getClientIP(request: Request): string {
|
|
43
|
+
// Check common proxy headers in order of priority
|
|
44
|
+
const xForwardedFor = request.headers.get('x-forwarded-for')
|
|
45
|
+
if (xForwardedFor) {
|
|
46
|
+
// x-forwarded-for can contain multiple IPs, take the first (original client)
|
|
47
|
+
return xForwardedFor.split(',')[0].trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const xRealIP = request.headers.get('x-real-ip')
|
|
51
|
+
if (xRealIP) {
|
|
52
|
+
return xRealIP.trim()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cfConnectingIP = request.headers.get('cf-connecting-ip')
|
|
56
|
+
if (cfConnectingIP) {
|
|
57
|
+
return cfConnectingIP.trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback: try to get from Bun's server socket (if available)
|
|
61
|
+
// This is set by Bun when running in server mode
|
|
62
|
+
const socketIP = (request as any).ip || (request as any).remoteAddress
|
|
63
|
+
if (socketIP) {
|
|
64
|
+
return socketIP
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return '127.0.0.1'
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
constructor(config?: Partial<FluxStackConfig>) {
|
|
40
71
|
// Load the full configuration
|
|
41
72
|
const fullConfig = config ? { ...fluxStackConfig, ...config } : fluxStackConfig
|
|
@@ -99,7 +130,7 @@ export class FluxStackFramework {
|
|
|
99
130
|
child: (context: Record<string, unknown>) => PluginLogger
|
|
100
131
|
time: (label: string) => void
|
|
101
132
|
timeEnd: (label: string) => void
|
|
102
|
-
request: (method: string, path: string, status?: number, duration?: number) => void
|
|
133
|
+
request: (method: string, path: string, status?: number, duration?: number, ip?: string) => void
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
const pluginLogger: PluginLogger = {
|
|
@@ -110,8 +141,8 @@ export class FluxStackFramework {
|
|
|
110
141
|
child: (context: Record<string, unknown>) => pluginLogger,
|
|
111
142
|
time: (label: string) => logger.time(label),
|
|
112
143
|
timeEnd: (label: string) => logger.timeEnd(label),
|
|
113
|
-
request: (method: string, path: string, status?: number, duration?: number) =>
|
|
114
|
-
logger.request(method, path, status, duration)
|
|
144
|
+
request: (method: string, path: string, status?: number, duration?: number, ip?: string) =>
|
|
145
|
+
logger.request(method, path, status, duration, ip)
|
|
115
146
|
}
|
|
116
147
|
|
|
117
148
|
this.pluginContext = {
|
|
@@ -439,7 +470,8 @@ export class FluxStackFramework {
|
|
|
439
470
|
? responseContext.statusCode
|
|
440
471
|
: Number(set.status) || 200
|
|
441
472
|
|
|
442
|
-
|
|
473
|
+
const clientIP = this.getClientIP(request)
|
|
474
|
+
logger.request(request.method, url.pathname, status, duration, clientIP)
|
|
443
475
|
}
|
|
444
476
|
|
|
445
477
|
// Execute onResponse hooks for all plugins (final logging, metrics)
|
|
@@ -30,19 +30,22 @@ export class ${name} extends LiveComponent<typeof ${name}.defaultState> {
|
|
|
30
30
|
static defaultState = {
|
|
31
31
|
count: 0
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
// Declarar propriedades do estado (criadas dinamicamente)
|
|
35
|
+
declare count: number
|
|
33
36
|
${constructorBlock}
|
|
34
37
|
async increment() {
|
|
35
|
-
this.
|
|
36
|
-
return { success: true, count: this.
|
|
38
|
+
this.count++
|
|
39
|
+
return { success: true, count: this.count }
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
async decrement() {
|
|
40
|
-
this.
|
|
41
|
-
return { success: true, count: this.
|
|
43
|
+
this.count--
|
|
44
|
+
return { success: true, count: this.count }
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
async reset() {
|
|
45
|
-
this.
|
|
48
|
+
this.count = 0
|
|
46
49
|
return { success: true }
|
|
47
50
|
}
|
|
48
51
|
}
|
|
@@ -126,16 +129,20 @@ export class ${name} extends LiveComponent<typeof ${name}.defaultState> {
|
|
|
126
129
|
message: 'Hello from ${name}!',
|
|
127
130
|
count: 0
|
|
128
131
|
}
|
|
132
|
+
|
|
133
|
+
// Declarar propriedades do estado (criadas dinamicamente)
|
|
134
|
+
declare message: string
|
|
135
|
+
declare count: number
|
|
129
136
|
${constructorBlock}
|
|
130
137
|
async updateMessage(payload: { message: string }) {
|
|
131
138
|
if (!payload.message?.trim()) throw new Error('Message cannot be empty')
|
|
132
|
-
this.
|
|
139
|
+
this.message = payload.message.trim()
|
|
133
140
|
return { success: true }
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
async increment() {
|
|
137
|
-
this.
|
|
138
|
-
return { success: true, count: this.
|
|
144
|
+
this.count++
|
|
145
|
+
return { success: true, count: this.count }
|
|
139
146
|
}
|
|
140
147
|
|
|
141
148
|
async reset() {
|
|
@@ -415,6 +415,7 @@ function initializeHttpMetrics(registry: MetricsRegistry, collector: MetricsColl
|
|
|
415
415
|
|
|
416
416
|
function startSystemMetricsCollection(context: PluginContext, collector: MetricsCollector, options: MonitoringOptions) {
|
|
417
417
|
const intervals: NodeJS.Timeout[] = []
|
|
418
|
+
const cpuCount = os.cpus().length // Cache — CPU count does not change at runtime
|
|
418
419
|
|
|
419
420
|
// Initialize system metrics in collector
|
|
420
421
|
collector.createGauge('process_memory_rss_bytes', 'Process resident set size in bytes')
|
|
@@ -462,8 +463,8 @@ function startSystemMetricsCollection(context: PluginContext, collector: Metrics
|
|
|
462
463
|
recordGauge(metricsRegistry, 'system_memory_free_bytes', freeMem)
|
|
463
464
|
recordGauge(metricsRegistry, 'system_memory_used_bytes', totalMem - freeMem)
|
|
464
465
|
|
|
465
|
-
// CPU count
|
|
466
|
-
recordGauge(metricsRegistry, 'system_cpu_count',
|
|
466
|
+
// CPU count (cached)
|
|
467
|
+
recordGauge(metricsRegistry, 'system_cpu_count', cpuCount)
|
|
467
468
|
|
|
468
469
|
// Load average (Unix-like systems only)
|
|
469
470
|
if (process.platform !== 'win32') {
|
|
@@ -696,11 +697,17 @@ function recordGauge(registry: MetricsRegistry, name: string, value: number, lab
|
|
|
696
697
|
})
|
|
697
698
|
}
|
|
698
699
|
|
|
700
|
+
const MAX_HISTOGRAM_VALUES = 1000
|
|
701
|
+
|
|
699
702
|
function recordHistogram(registry: MetricsRegistry, name: string, value: number, labels?: Record<string, string>) {
|
|
700
703
|
const key = createMetricKey(name, labels)
|
|
701
|
-
|
|
704
|
+
|
|
702
705
|
const existing = registry.histograms.get(key)
|
|
703
706
|
if (existing) {
|
|
707
|
+
if (existing.values.length >= MAX_HISTOGRAM_VALUES) {
|
|
708
|
+
// Keep the most recent half to preserve statistical relevance
|
|
709
|
+
existing.values = existing.values.slice(MAX_HISTOGRAM_VALUES >> 1)
|
|
710
|
+
}
|
|
704
711
|
existing.values.push(value)
|
|
705
712
|
existing.timestamp = Date.now()
|
|
706
713
|
} else {
|
|
@@ -4,43 +4,120 @@ import { clientConfig } from '@config'
|
|
|
4
4
|
import { pluginsConfig } from '@config'
|
|
5
5
|
import { isDevelopment } from "@core/utils/helpers"
|
|
6
6
|
import { join } from "path"
|
|
7
|
-
import { statSync, existsSync } from "fs"
|
|
7
|
+
import { statSync, existsSync, readdirSync } from "fs"
|
|
8
8
|
|
|
9
9
|
type Plugin = FluxStack.Plugin
|
|
10
10
|
|
|
11
11
|
const PLUGIN_PRIORITY = 800
|
|
12
12
|
const INDEX_FILE = "index.html"
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/** Cached at module load — NODE_ENV does not change at runtime */
|
|
15
|
+
const IS_DEV = isDevelopment()
|
|
16
|
+
|
|
17
|
+
/** One year in seconds — standard for hashed static assets */
|
|
18
|
+
const STATIC_MAX_AGE = 31536000
|
|
19
|
+
|
|
20
|
+
/** Extensions that carry a content hash in their filename (immutable) */
|
|
21
|
+
const HASHED_EXT = /\.[0-9a-f]{8,}\.\w+$/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursively collect all files under `dir` as relative paths (e.g. "/assets/app.abc123.js").
|
|
25
|
+
* Runs once at startup — in production the build output never changes.
|
|
26
|
+
*/
|
|
27
|
+
function collectFiles(dir: string, prefix = ''): Map<string, string> {
|
|
28
|
+
const map = new Map<string, string>()
|
|
29
|
+
try {
|
|
30
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const rel = prefix + '/' + entry.name
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
for (const [k, v] of collectFiles(join(dir, entry.name), rel)) {
|
|
35
|
+
map.set(k, v)
|
|
36
|
+
}
|
|
37
|
+
} else if (entry.isFile()) {
|
|
38
|
+
map.set(rel, join(dir, entry.name))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
return map
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Create static file handler with full in-memory cache */
|
|
15
46
|
function createStaticFallback() {
|
|
16
47
|
// Discover base directory once
|
|
17
48
|
const baseDir = existsSync('client') ? 'client'
|
|
18
49
|
: existsSync('dist/client') ? 'dist/client'
|
|
19
50
|
: clientConfig.build.outDir ?? 'dist/client'
|
|
20
51
|
|
|
52
|
+
// Pre-scan all files at startup — O(1) lookup per request
|
|
53
|
+
const fileMap = collectFiles(baseDir)
|
|
54
|
+
|
|
55
|
+
// Pre-resolve SPA fallback
|
|
56
|
+
const indexAbsolute = join(baseDir, INDEX_FILE)
|
|
57
|
+
const indexExists = existsSync(indexAbsolute)
|
|
58
|
+
const indexFile = indexExists ? Bun.file(indexAbsolute) : null
|
|
59
|
+
|
|
60
|
+
// Bun.file() handle cache — avoids re-creating handles on repeated requests
|
|
61
|
+
const fileCache = new Map<string, ReturnType<typeof Bun.file>>()
|
|
62
|
+
|
|
21
63
|
return (c: { request?: Request }) => {
|
|
22
64
|
const req = c.request
|
|
23
65
|
if (!req) return
|
|
24
66
|
|
|
25
|
-
|
|
67
|
+
// Fast pathname extraction — avoid full URL parse when possible
|
|
68
|
+
const rawUrl = req.url
|
|
69
|
+
let pathname: string
|
|
70
|
+
const qIdx = rawUrl.indexOf('?')
|
|
71
|
+
const pathPart = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx)
|
|
72
|
+
|
|
73
|
+
// Handle absolute URLs (http://...) vs relative paths
|
|
74
|
+
if (pathPart.charCodeAt(0) === 47) { // '/'
|
|
75
|
+
pathname = pathPart
|
|
76
|
+
} else {
|
|
77
|
+
// Absolute URL — find the path after ://host
|
|
78
|
+
const protoEnd = pathPart.indexOf('://')
|
|
79
|
+
if (protoEnd !== -1) {
|
|
80
|
+
const slashIdx = pathPart.indexOf('/', protoEnd + 3)
|
|
81
|
+
pathname = slashIdx === -1 ? '/' : pathPart.slice(slashIdx)
|
|
82
|
+
} else {
|
|
83
|
+
pathname = pathPart
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Decode percent-encoding only if needed
|
|
88
|
+
if (pathname.includes('%')) {
|
|
89
|
+
try { pathname = decodeURIComponent(pathname) } catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
26
92
|
if (pathname === '/' || pathname === '') {
|
|
27
93
|
pathname = `/${INDEX_FILE}`
|
|
28
94
|
}
|
|
29
95
|
|
|
30
|
-
//
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
96
|
+
// O(1) lookup in pre-scanned file map
|
|
97
|
+
const absolutePath = fileMap.get(pathname)
|
|
98
|
+
if (absolutePath) {
|
|
99
|
+
let file = fileCache.get(pathname)
|
|
100
|
+
if (!file) {
|
|
101
|
+
file = Bun.file(absolutePath)
|
|
102
|
+
fileCache.set(pathname, file)
|
|
35
103
|
}
|
|
36
|
-
} catch {}
|
|
37
104
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
105
|
+
// Hashed filenames are immutable — cache aggressively
|
|
106
|
+
if (HASHED_EXT.test(pathname)) {
|
|
107
|
+
return new Response(file, {
|
|
108
|
+
headers: {
|
|
109
|
+
'Cache-Control': `public, max-age=${STATIC_MAX_AGE}, immutable`
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return file
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// SPA fallback: serve index.html for unmatched routes
|
|
118
|
+
if (indexFile) {
|
|
119
|
+
return indexFile
|
|
120
|
+
}
|
|
44
121
|
}
|
|
45
122
|
}
|
|
46
123
|
|
|
@@ -94,7 +171,7 @@ export const vitePlugin: Plugin = {
|
|
|
94
171
|
return
|
|
95
172
|
}
|
|
96
173
|
|
|
97
|
-
if (!
|
|
174
|
+
if (!IS_DEV) {
|
|
98
175
|
context.logger.debug("Production mode: static file serving enabled")
|
|
99
176
|
context.app.all('*', createStaticFallback())
|
|
100
177
|
return
|
|
@@ -107,7 +184,7 @@ export const vitePlugin: Plugin = {
|
|
|
107
184
|
onServerStart: async (context: PluginContext) => {
|
|
108
185
|
if (!pluginsConfig.viteEnabled) return
|
|
109
186
|
|
|
110
|
-
if (!
|
|
187
|
+
if (!IS_DEV) {
|
|
111
188
|
context.logger.debug('Static files ready')
|
|
112
189
|
return
|
|
113
190
|
}
|
|
@@ -116,7 +193,7 @@ export const vitePlugin: Plugin = {
|
|
|
116
193
|
},
|
|
117
194
|
|
|
118
195
|
onBeforeRoute: async (ctx: RequestContext) => {
|
|
119
|
-
if (!
|
|
196
|
+
if (!IS_DEV) return
|
|
120
197
|
|
|
121
198
|
const shouldSkip = (pluginsConfig.viteExcludePaths ?? []).some(prefix =>
|
|
122
199
|
ctx.path === prefix || ctx.path.startsWith(prefix + '/')
|
package/core/plugins/config.ts
CHANGED
|
@@ -285,6 +285,9 @@ export class DefaultPluginConfigManager implements PluginConfigManager {
|
|
|
285
285
|
}
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/** Shared instance — stateless, safe to reuse across all plugin utils */
|
|
289
|
+
const sharedConfigManager = new DefaultPluginConfigManager()
|
|
290
|
+
|
|
288
291
|
/**
|
|
289
292
|
* Create plugin configuration utilities
|
|
290
293
|
*/
|
|
@@ -333,13 +336,11 @@ export function createPluginUtils(logger?: Logger): PluginUtils {
|
|
|
333
336
|
},
|
|
334
337
|
|
|
335
338
|
deepMerge: (target: any, source: any): any => {
|
|
336
|
-
|
|
337
|
-
return (manager as any).deepMerge(target, source)
|
|
339
|
+
return (sharedConfigManager as any).deepMerge(target, source)
|
|
338
340
|
},
|
|
339
341
|
|
|
340
342
|
validateSchema: (data: any, schema: any): { valid: boolean; errors: string[] } => {
|
|
341
|
-
const
|
|
342
|
-
const result = manager.validatePluginConfig({ name: 'temp', configSchema: schema }, data)
|
|
343
|
+
const result = sharedConfigManager.validatePluginConfig({ name: 'temp', configSchema: schema }, data)
|
|
343
344
|
return {
|
|
344
345
|
valid: result.valid,
|
|
345
346
|
errors: result.errors
|
|
@@ -359,6 +359,15 @@ export class PluginDiscovery {
|
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
/**
|
|
362
|
-
*
|
|
362
|
+
* @deprecated Unused — PluginRegistry handles discovery directly.
|
|
363
|
+
* Instantiation deferred to first access to avoid side effects at module load.
|
|
364
|
+
* Remove this export in the next major version.
|
|
363
365
|
*/
|
|
364
|
-
|
|
366
|
+
let _pluginDiscovery: PluginDiscovery | undefined
|
|
367
|
+
export function getPluginDiscovery(): PluginDiscovery {
|
|
368
|
+
_pluginDiscovery ??= new PluginDiscovery()
|
|
369
|
+
return _pluginDiscovery
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** @deprecated Use getPluginDiscovery() instead */
|
|
373
|
+
export const pluginDiscovery = {} as PluginDiscovery
|