/**
* Сервер визуального редактора диалогов (D8): node http без зависимостей.
* Отдаёт статику (index.html/editor.js/editor.css), читает и пишет графы
* в data/dialogues/*.json (атомарно, с mtime-гардом от конкурентной правки),
* валидацию и dry-run делает спавном vite-node tools/dialogues/probe.ts —
* правила одни (dialogueRules.ts), дублирования нет.
*
* Запуск: npm run dialogues [-- --port 5199]
*/
import http from 'node:http';
import { spawn } from 'node:child_process';
import { readFile, writeFile, rename, readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Корень монорепо (server.mjs — на 4 уровня ниже корня). */
const ROOT = fileURLToPath(new URL('../../../..', import.meta.url));
const DIALOGUES_DIR = path.join(ROOT, 'apps/game/src/data/dialogues');
const STATIC_DIR = path.join(ROOT, 'apps/game/tools/dialogue-editor');
const VITE_NODE = path.join(ROOT, 'node_modules/.bin/vite-node');
const PORT = Number(process.argv[process.argv.indexOf('--port') + 1] ?? 5199);
const JSON_SPACE = 4;
/** Спавн vite-node (TS-импорты из src): stdout — последняя строка (JSON). */
function viteNode(args, input) {
return new Promise((resolve, reject) => {
const p = spawn(VITE_NODE, args, { cwd: ROOT });
let out = '';
let err = '';
if (input !== undefined) {
p.stdin.write(input);
}
p.stdin.end();
p.stdout.on('data', (c) => (out += c));
p.stderr.on('data', (c) => (err += c));
p.on('error', reject);
p.on('close', (code) => {
const line = out.trim().split('\n').filter(Boolean).pop() ?? '';
try {
resolve(JSON.parse(line));
} catch {
reject(new Error(`probe упал (code ${code}): ${err.slice(-500) || line.slice(-200)}`));
}
});
});
}
/** Валидация графа (probe.ts): Invariants + анализ достижимости. */
function probe(graph) {
return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'validate', graph }));
}
/** Dry-run графа на пресете состояния. */
function dryRun(graph, preset) {
return viteNode(['apps/game/tools/dialogues/probe.ts'], JSON.stringify({ op: 'dry-run', graph, preset }));
}
/** Список графов: id + mtime (для гарандов конкурентной правки). */
async function listGraphs() {
const files = (await readdir(DIALOGUES_DIR)).filter((f) => f.endsWith('.json')).sort();
const out = [];
for (const f of files) {
const id = f.replace(/\.json$/, '');
out.push({ id, mtime: (await stat(path.join(DIALOGUES_DIR, f))).mtimeMs });
}
return out;
}
/** Канонический формат файла: 4 пробела + \\n в конце. */
function serialize(graph) {
return JSON.stringify(graph, null, 4) + '\n';
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
/** Ответ хелпером: JSON / текст / буфер статики / ошибка. */
function send(res, code, body, type = 'application/json; charset=utf-8') {
res.writeHead(code, { 'Content-Type': type });
if (Buffer.isBuffer(body) || typeof body === 'string') res.end(body);
else res.end(JSON.stringify(body));
}
/** Чтение тела запроса (JSON, лимит 2 МБ — графы маленькие). */
function readBody(req) {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', (c) => {
data += c;
if (data.length > 2 * 1024 * 1024) req.destroy();
});
req.on('end', () => {
try {
resolve(JSON.parse(data || '{}'));
} catch (e) {
reject(new Error(`тело не JSON: ${e.message}`));
}
});
});
}
/** PUT графа: mtime-гард -> валидация -> атомарная запись. */
async function putGraph(id, body) {
const file = path.join(DIALOGUES_DIR, `${id}.json`);
let current;
try {
current = (await stat(file)).mtimeMs;
} catch {
return { code: 404, body: { message: `граф «${id}» не найден` } };
}
if (typeof body.mtime !== 'number' || body.mtime !== current) {
// Конкурентная правка: отдаём свежий mtime — клиент предложит перечитать.
return { code: 409, body: { message: 'файл изменён на диске', mtime: current } };
}
const probeOut = await probe(body.graph);
if (probeOut.errors.length > 0) {
return { code: 422, body: probeOut };
}
const tmp = `${file}.tmp`;
await writeFile(tmp, serialize(body.graph));
await rename(tmp, file);
return { code: 200, body: { ok: true, mtime: (await stat(file)).mtimeMs } };
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
try {
if (url.pathname === '/api/graphs' && req.method === 'GET') {
return send(res, 200, await listGraphs());
}
const graphMatch = url.pathname.match(/^\/api\/graph\/(\w+)$/);
if (graphMatch) {
const id = graphMatch[1];
const file = path.join(DIALOGUES_DIR, `${id}.json`);
if (req.method === 'GET') {
const data = await readFile(file, 'utf8');
return send(res, 200, { id, mtime: (await stat(file)).mtimeMs, graph: JSON.parse(data) });
}
if (req.method === 'PUT') {
const { code, body } = await putGraph(id, await readBody(req));
return send(res, code, body);
}
}
if (url.pathname === '/api/dry-run' && req.method === 'POST') {
const { graph, preset } = await readBody(req);
return send(res, 200, await dryRun(graph, preset));
}
if (url.pathname === '/api/registries' && req.method === 'GET') {
return send(res, 200, await viteNode(['apps/game/tools/dialogues/registries.ts'], ''));
}
// Статика редактора (нет файла — 404, не 500).
const name = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
if (/^[\w.-]+$/.test(name)) {
try {
const data = await readFile(path.join(STATIC_DIR, name));
return send(res, 200, data, MIME[path.extname(name)] ?? 'application/octet-stream');
} catch {
return send(res, 404, { message: `нет файла «${name}»` });
}
}
send(res, 404, { message: 'нет такого пути' });
} catch (e) {
send(res, 500, { message: String(e?.message ?? e) });
}
});
server.listen(PORT, () => {
console.log(`редактор диалогов: http://localhost:${PORT}`);
});