How to Ruby logo

How to build a server side rendered browser extension

02 Jul 2020

knowbox-quick-presentation

Browser extensions are a natural place to deploy a a heavy-duty JavaScript SPA framework as extension popups really are just a single html page, and the usual url means for navigation is absent. But it’s also possible to keep our frontend simple (and rather stateless) and send all the html we need from the server to create a nice UX for the user.

As the server is in full control of the HTML, JS and CSS it sends, this approach has the benefits of:

  • dynamic layout and content
  • dynamic css
  • dynamic js

I’ve built a small notes taking app as a browser extension popup to demonstrate the full implementation, and here I’d like to highlight a few aspects that make it all possible:

Extension starter template

Although an extension could be as simple as a package.json with one js and one html file, if you want to go production level, you’re likely to need a a proper build system as well to serve the extension in development mode, to compile it for production, and all that ideally working accross different browsers. I’ve had a good deal of research done looking and indeed I’ve managed to find a great starter template from @abhijithvijayan - https://github.com/abhijithvijayan/web-extension-starter. Thank you @abhijithvijayan!

As mentioned early in the documentation - for simple no-framework JS, use the master branch.

Fetch all js and css from the server

I went all the way, so that even my JS and CSS come from the server. That allows me to:

  • not have to publish a new version of the extension every time I make a change
  • reduce iteration cycles day to seconds

This is pretty big as you have much much greater control and flexibility over what your users see at any point in time. A deploy of your code is immediately reflected on the extensions.

To achieve that, we’d just query a dynamic endpoint (http://localhost:3000/scripts/popup), which would return the latest link to the JS file ➡️ http://localhost:3000/assets/application-[hash].js. We’d then render that inline. CSS is bundled in with the JS together.

// extension/source/scripts/popup.js
import { fetchFromServer } from './popup/http_connection'

const path = '/scripts/popup'
fetchFromServer(path).then((popupScriptUrl) => {
  new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = popupScriptUrl // ➡️ http://localhost:3000/assets/application-[hash].js
    script.onload = resolve
    document.getElementsByTagName('script')
    document.head.appendChild(script)
  })
})

Executing remote code like this is not enabled by default for protection. To enable it, add your url to the content_security_policy key:

"content_security_policy": "script-src 'self' http://localhost:3000; object-src 'self'",

See the full extension manifest.json for details.

Render index page from server

The first thing to do when the javascript is loaded is to fetch the HTML for our index page.

fetchFromServer('/snippets').then((html) => {
  CableReady.DOMOperations.innerHtml({
    element: document.body,
    html,
  })
})

We basically replace the body of our popup with whatever html came from the server. Once the html on the page is updated, the main stimulus controller and a few web components elements from Material Design kick in to provide the interactivity.

Interactivity

Any further interactivity is via a small Stimulus controller.

Updates to the page are fetched via websockets with the help of Stimulus Reflex. In the default case - the main listing of snippets are re-rendered on the server and morphed back to the document.body element.

Local development

knowbox-local-development

When iterating over the popup, clicking the extension browser action in the top right corner of the browser can get quite frustrating. It’s very easy to setup our own mock implementation of the popup window.

Create a rails layout file with the exact same html as the original popup window. Within a separate JS pack, require the original popup.js which is rendered by the browser when opening the browser extension.

The only caveat is that any browser extension specific functionalities won’t work. It’s easy to mock them. In this case I’ve replaced the whole webextension-polyfill module with my own dev shim:

// config/webpack/development.js#L16-L18
config.resolve.alias = {
  'webextension-polyfill': './browser-dev-shim.js',
}