By Ruslan Prytula August 13, 2015 9:37 PM
Development of Cross-browser Extensions using ES6 and BabelJS

tl; dr… source code of the extension

Last few months we were working on our donation/funding service (Tips) that gives anyone the possibility to thank individual contributors of biggest IT communities such as Stack Overflow, Github and Gitter by sending small money rewards. Here is how it’s supposed to work:

Somewhere on Gitter or Stackoverflow Somewhere on Gitter or Stackoverflow: Somewhere on Gitter or Stackoverflow:

As the part of the service, we developed a browser extension for 3 browsers — Google Chrome, Firefox and Opera. In this post I would like to share what we’ve learned.

Disclaimer: this post is not about some cool framework that allows you to write all of your code once and have it run in all major browsers. Rather this blog post is about organizing your project so that you can re-use as much code as possible. Still some browser-specific coding is required.

The idea

We wanted to provide a way to send gratuities on biggest IT communities and after some discussions we came up with an idea, that a browser extension will be the best tool for this purpose as it allows us to work with 3rd party website’s DOM, change CSS, make AJAX requests and it’s very easy to install one through the browser’s extension store. Well, the latter is rather a big disadvantage than a plus. But we thought that if people like the idea they won’t mind installing a tiny browser extension.

How does the extension work?

Every time when user opens one of the supported websites (meaning GitHub, Stack Overflow or Gitter) or changes the page inside of that website, the extension should display small unobtrusive leave a tip button which leads to a payment page for all users how are able to receive tips (members of our service). Also the browser extension should display small icon in the address bar when it’s active so that the user is able to click on that icon to highlight all leave a tip buttons on a page.

Starting development

Start

Because most of the browsers still don’t support ES6 (except for latest version of Google Chrome) and because we wanted to use ES6 to try out the new JS syntax, we had to install a transpiler, a tool that transforms your ES6 source code to its ES5 equivalent so all browsers can understand it and execute. You can find several JS transpilers across the web, but for our extension we’ve chosen Babel because it’s simple, well-documented and there are a lot of examples of how to use it. Also we used GulpJS to put everything together. Tools we need:

  npm install -g gulp
  npm install -g gulp-babel

When after the installation was done, here is we how used babel in our gulpfile.js to transform our ES6-files (when we change them) to ES5-format from the src to the chrome folder (more about the folder structure later). We decided to start with an extension version for Google Chrome, the main browser we use.

var gulp = require("gulp"),
babel = require("gulp-babel");

gulp.task('watch', ['build-js'], function() {
  gulp.watch('./src/*.js', ['build-js']);
});

gulp.task('build-js', function() {
  return gulp.src('./src/*.js')
          .pipe(babel())
          // catch and print errors
          .on('error', console.error.bind(console))
          // put files to chrome data dir
          .pipe(gulp.dest('./chrome/data/'));
});

Chrome-specific Stuff

One of the main things of a browser extension is a manifest. It provides important metadata about the extension and tells the browser what the extension can do. Here is how it looks for the Google Chrome:

{
  "manifest_version": 2,
  "name": "Tips by 60 Devs",
  "description": "Tipping culture for the Web",
  "version": "1.5",
  "page_action": { // a toolbar action exposed by our extension
    "default_title": "Tips by 60devs",
    "default_icon": {
      "38": "data/icon_38.png"
    }
  },
  "permissions": [ // we need to have access to tabs and our website
    "tabs",
    "activeTab",
    "https://tips.60devs.com/*"
  ],
  "icons": { // icons
    "64": "data/icon_64.png",
    "128": "data/icon_128.png"
  },
  "content_scripts": [{ // this is where our content script is allowed to run
    "matches": [
      "https://github.com/*",
      "http://stackoverflow.com/*",
      "https://stackoverflow.com/*",
      "https://gitter.im/*",
      "https://tips.60devs.com/*"
    ],
    "js": ["data/app.js"],
    "css": ["data/app.css"],
    "all_frames": true
  }],
  "background": { // runs in the background
    "scripts": ["index.js"]
  }
}

