diff --git a/README.md b/README.md index cc7e9ac..b3f2b6b 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` / | `pnpm dev:server` | Monolith API/runner only | | `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) | | `pnpm build` | Production UI build | -| `pnpm start` | Monolith: API + worker + built UI | +| `pnpm start` | Monolith: API + worker + built UI (serves `dist` on :8700) | | `pnpm start:control` | Control plane only (migrates, manages PM2 children) | +| `pnpm start:web` | Production UI on :8500 (`dist` + proxies to control/HTTP) | | `pnpm start:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) | | `pnpm start:worker` | BullMQ worker only | | `pnpm migrate` | Apply SQLite migrations | @@ -140,10 +141,12 @@ Desired state is stored in `packages/server/data/control-state.json` (generation | `JFLOW_ROLE` | `all` | `all` (HTTP + cron + worker), `api`, or `worker`. Prefer `pnpm start:api` / `start:worker` under control. | | `JFLOW_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. | | `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. | +| `JFLOW_UI_PORT` | `8500` | Production UI server (`web-server.js`) port. | +| `JFLOW_HTTP_PORT` | `8700` | HTTP API port (PM2 children / UI proxy target). | | `JFLOW_LOG_LEVEL` | `debug` | Pino level | | `JFLOW_RETENTION_DAYS` | `30` | Run history prune | -| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev | -| `PORT` | `8700` | HTTP API port | +| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) | +| `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) | | `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) | | `COOKIE_SECURE` | (from `NODE_ENV`) | `true`/`false` — force Secure cookie flag. Use `false` for plain HTTP LAN access (`http://192.168.x.x`) | @@ -151,14 +154,29 @@ Workflow runs are **queued** via BullMQ. HTTP and manual triggers return `202 { ## Production +Control-plane topology (same ports as `pnpm dev:pm2`): + +| Process | Port | Role | +|---|---|---| +| `jflow-web` | **8500** | Built UI + proxies `/api` → :8700, `/ops` + `/api/auth` → :8600 | +| `jflow-control` | **8600** | Migrations, Ops API, starts/stops PM2 HTTP + workers | +| `jflow-http` | **8700** | API + cron enqueue (managed by control) | +| `jflow-worker` | — | BullMQ workers (managed by control) | + ```bash pnpm install pnpm build # Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH) -# Recommended: run control (migrates + manages PM2 HTTP/workers) -JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control -# Or monolith (dev-style): -# ... pnpm start +# Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …) +pm2 start ecosystem.config.cjs +# UI: http://localhost:8500 ``` -With control, serve the built UI from Vite preview, a reverse proxy, or set `JFLOW_SERVE_UI=1` on the HTTP process. +Or without the ecosystem file: + +```bash +NODE_ENV=production pnpm start:control # :8600 + PM2 children +NODE_ENV=production pnpm start:web # :8500 +``` + +Monolith (no Ops stop/scale): `pnpm build && pnpm start` serves the UI from the API process on :8700. Optional `JFLOW_SERVE_UI=1` on `start:api` does the same when you run HTTP alone — do **not** use that under control-plane mode (stopping HTTP would take down the UI). diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index ece8164..6f4d2ef 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,7 +1,9 @@ /** - * Production PM2 ecosystem (monolith runner). - * Use for deployed/single-process starts. For local multi-process Ops UI, use - * `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead. + * Production PM2 ecosystem (control plane + UI). + * Starts always-on processes only; HTTP (:8700) and workers are owned by + * control.js via PM2 (same as `pnpm dev:pm2`). + * + * Prerequisites: `pnpm build` (packages/web/dist), Redis, .env secrets. */ const fs = require("fs"); const path = require("path"); @@ -28,21 +30,40 @@ function loadEnv(file) { } const root = __dirname; +const env = { + NODE_ENV: "production", + ...loadEnv(path.join(root, ".env")), +}; module.exports = { apps: [ { - name: "jerapah-flow", + name: "jflow-control", cwd: root, - script: "packages/server/runner.js", + script: "packages/server/control.js", interpreter: "node", instances: 1, autorestart: true, max_restarts: 20, env: { - NODE_ENV: "production", - ...loadEnv(path.join(root, ".env")), + ...env, + JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600", + }, + }, + { + name: "jflow-web", + cwd: root, + script: "packages/server/web-server.js", + interpreter: "node", + instances: 1, + autorestart: true, + max_restarts: 20, + env: { + ...env, + JFLOW_UI_PORT: env.JFLOW_UI_PORT ?? "8500", + JFLOW_CONTROL_PORT: env.JFLOW_CONTROL_PORT ?? "8600", + JFLOW_HTTP_PORT: env.JFLOW_HTTP_PORT ?? "8700", }, }, ], -}; \ No newline at end of file +}; diff --git a/package.json b/package.json index 27ab9b3..f41eead 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "start:api": "pnpm --filter @jerapah-flow/server start:api", "start:worker": "pnpm --filter @jerapah-flow/server start:worker", "start:control": "pnpm --filter @jerapah-flow/server start:control", + "start:web": "pnpm --filter @jerapah-flow/server start:web", "migrate": "pnpm --filter @jerapah-flow/server migrate", "test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test", "lint": "pnpm --filter @jerapah-flow/web lint" diff --git a/packages/server/ops-proxy.js b/packages/server/ops-proxy.js new file mode 100644 index 0000000..afee931 --- /dev/null +++ b/packages/server/ops-proxy.js @@ -0,0 +1,97 @@ +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", +]); + +const REPLY_SKIP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "content-encoding", + "content-length", +]); + +/** + * Control origin used when the UI or HTTP process proxies to control. + * @returns {string} + */ +export function controlOrigin() { + const explicit = process.env.JFLOW_CONTROL_URL?.trim(); + if (explicit) return explicit.replace(/\/$/, ""); + const port = Number(process.env.JFLOW_CONTROL_PORT ?? 8600); + return `http://127.0.0.1:${port}`; +} + +/** + * HTTP API origin (workflow triggers + REST). + * @returns {string} + */ +export function httpOrigin() { + const explicit = process.env.JFLOW_HTTP_URL?.trim(); + if (explicit) return explicit.replace(/\/$/, ""); + const port = Number(process.env.JFLOW_HTTP_PORT ?? process.env.PORT ?? 8700); + return `http://127.0.0.1:${port}`; +} + +/** + * Forward the incoming request to `origin`, preserving path + query. + * @param {import("fastify").FastifyRequest} req + * @param {import("fastify").FastifyReply} reply + * @param {string} origin + * @param {{ unreachableMessage?: string }} [opts] + */ +export async function proxyToOrigin(req, reply, origin, opts = {}) { + const target = `${origin.replace(/\/$/, "")}${req.raw.url ?? "/"}`; + const headers = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value == null || HOP_BY_HOP.has(key.toLowerCase())) continue; + headers[key] = Array.isArray(value) ? value.join(", ") : String(value); + } + + const method = req.method.toUpperCase(); + const hasBody = method !== "GET" && method !== "HEAD"; + let body; + if (hasBody) { + if (Buffer.isBuffer(req.body)) body = req.body; + else if (typeof req.body === "string") body = req.body; + else if (req.body != null) { + body = JSON.stringify(req.body); + if (!headers["content-type"]) headers["content-type"] = "application/json"; + } + } + + let res; + try { + res = await fetch(target, { method, headers, body }); + } catch (err) { + const message = opts.unreachableMessage ?? "upstream unreachable"; + req.log.warn({ err, target }, `proxy: ${message}`); + return reply.code(502).send({ error: message }); + } + + reply.code(res.status); + res.headers.forEach((value, key) => { + if (REPLY_SKIP.has(key.toLowerCase())) return; + reply.header(key, value); + }); + return reply.send(Buffer.from(await res.arrayBuffer())); +} + +/** + * Forward `/ops/*` to the control plane (same-origin UI in production). + * @param {import("fastify").FastifyRequest} req + * @param {import("fastify").FastifyReply} reply + */ +export async function proxyOpsToControl(req, reply) { + return proxyToOrigin(req, reply, controlOrigin(), { + unreachableMessage: "control plane unreachable", + }); +} diff --git a/packages/server/package.json b/packages/server/package.json index ee83215..5f3c4ab 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -11,6 +11,7 @@ "start:api": "node server.js", "start:worker": "node worker.js", "start:control": "node control.js", + "start:web": "node web-server.js", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", "test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js", diff --git a/packages/server/web-server.js b/packages/server/web-server.js new file mode 100644 index 0000000..9110efd --- /dev/null +++ b/packages/server/web-server.js @@ -0,0 +1,116 @@ +/** + * Production UI server (:8500). + * Serves packages/web/dist and proxies /api, /ops, /admin, /u like Vite in dev. + * Always-on — survives Ops stop of jflow-http. + */ +import fs from "fs"; +import fastify from "fastify"; +import fastifyStatic from "@fastify/static"; +import { log } from "./logger.js"; +import { WEB_DIST } from "./paths.js"; +import { + controlOrigin, + httpOrigin, + proxyToOrigin, +} from "./ops-proxy.js"; + +if (!fs.existsSync(WEB_DIST)) { + log.error( + { WEB_DIST }, + "web dist missing — run `pnpm build` before starting the UI server", + ); + process.exit(1); +} + +const port = Number(process.env.JFLOW_UI_PORT ?? 8500); +const control = controlOrigin(); +const http = httpOrigin(); + +const server = fastify({ loggerInstance: log }); + +/** + * @param {import("fastify").FastifyRequest} req + * @param {import("fastify").FastifyReply} reply + */ +async function proxyApi(req, reply) { + const url = req.raw.url ?? ""; + // Match Vite: /api/auth → control (login works when HTTP is stopped). + if (url === "/api/auth" || url.startsWith("/api/auth/") || url.startsWith("/api/auth?")) { + return proxyToOrigin(req, reply, control, { + unreachableMessage: "control plane unreachable", + }); + } + return proxyToOrigin(req, reply, http, { + unreachableMessage: "HTTP API unreachable", + }); +} + +/** + * @param {import("fastify").FastifyRequest} req + * @param {import("fastify").FastifyReply} reply + */ +async function proxyOps(req, reply) { + return proxyToOrigin(req, reply, control, { + unreachableMessage: "control plane unreachable", + }); +} + +/** + * @param {import("fastify").FastifyRequest} req + * @param {import("fastify").FastifyReply} reply + */ +async function proxyHttp(req, reply) { + return proxyToOrigin(req, reply, http, { + unreachableMessage: "HTTP API unreachable", + }); +} + +server.all("/api", proxyApi); +server.all("/api/*", proxyApi); +server.all("/ops", proxyOps); +server.all("/ops/*", proxyOps); +server.all("/admin", proxyHttp); +server.all("/admin/*", proxyHttp); +server.all("/u", proxyHttp); +server.all("/u/*", proxyHttp); + +await server.register(fastifyStatic, { + root: WEB_DIST, + wildcard: false, +}); + +server.setNotFoundHandler((req, reply) => { + const url = req.raw.url ?? ""; + if ( + url.startsWith("/api") || + url.startsWith("/u/") || + url.startsWith("/admin") || + url.startsWith("/ops") + ) { + return reply.code(404).send({ error: "not found" }); + } + return reply.sendFile("index.html"); +}); + +async function shutdown() { + try { + await server.close(); + } catch (err) { + log.error({ err }, "web-server shutdown error"); + } + process.exit(0); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +try { + await server.listen({ host: "0.0.0.0", port }); + log.info( + { port, WEB_DIST, control, http }, + "UI server listening (static + proxy)", + ); +} catch (err) { + log.error({ err }, "failed to start UI server"); + process.exit(1); +}