@yarkivaev/scada 2.3.114 → 2.3.116

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.
Files changed (42) hide show
  1. package/README.md +109 -0
  2. package/charts/scada/Chart.yaml +2 -2
  3. package/charts/scada/templates/_helpers.tpl +12 -4
  4. package/charts/scada/templates/clickhouse.yaml +3 -3
  5. package/charts/scada/templates/collector.yaml +2 -2
  6. package/charts/scada/templates/namespace.yaml +6 -0
  7. package/charts/scada/templates/postgres.yaml +3 -3
  8. package/charts/scada/templates/processes.yaml +88 -0
  9. package/charts/scada/templates/rabbitmq-config.yaml +91 -0
  10. package/charts/scada/templates/rabbitmq.yaml +54 -113
  11. package/charts/scada/templates/site-server.yaml +5 -3
  12. package/charts/scada/templates/supervisor-sink.yaml +4 -2
  13. package/charts/scada/values.schema.json +22 -0
  14. package/charts/scada/values.yaml +21 -0
  15. package/index.js +3 -0
  16. package/package.json +8 -3
  17. package/src/application/deploy/collectorProcesses.js +43 -0
  18. package/src/application/deploy/expandFlows.js +21 -10
  19. package/src/application/deploy/publishedStreams.js +30 -0
  20. package/src/application/deploy/releaseEdges.js +13 -16
  21. package/src/application/deploy/releaseNodes.js +13 -0
  22. package/src/application/deploy/releaseValues.js +4 -15
  23. package/src/application/deploy/releases.js +3 -1
  24. package/src/application/deploy/subscriptions.js +20 -0
  25. package/src/infrastructure/deploy/executeRelease.js +102 -0
  26. package/src/infrastructure/ingest/durableConsume.js +2 -169
  27. package/src/infrastructure/ingest/modbus/silentStreams.js +9 -11
  28. package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
  29. package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +7 -18
  30. package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +8 -17
  31. package/src/infrastructure/messaging/rabbitmq/definitions.js +24 -0
  32. package/src/infrastructure/messaging/rabbitmq/reconcile.js +104 -0
  33. package/src/infrastructure/opcua/config.js +38 -0
  34. package/src/infrastructure/opcua/endpoint.js +80 -0
  35. package/src/infrastructure/opcua/index.js +10 -0
  36. package/src/infrastructure/opcua/session.js +60 -0
  37. package/src/infrastructure/opcua/source.js +35 -0
  38. package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
  39. package/src/infrastructure/operators/operatorsSync.js +5 -29
  40. package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
  41. package/src/infrastructure/persistence/metricsSensor.js +9 -6
  42. package/src/infrastructure/sync/operationSyncIngest.js +4 -16
