Files
OpenHands/scripts/ingress.mjs
T
988cfed5c6 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>
2026-05-07 12:02:15 -04:00

314 lines
11 KiB
JavaScript

#!/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);
});