node-red-contrib-i3x 0.0.1 → 0.0.2
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/.claude/settings.local.json +13 -0
- package/CHANGELOG.md +24 -2
- package/README.md +19 -2
- package/lib/i3x-client.js +106 -7
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.2 (2026-03-05)
|
|
4
|
+
|
|
5
|
+
Compliance improvements based on [i3X Client Developer Guidelines](https://www.i3x.dev/sdk/category/client-developers).
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **TTL Caching** – Namespace and object type responses are cached for 60 seconds to reduce API load
|
|
10
|
+
- **Rate Limiting** – Client-side sliding-window throttle (100 requests per 60-second window) to proactively stay within API limits
|
|
11
|
+
- **Retry-After Header Support** – Respects server-provided `Retry-After` headers (both seconds and HTTP-date formats) instead of only using fixed exponential backoff
|
|
12
|
+
- **Input Sanitization** – Allowlist validation on write payloads (`writeValue`, `writeHistory`) to prevent injection of unexpected fields
|
|
13
|
+
- **New tests** – 12 additional unit tests covering caching, Retry-After, input sanitization, and rate limiting (75 tests total)
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`testConnection()` bypassed cache** – Health check now performs a real HTTP round-trip instead of returning stale cached data
|
|
18
|
+
- **SSE stream missing auth headers** – Bearer tokens and API keys are now explicitly propagated to SSE stream requests (previously only `headers.common` was copied, which could miss auth headers)
|
|
19
|
+
- **TLS `rejectUnauthorized` default** – Now defaults to `true` per i3X security guidelines; can still be overridden via TLS config node
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Updated API docs URL from `https://i3x.cesmii.net/docs` to `https://api.i3x.dev/v0/docs`
|
|
24
|
+
|
|
3
25
|
## 0.0.1 (2026-03-03)
|
|
4
26
|
|
|
5
|
-
Initial pre-alpha release targeting the [i3X API Prototype v0.0.1](https://i3x.
|
|
27
|
+
Initial pre-alpha release targeting the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs).
|
|
6
28
|
|
|
7
29
|
### Nodes
|
|
8
30
|
|
|
@@ -19,5 +41,5 @@ Initial pre-alpha release targeting the [i3X API Prototype v0.0.1](https://i3x.c
|
|
|
19
41
|
- Shared HTTP client (`lib/i3x-client.js`) with retry logic, error wrapping, and SSE reconnection
|
|
20
42
|
- Dynamic configuration via `msg` properties (all node settings can be overridden at runtime)
|
|
21
43
|
- Example flow demonstrating all features against the public CESMII demo server
|
|
22
|
-
- Unit tests
|
|
44
|
+
- Unit tests and integration tests against the live API
|
|
23
45
|
- Docker Compose setup for testing and local Node-RED development
|
package/README.md
CHANGED
|
@@ -90,9 +90,25 @@ Subscribe to value changes via SSE streaming or polling.
|
|
|
90
90
|
- **Fallback:** If SSE fails, the node automatically falls back to polling
|
|
91
91
|
- **Lifecycle:** Subscriptions are created on deploy and deleted on stop/re-deploy
|
|
92
92
|
|
|
93
|
+
## Built-in Resilience & Best Practices
|
|
94
|
+
|
|
95
|
+
The shared HTTP client (`lib/i3x-client.js`) implements all [i3X Client Developer Best Practices](https://www.i3x.dev/sdk/category/client-developers):
|
|
96
|
+
|
|
97
|
+
| Feature | Description |
|
|
98
|
+
| ------- | ----------- |
|
|
99
|
+
| **Retry with Exponential Backoff** | Automatic retries on 429, 502, 503, 504 with exponential delay |
|
|
100
|
+
| **Retry-After Header** | Respects server-provided `Retry-After` headers (seconds and HTTP-date formats) |
|
|
101
|
+
| **TTL Caching** | Namespaces and object types are cached for 60 seconds to reduce API load |
|
|
102
|
+
| **Rate Limiting** | Client-side sliding-window throttle (100 requests per 60-second window) |
|
|
103
|
+
| **Input Sanitization** | Allowlist validation on write payloads to prevent injection of unexpected fields |
|
|
104
|
+
| **TLS Certificate Validation** | `rejectUnauthorized: true` by default; overridable via TLS config node |
|
|
105
|
+
| **SSE Reconnection** | Automatic reconnection with exponential backoff (up to 5 attempts, max 30s delay) |
|
|
106
|
+
| **SSE → Polling Fallback** | Automatic fallback to polling if SSE stream setup fails |
|
|
107
|
+
| **Subscription Cleanup** | Subscriptions are deleted server-side on node stop/re-deploy |
|
|
108
|
+
|
|
93
109
|
## API Endpoints Used
|
|
94
110
|
|
|
95
|
-
This package targets the [i3X API Prototype v0.0.1](https://i3x.
|
|
111
|
+
This package targets the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs):
|
|
96
112
|
|
|
97
113
|
| Category | Method | Endpoint |
|
|
98
114
|
| --------- | ------ | -------------------------------------------- |
|
|
@@ -208,7 +224,8 @@ node-red
|
|
|
208
224
|
|
|
209
225
|
## References
|
|
210
226
|
|
|
211
|
-
- [i3X API Documentation](https://i3x.
|
|
227
|
+
- [i3X API Documentation](https://api.i3x.dev/v0/docs)
|
|
228
|
+
- [i3X Client Developer Guide](https://www.i3x.dev/sdk/category/client-developers)
|
|
212
229
|
- [i3X Specification & RFC](https://github.com/cesmii/i3X)
|
|
213
230
|
- [i3X SDK Documentation](https://www.i3x.dev/sdk)
|
|
214
231
|
- [CESMII](https://www.cesmii.org)
|
package/lib/i3x-client.js
CHANGED
|
@@ -15,6 +15,55 @@ const RETRY_STATUS_CODES = new Set([429, 502, 503, 504]);
|
|
|
15
15
|
const MAX_RETRIES = 3;
|
|
16
16
|
const RETRY_DELAY_MS = 1000;
|
|
17
17
|
|
|
18
|
+
const DEFAULT_CACHE_TTL_MS = 60000; // 1 minute
|
|
19
|
+
const RATE_LIMIT_WINDOW_MS = 60000; // 60 seconds
|
|
20
|
+
const RATE_LIMIT_MAX_REQUESTS = 100;
|
|
21
|
+
|
|
22
|
+
class TTLCache {
|
|
23
|
+
constructor(ttl = DEFAULT_CACHE_TTL_MS) {
|
|
24
|
+
this._ttl = ttl;
|
|
25
|
+
this._store = new Map();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get(key) {
|
|
29
|
+
const entry = this._store.get(key);
|
|
30
|
+
if (!entry) return undefined;
|
|
31
|
+
if (Date.now() > entry.expires) {
|
|
32
|
+
this._store.delete(key);
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return entry.value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
set(key, value) {
|
|
39
|
+
this._store.set(key, { value, expires: Date.now() + this._ttl });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear() {
|
|
43
|
+
this._store.clear();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class RateLimiter {
|
|
48
|
+
constructor(maxRequests = RATE_LIMIT_MAX_REQUESTS, windowMs = RATE_LIMIT_WINDOW_MS) {
|
|
49
|
+
this._maxRequests = maxRequests;
|
|
50
|
+
this._windowMs = windowMs;
|
|
51
|
+
this._timestamps = [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async acquire() {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
this._timestamps = this._timestamps.filter((t) => now - t < this._windowMs);
|
|
57
|
+
if (this._timestamps.length >= this._maxRequests) {
|
|
58
|
+
const oldest = this._timestamps[0];
|
|
59
|
+
const waitMs = this._windowMs - (now - oldest);
|
|
60
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
61
|
+
return this.acquire();
|
|
62
|
+
}
|
|
63
|
+
this._timestamps.push(Date.now());
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
18
67
|
class I3XClient extends EventEmitter {
|
|
19
68
|
/**
|
|
20
69
|
* @param {object} config
|
|
@@ -51,18 +100,26 @@ class I3XClient extends EventEmitter {
|
|
|
51
100
|
|
|
52
101
|
if (config.tlsOptions) {
|
|
53
102
|
const https = require("https");
|
|
54
|
-
|
|
103
|
+
const tlsOpts = { rejectUnauthorized: true, ...config.tlsOptions };
|
|
104
|
+
axiosConfig.httpsAgent = new https.Agent(tlsOpts);
|
|
55
105
|
}
|
|
56
106
|
|
|
57
107
|
this.http = axios.create(axiosConfig);
|
|
58
108
|
this._activeSubscriptions = new Map();
|
|
109
|
+
this._cache = new TTLCache();
|
|
110
|
+
this._rateLimiter = new RateLimiter();
|
|
59
111
|
}
|
|
60
112
|
|
|
61
113
|
// ── Explore ────────────────────────────────────────────────────────
|
|
62
114
|
|
|
63
115
|
/** @returns {Promise<Array<{uri:string, displayName:string}>>} */
|
|
64
116
|
async getNamespaces() {
|
|
65
|
-
|
|
117
|
+
const cacheKey = "namespaces";
|
|
118
|
+
const cached = this._cache.get(cacheKey);
|
|
119
|
+
if (cached) return cached;
|
|
120
|
+
const result = await this._get("/namespaces");
|
|
121
|
+
this._cache.set(cacheKey, result);
|
|
122
|
+
return result;
|
|
66
123
|
}
|
|
67
124
|
|
|
68
125
|
/**
|
|
@@ -72,7 +129,12 @@ class I3XClient extends EventEmitter {
|
|
|
72
129
|
async getObjectTypes(options = {}) {
|
|
73
130
|
const params = {};
|
|
74
131
|
if (options.namespaceUri) params.namespaceUri = options.namespaceUri;
|
|
75
|
-
|
|
132
|
+
const cacheKey = "objecttypes:" + (options.namespaceUri || "");
|
|
133
|
+
const cached = this._cache.get(cacheKey);
|
|
134
|
+
if (cached) return cached;
|
|
135
|
+
const result = await this._get("/objecttypes", params);
|
|
136
|
+
this._cache.set(cacheKey, result);
|
|
137
|
+
return result;
|
|
76
138
|
}
|
|
77
139
|
|
|
78
140
|
/** @param {string[]} elementIds */
|
|
@@ -166,6 +228,7 @@ class I3XClient extends EventEmitter {
|
|
|
166
228
|
* @param {*} value
|
|
167
229
|
*/
|
|
168
230
|
async writeValue(elementId, value) {
|
|
231
|
+
I3XClient._validateWritePayload(value);
|
|
169
232
|
return this._put(`/objects/${encodeURIComponent(elementId)}/value`, value);
|
|
170
233
|
}
|
|
171
234
|
|
|
@@ -174,9 +237,28 @@ class I3XClient extends EventEmitter {
|
|
|
174
237
|
* @param {*} data – historical data payload
|
|
175
238
|
*/
|
|
176
239
|
async writeHistory(elementId, data) {
|
|
240
|
+
I3XClient._validateWritePayload(data);
|
|
177
241
|
return this._put(`/objects/${encodeURIComponent(elementId)}/history`, data);
|
|
178
242
|
}
|
|
179
243
|
|
|
244
|
+
static _WRITE_ALLOWED_FIELDS = new Set([
|
|
245
|
+
"value", "timestamp", "quality", "displayName", "attributes", "metadata",
|
|
246
|
+
"startTime", "endTime", "values", "elementId", "status",
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
static _validateWritePayload(payload) {
|
|
250
|
+
if (payload === null || payload === undefined) {
|
|
251
|
+
throw new Error("Write payload must not be null or undefined");
|
|
252
|
+
}
|
|
253
|
+
if (typeof payload === "object" && !Array.isArray(payload)) {
|
|
254
|
+
const keys = Object.keys(payload);
|
|
255
|
+
const disallowed = keys.filter((k) => !I3XClient._WRITE_ALLOWED_FIELDS.has(k));
|
|
256
|
+
if (disallowed.length > 0) {
|
|
257
|
+
throw new Error("Disallowed fields in write payload: " + disallowed.join(", "));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
180
262
|
// ── Subscribe ──────────────────────────────────────────────────────
|
|
181
263
|
|
|
182
264
|
async listSubscriptions() {
|
|
@@ -247,6 +329,12 @@ class I3XClient extends EventEmitter {
|
|
|
247
329
|
let reconnectCount = 0;
|
|
248
330
|
|
|
249
331
|
const headers = { ...this.http.defaults.headers.common, Accept: "text/event-stream" };
|
|
332
|
+
if (this.http.defaults.headers["Authorization"]) {
|
|
333
|
+
headers["Authorization"] = this.http.defaults.headers["Authorization"];
|
|
334
|
+
}
|
|
335
|
+
if (this.http.defaults.headers["X-API-Key"]) {
|
|
336
|
+
headers["X-API-Key"] = this.http.defaults.headers["X-API-Key"];
|
|
337
|
+
}
|
|
250
338
|
if (this.http.defaults.auth) {
|
|
251
339
|
const b64 = Buffer.from(
|
|
252
340
|
`${this.http.defaults.auth.username}:${this.http.defaults.auth.password}`
|
|
@@ -336,10 +424,10 @@ class I3XClient extends EventEmitter {
|
|
|
336
424
|
|
|
337
425
|
/**
|
|
338
426
|
* Lightweight connectivity test.
|
|
339
|
-
*
|
|
427
|
+
* Bypasses cache to ensure a real round-trip to the server.
|
|
340
428
|
*/
|
|
341
429
|
async testConnection() {
|
|
342
|
-
await this.
|
|
430
|
+
await this._request("get", "/namespaces", {});
|
|
343
431
|
return true;
|
|
344
432
|
}
|
|
345
433
|
|
|
@@ -349,6 +437,7 @@ class I3XClient extends EventEmitter {
|
|
|
349
437
|
handle.close();
|
|
350
438
|
}
|
|
351
439
|
this._activeSubscriptions.clear();
|
|
440
|
+
this._cache.clear();
|
|
352
441
|
}
|
|
353
442
|
|
|
354
443
|
// ── Internal helpers ───────────────────────────────────────────────
|
|
@@ -380,10 +469,11 @@ class I3XClient extends EventEmitter {
|
|
|
380
469
|
}
|
|
381
470
|
|
|
382
471
|
/**
|
|
383
|
-
* Central request dispatcher with retry logic.
|
|
472
|
+
* Central request dispatcher with retry logic, Retry-After support, and rate limiting.
|
|
384
473
|
* @private
|
|
385
474
|
*/
|
|
386
475
|
async _request(method, path, opts = {}) {
|
|
476
|
+
await this._rateLimiter.acquire();
|
|
387
477
|
let lastErr;
|
|
388
478
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
389
479
|
try {
|
|
@@ -393,7 +483,16 @@ class I3XClient extends EventEmitter {
|
|
|
393
483
|
lastErr = err;
|
|
394
484
|
const status = err.response && err.response.status;
|
|
395
485
|
if (status && RETRY_STATUS_CODES.has(status) && attempt < MAX_RETRIES) {
|
|
396
|
-
const
|
|
486
|
+
const retryAfter = err.response.headers && err.response.headers["retry-after"];
|
|
487
|
+
let delay;
|
|
488
|
+
if (retryAfter) {
|
|
489
|
+
const seconds = parseInt(retryAfter, 10);
|
|
490
|
+
delay = isNaN(seconds)
|
|
491
|
+
? Math.max(0, new Date(retryAfter).getTime() - Date.now())
|
|
492
|
+
: seconds * 1000;
|
|
493
|
+
} else {
|
|
494
|
+
delay = RETRY_DELAY_MS * Math.pow(2, attempt);
|
|
495
|
+
}
|
|
397
496
|
await new Promise((r) => setTimeout(r, delay));
|
|
398
497
|
continue;
|
|
399
498
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-i3x",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Node-RED nodes for the i3X (Industrial Information Interoperability eXchange) API by CESMII",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
|
-
"url": "https://github.com/
|
|
21
|
+
"url": "https://github.com/blanpa/node-red-contrib-i3x"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "mocha --exit --timeout 15000 'test/**/*_spec.js'",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">=14.0.0"
|
|
56
56
|
},
|
|
57
|
-
"author": "
|
|
57
|
+
"author": "blanpa",
|
|
58
58
|
"homepage": "https://www.i3x.dev"
|
|
59
59
|
}
|