Skip to content

A simple IPC server with express-like request and route handling that also supports broadcasting to multiple channels.

License

Notifications You must be signed in to change notification settings

h-sifat/express-ipc

Repository files navigation

Express-IPC

Module Type Npm Version GitHub Tag GitHub Issues

A simple IPC (Inter Process Communication) server with Express-like request and route handling that also supports broadcasting to multiple channels. It also provides an easy to use axios like client to communicate with the server.

It usages unix domain socket on Unix OS and windows named pipe on Windows OS. Which results in very fast communication speed (e.g., < 5ms latency for a 300Kb payload). Though it runs on the TCP protocol, it's request and response objects are designed like the HTTP's, meaning a request has properties like method, headers, query, body and so on. It is specifically designed to perform CRUD operations with plain JSON objects (request and response) and where the server has the ability to broadcast arbitrary JSON data to multiple channels (which clients must subscribe to, to receive the data).

Quick Links

  1. Installing
  2. Importing
  3. API Documentation
  4. Todo
  5. Development

Example Usages

Request and Response

server.js

const { Server } = require("express-ipc");

const socketPath = "./pipe";
const server = new Server();

const users = [
  { id: 1, name: "Alex" },
  { id: 2, name: "Alexa" },
];

server.get("/users/:id", ({ req, res }) => {
  const id = Number(req.params.id);
  const user = users.find((user) => user.id === id);

  if (user) res.send(user);
  else res.send({ message: `No user found with id: ${id}` }, { isError: true });
});

server.listen({
  path: socketPath,
  deleteSocketBeforeListening: true,
  callback() {
    console.log(`Server running on socket: ${server.socketPath}`);
  },
});

client.js

const { Client } = require("express-ipc");
const socketPath = "./pipe";

main();

async function main() {
  const client = new Client({ path: socketPath });

  try {
    const response = await client.get("/users/1");
    console.log(response);
  } catch (ex) {
    console.log(ex);
  }

  client.close();
}

Data Broadcasting

server.js

const { Server } = require("express-ipc");

const socketPath = "./pipe";
const server = new Server();

server.createChannels("test");

let count = 1;
setInterval(() => {
  server.broadcast({ channel: "test", data: { count: count++ } });
}, 1000);

server.listen({ path: socketPath, deleteSocketBeforeListening: true });

client.js

const { Client } = require("express-ipc");
const socketPath = "./pipe";

main();

async function main() {
  const client = new Client({ path: socketPath });

  await client.subscribe("test");

  client.on("broadcast", console.log);
}

Installation

npm install express-ipc

Importing

It uses the UMD module system so it supports all JavaScript module systems (es6, commonjs, and so on).

commonjs

const { Server, Client } = require("express-ipc");

es6

import { Server, Client } from "express-ipc";

Partial Importing

In case you only want to import the Server or the Client and don't want to carry extra baggage in your application. If you're using a module bundler, it's probably not necessary as unused code gets tree-shaken.

server

// es6
import { default as expressIpc } from "express-ipc/dist/server.js";
const Server = expressIpc.Server;

// or commonjs:
const { Server } = require("express-ipc/dist/server");

client

import { default as expressIpc } from "express-ipc/dist/client.js";
const Client = expressIpc.Client;

// or commonjs
const { Client } = require("express-ipc/dist/client");

API Documentation

Before we start, it is assumed that you are familiar with Express.js because the route handling and path pattern work exactly like Express with very little difference. So, I would highly recommend you to read the documentation of Express first.

Table Of Contents

  1. Server

    1. Constructor: Server()
    2. server.socketPath
    3. server.listen()
    4. server.close()
    5. server.createChannels()
    6. server.deleteChannels()
    7. server.broadcast()
    8. server.on()
  2. Routing

    1. Method
    2. Path
    3. Handler/Middleware
    4. Request Object (req)
    5. Response Object (res)
    6. Next Function (next)
    7. Error Handling
  3. Client

    1. Constructor: Client()
    2. client.subscribe()
    3. client.unsubscribe()
    4. client.request()
    5. client.get()
    6. client.delete()
    7. client.post()
    8. client.patch()
    9. client.on()
      1. Receiving Broadcasts
      2. Handling Client Errors
    10. client.close()

Server

Go to Table Of Contents

Constructor: Server()

The Server class constructor takes an optional object argument with two optional properties. It has the following interface:

