diff --git a/.github/workflows/pwa-bun.yml b/.github/workflows/pwa-bun.yml new file mode 100644 index 00000000..0f482611 --- /dev/null +++ b/.github/workflows/pwa-bun.yml @@ -0,0 +1,46 @@ +# app.moshcode.sh runs on Bun in production (see Dockerfile). Its dependencies are +# still installed with npm from apps/pwa/package-lock.json, as the image does. +name: pwa on bun + +on: + pull_request: + paths: ["apps/pwa/**", "Dockerfile", ".github/workflows/pwa-bun.yml"] + push: + branches: [main] + paths: ["apps/pwa/**", "Dockerfile", ".github/workflows/pwa-bun.yml"] + +permissions: + contents: read + +jobs: + test: + name: PWA tests + boot under Bun + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci --no-audit --no-fund + working-directory: apps/pwa + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + # One process per file, as `node --test` does: each file points DATABASE_URL + # at its own scratch database before importing config. --timeout: bun test + # stops a test after 5 s by default; node --test has no limit, and the pit + # tests seed 5000 names first. + - name: Tests (one Bun process per file) + working-directory: apps/pwa + run: | + failed=0 + for f in test/*.test.mjs; do bun test --timeout 120000 "./$f" > /tmp/t.log 2>&1 || { echo "FAIL $f"; cat /tmp/t.log; failed=1; }; done + exit $failed + - name: Server boots under Bun + run: | + DATABASE_URL="file:/tmp/pwa-ci.db" SESSION_SECRET=ci PORT=3000 bun apps/pwa/src/server.mjs > /tmp/server.log 2>&1 & + for i in $(seq 1 30); do + curl -sf http://127.0.0.1:3000/healthz && exit 0 + sleep 1 + done + cat /tmp/server.log; exit 1 diff --git a/Dockerfile b/Dockerfile index 86a8741f..b29c7ef6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,29 @@ # app.moshcode.sh on dev2: the web app lives in apps/pwa with its own dependencies # (Railway built it with rootDirectory=apps/pwa; from the repo root nixpacks installs # only the CLI's). The repo root is copied so relative imports keep resolving. -FROM node:22-slim +# +# Runs on Bun. The CLI in this repo is a Node/pnpm product and the PWA keeps its +# package-lock, so dependencies are still installed by `npm ci` exactly as before; +# only the runtime changes: Debian 12 (node:22-slim's base) with Bun's single +# binary and no node. Contract with dev2 unchanged: listens on $PORT (3000), answers +# /healthz, reads its secrets from app.env at run time. +FROM node:22-slim AS deps WORKDIR /app COPY . . RUN cd apps/pwa && (npm ci --omit=dev 2>/dev/null || npm install --omit=dev) + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=oven/bun:1.4.0-slim /usr/local/bin/bun /usr/local/bin/bun +# The non-root runtime user, uid 1000 like the oven/bun images. +RUN groupadd --gid 1000 bun && useradd --uid 1000 --gid bun --create-home --shell /bin/sh bun +WORKDIR /app +# --chown: dev2's checkout is group-only (660/2770) and COPY keeps those modes. +COPY --from=deps --chown=bun:bun /app /app +USER bun ENV NODE_ENV=production EXPOSE 3000 -CMD ["node", "apps/pwa/src/server.mjs"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD bun -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/healthz').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))" +CMD ["bun", "apps/pwa/src/server.mjs"] diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index 9eb03c84..23c0eb2e 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -149,6 +149,8 @@ async function main() { // Unref'd so it never holds the process open on its own. setInterval(sweep, SWEEP_MS).unref(); app.listen(config.port, () => console.log(`🤘 app.moshcode.sh on :${config.port} (${config.env}) — ${config.origin}`)); + // PID 1 in its container: without a handler `docker stop` waits 10 s for SIGKILL. + for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => process.exit(0)); } main().catch((e) => { console.error("boot failed:", e); process.exit(1); }); diff --git a/apps/pwa/test/moshpit-gateway-host.test.mjs b/apps/pwa/test/moshpit-gateway-host.test.mjs index ac807416..86955234 100644 --- a/apps/pwa/test/moshpit-gateway-host.test.mjs +++ b/apps/pwa/test/moshpit-gateway-host.test.mjs @@ -54,7 +54,12 @@ test("the origin is asked for the Moshpit name, not the target", async () => { } finally { server.close(); } }); -test("fetch() would have sent the wrong Host — the bug this replaces", async () => { +// Pins undici's behaviour: Node's fetch() drops a caller-supplied Host header. +// Bun's fetch() honours it, so there is no defect to pin when the suite runs on +// Bun (production does); the gateway's own http.request path is covered above. +test("fetch() would have sent the wrong Host — the bug this replaces", { + skip: process.versions.bun ? "Bun's fetch() keeps a caller-supplied Host" : false, +}, async () => { const { server, seen, port } = await virtualHost(); try { const res = await fetch(`http://127.0.0.1:${port}/`, {