@bonsae/nrg 0.5.3 → 0.6.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.
@@ -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.3",
3
+ "version": "0.6.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",
@@ -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
  }
@@ -2,32 +2,22 @@
2
2
  <div
3
3
  v-if="features.hasInputSchema || features.hasOutputSchema"
4
4
  class="form-row"
5
- style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap"
5
+ style="display: flex; align-items: center; gap: 12px"
6
6
  >
7
- <template v-if="features.hasInputSchema">
8
- <input
9
- :id="`node-input-validateInput-${localNode.id}`"
10
- type="checkbox"
11
- :checked="localNode.validateInput"
12
- style="width: auto; margin: 0"
13
- @change="
14
- localNode.validateInput = ($event.target as HTMLInputElement).checked
15
- "
16
- />
17
- <NodeRedInputLabel label="Validate Input" style="width: auto" />
18
- </template>
19
- <template v-if="features.hasOutputSchema">
20
- <input
21
- :id="`node-input-validateOutput-${localNode.id}`"
22
- type="checkbox"
23
- :checked="localNode.validateOutput"
24
- style="width: auto; margin: 0"
25
- @change="
26
- localNode.validateOutput = ($event.target as HTMLInputElement).checked
27
- "
28
- />
29
- <NodeRedInputLabel label="Validate Output" style="width: auto" />
30
- </template>
7
+ <NodeRedToggle
8
+ v-if="features.hasInputSchema"
9
+ :model-value="localNode.validateInput"
10
+ label="Validate Input"
11
+ style="flex: 1"
12
+ @update:model-value="localNode.validateInput = $event"
13
+ />
14
+ <NodeRedToggle
15
+ v-if="features.hasOutputSchema"
16
+ :model-value="localNode.validateOutput"
17
+ label="Validate Output"
18
+ style="flex: 1"
19
+ @update:model-value="localNode.validateOutput = $event"
20
+ />
31
21
  </div>
32
22
  <div style="width: 100%; padding-bottom: 12px">
33
23
  <NodeRedNodeForm
