c++node.jsprintfstdoutconsole.log

Intercept C++ module "stdout" from Node.js


I'm currently using the Noble Node.js module for Bluetooth (BLE) connections. The Noble module will occasionally print errors directly to the console via C++'s printf function. Noble has no Node.js bindings I can use to listen for this output, meaning I need a way to manually intercept the output.

I've tried overwriting Node's console.log and process.std..., but since Noble is using C++'s printf (and prints to the console directly), this approach does not work.

I would like to refrain from modifying the C++ directly, since I would have to change every instance of printf, making this solution difficult to maintain in the future. All interception needs to be done on the Node.js side. Piping the console output from a child process is also less than desirable.

Is there a simple way to do this?


Solution

  • EDIT 1: Everything I have below works on its own, but I'm having trouble translating this over to my actual library code. I can't get it to work within Electron with Webpack, since my library code is getting bundled with the Electron code, leading to errors.


    EDIT 2: I realized I had been asking the wrong question the entire time. The reason I needed Noble's logs was due to an error message it would print after a connection timeout (since there was no event hook offered by the library for errors). However, I implemented auto-refresh (where the list of available Bluetooth devices will refresh itself every few seconds), and if a connection fails, it will almost always disappear after a refresh. Problem solved!

    I've left the original answer below, as it may still be helpful to some. I'm not entirely sure if it actually captures Noble's output, but it may work for your case.


    I think I've come up with my own solution using spawn. Here's lib.js, the file containing my own "library code" (I'm only showing one example function here, but this is easily extensible):

    // ============================================ IMPORTS ============================================
    
    import events from "node:events";
    import { spawn } from "node:child_process";
    
    // ============================================ GLOBALS ============================================
    
    const emitter = new events.EventEmitter();
    let childProcess;
    let id = 0;
    
    //  ======================================== INITIALIZATION ========================================
    
    function initParentProcess() {
      // Create a child process of this file using spawn:
      childProcess = spawn("node", ["."], {
        stdio: ["pipe", "pipe", "pipe", "ipc"],
        env: { IS_CHILD: "true" },
      });
    
      // Listen for stdout/stderr output from the child process and forward it to the importing file;
      // Note that ".slice(0, -1)" removes ending newlines; you may or may not want this:
      childProcess.stdout?.on("data", (data) => {
        emitter.emit("stdout", data.toString().slice(0, -1));
      });
      childProcess.stderr?.on("data", (data) => {
        emitter.emit("stderr", data.toString().slice(0, -1));
      });
    
      // Once the child process is ready, emit the "ready" event:
      childProcess.once("message", (state) => {
        if (state === "ready") emitter.emit("ready");
      });
    }
    
    function initChildProcess() {
      // Satisfy the ESLint overlords:
      if (!process.send) return;
    
      // Handle incoming requests from the parent process:
      process.on("message", async ({ id, func, args: args }) => {
        // Satisfy the ESLint overlords:
        if (!process.send) return;
    
        // Handle each function and their arguments:
        switch (func) {
          case "example":
            process.send({ id, res: await _example(...args) });
            break;
          // Other functions should be handled here
        }
      });
    
      // Notify the parent process that the child process is ready:
      process.send("ready");
    }
    
    if (!process.env.IS_CHILD) {
      // If not a child process, this module must have been imported from another file:
      initParentProcess();
    } else {
      // Otherwise, this instance is the child process:
      initChildProcess();
    }
    
    // ======================================= ABSTRACTION LAYER =======================================
    
    // The handle function will forward work to the child process, where it will be completed.
    // Upon completion, the handle function's Promise will resolve with the result of the work.
    function handle(func, ...args) {
      // Handle setup:
      const handleID = id++;
      const listener = (resolve, id, res) => {
        // Only handle the message if the ID matches the handle ID:
        if (id !== handleID) return;
    
        // Remove the listener and resolve with the result:
        process.removeListener("message", listener);
        resolve(res);
      };
    
      // Return a Promise that resolves once we recieve a response from the child process:
      return new Promise((resolve) => {
        childProcess.send({ id: handleID, func, args });
        childProcess.on("message", ({ id, res }) => listener(resolve, id, res));
      });
    }
    
    // ================================= CHILD PROCESS IMPLEMENTATIONS =================================
    
    // This is the actual implementation of "example":
    async function _example(sampleArg) {
      return `Example output; sampleArg=${sampleArg}`;
    }
    
    //  ================================ PARENT PROCESS IMPLEMENTATIONS ================================
    
    // This is the module export for the importing file to use:
    export async function example(sampleArg) {
      return await handle("example", sampleArg);
    }
    
    //  ====================================== SUPPORTING EXPORTS ======================================
    
    // Event emitter exports so the importing file knows when this module is ready:
    export function on(event, callback) {
      emitter.on(event, callback);
    }
    export function once(event, callback) {
      emitter.once(event, callback);
    }
    
    export function close() {
      childProcess.kill();
    }
    

    And here's main.js:

    import { once, example, close } from "./lib.js";
    
    once("ready", async () => {
      console.log("Ready!");
      const result1 = await example("test");
      const result2 = await example("hello world");
      console.log("Result 1:", result1);
      console.log("Result 2:", result2);
      close();
    });
    

    Running node main.js will print:

    Ready!
    Result 1: Example output; sampleArg=test
    Result 2: Example output; sampleArg=hello world
    

    Additionally, I can now listen for stdout and stderr from main.js as follows:

    import { on } from "./lib.js";
    
    on("stdout", (data) => {
      console.log(data);
    });
    on("stderr", (data) => {
      console.log(data);
    });
    

    The way this works is broken into the following steps:

    It seems somewhat complicated for how simple of a problem I'm trying to solve, but if it works, it works. Please let me know if there's any way to simplify this without making it harder to use the module from main.js.