Support subpath
All checks were successful
CI / Backend tests (push) Successful in 30s
CI / Frontend lint (push) Successful in 33s
CI / Frontend build (push) Successful in 25s
CI / Frontend unit tests (push) Successful in 1m6s
CI / Playwright e2e (push) Successful in 1m43s
Docker / build-and-push (push) Successful in 2m30s
All checks were successful
CI / Backend tests (push) Successful in 30s
CI / Frontend lint (push) Successful in 33s
CI / Frontend build (push) Successful in 25s
CI / Frontend unit tests (push) Successful in 1m6s
CI / Playwright e2e (push) Successful in 1m43s
Docker / build-and-push (push) Successful in 2m30s
This commit is contained in:
parent
006ae81c3b
commit
a48aad974a
6 changed files with 50 additions and 9 deletions
|
|
@ -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 <base href> 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
|
||||
|
|
|
|||
21
CLAUDE.md
21
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 `<base href="/towers/">` 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 `<base>`.
|
||||
- **API calls are relative** (`api/v1/...`, no leading slash — see `api.service.ts`) so they resolve against `<base>`: `/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
|
||||
|
|
|
|||
|
|
@ -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 <base href> 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<TreeDto> {
|
||||
return firstValueFrom(
|
||||
this.http.get<TreeDto>('/api/v1/data', { headers: this.authHeaders(token) }),
|
||||
this.http.get<TreeDto>('api/v1/data', { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
async putData(token: string, tree: TreeDto): Promise<void> {
|
||||
await firstValueFrom(
|
||||
this.http.put('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
this.http.put('api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue