@bonsae/nrg 0.5.2 → 0.5.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.
@@ -1594,7 +1594,9 @@ async function build2(clientBuildOptions, buildContext) {
1594
1594
  throw new BuildError("client", error);
1595
1595
  } finally {
1596
1596
  if (generatedEntry) {
1597
- fs10.unlinkSync(entryPath);
1597
+ if (fs10.existsSync(entryPath)) {
1598
+ fs10.unlinkSync(entryPath);
1599
+ }
1598
1600
  }
1599
1601
  }
1600
1602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bonsae/nrg",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -17,6 +17,10 @@
17
17
  "pnpm": ">=10.11.0"
18
18
  },
19
19
  "scripts": {
20
+ "test": "vitest run && vitest run --config vitest.e2e.config.ts",
21
+ "test:unit": "vitest run",
22
+ "test:unit:watch": "vitest",
23
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
20
24
  "build": "node build.mjs",
21
25
  "typecheck": "tsc -p src/core/server/tsconfig.json --noEmit && tsc -p src/core/client/tsconfig.json --noEmit",
22
26
  "lint": "eslint src/",
@@ -103,6 +107,7 @@
103
107
  "@types/node": "^22.15.18",
104
108
  "@typescript-eslint/eslint-plugin": "^8.32.1",
105
109
  "@typescript-eslint/parser": "^8.32.1",
110
+ "@vitest/coverage-v8": "^4.1.5",
106
111
  "eslint": "^9.27.0",
107
112
  "eslint-config-prettier": "^10.1.8",
108
113
  "eslint-plugin-vue": "^10.1.0",
@@ -113,6 +118,7 @@
113
118
  "semantic-release": "^24.2.4",
114
119
  "typescript-eslint": "^8.32.1",
115
120
  "vite": "^6.3.4",
121
+ "vitest": "^4.1.5",
116
122
  "vue": "^3.5.14"
117
123
  }
118
124
  }
