@yarkivaev/scada 2.3.64 → 2.3.66

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yarkivaev/scada",
3
- "version": "2.3.64",
3
+ "version": "2.3.66",
4
4
  "description": "SCADA domain objects, state persistence, and plant monitoring",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import capturePng from './cdpShot.js';
2
3
 
3
4
  /**
4
5
  * Loopback Host Chromium accepts for HTTP CDP.
@@ -19,28 +20,54 @@ export function cdpRequest(target, path) {
19
20
  };
20
21
  }
21
22
 
23
+ /**
24
+ * Chooses ws or wss from Host and X-Forwarded-Proto.
25
+ *
26
+ * @param {string} hostHeader - incoming Host
27
+ * @param {string} [proto] - forwarded proto
28
+ * @returns {string} ws or wss
29
+ *
30
+ * @example
31
+ * schemeOf('scada.example');
32
+ */
33
+ export function schemeOf(hostHeader, proto) {
34
+ if (proto === 'https') {
35
+ return 'wss';
36
+ }
37
+ if (proto === 'http') {
38
+ return 'ws';
39
+ }
40
+ if (hostHeader.endsWith(':443')) {
41
+ return 'wss';
42
+ }
43
+ return hostHeader.includes(':') ? 'ws' : 'wss';
44
+ }
45
+
22
46
  /**
23
47
  * Rewrites CDP debugger URLs onto the same-origin inspect prefix.
24
48
  *
25
49
  * @param {string} text - upstream JSON or HTML
26
50
  * @param {string} hostHeader - incoming Host
27
51
  * @param {string} nodeId - topology node id
52
+ * @param {string} [proto] - forwarded proto
28
53
  * @returns {string} rewritten body
29
54
  *
30
55
  * @example
31
56
  * rewriteDebugger(body, 'example.test:443', 'm1');
32
57
  */
