@bonsae/nrg 0.18.4 → 0.19.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 (37) hide show
  1. package/README.md +38 -45
  2. package/package.json +1 -1
  3. package/server/index.cjs +96 -10
  4. package/server/resources/nrg-client.js +2269 -2233
  5. package/test/client/component/config.js +11 -0
  6. package/test/client/component/index.js +218 -235
  7. package/test/client/component/nrg.css +1 -0
  8. package/test/client/component/setup.js +1549 -140
  9. package/test/client/e2e/index.js +721 -367
  10. package/test/client/unit/index.js +204 -16
  11. package/test/client/unit/setup.js +209 -19
  12. package/test/server/unit/index.js +25 -4
  13. package/tsconfig/core/client.json +1 -1
  14. package/tsconfig/test/client/component.json +1 -1
  15. package/types/client.d.ts +98 -18
  16. package/types/server.d.ts +50 -12
  17. package/types/shims/brands.d.ts +32 -0
  18. package/types/shims/{form → client/form}/components/node-red-editor-input.vue.d.ts +1 -1
  19. package/types/shims/{form → client/form}/components/node-red-json-schema-form.vue.d.ts +21 -2
  20. package/types/shims/{form → client/form}/components/node-red-select-input.vue.d.ts +1 -0
  21. package/types/shims/{form → client/form}/components/node-red-typed-input.vue.d.ts +1 -0
  22. package/types/shims/client/types.d.ts +206 -0
  23. package/types/shims/components.d.ts +8 -8
  24. package/types/shims/constants.d.ts +4 -0
  25. package/types/shims/schema-options.d.ts +23 -10
  26. package/types/shims/typebox.d.ts +2 -2
  27. package/types/test-client-component.d.ts +170 -55
  28. package/types/test-client-e2e.d.ts +50 -0
  29. package/types/test-client-unit.d.ts +86 -22
  30. package/types/test-server-unit.d.ts +3 -1
  31. package/types/vite.d.ts +38 -9
  32. package/vite/index.js +733 -528
  33. /package/types/shims/{form → client/form}/components/node-red-config-input.vue.d.ts +0 -0
  34. /package/types/shims/{form → client/form}/components/node-red-input-label.vue.d.ts +0 -0
  35. /package/types/shims/{form → client/form}/components/node-red-input.vue.d.ts +0 -0
  36. /package/types/shims/{form → client/form}/components/node-red-toggle.vue.d.ts +0 -0
  37. /package/types/shims/{globals.d.ts → client/globals.d.ts} +0 -0
package/README.md CHANGED
@@ -13,41 +13,25 @@
13
13
 
14
14
  Build Node-RED nodes with Vue 3, TypeScript, JSON Schema validations, Vite and Vitest.
15
15
 
16
- ## Package Exports
17
-
18
- | Export | Description |
19
- | --- | --- |
20
- | `@bonsae/nrg` | Root entry — `defineRuntimeSettings` |
21
- | `@bonsae/nrg/server` | Server node classes, schema utilities, validation (`IONode`, `ConfigNode`, `defineIONode`, `defineConfigNode`, `defineModule`, `SchemaType`, `defineSchema`, `Infer`) |
22
- | `@bonsae/nrg/client` | Client-side registration (`registerTypes`, `defineNode`, `useFormNode`, `Infer`) |
23
- | `@bonsae/nrg/vite` | Vite plugin for building and developing Node-RED packages |
24
- | `@bonsae/nrg/test/server/unit` | Server unit test helpers (`createNode`, `createRED`, `MockRED`) |
25
- | `@bonsae/nrg/test/server/unit/config` | Server unit test default vitest config (`defaultConfig`) |
26
- | `@bonsae/nrg/test/client/unit` | Client unit test mocks (`createRED`, `createJQuery`, `useFormNode`) |
27
- | `@bonsae/nrg/test/client/unit/config` | Client unit test default vitest config (`defaultConfig`) |
28
- | `@bonsae/nrg/test/client/unit/setup` | Setup file that installs `RED` and `$` mocks on `window` |
29
- | `@bonsae/nrg/test/client/component` | Client component test helpers (`createNode`, `createRED`, `createJQuery`, `useFormNode`) |
30
- | `@bonsae/nrg/test/client/component/config` | Client component test default vitest config (`defaultConfig`) |
31
- | `@bonsae/nrg/test/client/component/setup` | Setup file that installs `RED` and `$` mocks on `window` with Vue i18n |
32
- | `@bonsae/nrg/test/client/e2e` | Browser E2E test helpers (`NodeRedEditor`, `NodeRedField`, `setup`, `teardown`) |
33
- | `@bonsae/nrg/tsconfig/base.json` | Base TypeScript configuration |
34
- | `@bonsae/nrg/tsconfig/core/server.json` | Core server source tsconfig |
35
- | `@bonsae/nrg/tsconfig/core/client.json` | Core client source tsconfig |
36
- | `@bonsae/nrg/tsconfig/test/server/unit.json` | Server unit test tsconfig |
37
- | `@bonsae/nrg/tsconfig/test/client/unit.json` | Client unit test tsconfig |
38
- | `@bonsae/nrg/tsconfig/test/client/component.json` | Client component test tsconfig |
39
- | `@bonsae/nrg/tsconfig/test/client/e2e.json` | Client E2E test tsconfig |
40
-
41
16
  ## Quick Start
