v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

222
src/server.js Normal file
View File

@@ -0,0 +1,222 @@
"use strict";
const http = require("http");
const path = require("path");
const fs = require("fs");
const { EventEmitter } = require("events");
const { loadConfig } = require("./config");
const { createLogger } = require("./logger");
const { hashPasswordSync } = require("./core/auth/password");
const { SessionStore } = require("./core/auth/session-store");
const { HistoryStore } = require("./core/history/history-store");
const { HistoryService, migrateLegacyHistory } = require("./core/history/history-service");
const { JobStore } = require("./core/jobs/job-store");
const { Queue } = require("./core/jobs/queue");
const { JobService } = require("./core/jobs/job-service");
const { ComfyClient } = require("./core/comfy/comfy-client");
const { ComfyRuntime } = require("./core/comfy/comfy-runtime");
const { CodexCli } = require("./core/codex/cli");
const { EngineRegistry } = require("./core/engines/engine");
const { createCodexEditEngine, createCodexSuggestEngine } = require("./core/engines/codex-engine");
const { createComfyUpscaleEngine } = require("./core/engines/comfy-upscale-engine");
const { createApp } = require("./http/app");
function printPasswordBanner(user, password) {
const line = "─".repeat(58);
console.log(`\n┌${line}┐`);
console.log(`│ Кадр: Пароль для входа (сгенерирован автоматически) │`);
console.log(`│ Логин : ${user.padEnd(48)}│`);
console.log(`│ Пароль: ${password.padEnd(48)}│`);
console.log(`│ Задайте APP_PASSWORD в окружении для фиксации. │`);
console.log(`└${line}┘\n`);
}
function listenOnPort(app, port) {
return new Promise((resolve, reject) => {
const server = http.createServer(app);
server.listen(port, () => resolve(server));
server.on("error", (error) => {
server.close();
reject(error);
});
});
}
async function startServer() {
const config = loadConfig(process.env);
const logger = createLogger({ level: config.LOG_LEVEL });
logger.info("Запуск веб-приложения «Кадр» v2...", config.getSanitized());
if (config.isGeneratedPassword) {
printPasswordBanner(config.APP_USER, config.APP_PASSWORD);
}
const passwordHash = hashPasswordSync(config.APP_PASSWORD);
// Ensure directories exist
fs.mkdirSync(config.DATA_DIR, { recursive: true });
fs.mkdirSync(config.HISTORY_DIR, { recursive: true });
fs.mkdirSync(path.join(config.DATA_DIR, ".codex-jobs"), { recursive: true });
// Migrate legacy history if present
migrateLegacyHistory(path.resolve("./history"), config.HISTORY_DIR, logger);
// Core stores and services
const historyStore = new HistoryStore({
dir: config.HISTORY_DIR,
limit: config.HISTORY_LIMIT,
logger,
});
const historyService = new HistoryService({ store: historyStore, logger });
const sessionStore = new SessionStore({
file: path.join(config.DATA_DIR, "sessions.json"),
ttlMs: config.SESSION_TTL_MS,
persist: config.SESSION_PERSIST,
logger,
});
const jobStore = new JobStore({
file: path.join(config.DATA_DIR, "jobs.jsonl"),
logger,
});
const comfyClient = new ComfyClient({
comfyUrl: config.COMFY_URL,
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
logger,
});
const comfyRuntime = new ComfyRuntime({
client: comfyClient,
requiredNodes: config.REQUIRED_NODES,
checkpoint: config.CHECKPOINT,
logger,
});
const codexCli = new CodexCli({
codexExe: config.CODEX_CLI_PATH,
defaultTimeoutMs: config.CODEX_EDIT_TIMEOUT_MS,
logger,
});
const events = new EventEmitter();
events.setMaxListeners(100);
// Engine Registry
const engineRegistry = new EngineRegistry();
const engineContext = {
config,
logger,
history: historyService,
comfyClient,
comfyRuntime,
codexCli,
};
engineRegistry.register("edit", (ctx) => createCodexEditEngine(ctx));
engineRegistry.register("suggest", (ctx) => createCodexSuggestEngine(ctx));
engineRegistry.register("upscale", (ctx) => createComfyUpscaleEngine(ctx));
// Queue and Job Service
const queue = new Queue({
concurrencyByGroup: {
codex: config.CODEX_CONCURRENCY,
"comfy-upscale": config.UPSCALE_CONCURRENCY,
},
maxQueuedJobs: config.MAX_QUEUED_JOBS,
jobStore,
engineRegistry,
engineContext,
events,
logger,
});
const jobService = new JobService({
store: jobStore,
queue,
dataDir: config.DATA_DIR,
logger,
});
// Start background queue workers
queue.start();
// Create Express App
const app = createApp({
config,
logger,
sessionStore,
passwordHash,
historyService,
jobService,
comfyClient,
comfyRuntime,
events,
});
// Bind to port
let server = null;
let boundPort = null;
for (const candidate of config.PORT_CANDIDATES) {
try {
server = await listenOnPort(app, candidate);
boundPort = candidate;
break;
} catch (err) {
if (err.code === "EADDRINUSE") {
logger.warn(`Порт ${candidate} занят, пробуем следующий...`);
continue;
}
throw err;
}
}
if (!server) {
throw new Error(
`Не удалось запустить сервер: все порты [${config.PORT_CANDIDATES.join(", ")}] заняты.`
);
}
logger.info(`Сервер «Кадр» слушает http://localhost:${boundPort}`, {
port: boundPort,
dataDir: config.DATA_DIR,
historyDir: config.HISTORY_DIR,
});
// Graceful shutdown
const shutdown = async (signal) => {
logger.info(`Получен сигнал ${signal}, корректное завершение работы...`);
try {
await queue.stop();
sessionStore.close();
jobStore.close();
server.close(() => {
logger.info("Сервер остановлен.");
process.exit(0);
});
} catch (err) {
logger.error("Ошибка при остановке сервера", { error: err.message });
process.exit(1);
}
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
return { app, server, boundPort, deps: engineContext };
}
if (require.main === module) {
startServer().catch((err) => {
console.error("Критическая ошибка при старте сервера:", err);
process.exit(1);
});
}
module.exports = {
startServer,
};