Bash, Pipes, & The Socket Pre-Release

— Written by jwerle

The [Socket][socket-sdk-dev] is a framework for building distributed web applications with any type of back-end. Socket makes it easy to connect a back-end process to a WebView running a web application.

YOU NEED EARLY ACCESS, JOIN OUR DISCORD TO GET ACCESS NOW.

In this blog post, we'll explore the IPC interface exposed by Socket in the [Desktop API][desktop-api] and create a simple desktop application that uses a Bash back-end to send user supplied output through Named Pipes to the web application.

Background

Socket is a simple framework for building web applications. It uses a declarative configuration for specifying things like compiler flags, window height/width settings, executable names, and more. The framework ships with a utility command line interface program called ssc. The ssc program compiles your application and is a suitable tool for bundling applications for the App Store, packaging for distribution, or even simply running your compiled application quickly for testing. The ssc program can also be used to initialize a new project. This blog post covers a variety of usage provided by the ssc program.

IPC

The Socket uses a custom IPC interface that is based on a URI scheme that looks like this:

ipc://command?key=value\n

The command part of the URI can be a built-in command like send, stdout, show, hide, exit, navigate, or a custom application specific value that can be handled by the Main (back-end) process or Render (front-end) process of the application. Query string key and value arguments should be encoded as URI components and separated by an ampersand character (&). IPC URIs are delimited by a single newline character (\n).

See the IPC Desktop API for more information on how this works.

IO

Application IO occurs over the stdin and stdout streams of the Main process and the [window.system][desktop-api-methods] object of the Render process using the IPC interface described above. All application IO is orchestrated by the Bridge process, which is hidden from you.

The Main process can also emit custom events to the Render process with the send command which can be observed with window.addEventListener().

Main -> Render

## Print IPC URI from `Main` process to stdout for the `Bridge` process to
## handle and emit to the `Render` process as a custom event on `window`
echo -ne "ipc://send?index=0&event=hello&value=world\n"
window.addEventListener('hello', (event) => {
  console.log(event.detail) // 'world'
})

The Render process can emit custom events to the Main process with the send command which can be observed by reading from stdin.

Render -> Main

