CI / build (push) Successful in 1m44s
- Add winePath helper that prepends Z: when platform is windows on Linux - Thread platform through buildModPath, buildAutoArgs, and Start() - Apply Z: prefix to -config=, -mod=, -profiles=, and %command% binary paths - Add tests for winePath, buildAutoArgs, and buildModPath with platform param - Fix formatting across services package with gofmt - Include all prior bug fixes from this branch
13 KiB
13 KiB
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 — 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
- Types:
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.Mutexfor thread safety. - LogStreamer -- pub/sub per named stream (
"server","steamcmd"). Subscribe returns achan string; Stream reads from anio.Reader(process stdout/stderr) and broadcasts to all subscribers of that key. - ProcessManager -- manages the Arma 3 server child process lifecycle. Uses
atomic.Int32state machine (idle/starting/running/stopping) withCompareAndSwapfor lock-free state transitions. Builds the command line from settings, resolves mod paths, monitors process exit.
Notable design decisions
- No database -- everything is files:
settings.json,*.cfgin$CFG_DIR, modlist JSON files. Backup means copying directories. - Single server instance -- ProcessManager allows only one running process at a time.
%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.- Two-tier mod resolution -- resolveModPath checks
$MODS_DIR/@namefirst (manual/symlinked), then falls back to$SERVERFILE_DIR/steamapps/workshop/content/107410/<id>for workshop downloads. - SteamCMD async with live streaming -- all steamcmd operations run in background goroutines, output broadcast to
"steamcmd"key. - Env-defined paths -- all dirs set via env vars with hardcoded defaults, resolved to absolute paths at startup.
SERVER_BINARY,SERVER_PARAMS,STEAMCMD_PATHoverride defaults at runtime. - Atomic state machine -- ProcessManager and SteamCmdManager use
atomic.Int32/atomic.BoolwithCompareAndSwapfor 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. Runsgo build ./...,npm ci && npm run build,go test ./..., andnpm test. Build + test CI.release.yml— Triggered onv*tag push. Runs GoReleaser v2 withrelease --clean, which builds Go binaries (linux/amd64 + windows/amd64), builds frontend viabefore.hooks, creates archives, builds + pushes a Docker image usingDockerfile.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 runstarts backend,make devstarts both backend + Vite dev server with hot reload (copies test stubs to dev-deploy, passesSERVER_BINARY/SERVER_PARAMS/STEAMCMD_PATHenv vars).make testruns both backend and frontend tests.