Share your terminal as a web application with Go
You will need Go 1.9+ set up on your machine. Git Bash or similar is required on Windows.
In this tutorial, we will explore how Pusher Channels can be used to share your terminal as a web page. If you want to play around with the code as you read this tutorial, visit this GitHub repository, which contains the final version of the code for you to check out and run locally.
A feature such as this is already available in CI servers, you already monitor in realtime the output of your build. It can also help in situations where you want a colleague to help troubleshoot an issue and don’t necessarily want to send log files back and forth, the colleague can take a look at your terminal directly instead.
Prerequisites
- Golang (
>=1.9
) . A working knowledge of Go is required to follow this tutorial. - A Pusher Channels application. Create one here.
- Git Bash if you are on Windows.
Building the program
An important aspect to this is implementing a Golang program that can act as a Pipe. So in short, we will be building a program that monitors the output of another program then displays it on the web UI we are going to build.
An example usage is:
$ ./script | go run main.go
Here is an example of what we will be building:
The next step of action is to build the Golang program that will be used as pipe. To get started, we need to create a Pusher Channels application, that can be done by visiting the dashboard. You will need to click on the Create new app button to get started:
You will then be redirected to a control panel for your app where you’d be able to view the information about the app and more importantly, the authorization keys you need to connect to the application.
Once the above has been done, we will then proceed to create the actual Golang program. To do a little recap again, this program will perform two tasks:
- Act as a pipe for another program
- Start an
HTTP
server that displays the output of another program (the one being piped) in realtime.
The first thing to do is to create a new directory in your $GOPATH
called pusher-channel-terminal-web-sync
. That can be done with the following command:
$ mkdir $GOPATH/github.com/pusher-tutorials/pusher-channel-terminal-web-sync
You will need to create an .env
file with the following contents:
// pusher-channel-terminal-web-sync/.env
PUSHER_APP_ID="PUSHER_APP_ID"
PUSHER_APP_KEY="PUSHER_APP_KEY"
PUSHER_APP_SECRET="PUSHER_APP_SECRET"
PUSHER_APP_CLUSTER="PUSHER_APP_CLUSTER"
PUSHER_APP_SECURE="1"
Please remember to replace the placeholders with the original values. They can be gotten from the control panel.
The next step of action is to create a main.go
file. This will house the actual code for connecting and publishing events to Pusher Channels so as to be able to show those in real time on the web.
You can create a main.go
file with the following command:
$ touch main.go
Once the file has been created, the next step is to fetch some required dependency such as Pusher’s client SDK. To do that, you will need to run the command below:
$ go get github.com/joho/godotenv
$ go get github.com/pusher/pusher-http-go
Once the above commands succeed, you will need to paste the following content into it:
// pusher-channel-terminal-web-sync/main.go
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"text/template"
"time"
"github.com/joho/godotenv"
pusher "github.com/pusher/pusher-http-go"
)
const (
channelName = "realtime-terminal"
eventName = "logs"
)
func main() {
var httpPort = flag.Int("http.port", 1500, "Port to run HTTP server on ?")
flag.Parse()
info, err := os.Stdin.Stat()
if err != nil {
log.Fatal(err)
}
if info.Mode()&os.ModeCharDevice != 0 {
log.Println("This command is intended to be used as a pipe such as yourprogram | thisprogram")
os.Exit(0)
}
if err := godotenv.Load(); err != nil {
log.Fatal("Error loading .env file")
}
appID := os.Getenv("PUSHER_APP_ID")
appKey := os.Getenv("PUSHER_APP_KEY")
appSecret := os.Getenv("PUSHER_APP_SECRET")
appCluster := os.Getenv("PUSHER_APP_CLUSTER")
appIsSecure := os.Getenv("PUSHER_APP_SECURE")
var isSecure bool
if appIsSecure == "1" {
isSecure = true
}
client := &pusher.Client{
AppId: appID,
Key: appKey,
Secret: appSecret,
Cluster: appCluster,
Secure: isSecure,
HttpClient: &http.Client{
Timeout: time.Minute * 2,
},
}
go func() {
var t *template.Template
var once sync.Once
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("."))))
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
once.Do(func() {
tem, err := template.ParseFiles("index.html")
if err != nil {
log.Fatal(err)
}
t = tem.Lookup("index.html")
})
t.Execute(w, nil)
})
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *httpPort), nil))
}()
reader := bufio.NewReader(os.Stdin)
var writer io.Writer
writer = pusherChannelWriter{client: client}
for {
in, _, err := reader.ReadLine()
if err != nil && err == io.EOF {
break
}
in = append(in, []byte("\n")...)
if _, err := writer.Write(in); err != nil {
log.Fatalln(err)
}
}
}
type pusherChannelWriter struct {
client *pusher.Client
}
func (pusher pusherChannelWriter) Write(p []byte) (int, error) {
s := string(p)
dd := bytes.Split(p, []byte("\n"))
var data = make([]string, 0, len(dd))
for _, v := range dd {
data = append(data, string(v))
}
_, err := pusher.client.Trigger(channelName, eventName, s)
return len(p), err
}
While the above code is a bit lengthy, I’d break it down.
- Line 35 - 38 is probably the most interesting part. We make sure the program can only be run if it is acting as a pipe to another program. An example is
someprogram | ourprogram
. - Line 66 - 88 is where we start the
HTTP
server. The server will load up anindex.html
file where the contents of the program we are acting as a pipe for will be seen in realtime. Maybe another interesting thing isvar once sync.Once
. Whatsync.Once
offers us is the ability to perform a task just once throughout the lifetime of a program, with this we load the contents ofindex.html
just once and don’t have to repeat it every time the web page is requested. - Line 109 - 125 is where we actually send output to Pusher Channels.
Great, something we have missed so far is the index.html
file. You will need to go ahead to create that in the root directory with the following command:
$ touch index.html
Open the newly created file and paste in the following contents:
// pusher-channel-terminal-web-sync/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pusher realtime terminal sync</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link href="static/app.css" rel="stylesheet">
<body>
<div id="terminal">
<pre>
<output id="logs"></output>
</pre>
</div>
</body>
<script src="https://js.pusher.com/4.3/pusher.min.js"></script>
<script src="static/app.js"></script>
</html>
As you may have noticed, we referenced app.js
and app.css
file. We will get started with the app.js
file , that can be done with the following command:
$ touch app.js
Once done, you will need to paste the following contents into it.:
// pusher-channel-terminal-web-sync/app.js
(function() {
const APP_KEY = 'PUSHER_APP_KEY';
const APP_CLUSTER = 'PUSHER_APP_CLUSTER';
const logsDiv = document.getElementById('logs');
const pusher = new Pusher(APP_KEY, {
cluster: APP_CLUSTER,
});
const channel = pusher.subscribe('realtime-terminal');
channel.bind('logs', data => {
const divElement = document.createElement('div');
divElement.innerHTML = data;
logsDiv.appendChild(divElement);
});
})();
Do make sure to replace
PUSHER_APP_KEY
andPUSHER_APP_CLUSTER
with your original credentials
You also need to create the app.css
file. That can be done with:
$ touch app.css
Once done, paste the following contents into it:
// pusher-channel-terminal-web-sync/app.css
#terminal {
font-family: courier, monospace;
color: #fff;
width:750px;
margin-left:auto;
margin-right:auto;
margin-top:100px;
font-size:14px;
}
body {
background-color: #000
}
Nothing too fancy right? We just make the website’s background black and try to mimic a real terminal.
All is set and we can go ahead to test our program. A major key to testing this is an application that writes to standard output, such programs like cat
or a running NodeJS program that writes log to standard output.
To make this as simple as can be, we will make use of another Go program that writes a UUID to standard output every second. This file can be created with:
# This should be done within the pusher-channel-terminal-web-sync directory
$ mkdir uuid
$ touch uuid/uuid.go
Since we will be generating UUIDs, we will require a dependency for that. You can install that by running:
$ go get github.com/google/uuid
In the newly created uuid.go
, paste the following contents into it:
// pusher-channel-terminal-web-sync/uuid/uuid.go
package main
import (
"fmt"
"time"
"github.com/google/uuid"
)
func main() {
for {
time.Sleep(time.Millisecond * 500)
fmt.Printf("Generating a new UUID -- %s", uuid.New())
fmt.Println()
}
}
All is set right now for us to test. To do this, we will need to build both the UUID generator and our actual program.
# Linux and Mac
$ go build -o uuidgenerator uuid/uuid.go
$ go build
# Windows
$ go build -o uuidgenerator.exe uuid/uuid.go
$ go build
Once the above has been done, we will then run both of them. That can be done by running the command below:
$ ./uuidgenerator | ./pusher-channel-terminal-web-sync
There should be no output in the terminal but you should visit http://localhost:1500 in other to view the output of the UUID generator in real time. You should be presented with something as depicted in the gif below:
Conclusion
In this tutorial, I have described how Pusher Channels can be leveraged to build a realtime view of your terminal. This can be really useful if you want to share your terminal with someone else on the same network as you are or with a tool such as ngrok. You could do something like ngrok http 1500
and share the link with someone else.
As always, you can find the code on GitHub.
26 March 2019
by Lanre Adelowo