I want some goofy functionality in my browser. Maybe I can add it with a simple extension? It doesn't exist, but writing it myself should be easy, right?
That's what I thought a couple of days ago. While I wasn't completely wrong, some parts of the development process were a bit more time-consuming than I expected. I won't say difficult, but rather hard to figure out using available documentation. While API documentation, core concepts, etc. are described quite nicely on developer.chrome.com, I wanted a specific developer experience:
- TypeScript with proper typing of
chrome
namespace - Splitting the code into multiple files and
import
/export
what was necessary - Debugging my code with simple
console.log
and/ordebugger
- Autocompletion in my
manifest.json
- Simple setup, without any bundlers and half of the Internet in my
node_modules
- Simple way of updating and testing the extension in the browser
In a better or worse way, I managed to set things up as I wanted. In this post, I'll briefly explain general extension concepts and show you how I've set up my development environment. In the next post or two I'll focus on the implementation details of my simple page-audio extension.
TLDR:
If you just want the code, here's the boilerplate repo:
Chromium extension boilerplate
This repository aims at being a starting point for developing a chromium extension.
It's as minimalistic as possible, but comes with pre-configured:
- autocompletion for
manifest.json
- TypeScript transpilation from
ts
folder todist
directory - types for
chrome
namespace - properly working
export
ing andimport
ing (with VS Code workspace setting for correct auto import format) - example
manifest.json
Happy coding!
ℹ️ I use Windows 11, MS Edge, VS Code and npm everywhere below ℹ️
Brief intro to extensions
Let's start with a crash course on general extension concepts.
Every extension has a manifest.json
file that defines its name, version, required permissions, and used files. Extensions can provide functionality in several different ways:
- via popup - extension popup is this small window that opens when you click the extension icon in the extension bar,
- via content scripts - scripts that are injected directly into websites and have DOM access,
- via background (service worker) scripts - scripts run in a separate context, independent from opened websites
There are other ways, but I'll stick to these three in this guide.
Another important concept is messaging. Usually, we need to combine the above methods, as all of them have different limitations. For example, background scripts don't depend on opened tabs and can be more useful for persisting state, but can't access the DOM of any website. Therefore, we might need to get some extension-wide data from the background script, pass it using a message to a content script, and modify the website from there.
It can also be useful to understand some basics about permissions. In short, some APIs won't work as expected if manifest.json
doesn't specify the correct permissions. For example, if we don't specify "tabs"
permission, objects returned from the tabs
API won't have a url
field. On the other hand, we shouldn't ask for too many permissions - if the extension is going to be public, users might be concerned about giving access to too many things.
Creating a simple extension
Inspired by https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world
Let's start with understanding the core concepts of our development workflow using an extremely simple extension that just displays some text in a popup.
Files
First of all, we need a manifest.json
file:
// manifest.json
{
"name": "Hello World",
"description": "Shows Hello World text",
"version": "1.0",
"manifest_version": 3,
"action": {
"default_popup": "hello.html",
"default_icon": "icon.png"
}
}
name
, description
, version
, and manifest_version
are probably self-explanatory. action.default_popup
is a path to an HTML file that will be rendered upon clicking the extension icon. default_icon
is a path to extension icon. Both paths are relative to manifest.json
location.
Now, add icon.png
(for example, this one) and hello.html
files in the same directory as manifest.json
.
hello.html
can look like that:
<!-- hello.html -->
<p>Hello world</p>
And your whole directory should look like that:
.
├── hello.html
├── icon.png
└── manifest.json
Activating the extension
To activate your extension:
- Go to edge://extensions/
- In the left sidebar, enable "Developer mode"
- "Allow extensions from other stores" might also be needed
- Above the extension list click "Load unpacked"
- Select the folder with your extension files
- Your extension should appear on the list and its icon in the extensions toolbar 🥳
Now, after clicking the icon it will show a small popup with "Hello world" text.
That covers the most important basics. Let's move to something more interesting.
Page-Audio extension environment setup
Autocomplete in manifest.json
We'll start again with the manifest.json
and empty directory.
It would be awesome to have autocomplete when writing the manifest.json
file, wouldn't it? Fortunately, it's a well-defined standard and has a JSON schema at https://json.schemastore.org/chrome-manifest. We just need it under the "$schema" key at the beginning of manifest.json
:
// manifest.json
{
"$schema": "https://json.schemastore.org/chrome-manifest"
}
and VS Code instantly starts helping us by suggesting field names and showing warnings if mandatory fields are missing. Awesome!🔥
To have something working for testing our setup, use manifest.json
looking this way:
// manifest.json
{
"$schema": "https://json.schemastore.org/chrome-manifest",
"name": "Page Audio",
"version": "0.0.0.1",
"manifest_version": 3,
"icons": {
"16": "icons/logo16x16.png",
"32": "icons/logo32x32.png",
"48": "icons/logo48x48.png",
"128": "icons/logo128x128.png"
},
"background": {
"service_worker": "dist/background.js",
"type": "module"
}
}
-
icons
- it's just a different way of specifying extension icons -
background
section - specifies the path with the service worker JS file and its type; it'smodule
as the code will useexport
andimport
later on
TypeScript
Using TypeScript... well, requires TypeScript. If you don't have it installed, start with
npm install -g typescript
Basic config
To have things organized, but not too complicated, I'll keep .ts
source files in the ts
directory. They will be taken from there by the transpiler and put in the dist
directory as .js
files.
This is described by the following .tsconfig
:
// .tsconfig
{
"compilerOptions": {
"target": "ES6",
"module": "ES6",
"outDir": "./dist",
"rootDir": "./ts",
"strict": true,
}
}
The most important bits are compiler.rootDir
and compiler.outDir
. The other fields can have different values or be completely removed (at least some of them).
That's the basic configuration - placing some files in the ts
directory and running tsc
in the root directory will create a corresponding .js
file in dist
. However, we're missing one important part - types for the chrome
namespace that we'll be using. The simplest solution is to add them via npm.
Adding chrome
types
Create an empty package.json
, just with the brackets:
// package.json
{
}
and in the command line run:
npm i -D chrome-types
You can also add scripts
to run tsc
build and in the watch mode. Final package.json
should look like this:
// package.json
{
"scripts": {
"build": "tsc",
"watch": "tsc -w"
},
"devDependencies": {
"chrome-types": "^0.1.327"
}
}
ℹ️ chrome-types
version might be higher in your case. ℹ️
After adding the types, we need to let TypeScript know about them. To do this, simply update .tsconfig.json
:
// .tsconfig.json
{
"compilerOptions": {
"target": "ES6",
"module": "ES6",
"outDir": "./dist",
"rootDir": "./ts",
"strict": true,
"types": ["chrome-types"] // Add this line!
}
}
To test if our setup works correctly:
-
In the
ts
folder, createbackground.ts
file with the following content
// ts/background.ts chrome.runtime.onInstalled.addListener(() => { console.log('Service Worker Installed'); });
-
In the command line, run
npm run watch
Verify if the
dist
directory was created andbackground.js
file appeared thereChange something in the
console.log
string ints/background.ts
file and save itCheck if it automatically updated
dist/background.js
.
If that works, awesome! We have nearly everything set up 🎉
You can also verify if your directory structure looks similar to that:
.
├── dist
│ └── background.js
├── icons
│ ├── logo128x128.png
│ ├── logo16x16.png
│ ├── logo32x32.png
│ └── logo48x48.png
├── manifest.json
├── node_modules
│ └── chrome-types
│ └── ... some stuff inside ...
├── package-lock.json
├── package.json
├── ts
│ └── background.ts
└── tsconfig.json
import
and export
As I've mentioned, I would like to split the code into smaller files. To do this, export
ing and import
ing must work correctly.
One step in that direction was specifying our service_worker
in manifest.json
as "type": "module"
. However, there's one difference between TypeScript and JavaScript when working with modules - while TypeScript doesn't need file extensions when importing, JavaScript does. So, for example, this import:
import { something } from "./fileWithFunctions";
will work in TS, but JS needs
import { something } from "./fileWithFunctions.js";
It's also important to understand, that TS transpiler does nothing to the import paths. And it's "smart" enough to understand that when importing from file.js
it should also look for file.ts
.
Combining all of that, TS will also be happy with JS-style import and will use the corresponding TS file when importing from file.js
. What we need to do is make sure that all imports in TS files have a .js
extension. To automate it in VS Code:
- Press
CTRL + ,
to open settings - Switch to "Workspace" tab
- Search for
typescript.preferences.importModuleSpecifierEnding
- Set it to ".js / .ts" option
Now, whenever you auto import using VS Code, it will add .js
to the filename 🧠
To test if things work correctly:
-
Create
ts/hello.ts
file with the following content
// ts/hello.ts export function hello() { console.log('Hello'); }
In
ts/background.ts
remove the currentconsole.log
line and start typing "hello"VS Code should autocomplete it and add the correct import after you accept the suggestion with Tab
-
In the end, the file should look like this:
// ts/background.ts import { hello } from "./hello.js"; chrome.runtime.onInstalled.addListener(() => { hello(); });
Note that import ends with the .js
extension. If you check dist/background.js
the extension is there as well and that's what makes everything work correctly.
To make sure we are at the same stage, you can compare the directory structure:
.
├── .vscode
│ └── settings.json
├── dist
│ ├── background.js
│ └── hello.js
├── icons
│ └── ... icons ...
├── manifest.json
├── node_modules
│ └── chrome-types
│ └── ... some stuff inside ...
├── package-lock.json
├── package.json
├── ts
│ ├── background.ts
│ └── hello.ts
└── tsconfig.json
Dev Tools for service worker
Okay, we have a decent development experience. We've also added some console.log
calls... but where to find them now?
If you add console.log
inside a content script, you can simply open Dev Tools and they will be there, as content scripts work in the same context as the page they are injected into. However, console.log
s from background scripts are hidden a bit more.
- Open edge://extensions/ and load your extension if you haven't done that yet
- Find your extension on the list
-
Click "service worker" link in "Inspect views" line:
-
A new Dev Tools window should open and you'll see logs from the service worker there
- if you don't see the logs, click "Reload" below the "Inspect views"
The three links at the bottom of the tile are also very important
- "Reload" - refreshes the whole extension, including changes to
manifest.json
; checkout this table to understand when reloading might be needed - "Remove" - deletes the extension
- "Details" - shows more information about the extension, for example, its permissions
- (optional) "Errors" - if there are errors when installing the service worker, this link will appear and take you to the list of errors
Phew. That took a moment, but, finally, our environment is set up nicely. From now on, we'll just have to
- Run
npm run watch
(if you stopped it) - Write our code in
ts
directory - (Optionally) Reload the extension from the extensions tab
And our extension will be automatically updated! ⚙️
If you have an idea how to also "Reload" automatically (w/o elaborate hacking), let me know in the comments
Summary
We have our environment ready!
- Autocomplete works in
manifest.json
, so we don't have to guess what are the correct values - TypeScript helps us with using
chrome
API correctly - Code can be split into smaller, logical files
- The code we write in the
ts
folder is updated automatically - We know where to find Dev Tools for the service worker and content scripts
In the next part, I'll describe the implementation details of my small "Page audio" extension.
Thanks for reading!
Top comments (0)