A microservices framework for Node.js
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.
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 terminalOn connection, math.js announces its patterns, so the client sends each call to it. See Routing and load balancing.
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.
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.
- 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.
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.
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
connectwaits for the announcements instead of failing. -
clientIdstill sends the call to one connection, theidgiven toconnect.
Devis does not find addresses: use the DNS names of Docker Compose or Kubernetes services for host.
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.
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
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' } });
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 });
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)));
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);
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.
Types ship with the package:
import devis = require("devis"); const sum = await devis.call<number>({ role: "math", cmd: "sum" }, { left: 1, right: 2 });
| 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.
- 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
clientIdthat no local handler matches goes to a connected service announcing the pattern, instead of failing withNo handle found. - Connections reconnect by themselves after a drop. Pass
reconnect: falseto keep the 4.0 behaviour, where only the next call reconnected. - Remote calls are traced when
@opentelemetry/apiis installed. - Requires devisPattern 2.1, installed with Devis.
callwithout a callback returns a Promise, and handlers can beasync.- Pattern key order no longer matters.
- Calls on one connection run in parallel. They fail with
request timed outafter 30 s by default, and withconnection closedwhen the connection drops; in 3.x they waited forever. - A remote result of
0,""ornullreaches the caller. In 3.x it was reported asNo handle found. callno longer removesclientIdfrom the pattern object you pass, nor adds fields to yourargs.- A call to an unknown
clientIdfails withNo transport foundinstead ofNo handle found. usePathandgetFunctionsare removed.usePathonly 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.
- 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.
MIT