Creating a browser extension for Safari and Chrome
Tags: react, typescript, safari, esbuild
Published: 2022-01-19 21:14:20

![Projects diagram](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5m3nyylfe4fr5vlwkr1p.png)
This article is not a detailed tutorial on how to create Web extensions for either Safari or Chrome. It is mainly an introduction to two demo projects hosted on Github on how to develop extensions that work on both Safari and Chrome (possibly Mozilla Firefox but not tested), using React/TypeScript/esbuild.

Safari extension requires a Swift project that contains iOS/macOS parent apps plus their extension apps that share a bunch of JavaScript and other resources.

The extension from my first attempt [here](https://medium.com/@bingqiao/safari-auto-refresh-web-extension-javascript-only-sort-of-9298970ec0ac) was a crude implementation in plain, vanilla JavaScript. There was no bundling, minifying, framework or typing. There wasn't even a separate JavaScript project. All JavaScript&resources belonged to the Swift project and were managed by Xcode.

After some more research and learning, I recreated the same extension using React/TypeScript, not just for Safari but Chrome too. The new project uses esbuild to create bundled and minified code.

## The extension project for Safari and Chrome
A much stripped down version of the extension resources project is hosted here [browser-ext-react-esbuild](https://github.com/bingqiao/browser-ext-react-esbuild) while the container app for iOS/macOS is hosted here [browser-ext
](https://github.com/bingqiao/browser-ext)

The first issue I had to address was how to create a Web extension using React/TypeScript/esbuild. Luckily there is already a template project that does exactly just that. [esbuild-react-chrome-extension](https://github.com/martonlederer/esbuild-react-chrome-extension)

The next issue is how to code in TypeScript against Web extension API for both Safari and Chrome. As it turns out Safari and Mozilla Firefox are very similar in their API but there are enough differences between them and Chrome to require different treatment especially when it comes to the use of "callbacks" and "promises" [Building a cross-browser extension](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Build_a_cross_browser_extension)

Initially I created wrapper functions to convert Chrome functions that require callback to return promise instead. The better approach, as I found out later, is probably to use   [webextension-polyfill](https://github.com/mozilla/webextension-polyfill/) from Mozilla and its [types](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/webextension-polyfill).

A caveat here is, I had to set `module` in "tsconfig.json" to `"commonjs"` as shown below:
```
{
  "compilerOptions": {
    ...
    "module": "commonjs",
    ...
}
```
Then do import assignment in JavaScript files that call extension API:
```
import browser = require('webextension-polyfill');
```
Using `import` like below didn't work for me:
```
import * as browser from 'webextension-polyfill';
```
The code generated by esbuild for the `import` above calls `__toESM` for `require_browser_polyfill()` which renders the polypill proxy ineffective.
```
var browser2 = __toESM(require_browser_polyfill());
```
## The container Swift project for Safari
Another issue is how to manage the React/extension project with the container Swift project.

The boilerplate extension resources (JavaScript/css, manifest and html files) created with a new Safari extension project are managed by Xcode. But I need them to be simply copied over from the React project, instead of having Xcode creating reference for every JavaScript/html/css/image file that needs to be part of the bundle it creates.

The figure below shows how those resource files are added to the Swift bundle after a Safari extension project is created in Xcode.
![How extension resources get added to Swift app bundle by default](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bthhs3ctb0uho9afyunt.png)
 
The problem is, we might have different files from the React project depending on whether it's a prod or dev build, especially if the bundler (such as Parcel) used generates randomised file names.

Instead, create an empty folder such as `build` under extension `Resources` via "finder" (not in Xcode).
![Create a new empty folder "build"](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cqkqrscrviyvyozu4ojb.png)
 
Then add this new empty folder to `Resources` in Xcode.
![Add folder to "Resources" in Xcode](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8sp5i25gc6mkynnpdjtd.png)

Finally, add the folder to `Copy Bundle Resources` build phase. This needs to be done for both iOS and macOS extension targets.

Now, all it takes to import new extension resources from the React project is to copy everything over to `Resources/build` folder in the Swift project.

The two sample projects are setup to work together as long as they are checked out side-by-side in the same directory.

Now you can develop and test the extension against Chrome solely in the extension resources project. To test against Safari, just run an npm command to build extension resources and copy contents of `dist` to the container Swift project, then build/run it in Xcode.

## The mechanism
![auto-refresh](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/llep3xn2qli5xu1dvl9g.png)
 
Auto-refresh is implemented using `setTimeout()`, `browser.tabs.reload()` and `browser.storage.local`.
* Every managed (marked to auto-refresh) browser tab has an entry in a map persisted in extension storage local: `tabId: boolean`;
* Upon loading, `content.js` looks up its tabId in this map;
* If there is an entry and the result is `true`, `content.js` will set up a timer of fixed interval (obviously the interval can be exposed to users too) to send a runtime message to `background.js`, asking for reload;
* `background.js` receives the request and reloads the sender tab via `browser.tabs.reload()`.

The approach above is different from my first attempt on auto-refresh extension:
* I was using a variable in `background.js` to hold tabs states which proves problematic. In Safari iOS, property `persistent` for `background.js` in `manifest.json` needs to be `false`, which means it can and will get reloaded. That explains why the extension was losing tab states whenever iPhone screen went dark. Using `browser.storage.local` seems to be the only viable alternative to tackling this issue, even though it adds quite a bit of complexity to the code base.
* Using `browser.storage.local`, I now have to figure out a way to clean up tabs states once the browser is restarted. This is a bit tricky for Safari which does not implement extension session API. The approach I used is to do a clean up in `browser.runtime.onStartup` event. This seems to work well but I'm not certain how water-tight this is.
* I was using `window.location = window.location.href` to do the reload. The better way is to call extension API `browser.tabs.reload()` which allows `bypassCache` argument to bypass browser cache.

Being able to test Safari extension easily in Chrome during development has saved me a lot of time. I'd be interested to hear if you have different approaches to some issues raised here.