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.
Files changed (80) hide show
  1. package/LLMD/INDEX.md +8 -1
  2. package/LLMD/agent.md +867 -0
  3. package/LLMD/config/environment-vars.md +30 -0
  4. package/LLMD/resources/live-auth.md +447 -0
  5. package/LLMD/resources/live-components.md +79 -21
  6. package/LLMD/resources/live-logging.md +158 -0
  7. package/LLMD/resources/live-upload.md +1 -1
  8. package/LLMD/resources/rest-auth.md +290 -0
  9. package/README.md +520 -340
  10. package/app/client/src/App.tsx +11 -0
  11. package/app/client/src/components/AppLayout.tsx +1 -0
  12. package/app/client/src/live/AuthDemo.tsx +332 -0
  13. package/app/server/auth/AuthManager.ts +213 -0
  14. package/app/server/auth/DevAuthProvider.ts +66 -0
  15. package/app/server/auth/HashManager.ts +123 -0
  16. package/app/server/auth/JWTAuthProvider.example.ts +101 -0
  17. package/app/server/auth/RateLimiter.ts +106 -0
  18. package/app/server/auth/contracts.ts +192 -0
  19. package/app/server/auth/guards/SessionGuard.ts +167 -0
  20. package/app/server/auth/guards/TokenGuard.ts +202 -0
  21. package/app/server/auth/index.ts +174 -0
  22. package/app/server/auth/middleware.ts +163 -0
  23. package/app/server/auth/providers/InMemoryProvider.ts +162 -0
  24. package/app/server/auth/sessions/SessionManager.ts +164 -0
  25. package/app/server/cache/CacheManager.ts +81 -0
  26. package/app/server/cache/MemoryDriver.ts +112 -0
  27. package/app/server/cache/contracts.ts +49 -0
  28. package/app/server/cache/index.ts +42 -0
  29. package/app/server/index.ts +14 -0
  30. package/app/server/live/LiveAdminPanel.ts +173 -0
  31. package/app/server/live/LiveCounter.ts +1 -0
  32. package/app/server/live/LiveLocalCounter.ts +13 -8
  33. package/app/server/live/LiveProtectedChat.ts +150 -0
  34. package/app/server/routes/auth.routes.ts +278 -0
  35. package/app/server/routes/index.ts +2 -0
  36. package/config/index.ts +8 -0
  37. package/config/system/auth.config.ts +49 -0
  38. package/config/system/session.config.ts +33 -0
  39. package/core/client/LiveComponentsProvider.tsx +76 -5
  40. package/core/client/components/Live.tsx +2 -1
  41. package/core/client/hooks/useLiveComponent.ts +47 -4
  42. package/core/client/index.ts +2 -1
  43. package/core/framework/server.ts +36 -4
  44. package/core/plugins/built-in/live-components/commands/create-live-component.ts +15 -8
  45. package/core/plugins/built-in/monitoring/index.ts +10 -3
  46. package/core/plugins/built-in/vite/index.ts +95 -18
  47. package/core/plugins/config.ts +5 -4
  48. package/core/plugins/discovery.ts +11 -2
  49. package/core/plugins/manager.ts +11 -5
  50. package/core/plugins/module-resolver.ts +1 -1
  51. package/core/plugins/registry.ts +53 -25
  52. package/core/server/live/ComponentRegistry.ts +79 -24
  53. package/core/server/live/LiveComponentPerformanceMonitor.ts +9 -8
  54. package/core/server/live/LiveLogger.ts +111 -0
  55. package/core/server/live/LiveRoomManager.ts +5 -4
  56. package/core/server/live/StateSignature.ts +644 -643
  57. package/core/server/live/auth/LiveAuthContext.ts +71 -0
  58. package/core/server/live/auth/LiveAuthManager.ts +304 -0
  59. package/core/server/live/auth/index.ts +19 -0
  60. package/core/server/live/auth/types.ts +179 -0
  61. package/core/server/live/auto-generated-components.ts +8 -2
  62. package/core/server/live/index.ts +16 -0
  63. package/core/server/live/websocket-plugin.ts +92 -16
  64. package/core/templates/create-project.ts +0 -3
  65. package/core/types/types.ts +133 -13
  66. package/core/utils/index.ts +17 -17
  67. package/core/utils/logger/index.ts +5 -2
  68. package/core/utils/version.ts +1 -1
  69. package/package.json +1 -8
  70. package/plugins/crypto-auth/index.ts +6 -0
  71. package/plugins/crypto-auth/server/CryptoAuthLiveProvider.ts +58 -0
  72. package/plugins/crypto-auth/server/index.ts +24 -21
  73. package/rest-tests/README.md +57 -0
  74. package/rest-tests/auth-token.http +113 -0
  75. package/rest-tests/auth.http +112 -0
  76. package/rest-tests/rooms-token.http +69 -0
  77. package/rest-tests/users-token.http +62 -0
  78. package/.dockerignore +0 -81
  79. package/Dockerfile +0 -70
  80. 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
- if (url) return url
60
- if (typeof window === 'undefined') return 'ws://localhost:3000/api/live/ws'
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
- // Always use current host - works for both dev and production
63
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
64
- return `${protocol}//${window.location.host}/api/live/ws`
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
- setMountFailed(true) // Previne loop infinito
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
  }
@@ -16,7 +16,8 @@ export {
16
16
  } from './LiveComponentsProvider'
17
17
  export type {
18
18
  LiveComponentsProviderProps,
19
- LiveComponentsContextValue
19
+ LiveComponentsContextValue,
20
+ LiveAuthOptions
20
21
  } from './LiveComponentsProvider'
21
22
 
22
23
  // Chunked Upload Hook
@@ -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
- logger.request(request.method, url.pathname, status, duration)
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.state.count++
36
- return { success: true, count: this.state.count }
38
+ this.count++
39
+ return { success: true, count: this.count }
37
40
  }
38
41
 
39
42
  async decrement() {
40
- this.state.count--
41
- return { success: true, count: this.state.count }
43
+ this.count--
44
+ return { success: true, count: this.count }
42
45
  }
43
46
 
44
47
  async reset() {
45
- this.state.count = 0
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.state.message = payload.message.trim()
139
+ this.message = payload.message.trim()
133
140
  return { success: true }
134
141
  }
135
142
 
136
143
  async increment() {
137
- this.state.count++
138
- return { success: true, count: this.state.count }
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', os.cpus().length)
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
- /** Create static file handler with cached base directory */
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
- let pathname = decodeURIComponent(new URL(req.url).pathname)
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
- // Try to serve the requested file
31
- const filePath = join(baseDir, pathname)
32
- try {
33
- if (statSync(filePath).isFile()) {
34
- return Bun.file(filePath)
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
- // SPA fallback: serve index.html
39
- const indexPath = join(baseDir, INDEX_FILE)
40
- try {
41
- statSync(indexPath)
42
- return Bun.file(indexPath)
43
- } catch {}
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 (!isDevelopment()) {
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 (!isDevelopment()) {
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 (!isDevelopment()) return
196
+ if (!IS_DEV) return
120
197
 
121
198
  const shouldSkip = (pluginsConfig.viteExcludePaths ?? []).some(prefix =>
122
199
  ctx.path === prefix || ctx.path.startsWith(prefix + '/')
@@ -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
- const manager = new DefaultPluginConfigManager()
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 manager = new DefaultPluginConfigManager()
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
- * Default plugin discovery instance
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
- export const pluginDiscovery = new PluginDiscovery()
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