we-superapp / apps /api /src /server.js
nenbuj
Fix v4: use rewriteUrl (not request.url setter) for HF prefix stripping
88e2e79
Raw History Blame Contribute Delete
5.89 kB
// Ядро: сборка сервера. Ведёт 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('<html><body><h1>МЫ</h1><p>Фронт не собран</p></body></html>');
});
}
const PORT = process.env.PORT || 3000;
app.listen({ port: PORT, host: '0.0.0.0' }).catch((e) => {
app.log.error(e);
process.exit(1);
});