Skip to content

Procedure-RPC/procedure.js

Repository files navigation

procedure.js 🔗

The simple RPC framework for Node.js.

npm package version npm package downloads typedocs coverage code quality license

npm test publish code coverage publish package publish docs

github twitter GitHub Sponsors donation button PayPal donation button

Description

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!

Table of contents

Install

npm i @procedure-rpc/procedure.js

Usage

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'

async/await

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

Parameters and return types

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.

A note about null and undefined

Optional parameter support

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.

null and undefined properties

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.

Pass by reference?

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.

Error handling

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.

Custom error messages

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'

Custom error data

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'
//   }
// }

API reference

The full API reference for procedure.js is available on GitHub Pages.

Quick links

Transports: More than just TCP!

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:

INPROC: intraprocess

Call functions between threads or modules of the same process.

  • inproc://foobar

IPC: intra/interprocess

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 (\).

TCP: intra/inter-network over TCP/IP

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.

WS: intra/inter-network over WebSockets

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.

Handling breaking changes to your procedures

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.

Language implementations

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! 💜

License

procedure.js is licensed under MIT © 2022 Tobey Blaber.