// Ядро: сборка сервера. Ведёт Claude. // Подхватывает core-роуты + автоматически все модули Кодекса из src/modules/*.js. import Fastify from 'fastify'; import websocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; import { readdirSync, existsSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import './core/db.js'; // инициализация БД/миграций import { requireAuth } from './core/auth.js'; import authRoutes from './core/routes-auth.js'; import spacesRoutes from './core/routes-spaces.js'; import { registerWs } from './core/ws.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); // Hugging Face Spaces: снимаем префикс пути через rewriteUrl // HF пробрасывает полный путь /spaces/{user}/{repo}/... в контейнер. const HF_PREFIX = process.env.HF_PREFIX || ''; const app = Fastify({ logger: true, rewriteUrl: (rawReq) => { const url = rawReq.url || ''; // /spaces/{user}/{repo}/... → /... const m = url.match(/^\/spaces\/[^/]+\/[^/]+(\/.*)?$/); if (m) return m[1] || '/'; if (HF_PREFIX && url.startsWith(HF_PREFIX)) return url.slice(HF_PREFIX.length) || '/'; return url; }, }); await app.register(websocket); // Пустое тело при Content-Type: application/json трактуем как {} — // фронт шлёт так POST-ы без полезной нагрузки (например /spaces/:id/join). // Контракт допускает `{ }`, иначе Fastify падает с FST_ERR_CTP_EMPTY_JSON_BODY (500). app.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => { if (!body || !body.trim()) return done(null, {}); try { done(null, JSON.parse(body)); } catch (e) { e.statusCode = 400; done(e, undefined); } }); // Единый обработчик ошибок в формате контракта { error: { code, message } } app.setErrorHandler((err, request, reply) => { if (err.status && err.code) { return reply.code(err.status).send({ error: { code: err.code, message: err.message } }); } request.log.error(err); // Включаем detail для отладки (убрать в проде) const detail = process.env.NODE_ENV === 'production' ? '' : ` (${err.message})`; return reply.code(500).send({ error: { code: 'INTERNAL', message: 'Внутренняя ошибка' + detail } }); }); // CORS для локальной разработки (фронт на 5173) app.addHook('onRequest', async (request, reply) => { reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); reply.header('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS'); if (request.method === 'OPTIONS') reply.code(204).send(); }); // --- Публичные роуты (без токена) --- await app.register(authRoutes); // --- WebSocket --- registerWs(app); // --- Защищённые роуты (требуют JWT) --- await app.register(async (secured) => { secured.addHook('preHandler', requireAuth); // ядро: пространства await secured.register(spacesRoutes); // модули Кодекса: автоподхват src/modules/*.js const modulesDir = join(__dirname, 'modules'); if (existsSync(modulesDir)) { for (const file of readdirSync(modulesDir)) { if (file.endsWith('.js')) { const mod = await import(pathToFileURL(join(modulesDir, file)).href); if (typeof mod.default === 'function') { await secured.register(mod.default); app.log.info(`module loaded: ${file}`); } } } } }); // --- Отдача собранного фронта (один origin для Telegram Mini App) --- // Ищем dist: пробуем несколько путей (Docker vs локально) const tryPaths = [ join(__dirname, '..', '..', 'web', 'dist'), // Docker: /app/src → /app/web/dist join(__dirname, '..', 'web', 'dist'), // альтернатива '/app/web/dist', // хардкод для Docker ]; let webDist = tryPaths.find(p => existsSync(join(p, 'index.html'))); if (!webDist) webDist = tryPaths.find(p => existsSync(p)); if (webDist) { app.log.info(`serving static from: ${webDist}`); await app.register(fastifyStatic, { root: webDist, wildcard: true, setHeaders: (res) => res.setHeader('Cache-Control', 'no-store'), }); // Явный маршрут для корня (на случай если wildcard не покрывает) app.get('/', async (request, reply) => { return reply.header('Cache-Control', 'no-store').sendFile('index.html'); }); // SPA-fallback: любой GET не-API путь отдаёт index.html app.setNotFoundHandler((request, reply) => { if (request.method === 'GET' && !request.url.startsWith('/auth') && !request.url.startsWith('/spaces') && !request.url.startsWith('/ws')) { return reply.header('Cache-Control', 'no-store').sendFile('index.html'); } return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Не найдено' } }); }); } else { app.log.warn('web/dist не найден по путям: ' + tryPaths.join(', ')); // Заглушка — чтобы не падать с Fastify default 404 app.get('/', async (request, reply) => { return reply.type('text/html').send('

МЫ

Фронт не собран

'); }); } const PORT = process.env.PORT || 3000; app.listen({ port: PORT, host: '0.0.0.0' }).catch((e) => { app.log.error(e); process.exit(1); });