113 lines
5.1 KiB
JavaScript
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);
|
|
});
|
|
}
|