@@ -1,169 +1,2 @@
1
- import amqp from 'amqplib';
2
-
3
- function connectBroker(url) {
4
- return amqp.connect(url, { heartbeat: 10 });
5
- }
6
-
7
- function scheduleLater(fn, pause) {
8
- return setTimeout(fn, pause);
9
- }
10
-
11
- function clearLater(handle) {
12
- clearTimeout(handle);
13
- }
14
-
15
- function reportFailure(error) {
16
- process.stderr.write(`amqp consume failed ${error.message}\n`);
17
- }
18
-
19
- function nextDelay(delay) {
20
- return Math.min(delay * 2, 30000);
21
- }
22
-
23
- function blankState() {
24
- return {
25
- generation: 0,
26
- timer: undefined,
27
- stopping: false,
28
- delay: 1000,
29
- session: undefined
30
- };
31
- }
32
-
33
- function running(state) {
34
- return Boolean(state.session) || (state.generation > 0 && !state.stopping);
35
- }
36
-
37
- function scheduleRetry(ctx, generation) {
38
- if (ctx.state.stopping || generation !== ctx.state.generation) {
39
- return;
40
- }
41
- const pause = ctx.state.delay;
42
- ctx.state.delay = nextDelay(ctx.state.delay);
43
- ctx.state.timer = ctx.schedule(() => {
44
- ctx.state.timer = undefined;
45
- return ctx.open(generation);
46
- }, pause);
47
- }
48
-
49
- function bindClose(ctx, generation, live) {
50
- let dropped = false;
51
- function lose() {
52
- if (dropped || ctx.state.stopping || generation !== ctx.state.generation) {
53
- return;
54
- }
55
- dropped = true;
56
- if (ctx.state.session === live) {
57
- ctx.state.session = undefined;
58
- }
59
- scheduleRetry(ctx, generation);
60
- }
61
- live.connection.on('error', lose);
62
- live.connection.on('close', lose);
63
- live.channel.on('error', lose);
64
- live.channel.on('close', lose);
65
- }
66
-
67
- async function accept(ctx, generation, connection) {
68
- if (ctx.state.stopping || generation !== ctx.state.generation) {
69
- await connection.close();
70
- return null;
71
- }
72
- return connection.createChannel();
73
- }
74
-
75
- async function keep(ctx, generation, connection, channel, tag) {
76
- if (ctx.state.stopping || generation !== ctx.state.generation) {
77
- await channel.close();
78
- await connection.close();
79
- return false;
80
- }
81
- const live = { connection, channel, tag };
82
- ctx.state.session = live;
83
- ctx.state.delay = 1000;
84
- bindClose(ctx, generation, live);
85
- return true;
86
- }
87
-
88
- async function open(ctx, generation) {
89
- if (ctx.state.stopping || generation !== ctx.state.generation) {
90
- return;
91
- }
92
- try {
93
- const connection = await ctx.connect(ctx.url);
94
- const channel = await accept(ctx, generation, connection);
95
- if (!channel) {
96
- return;
97
- }
98
- const tag = await ctx.subscribe(channel);
99
- await keep(ctx, generation, connection, channel, tag);
100
- } catch (error) {
101
- ctx.report(error);
102
- scheduleRetry(ctx, generation);
103
- }
104
- }
105
-
106
- function begin(ctx) {
107
- if (running(ctx.state)) {
108
- return Promise.resolve();
109
- }
110
- ctx.state.stopping = false;
111
- ctx.state.generation += 1;
112
- ctx.state.delay = 1000;
113
- return open(ctx, ctx.state.generation);
114
- }
115
-
116
- async function end(ctx) {
117
- ctx.state.stopping = true;
118
- ctx.state.generation += 1;
119
- if (ctx.state.timer) {
120
- ctx.clear(ctx.state.timer);
121
- ctx.state.timer = undefined;
122
- }
123
- const live = ctx.state.session;
124
- ctx.state.session = undefined;
125
- if (!live) {
126
- return;
127
- }
128
- await live.channel.cancel(live.tag);
129
- await live.channel.close();
130
- await live.connection.close();
131
- }
132
-
133
- /**
134
- * Keeps one AMQP consumer subscribed across broker restarts.
135
- *
136
- * @param {string} url - RabbitMQ AMQP URL
137
- * @param {function} subscribe - async (channel) => consumerTag
138
- * @param {object} [options] - connect, schedule, clear, report overrides
139
- * @returns {object} session with start and stop
140
- *
141
- * @example
142
- * const session = durableConsume('amqp://localhost', async (channel) => {
143
- * const tag = await channel.consume('scada.telemetry.ingest', () => {});
144
- * return tag.consumerTag;
145
- * });
146
- * await session.start();
147
- */
148
- export default function durableConsume(url, subscribe, options = {}) {
149
- const ctx = {
150
- url,
151
- subscribe,
152
- connect: options.connect || connectBroker,
153
- schedule: options.schedule || scheduleLater,
154
- clear: options.clear || clearLater,
155
- report: options.report || reportFailure,
156
- state: blankState(),
157
- open(generation) {
158
- return open(ctx, generation);
159
- }
160
- };
161
- return {
162
- start() {
163
- return begin(ctx);
164
- },
165
- stop() {
166
- return end(ctx);
167
- }
168
- };
169
- }
1
+ // Compatibility facade; transport lifecycle belongs to source-to-sink.
2
+ export { amqpSession as default } from '@yarkivaev/source-to-sink';
@@ -1,4 +1,4 @@
1
- import { clock } from '@yarkivaev/source-to-sink';
1
+ import { clock, intervalSource } from '@yarkivaev/source-to-sink';
2
2
 
