@interopio/gateway-server 0.4.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +94 -0
- package/dist/gateway-ent.cjs +305 -0
- package/dist/gateway-ent.cjs.map +7 -0
- package/dist/gateway-ent.js +277 -0
- package/dist/gateway-ent.js.map +7 -0
- package/dist/index.cjs +1713 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.js +1682 -0
- package/dist/index.js.map +7 -0
- package/dist/metrics-rest.cjs +21440 -0
- package/dist/metrics-rest.cjs.map +7 -0
- package/dist/metrics-rest.js +21430 -0
- package/dist/metrics-rest.js.map +7 -0
- package/gateway-server.d.ts +69 -0
- package/package.json +66 -0
- package/readme.md +9 -0
- package/src/common/compose.ts +40 -0
- package/src/gateway/ent/config.ts +174 -0
- package/src/gateway/ent/index.ts +18 -0
- package/src/gateway/ent/logging.ts +89 -0
- package/src/gateway/ent/server.ts +34 -0
- package/src/gateway/metrics/rest.ts +20 -0
- package/src/gateway/ws/core.ts +90 -0
- package/src/index.ts +3 -0
- package/src/logger.ts +6 -0
- package/src/mesh/connections.ts +101 -0
- package/src/mesh/rest-directory/routes.ts +38 -0
- package/src/mesh/ws/broker/core.ts +163 -0
- package/src/mesh/ws/cluster/core.ts +107 -0
- package/src/mesh/ws/relays/core.ts +159 -0
- package/src/metrics/routes.ts +86 -0
- package/src/server/address.ts +47 -0
- package/src/server/cors.ts +311 -0
- package/src/server/exchange.ts +379 -0
- package/src/server/monitoring.ts +167 -0
- package/src/server/types.ts +69 -0
- package/src/server/ws-client-verify.ts +79 -0
- package/src/server.ts +316 -0
- package/src/utils.ts +10 -0
- package/types/gateway-ent.d.ts +212 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import getLogger from '../logger.js';
|
|
2
|
+
|
|
3
|
+
const logger = getLogger('mesh.connections');
|
|
4
|
+
|
|
5
|
+
export type Node = { node: string, endpoint: string, users?: string[] };
|
|
6
|
+
export type NodeConnection = { node: string, connect: Node[] }
|
|
7
|
+
|
|
8
|
+
type NodeValue = Omit<Node, 'users'> & {
|
|
9
|
+
users: Set<string>, memberId: number, lastAccess: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NodeConnections {
|
|
13
|
+
announce(nodes: Node[]): NodeConnection[];
|
|
14
|
+
remove(node: string): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default class InMemoryNodeConnections implements NodeConnections {
|
|
18
|
+
private readonly nodes = new Map<string, NodeValue>();
|
|
19
|
+
private readonly nodesByEndpoint = new Map<string, string>();
|
|
20
|
+
private memberIds: number = 0;
|
|
21
|
+
|
|
22
|
+
constructor(private readonly timeout: number = 60000) {
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
announce(nodes: Node[]): NodeConnection[] {
|
|
26
|
+
for (const node of nodes) {
|
|
27
|
+
const {node: nodeId, users, endpoint} = node;
|
|
28
|
+
const foundId = this.nodesByEndpoint.get(endpoint);
|
|
29
|
+
if (foundId) {
|
|
30
|
+
if (foundId !== nodeId) {
|
|
31
|
+
logger.warn(`endpoint ${endpoint} clash. replacing node ${foundId} with ${nodeId}`);
|
|
32
|
+
this.nodesByEndpoint.set(endpoint, nodeId);
|
|
33
|
+
this.nodes.delete(foundId);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
logger.info(`endpoint ${endpoint} announced for ${nodeId}`);
|
|
37
|
+
this.nodesByEndpoint.set(endpoint, nodeId);
|
|
38
|
+
}
|
|
39
|
+
this.nodes.set(nodeId, this.updateNode(node, new Set<string>(users ?? []), nodeId, this.nodes.get(nodeId)));
|
|
40
|
+
}
|
|
41
|
+
this.cleanupOldNodes();
|
|
42
|
+
const sortedNodes = Array.from(this.nodes.values()).sort((a, b) => a.memberId - b.memberId);
|
|
43
|
+
return nodes.map((e) => {
|
|
44
|
+
const node = e.node;
|
|
45
|
+
const connect = this.findConnections(sortedNodes, this.nodes.get(node));
|
|
46
|
+
return {node, connect};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
remove(nodeId: string) {
|
|
51
|
+
const removed = this.nodes.get(nodeId);
|
|
52
|
+
if (removed) {
|
|
53
|
+
this.nodes.delete(nodeId);
|
|
54
|
+
const endpoint = removed.endpoint;
|
|
55
|
+
this.nodesByEndpoint.delete(endpoint);
|
|
56
|
+
logger.info(`endpoint ${endpoint} removed for ${nodeId}`);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private updateNode(newNode: Node, users: Set<string>, _key: string, oldNode?: NodeValue): NodeValue {
|
|
63
|
+
const node: Omit<NodeValue, 'lastAccess' | 'users'> = !oldNode ? {...newNode, memberId: this.memberIds++} : oldNode;
|
|
64
|
+
return {...node, users, lastAccess: Date.now()};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private cleanupOldNodes() {
|
|
68
|
+
const threshold = Date.now() - this.timeout;
|
|
69
|
+
for (const [nodeId,v] of this.nodes) {
|
|
70
|
+
if (v.lastAccess < threshold) {
|
|
71
|
+
if (logger.enabledFor('debug')) {
|
|
72
|
+
logger.debug(`${nodeId} expired - no announcement since ${new Date(v.lastAccess).toISOString()}, timeout is ${this.timeout} ms.`);
|
|
73
|
+
}
|
|
74
|
+
this.nodes.delete(nodeId);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private findConnections(sortedNodes: NodeValue[], node?: NodeValue): Node[] {
|
|
80
|
+
return sortedNodes.reduce((l, c) => {
|
|
81
|
+
if (node !== undefined && c.memberId < node.memberId) {
|
|
82
|
+
const intersection = new Set(c.users);
|
|
83
|
+
node.users.forEach(user => {
|
|
84
|
+
if (!c.users.has(user)) {
|
|
85
|
+
intersection.delete(user);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
c.users.forEach(user => {
|
|
89
|
+
if (!node.users.has(user)) {
|
|
90
|
+
intersection.delete(user);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
if (intersection.size > 0) {
|
|
94
|
+
const e: Node = {node: c.node, endpoint: c.endpoint};
|
|
95
|
+
return l.concat(e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return l;
|
|
99
|
+
}, new Array<Node>());
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {Middleware} from '../../server/types.js';
|
|
2
|
+
import {NodeConnections, Node} from '../connections.js';
|
|
3
|
+
import {HttpServerRequest, HttpServerResponse} from '../../server/exchange.js';
|
|
4
|
+
|
|
5
|
+
function routes(connections: NodeConnections): Middleware<HttpServerRequest, HttpServerResponse> {
|
|
6
|
+
return [
|
|
7
|
+
async (ctx, next: () => Promise<void>) => {
|
|
8
|
+
if (ctx.method === 'POST' && ctx.path === '/api/nodes') {
|
|
9
|
+
const json = await ctx.request.json;
|
|
10
|
+
if (!Array.isArray(json)) {
|
|
11
|
+
ctx.response.statusCode = 400
|
|
12
|
+
ctx.response._res.end();
|
|
13
|
+
} else {
|
|
14
|
+
const nodes = json as Node[];
|
|
15
|
+
const result = connections.announce(nodes);
|
|
16
|
+
const body = JSON.stringify(result);
|
|
17
|
+
ctx.response.headers.set('content-type', 'application/json');
|
|
18
|
+
ctx.response.statusCode = 200;
|
|
19
|
+
ctx.response._res
|
|
20
|
+
.end(body);
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
await next();
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
async ({method, path, response}, next: () => Promise<void>) => {
|
|
27
|
+
if (method === 'DELETE' && path?.startsWith('/api/nodes/')) {
|
|
28
|
+
const nodeId = path?.substring('/api/nodes/'.length);
|
|
29
|
+
connections.remove(nodeId);
|
|
30
|
+
response.statusCode = 200;
|
|
31
|
+
response._res.end();
|
|
32
|
+
} else {
|
|
33
|
+
await next();
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
export default routes;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as ws from 'ws';
|
|
2
|
+
import getLogger from '../../../logger.js';
|
|
3
|
+
import {socketKey} from '../../../utils.ts';
|
|
4
|
+
import {IOGateway} from '@interopio/gateway';
|
|
5
|
+
import GatewayEncoders = IOGateway.Encoding;
|
|
6
|
+
|
|
7
|
+
const logger = getLogger('mesh.ws.broker');
|
|
8
|
+
|
|
9
|
+
type Command =
|
|
10
|
+
// client to broker
|
|
11
|
+
{ type: 'hello', 'node-id': string }
|
|
12
|
+
| { type: 'bye', 'node-id': string }
|
|
13
|
+
| { type: 'data', from: string, to: 'all' | string}
|
|
14
|
+
// broker to client
|
|
15
|
+
| { type: 'node-added', 'node-id': string, 'new-node': string }
|
|
16
|
+
| { type: 'node-removed', 'node-id': string, 'removed-node': string }
|
|
17
|
+
|
|
18
|
+
;
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
function broadcastNodeAdded(nodes: NodeSockets, newSocket: ws.WebSocket, newNodeId: string) {
|
|
22
|
+
Object.entries(nodes.nodes).forEach(([nodeId, socket]) => {
|
|
23
|
+
if (nodeId !== newNodeId) {
|
|
24
|
+
newSocket.send(codec.encode({type: 'node-added', 'node-id': newNodeId, "new-node": nodeId}));
|
|
25
|
+
socket.send(codec.encode({type: 'node-added', 'node-id': nodeId, "new-node": newNodeId}));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function broadcastNodeRemoved(nodes: NodeSockets, removedNodeId: string) {
|
|
31
|
+
Object.entries(nodes.nodes).forEach(([nodeId, socket]) => {
|
|
32
|
+
if (nodeId !== removedNodeId) {
|
|
33
|
+
socket.send(codec.encode({type: 'node-removed', 'node-id': nodeId, "removed-node": removedNodeId}));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function onOpen(connectedNodes: NodeSockets, key: string) {
|
|
39
|
+
logger.info(`[${key}] connection accepted`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onClose(connectedNodes: NodeSockets, key: string, code: number, reason: string): void {
|
|
43
|
+
logger.info(`[${key}] connected closed [${code}](${reason})`);
|
|
44
|
+
const nodeIds = connectedNodes.sockets[key];
|
|
45
|
+
if (nodeIds) {
|
|
46
|
+
delete connectedNodes.sockets[key];
|
|
47
|
+
for (const nodeId of nodeIds) {
|
|
48
|
+
delete connectedNodes.nodes[nodeId];
|
|
49
|
+
}
|
|
50
|
+
for (const nodeId of nodeIds) {
|
|
51
|
+
broadcastNodeRemoved(connectedNodes, nodeId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function processMessage(connectedNodes: NodeSockets, socket: ws.WebSocket, key: string, msg: Command) {
|
|
57
|
+
switch (msg.type) {
|
|
58
|
+
case 'hello': {
|
|
59
|
+
const nodeId = msg['node-id'];
|
|
60
|
+
connectedNodes.nodes[nodeId] = socket;
|
|
61
|
+
connectedNodes.sockets[key] = connectedNodes.sockets[key] ?? [];
|
|
62
|
+
connectedNodes.sockets[key].push(nodeId);
|
|
63
|
+
logger.info(`[${key}] node ${nodeId} added.`);
|
|
64
|
+
broadcastNodeAdded(connectedNodes, socket, nodeId);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'bye': {
|
|
68
|
+
const nodeId = msg["node-id"];
|
|
69
|
+
delete connectedNodes[nodeId];
|
|
70
|
+
logger.info(`[${key}] node ${nodeId} removed.`);
|
|
71
|
+
broadcastNodeRemoved(connectedNodes, nodeId);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'data': {
|
|
75
|
+
const sourceNodeId = msg.from;
|
|
76
|
+
const targetNodeId = msg.to;
|
|
77
|
+
if ('all' === targetNodeId) {
|
|
78
|
+
Object.entries(connectedNodes.nodes).forEach(([nodeId, socket]) => {
|
|
79
|
+
if (nodeId !== sourceNodeId) {
|
|
80
|
+
socket.send(codec.encode(msg));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const socket = connectedNodes.nodes[targetNodeId];
|
|
86
|
+
if (socket) {
|
|
87
|
+
socket.send(codec.encode(msg));
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
logger.warn(`unable to send to node ${targetNodeId} message ${JSON.stringify(msg)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
default: {
|
|
96
|
+
logger.warn(`[${key}] ignoring unknown message ${JSON.stringify(msg)}`);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const codec = GatewayEncoders.transit<Command>({
|
|
103
|
+
keywordize: new Map<string, GatewayEncoders.KeywordizeCommand>([
|
|
104
|
+
['/type', '*'],
|
|
105
|
+
['/message/body/type', '*'],
|
|
106
|
+
['/message/origin', '*'],
|
|
107
|
+
['/message/receiver/type', '*'],
|
|
108
|
+
['/message/source/type', '*'],
|
|
109
|
+
['/message/body/type', '*'],
|
|
110
|
+
])
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function onMessage(connectedNodes: NodeSockets, socket: ws.WebSocket, key: string, msg: string): void {
|
|
114
|
+
try {
|
|
115
|
+
const decoded = codec.decode(msg);
|
|
116
|
+
if (logger.enabledFor('debug')) {
|
|
117
|
+
logger.debug(`[${key}] processing msg ${JSON.stringify(decoded)}`);
|
|
118
|
+
}
|
|
119
|
+
processMessage(connectedNodes, socket, key, decoded);
|
|
120
|
+
} catch (ex) {
|
|
121
|
+
logger.error(`[${key}] unable to process message`, ex);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class WebsocketBroker {
|
|
126
|
+
constructor(private readonly server: ws.WebSocketServer) {
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async close() {
|
|
130
|
+
this.server.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
type NodeSockets = {nodes: {[nodeId: string]: ws.WebSocket}, sockets: {[socket: string]: string[]}};
|
|
134
|
+
|
|
135
|
+
async function create(server: {wss: ws.WebSocketServer}): Promise<{close: () => Promise<void>}> {
|
|
136
|
+
const connectedNodes: NodeSockets = {nodes: {}, sockets: {}};
|
|
137
|
+
logger.info(`mesh server is listening`);
|
|
138
|
+
|
|
139
|
+
server.wss
|
|
140
|
+
.on('error', () => {
|
|
141
|
+
logger.error(`error starting mesh server`);
|
|
142
|
+
})
|
|
143
|
+
.on('connection', (socket, request) => {
|
|
144
|
+
const key = socketKey(request.socket);
|
|
145
|
+
onOpen(connectedNodes, key);
|
|
146
|
+
socket.on('error', (err: Error) => {
|
|
147
|
+
logger.error(`[${key}] websocket error: ${err}`, err);
|
|
148
|
+
});
|
|
149
|
+
socket.on('message', (data, isBinary) => {
|
|
150
|
+
if (Array.isArray(data)) {
|
|
151
|
+
data = Buffer.concat(data);
|
|
152
|
+
}
|
|
153
|
+
onMessage(connectedNodes, socket, key, data as unknown as string);
|
|
154
|
+
});
|
|
155
|
+
socket.on('close', (code, reason) => {
|
|
156
|
+
onClose(connectedNodes, key, code, reason as unknown as string);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return new WebsocketBroker(server.wss);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default create;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as ws from 'ws';
|
|
2
|
+
import {socketKey} from '../../../utils.js';
|
|
3
|
+
import {relays} from '../relays/core.js';
|
|
4
|
+
import getLogger from '../../../logger.js';
|
|
5
|
+
import {HttpServerRequest} from '../../../server/exchange.ts';
|
|
6
|
+
|
|
7
|
+
const logger = getLogger('mesh.ws.cluster');
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
function onMessage(key: string, node: string | undefined, socketsByNodeId: Map<string, Map<string, ws.WebSocket>>, msg: string) {
|
|
11
|
+
try {
|
|
12
|
+
relays.send(key, node, msg, (k, err?: Error) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
logger.warn(`${k} error writing msg ${msg}: ${err}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (logger.enabledFor('debug')) {
|
|
18
|
+
logger.debug(`${k} sent msg ${msg}`);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
} catch (ex) {
|
|
22
|
+
logger.error(`${key} unable to process message`, ex);
|
|
23
|
+
if (node) {
|
|
24
|
+
const socket = socketsByNodeId.get(node)?.get(key);
|
|
25
|
+
socket?.terminate();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function create(server: {wss: ws.WebSocketServer}): Promise<{ close?: () => Promise<void> }> {
|
|
31
|
+
const socketsByNodeId = new Map<string, Map<string, ws.WebSocket>>();
|
|
32
|
+
relays.onMsg = (k, nodeId, msg) => {
|
|
33
|
+
try {
|
|
34
|
+
const sockets = socketsByNodeId.get(nodeId);
|
|
35
|
+
if (sockets && sockets.size > 0) {
|
|
36
|
+
for (const [key, socket] of sockets) {
|
|
37
|
+
socket.send(msg, {binary: false}, (err?: Error) => {
|
|
38
|
+
if (err) {
|
|
39
|
+
logger.warn(`${key} error writing from ${k} msg ${msg}: ${err}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (logger.enabledFor('debug')) {
|
|
43
|
+
logger.debug(`${key} sent from ${k} msg ${msg}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
logger.warn(`${k} dropped msg ${msg}.`);
|
|
49
|
+
}
|
|
50
|
+
} catch (ex) {
|
|
51
|
+
logger.error(`${k} unable to process message`, ex);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
server.wss
|
|
55
|
+
.on('error', () => {
|
|
56
|
+
logger.error(`error starting mesh server`);
|
|
57
|
+
})
|
|
58
|
+
.on('listening', () => {
|
|
59
|
+
logger.info(`mesh server is listening`);
|
|
60
|
+
})
|
|
61
|
+
.on('connection', (socket, req) => {
|
|
62
|
+
const request = new HttpServerRequest(req);
|
|
63
|
+
const key = socketKey(request.socket);
|
|
64
|
+
const query = new URLSearchParams(request.query ?? undefined);
|
|
65
|
+
logger.info(`${key} connected on cluster with ${query}`);
|
|
66
|
+
const node = query.get('node');
|
|
67
|
+
if (node) {
|
|
68
|
+
let sockets = socketsByNodeId.get(node);
|
|
69
|
+
if (!sockets) {
|
|
70
|
+
sockets = new Map<string, ws.WebSocket>();
|
|
71
|
+
socketsByNodeId.set(node, sockets);
|
|
72
|
+
}
|
|
73
|
+
sockets.set(key, socket);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
socket.terminate();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
//onOpen(connectedNodes, key);
|
|
80
|
+
socket.on('error', (err: Error) => {
|
|
81
|
+
logger.error(`${key} websocket error: ${err}`, err);
|
|
82
|
+
});
|
|
83
|
+
socket.on('message', (data, _isBinary) => {
|
|
84
|
+
if (Array.isArray(data)) {
|
|
85
|
+
data = Buffer.concat(data);
|
|
86
|
+
}
|
|
87
|
+
onMessage(key, node, socketsByNodeId, data as unknown as string);
|
|
88
|
+
});
|
|
89
|
+
socket.on('close', (_code, _reason) => {
|
|
90
|
+
logger.info(`${key} disconnected from cluster`);
|
|
91
|
+
const sockets = socketsByNodeId.get(node);
|
|
92
|
+
if (sockets) {
|
|
93
|
+
sockets.delete(key);
|
|
94
|
+
if (sockets.size === 0) {
|
|
95
|
+
socketsByNodeId.delete(node);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
close: async () => {
|
|
102
|
+
server.wss.close();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default create;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as ws from 'ws';
|
|
2
|
+
import {socketKey} from '../../../utils.js';
|
|
3
|
+
import getLogger from '../../../logger.js';
|
|
4
|
+
|
|
5
|
+
import {IOGateway} from '@interopio/gateway';
|
|
6
|
+
import GatewayEncoders = IOGateway.Encoding;
|
|
7
|
+
|
|
8
|
+
const logger = getLogger('mesh.ws.relay');
|
|
9
|
+
|
|
10
|
+
type Command
|
|
11
|
+
= { type: 'hello', 'from': string, to: string }
|
|
12
|
+
| { type: 'bye', 'from': string, to: 'all' | string }
|
|
13
|
+
| { type: 'data', from: string, to: 'all' | string, data: {body: {type: string}}}
|
|
14
|
+
|
|
15
|
+
;
|
|
16
|
+
|
|
17
|
+
const codec = GatewayEncoders.transit<Command>({
|
|
18
|
+
keywordize: new Map<string, GatewayEncoders.KeywordizeCommand>([
|
|
19
|
+
['/type', '*'],
|
|
20
|
+
['/message/body/type', '*'],
|
|
21
|
+
['/message/origin', '*'],
|
|
22
|
+
['/message/receiver/type', '*'],
|
|
23
|
+
['/message/source/type', '*'],
|
|
24
|
+
['/message/body/type', '*'],
|
|
25
|
+
])
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export interface Relays {
|
|
29
|
+
onMsg?: ((key: string, node: string, msg: string) => void);
|
|
30
|
+
onErr?: ((key: string, err: Error) => void)
|
|
31
|
+
send(key: string, node: string | undefined, msg: string, cb: (key: string, err?: Error) => void): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class InternalRelays implements Relays {
|
|
35
|
+
// key -> socket
|
|
36
|
+
private readonly clients = new Map<string, ws.WebSocket>();
|
|
37
|
+
// node -> key
|
|
38
|
+
private readonly links = new Map<string, string>();
|
|
39
|
+
public onMsg?: (key: string, node: string, msg: string) => void
|
|
40
|
+
public onErr?: (key: string, err: Error) => void
|
|
41
|
+
|
|
42
|
+
add(key: string, soc: ws.WebSocket) {
|
|
43
|
+
this.clients.set(key, soc);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
remove(key: string) {
|
|
47
|
+
this.clients.delete(key);
|
|
48
|
+
for (const [node, k] of this.links) {
|
|
49
|
+
if (k === key) {
|
|
50
|
+
this.links.delete(node);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
receive(key: string, msg: string) {
|
|
56
|
+
const node = this.link(key, msg);
|
|
57
|
+
|
|
58
|
+
if (node && this.onMsg) {
|
|
59
|
+
this.onMsg(key, node, msg);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private link(key: string, msg: string): string | undefined {
|
|
64
|
+
try {
|
|
65
|
+
const decoded = codec.decode(msg);
|
|
66
|
+
const {type, from, to} = decoded;
|
|
67
|
+
if (to === 'all') {
|
|
68
|
+
switch (type) {
|
|
69
|
+
case 'hello': {
|
|
70
|
+
if (logger.enabledFor('debug')) {
|
|
71
|
+
logger.debug(`${key} registers node ${from}`);
|
|
72
|
+
}
|
|
73
|
+
this.links.set(from, key);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case 'bye': {
|
|
77
|
+
if (logger.enabledFor('debug')) {
|
|
78
|
+
logger.debug(`${key} unregisters node ${from}`);
|
|
79
|
+
}
|
|
80
|
+
this.links.delete(from);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
return from;
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (this.onErr) {
|
|
89
|
+
// probably a decode error or a bug
|
|
90
|
+
this.onErr(key, e instanceof Error ? e : new Error(`link failed :${e}`));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
logger.warn(`${key} unable to process ${msg}`, e);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
send(key: string, node: string, msg: string, cb: (k: string, err?: Error) => void): void {
|
|
99
|
+
const decoded = codec.decode(msg);
|
|
100
|
+
if (logger.enabledFor('debug')) {
|
|
101
|
+
logger.debug(`${key} sending msg to ${node} ${JSON.stringify(decoded)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const clientKey = this.links.get(node);
|
|
105
|
+
if (clientKey) {
|
|
106
|
+
const client = this.clients.get(clientKey);
|
|
107
|
+
if (client) {
|
|
108
|
+
client.send(msg, {binary: false}, (err?: Error) => {
|
|
109
|
+
cb(clientKey, err)
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`${key} no active link for ${decoded.to}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const internal = new InternalRelays();
|
|
118
|
+
export const relays: Relays = internal;
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async function create(server: {wss:ws.WebSocketServer}): Promise<{ close?: () => Promise<void> }> {
|
|
122
|
+
|
|
123
|
+
logger.info(`relays server is listening`);
|
|
124
|
+
server.wss
|
|
125
|
+
.on('error', () => {
|
|
126
|
+
logger.error(`error starting relays server`);
|
|
127
|
+
})
|
|
128
|
+
.on('connection', (socket, request) => {
|
|
129
|
+
const key = socketKey(request.socket);
|
|
130
|
+
logger.info(`${key} connected on relays`);
|
|
131
|
+
internal.add(key, socket);
|
|
132
|
+
//onOpen(connectedNodes, key);
|
|
133
|
+
socket.on('error', (err: Error) => {
|
|
134
|
+
logger.error(`[${key}] websocket error: ${err}`, err);
|
|
135
|
+
});
|
|
136
|
+
socket.on('message', (data, _isBinary) => {
|
|
137
|
+
if (Array.isArray(data)) {
|
|
138
|
+
data = Buffer.concat(data);
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
internal.receive(key, data as unknown as string);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
logger.warn(`[${key}] error processing received data '${data}'`, e);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
socket.on('close', (code, reason) => {
|
|
147
|
+
internal.remove(key);
|
|
148
|
+
|
|
149
|
+
logger.info(`${key} disconnected from relays`);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
close: async () => {
|
|
154
|
+
server.wss.close();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default create;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {Metrics} from '@interopio/gateway-metrics-api';
|
|
2
|
+
import {Middleware, WebExchange} from '../server/types.js';
|
|
3
|
+
import getLogger from '../logger.js';
|
|
4
|
+
import {FilePublisherConfig} from '@interopio/gateway/metrics/publisher/file';
|
|
5
|
+
import {HttpServerRequest, HttpServerResponse} from '../server/exchange.ts';
|
|
6
|
+
|
|
7
|
+
const logger = getLogger('metrics');
|
|
8
|
+
|
|
9
|
+
const COOKIE_NAME = 'GW_LOGIN';
|
|
10
|
+
|
|
11
|
+
function loggedIn(auth, ctx: WebExchange) {
|
|
12
|
+
if (auth) {
|
|
13
|
+
const cookieHeaderValue = ctx.request.headers.list('cookie');
|
|
14
|
+
const cookie = cookieHeaderValue?.join('; ').split('; ').find(value => value.startsWith(`${COOKIE_NAME}=`));
|
|
15
|
+
return cookie && (parseInt(cookie?.substring(COOKIE_NAME.length + 1)) > Date.now());
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function routes(config: {file?: FilePublisherConfig, auth?}): Promise<Middleware<HttpServerRequest, HttpServerResponse>> {
|
|
21
|
+
const {jsonFileAppender} = await import('@interopio/gateway/metrics/publisher/file');
|
|
22
|
+
const appender = jsonFileAppender(logger);
|
|
23
|
+
await appender.open(config.file?.location ?? 'metrics.ndjson', config.file?.append ?? true);
|
|
24
|
+
return [
|
|
25
|
+
async (ctx, next: () => Promise<void>) => {
|
|
26
|
+
if (ctx.method === 'GET' && ctx.path === '/api/metrics') {
|
|
27
|
+
if (loggedIn(config.auth, ctx)) {
|
|
28
|
+
ctx.response.statusCode = 200;
|
|
29
|
+
} else {
|
|
30
|
+
ctx.response.statusCode = 302;
|
|
31
|
+
ctx.response.headers.set('location', '/api/login?redirectTo=/api/metrics');
|
|
32
|
+
}
|
|
33
|
+
ctx.response._res.end();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
await next();
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
async (ctx, next: () => Promise<void>) => {
|
|
40
|
+
if (ctx.method === 'GET' && ctx.path === '/api/login') {
|
|
41
|
+
const redirectTo = new URLSearchParams(ctx.request.query ?? undefined).get('redirectTo');
|
|
42
|
+
const expires = Date.now() + 180 * 1000;
|
|
43
|
+
ctx.response.headers.add('set-cookie', `${COOKIE_NAME}=${expires}; Path=/api; SameSite=strict`)
|
|
44
|
+
if (redirectTo) {
|
|
45
|
+
ctx.response.statusCode = 302;
|
|
46
|
+
ctx.response.headers.set('location', redirectTo);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
ctx.response.statusCode = 200;
|
|
50
|
+
}
|
|
51
|
+
ctx.response._res.end();
|
|
52
|
+
} else {
|
|
53
|
+
await next();
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async (ctx, next: () => Promise<void>) => {
|
|
57
|
+
if (ctx.method === 'POST' && ctx.path === '/api/metrics') {
|
|
58
|
+
if (loggedIn(config.auth, ctx)) {
|
|
59
|
+
ctx.response.statusCode = 202;
|
|
60
|
+
ctx.response._res.end();
|
|
61
|
+
try {
|
|
62
|
+
const json = await ctx.request.json;
|
|
63
|
+
const update = json as Metrics.Update;
|
|
64
|
+
if (logger.enabledFor('debug')) {
|
|
65
|
+
logger.debug(`${JSON.stringify(update)}`);
|
|
66
|
+
}
|
|
67
|
+
if ((config.file?.status ?? false) || (update.status === undefined)) {
|
|
68
|
+
await appender.write(update);
|
|
69
|
+
}
|
|
70
|
+
} catch (e) {
|
|
71
|
+
logger.error(`error processing metrics`, e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
ctx.response.statusCode = 401;
|
|
76
|
+
ctx.response._res.end();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
await next();
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default routes;
|