@@ -162,7 +152,7 @@ export default defineComponent({
162
152
  error.instancePath,
163
153
  );
164
154
  if (
165
- error.parentSchema.format === "password" &&
155
+ error.parentSchema?.format === "password" &&
166
156
  errorValue === "__PWD__"
167
157
  ) {
168
158
  return acc;
@@ -12,12 +12,20 @@
12
12
  @update:value="node[field.key] = $event"
13
13
  />
14
14
 
15
+ <div v-else-if="field.inputType === 'boolean' && field.toggle">
16
+ <NodeRedToggle
17
+ :model-value="node[field.key]"
18
+ :label="field.label"
19
+ :icon="field.icon"
20
+ @update:model-value="node[field.key] = $event"
21
+ />
22
+ </div>
23
+
15
24
  <div v-else-if="field.inputType === 'boolean'">
16
25
  <NodeRedInputLabel
17
26
  :label="field.label"
18
27
  :icon="field.icon"
19
28
  :required="field.required"
20
- style="width: auto"
21
29
  />
22
30
  <input
23
31
  type="checkbox"
@@ -144,6 +152,7 @@
144
152
  import type { PropType } from "vue";
145
153
  import { defineComponent } from "vue";
146
154
  import NodeRedInputLabel from "./node-red-input-label.vue";
155
+ import NodeRedToggle from "./node-red-toggle.vue";
147
156
  import NodeRedInput from "./node-red-input.vue";
148
157
  import NodeRedSelectInput from "./node-red-select-input.vue";
149
158
  import NodeRedTypedInput from "./node-red-typed-input.vue";
@@ -169,6 +178,7 @@ interface NrgFormOptions {
169
178
  icon?: string;
170
179
  typedInputTypes?: string[];
171
180
  editorLanguage?: string;
181
+ toggle?: boolean;
172
182
  }
173
183
 
174
184
  interface FieldSchema {
@@ -197,7 +207,8 @@ interface FormField {
197
207
  | "select"
198
208
  | "typed"
199
209
  | "config"
200
- | "editor";
210
+ | "editor"
211
+ | "array-text";
201
212
  required: boolean;
202
213
  htmlType?: "text" | "number" | "password";
203
214
  options?: Array<{ value: string; label: string }>;
@@ -205,6 +216,7 @@ interface FormField {
205
216
  types?: string[];
206
217
  configType?: string;
207
218
  language?: string;
219
+ toggle?: boolean;
208
220
  }
209
221
 
210
222
  function formatLabel(key: string): string {
@@ -291,7 +303,14 @@ function buildField(
291
303
 
292
304
  switch (rawType) {
293
305
  case "boolean":
294
- return { key, label, icon, inputType: "boolean", required };
306
+ return {
307
+ key,
308
+ label,
309
+ icon,
310
+ inputType: "boolean",
311
+ required,
312
+ toggle: form.toggle,
313
+ };
295
314
 
296
315
  case "number":
297
316
  case "integer":
@@ -367,6 +386,7 @@ export default defineComponent({
367
386
  name: "NodeRedJsonSchemaForm",
368
387
  components: {
369
388
  NodeRedInputLabel,
389
+ NodeRedToggle,
370
390
  NodeRedInput,
371
391
  NodeRedSelectInput,
372
392
  NodeRedTypedInput,
@@ -0,0 +1,115 @@
1
+ <template>
2
+ <div class="nrg-toggle-wrapper">
3
+ <label class="nrg-toggle" :class="{ 'nrg-toggle--checked': modelValue }">
4
+ <input
5
+ type="checkbox"
6
+ :checked="modelValue"
7
+ class="nrg-toggle__input"
8
+ @change="
9
+ $emit(
10
+ 'update:modelValue',
11
+ ($event.target as HTMLInputElement).checked,
12
+ )
13
+ "
14
+ />
15
+ <span v-if="icon || label" class="nrg-toggle__label">
16
+ <i v-if="icon" :class="iconClass"></i>
17
+ {{ label }}
18
+ </span>
19
+ <span class="nrg-toggle__slider"></span>
20
+ </label>
21
+ </div>
22
+ </template>
23
+
24
+ <script lang="ts">
25
+ import { defineComponent } from "vue";
26
+
27
+ export default defineComponent({
28
+ name: "NodeRedToggle",
29
+ props: {
30
+ modelValue: {
31
+ type: Boolean,
32
+ default: false,
33
+ },
34
+ label: {
35
+ type: String,
36
+ default: "",
37
+ },
38
+ icon: {
39
+ type: String,
40
+ default: "",
41
+ },
42
+ },
43
+ emits: ["update:modelValue"],
44
+ computed: {
45
+ iconClass(): string {
46
+ if (!this.icon) return "";
47
+ const name = this.icon.startsWith("fa-") ? this.icon : `fa-${this.icon}`;
48
+ return `fa ${name}`;
49
+ },
50
+ },
51
+ });
52
+ </script>
53
+
54
+ <style scoped>
55
+ .nrg-toggle-wrapper {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ }
59
+
60
+ .nrg-toggle {
61
+ position: relative;
62
+ display: inline-flex;
63
+ align-items: center;
64
+ cursor: pointer;
65
+ gap: 8px;
66
+ user-select: none;
67
+ }
68
+
69
+ .nrg-toggle__input {
70
+ position: absolute;
71
+ opacity: 0;
72
+ width: 0;
73
+ height: 0;
74
+ }
75
+
76
+ .nrg-toggle__slider {
77
+ position: relative;
78
+ display: inline-block;
79
+ width: 36px;
80
+ min-width: 36px;
81
+ height: 20px;
82
+ background-color: var(--red-ui-secondary-border-color, #ccc);
83
+ border-radius: 10px;
84
+ transition: background-color 0.2s ease;
85
+ }
86
+
87
+ .nrg-toggle__slider::after {
88
+ content: "";
89
+ position: absolute;
90
+ top: 2px;
91
+ left: 2px;
92
+ width: 16px;
93
+ height: 16px;
94
+ background-color: white;
95
+ border-radius: 50%;
96
+ transition: transform 0.2s ease;
97
+ }
98
+
99
+ .nrg-toggle--checked .nrg-toggle__slider {
100
+ background-color: var(--red-ui-text-color-link, #0070d2);
101
+ }
102
+
103
+ .nrg-toggle--checked .nrg-toggle__slider::after {
104
+ transform: translateX(16px);
105
+ }
106
+
107
+ .nrg-toggle__label {
108
+ cursor: default;
109
+ white-space: nowrap;
110
+ }
111
+
112
+ .nrg-toggle__label i {
113
+ margin-right: 2px;
114
+ }
115
+ </style>
@@ -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");
@@ -9,6 +9,7 @@ import NodeRedConfigInput from "./components/node-red-config-input.vue";
9
9
  import NodeRedSelectInput from "./components/node-red-select-input.vue";
10
10
  import NodeRedEditorInput from "./components/node-red-editor-input.vue";
11
11
  import NodeRedInputLabel from "./components/node-red-input-label.vue";
12
+ import NodeRedToggle from "./components/node-red-toggle.vue";
12
13
  import NodeRedJsonSchemaForm from "./components/node-red-json-schema-form.vue";
13
14
 
14
15
  const _schemas: Record<string, any> = {};
@@ -165,6 +166,7 @@ function createNodeRedVueApp(
165
166
  });
166
167
 
167
168
  app.component("NodeRedInputLabel", NodeRedInputLabel);
169
+ app.component("NodeRedToggle", NodeRedToggle);
168
170
  app.component("NodeRedInput", NodeRedInput);
169
171
  app.component("NodeRedTypedInput", NodeRedTypedInput);
170
172
  app.component("NodeRedConfigInput", NodeRedConfigInput);
@@ -203,7 +205,7 @@ function getNodeState(node: Node): NodeState {
203
205
  const state: NodeState = {
204
206
  credentials: {},
205
207
  };
206
- Object.keys(node._def.defaults).forEach((prop) => {
208
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
207
209
  state[prop] = node[prop];
208
210
  });
209
211
  if (node._def.credentials) {
@@ -300,8 +302,6 @@ function defineNode<T extends NodeDefinition>(options: T): T {
300
302
  async function registerType(definition: NodeDefinition): Promise<void> {
301
303
  const { type } = definition;
302
304
  try {
303
- console.log(`Registering node type: ${type}`);
304
-
305
305
  const nodeDefinition = {
306
306
  ...(_schemas[type] ?? {}),
307
307
  ...definition,
@@ -311,9 +311,6 @@ async function registerType(definition: NodeDefinition): Promise<void> {
311
311
  const defaults = nodeDefinition.defaults ?? undefined;
312
312
  const credentials = nodeDefinition.credentials ?? undefined;
313
313
 
314
- console.log("defaults", defaults);
315
- console.log("credentials", credentials);
316
-
317
314
  const appContainerId = `nrg-app-${type}`;
318
315
 
319
316
  $("<script>", {
@@ -323,21 +320,20 @@ async function registerType(definition: NodeDefinition): Promise<void> {
323
320
  }).appendTo("body");
324
321
 
325
322
  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,
323
+ const validationSchema =
324
+ nodeDefinition.configSchema &&
325
+ nodeDefinition.credentialsSchema?.properties
326
+ ? {
327
+ ...nodeDefinition.configSchema,
328
+ properties: {
329
+ ...nodeDefinition.configSchema.properties,
330
+ credentials: {
331
+ type: "object",
332
+ properties: nodeDefinition.credentialsSchema.properties,
333
+ },
337
334
  },
338
- },
339
- }
340
- : nodeDefinition.configSchema;
335
+ }
336
+ : nodeDefinition.configSchema;
341
337
 
342
338
  const form =
343
339
  definition.form ??
@@ -360,7 +356,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
360
356
  const changed = !!Object.keys(changes)?.length;
361
357
  if (!changed) return false;
362
358
 
363
- Object.keys(node._def.defaults).forEach((prop) => {
359
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
364
360
  if (!node._def.defaults?.[prop]?.type) return;
365
361
  const oldConfigNodeId: string = node[prop] as string;
366
362
  const newConfigNodeId: string = node._newState![prop] as string;
@@ -376,7 +372,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
376
372
  }
377
373
  });
378
374
 
379
- Object.keys(node._def.defaults).forEach((prop) => {
375
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
380
376
  if (!node._def.defaults?.[prop]?.type) return;
381
377
  const newConfigNodeId: string = node._newState![prop] as string;
382
378
  if (!newConfigNodeId) return;
@@ -400,7 +396,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
400
396
  // overwriting the correctly-typed values already set by merge() above.
401
397
  const isConfigNode = definition.category === "config";
402
398
  if (isConfigNode) {
403
- Object.keys(node._def.defaults).forEach((prop) => {
399
+ Object.keys(node._def.defaults ?? {}).forEach((prop) => {
404
400
  if (node._def.defaults[prop].type) return; // config-node refs handled separately
405
401
  const inputId = `node-config-input-${prop}`;
406
402
  let input = $(`#${inputId}`);
@@ -484,9 +480,7 @@ async function registerType(definition: NodeDefinition): Promise<void> {
484
480
  */
485
481
  async function registerTypes(nodes: NodeDefinition[]): Promise<void> {
486
482
  try {
487
- console.log("Registering node types in parallel");
488
483
  await Promise.all(nodes.map((definition) => registerType(definition)));
489
- console.log("All node types registered in parallel");
490
484
  } catch (error) {
491
485
  console.error("Error registering node types:", error);
492
486
  throw error;
@@ -0,0 +1 @@
1
+ export { serveNrgResources } from "./serve-nrg-resources";
@@ -0,0 +1,54 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import type { RED } from "../types";
4
+
5
+ const MIME: Record<string, string> = {
6
+ ".js": "application/javascript",
7
+ ".mjs": "application/javascript",
8
+ ".css": "text/css",
9
+ ".json": "application/json",
10
+ ".map": "application/json",
11
+ ".png": "image/png",
12
+ ".svg": "image/svg+xml",
13
+ };
14
+
15
+ let _registered = false;
16
+
17
+ function serveNrgResources(RED: RED): void {
18
+ if (_registered) return;
19
+ _registered = true;
20
+
21
+ const clientDir = path.resolve(__dirname, "./resources");
22
+ if (!fs.existsSync(clientDir)) return;
23
+
24
+ const httpAdmin = (RED as any).httpAdmin;
25
+ if (!httpAdmin) return;
26
+
27
+ // /nrg/assets/ is not handled by Node-RED's editorApp, so our handler
28
+ // appended via use() is reached normally without any stack manipulation.
29
+ httpAdmin.use(function (req: any, res: any, next: any) {
30
+ const prefix = "/nrg/assets/";
31
+ if (!(req.path as string).startsWith(prefix)) return next();
32
+ let reqPath = (req.path as string).slice(prefix.length);
33
+ // Serve the Vue dev build in development for devtools support
34
+ if (
35
+ reqPath === "vue.esm-browser.prod.js" &&
36
+ process.env.NODE_ENV !== "production"
37
+ ) {
38
+ const devPath = path.resolve(clientDir, "vue.esm-browser.js");
39
+ if (fs.existsSync(devPath)) {
40
+ reqPath = "vue.esm-browser.js";
41
+ }
42
+ }
43
+ const filePath = path.resolve(clientDir, reqPath);
44
+ const rel = path.relative(clientDir, filePath);
45
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return next();
46
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
47
+ return next();
48
+ const ext = path.extname(filePath);
49
+ res.setHeader("Content-Type", MIME[ext] ?? "application/octet-stream");
50
+ fs.createReadStream(filePath).pipe(res);
51
+ });
52
+ }
53
+
54
+ export { serveNrgResources };
@@ -1,60 +1,10 @@
1
- import path from "path";
2
- import fs from "fs";
3
1
  import { getCredentialsFromSchema } from "./utils";
4
2
  import { Node } from "./nodes";
5
3
  import { type RED } from "./types";
6
4
  import { initValidator } from "./validator";
5
+ import { serveNrgResources } from "./api";
7
6
  import { NrgError } from "../errors";
8
7
 
9
- const MIME: Record<string, string> = {
10
- ".js": "application/javascript",
11
- ".mjs": "application/javascript",
12
- ".css": "text/css",
13
- ".json": "application/json",
14
- ".map": "application/json",
15
- ".png": "image/png",
16
- ".svg": "image/svg+xml",
17
- };
18
-
19
- let _nrgResourcesRegistered = false;
20
-
21
- function serveNrgResources(RED: RED): void {
22
- if (_nrgResourcesRegistered) return;
23
- _nrgResourcesRegistered = true;
24
-
25
- const clientDir = path.resolve(__dirname, "./resources");
26
- if (!fs.existsSync(clientDir)) return;
27
-
28
- const httpAdmin = (RED as any).httpAdmin;
29
- if (!httpAdmin) return;
30
-
31
- // /nrg/assets/ is not handled by Node-RED's editorApp, so our handler
32
- // appended via use() is reached normally without any stack manipulation.
33
- httpAdmin.use(function (req: any, res: any, next: any) {
34
- const prefix = "/nrg/assets/";
35
- if (!(req.path as string).startsWith(prefix)) return next();
36
- let reqPath = (req.path as string).slice(prefix.length);
37
- // Serve the Vue dev build in development for devtools support
38
- if (
39
- reqPath === "vue.esm-browser.prod.js" &&
40
- process.env.NODE_ENV !== "production"
41
- ) {
42
- const devPath = path.resolve(clientDir, "vue.esm-browser.js");
43
- if (fs.existsSync(devPath)) {
44
- reqPath = "vue.esm-browser.js";
45
- }
46
- }
47
- const filePath = path.resolve(clientDir, reqPath);
48
- const rel = path.relative(clientDir, filePath);
49
- if (rel.startsWith("..") || path.isAbsolute(rel)) return next();
50
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
51
- return next();
52
- const ext = path.extname(filePath);
53
- res.setHeader("Content-Type", MIME[ext] ?? "application/octet-stream");
54
- fs.createReadStream(filePath).pipe(res);
55
- });
56
- }
57
-
58
8
  type AnyNodeClass = (abstract new (...args: any[]) => Node) &
59
9
  Partial<typeof Node>;
60
10
 
@@ -187,7 +137,7 @@ async function registerType(RED: RED, NodeClass: AnyNodeClass) {
187
137
  RED.log.debug(`Type registered: ${NC.type}`);
188
138
  }
189
139
 
190
- type NodeRedPackageFunction = ((RED: RED) => Promise<void>) & {
140
+ type RegistrationFunction = ((RED: RED) => Promise<void>) & {
191
141
  nodes: AnyNodeClass[];
192
142
  };
193
143
 
@@ -199,7 +149,7 @@ type NodeRedPackageFunction = ((RED: RED) => Promise<void>) & {
199
149
  *
200
150
  * @param nodes - Array of node classes to register
201
151
  */
202
- function registerTypes(nodes: AnyNodeClass[]): NodeRedPackageFunction {
152
+ function registerTypes(nodes: AnyNodeClass[]): RegistrationFunction {
203
153
  const fn = async function (RED: RED) {
204
154
  initValidator(RED);
205
155
  serveNrgResources(RED);
@@ -214,12 +164,26 @@ function registerTypes(nodes: AnyNodeClass[]): NodeRedPackageFunction {
214
164
  throw error;
215
165
  }
216
166
  };
217
- (fn as NodeRedPackageFunction).nodes = nodes;
218
- return fn as NodeRedPackageFunction;
167
+ (fn as RegistrationFunction).nodes = nodes;
168
+ return fn as RegistrationFunction;
169
+ }
170
+
171
+ interface ModuleDefinition {
172
+ nodes: AnyNodeClass[];
173
+ }
174
+
175
+ function defineModule(definition: ModuleDefinition): ModuleDefinition {
176
+ return definition;
219
177
  }
220
178
 
221
- export { registerType, registerTypes };
222
- export { Node, IONode, ConfigNode } from "./nodes";
179
+ export { registerType, registerTypes, defineModule };
180
+ export {
181
+ Node,
182
+ IONode,
183
+ ConfigNode,
184
+ defineIONode,
185
+ defineConfigNode,
186
+ } from "./nodes";
223
187
  export { NrgError } from "../errors";
224
188
  export type { RED } from "./types";
225
189
  export { SchemaType, defineSchema } from "./schemas";