# EpicNext-CMS v1.0.3 A modern, high-performance content management system for Habbo hotel emulators, built on **Next.js 16** (App Router) with **Drizzle ORM** and **React 19**. Designed to integrate seamlessly with Polaris / Arcturus Morningstar MySQL/MariaDB databases. Features a premium animated homepage (typewriter hero, floating orbs, scroll counters), a full admin panel, NextAuth authentication (argon2id hashing with legacy md5/bcrypt auto-upgrade), real-time RCON communication, Server-Sent Events for live radio data, smooth page transitions, and PM2 production deployment. --- ## System Requirements | Component | Version | Notes | | --------------- | -------------- | ---------------------------------------- | | Node.js | 26.8.1 | Current release pinned in `.nvmrc` | | pnpm | >= 11.25.0 | Recommended package manager | | npm | >= 11.x | Supported alternative | | yarn | >= 4.x | Supported alternative | | MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator | | Docker | 24+ | Optional — for containerized deployment | | DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE | --- ## Quick Start ### 1. Clone & Install Choose your preferred package manager: ```bash git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git cd EpicNext-Cms ``` **pnpm (recommended):** ```bash pnpm install ``` **npm:** ```bash npm install ``` **yarn:** ```bash yarn install ``` ### 2. Database Setup The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one: ```sql CREATE DATABASE IF NOT EXISTS epicnext_cms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ``` The CMS reads emulator-owned tables (`users`, `items`, `rooms`, `bans`, etc.) directly. It never creates, alters, or drops them. The Drizzle schema in `src/db/schema.ts` is generated from the existing database structure and does not modify it. > **Note:** The CMS does **not** own the emulator schema — it maps to those tables via Drizzle. Never run `drizzle-kit push` / `migrate` against the shared DB. CMS-owned tables (`website_*`, `radio_*`, etc.) are created via idempotent SQL in `drizzle/migrations/`. ### 3. Configure Environment ```bash cp .env.example .env ``` Edit `.env` with at minimum: ```dotenv DATABASE_URL=mysql://user:password@127.0.0.1:3306/epicnext_cms AUTH_SECRET= HOTEL_NAME=YourHotel APP_URL=http://localhost:3002 ``` See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.). ### 4. Run CMS Migrations ```bash # pnpm pnpm db:migrate # npm npm run db:migrate # yarn yarn db:migrate ``` Creates all CMS-owned tables (`website_*`, `radio_*`, `acl_*`, `admin_audit_log`, etc.) via idempotent SQL files in `drizzle/migrations/`. Emulator tables are never touched. ### 5. Build & Start ```bash # Development (hot reload) pnpm dev # or: npm run dev / yarn dev # Production pnpm build && pnpm start # or: npm run build && npm start ``` Open `http://localhost:3002` in your browser. ### 6. First Login 1. Register an account at `/register`, or log in with an existing emulator account. 2. Grant yourself admin access: `UPDATE users SET rank = 7 WHERE username = 'yourname';` 3. Visit `/admin` and configure your hotel via **Admin → CMS Settings**. --- ## Docker Deployment The CMS ships with a multi-stage Dockerfile that automatically detects your package manager (pnpm, npm, or yarn) based on which lockfile is present. The **CMS** runs in Docker; the Nitro/Octane client stays outside and is served by nginx on the host. The server-side **avatar imager** also runs in its own Docker container (`avatar-imaging-pixinode`, port 8082) — see [Avatar Imaging](#avatar-imaging). ### Prerequisites - Docker 24+ and Docker Compose v2 - `.env` file configured (see step 3 above) - A MySQL/MariaDB database reachable from the container (`DATABASE_URL` host should point to the DB server, not `127.0.0.1` unless it's reachable from inside the container) - (Optional) A shared `Gamedata` directory at `/var/www/Gamedata` with write access (see [Volumes](#volumes) below) ### Quick Start with Docker ```bash # 1. Clone and configure git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git cd EpicNext-Cms cp .env.example .env # Edit .env with your database credentials and settings. # The container runs on the host network, so all 127.0.0.1 references # (DATABASE_URL, REDIS_URL, RCON, imaging) keep pointing at the host as-is. # 2. Stop any existing host-side CMS that occupies port 3002 (if present). pm2 stop next 2>/dev/null || true # 3. Ensure the write directories are owned by www-data (UID/GID 33) so the # container user can write imports/uploads to them. sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage # 4. Build and start docker compose up -d --build # 5. Run migrations. The slim runtime image has no source/tsx, so run migrations # on the HOST (against the same database) before/after starting the container. pnpm db:migrate # or: npm run db:migrate / yarn db:migrate # 6. Check logs docker compose logs -f cms ``` The CMS will be available at `http://localhost:3002`. > **Reverse proxy:** This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (`/client/`, `/nitro-client/`) and `/gamedata/` directly from `/var/www/Octane/dist` and `/var/www/Gamedata`, and proxies `/` to the CMS on `127.0.0.1:3002`. Point `APP_URL`/`NEXT_PUBLIC_APP_URL` at the public site URL. ### Automatic Updates Updating is fully scripted — no need to touch Docker or nginx by hand. The script `scripts/docker-update.sh` pulls the latest `main`, runs CMS migrations on the host, rebuilds the image, recreates the container and waits for a healthy status. It also makes sure a stale host-side PM2 CMS (`pm2 stop next`) stays stopped so it can't clash on port 3002. **Automatically (recommended):** a nightly cron job is already configured on a production server that installed this setup. It runs the script every night at 03:30 and appends to `logs/docker-update.cron.log`: ```bash crontab -e # 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1 ``` **Manually** — to update right now (same steps as the cron runs): ```bash cd /var/www/atom-nexst ./scripts/docker-update.sh # log: logs/docker-update.log ``` The script aborts safely (exit 1) if the working tree has uncommitted changes so a `git pull` can never clobber local edits, and leaves the container running if health fails so you can debug it (exit 3). Failed runs are reported in the log; an exit of 0 means the CMS is healthy on the new commit. ### Docker Commands | Command | Description | | ------------------------------------------ | ------------------------------------ | | `docker compose up -d --build` | Build and start in background | | `docker compose down` | Stop and remove containers | | `docker compose logs -f cms` | Follow CMS logs | | `docker compose exec cms sh` | Open a shell in the CMS container | | `docker compose restart cms` | Restart the CMS container | | `docker compose pull && docker compose up -d --build` | Update and redeploy | | `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) | | `./scripts/docker-update.sh` | Full automated update (manual or cron) | | `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` bulk-load container | ### How Package Manager Detection Works The Dockerfile checks for lockfiles in this order: 1. **`pnpm-lock.yaml`** → uses pnpm (fastest, recommended) 2. **`yarn.lock`** → uses yarn 3. **`package-lock.json`** → uses npm 4. **No lockfile** → falls back to `npm install` This means you can use any package manager on your host machine — the Docker build will automatically match. > Override the detection explicitly with `docker compose build --build-arg PACKAGE_MANAGER=pnpm` (or `npm` / `yarn`). ### Volumes Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#avatar-imaging)) run in Docker. The heavy client assets and gamedata stay on the host and are shared into the container so imports and uploads persist: | Container Path | Host Path | Mode | Purpose | | -------------------------- | -------------------------- | ---- | ------------------------------------ | | `/app/public/nitro-assets` | `./public/nitro-assets` | rw | Imported furni/pet/effect assets | | `/app/public/swf` | `./public/swf` | rw | SWF costumes / icons (imports) | | `/app/storage` | `./storage` | rw | Uploaded media (persistent) | | `/var/www/Gamedata` | `/var/www/Gamedata` | rw | Shared gamedata root (hardcoded path)| **About the hardcoded `/var/www/Gamedata` path:** `src/lib/services/furni-asset-dirs.ts` defines `DEFAULT_GAMEDATA_ROOT = /var/www/Gamedata` as an absolute on-disk path the CMS reads and mirrors imported assets into. The compose file mounts the host `/var/www/Gamedata` at the identical path inside the container so `existsSync('/var/www/Gamedata')` succeeds and nginx keeps serving `/gamedata/` from the same directory. **The Nitro/Octane client and renderer are NOT mounted** — nginx on the host serves them directly: | Component | Host path | How it's served | | -------------------- | ----------------------- | ---------------------------------------------------------------------- | | Nitro/Octane client | `/var/www/Octane/dist` | nginx `location ^~ /client/` and `/nitro-client/` alias | | Avatar imager | `/docker/Polaris-imager`| Docker container `avatar-imaging-pixinode` on port `8082` — see [Avatar Imaging](#avatar-imaging) | | Camera uploads | `/var/www/Camera` | nginx `/camera/` alias | **Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host: ```bash sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage ``` If your host user UID differs, override the run user at build time (defaults to `www-data`, which already exists in the base image with UID/GID 33): ```bash docker compose build --build-arg RUN_USER=1000 ``` ### Networking The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), DragonflyDB/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it. The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages. ### Health Check The container includes a health check that hits `/api/health` on port 3002 every 30 seconds (and reports DB/Redis/emulator status). Check status with: ```bash docker inspect --format='{{.State.Health.Status}}' epicnext-cms ``` ### MariaDB Turbo (bulk loads) `docker-compose.yml` ships an opt-in **`mariadb-turbo`** service — a dedicated MariaDB 11 container tuned for loading >50 MB JSON dumps (furnidata, external texts, etc.). It configures itself via mysqld flags on the `command:`, so no external `.cnf` file has to be mounted or kept in sync: ```bash pnpm db:up # docker compose --profile db up -d (starts mariadb-turbo) ``` Key settings (see the `command:` block in `docker-compose.yml`): - `max_allowed_packet = 512M` — 50 MB JSON batches no longer hit the 16 MB packet ceiling. - `innodb_flush_log_at_trx_commit = 2` + `innodb_doublewrite = 0` — durability relaxed for bulk writes. - `innodb_buffer_pool_size = 2G`, `innodb_log_file_size = 1G`, `bulk_insert_buffer_size = 512M`. - `net_read_timeout` / `net_write_timeout = 600`, `wait_timeout = 3600` — fixes the `drizzle-kit` **"Pulling schema from database..."** hang by never starving introspection/DDL sessions behind a long import. - `performance_schema = OFF` — saves ~1-2 GB RAM. The datadir lives in a **named volume** (`mariadb-turbo-data`), never a host bind-mount: shared-filesystem sync trashes InnoDB files the same way it corrupts pnpm's `node_modules`. Named volumes stay inside the container filesystem, so 50 MB of JSON writes never cross a host-sync boundary. > **Port note:** the service uses `network_mode: host` and binds `127.0.0.1:3306` — stop the host MariaDB first, otherwise the port collides with the standalone `.env` `DATABASE_URL` (`localhost:3306`). #### node_modules & host-sync corruption pnpm's store is hard-linked and its `.bin` shims are symlinks, so **node_modules must never be a host bind-mount** (Docker Desktop gRPC-FUSE/VirtioFS, Unison and Syncthing all corrupt it). The production build bakes `node_modules` into the image at build time; it is never bind-mounted. For local dev, mount a *named volume* (`node_modules:/app/node_modules`) instead of the host directory, and keep `node_modules` / the pnpm store out of any shared-filesystem bind. #### Loading a 50 MB JSON dump ```bash # Habbo furnidata (auto-detects roomitemtypes / wallitemtypes / effecttypes) pnpm db:bulk --file=/var/www/Gamedata/config/FurnitureData.json # Flat array of documents → generic JSON store pnpm db:bulk --file=/data/products.json --table=docs --category=furni # Key/value texts pnpm db:bulk --file=/data/external_texts.json --table=texts --category=default ``` The importer (`scripts/bulk-import-json.ts`) maps rows onto `src/db/schema-gamedata.ts`, batches them into **2000-row multi-row INSERTs** (one statement per chunk), uses `ON DUPLICATE KEY UPDATE` so re-runs are idempotent and interrupted loads resume, and prints rows/s + ETA. See ["ORM Setup & Type Generation"](#orm-setup--type-generation) → *Bulk JSON storage* for the design. --- ## ORM Setup & Type Generation ### Drizzle ORM (Primary Data Layer) Drizzle ORM is the runtime data layer. The connection is a singleton in `src/lib/db.ts`: ```ts import { db } from "@/lib/db"; import { users } from "@/db/schema"; import { eq } from "drizzle-orm/expressions"; const found = await db.select() .from(users) .where(eq(users.username, "hello")); ``` **Drizzle CLI** (`drizzle-kit`) is used for local development tasks — it is a devDependency and is never bundled in production. | Command | What it does | | ---------------------- | ---------------------------------------------------------------------- | | `pnpm db:generate` | Draft SQL from Drizzle schema into `drizzle/drafts/` (review + copy) | | `pnpm db:studio` | Open Drizzle Studio (dev only) | | `pnpm db:introspect` | Reverse-engineer an existing DB into a Drizzle schema draft | | `pnpm db:bulk` | Batch-import >50 MB JSON (2000-row chunks, resumable) via `scripts/bulk-import-json.ts` | | `pnpm db:schema:generate` | Regen committed `src/db/schema.ts` from previous schema + live DB | > The CMS does **not** use `drizzle-kit push` or `drizzle-kit migrate` — the database is shared with the emulator. Apply CMS DDL only via `pnpm db:migrate`. #### Bulk JSON storage (MariaDB) Emulator/hotel JSON dumps (furnidata, external texts, product data) are often >50 MB. MariaDB's `JSON` type is an alias for `LONGTEXT` and can **never be indexed directly**, so `src/db/schema-gamedata.ts` follows the "promote + index" pattern: - The raw JSON document stays intact in a `longtext` / `mediumtext` column (`payload`, `value`). - The fields you actually `WHERE` / `ORDER BY` on are promoted into real columns (`sprite_id`, `class_name`, `kind`, `title`, `text_key`) and indexed. - Optional **VIRTUAL generated columns** extract indexed fields from the payload with `JSON_EXTRACT` (MariaDB supports secondary indexes on virtual columns, 10.2+), so no duplicate data has to be written by the importer. Three tables are exported: | Table | Purpose | Unique key | | -------------------- | ---------------------------------------------- | --------------------------- | | `gamedata_furnidata` | One row per furni/clothing item (habbo furnidata_json shape) | `(source, sprite_id)` | | `gamedata_docs` | Generic JSON document store (per-key documents) | `(category, doc_key)` | | `gamedata_texts` | External-texts style key/value pairs | `(category, text_key)` | The bulk importer (`scripts/bulk-import-json.ts`, run via `pnpm db:bulk`) is DB-driven and fast precisely because each chunk is a *single* multi-row `INSERT … VALUES () ()… ON DUPLICATE KEY UPDATE`, so a 50 MB dump is a few dozen statements instead of hundreds of thousands of round-trips. Flags: | Flag | Default | Description | | -------------------- | ----------- | ------------------------------------------------- | | `--file=` | — (required)| JSON file (habbo furnidata_json or flat array) | | `--table=` | `furnidata` | One of `furnidata` \| `docs` \| `texts` | | `--source=` | `habbo` | `source` value for `furnidata` | | `--category=` | `default` | `category` value for `docs` / `texts` | | `--chunk-size=N` | `2000` | Rows per multi-row INSERT | | `--limit=N` | `0` | Stop after N rows (dry-test) | | `--truncate` | off | DELETE rows for this source/category first | The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt: each chunk commits on its own, and `ON DUPLICATE KEY UPDATE` makes re-runs overwrite instead of appending. --- ## DragonflyDB (Caching, Rate Limiting, SSE) DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-process memory. ### Install (Ubuntu/Debian) ```bash curl -fsSL -o /tmp/dragonfly_amd64.deb \ https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb apt-get install -y /tmp/dragonfly_amd64.deb systemctl enable --now dragonfly ``` ### Configure Edit `/etc/dragonfly/dragonfly.conf`: ```ini --bind=127.0.0.1 --port=6379 --maxmemory=2gb --version_check=false ``` ### Point the CMS at it ```dotenv REDIS_URL=redis://127.0.0.1:6379?connect_timeout=2 ``` Verify via `/api/health` — it should report `"redis": true`. --- ## Nginx Configuration The CMS is designed to run behind an nginx reverse proxy. Below is a reference configuration. ### Prerequisites - SSL certificates in `/etc/ssl/cert.pem` and `/etc/ssl/key.pem` (or use Let's Encrypt) - CMS running on `127.0.0.1:3002` ### Reference Configuration ```nginx proxy_cache_path /var/cache/nginx/html_cache levels=1:2 keys_zone=html_cache:50m max_size=500m inactive=10m use_temp_path=off; server { listen 80; server_name yourdomain.com; return 301 https://$host$request_uri; } server { listen 443 ssl; http2 on; server_name yourdomain.com; ssl_certificate /etc/ssl/cert.pem; ssl_certificate_key /etc/ssl/key.pem; ssl_protocols TLSv1.2 TLSv1.3; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; client_max_body_size 20m; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_pass http://127.0.0.1:3002; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_cache html_cache; proxy_cache_valid 200 60s; proxy_ignore_headers Vary; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; add_header X-Cache-Status $upstream_cache_status always; } location /api/ { proxy_pass http://127.0.0.1:3002; add_header Cache-Control "no-cache, no-store, must-revalidate"; } location /_next/static/ { proxy_pass http://127.0.0.1:3002; add_header Cache-Control "public, max-age=31536000, immutable"; } location /imaging/ { proxy_pass http://127.0.0.1:8082/; proxy_set_header Connection ""; proxy_http_version 1.1; add_header Cache-Control "public, max-age=300"; } location /health { access_log off; return 200 "OK"; add_header Content-Type text/plain; } } ``` > **`/imaging` trailing-slash gotcha:** `location /imaging` (no trailing slash) combined with `proxy_pass http://…8082/;` rewrites `/imaging/avatarimage` into `//avatarimage` and 404s. Always use `location /imaging/` + a trailing-slash `proxy_pass` so `/imaging/avatarimage` reaches the imager's `/avatarimage` route. --- ## Avatar Imaging Avatar images (profile figures, chat badges, forum avatars, admin previews) are rendered server-side by **Polaris-imager** ([duckietm/Polaris-imager](https://github.com/duckietm/Polaris-imager)) — a headless Nitro/Octane renderer that runs `@pixi/node` (real WebGL via `gl` + Xvfb) inside a Docker container named **`avatar-imaging-pixinode`**. It serves `GET /avatarimage?figure=…` on port **8082** and can emit PNG/APNG/GIF, gestures, actions, scenes and more. ### Request flow ``` browser → Cloudflare → (page rule /origin for /imaging/*) → host:8082 browser → nginx origin → location /imaging/ → 127.0.0.1:8082/avatarimage ``` URLs look like `https:///imaging/avatarimage?figure=hd-180-1.ch-210-66&img_format=png&size=l&direction=4`. The public `NEXT_PUBLIC_IMAGER_URL` points at that endpoint (see `src/lib/imager.ts`). Responses carry `Cache-Control: public, max-age=300`, so browsers and Cloudflare reuse a render for 5 minutes. ### Requirements (host) - Docker (daemon with `build.network: host`, same as the CMS build — this host disables Docker iptables). - An nginx that serves `/gamedata/` (FigureData/FigureMap/EffectMap…) and `/gamedata/bundled` (`.nitro` assets) over HTTP so the renderer can fetch them. The compose file points at `host.docker.internal:8081`. ### Configuration — `/docker/Polaris-imager/.env` ```dotenv NITRO_GAMEDATA_URL=http://host.docker.internal:8081/gamedata/config NITRO_ASSET_URL=http://host.docker.internal:8081/gamedata/bundled AVATAR_IMAGING_FPS=12 AVATAR_IMAGING_MAX_FRAMES=60 AVATAR_IMAGING_HOST=0.0.0.0 AVATAR_IMAGING_PORT=8082 AVATAR_IMAGING_RATELIMIT_MAX=600 AVATAR_IMAGING_SCENE=1 AVATAR_IMAGING_WARDROBE=1 AVATAR_IMAGING_HABBO_FONTS=1 AVATAR_IMAGING_CHAT_BUBBLES=1 AVATAR_IMAGING_HOTEL_URL=https:// AVATAR_IMAGING_HOTEL_NAME= ``` ### Build & start The image is self-contained — the `Dockerfile` clones the pinned Nitro/Octane renderer and runs the full `yarn`/`vite` bundle at build time, then produces a slim Debian runtime with Xvfb + Mesa software rendering: ```bash cd /docker/Polaris-imager docker compose build docker compose up -d # container: avatar-imaging-pixinode docker logs -f avatar-imaging-pixinode # expect: "listening on http://0.0.0.0:8082" ``` The container runs `restart: unless-stopped` and ships a healthcheck that probes `/health` (reports `{ "ready": true }` once the headless renderer has booted). Verify a render: ```bash curl -o avatar.png 'http://127.0.0.1:8082/avatarimage?figure=hr-893-45.hd-600-1.ch-255-66.lg-280-110.sh-295-62&img_format=png&size=l' ``` A production issue that this section documents: **the `avatar-imaging-pixinode` image can be removed by a `docker image prune`**, which takes the whole site's avatars offline (502 on `/imaging/*`). Keep the image tagged and re-build it via the commands above if it ever disappears (`docker compose build && docker compose up -d`). ### Figure string validation (`src/app/api/imaging/avatar/route.ts`) The CMS-side route re-validates the incoming figure against `/^[a-z]{2}-\d+(-\d+)?(\.[a-z]{2}-\d+(-\d+)?)*$/i`. Real Habbo figure strings use `type-id-color` parts separated by dots (`hd-180-1.ch-210-66`), so each part allows an optional second number. Invalid input is rejected with `400` before any render happens. --- ## Performance Tuning (production) The CMS shares its MySQL/MariaDB database with the emulator, so the hot wins come from caching, asset serving and database knob-tuning — not from a storage-engine migration. ### 1. gzip_static pre-compression of gamedata JSON The client downloads `FurnitureData.json` (~48 MB, one file per supported language) plus other large JSON from `/gamedata/`. nginx has `gzip` and `gzip_static on` (see `/etc/nginx/nginx.conf`), but with no pre-built `.gz` file it re-compressed the 48 MB **on every request** (~0.25–0.30 s of CPU per miss). `scripts/compress-gamedata.mjs` pre-compresses every JSON >1 MB in `/config` and `/bundled/config` once (gzip level 9): `FurnitureData*.json` go from ~48 MB to ~2.4 MB (~95 % smaller). It is idempotent — a file is only re-compressed when the source mtime is newer than its `.gz` (e.g. after a Studio catalog export). ```bash pnpm gamedata:compress # manual run ``` Once the `.gz` exists, nginx `gzip_static` serves it straight from disk with `Content-Encoding: gzip` and effectively zero CPU. **Measured result on the production origin: 0.25–0.30 s → ~0.005 s per file.** The production host runs the script on a cron so newly exported catalogs are compressed shortly after they land: ```bash crontab -e # */15 * * * * node /var/www/atom-nexst/scripts/compress-gamedata.mjs >> /var/www/atom-nexst/logs/gamedata-compress.log 2>&1 ``` ### 2. MariaDB tuning The whole `habbo` datadir is roughly 0.5 GB; the default InnoDB buffer pool (128 MB) pushed index/row reads to disk. The pool is raised to **4 GB** and the slow-query log is enabled (threshold 2 s), both live (`SET GLOBAL …`) and persisted for boot in `/etc/mysql/mariadb.conf.d/zz-cms-performance.cnf`: ```ini [mysqld] innodb_buffer_pool_size = 4G slow_query_log = 1 long_query_time = 2 slow_query_log_file = /var/log/mysql/mariadb-slow.log ``` The slow-query log surfaces hot-spot SQL for a follow-up index/query audit. The CMS connection pool itself is already tuned (pool size 25, `connection_limit` in `DATABASE_URL`). For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-turbo`** variant is available — see [MariaDB Turbo](#mariadb-turbo-bulk-loads). ### 3. Asset caching headers | Path | Cache-Control | | ------------------------------ | -------------------------------------------------------------- | | `/swf/**`, `/nitro-assets/**` | `public, max-age=604800, stale-while-revalidate=2592000` | | `/gamedata/**`, `/client/**` | `public` + `expires 7–30 d` (nginx) | | `/imaging/*` | `public, max-age=300` | | `/camera/**` | `public, max-age=31536000, immutable` | | `/_next/static/**` | `public, max-age=31536000, immutable` | ### 4. Cache layers (Redis + CDN) The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (DragonflyDB-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further. --- ## Production Deployment (PM2) ```bash pnpm build pm2 start pnpm --name "next" -- start pm2 save ``` Restart after updates: ```bash git pull pnpm install pnpm build pm2 restart next ``` --- ## Scripts | Command | Description | | ------------------------- | -------------------------------------------------- | | `pnpm dev` | Start development server (hot reload) | | `pnpm build` | Production build | | `pnpm start` | Start production server | | `pnpm typecheck` | Run TypeScript type checking | | `pnpm test` | Run all tests (Vitest) | | `pnpm db:migrate` | Apply pending SQL migrations | | `pnpm db:migrate:status` | Show migration status | | `pnpm db:schema:generate` | Regen `src/db/schema.ts` from prior schema + live DB | | `pnpm db:generate` | Draft SQL via drizzle-kit → `drizzle/drafts/` | | `pnpm db:studio` | Drizzle Studio (dev) | | `pnpm db:introspect` | drizzle-kit introspect (draft) | | `pnpm db:bulk` | Batch-import >50 MB JSON via `scripts/bulk-import-json.ts` | | `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` container | | `pnpm gamedata:compress` | Pre-compress large gamedata JSON to `.gz` (gzip_static) | | `pnpm analyze` | Build + open bundle analyzer | | `pnpm jobs:worker` | Start background task worker | | `pnpm biome:check` | Lint and format code | > Replace `pnpm` with `npm run` or `yarn` for other package managers. --- ## Performance Features | Feature | Description | | ------------------------------ | -------------------------------------------------------------- | | **React Compiler** | Automatic memoization — reduces unnecessary re-renders | | **View Transitions API** | Native browser transitions between page navigations | | **Lenis Smooth Scroll** | Fluid, customizable scrolling | | **Server-Sent Events** | Real-time radio now-playing & listeners via SSE | | **DragonflyDB Caching** | Caches API responses up to 30s in DragonflyDB | | **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) | | **Streaming & Suspense** | Next.js App Router streaming for fast page loads | | **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF | | **Compression** | Gzip compression enabled on all responses | | **gzip_static (gamedata)** | 48 MB FurnitureData JSON served from pre-built `.gz` — no per-request CPU | | **PWA** | Service worker with skip-waiting, stale CSS chunk recovery | | **Biome** | Fast linting and formatting (replaces ESLint + Prettier) | --- ## Architecture ``` ├── drizzle/ │ ├── migrations/ # CMS SQL migrations (idempotent, tracked in cms_migrations) │ └── drafts/ # drizzle-kit generate output (never auto-applied) ├── scripts/ │ ├── apply-migrations.ts # SQL migration runner (apply + status) │ ├── bulk-import-json.ts # 50 MB+ JSON importer (2000-row chunks, UPSERT) │ ├── jobs-worker.ts # Background task scheduler │ ├── generate-drizzle-schema.mjs # Regen src/db/schema.ts from schema + live DB │ └── compress-gamedata.mjs # Pre-compress large gamedata JSON (gzip_static) ├── src/ │ ├── db/ │ │ ├── schema.ts # Drizzle ORM schema (committed — runtime data layer) │ │ ├── schema-gamedata.ts # Large-JSON storage schema (furnidata/docs/texts) │ │ └── relations.ts # Drizzle relations │ ├── app/ # Next.js App Router (pages & API routes) │ ├── actions/ # Server Actions │ ├── components/ # UI components │ ├── lib/ │ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets │ │ ├── services/ # RCON, email, currency, PayPal, alerts │ │ ├── db.ts # Drizzle connection singleton (runtime) │ │ ├── redis.ts # Cache client (ioredis → DragonflyDB) │ │ └── motion.ts # Framer Motion animation variants │ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv) │ └── env.ts # Zod-validated environment schema ├── public/ │ ├── assets/ # Images, icons, fonts │ ├── nitro-assets/ # Nitro client assets (~3GB, volume-mounted in Docker) │ └── swf/ # SWF client files (volume-mounted in Docker) ├── docker-compose.yml # Docker Compose configuration ├── Dockerfile # Multi-stage, multi-package-manager Docker build ├── next.config.ts # Next.js configuration └── .env.example # Environment template with all options ``` --- ## Optional Integrations ### Emulator — RCON For live actions (credits, badges, rank changes, kick, ban, hotel alerts). The emulator must be running with RCON enabled at `RCON_HOST:RCON_PORT`. ### Client — Nitro The `/client` page loads the Nitro client. Configure the client URL via **Admin → CMS Settings** (`nitro_client_url`). See `update-Nitrov3.sh` for deployment. ### Radio Supports any streaming radio (e.g., Azuracast). Configure endpoints via CMS Settings: - `radio_now_playing_api_url` - `radio_listeners_api_url` The radio player uses SSE for real-time updates (10s interval, no polling). ### Background Jobs Run as a persistent process: ```bash pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker ``` ### AI Content Moderation Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`. ### FlareSolverr (Cloudflare Bypass) Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically. ```bash docker compose up -d flaresolverr ``` ```dotenv FLARESOLVERR_URL=http://localhost:8191 ``` ### Furniture Import with Auto-Translation The CMS automatically translates furniture names and descriptions to **13 languages** on every import: - **Native languages** (via official Habbo gamedata): Dutch, English, German, French, Spanish, Turkish, Italian - **Additional languages** (via LibreTranslate Docker at `127.0.0.1:5000`): Portuguese, Finnish, Polish, Russian, Arabic, Japanese ### Theming 12 preset themes with fully customizable colors via **Admin → Theme** (`/admin/theme`). --- ## Development ```bash pnpm dev # Start with hot reload pnpm typecheck # Type check all files pnpm test # Run test suite pnpm analyze # Build and analyze bundle sizes pnpm biome:check # Lint and format ``` ### Contributing 1. Ensure typecheck and tests pass: `pnpm typecheck && pnpm test` 2. Follow existing code conventions (Server Components where possible) 3. SQL migrations in `drizzle/migrations/` must be idempotent 4. For new database code, use the Drizzle runtime directly (`import { db } from "@/lib/db"`) 5. Avoid `any` — use `biome-ignore` comments only when unavoidable --- ## License CC BY-NC-SA 4.0. See `LICENSE` for details.