/
kochkatech
/
CorpGame
Обзор
Документация
Войти
/
kochkatech
/
CorpGame
Код
Запросы
1
Задачи
Вики
Пакеты
0
Релизы
1
CI/CD
Аналитика
master
backend/src/main.ts
170 строк
11 KB
kochkareal
feat(devops): свод к одному процессу Node на SQLite, портативная сборка без Docker; аудит безопасности
06 авг 2026, 15:06
06 авг 2026, 15:06
3266bcd
Код
Авторство
О чём код?
import 'reflect-metadata'; import { config as loadEnv } from 'dotenv'; import { copyFileSync, existsSync, mkdirSync, writeSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { clientDir, templateDbPath } from './common/app-paths'; // ВАЖНО: .env читается ДО всего остального. assertRequiredEnv() ниже смотрит на // process.env напрямую и падает раньше, чем поднимется ConfigModule, — то есть // раньше, чем @nestjs/config успел бы что-то прочитать. Пока запуск был только // через docker compose, это не всплывало (переменные приходили из окружения // контейнера), но `node dist/main.js` рядом с заполненным .env падал бы с // «не заданы переменные окружения». // // Путь считается от расположения собранного main.js, а не от process.cwd(): // ярлык или .bat могут запустить нас из любой директории. // разработка: backend/dist/main.js -> <репозиторий>/.env // портативная: app/server/main.js -> <папка игры>/.env loadEnv({ path: process.env.ENV_FILE ?? resolve(__dirname, '..', '..', '.env') }); import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import type { NextFunction, Request, Response } from 'express'; import cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; /** Значения-заглушки из .env.example — если они доехали до продакшена, секрет фактически публичный */ const PLACEHOLDER_VALUES = new Set(['change-me', 'change-me-too', 'dev-secret-change-me']); /** * Без этих переменных JWT-токены подписываются захардкоженным дефолтом из исходников * (jwt.util.ts) — тогда любой, кто читает публичный код, может подделать admin-токен * и получить полный доступ к панели администратора. Лучше упасть при старте, чем * молча взлететь в незащищённом режиме. */ function assertRequiredEnv() { const missing = ['JWT_SECRET', 'ADMIN_PASSWORD'].filter((key) => !process.env[key]?.trim()); if (missing.length > 0) { throw new Error( `Не заданы обязательные переменные окружения: ${missing.join(', ')}. ` + 'Задайте их в .env перед запуском (см. .env.example) — иначе аутентификация небезопасна.', ); } // В проде дополнительно запрещаем оставленные заглушки из .env.example — их знает каждый, // кто открывал этот репозиторий. В локальной разработке (NODE_ENV != production) разрешаем, // чтобы не мешать быстрому старту на своей машине. if (process.env.NODE_ENV === 'production') { const stillPlaceholder = ['JWT_SECRET', 'ADMIN_PASSWORD'].filter((key) => PLACEHOLDER_VALUES.has(process.env[key]!.trim()), ); if (stillPlaceholder.length > 0) { throw new Error( `${stillPlaceholder.join(', ')} всё ещё содержит значение-заглушку из .env.example. ` + 'Замените на настоящий секрет перед запуском в продакшене.', ); } } } /** * Готовит файл базы до подключения Prisma. * * Схему в новую базу накатывает не `prisma migrate deploy` при старте, а копия готового * template.db, собранного на сборочной машине. Иначе в поставку пришлось бы класть CLI * prisma и schema-engine (~20 МБ), а текущий Dockerfile вообще тянет их из сети при старте * контейнера — офлайн на мероприятии это фатально. * * Актуальность схемы в уже существующем файле проверяет PrismaService.assertSchemaIsCurrent(). */ function ensureDatabaseFile() { const url = process.env.DATABASE_URL; if (!url?.startsWith('file:')) return; // не SQLite (например, прогон против Postgres) — не наше дело const dbPath = url.slice('file:'.length); if (existsSync(dbPath)) return; mkdirSync(dirname(dbPath), { recursive: true }); const template = templateDbPath(); if (existsSync(template)) { copyFileSync(template, dbPath); return; } // Шаблона нет — значит запуск из исходников. Пустой файл Prisma откроет, а миграции // разработчик накатывает сам через `npx prisma migrate dev`. } /** * Раздаёт собранный фронтенд (vite build) из этого же процесса. Раньше этим занимались * два слоя nginx — внутренний в контейнере frontend и внешний, склеивавший его с /api и * /socket.io на один порт. Одному процессу Node всё это не нужно: и статика, и API, и * сокеты живут на одном порту по построению. * * Почему руками, а не через ServeStaticModule: у него исключения задаются шаблонами * path-to-regexp, синтаксис которых разъехался между Express 4 и 5 ('/api*' против * '/api{*path}'). Ошибка в шаблоне тихо превращает 404 от API в index.html со статусом * 200 — а frontend/src/api/client.ts проверяет content-type и на не-JSON возвращает null, * так что проглоченная ошибка всплывёт где-то в UI как «undefined is not an object» * вместо читаемого сообщения. Явная проверка startsWith('/api/') сломаться не может. */ function serveClient(app: NestExpressApplication) { // Папка называется client, а не public: рядом лежит скомпилированный Nest-модуль // src/public (публичный API дашборда), и две «public» в одной поставке путали бы. const dir = clientDir(); const indexHtml = join(dir, 'index.html'); // В разработке фронтенд отдаёт vite dev server (см. frontend/vite.config.ts), собранного // dist рядом нет — тогда просто не раздаём статику. if (!existsSync(indexHtml)) return; // Ассеты Vite содержат хэш в имени, поэтому кэшируются надолго. index.html — никогда: // после обновления игры закэшированный index.html ссылается на удалённые чанки, и // ведущий получает белый экран, а «почистите кэш браузера» — не та инструкция, которую // можно дать по телефону во время мероприятия. app.useStaticAssets(dir, { index: false, maxAge: '1y' }); app.use((req: Request, res: Response, next: NextFunction) => { if (req.method !== 'GET' && req.method !== 'HEAD') return next(); // Роутер Nest монтируется внутри app.init() — то есть ПОЗЖЕ этой мидлвары. // Полагаться на «Nest сматчит раньше» нельзя, проверка обязательна. if (req.path === '/api' || req.path.startsWith('/api/')) return next(); if (!req.headers.accept?.includes('text/html')) return next(); res.set('Cache-Control', 'no-store'); res.sendFile(indexHtml); }); // /socket.io/* сюда не доходит: engine.io цепляется к http.Server, снимая с него // существующие request-листенеры и подставляя свой, и передаёт управление Express // только для остальных путей. Отдельное исключение не нужно. } async function bootstrap() { assertRequiredEnv(); ensureDatabaseFile(); const app = await NestFactory.create<NestExpressApplication>(AppModule); app.use(cookieParser()); // CORS осознанно не включаем: фронтенд всегда на одном origin с API — либо раздаётся этим // же процессом (serveClient ниже), либо в разработке идёт через прокси Vite // (frontend/vite.config.ts: /api и /socket.io проксируются на тот же localhost:3000, // так что для браузера запрос выглядит same-origin). Кросс-origin обращений в проекте нет // ни одного — `enableCors({ origin: true, credentials: true })` здесь означал бы принимать // credentialed-запросы с любого источника без причины. app.setGlobalPrefix('api'); serveClient(app); // WebSocket-адаптер — встроенный IoAdapter из @nestjs/platform-socket.io. // Игра работает одним процессом (см. README), поэтому Redis-адаптер для // межпроцессной рассылки не нужен: балансировщика, между репликами которого // пришлось бы синхронизировать комнаты, здесь нет. // // 0.0.0.0 — чтобы игроки заходили с телефонов по адресу вида http://192.168.1.42. const port = Number(process.env.PORT) || 3000; await app.listen(port, '0.0.0.0'); } bootstrap().catch((err) => { // Лаунчер показывает ведущему хвост logs/server.log — сообщение должно попасть туда // целиком, а не превратиться в невнятный unhandled rejection. // // writeSync, а не console.error: при перенаправлении вывода в файл (лаунчер так и делает) // console.error пишет асинхронно, и следующий process.exit() обрезал бы сообщение — // ведущий увидел бы пустой лог вместо причины. // // process.exit, а не process.exitCode: Prisma держит открытое соединение, поэтому цикл // событий сам не опустеет и процесс висел бы вечно, а лаунчер ждал бы готовности до // таймаута вместо того, чтобы сразу показать ошибку. writeSync(2, `\nНе удалось запустить сервер: ${(err as Error).message}\n`); process.exit(1); });