3
3
  /**
4
4
  * Whether a stream is past the silence budget.
@@ -83,15 +83,12 @@ function gateState(config) {
83
83
  intervalSec: interval,
84
84
  sources,
85
85
  clk: config.clock || clock(),
86
- delay: config.delay || ((fn, ms) => {
87
- return setInterval(fn, ms);
88
- }),
89
- clear: config.clear || clearInterval,
86
+ delay: config.delay || setTimeout,
87
+ clear: config.clear || clearTimeout,
90
88
  lastSeen: new Map(),
91
89
  held: new Map(),
92
90
  live: new Set(),
93
91
  collector: undefined,
94
- timer: undefined,
95
92
  running: false
96
93
  };
97
94
  }
@@ -119,6 +116,9 @@ export default function silentStreams(config) {
119
116
  function runPulse() {
120
117
  pulse(state);
121
118
  }
119
+ const ticker = intervalSource(state.intervalSec * 1000, { accept: runPulse }, {
120
+ schedule: state.delay, clear: state.clear
121
+ });
122
122
  return {
123
123
  seen(name, at = state.clk.millis()) {
124
124
  state.lastSeen.set(name, at);
@@ -134,16 +134,14 @@ export default function silentStreams(config) {
134
134
  };
135
135
  },
136
136
  start() {
137
+ if (state.running) { return; }
137
138
  state.running = true;
138
139
  runPulse();
139
- state.timer = state.delay(runPulse, state.intervalSec * 1000);
140
+ void ticker.start();
140
141
  },
141
142
  stop() {
142
143
  state.running = false;
143
- if (state.timer !== undefined) {
144
- state.clear(state.timer);
145
- state.timer = undefined;
146
- }
144
+ void ticker.stop();
147
145
  for (const name of [...state.live]) {
148
146
  stopOne(state, name);
149
147
  }
@@ -1,3 +1,5 @@
1
+ import { intervalSource } from '@yarkivaev/source-to-sink';
2
+
1
3
  /**
2
4
  * Periodic wall-clock sweep that closes stale open segments without waiting for STOMP.
3
5
  *
@@ -18,46 +20,7 @@ export default function silentOpenWatch(closer, budget, options) {
18
20
  throw new Error(`Budget must be a positive number: ${budget}`);
19
21
  }
20
22
  const intervalMs = options && options.intervalMs ? options.intervalMs : 5000;
21
- const gate = { busy: false };
22
- let timer;
23
- let active = false;
24
- function claim() {
25
- const free = !gate.busy;
26
- gate.busy = true;
27
- return free;
28
- }
29
- function release() {
30
- gate.busy = false;
31
- }
32
- async function tick() {
33
- if (!claim()) {
34
- return;
35
- }
36
- try {
37
- await closer.close(budget);
38
- } catch {
39
- /* next tick retries */
40
- }
41
- release();
42
- }
43
- return {
44
- async start() {
45
- if (active) {
46
- return;
47
- }
48
- active = true;
49
- await tick();
50
- timer = setInterval(() => {
51
- void tick();
52
- }, intervalMs);
53
- },
54
- stop() {
55
- if (!active) {
56
- return;
57
- }
58
- active = false;
59
- clearInterval(timer);
60
- timer = undefined;
61
- }
62
- };
23
+ return intervalSource(intervalMs, {
24
+ accept() { return closer.close(budget); }
25
+ }, { immediate: true });
63
26
  }
@@ -1,5 +1,4 @@
1
- import { batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
2
- import durableConsume from '../durableConsume.js';
1
+ import { amqpSource, batch, circuit, clock, timedBatch } from '@yarkivaev/source-to-sink';
3
2
  import deliverToMqttRecord from './deliverToMqttRecord.js';
4
3
  import metricsCodec from '../mqtt/metricsTransformer.js';
5
4
  import streamNameFromTopic from './streamNameFromTopic.js';
@@ -20,7 +19,7 @@ export function acceptTelemetryDeliver(codec, fields, content, onSeen) {
20
19
  onSeen(name);
21
20
  }
22
21
  }