interface ServerConstructor_Argument {
  delimiter?: string;
  socketRoot?: string;
}
property default value description
delimiter "\f" This character is used to indicate the end a of serialized request or response data in the socket.
socketRoot os.tmpdir() If no absolute path is provided for the socket in the server.listen method's argument then the socket will be created in this directory.

Example:

const server = new Server({ delimiter: "\n", socketRoot: "./sockets" });

server.socketPath

A getter which returns the active socket path (a string). If the server is not running it returns undefined.

Go to Table Of Contents

server.listen()

Listens on the given socket path for requests. It takes a single object argument that has the following interface:

interface Listen_Argument {
  callback?: () => void;
  deleteSocketBeforeListening?: boolean;
  path: string | { namespace: string; id: string };
}
property description
path If path is a string than it should refer to a socket's absolute path. Otherwise, if it is an object of type {namespace: string; id: string} then the socketPath will be constructed from: path.join(socketRoot, namespace + "_" + id)
deleteSocketBeforeListening If the socket file already exists and we try to listen on it, an exception with the code "EADDRINUSE" will be thrown. To avoid this we can set this flag to true.
callback If provided then it'll be called when the server starts listening.

Example:

server.listen({
  deleteSocketBeforeListening: true,
  path: { namespace: "test_app", id: "v1" },
  callback() {
    console.log("Server running on socket: ", server.socketPath);
  },
});

Go to Table Of Contents

server.close()

Closes a server. It takes an optional callback function.

Go to Table Of Contents

server.createChannels()

Creates broadcast channels. It takes a rest argument or string or an array of strings.

Example:

server.createChannels("a", "b", ["c", "d"], "e");

Go to Table Of Contents

server.deleteChannels()

Deletes broadcast channels. It's signature is the same as server.createChannels.

Example:

server.deleteChannels(["a", "b", "e"], "c", "d");

Go to Table Of Contents

server.broadcast()

Broadcasts data to a channel. It takes a single object argument with the following interface:

interface Broadcast_Argument {
  data: object;
  channel: string;
  blacklist?: number[];
}
property description
data The data to broadcast.
channel The channel name.
blacklist An array of connectionIds. This can used to stop some connection from receiving the broadcast.

Tip: We can get the connectionId from a response object.

Example:

server.post("/exciting-news", ({ req, res }) => {
  // as this connection itself brought the news,
  // we don't need to echo the news back to it.
  // I know it's not a good example but it shows the functionality

  server.broadcast({
    data: req.body,
    channel: "exciting-news",
    blacklist: [res.connectionId],
  });
});

Go to Table Of Contents

server.on()

With this method we can add event listeners on the underlying socket server created with the net.createServer function.

Example:

server.on("error", (err) => {});

Go to Table Of Contents

Routing

Routing works similar to Express.js. It takes the following structure:

server.method(path, handler | middleware);

// example
server.get("/users/:id", ({ req, res, next }) => {});

Go to Table Of Contents

Method

Request methods. express-ipc only supports these four methods:

  1. get
  2. post
  3. patch
  4. delete

Additionally We can use all and use to define routes on paths that runs for any request method. Though all and use methods are similar but we can use the use method to define application level ( runs regardless of the request path) middlewares.

Example: Application level middleware

server.use(({ req, res, next }) => {
  // ... do something with the request object

  next(); // pass the request to the next middleware
});

Go to Table Of Contents

Path

The route path works exactly like express because it uses the same path-to-regexp package to parse route paths, that express uses. See the express documentation for Route Path.

Go to Table Of Contents

Handler / Middleware

The handler/middleware functions' signature is a little different from express. In Express, a middleware function has the following signature:

function (req, res, next) {}

It takes three arguments. In contrast, express-ipc packs these three arguments into an object.

function (arg) {arg.req; arg.res; arg.next}

// or better, if we destructure them
function ({req, res, next}) {}

// we can only the pick  properties that we are interested in
function ({req, next}) {}

Go to Table Of Contents

Error Handler / Middleware

In Express, an error handler takes four arguments:

function (err, req, res, next) {}

On the other hand, express-ipc takes two arguments:

function (reqResNextObject, err) {}

// only picking the required properties
function ({res}, err) {}

Handlers / Middlewares can be defined in various ways. Suppose that, we have two handlers named handler_a, handler_b and an error handler named error_handler. Then all the following examples are equivalent.

Example: 1