You can read all information about the manifset on developer.chrome.com, and we will stop only on the most important ones:

  • content_scripts - describes which (js, css) files will be executed by the browser when user opens an url specified in the matches array.
  • background - describes scripts that work continuously in background until you close the browser.

Background scripts are running in the context of the web browser so that they can access browser API and modify browser’s UI. The content scripts are those running in the context of a web page opened by the browser. You can think of them as of normal .js files included via a script tag into a web page. The browser provides an API to communicate between background and content scripts but this API varies from browser to browser.

We put our js app into data/app.js file and it will be loaded into github/gitter and stackoverflow pages. And index.js together with page_action will provide a way to interact with currently running content scripts.

Project Structure to Support Other Browsers

Now when we have an extension for Google Chrome - how do we port it to other browsers? First, we separate the app code from the browser specific code. The following folder structure is helpful:

  • src - source folder for main app code (content scripts written in ES6) that is the same for all browsers
  • chrome, firefox, opera folders contain an extension version for the corresponding browser. Opera extensions are exactly the same as Chrome so we would just sym-link the opera folder to chrome
  • (chrome|firefox|opera)/index.js - the main background script that uses browser’s API
  • images - a place to put icons and pics
  • gulpfile.js - the glue for this structure

Since we write the extension using ES6 and we need gulp to compile es6 to es5, we can use gulp to build browser-specific extensions too:

var gulp = require('gulp'),
    babel = require('gulp-babel');

// watch is helpful for development
gulp.task('watch', ['build'], function() {
  gulp.watch('./src/*.js', ['build-js']);
  gulp.watch('./src/*.css', ['build-css']);
});

// we're building js for chrome and ff
gulp.task('build-js', function() {
  return gulp.src('./src/*.js')
          .pipe(babel())
          .on('error', console.error.bind(console))
          .pipe(gulp.dest('./chrome/data/'))
          .pipe(gulp.dest('./firefox/data/'));
});

// we can tweak our css. E.g. autoprefix it but 
// here we just copy it
gulp.task('build-css', function() {
  return gulp.src('./src/*.css')
          .on('error', console.error.bind(console))
          .pipe(gulp.dest('./chrome/data/'))
          .pipe(gulp.dest('./firefox/data/'));
});

// we can optimize images here
// but again we just copy
gulp.task('build-data', function() {
  return gulp.src('./images/**/*')
          .on('error', console.error.bind(console))
          .pipe(gulp.dest('./chrome/data/'))
          .pipe(gulp.dest('./firefox/data/'));
});

// a single command for each piece
gulp.task('build', ['build-js', 'build-css', 'build-data']);

This gulpfile defines 3 tasks to build css, js and copy data respectively. Now src is where we develop the extension and thanks to gulp watch task we continuously sync our source with browser folders.

Of course, there are parts which vary depending on the browser. For example, Google Chrome requires a manifest and Firefox needs a package.json to describe extension metadata. These files go directly to the browser folders. For example, an equivalent of the chrome’s manifest for Firefox will be placed into firefox/package.json:

{
  "title": "Tips by 60 Devs",
  "name": "tips-extension",
  "version": "1.5.0",
  "description": "Tipping culture for the WebReward developers who help you and be rewarded for helping others.",
  "main": "index.js",
  "author": "60devs",
  "engines": {
    "firefox": ">=38.0a1",
    "fennec": ">=38.0a1"
  },
  "permissions": {
    "private-browsing": true,
    "cross-domain-content": ["https://github.com/",
            "http://stackoverflow.com/",
            "https://stackoverflow.com/",
            "https://gitter.im/",
            "https://tips.60devs.com/"]
  },
  "license": "MIT"
}

The index.js is quite different too because browsers have different APIs. For Chrome it’s simpler:

/* globals chrome */

var onTabChange = function(tab) {
  var host = tab.url.split('://')[1]
  var tabId = tab.id;

  // showing extension icon whenever the host is github|stackoverflow|gitter|tips.60devs
  if (/^(github|stackoverflow|gitter|tips\.60devs)/.test(host)) {
    chrome.pageAction.show(tabId);
  } else {
    chrome.pageAction.hide(tabId);
  }
}

