Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions core/plugins/built-in/monitoring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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<string, string>) {
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 {
Expand Down
113 changes: 95 additions & 18 deletions core/plugins/built-in/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const map = new Map<string, string>()
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<string, ReturnType<typeof Bun.file>>()

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
}
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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 + '/')
Expand Down
9 changes: 5 additions & 4 deletions core/plugins/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions core/plugins/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
let _pluginDiscovery: PluginDiscovery | undefined
export function getPluginDiscovery(): PluginDiscovery {
_pluginDiscovery ??= new PluginDiscovery()
return _pluginDiscovery
}

/** @deprecated Use getPluginDiscovery() instead */
export const pluginDiscovery = {} as PluginDiscovery
16 changes: 11 additions & 5 deletions core/plugins/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -186,7 +187,7 @@ export class PluginManager extends EventEmitter {
})

const executePlugin = async (plugin: Plugin): Promise<PluginHookResult> => {
if (!enabledPlugins.includes(plugin)) {
if (!enabledSet.has(plugin.name)) {
return {
success: true,
duration: 0,
Expand Down Expand Up @@ -291,9 +292,10 @@ export class PluginManager extends EventEmitter {
retries
}

// Create timeout promise
// Create timeout promise with cleanup
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
timeoutId = setTimeout(() => {
reject(new FluxStackError(
`Plugin '${plugin.name}' hook '${hook}' timed out after ${timeout}ms`,
'PLUGIN_TIMEOUT',
Expand All @@ -304,7 +306,7 @@ export class PluginManager extends EventEmitter {

// Execute the hook with appropriate context
let hookPromise: Promise<any>

switch (hook) {
case 'setup':
case 'onServerStart':
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion core/plugins/module-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class PluginModuleResolver {
this.resolveCache.delete(firstKey)
}
}
this.cacheSet(key, value)
this.resolveCache.set(key, value)
}

/**
Expand Down
Loading
Loading