@yarkivaev/scada-server 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # scada-server
2
+
3
+ Composable SCADA server with REST and SSE endpoints.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ npm install scada-server
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```javascript
14
+ import http from 'http';
15
+ import { scadaServer } from 'scada-server';
16
+
17
+ const api = scadaServer('/api', plant);
18
+ http.createServer((req, res) => api.handle(req, res)).listen(3000);
19
+ ```
20
+
21
+ ## Client
22
+
23
+ ```javascript
24
+ import { scadaClient } from 'scada-server/client';
25
+
26
+ const client = scadaClient('http://localhost:3000/api', fetch, EventSource);
27
+ const machine = client.machine('icht1');
28
+ const measurements = await machine.measurements({ from: 'now-1h', to: 'now' });
29
+ ```
30
+
31
+ ## Modules
32
+
33
+ ### Server
34
+ - `scadaServer`
35
+ - `routes`
36
+
37
+ ### Objects
38
+ - `route`
39
+ - `jsonResponse`
40
+ - `sseResponse`
41
+ - `errorResponse`
42
+ - `timeExpression`
43
+ - `pagination`
44
+ - `cursor`
45
+
46
+ ### Client
47
+ - `scadaClient`
48
+ - `machineClient`
49
+ - `sseConnection`
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,12 @@
1
+ /**
2
+ * SCADA Server JS Client.
3
+ * Provides typed methods for all API endpoints.
4
+ *
5
+ * @example
6
+ * import { scadaClient } from 'scada-server/client';
7
+ * const client = scadaClient('http://localhost:3000/api/v1', fetch, EventSource);
8
+ */
9
+
10
+ export { default as machineClient } from './machineClient.js';
11
+ export { default as scadaClient } from './scadaClient.js';
12
+ export { default as sseConnection } from './sseConnection.js';
@@ -0,0 +1,143 @@
1
+ import sseConnection from './sseConnection.js';
2
+
3
+ /**
4
+ * Client for single machine endpoints.
5
+ * Returns object with methods for machine operations.
6
+ *
7
+ * @param {string} baseUrl - API base URL
8
+ * @param {string} machineId - machine identifier
9
+ * @param {function} fetcher - fetch function
10
+ * @param {function} eventSource - EventSource constructor
11
+ * @returns {object} client with info, measurements, alerts, meltings methods
12
+ *
13
+ * @example
14
+ * const machine = machineClient(baseUrl, 'icht1', fetch, EventSource);
15
+ * const info = await machine.info();
16
+ */
17
+ export default function machineClient(baseUrl, machineId, fetcher, eventSource) {
18
+ const url = `${baseUrl}/machines/${machineId}`;
19
+ async function request(path, options) {
20
+ const response = await fetcher(`${url}${path}`, options);
21
+ const payload = await response.json();
22
+ if (!response.ok) {
23
+ throw payload;
24
+ }
25
+ return payload;
26
+ }
27
+ return {
28
+ async info() {
29
+ return request('');
30
+ },
31
+ async measurements(options) {
32
+ const params = new URLSearchParams();
33
+ if (options && options.keys) {
34
+ params.set('keys', options.keys.join(','));
35
+ }
36
+ if (options && options.from) {
37
+ params.set('from', options.from);
38
+ }
39
+ if (options && options.to) {
40
+ params.set('to', options.to);
41
+ }
42
+ if (options && options.step) {
43
+ params.set('step', String(options.step));
44
+ }
45
+ const qs = params.toString();
46
+ return request(`/measurements${qs ? `?${qs}` : ''}`);
47
+ },
48
+ measurementStream(options) {
49
+ const params = new URLSearchParams();
50
+ if (options && options.keys) {
51
+ params.set('keys', options.keys.join(','));
52
+ }
53
+ if (options && options.since) {
54
+ params.set('since', options.since);
55
+ }
56
+ if (options && options.step) {
57
+ params.set('step', String(options.step));
58
+ }
59
+ const qs = params.toString();
60
+ return sseConnection(
61
+ `${url}/measurements/stream${qs ? `?${qs}` : ''}`,
62
+ eventSource
63
+ );
64
+ },
65
+ async alerts(options) {
66
+ const params = new URLSearchParams();
67
+ if (options && options.page) {
68
+ params.set('page', String(options.page));
69
+ }
70
+ if (options && options.size) {
71
+ params.set('size', String(options.size));
72
+ }
73
+ if (options && Object.hasOwn(options, 'acknowledged')) {
74
+ params.set('acknowledged', String(options.acknowledged));
75
+ }
76
+ const qs = params.toString();
77
+ return request(`/alerts${qs ? `?${qs}` : ''}`);
78
+ },
79
+ alertStream() {
80
+ return sseConnection(`${url}/alerts/stream`, eventSource);
81
+ },
82
+ async acknowledge(alertId) {
83
+ return request(`/alerts/${alertId}`, {
84
+ method: 'PATCH',
85
+ headers: { 'Content-Type': 'application/json' },
86
+ body: JSON.stringify({ acknowledged: true })
87
+ });
88
+ },
89
+ async meltings(options) {
90
+ const params = new URLSearchParams();
91
+ if (options && options.after) {
92
+ params.set('after', options.after);
93
+ }
94
+ if (options && options.before) {
95
+ params.set('before', options.before);
96
+ }
97
+ if (options && options.limit) {
98
+ params.set('limit', String(options.limit));
99
+ }
100
+ if (options && options.active) {
101
+ params.set('active', 'true');
102
+ }
103
+ const qs = params.toString();
104
+ return request(`/meltings${qs ? `?${qs}` : ''}`);
105
+ },
106
+ async melting(meltingId) {
107
+ return request(`/meltings/${meltingId}`);
108
+ },
109
+ meltingStream() {
110
+ return sseConnection(`${url}/meltings/stream`, eventSource);
111
+ },
112
+ async startMelting() {
113
+ return request('/meltings/start', { method: 'POST' });
114
+ },
115
+ async stopMelting(meltingId) {
116
+ return request(`/meltings/${meltingId}/stop`, { method: 'POST' });
117
+ },
118
+ async weight() {
119
+ return request('/weight');
120
+ },
121
+ async setWeight(amount) {
122
+ return request('/weight', {
123
+ method: 'PUT',
124
+ headers: { 'Content-Type': 'application/json' },
125
+ body: JSON.stringify({ amount })
126
+ });
127
+ },
128
+ async load(amount) {
129
+ return request('/load', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ amount })
133
+ });
134
+ },
135
+ async dispense(amount) {
136
+ return request('/dispense', {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ amount })
140
+ });
141
+ }
142
+ };
143
+ }
@@ -0,0 +1,33 @@
1
+ import machineClient from './machineClient.js';
2
+
3
+ /**
4
+ * Main SCADA API client.
5
+ * Returns object with machines() and machine() methods.
6
+ *
7
+ * @param {string} baseUrl - server base URL
8
+ * @param {function} fetcher - fetch function
9
+ * @param {function} eventSource - EventSource constructor
10
+ * @returns {object} client with machines, machine methods
11
+ *
12
+ * @example
13
+ * const client = scadaClient('http://localhost:3000/api/v1', fetch, EventSource);
14
+ * const machines = await client.machines();
15
+ * const machine = client.machine('icht1');
16
+ */
17
+ export default function scadaClient(baseUrl, fetcher, eventSource) {
18
+ return {
19
+ async machines() {
20
+ const response = await fetcher(`${baseUrl}/machines`);
21
+ const payload = await response.json();
22
+ if (!response.ok) {
23
+ const error = new Error(payload.error.message);
24
+ error.code = payload.error.code;
25
+ throw error;
26
+ }
27
+ return payload;
28
+ },
29
+ machine(machineId) {
30
+ return machineClient(baseUrl, machineId, fetcher, eventSource);
31
+ }
32
+ };
33
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SSE connection wrapper with event callbacks.
3
+ * Returns immutable object with on() and close() methods.
4
+ *
5
+ * @param {string} url - SSE endpoint URL
6
+ * @param {function} eventSource - EventSource constructor (for testing)
7
+ * @returns {object} connection with on, close methods
8
+ *
9
+ * @example
10
+ * const conn = sseConnection(url, EventSource);
11
+ * conn.on('measurement', data => console.log(data));
12
+ * conn.close();
13
+ */
14
+ export default function sseConnection(url, EventSourceCtor) {
15
+ const source = new EventSourceCtor(url);
16
+ const handlers = {};
17
+ return {
18
+ on(event, notify) {
19
+ handlers[event] = notify;
20
+ source.addEventListener(event, (ev) => {
21
+ const payload = JSON.parse(ev.data);
22
+ notify(payload);
23
+ });
24
+ return this;
25
+ },
26
+ close() {
27
+ source.close();
28
+ }
29
+ };
30
+ }
package/index.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SCADA Server - Composable server implementing Supervisor API.
3
+ * Provides routes and scadaServer factory for integration with scada domain.
4
+ *
5
+ * @example
6
+ * import http from 'http';
7
+ * import { scadaServer } from 'scada-server';
8
+ * import { plant, meltingShop, meltingMachine } from 'scada';
9
+ *
10
+ * const p = createPlant(meltingMachine); // your plant factory
11
+ * const server = scadaServer('/api/v1', p);
12
+ * http.createServer((req, res) => server.handle(req, res)).listen(3000);
13
+ */
14
+
15
+ // Server
16
+ export { default as scadaServer } from './src/server/scadaServer.js';
17
+ export { default as routes } from './src/server/routes.js';
18
+
19
+ // Objects
20
+ export { default as route } from './src/objects/route.js';
21
+ export { default as jsonResponse } from './src/objects/jsonResponse.js';
22
+ export { default as errorResponse } from './src/objects/errorResponse.js';
23
+ export { default as sseResponse } from './src/objects/sseResponse.js';
24
+ export { default as timeExpression } from './src/objects/timeExpression.js';
25
+ export { default as pagination } from './src/objects/pagination.js';
26
+ export { default as cursor } from './src/objects/cursor.js';
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@yarkivaev/scada-server",
3
+ "version": "1.0.0",
4
+ "description": "Composable SCADA server implementing Supervisor API",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/yarkivaev/scada-server"
8
+ },
9
+ "type": "module",
10
+ "main": "index.js",
11
+ "scripts": {
12
+ "test": "mocha --exit 'test/unit/**/*.js'",
13
+ "test:integration": "mocha --exit 'test/integration/**/*.js'",
14
+ "test:all": "mocha --exit 'test/**/*.js'",
15
+ "coverage": "c8 --all --src src npm test",
16
+ "coverage:integration": "c8 --all --src client npm run test:integration",
17
+ "lint": "eslint .",
18
+ "lint:fix": "eslint . --fix"
19
+ },
20
+ "devDependencies": {
21
+ "@eslint/js": "^9.39.2",
22
+ "c8": "^10.1.3",
23
+ "eslint": "^9.39.2",
24
+ "globals": "^17.0.0",
25
+ "mocha": "^10.2.0"
26
+ },
27
+ "files": [
28
+ "index.js",
29
+ "src/",
30
+ "client/"
31
+ ]
32
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cursor-based pagination for meltings.
3
+ * Items must have a 'start' property with ISO8601 timestamp.
4
+ *
5
+ * @param {string} after - ISO8601 cursor (fetch after this time)
6
+ * @param {string} before - ISO8601 cursor (fetch before this time)
7
+ * @param {number} limit - max items to return
8
+ * @param {array} items - full item collection sorted by start desc
9
+ * @param {boolean} active - filter to show only active items (no end property)
10
+ * @returns {object} cursored with result() method
11
+ *
12
+ * @example
13
+ * const cs = cursor(undefined, undefined, 10, items);
14
+ * cs.result(); // { items: [...], nextCursor: '...', hasMore: true }
15
+ */
16
+ export default function cursor(after, before, limit, items, active) {
17
+ const lim = Math.max(1, limit);
18
+ return {
19
+ result() {
20
+ let filtered = items;
21
+ if (after) {
22
+ const afterTime = new Date(after).getTime();
23
+ filtered = filtered.filter((item) => {
24
+ return new Date(item.start).getTime() > afterTime;
25
+ });
26
+ }
27
+ if (before) {
28
+ const beforeTime = new Date(before).getTime();
29
+ filtered = filtered.filter((item) => {
30
+ return new Date(item.start).getTime() < beforeTime;
31
+ });
32
+ }
33
+ if (active) {
34
+ filtered = filtered.filter((item) => {
35
+ return !item.end;
36
+ });
37
+ }
38
+ const sliced = filtered.slice(0, lim);
39
+ const hasMore = filtered.length > lim;
40
+ const nextCursor = sliced.length > 0
41
+ ? sliced[sliced.length - 1].start
42
+ : undefined;
43
+ return {
44
+ items: sliced,
45
+ nextCursor,
46
+ hasMore
47
+ };
48
+ }
49
+ };
50
+ }
@@ -0,0 +1,20 @@
1
+ import jsonResponse from './jsonResponse.js';
2
+
3
+ /**
4
+ * Structured error response per API contract.
5
+ * Returns immutable object with send() method.
6
+ *
7
+ * @param {string} code - error code (NOT_FOUND, BAD_REQUEST, etc)
8
+ * @param {string} message - human-readable message
9
+ * @param {number} statusCode - HTTP status
10
+ * @returns {object} error with send method
11
+ *
12
+ * @example
13
+ * errorResponse('NOT_FOUND', "Machine 'icht99' not found", 404).send(res);
14
+ */
15
+ export default function errorResponse(code, message, statusCode) {
16
+ const payload = {
17
+ error: { code, message }
18
+ };
19
+ return jsonResponse(payload, statusCode);
20
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * JSON response formatter for HTTP.
3
+ * Returns immutable object with send() method.
4
+ *
5
+ * @param {object} payload - JSON-serializable data
6
+ * @param {number} statusCode - HTTP status (default 200)
7
+ * @returns {object} response with send method
8
+ *
9
+ * @example
10
+ * jsonResponse({ items: [] }).send(response);
11
+ * jsonResponse({ error: {...} }, 404).send(response);
12
+ */
13
+ export default function jsonResponse(payload, statusCode) {
14
+ const code = statusCode || 200;
15
+ return {
16
+ send(response) {
17
+ response.writeHead(code, {
18
+ 'Content-Type': 'application/json',
19
+ 'Access-Control-Allow-Origin': '*'
20
+ });
21
+ response.end(JSON.stringify(payload));
22
+ }
23
+ };
24
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Page-based pagination for alerts.
3
+ * Returns immutable object with result() method.
4
+ *
5
+ * @param {number} page - 1-based page number
6
+ * @param {number} size - items per page
7
+ * @param {array} items - full item collection
8
+ * @returns {object} paginated with result() method
9
+ *
10
+ * @example
11
+ * const pg = pagination(1, 10, items);
12
+ * pg.result(); // { items: [...], page: 1, size: 10, total: 100 }
13
+ */
14
+ export default function pagination(page, size, items) {
15
+ const pg = Math.max(1, page);
16
+ const sz = Math.max(1, size);
17
+ const start = (pg - 1) * sz;
18
+ const end = start + sz;
19
+ return {
20
+ result() {
21
+ return {
22
+ items: items.slice(start, end),
23
+ page: pg,
24
+ size: sz,
25
+ total: items.length
26
+ };
27
+ }
28
+ };
29
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * HTTP route with path matching and parameter extraction.
3
+ * Returns immutable object with matches() and handle() methods.
4
+ *
5
+ * @param {string} method - HTTP method (GET, PATCH, etc)
6
+ * @param {string} path - URL pattern with :param placeholders
7
+ * @param {function} action - receives (request, response, params, query)
8
+ * @returns {object} route with matches, handle methods
9
+ *
10
+ * @example
11
+ * const rt = route('GET', '/machines/:id', action);
12
+ * rt.matches(request); // boolean
13
+ * rt.handle(request, response);
14
+ */
15
+ export default function route(method, path, action) {
16
+ const parts = path.split('/');
17
+ const params = parts
18
+ .map((part, index) => {
19
+ return { part, index };
20
+ })
21
+ .filter((item) => {
22
+ return item.part.startsWith(':');
23
+ })
24
+ .map((item) => {
25
+ return { name: item.part.slice(1), index: item.index };
26
+ });
27
+ const pattern = parts
28
+ .map((part) => {
29
+ return part.startsWith(':') ? '[^/]+' : part;
30
+ })
31
+ .join('/');
32
+ const regex = new RegExp(`^${pattern}$`, 'u');
33
+ return {
34
+ matches(request) {
35
+ const url = request.url.split('?')[0];
36
+ return request.method === method && regex.test(url);
37
+ },
38
+ handle(request, response) {
39
+ const url = request.url.split('?')[0];
40
+ const urlParts = url.split('/');
41
+ const extracted = params.reduce((acc, param) => {
42
+ return { ...acc, [param.name]: urlParts[param.index] };
43
+ }, {});
44
+ const queryString = request.url.split('?')[1];
45
+ const query = queryString
46
+ ? queryString.split('&').reduce((acc, pair) => {
47
+ const [key, val] = pair.split('=');
48
+ return { ...acc, [key]: decodeURIComponent(val || '') };
49
+ }, {})
50
+ : {};
51
+ return action(request, response, extracted, query);
52
+ }
53
+ };
54
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Server-Sent Events response.
3
+ * Returns immutable object with emit(), heartbeat(), close() methods.
4
+ *
5
+ * @param {object} response - HTTP response object
6
+ * @param {function} clock - time provider for heartbeats
7
+ * @returns {object} sse with emit, heartbeat, close methods
8
+ *
9
+ * @example
10
+ * const sse = sseResponse(response, () => new Date());
11
+ * sse.emit('measurement', { key: 'voltage', value: 380 });
12
+ * sse.heartbeat();
13
+ * sse.close();
14
+ */
15
+ export default function sseResponse(response, clock) {
16
+ response.writeHead(200, {
17
+ 'Content-Type': 'text/event-stream',
18
+ 'Cache-Control': 'no-cache',
19
+ 'Connection': 'keep-alive',
20
+ 'Access-Control-Allow-Origin': '*'
21
+ });
22
+ return {
23
+ emit(event, payload) {
24
+ response.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
25
+ },
26
+ heartbeat() {
27
+ const timestamp = clock().toISOString();
28
+ response.write(`event: heartbeat\ndata: ${JSON.stringify({ timestamp })}\n\n`);
29
+ },
30
+ close() {
31
+ response.end();
32
+ }
33
+ };
34
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Parser for time expressions per API contract.
3
+ * Supports: now, beginning, ISO8601, with +/- duration operators.
4
+ *
5
+ * @param {string} expression - time expression string
6
+ * @param {function} clock - provides current time
7
+ * @param {function} beginning - provides first data timestamp
8
+ * @returns {object} parsed time with resolve() method
9
+ *
10
+ * @example
11
+ * const te = timeExpression('now-1h', clock, beginning);
12
+ * te.resolve(); // Date object 1 hour ago
13
+ */
14
+ export default function timeExpression(expression, clock, beginning) {
15
+ const durations = {
16
+ s: 1000,
17
+ m: 60 * 1000,
18
+ h: 60 * 60 * 1000,
19
+ d: 24 * 60 * 60 * 1000,
20
+ w: 7 * 24 * 60 * 60 * 1000,
21
+ M: 30 * 24 * 60 * 60 * 1000
22
+ };
23
+ return {
24
+ resolve() {
25
+ const match = expression.match(/^(now|beginning|[\dT:.Z-]+)([+-]\d+[smhdwM])?$/u);
26
+ if (!match) {
27
+ return clock();
28
+ }
29
+ const [, base, delta] = match;
30
+ let time;
31
+ if (base === 'now') {
32
+ time = clock().getTime();
33
+ } else if (base === 'beginning') {
34
+ time = beginning().getTime();
35
+ } else {
36
+ time = new Date(base).getTime();
37
+ }
38
+ if (delta) {
39
+ const sign = delta[0] === '+' ? 1 : -1;
40
+ const amount = parseInt(delta.slice(1, -1), 10);
41
+ const unit = delta.slice(-1);
42
+ time += sign * amount * durations[unit];
43
+ }
44
+ return new Date(time);
45
+ }
46
+ };
47
+ }