diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f37f2cf..8ce322a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -101,10 +101,10 @@ jobs: - name: Seed fixtures run: pnpm --filter @dorfteich/api db:seed - - name: Start api and web dev server + - name: Start api and static web server run: | - (cd apps/api && PORT=3001 node dist/main.js &) - (pnpm --filter @dorfteich/web dev -- --host --port 5173 --strictPort &) + (cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &) + (PORT=5173 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &) for i in $(seq 1 30); do curl -sf http://localhost:3001/api/v1/readyz >/dev/null && break sleep 2 @@ -119,6 +119,10 @@ jobs: E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://mailpit:8025 \ pnpm --filter @dorfteich/web exec playwright test e2e/auth.spec.ts + - name: Dump server logs on failure + if: failure() + run: tail -50 /tmp/api.log /tmp/web.log || true + images: name: Build container images # PR-only: on main the CD workflow builds and pushes the same images — diff --git a/eslint.config.mjs b/eslint.config.mjs index 6dd6fa3..821c14e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,7 +22,7 @@ export default tseslint.config( // Plain-Node maintenance scripts (no TypeScript, no bundler). files: ['scripts/**/*.mjs'], languageOptions: { - globals: { console: 'readonly', process: 'readonly' }, + globals: { console: 'readonly', process: 'readonly', URL: 'readonly' }, }, }, { diff --git a/scripts/e2e-static-server.mjs b/scripts/e2e-static-server.mjs new file mode 100644 index 0000000..15bb08d --- /dev/null +++ b/scripts/e2e-static-server.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * Minimal server for e2e runs: serves the built SPA (apps/web/dist) with + * SPA fallback and proxies /api/* to the api process — mirroring what + * nginx + Caddy do in production, without a dev server that may die + * under CI memory pressure. Node builtins only. + * + * PORT=5173 API_TARGET=http://127.0.0.1:3001 node scripts/e2e-static-server.mjs + */ +import { readFile } from 'node:fs/promises'; +import { createServer, request as httpRequest } from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '../apps/web/dist'); +const PORT = Number(process.env.PORT ?? 5173); +const API_TARGET = new URL(process.env.API_TARGET ?? 'http://127.0.0.1:3001'); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', +}; + +function proxyApi(req, res) { + const upstream = httpRequest( + { + hostname: API_TARGET.hostname, + port: API_TARGET.port, + path: req.url, + method: req.method, + headers: { ...req.headers, host: `${API_TARGET.hostname}:${API_TARGET.port}` }, + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); + upstreamRes.pipe(res); + }, + ); + upstream.on('error', () => { + res.writeHead(502).end('api unreachable'); + }); + req.pipe(upstream); +} + +async function serveStatic(req, res) { + const urlPath = new URL(req.url, 'http://x').pathname; + // Path traversal guard: resolve inside dist or fall back to the shell. + const filePath = path.normalize(path.join(DIST, urlPath)); + const target = + filePath.startsWith(DIST) && path.extname(filePath) ? filePath : path.join(DIST, 'index.html'); + try { + const body = await readFile(target); + res.writeHead(200, { + 'Content-Type': MIME[path.extname(target)] ?? 'application/octet-stream', + }); + res.end(body); + } catch { + res.writeHead(404).end('not found'); + } +} + +createServer((req, res) => { + if (req.url.startsWith('/api/')) proxyApi(req, res); + else void serveStatic(req, res); +}).listen(PORT, () => console.log(`e2e static server on :${PORT} → ${API_TARGET.href}`));