42
17
 
43
18
  ```bash
44
- # In your Node-RED package project
45
19
  pnpm add @bonsae/nrg
46
- pnpm add -D vite vue
20
+ pnpm add -D vite vue node-red
47
21
  ```
48
22
 
49
23
  > `vite` and `vue` are dev dependencies because they are only needed at build time. Vue is included as a dependency of nrg and served automatically at runtime.
50
24
 
25
+ ### Node-RED Resolution
26
+
27
+ The vite plugin needs a Node-RED instance for the dev server. It resolves it in this order:
28
+
29
+ 1. **`runtime.version`** — if specified in the plugin config, downloads that exact version via `npx` (overrides any locally installed version)
30
+ 2. **Local `node_modules`** — if `node-red` is installed as a dependency, it is used directly (fastest)
31
+ 3. **Fallback** — downloads the latest `node-red` via `npx`
32
+
33
+ Installing `node-red` as a dev dependency is recommended for fast, reliable dev server startup across all platforms (especially Windows). If you need a specific version (e.g., a beta), set `runtime.version` in the plugin config instead.
34
+
51
35
  **vite.config.ts**
52
36
 
53
37
  ```typescript
@@ -153,13 +137,13 @@ pnpm add -D vitest
153
137
 
154
138
  Optional peer dependencies:
155
139
 
156
- | Package | When to install |
157
- | --- | --- |
158
- | `@vitest/browser-playwright` | Component tests (Playwright browser provider for Vitest) |
159
- | `playwright` | Component tests or E2E tests (direct `import` in test files) |
160
- | `vitest-browser-vue` | Component tests (`render` helper for Vue components) |
161
- | `@vitest/coverage-v8` | Coverage with `--coverage` (V8 provider) |
162
- | `@vitest/coverage-istanbul` | Coverage with `--coverage` (Istanbul provider) |
140
+ | Package | When to install |
141
+ | ---------------------------- | ------------------------------------------------------------ |
142
+ | `@vitest/browser-playwright` | Component tests (Playwright browser provider for Vitest) |
143
+ | `playwright` | Component tests or E2E tests (direct `import` in test files) |
144
+ | `vitest-browser-vue` | Component tests (`render` helper for Vue components) |
145
+ | `@vitest/coverage-v8` | Coverage with `--coverage` (V8 provider) |
146
+ | `@vitest/coverage-istanbul` | Coverage with `--coverage` (Istanbul provider) |
163
147
 
164
148
  - `@bonsae/nrg/test/server/unit` — server-side unit tests
165
149
  - `@bonsae/nrg/test/client/unit` — client-side unit tests (TypeScript logic)
@@ -195,11 +179,14 @@ Test client-side TypeScript logic (validation, utilities) with mocked `RED` and
195
179
  import { defineConfig, mergeConfig } from "vitest/config";
196
180
  import { defaultConfig } from "@bonsae/nrg/test/client/unit/config";
197
181
 
198
- export default mergeConfig(defaultConfig, defineConfig({
199
- test: {
200
- include: ["tests/client/unit/**/*.test.ts"],
201
- },
202
- }));
182
+ export default mergeConfig(
183
+ defaultConfig,
184
+ defineConfig({
185
+ test: {
186
+ include: ["tests/client/unit/**/*.test.ts"],
187
+ },
188
+ }),
189
+ );
203
190
  ```
204
191
 
