Introduction

Nodes are the fundamental building blocks of the Nanoservice-ts framework. They encapsulate discrete pieces of business logic that can be composed together to create complex workflows. Each Node is designed to perform a specific task, making your code more modular, maintainable, and reusable.

Think of Nodes as specialized microservices that:

  • Have clearly defined inputs and outputs
  • Perform a single responsibility
  • Can be tested in isolation
  • Are composable through workflows

This modular approach allows developers to build complex applications by connecting simple, focused components rather than writing monolithic code.

Creating a Node

Using the CLI

The easiest way to create a new Node is using the Nanoservice-ts CLI:

npx nanoctl@latest create node

When you run this command, you’ll see the following interactive prompts:

+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +-+-+-+
 |N|A|N|O|S|E|R|V|I|C|E|-|T|S| |C|L|I|
 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +-+-+-+

┌   Creating a new Node 

◇  Please provide a name for the node
│  basic

◇  Select the nanoservice runtime
│  Typescript

◇  Select the nanoservice type
│  Module

◇  Select the template
│  Class

◇  Node "basic" created successfully

This command generates a complete Node package in your project’s src/nodes/ directory with the name you provided.

Node Structure

When you create a Node using the CLI, it generates the following folder structure:

src/
└── nodes/
    └── basic/
        ├── dist/
        ├── node_modules/
        ├── test/
        │   ├── helper.ts
        │   └── index.test.ts
        ├── .gitignore
        ├── CHANGELOG.md
        ├── config.json
        ├── index.ts
        ├── package-lock.json
        ├── package.json
        ├── README.md
        └── tsconfig.json

Each Node is essentially a self-contained package with its own dependencies, configuration, and tests. This structure allows Nodes to be developed, tested, and versioned independently.

Node Implementation

The Node Class

The core of a Node is its implementation class, which extends the NanoService base class from the @nanoservice-ts/runner package. Here’s the template generated by the CLI:

import { type INanoServiceResponse, NanoService, NanoServiceResponse } from "@nanoservice-ts/runner";
import { type Context, GlobalError } from "@nanoservice-ts/shared";

type InputType = {
  message?: string;
};

/**
 * Represents a Node service that extends the NanoService class.
 * This class is responsible for handling requests and providing responses
 * with automated validation using JSON Schema.
 */
export default class Node extends NanoService<InputType> {
  /**
   * Initializes a new instance of the Node class.
   * Sets up the input and output JSON Schema for automated validation.
   */
  constructor() {
    super();
    // Learn JSON Schema: https://json-schema.org/learn/getting-started-step-by-step
    this.inputSchema = {};
    // Learn JSON Schema: https://json-schema.org/learn/getting-started-step-by-step
    this.outputSchema = {};
  }

  /**
   * Handles the incoming request and returns a response.
   *
   * @param ctx - The context of the request.
   * @param inputs - The input data for the request.
   * @returns A promise that resolves to an INanoServiceResponse object.
   *
   * The method tries to execute the main logic and sets a success message in the response.
   * If an error occurs, it catches the error, creates a GlobalError object, sets the error details,
   * and sets the error in the response.
   */
  async handle(ctx: Context, inputs: InputType): Promise<INanoServiceResponse> {
    const response: NanoServiceResponse = new NanoServiceResponse();

    try {
      // Your code here
      response.setSuccess({ message: inputs.message || "Hello World from Node!" });
    } catch (error: unknown) {
      const nodeError: GlobalError = new GlobalError((error as Error).message);
      nodeError.setCode(500);
      nodeError.setStack((error as Error).stack);
      nodeError.setName(this.name);
      nodeError.setJson(undefined);

      response.setError(nodeError);
    }

    return response;
  }
}

Key Components of a Node Class

  1. Type Definitions:

    • InputType: Defines the structure of data the Node expects to receive.
    • You can also define an OutputType for better type safety of the response data.
  2. Constructor:

    • Initializes the Node and sets up JSON Schema for input and output validation.
    • You can also set metadata like name, version, description, and category here.
  3. Handle Method:

    • The core method where your business logic resides.
    • Takes a Context object and the typed inputs.
    • Returns a NanoServiceResponse containing either success data or an error.
    • Implements proper error handling with the GlobalError class.

Node Configuration

