@copperjs/copper
Version:
A lightweight chromium grid
180 lines (150 loc) • 5.58 kB
text/typescript
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();