What do you guys think about writting nodes using json schemas?

The framework I'm creating uses json schemas (draft v7) as the source of truth for configs (defaults), credentials, input and outputs messages. This is the folder structure of a single node.

This is an example of a Node that uses json schemas for configs, credentials, input and output messages

This an example of Config Node that use a schema for Config props only

In this framework you can use TypeBox to write your schemas with typechecking or just write an object that complies with json schema draft v7. The only additional keyword I had to add is "nodeType", which allows developers to define the node type of a config node.

Intelisense is based on the types you create from your schemas



Validations run in the server using the same schemas

The client side of your node is now reduced to a simple Vue component

And the registration is much simpler than ever before. You no longer have to redefine defaults or credentials. The source of thruth is also your schemas

If you want to use other node-red node props you can still do it

While writting your node forms you can use the same built in node-red inputs you are used to, but with a much simpler api.

Typed Input

Config Input

Select and MultiSelect Input

Editor Input

To avoid breaking the editor all these inputs are being mounted in the dom using node-red apis (jquery widgets)

sourcemaps out of the box in dev builds

vue devtools

you can choose to disable the save button on error



2 Likes

I think I have finaly reached a final design for the framework. You can check it out in the following link

The following is an example of an IONode. IONodes are the ones we can drag and drop in the canvas.

src/nodes/your-node/server/index.ts

import { Static } from "@sinclair/typebox";
import {
  CloseDoneFunction,
  InputDoneFunction,
  IONode,
  IONodeValidations,
  SendFunction,
} from "../../../core/server/nodes";
import RemoteServerConfigNode from "../../remote-server/server";
import {
  ConfigsSchema,
  CredentialsSchema,
  InputMessageSchema,
  OutputMessageSchema,
} from "../schemas";

export type YourNodeConfigs = Static<typeof ConfigsSchema>;
export type YourNodeCredentials = Static<typeof CredentialsSchema>;
export type YourNodeInputMessage = Static<typeof InputMessageSchema>;
export type YourNodeOutputMessage = Static<typeof OutputMessageSchema>;

export default class YourNode extends IONode<
  YourNodeConfigs,
  YourNodeCredentials,
  YourNodeInputMessage,
  YourNodeOutputMessage
> {
  static override validations: IONodeValidations = {
    configs: ConfigsSchema,
    credentials: CredentialsSchema,
    input: InputMessageSchema,
    outputs: OutputMessageSchema,
  };

  static override async init() {
    console.log("testing your node init");
  }

  async onInput(
    msg: {
      payload?: string | undefined;
      topic?: string | undefined;
      _msgid?: string | undefined;
    } & { myVariable?: string | undefined },
    send: SendFunction<
      {
        payload?: string | undefined;
        topic?: string | undefined;
        _msgid?: string | undefined;
      } & { originalType: "string" | "number"; processedTime: number }
    >,
    done: InputDoneFunction
  ): Promise<void> {
    console.log(this);
    console.log(msg);

    const server = IONode.getNode<RemoteServerConfigNode>(
      this.configs.remoteServer
    );

    console.log(server?.users);
  }

  async onClose(removed: boolean, done: CloseDoneFunction): Promise<void> {
    console.log("removing node");
    done();
  }
}

src/nodes/your-node/client/index.ts

import { defineNode } from "../../../core/client";
import component from "./Form.vue";

export default defineNode({
  category: "function",
  color: "#FFFFFF",
  inputs: 1,
  outputs: 1,
  icon: "vue.png",
  form: {
    component,
    disableSaveButtonOnError: true,
  }
});

src/nodes/your-node/client/Form.vue

