From 3d939426b180611a9eb700806e755edbf5dcd270 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 11 Jul 2026 21:57:53 +0200 Subject: [PATCH] build(docker): add runtime-configured frontend image --- .superpowers/sdd/container-task-4-report.md | 57 +++++++++++++++++++++ docker/frontend-entrypoint.sh | 9 ++++ docker/frontend.Dockerfile | 21 ++++++++ docker/nginx.conf.template | 26 ++++++++++ frontend/index.html | 1 + frontend/public/config.js | 1 + frontend/src/api/client.ts | 2 +- frontend/src/api/runtime-config.test.ts | 17 ++++++ frontend/src/api/runtime-config.ts | 17 ++++++ 9 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/container-task-4-report.md create mode 100644 docker/frontend-entrypoint.sh create mode 100644 docker/frontend.Dockerfile create mode 100644 docker/nginx.conf.template create mode 100644 frontend/public/config.js create mode 100644 frontend/src/api/runtime-config.test.ts create mode 100644 frontend/src/api/runtime-config.ts diff --git a/.superpowers/sdd/container-task-4-report.md b/.superpowers/sdd/container-task-4-report.md new file mode 100644 index 00000000..3f3df636 --- /dev/null +++ b/.superpowers/sdd/container-task-4-report.md @@ -0,0 +1,57 @@ +# Container Packaging Task 4 Report + +## Status + +Implemented and verified runtime-configured frontend packaging. + +## Changes + +- Added the browser runtime contract `window.__THOTHII_CONFIG__.backendBaseUrl`. +- Loaded `/config.js` before the Vite module entrypoint. +- Made runtime configuration take precedence while preserving `VITE_BACKEND_URL` and the + existing `http://localhost:8787` client default for development and tests. +- Added a multi-stage frontend image that builds with Node and serves static assets as + unprivileged UID/GID `101:101` with nginx on port 8080. +- Added startup-time `BACKEND_BASE_URL` substitution (default `/api`). +- Added `/api/` reverse proxying to `core:8787`, SPA fallback, no-cache runtime config, + and SSE-safe proxy settings (`proxy_buffering off`, `proxy_cache off`, one-hour read timeout). + +## TDD evidence + +- RED: `npx vitest run src/api/runtime-config.test.ts` failed because + `./runtime-config` did not exist. +- GREEN: targeted runtime config suite passed (3 tests after preserving the legacy client + default). + +## Verification + +- `cd frontend && npx vitest run --reporter=dot && npx tsc -b && npm run build` — exit 0 + (40 test files, 185 tests; TypeScript and Vite production build passed). +- `docker build -f docker/frontend.Dockerfile -t thothii-frontend:test .` — success. +- Image metadata reports `USER 101:101`. +- Two-container isolated-network smoke: + - `/config.js` returned `window.__THOTHII_CONFIG__ = { backendBaseUrl: "/api" };` + - `/api/health` proxied to the core image and returned `{"status":"ok"}`. + - an unknown nested route returned the SPA `index.html`. + - active nginx config contained `proxy_buffering off`, `proxy_cache off`, and + `proxy_read_timeout 1h`. + - `/config.js` returned `Cache-Control: no-store`. +- `sh -n docker/frontend-entrypoint.sh` and `git diff --check` — exit 0. + +## Secret-leakage inspection + +- `.dockerignore` excludes `.env*` (except examples), credentials/key formats, dependency + trees, build outputs, backend data, and deployment data. +- The runtime web root contained no `.env*`, `.pem`, `.key`, `.p12`, or `.pfx` files. +- Image history contained build/package instructions only; no secret build arguments or + credential values were introduced by this task. + +## Self-review / concerns + +- nginx resolves the `core` hostname at startup, matching the planned Compose service name; + standalone runs therefore need a reachable network alias named `core`. +- Existing frontend test warnings (React refs/act, MSW unmatched incidental requests, Vite + chunk-size warnings) remain; they did not fail the requested gates and are unrelated to + this task. +- `.superpowers/sdd/progress.md` was already modified by the orchestrator and was intentionally + excluded from this task's commit. diff --git a/docker/frontend-entrypoint.sh b/docker/frontend-entrypoint.sh new file mode 100644 index 00000000..0c418cdc --- /dev/null +++ b/docker/frontend-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +backend_base_url=${BACKEND_BASE_URL:-/api} +escaped_backend_base_url=$(printf '%s' "$backend_base_url" | sed 's/[&|\\]/\\&/g') +sed "s|__BACKEND_BASE_URL__|${escaped_backend_base_url}|g" \ + /usr/share/nginx/html/config.template.js > /usr/share/nginx/html/config.js + +exec nginx -g 'daemon off;' diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile new file mode 100644 index 00000000..a1c5beba --- /dev/null +++ b/docker/frontend.Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 +FROM node:22.19.0-bookworm-slim AS build +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +FROM nginxinc/nginx-unprivileged:1.27-alpine +USER root +COPY --from=build /src/frontend/dist /usr/share/nginx/html +COPY docker/nginx.conf.template /etc/nginx/conf.d/default.conf +COPY docker/frontend-entrypoint.sh /usr/local/bin/frontend-entrypoint +RUN mv /usr/share/nginx/html/config.js /usr/share/nginx/html/config.template.js \ + && sed -i 's|{}|{ backendBaseUrl: "__BACKEND_BASE_URL__" }|' /usr/share/nginx/html/config.template.js \ + && chmod 0555 /usr/local/bin/frontend-entrypoint \ + && chown -R 101:101 /usr/share/nginx/html + +EXPOSE 8080 +USER 101:101 +ENTRYPOINT ["/usr/local/bin/frontend-entrypoint"] diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template new file mode 100644 index 00000000..16148fc3 --- /dev/null +++ b/docker/nginx.conf.template @@ -0,0 +1,26 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + + location = /config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location /api/ { + proxy_pass http://core:8787/; + 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; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/index.html b/frontend/index.html index 4efb5f19..07fe9ee2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,7 @@
+ diff --git a/frontend/public/config.js b/frontend/public/config.js new file mode 100644 index 00000000..00a5e294 --- /dev/null +++ b/frontend/public/config.js @@ -0,0 +1 @@ +window.__THOTHII_CONFIG__ = {}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 58d8c175..5fb75b8d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -const BASE = import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787"; +import { backendBaseUrl as BASE } from "./runtime-config"; export async function apiFetch(path: string, init?: RequestInit): Promise { // Only declare a JSON content-type when we actually send a body. Body-less diff --git a/frontend/src/api/runtime-config.test.ts b/frontend/src/api/runtime-config.test.ts new file mode 100644 index 00000000..bede385b --- /dev/null +++ b/frontend/src/api/runtime-config.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { backendBaseUrl, resolveBackendUrl } from "./runtime-config"; + +describe("resolveBackendUrl", () => { + it("uses the runtime-injected backend URL", () => { + expect(resolveBackendUrl({ backendBaseUrl: "/api" })).toBe("/api"); + }); + + it("falls back to the Vite backend URL", () => { + expect(resolveBackendUrl(undefined)).toBe(import.meta.env.VITE_BACKEND_URL ?? ""); + }); + + it("preserves the client default when Vite has no configured backend", () => { + expect(backendBaseUrl).toBe(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8787"); + }); +}); diff --git a/frontend/src/api/runtime-config.ts b/frontend/src/api/runtime-config.ts new file mode 100644 index 00000000..0f045159 --- /dev/null +++ b/frontend/src/api/runtime-config.ts @@ -0,0 +1,17 @@ +export interface RuntimeConfig { + backendBaseUrl?: string; +} + +declare global { + interface Window { + __THOTHII_CONFIG__?: RuntimeConfig; + } +} + +export function resolveBackendUrl(config: RuntimeConfig | undefined): string { + return config?.backendBaseUrl ?? import.meta.env.VITE_BACKEND_URL ?? ""; +} + +export const backendBaseUrl = + resolveBackendUrl(typeof window === "undefined" ? undefined : window.__THOTHII_CONFIG__) || + "http://localhost:8787";