Compare commits
No commits in common. "b6650119a0c7175174ba7f1130c312b00a313412" and "5877bd3d8a86f7062116797a0d5c6f0bb5e4a410" have entirely different histories.
b6650119a0
...
5877bd3d8a
@ -1,22 +0,0 @@
|
||||
# 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,4 +11,3 @@ e2e/node_modules/
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
.omo/
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@ -1,38 +0,0 @@
|
||||
# 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;"]
|
||||
1
REQUIREMENTS.md
Normal file
1
REQUIREMENTS.md
Normal file
@ -0,0 +1 @@
|
||||
Product requirements live in `.pm/board.yaml`.
|
||||
@ -1,70 +0,0 @@
|
||||
# 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.
|
||||
@ -1,69 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
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<typeof createHud>)
|
||||
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()
|
||||
})
|
||||
@ -27,10 +27,10 @@ export function startGame(): void {
|
||||
let room = params.get('room')
|
||||
if (!room) {
|
||||
room = generateRoomCode()
|
||||
window.history.replaceState({}, '', `${window.location.pathname}?room=${room}`)
|
||||
window.history.replaceState({}, '', `/?room=${room}`)
|
||||
}
|
||||
|
||||
const shareLink = `${window.location.origin}${window.location.pathname}?room=${room}`
|
||||
const shareLink = `${window.location.origin}/?room=${room}`
|
||||
hud.setShareLink(shareLink)
|
||||
|
||||
const model = new GameModel()
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
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`)
|
||||
})
|
||||
@ -10,11 +10,8 @@ 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:'
|
||||
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 {
|
||||
onJoined: (room: string) => void
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user