/** * Service Registry * * The registry is the brain of horizontal scaling. It answers one * question for every proxy call: WHERE is this service? * * ═══════════════════════════════════════════════════════════════ * GROWTH STORY * ═══════════════════════════════════════════════════════════════ * * PHASE 1: Single machine ($20/mo VPS) * Registry mode: 'embedded' * - Runs in-process inside the supervisor * - All services are local (UDS or colocated) * - Registry is just a Map in memory * - Zero operational overhead * * PHASE 2: 2-3 machines ($100/mo) * Registry mode: 'multicast' * - Each supervisor announces its services via UDP multicast * on the local network (or via a simple HTTP gossip protocol) * - Nodes discover each other automatically * - No external dependencies (no etcd, no consul) * - Services that move to another machine are auto-discovered * * PHASE 3: 10+ machines (serious scale) * Registry mode: 'external' * - Pluggable backend: Redis, etcd, Consul, or managed service * - Full service mesh capabilities * - Health checks, circuit breakers, canary deployments * * The proxy layer doesn't know or care which phase you're in. * this.users.getUser('123') works identically in all three. * * ═══════════════════════════════════════════════════════════════ * REGISTRATION PROTOCOL * ═══════════════════════════════════════════════════════════════ * * When a service starts, it registers: * * { * name: 'users', * nodeId: 'node-abc123', * host: '10.0.1.5', * ports: { * http: 4001, // HTTP port (for remote calls via /__forge/invoke) * }, * udsPath: '/tmp/forge-1234/users-1.sock', // local only * workers: 4, * contract: { // what methods are available * methods: ['getUser', 'createUser', 'listUsers'], * events: ['user.created'], * }, * health: { * status: 'healthy', * cpu: 23, // percent * memory: 156, // MB * rpcLatencyP50: 2, // ms * rpcLatencyP99: 18, // ms * pendingRequests: 12, * lastHeartbeat: 1707500000000, * }, * metadata: { * version: '1.2.3', * region: 'us-east-1', * startedAt: 1707500000000, * }, * } * * ═══════════════════════════════════════════════════════════════ * TOPOLOGY RESOLUTION * ═══════════════════════════════════════════════════════════════ * * When a proxy call is made, the registry resolves the transport: * * registry.resolve('users') * → [ * { transport: 'local', instance: ... }, // colocated * { transport: 'uds', path: '/tmp/...' }, // same machine * { transport: 'http', host: '10.0.1.5:4001' }, // remote * ] * * The proxy picks the BEST option: * 1. Colocated (same process) → always preferred * 2. UDS (same machine) → preferred over network * 3. HTTP (closest region) → fallback */ import { EventEmitter } from "node:events"; import { type RegistryMode as RegistryModeValue } from "../core/config-enums.js"; export interface ServiceHealth { status: "healthy" | "degraded" | "unhealthy" | "draining"; cpu: number; memory: number; rpcLatencyP50: number; rpcLatencyP99: number; pendingRequests: number; lastHeartbeat: number; } export interface ServiceContract { methods: string[]; events: string[]; } export interface ServiceMetadata { version: string; region: string; startedAt: number; [key: string]: unknown; } export interface ServiceRegistration { name: string; nodeId: string; host: string; ports: { http?: number; }; udsPath: string | null; workers: number; contract: ServiceContract; health: ServiceHealth; metadata: ServiceMetadata; lastSeen?: number; } export interface ServiceRegistrationInput { name: string; ports?: { http?: number; }; udsPath?: string | null; workers?: number; contract?: Partial; health?: Partial; metadata?: Partial; [key: string]: unknown; } export interface ResolvedEndpoint { transport: "local" | "uds" | "http"; nodeId: string; instance?: { service: object; ctx: object; }; path?: string; address?: string; health: ServiceHealth; priority: number; } export interface TopologyEntry { nodeId: string; host: string; transport: "colocated" | "local" | "http"; status: string; cpu: number; memory: number; rpcLatencyP99: number; pendingRequests: number; workers: number; } export interface ExternalBackend { get(key: string): string | Promise; set(key: string, value: string, options: { ttl: number; }): void | Promise; watch(prefix: string, callback: (key: string, value: string) => void): void; delete(key: string): void | Promise; } interface RegistryOptions { mode?: RegistryModeValue; nodeId?: string; host?: string; heartbeatIntervalMs?: number; healthTimeoutMs?: number; httpBasePort?: number; clusterSecret?: string | null; staleReapMultiplier?: number; } export declare class ServiceRegistry extends EventEmitter { mode: RegistryModeValue; nodeId: string; host: string; heartbeatIntervalMs: number; healthTimeoutMs: number; httpBasePort: number; staleReapMultiplier: number; registrations: Map; localRegistrations: Map; localInstances: Map; _clusterSecret: string | null; _previousClusterSecret: string | null; _heartbeatTimer: ReturnType | null; _reapTimer: ReturnType | null; _backend: ExternalBackend | null; _drainTimers: Map>; _knownPeers: Map; _expectedClusterSize: number; _healthyStreak: Map; _minHealthyThreshold: number; _degraded: boolean; _multicastSocket?: import("node:dgram").Socket; _multicastAddr?: string; _multicastPort?: number; _multicastRateTimer?: ReturnType; constructor(options?: RegistryOptions); /** * Start the registry. In embedded mode this is a no-op. * In multicast/external mode, connects to peers. */ start(): Promise; /** * Register a local service. * Called by the Supervisor when a service worker starts. */ register(registration: ServiceRegistrationInput): ServiceRegistration; /** * Register a colocated service instance (same process). */ registerLocal(name: string, instance: { service: object; ctx: object; }): void; /** * Deregister a service (shutting down). */ deregister(serviceName: string): void; /** * Receive a registration from another node. */ receiveRemoteRegistration(reg: ServiceRegistration): void; /** * Check if we can see a quorum of the expected cluster. * Returns true if quorum is met or split-brain detection is disabled. * A node should avoid serving traffic if it's partitioned from the majority. */ hasQuorum(): boolean; /** * Update health for a local service. */ updateHealth(serviceName: string, health: Partial): void; /** * Resolve endpoints for a service, ordered by preference. * * Returns all known instances with their transport type and * health status, sorted by priority: * 1. Local (colocated, same process) * 2. UDS (same machine, different process) * 3. HTTP (remote, same region) * 4. HTTP (remote, different region) */ resolve(serviceName: string): ResolvedEndpoint[]; /** * Resolve the BEST single endpoint for a service. * Used by the proxy when making a call. */ resolveBest(serviceName: string): ResolvedEndpoint | null; /** * Get all known services and their locations. */ topology(): Record; /** * Multicast mode: UDP broadcast on the local network. * Each node announces its services every heartbeat interval. * New nodes are discovered automatically. */ _startMulticast(): Promise; /** * Announce services to the network. */ _announceToNetwork(reg: ServiceRegistration): void; _sendMulticastPayload(services: ServiceRegistration[]): void; _startExternal(): Promise; /** * Set an external backend (Redis, etcd, etc.) * * The backend must implement: * get(key) → string * set(key, value, options) → void * watch(prefix, callback) → void * delete(key) → void */ setBackend(backend: ExternalBackend): void; _sendHeartbeats(): void; _reapStale(): void; /** * Expose the topology via HTTP for gossip / dashboards. * The supervisor can mount this on its metrics server. */ httpHandler(req: { url?: string; method?: string; }, res: { writeHead(code: number, headers?: Record): void; end(body?: string): void; }): boolean; /** * REG-M3: Rotate the cluster secret at runtime. * The current secret becomes the previous secret (accepted during rotation), * and the new secret becomes the current one used for signing. */ rotateSecret(newSecret: string): void; stop(): Promise; } export {}; //# sourceMappingURL=ServiceRegistry.d.ts.map