205
192
  ```typescript
@@ -223,11 +210,14 @@ Test your Vue editor components with mocked Node-RED globals. Components that us
223
210
  import { defineConfig, mergeConfig } from "vitest/config";
224
211
  import { defaultConfig } from "@bonsae/nrg/test/client/component/config";
225
212
 
226
- export default mergeConfig(defaultConfig, defineConfig({
227
- test: {
228
- include: ["tests/client/component/**/*.test.ts"],
229
- },
230
- }));
213
+ export default mergeConfig(
214
+ defaultConfig,
215
+ defineConfig({
216
+ test: {
217
+ include: ["tests/client/component/**/*.test.ts"],
218
+ },
219
+ }),
220
+ );
231
221
  ```
232
222
 
233
223
  ```typescript
@@ -239,7 +229,10 @@ import MyForm from "../src/client/components/my-form.vue";
239
229
 
240
230
  describe("MyForm", () => {
241
231
  test("renders fields from injected node", async () => {
242
- const { provide } = createNode({ name: "test", url: "https://example.com" });
232
+ const { provide } = createNode({
233
+ name: "test",
234
+ url: "https://example.com",
235
+ });
243
236
  const screen = render(MyForm, {
244
237
  global: { provide },
245
238
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bonsae/nrg",
3
- "version": "0.18.4",
3
+ "version": "0.19.0",
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
@@ -280,6 +280,8 @@ var Node = class _Node {
280
280
  throw error;
281
281
  }
282
282
  );
283
+ createdPromise.catch(() => {
284
+ });
283
285
  node[WIRE_HANDLERS](this, createdPromise);
284
286
  },
285
287
  {
@@ -288,7 +290,14 @@ var Node = class _Node {
288
290
  }
289
291
  );
290
292
  NodeClass.validateSettings(RED);
291
- await Promise.resolve(NodeClass.registered?.(RED));
293
+ try {
294
+ await Promise.resolve(NodeClass.registered?.(RED));
295
+ } catch (error) {
296
+ const message = error instanceof Error ? error.message : String(error);
297
+ RED.log.error(
298
+ `Error during registered hook for ${NodeClass.type}: ${message}`
299
+ );
300
+ }
292
301
  }
293
302
  RED;
294
303
  node;
@@ -428,10 +437,14 @@ var Node = class _Node {
428
437
  };
429
438
 
430
439
  // src/core/server/nodes/io-node.ts
440
+ var RETURN_PROPERTY_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
441
+ var INPUT_KEY = "input";
431
442
  var IONode = class extends Node {
432
443
  static align;
433
444
  static color;
434
445
  static inputSchema;
446
+ // outputsSchema accepts any schema shape: with returnProperty the raw sent
447
+ // value is validated, and results are frequently non-objects.
435
448
  static outputsSchema;
436
449
  static validateInput = false;
437
450
  static validateOutput = false;
@@ -459,6 +472,12 @@ var IONode = class extends Node {
459
472
  return keys.length;
460
473
  }
461
474
  #send;
475
+ /**
476
+ * Most recent input message — the spread base for returnProperty wrapping. Not
477
+ * cleared after input() so late async sends merge with the last received
478
+ * message.
479
+ */
480
+ #currentInputMsg;
462
481
  context;
463
482
  constructor(RED, node, config, credentials) {
464
483
  super(RED, node, config, credentials);
@@ -471,6 +490,12 @@ var IONode = class extends Node {
471
490
  fn.flow = setupContext(context.flow);
472
491
  fn.global = setupContext(context.global);
473
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
+ );
498
+ }
474
499
  }
475
500
  [WIRE_HANDLERS](nodeRedNode, createdPromise) {
476
501
  super[WIRE_HANDLERS](nodeRedNode, createdPromise);
@@ -486,12 +511,14 @@ var IONode = class extends Node {
486
511
  }
487
512
  try {
488
513
  nodeRedNode.log("Calling input");
514
+ this.#currentInputMsg = msg;
489
515
  await Promise.resolve(this.#input(msg, send));
490
516
  this.#sendToPort("complete", {
491
517
  ...msg,
492
518
  complete: {
493
519
  source: this.#nodeSource()
494
- }
520
+ },
521
+ [INPUT_KEY]: msg
495
522
  });
496
523
  done();
497
524
  nodeRedNode.log("Input processed");
@@ -502,7 +529,8 @@ var IONode = class extends Node {
502
529
  error: {
503
530
  message: errorMsg,
504
531
  source: this.#nodeSource()
505
- }
532
+ },
533
+ [INPUT_KEY]: msg
506
534
  });
507
535
  if (error instanceof Error) {
508
536
  nodeRedNode.error(
@@ -541,8 +569,9 @@ var IONode = class extends Node {
541
569
  this.#send = void 0;
542
570
  }
543
571
  }
544
- send(msg) {
572
+ send(msg, contextMode = "nest") {
545
573
  const NodeClass = this.constructor;
574
+ const sendsValue = this.baseOutputs <= 1;
546
575
  const shouldValidateOutput = this.config.validateOutput ?? NodeClass.validateOutput;
547
576
  if (shouldValidateOutput && NodeClass.outputsSchema) {
548
577
  this.log("Validating output");
@@ -557,7 +586,7 @@ var IONode = class extends Node {
557
586
  });
558
587
  }
559
588
  } else if (isSchemaLike(rawSchema)) {
560
- if (Array.isArray(msg)) {
589
+ if (Array.isArray(msg) && !sendsValue) {
561
590
  const msgs = msg;
562
591
  for (let i = 0; i < msgs.length; i++) {
563
592
  if (msgs[i] == null) continue;
@@ -585,13 +614,57 @@ var IONode = class extends Node {
585
614
  }
586
615
  this.log("Output is valid");
587
616
  }
588
- const out = Array.isArray(msg) ? msg.slice(0, this.baseOutputs) : msg;
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);
589
621
  if (this.#send) {
590
622
  this.#send(out);
591
623
  } else {
592
624
  this.node.send(out);
593
625
  }
594
626
  }
627
+ /**
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).
631
+ */
632
+ /**
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".
639
+ */
640
+ #returnPropertyKey() {
641
+ const NodeClass = this.constructor;
642
+ const declared = NodeClass.configSchema?.properties?.returnProperty;
643
+ const configured = this.config.returnProperty;
644
+ if (typeof configured === "string" && configured.trim()) {
645
+ return configured.trim();
646
+ }
647
+ if (declared && typeof declared.default === "string" && declared.default) {
648
+ return declared.default;
649
+ }
650
+ return "output";
651
+ }
652
+ /**
653
+ * Merges a sent value into the incoming message at the returnProperty key so
654
+ * upstream message properties propagate. A fresh base is built per call so
655
+ * multi-port sends never share an object.
656
+ */
657
+ #wrapOutgoing(value, mode = "nest") {
658
+ const key = this.#returnPropertyKey();
659
+ const input = this.#currentInputMsg ?? {};
660
+ if (mode === "reset") {
661
+ return { [key]: value };
662
+ }
663
+ if (mode === "carry") {
664
+ return { ...input, [key]: value };
665
+ }
666
+ return { ...input, [key]: value, [INPUT_KEY]: input };
667
+ }
595
668
  // --- Built-in port management ---
596
669
  get baseOutputs() {
597
670
  return this.constructor.outputs ?? 0;
@@ -614,13 +687,16 @@ var IONode = class extends Node {
614
687
  * throw an error or call `this.error()` for the error port, and the complete
615
688
  * port is sent automatically on successful input processing.
616
689
  */
617
- sendToPort(port, msg) {
690
+ sendToPort(port, msg, contextMode = "nest") {
618
691
  if (port === "error" || port === "complete" || port === "status") {
619
692
  throw new NrgError(
620
693
  `sendToPort("${port}") is not allowed. Built-in ports are managed by the framework.`
621
694
  );
622
695
  }
623
- this.#sendToPort(port, msg);
696
+ this.#sendToPort(
697
+ port,
698
+ msg == null ? msg : this.#wrapOutgoing(msg, contextMode)
699
+ );
624
700
  }
625
701
  #sendToPort(port, msg) {
626
702
  let portIndex;
@@ -678,7 +754,8 @@ var IONode = class extends Node {
678
754
  error: {
679
755
  message,
680
756
  source: this.#nodeSource()
681
- }
757
+ },
758
+ [INPUT_KEY]: msg
682
759
  });
683
760
  }
684
761
  }
@@ -1086,9 +1163,18 @@ function TypedInput2(options) {
1086
1163
  [import_typebox2.Kind]: "TypedInput"
1087
1164
  };
1088
1165
  }
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
+ });
1173
+ }
1089
1174
  var SchemaType = Object.assign({}, import_typebox2.Type, {
1090
1175
  NodeRef,
1091
- TypedInput: TypedInput2
1176
+ TypedInput: TypedInput2,
1177
+ ReturnProperty
1092
1178
  });
1093
1179
  function markNonValidatable(schema) {
1094
1180
  const type = schema.type;