@bonsae/nrg-runtime 0.38.1 → 0.39.0

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.
Files changed (2) hide show
  1. package/index.cjs +265 -57
  2. package/package.json +1 -1
package/index.cjs CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/sdk/lib/runtime.ts
31
31
  var runtime_exports = {};
32
32
  __export(runtime_exports, {
33
+ Channels: () => Channels,
33
34
  ConfigNode: () => ConfigNode,
34
35
  IONode: () => IONode,
35
36
  Kind: () => import_typebox3.Kind,
@@ -43,13 +44,20 @@ __export(runtime_exports, {
43
44
  });
44
45
  module.exports = __toCommonJS(runtime_exports);
45
46
 
46
- // src/sdk/lib/server/nodes/symbols.ts
47
+ // src/sdk/lib/server/symbols/node.ts
47
48
  var NRG_SETUP_CLOSE_HANDLER = Symbol.for("nrg.setupCloseHandler");
48
49
  var NRG_SETUP_INPUT_HANDLER = Symbol.for("nrg.setupInputHandler");
49
50
  var NRG_NODE = Symbol.for("nrg.node");
50
51
  var NRG_CONFIG_NODE = Symbol.for("nrg.configNode");
51
52
  var NRG_PORTS = Symbol.for("nrg.ports");
52
53
 
54
+ // src/sdk/lib/server/symbols/channels-store.ts
55
+ var NRG_PROTECTED_CHANNEL = Symbol.for("nrg.protectedChannel");
56
+ var NRG_PRIVATE_CHANNEL = Symbol.for("nrg.privateChannel");
57
+ var NRG_MODULE_PRIVATE_CHANNEL = Symbol.for(
58
+ "nrg.modulePrivateChannel"
59
+ );
60
+
53
61
  // src/sdk/lib/shared/errors.ts
54
62
  var NrgError = class _NrgError extends Error {
55
63
  constructor(message) {
@@ -61,6 +69,7 @@ var NrgError = class _NrgError extends Error {
61
69
 
62
70
  // src/sdk/lib/server/module.ts
63
71
  function defineModule(definition) {
72
+ const packagePrivateChannel = Symbol("nrg.package.private");
64
73
  for (const NodeClass of definition.nodes) {
65
74
  if (!NodeClass[NRG_NODE]) {
66
75
  const name = NodeClass?.name || String(NodeClass);
@@ -68,6 +77,7 @@ function defineModule(definition) {
68
77
  `defineModule: "${name}" is not an nrg node class \u2014 extend IONode/ConfigNode.`
69
78
  );
70
79
  }
80
+ NodeClass[NRG_MODULE_PRIVATE_CHANNEL] = packagePrivateChannel;
71
81
  }
72
82
  return definition;
73
83
  }
@@ -249,44 +259,108 @@ var ValidationError = class _ValidationError extends Error {
249
259
  }
250
260
  };
251
261
 
252
- // src/sdk/lib/server/validation.ts
253
- function initValidator(RED) {
254
- if (RED.validator) return;
255
- const nrg = {
256
- validator: new Validator({
257
- customKeywords: [
258
- {
259
- keyword: "x-nrg-skip-validation",
260
- schemaType: "boolean",
261
- valid: true
262
- },
263
- {
264
- keyword: "x-nrg-node-type",
265
- type: "string",
266
- validate: (schemaValue, dataValue) => {
267
- if (!dataValue) return true;
268
- const node = RED.nodes.getNode(dataValue);
269
- return node?.type === schemaValue;
270
- }
271
- }
272
- ],
273
- customFormats: {
274
- "node-id": /^[a-zA-Z0-9-_]+$/
275
- }
276
- })
277
- };
278
- Object.defineProperty(RED, "_nrg", {
279
- value: nrg,
280
- writable: false,
281
- enumerable: false,
282
- configurable: false
283
- });
284
- Object.defineProperty(RED, "validator", {
285
- get: () => nrg.validator,
286
- enumerable: false,
287
- configurable: false
262
+ // src/sdk/lib/server/channels-store.ts
263
+ var ASSIGN_MESSAGE = "Cannot assign to a message channel. Channel data is written on send \u2014 `send(port, value, { protected, private })`. The `msg[Channels]` accessor is read + delete only (e.g. `delete msg[Channels].private.x`).";
264
+ var ChannelStore = class {
265
+ #byMsgid = /* @__PURE__ */ new Map();
266
+ #ttlMs;
267
+ #sweepMs;
268
+ #sweeper;
269
+ constructor(options = {}) {
270
+ this.#ttlMs = options.ttlMs ?? 5 * 6e4;
271
+ this.#sweepMs = options.sweepMs ?? 6e4;
272
+ }
273
+ #channel(msgid, partition, create) {
274
+ let entry = this.#byMsgid.get(msgid);
275
+ if (!entry) {
276
+ if (!create) return void 0;
277
+ entry = { lastTouched: Date.now(), partitions: /* @__PURE__ */ new Map() };
278
+ this.#byMsgid.set(msgid, entry);
279
+ this.#ensureSweeper();
280
+ } else {
281
+ entry.lastTouched = Date.now();
282
+ }
283
+ let channel = entry.partitions.get(partition);
284
+ if (!channel && create) {
285
+ channel = /* @__PURE__ */ new Map();
286
+ entry.partitions.set(partition, channel);
287
+ }
288
+ return channel;
289
+ }
290
+ /** Merge a producer's `{ … }` bag into a channel (send's protected/private args). */
291
+ merge(msgid, partition, data) {
292
+ const channel = this.#channel(msgid, partition, true);
293
+ for (const [key, value] of Object.entries(data)) channel.set(key, value);
294
+ }
295
+ get(msgid, partition, key) {
296
+ return this.#channel(msgid, partition, false)?.get(key);
297
+ }
298
+ set(msgid, partition, key, value) {
299
+ this.#channel(msgid, partition, true).set(key, value);
300
+ }
301
+ has(msgid, partition, key) {
302
+ return this.#channel(msgid, partition, false)?.has(key) ?? false;
303
+ }
304
+ /** Remove one entry — framework bookkeeping only; the resource owns release. */
305
+ delete(msgid, partition, key) {
306
+ this.#channel(msgid, partition, false)?.delete(key);
307
+ }
308
+ keys(msgid, partition) {
309
+ return [...this.#channel(msgid, partition, false)?.keys() ?? []];
310
+ }
311
+ #ensureSweeper() {
312
+ if (this.#sweeper) return;
313
+ this.#sweeper = setInterval(() => this.#sweep(), this.#sweepMs);
314
+ this.#sweeper.unref?.();
315
+ }
316
+ #sweep() {
317
+ const now = Date.now();
318
+ for (const [msgid, entry] of this.#byMsgid) {
319
+ if (now - entry.lastTouched > this.#ttlMs) this.#byMsgid.delete(msgid);
320
+ }
321
+ if (this.#byMsgid.size === 0 && this.#sweeper) {
322
+ clearInterval(this.#sweeper);
323
+ this.#sweeper = void 0;
324
+ }
325
+ }
326
+ };
327
+ function channelProxy(store, msgid, partition) {
328
+ if (msgid == null) {
329
+ return new Proxy(/* @__PURE__ */ Object.create(null), {
330
+ get: () => void 0,
331
+ set: () => {
332
+ throw new NrgError(ASSIGN_MESSAGE);
333
+ },
334
+ deleteProperty: () => true,
335
+ has: () => false,
336
+ ownKeys: () => [],
337
+ getOwnPropertyDescriptor: () => void 0
338
+ });
339
+ }
340
+ return new Proxy(/* @__PURE__ */ Object.create(null), {
341
+ get: (_t, key) => typeof key === "string" ? store.get(msgid, partition, key) : void 0,
342
+ // Assignment is a misuse — channels are written on send, not by mutating the
343
+ // incoming message. Throw loudly rather than silently writing a second path.
344
+ set: () => {
345
+ throw new NrgError(ASSIGN_MESSAGE);
346
+ },
347
+ deleteProperty: (_t, key) => {
348
+ if (typeof key === "string") store.delete(msgid, partition, key);
349
+ return true;
350
+ },
351
+ has: (_t, key) => typeof key === "string" ? store.has(msgid, partition, key) : false,
352
+ ownKeys: () => store.keys(msgid, partition),
353
+ getOwnPropertyDescriptor: (_t, key) => typeof key === "string" && store.has(msgid, partition, key) ? {
354
+ enumerable: true,
355
+ configurable: true,
356
+ writable: false,
357
+ value: store.get(msgid, partition, key)
358
+ } : void 0
288
359
  });
289
360
  }
361
+ function packageChannel(nodeClass) {
362
+ return nodeClass[NRG_MODULE_PRIVATE_CHANNEL] ?? NRG_PRIVATE_CHANNEL;
363
+ }
290
364
 
291
365
  // src/sdk/lib/server/api/assets.ts
292
366
  var import_node_path = __toESM(require("node:path"), 1);
@@ -320,6 +394,51 @@ function initRoutes(RED) {
320
394
  initAssetsRoutes(RED.httpAdmin);
321
395
  }
322
396
 
397
+ // src/sdk/lib/server/init.ts
398
+ function initValidator(RED) {
399
+ if (RED.validator) return;
400
+ const validator = new Validator({
401
+ customKeywords: [
402
+ {
403
+ keyword: "x-nrg-skip-validation",
404
+ schemaType: "boolean",
405
+ valid: true
406
+ },
407
+ {
408
+ keyword: "x-nrg-node-type",
409
+ type: "string",
410
+ validate: (schemaValue, dataValue) => {
411
+ if (!dataValue) return true;
412
+ const node = RED.nodes.getNode(dataValue);
413
+ return node?.type === schemaValue;
414
+ }
415
+ }
416
+ ],
417
+ customFormats: {
418
+ "node-id": /^[a-zA-Z0-9-_]+$/
419
+ }
420
+ });
421
+ Object.defineProperty(RED, "validator", {
422
+ get: () => validator,
423
+ enumerable: false,
424
+ configurable: false
425
+ });
426
+ }
427
+ function initChannelStore(RED) {
428
+ if (RED.channelStore) return;
429
+ const channelStore = new ChannelStore();
430
+ Object.defineProperty(RED, "channelStore", {
431
+ get: () => channelStore,
432
+ enumerable: false,
433
+ configurable: false
434
+ });
435
+ }
436
+ function init(RED) {
437
+ initValidator(RED);
438
+ initChannelStore(RED);
439
+ initRoutes(RED);
440
+ }
441
+
323
442
  // src/sdk/lib/shared/schemas/utils.ts
324
443
  function getCredentialsFromSchema(schema) {
325
444
  if (!schema?.properties) return void 0;
@@ -416,8 +535,7 @@ async function registerType(RED, NodeClass) {
416
535
  function registerTypes(nodes) {
417
536
  const fn = Object.assign(
418
537
  async function(RED) {
419
- initValidator(RED);
420
- initRoutes(RED);
538
+ init(RED);
421
539
  try {
422
540
  RED.log.info("Registering node types in series");
423
541
  for (const NodeClass of nodes) {
@@ -788,6 +906,9 @@ function setupContext(context, store) {
788
906
  return { get, set, keys, update, increment };
789
907
  }
790
908
 
909
+ // src/sdk/lib/server/nodes/types/ports.ts
910
+ var Channels = Symbol.for("nrg.channels");
911
+
791
912
  // src/sdk/lib/server/nodes/io-node.ts
792
913
  var import_node_async_hooks = require("node:async_hooks");
793
914
  var RETURN_PROPERTY_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
@@ -893,7 +1014,10 @@ var IONode = class _IONode extends Node {
893
1014
  this.#sendToPort("complete", {
894
1015
  ...result !== void 0 ? { complete: result } : {},
895
1016
  [SOURCE_KEY]: this.#nodeSource(),
896
- [INPUT_KEY]: msg
1017
+ [INPUT_KEY]: msg,
1018
+ // Runs after input() resolved (outside the ALS scope), so inherit
1019
+ // the incoming `_msgid` explicitly to keep the lineage id intact.
1020
+ ...this.#sourceMsgid(msg)
897
1021
  });
898
1022
  }
899
1023
  done();
@@ -916,7 +1040,10 @@ var IONode = class _IONode extends Node {
916
1040
  ...stack ? { stack } : {}
917
1041
  },
918
1042
  [SOURCE_KEY]: this.#nodeSource(),
919
- [INPUT_KEY]: msg
1043
+ [INPUT_KEY]: msg,
1044
+ // Runs in the catch (outside the ALS scope), so inherit the
1045
+ // incoming `_msgid` explicitly to keep the lineage id intact.
1046
+ ...this.#sourceMsgid(msg)
920
1047
  });
921
1048
  this.node.error(errorMsg);
922
1049
  }
@@ -946,20 +1073,65 @@ var IONode = class _IONode extends Node {
946
1073
  this.node.log("Input is valid");
947
1074
  }
948
1075
  }
1076
+ this.#setupChannels(msg);
949
1077
  return await _IONode.#invocation.run(
950
1078
  store,
951
1079
  () => Promise.resolve(this.input(msg))
952
1080
  );
953
1081
  }
954
- send(msg) {
955
- const multi = this.#baseOutputs > 1 && Array.isArray(msg);
956
- const values = multi ? msg.slice(0, this.#baseOutputs) : [msg];
957
- const out = values.map((m, port) => {
958
- if (m == null) return m;
959
- this.#validatePort(m, port);
960
- return this.#wrapOutgoing(m, this.#resolveContextMode(port), port);
1082
+ /**
1083
+ * Add the `[Channels]` accessor to the incoming message. SYMBOL-keyed, so it can
1084
+ * never collide with an author's own message fields and is already invisible to
1085
+ * JSON / `Object.keys` / the debug panel (the `enumerable: false` keeps it out of
1086
+ * a symbol-aware clone too). Reading `msg[Channels]` yields `{ protected, private }`
1087
+ * — proxies over the channel store keyed by the message's `_msgid`, `private`
1088
+ * partitioned by this node's package. A core function node gets the bare message
1089
+ * and has no accessor — the channels are structurally invisible to the flow author.
1090
+ *
1091
+ * Mutates `msg` in place (the caller already holds it as `TInput`). A non-object
1092
+ * message (never produced by real Node-RED, which always delivers an object) is
1093
+ * left untouched.
1094
+ */
1095
+ #setupChannels(msg) {
1096
+ if (msg == null || typeof msg !== "object") return;
1097
+ const store = this.RED.channelStore;
1098
+ const pkg = packageChannel(this.constructor);
1099
+ Object.defineProperty(msg, Channels, {
1100
+ configurable: true,
1101
+ enumerable: false,
1102
+ get() {
1103
+ const msgid = this._msgid;
1104
+ return {
1105
+ protected: channelProxy(store, msgid, NRG_PROTECTED_CHANNEL),
1106
+ private: channelProxy(store, msgid, pkg)
1107
+ };
1108
+ }
961
1109
  });
962
- this.#deliver(out);
1110
+ }
1111
+ /**
1112
+ * Merge the `protected`/`private` args into this message's channels. Keyed by the
1113
+ * outgoing `_msgid`: an input-triggered send inherits it; a source send (no
1114
+ * invocation) mints one and stamps it on the outgoing frames so a downstream
1115
+ * node reads the same channel. Contributions are STICKY — they persist for the
1116
+ * message's journey, so a middle node's plain `send()` keeps them alive.
1117
+ */
1118
+ #writeChannels(protectedData, privateData, out) {
1119
+ if (!protectedData && !privateData) return;
1120
+ const existing = _IONode.#invocation.getStore()?.inputMsg?.[MSGID_KEY];
1121
+ const msgid = existing ?? this.RED.util.generateId();
1122
+ if (!existing) {
1123
+ for (const m of out) {
1124
+ if (m && typeof m === "object") {
1125
+ const rec = m;
1126
+ if (rec[MSGID_KEY] === void 0) rec[MSGID_KEY] = msgid;
1127
+ }
1128
+ }
1129
+ }
1130
+ const store = this.RED.channelStore;
1131
+ if (protectedData) store.merge(msgid, NRG_PROTECTED_CHANNEL, protectedData);
1132
+ if (privateData) {
1133
+ store.merge(msgid, packageChannel(this.constructor), privateData);
1134
+ }
963
1135
  }
964
1136
  #deliver(out) {
965
1137
  const send = _IONode.#invocation.getStore()?.send;
@@ -1064,7 +1236,7 @@ var IONode = class _IONode extends Node {
1064
1236
  );
1065
1237
  if (mode === "reset")
1066
1238
  return this.#withMsgid({ [key]: value, [SOURCE_KEY]: source });
1067
- if (mode === "trace") return frame(input);
1239
+ if (mode === "trace") return frame({ ...input });
1068
1240
  const lastOnly = { ...input };
1069
1241
  delete lastOnly[INPUT_KEY];
1070
1242
  return frame(lastOnly);
@@ -1083,6 +1255,19 @@ var IONode = class _IONode extends Node {
1083
1255
  const msgid = _IONode.#invocation.getStore()?.inputMsg?.[MSGID_KEY];
1084
1256
  return msgid !== void 0 ? { ...frame, [MSGID_KEY]: msgid } : frame;
1085
1257
  }
1258
+ /**
1259
+ * The source message's `_msgid` as a spreadable frame fragment — for the
1260
+ * built-in complete/error auto-emits, which run AFTER `input()` has resolved,
1261
+ * i.e. OUTSIDE the invocation ALS scope, so {@link #withMsgid} (which reads the
1262
+ * scope) can't recover it. Stamping it here keeps the lifecycle frame on the
1263
+ * SAME lineage id as the data-port emits, so Catch/Complete grouping and any
1264
+ * `_msgid` correlation still work. `{}` when the source carries none (a source
1265
+ * node) — Node-RED then assigns one, exactly as {@link #withMsgid} documents.
1266
+ */
1267
+ #sourceMsgid(src) {
1268
+ const id = src?.[MSGID_KEY];
1269
+ return typeof id === "string" ? { [MSGID_KEY]: id } : {};
1270
+ }
1086
1271
  /**
1087
1272
  * Provenance stamped on every data-port output under `msg.source`: the
1088
1273
  * producing node plus the port the message was sent on (with the named-port
@@ -1126,32 +1311,51 @@ var IONode = class _IONode extends Node {
1126
1311
  * framework and cannot be sent to directly. Use `this.status()` for status,
1127
1312
  * throw an error or call `this.error()` for the error port, and the complete
1128
1313
  * port is sent automatically on successful input processing.
1314
+ *
1315
+ * The optional `channels` argument writes off-the-wire data to the message's
1316
+ * channel store (keyed by its `_msgid`): `protected` targets the package-shared
1317
+ * partition, `private` this package's own. It is reachable downstream via
1318
+ * `msg[Channels]` and never rides the serialized message. A single object (not
1319
+ * positional args) so new channels stay additive.
1129
1320
  */
1130
- sendToPort(port, msg) {
1321
+ send(port, msg, channels) {
1131
1322
  if (port === "error" || port === "complete" || port === "status") {
1132
1323
  throw new NrgError(
1133
- `sendToPort("${port}") is not allowed. Built-in ports are managed by the framework.`
1324
+ `send("${port}") is not allowed. Built-in ports are managed by the framework.`
1134
1325
  );
1135
1326
  }
1327
+ if (typeof port === "number") {
1328
+ if (!Number.isInteger(port) || port < 0) {
1329
+ throw new NrgError(
1330
+ `send(${port}) \u2014 a numeric output port must be a non-negative integer index.`
1331
+ );
1332
+ }
1333
+ if (port >= this.#baseOutputs && port < this.#totalOutputs) {
1334
+ throw new NrgError(
1335
+ `send(${port}) targets a framework-managed built-in port slot (error/complete/status).` + (this.#baseOutputs > 0 ? ` Send to a base output port (0..${this.#baseOutputs - 1}),` : ` Send to a base output port,`) + ` or use this.status() / this.error().`
1336
+ );
1337
+ }
1338
+ }
1136
1339
  const portIndex = typeof port === "number" ? port : this.#getNamedPortIndex(port);
1137
1340
  if (typeof port === "string" && portIndex === null) {
1138
1341
  const keys = this.#namedPortKeys();
1139
1342
  throw new NrgError(
1140
- keys && keys.length ? `sendToPort("${port}") \u2014 unknown output port. Valid named ports: ${keys.map((n) => `"${n}"`).join(", ")}.` : `sendToPort("${port}") \u2014 this node has no named output ports. Declare named ports with a Port<T> record in the node's Output generic, or send to a numeric port index.`
1343
+ keys && keys.length ? `send("${port}") \u2014 unknown output port. Valid named ports: ${keys.map((n) => `"${n}"`).join(", ")}.` : `send("${port}") \u2014 this node has no named output ports. Declare named ports with a Port<T> record in the node's Output generic, or send to a numeric port index.`
1141
1344
  );
1142
1345
  }
1143
1346
  const idx = portIndex ?? 0;
1144
1347
  if (msg == null) {
1145
- this.#sendToPort(port, msg);
1348
+ this.#sendToPort(port, msg, channels);
1146
1349
  return;
1147
1350
  }
1148
1351
  this.#validatePort(msg, idx);
1149
1352
  this.#sendToPort(
1150
1353
  port,
1151
- this.#wrapOutgoing(msg, this.#resolveContextMode(idx), idx)
1354
+ this.#wrapOutgoing(msg, this.#resolveContextMode(idx), idx),
1355
+ channels
1152
1356
  );
1153
1357
  }
1154
- #sendToPort(port, msg) {
1358
+ #sendToPort(port, msg, channels) {
1155
1359
  let portIndex;
1156
1360
  if (typeof port === "number") {
1157
1361
  portIndex = port;
@@ -1166,7 +1370,10 @@ var IONode = class _IONode extends Node {
1166
1370
  }
1167
1371
  const out = new Array(this.#totalOutputs);
1168
1372
  out[portIndex] = msg !== null && typeof msg === "object" ? this.#withMsgid(msg) : msg;
1169
- this.node.send(out);
1373
+ if (channels?.protected || channels?.private) {
1374
+ this.#writeChannels(channels.protected, channels.private, out);
1375
+ }
1376
+ this.#deliver(out);
1170
1377
  }
1171
1378
  /** The declared named output ports (from the injected `Output`-generic
1172
1379
  * topology's `Port<T>` record), or null when the node has none (a
@@ -1515,6 +1722,7 @@ function defineSchema(properties, options) {
1515
1722
  var import_typebox3 = require("@sinclair/typebox");
1516
1723
  // Annotate the CommonJS export names for ESM import in node:
1517
1724
  0 && (module.exports = {
1725
+ Channels,
1518
1726
  ConfigNode,
1519
1727
  IONode,
1520
1728
  Kind,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bonsae/nrg-runtime",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "description": "The runtime for Node-RED nodes built with @bonsae/nrg — node base classes, schemas, AJV validator, and the editor client. Carries no build tooling.",
5
5
  "author": "Allan Oricil <allanoricil@duck.com>",
6
6
  "license": "MIT",