node-red-contrib-tdn-eventsub 0.2.2 → 0.2.3

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,172 @@
1
+ 'use strict';
2
+ const { RestClient, RestError } = require('../lib/client');
3
+ const { OUT_RESPONSE, send, setStatus, reportError, storeName } = require('../lib/outputs');
4
+ const { configureCreds, resolveCreds, stripCreds } = require('../lib/credentials');
5
+ const { runSubscription } = require('../lib/subscription');
6
+
7
+ /**
8
+ * Event updates subscription.
9
+ *
10
+ * GET <base>/events/updates?<filters> (long poll: returns new events, or empty after ~50 s)
11
+ * loop: follow the returned updates/next link, forever
12
+ *
13
+ * Filters (node "Filter" setting, msg.query at start): e.g. group=23&type=20001&source=508
14
+ * Failure handling (back-off, hard stop on key/cert/licence errors): lib/subscription.js.
15
+ * Any message starts (or restarts) it; msg.payload "stop", msg.action "stop" or msg.stop = true ends it.
16
+ */
17
+
18
+ const PATH = '/events/updates';
19
+ const MAX_DELAY_MS = 20000;
20
+
21
+ function isStop(msg) {
22
+ const word = (v) => typeof v === 'string' && v.trim().toLowerCase() === 'stop';
23
+ return msg.stop === true || word(msg.payload) || word(msg.action);
24
+ }
25
+
26
+ /** The long-poll link to follow: updates.href (preferred) or next.href. */
27
+ function nextHref(res) {
28
+ const b = res.body || {};
29
+ return (b.updates && b.updates.href) || (b.next && b.next.href) || null;
30
+ }
31
+
32
+ function eventsOf(res) {
33
+ return res.body && Array.isArray(res.body.events) ? res.body.events : [];
34
+ }
35
+
36
+ module.exports = function (RED) {
37
+ function RestEventsNode(config) {
38
+ RED.nodes.createNode(this, config);
39
+ const node = this;
40
+ node.name = config.name;
41
+ node.server = RED.nodes.getNode(config.server);
42
+ node.query = (config.query || '').trim();
43
+ node.pollDelay = Math.min(Math.max(parseInt(config.pollDelay, 10) || 0, 0), MAX_DELAY_MS);
44
+ node.retryDelay = Math.max(parseInt(config.retryDelay, 10) || 5000, 100);
45
+ node.maxRetryDelay = Math.max(parseInt(config.maxRetryDelay, 10) || 60000, node.retryDelay);
46
+ node.emitTimeouts = config.emitTimeouts === true;
47
+ node.contextKey = (config.contextKey || '').trim();
48
+ node.contextStore = config.contextStore || '';
49
+ node.keepEvents = Math.max(parseInt(config.keepEvents, 10) || 100, 1);
50
+ node.autoStart = config.autoStart === true;
51
+ configureCreds(node, config);
52
+
53
+ let run = null; // { ctrl }
54
+
55
+ function storeEvents(events) {
56
+ if (!node.contextKey || !events.length) return;
57
+ const g = node.context().global;
58
+ const store = storeName(node.contextStore);
59
+ const list = (g.get(node.contextKey, store) || []).concat(events);
60
+ g.set(node.contextKey, list.slice(-node.keepEvents), store); // newest last, capped
61
+ }
62
+
63
+ async function loop(ctrl, cred, query) {
64
+ let client;
65
+ try {
66
+ client = node.server.getClient(cred, node);
67
+ } catch (e) {
68
+ reportError(node, e, { stopped: true, category: 'fatal' });
69
+ node.status({ fill: 'red', shape: 'ring', text: 'stopped: ' + (e.code || 'error') });
70
+ return;
71
+ }
72
+ const startHref = RestClient.addQuery(client.url(PATH), query);
73
+ let seq = 0;
74
+ const emit = (res, phase) => {
75
+ const events = eventsOf(res);
76
+ storeEvents(events);
77
+ send(node, OUT_RESPONSE, {
78
+ topic: 'rest/events/updates',
79
+ payload: res.body, // full server response
80
+ events,
81
+ phase, // 'events' | 'timeout'
82
+ seq: ++seq,
83
+ query,
84
+ statusCode: res.statusCode,
85
+ url: res.url
86
+ });
87
+ };
88
+ await runSubscription({
89
+ node,
90
+ client,
91
+ signal: ctrl.signal,
92
+ label: 'events',
93
+ extra: { query },
94
+ startHref,
95
+ nextHref,
96
+ hasData: (res) => eventsOf(res).length > 0,
97
+ onStart: () => {},
98
+ onData: (res) => emit(res, 'events'),
99
+ onEmpty: (res) => { if (node.emitTimeouts) emit(res, 'timeout'); },
100
+ dataText: (res) => `${eventsOf(res).length} event(s) @ ${new Date().toLocaleTimeString()}`
101
+ });
102
+ }
103
+
104
+ function stop(reason) {
105
+ if (run) {
106
+ run.ctrl.abort();
107
+ run = null;
108
+ setStatus(node, 'stopped', reason || 'stopped');
109
+ }
110
+ }
111
+
112
+ function start(cred, msgQuery) {
113
+ const query = Object.assign(RestClient.parseQueryString(node.query),
114
+ msgQuery ? (typeof msgQuery === 'string' ? RestClient.parseQueryString(msgQuery) : msgQuery) : {});
115
+ if (run) stop('replaced by new subscription');
116
+ const ctrl = new AbortController();
117
+ run = { ctrl };
118
+ loop(ctrl, cred, query).catch((e) => reportError(node, e)).finally(() => {
119
+ if (run && run.ctrl === ctrl) run = null;
120
+ });
121
+ }
122
+
123
+ if (!node.server) node.status({ fill: 'red', shape: 'dot', text: 'no server config' });
124
+ else node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
125
+
126
+ node.on('input', async function (msg, sendFn, done) {
127
+ done = done || function () {};
128
+ if (isStop(msg)) {
129
+ stripCreds(RED, node, msg);
130
+ if (run) stop('stopped by input');
131
+ else setStatus(node, 'stopped', 'not running');
132
+ return done();
133
+ }
134
+ let cred;
135
+ try {
136
+ cred = await resolveCreds(RED, node, msg);
137
+ } catch (e) {
138
+ reportError(node, e);
139
+ return done();
140
+ } finally {
141
+ stripCreds(RED, node, msg);
142
+ }
143
+ if (!node.server) {
144
+ reportError(node, new RestError('No REST server configured', { code: 'ENOSERVER' }));
145
+ return done();
146
+ }
147
+ start(cred, msg.query);
148
+ done(); // the trigger message is handled; the subscription carries on by itself
149
+ });
150
+
151
+ let autoTimer = null;
152
+ if (node.autoStart && node.server) {
153
+ // credentials from flow/global/env only (there is no msg yet)
154
+ autoTimer = setTimeout(() => {
155
+ autoTimer = null;
156
+ resolveCreds(RED, node, {}).then((cred) => start(cred)).catch((e) => reportError(node, e));
157
+ }, 500);
158
+ }
159
+
160
+ node.on('close', function (removed, done) {
161
+ if (typeof removed === 'function') { done = removed; }
162
+ if (autoTimer) clearTimeout(autoTimer);
163
+ if (run) { run.ctrl.abort(); run = null; }
164
+ node.status({});
165
+ done();
166
+ });
167
+ }
168
+
169
+ RED.nodes.registerType('tdn-rest-events', RestEventsNode);
170
+ };
171
+
172
+ module.exports.nextHref = nextHref;
@@ -76,22 +76,21 @@
76
76
  <script type="text/html" data-help-name="tdn-rest-item-status">