Each Node has a config.json file that defines its metadata, input/output schemas, and examples:

{
  "name": "node-name",
  "version": "1.0.0",
  "description": "",
  "group": "API",
  "config": {
    "type": "object",
    "properties": {
      "inputs": {
        "type": "object",
        "properties": {},
        "required": []
      }
    },
    "required": ["inputs"],
    "example": {
      "inputs": {
        "properties": {
          "url": "https://countriesnow.space/api/v0.1/countries/capital",
          "method": "POST",
          "headers": {
            "Content-Type": "application/json"
          },
          "body": {
            "data": "Hello World"
          }
        }
      }
    }
  },
  "input": {
    "anyOf": [
      {
        "type": "object"
      },
      {
        "type": "array"
      },
      {
        "type": "string"
      }
    ],
    "description": "This node accepts an object as input from the previous node or request body"
  },
  "output": {
    "type": "object",
    "description": "The response from the API call"
  },
  "steps": {
    "type": "boolean",
    "default": false
  },
  "functions": {
    "type": "array"
  }
}

This configuration file helps document the Node’s capabilities and provides examples for users.

Package Configuration

Each Node has its own package.json file with dependencies and scripts:

{
  "name": "basic",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "test:dev": "vitest",
    "test": "vitest run",
    "build": "rm -rf dist && tsc",
    "build:dev": "tsc --watch"
  },
  "author": "",
  "license": "Apache-2.0",
  "devDependencies": {
    "@types/node": "^22.13.4",
    "typescript": "^5.1.3",
    "vitest": "^3.0.4"
  },
  "dependencies": {
    "@nanoservice-ts/shared": "^0.0.9",
    "@nanoservice-ts/runner": "^0.1.21",
    "@nanoservice-ts/helper": "^0.1.4"
  },
  "private": true
}

This allows each Node to have its own dependencies and build process.

Testing Nodes

Nanoservice-ts generates test files for your Nodes automatically. The test setup includes:

Test Helper

The helper.ts file provides a mock Context object for testing:

import type { ParamsDictionary } from "@nanoservice-ts/runner";
import type { Context } from "@nanoservice-ts/shared";

export default function ctx(): Context {
  const ctx: Context = {
    response: {
      data: null,
      error: null,
    },
    request: {
      body: {},
    },
    config: {},
    id: "",
    error: {
      message: "",
      code: undefined,
      json: undefined,
      stack: undefined,
      name: undefined,
    },
    logger: {
      log: (message: string): void => {
        throw new Error("Function not implemented.");
      },
      getLogs: (): string[] => {
        throw new Error("Function not implemented.");
      },
      getLogsAsText: (): string => {
        throw new Error("Function not implemented.");
      },
      getLogsAsBase64: (): string => {
        throw new Error("Function not implemented.");
      },
      logLevel: (level: string, message: string): void => {
        throw new Error("Function not implemented.");
      },
      error: (message: string, stack: string): void => {
        throw new Error("Function not implemented.");
      },
    },
    eventLogger: undefined,
    _PRIVATE_: undefined,
  };

  ctx.config = {
    "api-call": {
      inputs: {
        url: "https://jsonplaceholder.typicode.com/todos/1",
        method: "GET",
      },
    },
  } as unknown as ParamsDictionary;

  return ctx;
}

Test File

The index.test.ts file contains basic tests for your Node:

import type ParamsDictionary from "@nanoservice-ts/shared/dist/types/ParamsDictionary";
import { beforeAll, expect, test } from "vitest";
import Node from "../index";
import ctx from "./helper";

let node: Node;

beforeAll(() => {
  node = new Node();
  node.name = "api-call";
});

// Validate Hello World from Node
test("Hello World from Node", async () => {
  const response = await node.handle(ctx(), {});
  const message: ParamsDictionary = { message: "Hello World from Node!" };

  expect(message).toEqual(response.data);
});

Running Tests

You can run tests using the scripts defined in package.json:

# Run tests in watch mode
npm run test:dev

# Run tests once
npm run test

The Context Object

The Context (ctx) object is a crucial part of the Nanoservice-ts framework. It’s passed to each Node’s handle method and provides:

  1. Request Data: Information about the incoming request (for HTTP triggers).
  2. Response Data: Output from previous Nodes in the workflow.
  3. Configuration: Node-specific configuration.
  4. Logging: Methods for logging information, warnings, and errors.
  5. Error Handling: Standardized error reporting.