server.post("/users", handler_a, handler_b, error_handler);
// or
server.post("/users", [handler_a, error_handler, handler_b]);
// or
server.post("/users", handler_a, [handler_b, error_handler]);
// or
server.post("/users", [handler_a, handler_b], error_handler);
// or
server.post("/users", handler_a, error_handler, [handler_b]);

Example: 2

server.post("/users", handler_a, handler_b);
server.post("/users", error_handler);

Example: 3

server.post("/users", error_handler);
server.post("/users", handler_a, handler_b);

Note: Error handlers are stored in different stacks than general request handlers or middlewares. So, it's ok if we mix them up.

Go to Table Of Contents

Request Object (req)

The request object or the req property in a handler's / middleware's first argument has the following interface.

interface Request {
  path: string;
  params: object;
  readonly url: string;
  readonly query: object;
  readonly headers: object;
  readonly body: object | null;
  readonly method: "get" | "post" | "delete" | "patch";
}

All the properties are readonly except path and params. Meaning we cannot reassign the readonly properties with new values. But, if the property is an object, we can modify it.

Example:

server.get("/users/:id", ({ req }) => {
  // reassigning: forbidden
  req.body = null; // will throw an error in strict mode

  // modifying: allowed
  req.body.test = "new property";
});

Go to Table Of Contents

Response Object (res)

The response object (res from a handler's / middleware's first argument) has the following interface:

interface Response {
  get isSent(): boolean;
  get headers(): object;
  get connectionId(): number;
  send(
    body?: object | null,
    options?: { endConnection?: boolean; isError?: boolean }
  ): void;
}
property description
isSent a getter; returns a boolean value indicating whether the send method has already been called.
headers a getter; returns the headers object of the response. Its properties are modifiable.
connectionId a getter; returns the connectionId of the underlying socket. Can be used to blacklist a connection when broadcasting data
send A method to send the response. It takes two optional arguments: first body and second options. If no argument is provided then the response body will be null. We can use the isError flag to mark the response as an error response and the endConnection to end the underlying socket.

Example

server.get("/users/:id", ({ req, res }) => {
  const id = Number(req.params.id);

  if (Number.isNaN(id)) {
    res.headers.statusCode = 400;
    return res.send({ message: "Invalid id" }, { isError: true });
  }

  const user = /* get the user somehow */;

  // res.isSent: false
  res.send(user);
  // res.isSent: true
});

Go to Table Of Contents

The next function

The next function from a handler's / middleware's first argument can be used to pass control to the next middleware or error handler.

Example:

server.get(
  "/users",
  ({ next }) => {
    next(); // pass control to the next handler
  },
  ({ res }) => {
    res.send(/* users */);
  }
);
server.get(
  "/users",
  ({ next }) => {
    // pass control to the next error handler
    next(new Error("failed"));
  },
  ({ res }, error) => {
    res.send(/* error response */, {isError: true});
  }
);

Go to Table Of Contents

Error Handling

If a handler / middleware throws an exception or rejects a promise it'll be automatically caught and passed to the next error handler or the default error handler (if no error handler is defined). But in any other cases, we've to pass an error manually to the next function to move to the error handlers.

Example:

Suppose that, we have a getUsers function that takes a callback function. In this case we can handle the error as shown in the following snippet.

server.get("/users", ({ next, res }) => {
  getUsers((error, users) => {
    if (error) next(error);
    else res.send(users);
  });
});

server.get("/users", ({ res }, error) => {
  // do something with the error
});

Go to Table Of Contents

Client

Before we start, we need to know the request and response object's shape.

Go to Table Of Contents

RequestPayload
interface RequestPayload {
  url: string;
  query: object;
  headers: object;
  body: object | null;
  method: "get" | "post" | "delete" | "patch";
}

Go to Table Of Contents

ResponsePayload
interface ResponsePayload {
  headers: object;
  body: object | null;
}

Go to Table Of Contents

Constructor: Client()

The Client constructor takes a single object as it's argument which has the following interface:

interface ClientConstructor_Argument {
  delimiter?: string;
  socketRoot?: string;
  path: Listen_Argument["path"];
}
property default value description
delimiter "\f" See ServerConstructor_Argument.delimiter
socketRoot os.tmpdir() See ServerConstructor_Argument.socketRoot
path See Listen_Argument.path

Go to Table Of Contents

client.subscribe()

