diff --git a/core/plugins/built-in/monitoring/index.ts b/core/plugins/built-in/monitoring/index.ts index 0ddf3bc7..5c803422 100644 --- a/core/plugins/built-in/monitoring/index.ts +++ b/core/plugins/built-in/monitoring/index.ts @@ -415,6 +415,7 @@ function initializeHttpMetrics(registry: MetricsRegistry, collector: MetricsColl function startSystemMetricsCollection(context: PluginContext, collector: MetricsCollector, options: MonitoringOptions) { const intervals: NodeJS.Timeout[] = [] + const cpuCount = os.cpus().length // Cache — CPU count does not change at runtime // Initialize system metrics in collector collector.createGauge('process_memory_rss_bytes', 'Process resident set size in bytes') @@ -462,8 +463,8 @@ function startSystemMetricsCollection(context: PluginContext, collector: Metrics recordGauge(metricsRegistry, 'system_memory_free_bytes', freeMem) recordGauge(metricsRegistry, 'system_memory_used_bytes', totalMem - freeMem) - // CPU count - recordGauge(metricsRegistry, 'system_cpu_count', os.cpus().length) + // CPU count (cached) + recordGauge(metricsRegistry, 'system_cpu_count', cpuCount) // Load average (Unix-like systems only) if (process.platform !== 'win32') { @@ -696,11 +697,17 @@ function recordGauge(registry: MetricsRegistry, name: string, value: number, lab }) } +const MAX_HISTOGRAM_VALUES = 1000 + function recordHistogram(registry: MetricsRegistry, name: string, value: number, labels?: Record) { const key = createMetricKey(name, labels) - + const existing = registry.histograms.get(key) if (existing) { + if (existing.values.length >= MAX_HISTOGRAM_VALUES) { + // Keep the most recent half to preserve statistical relevance + existing.values = existing.values.slice(MAX_HISTOGRAM_VALUES >> 1) + } existing.values.push(value) existing.timestamp = Date.now() } else { diff --git a/core/plugins/built-in/vite/index.ts b/core/plugins/built-in/vite/index.ts index 16775ec1..6948ca6a 100644 --- a/core/plugins/built-in/vite/index.ts +++ b/core/plugins/built-in/vite/index.ts @@ -4,43 +4,120 @@ import { clientConfig } from '@config' import { pluginsConfig } from '@config' import { isDevelopment } from "@core/utils/helpers" import { join } from "path" -import { statSync, existsSync } from "fs" +import { statSync, existsSync, readdirSync } from "fs" type Plugin = FluxStack.Plugin const PLUGIN_PRIORITY = 800 const INDEX_FILE = "index.html" -/** Create static file handler with cached base directory */ +/** Cached at module load — NODE_ENV does not change at runtime */ +const IS_DEV = isDevelopment() + +/** One year in seconds — standard for hashed static assets */ +const STATIC_MAX_AGE = 31536000 + +/** Extensions that carry a content hash in their filename (immutable) */ +const HASHED_EXT = /\.[0-9a-f]{8,}\.\w+$/ + +/** + * Recursively collect all files under `dir` as relative paths (e.g. "/assets/app.abc123.js"). + * Runs once at startup — in production the build output never changes. + */ +function collectFiles(dir: string, prefix = ''): Map { + const map = new Map() + try { + const entries = readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const rel = prefix + '/' + entry.name + if (entry.isDirectory()) { + for (const [k, v] of collectFiles(join(dir, entry.name), rel)) { + map.set(k, v) + } + } else if (entry.isFile()) { + map.set(rel, join(dir, entry.name)) + } + } + } catch {} + return map +} + +/** Create static file handler with full in-memory cache */ function createStaticFallback() { // Discover base directory once const baseDir = existsSync('client') ? 'client' : existsSync('dist/client') ? 'dist/client' : clientConfig.build.outDir ?? 'dist/client' + // Pre-scan all files at startup — O(1) lookup per request + const fileMap = collectFiles(baseDir) + + // Pre-resolve SPA fallback + const indexAbsolute = join(baseDir, INDEX_FILE) + const indexExists = existsSync(indexAbsolute) + const indexFile = indexExists ? Bun.file(indexAbsolute) : null + + // Bun.file() handle cache — avoids re-creating handles on repeated requests + const fileCache = new Map>() + return (c: { request?: Request }) => { const req = c.request if (!req) return - let pathname = decodeURIComponent(new URL(req.url).pathname) + // Fast pathname extraction — avoid full URL parse when possible + const rawUrl = req.url + let pathname: string + const qIdx = rawUrl.indexOf('?') + const pathPart = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx) + + // Handle absolute URLs (http://...) vs relative paths + if (pathPart.charCodeAt(0) === 47) { // '/' + pathname = pathPart + } else { + // Absolute URL — find the path after ://host + const protoEnd = pathPart.indexOf('://') + if (protoEnd !== -1) { + const slashIdx = pathPart.indexOf('/', protoEnd + 3) + pathname = slashIdx === -1 ? '/' : pathPart.slice(slashIdx) + } else { + pathname = pathPart + } + } + + // Decode percent-encoding only if needed + if (pathname.includes('%')) { + try { pathname = decodeURIComponent(pathname) } catch {} + } + if (pathname === '/' || pathname === '') { pathname = `/${INDEX_FILE}` } - // Try to serve the requested file - const filePath = join(baseDir, pathname) - try { - if (statSync(filePath).isFile()) { - return Bun.file(filePath) + // O(1) lookup in pre-scanned file map + const absolutePath = fileMap.get(pathname) + if (absolutePath) { + let file = fileCache.get(pathname) + if (!file) { + file = Bun.file(absolutePath) + fileCache.set(pathname, file) } - } catch {} - // SPA fallback: serve index.html - const indexPath = join(baseDir, INDEX_FILE) - try { - statSync(indexPath) - return Bun.file(indexPath) - } catch {} + // Hashed filenames are immutable — cache aggressively + if (HASHED_EXT.test(pathname)) { + return new Response(file, { + headers: { + 'Cache-Control': `public, max-age=${STATIC_MAX_AGE}, immutable` + } + }) + } + + return file + } + + // SPA fallback: serve index.html for unmatched routes + if (indexFile) { + return indexFile + } } } @@ -94,7 +171,7 @@ export const vitePlugin: Plugin = { return } - if (!isDevelopment()) { + if (!IS_DEV) { context.logger.debug("Production mode: static file serving enabled") context.app.all('*', createStaticFallback()) return @@ -107,7 +184,7 @@ export const vitePlugin: Plugin = { onServerStart: async (context: PluginContext) => { if (!pluginsConfig.viteEnabled) return - if (!isDevelopment()) { + if (!IS_DEV) { context.logger.debug('Static files ready') return } @@ -116,7 +193,7 @@ export const vitePlugin: Plugin = { }, onBeforeRoute: async (ctx: RequestContext) => { - if (!isDevelopment()) return + if (!IS_DEV) return const shouldSkip = (pluginsConfig.viteExcludePaths ?? []).some(prefix => ctx.path === prefix || ctx.path.startsWith(prefix + '/') diff --git a/core/plugins/config.ts b/core/plugins/config.ts index 5593e504..ff85635f 100644 --- a/core/plugins/config.ts +++ b/core/plugins/config.ts @@ -285,6 +285,9 @@ export class DefaultPluginConfigManager implements PluginConfigManager { } } +/** Shared instance — stateless, safe to reuse across all plugin utils */ +const sharedConfigManager = new DefaultPluginConfigManager() + /** * Create plugin configuration utilities */ @@ -333,13 +336,11 @@ export function createPluginUtils(logger?: Logger): PluginUtils { }, deepMerge: (target: any, source: any): any => { - const manager = new DefaultPluginConfigManager() - return (manager as any).deepMerge(target, source) + return (sharedConfigManager as any).deepMerge(target, source) }, validateSchema: (data: any, schema: any): { valid: boolean; errors: string[] } => { - const manager = new DefaultPluginConfigManager() - const result = manager.validatePluginConfig({ name: 'temp', configSchema: schema }, data) + const result = sharedConfigManager.validatePluginConfig({ name: 'temp', configSchema: schema }, data) return { valid: result.valid, errors: result.errors diff --git a/core/plugins/discovery.ts b/core/plugins/discovery.ts index 6dc6dcc1..bbfd0bc7 100644 --- a/core/plugins/discovery.ts +++ b/core/plugins/discovery.ts @@ -359,6 +359,15 @@ export class PluginDiscovery { } /** - * Default plugin discovery instance + * @deprecated Unused — PluginRegistry handles discovery directly. + * Instantiation deferred to first access to avoid side effects at module load. + * Remove this export in the next major version. */ -export const pluginDiscovery = new PluginDiscovery() \ No newline at end of file +let _pluginDiscovery: PluginDiscovery | undefined +export function getPluginDiscovery(): PluginDiscovery { + _pluginDiscovery ??= new PluginDiscovery() + return _pluginDiscovery +} + +/** @deprecated Use getPluginDiscovery() instead */ +export const pluginDiscovery = {} as PluginDiscovery \ No newline at end of file diff --git a/core/plugins/manager.ts b/core/plugins/manager.ts index 7dce17e2..dc515bbf 100644 --- a/core/plugins/manager.ts +++ b/core/plugins/manager.ts @@ -177,6 +177,7 @@ export class PluginManager extends EventEmitter { const results: PluginHookResult[] = [] const loadOrder = this.registry.getLoadOrder() const enabledPlugins = this.getEnabledPlugins() + const enabledSet = new Set(enabledPlugins.map(p => p.name)) this.logger.debug(`Executing hook '${hook}' on ${enabledPlugins.length} plugins`, { hook, @@ -186,7 +187,7 @@ export class PluginManager extends EventEmitter { }) const executePlugin = async (plugin: Plugin): Promise => { - if (!enabledPlugins.includes(plugin)) { + if (!enabledSet.has(plugin.name)) { return { success: true, duration: 0, @@ -291,9 +292,10 @@ export class PluginManager extends EventEmitter { retries } - // Create timeout promise + // Create timeout promise with cleanup + let timeoutId: ReturnType | undefined const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { + timeoutId = setTimeout(() => { reject(new FluxStackError( `Plugin '${plugin.name}' hook '${hook}' timed out after ${timeout}ms`, 'PLUGIN_TIMEOUT', @@ -304,7 +306,7 @@ export class PluginManager extends EventEmitter { // Execute the hook with appropriate context let hookPromise: Promise - + switch (hook) { case 'setup': case 'onServerStart': @@ -325,7 +327,11 @@ export class PluginManager extends EventEmitter { } // Race between hook execution and timeout - await Promise.race([hookPromise, timeoutPromise]) + try { + await Promise.race([hookPromise, timeoutPromise]) + } finally { + clearTimeout(timeoutId) + } const duration = Date.now() - startTime diff --git a/core/plugins/module-resolver.ts b/core/plugins/module-resolver.ts index 92539a8e..4b8cc2f3 100644 --- a/core/plugins/module-resolver.ts +++ b/core/plugins/module-resolver.ts @@ -30,7 +30,7 @@ export class PluginModuleResolver { this.resolveCache.delete(firstKey) } } - this.cacheSet(key, value) + this.resolveCache.set(key, value) } /** diff --git a/core/plugins/registry.ts b/core/plugins/registry.ts index f60a37f8..200a0007 100644 --- a/core/plugins/registry.ts +++ b/core/plugins/registry.ts @@ -676,14 +676,19 @@ export class PluginRegistry { } /** - * Update the load order based on dependencies and priorities + * Update the load order based on dependencies and priorities. + * + * Uses a priority-aware topological sort: at each round, picks all plugins + * whose dependencies are already placed, then sorts that group by priority + * (highest first) before appending. This preserves dependency constraints + * while respecting priority within each dependency level. */ private updateLoadOrder(): void { - const visited = new Set() + // First, detect circular dependencies via DFS const visiting = new Set() - const order: string[] = [] + const visited = new Set() - const visit = (pluginName: string) => { + const detectCycles = (pluginName: string) => { if (visiting.has(pluginName)) { throw new FluxStackError( `Circular dependency detected involving plugin '${pluginName}'`, @@ -691,41 +696,64 @@ export class PluginRegistry { 400 ) } - - if (visited.has(pluginName)) { - return - } + if (visited.has(pluginName)) return visiting.add(pluginName) - const plugin = this.plugins.get(pluginName) if (plugin?.dependencies) { - for (const dependency of plugin.dependencies) { - if (this.plugins.has(dependency)) { - visit(dependency) + for (const dep of plugin.dependencies) { + if (this.plugins.has(dep)) { + detectCycles(dep) } } } - visiting.delete(pluginName) visited.add(pluginName) - order.push(pluginName) } - // Visit all plugins to build dependency order for (const pluginName of this.plugins.keys()) { - visit(pluginName) + detectCycles(pluginName) } - // Sort by priority within dependency groups - this.loadOrder = order.sort((a, b) => { - const pluginA = this.plugins.get(a) - const pluginB = this.plugins.get(b) - if (!pluginA || !pluginB) return 0 - const priorityA = typeof pluginA.priority === 'number' ? pluginA.priority : 0 - const priorityB = typeof pluginB.priority === 'number' ? pluginB.priority : 0 - return priorityB - priorityA - }) + // Kahn's algorithm with priority-aware group selection + const placed = new Set() + const order: string[] = [] + const remaining = new Set(this.plugins.keys()) + + while (remaining.size > 0) { + // Find all plugins whose dependencies are satisfied + const ready: string[] = [] + for (const name of remaining) { + const plugin = this.plugins.get(name) + const deps = plugin?.dependencies ?? [] + const allDepsPlaced = deps.every(d => !this.plugins.has(d) || placed.has(d)) + if (allDepsPlaced) { + ready.push(name) + } + } + + if (ready.length === 0) { + // Should not happen after cycle detection, but guard against it + break + } + + // Sort ready plugins by priority (highest first) + ready.sort((a, b) => { + const pluginA = this.plugins.get(a) + const pluginB = this.plugins.get(b) + const priorityA = typeof pluginA?.priority === 'number' ? pluginA.priority : 0 + const priorityB = typeof pluginB?.priority === 'number' ? pluginB.priority : 0 + return priorityB - priorityA + }) + + for (const name of ready) { + order.push(name) + placed.add(name) + remaining.delete(name) + } + } + + this.loadOrder = order } /**