// Writes "ipc://send?event=hello&value=world\n" to stdin of the `Main` process
window.system.send({ event: 'hello', value: 'world' })
is_hello_event=0
## read from stdin
while read -r data; do
  ## detect `send` command in IPC
  if [[ "$data"  =~ ^ipc://send\? ]]; then
    ## read URI components `key=value` entries in an array
    IFS='&' read -r -d '' -a args < <(echo -e "$data" | sed 's/^.*?//g')

    ## for each `key=value`
    for arg in "${args[@]}"; do
      for (( i = 0; i < ${#arg}; ++i )); do
        ## look for '='
        if [ "${arg:$i:1}" == "=" ]; then
          break
        fi
      done

      key="${arg:0:$i}"
      value="${arg:(( $i + 1 ))}"

      if [ "$key" == "event" ] && [ "$value" == "hello" ]; then
        is_hello_event=1
      fi

      ## handle 'hello' event
      if (( is_hello_event == 1 )) && [ "$key" == "value" ]; then
        if [ -n "$value" ]; then
          echo -e "$value" ## 'world'
        fi
      fi
    done
  fi
done

Configuration

Application configuration for the [Socket][socket-sdk-dev] is simple and declared in a plain text file called socket.ini.

See the Socket Configuration for more information.

A simple configuration may look like:

name = MyApplication
title = My Application
output = build
version = 0.0.0

...

We'll explore more properties on socket.ini later in the blog post.

Named Pipes

In this blog post we'll make use of Named Pipes for asynchronous handling of application IO. Named pipes, or FIFOs (first-in-first-out) allow separate processes to read and write from a pipe by name. In this setup we'll use two named pipes that are directly connected to the stdin and stdout streams of the Main process for asynchronous IO communicating with the Render process. This is possible in bash with the mkfifo(1) command.

Setting Up a Project

In this section, we'll discuss setting up a new project, configuring it to build, and running a simple hello world to verify everything works.

Prerequisites

This blog post assumes a POSIX compliant OS because of the usage of named pipes and the mkfifo(1) command. The following operating systems are supported:

  • Linux
  • macOS

See Named Pipes in Windows for more information on why this is more difficult compared to POSIX environments.

Initializing a New Project

Before we get started, we'll need to create a new Socket project. The ssc command line interface program makes this easy with the --init flag from the current working directory.

mkdir bash-pipes-and-socket-sdk
cd bash-pipes-and-socket-sdk
ssc init

At this point we should have a brand new project initialized in the bash-pipes-and-socket-sdk directory.

tree

Running tree in the current working directory should yield the following results.

 .
 ├── socket.ini
 └── src
     └── index.html

1 directory, 2 files

The index.html file in the src/ directory should contain some simple HTML that may look like:

<html>Hello, World</html>

The socket.ini file should contain most properties needed to get started. However, we'll have to modify a few to get the example running.

Configuring Socket Project Settings

The socket.ini file created with ssc init contains an exhaustive list of possible key-value pairs for configuring the application. However, we'll only need to make use of a handful in this blog post. The initial configuration needed should look like:

# The name of the program
name: bash-pipes-and-socket-sdk

# The initial title of the window (can have spaces and symbols etc).
title: Bash, Pipes, & Socket

# A string that indicates the version of the cli tool and resources.
version: v0.0.1

# A directory is where your applications code is located.
input: src

# Shell command to build an application.
build: :

# The binary output path
output: build

# The name of the product executable
executable: bash-pipes-and-socket-sdk

# Advanced Compiler Settings (ie C++ compiler -02, -03, etc).
flags: -O1

# Advanced Compiler Settings for debug purposes (ie C++ compiler -g, etc).
debug_flags: -g -O3

# A boolean that determines if stdout and stderr should get forwarded
forward_console: true

# The Linux command to execute to spawn the "back-end" process.
linux_cmd: :

# The macOS command to execute to spawn the "back-end" process.
mac_cmd: :

# The initial height of the first window.
height: 512

# The initial width of the first window.
width: 1024

# Automatically determine architecture
arch: auto

Note the usage of the colon (:) for build, linux_cmd and mac_cmd. That is the syntax for no-operation. It is the NOP (or noop) operator for shell based languages.

Compiling and running the application would do nothing as there is no build command configured nor a linux_cmd or mac_cmd main command to launch and run the application.

A Simple Hello World to Verify Things Work

In this section, we'll configure the application for a simple "hello world" to verify that things work as expected.

We'll modify the build, linux_cmd, and mac_cmd properties in the socket.ini file to use simple inline Bash commands.

Build Command

First, we'll replace the Bash NOP (:) in the build property with a simple copy() function that copies source files (src/*) into the build directory.

# `copy(1)` gets the current build context as the first argument
build: copy() { cp src/* "$1"; }; copy

Notice that copy does not have arguments given to it even though it expects a single argument ($1). The ssc command line interface program during the build step will provide the output build directory where files should be installed or copied to. The root of the build output is configured with the output property, which we have set to build/.

Main Command

Next, we'll replace the Bash NOP (:) in the linux_cmd and mac_cmd properties with simple Bash that shows a window and navigates to the index.html file we copied from the src/ directory to the build output directory which is the current working directory of the application runtime. The $PWD shell variable, which points to the application runtime working directory can be safely used so we can get an absolute path to index.html.

linux_cmd: echo "ipc://show" && echo "ipc://navigate?value=file://$PWD/index.html"
mac_cmd: echo "ipc://show" && echo "ipc://navigate?value=file://$PWD/index.html"

Notice the file:// protocol used in the navigate command value argument. This is required to correctly navigate to a local file on disk. The path must be absolute.

Windows can be specified by their index as a query string argument index=N where N is a 0 based index. If omitted, index=0 is assumed and the default window is targeted.

Running the Hello World

At this point, the application has been configured for a simple "hello world" that can be used to verify things work as expected. The ssc command line interface program can compile the application and run it with the -r flag.

ssc build -r

If successful, you should see output that looks something like this:

• warning! $CXX env var not set, assuming defaults +0ms
• preparing build for linux +0ms
• package prepared +14ms
• copy () { cp src/* $1; }; copy build/bash-pipes-and-socket-sdk-dev_v0.0.1-1_x86_64/opt/bash-pipes-and-socket-sdk-dev --debug=1 +0ms
• ran user build command +4ms
• Creating Window#0 +1ms
• Showing Window#0 (seq=) +19ms

The application should launch and you should be presented a "Hello, World" that looks like this:

This verifies that we can configure the socket.ini file with custom build and runtime commands. We can use the ssc command line interface program to compile and run the "hello world" application as expected.

Building the Application

At this point, we have covered a bit about how the Socket works, how it is configured, and how to quickly get a verifiable hello world running with just simple Bash commands in the socket.ini file.

In this section, we'll create an application with a pure Bash back-end as the Main process and a simple web application in the Render process. The scope of the application will be to render a command line program's output in the Render process. We'll make use of a single dependency called xterm.js for rendering a terminal program output.

Writing the Front-End (Render Process)

The front-end application in the Render process will be pure HTML, CSS, and JavaScript. We will not use a build system. All code will be inline in the HTML with the exception of the xtermjs dependency, which will be included with <script/> and <link/> tags for the JavaScript and CSS source, respectively.

Writing the HTML

The HTML for this application will be pretty straightforward. We'll get started with this below which should replace the current contents of index.html in the src/ directory.

<!doctype html>
<html>
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    <title>Bash, Pipes, & Socket</title>
  </head>
  <body>
    <main></main>
  </body>
</html>

The <main/> DOM element will be the container for the application terminal output.

Let's verify how things look by running the application. We'll use the ssc build -r command, but we'll also include the -o flag which will tell the ssc command line interface program to only run the user build step, skipping the compile step for the native C++ application, which powers the Bridge process.

ssc build -r -o

If successful, you should see something like:

Styling with CSS

Styling the web application will be pretty minimal. We'll go for a dark terminal look.

First we'll reset the body padding/margin values and make the body full width and height.

body {
  background: #000;
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

The main DOM element will be the surface where we write output to. We'll give it a little bit of padding and a full width and height.

main {
  display: block;
  position: relative;
  padding: 4px;
  width: calc(100% - 8px);
  height: 100%;
}

We'll put this altogether into a <style /> tag in the <head /> of our HTML document which will result in an updated index.html that looks like:

<!doctype html>
<html>
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    <title>Bash, Pipes, & Socket</title>
    <style type="text/css" media="all">
      body {
        background: #000;
        padding: 0;
        margin: 0;
        width: 100%;
        height: 100%;
        overflow:
        hidden;
      }

      main {
        display: block;
        position: relative;
        padding: 4px;
        width: calc(100% - 8px);
        height: 100%;
      }
    </style>
  </head>
  <body>
    <main></main>
  </body>
</html>

Let's verify how things look again by running the application.

ssc build -r -o

If successful, you should see something like:

Including xtermjs

Before we move on to writing JavaScript for the front-end, we will need to include the xtermjs library dependencies which is exactly one JavaScript source file and one CSS source file. We'll include the necessary files with <script/> and <link/> tags. However, before we do that, we'll need to download the source files and modify our build command to copy them to our build output directory.

Vendor xterm.js and xterm.css

To ensure we can always have access to our dependency files, we'll put them in a new vendor/ directory next to the src/ directory.

First, lets create a new vendor/ directory, download xterm.js and xterm.css files with wget (or curl) from https://unpkg.com/

mkdir vendor
cd vendor
wget https://unpkg.com/xterm@3.0.1/dist/xterm.js
wget https://unpkg.com/xterm@3.0.1/dist/xterm.css

If successful, the vendor/ directory should look like this:

 .
 ├── xterm.css
 └── xterm.js

 0 directories, 2 files

Including xterm.js in Build Output

To include xtermjs dependency files in the build step, we'll need to modify the build command in the socket.ini file.

# `copy(1)` gets the current build context as the first argument
build: copy() { cp src/* vendor/* "$1"; }; copy

Changing the values in the socket.ini will require a recompile of the application's source files:

ssc build

Linking xterm.js and xterm.css in HTML

Linking xterm.js and xterm.css in HTML can now be done with <script/> and <link/> tags. We'll modify our HTML to include them which should now look like this:

<!doctype html>
<html>
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
    <title>Bash, Pipes, & Socket</title>
    <script type="text/javascript" src="xterm.js"></script>
    <link rel="stylesheet" type="text/css" href="xterm.css" />
    <style type="text/css" media="all">
      body {
        background: #000;
        padding: 0;
        margin: 0;
        width: 100%;
        height: 100%;
        overflow:
        hidden;
      }

      main {
        display: block;
        position: relative;
        padding: 4px;
        width: calc(100% - 8px);
        height: 100%;
      }
    </style>
  </head>
  <body>
    <main></main>
  </body>
</html>

At this point, we have covered the HTML, CSS and the xtermjs dependencies in the application. We have styles, a surface for writing output to, and a library to help us with writing that output.

Writing the JavaScript

In this section, we'll write the JavaScript for the web application in the Render process that makes use of the xtermjs library by listening for a data event on the global window object. This event will be sent from the Main process and include terminal output that will be rendered with the xtermjs library.

First, we'll initialize a new Terminal instance from the xtermjs library to write output to the <main/> DOM element.

const main = document.querySelector('main')
const terminal = new Terminal({ fontSize: 14, cols: 100 })

terminal.open(main)
terminal.writeln('Waiting for input...')

We'll modify the HTML and add this JavaScript to an inline <script /> tag at the end of the body which should now look like this:

<!doctype html>
<html>
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
    <title>Bash, Pipes, & Socket</title>
    <script type="text/javascript" src="xterm.js"></script>
    <link rel="stylesheet" type="text/css" href="xterm.css" />
    <style type="text/css" media="all">
      body {
        background: #000;
        padding: 0;
        margin: 0;
        width: 100%;
        height: 100%;
        overflow:
        hidden;
      }

      main {
        display: block;
        position: relative;
        padding: 4px;
        width: calc(100% - 8px);
        height: 100%;
      }
    </style>
  </head>
  <body>
    <main></main>
    <script type="text/javascript">
      const main = document.querySelector('main')
      const terminal = new Terminal({ fontSize: 14, cols: 100 })

      terminal.open(main)
      terminal.writeln('Waiting for input...')
    </script>
  </body>
</html>

Let's verify how things look again by running the application.

ssc build -r -o

If successful, you should see something like:

Next, we'll listen for the data event on the window object and reason about the structure of the data we are receiving.

window.addEventListener('data', (event) => {
  // `event` is a `CustomEvent`
})

Custom events sent to the Render process are instances of the CustomEvent class available globally to the web application. Custom data emitted on the event object can be accessed on the event.detail property. The detail property can be any valid JSON value.

For the application we are writing, we'll be sending over raw strings to the Render process. The raw strings will be the stdout of a terminal program of our choice that will hook up later.

Listening for the data event and writing each line to the Terminal instance from the xtermjs library would look something like:

window.addEventListener('data', (event) => {
  const lines = event.detail.split('\n')

  for (const line of lines) {
    terminal.writeln(line)
  }
})

We can add a type check for safety:

window.addEventListener('data', (event) => {
  if (typeof event.detail === 'string') {
    const lines = event.detail.split('\n')

    for (const line of lines) {
      terminal.writeln(line)
    }
  }
})

This approach just pipes incoming data from the Main process to the Render process to the Terminal instance which writes output to the <main /> DOM element.

The front-end can also send events to the back-end process. We'll want to notify the back-end when the front-end has loaded. We'll use the window.system.send() to do it.

window.system.send({ event: 'ready' })

The string format of event.detail is UTF-8 and therefore does not need to be decoded. However, if the Main process sent more complicated data, it would likely need to be encoded into a format like base64 to preserve encodings like ANSI escape codes as the IPC URI format indicates that components must be percent-encoded.

Decoding base64 in a web application is pretty straightforward and only requires a few global functions:

function decode (data) {
  return decodeURIComponent(escape(atob(data)))
}

Let's modify the data event listener to handle this:

window.addEventListener('data', (event) => {
  if (typeof event.detail === 'string') {
    const data = decode(event.detail)
    const lines = data.split('\n')

    for (const line of lines) {
      terminal.writeln(line)
    }
  }
})

This approach works well and handles base64 encoded data. The listener writes each line to the Terminal instance from the decoded data.

While the front-end is just a WebView, it does not support features like refresh with CTRL-R. We can easily support it by listening for the keydown event, detecting the CTRL key (or meta), the r key and calling window.location.refresh().

window.addEventListener('keydown', (event) => {
  if ((event.ctrlKey || event.metaKey) && event.key === 'r') {
    window.location.reload()
  }
})

We'll modify the inline <script /> tag in the HTML at the end of the body which should now look like this:

<!doctype html>
<html>
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8">
    <title>Bash, Pipes, & Socket</title>
    <script type="text/javascript" src="xterm.js"></script>
    <link rel="stylesheet" type="text/css" href="xterm.css" />
    <style type="text/css" media="all">
      body {
        background: #000;
        padding: 0;
        margin: 0;
        width: 100%;
        height: 100%;
        overflow:
        hidden;
      }

      main {
        display: block;
        position: relative;
        padding: 4px;
        width: calc(100% - 8px);
        height: 100%;
      }
    </style>
  </head>
  <body>
    <main></main>
    <script type="text/javascript">
      const main = document.querySelector('main')
      const terminal = new Terminal({ fontSize: 14, cols: 100 })

      terminal.open(main)
      terminal.writeln('Waiting for input...')

      window.system.send({ event: 'ready' })

      window.addEventListener('keydown', (event) => {
        if ((event.ctrlKey || event.metaKey) && event.key === 'r') {
          window.location.reload()
        }
      })

      window.addEventListener('data', (event) => {
        if (typeof event.detail === 'string') {
          const data = decode(event.detail)
          const lines = data.split('\n')

          for (const line of lines) {
            terminal.writeln(line)
          }
        }
      })

      function decode (data) {
        return decodeURIComponent(escape(atob(data)))
      }
    </script>
  </body>
</html>

Developer Console

It is worth noting that the Web Development Tools available in most modern browsers are available in the application environment during development. You can access them through the context menu within the viewport of the web application window by selecting the Inspect Element option and clicking the Console.

We can verify the global terminal object that is an instance of Terminal from the xtermjs library.

Writing the Back-End (Main Process)

The back-end application will be written in pure Bash and make use of named pipes for asynchronous input and output. The back-end will parse command line arguments provided to it from the Socket runtime, setup named pipes for IO, and spawn process for asynchronously handling IPC.

main.sh

First, let's create a new file in the src/ directory named main.sh. The file will be a Bash file so we'll start with a shebang at the top of the file on the first line that indicates bash as the interpreter of the file. We'll include the Bash from the linux_cmd and mac_cmd properties in the socket.ini file.

The initial contents of the file should look like this:

#!/usr/bin/env bash

echo "ipc://show"
echo "ipc://navigate?value=file://$PWD/index.html"

The script should be executable so run the following command to ensure it is:

chmod +x src/main.sh

Next, we'll replace the linux_cmd and mac_cmd property values with main.sh as that is the runtime command we want to execute.

linux_cmd: main.sh
mac_cmd: main.sh

Let's verify that things still work by running the following command:

ssc build -r

If successful, you should see something like:

Writing to Standard Output (stdout)

The back-end application can write to standard output using the IPC interface by making use of the stdout command. Let's use it to get some feedback in the terminal when running the application.

We'll modify main.sh by adding the following to the file:

echo "ipc://stdout?value=Starting+application"

The main.sh file should look something like:

#!/usr/bin/env bash

echo "ipc://show"
echo "ipc://navigate?value=file://$PWD/index.html"

## ' ' is encoded as '%20'
echo "ipc://stdout?value=Starting%20application"

Let's verify that things still work by running the following command:

ssc build -r -o

If successful, you should see output that looks something like this:

• warning! $CXX env var not set, assuming defaults +0ms
• preparing build for linux +0ms
• package prepared +11ms
• copy () { cp src/* vendor/* "$1"; }; copy build/bash-pipes-and-socket-sdk-dev_v0.0.1-1_x86_64/opt/bash-pipes-and-socket-sdk-dev --debug=1 +0ms
• ran user build command +1ms
• Creating Window#0 +0ms
• Showing Window#0 (seq=) +4ms
Starting application

Command Line Arguments

The back-end application receives command line arguments from the Socket upon launch. This includes values like the application name and version. The application also receives flags indicating if debug or test mode is enabled.

In this section, we'll parse the application name and version into Bash variables for usage later on.

We'll modify main.sh by adding the following to the file:

declare name=""
declare version=""

while (( $# > 0 )); do
  arg="$1"
  shift

  if (( ${#arg} > 2 )) && [ "--" == "${arg:0:2}" ]; then
    for (( i = 0; i < ${#arg}; ++i )); do
      ## look for '='
      if [ "${arg:$i:1}" == "=" ]; then
        break
      fi
    done

    key="${arg:0:$i}"
    value="${arg:(( $i + 1 ))}"

    if [ "$key" == "name" ] && (( ${#value} > 0 )); then
      name="$value"
    fi

    if [ "$key" == "version" ] && (( ${#value} > 0 )); then
      version="$value"
    fi
  fi
done

## '=' is encoded as '%3D'
echo "ipc://stdout?value=name+%3D+$name"
echo "ipc://stdout?value=name+%3D+$version"

The main.sh file should look something like:

#!/usr/bin/env bash

declare name=""
declare version=""

while (( $# > 0 )); do
  arg="$1"
  shift

  if [ "--" == "${arg:0:2}" ]; then
    arg="${arg:2}"

    for (( i = 0; i < ${#arg}; ++i )); do
      ## look for '='
      if [ "${arg:$i:1}" == "=" ]; then
        break
      fi
    done

    key="${arg:0:$i}"
    value="${arg:(( $i + 1 ))}"

    if [ "$key" == "name" ]; then
      name="$value"
    fi

    if [ "$key" == "version" ]; then
      version="$value"
    fi
  fi
done

echo "ipc://show"
echo "ipc://navigate?value=file://$PWD/index.html"

## ' ' is encoded as '%20'
echo "ipc://stdout?value=Starting%20application"

## '=' is encoded as '%3D'
echo "ipc://stdout?value=name+%3D+$name"
echo "ipc://stdout?value=version+%3D+$version"

Let's verify that things still work by running the following command:

ssc build -r -o

If successful, you should see output that looks something like this:

• warning! $CXX env var not set, assuming defaults +0ms
• preparing build for linux +0ms
• package prepared +20ms
• copy () { cp src/* vendor/* "$1"; }; copy build/bash-pipes-and-socket-sdk-dev_v0.0.1-1_x86_64/opt/bash-pipes-and-socket-sdk-dev --debug=1 +0ms
• ran user build command +1ms
• Creating Window#0 +1ms
• Showing Window#0 (seq=) +4ms
Starting application
name = bash-pipes-and-socket-sdk-dev
version = v0.0.1

URI Component Codec

The back-end writes to stdout and reads from stdin for communicating with the front-end using a custom IPC URI scheme. The components of the URI should be percent encoded. In this section, we'll create encode_uri_component() and decode_uri_component() functions for encoding and decoding URI component data.

First, let's create the encode_uri_component() function. It will percent-encoded characters using the built-in printf(1) Bash function with the exception of -_.~*'()a-zA-Z0-9 characters.

function encode_uri_component () {
  local string="$1"
  local length=${#string}
  local char=""
  local i

  for (( i = 0 ; i < length ; i++ )); do
    char=${string:$i:1}

    case "$char" in
      "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")") echo -ne "$char" ;;
      [a-zA-Z0-9]) echo -ne "$char" ;;
      *) printf '%%%02x' "'$char" ;;
    esac
  done
}

Next, let's create the decode_uri_component() function. It will decode percent-encoded data into UTF-8 characters using the built-in sed command with extended regular expression.

function decode_uri_component () {
  ## 'echo -e' is used so backslash escapes are interpreted
  echo -e "$(echo "$@" | sed -E 's/%([0-9a-fA-F]{2})/\\x\1/g;s/\+/ /g')"
}

Using these functions is pretty straightforward:

encode_uri_component "hello world"
hello%20world
encode_uri_component '{"data": "hello"}'
%7b%22data%22%3a%20%22hello%22%7d
decode_uri_component $(encode_uri_component "hello world")
hello world
decode_uri_component $(encode_uri_component '{"data": "hello"}')
{"data": "hello"}

We'll modify main.sh by adding these functions to the file which should look like:

#!/usr/bin/env bash

declare name=""
declare version=""

while (( $# > 0 )); do
  arg="$1"
  shift

  if [ "--" == "${arg:0:2}" ]; then
    arg="${arg:2}"

    for (( i = 0; i < ${#arg}; ++i )); do
      ## look for '='
      if [ "${arg:$i:1}" == "=" ]; then
        break
      fi
    done

    key="${arg:0:$i}"
    value="${arg:(( $i + 1 ))}"

    if [ "$key" = "name" ]; then
      name="$value"
    fi

    if [ "$key" == "version" ]; then
      version="$value"
    fi
  fi
done

function encode_uri_component () {
  local string="$1"
  local length=${#string}
  local char=""
  local i

  for (( i = 0 ; i < length ; i++ )); do
    char=${string:$i:1}

    case "$char" in
      "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")") echo -ne "$char" ;;
      [a-zA-Z0-9]) echo -ne "$char" ;;
      *) printf '%%%02x' "'$char" ;;
    esac
  done
}

function decode_uri_component () {
  ## 'echo -e' is used so backslash escapes are interpreted
  echo -e "$(echo "$@" | sed -E 's/%([0-9a-fA-F]{2})/\\x\1/g;s/\+/ /g')"
}

echo "ipc://show"
echo "ipc://navigate?value=file://$PWD/index.html"

## ' ' is encoded as '%20'
echo "ipc://stdout?value=Starting%20application"

## '=' is encoded as '%3D'
echo "ipc://stdout?value=name+%3D+$name"
echo "ipc://stdout?value=version+%3D+$version"

We can now encode and decode URI component data. We'll make use of these functions soon.

IPC IO

Now that we have an IPC URI codec, we can generalize how we communicate messages to the front-end.

First, let's create a function to write messages to the front-end. The function will accept a command name and a variable number of key=value arguments that we will parse and encode with the encode_uri_component() function created above. The function will delimit each key=value pair with an &.

function ipc_write () {
  printf "ipc://%s?" "$1"
  shift

  while (( $# > 0 )); do
    local arg="$1"
    local i=0
    shift

    for (( i = 0; i < ${#arg}; i++ )); do
      if [ "${arg:$i:1}" == "=" ]; then
        break
      fi
    done

    local key="${arg:0:$i}"
    local value="${arg:(( $i + 1))}"

    ## encode `key=value` pair
    echo -ne "$key=$(encode_uri_component "$(echo -ne "$value")")" 2>/dev/null

    if (( $# > 0 )); then
      printf '&'
    fi
  done

  ## flush with newline
  echo
}

Now, let's replace all of those message written to stdout with ipc_write() function calls which use the encode_uri_component() function to encode our messages.

ipc_write "show"
ipc_write "navigate" "value=file://$PWD/index.html"

ipc_write "stdout" "value=Starting application"

ipc_write "stdout" "value=name = $name"
ipc_write "stdout" "value=version = $version"

This works exactly how it should and is a suitable interface for writing IPC URI messages from the back-end.

function ipc_read () {
  while read -r data; do
    if [[ "$data"  =~ "ipc://" ]]; then
      decode_uri_component "$data"
      break
    fi
  done
}

We can use this function with ipc_write() to read a response back.

ipc_write "getScreenSize"
ipc_read | while read -r value; do
  ipc_write "stdout" "value=$value"
done

Which outputs something like:

ipc://resolve?seq=&state=0&value={"width":1503,"height":1002}

Notice the seq and state values. There is nothing specified for seq and state is set to 0. The state value can be either 0 (OK) or 1 (ERROR). The seq value is set if we provide one in the message written, which we did not. Let's try it again with a seq value.

ipc_write "getScreenSize" seq=123
ipc_read | while read -r value; do
  ipc_write "stdout" "value=$value"
done

Which outputs something like:

ipc://resolve?seq=123&state=0&value={"width":1503,"height":1002}

We can now see the corresponding seq value used in the getScreenSize command in the ipc_read() response. This is useful for asynchronous IO where responses can be emitted out of order.

We can improve the ipc_read() function to filter on messages for a given command type and seq value and then just emit the value from the read message. We can use a series of commands in a pipeline to just get the actual value from the value=.* part of the IPC URI query string. If a filtered message is not found, we'll return 1 for failure, otherwise 0 for success.

function ipc_read () {
  local command="$1"
  local seq="$2"

  while read -r data; do
    if [ -z "$command" ] || [[ "$data"  =~ "ipc://$command" ]]; then
      if [ -z "$seq" ] || [[ "$data" =~ "seq=$seq"\&? ]]; then
        decode_uri_component "$(
          echo -e "$data"   | ## echo unescaped data
          grep -o '?.*'     | ## grep query string part of URI
          tr -d '?'         | ## remove '?'
          tr '&' '\n'       | ## transform '&' into newlines
          grep 'value='     | ## grep line with leading 'value='
          sed 's/value=//g'   ## remove 'value=' so actual value is left over
        )"

        return 0
      fi
    fi
  done

  return 1
}

Responses for a message written over IPC use the resolve command and contain seq, state, and value parameters. We can filter for the "resolve" command and a seq value to get the response we're looking for.

ipc_write "getScreenSize" seq=123
ipc_read "resolve" 123 | while read -r value; do
  ipc_write "stdout" "value=$value"
done

At this point we have established a way to read and write IPC IO. However, all IO is synchronous and messages that are filtered out are ignored. This could cause undesired behavior. In the next section we'll make IPC IO asynchronous using named pipes and background jobs by using the ampersand (&) operator.

Before we continue, let's clean up the code and create a main() function that will accept the main.sh command line arguments. We'll refactor how the command line arguments are parsed into a parse_command_line_arguments() function and remove the example ipc_write() and ipc_read() function calls as we'll come back to that later. We'll return 1 if we fail to parse the values we want and write a fatal error message.

We'll call main() with the main.sh command line arguments at the end file.

The main.sh file should look something like this:

#!/usr/bin/env bash

declare name=""
declare version=""

function parse_command_line_arguments () {
  while (( $# > 0 )); do
    arg="$1"
    shift

    if [ "--" == "${arg:0:2}" ]; then
      arg="${arg:2}"

      for (( i = 0; i < ${#arg}; ++i )); do
        ## look for '='
        if [ "${arg:$i:1}" == "=" ]; then
          break
        fi
      done

      key="${arg:0:$i}"
      value="${arg:(( $i + 1 ))}"

      if [ "$key" = "name" ]; then
        name="$value"
      fi

      if [ "$key" == "version" ]; then
        version="$value"
      fi
    fi
  done

  if [ -z "$name" ] || [ -z "$version" ]; then
    local error="$(encode_uri_component "Missing 'name/version' in arguments")"
    printf "ipc://stdout?value=%s\n" "$error"
    return 1
  fi
}

function encode_uri_component () {
  local string="$1"
  local length=${#string}
  local char=""
  local i

  for (( i = 0 ; i < length ; i++ )); do
    char=${string:$i:1}

    case "$char" in
      "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")") echo -ne "$char" ;;
      [a-zA-Z0-9]) echo -ne "$char" ;;
      *) printf '%%%02x' "'$char" ;;
    esac
  done
}

function decode_uri_component () {
  ## 'echo -e' is used so backslash escapes are interpreted
  echo -e "$(echo "$@" | sed -E 's/%([0-9a-fA-F]{2})/\\x\1/g;s/\+/ /g')"
}

function ipc_read () {
  local command="$1"
  local seq="$2"

  while read -r data; do
    if [ -z "$command" ] || [[ "$data"  =~ "ipc://$command" ]]; then
      if [ -z "$seq" ] || [[ "$data" =~ "seq=$seq"\&? ]]; then
        decode_uri_component "$(
          echo -e "$data"   | ## echo unescaped data
          grep -o '?.*'     | ## grep query string part of URI
          tr -d '?'         | ## remove '?'
          tr '&' '\n'       | ## transform '&' into newlines
          grep 'value='     | ## grep line with leading 'value='
          sed 's/value=//g'   ## remove 'value=' so actual value is left over
        )"

        return 0
      fi
    fi
  done

  return 1
}

function ipc_write () {
  printf "ipc://%s?" "$1"
  shift

  while (( $# > 0 )); do
    local arg="$1"
    local i=0
    shift

    for (( i = 0; i < ${#arg}; i++ )); do
      if [ "${arg:$i:1}" == "=" ]; then
        break
      fi
    done

    local key="${arg:0:$i}"
    local value="${arg:(( $i + 1))}"

    ## encode `key=value` pair
    echo -ne "$key=$(encode_uri_component "$(echo -ne "$value")")" 2>/dev/null

    if (( $# > 0 )); then
      printf '&'
    fi
  done

  ## flush with newline
  echo
}

function main () {
  parse_command_line_arguments "$@" || return $?
}

main "$@" || exit $?

Note the usage of || return $?. This captures the return code of the called function and returns it to the caller if the called function returns a non-zero exit code.

Making IPC IO Asynchronous

In this section we'll create named pipes for asynchronous IPC IO and modify the ipc_read() and ipc_write() functions to read and write from and to the named pipes, also known as FIFOs (first in, first out).

Named Pipes (FIFOs)

Named pipes, also known as FIFOs for its behavior, are similar to anonymous pipes where data flows through a simplex channel. However, on POSIX systems, named pipes are just like regular files, but they are created with the mkfifo command.

Here is a quick example on how they work:

mkfifo pipe
cat ./pipe &
echo "Hello World" > ./pipe

Which should output:

Hello World

Named pipes will be used for buffering IPC stdin and stdout which will help make ipc_write() and ipc_read() asynchronous. First, let's setup the named pipes with the mkfifo command. They will be created in the working directory of the main application binary. We'll call them stdin and stdout and initialize them in a function called init_io().

function init_io () {
  local pipes=(stdin stdout)
  rm -f "${pipes[@]}" && mkfifo "${pipes[@]}"
}

We'll call this function after the parse_command_line_arguments() in the main() function.

function main () {
  parse_command_line_arguments "$@"
  init_io || return $?
}

Now that we have some named pipes setup, we'll need to setup IO redirection. We'll create a poll_io() function that will do this for us. First we'll read from the stdout named pipe and redirect output to the program stdout. This will be done in a background process. We'll track its PID in the global pids array. Next we'll read from the program stdin and write to the stdin named pipe. We'll use the tee command for reading the stdout named piped and writing to the stdin named pipe.

Add this to the globals at the top of main.sh

declare pids=()

The poll_io() function should look like:

function poll_io () {
  while true; do
    tee
  done < stdout & pids+=($!)

  while true; do
    # `tee -a` appends to the file
    tee -a stdin > /dev/null & pids+=($!)
  done
}

This function will be called at the end of main() function as it will block in a while true loop reading from the program stdin.

function main () {
  parse_command_line_arguments "$@"
  init_io || return $?
  poll_io || return $?
}

Signal Handler

The background processes in the poll_io() function will need to be killed when the program ends. We'll create a signal handler with the trap() function that will kill all tracked process IDs.

First, we'll create a signal handler which will kill all tracked process IDs and then exit the actual main program process.

function onsignal () {
  kill -9 "${pids[@]}"
  exit 0
}

Next, let's use trap() to handle SIGTERM, and SIGINT signals to call onsignal() when they occur. We'll create a function called init_signals() and call it in the main() function after the parse_command_line_arguments() function call.

function init_signals () {
  trap onsignal SIGTERM SIGINT
}
function main () {
  parse_command_line_arguments "$@" || return $?
  init_signals || return $?
  init_io || return $?
  poll_io || return $?
}

IPC IO Redirection

Now that we have named pipes setup for IO we can modify the ipc_write() and ipc_read() functions to use them instead of the program's standard input and output streams.

function ipc_read () {
  local command="$1"
  local seq="$2"

  while read -r data; do
    if [ -z "$command" ] || [[ "$data"  =~ "ipc://$command" ]]; then
      if [ -z "$seq" ] || [[ "$data" =~ "seq=$seq"\&? ]]; then
        decode_uri_component "$(
          echo -e "$data"   | ## echo unescaped data
          grep -o '?.*'     | ## grep query string part of URI
          tr -d '?'         | ## remove '?'
          tr '&' '\n'       | ## transform '&' into newlines
          grep 'value='     | ## grep line with leading 'value='
          sed 's/value=//g'   ## remove 'value=' so actual value is left over
        )"

        return 0
      fi
    fi
  done < stdin

  return 1
}

function ipc_write () {
  {
    printf "ipc://%s?" "$1"
    shift

    while (( $# > 0 )); do
      local arg="$1"
      local i=0
      shift

      for (( i = 0; i < ${#arg}; i++ )); do
        if [ "${arg:$i:1}" == "=" ]; then
          break
        fi
      done

      local key="${arg:0:$i}"
      local value="${arg:(( $i + 1))}"

      ## encode `key=value` pair
      echo -ne "$key=$(encode_uri_component "$(echo -ne "$value")")" 2>/dev/null

      if (( $# > 0 )); then
        printf '&'
      fi
    done

    ## flush with newline
    echo
  } > stdout
}

Rebuffering IPC Input

The ipc_read() function handles buffered input from the stdin named pipe, but it can filter out messages that can be lost. The ipc_read() function should rebuffer data it ignores instead so subsequent ipc_read() function calls can read messages out of order without data loss.

function ipc_read () {
  local command="$1"
  local seq="$2"

  while read -r data; do
    if [ -z "$command" ] || [[ "$data"  =~ "ipc://$command" ]]; then
      if [ -z "$seq" ] || [[ "$data" =~ "seq=$seq"\&? ]]; then
        decode_uri_component "$(
          echo -e "$data"   | ## echo unescaped data
          grep -o '?.*'     | ## grep query string part of URI
          tr -d '?'         | ## remove '?'
          tr '&' '\n'       | ## transform '&' into newlines
          grep 'value='     | ## grep line with leading 'value='
          sed 's/value=//g'   ## remove 'value=' so actual value is left over
        )"

        return 0
      fi
    fi

    ## rebuffer
    echo -e "$data" > stdin
  done < stdin

  return 1
}

IPC Sequences

As shown before, the IPC protocol makes use of a seq (sequence) value for resolving requests with ipc_read() made with ipc_write(). This value should be unique and monotonically incremented for each request. Let's introduce a next_sequence() function that returns a monotonically incremented seq value that can used by ipc_write() and filtered in ipc_read(). However, because we make use of background processes, we cannot atomically and monotonically increase this for all processes who use this value. To solve for this, we can just use a file! We can initialize a sequence file with an init_sequence() function called in the main() function.

First, let's introduce the init_sequence() and next_sequence() functions:

function init_sequence () {
  echo 0 | tee sequence >/dev/null
}

function next_sequence () {
  local seq="$(cat sequence)"
  echo $(( seq + 1 )) | tee sequence
}

Next, we'll call init_sequence() in the main() function:

function main () {
  parse_command_line_arguments "$@" || return $?
  init_sequence || return $?
  init_signals || return $?
  init_io || return $?
  poll_io || return $?
}

Finally, let's modify ipc_write() to use this value and return it as a status code. We'll skip the next_sequence() call for the stdout command as this type of IPC command will never resolve. We'll make the function write to the stdout named pipe in the background (&) so it can return quickly and echo the seq value to the caller.

function ipc_write () {
  local seq=0

  if [ "$1" != "stdout" ]; then
    seq="$(next_sequence)"
  fi

  {
    printf "ipc://%s?" "$1"
    shift

    if (( seq != 0 )); then
      printf "seq=%d&" "$seq"
    fi

    while (( $# > 0 )); do
      local arg="$1"
      local i=0
      shift

      for (( i = 0; i < ${#arg}; i++ )); do
        if [ "${arg:$i:1}" == "=" ]; then
          break
        fi
      done

      local key="${arg:0:$i}"
      local value="${arg:(( $i + 1))}"

      ## encode `key=value` pair
      echo -ne "$key=$(encode_uri_component "$(echo -ne "$value")")" 2>/dev/null

      if (( $# > 0 )); then
        printf '&'
      fi
    done

    ## flush with newline
    echo
  } > stdout & pids+=($!)

  echo "$seq"
  return "$seq"
}

We can use ipc_write() in concert with ipc_read() without thinking about the seq value:

local config="$(ipc_read resolve $(ipc_write getConfig))"

IPC API

At this point, we now have an architecture for asynchronous IPC IO using named pipes. The ipc_read() and ipc_write() functions can read and write asynchronous to and from these pipes and can even be used in subshells. These functions are pretty low level and will be pretty verbose when calling them. In this section, we'll abstract some IPC API calls into higher level functions for use in the main program logic in a follow up section below.

function show_window () {
  ## show window by index (default 0)
  ipc_read resolve "$(ipc_write show index="${1:-0}")"
}

function hide_window () {
  ## hide window by index (default 0)
  ipc_read resolve "$(ipc_write hide index="${1:-0}")"
}

function navigate_window () {
  ## navigate window to URL by index (default 0)
  ipc_read resolve "$(ipc_write navigate index="${2:-0}" value="$1")"
}

function send () {
  ## send data to window by index (default 0)
  ipc_read resolve "$(ipc_write send index="${2:-0}" value="$1")"
}

function set_size () {
  ## set size of window  by index (default 0)
  ipc_read resolve "$(ipc_write size index="${3:-0}" width="$1" height="$2")"
}

function get_config () {
  ## get config
  ipc_read resolve "$(ipc_write getConfig index=0)"
}

function log () {
  ## write variadic values to stdout
  ipc_write stdout index=0 value="$*" >/dev/null
}

Logger

The IPC API above should provide us with a cleaner interface for interacting with the IPC protocol. However, we can improve the log() function to make use of ANSI escape codes for a pretty logger and some info(), warn(), and error() logger functions that include the calling function name.

function info () {
  log "\e[34m info$(echo -en "\e[0m") (${FUNCNAME[1]})> $*"
}

function warn () {
  log "\e[33m warn$(echo -en "\e[0m") (${FUNCNAME[1]})> $*"
}

function debug () {
  log "\e[32mdebug$(echo -en "\e[0m") (${FUNCNAME[1]})> $*"
}

function error () {
  log "\e[31merror$(echo -en "\e[0m") (${FUNCNAME[1]})> $*"
}

Debugging

Socket application can have debug builds. Back-end applications will receive --debug=1 as a command line argument for debug builds. We can parse this value in the parse_command_line_arguments() function and set a global debug variable when it is present and set to 1.

if [ "$key" == "debug" ]; then
  debug="$value"
fi

We can modify the debug() function to branch on this value:

function debug () {
  if (( debug == 1 )); then
    log "\e[43m debug$(echo -en "\e[0m") (${FUNCNAME[1]})> $*"
  fi
}

Let's debug the ipc_write() and ipc_read() functions:

First, we'll hook into the pipeline write debug output for the IPC message payload such that it is not a stdout command.

function ipc_write () {
  local command="$1"
  local seq=0

  if [ "$command" != "stdout" ]; then
    seq="$(next_sequence)"
  fi

  {
    printf "ipc://%s?" "$1"
    shift

    if (( seq != 0 )); then
      printf "seq=%d&" "$seq"
    fi

    while (( $# > 0 )); do
      local arg="$1"
      local i=0
      shift

      for (( i = 0; i < ${#arg}; i++ )); do
        if [ "${arg:$i:1}" == "=" ]; then
          break
        fi
      done

      local key="${arg:0:$i}"
      local value="${arg:(( $i + 1))}"

      ## encode `key=value` pair
      echo -ne "$key=$(encode_uri_component "$(echo -ne "$value")")" 2>/dev/null

      if (( $# > 0 )); then
        printf '&'
      fi
    done

    ## flush with newline
    echo
  } | {
    while read -r line; do
      if [ "$command" != "stdout" ]; then
        debug "$line"
      fi

      echo -e "$line"
    done
  } > stdout & pids+=($!)

  echo "$seq"
  return "$seq"
}

Next, we'll simply just debug all incoming IPC payloads in ipc_read():

function ipc_read () {
  local command="$1"
  local seq="$2"

  while read -r data; do
    if [ -z "$command" ] || [[ "$data"  =~ "ipc://$command" ]]; then
      if [ -z "$seq" ] || [[ "$data" =~ "seq=$seq"\&? ]]; then
        decode_uri_component "$(
          echo -e "$data"   | ## echo unescaped data
          grep -o '?.*'     | ## grep query string part of URI
          tr -d '?'         | ## remove '?'
          tr '&' '\n'       | ## transform '&' into newlines
          grep 'value='     | ## grep line with leading 'value='
          sed 's/value=//g'   ## remove 'value=' so actual value is left over
        )"

        return 0
      fi
    fi

    ## rebuffer
    echo -e "$data" > stdin
  done < stdin

  return 1
}

Panic Function

In the case of a fatal error, we can introduce a special panic() function that will write an error and exit with code 1.

function panic () {
  log "\e[31mpanic$(echo -en "\e[0m") (${FUNCNAME[1]})> $*"
  exit 1
}

Main Program

Now we have established a solid framework for actually creating the main program business logic. We'll want to run this in the main() function, but will actually want to defer exeuction to the background so the poll_io() function can block at the end main(). Let's setup a start_appliciation() function where we will write the application code and call it right before poll_io()

First, let's add start_appliciation() to the main() function and track its pid.

function main () {
  parse_command_line_arguments "$@" || return $?
  init_sequence || return $?
  init_signals || return $?
  init_io || return $?

  start_appliciation & pids+=($!)

  poll_io || return $?
}

Next, let's setup the start_appliciation() function to show the window and navigate to the index.html file. We'll print some information to stdout too.

function start_appliciation () {
  info "Starting Application ($name@$version)"
  info "Program Arguments: $*"

  show_window || panic "Failed to show window"
  navigate_window "file://$PWD/index.html" || panic "Failed to navigate to 'index.html'"
}

When we run this program, we'll see output with debug logs that looks something like:

• warning! $CXX env var not set, assuming defaults +0ms
• preparing build for linux +0ms
• package prepared +10ms
• copy () { cp src/* vendor/* "$1"; }; copy build/bash-pipes-and-ssc-dev_v0.0.1-1_x86_64/opt/bash-pipes-and-socket-sdk-dev --debug=1 +0ms
• ran user build command +3ms
• Creating Window#0 +1ms
 info (start_application)> Starting Application (bash-pipes-and-socket-sdk-dev@v0.0.1)
 info (start_application)> Program Arguments: --version=v0.0.1 --name=bash-pipes-and-socket-sdk-dev --debug=1
debug (ipc_write)> ipc://show?seq=1&index=0
• Showing Window#0 (seq=1) +18ms
debug (ipc_write)> ipc://navigate?seq=2&index=0&value=file%3a%2f%2f%2fhome%2fwerle%2frepos%2fsocketsupply%2fbash-pipes-and-socket-sdk%2fbuild%2fbash-pipes-and-ssc-dev_v0.0.1-1_x86_64%2fopt%2fbash-pipes-and-socket-sdk-dev%2findex.html

Waiting for the Front End to be Ready

With a framework in place, we are ready to communicate with the front-end. However, if you remember in an earlier section, we sent an event from the front-end to back-end signaling that is ready with the following payload:

{
  "event": "ready"
}

We can wait for this object in the back-end by reading the IPC message stream until we get it. We can create a function called wait_for_ready_event() which does this.

function wait_for_ready_event () {
  local READY_EVENT='{"event":"ready"}'
  warn "Waiting for ready event from front-end"
  while [ "$(ipc_read)" != "$READY_EVENT" ]; do
    : ## no-op
  done
}

We can use this function in the start_application() function before we interact with the front-end.

function start_appliciation () {
  info "Starting Application ($name@$version)"
  info "Program Arguments: $*"

  show_window || panic "Failed to show window"
  navigate_window "file://$PWD/index.html" || panic "Failed to navigate to 'index.html'"

  wait_for_ready_event
}

Sending Data to the Front End

Now that we have a framework and a back-end application, we can start sending data to the front-end. However, if you recall in an earlier section we mentioned that the front-end may receive data base64 encoded which will be suitable for more complicated data formats. We can make use of the base64 command and create a send_data() function that just uses the send() function to do it.

function send_data () {
  ## `base64 -w 0` will disable line wrapping
  send 'data' "$(echo -e "$@" | base64 -w 0)"
}

We can try this out with a small hello world in our start_application() function with:

function start_appliciation () {
  info "Starting Application ($name@$version)"
  info "Program Arguments: $*"

  show_window || panic "Failed to show window"
  navigate_window "file://$PWD/index.html" || panic "Failed to navigate to 'index.html'"

  wait_for_ready_event

  send_data "hello, world"
}

You should see something like:

This works well with the exception of a gotcha! What happens if we were to refresh of the front-end with CTRL-R as supported in index.html? The front-end will send another ready event, but the back-end will not be waiting for it again. We can easily fix this by moving the wait_for_ready_event() function call into the predicate of a while loop and the application logic into the do ... done loop body.

function start_appliciation () {
  info "Starting Application ($name@$version)"
  info "Program Arguments: $*"

  show_window || panic "Failed to show window"
  navigate_window "file://$PWD/index.html" || panic "Failed to navigate to 'index.html'"

  while wait_for_ready_event; do
    send_data "hello, world"
  done
}

This should handle the case when the front-end refreshes with the back-end responding for each new ready event.

Clearing the Terminal

If you recall earlier in this blog post, we setup a simple way for data to be emitted from the back-end into the front-end and rendered with xtermjs. As data comes in, it is simply appended to the terminal surface. Ideally, we will want to stream data in and clear the screen for each event. We will need to clear the terminal screen each time we receive an event. We can easily do this with Terminal instance.

Let's add the following in the data event listener in index.html right before we right the new lines:

terminal.reset()
terminal.clear()

The new listener should look like:

window.addEventListener('data', (event) => {
  if (typeof event.detail === 'string') {
    const data = decode(event.detail)
    const lines = data.split('\n')

    terminal.reset()
    terminal.clear()

    for (const line of lines) {
      terminal.writeln(line)
    }
  }
})

If we run the application again, we should see that the Waiting for input... text is gone and hello, world is the only text present.

Testing with top

Now that we have a surface for writing terminal output to, it could be nice to see how this works in practice with a command we are familiar with. The top(1) command supports something called batch mode and allows us to specify the number of iterations it will run printing output to stdout. We can also set the number of columns with the COLUMNS environment variable which tells top how wide the output should be.

We can see some simple output by modify the while wait_for_ready_event loop.

while wait_for_ready_event; do
  ## We format for 100 columns wide in batch mode (-b) with 1 iterations (-n)
  ## piped to head for the first 16 rows of standard output
  send_data "$(COLUMNS=100 top -b -n 1 | head 16)"
done

You should see something like:

The width and height appear to be a little big. We can actually adjust this with the set_size() function we created earlier and call it in the while wait_for_ready_event loop.

while wait_for_ready_event; do
  warn "Setting 720x360 window size"
  set_size 720 360

  ## We format for 100 columns wide in batch mode (-b) with 1 iterations (-n)
  ## piped to head for the first 16 rows of standard output
  send_data "$(COLUMNS=100 top -b -n 1 | head 16)"
done

You should see something like:

This looks pretty good. Now what we can render output of the top command it should be possible to stream updates to the front-end with the same type of setup. We can do this with an inner while true loop that will continuously send the output of top with send_data().

while wait_for_ready_event; do
  warn "Setting 720x360 window size"
  set_size 720 360

  while true; do
    ## We format for 100 columns wide in batch mode (-b) with 1 iterations (-n)
    ## piped to head for the first 16 rows of standard output
    send_data "$(COLUMNS=100 top -b -n 1 | head 16)"
  done
done

You should see something like:

This works well, but the rendering is pretty choppy. We can improve this with some help from requestAnimationFrame()

Smooth Rendering

As stated in the previous section, the rendering of the terminal surface is a bit choppy. We can make this smoother by using the requestAnimationFrame function to queue up rendering to the frame buffer in a more efficient way.

We can modify the usage of terminal.reset(), terminal.clear(), and terminal.writeln() function calls to be called in a requestAnimationFrame() callback which will look something like:

requestAnimationFrame(() => {
  terminal.clear()
})

requestAnimationFrame(() => {
  terminal.reset()
})

requestAnimationFrame(() => {
  terminal.writeln(line)
})

Let's modify the data event listener which should look like:

window.addEventListener('data', (event) => {
  if (typeof event.detail === 'string') {
    const data = decode(event.detail)
    const lines = data.split('\n')

    requestAnimationFrame(() => {
      terminal.reset()
    })

    requestAnimationFrame(() => {
      terminal.clear()
    })

    for (const line of lines) {
      requestAnimationFrame(() => {
        terminal.writeln(line)
      })
    }
  }
})

Finally, we should see a much smoother render update:

Conclusion

In the example above we used the top command's output to render to the terminal surface in the WebView. However, it is possible to leverage a command of your choice to get the desired experience. The companion repository for this blog post is located at:

https://github.com/socketsupply/bash-pipes-and-socket-sdk

If you spot an issue or feel like contributing, open an issue or pull request and we'd be happy to review and merge it in.

If you have any questions or feedback, join our Discord Community