GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — GitHub - Devisjs/devis: Pattern-matching microservices for Node.js: async calls routed over TCP or Unix sockets, load balancing, reconnection, OpenTelemetry traces

BranchesTags
Open more actions menu

Latest commit

 

History

334 Commits
334 Commits

Folders and files

NameName
Last commit message
Last commit date
.github/workflows
.github/workflows
 
 
examples
examples
 
 
lib
lib
 
 
test
test
 
 
.eslintrc.js
.eslintrc.js
 
 
.eslintrc.json
.eslintrc.json
 
 
.gitignore
.gitignore
 
 
LICENSE
LICENSE
 
 
README.md
README.md
 
 
devis.d.ts
devis.d.ts
 
 
devis.js
devis.js
 
 
package.json
package.json
 
 

Repository files navigation

Devis

A microservices framework for Node.js

Version License CI

A microservice registers handlers under patterns (plain objects). Callers send a pattern and arguments; Devis routes the call to the matching handler, in the same process or over TCP, a Unix socket or a named pipe. Moving a service out of process does not change the calling code: Devis sends the call to the service that announced the pattern.

npm install devis

Node.js 22 or later is required.

Quick start: a service and a client over TCP

math.js registers two handlers and listens on port 3030:

const devis = require("devis");

devis.setName("math");

devis.push({ role: "math", cmd: "sum" }, async ({ left, right }) => left + right);

devis.push({ role: "math", cmd: "divide" }, async ({ left, right }) => {
    if (right === 0) throw new Error("division by zero");
    return left / right;
});

devis.listen({ host: "127.0.0.1", port: 3030 });

client.js connects to it and calls both:

const devis = require("devis");

devis.setName("client").connect({ id: "math", host: "127.0.0.1", port: 3030 }, async () => {
    console.log(await devis.call({ role: "math", cmd: "sum" }, { left: 1, right: 2 })); // 3

    try {
        await devis.call({ role: "math", cmd: "divide" }, { left: 1, right: 0 });
    } catch (err) {
        console.log(err.message); // division by zero
    }
    devis.close();
});
node math.js
node client.js   # in another terminal

On connection, math.js announces its patterns, so the client sends each call to it. See Routing and load balancing.

Handlers

A handler either returns its result (or a Promise of it), or calls done(err, result):

devis.push({ role: "users", cmd: "get" }, async ({ id }) => db.users.findOne({ id }));

devis.push({ role: "users", cmd: "count" }, (args, done) => {
    db.users.count((err, count) => done(err, count));
});

A handler that takes only args is treated as returning its result. A thrown error or a rejected Promise reaches the caller as an error, locally or over the network.

Calls

call returns a Promise when no callback is given. With a callback, it returns the instance so calls can be chained:

const user = await devis.call({ role: "users", cmd: "get" }, { id: 1 });

devis.call({ role: "users", cmd: "get" }, { id: 1 }, (err, user) => { /* ... */ });

Calls on the same connection run in parallel: a slow handler does not delay the others.

How matching works

  • A handler matches a call when all the keys of its pattern appear in the call with the same values. Key order does not matter, and the call may carry more keys: { role: "math", cmd: "sum", version: 2 } reaches the { role: "math", cmd: "sum" } handler, but { role: "math" } does not.
  • When several handlers match, the one with the most keys wins, so a { role: "math" } fallback never shadows { role: "math", cmd: "sum" }. On a tie, the first registered wins.
  • Registering the same pattern twice throws This pattern already exists.
  • Calling a pattern nobody registered fails with No handle found: the Promise rejects, or the call throws when a callback is given. On a remote service, the error is sent back to the caller and the service keeps running.
  • require("devis") returns one instance per process, so every file of a service shares the same patterns.

Failures and timeouts

A remote call fails instead of waiting forever:

Error When
request timed out No answer within the connection timeout (30 s by default, 0 disables it).
connection closed The connection dropped while the call was waiting.
No transport found No connection was opened with this clientId.
devis.connect({ id: "payments", host: "payments", port: 3030, timeout: 2000 });

