import http from "node:http"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const publicDir = path.join(__dirname, "public"); const seedPath = path.join(__dirname, "data", "seed-data.json"); const cachePath = path.join(__dirname, "data", "dashboard-cache.json"); const breadthHistoryPath = path.join(__dirname, "data", "breadth-history.json"); const port = Number(process.env.PORT || 4173); const host = process.env.HOST || (process.env.SPACE_ID ? "0.0.0.0" : "127.0.0.1"); const seedOnly = process.env.GEN_DASHBOARD_SEED_ONLY === "1"; const quoteUrl = "https://query1.finance.yahoo.com/v7/finance/quote"; const chartUrl = "https://query1.finance.yahoo.com/v8/finance/chart"; const stooqQuoteUrl = "https://stooq.com/q/l/"; let memoryCache = null; function nyDate(date = new Date()) { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(date); const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${byType.year}-${byType.month}-${byType.day}`; } function isoDate(date) { return date.toISOString().slice(0, 10); } function toYahooSymbol(symbol) { if (!symbol) return ""; if (symbol === "NDX") return "^NDX"; if (symbol === "VIX") return "^VIX"; return String(symbol).replace(".", "-").trim(); } function toStooqSymbol(symbol) { if (!symbol || String(symbol).startsWith("^") || symbol === "BTC-USD") return null; return `${String(symbol).replace(".", "-").toLowerCase()}.us`; } function percentFromQuote(value) { return Number.isFinite(value) ? value / 100 : null; } function number(value) { return Number.isFinite(value) ? value : null; } function uniq(values) { return [...new Set(values.filter(Boolean))]; } function chunk(values, size) { const groups = []; for (let i = 0; i < values.length; i += size) groups.push(values.slice(i, i + size)); return groups; } async function readJson(filePath, fallback = null) { try { return JSON.parse(await fs.readFile(filePath, "utf8")); } catch { return fallback; } } async function writeJson(filePath, data) { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); } async function fetchJson(url, timeoutMs = 9000) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal, headers: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36", accept: "application/json,text/plain,*/*", }, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } finally { clearTimeout(timeout); } } async function fetchYahooQuotes(symbols) { if (!symbols.length) return new Map(); const groups = chunk(uniq(symbols).map(toYahooSymbol), 80); const results = await Promise.allSettled( groups.map(async (group) => { const url = `${quoteUrl}?symbols=${encodeURIComponent(group.join(","))}`; const json = await fetchJson(url); return json?.quoteResponse?.result || []; }), ); const quotes = new Map(); for (const result of results) { if (result.status !== "fulfilled") continue; for (const quote of result.value) { quotes.set(quote.symbol, quote); } } return quotes; } function parseCsv(text) { return text .trim() .split(/\r?\n/) .map((line) => line.split(",").map((cell) => cell.trim())); } function toFinite(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } async function fetchStooqQuotes(symbols) { const symbolMap = new Map(); for (const symbol of symbols) { const stooq = toStooqSymbol(symbol); if (stooq) symbolMap.set(stooq.toUpperCase(), toYahooSymbol(symbol)); } if (!symbolMap.size) return new Map(); const groups = chunk([...symbolMap.keys()].map((symbol) => symbol.toLowerCase()), 80); const results = await Promise.allSettled( groups.map(async (group) => { const params = new URLSearchParams({ s: group.join(" "), f: "sd2t2ohlcvp", h: "", e: "csv" }); const text = await fetch(`${stooqQuoteUrl}?${params.toString()}`, { headers: { "user-agent": "Mozilla/5.0", accept: "text/csv,*/*" }, }).then((response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }); return parseCsv(text).slice(1); }), ); const quotes = new Map(); for (const result of results) { if (result.status !== "fulfilled") continue; for (const row of result.value) { const [stooqSymbol, date, , , , , closeRaw, volumeRaw, prevRaw] = row; if (!stooqSymbol || date === "N/D") continue; const symbol = symbolMap.get(stooqSymbol.toUpperCase()); const close = toFinite(closeRaw); const previous = toFinite(prevRaw); const volume = toFinite(volumeRaw); if (!symbol || !Number.isFinite(close)) continue; quotes.set(symbol, { symbol, regularMarketPrice: close, regularMarketPreviousClose: previous, regularMarketChangePercent: Number.isFinite(previous) && previous !== 0 ? ((close - previous) / previous) * 100 : null, regularMarketVolume: volume, source: "stooq", }); } } return quotes; } async function fetchQuotes(symbols) { const wanted = uniq(symbols); const yahooQuotes = await fetchYahooQuotes(wanted).catch(() => new Map()); const missing = wanted.filter((symbol) => !yahooQuotes.has(toYahooSymbol(symbol))); if (missing.length) { const stooqQuotes = await fetchStooqQuotes(missing).catch(() => new Map()); for (const [symbol, quote] of stooqQuotes) yahooQuotes.set(symbol, quote); } return yahooQuotes; } async function fetchChart(symbol, { range, interval = "1d", period1, period2 } = {}) { const params = new URLSearchParams({ interval }); if (range) { params.set("range", range); } else { params.set("period1", String(period1)); params.set("period2", String(period2 || Math.floor(Date.now() / 1000))); } const json = await fetchJson(`${chartUrl}/${encodeURIComponent(toYahooSymbol(symbol))}?${params.toString()}`); const result = json?.chart?.result?.[0]; if (!result?.timestamp?.length) return null; const quote = result.indicators?.quote?.[0] || {}; const closes = quote.close || []; const volumes = quote.volume || []; return result.timestamp .map((timestamp, index) => ({ date: isoDate(new Date(timestamp * 1000)), close: number(closes[index]), volume: number(volumes[index]), })) .filter((point) => point.close !== null); } function quoteLookup(quotes, symbol) { return quotes.get(toYahooSymbol(symbol)) || null; } function quoteChange(quotes, symbol) { return percentFromQuote(quoteLookup(quotes, symbol)?.regularMarketChangePercent); } function quotePrice(quotes, symbol) { return number(quoteLookup(quotes, symbol)?.regularMarketPrice); } function enrichHolding(holding, quotes, snapshot = null) { const quote = quoteLookup(quotes, holding.ticker); const volume = number(quote?.regularMarketVolume); const avgVolume = number(quote?.averageDailyVolume3Month) || number(quote?.averageDailyVolume10Day); return { ...holding, sector: holding.sector ?? snapshot?.sector ?? null, price: number(quote?.regularMarketPrice) ?? snapshot?.price ?? null, change: percentFromQuote(quote?.regularMarketChangePercent) ?? snapshot?.change ?? null, range: snapshot?.range ?? holding.range ?? null, volume: volume ?? null, avgVolume: avgVolume ?? null, volumeRatio: Number.isFinite(volume) && Number.isFinite(avgVolume) && avgVolume ? volume / avgVolume : null, fiftyTwoWeekHigh: number(quote?.fiftyTwoWeekHigh), fiftyTwoWeekLow: number(quote?.fiftyTwoWeekLow), }; } function breadth(holdings) { const tracked = holdings.filter((item) => Number.isFinite(item.change)); const advance = tracked.filter((item) => item.change > 0).length; const decline = tracked.filter((item) => item.change < 0).length; const unchanged = tracked.filter((item) => item.change === 0).length; return { advance, decline, unchanged, total: tracked.length || holdings.length }; } function topMovers(holdings) { const valid = holdings.filter((item) => Number.isFinite(item.change)); return { losers: [...valid].sort((a, b) => a.change - b.change).slice(0, 10), gainers: [...valid].sort((a, b) => b.change - a.change).slice(0, 10), }; } function topRangeRows(holdings) { return holdings .map((item) => { const high = item.fiftyTwoWeekHigh; const low = item.fiftyTwoWeekLow; let range = item.range; if (Number.isFinite(item.price) && Number.isFinite(high) && Number.isFinite(low) && high !== low) { range = (item.price - low) / (high - low); } return { ...item, range }; }) .filter((item) => item.ticker && Number.isFinite(item.range)) .sort((a, b) => (b.weight || 0) - (a.weight || 0)) .slice(0, 18); } function topContributions(holdings) { return holdings .filter((item) => Number.isFinite(item.weight) && Number.isFinite(item.change)) .sort((a, b) => b.weight - a.weight) .slice(0, 10) .map((item) => ({ ...item, contribution: item.weight * item.change })); } function buildRangeDistribution(holdings) { const bins = [ { label: "0-20%", min: 0, max: 0.2 }, { label: "20-40%", min: 0.2, max: 0.4 }, { label: "40-60%", min: 0.4, max: 0.6 }, { label: "60-80%", min: 0.6, max: 0.8 }, { label: "80-100%", min: 0.8, max: 1.01 }, ].map((bin) => ({ ...bin, count: 0, weight: 0 })); for (const holding of holdings) { const range = Number(holding.range); if (!Number.isFinite(range)) continue; const bin = bins.find((item) => range >= item.min && range < item.max); if (!bin) continue; bin.count += 1; bin.weight += Number.isFinite(holding.weight) ? holding.weight : 0; } return bins.map(({ label, count, weight }) => ({ label, count, weight })); } function buildLeadershipMap(holdings) { return holdings .filter((item) => item.ticker && Number.isFinite(item.range) && Number.isFinite(item.change)) .map((item) => ({ ticker: item.ticker, range: item.range, change: item.change, weight: Number.isFinite(item.weight) ? item.weight : 0, sector: item.sector || "", price: Number.isFinite(item.price) ? item.price : null, })) .sort((a, b) => b.weight - a.weight) .slice(0, 140); } function buildUnusualVolume(spyHoldings, qqqHoldings, sectors) { const seen = new Set(); const stockRows = [...qqqHoldings, ...spyHoldings] .filter((item) => { if (!item.ticker || seen.has(item.ticker)) return false; seen.add(item.ticker); return Number.isFinite(item.change) && Number.isFinite(item.volumeRatio); }) .map((item) => ({ ticker: item.ticker, change: item.change, volumeRatio: item.volumeRatio, weight: Number.isFinite(item.weight) ? item.weight : 0, })) .sort((a, b) => b.volumeRatio - a.volumeRatio) .slice(0, 40); if (stockRows.length >= 8) return { mode: "stocks", rows: stockRows }; return { mode: "sector-etfs", rows: sectors .filter((item) => Number.isFinite(item.change) && Number.isFinite(item.volumeAvgRatio)) .map((item) => ({ ticker: item.symbol || item.name, change: item.change, volumeRatio: item.volumeAvgRatio, weight: Number.isFinite(item.volumeM) ? item.volumeM : 0, })) .sort((a, b) => b.volumeRatio - a.volumeRatio), }; } function buildRiskRegime(spyTrend, summary) { const labels = spyTrend?.labels || []; const values = spyTrend?.series?.[0]?.values || []; const drawdown = spyTrend?.drawdown || values.map((value, index) => { const priorValues = values.slice(0, index + 1).filter(Number.isFinite); const runningMax = Math.max(...priorValues); return Number.isFinite(value) && Number.isFinite(runningMax) && runningMax > 0 ? value / runningMax - 1 : null; }); const returns = values .map((value, index) => (index > 0 && Number.isFinite(value) && Number.isFinite(values[index - 1]) ? value / values[index - 1] - 1 : null)) .filter(Number.isFinite); const recentReturns = returns.slice(-20); const mean = recentReturns.reduce((sum, value) => sum + value, 0) / Math.max(1, recentReturns.length); const variance = recentReturns.reduce((sum, value) => sum + (value - mean) ** 2, 0) / Math.max(1, recentReturns.length - 1); const realizedVol = recentReturns.length > 1 ? Math.sqrt(variance) * Math.sqrt(252) : null; const latestDrawdown = drawdown.filter(Number.isFinite).at(-1) ?? null; const maxDrawdown = Math.min(...drawdown.filter(Number.isFinite)); return { labels, series: [ { name: "SPY drawdown", values: drawdown }, { name: "VIX", values: labels.map(() => summary?.vix?.price ?? null) }, ], metrics: { currentDrawdown: latestDrawdown, maxDrawdown: Number.isFinite(maxDrawdown) ? maxDrawdown : null, realizedVol, vix: summary?.vix?.price ?? null, }, }; } function breadthPoint(date, summary) { const spyTotal = summary?.spy?.total || 0; const qqqTotal = summary?.qqq?.total || 0; return { date, spyAdvanceRatio: spyTotal ? summary.spy.advance / spyTotal : null, qqqAdvanceRatio: qqqTotal ? summary.qqq.advance / qqqTotal : null, spyNetRatio: spyTotal ? (summary.spy.advance - summary.spy.decline) / spyTotal : null, qqqNetRatio: qqqTotal ? (summary.qqq.advance - summary.qqq.decline) / qqqTotal : null, }; } async function updateBreadthHistory(date, summary) { const current = breadthPoint(date, summary); const existing = await readJson(breadthHistoryPath, []); const history = Array.isArray(existing) ? existing.filter((point) => point?.date !== date) : []; history.push(current); history.sort((a, b) => String(a.date).localeCompare(String(b.date))); const clipped = history.slice(-90); await writeJson(breadthHistoryPath, clipped); const rows = clipped.slice(-60); return { labels: rows.map((point) => point.date), series: [ { name: "SPY Adv%", values: rows.map((point) => point.spyAdvanceRatio) }, { name: "QQQ Adv%", values: rows.map((point) => point.qqqAdvanceRatio) }, { name: "SPY Net", values: rows.map((point) => point.spyNetRatio) }, { name: "QQQ Net", values: rows.map((point) => point.qqqNetRatio) }, ], }; } function normalizeSeedCharts(seed) { const mag7Labels = seed.charts.mag7.history.map((point) => String(point.index)); const mag7Series = seed.charts.mag7.names .map((name, index) => ({ name, values: seed.charts.mag7.history.map((point) => point.values[index]), })) .filter((series) => series.name); const relativeLabels = seed.charts.relative.history.map((point) => String(point.index)); const relativeSeries = seed.charts.relative.names .map((name, index) => ({ name, values: seed.charts.relative.history.map((point) => point.values[index]), })) .filter((series) => series.name); return { mag7: { labels: mag7Labels, series: mag7Series }, relative: { labels: relativeLabels, series: relativeSeries }, spyTrend: { labels: seed.charts.spyTrend.map((point) => point.date), series: [{ name: "SPY", values: seed.charts.spyTrend.map((point) => point.value) }], drawdown: seed.charts.spyTrend.map((point) => point.drawdown ?? null), }, beta: seed.charts.beta, }; } function withLatestPrice(chart, asOf, latestPrice) { if (!chart || !Number.isFinite(latestPrice)) return chart; const labels = [...(chart.labels || [])]; const series = (chart.series || []).map((item) => ({ ...item, values: [...(item.values || [])] })); if (!labels.length || !series.length) return chart; const lastIndex = labels.length - 1; if (labels[lastIndex] === asOf) { series[0].values[lastIndex] = latestPrice; } else { labels.push(asOf); series[0].values.push(latestPrice); } const values = series[0].values; const drawdown = values.map((value, index) => { const priorValues = values.slice(0, index + 1).filter(Number.isFinite); const runningMax = Math.max(...priorValues); return Number.isFinite(value) && Number.isFinite(runningMax) && runningMax > 0 ? value / runningMax - 1 : null; }); return { ...chart, labels, series, drawdown }; } function fallbackDashboard(seed, reason = "Using Excel cached data") { const qqqContributions = seed.qqqContributions; const charts = normalizeSeedCharts(seed); const dailyByTicker = new Map([...seed.snapshots.ndx, ...seed.snapshots.spx].map((row) => [row.ticker, row.change])); const mag7Daily = ["NVDA", "GOOG", "AMZN", "AAPL", "META", "MSFT", "TSLA"].map((ticker) => ({ ticker, change: dailyByTicker.get(ticker) ?? null, })); return { mode: "seed", source: "Excel cache", status: reason, asOf: seed.seedAsOf, generatedAt: new Date().toISOString(), nextAutoRefresh: "On next New York trading date or manual refresh", summary: seed.summary, sectors: seed.sectors, topMovers: seed.topMovers, qqqContributions, qqqContributionTotal: qqqContributions.reduce((sum, row) => sum + (row.contribution || 0), 0), rangeRows: seed.snapshots.spx.slice(0, 18), charts: { ...charts, mag7Daily, rangeDistribution: buildRangeDistribution(seed.snapshots.spx), leadershipMap: buildLeadershipMap(seed.snapshots.spx), unusualVolume: buildUnusualVolume(seed.snapshots.spx, seed.snapshots.ndx, seed.sectors), }, }; } function normalizedPerformance(historyBySymbol, symbols) { const labels = historyBySymbol.get(symbols[0])?.map((point) => point.date) || []; const series = []; for (const symbol of symbols) { const history = historyBySymbol.get(symbol) || []; const base = history.find((point) => Number.isFinite(point.close))?.close; if (!base) continue; series.push({ name: symbol.replace("^", ""), values: history.map((point) => ((point.close / base) - 1) * 100), }); } return { labels, series }; } async function buildLiveDashboard(seed) { if (seedOnly) return fallbackDashboard(seed, "Seed-only mode"); const quoteSymbols = uniq([ "SPY", "QQQ", "^VIX", ...seed.sectors.map((sector) => sector.symbol), ...seed.holdings.spy.map((holding) => holding.ticker), ...seed.holdings.qqq.map((holding) => holding.ticker), ]); const quotes = await fetchQuotes(quoteSymbols); if (!quotes.size) throw new Error("No quote data returned"); const spxSnapshotByTicker = new Map(seed.snapshots.spx.map((row) => [row.ticker, row])); const ndxSnapshotByTicker = new Map(seed.snapshots.ndx.map((row) => [row.ticker, row])); const spyHoldings = seed.holdings.spy.map((holding) => enrichHolding(holding, quotes, spxSnapshotByTicker.get(holding.ticker)), ); const qqqHoldings = seed.holdings.qqq.map((holding) => enrichHolding(holding, quotes, ndxSnapshotByTicker.get(holding.ticker)), ); const spyBreadth = breadth(spyHoldings); const qqqBreadth = breadth(qqqHoldings); const sectors = seed.sectors.map((sector) => { const quote = quoteLookup(quotes, sector.symbol); const volume = number(quote?.regularMarketVolume); const avgVolume = number(quote?.averageDailyVolume3Month) || number(quote?.averageDailyVolume10Day); return { ...sector, change: percentFromQuote(quote?.regularMarketChangePercent) ?? sector.change, volumeM: Number.isFinite(volume) ? volume / 1_000_000 : sector.volumeM, volumeAvgRatio: Number.isFinite(volume) && Number.isFinite(avgVolume) && avgVolume ? volume / avgVolume : sector.volumeAvgRatio, }; }); const mag7Symbols = ["NVDA", "GOOG", "AMZN", "AAPL", "META", "MSFT", "TSLA", "^NDX"]; const relativeSymbols = ["SPY", "QQQ", "GLD", "BTC-USD"]; const start2025 = Math.floor(Date.UTC(2025, 0, 1) / 1000); const thisYear = Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000); const chartRequests = [ ...mag7Symbols.map((symbol) => fetchChart(symbol, { range: "1mo" }).then((history) => [symbol, history])), ...relativeSymbols.map((symbol) => fetchChart(symbol, { period1: start2025 }).then((history) => [symbol, history]), ), fetchChart("SPY", { period1: thisYear }).then((history) => ["SPY_TREND", history]), ]; const chartResults = await Promise.allSettled(chartRequests); const historyBySymbol = new Map(); for (const result of chartResults) { if (result.status === "fulfilled" && result.value?.[1]?.length) historyBySymbol.set(result.value[0], result.value[1]); } const seedCharts = normalizeSeedCharts(seed); const mag7Live = normalizedPerformance(historyBySymbol, mag7Symbols); const relativeLive = normalizedPerformance(historyBySymbol, relativeSymbols); const spyTrendHistory = historyBySymbol.get("SPY_TREND"); const spyTrendSeed = withLatestPrice(seedCharts.spyTrend, nyDate(), quotePrice(quotes, "SPY")); const betaByTicker = new Map(seed.charts.beta.map((row) => [row.ticker, row])); const betaRows = qqqHoldings .map((holding) => { const beta = betaByTicker.get(holding.ticker)?.beta; return Number.isFinite(beta) && Number.isFinite(holding.change) ? { ticker: holding.ticker, beta, change: holding.change } : null; }) .filter(Boolean) .slice(0, 80); const qqqContributions = topContributions(qqqHoldings); const mag7Daily = ["NVDA", "GOOG", "AMZN", "AAPL", "META", "MSFT", "TSLA"].map((ticker) => { const holding = qqqHoldings.find((item) => item.ticker === ticker) || spyHoldings.find((item) => item.ticker === ticker); return { ticker, change: quoteChange(quotes, ticker) ?? holding?.change ?? null }; }); return { mode: "live", source: "Yahoo/Stooq live quotes with Excel holdings", status: "Live market data", asOf: nyDate(), generatedAt: new Date().toISOString(), nextAutoRefresh: "Daily, keyed to the New York market date", summary: { spy: { ...spyBreadth, change: quoteChange(quotes, "SPY") ?? seed.summary.spy.change }, qqq: { ...qqqBreadth, change: quoteChange(quotes, "QQQ") ?? seed.summary.qqq.change }, vix: { price: quotePrice(quotes, "^VIX") ?? seed.summary.vix.price, change: quoteChange(quotes, "^VIX") ?? seed.summary.vix.change, }, }, sectors, topMovers: { spyLosers: topMovers(spyHoldings).losers, spyGainers: topMovers(spyHoldings).gainers, qqqLosers: topMovers(qqqHoldings).losers, qqqGainers: topMovers(qqqHoldings).gainers, }, qqqContributions, qqqContributionTotal: qqqContributions.reduce((sum, row) => sum + row.contribution, 0), rangeRows: topRangeRows(spyHoldings), charts: { mag7: mag7Live.series.length >= 2 ? mag7Live : seedCharts.mag7, relative: relativeLive.series.length >= 2 ? relativeLive : seedCharts.relative, spyTrend: spyTrendHistory?.length ? { labels: spyTrendHistory.map((point) => point.date), series: [{ name: "SPY", values: spyTrendHistory.map((point) => point.close) }], } : spyTrendSeed, beta: betaRows.length ? betaRows : seedCharts.beta, mag7Daily, rangeDistribution: buildRangeDistribution(spyHoldings), leadershipMap: buildLeadershipMap(spyHoldings), unusualVolume: buildUnusualVolume(spyHoldings, qqqHoldings, sectors), }, }; } async function getDashboard(forceRefresh = false) { const today = nyDate(); const sourceMode = seedOnly ? "seed-only" : "normal"; if (!forceRefresh && memoryCache?.cacheDate === today) return memoryCache.data; const diskCache = await readJson(cachePath); if (!forceRefresh && diskCache?.cacheDate === today && diskCache?.sourceMode === sourceMode && diskCache?.data) { memoryCache = diskCache; return diskCache.data; } const seed = await readJson(seedPath); if (!seed) throw new Error("Missing data/seed-data.json. Run npm run seed first."); let data; try { data = await buildLiveDashboard(seed); } catch (error) { data = fallbackDashboard(seed, `Live refresh failed: ${error.message}`); } data.charts = data.charts || {}; data.charts.breadthTrend = await updateBreadthHistory(data.asOf || today, data.summary); data.charts.riskRegime = buildRiskRegime(data.charts.spyTrend, data.summary); memoryCache = { cacheDate: today, sourceMode, data }; await writeJson(cachePath, memoryCache); return data; } function sendJson(res, status, data) { const body = JSON.stringify(data); res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", }); res.end(body); } function contentType(filePath) { const ext = path.extname(filePath); return ( { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", }[ext] || "application/octet-stream" ); } async function serveStatic(req, res, pathname) { const cleanPath = pathname === "/" ? "/index.html" : decodeURIComponent(pathname); const filePath = path.normalize(path.join(publicDir, cleanPath)); if (!filePath.startsWith(publicDir)) { res.writeHead(403); res.end("Forbidden"); return; } try { const body = await fs.readFile(filePath); res.writeHead(200, { "content-type": contentType(filePath), "cache-control": "no-cache", }); res.end(body); } catch { res.writeHead(404); res.end("Not found"); } } const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname === "/api/dashboard") { try { const data = await getDashboard(url.searchParams.get("refresh") === "1"); sendJson(res, 200, data); } catch (error) { sendJson(res, 500, { error: error.message }); } return; } await serveStatic(req, res, url.pathname); }); server.listen(port, host, () => { const displayHost = host === "0.0.0.0" ? "127.0.0.1" : host; console.log(`Gen Dashboard page: http://${displayHost}:${port}`); }); setInterval(() => { getDashboard(false).catch((error) => { console.error("Scheduled refresh failed:", error.message); }); }, 60 * 60 * 1000);