/** * Entry point — boots the HTTP server (and, from Phase 1, the scheduler). * * `node server.js` is the whole install story: no build step, no dependencies, * one SQLite file. */ import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { config as processConfig } from './core/config.js'; import { getSetting, openDb, SETTING_KEYS } from './core/db.js'; import { assertInstancePaths, ensureInstanceKind } from './core/instance.js'; import { removePortFile, writePortFile } from './core/port-discovery.js'; import { recoverStaleRuns } from './core/runner.js'; import { localDate, startScheduler } from './core/scheduler.js'; import { seedIfDemoAndEmpty } from './core/seed.js'; import { getSubscriptionSchedule } from './core/subscription-scheduler.js'; import { SURFACE_LABEL } from './web/layout.js'; import { createRouter } from './web/router.js'; import { registerApiRoutes } from './web/pages/api.js'; import { registerPageRoutes } from './web/pages/index.js'; const APP_DIR = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = resolve(APP_DIR, 'public'); /** @returns {string} */ function readVersion() { try { const pkg = /** @type {{version?: string}} */ ( JSON.parse(readFileSync(resolve(APP_DIR, 'package.json'), 'utf8')) ); return pkg.version ?? '0.0.0'; } catch { return '0.0.0'; } } export const VERSION = readVersion(); /** * Describe the configured inference routes for the production-entrypoint message. * This is pure so startup copy can be exercised without opening a database or port. * * @param {import('./core/config.js').Config} config * @returns {string|null} startup guidance, or null when an API route is already configured */ export function startupGuidance(config) { if (config.demo) return 'Demo mode: live provider calls and the scheduler are disabled.'; const labels = (config.subscriptionSurfaces ?? []).map((surface) => SURFACE_LABEL[surface] ?? surface); if ((config.enabledProviders ?? []).length > 0) return null; if (labels.length > 0) { return `No API keys are configured. Subscription measurements are enabled for ${labels.join( ' and ', )}; Hearsay can run through signed-in plan allowance, which may incur overage.`; } return 'No inference route is configured. Authenticate Codex or Claude Code and set HEARSAY_CODEX_ENABLED=1 or HEARSAY_CLAUDE_CODE_ENABLED=1, or configure an optional API key for direct API measurements.'; } /** * Build the router for a given database + config. * * The page and API route tables live in `web/` (§10.2, §10.3); this function only * wires them together, so the HTTP surface stays in one lane. * * @param {Object} deps * @param {import('node:sqlite').DatabaseSync} deps.db * @param {import('./core/config.js').Config} deps.config * @returns {ReturnType} */ export function buildRouter({ db, config }) { const router = createRouter({ publicDir: PUBLIC_DIR }); registerPageRoutes(router, { db, config, version: VERSION }); registerApiRoutes(router, { db, config, version: VERSION }); return router; } /** * Boot an HTTP server. Pass `port: 0` for an ephemeral port (tests). * * @param {Object} [opts] * @param {number} [opts.port] * @param {string} [opts.host] * @param {string} [opts.dbPath] * @param {string} [opts.portFile] * @param {import('./core/config.js').Config} [opts.config] * @param {(message: string) => void} [opts.log] boot diagnostics; stderr by default * @returns {Promise<{server: import('node:http').Server, port: number, db: import('node:sqlite').DatabaseSync, scheduler: import('./core/scheduler.js').Scheduler, close: () => Promise}>} */ export async function startServer(opts = {}) { const config = opts.config ?? processConfig; const dbPath = opts.dbPath ?? config.dbPath; const port = opts.port ?? config.port; const host = opts.host ?? config.host; const portFile = opts.portFile ?? config.portFile; const log = opts.log ?? ((/** @type {string} */ message) => process.stderr.write(`${message}\n`)); assertInstancePaths(config, dbPath); const db = openDb(dbPath); try { ensureInstanceKind(db, config.demo); } catch (error) { db.close(); throw error; } // Demo mode with nothing to show boots into the fictional universe rather than an empty // dashboard (§12). Never touches a database that already has rows, seeded or live. const seeded = seedIfDemoAndEmpty(db, config); if (seeded) { process.stderr.write( `Demo mode: seeded ${seeded.responses} answers over ${seeded.runs} days (${seeded.from} to ${seeded.to}).\n`, ); } // §8.1 step 1: a 'running' row older than 2h is an orphan from a crashed process — // mark it failed now, or runPanel refuses with RunInProgressError for up to 2h. const recovered = recoverStaleRuns(db); if (recovered > 0) log(`Recovered ${recovered} stale running run(s) — marked failed.`); // §8.2: the daily scheduler. startScheduler itself declines demo mode and keyless // installs; the boot log states the next run time when it is live. const scheduler = startScheduler({ db, config }); if (scheduler.enabled && config.enabledProviders.length > 0) { const ranToday = getSetting(db, SETTING_KEYS.LAST_SCHEDULED_RUN_DATE, null) === localDate(new Date()); log(`Scheduler: next panel run ${ranToday ? 'tomorrow' : 'today'} at ${config.runAt} (local time).`); } if (!config.demo && config.subscriptionSurfaces.length > 0) { const schedule = getSubscriptionSchedule(db); log(schedule ? `Subscription schedule: ${schedule.runAt} (${schedule.timeZone}); see tracking health for the next occurrence and consent state.` : 'Subscription runner available; no active recurring schedule.'); } const router = buildRouter({ db, config }); const server = createServer((req, res) => { void router.handle(req, res); }); await new Promise((resolveListen, rejectListen) => { server.once('error', rejectListen); server.listen(port, host, () => { server.removeListener('error', rejectListen); resolveListen(undefined); }); }); const address = server.address(); const boundPort = typeof address === 'object' && address !== null ? address.port : port; try { writePortFile(portFile, boundPort); } catch (error) { scheduler.stop(); await new Promise((done) => server.close(() => done(undefined))); db.close(); throw error; } return { server, port: boundPort, db, scheduler, close: async () => { scheduler.stop(); await new Promise((done) => server.close(() => done(undefined))); removePortFile(portFile, boundPort); db.close(); }, }; } /** @returns {boolean} */ function isMainModule() { const entry = process.argv[1]; if (!entry) return false; return resolve(entry) === fileURLToPath(import.meta.url); } if (isMainModule()) { const { port } = await startServer(); process.stdout.write(`Hearsay v${VERSION} listening on http://${processConfig.host}:${port}\n`); const guidance = startupGuidance(processConfig); if (guidance) process.stdout.write(`${guidance}\n`); }