// Bootstraps the HTTP API + WebSocket hub + static assets. // // Exposed as a function so the Electron main process can boot the server // in-process (sharing the event loop, no IPC). `server/index.js` calls // this for plain `node server/index.js` CLI use. import 'dotenv/config'; import express from 'express'; import { createServer } from 'node:http'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync } from 'node:fs'; import { initDb } from './db/index.js'; import { authMiddleware, ensureBootstrapAdmin } from './auth.js'; import { applySeedIfEmpty } from './sources/seed.js'; import { scheduleHealthCheck } from './streams/checker.js'; import { attachWs } from './ws.js'; import { ensureImageDirs, getImageRoot } from './media/images.js'; import { router as authRoutes } from './routes/auth.js'; import { router as stationRoutes } from './routes/stations.js'; import { router as meRoutes } from './routes/me.js'; import { router as adminRoutes } from './routes/admin.js'; import { router as v1Routes } from './routes/v1.js'; import { router as roomRoutes } from './routes/rooms.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); export async function startServer(opts = {}) { const port = Number(opts.port ?? process.env.PORT ?? 4173); const host = opts.host ?? process.env.HOST ?? '0.0.0.0'; const dbPath = opts.dbPath ?? process.env.DB_PATH ?? './data/db/oradio.sqlite'; initDb(dbPath); ensureBootstrapAdmin({ username: process.env.ADMIN_BOOTSTRAP_USER, password: process.env.ADMIN_BOOTSTRAP_PASSWORD }); const seedResult = applySeedIfEmpty(); console.log('[seed]', seedResult); ensureImageDirs(); const app = express(); app.use(express.json({ limit: '512kb' })); app.use(authMiddleware); app.use('/api/auth', authRoutes); app.use('/api/stations', stationRoutes); app.use('/api/me', meRoutes); app.use('/api/admin', adminRoutes); app.use('/api/rooms', roomRoutes); app.use('/api/v1', v1Routes); // Locally-cached cover art and other media live under the configured image // root and are served unauthenticated on the LAN. Long cache OK — file names // include the station id and we rewrite the file on update. app.use('/media', express.static(getImageRoot(), { maxAge: '1h', fallthrough: false, setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); } })); // Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173. const publicDir = opts.publicDir ?? resolve(__dirname, 'public'); const sendHtml = (file) => (_req, res) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.sendFile(resolve(publicDir, file)); }; if (existsSync(publicDir)) { app.use(express.static(publicDir, { setHeaders(res, filePath) { if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); } else if (filePath.includes(`${publicDir}\\assets\\`) || filePath.includes(`${publicDir}/assets/`)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } } })); app.get('/admin', sendHtml('admin/index.html')); app.get('/docs', sendHtml('docs/index.html')); app.get('/master', sendHtml('master/index.html')); app.get('*', sendHtml('index.html')); } app.use((err, _req, res, _next) => { console.error(err); res.status(500).json({ error: String(err.message || err) }); }); const server = createServer(app); attachWs(server); scheduleHealthCheck(process.env.STREAM_CHECK_CRON); await new Promise((resolveListen) => server.listen(port, host, resolveListen)); const addr = server.address(); const actualPort = (addr && typeof addr === 'object') ? addr.port : port; console.log(`[oradio] api+ws on http://${host}:${actualPort}`); return { server, port: actualPort, host, stop: () => new Promise((res) => server.close(() => res())) }; }