node-red-contrib-i3x 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,425 @@
1
+ /**
2
+ * I3XClient – Shared HTTP client for the i3X (CESMII) API.
3
+ *
4
+ * Wraps all REST endpoints described in the i3X OpenAPI v0.0.1 spec.
5
+ * Used by every node-red-contrib-i3x node via the i3x-server config node.
6
+ *
7
+ * @see https://i3x.cesmii.net/docs
8
+ */
9
+ "use strict";
10
+
11
+ const axios = require("axios");
12
+ const { EventEmitter } = require("events");
13
+
14
+ const RETRY_STATUS_CODES = new Set([429, 502, 503, 504]);
15
+ const MAX_RETRIES = 3;
16
+ const RETRY_DELAY_MS = 1000;
17
+
18
+ class I3XClient extends EventEmitter {
19
+ /**
20
+ * @param {object} config
21
+ * @param {string} config.baseUrl – e.g. "https://i3x.cesmii.net"
22
+ * @param {string} [config.apiVersion] – path prefix, default ""
23
+ * @param {string} [config.authType] – "none"|"basic"|"bearer"|"apikey"
24
+ * @param {string} [config.username]
25
+ * @param {string} [config.password]
26
+ * @param {string} [config.token]
27
+ * @param {string} [config.apiKey]
28
+ * @param {object} [config.tlsOptions] – { rejectUnauthorized, ca, cert, key }
29
+ * @param {number} [config.timeout] – ms, default 10000
30
+ */
31
+ constructor(config) {
32
+ super();
33
+ this.baseUrl = (config.baseUrl || "").replace(/\/+$/, "");
34
+ this.apiVersion = config.apiVersion || "";
35
+ this.authType = config.authType || "none";
36
+ this.timeout = config.timeout || 10000;
37
+
38
+ const axiosConfig = {
39
+ baseURL: this._prefix(),
40
+ timeout: this.timeout,
41
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
42
+ };
43
+
44
+ if (this.authType === "basic" && config.username) {
45
+ axiosConfig.auth = { username: config.username, password: config.password || "" };
46
+ } else if (this.authType === "bearer" && config.token) {
47
+ axiosConfig.headers["Authorization"] = `Bearer ${config.token}`;
48
+ } else if (this.authType === "apikey" && config.apiKey) {
49
+ axiosConfig.headers["X-API-Key"] = config.apiKey;
50
+ }
51
+
52
+ if (config.tlsOptions) {
53
+ const https = require("https");
54
+ axiosConfig.httpsAgent = new https.Agent(config.tlsOptions);
55
+ }
56
+
57
+ this.http = axios.create(axiosConfig);
58
+ this._activeSubscriptions = new Map();
59
+ }
60
+
61
+ // ── Explore ────────────────────────────────────────────────────────
62
+
63
+ /** @returns {Promise<Array<{uri:string, displayName:string}>>} */
64
+ async getNamespaces() {
65
+ return this._get("/namespaces");
66
+ }
67
+
68
+ /**
69
+ * @param {object} [options]
70
+ * @param {string} [options.namespaceUri]
71
+ */
72
+ async getObjectTypes(options = {}) {
73
+ const params = {};
74
+ if (options.namespaceUri) params.namespaceUri = options.namespaceUri;
75
+ return this._get("/objecttypes", params);
76
+ }
77
+
78
+ /** @param {string[]} elementIds */
79
+ async queryObjectTypes(elementIds) {
80
+ return this._post("/objecttypes/query", { elementIds });
81
+ }
82
+
83
+ /**
84
+ * @param {object} [options]
85
+ * @param {string} [options.namespaceUri]
86
+ */
87
+ async getRelationshipTypes(options = {}) {
88
+ const params = {};
89
+ if (options.namespaceUri) params.namespaceUri = options.namespaceUri;
90
+ return this._get("/relationshiptypes", params);
91
+ }
92
+
93
+ /** @param {string[]} elementIds */
94
+ async queryRelationshipTypes(elementIds) {
95
+ return this._post("/relationshiptypes/query", { elementIds });
96
+ }
97
+
98
+ /**
99
+ * @param {object} [options]
100
+ * @param {string} [options.typeId]
101
+ * @param {boolean} [options.includeMetadata]
102
+ */
103
+ async getObjects(options = {}) {
104
+ const params = {};
105
+ if (options.typeId) params.typeId = options.typeId;
106
+ if (options.includeMetadata) params.includeMetadata = true;
107
+ return this._get("/objects", params);
108
+ }
109
+
110
+ /**
111
+ * @param {string[]} elementIds
112
+ * @param {object} [options]
113
+ * @param {boolean} [options.includeMetadata]
114
+ */
115
+ async listObjects(elementIds, options = {}) {
116
+ const body = { elementIds };
117
+ if (options.includeMetadata) body.includeMetadata = true;
118
+ return this._post("/objects/list", body);
119
+ }
120
+
121
+ /**
122
+ * @param {string[]} elementIds
123
+ * @param {object} [options]
124
+ * @param {string} [options.relationshipType]
125
+ * @param {boolean} [options.includeMetadata]
126
+ */
127
+ async getRelatedObjects(elementIds, options = {}) {
128
+ const body = { elementIds };
129
+ if (options.relationshipType) body.relationshiptype = options.relationshipType;
130
+ if (options.includeMetadata) body.includeMetadata = true;
131
+ return this._post("/objects/related", body);
132
+ }
133
+
134
+ // ── Query ──────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * @param {string[]} elementIds
138
+ * @param {object} [options]
139
+ * @param {number} [options.maxDepth] – 0 = infinite, 1 = no recursion
140
+ */
141
+ async readValues(elementIds, options = {}) {
142
+ const body = { elementIds };
143
+ if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
144
+ return this._post("/objects/value", body);
145
+ }
146
+
147
+ /**
148
+ * @param {string[]} elementIds
149
+ * @param {object} [options]
150
+ * @param {string} [options.startTime] – ISO 8601
151
+ * @param {string} [options.endTime] – ISO 8601
152
+ * @param {number} [options.maxDepth]
153
+ */
154
+ async readHistory(elementIds, options = {}) {
155
+ const body = { elementIds };
156
+ if (options.startTime) body.startTime = options.startTime;
157
+ if (options.endTime) body.endTime = options.endTime;
158
+ if (options.maxDepth !== undefined) body.maxDepth = options.maxDepth;
159
+ return this._post("/objects/history", body);
160
+ }
161
+
162
+ // ── Update ─────────────────────────────────────────────────────────
163
+
164
+ /**
165
+ * @param {string} elementId
166
+ * @param {*} value
167
+ */
168
+ async writeValue(elementId, value) {
169
+ return this._put(`/objects/${encodeURIComponent(elementId)}/value`, value);
170
+ }
171
+
172
+ /**
173
+ * @param {string} elementId
174
+ * @param {*} data – historical data payload
175
+ */
176
+ async writeHistory(elementId, data) {
177
+ return this._put(`/objects/${encodeURIComponent(elementId)}/history`, data);
178
+ }
179
+
180
+ // ── Subscribe ──────────────────────────────────────────────────────
181
+
182
+ async listSubscriptions() {
183
+ return this._get("/subscriptions");
184
+ }
185
+
186
+ /** @returns {Promise<{subscriptionId:string, message:string}>} */
187
+ async createSubscription() {
188
+ return this._post("/subscriptions", {});
189
+ }
190
+
191
+ /** @param {string} subscriptionId */
192
+ async getSubscription(subscriptionId) {
193
+ return this._get(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
194
+ }
195
+
196
+ /** @param {string} subscriptionId */
197
+ async deleteSubscription(subscriptionId) {
198
+ return this._delete(`/subscriptions/${encodeURIComponent(subscriptionId)}`);
199
+ }
200
+
201
+ /**
202
+ * @param {string} subscriptionId
203
+ * @param {string[]} elementIds
204
+ * @param {number} [maxDepth]
205
+ */
206
+ async registerMonitoredItems(subscriptionId, elementIds, maxDepth = 1) {
207
+ const body = { elementIds, maxDepth };
208
+ return this._post(
209
+ `/subscriptions/${encodeURIComponent(subscriptionId)}/register`,
210
+ body
211
+ );
212
+ }
213
+
214
+ /**
215
+ * @param {string} subscriptionId
216
+ * @param {string[]} elementIds
217
+ */
218
+ async unregisterMonitoredItems(subscriptionId, elementIds) {
219
+ const body = { elementIds };
220
+ return this._post(
221
+ `/subscriptions/${encodeURIComponent(subscriptionId)}/unregister`,
222
+ body
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Open an SSE stream for the given subscription.
228
+ * Supports automatic reconnection on stream errors.
229
+ *
230
+ * @param {string} subscriptionId
231
+ * @param {object} callbacks
232
+ * @param {function} callbacks.onData – called with each parsed SSE event
233
+ * @param {function} [callbacks.onError] – called on stream errors (instead of EventEmitter)
234
+ * @param {function} [callbacks.onReconnect] – called when a reconnection attempt starts
235
+ * @param {number} [maxReconnects=5] – max consecutive reconnection attempts
236
+ * @returns {{ close: function }} handle to close the stream
237
+ */
238
+ streamSubscription(subscriptionId, callbacks, maxReconnects = 5) {
239
+ if (typeof callbacks === "function") {
240
+ callbacks = { onData: callbacks };
241
+ }
242
+ const { onData, onError, onReconnect } = callbacks;
243
+
244
+ const url = `${this._prefix()}/subscriptions/${encodeURIComponent(subscriptionId)}/stream`;
245
+ let controller = new AbortController();
246
+ let closed = false;
247
+ let reconnectCount = 0;
248
+
249
+ const headers = { ...this.http.defaults.headers.common, Accept: "text/event-stream" };
250
+ if (this.http.defaults.auth) {
251
+ const b64 = Buffer.from(
252
+ `${this.http.defaults.auth.username}:${this.http.defaults.auth.password}`
253
+ ).toString("base64");
254
+ headers["Authorization"] = `Basic ${b64}`;
255
+ }
256
+
257
+ const connect = async () => {
258
+ if (closed) return;
259
+ try {
260
+ controller = new AbortController();
261
+ const response = await axios({
262
+ method: "get",
263
+ url,
264
+ headers,
265
+ responseType: "stream",
266
+ signal: controller.signal,
267
+ timeout: 0,
268
+ httpsAgent: this.http.defaults.httpsAgent,
269
+ });
270
+ reconnectCount = 0;
271
+ let buffer = "";
272
+ response.data.on("data", (chunk) => {
273
+ buffer += chunk.toString();
274
+ const parts = buffer.split("\n\n");
275
+ buffer = parts.pop();
276
+ for (const part of parts) {
277
+ const dataLine = part
278
+ .split("\n")
279
+ .find((l) => l.startsWith("data:"));
280
+ if (dataLine) {
281
+ try {
282
+ onData(JSON.parse(dataLine.slice(5).trim()));
283
+ } catch (_) {
284
+ onData(dataLine.slice(5).trim());
285
+ }
286
+ }
287
+ }
288
+ });
289
+ response.data.on("end", () => {
290
+ if (!closed) reconnect();
291
+ });
292
+ response.data.on("error", (err) => {
293
+ if (!closed) reconnect(err);
294
+ });
295
+ } catch (err) {
296
+ if (!closed) reconnect(err);
297
+ }
298
+ };
299
+
300
+ const reconnect = (err) => {
301
+ if (closed) return;
302
+ reconnectCount++;
303
+ if (reconnectCount > maxReconnects) {
304
+ if (onError) onError(err || new Error("Max reconnection attempts reached"));
305
+ return;
306
+ }
307
+ if (onReconnect) onReconnect(reconnectCount);
308
+ const delay = Math.min(1000 * Math.pow(2, reconnectCount - 1), 30000);
309
+ setTimeout(connect, delay);
310
+ };
311
+
312
+ connect();
313
+
314
+ const handle = {
315
+ close: () => {
316
+ closed = true;
317
+ controller.abort();
318
+ },
319
+ };
320
+ this._activeSubscriptions.set(subscriptionId, handle);
321
+ return handle;
322
+ }
323
+
324
+ /**
325
+ * Poll-based sync: returns and clears queued updates.
326
+ * @param {string} subscriptionId
327
+ */
328
+ async syncSubscription(subscriptionId) {
329
+ return this._post(
330
+ `/subscriptions/${encodeURIComponent(subscriptionId)}/sync`,
331
+ {}
332
+ );
333
+ }
334
+
335
+ // ── Utility ────────────────────────────────────────────────────────
336
+
337
+ /**
338
+ * Lightweight connectivity test.
339
+ * Uses GET /namespaces as health indicator.
340
+ */
341
+ async testConnection() {
342
+ await this._get("/namespaces");
343
+ return true;
344
+ }
345
+
346
+ /** Close all active SSE streams and clean up. */
347
+ destroy() {
348
+ for (const [, handle] of this._activeSubscriptions) {
349
+ handle.close();
350
+ }
351
+ this._activeSubscriptions.clear();
352
+ }
353
+
354
+ // ── Internal helpers ───────────────────────────────────────────────
355
+
356
+ /** @private */
357
+ _prefix() {
358
+ const ver = this.apiVersion ? `/${this.apiVersion.replace(/^\//, "")}` : "";
359
+ return `${this.baseUrl}${ver}`;
360
+ }
361
+
362
+ /** @private */
363
+ async _get(path, params) {
364
+ return this._request("get", path, { params });
365
+ }
366
+
367
+ /** @private */
368
+ async _post(path, data) {
369
+ return this._request("post", path, { data });
370
+ }
371
+
372
+ /** @private */
373
+ async _put(path, data) {
374
+ return this._request("put", path, { data });
375
+ }
376
+
377
+ /** @private */
378
+ async _delete(path) {
379
+ return this._request("delete", path);
380
+ }
381
+
382
+ /**
383
+ * Central request dispatcher with retry logic.
384
+ * @private
385
+ */
386
+ async _request(method, path, opts = {}) {
387
+ let lastErr;
388
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
389
+ try {
390
+ const res = await this.http.request({ method, url: path, ...opts });
391
+ return res.data;
392
+ } catch (err) {
393
+ lastErr = err;
394
+ const status = err.response && err.response.status;
395
+ if (status && RETRY_STATUS_CODES.has(status) && attempt < MAX_RETRIES) {
396
+ const delay = RETRY_DELAY_MS * Math.pow(2, attempt);
397
+ await new Promise((r) => setTimeout(r, delay));
398
+ continue;
399
+ }
400
+ throw this._wrapError(err);
401
+ }
402
+ }
403
+ throw this._wrapError(lastErr);
404
+ }
405
+
406
+ /**
407
+ * Produce a normalised error object.
408
+ * @private
409
+ */
410
+ _wrapError(err) {
411
+ if (err._i3x) return err;
412
+ const wrapped = new Error(err.message);
413
+ wrapped._i3x = true;
414
+ if (err.response) {
415
+ wrapped.statusCode = err.response.status;
416
+ wrapped.statusText = err.response.statusText;
417
+ wrapped.body = err.response.data;
418
+ } else if (err.code) {
419
+ wrapped.code = err.code;
420
+ }
421
+ return wrapped;
422
+ }
423
+ }
424
+
425
+ module.exports = I3XClient;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared utilities for i3x Node-RED nodes.
3
+ * Eliminates boilerplate for server binding and status management.
4
+ */
5
+ "use strict";
6
+
7
+ /**
8
+ * Bind an operation node to its i3x-server config node.
9
+ * Sets up connection status indicators and returns false if no server is configured.
10
+ *
11
+ * @param {object} node – the Node-RED node instance
12
+ * @param {object} RED – the Node-RED runtime
13
+ * @param {string} serverId – config.server
14
+ * @returns {boolean} true if server is available, false otherwise
15
+ */
16
+ function bindServer(node, RED, serverId) {
17
+ node.server = RED.nodes.getNode(serverId);
18
+
19
+ if (!node.server) {
20
+ node.status({ fill: "red", shape: "ring", text: "no server configured" });
21
+ return false;
22
+ }
23
+
24
+ node.server.on("connected", () => {
25
+ node.status({ fill: "green", shape: "dot", text: "connected" });
26
+ });
27
+ node.server.on("disconnected", () => {
28
+ node.status({ fill: "red", shape: "ring", text: "disconnected" });
29
+ });
30
+
31
+ if (node.server.connected) {
32
+ node.status({ fill: "green", shape: "dot", text: "connected" });
33
+ }
34
+
35
+ return true;
36
+ }
37
+
38
+ /**
39
+ * Parse a value that may be a comma-separated string or an array into an array of strings.
40
+ * @param {string|string[]} input
41
+ * @returns {string[]}
42
+ */
43
+ function parseIds(input) {
44
+ if (Array.isArray(input)) return input;
45
+ if (typeof input === "string") {
46
+ return input.split(",").map((s) => s.trim()).filter(Boolean);
47
+ }
48
+ return [];
49
+ }
50
+
51
+ /**
52
+ * Backwards-compatible send helper for Node-RED < 1.0.
53
+ * @param {object} node
54
+ * @param {function|undefined} send
55
+ * @returns {function}
56
+ */
57
+ function safeSend(node, send) {
58
+ return send || function () { node.send.apply(node, arguments); };
59
+ }
60
+
61
+ module.exports = { bindServer, parseIds, safeSend };
@@ -0,0 +1,106 @@
1
+ <script type="text/html" data-template-name="i3x-browse">
2
+ <div class="form-row">
3
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-server"><i class="fa fa-server"></i> Server</label>
8
+ <input type="text" id="node-input-server">
9
+ </div>
10
+ <div class="form-row">
11
+ <label for="node-input-browseTarget"><i class="fa fa-sitemap"></i> Target</label>
12
+ <select id="node-input-browseTarget">
13
+ <option value="namespaces">Namespaces</option>
14
+ <option value="objecttypes">Object Types</option>
15
+ <option value="relationshiptypes">Relationship Types</option>
16
+ <option value="objects">Objects</option>
17
+ <option value="related">Related Objects</option>
18
+ </select>
19
+ </div>
20
+ <div class="form-row i3x-browse-elementId">
21
+ <label for="node-input-elementId"><i class="fa fa-crosshairs"></i> Element ID</label>
22
+ <input type="text" id="node-input-elementId" placeholder="optional – filter by element ID">
23
+ </div>
24
+ <div class="form-row i3x-browse-typeId">
25
+ <label for="node-input-typeId"><i class="fa fa-filter"></i> Type ID</label>
26
+ <input type="text" id="node-input-typeId" placeholder="optional – filter objects by type">
27
+ </div>
28
+ <div class="form-row i3x-browse-ns">
29
+ <label for="node-input-namespaceUri"><i class="fa fa-folder-o"></i> Namespace</label>
30
+ <input type="text" id="node-input-namespaceUri" placeholder="optional – filter by namespace URI">
31
+ </div>
32
+ <div class="form-row i3x-browse-rel">
33
+ <label for="node-input-relationshipType"><i class="fa fa-link"></i> Rel. Type</label>
34
+ <input type="text" id="node-input-relationshipType" placeholder="optional – filter by relationship type">
35
+ </div>
36
+ <div class="form-row i3x-browse-meta">
37
+ <label for="node-input-includeMetadata"><i class="fa fa-info-circle"></i> Metadata</label>
38
+ <input type="checkbox" id="node-input-includeMetadata" style="width:auto;">
39
+ <span> Include full metadata</span>
40
+ </div>
41
+ </script>
42
+
43
+ <script type="text/html" data-help-name="i3x-browse">
44
+ <p>Explores the i3X information model – namespaces, object types, relationship types, objects, and related objects.</p>
45
+
46
+ <h3>Inputs</h3>
47
+ <dl class="message-properties">
48
+ <dt class="optional">browseTarget <span class="property-type">string</span></dt>
49
+ <dd>What to browse: <code>namespaces</code>, <code>objecttypes</code>, <code>relationshiptypes</code>, <code>objects</code>, or <code>related</code>.</dd>
50
+ <dt class="optional">elementId <span class="property-type">string | string[]</span></dt>
51
+ <dd>One or more element IDs to query directly.</dd>
52
+ <dt class="optional">typeId <span class="property-type">string</span></dt>
53
+ <dd>Filter objects by type ID (only for <code>objects</code> target).</dd>
54
+ <dt class="optional">namespaceUri <span class="property-type">string</span></dt>
55
+ <dd>Filter by namespace URI (for <code>objecttypes</code> / <code>relationshiptypes</code>).</dd>
56
+ <dt class="optional">relationshipType <span class="property-type">string</span></dt>
57
+ <dd>Filter related objects by relationship type.</dd>
58
+ </dl>
59
+
60
+ <h3>Outputs</h3>
61
+ <dl class="message-properties">
62
+ <dt>payload <span class="property-type">array</span></dt>
63
+ <dd>Array of discovered objects matching the browse criteria.</dd>
64
+ </dl>
65
+
66
+ <h3>Details</h3>
67
+ <p>This node talks to the <b>Explore</b> endpoints of the i3X API.
68
+ <code>msg</code> properties override the values configured in the node editor.</p>
69
+ </script>
70
+
71
+ <script type="text/javascript">
72
+ RED.nodes.registerType("i3x-browse", {
73
+ category: "i3x",
74
+ color: "#5DB87C",
75
+ defaults: {
76
+ name: { value: "" },
77
+ server: { value: "", type: "i3x-server", required: true },
78
+ browseTarget: { value: "objects" },
79
+ elementId: { value: "" },
80
+ typeId: { value: "" },
81
+ namespaceUri: { value: "" },
82
+ includeMetadata: { value: false },
83
+ relationshipType: { value: "" },
84
+ },
85
+ inputs: 1,
86
+ outputs: 1,
87
+ icon: "i3x-icon.svg",
88
+ paletteLabel: "i3x browse",
89
+ label: function () {
90
+ return this.name || this.browseTarget || "i3x browse";
91
+ },
92
+ oneditprepare: function () {
93
+ var target = $("#node-input-browseTarget");
94
+ function toggle() {
95
+ var v = target.val();
96
+ $(".i3x-browse-elementId").toggle(v !== "namespaces");
97
+ $(".i3x-browse-typeId").toggle(v === "objects");
98
+ $(".i3x-browse-ns").toggle(v === "objecttypes" || v === "relationshiptypes");
99
+ $(".i3x-browse-rel").toggle(v === "related");
100
+ $(".i3x-browse-meta").toggle(v === "objects" || v === "related");
101
+ }
102
+ target.on("change", toggle);
103
+ toggle();
104
+ },
105
+ });
106
+ </script>
@@ -0,0 +1,93 @@
1
+ /**
2
+ * i3x-browse – Explore namespaces, object types, objects, and relationships.
3
+ */
4
+ "use strict";
5
+
6
+ const { bindServer, safeSend } = require("../lib/node-utils");
7
+
8
+ module.exports = function (RED) {
9
+ function I3XBrowseNode(config) {
10
+ RED.nodes.createNode(this, config);
11
+ const node = this;
12
+
13
+ node.browseTarget = config.browseTarget || "objects";
14
+ node.elementId = config.elementId || "";
15
+ node.typeId = config.typeId || "";
16
+ node.namespaceUri = config.namespaceUri || "";
17
+ node.includeMetadata = config.includeMetadata || false;
18
+ node.relationshipType = config.relationshipType || "";
19
+
20
+ if (!bindServer(node, RED, config.server)) return;
21
+
22
+ node.on("input", async function (msg, send, done) {
23
+ send = safeSend(node, send);
24
+ const client = node.server.client;
25
+
26
+ const target = msg.browseTarget || node.browseTarget;
27
+ const elementId = msg.elementId || node.elementId;
28
+ const typeId = msg.typeId || node.typeId;
29
+ const nsUri = msg.namespaceUri || node.namespaceUri;
30
+ const inclMeta = msg.includeMetadata !== undefined ? msg.includeMetadata : node.includeMetadata;
31
+ const relType = msg.relationshipType || node.relationshipType;
32
+
33
+ node.status({ fill: "blue", shape: "dot", text: "requesting..." });
34
+
35
+ try {
36
+ let result;
37
+ switch (target) {
38
+ case "namespaces":
39
+ result = await client.getNamespaces();
40
+ break;
41
+ case "objecttypes":
42
+ if (elementId) {
43
+ const ids = Array.isArray(elementId) ? elementId : [elementId];
44
+ result = await client.queryObjectTypes(ids);
45
+ } else {
46
+ result = await client.getObjectTypes({ namespaceUri: nsUri || undefined });
47
+ }
48
+ break;
49
+ case "relationshiptypes":
50
+ if (elementId) {
51
+ const ids = Array.isArray(elementId) ? elementId : [elementId];
52
+ result = await client.queryRelationshipTypes(ids);
53
+ } else {
54
+ result = await client.getRelationshipTypes({ namespaceUri: nsUri || undefined });
55
+ }
56
+ break;
57
+ case "objects":
58
+ if (elementId) {
59
+ const ids = Array.isArray(elementId) ? elementId : [elementId];
60
+ result = await client.listObjects(ids, { includeMetadata: inclMeta });
61
+ } else {
62
+ result = await client.getObjects({ typeId: typeId || undefined, includeMetadata: inclMeta });
63
+ }
64
+ break;
65
+ case "related":
66
+ if (!elementId) {
67
+ throw new Error("elementId is required for related objects query");
68
+ }
69
+ {
70
+ const ids = Array.isArray(elementId) ? elementId : [elementId];
71
+ result = await client.getRelatedObjects(ids, {
72
+ relationshipType: relType || undefined,
73
+ includeMetadata: inclMeta,
74
+ });
75
+ }
76
+ break;
77
+ default:
78
+ throw new Error("Unknown browse target: " + target);
79
+ }
80
+
81
+ msg.payload = result;
82
+ node.status({ fill: "green", shape: "dot", text: "ok" });
83
+ send(msg);
84
+ if (done) done();
85
+ } catch (err) {
86
+ node.status({ fill: "red", shape: "ring", text: err.message.substring(0, 32) });
87
+ if (done) done(err); else node.error(err, msg);
88
+ }
89
+ });
90
+ }
91
+
92
+ RED.nodes.registerType("i3x-browse", I3XBrowseNode);
93
+ };