2025-05-13 12:50:36 +00:00

432 lines
11 KiB
JavaScript
Executable File

require("dotenv").config()
import path from "node:path"
import EventEmitter from "@foxify/events"
import { Observable } from "@gullerya/object-observer"
import IPCRouter from "linebridge/dist/classes/IPCRouter"
import { onExit } from "signal-exit"
import chalk from "chalk"
import treeKill from "tree-kill"
import scanServices from "./utils/scanServices"
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"
global.debugFlag = process.env.DEBUG === "true"
const isProduction = process.env.NODE_ENV === "production"
/**
* 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 ?? "nginx"
eventBus = new EventEmitter()
state = {
proxyPort: process.env.GATEWAY_LISTEN_PORT ?? 9000,
internalIp: "0.0.0.0",
allReady: false,
}
selectedService = null
serviceManager = new ServiceManager()
services = []
serviceRegistry = Observable.from({})
gateway = null
ipcRouter = null
/**
* Creates service registry entries based on discovered services
*/
async createServicesRegistry() {
for await (const servicePath of this.services) {
const instanceFile = path.basename(servicePath)
const instanceBasePath = path.dirname(servicePath)
const servicePkg = require(
path.resolve(instanceBasePath, "package.json"),
)
this.serviceRegistry[servicePkg.name] = {
index: this.services.indexOf(servicePath),
id: servicePkg.name,
version: servicePkg.version,
file: instanceFile,
cwd: instanceBasePath,
ready: false,
}
}
}
/**
* Creates and initializes all service instances
*/
async createServiceInstances() {
if (!debugFlag) {
console.log(`🔰 Starting all services, please wait...`)
}
for await (const servicePath of this.services) {
const instanceBasePath = path.dirname(servicePath)
const servicePkg = require(
path.resolve(instanceBasePath, "package.json"),
)
const serviceId = servicePkg.name
const serviceConfig = {
id: serviceId,
version: servicePkg.version,
path: servicePath,
cwd: instanceBasePath,
isProduction,
internalIp: this.state.internalIp,
}
// Create service instance
const service = new Service(serviceConfig, {
onReady: this.onServiceReady.bind(this),
onIPCData: this.onServiceIPCData.bind(this),
onServiceExit: this.onServiceExit.bind(this),
})
// Add to service manager
this.serviceManager.addService(service)
// Initialize service
await service.initialize()
}
}
/**
* Handler for service ready event
* @param {Service} service - Service that is ready
*/
onServiceReady(service) {
const serviceId = service.id
this.serviceRegistry[serviceId].initialized = true
this.serviceRegistry[serviceId].ready = true
// Check if all services are ready
this.checkAllServicesReady()
}
/**
* Handler for service IPC data
* @param {Service} service - Service sending the data
* @param {object} data - IPC data received
*/
async onServiceIPCData(service, data) {
const id = service.id
if (data.type === "log") {
console.log(`[ipc:${id}] ${data.message}`)
}
if (data.status === "ready") {
this.onServiceReady(service)
}
if (data.type === "service:register") {
await this.handleServiceRegistration(service, data)
}
}
/**
* Handler for service exit
* @param {Service} service - Service that exited
* @param {number} code - Exit code
* @param {Error} error - Error if any
*/
onServiceExit(service, code, error) {
const id = service.id
this.serviceRegistry[id].initialized = true
this.serviceRegistry[id].ready = false
console.log(`[${id}] Exit with code ${code}`)
if (error) {
console.error(error)
}
if (typeof this.gateway.unregisterAllFromService === "function") {
this.gateway.unregisterAllFromService(id)
}
}
/**
* Loads and registers additional proxy routes from ../../extra-proxies.js
*/
async registerExtraProxies() {
try {
// Dynamic import is relative to the current file.
// extra-proxies.js can be CJS (module.exports = ...) or ESM (export default ...)
const extraProxiesModule = require(
path.resolve(process.cwd(), "extra-proxies.js"),
)
const extraProxies = extraProxiesModule.default // Node's CJS/ESM interop puts module.exports on .default
if (
!extraProxies ||
typeof extraProxies !== "object" ||
Object.keys(extraProxies).length === 0
) {
console.log(
"[Gateway] No extra proxies defined in `extra-proxies.js`, file is empty, or format is invalid. Skipping.",
)
return
}
console.log(
`[Gateway] Registering extra proxies from 'extra-proxies.js'...`,
)
for (const proxyPathKey in extraProxies) {
if (
Object.prototype.hasOwnProperty.call(
extraProxies,
proxyPathKey,
)
) {
const config = extraProxies[proxyPathKey]
if (!config || typeof config.target !== "string") {
console.warn(
`[Gateway] Skipping invalid extra proxy config for path: '${proxyPathKey}' in 'extra-proxies.js'. Target is missing or not a string.`,
)
continue
}
let registrationPath = proxyPathKey
// Normalize paths ending with /*
// e.g., "/spectrum/*" becomes "/spectrum"
// e.g., "/*" becomes "/"
if (registrationPath.endsWith("/*")) {
registrationPath = registrationPath.slice(0, -2)
if (registrationPath === "") {
registrationPath = "/"
}
}
console.log(
`[Gateway] Registering extra proxy: '${proxyPathKey}' (as '${registrationPath}') -> ${config.target}`,
)
await this.gateway.register({
serviceId: `extra-proxy:${registrationPath}`, // Unique ID for this proxy rule
path: registrationPath,
target: config.target,
pathRewrite: config.pathRewrite, // undefined if not present
websocket: !!config.websocket, // false if not present or falsy
})
}
}
} catch (error) {
// Handle cases where the extra-proxies.js file might not exist
if (
error.code === "ERR_MODULE_NOT_FOUND" ||
(error.message &&
error.message.toLowerCase().includes("cannot find module"))
) {
console.log(
"[Gateway] `extra-proxies.js` not found. Skipping extra proxy registration.",
)
} else {
console.error(
"[Gateway] Error loading or registering extra proxies from `extra-proxies.js`:",
error,
)
}
}
}
/**
* 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 handleServiceRegistration(service, data) {
const { id } = service
const { namespace, http, websocket, listen } = data.register
if (http && http.enabled === true && Array.isArray(http.paths)) {
for (const path of http.paths) {
await this.gateway.register({
serviceId: id,
path: path,
target: `${http.proto}://${listen.ip}:${listen.port}${path}`,
websocket: !!websocket,
})
}
}
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
*/
checkAllServicesReady() {
if (this.state.allReady) return
const allServicesInitialized = Object.values(
this.serviceRegistry,
).every((service) => service.initialized)
if (allServicesInitialized) {
this.state.allReady = true
this.onAllServicesReady()
}
}
/**
* Reload all services
*/
reloadAllServices = () => {
this.serviceManager.reloadAllServices()
}
/**
* Handle when all services are ready
*/
onAllServicesReady = async () => {
//console.clear()
//console.log(comtyAscii)
console.log("\n")
console.log(`🎉 All services[${this.services.length}] ready!\n`)
console.log(`USE: select <service>, reload, exit\n`)
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.log(`\n🛑 Preparing to exit...`)
if (typeof this.gateway.stop === "function") {
console.log(`Stopping gateway...`)
this.gateway.stop()
}
console.log(`Stopping all services...`)
this.serviceManager.stopAllServices()
treeKill(process.pid)
}
/**
* 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
process.stdout.setMaxListeners(150)
process.stderr.setMaxListeners(150)
this.services = await scanServices()
this.ipcRouter = new IPCRouter()
global.eventBus = this.eventBus
global.ipcRouter = this.ipcRouter
if (this.services.length === 0) {
console.error("❌ No services found")
return process.exit(1)
}
console.log(comtyAscii)
console.log(
`\nRunning ${chalk.bgBlue(`${pkg.name}`)} | ${chalk.bgMagenta(`[v${pkg.version}]`)} | ${this.state.internalIp} | ${isProduction ? "production" : "development"} | ${this.constructor.gatewayMode} |\n`,
)
console.log(`📦 Found ${this.services.length} service(s)`)
// Initialize gateway
this.gateway = new Managers[this.constructor.gatewayMode]({
port: this.state.proxyPort,
internalIp: this.state.internalIp,
key_file_name: process.env.GATEWAY_SSL_KEY,
cert_file_name: process.env.GATEWAY_SSL_CERT,
})
if (typeof this.gateway.initialize === "function") {
await this.gateway.initialize()
}
// Register any externally defined proxies before services start
await this.registerExtraProxies()
// Watch for service state changes
Observable.observe(this.serviceRegistry, (changes) => {
this.checkAllServicesReady()
})
await this.createServicesRegistry()
await this.createServiceInstances()
if (!isProduction) {
this.startRELP()
}
}
startRELP() {
// Initialize REPL interface
new RELP({
attachAllServicesSTD: () =>
this.serviceManager.attachAllServicesStd(),
detachAllServicesSTD: () =>
this.serviceManager.detachAllServicesStd(),
attachServiceSTD: (id) => this.serviceManager.attachServiceStd(id),
dettachServiceSTD: (id) => this.serviceManager.detachServiceStd(id),
reloadService: () => {
const selectedService = this.serviceManager.getSelectedService()
if (!selectedService) {
console.error(`No service selected`)
return false
}
if (selectedService === "all") {
return this.reloadAllServices()
}
return selectedService.reload()
},
onAllServicesReady: this.onAllServicesReady,
})
}
}