The Context object facilitates data flow between Nodes in a workflow and provides essential services to each Node.

Input and Output Validation

Nanoservice-ts uses JSON Schema for input and output validation. You can define schemas in your Node’s constructor:

constructor() {
  super();
  this.inputSchema = {
    type: "object",
    properties: {
      message: { type: "string" }
    },
    required: []
  };
  this.outputSchema = {
    type: "object",
    properties: {
      message: { type: "string" }
    },
    required: ["message"]
  };
}

These schemas ensure that:

  • Your Node receives correctly formatted inputs
  • Your Node produces correctly formatted outputs
  • Errors are caught early and reported clearly

Error Handling

Proper error handling is essential in Nanoservice-ts. The framework provides the GlobalError class for standardized error reporting:

try {
  // Your code here
  response.setSuccess({ message: "Success!" });
} catch (error: unknown) {
  const nodeError: GlobalError = new GlobalError((error as Error).message);
  nodeError.setCode(500);
  nodeError.setStack((error as Error).stack);
  nodeError.setName(this.name);
  nodeError.setJson(undefined);

  response.setError(nodeError);
}

This approach ensures that errors are properly captured, logged, and can be handled by the workflow.

Built-in Nodes

Nanoservice-ts comes with several built-in Nodes that provide common functionality. These are registered in the src/Nodes.ts file:

import ApiCall from "@nanoservice-ts/api-call";
import IfElse from "@nanoservice-ts/if-else";
import type { NodeBase } from "@nanoservice-ts/shared";

const nodes: {
  [key: string]: NodeBase;
} = {
  "@nanoservice-ts/api-call": new ApiCall(),
  "@nanoservice-ts/if-else": new IfElse(),
};

export default nodes;

Key Built-in Nodes

  1. @nanoservice-ts/api-call:

    • Makes HTTP requests to external APIs
    • Supports various HTTP methods (GET, POST, PUT, DELETE, etc.)
    • Handles request headers, body, and response parsing
  2. @nanoservice-ts/if-else:

    • Provides conditional branching in workflows
    • Evaluates conditions and executes different steps based on the results
    • Essential for creating dynamic workflows

Best Practices for Node Development

Single Responsibility Principle

Each Node should do one thing and do it well. If a Node is becoming complex, consider breaking it into multiple Nodes.

Clear Input/Output Contracts

Define precise input and output types and schemas. This makes your Nodes more predictable and easier to use.

Proper Error Handling

Always catch and properly report errors. Use the GlobalError class for standardized error reporting.

Comprehensive Testing

Write thorough tests for your Nodes. Test both success and error scenarios.

Descriptive Naming

Use clear, descriptive names for your Nodes and their inputs/outputs. This makes workflows easier to understand.

Statelessness

Design Nodes to be stateless. Any state should be passed through the Context object or stored externally.

Reusability

Design Nodes to be reusable across different workflows. Avoid hardcoding workflow-specific logic.

Advanced Node Concepts

Custom Node Types

While the basic Node template is sufficient for most use cases, you can create specialized Node types for specific purposes:

  • Transformation Nodes: Convert data from one format to another
  • Integration Nodes: Connect to external services or APIs
  • Decision Nodes: Implement complex business rules
  • Aggregation Nodes: Combine data from multiple sources

Node Composition

Complex operations can be achieved by composing multiple Nodes in a workflow rather than creating complex Nodes. This approach:

  • Improves maintainability
  • Enhances reusability
  • Simplifies testing
  • Makes workflows more flexible

Node Versioning

As your application evolves, you may need to update your Nodes. Consider versioning strategies:

  • Semantic versioning for Node packages
  • Backward compatibility considerations
  • Deprecation policies
  • Migration strategies

Conclusion

Nodes are the foundation of the Nanoservice-ts framework. By understanding how to create, configure, and test Nodes, you can build modular, maintainable applications that are easy to extend and adapt to changing requirements.

The Node-based architecture encourages good software design practices like separation of concerns, modularity, and testability, leading to more robust and maintainable applications.

Next, learn about how to compose Nodes into Workflows to create complete applications.