Simo cbaa115d56
CI / check (push) Successful in 59s
CI / deploy (push) Failing after 1m21s
fix(deploy): verify Docker clone updates against the served release
2026-09-07 21:17:34 +02:00
2026-07-13 21:57:41 +02:00

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:

git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
cd EpicNext-Cms

pnpm (recommended):

pnpm install

npm:

npm install

yarn:

yarn install

2. Database Setup

The CMS shares a database with the Polaris/Arcturus emulator. Use an existing database or create a new one:

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

cp .env.example .env

Edit .env with at minimum:

DATABASE_URL=mysql://user:[email protected]:3306/epicnext_cms
AUTH_SECRET=<random 32+ character string>
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

# 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

# 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.

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 below)

Quick Start with Docker

# 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 mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata

# 4. Build, migrate and start a release tied to the Git commit.
# Requires Linux, Bash, Git, flock and Docker Compose v2; no host Node/pnpm.
bash scripts/docker-update.sh

# 5. Follow logs
docker compose logs -f cms

The CMS listens on port 3002 using Linux host networking. Existing database, Redis, emulator and shared gamedata services must already be configured. Create the mounted directories before installation; do not mount the checkout, .next or node_modules over /app inside the production container.

Updating a Docker clone

From your clone, run:

# Also verifies that your public domain serves the expected commit:
CMS_PUBLIC_URL=https://your-hotel.example bash scripts/docker-update.sh

The script follows the current branch's configured Git upstream. It refuses local uncommitted/untracked work, pulls fast-forward only and restarts itself if the updater changed. It does not reset or delete local work. Configure the upstream to your fork/branch if that is where you receive updates.

It builds a commit-tagged image and runs migrations in the matching builder container, using the clone's .env; no host dependency installation is needed. Only after build and migrations succeed does it recreate the CMS service. It compares the running image ID, image revision and /api/health release with the expected Git commit. With CMS_PUBLIC_URL, it also checks the domain through your proxy/CDN. A healthy response from another release is an error.

git pull alone updates source files, not an existing container. docker compose restart restarts the same image. docker compose pull downloads registry images; it does not fetch changes to this Git-built CMS. Use the updater for releases. A clone does not install an automatic scheduler. If desired, explicitly add a cron entry with the absolute path of your checkout; keep logs in its logs directory. Do not configure both CI and Compose updates for the same instance. The updater refuses an active epicnext-cms-app CI-managed container.

Logs: logs/docker-update.log. A failure after recreation leaves the candidate running for diagnosis and returns nonzero; this Compose updater does not promise automatic application or database rollback. Persistent volumes are preserved. Before production migrations, retain your normal database backup. The existing CI deploy continues to restore its previous container on failed verification.

Diagnose an update that is not visible

git rev-parse HEAD
docker inspect --format '{{.Image}}' epicnext-cms
docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' epicnext-cms
curl -fsS http://127.0.0.1:3002/api/health
curl -fsS https://your-hotel.example/api/health

Both HTTP responses must report the expected release. unknown means the image was built without a commit ID. If the local endpoint is current but the public one is old, check nginx's upstream, other CMS processes and proxy/CDN caching. Health responses must not be cached. The release is compiled into Next.js and cannot be changed simply by injecting a new variable into an old container.

Docker commands

Command Purpose
bash scripts/docker-update.sh Pull, build, migrate, recreate and verify
docker compose logs -f cms View CMS logs
docker compose exec cms sh Open a shell in the running CMS
docker compose restart cms Restart the existing release
docker compose down Stop services; does not update code
pnpm db:up / pnpm db:down Manage the optional MariaDB service

For a manual build, export CMS_RELEASE=$(git rev-parse HEAD) before docker compose build cms; deployment and migration verification remain your responsibility. Docker uses the committed pnpm lockfile and pinned Node version. The build cache can stay enabled: copying changed source invalidates the application build layer. Deleting all Docker cache is not an update mechanism.

Volumes

Only the CMS (and the optional avatar imager container, see 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
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:

sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
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):

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:

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:

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

# 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" → 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:

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=<path> — (required) JSON file (habbo furnidata_json or flat array)
--table=<name> furnidata One of furnidata | docs | texts
--source=<x> habbo source value for furnidata
--category=<x> 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)

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:

--bind=127.0.0.1
--port=6379
--maxmemory=2gb
--version_check=false

Point the CMS at it

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

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) — 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://<hotel>/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

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://<hotel>
AVATAR_IMAGING_HOTEL_NAME=<hotel>

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:

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:

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 <Gamedata>/config and <Gamedata>/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).

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:

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:

[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.

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)

pnpm build
pm2 start pnpm --name "next" -- start
pm2 save

Restart after updates:

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:

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.

docker compose up -d flaresolverr
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

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.

S
Description
No description provided
Readme CC-BY-SA-4.0
172 MiB
1 Stars 1 Watchers 0 Forks
Languages
TypeScript 95.9%
JavaScript 1.4%
Shell 1.1%
CSS 1.1%
PHP 0.3%
Other 0.1%