feat(ecosystem): update PM2 configuration for control plane and web server
Deploy to Raspberry Pi / deploy (push) Successful in 52s
Deploy to Raspberry Pi / deploy (push) Successful in 52s
- Renamed the control process to `jflow-control` and updated the script path to `control.js`. - Introduced a new `jflow-web` process for the production UI, serving on port 8500. - Enhanced environment variable handling for both processes, ensuring proper port assignments. - Updated README to reflect new process architecture and usage instructions for starting the application.
This commit is contained in:
@@ -105,8 +105,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
|||||||
| `pnpm dev:server` | Monolith API/runner only |
|
| `pnpm dev:server` | Monolith API/runner only |
|
||||||
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
|
||||||
| `pnpm build` | Production UI build |
|
| `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: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:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
|
||||||
| `pnpm start:worker` | BullMQ worker only |
|
| `pnpm start:worker` | BullMQ worker only |
|
||||||
| `pnpm migrate` | Apply SQLite migrations |
|
| `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_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_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
|
||||||
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. |
|
| `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_LOG_LEVEL` | `debug` | Pino level |
|
||||||
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
|
||||||
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev |
|
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) |
|
||||||
| `PORT` | `8700` | HTTP API port |
|
| `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) |
|
||||||
| `NODE_ENV` | — | Set `production` for secure cookies (unless overridden) |
|
| `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`) |
|
| `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
|
## 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
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build
|
pnpm build
|
||||||
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
|
||||||
# Recommended: run control (migrates + manages PM2 HTTP/workers)
|
# Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …)
|
||||||
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control
|
pm2 start ecosystem.config.cjs
|
||||||
# Or monolith (dev-style):
|
# UI: http://localhost:8500
|
||||||
# ... pnpm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
+28
-7
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Production PM2 ecosystem (monolith runner).
|
* Production PM2 ecosystem (control plane + UI).
|
||||||
* Use for deployed/single-process starts. For local multi-process Ops UI, use
|
* Starts always-on processes only; HTTP (:8700) and workers are owned by
|
||||||
* `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead.
|
* control.js via PM2 (same as `pnpm dev:pm2`).
|
||||||
|
*
|
||||||
|
* Prerequisites: `pnpm build` (packages/web/dist), Redis, .env secrets.
|
||||||
*/
|
*/
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
@@ -28,20 +30,39 @@ function loadEnv(file) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = __dirname;
|
const root = __dirname;
|
||||||
|
const env = {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
...loadEnv(path.join(root, ".env")),
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: "jerapah-flow",
|
name: "jflow-control",
|
||||||
cwd: root,
|
cwd: root,
|
||||||
script: "packages/server/runner.js",
|
script: "packages/server/control.js",
|
||||||
interpreter: "node",
|
interpreter: "node",
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
max_restarts: 20,
|
max_restarts: 20,
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
...env,
|
||||||
...loadEnv(path.join(root, ".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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
"start:api": "pnpm --filter @jerapah-flow/server start:api",
|
||||||
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
"start:worker": "pnpm --filter @jerapah-flow/server start:worker",
|
||||||
"start:control": "pnpm --filter @jerapah-flow/server start:control",
|
"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",
|
"migrate": "pnpm --filter @jerapah-flow/server migrate",
|
||||||
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
|
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
|
||||||
"lint": "pnpm --filter @jerapah-flow/web lint"
|
"lint": "pnpm --filter @jerapah-flow/web lint"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"start:api": "node server.js",
|
"start:api": "node server.js",
|
||||||
"start:worker": "node worker.js",
|
"start:worker": "node worker.js",
|
||||||
"start:control": "node control.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)))\"",
|
"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: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",
|
"test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user