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/README.md +120 -69
- package/examples/rest-basic.json +387 -72
- package/lib/client.js +177 -76
- package/lib/outputs.js +2 -0
- package/lib/subscription.js +117 -0
- package/nodes/rest-events.html +133 -0
- package/nodes/rest-events.js +172 -0
- package/nodes/rest-item-status.html +15 -7
- package/nodes/rest-item-status.js +53 -96
- package/nodes/rest-lists.html +379 -60
- package/nodes/rest-lists.js +214 -75
- package/nodes/rest-server.html +36 -9
- package/nodes/rest-server.js +108 -53
- package/package.json +3 -2
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
|
-
* -
|
|
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))
|
|
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
|
|
107
|
-
* @param {number} [o.port] default 8904
|
|
108
|
-
* @param {string} [o.
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
347
|
-
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
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
|
|
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
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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&type=20001&source=508">
|
|
55
|
+
</div>
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<label> </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–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> </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 <server base>/events/updates?<filter></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 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 – 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 – 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> – 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> – 404 on a followed link: starts again from the start endpoint.</li>
|
|
129
|
+
<li><b>transient</b> – 5xx, 408, 429, timeout, network: back-off <i>retry delay</i> × 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.<key></code> keeps the last <i>Keep last</i> events (oldest first).</p>
|
|
133
|
+
</script>
|