77
77
  <p>Subscribes to status changes for a set of REST Server items and keeps listening indefinitely.
78
78
  Needs REST Server v8.30+ and the RESTStatus licence.</p>
79
- <p>Sequence: <code>POST features.items.updates.href</code> with <code>{"itemIds":[...]}</code> (returns current state),
79
+ <p>Sequence: <code>POST &lt;server base&gt;/items/updates</code> (e.g. <code>https://192.168.0.20:8904/api/items/updates</code>) with <code>{"itemIds":[...]}</code> (returns current state),
80
80
  then an update-wait <code>GET next.href</code>. When an update arrives it is output and a new update-wait is sent.
81
81
  When an update-wait times out (~50&nbsp;s, empty <code>updates</code>) another one is sent.
82
- A <code>404</code> means the server dropped the subscription (more than 30&nbsp;s between calls, or server restart), so it re-subscribes.
83
- Any other error is reported, then retried with exponential back-off.</p>
82
+ A <code>404</code> on a followed link means the server dropped the subscription (more than 30&nbsp;s between calls, or server restart), so it re-subscribes.</p>
84
83
  <h3>Input</h3>
85
84
  <dl class="message-properties">
86
85
  <dt>payload <span class="property-type">string | number | array | object</span></dt>
87
86
  <dd>The source id(s): <code>"508"</code>, <code>"508,526"</code>, <code>["508","526"]</code>,
88
87
  <code>{"itemIds":["508","526"]}</code>, or an array of item objects with an <code>id</code> (e.g. straight from <i>get items</i> results).
89
- <code>msg.itemIds</code> takes precedence if present.</dd>
88
+ <code>msg.itemIds</code>, then <code>msg.id</code>, take precedence if present.</dd>
90
89
  <dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
91
90
  Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
92
91
  <code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
93
92
  Missing fields fall back to the server config. A msg-borne credentials property is removed from the trigger msg.</dd>
94
- <dd>Credentials are read once per trigger and kept for that subscription. A new trigger replaces the running subscription. <code>payload</code> = <code>"stop"</code> or <code>msg.stop = true</code> stops it.</dd>
93
+ <dd>Credentials are read once per trigger and kept for that subscription. A new trigger replaces the running subscription. <code>payload</code> = <code>"stop"</code>, <code>msg.action = "stop"</code> or <code>msg.stop = true</code> stops it.</dd>
95
94
  </dl>
96
95
  <h3>Outputs</h3>
97
96
  <ol class="node-ports">
@@ -103,9 +102,18 @@
103
102
  <dt>itemIds / seq / statusCode / url</dt><dd></dd>
104
103
  </dl>
105
104
  </li>
106
- <li>Node status &ndash; <code>payload.state</code>: <code>subscribing</code>, <code>subscribed</code>, <code>update</code>, <code>resubscribing</code>, <code>retrying</code>, <code>stopped</code>. Each long-poll wait and timeout shows on the node badge only, so this output isn't flooded every ~50&nbsp;s.</li>
107
- <li>Error &ndash; <code>payload.message</code>, <code>statusCode</code>, <code>body</code> (on 9.60+ a 403 lists the ids you may monitor), <code>attempt</code>, <code>retryInMs</code>.</li>
105
+ <li>Status &ndash; successful server responses only: <code>subscribed</code> (POST ok), <code>update</code> (poll with updates); plus <code>stopped</code> on a stop input.
106
+ Empty long polls (~every 50&nbsp;s) show on the node badge only.</li>
107
+ <li>Error &ndash; <code>payload.message</code>, <code>statusCode</code>, <code>body</code> (on 9.60+ a 403 lists the ids you may monitor), <code>category</code>, <code>stopped</code>, <code>attempt</code>, <code>retryInMs</code>.</li>
108
108
  </ol>
109
+ <h3>Failures</h3>
110
+ <p>Every non-success goes to the error output (never the status output), with <code>category</code>:</p>
111
+ <ul>
112
+ <li><b>fatal</b> &ndash; 401 (API key), 403 (licence / privilege), 404 on the subscribe endpoint, other 4xx, certificate or TLS failure:
113
+ polling <b>stops</b> (<code>stopped: true</code>) until a new trigger or redeploy.</li>
114
+ <li><b>expired</b> &ndash; 404 on a followed link (subscription dropped by the server): re-subscribes immediately.</li>
115
+ <li><b>transient</b> &ndash; 5xx, 408, 429, timeout, network: back-off <i>retry delay</i> &times; 2<sup>n</sup> up to <i>max</i>, then re-subscribes. Runs until stopped.</li>
116
+ </ul>
109
117
  <h3>Global context (optional)</h3>
110
118
  <p>If a key is set, <code>global.&lt;key&gt;</code> is kept as a map of item id &rarr; latest status (with an <code>updated</code> time).</p>
111
119
  <h3>Notes</h3>
@@ -1,26 +1,25 @@
1
1
  'use strict';
2
2
  const { OUT_RESPONSE, send, setStatus, reportError, storeName, nowIso } = require('../lib/outputs');
3
3
  const { configureCreds, resolveCreds, stripCreds } = require('../lib/credentials');
4
+ const { runSubscription } = require('../lib/subscription');
5
+ const { RestError } = require('../lib/client');
4
6
 
5
7
  /**
6
8
  * Item status subscription (REST Server v8.30+, RESTStatus licence).
7
9
  *
8
- * POST features.items.updates.href {itemIds:[...]} -> current status + next.href
9
- * loop: GET next.href (long poll, ~50 s server timeout)
10
- * -> updates (emit) or empty (timeout: just wait again)
11
- * -> always follow the new next.href
12
- * 404 -> subscription dropped (>30 s gap or server restart): POST again
13
- * other errors -> back off and re-subscribe, forever, until stopped
10
+ * POST <base>/items/updates {itemIds:[...]} -> current status + next.href
11
+ * loop: GET next.href (long poll, ~50 s server timeout) -> updates or empty, follow next.href
14
12
  *
13
+ * Failure handling (back-off, hard stop on key/cert/licence errors): lib/subscription.js.
15
14
  * One trigger starts it; it then runs indefinitely. A new trigger with ids replaces
16
- * the subscription; msg.payload "stop" (or msg.stop = true) ends it.
15
+ * the subscription; msg.payload "stop", msg.action "stop" or msg.stop = true ends it.
17
16
  */
18
17
 
19
- const MAX_GAP_MS = 20000; // the server drops a subscription if next.href isn't fetched within 30 s
20
- const LONG_POLL_MS = 120000; // client-side timeout for the GET (server returns at ~50 s)
18
+ const PATH = '/items/updates';
19
+ const MAX_GAP_MS = 20000; // the server drops a subscription if next.href isn't fetched within 30 s
21
20
 
22
21
  function parseIds(msg) {
23
- let v = msg.itemIds !== undefined ? msg.itemIds : msg.payload;
22
+ let v = msg.itemIds !== undefined ? msg.itemIds : (msg.id !== undefined ? msg.id : msg.payload);
24
23
  if (v && typeof v === 'object' && !Array.isArray(v) && v.itemIds !== undefined) v = v.itemIds;
25
24
  if (typeof v === 'number') v = [v];
26
25
  if (typeof v === 'string') v = v.split(/[\s,;]+/);
@@ -36,7 +35,8 @@ function parseIds(msg) {
36
35
  }
37
36
 
38
37
  function isStop(msg) {
39
- return msg.stop === true || (typeof msg.payload === 'string' && msg.payload.trim().toLowerCase() === 'stop');
38
+ const word = (v) => typeof v === 'string' && v.trim().toLowerCase() === 'stop';
39
+ return msg.stop === true || word(msg.payload) || word(msg.action);
40
40
  }
41
41
 
42
42
  module.exports = function (RED) {
@@ -46,7 +46,7 @@ module.exports = function (RED) {
46
46
  node.name = config.name;
47
47
  node.server = RED.nodes.getNode(config.server);
48
48
  node.pollDelay = Math.min(Math.max(parseInt(config.pollDelay, 10) || 0, 0), MAX_GAP_MS);
49
- node.retryDelay = Math.max(parseInt(config.retryDelay, 10) || 5000, 500);
49
+ node.retryDelay = Math.max(parseInt(config.retryDelay, 10) || 5000, 100);
50
50
  node.maxRetryDelay = Math.max(parseInt(config.maxRetryDelay, 10) || 60000, node.retryDelay);
51
51
  node.emitTimeouts = config.emitTimeouts === true;
52
52
  node.contextKey = (config.contextKey || '').trim();
@@ -55,15 +55,6 @@ module.exports = function (RED) {
55
55
 
56
56
  let run = null; // { ctrl, ids }
57
57
 
58
- function sleep(ms, signal) {
59
- return new Promise((resolve) => {
60
- if (signal.aborted) return resolve();
61
- const t = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
62
- const onAbort = () => { clearTimeout(t); resolve(); };
63
- signal.addEventListener('abort', onAbort, { once: true });
64
- });
65
- }
66
-
67
58
  function storeUpdates(updates) {
68
59
  if (!node.contextKey || !Array.isArray(updates) || !updates.length) return;
69
60
  const g = node.context().global;
@@ -74,83 +65,49 @@ module.exports = function (RED) {
74
65
  g.set(node.contextKey, map, store);
75
66
  }
76
67
 
77
- function emit(res, phase, ids, seq) {
78
- const body = res.body || {};
79
- storeUpdates(body.updates);
80
- send(node, OUT_RESPONSE, {
81
- topic: 'rest/items/updates',
82
- payload: body, // full server response
83
- updates: Array.isArray(body.updates) ? body.updates : [],
84
- phase, // 'subscribe' | 'update' | 'timeout'
85
- seq,
86
- itemIds: ids,
87
- statusCode: res.statusCode,
88
- url: res.url
89
- });
68
+ function emitter(ids) {
69
+ let seq = 0;
70
+ return (res, phase) => {
71
+ const body = res.body || {};
72
+ storeUpdates(body.updates);
73
+ send(node, OUT_RESPONSE, {
74
+ topic: 'rest/items/updates',
75
+ payload: body, // full server response
76
+ updates: Array.isArray(body.updates) ? body.updates : [],
77
+ phase, // 'subscribe' | 'update' | 'timeout'
78
+ seq: ++seq,
79
+ itemIds: ids,
80
+ statusCode: res.statusCode,
81
+ url: res.url
82
+ });
83
+ };
90
84
  }
91
85
 
92
86
  async function loop(ids, ctrl, cred) {
93
- const signal = ctrl.signal;
94
87
  let client;
95
- // credentials are taken once, from the trigger; the subscription keeps using them
96
- try { client = node.server.getClient(cred); } catch (e) { reportError(node, e); return; }
97
-
98
- let next = null;
99
- let failures = 0;
100
- let seq = 0;
101
- while (!signal.aborted) {
102
- try {
103
- if (!next) {
104
- setStatus(node, 'subscribing', `subscribing ${ids.length} item(s)`, { itemIds: ids });
105
- const href = await client.feature('items.updates', { signal });
106
- const res = await client.request('POST', href, { itemIds: ids }, { signal });
107
- if (signal.aborted) break;
108
- emit(res, 'subscribe', ids, ++seq);
109
- next = res.body && res.body.next && res.body.next.href;
110
- if (!next) throw new Error('Subscription response had no next.href');
111
- setStatus(node, 'subscribed', `subscribed ${ids.length} item(s)`, { itemIds: ids });
112
- } else {
113
- if (node.pollDelay) await sleep(node.pollDelay, signal);
114
- if (signal.aborted) break;
115
- // badge only - every long poll would otherwise flood the status output
116
- node.status({ fill: 'green', shape: 'ring', text: `waiting (${ids.length} item(s))` });
117
- const res = await client.request('GET', next, null, { signal, timeoutMs: LONG_POLL_MS });
118
- if (signal.aborted) break;
119
- const updates = res.body && Array.isArray(res.body.updates) ? res.body.updates : [];
120
- if (updates.length) {
121
- emit(res, 'update', ids, ++seq);
122
- setStatus(node, 'update', `${updates.length} update(s) @ ${new Date().toLocaleTimeString()}`, {
123
- count: updates.length,
124
- ids: updates.map((u) => u.id)
125
- });
126
- } else {
127
- // update-wait timed out with nothing new: send another update-wait.
128
- // Badge only - this happens every ~50 s and would flood the status output.
129
- if (node.emitTimeouts) emit(res, 'timeout', ids, ++seq);
130
- node.status({ fill: 'green', shape: 'ring', text: `wait timed out, re-waiting (${ids.length} item(s))` });
131
- }
132
- next = res.body && res.body.next && res.body.next.href;
133
- if (!next) {
134
- setStatus(node, 'resubscribing', 'no next link, re-subscribing');
135
- }
136
- }
137
- failures = 0;
138
- } catch (err) {
139
- if (signal.aborted || err.aborted) break;
140
- next = null;
141
- if (err.statusCode === 404 && failures === 0) {
142
- // subscription expired (gap > 30 s or server restart) - re-POST straight away once
143
- setStatus(node, 'resubscribing', 'subscription expired (404), re-subscribing');
144
- failures++;
145
- continue;
146
- }
147
- failures++;
148
- const delay = Math.min(node.retryDelay * Math.pow(2, Math.min(failures - 1, 10)), node.maxRetryDelay);
149
- reportError(node, err, { itemIds: ids, attempt: failures, retryInMs: delay });
150
- setStatus(node, 'retrying', `retry ${failures} in ${Math.round(delay / 1000)} s`, { attempt: failures, retryInMs: delay, error: err.message });
151
- await sleep(delay, signal);
152
- }
88
+ try {
89
+ client = node.server.getClient(cred, node);
90
+ } catch (e) {
91
+ reportError(node, e, { itemIds: ids, stopped: true, category: 'fatal' });
92
+ node.status({ fill: 'red', shape: 'ring', text: 'stopped: ' + (e.code || 'error') });
93
+ return;
153
94
  }
95
+ const emit = emitter(ids);
96
+ const updatesOf = (res) => (res.body && Array.isArray(res.body.updates) ? res.body.updates : []);
97
+ await runSubscription({
98
+ node,
99
+ client,
100
+ signal: ctrl.signal,
101
+ label: `${ids.length} item(s)`,
102
+ extra: { itemIds: ids },
103
+ start: () => client.request('POST', client.url(PATH), { itemIds: ids }, { signal: ctrl.signal }),
104
+ nextHref: (res) => (res.body && res.body.next && res.body.next.href) || null,
105
+ hasData: (res) => updatesOf(res).length > 0,
106
+ onStart: (res) => emit(res, 'subscribe'),
107
+ onData: (res) => emit(res, 'update'),
108
+ onEmpty: (res) => { if (node.emitTimeouts) emit(res, 'timeout'); },
109
+ dataText: (res) => `${updatesOf(res).length} update(s) @ ${new Date().toLocaleTimeString()}`
110
+ });
154
111
  }
155
112
 
156
113
  function stop(reason) {
@@ -161,7 +118,7 @@ module.exports = function (RED) {
161
118
  }
162
119
  }
163
120
 
164
- if (!node.server) setStatus(node, 'error', 'no server config');
121
+ if (!node.server) node.status({ fill: 'red', shape: 'dot', text: 'no server config' });
165
122
  else node.status({ fill: 'grey', shape: 'ring', text: 'idle' });
166
123
 
167
124
  node.on('input', async function (msg, sendFn, done) {
@@ -182,12 +139,12 @@ module.exports = function (RED) {
182
139
  stripCreds(RED, node, msg);
183
140
  }
184
141
  if (!node.server) {
185
- reportError(node, new Error('No REST server configured'));
142
+ reportError(node, new RestError('No REST server configured', { code: 'ENOSERVER' }));
186
143
  return done();
187
144
  }
188
145
  const ids = parseIds(msg);
189
146
  if (!ids.length) {
190
- reportError(node, new Error('No item ids in msg.payload / msg.itemIds (string "508,526", array, or {itemIds:[...]})'));
147
+ reportError(node, new RestError('No item ids in msg.payload / msg.itemIds / msg.id (string "508,526", array, or {itemIds:[...]})', { code: 'EBADINPUT' }));
191
148
  return done();
192
149
  }
193
150
  if (run) stop('replaced by new subscription');