diff --git a/Dockerfile.fullstack b/Dockerfile.fullstack index b8f4081..b24e372 100644 --- a/Dockerfile.fullstack +++ b/Dockerfile.fullstack @@ -1,25 +1,23 @@ -# Combined UI + server image: one exposed port, nginx out front. +# Combined UI + server image: one exposed port, no reverse proxy. # -# nginx serves the built static UI and reverse-proxies /api and /socket.io -# (WebSocket upgrade included) to the Node server on loopback:8787 — the same -# routing the Vite dev proxy provides (see vite.config.ts). The server still -# spawns the Pi coding agent (`pi`), which shells out to ripgrep/fd/jq/git, so -# those CLIs are installed alongside the published Pi package. +# The Node server serves the built static UI, the REST API, and the Socket.IO +# transport (Express and Socket.IO share one HTTP server) from a single port — +# the same routing the Vite dev proxy provides (see vite.config.ts). The server +# also spawns the Pi coding agent (`pi`), which shells out to ripgrep/fd/jq/git, +# so those CLIs are installed alongside the published Pi package. # # Layered as three stages so each cache layer is invalidated independently: -# 1. base - Node + CLI tools + nginx + Pi (changes rarely) +# 1. base - Node + CLI tools + Pi (changes rarely) # 2. builder - install deps, bundle the server (dist/) and build the UI (ui-dist/) -# 3. runtime - copy the compiled bundle + static UI + nginx config and run +# 3. runtime - copy the compiled bundle + static UI and run # --------------------------------------------------------------------------- -# Stage 1: base — Node runtime, the CLI tools Pi needs, nginx, and Pi itself. +# Stage 1: base — Node runtime, the CLI tools Pi needs, and Pi itself. # --------------------------------------------------------------------------- FROM node:24-bookworm-slim AS base -# CLI tools the Pi agent's built-in tools (bash/grep/find) rely on at runtime, -# plus nginx (reverse proxy) and gettext-base (envsubst, used to template the -# nginx config at startup). Debian ships `fd` as `fdfind`, so expose it under -# the expected `fd` name. +# CLI tools the Pi agent's built-in tools (bash/grep/find) rely on at runtime. +# Debian ships `fd` as `fdfind`, so expose it under the expected `fd` name. RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ @@ -27,8 +25,6 @@ RUN apt-get update \ fd-find \ jq \ ca-certificates \ - nginx \ - gettext-base \ && ln -s "$(command -v fdfind)" /usr/local/bin/fd \ && rm -rf /var/lib/apt/lists/* @@ -87,16 +83,19 @@ RUN test -f apps/web/dist/index.html || { echo "missing apps/web/dist/index.html # --------------------------------------------------------------------------- -# Stage 3: runtime — assemble the final image from base (tools + nginx + Pi) -# plus the self-contained server bundle and the static UI. No node_modules / -# pnpm: the bundle inlines all deps. +# Stage 3: runtime — assemble the final image from base (tools + Pi) plus the +# self-contained server bundle and the static UI. No node_modules / pnpm: the +# bundle inlines all deps. # --------------------------------------------------------------------------- FROM base AS runtime ENV NODE_ENV=production -# Cloud Run injects PORT; default to 8000. nginx listens on this externally -# exposed port; the Node server runs internally on 8787. +# Cloud Run injects PORT; default to 8000. The Node server listens on this +# externally exposed port directly. ENV PORT=8000 +# Directory the server serves the built static UI from (with an SPA fallback), +# so the image needs no reverse proxy. See apps/server/src/middleware/staticUi.ts. +ENV WEB_DIST_DIR=/app/ui-dist # Writable locations on Cloud Run for session workspaces and Pi's lockfiles/config. ENV SESSIONS_ROOT=/tmp/sessions ENV HOME=/tmp @@ -114,8 +113,7 @@ ENV PI_PROXY_URL=https://proxy.shopify.ai COPY --from=builder /app/apps/server/dist ./dist COPY --from=builder /app/apps/web/dist ./ui-dist -# nginx config template + process supervisor entrypoint. -COPY docker/nginx.conf.template /app/docker/nginx.conf.template +# Process entrypoint. COPY docker/docker-entrypoint.sh /app/docker/docker-entrypoint.sh RUN chmod +x /app/docker/docker-entrypoint.sh diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 7b102c1..590782a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -9,6 +9,14 @@ import { /** Port the dev server listens on. Vite proxies /api and /socket.io here. */ export const PORT = Number(process.env.PORT ?? 8787); +/** + * Directory holding the built static UI (index.html + hashed assets). When set, + * the server serves it with an SPA history fallback so the combined image needs + * no reverse proxy. Unset in dev, where Vite serves the UI and proxies /api + + * /socket.io here. + */ +export const WEB_DIST_DIR = process.env.WEB_DIST_DIR; + /** * Root directory that holds every session's scoped folder. Each session gets * its own subdirectory (`SESSIONS_ROOT/`) which a Pi worker will run in. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 826c213..6931bee 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -5,6 +5,7 @@ import { Server as SocketIOServer } from "socket.io"; import { PORT } from "./config.ts"; import { errorHandler } from "./middleware/errorHandler.ts"; +import { serveStaticUi } from "./middleware/staticUi.ts"; import { MemoryManager } from "./pi/memory.ts"; import { PiAgentManager } from "./pi/piAgentManager.ts"; import { TriggerEngine } from "./pi/triggers/triggerEngine.ts"; @@ -123,6 +124,10 @@ app.use( // Internal API for the session extension running inside each Pi process. app.use("/internal/session", createInternalSessionRouter(store, emitUiCommand)); +// Serves the built UI + SPA fallback in the combined image so no reverse proxy +// is needed; no-op in dev. Mounted after every API/internal router. +serveStaticUi(app); + // Mounted last: async failures from any handler above land here with a // consistent `{ error }` shape (Express 5 forwards rejected promises to it). app.use(errorHandler); diff --git a/apps/server/src/middleware/staticUi.ts b/apps/server/src/middleware/staticUi.ts new file mode 100644 index 0000000..2a4a23a --- /dev/null +++ b/apps/server/src/middleware/staticUi.ts @@ -0,0 +1,36 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import express, { type Express } from "express"; + +import { WEB_DIST_DIR } from "../config.ts"; + +/** + * Serves the built static UI and an SPA history fallback from the same app that + * handles /api and Socket.IO, so the combined image needs no reverse proxy. + * No-op in dev (WEB_DIST_DIR unset), where Vite serves the UI and proxies /api + + * /socket.io here. + * + * Mount last, after every API/internal router: `express.static` only matches + * real build files, and the fallback returns index.html for any other GET so + * client-side routing can take over — while letting unknown /api and /internal + * paths fall through to the normal 404. Socket.IO intercepts /socket.io before + * Express, so it needs no handling here. + */ +export function serveStaticUi(app: Express): void { + if (!WEB_DIST_DIR) return; + if (!existsSync(WEB_DIST_DIR)) { + console.warn(`[server] WEB_DIST_DIR ${WEB_DIST_DIR} not found; UI not served`); + return; + } + + const indexHtml = path.join(WEB_DIST_DIR, "index.html"); + app.use(express.static(WEB_DIST_DIR)); + app.use((req, res, next) => { + if (req.method !== "GET" && req.method !== "HEAD") return next(); + if (req.path.startsWith("/api") || req.path.startsWith("/internal")) { + return next(); + } + res.sendFile(indexHtml); + }); +} diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 1cf768b..5bf2b43 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -1,24 +1,10 @@ #!/usr/bin/env bash -# Boots the combined image: render the nginx config for the externally exposed -# ${PORT}, start the Node server on a fixed loopback port, then run nginx in the -# foreground. If either process exits, tear the whole container down so an -# orchestrator (Cloud Run, etc.) restarts it instead of leaving a half-up image. +# Combined image entrypoint. The Node server serves the built UI, the REST API, +# and the Socket.IO transport from one port ($PORT) — no reverse proxy. exec so +# node is PID 1 and receives the orchestrator's signals (SIGTERM/SIGINT) directly. set -euo pipefail -# nginx owns the externally exposed port (Cloud Run injects PORT; default 8000). +# Cloud Run injects PORT; default to 8000. The server reads it via config.ts. export PORT="${PORT:-8000}" -envsubst '${PORT}' < /app/docker/nginx.conf.template > /etc/nginx/conf.d/default.conf -# Internal Node server on a fixed loopback port, kept distinct from nginx's PORT -# so config.ts's INTERNAL_URL (http://127.0.0.1:${PORT}) resolves to the server -# itself rather than to nginx. -PORT=8787 node /app/dist/index.js & -node_pid=$! - -nginx -g 'daemon off;' & -nginx_pid=$! - -# Whichever process exits first, stop the other and fail so the container exits. -wait -n -kill "$node_pid" "$nginx_pid" 2>/dev/null || true -exit 1 +exec node /app/dist/index.js diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template deleted file mode 100644 index 43bc89f..0000000 --- a/docker/nginx.conf.template +++ /dev/null @@ -1,48 +0,0 @@ -# Single-port reverse proxy fronting the combined UI + server image. -# -# Mirrors the Vite dev proxy (vite.config.ts): nginx serves the built static UI -# and forwards /api and /socket.io (with WebSocket upgrade) to the internal Node -# server on loopback:8787. ${PORT} is rendered by docker-entrypoint.sh via -# `envsubst '${PORT}'`, which leaves nginx's own $-vars ($host/$uri/...) intact. -server { - listen ${PORT}; - server_name _; - - root /app/ui-dist; - index index.html; - - # Allow uploads up to 50MB (plus multipart overhead). The upstream Node - # server enforces the authoritative per-file limit via multer. - client_max_body_size 55m; - - # REST + internal-egress API. No path rewrite: the full /api/... path is - # passed through, matching Vite's prefix proxy key "/api". - location /api { - proxy_pass http://127.0.0.1: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; - } - - # Socket.IO transport. The Upgrade/Connection headers reproduce Vite's - # `ws: true`, and the long read timeout keeps idle WebSockets alive. - location /socket.io { - proxy_pass http://127.0.0.1:8787; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - 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_read_timeout 86400; - } - - # SPA history fallback: serve the requested asset if it exists, otherwise - # hand back index.html so client-side routing can take over. - location / { - try_files $uri $uri/ /index.html; - } -}