Write nodes using Typescript + Vue 3 + JSON Schemas for runtime validations

The goal of this framework is to modernize the creation of nodes, while also improving their maintainability and lifespan. It will be part of the nrg-cli v3. THE AGE OF NON MINIFIED SINGLE FILE NODES AND JQUERY IS IN THE PAST!

How to define the client-side of your node. As you can see, you dont need to define defaults or credentials again because the source of truth for those is defined in the server-side of your node, using a json schema.

import { defineNode } from "../../../core/client";
import component from "../components/your-node-form.vue";

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

The server side part of a node can be created by extending the base classes called IONode and ConfigNode, as shown below. The definition of defaults and credentials happen in the server, using JSON Schemas and it is then sent to the client-side of the node via an http request done in the client right before the node is registered. Defaults and credentials are extracted from the schema and used in RED.nodes.registerType alongside the other properties you defined for the client part of your node.

IO Node

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

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 node init");

    try {
      const response = await fetch("https://dog.ceo/api/breeds/image/random");
      if (!response.ok) {
        throw new Error(`Response status: ${response.status}`);
      }

      const json = await response.json();
      console.log(json);
    } catch (error) {
      if (error instanceof Error) {
        console.error("Error while fetching dogs: ", error.message);
      } else {
        console.error("Unknown error occurred: ", error);
      }
    }
  }

  override 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);

    const outputMsg: YourNodeOutputMessage = {
      originalType: "number",
      processedTime: 1,
    };
    send(outputMsg);
    done();
  }

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

Config node

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

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 async init() {
    console.log("testing remote-server node init");

    try {
      const response = await fetch("https://dog.ceo/api/breeds/image/random");
      if (!response.ok) {
        throw new Error(`Response status: ${response.status}`);
      }

      const json = await response.json();
      console.log(json);
    } catch (error) {
      if (error instanceof Error) {
        console.error("Error while fetching dogs: ", error.message);
      } else {
        console.error("Unknown error occurred: ", error);
      }
    }
  }
}

It is really easy to build your node library with this template

pnpm install
pnpm build

After building your node library, a dist will appear in your local directory

You can install the build to your local Node-RED instance running

cd ~/.node-red
npm install ${PATH_CLONED_REPO}/dist

The index.html file is generated automatically.

The files under dist can be published to npm or other package manager of your choice. It contains everything consumers will need, like source maps and type declarations.

The server part of the node, as well as its dependencies, are bundled as common js using esbuild. Client dependencies are bundled using vite, chuncked and minified for fast loading. There is an example in /src/server/vite.config.ts. Tree shaking and minification are performed in both builds.

During development locales are grouped by node to ease maintenance

once the project is built, labels and docs are grouped the way Node-RED is expecting

using i18n is now easier and natural than ever. A $i18n global function is available to all your components. Labels are automatically scoped by the node's type.

There are a ton of other enhancements when using Vue for authoring your Node config forms. For example, typed and editor inputs are created using NodeRedTypedInput and NodeRedEditorInput in your component template. You no longer need to write any additional jquery function, or declare 2 props to store the value and the type of your typed input separately, like myPropValue and myPropType. Instead, you can have a single prop called myProp and pass it to your NodeRedTypedInput component. In the server myProp will have its value and type stored in myProp.value and myProp.type respectively. Additionaly, when exported, myProp will be serialized as an object, which is way easier to read.

Custom jquery widget and RED type augumentations will be replaced by the future official types that @GogoVega is writting. The ones in the repo are 99.99% incomplete and horrible, and were created just for testing intellisense in vscode's ts lang server.

The core folder and its dependencies is going to be packaged under @allanoricil/nrg-core (I wish I could own the @nrg scope but github ignored my requests). If node-red core team accepts my framework model I can put the nrg packages under the @node-red scope.

If you have ideas just open a PR or leave a comment here. Thanks.

1 Like

@dceejay @knolleary @hardillb @Sean-McG @Steve-Mcl can you give me a star in github to influence people. Everyone that follows you will see it.

Those who saw it dont need to be shy. Go ahead and test it. Just dont forget to leave a star because it helps influencing the masses

editor inputs can now be expanded very easily

https://github.com/user-attachments/assets/14a7b50b-4f2f-4f98-95e3-a95999191786

<template>
      <NodeRedEditorInput
        v-model:value="node.csstest"
        language="css"
        :error="errors['node.csstest']"
      />
</template>