mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
feat: add automation backend integration with standalone ingress proxy (#127)
* feat: add automation backend integration with standalone ingress proxy - Add scripts/ingress.mjs: standalone HTTP reverse proxy for routing traffic to multiple backends based on URL path prefix - Add scripts/dev-with-automation.mjs: orchestrates full stack with agent-server, automation backend (both via uvx), Vite, and ingress - Make 'npm run dev' run full stack by default (was dev:safe, now dev:automation) - Rename 'npm run dev:safe' to 'npm run dev:minimal' for agent-server + Vite only - Update README with new quickstart showing full stack as default - Update AGENTS.md with architecture documentation Architecture: http://localhost:8000 (Ingress) ├── /api/automation/* → Automation Backend (:18001) ├── /api/*, /sockets → Agent Server (:18000) └── /* (default) → Vite Dev Server (:3001) * test: add tests for ingress and dev-with-automation scripts - Add __tests__/scripts/ingress.test.ts with 14 tests covering: - CLI argument parsing (--help, --port, --route, --default) - Route matching (exact, prefix, longest-match-first) - Proxy functionality (forwarding, query params, error handling) - 502 response when backend unavailable - 503 response for unmatched routes with no default - Add __tests__/scripts/dev-with-automation.test.ts with 19 tests covering: - buildAutomationCommand() with various git refs/repos - buildConfig() port and path configuration - CLI --help output - Graceful exit when uvx is missing - Export testable functions from dev-with-automation.mjs * fix: prevent dev-with-automation from auto-executing when imported The script was calling main() unconditionally, which caused test failures when vitest imported the module. Now check if the module is the main entry point before executing. --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
hieptl
parent
ef03d8c144
commit
988cfed5c6
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Development Stack with Automation Service
|
||||
*
|
||||
* Extends agent-canvas's dev-safe.mjs to additionally run the OpenHands Automation
|
||||
* backend via uvx. No cloning required - runs directly from git reference.
|
||||
*
|
||||
* Uses a standalone ingress proxy to route traffic to multiple backends.
|
||||
*
|
||||
* Architecture:
|
||||
* ┌──────────────────────────────────────────────────────────────────────────┐
|
||||
* │ http://localhost:8000 (Ingress Proxy) │
|
||||
* │ /api/automation/* → Automation Backend │
|
||||
* │ /api/*, /sockets → Agent Server │
|
||||
* │ /* → Vite Dev Server │
|
||||
* └──────────────────────────────────────────────────────────────────────────┘
|
||||
* │ │ │
|
||||
* ▼ ▼ ▼
|
||||
* ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐
|
||||
* │ Vite │ │ Agent Server │ │ Automation │
|
||||
* │ :3001 │ │ (uvx) :18000 │ │ Backend (uvx) │
|
||||
* │ │ │ │ │ :18001 │
|
||||
* └─────────────┘ └───────────────┘ └──────────────────┘
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/dev-with-automation.mjs
|
||||
* node scripts/dev-with-automation.mjs --automation-ref feat/my-branch
|
||||
* node scripts/dev-with-automation.mjs --port 12000
|
||||
*
|
||||
* Environment variables:
|
||||
* - PORT: Ingress port (default: 8000)
|
||||
* - OH_AUTOMATION_GIT_REF: Git ref for automation (default: main)
|
||||
* - OH_AGENT_SERVER_GIT_REF: Git ref for agent-server
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { homedir } from "node:os";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
buildAgentServerCommand,
|
||||
buildSafeDevConfig,
|
||||
buildAgentServerEnv,
|
||||
buildNpmScriptCommand,
|
||||
formatMissingUvxGuidance,
|
||||
} from "./dev-safe.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, "..");
|
||||
|
||||
const DEFAULT_AUTOMATION_REPO = "https://github.com/OpenHands/automation";
|
||||
const DEFAULT_AUTOMATION_GIT_REF = "main";
|
||||
const DEFAULT_BACKEND_PORT = 18000;
|
||||
const DEFAULT_AUTOMATION_PORT = 18001;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Terminal Styling
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const c = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
};
|
||||
|
||||
function logService(name, message, color = c.reset) {
|
||||
const ts = new Date().toISOString().split("T")[1].split(".")[0];
|
||||
console.log(`${c.dim}${ts}${c.reset} ${color}[${name}]${c.reset} ${message}`);
|
||||
}
|
||||
|
||||
function logStep(step, message) {
|
||||
console.log(`${c.cyan}[${step}]${c.reset} ${message}`);
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
console.log(`${c.green}✓${c.reset} ${message}`);
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
console.error(`${c.red}✗${c.reset} ${message}`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Configuration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const config = {
|
||||
port: null,
|
||||
automationGitRef: null,
|
||||
automationRepo: null,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case "-p":
|
||||
case "--port":
|
||||
config.port = parseInt(args[++i], 10);
|
||||
break;
|
||||
case "--automation-ref":
|
||||
config.automationGitRef = args[++i];
|
||||
break;
|
||||
case "--automation-repo":
|
||||
config.automationRepo = args[++i];
|
||||
break;
|
||||
case "-v":
|
||||
case "--verbose":
|
||||
config.verbose = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Agent Canvas + Automation Development Stack
|
||||
|
||||
Runs agent-canvas with the automation backend (via uvx, no clone needed).
|
||||
Uses a standalone ingress proxy to route traffic.
|
||||
|
||||
USAGE:
|
||||
node scripts/dev-with-automation.mjs [options]
|
||||
|
||||
OPTIONS:
|
||||
-p, --port <port> Ingress port (default: 8000)
|
||||
--automation-ref <ref> Git ref for automation (branch/tag/SHA, default: main)
|
||||
--automation-repo <url> Git repo URL (default: ${DEFAULT_AUTOMATION_REPO})
|
||||
-v, --verbose Show detailed output
|
||||
-h, --help Show this help
|
||||
|
||||
ENVIRONMENT VARIABLES:
|
||||
PORT Alternative to --port
|
||||
OH_AUTOMATION_GIT_REF Alternative to --automation-ref
|
||||
OH_AGENT_SERVER_GIT_REF Git ref for agent-server SDK
|
||||
OH_SECRET_KEY Secret key for sessions
|
||||
|
||||
ACCESS POINTS:
|
||||
Main UI: http://localhost:PORT/
|
||||
API Docs: http://localhost:PORT/api/automation/docs
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the uvx command for running automation backend.
|
||||
*/
|
||||
function buildAutomationCommand(env = process.env) {
|
||||
const gitRef = env.OH_AUTOMATION_GIT_REF || DEFAULT_AUTOMATION_GIT_REF;
|
||||
const repoUrl = env.OH_AUTOMATION_REPO || DEFAULT_AUTOMATION_REPO;
|
||||
|
||||
// Build git URL with ref
|
||||
const gitUrl = `git+${repoUrl}@${gitRef}`;
|
||||
|
||||
return {
|
||||
command: "uvx",
|
||||
args: [
|
||||
"--from", gitUrl,
|
||||
"uvicorn", "automation.app:app",
|
||||
],
|
||||
source: `git (${gitRef})`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfig(args, env = process.env) {
|
||||
// Apply args to env for buildAutomationCommand
|
||||
if (args.automationGitRef) {
|
||||
env.OH_AUTOMATION_GIT_REF = args.automationGitRef;
|
||||
}
|
||||
if (args.automationRepo) {
|
||||
env.OH_AUTOMATION_REPO = args.automationRepo;
|
||||
}
|
||||
|
||||
const ingressPort = args.port || parseInt(env.PORT, 10) || 8000;
|
||||
const backendPort = DEFAULT_BACKEND_PORT;
|
||||
const automationPort = DEFAULT_AUTOMATION_PORT;
|
||||
const vitePort = 3001;
|
||||
const vscodePort = backendPort + 1000;
|
||||
|
||||
return {
|
||||
// Ingress port (main entry point)
|
||||
ingressPort,
|
||||
|
||||
// Service ports (internal)
|
||||
agentServerPort: backendPort,
|
||||
autoBackendPort: automationPort,
|
||||
vitePort,
|
||||
vscodePort,
|
||||
|
||||
// Paths
|
||||
canvasPath: projectRoot,
|
||||
|
||||
// Data directories (same as dev-safe.mjs)
|
||||
stateDir: join(homedir(), ".openhands", "agent-canvas"),
|
||||
|
||||
verbose: args.verbose,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Prerequisites & Setup
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function commandExists(cmd) {
|
||||
try {
|
||||
execSync(`command -v ${cmd}`, { stdio: "pipe" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkPrerequisites() {
|
||||
logStep("1/2", "Checking prerequisites...");
|
||||
|
||||
if (!commandExists("uvx")) {
|
||||
console.error(formatMissingUvxGuidance(projectRoot));
|
||||
process.exit(1);
|
||||
}
|
||||
logSuccess("uvx found");
|
||||
|
||||
if (!commandExists("npm")) {
|
||||
logError("npm is required but not found");
|
||||
process.exit(1);
|
||||
}
|
||||
logSuccess("npm found");
|
||||
}
|
||||
|
||||
function ensureDirectories(config) {
|
||||
const dirs = [
|
||||
config.stateDir,
|
||||
join(config.stateDir, "tmux"),
|
||||
join(config.stateDir, "conversations"),
|
||||
join(config.stateDir, "workspaces"),
|
||||
join(config.stateDir, "bash_events"),
|
||||
join(config.stateDir, "storage"),
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Process Management
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const processes = new Map();
|
||||
|
||||
function spawnService(name, command, args, options = {}) {
|
||||
const proc = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env, ...options.env },
|
||||
cwd: options.cwd,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
const color = options.color || c.reset;
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
logService(name, line.trim(), color);
|
||||
});
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
logService(name, line.trim(), c.yellow);
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
logError(`${name} failed to start: ${error.message}`);
|
||||
});
|
||||
|
||||
proc.on("exit", (code, signal) => {
|
||||
if (code !== 0 && code !== null && !shuttingDown) {
|
||||
logService(name, `Exited with code ${code}`, c.red);
|
||||
}
|
||||
processes.delete(name);
|
||||
});
|
||||
|
||||
processes.set(name, proc);
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function waitForService(name, url, timeoutMs = 30000) {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
logService(name, `Ready at ${url}`, c.green);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Keep trying
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
logService(name, `Timeout waiting for ${url}`, c.red);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Service Starters
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function startAgentServer(config) {
|
||||
logService("agent-server", `Starting on port ${config.agentServerPort}...`, c.blue);
|
||||
|
||||
const agentServerCmd = buildAgentServerCommand(process.env);
|
||||
logService("agent-server", `Using ${agentServerCmd.source}`, c.dim);
|
||||
|
||||
// Build safe config for agent-server env vars
|
||||
const safeConfig = buildSafeDevConfig(config.canvasPath, {
|
||||
...process.env,
|
||||
OH_CANVAS_SAFE_STATE_DIR: config.stateDir,
|
||||
OH_CANVAS_SAFE_BACKEND_PORT: config.agentServerPort.toString(),
|
||||
OH_CANVAS_SAFE_VSCODE_PORT: config.vscodePort.toString(),
|
||||
});
|
||||
|
||||
const agentServerEnv = buildAgentServerEnv(safeConfig);
|
||||
|
||||
spawnService(
|
||||
"agent-server",
|
||||
agentServerCmd.command,
|
||||
[...agentServerCmd.args, "--host", "127.0.0.1", "--port", String(config.agentServerPort)],
|
||||
{
|
||||
cwd: safeConfig.workspacesPath,
|
||||
env: agentServerEnv,
|
||||
color: c.blue,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function startAutomationBackend(config) {
|
||||
logService("automation", `Starting on port ${config.autoBackendPort}...`, c.green);
|
||||
|
||||
const automationCmd = buildAutomationCommand(process.env);
|
||||
logService("automation", `Using ${automationCmd.source}`, c.dim);
|
||||
|
||||
spawnService(
|
||||
"automation",
|
||||
automationCmd.command,
|
||||
[
|
||||
...automationCmd.args,
|
||||
"--host", "127.0.0.1",
|
||||
"--port", config.autoBackendPort.toString(),
|
||||
],
|
||||
{
|
||||
cwd: config.stateDir,
|
||||
env: {
|
||||
AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`,
|
||||
AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`,
|
||||
AUTOMATION_BASE_URL: `http://localhost:3001`,
|
||||
AUTOMATION_WORKSPACE_BASE: join(config.stateDir, "workspaces"),
|
||||
AUTOMATION_AUTH_DISABLED: "true",
|
||||
FILE_STORE: "local",
|
||||
LOCAL_STORAGE_PATH: join(config.stateDir, "storage"),
|
||||
OPENHANDS_SUPPRESS_BANNER: "1",
|
||||
},
|
||||
color: c.green,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Main
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
function shutdown() {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
console.log("");
|
||||
console.log(`${c.yellow}Shutting down...${c.reset}`);
|
||||
|
||||
for (const [name, proc] of processes) {
|
||||
logService(name, "Stopping...", c.dim);
|
||||
proc.kill("SIGTERM");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
for (const [, proc] of processes) {
|
||||
if (!proc.killed) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
function startIngress(config) {
|
||||
logService("ingress", `Starting on port ${config.ingressPort}...`, c.yellow);
|
||||
|
||||
const ingressScript = join(projectRoot, "scripts", "ingress.mjs");
|
||||
|
||||
spawnService(
|
||||
"ingress",
|
||||
"node",
|
||||
[
|
||||
ingressScript,
|
||||
"--port", config.ingressPort.toString(),
|
||||
"--route", `/api/automation=http://localhost:${config.autoBackendPort}`,
|
||||
"--route", `/api=http://localhost:${config.agentServerPort}`,
|
||||
"--route", `/sockets=http://localhost:${config.agentServerPort}`,
|
||||
"--route", `/server_info=http://localhost:${config.agentServerPort}`,
|
||||
"--route", `/health=http://localhost:${config.agentServerPort}`,
|
||||
"--route", `/ready=http://localhost:${config.agentServerPort}`,
|
||||
"--route", `/alive=http://localhost:${config.agentServerPort}`,
|
||||
"--default", `http://localhost:${config.vitePort}`,
|
||||
],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
color: c.yellow,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function startVite(config) {
|
||||
logService("vite", `Starting on port ${config.vitePort}...`, c.magenta);
|
||||
|
||||
const frontendCommand = buildNpmScriptCommand("dev:frontend");
|
||||
|
||||
spawnService("vite", frontendCommand.command, frontendCommand.args, {
|
||||
cwd: config.canvasPath,
|
||||
env: {
|
||||
// Point Vite at the ingress (so client-side fetches work)
|
||||
VITE_BACKEND_HOST: `127.0.0.1:${config.ingressPort}`,
|
||||
VITE_BACKEND_BASE_URL: `http://127.0.0.1:${config.ingressPort}`,
|
||||
VITE_WORKING_DIR: join(config.stateDir, "workspaces"),
|
||||
VITE_FRONTEND_PORT: config.vitePort.toString(),
|
||||
},
|
||||
color: c.magenta,
|
||||
});
|
||||
}
|
||||
|
||||
function printBanner(config) {
|
||||
console.log("");
|
||||
console.log(
|
||||
`${c.green}${c.bold}╔══════════════════════════════════════════════════════════════╗${c.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${c.green}${c.bold}║${c.reset} ${c.bold}Agent Canvas + Automation Stack${c.reset} ${c.green}${c.bold}║${c.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${c.green}${c.bold}╠══════════════════════════════════════════════════════════════╣${c.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${c.green}${c.bold}║${c.reset} Main UI: ${c.cyan}http://localhost:${config.ingressPort}/${c.reset}`.padEnd(75) + `${c.green}${c.bold}║${c.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${c.green}${c.bold}║${c.reset} API Docs: ${c.cyan}http://localhost:${config.ingressPort}/api/automation/docs${c.reset}`.padEnd(75) + `${c.green}${c.bold}║${c.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${c.green}${c.bold}║${c.reset} ${c.green}${c.bold}║${c.reset}`
|
||||
);
|
||||
console.log(
|
||||
`${c.green}${c.bold}╚══════════════════════════════════════════════════════════════╝${c.reset}`
|
||||
);
|
||||
console.log("");
|
||||
console.log(`${c.dim}State directory: ${config.stateDir}${c.reset}`);
|
||||
console.log(`${c.dim}Press Ctrl+C to stop${c.reset}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs();
|
||||
const config = buildConfig(args);
|
||||
|
||||
console.log("");
|
||||
console.log(`${c.cyan}${c.bold}Agent Canvas + Automation Development Stack${c.reset}`);
|
||||
console.log("");
|
||||
|
||||
// Setup phase
|
||||
checkPrerequisites();
|
||||
ensureDirectories(config);
|
||||
|
||||
// Start services phase
|
||||
logStep("2/2", "Starting services...");
|
||||
|
||||
// 1. Start agent-server first (other services depend on it)
|
||||
startAgentServer(config);
|
||||
await waitForService(
|
||||
"agent-server",
|
||||
`http://localhost:${config.agentServerPort}/server_info`
|
||||
);
|
||||
|
||||
// 2. Start automation backend
|
||||
startAutomationBackend(config);
|
||||
|
||||
// 3. Start Vite dev server (no proxy config needed - ingress handles routing)
|
||||
startVite(config);
|
||||
|
||||
// 4. Wait for services to be ready
|
||||
await delay(2000);
|
||||
|
||||
// 5. Start ingress proxy (routes traffic to all backends)
|
||||
startIngress(config);
|
||||
|
||||
// Wait for ingress to start
|
||||
await delay(1000);
|
||||
|
||||
printBanner(config);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Exports for testing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export {
|
||||
buildAutomationCommand,
|
||||
buildConfig,
|
||||
DEFAULT_AUTOMATION_REPO,
|
||||
DEFAULT_AUTOMATION_GIT_REF,
|
||||
DEFAULT_BACKEND_PORT,
|
||||
DEFAULT_AUTOMATION_PORT,
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Main entry point (only when run directly, not when imported)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Check if this module is the main entry point
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
|
||||
if (isMainModule) {
|
||||
main().catch((err) => {
|
||||
logError(`Fatal error: ${err.message}`);
|
||||
if (err.stack) {
|
||||
console.error(c.dim + err.stack + c.reset);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Standalone Ingress / Reverse Proxy
|
||||
*
|
||||
* A minimal HTTP reverse proxy that routes requests to multiple backends
|
||||
* based on URL path. Completely independent of any backend implementation.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/ingress.mjs [options]
|
||||
* node scripts/ingress.mjs --port 8000 --route "/api/automation=http://localhost:18001" --route "/api=http://localhost:18000" --default "http://localhost:3001"
|
||||
*
|
||||
* Environment variables:
|
||||
* INGRESS_PORT - Port to listen on (default: 8000)
|
||||
* INGRESS_ROUTES - JSON object of path prefix -> backend URL
|
||||
* INGRESS_DEFAULT - Default backend for unmatched routes
|
||||
*
|
||||
* Route matching:
|
||||
* - Routes are matched by longest prefix first
|
||||
* - More specific routes take precedence (e.g., /api/automation before /api)
|
||||
*/
|
||||
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import process from "node:process";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Configuration
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const config = {
|
||||
port: 8000,
|
||||
routes: {},
|
||||
defaultBackend: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case "-p":
|
||||
case "--port":
|
||||
config.port = parseInt(args[++i], 10);
|
||||
break;
|
||||
case "-r":
|
||||
case "--route":
|
||||
// Format: "/path=http://host:port"
|
||||
const [path, url] = args[++i].split("=");
|
||||
config.routes[path] = url;
|
||||
break;
|
||||
case "-d":
|
||||
case "--default":
|
||||
config.defaultBackend = args[++i];
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Standalone Ingress / Reverse Proxy
|
||||
|
||||
Routes HTTP requests to multiple backends based on URL path prefix.
|
||||
|
||||
USAGE:
|
||||
node scripts/ingress.mjs [options]
|
||||
|
||||
OPTIONS:
|
||||
-p, --port <port> Port to listen on (default: 8000)
|
||||
-r, --route <path=url> Add a route (can be repeated)
|
||||
-d, --default <url> Default backend for unmatched routes
|
||||
-h, --help Show this help
|
||||
|
||||
ENVIRONMENT VARIABLES:
|
||||
INGRESS_PORT Port to listen on
|
||||
INGRESS_ROUTES JSON object: {"path": "url", ...}
|
||||
INGRESS_DEFAULT Default backend URL
|
||||
|
||||
EXAMPLES:
|
||||
# Basic setup with agent server and automation
|
||||
node scripts/ingress.mjs \\
|
||||
--port 8000 \\
|
||||
--route "/api/automation=http://localhost:18001" \\
|
||||
--route "/api=http://localhost:18000" \\
|
||||
--route "/sockets=http://localhost:18000" \\
|
||||
--default "http://localhost:3001"
|
||||
|
||||
# Using environment variables
|
||||
INGRESS_PORT=8000 \\
|
||||
INGRESS_ROUTES='{"/ api/automation":"http://localhost:18001","/api":"http://localhost:18000"}' \\
|
||||
INGRESS_DEFAULT="http://localhost:3001" \\
|
||||
node scripts/ingress.mjs
|
||||
|
||||
ROUTE MATCHING:
|
||||
Routes are sorted by path length (longest first), so more specific
|
||||
routes like /api/automation will match before /api.
|
||||
`);
|
||||
}
|
||||
|
||||
function buildConfig(args, env = process.env) {
|
||||
let routes = { ...args.routes };
|
||||
|
||||
// Merge env routes
|
||||
if (env.INGRESS_ROUTES) {
|
||||
try {
|
||||
const envRoutes = JSON.parse(env.INGRESS_ROUTES);
|
||||
routes = { ...routes, ...envRoutes };
|
||||
} catch (e) {
|
||||
console.error("Failed to parse INGRESS_ROUTES:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
port: args.port || parseInt(env.INGRESS_PORT, 10) || 8000,
|
||||
routes,
|
||||
defaultBackend: args.defaultBackend || env.INGRESS_DEFAULT || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Router
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createRouter(routes, defaultBackend) {
|
||||
// Sort routes by path length (longest first) for most-specific matching
|
||||
const sortedRoutes = Object.entries(routes).sort(
|
||||
([a], [b]) => b.length - a.length
|
||||
);
|
||||
|
||||
return function route(url) {
|
||||
for (const [prefix, backend] of sortedRoutes) {
|
||||
if (url === prefix || url.startsWith(prefix + "/") || url.startsWith(prefix + "?")) {
|
||||
return backend;
|
||||
}
|
||||
}
|
||||
return defaultBackend;
|
||||
};
|
||||
}
|
||||
|
||||
function parseBackendUrl(backendUrl) {
|
||||
const url = new URL(backendUrl);
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
port: parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80),
|
||||
protocol: url.protocol,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Proxy
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function proxyRequest(req, res, backendUrl) {
|
||||
const backend = parseBackendUrl(backendUrl);
|
||||
|
||||
const options = {
|
||||
hostname: backend.hostname,
|
||||
port: backend.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `${backend.hostname}:${backend.port}`,
|
||||
},
|
||||
};
|
||||
|
||||
const proxyReq = httpRequest(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
});
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
console.error(`Proxy error for ${req.url}:`, err.message);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502);
|
||||
res.end(`Bad Gateway: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
req.pipe(proxyReq, { end: true });
|
||||
}
|
||||
|
||||
function proxyWebSocket(req, socket, head, backendUrl) {
|
||||
const backend = parseBackendUrl(backendUrl);
|
||||
|
||||
const options = {
|
||||
hostname: backend.hostname,
|
||||
port: backend.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `${backend.hostname}:${backend.port}`,
|
||||
},
|
||||
};
|
||||
|
||||
const proxyReq = httpRequest(options);
|
||||
|
||||
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
||||
socket.write(
|
||||
`HTTP/${proxyRes.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n`
|
||||
);
|
||||
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
|
||||
socket.write(`${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r\n`);
|
||||
}
|
||||
socket.write("\r\n");
|
||||
|
||||
if (proxyHead.length > 0) {
|
||||
socket.write(proxyHead);
|
||||
}
|
||||
|
||||
proxySocket.pipe(socket, { end: true });
|
||||
socket.pipe(proxySocket, { end: true });
|
||||
});
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
console.error(`WebSocket proxy error for ${req.url}:`, err.message);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
proxyReq.end();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Server
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function startIngress(config) {
|
||||
const route = createRouter(config.routes, config.defaultBackend);
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const backend = route(req.url);
|
||||
|
||||
if (!backend) {
|
||||
res.writeHead(503);
|
||||
res.end("No backend configured for this route");
|
||||
return;
|
||||
}
|
||||
|
||||
proxyRequest(req, res, backend);
|
||||
});
|
||||
|
||||
// Handle WebSocket upgrades
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const backend = route(req.url);
|
||||
|
||||
if (!backend) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
proxyWebSocket(req, socket, head, backend);
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log("");
|
||||
console.log("╔═══════════════════════════════════════════════════════════════╗");
|
||||
console.log("║ Ingress Proxy ║");
|
||||
console.log("╠═══════════════════════════════════════════════════════════════╣");
|
||||
console.log(`║ Listening on: http://localhost:${config.port}/`.padEnd(66) + "║");
|
||||
console.log("╠═══════════════════════════════════════════════════════════════╣");
|
||||
console.log("║ Routes: ║");
|
||||
|
||||
const sortedRoutes = Object.entries(config.routes).sort(
|
||||
([a], [b]) => b.length - a.length
|
||||
);
|
||||
for (const [path, backend] of sortedRoutes) {
|
||||
const line = ` ${path} → ${backend}`;
|
||||
console.log(`║ ${line.padEnd(61)}║`);
|
||||
}
|
||||
|
||||
if (config.defaultBackend) {
|
||||
const line = ` * (default) → ${config.defaultBackend}`;
|
||||
console.log(`║ ${line.padEnd(61)}║`);
|
||||
}
|
||||
|
||||
console.log("║ ║");
|
||||
console.log("╚═══════════════════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Main
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const args = parseArgs();
|
||||
const config = buildConfig(args);
|
||||
|
||||
if (Object.keys(config.routes).length === 0 && !config.defaultBackend) {
|
||||
console.error("Error: No routes configured. Use --route or --default options.");
|
||||
console.error("Run with --help for usage information.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
startIngress(config);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\nShutting down...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("\nShutting down...");
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user