Skip to content

Latest commit

 

History

History
81 lines (65 loc) · 17.4 KB

tech-stack.md

File metadata and controls

81 lines (65 loc) · 17.4 KB

Технологический стек — решение и обоснование (ADR)

Какой стек выбран, почему именно он, и по каким измеримым триггерам его стоит пересматривать. Спутник architecture.md (§3) и tech-research.md (анализ опций). Цель — чтобы выбор не пере-обсуждался: здесь зафиксированы причины и условия смены.

Принцип, который решает 80% выбора

Одна и та же детерминированная симуляция обязана крутиться и на сервере (авторитет), и в браузере (превью «если атакую — что будет», сверяемое hashState). Это «same-code-both-sides». Он один отсекает почти всё: язык ядра должен исполняться в браузере без границы перевода — значит JS/TS или что-то→WASM. Всё остальное в стеке — следствие этого плюс жанра (real-time асинхронное MMO, 24/7, server-authority, детерминизм).

Стек по слоям

Слой Выбор Почему Триггер пересмотра
Sim-ядро TypeScript (shared-core) Нулевая поверхность перевода сервер↔браузер; data-driven контент на JSON+zod — родная эргономика; нагрузка логическая, не вычислительная (мир идёт по формуле, не по кадрам). Детерминизм держится в бит-точном подмножестве IEEE-754 (Math.sqrt бит-точен по спеке, трансцендентные забанены ESLint-ом) Rust→WASM только при И заморозке схемы И измеренном сбое (воспроизведённый desync на подмножестве — крайне маловероятно — или упёртый профилем CPU редьюсера)
Слой действий TS (action-layer, вне ядра) Валидация/авторизация/идемпотентность в одном узле; ядро остаётся чистым —
Серверный рантайм Node 20+ / TS Тот же язык и тот же shared-core, что у клиента; нагрузка I/O-bound и событийная (advanceTo спит между событиями); скорость команды Go/Elixir-шлюз только когда кластер Node — измеренное узкое место по числу stateful-соединений
Транспорт сырой ws (+ тонкий Fastify для HTTP рядом) Минимум, ты владеешь протоколом (packages/protocol), noServer-режим даёт контроль над upgrade/auth. socket.io/Fastify-ws — лишний вес и магия Тонкий Fastify добавить только если понадобится REST-API рядом — сработал (SV-0.1): Fastify держит /health//ready//metrics и create/join API, WS остался на сыром ws
Персистентность Postgres + JSONB GameState уже JSON → в JSONB без маршалинга; ACID + транзакции + оптимистичные блокировки (anti-double-spend); один датастор. Mongo не даёт выигрыша над JSONB и теряет транзакции —
Планировщик событий v1: встроенная будилка (MatchRoom.msUntilNextEvent → advanceTo) → v2: pg-boss на Postgres Истина расписания и так в состоянии (scheduled[]). v1 — драйвер в процессе хоста будит комнату к ближайшему событию (одного процесса хватает). pg-boss = та же «разбудить к событию», но durable и в той же БД/транзакции, что и запись состояния — нужен на мульти-процессе. Никакого второго инфра-зверя Redis+BullMQ только если очередь станет горячее, чем тянет Postgres (десятки тысяч джоб/с)
Кэш / pub-sub пока ничего → Postgres LISTEN/NOTIFY До нескольких процессов кэш не нужен (состояние горячее в памяти комнаты) Redis — при >1 серверного процесса (presence/locks/cross-node fan-out)
Клиент PWA-first (TS + сборщик), карта Canvas2D→WebGL Одна TS-кодовая база, переиспользует shared-core для мгновенного превью; асинхронному жанру не нужна нативная input-латентность. RN/Flutter = второй рантайм и потеря общего ядра WebGL/PixiJS — когда на экране сотни движущихся сущностей (сейчас десятки блипов)
Мобильная обёртка Capacitor Тонкий нативный шелл вокруг PWA → push + офлайн + APK, без переписывания Нативный Kotlin/Swift — практически никогда, кроме тонкого шелла при жёсткой блокировке push/фон
Аналитика / античит-ML Python (офлайн) Воронки, когорты, anomaly-detection по отклонённым действиям — вне горячего пути, ядра не касается. Первый слой античита — replay на TS-ядре (Python не нужен) Подключать пост-лонч, когда накопятся матч-данные
Инфра / хостинг Docker Compose на VPS (сервер + Postgres + Caddy) + Cloudflare для сайта клиента Один хост на старте: подписанный образ, Postgres только на loopback, TLS через Caddy (deploy/); Cloudflare раздаёт статику клиента, сервер партий за ним не стоит Managed Postgres и бэкапы вне машины (SE-3.5); своя оркестрация/k8s — только на мульти-региональном масштабе
Auth свой логин+пароль (scrypt + session/join-JWT, SE-1.x); внешний OIDC — цель прода Своя крипта паролей уже есть (password.ts scrypt); JWT в ws-handshake (auth.ts); OIDC — соц-вход позже (accounts-roadmap) —
CI GitHub Actions (.github/workflows/) Полный DevSecOps на GitHub: security.yml (Semgrep/CodeQL/Trivy/OSV/Gitleaks/TruffleHog/zizmor + гейт), ci.yml (гейт + OSV-Scanner против сервис-Postgres), image.yml (подписанный образ), android.yml и rustore*.yml (APK), pages.yml. Миграция с GitLab зафиксирована в docs/security/audit-2026-06-27.md —

