Compile outputs fun

Lerna to manage multiple modules - Site killer extension for Chrome and Firefox - Part 1

Published 2 years agoTypescript

Overview

We will make a site killer browser extension here for Chrome and Firefox. It's meant to kill the tab if the hostname is in the blocked list. There will be 3 modules in this project:

Project Description
site-killer-common Base module that contains all the logics
site-killer-chrome Module to bind the Chrome specific capabilities to the logics behind
site-killer-firefox Module to bind the Firefox specific capabilities to the logics behind

We will use Lerna to manage these projects.

How to do it?

1. Setup

First we will setup Lerna. Create a new empty directory and run this command:

echo {} > package.json
npm i -D lerna
npx lerna init

This will install Lerna in the workspace only, so you can use different version of Lerna in your machine. You should see lerna.json is created in your directory with these contents:

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

It means it will manage the modules in the packages directory. Now we modify package.json to add some scripts to it to help us in the code development flow:

{
  ...
  "name": "site-killer",
  "scripts": {
    "bootstrap": "lerna exec --stream --include-dependencies npm install",
    "build": "lerna run --stream --include-dependencies build",
    "dev": "lerna run --parallel dev"
  }
}

Now you can do these:

Command Description
npm run bootstrap Install the dependencies for all the modules. It will do these following the order of the dependencies of the module.
npm run build Build all the modules. It will do these following the order of the dependencies of the module so the dependencies will get to be built first.
npm run dev Watch and build all the modules if there are changes. It will do these in parallel. It will show some error in the initial stage where the dependencies are not built first, but eventually it should get everything correct.

Now we can start the development work.

2. Create site-killer-common module

2.1 Setup Webpack

Create the directory ./packages/common and create package.json in it with these contents:

{
    "name": "site-killer-common",
    "version": "1.0.0",
    "main": "./dist/index.js",
    "scripts": {
        "build": "webpack",
        "dev": "webpack --watch --devtool inline-source-map"
    }
}

This will enable the scripts to build or watch the module. It also tell where is the main entry file that contains all the exported capabilities from this module. Next we will install the necessary dependencies for it by running this command:

npm i -D webpack webpack-cli ts-loader typescript copy-webpack-plugin typings-bundler-plugin

This will install these dependencies:

Dependency Name Description
webpack + webpack-cli Webpack to build your module
ts-loader Webpack loader to compile the Typescript files
typescript Typescript compiler
copy-webpack-plugin Copy files to dist directory after Webpack compilation stage
typings-bundler-plugin Generate the type definition files in the dist directory

Then we create webpack.config.js to tell Webpack how to compile our module:

const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const TypingsBundlerPlugin = require('typings-bundler-plugin');

module.exports = {
    entry: {
        index: './src/index.ts'    // this is our main entry file, this will contain all the exports from our module
    },
    output: {
        path: path.resolve(__dirname, 'dist'),    // this is the output directory
        filename: '[name].js',    // this is the output file name
        libraryTarget: 'umd'    // this means we are building a library
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/
            }
        ]
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.js']
    },
    plugins: [
        new CopyPlugin({    // copy the files from assets to the dist directory
            patterns: [
                { from: './assets' }
            ]
        }),
        new TypingsBundlerPlugin({    // generate the type definition file
            out: 'index.d.ts'
        })
    ],
    watchOptions: {    // ignore output directories so we don't trigger the Webpack infinitely here
        ignored: /\/common\/dist\//
    }
};

Next we create the tsconfig.json to tell the Typescript compiler how to compile our module:

{
    "compilerOptions": {
        "sourceMap": true,
        "declaration": true
    }
}

This will generate the source maps and the type definition file. The source maps is very useful if we want to debug our codes later. It must be turned ON here then the Webpack can choose if it wants to include it in the final output.

2.2 Code the logics

First we will create a global-events system to wire the events in the the module so we don't have to pass the callbacks around. Create ./src/utils/global-events.ts with these contents:

export namespace globalEvents {

    class Event {

        private readonly callbacks: (() => void)[] = [];

        register(callback: () => void) {
            this.callbacks.push(callback);
        }

        notify() {
            this.callbacks.forEach(e => e());
        }

    }

    export const blockedList = new Event();
    export const visitedList = new Event();
    export const rerun = new Event();

}

We have defined 3 global events here:

Event Name Description
blockedList Triggered when the blocked list changed
visitedList Triggered when the visited list changed
rerun Triggered when need to rerun the logic to kill the tabs

Then we will create ./src/utils/blocked-list.ts to manage the blocked list:

import { globalEvents } from "./global-events";

export namespace blockedList {

    export function get() {
        const list = JSON.parse(localStorage.getItem('blockedList'));
        return Array.isArray(list) ? list as string[] : [];
    }

    export function update(list: string[]) {
        localStorage.setItem('blockedList', JSON.stringify(list));
    }

    export function add(hostname: string) {
        const list = get();
        const newList = list.filter(e => e != hostname).concat(hostname).sort();
        update(newList);
        globalEvents.blockedList.notify();
    }

    export function remove(hostname: string) {
        update(get().filter(e => e != hostname));
        globalEvents.blockedList.notify();
    }

    export function clear() {
        update([]);
        globalEvents.blockedList.notify();
    }

    export function contains(hostname: string) {
        return !!get().find(e => e == hostname);
    }

}

This is just some simple CRUD methods to manage the list. It will always load the list from local storage and store it whenever there are changes. It will also send out an event if there are changes in the list.

Then we will create ./src/utils/visited-list.ts to manage the visited list:

import { blockedList } from "./blocked-list";
import { globalEvents } from "./global-events";

export namespace visitedList {

