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 .