Уже выбрано в коде (на момент написания)

Стек сошёлся на лёгком варианте, и это правильно:

  • Ядро: @void/shared-core (TS), детерминированное, ESLint-гард детерминизма.
  • Action-layer: @void/action-layer (TS).
  • Сервер: Node + TS, сырой ws (upgrade через noServer), pg (стор packages/server/src/store/postgres/), тонкий Fastify для HTTP-поверхности рядом (/health//ready//metrics, create/join API — триггер «REST-API рядом» сработал, SV-0.1). Нет Redis и BullMQ — от них отказались осознанно.
  • Клиент: PWA-first (TS), прототип на Canvas2D, Capacitor-обёртка (APK).
  • Хостинг: Docker-стек на VPS владельца (deploy/); сайт клиента — воркер Cloudflare (wrangler.jsonc), сервер партий — на VPS, без Cloudflare перед ним.
  • CI: GitHub Actions (см. таблицу выше).
  • Redis-шов уже заложен: эфемерные данные матча (пинги) живут за интерфейсом EphemeralStore (packages/server/src/ephemeral.ts) с in-memory-имплом. Это и есть «заложить Redis заранее» без преждевременного зверя: на мульти-процессе Redis-импл вставляется одной заменой файла, логика комнаты не меняется (триггер №5 ниже).

Историческая сноска: architecture.md §3 и старый roadmap.md упоминают Fastify + Redis + BullMQ. Это исходный набросок; персистентность пере-зафиксирована на pg-boss/Postgres (persistence-roadmap.md), а транспорт — на сырой ws. Этот документ отражает актуальное решение.

Чего НЕ добавлять (и почему)

  • Redis/BullMQ сейчас — для одного процесса дублируют Postgres + in-memory комнату; меньше зверей = меньше точек отказа.
  • Переписывать ядро на Rust/Go/C# — сменишь удержанный, измеримый, чинимый-в-TS риск на неудержанный на golden-границе нового языка и потеряешь общий код с клиентом. Rust→WASM — только «план Б» под триггер.
  • ORM поверх JSONB — воюет с JSONB, тащит неявную не-детерминированность; хватит тонкого слоя на pg.
  • GraphQL / микросервисы / k8s — решения для проблем масштаба, которых ещё нет.
  • React Native / Flutter — второй рантайм и потеря переиспользования shared-core на клиенте.

Триггеры смены (консолидировано)

Полиглот по краям — только по измеримому условию, не «на всякий случай»:

  1. Rust→WASM-ядро — заморожена схема И воспроизведён desync на бит-точном подмножестве, либо профиль упёрся в CPU редьюсера.
  2. Elixir/Go-шлюз — измеренный потолок stateful-соединений у кластера Node (только край транспорта, не sim).
  3. WebGL-рендер — сотни движущихся сущностей в кадре.
  4. Python-аналитика/ML — накоплены матч-данные пост-лонч.
  5. Redis — появился второй серверный процесс (cross-node pub-sub / presence / locks).

Идеал одной фразой

TypeScript-моностек на общем shared-core, тонкий ws-сервер на Node, Postgres+JSONB как единственный датастор (планировщик — v1 встроенная будилка, durable-эволюция через pg-boss в той же БД), PWA-клиент с Capacitor-обёрткой, Python только для офлайн-аналитики. Минимум движущихся частей, один язык от карты до авторитета, одна БД. Языки по краям добавляются только по названным триггерам — до тех пор новый язык = налог без выгоды.