    export function get() {
        const list = JSON.parse(localStorage.getItem('visitedList'));
        return Array.isArray(list) ? list as string[] : [];
    }

    export function update(list: string[]) {
        localStorage.setItem('visitedList', JSON.stringify(list));
    }

    export function add(url: URL) {
        if (!url.protocol.match(/^https?:$/) || blockedList.contains(url.hostname)) {
            return;
        }

        const list = get();
        const newList = list.filter(e => e != url.hostname).concat(url.hostname);
        arrayKeepLast(newList, 100);
        update(newList);
        globalEvents.visitedList.notify();
    }

    export function remove(hostname: string) {
        update(get().filter(e => e != hostname));
        globalEvents.visitedList.notify();
    }

    export function clear() {
        update([]);
        globalEvents.visitedList.notify();
    }

    function arrayKeepLast<T>(list: T[], keepCount: number) {
        const deleteCount = list.length - keepCount;
        if (deleteCount > 0) {
            list.splice(0, deleteCount);
        }
    }
}

It's almost same like the blocked list. The difference is that during addition, it will extract the hostname from the URL and only supports HTTP or HTTPS protocol. It only keeps the latest 100 items.

2.3 Craft the interface

The interface will be wholly constructed in code. First we create ./src/comps/popup.ts :

import { blockedListComponent } from './blocked-list';
import { visitedListComponent } from './visited-list';
import { globalEvents } from '../utils/global-events';

export function popupComponent() {
    const div = document.createElement('div');

    function refresh() {
        div.innerHTML = '';
        div.appendChild(blockedListComponent());
        div.appendChild(visitedListComponent());
    }

    globalEvents.blockedList.register(() => refresh());
    globalEvents.visitedList.register(() => refresh());

    refresh();

    return div;
}

It will just call the blockedListComponent and visitedListComponent to render the list. It will also refresh the contents when the blocked list or the visited list changed.

Then we create ./src/comps/blocked-list.ts :

import { blockedList } from "../utils/blocked-list";
import { globalEvents } from "../utils/global-events";

export function blockedListComponent() {
    const div = document.createElement('div');

    div.appendChild(header());

    const ul = document.createElement('ul');
    div.appendChild(ul);

    const items = blockedList.get();
    if (items.length) {
        items.forEach(e => ul.appendChild(listItem(e)));
    } else {
        const li = document.createElement('li');
        li.textContent = 'Empty';
        ul.appendChild(li);
    }

    return div;
}

function header() {
    const header = document.createElement('h3');

    const span = document.createElement('span');
    span.textContent = 'Blocked';
    span.style.marginRight = '.3em';
    header.appendChild(span);

    const clearButton = document.createElement('a');
    clearButton.textContent = '[Clear]';
    clearButton.href = "#";
    clearButton.onclick = () => {
        blockedList.clear();
        return false;
    };
    header.appendChild(clearButton);

    const rerunButton = document.createElement('a');
    rerunButton.textContent = '[Rerun]';
    rerunButton.href = "#";
    rerunButton.onclick = () => {
        globalEvents.rerun.notify();
        return false;
    };
    header.appendChild(rerunButton);

    return header;
}

function listItem(item: string) {
    const li = document.createElement('li');

    const removeButton = document.createElement('a');
    removeButton.textContent = '[Remove]';
    removeButton.href = "#";
    removeButton.onclick = () => {
        blockedList.remove(item);
        return false;
    };
    li.appendChild(removeButton);

    const span = document.createElement('span');
    span.textContent = item;
    span.style.marginLeft = '.3em';
    li.appendChild(span);

    return li;
}

It consists of 3 sections:

Section Description
blockedListComponent Create the list component
header Create the header with clear and rerun button
listItem Create the list item component

Then we create ./src/utils/visited-list.ts :

import { blockedList } from "../utils/blocked-list";
import { visitedList } from "../utils/visited-list";

export function visitedListComponent() {
    const div = document.createElement('div');

    div.appendChild(header());

    const ul = document.createElement('ul');
    div.appendChild(ul);

    const items = visitedList.get().reverse();
    if (items.length) {
        items.forEach(e => ul.appendChild(listItem(e)));
    } else {
        const li = document.createElement('li');
        li.textContent = 'Empty';
        ul.appendChild(li);
    }
    
    return div;
}

function header() {
    const header = document.createElement('h3');

    const span = document.createElement('span');
    span.textContent = 'Visited';
    span.style.marginRight = '.3em';
    header.appendChild(span);

    const clearButton = document.createElement('a');
    clearButton.textContent = '[Clear]';
    clearButton.href = "#";
    clearButton.onclick = () => {
        visitedList.clear();
        return false;
    };
    header.appendChild(clearButton);

    return header;
}

function listItem(item: string) {
    const li = document.createElement('li');

    const blockButton = document.createElement('a');
    blockButton.textContent = '[Block]';
    blockButton.href = "#";
    blockButton.onclick = () => {
        blockedList.add(item);
        visitedList.remove(item);
        return false;
    };
    li.appendChild(blockButton);

    const span = document.createElement('span');
    span.textContent = item;
    span.style.marginLeft = '.3em';
    li.appendChild(span);

    return li;
}

It's similar to blockedListComponent .

2.4 Export

Now we need to export them so they can be used by other module. Create ./src/index.ts with these contents:

export { popupComponent } from './comps/popup';
export { blockedList } from './utils/blocked-list';
export { visitedList } from './utils/visited-list';
export { globalEvents } from './utils/global-events';

This will export them in the main entry file so the downstream doesn't have to import them from the inner files.

Create an empty ./packages/common/assets directory for now. We will populate it later. Now you can run this command in the root directory to build it:

npm run build

The output will be in ./packages/common/dist directory.

Next we will work on the real Chrome extension .