Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 20 additions & 22 deletions Dockerfile.fullstack
Original file line number Diff line number Diff line change
@@ -1,34 +1,30 @@
# 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 \
ripgrep \
fd-find \
jq \
ca-certificates \
nginx \
gettext-base \
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd \
&& rm -rf /var/lib/apt/lists/*

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>`) which a Pi worker will run in.
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/middleware/staticUi.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
24 changes: 5 additions & 19 deletions docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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
48 changes: 0 additions & 48 deletions docker/nginx.conf.template

This file was deleted.