node-red-contrib-i3x 0.0.2 → 0.0.4
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 +2 -1
- package/CHANGELOG.md +55 -0
- package/README.md +4 -2
- package/lib/i3x-client.js +195 -56
- package/lib/node-utils.js +27 -1
- package/nodes/i3x-browse.html +13 -0
- package/nodes/i3x-browse.js +14 -19
- package/nodes/i3x-history.html +14 -0
- package/nodes/i3x-history.js +6 -6
- package/nodes/i3x-read.html +15 -0
- package/nodes/i3x-read.js +6 -6
- package/nodes/i3x-server.html +926 -0
- package/nodes/i3x-server.js +122 -0
- package/nodes/i3x-subscribe.html +14 -1
- package/nodes/i3x-subscribe.js +10 -7
- package/nodes/i3x-write.html +15 -2
- package/nodes/i3x-write.js +3 -3
- package/nodes/icons/i3x-icon.svg +1 -8
- package/package.json +5 -5
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"Bash(npm test:*)",
|
|
8
8
|
"Bash(node-red:*)",
|
|
9
9
|
"Bash(npx node-red:*)",
|
|
10
|
-
"Bash(docker compose:*)"
|
|
10
|
+
"Bash(docker compose:*)",
|
|
11
|
+
"Bash(ls /home/la/private/node-red-contrib-i3x/docker-compose* /home/la/private/node-red-contrib-i3x/Dockerfile* 2>/dev/null)"
|
|
11
12
|
]
|
|
12
13
|
}
|
|
13
14
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.4 (2026-04-12)
|
|
4
|
+
|
|
5
|
+
Migration to i3X API 1.0-Beta specification with enhanced query capabilities and improved API alignment.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Server Info Endpoint** – New `getInfo()` method retrieves server metadata (spec version, server version, capabilities) from `GET /info` endpoint with TTL caching
|
|
10
|
+
- **Single-Object History Query** – New `getHistory(elementId, options)` method for `GET /objects/{elementId}/history` endpoint
|
|
11
|
+
- **Partial Response Handling** – History queries now detect HTTP 206 status and set `_partial: true` flag on results when server returns incomplete data
|
|
12
|
+
- **Root Objects Filter** – `getObjects()` now supports `root: true` parameter to retrieve only root-level objects
|
|
13
|
+
- **Enhanced Subscribe Options** – Subscribe node now supports `maxDepth`, `includeMetadata`, and `returnMode` parameters for fine-grained control
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **API Version Update** – Updated from i3X v0.0.1 to 1.0-Beta specification
|
|
18
|
+
- **API Documentation URL** – Changed from `https://i3x.cesmii.net/docs` to `https://api.i3x.dev/v1/docs`
|
|
19
|
+
- **Parameter Naming** – `typeId` parameter renamed to `typeElementId` in `getObjects()` (legacy `typeId` still supported as alias)
|
|
20
|
+
- **Relationship Type Parameter** – Fixed casing from `relationshiptype` to `relationshipType` in `getRelatedObjects()`
|
|
21
|
+
- **History Query Implementation** – `getHistoryBulk()` now uses `_requestRaw()` to access HTTP status codes for partial response detection
|
|
22
|
+
|
|
23
|
+
### Tests
|
|
24
|
+
|
|
25
|
+
- Updated all test cases to reflect 1.0-Beta API changes
|
|
26
|
+
- Added tests for new `getInfo()` endpoint
|
|
27
|
+
- Added tests for single-object history query
|
|
28
|
+
- Added tests for partial response handling (HTTP 206)
|
|
29
|
+
- Updated integration tests for new parameter names
|
|
30
|
+
|
|
31
|
+
## 0.0.3 (2026-03-10)
|
|
32
|
+
|
|
33
|
+
Hardening, security improvements, and new browser widget features.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Live Values in Browser Widget** – The tree view now displays the current value, quality, and timestamp next to each element. Values are fetched automatically when expanding types or children, and on search results. Hover to see full value with quality and timestamp.
|
|
38
|
+
- **Test Connection Button** – Server config panel now includes a "Test Connection" button to verify connectivity without deploying
|
|
39
|
+
- **New admin endpoint** `POST /i3x-server/:id/browse/values` – Batch-reads live values for up to 50 elements (used by browser widget)
|
|
40
|
+
- **`statusError()` utility** – Smarter error message truncation (48 chars with `...`) replacing the hard `substring(0, 32)` cut across all nodes
|
|
41
|
+
- **`clampMaxDepth()` utility** – Validates and clamps `maxDepth` to 0–100 range, preventing negative or excessively large values
|
|
42
|
+
|
|
43
|
+
### Security
|
|
44
|
+
|
|
45
|
+
- **HTTPS warning** – Nodes now warn at startup when credentials are sent over plain HTTP to non-localhost servers
|
|
46
|
+
- **Error response sanitization** – `_wrapError()` strips sensitive fields (`token`, `password`, `apiKey`, `secret`) from API error response bodies to prevent accidental credential leakage in logs
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **SSE auth header duplication** – `streamSubscription()` now copies all configured headers cleanly via spread instead of manually duplicating individual auth headers
|
|
51
|
+
- **Poll interval minimum** – Increased from 500ms to 1000ms to prevent accidental API overload
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- Error status messages across all nodes now show up to 48 characters (was 32)
|
|
56
|
+
- `maxDepth` is validated on all nodes that accept it (read, history, subscribe)
|
|
57
|
+
|
|
3
58
|
## 0.0.2 (2026-03-05)
|
|
4
59
|
|
|
5
60
|
Compliance improvements based on [i3X Client Developer Guidelines](https://www.i3x.dev/sdk/category/client-developers).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Node-RED nodes for the **i3X** (Industrial Information Interoperability eXchange
|
|
|
4
4
|
|
|
5
5
|
i3X is an open, vendor-agnostic REST API specification for standardised access to contextualised manufacturing information platforms (Historians, MES, MOM, etc.).
|
|
6
6
|
|
|
7
|
-
> **Note:** The i3X API is currently in **
|
|
7
|
+
> **Note:** The i3X API is currently in **beta (v1.0-Beta)**. Response structures may change as the specification evolves.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -108,10 +108,11 @@ The shared HTTP client (`lib/i3x-client.js`) implements all [i3X Client Develope
|
|
|
108
108
|
|
|
109
109
|
## API Endpoints Used
|
|
110
110
|
|
|
111
|
-
This package targets the [i3X API
|
|
111
|
+
This package targets the [i3X API 1.0-Beta](https://api.i3x.dev/v1/docs):
|
|
112
112
|
|
|
113
113
|
| Category | Method | Endpoint |
|
|
114
114
|
| --------- | ------ | -------------------------------------------- |
|
|
115
|
+
| Info | GET | `/info` |
|
|
115
116
|
| Explore | GET | `/namespaces` |
|
|
116
117
|
| Explore | GET | `/objecttypes` |
|
|
117
118
|
| Explore | POST | `/objecttypes/query` |
|
|
@@ -121,6 +122,7 @@ This package targets the [i3X API Prototype v0.0.1](https://api.i3x.dev/v0/docs)
|
|
|
121
122
|
| Explore | POST | `/objects/list` |
|
|
122
123
|
| Explore | POST | `/objects/related` |
|
|
123
124
|
| Query | POST | `/objects/value` |
|
|
125
|
+
| Query | GET | `/objects/{elementId}/history` |
|
|
124
126
|
| Query | POST | `/objects/history` |
|
|
125
127
|
| Update | PUT | `/objects/{elementId}/value` |
|
|
126
128
|
| Update | PUT | `/objects/{elementId}/history` |
|
package/lib/i3x-client.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* I3XClient – Shared HTTP client for the i3X (CESMII) API.
|
|
3
3
|
*
|
|
4
|
-
* Wraps all REST endpoints described in the i3X OpenAPI
|
|
4
|
+
* Wraps all REST endpoints described in the i3X OpenAPI 1.0-Beta spec.
|
|
5
5
|
* Used by every node-red-contrib-i3x node via the i3x-server config node.
|
|
6
6
|
*
|
|
7
|
-
* @see https://i3x.
|
|
7
|
+
* @see https://api.i3x.dev/v1/docs
|
|
8
8
|
*/
|
|
9
9
|
"use strict";
|
|
10
10
|
|
|
@@ -84,6 +84,14 @@ class I3XClient extends EventEmitter {
|
|
|
84
84
|
this.authType = config.authType || "none";
|
|
85
85
|
this.timeout = config.timeout || 10000;
|
|
86
86
|
|
|
87
|
+
// Warn if credentials are sent over plain HTTP (not localhost)
|
|
88
|
+
if (this.authType !== "none" && this.baseUrl && !this.baseUrl.startsWith("https://")) {
|
|
89
|
+
const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|::1)(:|\/|$)/.test(this.baseUrl);
|
|
90
|
+
if (!isLocal) {
|
|
91
|
+
this._httpsWarning = "Credentials sent over plain HTTP – use HTTPS in production";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
87
95
|
const axiosConfig = {
|
|
88
96
|
baseURL: this._prefix(),
|
|
89
97
|
timeout: this.timeout,
|
|
@@ -110,6 +118,21 @@ class I3XClient extends EventEmitter {
|
|
|
110
118
|
this._rateLimiter = new RateLimiter();
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
// ── Server Info ──────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Retrieve server information (no authentication required).
|
|
125
|
+
* @returns {Promise<{specVersion:string, serverVersion:string, serverName:string, capabilities:object}>}
|
|
126
|
+
*/
|
|
127
|
+
async getInfo() {
|
|
128
|
+
const cacheKey = "info";
|
|
129
|
+
const cached = this._cache.get(cacheKey);
|
|
130
|
+
if (cached) return cached;
|
|
131
|
+
const result = await this._get("/info");
|
|
132
|
+
this._cache.set(cacheKey, result);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
113
136
|
// ── Explore ────────────────────────────────────────────────────────
|
|
114
137
|
|
|
115
138
|
/** @returns {Promise<Array<{uri:string, displayName:string}>>} */
|
|
@@ -158,14 +181,18 @@ class I3XClient extends EventEmitter {
|
|
|
158
181
|
}
|
|
159
182
|
|
|
160
183
|
/**
|
|
161
|
-
* @param {object}
|
|
162
|
-
* @param {string} [options.
|
|
184
|
+
* @param {object} [options]
|
|
185
|
+
* @param {string} [options.typeElementId] – filter by type (Beta spec name)
|
|
186
|
+
* @param {string} [options.typeId] – legacy alias for typeElementId
|
|
163
187
|
* @param {boolean} [options.includeMetadata]
|
|
188
|
+
* @param {boolean} [options.root] – return only root objects
|
|
164
189
|
*/
|
|
165
190
|
async getObjects(options = {}) {
|
|
166
191
|
const params = {};
|
|
167
|
-
|
|
192
|
+
const typeFilter = options.typeElementId || options.typeId;
|
|
193
|
+
if (typeFilter) params.typeElementId = typeFilter;
|
|
168
194
|
if (options.includeMetadata) params.includeMetadata = true;
|
|
195
|
+
if (options.root) params.root = true;
|
|
169
196
|
return this._get("/objects", params);
|
|
170
197
|
}
|
|
171
198
|
|
|
@@ -188,7 +215,7 @@ class I3XClient extends EventEmitter {
|
|
|
188
215
|
*/
|
|
189
216
|
async getRelatedObjects(elementIds, options = {}) {
|
|
190
217
|
const body = { elementIds };
|
|
191
|
-
if (options.relationshipType) body.
|
|
218
|
+
if (options.relationshipType) body.relationshipType = options.relationshipType;
|
|
192
219
|
if (options.includeMetadata) body.includeMetadata = true;
|
|
193
220
|
return this._post("/objects/related", body);
|
|
194
221
|
}
|
|
@@ -207,6 +234,7 @@ class I3XClient extends EventEmitter {
|
|
|
207
234
|
}
|
|
208
235
|
|
|
209
236
|
/**
|
|
237
|
+
* Bulk historical values query (POST).
|
|
210
238
|
* @param {string[]} elementIds
|
|
211
239
|
* @param {object} [options]
|
|
212
240
|
* @param {string} [options.startTime] – ISO 8601
|
|
@@ -218,7 +246,37 @@ class I3XClient extends EventEmitter {
|
|
|
218
246
|
if (options.startTime) body.startTime = options.startTime;
|
|
219
247
|
if (options.endTime) body.endTime = options.endTime;
|
|
220
248
|
if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
|
|
221
|
-
|
|
249
|
+
const res = await this._requestRaw("post", "/objects/history", { data: body });
|
|
250
|
+
const result = I3XClient._unwrapEnvelope(res.data);
|
|
251
|
+
if (res.status === 206) {
|
|
252
|
+
result._partial = true;
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Single-object historical values query (GET).
|
|
259
|
+
* @param {string} elementId
|
|
260
|
+
* @param {object} [options]
|
|
261
|
+
* @param {string} [options.startTime] – ISO 8601
|
|
262
|
+
* @param {string} [options.endTime] – ISO 8601
|
|
263
|
+
* @param {number} [options.maxDepth]
|
|
264
|
+
*/
|
|
265
|
+
async getHistory(elementId, options = {}) {
|
|
266
|
+
const params = {};
|
|
267
|
+
if (options.startTime) params.startTime = options.startTime;
|
|
268
|
+
if (options.endTime) params.endTime = options.endTime;
|
|
269
|
+
if (options.maxDepth !== undefined) params.maxDepth = options.maxDepth;
|
|
270
|
+
const res = await this._requestRaw(
|
|
271
|
+
"get",
|
|
272
|
+
`/objects/${encodeURIComponent(elementId)}/history`,
|
|
273
|
+
{ params }
|
|
274
|
+
);
|
|
275
|
+
const result = I3XClient._unwrapEnvelope(res.data);
|
|
276
|
+
if (res.status === 206) {
|
|
277
|
+
result._partial = true;
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
222
280
|
}
|
|
223
281
|
|
|
224
282
|
// ── Update ─────────────────────────────────────────────────────────
|
|
@@ -259,82 +317,124 @@ class I3XClient extends EventEmitter {
|
|
|
259
317
|
}
|
|
260
318
|
}
|
|
261
319
|
|
|
262
|
-
// ── Subscribe
|
|
263
|
-
|
|
264
|
-
async listSubscriptions() {
|
|
265
|
-
return this._get("/subscriptions");
|
|
266
|
-
}
|
|
320
|
+
// ── Subscribe (Beta-Spec: body-based endpoints) ──────────────────
|
|
267
321
|
|
|
268
|
-
/**
|
|
269
|
-
|
|
270
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Create a new subscription.
|
|
324
|
+
* @param {object} [options]
|
|
325
|
+
* @param {string} [options.clientId] – optional client identifier
|
|
326
|
+
* @param {string} [options.displayName] – optional human-readable name
|
|
327
|
+
* @returns {Promise<{subscriptionId:string, clientId?:string, displayName?:string}>}
|
|
328
|
+
*/
|
|
329
|
+
async createSubscription(options = {}) {
|
|
330
|
+
const body = {};
|
|
331
|
+
if (options.clientId) body.clientId = options.clientId;
|
|
332
|
+
if (options.displayName) body.displayName = options.displayName;
|
|
333
|
+
return this._post("/subscriptions", body);
|
|
271
334
|
}
|
|
272
335
|
|
|
273
|
-
/**
|
|
274
|
-
|
|
275
|
-
|
|
336
|
+
/**
|
|
337
|
+
* List subscriptions by IDs.
|
|
338
|
+
* @param {string[]} subscriptionIds
|
|
339
|
+
* @param {object} [options]
|
|
340
|
+
* @param {string} [options.clientId]
|
|
341
|
+
*/
|
|
342
|
+
async listSubscriptions(subscriptionIds, options = {}) {
|
|
343
|
+
const body = { subscriptionIds };
|
|
344
|
+
if (options.clientId) body.clientId = options.clientId;
|
|
345
|
+
return this._post("/subscriptions/list", body);
|
|
276
346
|
}
|
|
277
347
|
|
|
278
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Delete one or more subscriptions.
|
|
350
|
+
* @param {string[]} subscriptionIds
|
|
351
|
+
* @param {object} [options]
|
|
352
|
+
* @param {string} [options.clientId]
|
|
353
|
+
*/
|
|
354
|
+
async deleteSubscriptions(subscriptionIds, options = {}) {
|
|
355
|
+
const body = { subscriptionIds };
|
|
356
|
+
if (options.clientId) body.clientId = options.clientId;
|
|
357
|
+
return this._post("/subscriptions/delete", body);
|
|
281
358
|
}
|
|
282
359
|
|
|
283
360
|
/**
|
|
361
|
+
* Register monitored items on a subscription.
|
|
284
362
|
* @param {string} subscriptionId
|
|
285
363
|
* @param {string[]} elementIds
|
|
286
|
-
* @param {
|
|
364
|
+
* @param {object} [options]
|
|
365
|
+
* @param {number} [options.maxDepth] – default 1
|
|
366
|
+
* @param {string} [options.clientId]
|
|
287
367
|
*/
|
|
288
|
-
async registerMonitoredItems(subscriptionId, elementIds,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
368
|
+
async registerMonitoredItems(subscriptionId, elementIds, options = {}) {
|
|
369
|
+
// Support legacy positional maxDepth: registerMonitoredItems(id, ids, 2)
|
|
370
|
+
if (typeof options === "number") {
|
|
371
|
+
options = { maxDepth: options };
|
|
372
|
+
}
|
|
373
|
+
const body = { subscriptionId, elementIds, maxDepth: options.maxDepth !== undefined ? options.maxDepth : 1 };
|
|
374
|
+
if (options.clientId) body.clientId = options.clientId;
|
|
375
|
+
return this._post("/subscriptions/register", body);
|
|
294
376
|
}
|
|
295
377
|
|
|
296
378
|
/**
|
|
379
|
+
* Unregister monitored items from a subscription.
|
|
297
380
|
* @param {string} subscriptionId
|
|
298
381
|
* @param {string[]} elementIds
|
|
382
|
+
* @param {object} [options]
|
|
383
|
+
* @param {string} [options.clientId]
|
|
299
384
|
*/
|
|
300
|
-
async unregisterMonitoredItems(subscriptionId, elementIds) {
|
|
301
|
-
const body = { elementIds };
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
body
|
|
305
|
-
);
|
|
385
|
+
async unregisterMonitoredItems(subscriptionId, elementIds, options = {}) {
|
|
386
|
+
const body = { subscriptionId, elementIds };
|
|
387
|
+
if (options.clientId) body.clientId = options.clientId;
|
|
388
|
+
return this._post("/subscriptions/unregister", body);
|
|
306
389
|
}
|
|
307
390
|
|
|
308
391
|
/**
|
|
309
|
-
* Open an SSE stream for the given subscription.
|
|
392
|
+
* Open an SSE stream for the given subscription (POST).
|
|
310
393
|
* Supports automatic reconnection on stream errors.
|
|
311
394
|
*
|
|
312
395
|
* @param {string} subscriptionId
|
|
313
396
|
* @param {object} callbacks
|
|
314
397
|
* @param {function} callbacks.onData – called with each parsed SSE event
|
|
315
|
-
* @param {function} [callbacks.onError] – called on stream errors
|
|
398
|
+
* @param {function} [callbacks.onError] – called on stream errors
|
|
316
399
|
* @param {function} [callbacks.onReconnect] – called when a reconnection attempt starts
|
|
317
|
-
* @param {
|
|
400
|
+
* @param {object} [options]
|
|
401
|
+
* @param {string} [options.clientId]
|
|
402
|
+
* @param {number} [options.maxReconnects=5]
|
|
318
403
|
* @returns {{ close: function }} handle to close the stream
|
|
319
404
|
*/
|
|
320
|
-
streamSubscription(subscriptionId, callbacks,
|
|
405
|
+
streamSubscription(subscriptionId, callbacks, options = {}) {
|
|
321
406
|
if (typeof callbacks === "function") {
|
|
322
407
|
callbacks = { onData: callbacks };
|
|
323
408
|
}
|
|
409
|
+
// Support legacy positional maxReconnects number
|
|
410
|
+
if (typeof options === "number") {
|
|
411
|
+
options = { maxReconnects: options };
|
|
412
|
+
}
|
|
324
413
|
const { onData, onError, onReconnect } = callbacks;
|
|
414
|
+
const maxReconnects = options.maxReconnects !== undefined ? options.maxReconnects : 5;
|
|
415
|
+
|
|
416
|
+
const url = `${this._prefix()}/subscriptions/stream`;
|
|
417
|
+
const postBody = { subscriptionId };
|
|
418
|
+
if (options.clientId) postBody.clientId = options.clientId;
|
|
325
419
|
|
|
326
|
-
const url = `${this._prefix()}/subscriptions/${encodeURIComponent(subscriptionId)}/stream`;
|
|
327
420
|
let controller = new AbortController();
|
|
328
421
|
let closed = false;
|
|
329
422
|
let reconnectCount = 0;
|
|
330
423
|
|
|
331
|
-
const headers = {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
424
|
+
const headers = {
|
|
425
|
+
...this.http.defaults.headers.common,
|
|
426
|
+
...this.http.defaults.headers,
|
|
427
|
+
Accept: "text/event-stream",
|
|
428
|
+
"Content-Type": "application/json",
|
|
429
|
+
};
|
|
430
|
+
// Remove non-header axios defaults that got spread in
|
|
431
|
+
delete headers.common;
|
|
432
|
+
delete headers.get;
|
|
433
|
+
delete headers.post;
|
|
434
|
+
delete headers.put;
|
|
435
|
+
delete headers.delete;
|
|
436
|
+
delete headers.patch;
|
|
437
|
+
delete headers.head;
|
|
338
438
|
if (this.http.defaults.auth) {
|
|
339
439
|
const b64 = Buffer.from(
|
|
340
440
|
`${this.http.defaults.auth.username}:${this.http.defaults.auth.password}`
|
|
@@ -347,8 +447,9 @@ class I3XClient extends EventEmitter {
|
|
|
347
447
|
try {
|
|
348
448
|
controller = new AbortController();
|
|
349
449
|
const response = await axios({
|
|
350
|
-
method: "
|
|
450
|
+
method: "post",
|
|
351
451
|
url,
|
|
452
|
+
data: postBody,
|
|
352
453
|
headers,
|
|
353
454
|
responseType: "stream",
|
|
354
455
|
signal: controller.signal,
|
|
@@ -410,14 +511,19 @@ class I3XClient extends EventEmitter {
|
|
|
410
511
|
}
|
|
411
512
|
|
|
412
513
|
/**
|
|
413
|
-
* Poll-based sync:
|
|
514
|
+
* Poll-based sync: acknowledge previously received updates and return pending ones.
|
|
414
515
|
* @param {string} subscriptionId
|
|
516
|
+
* @param {object} [options]
|
|
517
|
+
* @param {number} [options.lastSequenceNumber] – acknowledge events up to this sequence number
|
|
518
|
+
* @param {string} [options.clientId]
|
|
415
519
|
*/
|
|
416
|
-
async syncSubscription(subscriptionId) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
520
|
+
async syncSubscription(subscriptionId, options = {}) {
|
|
521
|
+
const body = { subscriptionId };
|
|
522
|
+
if (options.lastSequenceNumber !== undefined) {
|
|
523
|
+
body.lastSequenceNumber = options.lastSequenceNumber;
|
|
524
|
+
}
|
|
525
|
+
if (options.clientId) body.clientId = options.clientId;
|
|
526
|
+
return this._post("/subscriptions/sync", body);
|
|
421
527
|
}
|
|
422
528
|
|
|
423
529
|
// ── Utility ────────────────────────────────────────────────────────
|
|
@@ -470,15 +576,38 @@ class I3XClient extends EventEmitter {
|
|
|
470
576
|
|
|
471
577
|
/**
|
|
472
578
|
* Central request dispatcher with retry logic, Retry-After support, and rate limiting.
|
|
579
|
+
* Returns unwrapped result data. Use _requestRaw for full response access.
|
|
473
580
|
* @private
|
|
474
581
|
*/
|
|
475
582
|
async _request(method, path, opts = {}) {
|
|
583
|
+
const res = await this._requestRaw(method, path, opts);
|
|
584
|
+
return I3XClient._unwrapEnvelope(res.data);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Unwrap the Beta-Spec response envelope if present.
|
|
589
|
+
* SuccessResponse: {success, result} → result
|
|
590
|
+
* BulkResponse: {success, results} → results
|
|
591
|
+
* @private
|
|
592
|
+
*/
|
|
593
|
+
static _unwrapEnvelope(data) {
|
|
594
|
+
if (data && typeof data === "object" && data.success === true) {
|
|
595
|
+
if ("result" in data) return data.result;
|
|
596
|
+
if ("results" in data) return data.results;
|
|
597
|
+
}
|
|
598
|
+
return data;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Central request dispatcher returning the full axios response.
|
|
603
|
+
* @private
|
|
604
|
+
*/
|
|
605
|
+
async _requestRaw(method, path, opts = {}) {
|
|
476
606
|
await this._rateLimiter.acquire();
|
|
477
607
|
let lastErr;
|
|
478
608
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
479
609
|
try {
|
|
480
|
-
|
|
481
|
-
return res.data;
|
|
610
|
+
return await this.http.request({ method, url: path, ...opts });
|
|
482
611
|
} catch (err) {
|
|
483
612
|
lastErr = err;
|
|
484
613
|
const status = err.response && err.response.status;
|
|
@@ -513,7 +642,17 @@ class I3XClient extends EventEmitter {
|
|
|
513
642
|
if (err.response) {
|
|
514
643
|
wrapped.statusCode = err.response.status;
|
|
515
644
|
wrapped.statusText = err.response.statusText;
|
|
516
|
-
|
|
645
|
+
// Sanitize response body to avoid leaking auth details
|
|
646
|
+
const body = err.response.data;
|
|
647
|
+
if (body && typeof body === "object") {
|
|
648
|
+
const sanitized = { ...body };
|
|
649
|
+
for (const key of ["authorization", "token", "apiKey", "api_key", "password", "secret"]) {
|
|
650
|
+
delete sanitized[key];
|
|
651
|
+
}
|
|
652
|
+
wrapped.body = sanitized;
|
|
653
|
+
} else {
|
|
654
|
+
wrapped.body = body;
|
|
655
|
+
}
|
|
517
656
|
} else if (err.code) {
|
|
518
657
|
wrapped.code = err.code;
|
|
519
658
|
}
|
package/lib/node-utils.js
CHANGED
|
@@ -58,4 +58,30 @@ function safeSend(node, send) {
|
|
|
58
58
|
return send || function () { node.send.apply(node, arguments); };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Truncate an error message for node status display.
|
|
63
|
+
* Keeps up to 48 characters and appends "..." if truncated.
|
|
64
|
+
* @param {string} msg
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function statusError(msg) {
|
|
68
|
+
if (!msg) return "error";
|
|
69
|
+
if (msg.length <= 48) return msg;
|
|
70
|
+
return msg.substring(0, 45) + "...";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Clamp maxDepth to a valid range (0–100).
|
|
75
|
+
* @param {number} val
|
|
76
|
+
* @param {number} [fallback=1]
|
|
77
|
+
* @returns {number}
|
|
78
|
+
*/
|
|
79
|
+
function clampMaxDepth(val, fallback) {
|
|
80
|
+
if (fallback === undefined) fallback = 1;
|
|
81
|
+
var n = parseInt(val, 10);
|
|
82
|
+
if (isNaN(n) || n < 0) return fallback;
|
|
83
|
+
if (n > 100) return 100;
|
|
84
|
+
return n;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { bindServer, parseIds, safeSend, statusError, clampMaxDepth };
|
package/nodes/i3x-browse.html
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
<label for="node-input-elementId"><i class="fa fa-crosshairs"></i> Element ID</label>
|
|
22
22
|
<input type="text" id="node-input-elementId" placeholder="optional – filter by element ID">
|
|
23
23
|
</div>
|
|
24
|
+
<div class="form-row i3x-browse-elementId">
|
|
25
|
+
<div id="i3x-browse-browser"></div>
|
|
26
|
+
</div>
|
|
24
27
|
<div class="form-row i3x-browse-typeId">
|
|
25
28
|
<label for="node-input-typeId"><i class="fa fa-filter"></i> Type ID</label>
|
|
26
29
|
<input type="text" id="node-input-typeId" placeholder="optional – filter objects by type">
|
|
@@ -66,6 +69,7 @@
|
|
|
66
69
|
<h3>Details</h3>
|
|
67
70
|
<p>This node talks to the <b>Explore</b> endpoints of the i3X API.
|
|
68
71
|
<code>msg</code> properties override the values configured in the node editor.</p>
|
|
72
|
+
<p>Use the <b>Browse</b> button to visually select an element from the server.</p>
|
|
69
73
|
</script>
|
|
70
74
|
|
|
71
75
|
<script type="text/javascript">
|
|
@@ -101,6 +105,15 @@
|
|
|
101
105
|
}
|
|
102
106
|
target.on("change", toggle);
|
|
103
107
|
toggle();
|
|
108
|
+
|
|
109
|
+
if (window.I3XBrowser) {
|
|
110
|
+
this._browser = I3XBrowser.create({
|
|
111
|
+
container: "#i3x-browse-browser",
|
|
112
|
+
serverField: "#node-input-server",
|
|
113
|
+
targetField: "#node-input-elementId",
|
|
114
|
+
mode: "single",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
104
117
|
},
|
|
105
118
|
});
|
|
106
119
|
</script>
|
package/nodes/i3x-browse.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
"use strict";
|
|
5
5
|
|
|
6
|
-
const { bindServer, safeSend } = require("../lib/node-utils");
|
|
6
|
+
const { bindServer, parseIds, safeSend, statusError } = require("../lib/node-utils");
|
|
7
7
|
|
|
8
8
|
module.exports = function (RED) {
|
|
9
9
|
function I3XBrowseNode(config) {
|
|
@@ -24,13 +24,13 @@ module.exports = function (RED) {
|
|
|
24
24
|
const client = node.server.client;
|
|
25
25
|
|
|
26
26
|
const target = msg.browseTarget || node.browseTarget;
|
|
27
|
-
const
|
|
27
|
+
const ids = parseIds(msg.elementId || node.elementId);
|
|
28
28
|
const typeId = msg.typeId || node.typeId;
|
|
29
29
|
const nsUri = msg.namespaceUri || node.namespaceUri;
|
|
30
30
|
const inclMeta = msg.includeMetadata !== undefined ? msg.includeMetadata : node.includeMetadata;
|
|
31
31
|
const relType = msg.relationshipType || node.relationshipType;
|
|
32
32
|
|
|
33
|
-
node.status({ fill: "blue", shape: "dot", text: "
|
|
33
|
+
node.status({ fill: "blue", shape: "dot", text: "browsing " + target + "..." });
|
|
34
34
|
|
|
35
35
|
try {
|
|
36
36
|
let result;
|
|
@@ -39,51 +39,46 @@ module.exports = function (RED) {
|
|
|
39
39
|
result = await client.getNamespaces();
|
|
40
40
|
break;
|
|
41
41
|
case "objecttypes":
|
|
42
|
-
if (
|
|
43
|
-
const ids = Array.isArray(elementId) ? elementId : [elementId];
|
|
42
|
+
if (ids.length) {
|
|
44
43
|
result = await client.queryObjectTypes(ids);
|
|
45
44
|
} else {
|
|
46
45
|
result = await client.getObjectTypes({ namespaceUri: nsUri || undefined });
|
|
47
46
|
}
|
|
48
47
|
break;
|
|
49
48
|
case "relationshiptypes":
|
|
50
|
-
if (
|
|
51
|
-
const ids = Array.isArray(elementId) ? elementId : [elementId];
|
|
49
|
+
if (ids.length) {
|
|
52
50
|
result = await client.queryRelationshipTypes(ids);
|
|
53
51
|
} else {
|
|
54
52
|
result = await client.getRelationshipTypes({ namespaceUri: nsUri || undefined });
|
|
55
53
|
}
|
|
56
54
|
break;
|
|
57
55
|
case "objects":
|
|
58
|
-
if (
|
|
59
|
-
const ids = Array.isArray(elementId) ? elementId : [elementId];
|
|
56
|
+
if (ids.length) {
|
|
60
57
|
result = await client.listObjects(ids, { includeMetadata: inclMeta });
|
|
61
58
|
} else {
|
|
62
59
|
result = await client.getObjects({ typeId: typeId || undefined, includeMetadata: inclMeta });
|
|
63
60
|
}
|
|
64
61
|
break;
|
|
65
62
|
case "related":
|
|
66
|
-
if (!
|
|
63
|
+
if (!ids.length) {
|
|
67
64
|
throw new Error("elementId is required for related objects query");
|
|
68
65
|
}
|
|
69
|
-
{
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
includeMetadata: inclMeta,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
66
|
+
result = await client.getRelatedObjects(ids, {
|
|
67
|
+
relationshipType: relType || undefined,
|
|
68
|
+
includeMetadata: inclMeta,
|
|
69
|
+
});
|
|
76
70
|
break;
|
|
77
71
|
default:
|
|
78
72
|
throw new Error("Unknown browse target: " + target);
|
|
79
73
|
}
|
|
80
74
|
|
|
75
|
+
const count = Array.isArray(result) ? result.length : 1;
|
|
81
76
|
msg.payload = result;
|
|
82
|
-
node.status({ fill: "green", shape: "dot", text: "
|
|
77
|
+
node.status({ fill: "green", shape: "dot", text: count + " " + target });
|
|
83
78
|
send(msg);
|
|
84
79
|
if (done) done();
|
|
85
80
|
} catch (err) {
|
|
86
|
-
node.status({ fill: "red", shape: "ring", text: err.message
|
|
81
|
+
node.status({ fill: "red", shape: "ring", text: statusError(err.message) });
|
|
87
82
|
if (done) done(err); else node.error(err, msg);
|
|
88
83
|
}
|
|
89
84
|
});
|