Saltar ó contido principal

Writing a browser extension in typescript

Every once in a while I write some small web browser extension to do little things. You could, for example, write an extension for showing the ID's of elements in a web or replacing AI with "a bunch of dudes", and after firefox adopting WebExtensions as the way to write these plugins we can make it so the same code works with Firefox and Chrome!

While we're at it, let's see which extra steps we have to take to write these extensions on Typescript, after we have a running Javascript version.

So let's go ahead and write a simple extension which allows us show the ID's of elements on a web. This could be used, for example, to generate links which end with #id-of-element which when opened will make the browser scroll to that element. We won't go in depth, let's make it fast.

Extension popup

The base

Firstly, for the extension to be understood by the browser it needs some files to be created, so let's make a directory for our extension. I'll go with show-element-ids as the name, but you can use whatever you prefer.

mkdir show-element-ids
cd show-element-ids

Inside, create a manifest.json file with a content like this (minus the #comments):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
  "manifest_version": 2,  # Just have this as a version
  "name": "show-element-ids",  # This can be any string you like
  "version": "0.0.1",     # You can use any version here

  #  Little description of your extension
  "description": "Make the ID of elements in the page clickable.",

  #  A simple (48x48) icon for your extension. (More sizes are supported)
  "icons": {
    "48": "icons/icon-48.png"
  },

  #  Ask for permissions to fiddle with the active tab when invoked
  "permissions": [
    "activeTab"
  ],

  # Define a pop-up to be shown when the icon is clicked
  "browser_action": {
    # Default an icon for the titlebar button
    "default_icon": {
      # Same as the "icons" entry above
      "48": "icons/icon-48.png"
    },
    "default_title": "show-element-ids", # Title for the button
    "default_popup": "popup/show-element-ids.html" # A file we'll create later
  }
}

Note that you'll have to remove everything after the # (included) from the manifest. These are just comments and won't be understood by the browser.

Note, also, that this differs a little from what's on Mozilla wiki: Your first extension (a great resource!) because we'll write an extension that will run on demand instead of one that is automatically triggered when you enter a web. You can see that instead of "content_scripts" we have "default_actions".

Now let's create a directory for the icons named icons, in the same directory as the manifest.json file. We can get an icon from LogoDust for starters (they are open-source and pretty good), just resize it to 48x48 pixels and place it into icons/icon-48.png.

One last step before we can load the firt version, let's create the view that will be created when the toolbar icon is pressed. Create a popup directory and write some HTML inside the show-element-ids.html file.

mkdir popup
echo 'This is a test' > popup/show-element-ids.html

Ok, now we're ready to load this extension on the browser.

Loading the extension

This step differs depending on the browser you use, so I'll just point you to the relevant documentation for each browser:

From here on I follow the steps considering we're using firefox to test the application, but the same code will be compatible with both browsers.

The result now show be this:

Extension popup showing when clicked

Adding some logic

Now that we have loaded a interface for the extension, we can add some logic. For starters we'll add a button that triggers an alert(). To do this update the popup/show-element-ids.html file to contain this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
    </head>

    <body>
        <button id="run_button">
            RUN
        </button>

        <script src="show-element-ids.js"></script>
    </body>
</html>

Now, to add functionality to the button, add (in the popup directory) this show-element-ids.js file:

function activate() {
    alert("Running!");
}

document.getElementById("run_button").onclick = activate;

Note that we cannot add Javascript events on the HTML file (like handlers for onclick events). Instead we have to add then from the Javascript file.

Right now, our extension should work as this:

Extension popup with embedded alert()

Modifying the current tab

The goal of our extension was to show the ID's of elements in a web. So let's start by injecting code in the current tab, which then we can adapt for our goal.

Note that to manipulate the active tab we need the "activeTab" permission, which we requested on the manifest.json file. Also, special tabs like the ones containing browser pages (the ones starting with about:) cannot be manipulated.

First, to launch the alert() dialog on the tab, let's define some useful functions on show-element-ids.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Channel to communicate with the browser
const Browser = window.browser;

// Find the currently active tab
function get_current_tab() {
    return new Promise((resolve, reject) => {
        Browser.tabs.query({active: true, lastFocusedWindow: true}, (tab) => {
            if (tab === undefined) {
                reject("Error retrieving tab");
            }
            resolve(tab[0]);
        });
    });
}

// Run a javascript file on a tab. When it's completed
function run_on_tab(tab, file) {
    return new Promise((resolve, reject) => {
        Browser.tabs.executeScript(tab.id, {
            file,
        }, resolve );
    });
}

And update the code that we had previously there to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function activate() {
    get_current_tab()
        .then(tab => {
            return run_on_tab(tab, "/popup/injected.js");
        })
        .then(() => {
            console.log("Execution successful");  // Back on the extension
        });
}

document.getElementById("run_button").onclick = activate;

Finally, just write the desired logic on the popup/injected.js file, for example:

1
alert("We're inside the tab!");

Extension running injecting code in the tab

Do note something, though launching an alert() inhibits the popup code to detect that the injected one has finished, we can see that the promise result (console.log("Execution successful");) is never run. This wouldn't happen if we used console.log() on the browser tab, instead of running alert().

Detecting and showing element IDs

