@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 +21 -0
- package/README.md +53 -0
- package/client/index.js +12 -0
- package/client/machineClient.js +143 -0
- package/client/scadaClient.js +33 -0
- package/client/sseConnection.js +30 -0
- package/index.js +26 -0
- package/package.json +32 -0
- package/src/objects/cursor.js +50 -0
- package/src/objects/errorResponse.js +20 -0
- package/src/objects/jsonResponse.js +24 -0
- package/src/objects/pagination.js +29 -0
- package/src/objects/route.js +54 -0
- package/src/objects/sseResponse.js +34 -0
- package/src/objects/timeExpression.js +47 -0
- package/src/server/alertRoute.js +96 -0
- package/src/server/alertStream.js +68 -0
- package/src/server/machineRoute.js +115 -0
- package/src/server/measurementRoute.js +63 -0
- package/src/server/measurementStream.js +71 -0
- package/src/server/meltingRoute.js +175 -0
- package/src/server/meltingStream.js +70 -0
- package/src/server/routes.js +39 -0
- package/src/server/scadaServer.js +41 -0
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
|
package/client/index.js
ADDED
|
@@ -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
|
+
}
|