# Codebase Analysis: arma3-web-server ## Identity - **Language / runtime**: Go 1.25 (backend), TypeScript 6 + React 19 (frontend) - **Framework(s)**: Gin v1.12 (HTTP router), gorilla/websocket (WS), TanStack Query v5 (data fetching), Tailwind CSS 4 (styling), Vite 8 (bundler) - **Build system**: Go toolchain (`go build`), Vite/Rollup for frontend, multi-stage Docker build - **Test framework**: Go `testing` + Vitest + React Testing Library ## Code Style - **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) — format: `type(scope): description` - Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`, `perf` - Scopes: `backend`, `frontend`, `ci`, `docker`, or omit for project-wide changes - Examples: `feat(backend): add mod cleanup endpoint`, `fix(frontend): resolve race in modlist editor`, `docs: update PLAN.md to reflect current architecture` ## Directory map ``` arma3-web-server/ ├── backend/ # Go backend │ ├── cmd/server/main.go # Entry point, env parsing, dir creation, startup auto-tasks, wiring │ ├── embed/ │ │ ├── embed.go # //go:embed dist — embeds frontend into Go binary │ │ └── dist/ # Pre-built frontend SPA assets │ ├── internal/ │ │ ├── api/ # Gin HTTP handlers + WebSocket endpoints │ │ │ ├── router.go # Route registration, Handler struct + New() │ │ │ ├── settings.go # Settings CRUD + server start/stop/restart + steamcmd │ │ │ ├── configs.go # .cfg file CRUD handlers │ │ │ ├── modlists.go # Modlist CRUD + import + export + check/download-missing/update-all │ │ │ ├── mods.go # Mod listing, deletion, bulk cleanup │ │ │ ├── health.go # Comprehensive health check endpoint │ │ │ └── logs.go # WS streaming (server, steamcmd, rpt) + log file listing │ │ ├── models/ # Data structs │ │ │ ├── settings.go # ServerSettings (singleton) │ │ │ └── modlist.go # Modlist + ModEntry + ModlistListItem │ │ └── services/ # Business logic layer │ │ ├── settings.go # JSON load/save from data/settings.json │ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR │ │ ├── modlist_manager.go # Modlist CRUD against data/modlists/*.json │ │ ├── modlist_parser.go # HTML Arma Launcher preset parser + renderer │ │ ├── mod_manager.go # Workshop + local mod discovery + usage map │ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver (atomic state machine) │ │ ├── steamcmd.go # SteamCMD manager (UpdateGame, DownloadMod, DownloadMods) │ │ ├── scheduler.go # Cron-based scheduled updates │ │ ├── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels │ │ └── testdata/ # Stub binaries for testing │ │ ├── arma3server_x64 # Server stub: logs args, heartbeat, -t N exit, RPT writes │ │ └── steamcmd # SteamCMD stub: fake downloads, creates mod dirs │ ├── go.mod / go.sum │ └── serverfiles/ # Default SERVERFILE_DIR (created at startup) ├── frontend/ # React SPA │ ├── src/ │ │ ├── App.tsx # Router setup (React Router v7) │ │ ├── main.tsx # Entry point (QueryClient + BrowserRouter) │ │ ├── index.css # Tailwind v4 imports │ │ ├── api/client.ts # Typed fetch wrappers + WS URL builders │ │ ├── types/index.ts # ServerSettings, Modlist, ModEntry, ConfigInfo, ServerPaths │ │ ├── pages/ # 7 route-level page components │ │ │ ├── Dashboard.tsx # Overview cards (status, configs, modlists) │ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD + automation │ │ │ ├── Configs.tsx # List/create/duplicate/delete configs │ │ │ ├── ConfigEditor.tsx # Full-page Monaco editor for a single config │ │ │ ├── Modlists.tsx # List/create/duplicate/delete + HTML import │ │ │ ├── ModlistEditor.tsx # Mod list reorder + enable/disable + check/download-missing/update-all │ │ │ ├── Mods.tsx # Installed mods table + search + delete + cleanup │ │ │ ├── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser │ │ │ └── Status.tsx # Health check / deploy status dashboard │ │ └── components/ │ │ ├── ConfigEditor.tsx # Monaco editor wrapper │ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket │ │ └── Layout.tsx # Sidebar navigation shell │ ├── package.json │ ├── vite.config.ts # Proxy /api + /ws to :8080 │ └── tsconfig*.json ├── data/ # Runtime data (mounted volume in Docker) │ ├── presets/ # (unused) │ └── servers/ # (unused) ├── dev-deploy/ # Local development runtime data (git-ignored) ├── .gitea/workflows/ │ ├── ci.yml # Build-only CI (Go + frontend) │ └── release.yml # GoReleaser-based release on tag push ├── Dockerfile # Multi-stage: Go build -> npm build -> alpine runtime ├── Dockerfile.goreleaser # Single-stage for GoReleaser (pre-built artifacts injected) ├── .goreleaser.yaml # GoReleaser v2 config (Gitea release target) ├── docker-compose.yml # Single service with volume mounts + env vars └── Makefile # Convenience targets: backend, frontend, build, run, dev, clean ``` ## Dependencies ### Backend (Go) | Module | Type | Version | Purpose | |---|---|---|---| | `github.com/gin-gonic/gin` | framework | v1.12.0 | HTTP routing, middleware, request binding | | `github.com/gorilla/websocket` | library | v1.5.3 | WebSocket upgrade + message I/O | | `github.com/google/uuid` | utility | v1.6.0 | UUID generation for modlists | | `github.com/robfig/cron/v3` | scheduler | v3.0.1 | Cron expression parsing and scheduled task execution | | `golang.org/x/net` | stdlib | v0.51.0 | HTML parser for modlist import | ### Frontend (npm) | Package | Type | Version | Purpose | |---|---|---|---| | `react` / `react-dom` | framework | ^19.2.7 | UI rendering | | `react-router-dom` | routing | ^7.18.1 | Client-side routing (7 pages) | | `@tanstack/react-query` | data fetching | ^5.101.2 | Server state, caching, polling, mutations | | `tailwindcss` | styling | ^4.3.2 | Utility-first CSS | | `@monaco-editor/react` | editor | ^4.7.0 | Monaco code editor for .cfg files | | `@xterm/xterm` / `@xterm/addon-fit` | terminal | ^6.0.0 / ^0.11.0 | In-browser xterm.js terminal | | `vite` | bundler | ^8.1.1 | Dev server + production build | | `@vitejs/plugin-react` | tooling | ^6.0.3 | React fast-refresh / JSX transform | | `typescript` | language | ~6.0.2 | Type checking | | `oxlint` | linter | ^1.71.0 | Linting | | `vitest` | test runner | ^4.1.10 | Frontend unit/integration tests | | `@testing-library/react` | test utility | ^16.3.0 | React component testing | | `@testing-library/jest-dom` | test matcher | ^7.0.0 | DOM assertion matchers | | `jsdom` | test env | ^29.1.1 | Browser environment for tests | ## Architecture ### Pattern: Layered (API -> Service -> Model), file-based persistence ``` HTTP/WS Gin Router Services Filesystem --------- ---------- -------- ---------- Browser -- REST/WS --> api.Handler --> SettingsManager --> data/settings.json | ConfigManager --> $CFG_DIR/*.cfg | ModlistManager --> data/modlists/*.json | ProcessManager --> spawn arma3server_x64 | SteamCmdManager --> spawn steamcmd | LogStreamer --> pub/sub (channels) +---> WebSocket clients (xterm.js) ``` ### Key abstractions - **Handler** (api package) -- stateless; receives service pointers via constructor injection. All handlers are methods on Handler. - ***Manager** (services package) -- stateful structs for each concern. Hold their own `sync.Mutex` for thread safety. - **LogStreamer** -- pub/sub per named stream (`"server"`, `"steamcmd"`). Subscribe returns a `chan string`; Stream reads from an `io.Reader` (process stdout/stderr) and broadcasts to all subscribers of that key. - **ProcessManager** -- manages the Arma 3 server child process lifecycle. Uses `atomic.Int32` state machine (idle/starting/running/stopping) with `CompareAndSwap` for lock-free state transitions. Builds the command line from settings, resolves mod paths, monitors process exit. ### Notable design decisions 1. **No database** -- everything is files: `settings.json`, `*.cfg` in `$CFG_DIR`, modlist JSON files. Backup means copying directories. 2. **Single server instance** -- ProcessManager allows only one running process at a time. 3. **`%command%` substitution** -- if ServerParameters contains `%command%`, the entire textarea is treated as a shell template, with `%command%` replaced by the binary path and auto-args (`-config=`, `-mod=`, `-profiles=`, `-port=`) appended. Enables Wine wrappers. 4. **Two-tier mod resolution** -- resolveModPath checks `$MODS_DIR/@name` first (manual/symlinked), then falls back to `$SERVERFILE_DIR/steamapps/workshop/content/107410/` for workshop downloads. 5. **SteamCMD async with live streaming** -- all steamcmd operations run in background goroutines, output broadcast to `"steamcmd"` key. 6. **Env-defined paths** -- all dirs set via env vars with hardcoded defaults, resolved to absolute paths at startup. `SERVER_BINARY`, `SERVER_PARAMS`, `STEAMCMD_PATH` override defaults at runtime. 7. **Atomic state machine** -- ProcessManager and SteamCmdManager use `atomic.Int32`/`atomic.Bool` with `CompareAndSwap` for lock-free state transitions, eliminating race conditions between concurrent Start/Stop calls. ## Data flow (example: Start server) ``` User clicks "Start" (Settings.tsx) --> handleSaveThen() -- auto-saves if dirty --> POST /api/server/start --> Handler.StartServer() (settings.go) --> process.Start() (server_process.go) --> Load settings.json --> SERVER_PARAMS env overrides ServerParameters if set --> SERVER_BINARY env overrides platform default binary name if set --> If %command% in ServerParameters: --> Replace %command% with binPath --> Split into command + args --> Append buildAutoArgs(s): -config=, -mod=, -profiles=, -port= --> exec.CommandContext(ctx, parts[0], append(parts[1:], autoArgs...)...) Else: --> buildArgs(s): split parameters + append auto-args --> exec.CommandContext(ctx, binPath, args...) --> cmd.Dir = serverfileDir --> Pipe stdout/stderr --> cmd.Start() --> Stream stdout/stderr to LogStreamer("server") --> Monitor exit, broadcast [SERVER_PROCESS_EXITED] --> Returns {"status": "starting"} --> Frontend polls GET /api/server/status every 3s --> Shows "Running" badge ``` ## CI/CD ### Gitea Actions (`/.gitea/workflows/`) - **`ci.yml`** — Triggered on push to any branch or PR. Runs `go build ./...`, `npm ci && npm run build`, `go test ./...`, and `npm test`. Build + test CI. - **`release.yml`** — Triggered on `v*` tag push. Runs GoReleaser v2 with `release --clean`, which builds Go binaries (linux/amd64 + windows/amd64), builds frontend via `before.hooks`, creates archives, builds + pushes a Docker image using `Dockerfile.goreleaser`, and publishes a Gitea release. ### Docker - **`Dockerfile`** — Multi-stage build: Go compile → npm build → Debian slim runtime + SteamCMD. Single self-contained image. - **`Dockerfile.goreleaser`** — Single-stage assembly-only Dockerfile for GoReleaser CI. Expects pre-built artifacts injected by GoReleaser. ### Local dev - **Makefile targets** — `make run` starts backend, `make dev` starts both backend + Vite dev server with hot reload (copies test stubs to dev-deploy, passes `SERVER_BINARY`/`SERVER_PARAMS`/`STEAMCMD_PATH` env vars). `make test` runs both backend and frontend tests.