@bonsae/nrg 0.20.1 → 0.21.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.
package/README.md CHANGED
@@ -130,6 +130,14 @@ export default defineModule({
130
130
 
131
131
  See the [consumer template](https://github.com/AllanOricil/node-red-vue-template) for a complete example.
132
132
 
133
+ ### The generated editor form
134
+
135
+ nrg builds the node's edit dialog from your schema — no HTML or jQuery. Your config fields render first, then a **Ports Settings** section (input/output validation, return key, and per-port [context modes](https://bonsaedev.github.io/nrg/guide/schemas#context-modes)) and a **Lifecycle Ports** section (error / complete / status):
136
+
137
+ <p align="center">
138
+ <img alt="nrg generated editor form" src="docs/public/editor-form.png" width="360"/>
139
+ </p>
140
+
133
141
  ## Testing
134
142
 
135
143
  NRG provides five test libraries and bundles most test infrastructure as direct dependencies. Install `vitest` plus any optional peer dependencies you need:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bonsae/nrg",
3
- "version": "0.20.1",
3
+ "version": "0.21.1",
4
4
  "description": "NRG framework — build Node-RED nodes with Vue 3, TypeScript, and JSON Schema",
5
5
  "author": "Allan Oricil <allanoricil@duck.com>",
6
6
  "license": "MIT",
package/server/index.cjs CHANGED
@@ -443,8 +443,8 @@ var IONode = class extends Node {
443
443
  static align;
444
444
  static color;
445
445
  static inputSchema;
446
- // outputsSchema accepts any schema shape: with returnProperty the raw sent
447
- // value is validated, and results are frequently non-objects.
446
+ // outputsSchema accepts any schema shape: the raw sent value (per port) is
447
+ // validated, and results are frequently non-objects.
448
448
  static outputsSchema;
449
449
  static validateInput = false;
450
450
  static validateOutput = false;
@@ -473,7 +473,7 @@ var IONode = class extends Node {
473
473
  }
474
474
  #send;
475
475
  /**
476
- * Most recent input message — the spread base for returnProperty wrapping. Not
476
+ * Most recent input message — the spread base for output wrapping. Not
477
477
  * cleared after input() so late async sends merge with the last received
478
478
  * message.
479
479
  */
@@ -490,11 +490,15 @@ var IONode = class extends Node {
490
490
  fn.flow = setupContext(context.flow);
491
491
  fn.global = setupContext(context.global);
492
492
  this.context = fn;
493
- const returnPropertyKey = this.#returnPropertyKey();
494
- if (!RETURN_PROPERTY_PATTERN.test(returnPropertyKey)) {
495
- throw new NrgError(
496
- `Invalid returnProperty key "${returnPropertyKey}" in ${this.constructor.type} \u2014 it must be a valid JavaScript identifier (letters, digits, _, $; not starting with a digit)`
497
- );
493
+ const outputReturnProperties = this.config.outputReturnProperties;
494
+ if (outputReturnProperties) {
495
+ for (const [port, key] of Object.entries(outputReturnProperties)) {
496
+ if (typeof key === "string" && key.trim() && !RETURN_PROPERTY_PATTERN.test(key.trim())) {
497
+ throw new NrgError(
498
+ `Invalid return property "${key}" for output port ${port} in ${this.constructor.type} \u2014 it must be a valid JavaScript identifier (letters, digits, _, $; not starting with a digit)`
499
+ );
500
+ }
501
+ }
498
502
  }
499
503
  }
500
504
  [WIRE_HANDLERS](nodeRedNode, createdPromise) {
@@ -569,55 +573,26 @@ var IONode = class extends Node {
569
573
  this.#send = void 0;
570
574
  }
571
575
  }
572
- send(msg, contextMode = "nest") {
573
- const NodeClass = this.constructor;
576
+ send(msg) {
574
577
  const sendsValue = this.baseOutputs <= 1;
575
- const shouldValidateOutput = this.config.validateOutput ?? NodeClass.validateOutput;
576
- if (shouldValidateOutput && NodeClass.outputsSchema) {
577
- this.log("Validating output");
578
- const rawSchema = NodeClass.outputsSchema;
579
- if (Array.isArray(rawSchema)) {
580
- const msgs = msg;
581
- for (let i = 0; i < rawSchema.length; i++) {
582
- if (msgs[i] == null) continue;
583
- this.RED.validator.validate(msgs[i], rawSchema[i], {
584
- cacheKey: rawSchema[i].$id || `${NodeClass.type}:output-schema:${i}`,
585
- throwOnError: true
586
- });
587
- }
588
- } else if (isSchemaLike(rawSchema)) {
589
- if (Array.isArray(msg) && !sendsValue) {
590
- const msgs = msg;
591
- for (let i = 0; i < msgs.length; i++) {
592
- if (msgs[i] == null) continue;
593
- this.RED.validator.validate(msgs[i], rawSchema, {
594
- cacheKey: rawSchema.$id || `${NodeClass.type}:output-schema`,
595
- throwOnError: true
596
- });
597
- }
598
- } else {
599
- this.RED.validator.validate(msg, rawSchema, {
600
- cacheKey: rawSchema.$id || `${NodeClass.type}:output-schema`,
601
- throwOnError: true
602
- });
603
- }
604
- } else {
605
- const schemaArray = Object.values(rawSchema);
606
- const msgs = msg;
607
- for (let i = 0; i < schemaArray.length; i++) {
608
- if (msgs[i] == null) continue;
609
- this.RED.validator.validate(msgs[i], schemaArray[i], {
610
- cacheKey: schemaArray[i].$id || `${NodeClass.type}:output-schema:${i}`,
611
- throwOnError: true
612
- });
613
- }
614
- }
615
- this.log("Output is valid");
578
+ if (Array.isArray(msg) && !sendsValue) {
579
+ const slots = msg.slice(0, this.baseOutputs);
580
+ const out = slots.map((m, port) => {
581
+ if (m == null) return m;
582
+ this.#validatePort(m, port);
583
+ return this.#wrapOutgoing(m, this.#resolveContextMode(port), port);
584
+ });
585
+ this.#deliver(out);
586
+ return;
616
587
  }
617
- const truncated = Array.isArray(msg) && !sendsValue ? msg.slice(0, this.baseOutputs) : msg;
618
- const out = Array.isArray(truncated) && !sendsValue ? truncated.map(
619
- (m) => m == null ? m : this.#wrapOutgoing(m, contextMode)
620
- ) : truncated == null ? truncated : this.#wrapOutgoing(truncated, contextMode);
588
+ if (msg == null) {
589
+ this.#deliver(msg);
590
+ return;
591
+ }
592
+ this.#validatePort(msg, 0);
593
+ this.#deliver(this.#wrapOutgoing(msg, this.#resolveContextMode(0), 0));
594
+ }
595
+ #deliver(out) {
621
596
  if (this.#send) {
622
597
  this.#send(out);
623
598
  } else {
@@ -625,45 +600,69 @@ var IONode = class extends Node {
625
600
  }
626
601
  }
627
602
  /**
628
- * Resolves the active return key. `null` = the node did not declare
629
- * `returnProperty` in its configSchema, so its code owns the outgoing message
630
- * shape (no wrapping).
603
+ * Per-port output validation. A port validates when its flow-author flag
604
+ * (`config.validateOutputs[port]`) or the node's static `validateOutput`
605
+ * fallback is on and a schema exists for that port.
631
606
  */
607
+ #validatePort(value, port) {
608
+ const NodeClass = this.constructor;
609
+ const configured = this.config.validateOutputs?.[port];
610
+ if (!(configured ?? NodeClass.validateOutput)) return;
611
+ const schema = this.#outputSchemaForPort(port);
612
+ if (!schema) return;
613
+ this.log("Validating output");
614
+ this.RED.validator.validate(value, schema, {
615
+ cacheKey: schema.$id || `${NodeClass.type}:output-schema:${port}`,
616
+ throwOnError: true
617
+ });
618
+ this.log("Output is valid");
619
+ }
620
+ /** Resolves the output schema for a base-output port: array → `[port]`,
621
+ * record → the port-th value, single schema → itself. */
622
+ #outputSchemaForPort(port) {
623
+ const raw = this.constructor.outputsSchema;
624
+ if (!raw) return void 0;
625
+ if (Array.isArray(raw)) return raw[port];
626
+ if (isSchemaLike(raw)) return raw;
627
+ return Object.values(raw)[port];
628
+ }
632
629
  /**
633
- * Every node has a return property — `"output"` by default. Declaring
634
- * `SchemaType.ReturnProperty()` in the configSchema doesn't create it; it
635
- * only exposes the key to the flow author so they can override it in the
636
- * editor (and lets the node pick a different default). So `this.send(x)`
637
- * always means "x is the value at the return key", never "x is the whole
638
- * outgoing message".
630
+ * The return key for an output port — `"output"` unless a custom one is set
631
+ * via `outputReturnProperties[port]` (author default and/or flow-author
632
+ * override, only possible when the node declares `outputReturnProperties`).
633
+ * `this.send(x)` always means "x is the value at this port's return key",
634
+ * never "x is the whole outgoing message".
639
635
  */
640
- #returnPropertyKey() {
641
- const NodeClass = this.constructor;
642
- const declared = NodeClass.configSchema?.properties?.returnProperty;
643
- const configured = this.config.returnProperty;
636
+ #returnPropertyKey(port) {
637
+ const configured = this.config.outputReturnProperties?.[port];
644
638
  if (typeof configured === "string" && configured.trim()) {
645
639
  return configured.trim();
646
640
  }
647
- if (declared && typeof declared.default === "string" && declared.default) {
648
- return declared.default;
649
- }
650
641
  return "output";
651
642
  }
643
+ /**
644
+ * Resolves the context mode for a base-output port from the flow author's
645
+ * per-port config (`config.outputContextModes[port]`, written by the editor
646
+ * when the node declares `outputContextModes`), falling back to `"carry"`.
647
+ */
648
+ #resolveContextMode(port) {
649
+ return this.config.outputContextModes?.[port] ?? "carry";
650
+ }
652
651
  /**
653
652
  * Merges a sent value into the incoming message at the returnProperty key so
654
653
  * upstream message properties propagate. A fresh base is built per call so
655
654
  * multi-port sends never share an object.
656
655
  */
657
- #wrapOutgoing(value, mode = "nest") {
658
- const key = this.#returnPropertyKey();
656
+ #wrapOutgoing(value, mode, port) {
657
+ const key = this.#returnPropertyKey(port);
659
658
  const input = this.#currentInputMsg ?? {};
660
659
  if (mode === "reset") {
661
660
  return { [key]: value };
662
661
  }
663
- if (mode === "carry") {
664
- return { ...input, [key]: value };
662
+ if (mode === "trace") {
663
+ return { ...input, [key]: value, [INPUT_KEY]: input };
665
664
  }
666
- return { ...input, [key]: value, [INPUT_KEY]: input };
665
+ return { ...input, [key]: value };
667
666
  }
668
667
  // --- Built-in port management ---
669
668
  get baseOutputs() {
@@ -687,15 +686,17 @@ var IONode = class extends Node {
687
686
  * throw an error or call `this.error()` for the error port, and the complete
688
687
  * port is sent automatically on successful input processing.
689
688
  */
690
- sendToPort(port, msg, contextMode = "nest") {
689
+ sendToPort(port, msg) {
691
690
  if (port === "error" || port === "complete" || port === "status") {
692
691
  throw new NrgError(
693
692
  `sendToPort("${port}") is not allowed. Built-in ports are managed by the framework.`
694
693
  );
695
694
  }
695
+ const portIndex = typeof port === "number" ? port : this.#getNamedPortIndex(port);
696
+ const mode = this.#resolveContextMode(portIndex ?? 0);
696
697
  this.#sendToPort(
697
698
  port,
698
- msg == null ? msg : this.#wrapOutgoing(msg, contextMode)
699
+ msg == null ? msg : this.#wrapOutgoing(msg, mode, portIndex ?? 0)
699
700
  );
700
701
  }
701
702
  #sendToPort(port, msg) {
@@ -1163,18 +1164,37 @@ function TypedInput2(options) {
1163
1164
  [import_typebox2.Kind]: "TypedInput"
1164
1165
  };
1165
1166
  }
1166
- function ReturnProperty(options) {
1167
- return import_typebox2.Type.String({
1168
- description: "Message property that receives this node's result. The rest of the incoming message is propagated unchanged, and the prior message is kept under `input`.",
1169
- pattern: "^[A-Za-z_$][A-Za-z0-9_$]*$",
1170
- default: "output",
1171
- ...options
1172
- });
1167
+ function OutputReturnProperties(options) {
1168
+ return import_typebox2.Type.Record(
1169
+ import_typebox2.Type.Number(),
1170
+ import_typebox2.Type.String({ pattern: "^[A-Za-z_$][A-Za-z0-9_$]*$" }),
1171
+ {
1172
+ description: "Per-port return property, keyed by output port index. A missing entry falls back to `output`.",
1173
+ default: {},
1174
+ ...options
1175
+ }
1176
+ );
1177
+ }
1178
+ function OutputContextModes(options) {
1179
+ return import_typebox2.Type.Record(
1180
+ import_typebox2.Type.Number(),
1181
+ import_typebox2.Type.Union([
1182
+ import_typebox2.Type.Literal("carry"),
1183
+ import_typebox2.Type.Literal("trace"),
1184
+ import_typebox2.Type.Literal("reset")
1185
+ ]),
1186
+ {
1187
+ description: "Per-port context mode, keyed by output port index. A missing entry falls back to `carry`.",
1188
+ default: {},
1189
+ ...options
1190
+ }
1191
+ );
1173
1192
  }
1174
1193
  var SchemaType = Object.assign({}, import_typebox2.Type, {
1175
1194
  NodeRef,
1176
1195
  TypedInput: TypedInput2,
1177
- ReturnProperty
1196
+ OutputReturnProperties,
1197
+ OutputContextModes
1178
1198
  });
1179
1199
  function markNonValidatable(schema) {
1180
1200
  const type = schema.type;