Files
admin/host-stacks/vds-kzntsv/sched-custom/custom-entry.mjs

113 lines
5.1 KiB
JavaScript

// sched custom single-binary: embedded engine + admin API + UI morda + MCP (Streamable HTTP) + storage (MariaDB/MySQL).
// One HTTP server on :8080, routed:
// /api/* → createAdminApi.handleRequest (health open, rest Bearer SCHED_ADMIN_KEY)
// /mcp → createMcpHttpHandler (Streamable HTTP MCP, projection of admin API)
// /sched-ui.bundle.js → static UI bundle
// / → UI morda (html)
// Engine via createDaemon (runs ticks + sync; we supply storage+adminApi ourselves on OUR server).
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { createDaemon } from '@schedjs/daemon';
import { createAdminApi } from '@schedjs/admin-api';
import { createMysqlStorage, dbNameFromUrl } from '@schedjs/storage-mysql';
import { createPool } from 'mysql2/promise';
import { AdminApiClient } from '@schedjs/mcp';
// Subpath imports are blocked by npm `exports` masks — import the dist modules by
// absolute file URL (a direct file reference, not a package specifier).
const { createMcpHttpHandler } = await import(pathToFileURL('/app/node_modules/@schedjs/mcp/dist/http.js').href);
const { mordaHtml, DEFAULT_UI_BUNDLE } = await import(pathToFileURL('/app/node_modules/@schedjs/ui/dist/morda.js').href);
const PORT = Number(process.env.SCHED_PORT ?? 8080);
const HOST = process.env.SCHED_HOST ?? '0.0.0.0';
const ADMIN_KEY = process.env.SCHED_ADMIN_KEY; // Bearer; unset → open (dev)
const TASKS = process.env.SCHED_TASKS ?? '/app/tasks.json';
// --- storage (MariaDB/MySQL) ---
const MYSQL_URL = process.env.MYSQL_URL;
if (!MYSQL_URL) throw new Error('MYSQL_URL is not set — export MYSQL_URL=<connstring>');
const pool = createPool(MYSQL_URL);
const storage = await createMysqlStorage(pool); // self-migrates on open
const dbName = dbNameFromUrl(MYSQL_URL);
// --- daemon: engine only (we own the http server) ---
const daemon = createDaemon({
storage,
tasksPath: TASKS,
// no `admin` here — createDaemon would bind its own /api-only server; we route ourselves
});
await daemon.start();
const engine = daemon.engine;
const adminApi = createAdminApi({ engine, storage, auth: ADMIN_KEY ? { apiKey: ADMIN_KEY } : undefined, version: '0.12.1-custom' });
// --- MCP: projection of admin API via HTTP client to ourselves ---
// AdminApiClient appends paths like '/tasks' to baseUrl → must end at the /api mount point.
const ADMIN_BASE = `http://127.0.0.1:${PORT}/api`;
const mcpClient = new AdminApiClient(ADMIN_BASE, ADMIN_KEY);
const mcpHandler = createMcpHttpHandler({ client: mcpClient, readonly: process.env.SCHED_MCP_READONLY === '1' });
// --- static UI bundle bytes ---
let uiBundle = null;
try { uiBundle = readFileSync(DEFAULT_UI_BUNDLE); } catch { /* bundle missing → morda 404s its script */ }
// One HTTP server, auth at the boundary. OPEN (no token): /api/health (probe) + the UI itself
// (/ and /sched-ui.bundle.js — static login page/JS bundle, ZERO data; data comes from /api/*).
// AUTH required (Bearer SCHED_ADMIN_KEY): /api/* (except health) and /mcp — the actual data plane.
// This is the vitya correction: morda stays reachable (it's just the login form), data plane is locked.
const AUTH_OPEN_PATHS = ['/api/health', '/sched-ui.bundle.js'];
function isOpenPath(p) {
return AUTH_OPEN_PATHS.includes(p) || p === '/'; // UI root = static login page, no data
}
function isAuthorized(req) {
if (!ADMIN_KEY) return true; // dev mode, open
const h = req.headers.authorization;
return h === `Bearer ${ADMIN_KEY}`;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const p = url.pathname;
try {
if (!isOpenPath(p) && !isAuthorized(req)) {
res.writeHead(401, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'unauthorized' }));
return;
}
if (p === '/api' || p.startsWith('/api/')) {
req.url = p.replace(/^\/api/, '') + url.search; // strip /api mount prefix
await adminApi.handleRequest(req, res);
return;
}
if (p === '/mcp' || p.startsWith('/mcp/')) {
await mcpHandler(req, res);
return;
}
if (p === '/sched-ui.bundle.js' && uiBundle) {
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end(uiBundle);
return;
}
// UI morda for everything else (root + non-api paths)
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(mordaHtml({ apiBase: '/api', bundleSrc: '/sched-ui.bundle.js', tokenKey: 'sched-token' }));
} catch (err) {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
}
});
server.listen(PORT, HOST, () => {
process.stderr.write(`sched custom up: http://${HOST}:${PORT} (storage=mysql db=${dbName}, admin_auth=${ADMIN_KEY ? 'on' : 'OFF'}, mcp=on)\n`);
});
// Graceful stop on SIGTERM
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, () => {
daemon.stop();
server.close();
pool.end();
process.exit(0);
});
}