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.
package/lib/client.js CHANGED
@@ -2,11 +2,15 @@
2
2
  /**
3
3
  * Minimal REST Server API client.
4
4
  *
5
+ * - Fixed endpoint paths under a base URL: <protocol>://<host>:<port><basePath><path>
6
+ * e.g. https://192.168.0.20:8904/api/access_groups. The base can be fixed at
7
+ * construction or chosen per request (withBase), e.g. from global context.
5
8
  * - Auth: "Authorization: Basic base64(':' + key)" by default (server v9.0+),
6
9
  * "Authorization: <scheme> <key>" when a custom scheme token is configured,
7
10
  * or the key in its own header ("X-API-Key: <key>" or a configured name)
8
11
  * - Credentials can be fixed at construction or supplied later (withCredentials)
9
- * - HATEOAS: everything is discovered from GET /api (features.*.href)
12
+ * - Failures are classified fatal (4xx, cert/TLS/auth/config) or transient
13
+ * (5xx, 408, 429, timeouts, network); only transient ones are retried
10
14
  * - Optional server certificate pinning by SHA-1 or SHA-256 fingerprint
11
15
  * (lets you talk to a self-signed REST Server without disabling verification)
12
16
  * - Optional client certificate (server "pinned client certificate" / thumbprint)
@@ -20,6 +24,7 @@ const fs = require('fs');
20
24
  const { URL } = require('url');
21
25
 
22
26
  const DEFAULT_PORT = 8904;
27
+ const DEFAULT_BASE_PATH = '/api';
23
28
  const TOKEN_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; // RFC 7230 token: a valid auth-scheme name
24
29
 
25
30
  function normFp(fp) {
@@ -68,15 +73,17 @@ function normaliseCredentials(cred, defaults) {
68
73
  function buildAuthHeaders(cred) {
69
74
  const c = normaliseCredentials(cred);
70
75
  if (!c.apiKey) throw new RestError('No API key: store one in the server config or pass a credentials object', { code: 'ENOCREDS' });
71
- if (/[\r\n]/.test(c.apiKey)) throw new RestError('API key must not contain line breaks');
72
- if (!AUTH_TYPES.includes(c.authType)) throw new RestError(`Auth type must be one of: ${AUTH_TYPES.join(', ')}`);
76
+ if (/[\r\n]/.test(c.apiKey)) throw new RestError('API key must not contain line breaks', { config: true });
77
+ if (!AUTH_TYPES.includes(c.authType)) throw new RestError(`Auth type must be one of: ${AUTH_TYPES.join(', ')}`, { config: true });
73
78
  if (c.authType === 'header') {
74
79
  if (!TOKEN_RE.test(c.headerName) || RESERVED_HEADERS.includes(c.headerName.toLowerCase())) {
75
- throw new RestError(`Invalid API key header name "${c.headerName}"`);
80
+ throw new RestError(`Invalid API key header name "${c.headerName}"`, { config: true });
76
81
  }
77
82
  return { [c.headerName]: c.apiKey };
78
83
  }
79
- if (c.authScheme && !TOKEN_RE.test(c.authScheme)) throw new RestError('Auth scheme must be a single token (letters, digits, - _ . etc., no spaces)');
84
+ if (c.authScheme && !TOKEN_RE.test(c.authScheme)) {
85
+ throw new RestError('Auth scheme must be a single token (letters, digits, - _ . etc., no spaces)', { config: true });
86
+ }
80
87
  return {
81
88
  Authorization: c.authScheme
82
89
  ? c.authScheme + ' ' + c.apiKey
@@ -84,6 +91,45 @@ function buildAuthHeaders(cred) {
84
91
  };
85
92
  }
86
93
 
94
+ /**
95
+ * Build the server address from { url | host, port, basePath, protocol, plainHttp }.
96
+ * `host` may be a bare host/IP or a full URL; explicit fields override its parts.
97
+ * Without a scheme or port the port defaults to 8904; the base path defaults to /api.
98
+ * Returns { base: URL (origin only), apiRoot: origin + base path }.
99
+ */
100
+ function parseTarget(o) {
101
+ o = o || {};
102
+ const given = String(o.host || o.url || '').trim();
103
+ const bad = (m) => new RestError(m, { code: 'ENOSERVER' });
104
+ if (!given) throw bad('No server address: set host in global context or the server config');
105
+ const hadScheme = /^https?:\/\//i.test(given);
106
+ let u;
107
+ try { u = new URL(hadScheme ? given : 'https://' + given); } catch (e) { throw bad(`Invalid server address "${given}"`); }
108
+
109
+ const proto = String(o.protocol || '').trim().toLowerCase().replace(/:?\/*$/, '');
110
+ if (proto) {
111
+ if (proto !== 'http' && proto !== 'https') throw bad(`Protocol must be "http" or "https", got "${o.protocol}"`);
112
+ u.protocol = proto + ':';
113
+ } else if (o.plainHttp) {
114
+ u.protocol = 'http:';
115
+ }
116
+
117
+ if (o.port !== undefined && o.port !== null && String(o.port).trim() !== '') {
118
+ const port = Number(o.port);
119
+ if (!(Number.isInteger(port) && port > 0 && port < 65536)) throw bad(`Invalid port "${o.port}"`);
120
+ u.port = String(port); // the default port for the scheme (80/443) is dropped from the URL, which is fine
121
+ } else if (!u.port && !hadScheme) {
122
+ u.port = String(DEFAULT_PORT);
123
+ }
124
+
125
+ let path = o.basePath !== undefined && o.basePath !== null && String(o.basePath).trim() !== ''
126
+ ? String(o.basePath).trim()
127
+ : (u.pathname && u.pathname !== '/' ? u.pathname : DEFAULT_BASE_PATH);
128
+ path = ('/' + path.replace(/^\/+/, '')).replace(/\/+$/, '');
129
+ const base = new URL(u.origin);
130
+ return { base, apiRoot: base.origin + path };
131
+ }
132
+
87
133
  // OpenSSL errors seen when the other end isn't speaking TLS at all
88
134
  const NOT_TLS_CODES = ['ERR_SSL_WRONG_VERSION_NUMBER', 'ERR_SSL_UNEXPECTED_MESSAGE', 'ERR_SSL_PACKET_LENGTH_TOO_LONG', 'ERR_SSL_HTTP_REQUEST', 'ERR_SSL_RECORD_LAYER_FAILURE'];
89
135
 
@@ -91,7 +137,7 @@ const NOT_TLS_CODES = ['ERR_SSL_WRONG_VERSION_NUMBER', 'ERR_SSL_UNEXPECTED_MESSA
91
137
  function notTlsHint(e) {
92
138
  if (e && NOT_TLS_CODES.includes(e.code)) {
93
139
  e.message = `TLS handshake failed (${e.code}): the endpoint may not be using TLS. ` +
94
- 'For a plain-HTTP endpoint tick "Plain HTTP (insecure)" in the server config. ' + e.message;
140
+ 'For a plain-HTTP endpoint set protocol "http" (global context) or tick "Plain HTTP (insecure)" in the server config. ' + e.message;
95
141
  }
96
142
  return e;
97
143
  }
@@ -100,36 +146,72 @@ function abortError() {
100
146
  return new RestError('Request aborted', { code: 'ABORTED', aborted: true });
101
147
  }
102
148
 
149
+ // failures that retrying cannot fix: stop (and stop any polling)
150
+ const FATAL_CODES = ['ECERTPIN', 'ENOCREDS', 'EDOWNGRADE', 'ENOSERVER', 'EPROTO',
151
+ 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
152
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'CERT_REVOKED', 'ERR_TLS_CERT_ALTNAME_INVALID'];
153
+
154
+ /**
155
+ * 'aborted' | 'fatal' | 'transient'.
156
+ * fatal: 4xx except 408/429 (401 key, 403 licence/privilege, 404 endpoint, 400 bad request...),
157
+ * certificate / TLS / auth / configuration problems.
158
+ * transient: 408, 429, 5xx, timeouts and network errors.
159
+ */
160
+ function classify(err) {
161
+ if (!err) return 'transient';
162
+ if (err.aborted) return 'aborted';
163
+ if (err.fatal || err.config) return 'fatal';
164
+ const s = err.statusCode;
165
+ if (s) return s === 408 || s === 429 || s >= 500 ? 'transient' : 'fatal';
166
+ const code = String(err.code || '');
167
+ if (FATAL_CODES.includes(code) || code.startsWith('ERR_SSL_') || code.startsWith('ERR_TLS_') || code.startsWith('CERT_')) return 'fatal';
168
+ return 'transient';
169
+ }
170
+
171
+ /** Retrying these can't create duplicates. POST/PATCH are never retried. */
172
+ const IDEMPOTENT = ['GET', 'HEAD', 'DELETE', 'PUT'];
173
+
174
+ /** setTimeout that resolves early when the signal aborts. */
175
+ function sleep(ms, signal) {
176
+ return new Promise((resolve) => {
177
+ if (signal && signal.aborted) return resolve();
178
+ const onAbort = () => { clearTimeout(t); resolve(); };
179
+ const t = setTimeout(() => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); }, ms);
180
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
181
+ });
182
+ }
183
+
184
+ /** Back-off delay for retry n (1-based): base * 2^(n-1), capped. */
185
+ function backoff(n, baseMs, maxMs) {
186
+ return Math.min(baseMs * Math.pow(2, Math.min(n - 1, 16)), maxMs);
187
+ }
188
+
103
189
  class RestClient {
104
190
  /**
105
191
  * @param {object} o
106
- * @param {string} o.url host, IP or URL (https:// assumed)
107
- * @param {number} [o.port] default 8904
108
- * @param {string} [o.apiKey] optional; without it use withCredentials() per request source
192
+ * @param {string} [o.url] host, IP or URL; optional when requests get a base via withBase()
193
+ * @param {number} [o.port] default 8904 when the URL has no scheme
194
+ * @param {string} [o.basePath] default '/api' (or the URL's own path)
195
+ * @param {boolean} [o.plainHttp] use unencrypted http:// (bench/test only)
196
+ * @param {string} [o.apiKey] optional; without it use withCredentials()
109
197
  * @param {string} [o.authType] 'authorization' (default) or 'header'
110
198
  * @param {string} [o.authScheme] authorization: '' = Basic with ':<key>'; otherwise '<scheme> <key>'
111
199
  * @param {string} [o.headerName] header: name of the key header (default X-API-Key)
112
200
  * @param {string} [o.fingerprint] server cert SHA-1 (40 hex) or SHA-256 (64 hex), any separators
113
201
  * @param {boolean} [o.rejectUnauthorized] used only when no fingerprint is set
114
202
  * @param {string} [o.certPath] [o.keyPath] [o.pfxPath] [o.passphrase] client certificate
115
- * @param {boolean} [o.rewriteHrefs] force followed hrefs back onto the configured host:port
116
- * @param {boolean} [o.plainHttp] use unencrypted http:// whatever the URL says (bench/test only)
203
+ * @param {boolean} [o.rewriteHrefs] force followed hrefs back onto the base host:port
117
204
  * @param {number} [o.timeoutMs] default request timeout
118
205
  */
119
206
  constructor(o) {
120
207
  o = o || {};
121
- let raw = String(o.url || '').trim();
122
- if (!raw) throw new RestError('No server URL configured');
123
- if (!/^https?:\/\//i.test(raw)) raw = 'https://' + raw;
124
- const u = new URL(raw);
125
- if (o.plainHttp) u.protocol = 'http:';
126
- const port = parseInt(o.port, 10);
127
- if (port > 0) u.port = String(port);
128
- else if (!u.port) u.port = String(DEFAULT_PORT);
129
-
130
- this.base = u;
131
- const path = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/+$/, '') : '/api';
132
- this.apiRoot = u.origin + path;
208
+ this.base = null;
209
+ this.apiRoot = null;
210
+ if (String(o.url || '').trim()) {
211
+ const t = parseTarget({ url: o.url, port: o.port, basePath: o.basePath, plainHttp: o.plainHttp });
212
+ this.base = t.base;
213
+ this.apiRoot = t.apiRoot;
214
+ }
133
215
  // no key here is fine: credentials can be supplied later with withCredentials().
134
216
  // The auth settings are still checked now, so a bad scheme/header fails at deploy.
135
217
  this.authDefaults = { authType: o.authType, authScheme: o.authScheme, headerName: o.headerName };
@@ -138,7 +220,7 @@ class RestClient {
138
220
  this.authHeaders = hasKey ? headers : null;
139
221
  this.fingerprint = normFp(o.fingerprint);
140
222
  if (this.fingerprint && this.fingerprint.length !== 40 && this.fingerprint.length !== 64) {
141
- throw new RestError('Certificate fingerprint must be SHA-1 (40 hex) or SHA-256 (64 hex)');
223
+ throw new RestError('Certificate fingerprint must be SHA-1 (40 hex) or SHA-256 (64 hex)', { config: true });
142
224
  }
143
225
  this.rejectUnauthorized = o.rejectUnauthorized !== false;
144
226
  this.rewriteHrefs = o.rewriteHrefs !== false;
@@ -150,30 +232,42 @@ class RestClient {
150
232
  if (o.certPath) this.tlsExtra.cert = fs.readFileSync(o.certPath);
151
233
  if (o.keyPath) this.tlsExtra.key = fs.readFileSync(o.keyPath);
152
234
  } catch (e) {
153
- throw new RestError('Cannot read client certificate: ' + e.message);
235
+ throw new RestError('Cannot read client certificate: ' + e.message, { config: true });
154
236
  }
155
237
  if (o.passphrase) this.tlsExtra.passphrase = o.passphrase;
156
-
157
- this._discovery = null;
158
- this._discoveryAt = 0;
159
238
  }
160
239
 
161
240
  /**
162
241
  * A client for the same server using other credentials (bare key string or
163
242
  * { apiKey, authType, authScheme, headerName }; missing fields fall back to this
164
- * client's auth settings). Shares TLS settings; has its own discovery cache,
165
- * because what /api lists depends on the operator behind the key.
243
+ * client's auth settings). Shares TLS settings.
166
244
  */
167
245
  withCredentials(cred) {
168
246
  const c = Object.create(this);
169
247
  c.authHeaders = buildAuthHeaders(normaliseCredentials(cred, this.authDefaults));
170
- c._discovery = null;
171
- c._discoveryAt = 0;
172
248
  return c;
173
249
  }
174
250
 
175
- /** Resolve an href (absolute or relative) and optionally pin it to the configured host:port. */
251
+ /** A client for another address: { host, port, basePath, protocol } (see parseTarget). */
252
+ withBase(target) {
253
+ const t = parseTarget(target);
254
+ const c = Object.create(this);
255
+ c.base = t.base;
256
+ c.apiRoot = t.apiRoot;
257
+ return c;
258
+ }
259
+
260
+ /** Absolute URL of an endpoint under the base path: url('/cardholders', '325') -> .../api/cardholders/325 */
261
+ url(path, ...segments) {
262
+ if (!this.apiRoot) throw new RestError('No server address: set host in global context or the server config', { code: 'ENOSERVER' });
263
+ let p = String(path || '').replace(/^\/*/, '/').replace(/\/+$/, '');
264
+ for (const s of segments) p += '/' + encodeURIComponent(String(s));
265
+ return this.apiRoot + p;
266
+ }
267
+
268
+ /** Resolve an href (absolute or relative) and optionally pin it to the base host:port. */
176
269
  resolve(href) {
270
+ if (!this.base) throw new RestError('No server address: set host in global context or the server config', { code: 'ENOSERVER' });
177
271
  const t = new URL(href, this.base.origin);
178
272
  if (this.rewriteHrefs) {
179
273
  t.protocol = this.base.protocol;
@@ -262,7 +356,7 @@ class RestClient {
262
356
  }
263
357
 
264
358
  /**
265
- * Make a request. Resolves { statusCode, headers, body, raw, url }.
359
+ * Make one request. Resolves { statusCode, headers, body, raw, url, method }.
266
360
  * Rejects RestError for transport errors and HTTP >= 400 (statusCode/body attached).
267
361
  */
268
362
  async request(method, href, body, opts) {
@@ -270,11 +364,11 @@ class RestClient {
270
364
  const target = this.resolve(href || this.apiRoot);
271
365
  const timeoutMs = opts.timeoutMs || this.timeoutMs;
272
366
  const signal = opts.signal;
273
- // The socket is opened (and the server cert checked) before any header leaves,
274
- // so the API key is never sent to a server that fails the fingerprint pin.
275
367
  if (!this.authHeaders) {
276
368
  throw new RestError('No API key: store one in the server config or pass a credentials object', { code: 'ENOCREDS' });
277
369
  }
370
+ // The socket is opened (and the server cert checked) before any header leaves,
371
+ // so the API key is never sent to a server that fails the fingerprint pin.
278
372
  const sock = await this._connect(target, signal, timeoutMs);
279
373
  const payload = body === undefined || body === null ? null : Buffer.from(JSON.stringify(body));
280
374
  const headers = Object.assign({
@@ -343,59 +437,66 @@ class RestClient {
343
437
  });
344
438
  }
345
439
 
346
- /** GET /api, cached for cacheMs (default 10 min). */
347
- async discover(opts) {
440
+ /**
441
+ * request() with back-off on transient failures, for idempotent methods only.
442
+ * opts.retry = { retries, delayMs, maxDelayMs, onRetry(err, attempt, delayMs) }.
443
+ * Fatal failures (and POST/PATCH failures) are thrown at once with err.category set.
444
+ */
445
+ async requestWithRetry(method, href, body, opts) {
348
446
  opts = opts || {};
349
- const cacheMs = opts.cacheMs === undefined ? 600000 : opts.cacheMs;
350
- if (!opts.force && this._discovery && Date.now() - this._discoveryAt < cacheMs) return this._discovery;
351
- const res = await this.request('GET', this.apiRoot, null, opts);
352
- if (!res.body || typeof res.body !== 'object' || !res.body.features) {
353
- throw new RestError('GET /api did not return a features block (is this a REST Server API endpoint?)', res);
354
- }
355
- this._discovery = res.body;
356
- this._discoveryAt = Date.now();
357
- return this._discovery;
358
- }
359
-
360
- /** Look up features.<a>.<b>.href, e.g. feature('accessGroups.accessGroups'). */
361
- async feature(path, opts) {
362
- const d = await this.discover(opts);
363
- let node = d.features;
364
- for (const part of path.split('.')) node = node && node[part];
365
- if (!node || !node.href) {
366
- // licences may have changed; re-discover once before giving up
367
- if (!(opts && opts.force)) return this.feature(path, Object.assign({}, opts, { force: true }));
368
- throw new RestError(`Feature "features.${path}.href" not present in /api (not licensed, or operator lacks privilege)`, { code: 'ENOFEATURE' });
447
+ const r = opts.retry || {};
448
+ const retries = IDEMPOTENT.includes(method) ? Math.max(parseInt(r.retries, 10) || 0, 0) : 0;
449
+ for (let attempt = 1; ; attempt++) {
450
+ try {
451
+ return await this.request(method, href, body, opts);
452
+ } catch (err) {
453
+ err.category = classify(err);
454
+ err.attempt = attempt;
455
+ if (err.category !== 'transient' || attempt > retries) throw err;
456
+ const delay = backoff(attempt, r.delayMs || 5000, r.maxDelayMs || 60000);
457
+ err.retryInMs = delay;
458
+ if (r.onRetry) r.onRetry(err, attempt, delay);
459
+ await sleep(delay, opts.signal);
460
+ if (opts.signal && opts.signal.aborted) throw abortError();
461
+ }
369
462
  }
370
- return node.href;
371
463
  }
372
464
 
373
465
  /**
374
- * Follow next.href until exhausted. Resolves { results, pages, pageCount }.
375
- * @param {string} href first page
376
- * @param {object} [opts] { maxPages, signal, timeoutMs, onPage(body, index) }
466
+ * GET href; if the response is a page ({results, next}), follow next.href until exhausted.
467
+ * Resolves { body, paged, results, pages, pageCount, responses }.
468
+ * opts: { maxPages, signal, timeoutMs, retry, onResponse(res, index) }.
377
469
  */
378
- async getAll(href, opts) {
470
+ async fetch(href, opts) {
379
471
  opts = opts || {};
380
472
  const maxPages = parseInt(opts.maxPages, 10) > 0 ? parseInt(opts.maxPages, 10) : Infinity;
381
- const results = [];
382
- const pages = [];
383
- const seen = new Set();
384
- let url = href;
385
- while (url && pages.length < maxPages) {
386
- if (seen.has(url)) break; // defensive: never loop on the same link
387
- seen.add(url);
388
- const res = await this.request('GET', url, null, opts);
473
+ const first = await this.requestWithRetry('GET', href, null, opts);
474
+ if (opts.onResponse) opts.onResponse(first, 0);
475
+ const b0 = first.body;
476
+ if (!b0 || typeof b0 !== 'object' || !Array.isArray(b0.results)) {
477
+ return { body: b0, paged: false, response: first };
478
+ }
479
+ const results = b0.results.slice();
480
+ const pages = [b0];
481
+ const seen = new Set([href]);
482
+ let next = b0.results.length && b0.next && b0.next.href;
483
+ while (next && pages.length < maxPages && !seen.has(next)) { // seen: never loop on the same link
484
+ seen.add(next);
485
+ const res = await this.requestWithRetry('GET', next, null, opts);
389
486
  const b = res.body || {};
390
487
  pages.push(b);
488
+ if (opts.onResponse) opts.onResponse(res, pages.length - 1);
391
489
  const batch = Array.isArray(b.results) ? b.results : [];
392
- for (const r of batch) results.push(r);
393
- if (opts.onPage) opts.onPage(b, pages.length - 1);
490
+ for (const x of batch) results.push(x);
394
491
  if (!batch.length) break;
395
- url = b.next && b.next.href ? b.next.href : null;
492
+ next = b.next && b.next.href;
396
493
  }
397
- return { results, pages, pageCount: pages.length };
494
+ return { body: b0, paged: true, results, pages, pageCount: pages.length, response: first };
398
495
  }
399
496
  }
400
497
 
401
- module.exports = { RestClient, RestError, normaliseCredentials, buildAuthHeaders, normFp, fmtFp, DEFAULT_PORT, DEFAULT_KEY_HEADER };
498
+ module.exports = {
499
+ RestClient, RestError, classify, backoff, sleep, parseTarget,
500
+ normaliseCredentials, buildAuthHeaders, normFp, fmtFp,
501
+ DEFAULT_PORT, DEFAULT_BASE_PATH, DEFAULT_KEY_HEADER
502
+ };
package/lib/outputs.js CHANGED
@@ -15,6 +15,8 @@ const COLOURS = {
15
15
  requesting: { fill: 'blue', shape: 'dot' },
16
16
  paging: { fill: 'blue', shape: 'dot' },
17
17
  done: { fill: 'green', shape: 'dot' },
18
+ response: { fill: 'green', shape: 'ring' },
19
+ events: { fill: 'green', shape: 'dot' },
18
20
  subscribing: { fill: 'blue', shape: 'ring' },
19
21
  subscribed: { fill: 'green', shape: 'ring' },
20
22
  waiting: { fill: 'green', shape: 'ring' },
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ /**
3
+ * Long-poll subscription loop shared by the item status and events nodes.
4
+ *
5
+ * start (optional, e.g. POST /items/updates) -> first response + next link
6
+ * loop: GET next link (long poll; server answers on change or after ~50 s)
7
+ * data -> onData + status output
8
+ * empty -> node badge only (no status flood)
9
+ * always follow the new next link
10
+ *
11
+ * Failures (all go to the error output, never the status output):
12
+ * fatal 401 / 403 / 404 on the start endpoint / other 4xx / cert / TLS / config
13
+ * -> error, polling STOPS until a new trigger or redeploy
14
+ * expired 404 on a followed next link (subscription dropped: >30 s gap, server restart)
15
+ * -> error, re-subscribe immediately (a second 404 in a row is fatal)
16
+ * transient 5xx / 408 / 429 / timeout / network
17
+ * -> error, back off retryDelay * 2^n up to maxRetryDelay, re-subscribe, forever
18
+ */
19
+ const { RestError, classify, backoff, sleep } = require('./client');
20
+ const { setStatus, reportError } = require('./outputs');
21
+
22
+ const LONG_POLL_MS = 120000; // client-side timeout for a long poll (server returns at ~50 s)
23
+
24
+ /**
25
+ * @param {object} o
26
+ * @param {object} o.node Node-RED node (pollDelay, retryDelay, maxRetryDelay)
27
+ * @param {object} o.client RestClient
28
+ * @param {AbortSignal} o.signal
29
+ * @param {string} o.label e.g. '3 item(s)'
30
+ * @param {function} [o.start] async () => res (subscribe request; omit to poll startHref directly)
31
+ * @param {string} [o.startHref] first long-poll URL when there is no start request
32
+ * @param {function} o.nextHref (res) => next long-poll URL or null
33
+ * @param {function} o.hasData (res) => boolean
34
+ * @param {function} o.onStart (res) => void (emit the subscribe response)
35
+ * @param {function} o.onData (res) => void (emit updates)
36
+ * @param {function} [o.onEmpty] (res) => void (optional emit of empty polls)
37
+ * @param {function} o.dataText (res) => status text for a data response
38
+ * @param {object} [o.extra] added to every status/error payload
39
+ * @returns {Promise<'stopped'|'fatal'>}
40
+ */
41
+ async function runSubscription(o) {
42
+ const { node, client, signal, label } = o;
43
+ const extra = o.extra || {};
44
+ let next = null;
45
+ let fromStart = true; // is `next` the start URL (404 there = endpoint missing = fatal)?
46
+ let failures = 0;
47
+ let expiredInARow = 0;
48
+
49
+ while (!signal.aborted) {
50
+ try {
51
+ if (!next) {
52
+ fromStart = true;
53
+ if (o.start) {
54
+ node.status({ fill: 'blue', shape: 'ring', text: `subscribing ${label}` });
55
+ const res = await o.start();
56
+ if (signal.aborted) break;
57
+ o.onStart(res);
58
+ next = o.nextHref(res);
59
+ if (!next) throw new RestError('Subscription response had no next link', { code: 'ENONEXT', fatal: true });
60
+ setStatus(node, 'subscribed', `subscribed ${label}`, Object.assign({ statusCode: res.statusCode, url: res.url }, extra));
61
+ fromStart = false;
62
+ } else {
63
+ next = o.startHref;
64
+ fromStart = true;
65
+ }
66
+ } else {
67
+ if (node.pollDelay) await sleep(node.pollDelay, signal);
68
+ if (signal.aborted) break;
69
+ node.status({ fill: 'green', shape: 'ring', text: `waiting (${label})` });
70
+ const res = await client.request('GET', next, null, { signal, timeoutMs: LONG_POLL_MS });
71
+ if (signal.aborted) break;
72
+ if (o.hasData(res)) {
73
+ o.onData(res);
74
+ setStatus(node, 'update', o.dataText(res), Object.assign({ statusCode: res.statusCode, url: res.url }, extra));
75
+ } else {
76
+ // update-wait timed out with nothing new: badge only, then wait again
77
+ if (o.onEmpty) o.onEmpty(res);
78
+ node.status({ fill: 'green', shape: 'ring', text: `no change, re-waiting (${label})` });
79
+ }
80
+ const n = o.nextHref(res);
81
+ fromStart = false;
82
+ if (n) next = n;
83
+ // no new link: keep polling the same one (events) or re-subscribe (item status)
84
+ else if (o.start) next = null;
85
+ }
86
+ failures = 0;
87
+ expiredInARow = 0;
88
+ } catch (err) {
89
+ if (signal.aborted || err.aborted) break;
90
+ let category = classify(err);
91
+ if (err.statusCode === 404 && !fromStart && expiredInARow === 0) category = 'expired';
92
+ err.category = category;
93
+
94
+ if (category === 'fatal') {
95
+ reportError(node, err, Object.assign({ category, stopped: true }, extra));
96
+ node.status({ fill: 'red', shape: 'ring', text: `stopped: ${err.statusCode ? 'HTTP ' + err.statusCode : (err.code || 'error')}` });
97
+ return 'fatal';
98
+ }
99
+ next = null;
100
+ if (category === 'expired') {
101
+ expiredInARow++;
102
+ reportError(node, err, Object.assign({ category, resubscribing: true }, extra));
103
+ node.status({ fill: 'yellow', shape: 'ring', text: 'subscription expired, re-subscribing' });
104
+ continue;
105
+ }
106
+ expiredInARow = 0;
107
+ failures++;
108
+ const delay = backoff(failures, node.retryDelay, node.maxRetryDelay);
109
+ reportError(node, err, Object.assign({ category, attempt: failures, retryInMs: delay }, extra));
110
+ node.status({ fill: 'yellow', shape: 'dot', text: `retry ${failures} in ${Math.round(delay / 1000)} s` });
111
+ await sleep(delay, signal);
112
+ }
113
+ }
114
+ return 'stopped';
115
+ }
116
+
117
+ module.exports = { runSubscription, LONG_POLL_MS };
@@ -0,0 +1,133 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('tdn-rest-events', {
3
+ category: 'TDN REST',
4
+ color: '#E2D96E',
5
+ defaults: {
6
+ name: { value: '' },
7
+ server: { value: '', type: 'tdn-rest-server', required: true },
8
+ creds: { value: 'credentials' },
9
+ credsType: { value: 'msg' },
10
+ query: { value: '' },
11
+ autoStart: { value: false },
12
+ pollDelay: { value: 0, validate: function (v) { var n = Number(v); return !isNaN(n) && n >= 0 && n <= 20000; } },
13
+ retryDelay: { value: 5000, validate: RED.validators.number() },
14
+ maxRetryDelay: { value: 60000, validate: RED.validators.number() },
15
+ emitTimeouts: { value: false },
16
+ contextKey: { value: '' },
17
+ contextStore: { value: '' },
18
+ keepEvents: { value: 100, validate: RED.validators.number() }
19
+ },
20
+ inputs: 1,
21
+ outputs: 3,
22
+ outputLabels: ['events', 'node status', 'error'],
23
+ icon: 'font-awesome/fa-bell',
24
+ label: function () { return this.name || 'event updates'; },
25
+ paletteLabel: 'event updates',
26
+ oneditprepare: function () {
27
+ $('#node-input-creds').typedInput({ default: 'msg', types: ['msg', 'flow', 'global', 'env'], typeField: '#node-input-credsType' });
28
+ var sel = $('#node-input-contextStore');
29
+ var stores = (RED.settings.context && RED.settings.context.stores) || ['default'];
30
+ sel.empty();
31
+ sel.append($('<option>').val('').text('default'));
32
+ stores.forEach(function (s) { if (s !== 'default') sel.append($('<option>').val(s).text(s)); });
33
+ sel.val(this.contextStore || '');
34
+ }
35
+ });
36
+ </script>
37
+
38
+ <script type="text/html" data-template-name="tdn-rest-events">
39
+ <div class="form-row">
40
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
41
+ <input type="text" id="node-input-name" placeholder="event updates">
42
+ </div>
43
+ <div class="form-row">
44
+ <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
45
+ <input type="text" id="node-input-server">
46
+ </div>
47
+ <div class="form-row">
48
+ <label for="node-input-creds"><i class="fa fa-key"></i> Credentials</label>
49
+ <input type="text" id="node-input-creds" style="width:70%">
50
+ <input type="hidden" id="node-input-credsType">
51
+ </div>
52
+ <div class="form-row">
53
+ <label for="node-input-query"><i class="fa fa-filter"></i> Filter</label>
54
+ <input type="text" id="node-input-query" placeholder="optional, e.g. group=23&amp;type=20001&amp;source=508">
55
+ </div>
56
+ <div class="form-row">
57
+ <label>&nbsp;</label>
58
+ <input type="checkbox" id="node-input-autoStart" style="display:inline-block;width:auto;vertical-align:top">
59
+ <label for="node-input-autoStart" style="width:auto">Start on deploy (credentials from flow / global / env)</label>
60
+ </div>
61
+ <div class="form-row">
62
+ <label for="node-input-pollDelay">Delay between waits (ms)</label>
63
+ <input type="text" id="node-input-pollDelay" style="width:100px"> <span>0&ndash;20000</span>
64
+ </div>
65
+ <div class="form-row">
66
+ <label for="node-input-retryDelay">Retry delay (ms)</label>
67
+ <input type="text" id="node-input-retryDelay" style="width:100px">
68
+ </div>
69
+ <div class="form-row">
70
+ <label for="node-input-maxRetryDelay">Max retry delay (ms)</label>
71
+ <input type="text" id="node-input-maxRetryDelay" style="width:100px">
72
+ </div>
73
+ <div class="form-row">
74
+ <label>&nbsp;</label>
75
+ <input type="checkbox" id="node-input-emitTimeouts" style="display:inline-block;width:auto;vertical-align:top">
76
+ <label for="node-input-emitTimeouts" style="width:auto">Also output empty (timed-out) wait responses</label>
77
+ </div>
78
+ <div class="form-row">
79
+ <label for="node-input-contextKey"><i class="fa fa-globe"></i> Global key</label>
80
+ <input type="text" id="node-input-contextKey" placeholder="optional, e.g. rest_events">
81
+ </div>
82
+ <div class="form-row">
83
+ <label for="node-input-keepEvents">Keep last</label>
84
+ <input type="text" id="node-input-keepEvents" style="width:100px"> <span>events</span>
85
+ </div>
86
+ <div class="form-row">
87
+ <label for="node-input-contextStore"><i class="fa fa-database"></i> Context store</label>
88
+ <select id="node-input-contextStore"></select>
89
+ </div>
90
+ </script>
91
+
92
+ <script type="text/html" data-help-name="tdn-rest-events">
93
+ <p>Subscribes to new events and keeps listening indefinitely.</p>
94
+ <p>Sequence: <code>GET &lt;server base&gt;/events/updates?&lt;filter&gt;</code> (e.g. <code>https://192.168.0.20:8904/api/events/updates</code>).
95
+ The server answers as soon as there are new events, or after ~50&nbsp;s with none. Each response carries the link for the
96
+ next wait (<code>updates.href</code>, or <code>next.href</code>), which is followed straight away, forever.</p>
97
+ <h3>Input</h3>
98
+ <dl class="message-properties">
99
+ <dt>any <span class="property-type">msg</span></dt>
100
+ <dd>Starts the subscription, or restarts it if it is running. <code>payload</code> = <code>"stop"</code>,
101
+ <code>msg.action = "stop"</code> or <code>msg.stop = true</code> stops it.</dd>
102
+ <dt class="optional">query <span class="property-type">object | string</span></dt>
103
+ <dd>Filter parameters merged over the node's <i>Filter</i>, e.g. <code>{"group":"23","source":"508"}</code>.
104
+ Use <i>get event groups</i> to find group and type ids.</dd>
105
+ <dt class="optional">credentials <span class="property-type">string | object</span></dt><dd>Used only when the server config has no API key stored.
106
+ Read from the <i>Credentials</i> property (default <code>msg.credentials</code>; msg, flow, global or env): a bare key string, or
107
+ <code>{"apiKey":"...", "authType":"authorization"|"header", "authScheme":"...", "headerName":"X-API-Key"}</code>.
108
+ Read once per start; removed from the trigger msg.</dd>
109
+ </dl>
110
+ <h3>Outputs</h3>
111
+ <ol class="node-ports">
112
+ <li>Events
113
+ <dl class="message-properties">
114
+ <dt>payload <span class="property-type">object</span></dt><dd>Full server response.</dd>
115
+ <dt>events <span class="property-type">array</span></dt><dd>Shortcut to <code>payload.events</code>.</dd>
116
+ <dt>phase <span class="property-type">string</span></dt><dd><code>events</code>, or <code>timeout</code> (only if enabled).</dd>
117
+ <dt>query / seq / statusCode / url</dt><dd></dd>
118
+ </dl>
119
+ </li>
120
+ <li>Status &ndash; successful server responses with events (<code>state: "update"</code>), plus <code>stopped</code> on a stop input.
121
+ Empty long polls show on the node badge only.</li>
122
+ <li>Error &ndash; every non-success, with <code>category</code>, <code>stopped</code>, <code>attempt</code>, <code>retryInMs</code>.</li>
123
+ </ol>
124
+ <h3>Failures</h3>
125
+ <ul>
126
+ <li><b>fatal</b> &ndash; 401 (API key), 403 (licence / privilege), 404 on the start endpoint, other 4xx, certificate or TLS failure:
127
+ polling <b>stops</b> until a new trigger or redeploy.</li>
128
+ <li><b>expired</b> &ndash; 404 on a followed link: starts again from the start endpoint.</li>
129
+ <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 continues.</li>
130
+ </ul>
131
+ <h3>Global context (optional)</h3>
132
+ <p>If a key is set, <code>global.&lt;key&gt;</code> keeps the last <i>Keep last</i> events (oldest first).</p>
133
+ </script>