From 0a81308bafee731e6849896ce721fdd8de7638d0 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Mon, 7 Sep 2026 19:14:19 -0700 Subject: [PATCH] docker changes --- .dockerignore | 22 ++++++++++++ .gitignore | 1 + Dockerfile | 38 +++++++++++++++++++++ REQUIREMENTS.md | 1 - docker/README.md | 70 +++++++++++++++++++++++++++++++++++++++ docker/nginx.conf | 69 ++++++++++++++++++++++++++++++++++++++ frontend/src/game.test.ts | 37 +++++++++++++++++++++ frontend/src/game.ts | 4 +-- frontend/src/net.test.ts | 31 +++++++++++++++++ frontend/src/net.ts | 5 ++- 10 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 REQUIREMENTS.md create mode 100644 docker/README.md create mode 100644 docker/nginx.conf create mode 100644 frontend/src/game.test.ts create mode 100644 frontend/src/net.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ef41d03 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Allow only the files required by the two builds. No credentials or host dependencies. +** +!Dockerfile +!docker/ +!docker/nginx.conf +!frontend/ +!frontend/package.json +!frontend/package-lock.json +!frontend/tsconfig.json +!frontend/index.html +!frontend/src/ +!frontend/src/** +!frontend/public/ +!frontend/public/** +!backend/ +!backend/Cargo.toml +!backend/Cargo.lock +!backend/src/ +!backend/src/** +**/.env* +**/*.pem +**/*.key diff --git a/.gitignore b/.gitignore index c38215a..81b3ea2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ e2e/node_modules/ # Misc *.log .DS_Store +.omo/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afc6aaf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 +FROM node:22-bookworm-slim AS frontend-build +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +ARG VITE_BASE_PATH=/curltastic/ +RUN npm test -- --pool=forks --poolOptions.forks.singleFork \ + && npm run build -- --base="${VITE_BASE_PATH}" + +FROM rust:1.96-bookworm AS backend-build +WORKDIR /app +COPY backend/Cargo.toml backend/Cargo.lock ./ +COPY backend/src/ ./src/ +RUN cargo build --locked --release + +FROM debian:bookworm-slim AS backend +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 curltastic \ + && useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin curltastic +COPY --from=backend-build /app/target/release/curltastic-backend /usr/local/bin/curltastic-backend +USER 10001:10001 +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl --fail --silent --show-error http://127.0.0.1:3000/ || exit 1 +ENTRYPOINT ["/usr/local/bin/curltastic-backend"] + +FROM nginx:1.28-alpine AS frontend +COPY docker/nginx.conf /etc/nginx/nginx.conf +COPY --from=frontend-build /app/dist/ /usr/share/nginx/html/ +USER 101:101 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["nginx"] +CMD ["-g", "daemon off;"] diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md deleted file mode 100644 index 75fe000..0000000 --- a/REQUIREMENTS.md +++ /dev/null @@ -1 +0,0 @@ -Product requirements live in `.pm/board.yaml`. diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..78976bb --- /dev/null +++ b/docker/README.md @@ -0,0 +1,70 @@ +# Production images + +Build from the repository root (no source mounts or runtime package installs): + +```sh +docker build --target frontend -t curltastic-frontend . +docker build --target backend -t curltastic-backend . +``` + +| Target | Listener | Runtime user | Healthcheck | +| --- | --- | --- | --- | +| `frontend` (default final target) | HTTP `8080` | `101:101` | `GET /healthz` | +| `backend` | HTTP/WebSocket `3000` | `10001:10001` | `GET /` | + +The frontend build argument `VITE_BASE_PATH` defaults to `/curltastic/` and is +passed to `vite build --base`. There are no required runtime environment +variables, credentials, writable application directories, or persistent volumes. +The Rust server keeps rooms in memory; rebuilding/restarting it loses games. + +## Compose integration contract + +- Publish/route only frontend port **8080**. Explicitly set Traefik's load-balancer + port to 8080; do not infer it from the upstream nginx image metadata. +- Traefik routes `/curltastic/` and strips `/curltastic` before forwarding. +- Attach frontend to Traefik's external `web` network and a private application + network. Put backend only on that private network, with the service/DNS name + **`curltastic-backend`**. Do not publish backend port 3000 or enable Traefik on it. +- The browser requests public `/curltastic/ws` using its existing host, port and + TLS scheme. nginx receives `/ws` and proxies the upgrade and query string to + `curltastic-backend:3000/ws`. It also proxies `POST /room`, exposed publicly as + `/curltastic/room`. The current frontend creates room IDs locally and does not + make HTTP room requests; there is no separate frontend HTTP API URL to set. +- Docker DNS is re-resolved every five seconds so backend replacement during + Compose watch does not leave nginx using a stale container address. +- Recommended runtime settings: `read_only: true`, `cap_drop: [ALL]`, + `security_opt: [no-new-privileges:true]`. Give frontend a writable `/tmp` tmpfs + (mode 1777) for nginx PID/temp files. Backend needs no writable mount. +- Never mount source code, a Docker socket, or host credentials. The build context + is allowlisted by `.dockerignore`; host dependencies and `.env*` are excluded. +- Use Compose watch `action: rebuild`: frontend changes rebuild the `frontend` + target, backend changes rebuild the `backend` target. Ignore node_modules, + dist, target, .git and other generated files. Watch Dockerfile for both services + and `docker/nginx.conf` for frontend. + +The nginx listener expects already-stripped paths; a raw host-port request to +`/curltastic/` does not simulate Traefik. Test the public route through a +prefix-stripping test proxy, or request `/`, `/assets/...`, `/ws`, `/room` directly +when testing nginx alone. + +## Development and verification + +Local `npm run dev` still defaults to `/` and connects to the page hostname on +backend port 3000. Existing `VITE_WS_URL` overrides are preserved for custom local +setups; Vite variables are build-time settings, not runtime container settings. +Room navigation and share links retain the page path. + +```sh +(cd frontend && npm ci && npm test -- --pool=forks --poolOptions.forks.singleFork) +(cd frontend && npm run build -- --base=/curltastic/) +(cd backend && cargo test --locked) +``` + +The frontend Docker build runs its tests and TypeScript checking before producing +static assets. The backend build uses the committed Cargo lockfile. + +Verification at implementation: frontend 26 tests passed, Rust 22 tests passed, +and the production subpath frontend build passed. Both Docker build attempts were +denied by the execution approval layer; image builds, nginx syntax, and live +HTTP/WebSocket proxy behavior therefore remain unverified. No servers were +started and no deployment was performed. diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..64b6c6e --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,69 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + server_tokens off; + sendfile on; + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + # Re-resolve the private service after Compose watch replaces its container. + resolver 127.0.0.11 valid=5s ipv6=off; + + server { + listen 8080; + root /usr/share/nginx/html; + index index.html; + client_max_body_size 16k; + set $curltastic_backend curltastic-backend:3000; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Traefik removes /curltastic before these requests reach nginx. + location = /ws { + proxy_pass http://$curltastic_backend$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + + location = /room { + proxy_pass http://$curltastic_backend$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Connection ""; + } + + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } + } +} diff --git a/frontend/src/game.test.ts b/frontend/src/game.test.ts new file mode 100644 index 0000000..3ce9395 --- /dev/null +++ b/frontend/src/game.test.ts @@ -0,0 +1,37 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { startGame } from './game' +import { connect } from './net' +import { createHud } from './hud' + +vi.mock('./net', () => ({ connect: vi.fn(), sendThrow: vi.fn() })) +vi.mock('./renderer', () => ({ createRenderer: () => ({ draw: vi.fn() }) })) +vi.mock('./hud', () => ({ + createHud: vi.fn(), + createVelocitySelector: () => ({ setEnabled: vi.fn() }), + createCurlSelector: () => ({ setEnabled: vi.fn() }), +})) + +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() }) + +it.each(['', '?room=INVITE'])('keeps room navigation and share links under the app path (%s)', (search) => { + const element = { addEventListener: vi.fn(), value: 'team1' } + const setShareLink = vi.fn() + vi.mocked(createHud).mockReturnValue({ + root: element, velocityControl: element, curlSelector: element, + throwButton: element, teamSelect: element, + setShareLink, setTeam: vi.fn(), update: vi.fn(), + } as unknown as ReturnType) + vi.stubGlobal('document', { querySelector: () => ({ + querySelector: () => element, appendChild: vi.fn(), + }) }) + const location = new URL(`https://example.test/curltastic/${search}`) + const replaceState = vi.fn() + vi.stubGlobal('window', { location, history: { replaceState } }) + vi.stubGlobal('localStorage', { getItem: () => null }) + vi.stubGlobal('requestAnimationFrame', vi.fn()) + startGame() + const room = vi.mocked(connect).mock.calls[0][0] + expect(setShareLink).toHaveBeenCalledWith(`https://example.test/curltastic/?room=${room}`) + if (!search) expect(replaceState).toHaveBeenCalledWith({}, '', `/curltastic/?room=${room}`) + else expect(replaceState).not.toHaveBeenCalled() +}) diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 1cd3d77..e1bbf86 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -27,10 +27,10 @@ export function startGame(): void { let room = params.get('room') if (!room) { room = generateRoomCode() - window.history.replaceState({}, '', `/?room=${room}`) + window.history.replaceState({}, '', `${window.location.pathname}?room=${room}`) } - const shareLink = `${window.location.origin}/?room=${room}` + const shareLink = `${window.location.origin}${window.location.pathname}?room=${room}` hud.setShareLink(shareLink) const model = new GameModel() diff --git a/frontend/src/net.test.ts b/frontend/src/net.test.ts new file mode 100644 index 0000000..88592b2 --- /dev/null +++ b/frontend/src/net.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, it, vi } from 'vitest' + +const callbacks = { + onJoined: vi.fn(), onWaiting: vi.fn(), onGameState: vi.fn(), + onTrajectories: vi.fn(), onGameOver: vi.fn(), onError: vi.fn(), onClose: vi.fn(), +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.resetModules() +}) + +it.each([ + [false, 'https://example.test:8443/curltastic/', '/curltastic/', '', 'wss://example.test:8443/curltastic/ws'], + [false, 'http://localhost:4001/curltastic/', '/curltastic/', '', 'ws://localhost:4001/curltastic/ws'], + [false, 'https://example.test/', '/', '', 'wss://example.test/ws'], + [true, 'http://localhost:5173/', '/', '', 'ws://localhost:3000/ws'], + [true, 'https://example.test:5173/', '/', '', 'wss://example.test:3000/ws'], + [false, 'https://example.test/curltastic/', '/curltastic/', 'wss://custom.test/ws', 'wss://custom.test/ws'], +])('resolves the WebSocket endpoint (dev=%s, page=%s)', async (dev, page, base, override, expected) => { + vi.stubEnv('DEV', dev) + vi.stubEnv('BASE_URL', base) + vi.stubEnv('VITE_WS_URL', override) + vi.stubGlobal('window', { location: new URL(page) }) + const WebSocket = vi.fn() + vi.stubGlobal('WebSocket', WebSocket) + const { connect } = await import('./net') + connect('A&B C', callbacks) + expect(WebSocket).toHaveBeenCalledWith(`${expected}?room=A%26B%20C`) +}) diff --git a/frontend/src/net.ts b/frontend/src/net.ts index d352733..169ab80 100644 --- a/frontend/src/net.ts +++ b/frontend/src/net.ts @@ -10,7 +10,10 @@ const WS_URL = import.meta.env.VITE_WS_URL function resolveWsUrl(): string { if (WS_URL) return WS_URL const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - return `${protocol}//${window.location.hostname}:3000/ws` + if (import.meta.env.DEV) { + return `${protocol}//${window.location.hostname}:3000/ws` + } + return `${protocol}//${window.location.host}${import.meta.env.BASE_URL}ws` } export interface NetCallbacks {