Build a Chrome extension to track cryptocurrency prices - Part 2: Allowing user interaction
You will need Node 4+ and the Heroku CLI installed on your machine. Knowledge of JavaScript will be helpful.
If you made it to this part of the guide, then by the end of this guide, you will realize building Chrome extensions is like building a website. Emphasis on “Like” 😃 .
In the last part of the tutorial, we got into Chrome extensions and built our first extension. We connected it to our server using Pusher to get updates in realtime.
For this tutorial, we will allow users to customize their experience a little.
Prerequisites
- You have read the first part of this guide
New tools we will add
Creating our server with Express instead of HTTPS
Ah… yes. This is an important update to explain so you do not get confused. If you are familiar with Node.js, you would realize this is a no-brainer. If we were to use HTTPS to create our server, we would have to designed middlewares (maybe?) to check for the following:
- Type of request we are receiving [GET, POST, PUT …]
- The URI of the request
- The body of the request
- Create self-signed SSL certs for the HTTPS server to work
- A few more other things we might not know off the top of our head until we proceed to use it
With Express and body-parser you will be handling requests like “Please, can I get a real challenge?” 💪. We still use HTTPS to make the request to CryptoCompare’s API because as of this writing, they do not support requests over HTTP.
Enough talking, open your index.js
file. To reduce confusions, we will assume that this file is empty. So feel free to wipe everything in it and start afresh.
Importing all we will need
// index.js
"use strict";
const cryptos = require('./cryptos.json')
const jwt = require('jsonwebtoken')
const bodyParser= require('body-parser')
const helpers = require('./helpers')
const config = require('./config')
const express = require('express')
const bcrypt = require('bcrypt')
const DB = require('./db')
We have a few additions here that we need to install and define. Let us start with what we need to install. Run the following command on your console
$ npm install --save express body-parser jsonwebtoken sqlite3 bcrypt
Now, create the following files:
$ touch helpers.js
$ touch db.js
We shall define their contents soon.
Initial definitions of what we will need
Back to index.js
file, add the following definitions:
//index.js
[...]
const db = new DB("sqlitedb")
const app = express()
const router = express.Router()
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
At this stage, we have imported our database manager and passed the name we want it to create the database with. Then we created an instance of express
and express.Router()
to allow us to create a server and define routes respectively.
We also informed our express.Router()
handler to use body-parser
definitions, this will help us to read data sent from the client either as json
or regular form submission.
More initial definitions
// index.js
[...]
const {allowCrossDomain,fetchCoins,handleResponse,handleFavoriteResponse,generateUrl} = helpers
app.use(allowCrossDomain)
const defaultUrl= generateUrl(cryptos.coins,cryptos.currencies)
fetchCoins(defaultUrl,handleResponse)
// We will define our routes here
app.use(router)
app.listen(process.env.PORT || 4003)
We imported helper functions to help us process our requests and responses to users. We will create them shortly. The allowCrossDomain
middleware will allow our Express application receive a request from a domain other than itself. For instance, if we run our app on localhost:4000
, we can not make a call to it from localhost:4001
without a pre-flight issue (CORS).
The fetchCoins()
method fetches the coins and sends them to Pusher which will broadcast to all our users who are listening on that channel.
Finally, we set our application to listen on either a dynamically assigned port or 4003. We are set to go at this point.
Setting up our authentication routes
Still in the index.js
file, let us start with the route to handle authentication:
//index.js
[...]
// We will define our routes here
router.post('/auth', function(req, res) {
db.selectByEmail(req.body.email, (err,user) => {
if (err) return res.status(500).send(JSON.stringify({message : "There was a problem getting user"}))
if(user) {
if(!bcrypt.compareSync(req.body.password, user.user_pass)) {
return res.status(400).send(JSON.stringify({message : "The email or password incorrect"}))
}
let token = jwt.sign({ id: user.id }, config.secret, {
expiresIn: 86400 // expires in 24 hours
})
res.status(200).send(JSON.stringify({token: token, user_id:user.id}))
} else {
db.insertUser([req.body.email,bcrypt.hashSync(req.body.password, 8)],
function (err, id) {
if (err) return res.status(500).send(JSON.stringify({message : "There was a problem getting user"}))
else {
let token = jwt.sign({ id: id }, config.secret, {
expiresIn: 86400 // expires in 24 hours
});
res.status(200).send(JSON.stringify({token: token, user_id:id}))
}
});
}
})
})
[...]
In our /auth
route, we are checking if the user exists. If the email and password matches what we stored, we log the user in and send them a token. If the email exists but the password does not match, we have to handle that as well.
jwt
creates a hashed token using the user’s ID and the secret we defined in our config.js file. It sets the token to expire in 24 hours but you can make this longer or shorter
What we are sending back to the user is their token and ID to help them make requests in the future.
When we cannot find the email address of a user in our database, we assume we have a new user and register them directly. We also generated a token and returned it to the user.
WARNING❗
For any reason whatsoever, do not implement your authentication in a production application like this. While it provides convenience for you, it is very bad for user experience. We did it for the sake of this guide to get quickly to the most important thing — the Chrome extension.
Setting up routes to fetch coins
//index.js
[...]
router.get('/coins', function(req, res) {
let token = req.headers['x-access-token'];
if (!token) return res.status(401).send(JSON.stringify({message: 'Unauthorized request!' }))
jwt.verify(token, config.secret, function(err, decoded) {
if (err) return res.status(500).send(JSON.stringify({message: 'Failed to authenticate token.' }))
res.status(200).send(JSON.stringify({coins : cryptos.coins}))
});
})
[...]
The coins route is protected by the access token. This means only authenticated users can fetch the coins we track their prices.
Setting up routes to add our favorite coins
//index.js
[...]
router.post('/favorite/add', function(req, res) {
let token = req.headers['x-access-token'];
jwt.verify(token, config.secret, function(err, decoded) {
if (err) return res.status(401).send(JSON.stringify({message: 'Unauthorized request' }))
db.insertFavorite([req.body.coin, decoded.id], (err,favs) => {
if (err) return res.status(500).send(JSON.stringify({message : "There was a problem adding your favs"}))
res.status(200).send(JSON.stringify({message: "Coin added to your favorites"}))
});
});
})
[...]
The good thing about our token is that we encoded it with the user’s ID. This means, once the user presents a valid token, we can find out who the user is from the token. Awesome right?
Once we find who the user is, we add the coin they chose to their favorites. Whenever they click on the link to see their favorites, we return it to them and track price changes in realtime.
Setting up routes to fetch our favorite coins
//index.js
[...]
router.get('/favorite', function(req, res) {
let token = req.headers['x-access-token'];
jwt.verify(token, config.secret, function(err, decoded) {
if (err) return res.status(401).send(JSON.stringify({message: 'Unauthorized request' }))
db.selectFavorite(decoded.id, (err,favs) => {
// We use the favs returned by the db manager
if (err) return res.status(500).send(JSON.stringify({message : "There was a problem getting your favs"}))
let coins = []
if (favs && favs.length > 0) {
favs.forEach( fav => coins.push(fav.coin))
const url = generateUrl(coins,cryptos.currencies)
const event = `user${decoded.id}`
fetchCoins(url, handleFavoriteResponse, event)
res.status(200).send(JSON.stringify({event : event}))
} else {
res.status(200).send(JSON.stringify({message : "You do not have favs"}))
}
});
});
})
[...]
This is straightforward. If the token is invalid, we return 401: Unauthorised
to the user and that settles it. If the token is valid, then we decode it and retrieve the user’s favorite coins.
Sqlite3
returns an array of objects for us, so we take all the coins and push them into an array — coins. We use the coins array to construct the URL we will use to make requests. Then we generate an event for the user based on theirid
. This makes it possible for us to send a message that only the user will receive.
Our route definitions are complete now. Let us define our helper functions.
Defining the helpers
The helpers are set of functions we stored in helpers.js
file. Open the file and add the following
const Pusher = require('pusher')
const config = require('./config')
const https = require('https')
const pusher = new Pusher(config)
// The functions
const allowCrossDomain = (req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', '*');
res.header('Access-Control-Allow-Headers', '*');
next();
}
let fetchCoins = (url, handler, event = false) => {
setInterval(() => {
https.get(url, response => {
response.setEncoding('utf8')
.on('data', data => event? handler(data,event) : handler(data))
.on('error', e => console.error(e.message))
})
}, 10000)
}
let handleResponse = (data) => {
pusher.trigger('cryptowatch', 'prices', {
"update": data
});
}
let handleFavoriteResponse = (data,event) => {
pusher.trigger('cryptowatch', event, {
"update": data
});
}
let generateUrl = (coins,currencies) => {
return `https://min-api.cryptocompare.com/data/pricemulti?fsyms=${coins.join()}&tsyms=${currencies.join()}`
}
module.exports = {
allowCrossDomain : allowCrossDomain,
fetchCoins : fetchCoins,
handleResponse : handleResponse,
handleFavoriteResponse : handleFavoriteResponse,
generateUrl : generateUrl
}
We are using Pusher in one of the functions here, so it only makes sense we define it. We exported all the functions with their name so it is easy for us to import them where we need it. We created allowCrossDomain
function to help us handle CORS. We also moved our fetchCoins
method from part one into our helper file, this way the method is reusable especially when it comes to creating dynamic events on Pusher channel.
Finally, we defined our generateURL
method, which we will call to fetch our defined currencies or a user’s favorite.
The database manager
The database manager manages the database. We will define the queries to create the database, select from the database and insert into it. Because this is not part of our focus, I will gloss over the long code pasted below and explain things that may vary if you are familiar with MySQL.
Insert the following content into our db.js
file:
//db.js
"use strict";
const sqlite3 = require('sqlite3').verbose();
class Db {
constructor(file) {
this.db = new sqlite3.Database(file);
this.createTables()
}
createTables() {
let sql = `
CREATE TABLE IF NOT EXISTS user (
id integer PRIMARY KEY,
email text NOT NULL UNIQUE,
user_pass text NOT NULL)`
this.db.run(sql);
sql = `
CREATE TABLE IF NOT EXISTS favorite (
id integer PRIMARY KEY,
coin text NOT NULL,
user_id integer NOT NULL)`
this.db.run(sql);
return true
}
selectByEmail(email, callback) {
return this.db.get(
`SELECT * FROM user WHERE email = ?`,
[email], (err,row) => {
callback(err,row)
}
)
}
selectFavorite(user_id, callback) {
return this.db.all(
`SELECT * FROM favorite WHERE user_id = ?`,
[user_id], (err,row) => {
callback(err,row)
}
)
}
insertUser(user, callback) {
return this.db.run(
'INSERT INTO user (email,user_pass) VALUES (?,?)',
user, function(err) {
callback(err,this.lastID)
}
)
}
insertFavorite(favs, callback) {
return this.db.run(
'INSERT INTO favorite (coin,user_id) VALUES (?,?)',
favs, err => {
callback(err)
}
)
}
}
module.exports = Db
A few things to know:
- We will execute
sqlite3
queries that will not return a row or rows with.run()
method. - When we want to fetch a single row, we will execute the query with a
.get()
method. - When we want to fetch multiple rows, we will execute the query with a
.all()
method. - To get the
id
of the last inserted item, we runthis.lastID
inside the callback function we passed to.run()
method. - All
sqlite3
methods are asynchronous, hence the callback functions we pass to each one.
Now, let us modify our Chrome extension.
Updating our Chrome extension
We have explained Chrome extensions in details in the last post. This means we will get right to our customization.
The first thing we want to do is get permission for our extension to use Chrome storage. So, modify the permissions
array in the extension/manifest.json
file and add the following:
[...]
"permissions": [
"activeTab",
"storage"
]
[...]
Let us update our extension/index.html
file
//extension/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Crypto Watcher</title>
<link rel="stylesheet" type="text/css" href="./css/style.css">
</head>
<body>
<h1>Welcome to Crytowatcher</h1>
<div class="box hide">
<a href="/coins.html" class="link">1. All Coins</a>
<a href="/favorite.html" class="link">2. My Favorites</a>
<a href="/add-favorite.html" class="link">3. Add Favorites</a>
</div>
<div class="login hide">
<form id="form">
<input class="form-elements" type="email" name="email" value="" placeholder="email" required>
<input class="form-elements" type="password" name="password" value="" placeholder="password" required>
<button class="form-elements btn">Submit</button>
</form>
</div>
<script src="./js/index.js"></script>
</body>
</html>
We removed the style we included in the page and then proceed to add it to an external stylesheet. We will create the external stylesheet later.
Things to note:
- The first thing you will notice is that we removed the Pusher scripts we had on this page before. We moved it to where we will need it shortly.
- Next, we added links to other pages. We will create the pages very soon.
- Then we have a form which will handle login.
- It seems we are hiding both the link and the form. So what shows up?
- We have removed the
script.js
file and createdindex.js
in the/js
directory.
Before we proceed, open you terminal to create the following folder and files.
$ mkdir extension/js
$ touch extension/js/index.js
$ touch extension/js/favorite.js
$ touch extension/js/auth.js
$ touch extension/js/add-favorite.js
$ mkdir extension/css
$ touch extension/css/base.css
$ touch extension/css/style.css
$ touch extension/favorite.html
$ touch extension/add-favorite.html
$ touch extension/coins.html
Finally, we want to move pusher.min.js
and scripts.js
file into our js
folder. Run this command to move the file:
mv extension/pusher.min.js extension/js
mv extension/scripts.js extension/js
Now, open the extension/js/index.js
script and insert the following:
//extension/js/index.js
window.addEventListener('load', function(evt) {
const form = document.getElementById('form')
document.getElementById('form').addEventListener('submit', function(e) {
e.preventDefault()
let options = {
method : "POST",
body : JSON.stringify({
'email' : this.elements.email.value,
'password' : this.elements.password.value
}),
headers : new Headers({'content-type': 'application/json'})
}
fetch("http://localhost:4003/auth",options)
.then(res => {
if(res.ok) return res.json()
else throw new Error(res.status)
})
.then(data => {
storeToken(data, () => {
document.querySelector('.box').classList.remove('hide')
document.querySelector('.login').classList.add('hide')
})
})
.catch(error => {
console.log(error)
})
})
getToken(result => {
if(!result) {
document.querySelector('.login').classList.remove('hide')
}
else {
document.querySelector('.box').classList.remove('hide')
}
})
})
function storeToken(data, callback){
let dt = new Date()
chrome.storage.local.set(
{ "access-token":
{
'token' : data.token,
'user_id' : data.user_id,
'expires' : dt.setDate(dt.getDate()+1)
}
},
() => callback()
);
}
function getToken(callback){
chrome.storage.local.get("access-token", result => {
let data = false
if (result['access-token']) {
let expires = new Date(result['access-token']['expires'])
let now = new Date()
if (expires > now) {
data = true
}
else {
chrome.storage.local.remove("access-token", () => {})
}
}
callback(data)
});
}
These two methods handle our authorization token. One stores it in Chrome storage and the second retrieves it. Chrome storage works like local storage but it is asynchronous, hence the callback functions we defined.
When we store tokens, we store a time they are to expire as well. This allows us to remove the token if it has expired without having to make a request to the server. Since we know the tokens typically last a day, we set the expiration time here on the frontend.
When we retrieve a token, we check if it has expired or not. If it has expired, we remove it completely and so the user will not try to use it to make a request.
Defining our stylesheet for our index page
/* extension/css/style.css*/
body {
min-width: 200px;
height: 300px;
padding: 10px;
}
h1 {
text-align: center;
margin-bottom: 2rem;
}
.box {
display: block;
}
.box .link {
transition: 0.2s all;
font-size: 16px;
font-weight: 900;
padding: 5px;
text-decoration: none;
color: #3A4A33;
display: block;
}
.box .link:hover {
transition: 0.1s linear;
opacity: 0.8;
font-weight: 300;
text-decoration: underline;
}
.hide {
display: none
}
.form-elements {
display: block;
height: 20px;
border-radius: 2px;
margin: 0 auto;
margin-bottom: 10px;
border: #eee 0.5px solid;
max-width: 100%;
padding: 5px;
}
.select-elements {
display: block;
height: 30px;
margin-bottom: 10px;
border: #eee 0.5px solid;
width: 100%;
padding: 5px;
}
select:required:invalid {
color :#AAAAAA;
}
option[value=''][disabled] {
display: none;
}
.btn {
height: auto;
transition: 0.5s all;
background: #4AA71B;;
color: #FFFFFF;
margin-top: 1rem;
padding: 0.5rem 2rem;
border-radius: 4px;
}
.btn:hover {
transition: 0.5s all;
background: #4A872B;
cursor: pointer;
}
Next, insert the following content into base.css
file
/* extension/css/base.css */
body {
min-width: 200px;
height: 300px;
}
.back-btn {
height: 1.2rem;
color: #FFFFFF;
font-size: 14px;
font-weight: 900em;
cursor: pointer;
border-radius: 4px;
background: #3A3A3B;
text-decoration: none;
padding: 0.2rem 1rem;
margin-top: 0.5rem;
}
nav {
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto;
justify-content: space-between;
align-content: space-between;
}
Create a page to list all coins
The first page we want to make is the page to display all coins.
Open the file extension/coins.html
and add the following content:
// extension/coins.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Crypto Watcher</title>
<link rel="stylesheet" type="text/css" href="./css/base.css">
</head>
<body>
<nav>
<h1>Coin Prices</h1>
<a href="/index.html" class="back-btn">←</a>
</nav>
<main>
<ol id="crypto-prices">Fetching coins...</ol>
</main>
<script src="./js/pusher.min.js"></script>
<script src="./js/auth.js"></script>
<script src="./js/scripts.js"></script>
</body>
</html>
We changed a few little things:
- We added an
auth.js
script, which ensures only an authenticated user views this page. - We moved the
scripts.js
file from in/js
directory and changed nothing in it. - We added a link to take us back to home page.
- We now have a
nav
andmain
section on our page.
Create a page to view all user defined favorite coins
Open the extension/favorite.html
file and insert the following content
// extension/favorite.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Crypto Watcher</title>
<link rel="stylesheet" type="text/css" href="./css/base.css">
</head>
<body>
<nav>
<h1>My Favorites</h1>
<a href="/index.html" class="back-btn">←</a>
</nav>
<main>
<ol id="crypto-prices">Fetching coins...</ol>
</main>
<script src="./js/pusher.min.js"></script>
<script src="./js/auth.js"></script>
<script src="./js/favorite.js"></script>
</body>
</html>
Now, let us create the content of our extension/js/favorite.js
file:
//extension/js/favorite.js
const pusher = new Pusher('Your-App-Key', {
cluster: 'Your-cluster-key',
encrypted: true
})
function handleBinding(event){
let channel = pusher.subscribe('cryptowatch');
channel.bind(event, (data) => {
let priceLists = ""
let obj = JSON.parse(data.update)
Object.keys(obj).forEach( (key, index) => {
priceLists += `<li>${key}: </br>`
let currencies = obj[key]
let currencyLists = "<ul>"
Object.keys(currencies).forEach( (currency, index) => {
currencyLists += `<li>${currency} : ${currencies[currency]}</li>`
});
currencyLists += "</ul>"
priceLists += `${currencyLists}</li>`
});
document.getElementById('crypto-prices').innerHTML = priceLists
});
}
Remember to replace ‘Your-App-Key’ and ‘Your-cluster-key’ with the values you generated from Pusher’s dashboard
We have a similar script running here to what we had in part one that fetches and updates realtime data. The only difference is that we took the channel subscription part and put it in a function. You will see why below:
// favorite.js
[...]
window.addEventListener('load', function(evt) {
let xhr = new XMLHttpRequest();
getToken(function(result) {
xhr.open("GET", 'http://localhost:4003/favorite', true);
xhr.setRequestHeader('x-access-token',result.token)
xhr.send();
})
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
let res = JSON.parse(xhr.responseText)
if(res.event) handleBinding(res.event)
else document.getElementById('crypto-prices').innerHTML = res.message
}
}
})
As you can see here, we make a request to our API to get the user’s favorites. Upon getting the result, we call the function where we defined the channel binding and pass the user’s unique event. This is how they will get custom coin price updates. Cool right? 😎
Create a page for users to add favorite coins
Open the extension/add-favorite.html
file and insert the following content
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Crypto Watcher</title>
<link rel="stylesheet" type="text/css" href="./css/base.css">
<link rel="stylesheet" type="text/css" href="./css/style.css">
</head>
<body>
<nav>
<h1>Add Favorite</h1>
<a href="/index.html" class="back-btn">←</a>
</nav>
<main>
<form id="form">
<select id="coin" class="select-elements" type="text" name="coin" required>
<option disabled>-- Select Coin --</option>
</select>
<button class="form-elements btn">Submit</button>
</form>
</main>
<script src="./js/auth.js"></script>
<script src="./js/add-favorite.js"></script>
</body>
</html>
Let us create the content for out add-favorite.js
file. It has two main parts. Open the file and edit as follows:
The first part fetches the coins
//extension/js/add-favorite.js
window.addEventListener('load', function(evt) {
let xhr = new XMLHttpRequest();
let coinOptions = document.getElementById('coin')
getToken(result => {
xhr.open("GET", "http://localhost:4003/coins", true)
xhr.setRequestHeader('x-access-token',result.token)
xhr.send()
})
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
let result = JSON.parse(xhr.responseText)
result.coins.forEach(coin => {
let el = document.createElement('option')
el.value = coin
coinOptions.appendChild(el)
el.innerText = coin
})
}
}
// Form submission
})
And the second submits the form:
//extension/js/add-favorite.js
[...]
document.getElementById('form').addEventListener('submit', function(e) {
e.preventDefault()
getToken(result => {
let options = {
method : "POST",
body : JSON.stringify({coin : this.elements.coin.value}),
headers : new Headers({
'content-type': 'application/json',
'x-access-token' : result.token
})
}
fetch('http://localhost:4003/favorite/add',options)
.then(res => {
if(res.ok) return res.json()
else throw new Error(res.status)
})
.then(data => {
window.location = "/favorite.html"
})
.catch(error => console.log(error))
})
})
[...]
Finally, we need to create our auth.js
file
Open the extension/js/auth.js
file and add the following content
getToken(function(result) {
if(!result) {
window.location = "/index.html"
}
})
function getToken(callback){
chrome.storage.local.get("access-token", (result) => {
let data = false
if (result['access-token']) {
let expires = new Date(result['access-token']['expires'])
let now = new Date()
if (expires > now) {
data = {
'token' : result['access-token']['token'],
'user_id' : result['access-token']['user_id']
}
}
else {
chrome.storage.local.remove("access-token", () => {})
}
}
callback(data)
})
}
We called the getToken
function and check if we have a valid token. If we don’t, we redirect the user to the index.html
file where they can log in.
Testing the extension
If you got to this point, then I am happy to tell you that we are done coding. Time to mount the extension and give it a trial. From your browser, open chrome://extensions/
and click Load Unpacked
. Then select your extension’s directory.
If you did not deploy your application to Heroku like we did in part one, you can run it locally to give it a trial. However, if you deployed to Heroku, please go through the code and replace http://localhost:4003
with your Heroku app URL.
To run it locally:
$ npm start
Your extension should look like this:
NOTE: On My Favorites page, there will be an initial delay of about ten seconds when trying to fetch the coin for the first time
Conclusion
In the last part, we said we would extend the extension to make it easy for a user to specify the tokens they want to track. Well, we have delivered that.
At the start of this tutorial, I mentioned that when you are done with it, you would realise building a Chrome extension is like making a webpage. There are a few things that change when you are building an extension.
Before you say “Wow! So this is all there is to Chrome extension”, let me pause you for a moment. A Chrome extension can interact with the webpage where it is loaded. It can also do so much more than what we have seen so far.
The source code to the application in this article is available on GitHub.
27 October 2018
by Fisayo Afolayan