import { registerPlugin, rootDir, loadConfig } from '../index.js'; import { Level } from 'level'; import ttl from 'level-ttl'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { Readable } from 'stream'; import cookie from 'cookie'; import { getRealIP } from '../utils/network.js'; import { parseDuration } from '../utils/time.js'; // Load stats configuration const statsConfig = {}; await loadConfig('stats', statsConfig); // Map configuration to internal structure const enabled = statsConfig.Core.Enabled; const statsTTL = parseDuration(statsConfig.Storage.StatsTTL); const statsUIPath = statsConfig.WebUI.StatsUIPath; const statsAPIPath = statsConfig.WebUI.StatsAPIPath; // Determine __dirname for ES modules const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Adds createReadStream support to LevelDB instances using async iterator. */ function addReadStreamSupport(dbInstance) { if (!dbInstance.createReadStream) { dbInstance.createReadStream = (opts) => Readable.from( (async function* () { for await (const [key, value] of dbInstance.iterator(opts)) { yield { key, value }; } })(), ); } return dbInstance; } // Initialize LevelDB for stats under db/stats with TTL and stream support const statsDBPath = path.join(rootDir, 'db', 'stats'); await fs.mkdir(statsDBPath, { recursive: true }); let rawStatsDB = new Level(statsDBPath, { valueEncoding: 'json' }); rawStatsDB = addReadStreamSupport(rawStatsDB); const statsDB = ttl(rawStatsDB, { defaultTTL: statsTTL }); addReadStreamSupport(statsDB); /** * Record a stat event with a metric name and optional data. * @param {string} metric * @param {object} data */ function recordEvent(metric, data = {}) { // Skip if statsDB is not initialized if (typeof statsDB === 'undefined' || !statsDB || typeof statsDB.put !== 'function') { console.warn(`stats: cannot record "${metric}", statsDB not available`); return; } const timestamp = Date.now(); // key includes metric and timestamp and a random suffix to avoid collisions const key = `${metric}:${timestamp}:${Math.random().toString(36).slice(2, 8)}`; try { // Use callback form to avoid promise chaining statsDB.put(key, { timestamp, metric, ...data }, (err) => { if (err) console.error('stats: failed to record event', err); }); } catch (err) { console.error('stats: failed to record event', err); } } // Handler for serving the stats HTML UI async function handleStatsPage(req, res) { const url = new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`); if (url.pathname !== statsUIPath) return false; try { const html = await fs.readFile(path.join(__dirname, 'stats.html'), 'utf8'); res.status(200).type('html').send(html); return true; } catch (e) { res.status(404).send('Stats UI not found'); return true; } } // Handler for stats API async function handleStatsAPI(req, res) { const url = new URL(`${req.protocol}://${req.get('host')}${req.originalUrl}`); if (url.pathname !== statsAPIPath) return false; const metric = url.searchParams.get('metric'); const start = parseInt(url.searchParams.get('start') || '0', 10); const end = parseInt(url.searchParams.get('end') || `${Date.now()}`, 10); const result = []; // Iterate over keys for this metric in the time range for await (const [key, value] of statsDB.iterator({ gte: `${metric}:${start}`, lte: `${metric}:${end}\uffff`, })) { result.push(value); } res.status(200).json(result); return true; } // Middleware for stats plugin function StatsMiddleware() { return { middleware: async (req, res, next) => { // Always serve stats UI and API first, bypassing auth const pageHandled = await handleStatsPage(req, res); if (pageHandled) return; const apiHandled = await handleStatsAPI(req, res); if (apiHandled) return; // For any other routes, do not handle return next(); } }; } // Register the stats plugin if (enabled) { registerPlugin('stats', StatsMiddleware()); } else { console.log('Stats plugin disabled via config'); } // Export recordEvent for other plugins to use export { recordEvent };