<template>
  <div>
    <div class="form-row">
      <label><i class="fa fa-tag"></i> Username</label>
      <NodeRedInput
        v-model:value="node.credentials.username"
        :error="errors['node.credentials.username']"
      />
    </div>

    <div class="form-row">
      <label><i class="fa fa-tag"></i> Password</label>
      <NodeRedInput
        v-model:value="node.credentials.password"
        type="password"
        :error="errors['node.credentials.password']"
      />
    </div>

    <div class="form-row">
      <label><i class="fa fa-tag"></i> Password 2</label>
      <NodeRedInput
        v-model:value="node.credentials.password2"
        type="password"
        :error="errors['node.credentials.password2']"
      />
    </div>

    <div class="form-row">
      <label>Typed Input</label>
      <NodeRedTypedInput
        v-model:value="node.myProperty"
        :types="types"
        :error="errors['node.myProperty']"
      />
    </div>

    <div class="form-row">
      <label>Typed Input 2</label>
      <NodeRedTypedInput
        v-model:value="node.myProperty2"
        :error="errors['node.myProperty2']"
      />
    </div>

    <div class="form-row">
      <label>Config Input</label>
      <NodeRedConfigInput
        v-model:value="node.remoteServer"
        type="remote-server"
        :error="errors['node.remoteServer']"
      />
    </div>

    <div class="form-row">
      <label>Config Input</label>
      <NodeRedConfigInput
        v-model:value="node.anotherRemoteServer"
        type="remote-server"
        :error="errors['node.anotherRemoteServer']"
      />
    </div>

    <div class="form-row">
      <label>Select Input</label>
      <NodeRedSelectInput
        v-model:value="node.country"
        :options="countries"
        :error="errors['node.country']"
      />
    </div>

    <div class="form-row">
      <label>MultiSelect Input</label>
      <NodeRedSelectInput
        v-model:value="node.fruit"
        :options="fruits"
        multiple
        :error="errors['node.fruit']"
      />
    </div>

    <div class="form-row">
      <label>Select Input</label>
      <NodeRedSelectInput
        v-model:value="node.number"
        :options="numbers"
        :error="errors['node.number']"
      />
    </div>

    <div class="form-row">
      <label>Select Input</label>
      <NodeRedSelectInput
        v-model:value="node.object"
        :options="objects"
        multiple
        :error="errors['node.object']"
      />
    </div>

    <div class="form-row">
      <label>Select Input</label>
      <NodeRedSelectInput
        v-model:value="node.array"
        :options="arrays"
        :error="errors['node.array']"
      />
    </div>

    <div class="form-row">
      <label>Editor with default height 200px and JSON</label>
      <NodeRedEditorInput
        v-model:value="node.jsontest"
        :error="errors['node.jsontest']"
      />
    </div>

    <div class="form-row">
      <label>Editor with custom height and CSS</label>
      <NodeRedEditorInput
        v-model:value="node.csstest"
        language="css"
        style="height: 100px"
        :error="errors['node.csstest']"
      />
    </div>

    <!-- NOTE: this is loaded from the resources/{pkg.name} folder -->
    <img src="/vue.png" />

    <!-- NOTE: this is added to the bundled .js -->
    <img :src="logo" alt="Logo" />
  </div>
</template>

<script>
import logo from "../../../assets/vue.png";
export default {
  name: "NodeRedNodeForm",
  props: {
    node: {
      type: Object,
      required: true,
    },
    errors: {
      type: Object,
      default: () => ({}),
    },
  },
  data() {
    return {
      logo,
      types: ["str", "msg", "node"],
      countries: [
        { value: "usa", label: "usa" },
        { value: "argentina", label: "argentina" },
        { value: "brasil", label: "brasil" },
      ],
      fruits: [
        { value: "apple", label: "apple" },
        { value: "melon", label: "melon" },
        { value: "raspberry", label: "raspberry" },
      ],
      numbers: [
        { value: "1", label: "1" },
        { value: "2", label: "2" },
        { value: "3", label: "3" },
      ],
      objects: [
        { value: JSON.stringify({ test: "a" }), label: "a" },
        { value: JSON.stringify({ test: "b" }), label: "b" },
        { value: JSON.stringify({ test: "c" }), label: "c" },
      ],
      arrays: [
        { value: JSON.stringify(["a"]), label: "a" },
        { value: JSON.stringify(["b"]), label: "b" },
        { value: JSON.stringify(["c"]), label: "c" },
      ],
    };
  },
  mounted() {
    console.log("MOUNTED");
    console.log(this.logo);
  },
};
</script>

src/nodes/your-node/schemas.ts

import { Type, Static } from "@sinclair/typebox";
import {
  IONodeConfigsSchema,
  TypedInputSchema,
  MessageSchema,
} from "../../core/schemas";

