diff --git a/.github/workflows/frontend-container.yml b/.github/workflows/frontend-container.yml new file mode 100644 index 00000000..f6e2f406 --- /dev/null +++ b/.github/workflows/frontend-container.yml @@ -0,0 +1,58 @@ +name: Frontend container + +on: + push: + paths: + - 'frontend/**' + - '.github/workflows/frontend-container.yml' + pull_request: + paths: + - 'frontend/**' + - '.github/workflows/frontend-container.yml' + +permissions: + contents: read + +concurrency: + group: frontend-container-${{ github.ref }} + cancel-in-progress: true + +jobs: + container: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build frontend image + run: docker build --pull -t payd-frontend:ci -f frontend/Dockerfile frontend + - name: Check image size and nginx configuration + run: | + bytes=$(docker image inspect payd-frontend:ci --format '{{.Size}}') + echo "Frontend image size: ${bytes} bytes" + test "$bytes" -lt 50000000 + docker run --rm payd-frontend:ci nginx -t + - name: Check static serving and SPA fallback + shell: bash + run: | + set -euo pipefail + container=$(docker run -d -p 127.0.0.1:8080:80 payd-frontend:ci) + trap 'docker rm -f "$container" >/dev/null' EXIT + for attempt in $(seq 1 30); do + if curl -fsS http://127.0.0.1:8080/ -o /tmp/index.html; then break; fi + sleep 1 + done + curl -fsS -D /tmp/index.headers http://127.0.0.1:8080/index.html -o /tmp/index.html + grep -qi '^Cache-Control: no-store' /tmp/index.headers + curl -fsS -D /tmp/route.headers http://127.0.0.1:8080/dashboard -o /tmp/route.html + cmp /tmp/index.html /tmp/route.html + grep -qi '^Cache-Control: no-store' /tmp/route.headers + asset=$(docker exec "$container" find /usr/share/nginx/html/assets -type f -name '*.js' | head -n 1) + test -n "$asset" + asset=${asset#/usr/share/nginx/html} + curl -fsS -D /tmp/asset.headers "http://127.0.0.1:8080${asset}" -o /dev/null + grep -qi '^Cache-Control: public, max-age=31536000, immutable' /tmp/asset.headers + status=$(curl -sS -D /tmp/missing.headers -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/assets/missing-container-smoke.js) + test "$status" = 404 + ! grep -qi 'immutable' /tmp/missing.headers diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..ffec2715 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,10 @@ +.git +**/node_modules +**/dist +**/.vite +**/coverage +playwright-report +test-results +*.log +.env +.env.* diff --git a/frontend/DOCKER.md b/frontend/DOCKER.md new file mode 100644 index 00000000..682cb442 --- /dev/null +++ b/frontend/DOCKER.md @@ -0,0 +1,40 @@ +# Frontend container + +From the repository root: + +```sh +docker build --pull -t payd-frontend:local -f frontend/Dockerfile frontend +docker run --rm -p 8080:80 payd-frontend:local +``` + +The Node 22 build stage runs the existing locked dependency install and +`npm run build`; only Vite's `dist` output enters the nginx Alpine slim runtime. +Port 80 matches the Kubernetes and Helm frontend service/probe configuration. +Build errors are not suppressed. No backend, database, or provider runs in this +image: route API requests to the backend through the existing ingress. Vite's +local development proxy is not part of a production static server. + +Vite embeds public configuration at build time. Pass the required +`PUBLIC_STELLAR_NETWORK`, `PUBLIC_STELLAR_NETWORK_PASSPHRASE`, +`PUBLIC_STELLAR_RPC_URL`, and `PUBLIC_STELLAR_HORIZON_URL` using `--build-arg`. +Do not pass secrets as build arguments. Local `.env` files are excluded from the +build context; runtime `docker run -e` values do not rewrite the built application. + +## Focused acceptance checks + +```sh +# Issue #539 requires the uncompressed local image to be below 50 MB. +bytes=$(docker image inspect payd-frontend:local --format '{{.Size}}') +test "$bytes" -lt 50000000 + +docker run --rm payd-frontend:local nginx -t +curl -fsSI http://localhost:8080/index.html # Cache-Control: no-store +curl -fsSI http://localhost:8080/dashboard # SPA fallback; no-store +curl -sSI http://localhost:8080/assets/missing.js # 404; no immutable cache +# Request a real /assets/.js from index.html: +# Cache-Control: public, max-age=31536000, immutable +``` + +`index.html` and SPA fallbacks are never stored. Vite's hashed `/assets/` files +are immutable for one year; other public files must revalidate. Check the image +size after building, since application assets and upstream image tags can grow. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..98e6f272 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +# Build from the repository root: +# docker build -t payd-frontend -f frontend/Dockerfile frontend +FROM node:22-alpine AS build +WORKDIR /app + +# The wallet dependency tree includes node-usb, which builds from source on musl. +# These tools and headers remain in the build stage, not the nginx image. +RUN apk add --no-cache python3 make g++ pkgconf eudev-dev linux-headers + +# Copy the workspace sources before npm ci so local packages are available too. +COPY . . +RUN npm ci --legacy-peer-deps + +# These are public browser configuration, never credentials. Vite substitutes +# them at build time; setting container environment variables cannot change dist. +ARG PUBLIC_STELLAR_NETWORK +ARG PUBLIC_STELLAR_NETWORK_PASSPHRASE +ARG PUBLIC_STELLAR_RPC_URL +ARG PUBLIC_STELLAR_HORIZON_URL +RUN npm run build + +FROM nginx:stable-alpine-slim AS runtime +RUN rm -rf /usr/share/nginx/html/* +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/ /usr/share/nginx/html/ +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..027a1a23 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + listen [::]:80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Vite fingerprints these files. Missing assets must not become HTML or + # receive a long-lived cache header. + location /assets/ { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # The entry point must always reflect the currently deployed asset hashes. + # Internal SPA redirects also pass through this exact location. + location = /index.html { + add_header Cache-Control "no-store" always; + } + + # Public files outside /assets are not necessarily content-addressed. + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache" always; + } +}