UNPKG

@copperjs/copper

Version:
180 lines (150 loc) 5.58 kB
import fetch from 'node-fetch'; import { NoMatchingNode, SessionNotFound } from '../common/errors'; import { delay, removeWsUrl } from '../common/utils'; import { IWebSocketHandler } from '../common/websockets'; import { logger } from '../logger'; import { NodeConfig } from '../node/config'; import { copperConfig } from '../standalone/config'; import { serializedSession } from '../standalone/sessionManager'; export class Node { private sessions = new Set<string>(); private shouldCheckIsAlive = true; private isAlive = false; constructor(private config: NodeConfig) { this.config.urlPrefix = copperConfig.value.routesPrefix; this.config.maxSession = this.config.maxSession ?? Number.POSITIVE_INFINITY; this.config.nodePolling = this.config.nodePolling || 10000; if (this.config.maxSession < 1) { this.config.maxSession = Number.POSITIVE_INFINITY; } this.checkIsAlive(); } static getId(config: Pick<NodeConfig, 'host' | 'port'>) { return `${config.host}:${config.port}`; } get id() { return Node.getId(this.config); } get URL() { return `http://${this.config.host}:${this.config.port}`; } get webSocketURL() { return `ws://${this.config.host}:${this.config.port}`; } get urlPrefix() { return this.config.urlPrefix; } get freeSlots() { return this.config.maxSession - this.sessions.size; } get canCreateSession() { return this.freeSlots > 0 && this.isAlive; } getSessions() { return Array.from(this.sessions); } registerSession(id: string) { this.sessions.add(id); } deregisterSession(id: string) { this.sessions.delete(id); } deregister() { this.isAlive = false; this.shouldCheckIsAlive = false; } private async checkIsAlive() { if (!this.shouldCheckIsAlive) { return; } try { await fetch(`${this.URL}${this.config.urlPrefix}status`, { timeout: 1000 }); if (!this.isAlive) { logger.warn(`node ${this.id} connected`); } this.isAlive = true; } catch (err) { if (this.isAlive) { logger.warn(`node ${this.id} disconnected`); } this.isAlive = false; } await delay(this.config.nodePolling); process.nextTick(() => this.checkIsAlive()); } } export class Grid implements IWebSocketHandler { private nodes = new Map<string, Node>(); private sessionNodeMap = new Map<string, string>(); private sessions = new Map<string, serializedSession>(); registerNode(config: NodeConfig) { const node = new Node(config); this.nodes.set(node.id, node); logger.info(`registered node ${node.id}`); return node; } deregisterNode(host: string, port: string | number) { const nodeId = Node.getId({ host, port }); if (!this.nodes.has(nodeId)) { logger.error(`failed deregistering node ${nodeId}`); throw new NoMatchingNode(`node ${nodeId} not registered`); } const node = this.nodes.get(nodeId)!; this.nodes.delete(nodeId); node.deregister(); node.getSessions().map((sessionId) => this._removeSession(sessionId)); logger.info(`deregistered node ${node.id}`); } async createSession(body = {}) { const candidates = Array.from(this.nodes.values()).filter((node) => node.canCreateSession); const node = candidates.length ? candidates.reduce((prev, curr) => (curr.freeSlots > prev.freeSlots ? curr : prev)) : null; if (!node) { throw new NoMatchingNode('cannot find a free node to create a session on'); } const session: { sessionId: string; value: serializedSession } = await fetch( `${node.URL}${node.urlPrefix}session`, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, }, ).then((res) => res.json()); node.registerSession(session.sessionId); this.sessionNodeMap.set(session.sessionId, node.id); this.sessions.set(session.sessionId, removeWsUrl(session.value)); return session.value; } getWebSocketUrl(sessionId: string) { return `${this.getNode(sessionId).webSocketURL}/ws/${sessionId}`; } listSessions() { return Array.from(this.sessions.values()); } getSession(id: string) { if (!this.sessions.has(id)) { throw new SessionNotFound(id); } return this.sessions.get(id)!; } getNode(sessionId: string) { const nodeId = this.sessionNodeMap.get(this.getSession(sessionId)!.id)!; return this.nodes.get(nodeId)!; } _removeSession(sessionId: string) { this.getSession(sessionId); // throw if no session this.sessionNodeMap.delete(sessionId); this.sessions.delete(sessionId); } async removeSession(sessionId: string) { const node = this.getNode(sessionId); node.deregisterSession(sessionId); this._removeSession(sessionId); return await fetch(`${node.URL}${node.urlPrefix}session/${sessionId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, }).then((res) => res.json()); } } export const grid = new Grid();