@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.
@@ -0,0 +1,96 @@
1
+ import errorResponse from '../objects/errorResponse.js';
2
+ import jsonResponse from '../objects/jsonResponse.js';
3
+ import pagination from '../objects/pagination.js';
4
+ import route from '../objects/route.js';
5
+
6
+ /**
7
+ * Alert routes factory.
8
+ * Creates routes for GET and PATCH /machines/:machineId/alerts.
9
+ *
10
+ * @param {string} basePath - base URL path
11
+ * @param {object} plant - plant domain object from scada package
12
+ * @returns {array} array of route objects
13
+ *
14
+ * @example
15
+ * const routes = alertRoute('/api/v1', plant);
16
+ */
17
+ export default function alertRoute(basePath, plant) {
18
+ function find(id) {
19
+ for (const shop of Object.values(plant.shops.get())) {
20
+ const machine = shop.machines.get()[id];
21
+ if (machine) {
22
+ return machine;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+ function findAlert(alertId) {
28
+ for (const shop of Object.values(plant.shops.get())) {
29
+ for (const machine of Object.values(shop.machines.get())) {
30
+ const alert = machine.alerts().find((a) => {
31
+ return a.id === alertId;
32
+ });
33
+ if (alert) {
34
+ return alert;
35
+ }
36
+ }
37
+ }
38
+ return undefined;
39
+ }
40
+ return [
41
+ route(
42
+ 'GET',
43
+ `${basePath}/machines/:machineId/alerts`,
44
+ (req, res, params, query) => {
45
+ const machine = find(params.machineId);
46
+ if (!machine) {
47
+ jsonResponse({ items: [], page: 1, size: 10, total: 0 }).send(res);
48
+ return;
49
+ }
50
+ const page = query.page ? parseInt(query.page, 10) : 1;
51
+ const size = query.size ? parseInt(query.size, 10) : 10;
52
+ let alerts = machine.alerts();
53
+ if (query.acknowledged === 'true') {
54
+ alerts = alerts.filter((a) => {
55
+ return a.acknowledged === true;
56
+ });
57
+ } else if (query.acknowledged === 'false') {
58
+ alerts = alerts.filter((a) => {
59
+ return a.acknowledged === false;
60
+ });
61
+ }
62
+ const mapped = alerts.map((a) => {
63
+ return { id: a.id, message: a.message, timestamp: a.timestamp.toISOString(), object: a.object, acknowledged: a.acknowledged };
64
+ });
65
+ const paginated = pagination(page, size, mapped).result();
66
+ jsonResponse({ items: paginated.items, page: paginated.page, size: paginated.size, total: paginated.total }).send(res);
67
+ }
68
+ ),
69
+ route(
70
+ 'PATCH',
71
+ `${basePath}/machines/:machineId/alerts/:alertId`,
72
+ (req, res, params) => {
73
+ let body = '';
74
+ req.on('data', (chunk) => {
75
+ body += chunk;
76
+ });
77
+ req.on('end', () => {
78
+ const changes = JSON.parse(body);
79
+ const alert = findAlert(params.alertId);
80
+ if (!alert) {
81
+ errorResponse(
82
+ 'NOT_FOUND',
83
+ `Alert '${params.alertId}' not found`,
84
+ 404
85
+ ).send(res);
86
+ return;
87
+ }
88
+ if (changes.acknowledged === true && !alert.acknowledged) {
89
+ alert.acknowledge();
90
+ }
91
+ jsonResponse({ id: alert.id, message: alert.message, timestamp: alert.timestamp.toISOString(), object: alert.object, acknowledged: true }).send(res);
92
+ });
93
+ }
94
+ )
95
+ ];
96
+ }
@@ -0,0 +1,68 @@
1
+ import route from '../objects/route.js';
2
+ import sseResponse from '../objects/sseResponse.js';
3
+
4
+ /**
5
+ * Alert stream route factory.
6
+ * Creates SSE route for /machines/:machineId/alerts/stream.
7
+ * Streams alert_created and alert_updated events for the specified machine.
8
+ *
9
+ * @param {string} basePath - base URL path
10
+ * @param {object} plant - plant domain object from scada package
11
+ * @param {function} clock - time provider
12
+ * @returns {array} array of route objects
13
+ *
14
+ * @example
15
+ * const routes = alertStream('/api/v1', plant, clock);
16
+ */
17
+ export default function alertStream(basePath, plant, clock) {
18
+ function find(machineId) {
19
+ for (const shop of Object.values(plant.shops.get())) {
20
+ const machine = shop.machines.get()[machineId];
21
+ if (machine) {
22
+ return { machine, shop };
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+ return [
28
+ route(
29
+ 'GET',
30
+ `${basePath}/machines/:machineId/alerts/stream`,
31
+ (req, res, params) => {
32
+ const sse = sseResponse(res, clock);
33
+ sse.heartbeat();
34
+ const result = find(params.machineId);
35
+ if (!result) {
36
+ sse.close();
37
+ return;
38
+ }
39
+ const { machine, shop } = result;
40
+ const subscription = shop.alerts.stream((event) => {
41
+ if (event.alert.object === machine.name()) {
42
+ if (event.type === 'created') {
43
+ sse.emit('alert_created', {
44
+ id: event.alert.id,
45
+ message: event.alert.message,
46
+ timestamp: event.alert.timestamp.toISOString(),
47
+ object: event.alert.object,
48
+ acknowledged: event.alert.acknowledged
49
+ });
50
+ } else if (event.type === 'acknowledged') {
51
+ sse.emit('alert_updated', {
52
+ id: event.alert.id,
53
+ acknowledged: true
54
+ });
55
+ }
56
+ }
57
+ });
58
+ const heartbeat = setInterval(() => {
59
+ sse.heartbeat();
60
+ }, 30000);
61
+ req.on('close', () => {
62
+ clearInterval(heartbeat);
63
+ subscription.cancel();
64
+ });
65
+ }
66
+ )
67
+ ];
68
+ }
@@ -0,0 +1,115 @@
1
+ import errorResponse from '../objects/errorResponse.js';
2
+ import jsonResponse from '../objects/jsonResponse.js';
3
+ import route from '../objects/route.js';
4
+
5
+ /**
6
+ * Machine routes factory.
7
+ * Creates routes for GET /machines and GET /machines/:machineId.
8
+ *
9
+ * @param {string} basePath - base URL path
10
+ * @param {object} plant - plant domain object from scada package
11
+ * @returns {array} array of route objects
12
+ *
13
+ * @example
14
+ * const routes = machineRoute('/api/v1', plant);
15
+ */
16
+ export default function machineRoute(basePath, plant) {
17
+ function all() {
18
+ return Object.values(plant.shops.get()).flatMap((shop) => {
19
+ return Object.values(shop.machines.get()).map((machine) => {
20
+ return { id: machine.name(), name: machine.name() };
21
+ });
22
+ });
23
+ }
24
+ function find(id) {
25
+ for (const shop of Object.values(plant.shops.get())) {
26
+ const machine = shop.machines.get()[id];
27
+ if (machine) {
28
+ return { id: machine.name(), name: machine.name() };
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ function findMachine(id) {
34
+ for (const shop of Object.values(plant.shops.get())) {
35
+ const machine = shop.machines.get()[id];
36
+ if (machine) {
37
+ return machine;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ return [
43
+ route('GET', `${basePath}/machines`, (req, res) => {
44
+ jsonResponse({ items: all() }).send(res);
45
+ }),
46
+ route('GET', `${basePath}/machines/:machineId`, (req, res, params) => {
47
+ const machine = find(params.machineId);
48
+ if (!machine) {
49
+ errorResponse(
50
+ 'NOT_FOUND',
51
+ `Machine '${params.machineId}' not found`,
52
+ 404
53
+ ).send(res);
54
+ return;
55
+ }
56
+ jsonResponse(machine).send(res);
57
+ }),
58
+ route('GET', `${basePath}/machines/:machineId/weight`, (req, res, params) => {
59
+ const machine = findMachine(params.machineId);
60
+ if (!machine) {
61
+ errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
62
+ return;
63
+ }
64
+ jsonResponse({ amount: machine.chronology().get({ type: 'current' }).weight }).send(res);
65
+ }),
66
+ route('PUT', `${basePath}/machines/:machineId/weight`, (req, res, params) => {
67
+ const machine = findMachine(params.machineId);
68
+ if (!machine) {
69
+ errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
70
+ return;
71
+ }
72
+ let body = '';
73
+ req.on('data', (chunk) => {
74
+ body += chunk;
75
+ });
76
+ req.on('end', () => {
77
+ const data = JSON.parse(body);
78
+ machine.reset(data.amount);
79
+ jsonResponse({ amount: machine.chronology().get({ type: 'current' }).weight }).send(res);
80
+ });
81
+ }),
82
+ route('POST', `${basePath}/machines/:machineId/load`, (req, res, params) => {
83
+ const machine = findMachine(params.machineId);
84
+ if (!machine) {
85
+ errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
86
+ return;
87
+ }
88
+ let body = '';
89
+ req.on('data', (chunk) => {
90
+ body += chunk;
91
+ });
92
+ req.on('end', () => {
93
+ const data = JSON.parse(body);
94
+ machine.load(data.amount);
95
+ jsonResponse({ amount: data.amount }).send(res);
96
+ });
97
+ }),
98
+ route('POST', `${basePath}/machines/:machineId/dispense`, (req, res, params) => {
99
+ const machine = findMachine(params.machineId);
100
+ if (!machine) {
101
+ errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
102
+ return;
103
+ }
104
+ let body = '';
105
+ req.on('data', (chunk) => {
106
+ body += chunk;
107
+ });
108
+ req.on('end', () => {
109
+ const data = JSON.parse(body);
110
+ machine.dispense(data.amount);
111
+ jsonResponse({ amount: data.amount }).send(res);
112
+ });
113
+ })
114
+ ];
115
+ }
@@ -0,0 +1,63 @@
1
+ import jsonResponse from '../objects/jsonResponse.js';
2
+ import route from '../objects/route.js';
3
+ import timeExpression from '../objects/timeExpression.js';
4
+
5
+ /**
6
+ * Measurement routes factory.
7
+ * Creates route for GET /machines/:machineId/measurements.
8
+ *
9
+ * @param {string} basePath - base URL path
10
+ * @param {object} plant - plant domain object from scada package
11
+ * @param {function} clock - time provider
12
+ * @returns {array} array of route objects
13
+ *
14
+ * @example
15
+ * const routes = measurementRoute('/api/v1', plant, clock);
16
+ */
17
+ export default function measurementRoute(basePath, plant, clock) {
18
+ function beginning() {
19
+ return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
20
+ }
21
+ function find(id) {
22
+ for (const shop of Object.values(plant.shops.get())) {
23
+ const machine = shop.machines.get()[id];
24
+ if (machine) {
25
+ return machine;
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+ return [
31
+ route(
32
+ 'GET',
33
+ `${basePath}/machines/:machineId/measurements`,
34
+ async (req, res, params, query) => {
35
+ const machine = find(params.machineId);
36
+ if (!machine) {
37
+ jsonResponse({ items: [] }).send(res);
38
+ return;
39
+ }
40
+ const requested = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
41
+ const keys = requested.filter((key) => {return machine.sensors[key]});
42
+ const fromExpr = query.from || 'now-1M';
43
+ const toExpr = query.to || 'now';
44
+ const from = timeExpression(fromExpr, clock, beginning).resolve();
45
+ const to = timeExpression(toExpr, clock, beginning).resolve();
46
+ const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
47
+ const range = { start: from, end: to };
48
+ const promises = keys.map(async (key) => {
49
+ const sensor = machine.sensors[key];
50
+ const measurements = await sensor.measurements(range, step);
51
+ const unit = measurements.length > 0 ? measurements[0].unit : '';
52
+ const values = measurements.map((m) => {return {
53
+ timestamp: m.timestamp.toISOString(),
54
+ value: m.value
55
+ }});
56
+ return { key, name: sensor.name(), unit, values };
57
+ });
58
+ const items = await Promise.all(promises);
59
+ jsonResponse({ items }).send(res);
60
+ }
61
+ )
62
+ ];
63
+ }
@@ -0,0 +1,71 @@
1
+ import route from '../objects/route.js';
2
+ import sseResponse from '../objects/sseResponse.js';
3
+ import timeExpression from '../objects/timeExpression.js';
4
+
5
+ /**
6
+ * Measurement stream route factory.
7
+ * Creates SSE route for /machines/:machineId/measurements/stream.
8
+ *
9
+ * @param {string} basePath - base URL path
10
+ * @param {object} plant - plant domain object from scada package
11
+ * @param {function} clock - time provider
12
+ * @returns {array} array of route objects
13
+ *
14
+ * @example
15
+ * const routes = measurementStream('/api/v1', plant, clock);
16
+ */
17
+ export default function measurementStream(basePath, plant, clock) {
18
+ function beginning() {
19
+ return new Date(clock().getTime() - 30 * 24 * 60 * 60 * 1000);
20
+ }
21
+ function find(id) {
22
+ for (const shop of Object.values(plant.shops.get())) {
23
+ const machine = shop.machines.get()[id];
24
+ if (machine) {
25
+ return machine;
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+ return [
31
+ route(
32
+ 'GET',
33
+ `${basePath}/machines/:machineId/measurements/stream`,
34
+ (req, res, params, query) => {
35
+ const sse = sseResponse(res, clock);
36
+ sse.heartbeat();
37
+ const machine = find(params.machineId);
38
+ if (!machine) {
39
+ sse.close();
40
+ return;
41
+ }
42
+ const keys = query.keys ? query.keys.split(',') : Object.keys(machine.sensors);
43
+ const sinceExpr = query.since || 'now';
44
+ const since = timeExpression(sinceExpr, clock, beginning).resolve();
45
+ const step = query.step ? parseInt(query.step, 10) * 1000 : 1000;
46
+ const subscriptions = [];
47
+ keys.forEach((key) => {
48
+ if (machine.sensors[key]) {
49
+ const subscription = machine.sensors[key].stream(since, step, (item) => {
50
+ sse.emit('measurement', {
51
+ key,
52
+ timestamp: item.timestamp.toISOString(),
53
+ value: item.value
54
+ });
55
+ });
56
+ subscriptions.push(subscription);
57
+ }
58
+ });
59
+ const heartbeat = setInterval(() => {
60
+ sse.heartbeat();
61
+ }, 30000);
62
+ req.on('close', () => {
63
+ clearInterval(heartbeat);
64
+ subscriptions.forEach((subscription) => {
65
+ subscription.cancel();
66
+ });
67
+ });
68
+ }
69
+ )
70
+ ];
71
+ }
@@ -0,0 +1,175 @@
1
+ import cursor from '../objects/cursor.js';
2
+ import errorResponse from '../objects/errorResponse.js';
3
+ import jsonResponse from '../objects/jsonResponse.js';
4
+ import route from '../objects/route.js';
5
+
6
+ /**
7
+ * Converts melting domain object to API response format.
8
+ *
9
+ * @param {object} melting - melting domain object
10
+ * @returns {object} API response object
11
+ */
12
+ function format(melting) {
13
+ const data = melting.chronology().get();
14
+ const result = {
15
+ id: melting.id(),
16
+ start: data.start.toISOString(),
17
+ initial: data.initial,
18
+ weight: data.weight,
19
+ loaded: data.loaded,
20
+ dispensed: data.dispensed
21
+ };
22
+ if (data.end) {
23
+ result.end = data.end.toISOString();
24
+ }
25
+ return result;
26
+ }
27
+
28
+ /**
29
+ * Melting read routes factory.
30
+ * Creates routes for GET /machines/:machineId/meltings.
31
+ *
32
+ * @param {string} basePath - base URL path
33
+ * @param {function} findMachine - function to find machine by ID
34
+ * @returns {array} array of route objects
35
+ */
36
+ function readRoutes(basePath, findMachine) {
37
+ return [
38
+ route('GET', `${basePath}/machines/:machineId/meltings`, (req, res, params, query) => {
39
+ const result = findMachine(params.machineId);
40
+ if (!result) {
41
+ jsonResponse({ items: [], hasMore: false }).send(res);
42
+ return;
43
+ }
44
+ const { machine, shop } = result;
45
+ const {after} = query;
46
+ const {before} = query;
47
+ const limit = query.limit ? parseInt(query.limit, 10) : 10;
48
+ const active = query.active === 'true';
49
+ const meltings = shop.meltings.query({ machine }).map(format).sort((a, b) => {
50
+ return new Date(b.start).getTime() - new Date(a.start).getTime();
51
+ });
52
+ const paginated = cursor(after, before, limit, meltings, active).result();
53
+ jsonResponse({ items: paginated.items, nextCursor: paginated.nextCursor, hasMore: paginated.hasMore }).send(res);
54
+ }),
55
+ route('GET', `${basePath}/machines/:machineId/meltings/:meltingId`, (req, res, params) => {
56
+ const result = findMachine(params.machineId);
57
+ if (!result) {
58
+ errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
59
+ return;
60
+ }
61
+ const { machine, shop } = result;
62
+ const melting = shop.meltings.query({ machine }).find((m) => {
63
+ return m.id() === params.meltingId;
64
+ });
65
+ if (!melting) {
66
+ errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
67
+ return;
68
+ }
69
+ jsonResponse(format(melting)).send(res);
70
+ })
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * Melting write routes factory.
76
+ * Creates routes for POST/PUT /machines/:machineId/meltings.
77
+ *
78
+ * @param {string} basePath - base URL path
79
+ * @param {function} findMachine - function to find machine by ID
80
+ * @returns {array} array of route objects
81
+ */
82
+ function writeRoutes(basePath, findMachine) {
83
+ return [
84
+ route('POST', `${basePath}/machines/:machineId/meltings/start`, (req, res, params) => {
85
+ const result = findMachine(params.machineId);
86
+ if (!result) {
87
+ errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
88
+ return;
89
+ }
90
+ const { machine, shop } = result;
91
+ const active = shop.meltings.add(machine, {});
92
+ res.writeHead(201, { 'Content-Type': 'application/json' });
93
+ res.end(JSON.stringify(format(active)));
94
+ }),
95
+ route('POST', `${basePath}/machines/:machineId/meltings/:meltingId/stop`, (req, res, params) => {
96
+ const result = findMachine(params.machineId);
97
+ if (!result) {
98
+ errorResponse('NOT_FOUND', `Active melting '${params.meltingId}' not found`, 404).send(res);
99
+ return;
100
+ }
101
+ const { shop } = result;
102
+ const melting = shop.meltings.query({ id: params.meltingId });
103
+ if (!melting || !melting.stop) {
104
+ errorResponse('NOT_FOUND', `Active melting '${params.meltingId}' not found`, 404).send(res);
105
+ return;
106
+ }
107
+ const completed = melting.stop();
108
+ jsonResponse(format(completed)).send(res);
109
+ }),
110
+ route('POST', `${basePath}/machines/:machineId/meltings`, (req, res, params) => {
111
+ const result = findMachine(params.machineId);
112
+ if (!result) {
113
+ errorResponse('NOT_FOUND', `Machine '${params.machineId}' not found`, 404).send(res);
114
+ return;
115
+ }
116
+ let body = '';
117
+ req.on('data', (chunk) => {
118
+ body += chunk;
119
+ });
120
+ req.on('end', () => {
121
+ const data = JSON.parse(body);
122
+ const { machine, shop } = result;
123
+ const melting = shop.meltings.add(machine, data);
124
+ res.writeHead(201, { 'Content-Type': 'application/json' });
125
+ res.end(JSON.stringify(format(melting)));
126
+ });
127
+ }),
128
+ route('PUT', `${basePath}/machines/:machineId/meltings/:meltingId`, (req, res, params) => {
129
+ const result = findMachine(params.machineId);
130
+ if (!result) {
131
+ errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
132
+ return;
133
+ }
134
+ let body = '';
135
+ req.on('data', (chunk) => {
136
+ body += chunk;
137
+ });
138
+ req.on('end', () => {
139
+ const data = JSON.parse(body);
140
+ const { shop } = result;
141
+ const melting = shop.meltings.query({ id: params.meltingId });
142
+ if (!melting) {
143
+ errorResponse('NOT_FOUND', `Melting '${params.meltingId}' not found`, 404).send(res);
144
+ return;
145
+ }
146
+ const updated = melting.update(data);
147
+ jsonResponse(format(updated)).send(res);
148
+ });
149
+ })
150
+ ];
151
+ }
152
+
153
+ /**
154
+ * Melting routes factory.
155
+ * Creates routes for /machines/:machineId/meltings.
156
+ *
157
+ * @param {string} basePath - base URL path
158
+ * @param {object} plant - plant domain object from scada package
159
+ * @returns {array} array of route objects
160
+ *
161
+ * @example
162
+ * const routes = meltingRoute('/api/v1', plant);
163
+ */
164
+ export default function meltingRoute(basePath, plant) {
165
+ function findMachine(id) {
166
+ for (const shop of Object.values(plant.shops.get())) {
167
+ const machine = shop.machines.get()[id];
168
+ if (machine) {
169
+ return { machine, shop };
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+ return [...readRoutes(basePath, findMachine), ...writeRoutes(basePath, findMachine)];
175
+ }
@@ -0,0 +1,70 @@
1
+ import route from '../objects/route.js';
2
+ import sseResponse from '../objects/sseResponse.js';
3
+
4
+ /**
5
+ * Melting stream route factory.
6
+ * Creates SSE route for /machines/:machineId/meltings/stream.
7
+ *
8
+ * @param {string} basePath - base URL path
9
+ * @param {object} plant - plant domain object from scada package
10
+ * @param {function} clock - time provider
11
+ * @returns {array} array of route objects
12
+ *
13
+ * @example
14
+ * const routes = meltingStream('/api/v1', plant, clock);
15
+ */
16
+ export default function meltingStream(basePath, plant, clock) {
17
+ function findMachine(id) {
18
+ for (const shop of Object.values(plant.shops.get())) {
19
+ const machine = shop.machines.get()[id];
20
+ if (machine) {
21
+ return { machine, shop };
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+ return [
27
+ route(
28
+ 'GET',
29
+ `${basePath}/machines/:machineId/meltings/stream`,
30
+ (req, res, params) => {
31
+ const sse = sseResponse(res, clock);
32
+ sse.heartbeat();
33
+ const result = findMachine(params.machineId);
34
+ if (!result) {
35
+ sse.close();
36
+ return;
37
+ }
38
+ const { machine, shop } = result;
39
+ const subscription = shop.meltings.query({ stream: (event) => {
40
+ if (event.type === 'started') {
41
+ const m = event.melting;
42
+ const data = m.chronology().get();
43
+ sse.emit('melting_started', {
44
+ id: m.id(),
45
+ start: data.start.toISOString()
46
+ });
47
+ } else if (event.type === 'completed') {
48
+ const m = event.melting;
49
+ const data = m.chronology().get();
50
+ sse.emit('melting_ended', {
51
+ id: m.id(),
52
+ end: data.end.toISOString(),
53
+ initial: data.initial,
54
+ weight: data.weight,
55
+ loaded: data.loaded,
56
+ dispensed: data.dispensed
57
+ });
58
+ }
59
+ }});
60
+ const heartbeat = setInterval(() => {
61
+ sse.heartbeat();
62
+ }, 30000);
63
+ req.on('close', () => {
64
+ clearInterval(heartbeat);
65
+ subscription.cancel();
66
+ });
67
+ }
68
+ )
69
+ ];
70
+ }