chrome.tabs.onActivated.addListener(function(activeInfo) {
  chrome.tabs.get(activeInfo.tabId, onTabChange);
});

chrome.tabs.onUpdated.addListener(function(tabId, changedInfo, tab) {
  onTabChange(tab);
});

chrome.pageAction.onClicked.addListener(function(tab) {
  // here we send a message to the content script to highlight `leave a tip` buttons
  chrome.tabs.sendMessage(tab.id, {
    action: 'pageActionClick'
  });
});

It handles the interactions mostly. And for Firefox it’s more complicated because one has to attach content scripts imperatively:

var buttons = require('sdk/ui/button/action');
var tabs = require('sdk/tabs');
var data = require('sdk/self').data;
var pageMod = require('sdk/page-mod');

var myTabs = {};

function createButton() {
  // button for the URL bar
  return buttons.ActionButton({
    id: 'tips-link',
    label: 'Highlight Tip Links',
    disabled: true,
    icon: {
      16: './icon_38.png',
      32: './icon_38.png',
      64: './icon_64.png'
    },
    onClick: function handleClick(state) {
      var worker = myTabs[tabs.activeTab.id];
      worker.port.emit('pageActionClick', {
        action: 'pageActionClick'
      });
    }
  });
}

var button = null;

function onAttach(worker) {
  var tab = worker.tab;
  var host = tab.url.split('://')[1];
  var tabId = tab.id;
  if (/^(github|stackoverflow|gitter|tips\.60devs)/.test(host)) {
    if (!button) {
      button = createButton();
    }
    button.state(tab, {
      disabled: false,
      icon: {
        16: './icon_38.png',
        32: './icon_38.png',
        64: './icon_64.png'
      }
    });
  } else {
    if (button) {
      button.destroy();
    }
    button = null;
  }
  myTabs[tabId] = worker;
  worker.on('detach', function() {
    if (button) {
      button.destroy();
    }
    button = null;
  });
}

var { List } = require('sdk/util/list');

// include app.js and app.css to the listed web sites
pageMod.PageMod({
  include: ['https://github.com/*',
            'http://stackoverflow.com/*',
            'https://stackoverflow.com/*',
            'https://gitter.im/*',
            'https://tips.60devs.com/*'],
  contentScriptFile: data.url('app.js'),
  contentStyleFile: data.url('app.css'),
  onAttach: onAttach
});

Both index.js files accomplish the same task - they add a button to the browsers toolbar when supported pages are opened. And when the button is clicked, index.js sends a message to the app running as a content script.

Another part that is browser-specific is messaging:

// src/app.js
if (typeof chrome !== 'undefined') {
  // running in Google Chrome
  chrome.runtime.onMessage.addListener(this.onExtensionMessage.bind(this))
} else {
  // FF
  self.port.on('pageActionClick', this.onExtensionMessage.bind(this));
}

In Chrome it’s done via a chrome object and in FF via a port object (this is based on Web Workers standard as far as a I know). This is how you can know in which type of web browser the extension is being executed.

Development Workflow

Normally, you start developing an extension for one browser keeping all browser differences in mind. So in our setup, you run gulp watch, open chrome://extensions/ in Google Chrome and load your extension using Load unpacked extension... button. The reload button on the extensions page helps to make sure you are running the latest version of extension.

For Firefox, you will need a tool called jpm. Using it you can start a Firefox instance with the extension installed from the firefox directory in our setup:

cd firefox
jpm

Once everything is ready you can build an xpi using the jpm xpi command.

For Google Chrome and Opera you would just zip the corresponding folder and upload to Web Store. For FF you upload the xpi file.

Safari - Sorry :-(

We’ve got no extension for Safari because it costs some money to publish and requires a Mac for development as far as we know. But I would assume that the process of creating an extension in Safari is similar to Chrome’s one.

Install our Tips extension, check out the complete extension source on Github and thanks for reading!