A dropped connection is reopened by itself, after 1 s, then 2 s, 4 s and up to 30 s between attempts, so services and clients can start in any order and a restarted service gets traffic back. Pass reconnect: false to connect to turn it off.

Routing and load balancing

When a client connects, the service sends it the list of its patterns, and sends it again when a pattern is pushed later. A call without clientId goes to the service whose pattern matches best:

devis.connect({ id: "users", host: "users", port: 3030 });
devis.connect({ id: "orders", host: "orders", port: 3030 });

await devis.call({ role: "users", cmd: "get" }, { id: 1 });     // sent to users
await devis.call({ role: "orders", cmd: "list" }, { user: 1 }); // sent to orders
  • Connect several instances of a service and calls go to each in turn, skipping disconnected ones:

    devis.connect({ id: "math-1", host: "10.0.0.1", port: 3030 });
    devis.connect({ id: "math-2", host: "10.0.0.2", port: 3030 });
  • A local handler wins over a remote one with the same number of keys.

  • A call made right after connect waits for the announcements instead of failing.

  • clientId still sends the call to one connection, the id given to connect.

Devis does not find addresses: use the DNS names of Docker Compose or Kubernetes services for host.

Tracing with OpenTelemetry

Install @opentelemetry/api next to Devis, and register an OpenTelemetry SDK as usual. Every remote call then records a client span on the caller and a server span on the service, named after the pattern (cmd:sum,role:math), with rpc.system, rpc.method, server.address, server.port and devis.service attributes. The trace context travels with the call, so a request crossing several services reads as one trace, and calls made inside a handler join it.

npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-proto
// tracing.js, loaded first: node --require ./tracing.js math.js
const { NodeSDK } = require("@opentelemetry/sdk-node");
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-proto");

const sdk = new NodeSDK({ serviceName: "math", traceExporter: new OTLPTraceExporter() });
sdk.start();
// Flush pending spans when the process ends.
process.on("beforeExit", () => sdk.shutdown());
process.on("SIGTERM", () => sdk.shutdown().finally(() => process.exit(0)));