23
- codec.accept({ topic: record.topic, payload: record.payload.toString() });
22
+ return codec.accept({ topic: record.topic, payload: record.payload.toString() });
24
23
  }
25
24
 
26
25
  /**
@@ -50,16 +49,6 @@ export function telemetryConsumer(codec, channel, onSeen) {
50
49
  * @param {object} [options] - batch and prefetch options
51
50
  * @returns {object} ingest with start and stop
52
51
  */
53
- function subscribeMetrics(queue, prefetch, codec, onSeen) {
54
- return async (channel) => {
55
- await channel.assertQueue(queue, { durable: true });
56
- channel.prefetch(prefetch);
57
- const onMessage = telemetryConsumer(codec, channel, onSeen);
58
- const tag = await channel.consume(queue, onMessage, { noAck: false });
59
- return tag.consumerTag;
60
- };
61
- }
62
-
63
52
  export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
64
53
  const prefetch = options.prefetch || 32;
65
54
  const size = options.size || 100;
@@ -70,11 +59,11 @@ export default function amqpMetricsIngest(amqpUrl, queue, sink, options = {}) {
70
59
  const breaker = circuit(threshold, timeout, clk);
71
60
  const collector = timedBatch(batch(sink, size, breaker), interval);
72
61
  const codec = metricsCodec(collector);
73
- const session = durableConsume(
74
- amqpUrl,
75
- subscribeMetrics(queue, prefetch, codec, options.onSeen),
76
- options
77
- );
62
+ const session = amqpSource(amqpUrl, queue, {
63
+ accept(delivery) {
64
+ return acceptTelemetryDeliver(codec, delivery.fields, delivery.content, options.onSeen);
65
+ }
66
+ }, { ...options, prefetch });
78
67
  return {
79
68
  start() {
80
69
  return session.start();
@@ -1,4 +1,4 @@
1
- import durableConsume from '../durableConsume.js';
1
+ import { amqpSource } from '@yarkivaev/source-to-sink';
2
2
  import deliverToMqttRecord from './deliverToMqttRecord.js';
3
3
 
4
4
  /**
@@ -34,24 +34,14 @@ export function relayConsumer(sink, channel) {
34
34
  * @param {object} [options] - prefetch count
35
35
  * @returns {object} Relay with start and stop
36
36
  */
37
- function subscribeRelay(queue, prefetch, sink) {
38
- return async (channel) => {
39
- await channel.assertQueue(queue, { durable: true });
40
- channel.prefetch(prefetch);
41
- const onMessage = relayConsumer(sink, channel);
42
- const tag = await channel.consume(queue, onMessage, { noAck: false });
43
- return tag.consumerTag;
44
- };
45
- }
46
-
47
37
  export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
48
38
  const prefetch = options.prefetch || 32;
49
39
  let opened = false;
50
- const session = durableConsume(
51
- amqpUrl,
52
- subscribeRelay(queue, prefetch, sink),
53
- options
54
- );
40
+ const session = amqpSource(amqpUrl, queue, {
41
+ accept(delivery) {
42
+ return sink.write([deliverToMqttRecord(delivery.fields, delivery.content)]);
43
+ }
44
+ }, { ...options, prefetch });
55
45
  return {
56
46
  start() {
57
47
  if (!opened) {
@@ -62,7 +52,8 @@ export default function amqpMqttRelay(amqpUrl, queue, sink, options = {}) {
62
52
  },
63
53
  async stop() {
64
54
  await session.stop();
65
- sink.stop();
55
+ await sink.stop();
56
+ opened = false;
66
57
  }
67
58
  };
68
59
  }
@@ -0,0 +1,24 @@
1
+ /** Builds durable topic exchanges, queues and AMQP 0-9-1 outboxes for one virtual host. */
2
+ export default function rabbitmqDefinitions({ vhost, users = [], permissions = [], exchanges = [],
3
+ queues = [], bindings = [], outboxes = [] }) {
4
+ if (!vhost) { throw new Error('a virtual host is required'); }
5
+ const routes = outboxes.flatMap(({ exchange, queue, keys }) => {
6
+ return keys.map((key) => { return { source: exchange, destination: queue, key }; });
7
+ });
8
+ const names = [...queues, ...outboxes.map(({ queue }) => { return queue; })];
9
+ return { vhosts: [{ name: vhost }], users, permissions,
10
+ exchanges: exchanges.map((name) => { return { name, vhost, type: 'topic', durable: true, auto_delete: false }; }),
11
+ queues: [...new Set(names)].map((name) => { return { name, vhost, durable: true, auto_delete: false, arguments: {} }; }),
12
+ bindings: [...bindings, ...routes].map(({ source, destination, key }) => {
13
+ return { source, vhost, destination, destination_type: 'queue', routing_key: key, arguments: {} };
14
+ }),
15
+ parameters: outboxes.filter(({ shovel }) => { return shovel; }).map(({ queue, shovel }) => {
16
+ return { vhost, component: 'shovel', name: shovel.name, value: {
17
+ 'src-protocol': 'amqp091', 'src-uri': shovel.sourceUri, 'src-queue': queue,
18
+ 'dest-protocol': 'amqp091', 'dest-uri': shovel.destinationUri,
19
+ 'dest-exchange': shovel.destinationExchange, 'reconnect-delay': shovel.reconnectDelay,
20
+ 'dest-publish-properties': { delivery_mode: 2 }
21
+ } };
22
+ })
23
+ };
24
+ }
@@ -0,0 +1,104 @@
1
+ /** This module is self-contained so clients can ship it to an existing Node.js pod. */
2
+ function canonical(value) {
3
+ if (Array.isArray(value)) { return value.map(canonical); }
4
+ if (value && typeof value === 'object') {
5
+ return Object.fromEntries(Object.keys(value).sort().map((key) => {
6
+ return [key, canonical(value[key])];
7
+ }));
8
+ }
9
+ return value;
10
+ }
11
+
12
+ /** Runtime counters and other fields outside the declaration do not require a write. */
13
+ function same(actual, desired) {
14
+ return actual && Object.keys(desired).every((key) => {
15
+ return JSON.stringify(canonical(actual[key])) === JSON.stringify(canonical(desired[key]));
16
+ });
17
+ }
18
+
19
+ function apiAt({ base, username, password }) {
20
+ if (!base || typeof username !== 'string' || typeof password !== 'string') {
21
+ throw new Error('management base, username and password are required');
22
+ }
23
+ const auth = Buffer.from(`${username}:${password}`).toString('base64');
24
+ return async (method, path, body) => {
25
+ const response = await fetch(`${base.replace(/\/$/u, '')}/api/${path}`, { method,
26
+ headers: { authorization: `Basic ${auth}`, 'content-type': 'application/json' },
27
+ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
28
+ if (!response.ok) { throw new Error(`broker ${method} ${path}: HTTP ${response.status}`); }
29
+ const text = await response.text();
30
+ return text ? JSON.parse(text) : null;
31
+ };
32
+ }
33
+
34
+ /** A conflicting immutable declaration fails in place, preserving the stored queue. */
35
+ async function declare(api, path, desired) {
36
+ const current = await api('GET', path);
37
+ const changes = desired.filter((item) => {
38
+ return !same(current.find((row) => { return row.name === item.name; }), item);
39
+ });
40
+ await Promise.all(changes.map((item) => { return api('PUT', `${path}/${encodeURIComponent(item.name)}`, item); }));
41
+ return changes.length;
42
+ }
43
+
44
+ function identity(item) {
45
+ return { source: item.source, destination: item.destination, kind: item.destination_type || 'queue',
46
+ key: item.routing_key, arguments: item.arguments || {} };
47
+ }
48
+
49
+ function bindingPath(path, item) {
50
+ const kind = item.destination_type === 'exchange' ? 'e' : 'q';
51
+ return `${path}/e/${encodeURIComponent(item.source)}/${kind}/${encodeURIComponent(item.destination)}`;
52
+ }
53
+
54
+ async function bindings(api, path, desired, ownsBinding) {
55
+ const current = await api('GET', path);
56
+ function includes(rows, item) {
57
+ return rows.some((row) => { return same(identity(row), identity(item)); });
58
+ }
59
+ const missing = desired.filter((item) => { return !includes(current, item); });
60
+ const stale = current.filter((item) => { return item.source && ownsBinding?.(item) && !includes(desired, item); });
61
+ await Promise.all(missing.map((item) => {
62
+ return api('POST', bindingPath(path, item), { routing_key: item.routing_key, arguments: item.arguments || {} });
63
+ }));
64
+ await Promise.all(stale.map((item) => {
65
+ return api('DELETE', `${bindingPath(path, item)}/${encodeURIComponent(item.properties_key)}`);
66
+ }));
67
+ return missing.length + stale.length;
68
+ }
69
+
70
+ async function shovels(api, path, desired, ownsShovel) {
71
+ const current = await api('GET', path);
72
+ const changed = desired.filter((item) => {
73
+ const found = current.find((row) => { return row.name === item.name; });
74
+ return !same(found, { value: item.value });
75
+ });
76
+ const stale = current.filter((item) => {
77
+ return ownsShovel?.(item) && !desired.some((row) => { return row.name === item.name; });
78
+ });
79
+ await Promise.all(changed.map((item) => {
80
+ return api('PUT', `${path}/${encodeURIComponent(item.name)}`, { value: item.value });
81
+ }));
82
+ await Promise.all(stale.map((item) => { return api('DELETE', `${path}/${encodeURIComponent(item.name)}`); }));
83
+ return changed.length + stale.length;
84
+ }
85
+
86
+ /** Apply only topology belonging to the selected virtual host. Users and permissions are bootstrap data. */
87
+ export default async function reconcileRabbitmq(desired, options) {
88
+ const { vhost, ownsBinding, ownsShovel } = options;
89
+ if (!vhost) { throw new Error('a virtual host is required'); }
90
+ const api = apiAt(options);
91
+ const host = encodeURIComponent(vhost);
92
+ const objects = [...desired.exchanges, ...desired.queues, ...desired.bindings, ...desired.parameters];
93
+ if (objects.some((item) => { return item.vhost !== undefined && item.vhost !== vhost; })) {
94
+ throw new Error('topology contains a different virtual host');
95
+ }
96
+ if (desired.parameters.some((item) => { return item.component !== 'shovel'; })) {
97
+ throw new Error('only shovel parameters can be reconciled');
98
+ }
99
+ const exchanges = await declare(api, `exchanges/${host}`, desired.exchanges);
100
+ const queues = await declare(api, `queues/${host}`, desired.queues);
101
+ const links = await bindings(api, `bindings/${host}`, desired.bindings, ownsBinding);
102
+ const parameters = await shovels(api, `parameters/shovel/${host}`, desired.parameters, ownsShovel);
103
+ return exchanges + queues + links + parameters;
104
+ }
@@ -0,0 +1,38 @@
1
+ export function positive(value, name) {
2
+ if (!Number.isFinite(value) || value <= 0) {
3
+ throw new TypeError(`${name} must be a positive number`);
4
+ }
5
+ }
6
+ function validateChannels(channels) {
7
+ if (!Array.isArray(channels) || channels.length === 0) {
8
+ throw new TypeError('channels must be a nonempty array');
9
+ }
10
+ const nodes = new Set();
11
+ channels.forEach(({ nodeId }) => {
12
+ if (typeof nodeId !== 'string' || !nodeId.trim() || nodes.has(nodeId)) {
13
+ throw new TypeError('invalid or duplicate nodeId');
14
+ }
15
+ nodes.add(nodeId);
16
+ });
17
+ }
18
+
19
+ function validUrl(value) {
20
+ const url = new URL(value);
21
+ return url.protocol === 'opc.tcp:' && Boolean(url.hostname) && !url.username && !url.password;
22
+ }
23
+ export function validateConnections(connections) {
24
+ if (!Array.isArray(connections)) {
25
+ throw new TypeError('connections must be an array');
26
+ }
27
+ const urls = new Set();
28
+ connections.forEach((connection) => {
29
+ if (typeof connection.url !== 'string' || !validUrl(connection.url) || urls.has(connection.url)) {
30
+ throw new TypeError('invalid or duplicate OPC UA URL');
31
+ }
32
+ urls.add(connection.url);
33
+ positive(connection.publishMs, 'publishMs');
34
+ positive(connection.heartbeatMs, 'heartbeatMs');
35
+ validateChannels(connection.channels);
36
+ });
37
+ return connections;
38
+ }
@@ -0,0 +1,80 @@
1
+ import { OPCUAClient } from 'node-opcua';
2
+ import session from './session.js';
3
+
4
+ function report(options, error, connection) {
5
+ try {
6
+ options.onError?.(error, connection);
7
+ } catch {
8
+ // Observability callbacks must not break endpoint recovery.
9
+ }
10
+ }
11
+ function delay(state, milliseconds) {
12
+ return new Promise((resolve) => {
13
+ state.wake = resolve;
14
+ state.timer = setTimeout(resolve, milliseconds);
15
+ }).finally(() => { clearTimeout(state.timer); });
16
+ }
17
+ function clientFor(options) {
18
+ return OPCUAClient.create({ endpointMustExist: false, keepSessionAlive: false,
19
+ connectionStrategy: { maxRetry: 0 }, transportTimeout: options.connectTimeoutMs,
20
+ defaultTransactionTimeout: options.requestTimeoutMs, requestedSessionTimeout: options.requestTimeoutMs * 2 });
21
+ }
22
+ function disconnection(client) {
23
+ let pending;
24
+ return () => {
25
+ pending ??= client.disconnect();
26
+ return pending;
27
+ };
28
+ }
29
+ async function attempt(state, connection, publish, options) {
30
+ const client = clientFor(options);
31
+ const close = disconnection(client);
32
+ state.close = close;
33
+ const current = { active: true, connection, wake: null, timer: null, pending: new Set() };
34
+ state.current = current;
35
+ try {
36
+ await client.connect(connection.url);
37
+ if (state.active) {
38
+ await session(current, client, connection, publish, options);
39
+ }
40
+ } catch (error) {
41
+ if (state.active) {
42
+ report(options, error, connection);
43
+ }
44
+ } finally {
45
+ current.active = false;
46
+ try { await close(); } finally { await Promise.all(current.pending); }
47
+ }
48
+ }
49
+ async function run(state, connection, publish, options) {
50
+ while (state.active) {
51
+ // Each endpoint deliberately serializes attempts and heartbeat reads.
52
+ // eslint-disable-next-line no-await-in-loop
53
+ await attempt(state, connection, publish, options);
54
+ if (state.active) {
55
+ // eslint-disable-next-line no-await-in-loop
56
+ await delay(state, options.retryMs);
57
+ }
58
+ }
59
+ }
60
+ export default function endpoint(connection, publish, options) {
61
+ const state = { active: false, close: null, current: null, task: null, wake: null, timer: null };
62
+ return {
63
+ start() {
64
+ if (!state.active) {
65
+ state.active = true;
66
+ state.task = run(state, connection, publish, options);
67
+ }
68
+ },
69
+ async stop() {
70
+ state.active = false;
71
+ if (state.current) {
72
+ state.current.active = false;
73
+ state.current.wake?.();
74
+ }
75
+ state.wake?.();
76
+ await state.close?.();
77
+ await state.task;
78
+ }
79
+ };
80
+ }
@@ -0,0 +1,10 @@
1
+ import opcuaSource from './source.js';
2
+ export { validateConnections } from './config.js';
3
+
4
+ /** Compatibility adapter for callback-based SCADA consumers. */
5
+ export default function opcuaConnections(connections, publish, options = {}) {
6
+ if (typeof publish !== 'function') { throw new TypeError('publish must be a function'); }
7
+ return opcuaSource(connections, { accept: ({ channel, value }) => { return publish(channel, value); } }, options);
8
+ }
9
+
10
+ export { default as opcuaSource } from './source.js';