654 lines
23 KiB
JavaScript
654 lines
23 KiB
JavaScript
// MelodyMuse — single-file production server.
|
|
//
|
|
// Responsibilities:
|
|
// 1. Serve the built Vite SPA from `./dist/` (SPA fallback to index.html).
|
|
// 2. Proxy LLM calls through three JSON endpoints so the browser never
|
|
// sees the API key.
|
|
//
|
|
// Run with: node server.mjs
|
|
// Or via: npm start
|
|
//
|
|
// Required env vars (the server exits if any are missing):
|
|
// LLM_ENDPOINT — OpenAI-compatible base URL, e.g. https://api.minimax.chat/v1
|
|
// LLM_API_KEY — provider secret key
|
|
// Optional env vars:
|
|
// LLM_MODEL — model name (default: MiniMax-M3)
|
|
// PORT — listen port (default: 3000)
|
|
// CORS_ORIGIN — "*" to allow any origin, or a specific origin
|
|
// (default: "*", fine for single-user personal use)
|
|
|
|
import { createServer } from 'node:http';
|
|
import { readFile, writeFile, stat, mkdir } from 'node:fs/promises';
|
|
import { extname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
buildStylePrompt,
|
|
buildSystemPrompt,
|
|
buildUserMessage,
|
|
setActivePrompts,
|
|
DEFAULT_PROMPTS,
|
|
} from './server/prompts.mjs';
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Configuration
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const DIST = join(__dirname, 'dist');
|
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
|
|
const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data');
|
|
const HISTORY_FILE = join(DATA_DIR, 'history.json');
|
|
const PROMPTS_FILE = join(DATA_DIR, 'prompts.json');
|
|
const MAX_HISTORY = 500;
|
|
|
|
const LLM = {
|
|
endpoint: (process.env.LLM_ENDPOINT || '').replace(/\/+$/, ''),
|
|
apiKey: process.env.LLM_API_KEY || '',
|
|
model: process.env.LLM_MODEL || 'MiniMax-M3',
|
|
};
|
|
|
|
if (!LLM.endpoint || !LLM.apiKey) {
|
|
console.error('');
|
|
console.error(' [FATAL] LLM_ENDPOINT and LLM_API_KEY must be set.');
|
|
console.error(` LLM_ENDPOINT: ${LLM.endpoint || '<missing>'}`);
|
|
console.error(` LLM_API_KEY: ${LLM.apiKey ? '********' : '<missing>'}`);
|
|
console.error('');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Ensure data directory exists
|
|
await mkdir(DATA_DIR, { recursive: true });
|
|
|
|
// Load prompts
|
|
try {
|
|
const customPromptsStr = await readFile(PROMPTS_FILE, 'utf-8');
|
|
const customPrompts = JSON.parse(customPromptsStr);
|
|
setActivePrompts(customPrompts);
|
|
console.log('[startup] Custom system prompts loaded');
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
console.error('[startup] Failed to read prompts.json:', err.message);
|
|
}
|
|
}
|
|
|
|
console.log(`[startup] Model: ${LLM.model}`);
|
|
console.log(`[startup] Endpoint: ${LLM.endpoint}`);
|
|
console.log(`[startup] Data dir: ${DATA_DIR}`);
|
|
console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// History helpers
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
let historyCache = null;
|
|
|
|
async function readHistory() {
|
|
if (historyCache !== null) return historyCache;
|
|
try {
|
|
const raw = await readFile(HISTORY_FILE, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
historyCache = Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
historyCache = [];
|
|
}
|
|
return historyCache;
|
|
}
|
|
|
|
async function writeHistory(entries) {
|
|
historyCache = entries;
|
|
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// JSON extraction + shape validation
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
function isObject(v) {
|
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
}
|
|
|
|
// Pull a JSON object out of a model reply that may have wrapped it in
|
|
// ```json ... ``` fences or added a brief preamble.
|
|
function extractJson(text) {
|
|
const trimmed = text.trim();
|
|
try { return JSON.parse(trimmed); } catch { /* fall through */ }
|
|
|
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
if (fenced) {
|
|
try { return JSON.parse(fenced[1].trim()); } catch { /* fall through */ }
|
|
}
|
|
|
|
const firstBrace = trimmed.indexOf('{');
|
|
const lastBrace = trimmed.lastIndexOf('}');
|
|
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
const slice = trimmed.slice(firstBrace, lastBrace + 1);
|
|
try { return JSON.parse(slice); } catch { /* fall through */ }
|
|
}
|
|
|
|
throw new Error('Model response did not contain a parseable JSON object');
|
|
}
|
|
|
|
const REQUIRED_KEYS = [
|
|
'titles',
|
|
'lyrics',
|
|
'style',
|
|
'negative_style',
|
|
'youtube_description',
|
|
'video_prompts',
|
|
];
|
|
|
|
function validateShape(parsed) {
|
|
if (!isObject(parsed)) throw new Error('Model response is not an object');
|
|
for (const k of REQUIRED_KEYS) {
|
|
if (!(k in parsed)) continue; // partial regen may omit keys
|
|
const v = parsed[k];
|
|
if (k === 'titles') {
|
|
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) {
|
|
throw new Error('"titles" must be an array of strings');
|
|
}
|
|
} else if (k === 'video_prompts') {
|
|
if (!Array.isArray(v)) throw new Error('"video_prompts" must be an array');
|
|
} else if (typeof v !== 'string') {
|
|
throw new Error(`"${k}" must be a string`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateFullAssets(parsed) {
|
|
if (!isObject(parsed)) throw new Error('Model response is not an object');
|
|
for (const k of REQUIRED_KEYS) {
|
|
if (!(k in parsed)) {
|
|
throw new Error(`Model response is missing required key "${k}"`);
|
|
}
|
|
}
|
|
validateShape(parsed);
|
|
}
|
|
|
|
// For partial regen, drop any keys the model returned that we didn't ask for.
|
|
// Keeps the merged state predictable.
|
|
function pickResultForSection(section, result) {
|
|
switch (section) {
|
|
case 'titles':
|
|
return { titles: result.titles };
|
|
case 'lyrics':
|
|
return { lyrics: result.lyrics };
|
|
case 'style':
|
|
return {
|
|
style: result.style,
|
|
negative_style: result.negative_style,
|
|
};
|
|
case 'video_prompts':
|
|
return { video_prompts: result.video_prompts };
|
|
case 'youtube_description':
|
|
return { youtube_description: result.youtube_description };
|
|
default:
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Provider call
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
async function callProvider(messages, opts = {}, signal) {
|
|
const url = `${LLM.endpoint}/chat/completions`;
|
|
let resp;
|
|
try {
|
|
resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${LLM.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: LLM.model,
|
|
max_tokens: opts.max_tokens ?? 4000,
|
|
messages,
|
|
}),
|
|
signal,
|
|
});
|
|
} catch (err) {
|
|
if (err && err.name === 'AbortError') throw err;
|
|
throw new Error(
|
|
`Could not reach ${url}. Check LLM_ENDPOINT and that the provider is reachable from this server. (Details: ${err.message})`,
|
|
);
|
|
}
|
|
|
|
if (!resp.ok) {
|
|
const text = await resp.text().catch(() => '');
|
|
throw new Error(
|
|
`Provider returned ${resp.status} ${resp.statusText}${text ? `: ${text.slice(0, 400)}` : ''}`,
|
|
);
|
|
}
|
|
|
|
const data = await resp.json();
|
|
let content = data.choices?.[0]?.message?.content;
|
|
if (typeof content !== 'string' || !content.trim()) {
|
|
throw new Error('Provider response did not include a message');
|
|
}
|
|
|
|
// Strip <think> blocks generated by reasoning models (like MiniMax-M3 or DeepSeek-R1)
|
|
content = content.replace(/<think>[\s\S]*?(?:<\/think>\s*|$)/gi, '').trim();
|
|
|
|
return content;
|
|
}
|
|
|
|
// Strip a leading "Style: " or surrounding quotes that some models like to add.
|
|
function cleanStyleString(s) {
|
|
let out = s.trim();
|
|
// Strip a single pair of wrapping quotes, either kind.
|
|
if (
|
|
(out.startsWith('"') && out.endsWith('"')) ||
|
|
(out.startsWith("'") && out.endsWith("'"))
|
|
) {
|
|
out = out.slice(1, -1).trim();
|
|
}
|
|
// Strip a "Style:" / "Style prompt:" prefix if the model added one.
|
|
out = out.replace(/^(style\s*(prompt)?\s*[:\-]\s*)/i, '');
|
|
// Strip a trailing period.
|
|
out = out.replace(/\.+$/, '').trim();
|
|
return out;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// HTTP helpers
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
const STATIC_TYPES = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.mjs': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.ico': 'image/x-icon',
|
|
'.webp': 'image/webp',
|
|
'.woff': 'font/woff',
|
|
'.woff2':'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
'.map': 'application/json; charset=utf-8',
|
|
};
|
|
|
|
function json(res, body, status = 200) {
|
|
res.writeHead(status, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
});
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function readJsonBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
req.on('data', (chunk) => {
|
|
data += chunk;
|
|
// Guard against runaway payloads.
|
|
if (data.length > 1_000_000) {
|
|
reject(new Error('Request body too large'));
|
|
req.destroy();
|
|
}
|
|
});
|
|
req.on('end', () => {
|
|
if (!data) return resolve({});
|
|
try { resolve(JSON.parse(data)); }
|
|
catch { reject(new Error('Invalid JSON body')); }
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
// Combined abort signal: the request stream closing (client disconnect) OR
|
|
// the explicit AbortSignal passed in.
|
|
function combinedSignal(req, explicit) {
|
|
const ctrl = new AbortController();
|
|
const onClose = () => ctrl.abort(new Error('client disconnected'));
|
|
req.once('close', onClose);
|
|
if (explicit) {
|
|
if (explicit.aborted) ctrl.abort(explicit.reason);
|
|
else explicit.addEventListener('abort', () => ctrl.abort(explicit.reason), { once: true });
|
|
}
|
|
return ctrl.signal;
|
|
}
|
|
|
|
async function serveStatic(req, res) {
|
|
// Strip query string and decode.
|
|
const urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
// Disallow path traversal.
|
|
if (urlPath.includes('..')) {
|
|
res.writeHead(400); res.end('Bad request'); return;
|
|
}
|
|
|
|
let filePath = join(DIST, urlPath);
|
|
try {
|
|
const s = await stat(filePath);
|
|
if (s.isDirectory()) filePath = join(filePath, 'index.html');
|
|
} catch {
|
|
// SPA fallback — serve index.html for unknown routes.
|
|
filePath = join(DIST, 'index.html');
|
|
}
|
|
|
|
try {
|
|
const data = await readFile(filePath);
|
|
const ext = extname(filePath).toLowerCase();
|
|
const type = STATIC_TYPES[ext] || 'application/octet-stream';
|
|
// Hashed Vite assets get long-lived caching; index.html and the un-hashed
|
|
// root never get cached so deploys take effect immediately.
|
|
const isHashedAsset = /\/assets\//.test(urlPath) && /\.[a-z0-9]{8}\./.test(urlPath);
|
|
const cacheControl = isHashedAsset
|
|
? 'public, max-age=31536000, immutable'
|
|
: 'no-cache';
|
|
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cacheControl });
|
|
res.end(data);
|
|
} catch (err) {
|
|
res.writeHead(500); res.end('Internal error');
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Route handlers
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
function applyCors(res) {
|
|
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
}
|
|
|
|
async function handleHealth(req, res) {
|
|
json(res, {
|
|
ok: true,
|
|
llm_configured: true,
|
|
model: LLM.model,
|
|
endpoint: LLM.endpoint,
|
|
});
|
|
}
|
|
|
|
function handleConfig(req, res) {
|
|
let videoAiLinks = [];
|
|
const envLinks = process.env.VIDEO_AI_LINKS || '';
|
|
if (envLinks.trim()) {
|
|
// Expected format: "Kling AI=https://klingai.com,Luma=https://lumalabs.ai"
|
|
videoAiLinks = envLinks.split(',').map(part => {
|
|
const sep = part.includes('=') ? '=' : '|';
|
|
const idx = part.indexOf(sep);
|
|
if (idx > 0) {
|
|
return { name: part.slice(0, idx).trim(), url: part.slice(idx + 1).trim() };
|
|
}
|
|
return null;
|
|
}).filter(link => link !== null);
|
|
}
|
|
|
|
json(res, { videoAiLinks });
|
|
}
|
|
|
|
async function handleStyleRandom(req, res) {
|
|
let body;
|
|
try { body = await readJsonBody(req); }
|
|
catch (err) { return json(res, { error: err.message }, 400); }
|
|
|
|
const mode = body?.mode === 'crazy' ? 'crazy' : 'normal';
|
|
const idea = typeof body?.idea === 'string' ? body.idea.trim() : '';
|
|
const signal = combinedSignal(req);
|
|
|
|
const userMsg = idea
|
|
? `Generate one style description now. (User's idea: "${idea}")`
|
|
: 'Generate one style description now.';
|
|
|
|
try {
|
|
const content = await callProvider(
|
|
[
|
|
{ role: 'system', content: buildStylePrompt(mode, idea) },
|
|
{ role: 'user', content: userMsg },
|
|
],
|
|
{ max_tokens: 8192 },
|
|
signal,
|
|
);
|
|
const style = cleanStyleString(content);
|
|
if (!style) throw new Error('Model returned an empty style');
|
|
json(res, { style, mode });
|
|
} catch (err) {
|
|
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
|
|
return;
|
|
}
|
|
console.error('[style/random]', err);
|
|
json(res, { error: err.message || 'Failed to generate style' }, 500);
|
|
}
|
|
}
|
|
|
|
async function handleGetHistory(req, res) {
|
|
try {
|
|
const entries = await readHistory();
|
|
json(res, entries);
|
|
} catch (err) {
|
|
console.error('[history/get]', err);
|
|
json(res, { error: 'Failed to read history' }, 500);
|
|
}
|
|
}
|
|
|
|
async function handleAddHistory(req, res) {
|
|
let body;
|
|
try { body = await readJsonBody(req); }
|
|
catch (err) { return json(res, { error: err.message }, 400); }
|
|
|
|
if (!body || typeof body.id !== 'string' || !body.input || !body.assets) {
|
|
return json(res, { error: 'Invalid history entry' }, 400);
|
|
}
|
|
|
|
try {
|
|
const entries = await readHistory();
|
|
// Deduplicate by id
|
|
const filtered = entries.filter(e => e.id !== body.id);
|
|
const next = [body, ...filtered].slice(0, MAX_HISTORY);
|
|
await writeHistory(next);
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
console.error('[history/add]', err);
|
|
json(res, { error: 'Failed to save history' }, 500);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteHistory(req, res, id) {
|
|
try {
|
|
const entries = await readHistory();
|
|
const next = entries.filter(e => e.id !== id);
|
|
await writeHistory(next);
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
console.error('[history/delete]', err);
|
|
json(res, { error: 'Failed to delete entry' }, 500);
|
|
}
|
|
}
|
|
|
|
async function handleClearHistory(req, res) {
|
|
try {
|
|
await writeHistory([]);
|
|
json(res, { ok: true });
|
|
} catch (err) {
|
|
console.error('[history/clear]', err);
|
|
json(res, { error: 'Failed to clear history' }, 500);
|
|
}
|
|
}
|
|
|
|
async function handleGenerate(req, res) {
|
|
let body;
|
|
try { body = await readJsonBody(req); }
|
|
catch (err) { return json(res, { error: err.message }, 400); }
|
|
|
|
if (!body || typeof body.input !== 'string' || !body.input.trim()) {
|
|
return json(res, { error: 'Missing "input"' }, 400);
|
|
}
|
|
if (!body.section) body.section = 'all';
|
|
if (!body.vocals) body.vocals = 'vocals';
|
|
if (!body.language) body.language = 'English';
|
|
|
|
const signal = combinedSignal(req);
|
|
|
|
const MAX_ATTEMPTS = 2;
|
|
let lastErr;
|
|
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
if (signal.aborted) break;
|
|
try {
|
|
const content = await callProvider(
|
|
[
|
|
{ role: 'system', content: buildSystemPrompt(body) },
|
|
{ role: 'user', content: buildUserMessage(body) },
|
|
],
|
|
{ max_tokens: 32768 },
|
|
signal,
|
|
);
|
|
|
|
if (body.section === 'lyrics_partial') {
|
|
return json(res, { lyrics: content });
|
|
}
|
|
|
|
const parsed = extractJson(content);
|
|
|
|
if (body.section === 'all') {
|
|
validateFullAssets(parsed);
|
|
} else {
|
|
validateShape(parsed);
|
|
}
|
|
|
|
const result = body.section === 'all' ? parsed : pickResultForSection(body.section, parsed);
|
|
return json(res, result);
|
|
} catch (err) {
|
|
if (err && (err.name === 'AbortError' || /client disconnected/i.test(err.message))) {
|
|
return;
|
|
}
|
|
lastErr = err;
|
|
if (attempt < MAX_ATTEMPTS) {
|
|
console.warn(`[generate] attempt ${attempt} failed, retrying… (${err.message})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.error('[generate]', lastErr);
|
|
json(res, { error: lastErr?.message || 'Generation failed' }, 500);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// System Prompts Config Handlers
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
import { activePrompts } from './server/prompts.mjs';
|
|
import { unlink } from 'node:fs/promises';
|
|
|
|
function handleGetSystemPrompts(req, res) {
|
|
json(res, activePrompts);
|
|
}
|
|
|
|
async function handlePostSystemPrompts(req, res) {
|
|
try {
|
|
const body = await parseJsonBody(req);
|
|
// Merge with defaults to ensure all keys exist
|
|
const newPrompts = { ...DEFAULT_PROMPTS, ...body };
|
|
setActivePrompts(newPrompts);
|
|
await writeFile(PROMPTS_FILE, JSON.stringify(newPrompts, null, 2), 'utf-8');
|
|
json(res, { success: true });
|
|
} catch (err) {
|
|
console.error('[prompts] failed to save', err);
|
|
json(res, { error: 'Failed to save prompts' }, 500);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteSystemPrompts(req, res) {
|
|
try {
|
|
try {
|
|
await unlink(PROMPTS_FILE);
|
|
} catch (e) {
|
|
if (e.code !== 'ENOENT') throw e;
|
|
}
|
|
setActivePrompts(DEFAULT_PROMPTS);
|
|
json(res, { success: true });
|
|
} catch (err) {
|
|
console.error('[prompts] failed to delete', err);
|
|
json(res, { error: 'Failed to restore default prompts' }, 500);
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
// Server
|
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
|
|
const server = createServer(async (req, res) => {
|
|
applyCors(res);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// API
|
|
if (req.method === 'GET' && req.url.split('?')[0] === '/api/health') {
|
|
return handleHealth(req, res);
|
|
}
|
|
if (req.method === 'GET' && req.url.split('?')[0] === '/api/config') {
|
|
return handleConfig(req, res);
|
|
}
|
|
|
|
// System Prompts endpoints
|
|
if (req.method === 'GET' && req.url.split('?')[0] === '/api/system-prompts') {
|
|
return handleGetSystemPrompts(req, res);
|
|
}
|
|
if (req.method === 'POST' && req.url.split('?')[0] === '/api/system-prompts') {
|
|
return handlePostSystemPrompts(req, res);
|
|
}
|
|
if (req.method === 'DELETE' && req.url.split('?')[0] === '/api/system-prompts') {
|
|
return handleDeleteSystemPrompts(req, res);
|
|
}
|
|
|
|
if (req.method === 'POST' && req.url.split('?')[0] === '/api/generate') {
|
|
return handleGenerate(req, res);
|
|
}
|
|
if (req.method === 'POST' && req.url.split('?')[0] === '/api/style/random') {
|
|
return handleStyleRandom(req, res);
|
|
}
|
|
// History endpoints
|
|
if (req.method === 'GET' && req.url.split('?')[0] === '/api/history') {
|
|
return handleGetHistory(req, res);
|
|
}
|
|
if (req.method === 'POST' && req.url.split('?')[0] === '/api/history') {
|
|
return handleAddHistory(req, res);
|
|
}
|
|
if (req.method === 'DELETE' && req.url.split('?')[0] === '/api/history') {
|
|
return handleClearHistory(req, res);
|
|
}
|
|
const deleteMatch = req.method === 'DELETE' && req.url.match(/^\/api\/history\/([^/?]+)/);
|
|
if (deleteMatch) {
|
|
return handleDeleteHistory(req, res, decodeURIComponent(deleteMatch[1]));
|
|
}
|
|
|
|
// Anything under /api that didn't match is a 404 (don't fall through to the SPA).
|
|
if (req.url.startsWith('/api/')) {
|
|
return json(res, { error: 'Not found' }, 404);
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
return serveStatic(req, res);
|
|
}
|
|
|
|
res.writeHead(405);
|
|
res.end('Method not allowed');
|
|
});
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
// The boot log already happened above.
|
|
});
|
|
|
|
// Graceful shutdown so the process exits cleanly when Docker stops it.
|
|
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
process.on(sig, () => {
|
|
console.log(`[shutdown] ${sig} received, closing server…`);
|
|
server.close(() => process.exit(0));
|
|
// Hard exit if it takes too long.
|
|
setTimeout(() => process.exit(1), 5000).unref();
|
|
});
|
|
}
|