support for multiple gateways & improve gateway codebase

This commit is contained in:
SrGooglo 2025-04-01 00:36:44 +00:00
parent e6cb90e270
commit a7a4073348
17 changed files with 998 additions and 295 deletions

View File

@ -20,6 +20,8 @@
/**/**/d_data
/**/**/redis_data
/**/**/*.env
/**/**/.nginx
/**/**/nginx-bin
# Locks
/**/**/package-lock.json

View File

@ -20,6 +20,8 @@
/**/**/d_data
/**/**/redis_data
/**/**/*.env
/**/**/.nginx
/**/**/nginx-bin
# Locks
/**/**/package-lock.json

157
packages/server/gateway/index.js Normal file → Executable file
View File

@ -8,24 +8,26 @@ import { onExit } from "signal-exit"
import chalk from "chalk"
import treeKill from "tree-kill"
import getIgnoredFiles from "./utils/getIgnoredFiles"
import scanServices from "./utils/scanServices"
import spawnService from "./utils/spawnService"
import Proxy from "./proxy"
import RELP from "./repl"
import comtyAscii from "./ascii"
import pkg from "../package.json"
import ServiceManager from "./services/manager"
import Service from "./services/service"
import * as Managers from "./managers"
const isProduction = process.env.NODE_ENV === "production"
const sslKey = path.resolve(process.cwd(), ".ssl", "privkey.pem")
const sslCert = path.resolve(process.cwd(), ".ssl", "cert.pem")
/**
* Gateway class - Main entry point for the service orchestrator
* Manages service discovery, spawning, and communication
*/
export default class Gateway {
static gatewayMode = process.env.GATEWAY_MODE ?? "http_proxy"
eventBus = new EventEmitter()
state = {
@ -39,7 +41,8 @@ export default class Gateway {
services = []
serviceRegistry = Observable.from({})
proxy = null
gateway = null
ipcRouter = null
/**
@ -134,12 +137,8 @@ export default class Gateway {
this.onServiceReady(service)
}
if (data.type === "router:register") {
await this.handleRouterRegistration(service, data)
}
if (data.type === "router:ws:register") {
await this.handleWebsocketRegistration(service, data)
if (data.type === "service:register") {
await this.handleServiceRegistration(service, data)
}
}
@ -161,65 +160,49 @@ export default class Gateway {
console.error(error)
}
this.proxy.unregisterAllFromService(id)
}
/**
* Handle router registration requests from services
* @param {Service} service - Service registering a route
* @param {object} msg - Registration message
*/
async handleRouterRegistration(service, msg) {
const id = service.id
if (msg.data.path_overrides) {
for await (const pathOverride of msg.data.path_overrides) {
await this.proxy.register({
serviceId: id,
path: `/${pathOverride}`,
target: `http://${this.state.internalIp}:${msg.data.listen.port}/${pathOverride}`,
pathRewrite: {
[`^/${pathOverride}`]: "",
},
})
}
} else {
await this.proxy.register({
serviceId: id,
path: `/${id}`,
target: `http://${msg.data.listen.ip}:${msg.data.listen.port}`,
})
if (typeof this.gateway.unregisterAllFromService === "function") {
this.gateway.unregisterAllFromService(id)
}
}
/**
* Handle websocket registration requests from services
* @param {Service} service - Service registering a websocket
* Handle both router and websocket registration requests from services
* @param {Service} service - Service registering a route or websocket
* @param {object} msg - Registration message
* @param {boolean} isWebsocket - Whether this is a websocket registration
*/
async handleWebsocketRegistration(service, msg) {
const id = service.id
const listenPort = msg.data.listen_port ?? msg.data.listen?.port
let target = `http://${this.state.internalIp}:${listenPort}`
async handleServiceRegistration(service, data) {
const { id } = service
const { namespace, http, websocket, listen } = data.register
if (!msg.data.ws_path && msg.data.namespace) {
target += `/${msg.data.namespace}`
}
if (msg.data.ws_path && msg.data.ws_path !== "/") {
target += `/${msg.data.ws_path}`
}
await this.proxy.register({
if (http && http.enabled === true && Array.isArray(http.paths)) {
for (const path of http.paths) {
await this.gateway.register({
serviceId: id,
path: `/${msg.data.namespace}`,
target: target,
pathRewrite: {
[`^/${msg.data.namespace}`]: "",
},
ws: true,
path: path,
target: `${http.proto}://${listen.ip}:${listen.port}${path}`,
})
}
}
if (websocket && websocket.enabled === true) {
await this.gateway.register({
serviceId: id,
websocket: true,
path: websocket.path,
target: `${http.proto}://${listen.ip}:${listen.port}${websocket.path}`,
})
}
if (this.state.allReady) {
if (typeof this.gateway.applyConfiguration === "function") {
await this.gateway.applyConfiguration()
}
if (typeof this.gateway.reload === "function") {
await this.gateway.reload()
}
}
}
/**
* Check if all services are ready and trigger the ready event
@ -232,6 +215,7 @@ export default class Gateway {
).every((service) => service.initialized)
if (allServicesInitialized) {
this.state.allReady = true
this.onAllServicesReady()
}
}
@ -247,29 +231,34 @@ export default class Gateway {
* Handle when all services are ready
*/
onAllServicesReady = async () => {
if (this.state.allReady) {
return false
}
//console.clear()
//console.log(comtyAscii)
console.clear()
this.state.allReady = true
console.log(comtyAscii)
console.log("\n\n\n")
console.log(`🎉 All services[${this.services.length}] ready!\n`)
console.log(`USE: select <service>, reload, exit`)
await this.proxy.listen(this.state.proxyPort, this.state.internalIp)
if (typeof this.gateway.applyConfiguration === "function") {
await this.gateway.applyConfiguration()
}
if (typeof this.gateway.start === "function") {
await this.gateway.start()
}
}
/**
* Clean up resources on gateway exit
*/
onGatewayExit = () => {
console.clear()
//console.clear()
console.log(`\n🛑 Preparing to exit...`)
console.log(`Stopping proxy...`)
this.proxy.close()
if (typeof this.gateway.stop === "function") {
console.log(`Stopping gateway...`)
this.gateway.stop()
}
console.log(`Stopping all services...`)
this.serviceManager.stopAllServices()
@ -280,6 +269,13 @@ export default class Gateway {
* Initialize the gateway and start all services
*/
async initialize() {
if (!Managers[this.constructor.gatewayMode]) {
console.error(
`❌ Gateway mode [${this.constructor.gatewayMode}] not supported`,
)
return 0
}
onExit(this.onGatewayExit)
// Increase limits to handle many services
@ -287,7 +283,6 @@ export default class Gateway {
process.stderr.setMaxListeners(150)
this.services = await scanServices()
this.proxy = new Proxy()
this.ipcRouter = new IPCRouter()
if (this.services.length === 0) {
@ -295,12 +290,24 @@ export default class Gateway {
return process.exit(1)
}
// Initialize gateway
this.gateway = new Managers[this.constructor.gatewayMode]({
port: this.state.proxyPort,
internalIp: this.state.internalIp,
cert_file_name: sslCert,
key_file_name: sslKey,
})
if (typeof this.gateway.initialize === "function") {
await this.gateway.initialize()
}
// Make key components available globally
global.eventBus = this.eventBus
global.ipcRouter = this.ipcRouter
global.proxy = this.proxy
console.clear()
//console.clear()
console.log(comtyAscii)
console.log(
`\nRunning ${chalk.bgBlue(`${pkg.name}`)} | ${chalk.bgMagenta(`[v${pkg.version}]`)} | ${this.state.internalIp} | ${isProduction ? "production" : "development"} \n\n\n`,
@ -316,6 +323,12 @@ export default class Gateway {
await this.createServicesRegistry()
await this.createServiceInstances()
// WARNING: Starting relp makes uwebsockets unable to work properly, surging some bugs from nodejs (domain.enter)
// use another alternative to parse commands, like stdin reading or something...
//this.startRELP()
}
startRELP() {
// Initialize REPL interface
new RELP({
attachAllServicesSTD: () =>

View File

@ -0,0 +1,310 @@
import httpProxy from "http-proxy"
import defaults from "linebridge/dist/defaults"
import pkg from "../../../package.json" // Ajustado la ruta para que coincida con tu estructura
import http from "node:http"
import https from "node:https"
import fs from "node:fs"
import path from "node:path"
function getHttpServerEngine(extraOptions = {}, handler = () => {}) {
const sslKey = path.resolve(process.cwd(), ".ssl", "privkey.pem")
const sslCert = path.resolve(process.cwd(), ".ssl", "cert.pem")
if (fs.existsSync(sslKey) && fs.existsSync(sslCert)) {
console.log("Using HTTPS server")
return https.createServer(
{
key: fs.readFileSync(sslKey),
cert: fs.readFileSync(sslCert),
...extraOptions,
},
handler,
)
} else {
console.log("Using HTTP server")
return http.createServer(extraOptions, handler)
}
}
export default class Proxy {
constructor() {
this.routes = new Map()
this.config = {}
// Crear servidor HTTP
this.server = getHttpServerEngine({}, this.handleRequest.bind(this))
// Manejar upgrades de WebSocket
this.server.on("upgrade", this.handleUpgrade.bind(this))
// Crear una única instancia de proxy que se reutilizará
this.proxyServer = httpProxy.createProxyServer({
changeOrigin: true,
xfwd: true,
})
// Manejar errores del proxy
this.proxyServer.on("error", (err, req, res) => {
console.error("Proxy error:", err)
if (res && !res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" })
res.end(
JSON.stringify({
error: "Bad Gateway",
message: err.message,
}),
)
}
})
}
register = ({ serviceId, path, target, pathRewrite, websocket } = {}) => {
if (!path || !target) {
throw new Error("Path and target are required")
}
if (this.routes.has(path)) {
console.warn(`Route already registered [${path}], skipping...`)
return false
}
const routeObj = {
serviceId: serviceId ?? "default_service",
path,
target,
pathRewrite,
isWebSocket: !!websocket,
}
console.log(
`🔗 Registering ${websocket ? "websocket" : "http"} route [${path}] -> [${target}]`,
)
this.routes.set(path, routeObj)
return true
}
unregister = (path) => {
if (!this.routes.has(path)) {
console.warn(`Route not registered [${path}], skipping...`)
return false
}
console.log(`🔗 Unregistering route [${path}]`)
this.routes.delete(path)
return true
}
unregisterAllFromService = (serviceId) => {
const pathsToRemove = []
this.routes.forEach((route, path) => {
if (route.serviceId === serviceId) {
pathsToRemove.push(path)
}
})
pathsToRemove.forEach(this.unregister)
}
initialize = async () => {
// No es necesario inicializar nada, el servidor ya está configurado
return true
}
start = async (port = 9000, host = "0.0.0.0") => {
return new Promise((resolve, reject) => {
this.server.listen(port, host, (err) => {
if (err) {
console.error("Failed to start server:", err)
return reject(err)
}
console.log(`🚀 Server listening on ${host}:${port}`)
resolve()
})
})
}
stop = async () => {
return new Promise((resolve) => {
this.server.close(() => {
console.log("Server stopped")
this.proxyServer.close()
resolve()
})
})
}
applyConfiguration = async () => {
// No se necesita aplicar configuración específica
return true
}
reload = async () => {
// No es necesario recargar nada
return true
}
rewritePath = (rewriteConfig, path) => {
let result = path
if (!rewriteConfig) return result
for (const [pattern, replacement] of Object.entries(rewriteConfig)) {
const regex = new RegExp(pattern)
if (regex.test(path)) {
result = result.replace(regex, replacement)
break
}
}
return result
}
setCorsHeaders = (res) => {
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader(
"Access-Control-Allow-Methods",
"GET,HEAD,PUT,PATCH,POST,DELETE",
)
res.setHeader("Access-Control-Allow-Headers", "*")
return res
}
findRouteForPath(url) {
const urlPath = url.split("?")[0]
const segments = urlPath.split("/").filter(Boolean)
if (segments.length === 0) return null
const namespace = `/${segments[0]}`
return this.routes.get(namespace)
}
handleRequest = (req, res) => {
this.setCorsHeaders(res)
// Si es una solicitud OPTIONS, responder inmediatamente
if (req.method === "OPTIONS") {
res.statusCode = 204
res.end()
return
}
// Responder a solicitudes raíz
if (req.url === "/") {
res.setHeader("Content-Type", "application/json")
res.end(
JSON.stringify({
name: pkg.name,
version: pkg.version,
lb_version: defaults?.version || "unknown",
}),
)
return
}
// Encontrar la ruta para esta solicitud
const route = this.findRouteForPath(req.url)
if (!route) {
res.statusCode = 404
res.setHeader("Content-Type", "application/json")
res.end(
JSON.stringify({
error: "Gateway route not found",
details:
"The requested route does not exist or the service is down",
path: req.url,
}),
)
return
}
// Preparar opciones de proxy
const proxyOptions = {
target: route.target,
changeOrigin: true,
xfwd: true,
}
// save original url
req.originalUrl = req.url
// Aplicar reescritura de ruta si está configurada
if (route.pathRewrite) {
req.url = this.rewritePath(route.pathRewrite, req.url)
} else {
req.url = this.rewritePath({ [`^${route.path}`]: "" }, req.url)
}
// Agregar encabezados personalizados
this.proxyServer.on("proxyReq", (proxyReq, req, res, options) => {
proxyReq.setHeader("x-linebridge-version", pkg.version)
proxyReq.setHeader(
"x-forwarded-for",
req.socket.remoteAddress || req.ip,
)
proxyReq.setHeader("x-service-id", route.serviceId)
proxyReq.setHeader(
"X-Forwarded-Proto",
req.socket.encrypted ? "https" : "http",
)
})
// Proxy la solicitud
this.proxyServer.web(req, res, proxyOptions, (err) => {
if (err) {
console.error("Proxy error:", err)
if (!res.headersSent) {
res.statusCode = 502
res.setHeader("Content-Type", "application/json")
res.end(
JSON.stringify({
error: "Bad Gateway",
message: err.message,
}),
)
}
}
})
}
handleUpgrade = (req, socket, head) => {
// Encontrar la ruta para esta conexión WebSocket
const route = this.findRouteForPath(req.url)
if (!route) {
console.error(`WebSocket route not found for ${req.url}`)
socket.write("HTTP/1.1 404 Not Found\r\n\r\n")
socket.destroy()
return
}
// save original url
req.originalUrl = req.url
// Aplicar reescritura de ruta si está configurada
if (route.pathRewrite) {
req.url = this.rewritePath(route.pathRewrite, req.url)
} else {
req.url = this.rewritePath({ [`^${route.path}`]: "" }, req.url)
}
// Crear un objeto de opciones de proxy específico para WebSocket
const wsProxyOptions = {
target: route.target,
ws: true,
changeOrigin: true,
}
// Proxy la conexión WebSocket
this.proxyServer.ws(req, socket, head, wsProxyOptions, (err) => {
if (err) {
console.error("WebSocket proxy error:", err)
socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n")
socket.destroy()
}
})
}
}

View File

@ -0,0 +1,2 @@
export { default as nginx } from "./nginx"
export { default as http_proxy } from "./http-proxy"

View File

@ -0,0 +1,599 @@
import fs from "node:fs/promises"
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
import path from "node:path"
import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
const localNginxBinary = path.resolve(process.cwd(), "nginx")
/**
* NginxManager - Optimized version that batches configurations
* Waits for all services to register before applying configuration
*/
export default class NginxManager {
constructor(options = {}) {
this.options = options
this.ssl = {
on: false,
cert_file_name: null,
key_file_name: null,
}
this.port = options.port || 9000
this.internalIp = options.internalIp || "0.0.0.0"
// Set binary path
this.nginxBinary = existsSync(localNginxBinary)
? localNginxBinary
: "nginx"
// Directory structure
this.nginxWorkDir =
options.nginxWorkDir || path.join(process.cwd(), ".nginx")
this.configDir = path.join(this.nginxWorkDir, "conf")
this.tempDir = path.join(this.nginxWorkDir, "temp")
this.logsDir = path.join(this.tempDir, "logs")
this.cacheDir = path.join(this.tempDir, "cache")
// Configuration files
this.mainConfigPath = path.join(this.configDir, "nginx.conf")
this.servicesConfigPath = path.join(this.configDir, "services.conf")
// Process reference
this.nginxProcess = null
this.isNginxRunning = false
// Debug mode
this.debug = options.debug || false
if (
existsSync(this.options.cert_file_name) &&
existsSync(this.options.key_file_name)
) {
console.log("[nginx] Setting SSL listen mode")
this.ssl.on = true
this.ssl.cert_file_name = this.options.cert_file_name
this.ssl.key_file_name = this.options.key_file_name
}
}
routes = new Map() // key: path, value: { serviceId, target, pathRewrite, ws }
/**
* Initialize the directory structure and configuration files
*/
async initialize() {
try {
// Create directories
this._ensureDirectories()
// Create mime.types file
await this.writeMimeTypes()
// Generate main config file
await this.generateMainConfig()
console.log(`🔧 Using Nginx binary: ${this.nginxBinary}`)
return true
} catch (error) {
console.error("❌ Failed to initialize Nginx configuration:", error)
return false
}
}
/**
* Ensure all required directories exist
*/
_ensureDirectories() {
const dirs = [
this.configDir,
this.tempDir,
this.logsDir,
this.cacheDir,
path.join(this.cacheDir, "client_body"),
path.join(this.cacheDir, "proxy"),
]
// Create all directories
for (const dir of dirs) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
// Create empty log files
const logFiles = [
path.join(this.logsDir, "access.log"),
path.join(this.logsDir, "error.log"),
]
for (const file of logFiles) {
if (!existsSync(file)) {
writeFileSync(file, "")
}
}
}
/**
* Generate the main Nginx configuration file
*/
async generateMainConfig() {
// Normalize paths for Nginx
const normalizedConfigDir = this.configDir.replace(/\\/g, "/")
const normalizedTempDir = this.tempDir.replace(/\\/g, "/")
const normalizedLogsDir = path
.join(this.tempDir, "logs")
.replace(/\\/g, "/")
const normalizedCacheDir = path
.join(this.tempDir, "cache")
.replace(/\\/g, "/")
const config = `
# Nginx configuration for Comty API Gateway
# Auto-generated - Do not edit manually
worker_processes auto;
error_log ${normalizedLogsDir}/error.log ${this.debug ? "debug" : "error"};
pid ${normalizedTempDir}/nginx.pid;
events {
worker_connections 1024;
}
http {
include ${normalizedConfigDir}/mime.types;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
log_format debug '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'upstream_addr: $upstream_addr '
'upstream_status: $upstream_status '
'request_time: $request_time '
'http_version: $server_protocol';
access_log ${normalizedLogsDir}/access.log ${this.debug ? "debug" : "main"};
sendfile on;
tcp_nopush on;
tcp_nodelay on;
client_max_body_size 100M;
# WebSocket support
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Temp directories
client_body_temp_path ${normalizedCacheDir}/client_body;
proxy_temp_path ${normalizedCacheDir}/proxy;
# Set proxy timeouts
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
server {
${this.ssl.on ? `listen ${this.port} ssl;` : `listen ${this.port};`}
server_name _;
${this.ssl.cert_file_name ? `ssl_certificate ${this.ssl.cert_file_name};` : ""}
${this.ssl.key_file_name ? `ssl_certificate_key ${this.ssl.key_file_name};` : ""}
# Default route
location / {
add_header Content-Type application/json;
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Headers' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET,HEAD,PUT,PATCH,POST,DELETE' always;
return 200 '{"ok":1}';
}
# Include service-specific configurations
include ${normalizedConfigDir}/services.conf;
}
}
`
console.log(`📝 Nginx configuration initialized at ${this.configDir}`)
await fs.writeFile(this.mainConfigPath, config)
}
// Create mime.types file if it doesn't exist
async writeMimeTypes() {
const mimeTypesPath = path.join(this.configDir, "mime.types")
if (!existsSync(mimeTypesPath)) {
// Basic MIME types
const mimeTypes = `types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
text/plain txt;
image/png png;
image/svg+xml svg svgz;
application/json json;
}`
await fs.writeFile(mimeTypesPath, mimeTypes)
}
}
/**
* Register a new service route in Nginx - queues for batch processing
* @param {Object} routeConfig - Route configuration
* @returns {Boolean} - Success status
*/
async register(routeConfig) {
try {
const {
serviceId,
path: routePath,
target,
pathRewrite,
websocket,
} = routeConfig
// Normalize path
let normalizedPath = routePath.startsWith("/")
? routePath
: `/${routePath}`
if (this.debug) {
console.log(
`🔍 Registering route for [${serviceId}]: ${normalizedPath} -> ${target} (${websocket ? "WebSocket" : "HTTP"})`,
)
}
// Store the route with improved handling of path rewrites
const effectivePathRewrite = pathRewrite || {}
this.routes.set(normalizedPath, {
serviceId,
target,
pathRewrite: effectivePathRewrite,
websocket: !!websocket,
})
return true
} catch (error) {
console.error(
`❌ Failed to register route for [${routeConfig.serviceId}]:`,
error,
)
return false
}
}
/**
* Apply the current configuration (generate config and reload/start Nginx)
*/
async applyConfiguration() {
try {
console.log(
`🔄 Applying configuration with ${this.routes.size} routes...`,
)
// Generate services configuration
await this.regenerateServicesConfig()
// Verify configuration is valid
const configTest = this.execNginxCommand(
["-t", "-c", this.mainConfigPath],
true,
)
if (!configTest.success) {
throw new Error(
`Configuration validation failed: ${configTest.error}`,
)
}
console.log(`✅ Configuration applied successfully`)
} catch (error) {
console.error(`❌ Failed to apply configuration:`, error)
}
}
/**
* Unregister all routes for a specific service
* @param {String} serviceId - Service ID to unregister
* @returns {Boolean} - Success status
*/
async unregisterAllFromService(serviceId) {
try {
// Find and remove all routes for this service
for (const [path, route] of this.routes.entries()) {
if (route.serviceId === serviceId) {
this.routes.delete(path)
}
}
console.log(`📝 Removed routes for service [${serviceId}]`)
return true
} catch (error) {
console.error(
`❌ Failed to unregister routes for service [${serviceId}]:`,
error,
)
return false
}
}
/**
* Regenerate the services configuration file
*/
async regenerateServicesConfig() {
let config = `# Service routes\n# Last updated: ${new Date().toISOString()}\n# Total routes: ${this.routes.size}\n\n`
// Special case - no routes yet
if (this.routes.size === 0) {
config += "# No services registered yet\n"
await fs.writeFile(this.servicesConfigPath, config)
return
}
// Add all routes
for (const [path, route] of this.routes.entries()) {
config += this.generateLocationBlock(path, route)
}
// Write the config
await fs.writeFile(this.servicesConfigPath, config)
if (this.debug) {
console.log(`📄 Writted [${this.routes.size}] service routes`)
}
}
/**
* Generate a location block for a route
* @param {String} path - Route path
* @param {Object} route - Route configuration
* @returns {String} - Nginx location block
*/
generateLocationBlock(path, route) {
// Create rewrite configuration if needed
let rewriteConfig = ""
if (route.pathRewrite && Object.keys(route.pathRewrite).length > 0) {
rewriteConfig += "# Path rewrite rules\n"
for (const [pattern, replacement] of Object.entries(
route.pathRewrite,
)) {
// Improved rewrite pattern that preserves query parameters
rewriteConfig += `\trewrite ${pattern} ${replacement}$is_args$args break;`
}
} else {
// If no explicit rewrite is defined, but we need to strip the path prefix,
// Generate a default rewrite that preserves the URL structure
if (path !== "/") {
rewriteConfig += "# Default path rewrite to strip prefix\n"
rewriteConfig += `\trewrite ^${path}(/.*)$ $1$is_args$args break;\n`
rewriteConfig += `\trewrite ^${path}$ / break;`
}
}
// Determine if this is a root location or a more specific path
const locationDirective =
path === "/" ? "location /" : `location ${path}`
// Build the full location block with proper indentation
return `
${locationDirective} {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Headers' '*';
add_header 'Access-Control-Allow-Methods' 'GET,HEAD,PUT,PATCH,POST,DELETE';
return 200;
}
# Set proxy configuration
proxy_http_version 1.1;
proxy_pass_request_headers on;
# Standard proxy headers
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Set headers for WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Proxy pass to service
proxy_pass ${route.target};
}
`
}
/**
* Start the Nginx server
* @returns {Boolean} - Success status
*/
async start() {
try {
// Start Nginx
this.nginxProcess = spawn(
this.nginxBinary,
[
"-c",
this.mainConfigPath,
"-g",
"daemon off;",
"-p",
this.tempDir,
],
{
stdio: ["ignore", "pipe", "pipe"],
},
)
this.nginxProcess.stdout.on("data", (data) => {
console.log(`[Nginx] ${data.toString().trim()}`)
})
this.nginxProcess.stderr.on("data", (data) => {
console.error(`[Nginx] ${data.toString().trim()}`)
})
this.nginxProcess.on("close", (code) => {
this.isNginxRunning = false
if (code !== 0 && code !== null) {
console.error(`Nginx process exited with code ${code}`)
}
this.nginxProcess = null
})
// Wait briefly to check for immediate startup errors
await new Promise((resolve) => setTimeout(resolve, 500))
if (this.nginxProcess.exitCode !== null) {
throw new Error(
`Nginx failed to start (exit code: ${this.nginxProcess.exitCode})`,
)
}
this.isNginxRunning = true
console.log(`🚀 Nginx started on port ${this.port}`)
return true
} catch (error) {
this.isNginxRunning = false
console.error("❌ Failed to start Nginx:", error.message)
return false
}
}
/**
* Execute an Nginx command
* @param {Array} args - Command arguments
* @param {Boolean} returnOutput - Whether to return command output
* @returns {Object} - Success status and output/error
*/
execNginxCommand(args, returnOutput = false) {
try {
// Always include prefix to set the temp directory
const allArgs = [...args, "-p", this.tempDir]
const cmdString = `"${this.nginxBinary}" ${allArgs.join(" ")}`
if (this.debug) {
console.log(`🔍 Executing: ${cmdString}`)
}
const output = execSync(cmdString, {
encoding: "utf8",
stdio: returnOutput ? "pipe" : "inherit",
})
return {
success: true,
output: returnOutput ? output : null,
}
} catch (error) {
return {
success: false,
error: error.message,
output: error.stdout,
}
}
}
/**
* Reload the Nginx configuration
* @returns {Boolean} - Success status
*/
async reload() {
try {
// Test configuration validity
const configTest = this.execNginxCommand(
["-t", "-c", this.mainConfigPath],
true,
)
if (!configTest.success) {
throw new Error(
`Configuration test failed: ${configTest.error}`,
)
}
// If Nginx isn't running, start it
if (
!this.isNginxRunning ||
!this.nginxProcess ||
this.nginxProcess.exitCode !== null
) {
return await this.start()
}
// Send reload signal
const result = this.execNginxCommand([
"-s",
"reload",
"-c",
this.mainConfigPath,
])
if (!result.success) {
throw new Error(`Failed to reload Nginx: ${result.error}`)
}
console.log("🔄 Nginx configuration reloaded")
return true
} catch (error) {
console.error("❌ Failed to reload Nginx:", error.message)
return false
}
}
/**
* Stop the Nginx server
* @returns {Boolean} - Success status
*/
async close() {
try {
if (this.nginxProcess) {
// Try graceful shutdown first
this.execNginxCommand(["-s", "quit", "-c", this.mainConfigPath])
// Give Nginx time to shut down
await new Promise((resolve) => setTimeout(resolve, 1000))
// If still running, force kill
if (this.nginxProcess && this.nginxProcess.exitCode === null) {
this.nginxProcess.kill("SIGTERM")
// If STILL running after another second, use SIGKILL
await new Promise((resolve) => setTimeout(resolve, 1000))
if (
this.nginxProcess &&
this.nginxProcess.exitCode === null
) {
this.nginxProcess.kill("SIGKILL")
}
}
this.nginxProcess = null
}
this.isNginxRunning = false
console.log("🛑 Nginx stopped")
return true
} catch (error) {
console.error("❌ Failed to stop Nginx:", error.message)
return false
}
}
}

View File

@ -1,227 +0,0 @@
import httpProxy from "http-proxy"
import defaults from "linebridge/dist/defaults"
import pkg from "../package.json"
import http from "node:http"
import https from "node:https"
import fs from "node:fs"
import path from "node:path"
function getHttpServerEngine(extraOptions = {}, handler = () => {}) {
const sslKey = path.resolve(process.cwd(), ".ssl", "privkey.pem")
const sslCert = path.resolve(process.cwd(), ".ssl", "cert.pem")
if (fs.existsSync(sslKey) && fs.existsSync(sslCert)) {
return https.createServer(
{
key: fs.readFileSync(sslKey),
cert: fs.readFileSync(sslCert),
...extraOptions,
},
handler,
)
} else {
return http.createServer(extraOptions, handler)
}
}
export default class Proxy {
constructor() {
this.proxys = new Map()
this.wsProxys = new Map()
this.http = getHttpServerEngine({}, this.handleHttpRequest)
this.http.on("upgrade", this.handleHttpUpgrade)
}
http = null
register = ({ serviceId, path, target, pathRewrite, ws } = {}) => {
if (!path) {
throw new Error("Path is required")
}
if (!target) {
throw new Error("Target is required")
}
if (this.proxys.has(path)) {
console.warn(`Proxy already registered [${path}], skipping...`)
return false
}
const proxy = httpProxy.createProxyServer({
target: target,
})
proxy.on("proxyReq", (proxyReq, req, res, options) => {
proxyReq.setHeader("x-linebridge-version", pkg.version)
proxyReq.setHeader("x-forwarded-for", req.socket.remoteAddress)
proxyReq.setHeader("x-service-id", serviceId)
proxyReq.setHeader(
"X-Forwarded-Proto",
req.socket.encrypted ? "https" : "http",
)
})
proxy.on("error", (e) => {
console.error(e)
})
const proxyObj = {
serviceId: serviceId ?? "default_service",
path: path,
target: target,
pathRewrite: pathRewrite,
proxy: proxy,
}
if (ws) {
console.log(
`🔗 Registering websocket proxy [${path}] -> [${target}]`,
)
this.wsProxys.set(path, proxyObj)
} else {
console.log(`🔗 Registering path proxy [${path}] -> [${target}]`)
this.proxys.set(path, proxyObj)
}
return true
}
unregister = (path) => {
if (!this.proxys.has(path)) {
console.warn(`Proxy not registered [${path}], skipping...`)
return false
}
console.log(`🔗 Unregistering path proxy [${path}]`)
this.proxys.get(path).proxy.close()
this.proxys.delete(path)
}
unregisterAllFromService = (serviceId) => {
this.proxys.forEach((value, key) => {
if (value.serviceId === serviceId) {
this.unregister(value.path)
}
})
}
listen = async (port = 9000, host = "0.0.0.0", cb) => {
return await new Promise((resolve, reject) => {
this.http.listen(port, host, () => {
console.log(`🔗 Proxy listening on ${host}:${port}`)
if (cb) {
cb(this)
}
resolve(this)
})
})
}
rewritePath = (rewriteConfig, path) => {
let result = path
const rules = []
for (const [key, value] of Object.entries(rewriteConfig)) {
rules.push({
regex: new RegExp(key),
value: value,
})
}
for (const rule of rules) {
if (rule.regex.test(path)) {
result = result.replace(rule.regex, rule.value)
break
}
}
return result
}
setCorsHeaders = (res) => {
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader(
"Access-Control-Allow-Methods",
"GET,HEAD,PUT,PATCH,POST,DELETE",
)
res.setHeader("Access-Control-Allow-Headers", "*")
return res
}
handleHttpRequest = (req, res) => {
res = this.setCorsHeaders(res)
const sanitizedUrl = req.url.split("?")[0]
// preflight continue with code 204
if (req.method === "OPTIONS") {
res.statusCode = 204
res.end()
return
}
if (sanitizedUrl === "/") {
return res.end(
JSON.stringify({
name: pkg.name,
version: pkg.version,
lb_version: defaults.version,
}),
)
}
const namespace = `/${sanitizedUrl.split("/")[1]}`
const route = this.proxys.get(namespace)
if (!route) {
res.statusCode = 404
res.end(
JSON.stringify({
error: "Gateway route not found",
details:
"The gateway route you are trying to access does not exist, maybe the service is down...",
namespace: namespace,
}),
)
return null
}
if (route.pathRewrite) {
req.url = this.rewritePath(route.pathRewrite, req.url)
}
route.proxy.web(req, res)
}
handleHttpUpgrade = (req, socket, head) => {
const namespace = `/${req.url.split("/")[1].split("?")[0]}`
const route = this.wsProxys.get(namespace)
if (!route) {
// destroy socket
socket.destroy()
return false
}
if (route.pathRewrite) {
req.url = this.rewritePath(route.pathRewrite, req.url)
}
route.proxy.ws(req, socket, head)
}
close = () => {
this.http.close()
}
}

0
packages/server/gateway/repl.js Normal file → Executable file
View File

0
packages/server/gateway/services/manager.js Normal file → Executable file
View File

0
packages/server/gateway/services/service.js Normal file → Executable file
View File

View File

0
packages/server/gateway/utils/getIgnoredFiles.js Normal file → Executable file
View File

0
packages/server/gateway/utils/scanServices.js Normal file → Executable file
View File

0
packages/server/gateway/utils/spawnService.js Normal file → Executable file
View File

0
packages/server/gateway/vars.js Normal file → Executable file
View File

View File

@ -11,6 +11,8 @@
"dev": "cross-env NODE_ENV=development hermes-node ./start.js"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.2",
"@grpc/proto-loader": "^0.7.13",
"@infisical/sdk": "^2.1.8",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.56.1",
@ -25,7 +27,7 @@
"cross-env": "^7.0.3",
"http-proxy": "^1.18.1",
"jsonwebtoken": "^9.0.2",
"linebridge": "^0.24.1",
"linebridge": "^0.25.2",
"minimatch": "^10.0.1",
"minio": "^8.0.1",
"module-alias": "^2.2.3",