diff --git a/.forgejo/workflows/docker.yml b/.forgejo/workflows/docker.yml index f6adfd3..6b83a31 100644 --- a/.forgejo/workflows/docker.yml +++ b/.forgejo/workflows/docker.yml @@ -69,6 +69,11 @@ jobs: with: context: . push: true + # The published image is deployed at https://schmelczer.dev/towers/, + # so the SPA is built with that sub-path baked into and + # the service-worker manifest. Change this if the deploy path changes. + build-args: | + BASE_HREF=/towers/ tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache diff --git a/CLAUDE.md b/CLAUDE.md index 091d70c..9c696f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,9 +58,28 @@ docker-compose.dev.yml # Ephemeral volume for Playwright runs - **Frontend**: Angular 21+, **standalone components** (no NgModule), `signal()` / `computed()` / `effect()` / `linkedSignal()`, **zoneless change detection** (`provideZonelessChangeDetection`), `@if`/`@for` control flow, **OnPush** everywhere, esbuild builder (`@angular/build:application`), Reactive Forms, Angular Service Worker (PWA), Angular CDK (drag-drop, A11yModule for focus trap) - **Backend**: FastAPI, pydantic v2, slowapi (rate limiting), structlog, **sqlite3 with WAL + foreign_keys ON** every connection, uv-managed deps -- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*` +- **Runtime topology**: single Docker container — FastAPI process serves both `/api/v1/*` JSON endpoints AND the built Angular SPA as static files with SPA fallback. Behind nginx; uvicorn launched with `--proxy-headers --forwarded-allow-ips=*`. **Deployed under a sub-path** (`https://schmelczer.dev/towers/`) — see "Sub-path deployment" below - **Storage**: SQLite at `/data/life-towers.db` on a named Docker volume. Tree-replace semantics (PUT replaces user's full tree atomically inside `BEGIN IMMEDIATE`) +## Sub-path deployment + +The app is deployed under a path: `https://schmelczer.dev/towers/`. The mechanism, and the one rule that keeps it working: + +- **SPA**: built with `ng build --base-href /towers/` (wired via the `BASE_HREF` Docker build arg; default `/`, set to `/towers/` in `.forgejo/workflows/docker.yml`). This stamps `` into `index.html` and prefixes every URL in the service-worker manifest (`ngsw.json`). Asset and script `src`s stay relative, so they resolve against ``. +- **API calls are relative** (`api/v1/...`, no leading slash — see `api.service.ts`) so they resolve against ``: `/towers/api/v1/...` in prod, `/api/v1/...` at the root for dev/e2e. **Never reintroduce a leading slash** — it pins calls to the origin root and breaks the sub-path deploy. +- **Backend is path-agnostic** — it still serves the API at `/api/v1/*` and the SPA at the container root. **nginx strips the prefix** before proxying, so the backend never sees `/towers`. Because of that strip, the backend can't infer its public URL, so `LIFE_TOWERS_PUBLIC_URL` (set in `docker-compose.yml`) is what makes the server-rendered canonical / OG / Twitter tags correct (`main.py:_absolute_meta_urls`). +- **Required nginx** (the trailing slash on `proxy_pass` is what strips `/towers/`): + ```nginx + location = /towers { return 308 /towers/; } # add the trailing slash + location /towers/ { + proxy_pass http://127.0.0.1:8000/; # trailing slash strips the prefix + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + ``` +- **Dev/e2e stay at the root**: `docker-compose.dev.yml` builds with the default `BASE_HREF=/`, and Playwright hits `http://life-towers:8000/`. The relative API paths work identically there, so e2e exercises the same code. + ## Build / dev / test / deploy ```bash diff --git a/Dockerfile b/Dockerfile index 82fba50..6327bb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,16 @@ # Stage 1: SPA build FROM node:22-alpine AS spa-build WORKDIR /build +# Sub-path the SPA is served under, e.g. "/towers/" for https://schmelczer.dev/towers/. +# Defaults to "/" so local/dev/e2e builds (served at the container root) work +# unchanged. The production image (see .forgejo/workflows/docker.yml) overrides +# this. The trailing slash matters — it becomes and the service +# worker (ngsw.json) URL prefix. +ARG BASE_HREF=/ COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ -RUN npm run build +RUN npm run build -- --base-href="$BASE_HREF" # Angular's application builder outputs to dist/frontend/browser/ # Stage 2: runtime diff --git a/docker-compose.yml b/docker-compose.yml index a3c9842..b86d254 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,13 @@ services: # or set it to e.g. `life-towers:local` and uncomment `build: .` for # local builds. image: ${LIFE_TOWERS_IMAGE:-life-towers:local} - # build: . # uncomment for local builds (or use docker-compose.dev.yml) + # For a local production build, uncomment the block below. BASE_HREF must + # match the sub-path the app is served under (the published image is built + # with /towers/ by .forgejo/workflows/docker.yml). + # build: + # context: . + # args: + # BASE_HREF: /towers/ pull_policy: ${LIFE_TOWERS_PULL_POLICY:-missing} ports: - "${LIFE_TOWERS_PORT:-8000}:8000" @@ -17,6 +23,11 @@ services: - life-towers-data:/data environment: LIFE_TOWERS_ALLOWED_ORIGIN: "${LIFE_TOWERS_ALLOWED_ORIGIN:-}" + # Absolute public URL of the deployment, used to render correct canonical + # / Open Graph / Twitter tags. Required when served under a sub-path, + # because nginx strips the prefix before proxying so the backend can't + # otherwise infer it. Override in .env for a different host/path. + LIFE_TOWERS_PUBLIC_URL: "${LIFE_TOWERS_PUBLIC_URL:-https://schmelczer.dev/towers/}" restart: unless-stopped logging: driver: json-file diff --git a/frontend/src/app/services/api.service.ts b/frontend/src/app/services/api.service.ts index 58bf117..3572df0 100644 --- a/frontend/src/app/services/api.service.ts +++ b/frontend/src/app/services/api.service.ts @@ -8,24 +8,24 @@ export class ApiService { private readonly http = inject(HttpClient); health(): Promise<{ status: string }> { - return firstValueFrom(this.http.get<{ status: string }>('/api/v1/health')); + return firstValueFrom(this.http.get<{ status: string }>('api/v1/health')); } register(token: string): Promise<{ user_id: string }> { return firstValueFrom( - this.http.post<{ user_id: string }>('/api/v1/register', { token }), + this.http.post<{ user_id: string }>('api/v1/register', { token }), ); } getData(token: string): Promise { return firstValueFrom( - this.http.get('/api/v1/data', { headers: this.authHeaders(token) }), + this.http.get('api/v1/data', { headers: this.authHeaders(token) }), ); } async putData(token: string, tree: TreeDto): Promise { await firstValueFrom( - this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }), + this.http.put('api/v1/data', tree, { headers: this.authHeaders(token) }), ); } diff --git a/frontend/src/app/services/api.service.vitest.ts b/frontend/src/app/services/api.service.vitest.ts index 4b9b4e3..282fdbe 100644 --- a/frontend/src/app/services/api.service.vitest.ts +++ b/frontend/src/app/services/api.service.vitest.ts @@ -24,7 +24,7 @@ describe('ApiService', () => { it('gets data with a bearer token', async () => { const tree: TreeDto = { pages: [] }; const promise = service.getData('token-1'); - const req = http.expectOne('/api/v1/data'); + const req = http.expectOne('api/v1/data'); expect(req.request.method).toBe('GET'); expect(req.request.headers.get('Authorization')).toBe('Bearer token-1'); req.flush(tree); @@ -34,7 +34,7 @@ describe('ApiService', () => { it('puts data with a bearer token', async () => { const tree: TreeDto = { pages: [] }; const promise = service.putData('token-1', tree); - const req = http.expectOne('/api/v1/data'); + const req = http.expectOne('api/v1/data'); expect(req.request.method).toBe('PUT'); expect(req.request.headers.get('Authorization')).toBe('Bearer token-1'); expect(req.request.body).toBe(tree);