diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..275abb0 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,6 @@ +# Caddy terminates TLS (automatic certificates) and proxies to the game. +# Replace the hostname with your domain, or use the sslip.io form +# (wizwar..sslip.io) for zero DNS setup. +{$WIZWAR_HOST} + +reverse_proxy localhost:8787 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..e0d26e7 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,36 @@ +# Deploying Wiz-War + +The game runs on a single DigitalOcean droplet: one Node process serves the +built client and the websocket on port 8787; Caddy in front terminates TLS +with automatic certificates. Room files persist in /var/lib/wizwar/rooms and +survive deploys and reboots. + +## Current production + +- Droplet: `wizwar` (nyc3, s-1vcpu-1gb, tag `wizwar`), IP 104.236.96.198 +- URL: https://wizwar.104.236.96.198.sslip.io + (sslip.io resolves the embedded IP — zero DNS setup. To use a real domain, + point an A record at the droplet and update /etc/caddy/Caddyfile.) + +## Everyday deploys + + deploy/deploy.sh 104.236.96.198 + +Builds the client locally, rsyncs the repo (minus node_modules, /data, .git, +research), installs dependencies on the droplet, and restarts the service. +Games in progress survive: state lives in room files, and clients reconnect +automatically. + +## New droplet from scratch + +1. `doctl compute droplet create wizwar --region nyc3 --size s-1vcpu-1gb \ + --image ubuntu-24-04-x64 --ssh-keys --tag-name wizwar --wait` +2. `scp deploy/setup-droplet.sh root@:/root/ && ssh root@ \ + "bash /root/setup-droplet.sh wizwar..sslip.io"` +3. `deploy/deploy.sh ` + +## Operations + +- Logs: `ssh root@ journalctl -u wizwar -f` +- Restart: `ssh root@ systemctl restart wizwar` +- Backup games: `scp -r root@:/var/lib/wizwar/rooms ./rooms-backup` diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..f04c3dc --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Build locally, push to the droplet, restart. Usage: deploy/deploy.sh +set -euo pipefail +HOST="${1:?usage: deploy.sh }" + +npm run build --workspace=@wizwar/web +rsync -az --delete \ + --exclude node_modules --exclude /data --exclude .git --exclude research \ + ./ "root@$HOST:/opt/wizwar/" +ssh "root@$HOST" ' + cd /opt/wizwar && npm install --no-audit --no-fund + chown -R wizwar:wizwar /opt/wizwar + cp /opt/wizwar/deploy/wizwar.service /etc/systemd/system/wizwar.service + systemctl daemon-reload + systemctl enable --now wizwar + systemctl restart wizwar + systemctl --no-pager -l status wizwar | head -5 +' +echo "deployed." diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh new file mode 100755 index 0000000..2ebbe3c --- /dev/null +++ b/deploy/setup-droplet.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# One-time droplet setup. Run ON the droplet as root: +# bash setup-droplet.sh wizwar..sslip.io +set -euo pipefail +HOST="${1:?usage: setup-droplet.sh }" + +apt-get update -q +apt-get install -qy curl git rsync + +# Node 22 +curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +apt-get install -qy nodejs + +# Caddy (auto-HTTPS) +apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | tee /etc/apt/sources.list.d/caddy-stable.list +apt-get update -q && apt-get install -qy caddy + +# App user + directories +id -u wizwar &>/dev/null || useradd -r -m -d /opt/wizwar-home wizwar +mkdir -p /opt/wizwar /var/lib/wizwar/rooms +chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar + +# Caddy vhost +printf '%s\n\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile +systemctl reload caddy + +echo "droplet ready — now run deploy.sh from your machine" diff --git a/deploy/wizwar.service b/deploy/wizwar.service new file mode 100644 index 0000000..e6d2153 --- /dev/null +++ b/deploy/wizwar.service @@ -0,0 +1,17 @@ +[Unit] +Description=Wiz-War game server +After=network.target + +[Service] +Type=simple +User=wizwar +WorkingDirectory=/opt/wizwar/packages/server +Environment=PORT=8787 +Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms +Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist +ExecStart=/usr/bin/npx tsx src/index.ts +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fa1b8c2..f8b11cb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -11,6 +11,9 @@ // {type:"state", view} redacted full view (after every change) // {type:"error", message} +import { createServer } from "node:http"; +import { readFileSync, existsSync } from "node:fs"; +import { extname, join, normalize } from "node:path"; import { WebSocketServer, WebSocket } from "ws"; import type { Command, PlayerId } from "@wizwar/engine"; import { @@ -30,7 +33,33 @@ import { const port = Number(process.env.PORT ?? 8787); loadPersistedRooms(); -const wss = new WebSocketServer({ port }); + +// One process serves both the built client and the websocket, so production +// needs only a TLS proxy in front (or nothing, on a LAN). +const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist"); +const MIME: Record = { + ".html": "text/html", ".js": "text/javascript", ".css": "text/css", + ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", + ".woff2": "font/woff2", ".json": "application/json", +}; +const httpServer = createServer((req, res) => { + try { + const url = (req.url ?? "/").split("?")[0]!; + let file = normalize(join(STATIC_DIR, url === "/" ? "index.html" : url)); + if (!file.startsWith(normalize(STATIC_DIR))) { + res.writeHead(403).end(); + return; + } + if (!existsSync(file)) file = join(STATIC_DIR, "index.html"); // SPA fallback + const body = readFileSync(file); + res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" }); + res.end(body); + } catch { + res.writeHead(500).end(); + } +}); +const wss = new WebSocketServer({ server: httpServer, path: undefined }); +httpServer.listen(port); interface Session { socket: WebSocket; @@ -182,4 +211,4 @@ wss.on("connection", (socket) => { }); }); -console.log(`wizwar server listening on ws://localhost:${port}`); +console.log(`wizwar serving client + websocket on port ${port}`); diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index d6abf4f..83a66a3 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -3,7 +3,11 @@ import type { Command, GameEvent, GameView } from "@wizwar/engine"; import { cardDef } from "@wizwar/engine"; -const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787"; +const SERVER_URL = + import.meta.env.VITE_WIZWAR_SERVER ?? + (location.hostname === "localhost" + ? "ws://localhost:8787" + : `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`); export function humanize(e: GameEvent): string | null { switch (e.type) {