From 44141ba17841ef2882055ac9e5c6ad7646705fc8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 1 Oct 2026 15:06:43 +0000 Subject: [PATCH 1/2] Run the web app on Bun (pairux.com, installer.pairux.com) Both dev2 sites build apps/web/Dockerfile and now run the Next standalone server under Bun instead of Node. - Image: Node + pnpm only install dependencies (pnpm stays the monorepo's package manager: the Electron and Expo toolchains, the patched @expo/cli and the release scripts depend on it, and none of them run on dev2). Next builds with `bun --bun next build`; the runtime is alpine:3.24 (the Alpine node:24-alpine had, so ffmpeg for call analysis is unchanged) with Bun's musl binary copied in, the pattern icemap.app runs in production. Port 8080, /api/health, the three NEXT_PUBLIC_* build args and the non-root uid 1001 are unchanged; the healthcheck uses Bun's fetch on 127.0.0.1. - apps/web scripts: `bun --bun next dev|build|start`. - CI: a new "web on Bun" workflow installs with pnpm, builds the web app under Bun and boots the standalone server under Bun. ci.yml is left alone (it is managed by the sh1pt Actions Fleet). Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/web-bun.yml | 56 +++++++++++++++++++++++++++++++++++ apps/web/Dockerfile | 42 +++++++++++++++++--------- apps/web/package.json | 6 ++-- 3 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/web-bun.yml diff --git a/.github/workflows/web-bun.yml b/.github/workflows/web-bun.yml new file mode 100644 index 0000000..15f8c92 --- /dev/null +++ b/.github/workflows/web-bun.yml @@ -0,0 +1,56 @@ +# The web app (pairux.com and installer.pairux.com on dev2) builds and runs on +# Bun. pnpm stays the monorepo's package manager; this checks what the image +# does: install with pnpm, `bun --bun next build`, standalone server under Bun. +name: web on Bun + +on: + push: + branches: [master] + pull_request: + paths: + - 'apps/web/**' + - 'packages/**' + - 'pnpm-lock.yaml' + - '.github/workflows/web-bun.yml' + +permissions: + contents: read + +jobs: + build-and-boot: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + # Placeholders: the build only needs the public values to exist. + NEXT_PUBLIC_SUPABASE_URL: https://example.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder + NEXT_PUBLIC_APP_URL: http://127.0.0.1:8080 + NEXT_TELEMETRY_DISABLED: 1 + steps: + - uses: actions/checkout@v6 + # pnpm/action-setup reads pnpm@9.15.0 from packageManager. + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24' + cache: pnpm + # The same Bun the image pins (apps/web/Dockerfile). + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - run: pnpm install --frozen-lockfile --filter @pairux/web... + - run: pnpm --filter @pairux/shared-types build + - name: Build the web app under Bun + run: cd apps/web && bun --bun ./node_modules/next/dist/bin/next build + - name: Standalone server answers under Bun + run: | + cd apps/web + cp -r public .next/standalone/apps/web/ + cp -r .next/static .next/standalone/apps/web/.next/ + (cd .next/standalone && PORT=8080 HOSTNAME=127.0.0.1 bun apps/web/server.js > /tmp/server.log 2>&1 &) + for i in $(seq 1 30); do + code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/health || true) + [ "$code" = 200 ] && { echo "/api/health -> $code"; exit 0; } + sleep 1 + done + cat /tmp/server.log; exit 1 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 29b73b0..f499682 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,10 +1,21 @@ # =========================================== -# PairUX Web App - Railway Dockerfile +# PairUX Web App (pairux.com, installer.pairux.com on dev2) # =========================================== -# Multi-stage build for optimized production image -# Uses Node.js 24 Alpine for minimal footprint +# Runs on Bun. pnpm stays this monorepo's package manager: the desktop +# (Electron) and mobile (Expo) toolchains, the patched @expo/cli +# (pnpm.patchedDependencies) and the release scripts all depend on it, and +# none of that runs here. So Node + pnpm only install dependencies in the +# first stages; Next builds under Bun (`bun --bun next build`) and the +# standalone server runs under Bun on Alpine. +# +# Alpine 3.24 at run time, as under node:24-alpine: call analysis shells out +# to ffmpeg/ffprobe (src/lib/call-analysis/media.ts), and oven/bun:*-alpine is +# Alpine 3.22 (older ffmpeg). So the runtime is alpine:3.24 with Bun's musl +# binary copied in, the same pattern icemap.app runs in production. # =========================================== +FROM oven/bun:1.4.0-alpine AS bun + # Stage 1: Dependencies FROM node:24-alpine AS deps RUN apk add --no-cache libc6-compat @@ -55,18 +66,21 @@ ENV NODE_ENV=production # Build shared-types first (dependency of web) RUN pnpm --filter @pairux/shared-types build -# Build the web application -RUN pnpm --filter @pairux/web build +# Build the web application with Next running on Bun. +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun +RUN cd apps/web && bun --bun ./node_modules/next/dist/bin/next build # Stage 3: Runner -FROM node:24-alpine AS runner +FROM alpine:3.24 AS runner WORKDIR /app -# ffmpeg/ffprobe: AI call analysis joins the uploaded call audio, measures it -# and cuts it into pieces for transcription (src/lib/call-analysis/media.ts). -RUN apk add --no-cache ffmpeg +# libstdc++/libgcc for Bun's musl binary. ffmpeg/ffprobe: AI call analysis +# joins the uploaded call audio, measures it and cuts it into pieces for +# transcription (src/lib/call-analysis/media.ts). +RUN apk add --no-cache libstdc++ libgcc ffmpeg +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun -# Security: Run as non-root user +# Security: Run as non-root user (same uid/gid as the Node image) RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -88,9 +102,9 @@ USER nextjs # Expose port EXPOSE 8080 -# Health check +# Health check (Bun's fetch; 127.0.0.1, since Next binds IPv4 only) HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1 + CMD bun -e "fetch('http://127.0.0.1:8080/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" -# Start the application -CMD ["node", "apps/web/server.js"] +# Start the standalone server under Bun +CMD ["bun", "apps/web/server.js"] diff --git a/apps/web/package.json b/apps/web/package.json index 41be4cc..4a5a9a9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", + "dev": "bun --bun next dev", + "build": "bun --bun next build", + "start": "bun --bun next start", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "typecheck": "tsc --noEmit", From 51c517d5ea86fe4cd0caf9b7d68c5e80631611d7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 1 Oct 2026 15:33:09 +0000 Subject: [PATCH 2/2] Build the web app on Node; Turbopack's PostCSS worker rejects --bun Next 16.3's Turbopack evaluates PostCSS in a separate node worker and passes it the parent's flags, so under bun --bun with a real Node on PATH it dies with "node: --bun is not allowed in NODE_OPTIONS". The build stays on Node (pnpm --filter @pairux/web build, as before); the standalone server runs on Bun. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/web-bun.yml | 9 +++++---- apps/web/Dockerfile | 13 ++++++++----- apps/web/package.json | 4 ++-- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/web-bun.yml b/.github/workflows/web-bun.yml index 15f8c92..f0dc019 100644 --- a/.github/workflows/web-bun.yml +++ b/.github/workflows/web-bun.yml @@ -1,6 +1,7 @@ # The web app (pairux.com and installer.pairux.com on dev2) builds and runs on -# Bun. pnpm stays the monorepo's package manager; this checks what the image -# does: install with pnpm, `bun --bun next build`, standalone server under Bun. +# Bun. pnpm stays the monorepo's package manager and Next builds on Node (see +# apps/web/Dockerfile); this checks what the image does: install with pnpm, +# build, then boot the standalone server under Bun. name: web on Bun on: @@ -40,8 +41,8 @@ jobs: bun-version: 1.4.0 - run: pnpm install --frozen-lockfile --filter @pairux/web... - run: pnpm --filter @pairux/shared-types build - - name: Build the web app under Bun - run: cd apps/web && bun --bun ./node_modules/next/dist/bin/next build + - name: Build the web app + run: pnpm --filter @pairux/web build - name: Standalone server answers under Bun run: | cd apps/web diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index f499682..5a1b998 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -4,8 +4,8 @@ # Runs on Bun. pnpm stays this monorepo's package manager: the desktop # (Electron) and mobile (Expo) toolchains, the patched @expo/cli # (pnpm.patchedDependencies) and the release scripts all depend on it, and -# none of that runs here. So Node + pnpm only install dependencies in the -# first stages; Next builds under Bun (`bun --bun next build`) and the +# none of that runs here. So Node + pnpm install dependencies and build in the +# first stages (see the build step for why Next builds on Node), and the # standalone server runs under Bun on Alpine. # # Alpine 3.24 at run time, as under node:24-alpine: call analysis shells out @@ -66,9 +66,12 @@ ENV NODE_ENV=production # Build shared-types first (dependency of web) RUN pnpm --filter @pairux/shared-types build -# Build the web application with Next running on Bun. -COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun -RUN cd apps/web && bun --bun ./node_modules/next/dist/bin/next build +# Build the web application. The build stays on Node: Next 16.3's Turbopack +# runs PostCSS (Tailwind) in a separate `node` worker and hands it the parent +# process's flags, so under `bun --bun` with a real Node on PATH the worker +# dies with "node: --bun is not allowed in NODE_OPTIONS". What runs in +# production is the standalone server below, and that runs on Bun. +RUN pnpm --filter @pairux/web build # Stage 3: Runner FROM alpine:3.24 AS runner diff --git a/apps/web/package.json b/apps/web/package.json index 4a5a9a9..11d777d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "bun --bun next dev", - "build": "bun --bun next build", + "dev": "next dev", + "build": "next build", "start": "bun --bun next start", "lint": "eslint src/", "lint:fix": "eslint src/ --fix",