server.js
Rama main · réplica de lectura del repositorio original.
/**
* 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<typeof createRouter>}
*/
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<void>}>}
*/
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`);
}