@@ -162,7 +162,7 @@ export default defineComponent({
162
162
  error.instancePath,
163
163
  );
164
164
  if (
165
- error.parentSchema.format === "password" &&
165
+ error.parentSchema?.format === "password" &&
166
166
  errorValue === "__PWD__"
167
167
  ) {
168
168
  return acc;
@@ -197,7 +197,8 @@ interface FormField {
197
197
  | "select"
198
198
  | "typed"
199
199
  | "config"
200
- | "editor";
200
+ | "editor"
201
+ | "array-text";
201
202
  required: boolean;
202
203
  htmlType?: "text" | "number" | "password";
203
204
  options?: Array<{ value: string; label: string }>;
@@ -136,6 +136,12 @@ export default defineComponent({
136
136
  this.onChange();
137
137
  });
138
138
  },
139
+ beforeUnmount() {
140
+ if (this._observer) {
141
+ this._observer.disconnect();
142
+ this._observer = null;
143
+ }
144
+ },
139
145
  methods: {
140
146
  onChange() {
141
147
  const newValue = this.$input.typedInput("value");
@@ -203,7 +203,7 @@ function getNodeState(node: Node): NodeState {
203
203
  const state: NodeState = {
204
204
  credentials: {},
205
205
  };
206
- Object.keys(node._def.defaults).forEach((prop) => {
206
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
207
207
  state[prop] = node[prop];
208
208
  });
209
209
  if (node._def.credentials) {
@@ -300,8 +300,6 @@ function defineNode<T extends NodeDefinition>(options: T): T {
300
300
  async function registerType(definition: NodeDefinition): Promise<void> {
301
301
  const { type } = definition;
302
302
  try {
303
- console.log(`Registering node type: ${type}`);
304
-
305
303
  const nodeDefinition = {
306
304
  ...(_schemas[type] ?? {}),
307
305
  ...definition,
@@ -311,9 +309,6 @@ async function registerType(definition: NodeDefinition): Promise<void> {
311
309
  const defaults = nodeDefinition.defaults ?? undefined;
312
310
  const credentials = nodeDefinition.credentials ?? undefined;
313
311
 
314
- console.log("defaults", defaults);
315
- console.log("credentials", credentials);
316
-
317
312
  const appContainerId = `nrg-app-${type}`;
318
313
 
319
314
  $("<script>", {
@@ -323,21 +318,20 @@ async function registerType(definition: NodeDefinition): Promise<void> {
323
318
  }).appendTo("body");
324
319
 
325
320
  function oneditprepare(this: Node) {
326
- console.log("oneditprepare");
327
- console.log(this);
328
-
329
- const validationSchema = nodeDefinition.credentialsSchema?.properties
330
- ? {
331
- ...nodeDefinition.configSchema,
332
- properties: {
333
- ...nodeDefinition.configSchema.properties,
334
- credentials: {
335
- type: "object",
336
- properties: nodeDefinition.credentialsSchema.properties,
321
+ const validationSchema =
322
+ nodeDefinition.configSchema &&
323
+ nodeDefinition.credentialsSchema?.properties
324
+ ? {
325
+ ...nodeDefinition.configSchema,
326
+ properties: {
327
+ ...nodeDefinition.configSchema.properties,
328
+ credentials: {
329
+ type: "object",
330
+ properties: nodeDefinition.credentialsSchema.properties,
331
+ },
337
332
  },
338
- },
339
- }
340
- : nodeDefinition.configSchema;
333
+ }
334
+ : nodeDefinition.configSchema;
341
335
 
342
336
  const form =
343
337
  definition.form ??
@@ -360,7 +354,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
360
354
  const changed = !!Object.keys(changes)?.length;
361
355
  if (!changed) return false;
362
356
 
363
- Object.keys(node._def.defaults).forEach((prop) => {
357
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
364
358
  if (!node._def.defaults?.[prop]?.type) return;
365
359
  const oldConfigNodeId: string = node[prop] as string;
366
360
  const newConfigNodeId: string = node._newState![prop] as string;
@@ -376,7 +370,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
376
370
  }
377
371
  });
378
372
 
379
- Object.keys(node._def.defaults).forEach((prop) => {
373
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
380
374
  if (!node._def.defaults?.[prop]?.type) return;
381
375
  const newConfigNodeId: string = node._newState![prop] as string;
382
376
  if (!newConfigNodeId) return;
@@ -400,7 +394,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
400
394
  // overwriting the correctly-typed values already set by merge() above.
401
395
  const isConfigNode = definition.category === "config";
402
396
  if (isConfigNode) {
403
- Object.keys(node._def.defaults).forEach((prop) => {
397
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
404
398
  if (node._def.defaults[prop].type) return; // config-node refs handled separately
405
399
  const inputId = `node-config-input-${prop}`;
406
400
  let input = $(`#${inputId}`);
@@ -484,9 +478,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
484
478
  */
485
479
  async function registerTypes(nodes: NodeDefinition[]): Promise<void> {
486
480
  try {
487
- console.log("Registering node types in parallel");
488
481
  await Promise.all(nodes.map((definition) => registerType(definition)));
489
- console.log("All node types registered in parallel");
490
482
  } catch (error) {
491
483
  console.error("Error registering node types:", error);
492
484
  throw error;
@@ -0,0 +1,9 @@
1
+ class NrgError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+ this.name = "NrgError";
5
+ Object.setPrototypeOf(this, NrgError.prototype);
6
+ }
7
+ }
8
+
9
+ export { NrgError };
@@ -4,6 +4,7 @@ import { getCredentialsFromSchema } from "./utils";
4
4
  import { Node } from "./nodes";
5
5
  import { type RED } from "./types";
6
6
  import { initValidator } from "./validator";
7
+ import { NrgError } from "../errors";
7
8
 
8
9
  const MIME: Record<string, string> = {
9
10
  ".js": "application/javascript",
@@ -32,9 +33,7 @@ function serveNrgResources(RED: RED): void {
32
33
  httpAdmin.use(function (req: any, res: any, next: any) {
33
34
  const prefix = "/nrg/assets/";
34
35
  if (!(req.path as string).startsWith(prefix)) return next();
35
- let reqPath = (req.path as string)
36
- .slice(prefix.length)
37
- .replace(/\.\./g, "");
36
+ let reqPath = (req.path as string).slice(prefix.length);
38
37
  // Serve the Vue dev build in development for devtools support
39
38
  if (
40
39
  reqPath === "vue.esm-browser.prod.js" &&
@@ -46,7 +45,8 @@ function serveNrgResources(RED: RED): void {
46
45
  }
47
46
  }
48
47
  const filePath = path.resolve(clientDir, reqPath);
49
- if (!filePath.startsWith(clientDir)) return next();
48
+ const rel = path.relative(clientDir, filePath);
49
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return next();
50
50
  if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
51
51
  return next();
52
52
  const ext = path.extname(filePath);
@@ -70,24 +70,24 @@ async function registerType(RED: RED, NodeClass: AnyNodeClass) {
70
70
  const NC = NodeClass as any;
71
71
  RED.log.debug(`Registering Type: ${NC.type}`);
72
72
  if (!(NC.prototype instanceof Node)) {
73
- throw new Error(`${NC.name} must extend IONode or ConfigNode classes`);
73
+ throw new NrgError(`${NC.name} must extend IONode or ConfigNode classes`);
74
74
  }
75
75
 
76
76
  if (!NC.type) {
77
- throw new Error("type must be provided when registering the node");
77
+ throw new NrgError("type must be provided when registering the node");
78
78
  }
79
79
 
80
80
  if (NC.color && !/^#[0-9A-Fa-f]{6}$/.test(NC.color)) {
81
- throw new Error(
81
+ throw new NrgError(
82
82
  `Invalid color for ${NodeClass.type}: ${NC.color} color must be in hex format`,
83
83
  );
84
84
  }
85
85
 
86
86
  if (
87
87
  NC.inputs !== undefined &&
88
- (!Number.isInteger(NC.inputs) || (NC.inputs != 0 && NC.inputs != 1))
88
+ (!Number.isInteger(NC.inputs) || (NC.inputs !== 0 && NC.inputs !== 1))
89
89
  ) {
90
- throw new Error(
90
+ throw new NrgError(
91
91
  `Invalid number of inputs for ${NodeClass.type}: inputs must be 0 or 1`,
92
92
  );
93
93
  }
@@ -96,7 +96,7 @@ async function registerType(RED: RED, NodeClass: AnyNodeClass) {
96
96
  NC.outputs !== undefined &&
97
97
  (!Number.isInteger(NC.outputs) || NC.outputs < 0)
98
98
  ) {
99
- throw new Error(
99
+ throw new NrgError(
100
100
  `Invalid number of outputs for ${NodeClass.type}: outputs must be a positive integer`,
101
101
  );
102
102
  }
@@ -106,8 +106,14 @@ async function registerType(RED: RED, NodeClass: AnyNodeClass) {
106
106
  function (this: any, config: any) {
107
107
  RED.nodes.createNode(this, config);
108
108
  const node = new NC(RED, this, config, this.credentials);
109
- // NOTE: save node intance inside node-red's node so that the proxy can resolve it lazyly
110
- this._node = node;
109
+ // NOTE: save node instance inside node-red's node so that the proxy can resolve it lazily.
110
+ // Non-writable to prevent accidental clobbering by other code in the process.
111
+ Object.defineProperty(this, "_node", {
112
+ value: node,
113
+ writable: false,
114
+ configurable: false,
115
+ enumerable: false,
116
+ });
111
117
 
112
118
  // NOTE: created promise must be here because we only want it to start after the whole object creation chain has been completed: child -> IONode -> Node -> IONode -> child -> done
113
119
  const createdPromise = Promise.resolve(node.created?.()).catch(
@@ -214,6 +220,7 @@ function registerTypes(nodes: AnyNodeClass[]): NodeRedPackageFunction {
214
220
 
215
221
  export { registerType, registerTypes };
216
222
  export { Node, IONode, ConfigNode } from "./nodes";
223
+ export { NrgError } from "../errors";
217
224
  export type { RED } from "./types";
218
225
  export { SchemaType, defineSchema } from "./schemas";
219
226
  export type { Schema, Infer } from "./schemas/types";
@@ -115,7 +115,11 @@ abstract class Node<TConfig = any, TCredentials = any, TSettings = any> {
115
115
  );
116
116
  }
117
117
  }
118
- (this as any).config = setupConfigProxy(RED, config);
118
+ (this as any).config = setupConfigProxy(
119
+ RED,
120
+ config,
121
+ constructor.configSchema,
122
+ );
119
123
 
120
124
  if (constructor.credentialsSchema && credentials) {
121
125
  this.log("Validating credentials");
@@ -1,6 +1,7 @@
1
1
  import type { ResolveNodeRefs } from "../schemas/types";
2
2
  import type { RED, NodeRedContextStore } from "../types";
3
3
  import type { NodeContextStore } from "./types";
4
+ import { NrgError } from "../../errors";
4
5
 
5
6
  function setupContext(
6
7
  context: NodeRedContextStore,
@@ -31,50 +32,72 @@ function setupContext(
31
32
  function setupConfigProxy<T extends object>(
32
33
  RED: RED,
33
34
  config: T,
35
+ schema?: any,
34
36
  ): ResolveNodeRefs<T> {
35
- // NOTE: must not proxy its own id or parents ids
36
37
  const SKIP_PROPS = new Set(["id", "_id", "_users"]);
37
38
 
39
+ // Build a set of property names that are node references based on the schema.
40
+ // Only these properties will have their string values resolved via RED.nodes.getNode().
41
+ const nodeRefProps = new Set<string>();
42
+ if (schema?.properties) {
43
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
44
+ if ((propSchema as any)?.["x-nrg-node-type"]) {
45
+ nodeRefProps.add(key);
46
+ }
47
+ }
48
+ }
49
+
50
+ // Per-node-instance cache: original object/array -> proxy or mapped array.
51
+ // This preserves reference equality: config.server === config.server
52
+ const cache = new WeakMap<object, any>();
53
+
38
54
  const createProxy = <O extends object>(obj: O): any => {
39
- return new Proxy(obj, {
40
- get(target: any, prop: string | symbol): any {
41
- if (typeof prop === "symbol") {
42
- return target[prop];
43
- }
55
+ const cached = cache.get(obj);
56
+ if (cached) return cached;
44
57
 
45
- if (SKIP_PROPS.has(prop)) {
46
- return target[prop];
58
+ if (Array.isArray(obj)) {
59
+ // Map once, cache the result array so identity is stable across reads
60
+ const mapped = obj.map((item) => {
61
+ if (item && typeof item === "object") {
62
+ return createProxy(item);
47
63
  }
64
+ return item;
65
+ });
66
+ cache.set(obj, mapped);
67
+ return mapped;
68
+ }
48
69
 
49
- const value = target[prop];
70
+ const proxy = new Proxy(obj, {
71
+ get(target: any, prop: string | symbol): any {
72
+ if (typeof prop === "symbol") return target[prop];
73
+ if (SKIP_PROPS.has(prop)) return target[prop];
50
74
 
51
- if (typeof value === "string" && value.length > 0) {
52
- // NOTE: using the instance provided by the user instead of node-red's internal one
53
- const node = RED.nodes.getNode(value)?._node;
54
- return node || value;
55
- }
75
+ const value = target[prop];
56
76
 
57
- if (Array.isArray(value)) {
58
- return value.map((item) => {
59
- if (typeof item === "string") {
60
- // NOTE: using the instance provided by the user instead of node-red's internal one
61
- const node = RED.nodes.getNode(item)?._node;
62
- return node || item;
63
- }
64
- if (item && typeof item === "object") {
65
- return createProxy(item);
66
- }
67
- return item;
68
- });
77
+ // Only resolve strings as node references if the schema marks the property
78
+ if (
79
+ typeof value === "string" &&
80
+ value.length > 0 &&
81
+ nodeRefProps.has(prop)
82
+ ) {
83
+ return RED.nodes.getNode(value)?._node ?? value;
69
84
  }
70
85
 
71
86
  if (value && typeof value === "object") {
72
- return createProxy(value);
87
+ return createProxy(value); // hits the cache on repeat access
73
88
  }
74
89
 
75
90
  return value;
76
91
  },
92
+ set(_target: any, prop: string | symbol): boolean {
93
+ throw new NrgError(
94
+ `Cannot set property '${String(prop)}' on read-only node config`,
95
+ );
96
+ },
77
97
  });
98
+
99
+ cache.set(obj, proxy);
100
+ return proxy;
78
101
  };
79
102
 
80
103
  return createProxy(config) as ResolveNodeRefs<T>;
@@ -217,7 +217,9 @@ async function build(
217
217
  throw new BuildError("client", error as Error);
218
218
  } finally {
219
219
  if (generatedEntry) {
220
- fs.unlinkSync(entryPath);
220
+ if (fs.existsSync(entryPath)) {
221
+ fs.unlinkSync(entryPath);
222
+ }
221
223
  }
222
224
  }
223
225
  }