diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..849a504 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.codebase-memory +.env +.env.* +!.env.example +**/node_modules +**/dist +**/build +**/coverage +**/.cache +**/.tmp +**/*.log +.torollo +.torollo-data +AGENTS.md +CLAUDE.md +doc diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c91245 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Compose project and host exposure +COMPOSE_PROJECT_NAME=torollo +TOROLLO_BIND_ADDRESS=127.0.0.1 +TOROLLO_FRONTEND_PORT=23232 + +# Used only by compose.dev.yaml, where the browser calls the backend directly. +TOROLLO_BACKEND_PORT=23233 + +# Host-side Docker socket mounted into the backend container. +# Rootless Docker commonly uses /run/user//docker.sock. +TOROLLO_DOCKER_SOCKET=/var/run/docker.sock + +# Comma-separated exact browser origins. Leave blank for localhost-only use. +# LAN example: http://192.168.1.50:23232 +TOROLLO_ALLOWED_ORIGINS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42f441..bc53b35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,3 +64,25 @@ jobs: - name: Run Tests run: npm test + + compose-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Validate production Compose configuration + run: docker compose config --quiet + + - name: Validate development Compose configuration + run: docker compose -f compose.yaml -f compose.dev.yaml config --quiet + + - name: Build production images + run: docker compose build + + - name: Build development images + run: docker compose -f compose.yaml -f compose.dev.yaml build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0968d3b..8354c0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,6 +49,14 @@ npm run dev You can now access the interface at `http://localhost:23232`. +Alternatively, run both services in containers with hot reload: + +```bash +docker compose -f compose.yaml -f compose.dev.yaml up --build +``` + +This requires Docker Compose v2. The backend is available on port `23233`, the frontend on `23232`, and Torollo state is kept in the Compose-managed `torollo-data` volume. Rebuild the images after changing either package's dependencies. + ### 4. Testing the Production Build (CLI Mode) If you want to test how the application runs when bundled via the CLI tool: ```bash diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a203ca9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1.7 + +ARG NODE_IMAGE=node:20.20.2-bookworm-slim +ARG NGINX_IMAGE=nginx:1.28.3-alpine + +FROM ${NODE_IMAGE} AS backend-deps +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci + +FROM backend-deps AS backend-build +COPY backend/tsconfig.json ./ +COPY backend/src ./src +RUN npm run build + +FROM backend-deps AS backend-dev +COPY backend/tsconfig.json ./ +COPY backend/src ./src +EXPOSE 23233 +CMD ["npm", "run", "dev"] + +FROM ${NODE_IMAGE} AS backend-runtime +ENV NODE_ENV=production \ + PORT=23233 \ + TOROLLO_HOST=0.0.0.0 +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev +COPY --from=backend-build /app/backend/dist ./dist +COPY roadmaps /app/roadmaps +EXPOSE 23233 +CMD ["node", "dist/server.js"] + +FROM ${NODE_IMAGE} AS frontend-deps +WORKDIR /app +COPY package.json ./package.json +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci + +FROM frontend-deps AS frontend-build +COPY frontend ./ +RUN npm run build + +FROM frontend-deps AS frontend-dev +COPY frontend ./ +EXPOSE 23232 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "23232"] + +FROM ${NGINX_IMAGE} AS frontend-runtime +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html +COPY docker/frontend-env.js /usr/share/nginx/html/env.js +EXPOSE 23232 +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index de80bc5..0ec1bdc 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,52 @@ npx torollo start Open the app, create a project, and hit **Learning** in the topbar — the best first contact with Torollo is a guided roadmap, not an empty canvas. Start with **Deploy a resilient three-tier app**: it walks you from a single web server to a load-balanced, firewalled, database-backed architecture in ten validated steps. +### Run with Docker Compose + +The Compose setup builds the frontend and backend, serves them through one local URL, mounts the host Docker socket so Torollo can create lab resources, and persists projects and learning progress in a named volume. + +Prerequisites: + +- Docker Engine or Docker Desktop with the Docker daemon running. +- Docker Compose v2 (`docker compose version`). + +Start the production stack: + +```bash +cp .env.example .env # optional: defaults work for localhost +docker compose up --build -d +docker compose logs -f +``` + +Open `http://localhost:23232`. The first boot can take several minutes while Torollo prepares its node images; follow the backend logs to see progress. Stop the application without deleting its state with: + +```bash +docker compose down +``` + +For development with Vite and nodemon hot reload: + +```bash +docker compose -f compose.yaml -f compose.dev.yaml up --build +``` + +The development override also publishes the backend at `http://localhost:23233`. Source and roadmap changes are bind-mounted; rebuild after changing dependencies. + +Compose reads these optional settings from the root `.env` file: + +| Variable | Default | Purpose | +|---|---|---| +| `COMPOSE_PROJECT_NAME` | `torollo` | Prefix for containers, networks, and volumes | +| `TOROLLO_BIND_ADDRESS` | `127.0.0.1` | Host address used for published ports | +| `TOROLLO_FRONTEND_PORT` | `23232` | Browser-facing frontend/proxy port | +| `TOROLLO_BACKEND_PORT` | `23233` | Backend port published only by the development override | +| `TOROLLO_DOCKER_SOCKET` | `/var/run/docker.sock` | Host Docker socket to mount into the backend | +| `TOROLLO_ALLOWED_ORIGINS` | empty | Comma-separated extra browser origins | + +Project and roadmap-progress data live in the `torollo-data` named volume. `docker compose down` preserves it; `docker compose down -v` permanently deletes it. + +> ⚠️ **Docker access:** The socket mount gives the backend control of the host Docker daemon, which is Torollo's core function. Torollo has no authentication, so anyone who can reach the published port inherits that control — the default `TOROLLO_BIND_ADDRESS=127.0.0.1` is what keeps the stack private, not `TOROLLO_ALLOWED_ORIGINS`, which only covers browsers. Read [Self-Hosting & Network Exposure](#self-hosting--network-exposure) before changing it, and do not run untrusted Torollo images. Rootless Docker users can set `TOROLLO_DOCKER_SOCKET=/run/user//docker.sock`. + --- ## Guided, auto-graded roadmaps @@ -85,6 +131,13 @@ To access Torollo from another machine (e.g. a home lab server), opt in explicit TOROLLO_HOST=0.0.0.0 TOROLLO_ALLOWED_ORIGINS=http://:23232 npx torollo start ``` +For Compose, set the equivalent root `.env` values and restart the stack: + +```dotenv +TOROLLO_BIND_ADDRESS=0.0.0.0 +TOROLLO_ALLOWED_ORIGINS=http://:23232 +``` + - `TOROLLO_HOST` — address the API binds to (default `127.0.0.1`; set to `0.0.0.0` to listen on all interfaces). - `TOROLLO_ALLOWED_ORIGINS` — comma-separated list of exact extra origins allowed to call the API from a browser (the address you type into the browser, e.g. `http://192.168.1.5:23232`). diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..c15f647 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,50 @@ +services: + backend: + image: torollo/backend-dev:local + build: + target: backend-dev + command: npm run dev + environment: + NODE_ENV: development + ports: + - target: 23233 + published: ${TOROLLO_BACKEND_PORT:-23233} + host_ip: ${TOROLLO_BIND_ADDRESS:-127.0.0.1} + protocol: tcp + volumes: + - type: bind + source: ./backend/src + target: /app/backend/src + - type: bind + source: ./backend/tsconfig.json + target: /app/backend/tsconfig.json + read_only: true + - type: bind + source: ./roadmaps + target: /app/roadmaps + read_only: true + + frontend: + image: torollo/frontend-dev:local + build: + target: frontend-dev + command: npm run dev -- --host 0.0.0.0 --port 23232 + environment: + VITE_TOROLLO_BACKEND_PORT: ${TOROLLO_BACKEND_PORT:-23233} + volumes: + - type: bind + source: ./frontend/src + target: /app/frontend/src + - type: bind + source: ./frontend/public + target: /app/frontend/public + - type: bind + source: ./frontend/index.html + target: /app/frontend/index.html + read_only: true + - type: bind + source: ./frontend/vite.config.ts + target: /app/frontend/vite.config.ts + read_only: true + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:23232').then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..31a654f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,59 @@ +name: ${COMPOSE_PROJECT_NAME:-torollo} + +services: + backend: + image: torollo/backend:local + build: + context: . + target: backend-runtime + environment: + NODE_ENV: production + PORT: "23233" + TOROLLO_HOST: 0.0.0.0 + TOROLLO_ALLOWED_ORIGINS: ${TOROLLO_ALLOWED_ORIGINS:-} + expose: + - "23233" + volumes: + - type: bind + source: ${TOROLLO_DOCKER_SOCKET:-/var/run/docker.sock} + target: /var/run/docker.sock + - type: volume + source: torollo-data + target: /root/.torollo + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:23233/health').then(response => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + init: true + restart: unless-stopped + + frontend: + image: torollo/frontend:local + build: + context: . + target: frontend-runtime + depends_on: + backend: + condition: service_healthy + ports: + - target: 23232 + published: ${TOROLLO_FRONTEND_PORT:-23232} + host_ip: ${TOROLLO_BIND_ADDRESS:-127.0.0.1} + protocol: tcp + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:23232/healthz"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 5s + init: true + restart: unless-stopped + +volumes: + torollo-data: diff --git a/docker/frontend-env.js b/docker/frontend-env.js new file mode 100644 index 0000000..4c1a594 --- /dev/null +++ b/docker/frontend-env.js @@ -0,0 +1 @@ +window.TOROLLO_BACKEND_URL = window.location.origin; diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..9706baf --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,75 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 23232; + server_name _; + server_tokens off; + + root /usr/share/nginx/html; + index index.html; + + # A literal upstream name is resolved once at startup and cached for the + # lifetime of the container, so nginx would keep proxying to a stale address + # after `docker compose up` recreates the backend. Going through a variable + # defers the lookup to request time, against Docker's embedded DNS. + resolver 127.0.0.11 ipv6=off valid=10s; + set $torollo_backend backend; + + # add_header replaces rather than merges across levels: any location + # declaring its own header loses the two below and has to repeat them. + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 'ok'; + } + + location = /env.js { + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Cache-Control 'no-store, no-cache, must-revalidate' always; + try_files $uri =404; + } + + location /api/ { + proxy_pass http://$torollo_backend:23233; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Creating a node blocks on pulling its image, which on a cold host runs + # well past the default 60s. Timing out here would report a failure for a + # container the backend goes on to create. + proxy_read_timeout 15m; + } + + location = /health { + proxy_pass http://$torollo_backend:23233; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /socket.io/ { + proxy_pass http://$torollo_backend:23233; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 1h; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/shared/config/apiBase.test.ts b/frontend/src/shared/config/apiBase.test.ts new file mode 100644 index 0000000..e84ab84 --- /dev/null +++ b/frontend/src/shared/config/apiBase.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { resolveApiBase } from './apiBase' + +describe('resolveApiBase', () => { + it('uses the runtime URL for a same-origin Compose deployment', () => { + expect(resolveApiBase({ + runtimeUrl: 'https://torollo.example.test/', + hostname: 'torollo.example.test', + })).toBe('https://torollo.example.test') + }) + + it('prefers the runtime URL over a configured port', () => { + expect(resolveApiBase({ + runtimeUrl: 'http://torollo.example.test:8080', + runtimePort: 24001, + hostname: 'torollo.example.test', + })).toBe('http://torollo.example.test:8080') + }) + + it('falls back to the page host on the configured port', () => { + expect(resolveApiBase({ + runtimePort: 24001, + hostname: '192.168.1.50', + })).toBe('http://192.168.1.50:24001') + }) + + it('uses the legacy default port when no runtime configuration exists', () => { + expect(resolveApiBase({ + hostname: 'localhost', + })).toBe('http://localhost:23233') + }) +}) diff --git a/frontend/src/shared/config/apiBase.ts b/frontend/src/shared/config/apiBase.ts new file mode 100644 index 0000000..5545cff --- /dev/null +++ b/frontend/src/shared/config/apiBase.ts @@ -0,0 +1,39 @@ +export interface ApiBaseOptions { + runtimeUrl?: string + runtimePort?: number + hostname: string +} + +declare global { + interface Window { + TOROLLO_BACKEND_URL?: string + TOROLLO_BACKEND_PORT?: number + } +} + +/** + * Same-origin deployments (Compose, behind the nginx proxy) inject an explicit + * runtime URL through `env.js`. Everywhere else — Vite dev and the CLI — the + * API answers on its own port, on the host the page was served from. + */ +export function resolveApiBase({ + runtimeUrl, + runtimePort, + hostname, +}: ApiBaseOptions): string { + if (runtimeUrl !== undefined) { + return runtimeUrl.replace(/\/$/, '') + } + + return `http://${hostname}:${runtimePort || 23233}` +} + +const viteBackendPort = Number(import.meta.env.VITE_TOROLLO_BACKEND_PORT) + +export const API_BASE = resolveApiBase({ + runtimeUrl: window.TOROLLO_BACKEND_URL, + runtimePort: import.meta.env.DEV && Number.isInteger(viteBackendPort) && viteBackendPort > 0 + ? viteBackendPort + : window.TOROLLO_BACKEND_PORT, + hostname: window.location.hostname, +}) diff --git a/frontend/src/shared/types/index.ts b/frontend/src/shared/types/index.ts index 58ab258..39a8393 100644 --- a/frontend/src/shared/types/index.ts +++ b/frontend/src/shared/types/index.ts @@ -39,7 +39,5 @@ export interface LearningIntent { roadmap?: { id: string; language: string }; } -/** API base URL for the backend */ -export const API_BASE = import.meta.env.DEV - ? 'http://localhost:23233' - : `http://${window.location.hostname}:${(window as any).TOROLLO_BACKEND_PORT || 23233}`; +/** API base URL for the backend. Kept here as the stable import surface. */ +export { API_BASE } from '../config/apiBase';