OTEL_EXPORTER_OTLP_ENDPOINT sets the collector address (http://localhost:4318 by default).

Without @opentelemetry/api, Devis records nothing and needs no configuration. Services older than 4.1 ignore the trace context.

Recipes

Call a handler in the same process

const devis = require("devis");

devis.push({ role: "greeter", cmd: "hello" }, async ({ name }) => "Hello " + name);

console.log(await devis.call({ role: "greeter", cmd: "hello" }, { name: "Ada" })); // Hello Ada

Split a service into modules

Export the instance from a module, then load its patterns with use (the path is relative to the calling file):

// services/users.js
const devis = require("devis");

devis.push({ role: "users", cmd: "get" }, async ({ id }) => ({ id, name: "Ada" }));

module.exports = devis;
// index.js
const devis = require("devis").use("./services/users");

devis.call({ role: "users", cmd: "get" }, { id: 1 }).then((user) => {
    console.log(user); // { id: 1, name: 'Ada' }
});

Use a Unix socket or a named pipe

listen and connect take the same options as Node.js net, so pass path instead of host and port:

// server
devis.push({ role: "ping" }, async () => "pong");
devis.listen({ path: "/tmp/devis-math.sock" });  // Windows: { path: "\\\\.\\pipe\\devis-math" }
// client
devis.connect({ id: "local", path: "/tmp/devis-math.sock" }, async () => {
    console.log(await devis.call({ clientId: "local", role: "ping" })); // pong
});

Shut down cleanly

close() without an id closes every connection and server, then calls back once the ports are released:

process.on("SIGTERM", () => devis.close(() => process.exit(0)));

Send logs elsewhere, or silence them

Devis logs connections and errors to the console. Pass any object with info and error methods, such as pino or winston, or null to silence it:

devis.setLogger(pino());
devis.setLogger(null);

Add storage or messaging with a plugin

plug loads the patterns of an installed plugin:

Plugin Patterns
devis-mongo-client { role: "mongodb", action: "connect" | "insert" | "find" | "update" | "delete" | ... }
devis-pub_sub-redis { role: "pub" | "sub", action: "create" }, { transport: "pub/sub", action: "subscribe" | "pubslish" }
devis_rabbitmq adds listenMQ, clientMQ and callMQ to call services through RabbitMQ
npm install devis devis-mongo-client
const devis = require("devis").plug("devis-mongo-client");

async function main() {
    await devis.call({ role: "mongodb", action: "connect" }, { url: "mongodb://localhost:27017/shop" });
    await devis.call({ role: "mongodb", action: "insert" }, { collection: "books", type: "many", data: [{ title: "Dune" }, { title: "Hyperion" }], close: false });
    const books = await devis.call({ role: "mongodb", action: "find" }, { collection: "books", query: {} });
    console.log(books.map((book) => book.title)); // [ 'Dune', 'Hyperion' ]
}

main();

More runnable examples, including Redis and RabbitMQ, are in Devis-Examples. To start a new project, use generator-devis.

TypeScript

Types ship with the package:

import devis = require("devis");

const sum = await devis.call<number>({ role: "math", cmd: "sum" }, { left: 1, right: 2 });

API

Method Description
push(pattern, handler) Registers handler for pattern. The handler returns its result or calls done(err, result).
call(pattern, [args], [callback]) Calls the handler matching pattern with args (default {}), locally or on the connected service that announced it. Returns a Promise without callback. Add clientId to the pattern to pick a connection.
listen(options, [callback]) Exposes the registered patterns. options are net options: { host, port } or { path }.
connect(options, [callback]) Connects to a remote service. options.id is required and unique; it is the clientId used by call. options.timeout is in milliseconds; options.reconnect: false disables reconnection.
close([id]), close([callback]) Closes the connection id, or every connection and server.
use(path) Loads the patterns of a module exporting a Devis instance, relative to the calling file.
plug(name) Loads the patterns of an installed plugin.
setName(name) Names the instance; used in logs and in log().
setLogger(logger) Sends logs to logger.info and logger.error; null silences them.
log() Writes the local patterns to <name>-log.txt.

Every method except a callback-less call returns the instance, so calls can be chained.

Upgrading from 4.0

  • Patterns match partially: a call carrying extra keys now reaches the handler instead of failing with No handle found. Calls that matched in 4.0 match the same handler.
  • A call without clientId that no local handler matches goes to a connected service announcing the pattern, instead of failing with No handle found.
  • Connections reconnect by themselves after a drop. Pass reconnect: false to keep the 4.0 behaviour, where only the next call reconnected.
  • Remote calls are traced when @opentelemetry/api is installed.
  • Requires devisPattern 2.1, installed with Devis.

Upgrading from 3.x

  • call without a callback returns a Promise, and handlers can be async.
  • Pattern key order no longer matters.
  • Calls on one connection run in parallel. They fail with request timed out after 30 s by default, and with connection closed when the connection drops; in 3.x they waited forever.
  • A remote result of 0, "" or null reaches the caller. In 3.x it was reported as No handle found.
  • call no longer removes clientId from the pattern object you pass, nor adds fields to your args.
  • A call to an unknown clientId fails with No transport found instead of No handle found.
  • usePath and getFunctions are removed. usePath only worked on Wakanda Server.
  • The wire format is unchanged: 3.x and 4.x services and clients can talk to each other. Over the wire, a 3.x service still matches keys in order.

Upgrading from 2.x

  • Node.js 22 or later is required.
  • devisPattern 2 is plain JavaScript, so CMake and a C++ toolchain are no longer needed to install Devis.
  • Each microservice now has its own pattern table. In 2.x, two matchers in the same process could resolve each other's patterns to the wrong handler.

License

MIT

About

Pattern-matching microservices for Node.js: async calls routed over TCP or Unix sockets, load balancing, reconnection, OpenTelemetry traces

www.npmjs.com/package/devis

Topics

devisjavascriptload-balancingmicroservice-frameworkmicroservicesnamed-pipesnodejsopentelemetrypattern-matchingrpctcpunix-socket

Resources

Readme
MIT license
Activity
Custom properties

Stars

79 stars

Watchers

6 watching

Forks

12 forks
Report repository

Releases

Packages

Used by

Contributors

Languages

You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.