metacom
Version:
Communication protocol for Metarhia stack with rpc, events, binary streams, memory and db access
394 lines (349 loc) • 11.7 kB
JavaScript
;
const http = require('node:http');
const https = require('node:https');
const { EventEmitter } = require('node:events');
const metautil = require('metautil');
const ws = require('ws');
const { HttpTransport, WsTransport, HEADERS } = require('./transport.js');
const { MetaReadable, MetaWritable, chunkDecode } = require('./streams.js');
const SHORT_TIMEOUT = 500;
const EMPTY_PACKET = Buffer.from('{}');
const DEFAULT_LISTEN_RETRY = 3;
const createProxy = (data, save) =>
new Proxy(data, {
get: (data, key) => {
const value = Reflect.get(data, key);
return value;
},
set: (data, key, value) => {
const success = Reflect.set(data, key, value);
if (save) save(data);
return success;
},
});
class Session {
constructor(token, data, server) {
this.token = token;
const { application, console } = server;
this.state = createProxy(data, (data) => {
application.auth.saveSession(token, data).catch((err) => {
console.error(err);
});
});
}
}
const sessions = new Map(); // token: Session
class Context {
constructor(client) {
this.client = client;
this.uuid = metautil.generateUUID();
this.state = {};
this.session = client?.session || null;
}
}
class Client extends EventEmitter {
#transport;
#streamId;
constructor(transport) {
super();
this.#transport = transport;
this.#streamId = 0;
this.ip = transport.ip;
this.session = null;
this.streams = new Map();
transport.server.clients.add(this);
transport.once('close', () => {
this.destroy();
transport.server.clients.delete(this);
});
}
error(code, options) {
this.#transport.error(code, options);
}
send(obj, code) {
this.#transport.send(obj, code);
}
createContext() {
return new Context(this);
}
emit(name, data) {
if (name === 'close') return void super.emit(name, data);
this.sendEvent(name, data);
}
sendEvent(name, data) {
const packet = { type: 'event', name, data };
if (!this.#transport.connection) {
throw new Error(`Can't send metacom event to http transport`);
}
this.send(packet);
}
getStream(id) {
if (!this.#transport.connection) {
throw new Error(`Can't receive stream from http transport`);
}
const stream = this.streams.get(id);
if (stream) return stream;
throw new Error(`Stream ${id} is not initialized`);
}
createStream(name, size) {
if (!this.#transport.connection) {
throw new Error(`Can't send metacom streams to http transport`);
}
if (!name) throw new Error('Stream name is not provided');
if (!size) throw new Error('Stream size is not provided');
const id = --this.#streamId;
const stream = new MetaWritable(id, name, size, this.#transport);
this.streams.set(id, stream);
return stream;
}
initializeSession(token, data = {}) {
this.finalizeSession();
this.session = new Session(token, data, this.#transport.server);
sessions.set(token, this.session);
return true;
}
finalizeSession() {
if (!this.session) return false;
sessions.delete(this.session.token);
this.session = null;
return true;
}
startSession(token, data = {}) {
this.initializeSession(token, data);
if (!this.#transport.connection) this.#transport.sendSessionCookie(token);
return true;
}
restoreSession(token) {
const session = sessions.get(token);
if (!session) return false;
this.session = session;
return true;
}
close() {
this.#transport.close();
}
destroy() {
this.emit('close');
if (!this.session) return;
sessions.delete(this.session.token);
}
}
const addHeaders = ({ origin }) => {
if (origin) HEADERS['Access-Control-Allow-Origin'] = origin;
};
class Server {
constructor(application, options) {
this.retry = options.retry ?? DEFAULT_LISTEN_RETRY;
this.application = application;
this.options = options;
this.balancer = options.kind === 'balancer';
this.console = application.console;
if (options.cors) addHeaders(options.cors);
this.httpServer = null;
this.wsServer = null;
this.clients = new Set();
this.init();
}
init() {
const { application, balancer, options } = this;
const { protocol, nagle = true, key, cert, SNICallback } = options;
const proto = protocol === 'http' || balancer ? http : https;
const opt = { key, cert, noDelay: !nagle, SNICallback };
this.httpServer = proto.createServer(opt);
this.httpServer.on('request', async (req, res) => {
const transport = new HttpTransport(this, req, res);
const api = req.url.startsWith('/api');
if (!api && !(balancer && req.url === '/')) {
if (application.static.constructor.name !== 'Static') return;
return void application.static.serve(req.url, transport);
}
if (balancer) this.balancing(transport);
if (res.writableEnded) return;
const client = new Client(transport);
const data = await metautil.receiveBody(req).catch(() => null);
if (req.url === '/api') {
if (req.method !== 'POST') transport.error(403);
else this.message(client, data);
return;
}
this.request(client, transport, data);
});
if (balancer) return;
this.wsServer = new ws.Server({ server: this.httpServer });
this.wsServer.on('connection', (connection, req) => {
const transport = new WsTransport(this, req, connection);
const client = new Client(transport);
connection.on('message', (data, isBinary) => {
if (isBinary) this.binary(client, new Uint8Array(data));
else this.message(client, data);
});
});
}
listen() {
const { console, options } = this;
const { host, port, timeouts } = options;
return new Promise((resolve, reject) => {
this.httpServer.on('listening', () => {
console.info(`Listen port ${port}`);
resolve(this);
});
const server = this.wsServer || this.httpServer;
server.on('error', (err) => {
if (err.code !== 'EADDRINUSE') return;
this.retry--;
if (this.retry === 0) return void reject(err);
console.warn(`Address in use: ${host}:${port}, retry...`);
setTimeout(() => {
this.httpServer.listen(port, host);
}, timeouts.bind);
});
this.httpServer.listen(port, host);
});
}
message(client, data) {
if (Buffer.compare(EMPTY_PACKET, data) === 0) {
return void client.send({});
}
const packet = metautil.jsonParse(data) || {};
const { id, type, method } = packet;
if (type === 'call' && id && method) return void this.rpc(client, packet);
else if (type === 'stream' && id) return void this.stream(client, packet);
const error = new Error('Packet structure error');
client.error(500, { error, pass: true });
}
async rpc(client, packet) {
const { id, method, args } = packet;
const [unitName, methodName] = method.split('/');
const [unit, ver = '*'] = unitName.split('.');
const proc = this.application.getMethod(unit, ver, methodName);
if (!proc) return void client.error(404, { id });
const context = client.createContext();
if (!client.session && proc.access !== 'public') {
return void client.error(403, { id });
}
try {
await proc.enter();
} catch {
return void client.error(503, { id });
}
let result = null;
try {
result = await proc.invoke(context, args);
} catch (error) {
let code = error.code === 'ETIMEOUT' ? 408 : 500;
if (typeof error.code === 'number') code = error.code;
error.httpCode = code <= 599 ? code : 500;
return void client.error(code, { id, error });
} finally {
proc.leave();
}
if (metautil.isError(result)) {
const { code, httpCode = 200 } = result;
return void client.error(code, { id, error: result, httpCode });
}
client.send({ type: 'callback', id, result });
this.console.log(`${client.ip}\t${method}`);
}
async stream(client, packet) {
const { id, name, size, status } = packet;
const tag = id + '/' + name;
try {
const stream = client.streams.get(id);
if (status) {
if (!stream) throw new Error(`Stream ${tag} is not initialized`);
if (status === 'end') await stream.close();
if (status === 'terminate') await stream.terminate();
return void client.streams.delete(id);
}
const valid = typeof name === 'string' && Number.isSafeInteger(size);
if (!valid) throw new Error('Stream packet structure error');
if (stream) throw new Error(`Stream ${tag} is already initialized`);
{
const stream = new MetaReadable(id, name, size);
client.streams.set(id, stream);
this.console.log(`${client.ip}\tstream ${tag} init`);
}
} catch (error) {
this.console.error(`${client.ip}\tstream ${tag} error`);
client.error(400, { id, error, pass: true });
}
}
binary(client, data) {
const { id, payload } = chunkDecode(data);
try {
const upstream = client.streams.get(id);
if (upstream) {
upstream.push(payload);
} else {
const error = new Error(`Stream ${id} is not initialized`);
client.error(400, { id, error, pass: true });
}
} catch (error) {
this.console.error(`${client.ip}\tstream ${id} error`);
client.error(400, { id: 0, error });
}
}
request(client, transport, data) {
const { application } = this;
const { headers, url, method: verb } = transport.req;
const pathname = url.slice('/api/'.length);
const [path, params] = metautil.split(pathname, '?');
const parameters = metautil.parseParams(params);
const [unit, method] = metautil.split(path, '/');
const body = metautil.jsonParse(data) || {};
const args = { ...parameters, ...body };
const packet = { id: 0, method: unit + '/' + method, args };
const hook = application.getHook(unit);
if (hook) this.hook(client, hook, packet, verb, headers);
else this.rpc(client, packet);
}
async hook(client, proc, packet, verb, headers) {
const { id, method, args } = packet;
if (!proc) return void client.error(404, { id });
const context = client.createContext();
try {
await proc.enter();
} catch {
return void client.error(503, { id });
}
let result = null;
try {
const par = { verb, method, args, headers };
result = await proc.invoke(context, par);
} catch (error) {
client.error(500, { id, error });
} finally {
proc.leave();
}
if (metautil.isError(result)) {
const { code, httpCode = 200 } = result;
return void client.error(code, { id, error: result, httpCode });
}
client.send(result);
this.console.log(`${client.ip}\t${method}`);
}
balancing(transport) {
const host = metautil.parseHost(transport.req.headers.host);
const { protocol, ports } = this.options;
const targetPort = metautil.sample(ports);
const targetPath = transport.req.url || '/';
transport.redirect(`${protocol}://${host}:${targetPort}${targetPath}`);
}
closeClients() {
for (const client of this.clients) {
client.close();
}
}
async close() {
if (!this.httpServer.listening) return;
this.httpServer.close((err) => {
if (err) this.console.error(err);
});
if (this.clients.size === 0) return;
this.closeClients();
while (this.clients.size > 0) {
await metautil.delay(SHORT_TIMEOUT);
}
}
}
module.exports = { Server };