- GET /api/scenarios/templates: the 3 era templates (templateStore from
presets/templates/).
- POST /api/scenarios/draft: generateDraft (template + description + year) ->
{ world, checkpoints } (502 on transport failure / unusable world).
- POST /api/scenarios/commit: finalizeWorld (constrained to approved
checkpoints, optionally based on the re-sent draft) -> saved GameState,
returns { id } (a normal game at #/game/<id>).
- scenarioLlm: the deterministic scenario stub under LLM_STUB=1 (distinct
from the untouched turn stub), the live client otherwise.
- jump route: synthesize a minimal Preset for dynamic games (presetId
'dynamic', no static file) so their turns run; genuinely-missing static
presets still 404.
Fixes #52
512 lines
21 KiB
TypeScript
512 lines
21 KiB
TypeScript
// Server entry: one Express process that serves the built client, the /api REST
|
||
// routes, and the /sse stream — all on a single internal port (default 4321).
|
||
// In dev, Vite (:5173) proxies /api and /sse here; in Docker/Traefik the whole
|
||
// thing is one origin.
|
||
import express, { type NextFunction, type Request, type Response } from 'express';
|
||
import { randomUUID } from 'node:crypto';
|
||
import { existsSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import { deepClone, seedRegionStats } from '../shared/protocol';
|
||
import type {
|
||
AddActionRequest,
|
||
AddDiplomacyRequest,
|
||
ConfirmNationOpRequest,
|
||
CreateGameRequest,
|
||
RejectNationOpRequest,
|
||
RemoveQueueItemRequest,
|
||
} from '../shared/protocol';
|
||
import type { GameState, Preset } from '../shared/types';
|
||
import type { WorldDraft } from '../shared/scenario';
|
||
import { createLlmClient } from './llm';
|
||
import { makeStubLlmClient } from './stub';
|
||
import { makeScenarioStubLlmClient } from './stubScenario';
|
||
import { finalizeWorld, generateDraft } from './scenario';
|
||
import { askAdvisor, confirmNationOp, queueRoundItem, rejectNationOp, removeRoundItem, resolveRegionKey, runTurn } from './engine';
|
||
import { buildRegionMap } from './map';
|
||
import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, loadRegions, neighboursOf, referenceForKeys, regionName, validCountryCodes, validKeys } from './regions';
|
||
import { createPresetStore } from './presets';
|
||
import { createTemplateStore } from './templates';
|
||
import { openDb, type Db } from './db';
|
||
|
||
const here = fileURLToPath(new URL('.', import.meta.url));
|
||
|
||
/**
|
||
* Resolve the project root: dev (tsx) runs from <root>/server, the compiled prod
|
||
* bundle runs from <root>/server/dist. Probe a couple of candidates for presets/.
|
||
*/
|
||
function findRoot(): string {
|
||
const candidates = [
|
||
path.resolve(here, '..'), // dev: <root> | prod: <root>/server
|
||
path.resolve(here, '../..'), // prod: <root>
|
||
];
|
||
for (const c of candidates) {
|
||
if (existsSync(path.join(c, 'presets', 'index.json'))) return c;
|
||
}
|
||
return candidates[0];
|
||
}
|
||
|
||
const root = findRoot();
|
||
const presetsDir = path.join(root, 'presets');
|
||
const clientDir = process.env.CLIENT_DIST ?? path.join(root, 'dist');
|
||
const dataDir = process.env.DATA_DIR ?? path.join(root, 'data');
|
||
|
||
// PORT wins; HISTORY_API_PORT is the override used by the dev proxy (vite) and
|
||
// the e2e config, so honor it as a fallback to keep all three in agreement.
|
||
const PORT = parseInt(process.env.PORT ?? process.env.HISTORY_API_PORT ?? '4321', 10);
|
||
const presetStore = createPresetStore(presetsDir);
|
||
// (v0.31, #52) Era templates for the "Create scenario" path (presets/templates/).
|
||
const templateStore = createTemplateStore(path.join(root, 'presets', 'templates'));
|
||
// Load the region-key manifest + geo once at boot (v0.19 #16 P1). A missing file
|
||
// just means region expansion/reference degrade to the raw keys (server still boots).
|
||
loadRegions(root);
|
||
const db: Db = openDb(path.join(dataDir, 'history.db'));
|
||
// LLM_STUB=1 → deterministic stub (tests/e2e/dev without a live model).
|
||
const useStub = process.env.LLM_STUB === '1';
|
||
const llm = useStub ? makeStubLlmClient() : createLlmClient();
|
||
// (v0.31, #52) The scenario-generation LLM client: the deterministic scenario
|
||
// stub under LLM_STUB=1 (a distinct transport from the turn stub, which stays
|
||
// untouched), and the live client otherwise.
|
||
const scenarioLlm = useStub ? makeScenarioStubLlmClient() : llm;
|
||
|
||
const app = express();
|
||
app.use(express.json());
|
||
|
||
// --- SSE client registry (gameId -> set of open responses) ---
|
||
const clients = new Map<string, Set<Response>>();
|
||
|
||
function addClient(res: Response, id: string): void {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
Connection: 'keep-alive',
|
||
'Access-Control-Allow-Origin': '*',
|
||
});
|
||
res.write('retry: 2000\n\n');
|
||
let set = clients.get(id);
|
||
if (!set) {
|
||
set = new Set();
|
||
clients.set(id, set);
|
||
}
|
||
set.add(res);
|
||
res.on('close', () => {
|
||
set.delete(res);
|
||
if (set.size === 0) clients.delete(id);
|
||
});
|
||
}
|
||
|
||
function broadcast(id: string, event: string, data: unknown): void {
|
||
const set = clients.get(id);
|
||
if (!set) return;
|
||
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
for (const r of set) r.write(chunk);
|
||
}
|
||
|
||
// --- Routes ---
|
||
|
||
/**
|
||
* (v0.31, #52) Synthesize the minimal `Preset` the turn engine needs for a
|
||
* dynamically generated game (`presetId: 'dynamic'` — there is no static preset
|
||
* file on disk). `runTurn` only consumes `title` / `startDate` /
|
||
* `worldBackground` / `simulationRules` (never `preset.nations`), so a generic
|
||
* alternate-history background plus the standard rules is sufficient; `nations`
|
||
* is carried from the saved world so the type is complete. `startDate` uses the
|
||
* game's current date (GameState stores no separate start date) — it only feeds
|
||
* the static "WORLD: <title> — <date>" header of the system prompt.
|
||
*/
|
||
function dynamicPresetFromGame(game: GameState): Preset {
|
||
return {
|
||
id: game.presetId,
|
||
title: game.presetTitle,
|
||
startDate: game.date,
|
||
summary: game.presetTitle,
|
||
worldBackground: `${game.presetTitle} is a dynamically generated alternate-history world. Keep every nation's stats, borders and relations internally consistent as the world evolves.`,
|
||
simulationRules: [
|
||
"Advance the shared world for ALL nations, not only the player's.",
|
||
'Keep every stat value between 0 and 100.',
|
||
'Only change borders when war or a decisive political shift justifies it.',
|
||
'Prefer 1-4 events per turn; keep each narrative to 1-3 sentences.',
|
||
'Use only nation ids and region keys that exist in the provided state.',
|
||
],
|
||
nations: Object.values(game.nations),
|
||
};
|
||
}
|
||
|
||
app.get('/api/health', (_req, res) => {
|
||
res.json({ ok: true, stub: useStub, port: PORT });
|
||
});
|
||
|
||
app.get('/api/presets', (_req, res) => {
|
||
res.json({ presets: presetStore.listMeta() });
|
||
});
|
||
|
||
app.get('/api/presets/:id', (req, res) => {
|
||
const preset = presetStore.get(req.params.id);
|
||
if (!preset) return res.status(404).json({ error: 'unknown preset' });
|
||
res.json({ preset });
|
||
});
|
||
|
||
app.post('/api/games', (req: Request, res) => {
|
||
const body = req.body as CreateGameRequest | undefined;
|
||
const preset = presetStore.get(body?.presetId ?? '');
|
||
if (!preset) return res.status(400).json({ error: 'unknown preset' });
|
||
const nation = preset.nations.find((n) => n.id === body?.playerNationId);
|
||
if (!nation) return res.status(400).json({ error: 'unknown nation' });
|
||
const state: GameState = {
|
||
id: randomUUID(),
|
||
presetId: preset.id,
|
||
presetTitle: preset.title,
|
||
playerNationId: nation.id,
|
||
turn: 1,
|
||
date: preset.startDate,
|
||
nations: Object.fromEntries(preset.nations.map((n) => [n.id, deepClone(n)])),
|
||
events: [],
|
||
pending: [],
|
||
version: 1, // the first saved revision; every later mutation bumps it
|
||
// (v0.22, #16 P3) No man's land can only arise mid-game — a fresh game
|
||
// starts with nothing released; this list grows as the LLM releases
|
||
// regions and shrinks as they get re-claimed.
|
||
unowned: [],
|
||
// (v0.23, #16 P4a) No pending nation-op proposals on a fresh game; the
|
||
// list grows as the LLM proposes renames (and, later, splits/merges) and
|
||
// shrinks as the player confirms or rejects them.
|
||
pendingNationOps: [],
|
||
// (v0.27, #44) No markers on a fresh game; the LLM places them over time.
|
||
markers: [],
|
||
// (v0.28, #46) Per-region stats are seeded below, once territories hold
|
||
// real region keys — each region starts as a copy of its owner's initial
|
||
// stats, so the derived aggregate equals the nation's stats on turn 1.
|
||
regionStats: {},
|
||
};
|
||
// v0.19 (#16 P1): expand each nation's ISO_A3 country list to the region keys
|
||
// of all its sub-regions, so ownership (and the choropleth) is at region
|
||
// granularity from the very first turn. Preset JSONs stay country-level.
|
||
for (const n of Object.values(state.nations)) n.territory = expandCountriesToKeys(n.territory);
|
||
// (v0.28, #46): seed per-region stats AFTER the expansion so each owned
|
||
// region key gets its owner's initial stats (see seedRegionStats).
|
||
state.regionStats = seedRegionStats(state.nations);
|
||
db.save(state);
|
||
res.json({ game: state });
|
||
});
|
||
|
||
// List every saved game (newest-updated first) as a lightweight summary, so the
|
||
// home screen can offer to resume prior games. `db.listIds()` is already ordered
|
||
// by updated_at DESC.
|
||
app.get('/api/games', (_req, res) => {
|
||
const games = db
|
||
.listIds()
|
||
.map((id) => db.load(id))
|
||
.filter((g): g is GameState => g !== null)
|
||
.map((g) => ({
|
||
id: g.id,
|
||
presetId: g.presetId,
|
||
presetTitle: g.presetTitle,
|
||
playerNationId: g.playerNationId,
|
||
turn: g.turn,
|
||
date: g.date,
|
||
}));
|
||
res.json({ games });
|
||
});
|
||
|
||
app.get('/api/games/:id', (req, res) => {
|
||
const game = db.load(req.params.id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
res.json({ game });
|
||
});
|
||
|
||
app.get('/api/games/:id/map', (req, res) => {
|
||
const game = db.load(req.params.id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
res.json({ regions: buildRegionMap(game) });
|
||
});
|
||
|
||
// Delete a saved game entirely. Fast, non-LLM. 404 if it doesn't exist; on
|
||
// success broadcast an SSE `error` to any other open tab watching it so it
|
||
// returns home (see the `event: error` handling in src/lib/sse.ts).
|
||
app.delete('/api/games/:id', (req, res) => {
|
||
const id = req.params.id;
|
||
const removed = db.delete(id);
|
||
if (!removed) return res.status(404).json({ error: 'game not found' });
|
||
broadcast(id, 'error', { message: 'game deleted' });
|
||
res.json({ deleted: true });
|
||
});
|
||
|
||
// Queue a free-text player action onto the pending round. No LLM call and no
|
||
// turn advance — the round is only committed (in one turn) by "Jump forward".
|
||
app.post('/api/games/:id/action', (req, res) => {
|
||
const id = req.params.id;
|
||
const game = db.load(id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
const text = (req.body as AddActionRequest | undefined)?.text?.trim();
|
||
if (!text) return res.status(400).json({ error: 'empty action' });
|
||
const next = queueRoundItem(game, { kind: 'action', text });
|
||
db.save(next);
|
||
broadcast(id, 'state', { state: next });
|
||
res.json({ game: next });
|
||
});
|
||
|
||
// Queue a diplomatic message to a target nation onto the pending round. No LLM
|
||
// call and no advance.
|
||
app.post('/api/games/:id/diplomacy', (req, res) => {
|
||
const id = req.params.id;
|
||
const game = db.load(id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
const body = (req.body ?? {}) as AddDiplomacyRequest;
|
||
const message = body.message?.trim();
|
||
if (!body.to || !message) return res.status(400).json({ error: 'empty message' });
|
||
const next = queueRoundItem(game, { kind: 'diplomacy', to: body.to, text: message });
|
||
db.save(next);
|
||
broadcast(id, 'state', { state: next });
|
||
res.json({ game: next });
|
||
});
|
||
|
||
// Remove one item from the pending round, by its index in `game.pending`. No
|
||
// LLM call, no turn advance — same fast save → broadcast → respond order as the
|
||
// add routes above. The mutated state bumps `version`, so clients (which apply
|
||
// states "never go backwards") always reconcile to it.
|
||
app.post('/api/games/:id/queue/remove', (req, res) => {
|
||
const id = req.params.id;
|
||
const game = db.load(id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
const index = (req.body as RemoveQueueItemRequest | undefined)?.index;
|
||
const next = removeRoundItem(game, index);
|
||
if (!next) return res.status(400).json({ error: 'invalid queue index' });
|
||
db.save(next);
|
||
broadcast(id, 'state', { state: next });
|
||
res.json({ game: next });
|
||
});
|
||
|
||
// (v0.23, #16 P4a; v0.24, #16 P4b) Resolve a pending nation-op proposal
|
||
// waiting in game.pendingNationOps: a rename takes effect (body.edits.name
|
||
// may override the proposed name), a split partitions the nation's territory
|
||
// into the proposed parts (body.edits.playerPart picks which part the player
|
||
// leads, default 0), and a merge folds its inputs into the survivor
|
||
// (body.edits.name may override the merged name). No LLM call, no turn
|
||
// advance — same fast save → broadcast → respond order as /queue/remove.
|
||
// The version bump lets "never go backwards" clients reconcile.
|
||
app.post('/api/games/:id/nation-op/confirm', (req, res) => {
|
||
const id = req.params.id;
|
||
const game = db.load(id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
const body = (req.body ?? {}) as ConfirmNationOpRequest;
|
||
const next = confirmNationOp(game, body.index, body.edits);
|
||
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||
db.save(next);
|
||
broadcast(id, 'state', { state: next });
|
||
res.json({ game: next });
|
||
});
|
||
|
||
app.post('/api/games/:id/nation-op/reject', (req, res) => {
|
||
const id = req.params.id;
|
||
const game = db.load(id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
const body = (req.body ?? {}) as RejectNationOpRequest;
|
||
const next = rejectNationOp(game, body.index);
|
||
if (!next) return res.status(400).json({ error: 'invalid proposal index' });
|
||
db.save(next);
|
||
broadcast(id, 'state', { state: next });
|
||
res.json({ game: next });
|
||
});
|
||
|
||
// Jump forward: commit the whole pending round (or an empty one) to the model
|
||
// in a single turn. applyTurnResult clears the pending queue on the returned
|
||
// state. 404 for not-found/preset-missing; 502 for LLM/model (transport) errors.
|
||
app.post('/api/games/:id/jump', async (req, res) => {
|
||
const id = req.params.id;
|
||
const game = db.load(id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
let preset = presetStore.get(game.presetId);
|
||
if (!preset) {
|
||
// (v0.31, #52) A dynamically generated game has no static preset file:
|
||
// synthesize one from the saved world so the turn engine can run. A
|
||
// genuinely-missing static preset still 404s.
|
||
if (game.presetId !== 'dynamic') return res.status(404).json({ error: 'preset_missing' });
|
||
preset = dynamicPresetFromGame(game);
|
||
}
|
||
try {
|
||
const { state, result } = await runTurn({
|
||
llm,
|
||
preset,
|
||
state: game,
|
||
items: game.pending,
|
||
// (v0.22, #16 P3) the region reference table must also cover released
|
||
// (no-man's-land) keys — they belong to no nation's territory, but the
|
||
// model needs their names to re-claim them.
|
||
regionReference: referenceForKeys(
|
||
new Set([...allTerritoryKeys(game), ...(game.unowned ?? [])]),
|
||
),
|
||
validRegionKeys: validKeys(),
|
||
// v0.21 (#16 P2): when the region-adjacency manifest loaded, hand the
|
||
// engine the resolver — the turn then runs with the LLM's getNeighbours
|
||
// tool and the player-frontier prompt field (both switch off without it).
|
||
getNeighbours: hasAdjacency() ? (keys) => neighboursOf(keys) : undefined,
|
||
// (v0.27, #44) when the centroid manifest loaded, hand the engine the
|
||
// resolver so a turn can place new map markers at their region's
|
||
// centroid (without it, marker adds are dropped; existing markers are
|
||
// still maintained).
|
||
centroidOf: hasCentroids() ? (key) => centroidOf(key) : undefined,
|
||
// (v0.28, #46) when the region-geo manifest (area + population) loaded,
|
||
// hand the engine the resolver so the end-of-turn recompute weights each
|
||
// owned region by area×population (without it the aggregate degrades to
|
||
// an unweighted median — still region-consistent, just unweighted).
|
||
geoOf: hasGeo() ? (key) => geoOf(key) : undefined,
|
||
// (v0.29, #46 PR 2) region ref resolver: lets the model name a
|
||
// regionChanges target by its human-readable name (resolved
|
||
// case-insensitively against the region-key manifest) or its key;
|
||
// unknown / ambiguous refs are dropped. Always on — the resolver is
|
||
// pure (validKeys + regionName) and degrades to a key-only match when
|
||
// the manifest is absent.
|
||
regionKeyResolver: (ref) => resolveRegionKey(ref, validKeys(), (k) => regionName(k)),
|
||
// (v0.30, #50) region display name for the engine's "invasion repelled"
|
||
// event (geo properties.name, else the raw key). Always on — the
|
||
// resolver is pure and degrades to the key when the manifest is absent.
|
||
regionNameOf: (k) => regionName(k),
|
||
});
|
||
db.save(state);
|
||
broadcast(id, 'turn', { state, result });
|
||
res.json({ state, result });
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
res.status(502).json({ error: `jump failed: ${msg}` });
|
||
}
|
||
});
|
||
|
||
app.post('/api/games/:id/advisor', async (req, res) => {
|
||
const game = db.load(req.params.id);
|
||
if (!game) return res.status(404).json({ error: 'game not found' });
|
||
const prompt = (req.body as { prompt?: string } | undefined)?.prompt;
|
||
try {
|
||
const suggestions = await askAdvisor(llm, game, prompt);
|
||
res.json({ suggestions });
|
||
} catch {
|
||
// Live model down → degrade gracefully with a canned idea list.
|
||
res.json({
|
||
suggestions: [
|
||
'Rearm quietly to buy time while the great powers watch.',
|
||
'Court a neutral neighbor for a defensive pact.',
|
||
'Expand industry to sustain a longer conflict.',
|
||
],
|
||
});
|
||
}
|
||
});
|
||
|
||
// --- Dynamic scenario generation (v0.31, #52) ---
|
||
//
|
||
// The additive "Create scenario" path. Step 1 (POST /draft) designs a draft
|
||
// world + an advisor checkpoint list; the client shows both, the player
|
||
// approves/removes checkpoints and picks a nation; step 2 (POST /commit)
|
||
// finalizes the world (constrained to the approved checkpoints) into a saved,
|
||
// playable game. Both steps ride the scenario LLM client (the deterministic
|
||
// scenario stub under LLM_STUB=1, the live model otherwise). All existing
|
||
// routes are untouched.
|
||
|
||
app.get('/api/scenarios/templates', (_req, res) => {
|
||
res.json({ templates: templateStore.listMeta() });
|
||
});
|
||
|
||
app.post('/api/scenarios/draft', async (req, res) => {
|
||
const body = (req.body ?? {}) as { templateId?: string; description?: string; year?: number };
|
||
const template = templateStore.get(body.templateId ?? '');
|
||
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||
const codes = validCountryCodes();
|
||
try {
|
||
const { world, checkpoints } = await generateDraft({
|
||
llm: scenarioLlm,
|
||
template,
|
||
description,
|
||
year,
|
||
// No region data → no code filtering (the draft keeps whatever codes the
|
||
// model emitted; expansion degrades to the raw keys, like presets do).
|
||
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||
});
|
||
if (!world) return res.status(502).json({ error: 'generate: the model returned no usable world' });
|
||
res.json({ world, checkpoints });
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
res.status(502).json({ error: `generate failed: ${msg}` });
|
||
}
|
||
});
|
||
|
||
app.post('/api/scenarios/commit', async (req, res) => {
|
||
const body = (req.body ?? {}) as {
|
||
templateId?: string;
|
||
description?: string;
|
||
year?: number;
|
||
draft?: WorldDraft;
|
||
approvedCheckpoints?: string[];
|
||
playerNationId?: string;
|
||
};
|
||
const template = templateStore.get(body.templateId ?? '');
|
||
if (!template) return res.status(400).json({ error: 'unknown template' });
|
||
const description = typeof body.description === 'string' ? body.description.trim() : '';
|
||
const year = Number.isFinite(body.year as number) ? (body.year as number) : template.year;
|
||
const approvedCheckpoints = Array.isArray(body.approvedCheckpoints)
|
||
? body.approvedCheckpoints.filter((c): c is string => typeof c === 'string' && c.trim() !== '')
|
||
: [];
|
||
const playerNationId = typeof body.playerNationId === 'string' ? body.playerNationId : '';
|
||
const codes = validCountryCodes();
|
||
try {
|
||
const state = await finalizeWorld({
|
||
llm: scenarioLlm,
|
||
template,
|
||
description,
|
||
year,
|
||
approvedCheckpoints,
|
||
draft: body.draft,
|
||
playerNationId,
|
||
validCountryCodes: codes.size > 0 ? codes : undefined,
|
||
expand: (cs) => expandCountriesToKeys(cs),
|
||
id: randomUUID(),
|
||
title: template.name,
|
||
startDate: `${year}-01-01`,
|
||
});
|
||
db.save(state);
|
||
res.json({ id: state.id });
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
res.status(502).json({ error: `commit failed: ${msg}` });
|
||
}
|
||
});
|
||
|
||
// --- SSE stream ---
|
||
app.get('/sse/:id', (req, res) => {
|
||
const id = req.params.id;
|
||
const game = db.load(id);
|
||
addClient(res, id);
|
||
if (game) {
|
||
res.write(`event: state\ndata: ${JSON.stringify({ state: game })}\n\n`);
|
||
} else {
|
||
res.write(`event: error\ndata: ${JSON.stringify({ message: 'game not found' })}\n\n`);
|
||
}
|
||
});
|
||
|
||
// --- Static client + SPA fallback ---
|
||
if (existsSync(clientDir)) {
|
||
app.use(express.static(clientDir));
|
||
app.get('*', (req, res) => {
|
||
if (req.path.startsWith('/api/') || req.path.startsWith('/sse/')) {
|
||
return res.status(404).send('not found');
|
||
}
|
||
res.sendFile(path.join(clientDir, 'index.html'));
|
||
});
|
||
}
|
||
|
||
// Unhandled async errors shouldn't take down the process.
|
||
app.use((err: unknown, _req: Request, res: Response, next: NextFunction) => {
|
||
console.error('[history] unhandled error:', err);
|
||
if (!res.headersSent) res.status(500).json({ error: 'internal error' });
|
||
next();
|
||
});
|
||
|
||
app.listen(PORT, () => {
|
||
console.log(
|
||
`[history] listening on :${PORT} (llm=${useStub ? 'STUB' : 'live'}) client=${existsSync(clientDir) ? 'found' : 'absent'}`,
|
||
);
|
||
});
|
||
|
||
// Exported for tests (createApp is implicit via app; here we just expose handles).
|
||
export { app, db, llm, scenarioLlm, templateStore };
|