Node.js: run a shell command in real time with Fastify and WebSockets

Node.js: run a shell command in real time with Fastify and WebSockets

In this tutorial we're going to run a shell command in real time by using Fastify and WebSockets.

In this tutorial we're going to run a shell command in real time by using Fastify and WebSockets.

Fastify comes with a really effective plugin that handles a web socket connection for us. This plugin is part of the Fastify's official ecosystem and is named @fastify/websocket.

We're going to use a simple ping command in order to see on a web page the output coming directly from the shell as the terminal is processing the provided command.

As the terminal yields a new line of output, this new line will be sent to the client through a web socket thus giving the user a real time experience.

Users can provide a host name or an IP address, so we need to validate this kind of input in order to avoid potential shell injection attacks.

First, we need to install the required NPM packages:

npm install fastify @fastify/websocket @fastify/static validator

Our app will simply have a form on a static HTML page and an API endpoint where we can use Web Sockets through the websocket Fastify plugin.

The logic behind the shell command can be actually wrapped within a specific class.

'use strict';

const { spawn } = require('child_process');
const validator = require('validator');

class Ping {
    constructor(host, times = 3) {
        this.host = host;
        this.times = times;
    }

    validate() {
        if(!validator.isFQDN(this.host) && !validator.isIP(this.host)) {
            return false;
        }
        if(this.times < 1 || this.times > 10) {
            return false;
        }
        const times = this.times.toString();

        return validator.isInt(times);
    }

    send({ ondata = function () {}, onerror = function () {}, onclose = function () {} }) {
        if(!this.validate()) {
            throw new Error('Invalid parameters.');
        }
        const cmd = spawn('ping', ['-c', this.times, this.host]);

        cmd.stdout.on('data', data => {
            ondata(data.toString());
        });

        cmd.stderr.on('data', data => {
            onerror(data.toString());
        });

        cmd.on('close', code => {
            onclose(code);
        });
    }
}

module.exports = Ping;

The validate() method makes use of the NPM's validator module to make sure that the provided host parameter will always be either a FQDN or an IPv4/IPv6 address. If this strict validation fails, the send() method will immediately throw an exception. This method contains the actual execution of our command within the shell. There are three scenarios that need to be addressed here:

  1. The execution proceeds without any error, so the output is sent to stdout.
  2. The execution returns an error, so the output is sent to stderr (e.g. the host resolution fails).
  3. The command finally exits with a status code. This code is handled by the close event.

We have defined three specific callback functions that we can use when we bind our class to the Fastify's API route that manages a web socket connection:

'use strict';

const Ping = require('../lib/Ping');


module.exports = function (fastify, opts, done) {
    
    fastify.get('/ping', { websocket: true }, (connection, request) => {

        connection.socket.on('message', message => {

            const data = JSON.parse(message.toString());

            try {
                const ping = new Ping(data.host, data.times);
                const actions = {
                    ondata(data) {
                        connection.socket.send(data);
                    },
                    onerror(msg) {
                        connection.socket.send(`error: ${msg}`);
                    },
                    onclose(code) {
                        connection.socket.send(`process exited with code ${code}`);
                    }
                };
                ping.send(actions);
            } catch (err) {
                connection.socket.send('Request error.');
            }

        });

    });
    done();
};    

A JavaScript client sends the JSON string containing the host name and the number or retries. The server-side code gets this string once a web socket connection is established. Then the shell command is executed, and every time one of the aforementioned shell events are triggered, we send the returned output to the client by using the socket's send() method.

The client-side code is pretty straightforward:

 const form = document.getElementById('ping-form');
            if(form !== null) {
                const response =  document.getElementById('output');
                const hostInput = document.getElementById('host');
                const submit = form.querySelector('input[type="submit"]');

                const wsURL = 'ws://' + location.host + '/api/ping';
                const wS = new WebSocket(wsURL);

                const handleWSMsg = (data, target) => {
                    if(data.includes('Error') || data.includes('error:')) {
                        target.innerHTML = data;
                        return false;
                    }
                    if(!data.includes('process')) {
                        let line = document.createElement('div');
                        line.innerText = data;
                        target.appendChild(line);
                    } else {
                        let close = document.createElement('div');
                        close.innerText = data;
                        target.appendChild(close);
                        submit.removeAttribute('disabled', 'disabled');
                    }
                };

                wS.onmessage = evt => {
                    handleWSMsg(evt.data, response);
                };


                form.addEventListener('submit', e => {
                    e.preventDefault();
                    response.innerHTML = '';
                    submit.setAttribute('disabled', 'disabled');
                    const host = hostInput.value;
                    const times = 3;

                    const data = {
                        host,
                        times
                    };
                    wS.send(JSON.stringify(data));
                }, false);
            }

Our client establishes the initial connection by opening a new web socket on the Fastify's endpoint. When the form is submitted, a JSON string is sent to Fastify containing the host name and the number of attempts. The message event handler listens for every message received from the server and updates the DOM accordingly. This can be achieved by reading the data property of the message event object.

Conclusion

A plugin is generally a better solution when it comes to keep our code as simple and clean as possible. Instead of using a Web Socket server globally when the only thing we need is a dedicated API endpoint, Fastify provides us with a more flexible tool that can be further customized in order to meet our needs.

Source code

Node.js: ping command with Fastify and Web Socket