33
- export function rewriteDebugger(text, hostHeader, nodeId) {
34
- const secure = hostHeader.endsWith(':443');
35
- const scheme = secure ? 'wss' : 'ws';
58
+ export function rewriteDebugger(text, hostHeader, nodeId, proto) {
59
+ const scheme = schemeOf(hostHeader, proto);
60
+ const httpScheme = scheme === 'wss' ? 'https' : 'http';
36
61
  const host = hostHeader.replace(/:443$/u, '').replace(/:80$/u, '');
37
62
  const prefix = `/infra/inspect/${nodeId}`;
38
63
  const origin = `${scheme}://${host}${prefix}`;
39
- const query = `$<scheme>=${host}${prefix}`;
64
+ const query = `${scheme}=${host}${prefix}`;
65
+ const frontend = `${httpScheme}://${host}${prefix}/devtools/inspector.html`;
40
66
  return text
67
+ .replace(/https:\/\/chrome-devtools-frontend\.appspot\.com\/serve_rev\/@[^"'?\s]+\/inspector\.html/gu, frontend)
41
68
  .replace(/"\/devtools\//gu, `"${prefix}/devtools/`)
42
69
  .replace(/wss?:\/\/127\.0\.0\.1:\d+/gu, origin)
43
- .replace(/(?<scheme>wss?)=127\.0\.0\.1:\d+/gu, query);
70
+ .replace(/(?:wss?)=127\.0\.0\.1:\d+/gu, query);
44
71
  }
45
72
 
46
73
  function readBody(res) {
@@ -68,43 +95,6 @@ function requestCdp(target, path) {
68
95
  });
69
96
  }
70
97
 
71
- function capturePng(wsUrl) {
72
- return new Promise((resolve, reject) => {
73
- const socket = new globalThis.WebSocket(wsUrl);
74
- const timer = setTimeout(() => {
75
- socket.close();
76
- reject(new Error(`cdp screenshot timed out for ${wsUrl}`));
77
- }, 10000);
78
- socket.addEventListener('error', (err) => {
79
- clearTimeout(timer);
80
- reject(err);
81
- });
82
- socket.addEventListener('open', () => {
83
- socket.send(JSON.stringify({ id: 1, method: 'Page.captureScreenshot', params: { format: 'png' } }));
84
- });
85
- socket.addEventListener('message', (event) => {
86
- const message = JSON.parse(String(event.data));
87
- if (message.id !== 1) {
88
- return;
89
- }
90
- clearTimeout(timer);
91
- socket.close();
92
- resolve(Buffer.from(message.result.data, 'base64'));
93
- });
94
- });
95
- }
96
-
97
- async function defaultShot(target, requestImpl) {
98
- const listed = await requestImpl(target, '/json/list');
99
- const pages = JSON.parse(listed.body.toString('utf8'));
100
- const page = Array.isArray(pages) ? pages[0] : pages;
101
- if (!page || !page.webSocketDebuggerUrl) {
102
- throw new Error(`cdp page websocket is missing on ${target.host}:${target.port}`);
103
- }
104
- const wsUrl = page.webSocketDebuggerUrl.replace(/wss?:\/\/127\.0\.0\.1:\d+/u, `ws://${target.host}:${target.port}`);
105
- return capturePng(wsUrl);
106
- }
107
-
108
98
  /**
109
99
  * HTTP CDP client that forces a loopback Host and rewrites debugger URLs.
110
100
  *
@@ -118,12 +108,12 @@ async function defaultShot(target, requestImpl) {
118
108
  export default function cdpGateway(requestImpl, shotImpl) {
119
109
  const request = requestImpl || requestCdp;
120
110
  const shot = shotImpl || ((target) => {
121
- return defaultShot(target, request);
111
+ return capturePng(target, request);
122
112
  });
123
113
  return {
124
- async json(target, path, hostHeader, nodeId) {
114
+ async json(target, path, hostHeader, nodeId, proto) {
125
115
  const result = await request(target, path);
126
- return rewriteDebugger(result.body.toString('utf8'), hostHeader, nodeId);
116
+ return rewriteDebugger(result.body.toString('utf8'), hostHeader, nodeId, proto);
127
117
  },
128
118
  shot(target) {
129
119
  return shot(target);
@@ -0,0 +1,122 @@
1
+ import http from 'node:http';
2
+ import crypto from 'node:crypto';
3
+
4
+ function encode(text) {
5
+ const payload = Buffer.from(text, 'utf8');
6
+ const mask = crypto.randomBytes(4);
7
+ const size = payload.length < 126 ? payload.length : 126;
8
+ const head = payload.length < 126
9
+ ? Buffer.from([0x81, 0x80 | size])
10
+ : Buffer.concat([Buffer.from([0x81, 0x80 | 126]), Buffer.from([payload.length >> 8, payload.length & 255])]);
11
+ const masked = Buffer.from(payload);
12
+ for (let i = 0; i < payload.length; i += 1) {
13
+ masked[i] ^= mask[i % 4];
14
+ }
15
+ return Buffer.concat([head, mask, masked]);
16
+ }
17
+
18
+ function header(buf) {
19
+ if (buf.length < 2) {
20
+ return undefined;
21
+ }
22
+ const code = buf[1] & 127;
23
+ if (code < 126) {
24
+ return { len: code, offset: 2 };
25
+ }
26
+ if (code === 126 && buf.length >= 4) {
27
+ return { len: buf.readUInt16BE(2), offset: 4 };
28
+ }
29
+ if (code === 127 && buf.length >= 10) {
30
+ return { len: Number(buf.readBigUInt64BE(2)), offset: 10 };
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ function decode(buf) {
36
+ const meta = header(buf);
37
+ if (!meta || buf.length < meta.offset + meta.len) {
38
+ return undefined;
39
+ }
40
+ const opcode = buf[0] & 15;
41
+ if (opcode !== 1 && opcode !== 2) {
42
+ return undefined;
43
+ }
44
+ return buf.subarray(meta.offset, meta.offset + meta.len);
45
+ }
46
+
47
+ function openSocket(target, path) {
48
+ return new Promise((resolve, reject) => {
49
+ const req = http.request({
50
+ hostname: target.host,
51
+ port: target.port,
52
+ path,
53
+ method: 'GET',
54
+ headers: {
55
+ Host: `127.0.0.1:${target.port}`,
56
+ Connection: 'Upgrade',
57
+ Upgrade: 'websocket',
58
+ 'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'),
59
+ 'Sec-WebSocket-Version': '13'
60
+ }
61
+ });
62
+ req.on('upgrade', (res, socket) => {
63
+ resolve(socket);
64
+ });
65
+ req.on('error', reject);
66
+ req.end();
67
+ });
68
+ }
69
+
70
+ function pathOf(wsUrl) {
71
+ return new URL(wsUrl.replace(/^wss:/u, 'http:').replace(/^ws:/u, 'http:')).pathname;
72
+ }
73
+
74
+ /**
75
+ * Captures a PNG screenshot over CDP without a global WebSocket constructor.
76
+ *
77
+ * @param {object} target - inspect { host, port }
78
+ * @param {function} requestImpl - (target, path) => { body }
79
+ * @returns {Promise<Buffer>} png bytes
80
+ *
81
+ * @example
82
+ * await capturePng({ host: '127.0.0.1', port: 9222 }, requestCdp);
83
+ */
84
+ export default async function capturePng(target, requestImpl) {
85
+ const listed = await requestImpl(target, '/json/list');
86
+ const pages = JSON.parse(listed.body.toString('utf8'));
87
+ const page = Array.isArray(pages) ? pages[0] : pages;
88
+ if (!page || !page.webSocketDebuggerUrl) {
89
+ throw new Error(`cdp page websocket is missing on ${target.host}:${target.port}`);
90
+ }
91
+ const socket = await openSocket(target, pathOf(page.webSocketDebuggerUrl));
92
+ return new Promise((resolve, reject) => {
93
+ const timer = setTimeout(() => {
94
+ socket.destroy();
95
+ reject(new Error(`cdp screenshot timed out for ${target.host}:${target.port}`));
96
+ }, 10000);
97
+ const chunks = [];
98
+ socket.on('data', (chunk) => {
99
+ chunks.push(chunk);
100
+ const payload = decode(Buffer.concat(chunks));
101
+ if (!payload) {
102
+ return;
103
+ }
104
+ const message = JSON.parse(payload.toString('utf8'));
105
+ if (message.id !== 1) {
106
+ return;
107
+ }
108
+ clearTimeout(timer);
109
+ socket.destroy();
110
+ resolve(Buffer.from(message.result.data, 'base64'));
111
+ });
112
+ socket.on('error', (err) => {
113
+ clearTimeout(timer);
114
+ reject(err);
115
+ });
116
+ socket.write(encode(JSON.stringify({
117
+ id: 1,
118
+ method: 'Page.captureScreenshot',
119
+ params: { format: 'png' }
120
+ })));
121
+ });
122
+ }
@@ -43,6 +43,9 @@ export default function infraRoute() {
43
43
  route('GET', '/infra/graph.js', (req, res) => {
44
44
  sendFile(res, 'infra/graph.js', 'text/javascript; charset=utf-8');
45
45
  }),
46
+ route('GET', '/infra/graphLayout.js', (req, res) => {
47
+ sendFile(res, 'infra/graphLayout.js', 'text/javascript; charset=utf-8');
48
+ }),
46
49
  route('GET', '/infra/panel.js', (req, res) => {
47
50
  sendFile(res, 'infra/panel.js', 'text/javascript; charset=utf-8');
48
51
  })
@@ -16,6 +16,10 @@ function hostOf(req) {
16
16
  return req.headers.host || req.headers.Host || 'localhost';
17
17
  }
18
18
 
19
+ function protoOf(req) {
20
+ return req.headers['x-forwarded-proto'] || req.headers['X-Forwarded-Proto'];
21
+ }
22
+
19
23
  function cdpPath(rest) {
20
24
  if (rest === '/json' || rest === '/json/list') {
21
25
  return '/json/list';
@@ -39,7 +43,7 @@ function queryOf(url) {
39
43
 
40
44
  async function sendJson(cdp, target, req, res, nodeId) {
41
45
  const path = cdpPath(inspectUrl(req.url).path);
42
- const body = await cdp.json(target, path, hostOf(req), nodeId);
46
+ const body = await cdp.json(target, path, hostOf(req), nodeId, protoOf(req));
43
47
  res.writeHead(200, { 'Content-Type': 'application/json' });
44
48
  res.end(body);
45
49
  }
@@ -1,3 +1,5 @@
1
+ import positions from './graphLayout.js';
2
+
1
3
  const SVG = 'http://www.w3.org/2000/svg';
2
4
 
3
5
  function shops(nodes) {
@@ -6,31 +8,11 @@ function shops(nodes) {
6
8
  });
7
9
  }
8
10
 
9
- function children(nodes, parentId, kind) {
10
- return nodes.filter((node) => {
11
- return node.parent === parentId && node.kind === kind;
12
- });
13
- }
14
-
15
- function placed(graph) {
16
- const positions = {};
17
- shops(graph.nodes).forEach((area, i) => {
18
- const x = 170 + i * 280;
19
- positions[area.id] = { x, y: 56 };
20
- children(graph.nodes, area.id, 'machine').forEach((item, j) => {
21
- const y = 150 + j * 96;
22
- positions[item.id] = { x, y };
23
- children(graph.nodes, item.id, 'collector').forEach((col, k) => {
24
- positions[col.id] = { x: x + 96, y: y + k * 26 };
25
- });
26
- });
27
- });
28
- graph.nodes.filter((node) => {
29
- return node.kind === 'collector' && !positions[node.id];
30
- }).forEach((node, i) => {
31
- positions[node.id] = { x: 72, y: 88 + i * 54 };
11
+ function floor(placed) {
12
+ const ys = Object.values(placed).map((item) => {
13
+ return item.y + 80;
32
14
  });
33
- return positions;
15
+ return Math.max(520, ...ys);
34
16
  }
35
17
 
36
18
  function el(name, attrs) {
@@ -41,10 +23,10 @@ function el(name, attrs) {
41
23
  return node;
42
24
  }
43
25
 
44
- function paintLink(svg, graph, positions) {
26
+ function paintLink(svg, graph, laid) {
45
27
  graph.links.forEach((link) => {
46
- const from = positions[link.from];
47
- const to = positions[link.to];
28
+ const from = laid[link.from];
29
+ const to = laid[link.to];
48
30
  if (!from || !to) {
49
31
  return;
50
32
  }
@@ -95,12 +77,12 @@ export default function draw(svg, graph, onClick) {
95
77
  svg.removeChild(svg.firstChild);
96
78
  }
97
79
  const columns = Math.max(shops(graph.nodes).length, 1);
98
- svg.setAttribute('viewBox', `0 0 ${Math.max(960, columns * 280 + 120)} 520`);
99
- const positions = placed(graph);
100
- paintLink(svg, graph, positions);
80
+ const laid = positions(graph);
81
+ svg.setAttribute('viewBox', `0 0 ${Math.max(960, columns * 280 + 120)} ${floor(laid)}`);
82
+ paintLink(svg, graph, laid);
101
83
  graph.nodes.forEach((node) => {
102
- if (positions[node.id]) {
103
- paintNode(svg, node, positions[node.id], onClick);
84
+ if (laid[node.id]) {
85
+ paintNode(svg, node, laid[node.id], onClick);
104
86
  }
105
87
  });
106
88
  }
@@ -0,0 +1,68 @@
1
+ function shops(nodes) {
2
+ return nodes.filter((node) => {
3
+ return node.kind === 'shop';
4
+ });
5
+ }
6
+
7
+ function children(nodes, parentId, kind) {
8
+ return nodes.filter((node) => {
9
+ return node.parent === parentId && node.kind === kind;
10
+ });
11
+ }
12
+
13
+ function nest(nodes, item, x, y, placed) {
14
+ children(nodes, item.id, 'collector').forEach((col, k) => {
15
+ placed[col.id] = { x: x + 96, y: y + k * 26 };
16
+ });
17
+ }
18
+
19
+ function machines(nodes, area, x, base, placed) {
20
+ children(nodes, area.id, 'machine').forEach((item, j) => {
21
+ const y = base + j * 96;
22
+ placed[item.id] = { x, y };
23
+ nest(nodes, item, x, y, placed);
24
+ });
25
+ }
26
+
27
+ function extras(nodes, area, x, placed) {
28
+ const items = children(nodes, area.id, 'collector');
29
+ items.forEach((col, k) => {
30
+ placed[col.id] = { x, y: 108 + k * 28 };
31
+ });
32
+ return items.length;
33
+ }
34
+
35
+ function column(area, index, nodes, placed) {
36
+ const x = 170 + index * 280;
37
+ placed[area.id] = { x, y: 56 };
38
+ const count = extras(nodes, area, x, placed);
39
+ const base = count === 0 ? 150 : 108 + count * 28 + 50;
40
+ machines(nodes, area, x, base, placed);
41
+ }
42
+
43
+ function leftovers(nodes, placed) {
44
+ nodes.filter((node) => {
45
+ return node.kind === 'collector' && !placed[node.id];
46
+ }).forEach((node, i) => {
47
+ placed[node.id] = { x: 72, y: 88 + i * 54 };
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Column positions for topology nodes by parent and kind.
53
+ *
54
+ * @param {object} graph - topology contract { nodes }
55
+ * @returns {object} id -> { x, y }
56
+ *
57
+ * @example
58
+ * positions({ nodes: [{ id: 'area-1', kind: 'shop' }] })['area-1'].x === 170;
59
+ */
60
+ export default function positions(graph) {
61
+ const nodes = graph.nodes || [];
62
+ const placed = {};
63
+ shops(nodes).forEach((area, i) => {
64
+ column(area, i, nodes, placed);
65
+ });
66
+ leftovers(nodes, placed);
67
+ return placed;
68
+ }