One thing that remains before we can start moving our code to Typescript is to detect the elements with an ID and show them somehow (the whole point of the extension). We can update popup/injected.js to do this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(function() {
    const elements_with_ids = document.querySelectorAll('*[id]');
    const stylesheet = document.styleSheets[0];

    const mark_element = function(element) {
        const rule = '#' + element.id + '::after{ content:" #' + element.id + '"; }';
        stylesheet.insertRule(rule, stylesheet.cssRules.length);
    };

    for (const element of elements_with_ids) {
        mark_element(element);
    }
})();

This will just append to the HTML elements with and .id property a text showing that ID with an # before.

Extension modifying the DOM

Moving to typescript

At last, the only thing that remains is to move our Javascript code to Typescript. To do this create a src directory, which we'll use to store the "uncompiled" typescript code, and copy there the Javascript files.

mkdir src
mv popup/injected.js src/injected.ts
mv popup/show-element-ids.js src/show-element-ids.ts

Remember to install the Typescript compiler

npm install -g tsc

And write the typescript configuration for both files. Normally a single configuration can ingest multiple files but keep in mind that we want two outputs, as the injected script has to be separate so it can be loaded into the tab. As such, these are the configurations, just add them on the same directory as the manifest.json.

tsconfig_injected.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "compileOnSave": false,
  "files": [
    "./src/injected.ts"
  ],
  "compilerOptions": {
    "outFile": "./popup/injected.js",
    "sourceMap": true,
    "module": "amd",
    "declaration": false,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": [
      "es2016",
      "dom"
    ]
  }
}

tsconfig_popup.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "compileOnSave": false,
  "files": [
    "./src/show-element-ids.ts"
  ],
  "compilerOptions": {
    "outFile": "./popup/show-element-ids.js",
    "sourceMap": true,
    "module": "amd",
    "declaration": false,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "lib": [
      "es2016",
      "dom"
    ]
  }
}

And let's throw in a Makefile to simplify the compilation as using webpack for this seems overkill:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
.PHONY: all build

TSC:=tsc

all: build
build: dist/extension.xpi

dist/extension.xpi: popup/injected.js popup/show-element-ids.js manifest.json popup/show-element-ids.html icons/icon-48.png
    zip $@ $+ # WARNING ON COPY: this line must start with TAB

popup/injected.js: src/injected.ts
    $(TSC) --project tsconfig_injected.json # WARNING ON COPY: this line must start with TAB

popup/show-element-ids.js: src/show-element-ids.ts
    $(TSC) --project tsconfig_popup.json # WARNING ON COPY: this line must start with TAB

With this we can build our extension with make and it'll generate a nice ZIP file on dist/extension.xpi. But if we try to do this make, we find the following errors:

> make
tsc --project tsconfig_injected.json
src/injected.ts:7:20 - error TS2339: Property 'insertRule' does not exist on type 'StyleSheet'.

7         stylesheet.insertRule(rule, stylesheet.cssRules.length);
                     ~~~~~~~~~~

src/injected.ts:7:48 - error TS2339: Property 'cssRules' does not exist on type 'StyleSheet'.

7         stylesheet.insertRule(rule, stylesheet.cssRules.length);
                                                 ~~~~~~~~

src/injected.ts:10:27 - error TS2495: Type 'NodeListOf<Element>' is not an array type or a string type.

10     for (const element of elements_with_ids) {
                             ~~~~~~~~~~~~~~~~~


Found 3 errors.

make: *** [Makefile:12: popup/injected.js] Error 2

There are some typing ambiguities, so we have to update our src/injected.ts and show-element-ids.ts file so we can compile them as Typescript:

src/injected.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(function() {
    const elements_with_ids = document.querySelectorAll('*[id]') as any as HTMLElement[];
    const stylesheet = document.styleSheets[0] as CSSStyleSheet;

    const mark_element = function(element) {
        const rule = '#' + element.id + '::after{ content:" #' + element.id + '"; }';
        stylesheet.insertRule(rule, stylesheet.cssRules.length);
    };

    for (const element of elements_with_ids) {
        mark_element(element);
    }
})();

src/show-element-ids.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Channel to communicate with the browser
const Browser = (window as any).browser;

// Find the currently active tab
function get_current_tab() {
    return new Promise((resolve, reject) => {
        Browser.tabs.query({active: true, lastFocusedWindow: true}, (tab) => {
            if (tab === undefined) {
                reject("Error retrieving tab");
            }
            resolve(tab[0]);
        });
    });
}

// Run a javascript file on a tab. When it's completed
function run_on_tab(tab, file) {
    return new Promise((resolve, reject) => {
        Browser.tabs.executeScript(tab.id, {
            file,
        }, resolve );
    });
}

function activate() {
    get_current_tab().then(tab => {
        return run_on_tab(tab, "/popup/injected.js");
    })
        .then(() => {
            console.log("Execution successful");  // Back on the extension
        });
}

document.getElementById("run_button").onclick = activate;

And with this, the compilation now runs successfully:

>  make
tsc --project tsconfig_injected.json
tsc --project tsconfig_popup.json
zip dist/extension.xpi popup/injected.js popup/show-element-ids.js manifest.json popup/show-element-ids.html icons/icon-48.png
  adding: popup/injected.js (deflated 51%)
  adding: popup/show-element-ids.js (deflated 53%)
  adding: manifest.json (deflated 52%)
  adding: popup/show-element-ids.html (deflated 38%)
  adding: icons/icon-48.png (stored 0%)

And we have an extension which we can work on, built from Typescript code!

You can find the final code on GitHub or GitLab.

References