const ConfigsSchema = Type.Object(
  {
    ...IONodeConfigsSchema.properties,
    name: Type.String({ default: "your-node" }),
    myProperty: TypedInputSchema,
    myProperty2: TypedInputSchema,
    remoteServer: Type.String({ nodeType: "remote-server" }),
    anotherRemoteServer: Type.Optional(
      Type.String({ nodeType: "remote-server" })
    ),
    country: Type.String({ default: "brazil" }),
    fruit: Type.Array(Type.String(), { default: ["apple", "melon"] }),
    number: Type.String({ default: "1" }),
    object: Type.Array(Type.String(), {
      default: [JSON.stringify({ test: "a" }), JSON.stringify({ test: "b" })],
    }),
    array: Type.String({
      default: '["a"]',
    }),
    jsontest: Type.String({ default: "" }),
    csstest: Type.String({ default: "" }),
  },
  {
    $id: "YourNodeConfigsSchema",
  }
);

const CredentialsSchema = Type.Object(
  {
    password: Type.Optional(
      Type.String({
        default: "",
        minLength: 8,
        maxLength: 20,
        pattern: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]+$/.source,
        format: "password",
      })
    ),
    password2: Type.Optional(
      Type.String({
        default: "",
        pattern:
          /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
            .source,
        format: "password",
      })
    ),
    username: Type.Optional(
      Type.String({ default: "", maxLength: 10, minLength: 5 })
    ),
  },
  {
    $id: "YourNodeCredentialsSchema",
  }
);

const InputMessageSchema = Type.Intersect(
  [
    MessageSchema,
    Type.Object({
      myVariable: Type.Optional(Type.String()),
    }),
  ],
  {
    $id: "YourNodeInputMessageSchema",
  }
);

const OutputMessageSchema = Type.Intersect(
  [
    MessageSchema,
    Type.Object({
      originalType: Type.Union([
        Type.Literal("string"),
        Type.Literal("number"),
      ]),
      processedTime: Type.Number(),
    }),
    Type.Unknown(),
  ],
  { $id: "YourNodeOutputMessageSchema" }
);

export {
  ConfigsSchema,
  CredentialsSchema,
  InputMessageSchema,
  OutputMessageSchema,
};

The only part that is a little bit complicated is defining JSON Schemas using TypeBox. However, if you don't like it, you can define the JSON Schema by hand, using an object.

And this is how you create a ConfigNode

src/nodes/remote-server/server/index.ts

import { Static } from "@sinclair/typebox";
import { ConfigNode, ConfigNodeValidations } from "../../../core/server/nodes";
import { ConfigsSchema } from "../schemas";

export type RemoteServerConfigs = Static<typeof ConfigsSchema>;

export default class RemoteServerConfigNode extends ConfigNode<RemoteServerConfigs> {
  static override validations: ConfigNodeValidations = {
    configs: ConfigsSchema,
  };

  // NOTE: run only once when node type is registered
  static override init() {
    console.log("server-node");
  }
}

src/nodes/remote-server/client/index.ts

import { defineNode } from "../../../core/client";
import component from "./Form.vue";

export default defineNode({
  category: "config",
  color: "#a6bbcf",
  form: {
    component,
    disableSaveButtonOnError: true,
  },
});

src/nodes/remote-server/client/Form.vue

<template>
  <div>
    <div class="form-row">
      <label><i class="fa fa-tag"></i> Name</label>
      <NodeRedInput
        v-model:value="node.name"
        :error="errors['node.name']"
        placeholder="name"
      />
    </div>
    <div class="form-row">
      <label><i class="fa fa-tag"></i> Hostname</label>
      <NodeRedInput
        v-model:value="node.host"
        :error="errors['node.host']"
        placeholder="hostname"
      />
    </div>
  </div>
</template>

<script>
export default {
  name: "NodeRedNodeForm",
  props: {
    node: {
      type: Object,
      required: true,
    },
    errors: {
      type: Object,
      default: () => ({}),
    },
  },
};
</script>

src/nodes/remote-server/schemas.ts

import { Type } from "@sinclair/typebox";
import { ConfigNodeConfigsSchema } from "../../core/schemas";

const ConfigsSchema = Type.Object(
  {
    ...ConfigNodeConfigsSchema.properties,
    name: Type.String({ default: "remote-server", minLength: 10 }),
    host: Type.String({ default: "localhost" }),
  },
  {
    $id: "RemoteServerConfigsSchema",
  }
);

export { ConfigsSchema };