IPC between NodeJS & Python

At Atri Labs, we are making it easy for web development and ML product development to go hand-in-hand. One of the challenges is how NodeJS and Python processes can communicate with each other.

I am sending and receiving data between Python and NodeJS through IPC. These sockets are unix domain sockets in Unix based OS and named pipes in Windows. I cannot come up with a better solution than this because of the following limitations with other approaches:

We can use IPC (Inter Process Communication) to send and receive data between Python and NodeJS. There are many ways of doing IPC:

  1. Using stdin/stdout - The disadvantage is that we won't be able to write print/debug statements for debugging purposes.

  2. Using Unix named pipes - The disadvantage is that the equivalent doesn't exist in Windows. The Unix named pipes are not equivalent to windows named pipes. The windows named pipes is more of a client-server thing.

  3. TCP network sockets - Don't want to bind to a port. This entails finding a free port first which becomes messy.

Solution

The best approach is to use different IPC in Windows and Unix based operating system. In Unix based system, we can use Unix domain sockets. In Windows, we can use named pipes.

NodeJS IPC Server

The code for creating a NodeJS server for both named pipes in Windows & Unix domain sockets in Unix is the same as shown below. The difference is what we pass to the `server.listen` function call.

Full code visit this GitHub repo link.

import net from "net";

export function createIPCServer(callbacks: {
  onClientSocketEnd?: (data: string) => void;
}) {
  const { onClientSocketEnd } = callbacks;

  const server = net.createServer((socket) => {
    let chunk = "";
    socket.on("data", (data) => {
      chunk = chunk + data.toString();
    });
    socket.on("end", function () {
      onClientSocketEnd?.(chunk);
      server.close();
    });
  });

  return server;
}

The code below shows how we call server.listen in Windows and Unix. You can notice that we are creating a named pipe filename for Windows and a Unix domain socket filename for Window. For full code refer to this.

import { generateSocketFilename } from "./generateSocketFilename";
import { generatePipePath } from "./generatePipePath";
import { createIPCServer } from "./createIPCServer";
import { executeChildProcess } from "./executeChildProcess";

/**
 * The child process will be provided ATRI_IPC_PATH
 * with the path to socket file or pipe path.
 *
 * The process.env is ignored if custom env is provided.
 */
export function runIPC(
  options: {
    // default is atri
    prefix?: string;
    // default is empty string
    suffix?: string;
    // default is os.tmpdir()
    tmpdir?: string;
    cmd: string;
    args?: string[];
    env?: typeof process.env;
    abortController?: AbortController;
  },
  callbacks?: {
    onClientSocketEnd?: (data: string) => void;
    onStdOut?: (data: any) => void;
    onStdError?: (data: any) => void;
    onChildProcessClose?: (code: number | null) => void;
    onServerListen?: (ipcPath: string) => void;
  }
) {
  const server = createIPCServer({
    onClientSocketEnd: callbacks?.onClientSocketEnd,
  });
  const ATRI_IPC_PATH =
    process.platform === "win32"
      ? generatePipePath(options)
      : generateSocketFilename(options);
  const extraEnv = { ATRI_IPC_PATH };

  server.listen(ATRI_IPC_PATH, () => {
    callbacks?.onServerListen?.(ATRI_IPC_PATH);
    executeChildProcess(
      {
        cmd: options.cmd,
        args: options.args,
        env: {
          ...(options.env !== undefined ? options.env : process.env),
          ...extraEnv,
        },
        abortController: options.abortController,
      },
      { onStdError: callbacks?.onStdError, onStdOut: callbacks?.onStdOut }
    ).then(callbacks?.onChildProcessClose);
  });

  return server;
}

The named pipe filename must start with \\.\pipe\put_your_pipename_here.

import crypto from "crypto";

export function generatePipePath(options: {
  prefix?: string;
  suffix?: string;
}) {
  let { prefix, suffix } = options;
  prefix = prefix !== undefined ? prefix : "atri";
  suffix = suffix !== undefined ? suffix : "";
  const randomString = crypto.randomBytes(16).toString("hex");
  return `\\\\.\\pipe\\${prefix}${randomString}${suffix}`;
}

Python IPC client

Creating a named socket client is a bit tricky. You need to install win32file and win32pipe python packages. The code for Python IPC clients looks as below (link to full code here):

import os
import platform

def send_ipc_msg(msg: bytes):
    ATRI_IPC_PATH = os.environ["ATRI_IPC_PATH"]
    if platform.system() == "Windows":
        import win32file
        import win32pipe
        handle = win32file.CreateFile(ATRI_IPC_PATH, win32file.GENERIC_READ | win32file.GENERIC_WRITE,
                              0, None, win32file.OPEN_EXISTING, win32file.FILE_ATTRIBUTE_NORMAL, None)
        win32file.WriteFile(handle, msg)
    else:
        import socket
        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        s.connect(ATRI_IPC_PATH)
        s.send(msg)
        s.close()

At Atri Labs, we are building the first web framework that takes both I/O and compute into account. Please support us by giving our repo a star.