The simple RPC framework for Node.js.
A lightweight alternative to the boilerplate-heavy gRPC, or spinning up a HTTP server and REST API. Procedure links your independent applications and services with as little code as possible, so that you can just focus on building your app!
// my-app/index.js
// a simple procedure which returns the square of a given number
const procedure = new Procedure((n) => n ** 2).bind("tcp://*:5000");
// some-other-app/index.js
// calling the procedure to find the square of 8
let squared = await call("tcp://localhost:5000", 8);
console.log(squared); // outputs 64
Procedure allows you to define procedures which can be called over TCP, WebSockets, IPC, and across threads or modules in the same process. Use whichever transport is most appropriate for your use case, or mix-and-match!
- procedure.js 🔗
npm i @procedure-rpc/procedure.js
With Procedure, setting up your function to be called from another process (whether remote or local) is remarkably simple:
import Procedure from "@procedure-rpc/procedure.js";
const procedure = new Procedure((n) => n ** 2);
procedure.bind("tcp://*:5000");
And calling it is just as easy:
import { call } from "@procedure-rpc/procedure.js";
let x = 8;
let xSquared = await call("tcp://localhost:5000", x);
console.log(xSquared); // outputs 64
console.log(typeof xSquared); // outputs 'number'
Asynchronous functions are fully supported:
import { ProcedureExcecutionError } from "@procedure-rpc/procedure.js/errors";
const procedure = new Procedure(async () => {
const response = await fetch("https://catfact.ninja/fact");
if (response.ok) {
return (await response.json()).fact;
} else {
throw new ProcedureExecutionError(
`${response.status}: ${response.statusText}`
);
}
}).bind("tcp://127.0.0.1:8888");
Parameter and return types can be anything supported by the msgpack serialization format, which covers much of JavaScript by default, and you can handle unsupported types with Extension Types. We generally recommend sticking to PODs. It is possible to pass more complex types around in many cases - but note that they will be passed by value, not by reference.
Procedure supports a single parameter, or none. We considered supporting multiple parameters, but this increases the complexity of the design and leads to potentially inconsistent APIs for different language implementations, while multiple parameters can easily be simulated through the use of PODs (e.g. object literals, property bags) or arrays in virtually any programming language.
If you have existing functions with multiple parameters which you want to expose as procedures, wrapping them is trivial:
function myFunction(a, b, c) {
return a + b * c;
}
const procedure = new Procedure((params) => myFunction(...params));
procedure.bind("tcp://*:30666");
Which can then be called like so:
call("tcp://localhost:30666", [1, 2, 3]);
For functions where you have optional parameters, it might make more sense to use object literals/property bags instead of arrays.
Functions which accept multiple parameters where only the first is required (or none) will work as is, but you will only be able to pass the first parameter via call
.
In the JavaScript implementation of msgpack, undefined
is mapped to null
. This means that all undefined
values will be decoded as null
, and there is no way to differentiate between the two.
This causes an issue for procedures which accept an optional parameter, as in most implementations of optional parameters in JavaScript, only undefined
is coerced into a default value.
It also means that procedures with no return value will evaluate to null
instead of undefined
, which could cause unexpected behavior if you were to pass the return value of a procedure into another function as an optional parameter.
To handle these inconsistencies, we coerce a msgpack decoded null
to undefined
. This does not affect the properties of objects - they will still be evaluated as null
when they were either null
or undefined
.
To disable this behavior, you can set optionalParameterSupport
to false
for either procedure definitions or calls, or both:
const procedure = new Procedure((x) => x, { optionalParameterSupport: false });
procedure.bind("tcp://*:54321");
await call("tcp://localhost:54321", x, { optionalParameterSupport: false });
Note that disabling at the definition will not affect the return value, and disabling at the call will not affect the input parameter.
For objects, we do not coerce null
properties to undefined
. Instead, we leave them as is, but properties with the value of undefined
are ignored, thereby allowing those properties to be evaluated as undefined
at the other end, while null
properties remain null
.
This operation adds some overhead, and any code that relies on the presence of a property to infer meaning may not work as expected, e.g. if ('prop' in obj)
.
To disable this behavior, you can set ignoreUndefinedProperties
to false
for either procedure definitions or calls, or both:
const procedure = new Procedure((x) => x, { ignoreUndefinedProperties: false });
procedure.bind("tcp://*:54321");
await call("tcp://localhost:54321", x, { ignoreUndefinedProperties: false });
Note that disabling at the definition will not affect the return value, and disabling at the call will not affect the input parameter.
It is impossible to pass by reference with Procedure. All data is encoded and then sent across the wire, similar to what happens when a REST API responds to a request by sending back a JSON string/file. You can parse that JSON into an object and access its data, but you only have a copy of the data that lives on the server.
For example, if you were to implement the following procedure:
const procedure = new Procedure((x) => (x.foo = "bar")).bind("tcp://*:33333");
And then call it like so:
let obj = { foo: 123 };
await call("tcp://localhost:33333", obj);
console.log(obj); // outputs '{ foo: 123 }'
The obj
object would remain unchanged, because the procedure is acting on a clone of the object, not the object itself. First, the object is encoded for transmission by msgpack, then sent across the wire by nanomsg, and finally decoded by msgpack at the other end into a brand new object.
When unhandled exceptions occur during execution of a procedure, the procedure safely passes an error message back to be thrown at the callsite:
const procedure = new Procedure((n) => n ** 2);
procedure.bind("tcp://*:5000");
let x = { foo: "bar" };
let xSquared = await call("tcp://localhost:5000", x);
// throws ProcedureExecutionError: An unhandled exception was thrown during procedure execution.
There are a number of custom ProcedureErrors, all relating to a specific class of error, e.g.
- the procedure was not found at the endpoint,
- the request timed out while waiting for a response,
- the request was cancelled by the client,
- an unhandled exception was thrown internally by either the server or the client,
- etc.
In the event that you want to expose more detailed information back to the caller when an error occurs, you can simply throw a ProcedureError yourself:
import { ProcedureExecutionError } from "@procedure-rpc/procedure.js/errors";
const procedure = new Procedure((n) => {
if (typeof n !== "number") {
throw new ProcedureExecutionError(
`Expected n to be a number, got '${typeof n}'`
);
}
return n ** 2;
}).bind("tcp://*:5000");
let x = { foo: "bar" };
let xSquared = await call("tcp://localhost:5000", x);
// throws ProcedureExecutionError: Expected n to be a number, got 'object'
You can optionally pass an object into the constructor of a ProcedureError and it will be attached to the data
property of the thrown error:
import { ProcedureExecutionError } from "@procedure-rpc/procedure.js/errors";
const procedure = new Procedure((n) => {
if (typeof n !== "number") {
throw new ProcedureExecutionError(
`Expected n to be a number, got '${typeof n}'`,
{ n }
);
}
return n ** 2;
}).bind("tcp://*:5000");
let x = { foo: "bar" },
xSquared;
try {
xSquared = await call("tcp://localhost:5000", x);
} catch (e) {
console.error(e?.name, "-", e?.message, e?.data);
}
// outputs ProcedureExecutionError - Expected n to be a number, got 'object' {
// n: {
// foo: 'bar'
// }
// }
The full API reference for procedure.js is available on GitHub Pages.
The examples in this readme all use TCP to demonstrate the most common use case for RPC. However, Procedure is built on top of nanomsg, which means it supports all of the same transports that nanomsg does:
Call functions between threads or modules of the same process.
inproc://foobar
Call functions between different processes on the same host.
ipc://foobar.ipc
ipc:///tmp/test.ipc
ipc://my-app/my-procedure
On POSIX compliant systems (ubuntu, macOS, etc.), UNIX domain sockets are used and IPC addresses are file references. Both relative (ipc://foobar.ipc
) and absolute (ipc:///tmp/foobar.ipc
) paths may be used, assuming access rights on the files are set appropriately.
On Windows, named pipes are used and IPC addresses are arbitrary case-insensitive strings containing any characters except backslash (\
).
Call functions between processes across TCP with support for IPv4 and IPv6 addresses and DNS names*.
tcp://*:80
tcp://192.168.0.5:5600
tcp://localhost:33000
*
TLS (tcp+tls://
) is not currently supported.
* DNS names are only supported when calling a procedure, not when defining.
Call functions between processes across WebSockets over TCP with support for both IPv4 and IPv6 addresses and DNS names*.
ws://*
ws://127.0.0.1:8080
ws://example.com
*
TLS (wss://
) is not currently supported.
* DNS names are only supported when calling a procedure, not when defining.
Procedure has no way of knowing what the parameter or return types of the procedure at the other end of the call will be. If you rewrite a procedure to return a different type or to accept a different parameter type, you will only get errors at runtime, not at compile time.
Therefore, if you are developing procedures for public consumption, be mindful of the fact that breaking changes on the same endpoint will result in unhappy consumers!
If you do need to make breaking changes to a procedure, it is recommended to either:
-
implement the breaking changes on a new endpoint, while keeping the original available:
myFunction(x) { return isNaN(x); // return boolean indicating whether x is NaN } myFunctionV2(x) { if (isNaN(x)) { // do stuff with x when it is NaN return true; } // breaking change, we no longer return a boolean in all cases } const procedure = new Procedure(myFunction).bind('tcp://*:33000'); const procedureV2 = new Procedure(myFunctionV2).bind('tcp://*:33001');
const v1Result = await call("tcp://localhost:33000"); // returns false const v2Result = await call("tcp://localhost:33001"); // returns undefined
-
use a parameter or property to specify a version modifier, defaulting to the original when unspecified:
myFunction(x) { return isNaN(x); // return boolean indicating whether x is NaN } myFunctionV2(x) { if (isNaN(x)) { // do stuff with x when it is NaN return true; } // breaking change, we no longer return a boolean in all cases } const procedure = new Procedure(options => { switch (options?.version) { case 2: return myFunctionV2(options.x); default: return myFunction(options.x); } }).bind('tcp://*:33000');
const v1Result = await call("tcp://localhost:33000"); // returns false const v2Result = await call("tcp://localhost:33000", { version: 2 }); //returns undefined
You may prefer to use a semver compatible string for versioning.
As Procedure is designed around nanomsg and msgpack, it can be implemented in any language that has both a nanomsg binding and a msgpack implementation.
Presently, the only official implementation of Procedure is procedure.js for Node.js, but a .NET implementation written in C# and a stripped-down browser library for calling procedures via the WS transport are currently being worked on.
If you would like to contribute a Procedure implementation in another language, please feel free! Create a GitHub repository for the language implementation and open an issue with us once it's ready for review! 💜
procedure.js is licensed under MIT © 2022 Tobey Blaber.