Subscribe to channels. It has the following signature:

subscribe(
  ...channelsRestArg: (string | string[])[]
): Promise<ResponsePayload>

See ResponsePayload

Example:

await client.subscribe("a", "b", ["c", "d"], "e");

Go to Table Of Contents

client.unsubscribe()

Unsubscribe to channels. It has the following signature:

unsubscribe(
  ...channelsRestArg: (string | string[])[]
): Promise<ResponsePayload>

See ResponsePayload

Example:

await client.unsubscribe(["a", "b"], "c", "d", "e");

Go to Table Of Contents

client.request()

This method can be used to make request to the server. It has the following signature:

interface Request_Argument {
  url: string;
  query?: object;
  headers?: object;
  body?: object | null;
  method: "get" | "post" | "delete" | "patch";
}

type request = (
  arg: Request_Argument,
  options?: { timeout?: number }
) => Promise<ResponsePayload>;

See ResponsePayload

Only the url and method property is required and the rest are optional.

Example:

const users = await client.request({ url: "/users", method: "get" });

We can also provide a timeout (in milliseconds) for the request. If the server doesn't respond within time then the request will be rejected with a timeout error.

const users = await client.request(
  { url: "/users", method: "get" },
  { timeout: 1000 }
);

Note: If the response does arrive after the request has been timed out, an "unhandled_response" event will be emitted.

Tip: All the request methods (request, get, ...) are generic, so you can specify the body and headers type. I'm really busy to document them right now.

interface User {
  name: string;
  email: string;
}

const users = await client.request<User[]>({
  url: "/users",
  method: "get",
});
// typeof users: Users[]

Go to Table Of Contents

client.get()

The get method is similar to the client.request method. It just sets the method property to "get" for us.

It has the following signature:

type get = (
  url: string,
  other?: {
    query?: object;
    headers?: object;
    timeout?: number;
    body?: object | null;
  }
) => Promise<ResponsePayload>;

See ResponsePayload

The other parameter is optional, so are all of its properties.

Example:

const users = await client.get("/users", {
  headers: { "x-auth-token": "aa9fa6d82308" },
});

Go to Table Of Contents

client.delete()

Sends a request with the request-method set to "delete". It has exactly the same signature as the client.get() method.

Go to Table Of Contents

client.post()

Sends a request with the request-method set to "post". Signature:

type post = (
  url: string,
  other: {
    query?: object;
    headers?: object;
    timeout?: number;
    body: object | null;
  }
) => Promise<ResponsePayload>;

See ResponsePayload

For the client.post method the second parameter is required and it's body property is also required.

Example:

const user = { id: 1, name: "Alex" };
const response = await client.post("/users", { body: user });

Go to Table Of Contents

client.patch()

Sends a request with the request-method set to "patch". It has exactly the same signature as client.post() method.

Example:

const edited = await client.patch("/users/1", {
  body: { name: "Alex Smith" },
});

Go to Table Of Contents

client.on()

The Client class inherits from the EventEmitter class. It emits the following events.

  1. "error": mostly for socket errors
  2. "broadcast: for receiving broadcast
  3. "unhandled_response": for timed out requests

We can use the client.on method to subscribe to these events.

Go to Table Of Contents

Receiving Broadcasts

We can receive broadcasts by adding an event listener on the "broadcast" event. The broadcast data has the following interface:

interface Broadcast {
  data: any;
  channel: string;
}

Example:

client.on("broadcast", (data) => {});

Go to Table Of Contents

Handling Errors

Subscribe to the "socket_error" event to get notified about any errors on the underlying socket. With this event you can get notified if the server quits or closes your client connection.

Example:

client.on("socket_error", (error) => {});

Go to Table Of Contents

client.close()

Closes the underlying socket and no requests can be sent after the socket is closed.

Todo

  • Do thorough testing (currently coverage is 90%).
  • Support data formats other than JSON (e.g., Buffer)

Development

# Run tests
npm test
# Run tests in watch mode
npm test:watch
# Run tests with coverage
npm test:coverage

# Build / Bundle
npm run build

If you find a bug or want to improve something please feel free to open an issue. Pull requests are also welcomed 💝. Finally, if you appreciate me writing a docs of 900 lines, please give this project a ⭐ on github. So that, I can feel a little better about the time I spent/wasted on this project.

About

A simple IPC server with express-like request and route handling that also supports broadcasting to multiple channels.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages