Menu

AdGuard VPN Browser Extension migrated to Manifest V3

Manifest V3, the new Chrome API version, has become a stumbling block for many developers. It makes significant changes to the rules that browser extensions must follow. Not only do they cause some inconvenience, but they also lead to a partial loss of functionality.

In August 2022, AdGuard released the world's first ad-blocking browser extension based on Manifest V3. The development was challenging: we faced many problems along the way. However, at that time we proved that there is a future for ad blockers even in the reality of Manifest V3.

Despite the fact that Google has extended the lifetime of Manifest V2 extensions until at least January 2024, we have not postponed the development of AdGuard VPN Browser Extension which works according to Manifest V3 rules, and we are ready to present it to you now.

AdGuard VPN MV3 Browser Extension

With our experience in developing the AdGuard MV3, we didn't expect working on AdGuard VPN MV3 to be a walk in the park, and we were right. Let's talk about the problems we encountered and how we solved them.

Service worker falling asleep

The previous version of Manifest had a background page that allowed the extension to run once after installation or when a browser was opened, and continue to work in the background. In Manifest V3, the background page is replaced by the service worker.

It allows extensions to run in the background only when the user needs them. The idea behind Google Chrome is to greatly reduce the consumption of system resources. And for infrequently used browser extensions, this will work. But if an extension has to help the user constantly, like AdGuard VPN, a service worker that frequently goes to sleep and wakes up is more likely to increase the load on the system than decrease it.

And that's not all. Chrome keeps a close eye on the service worker. So, if the extension doesn't make any API calls or events within 30 seconds to prevent the service worker from falling asleep, the browser will simply stop the script.

Stopping the service worker threatens AdGuard VPN Browser Extension with many errors. For example, it will not receive the latest information about available locations, remaining free traffic, excluded services, other user devices connected to the account, and available bonuses; it will not be able to update access keys and the tray icon with the extension status.

Solution

We mentioned above that events and API calls prevent the service worker from entering hibernation mode. These can be:

  • messages from an extension's popup, options page or content script
  • proxy authentication request
  • an extension's popup opening
  • a new browser tab opening, refreshing an existing tab, or switching between browser windows
  • choosing any action from the context menu on the page (adding the website to exclusions, changing the exclusions mode)
  • triggering of the timer set via Alarm API (more on that later)

In a nutshell, it works like this: the service worker logs the event listener, and the browser records the event. When the event happens, the browser sees if it has an extension that needs to be woken up in response to this event. If it does, it wakes the extension's service worker and passes the event to the listener.

In Manifest V2 environment, it didn't matter where in the code the event listener was located – its registration could occur asynchronously because the background page was always running and never restarted.

In Manifest V3 the service worker is restarted when an event occurs. In this case, in order for the event to hit the listener, it must be registered at the top level of the code (synchronously).

In the example below you can see how the listener for popup messages, options page and content scripts is now set:

// add the listener for messages from popup, options page and userscripts
chrome.runtime.onMessage.addListener((event) => console.log(event));

And this example illustrates how the listener for context menu action events is set:

// add the listener for context menu actions
chrome.contextMenus.onClicked.addListener((event) => console.log(event));

This shows how to set the listener for tab action events:

// add the listener for tab updated
chrome.tabs.onUpdated.addListener((event) => console.log(event));
// add the listener for tab activated
chrome.tabs.onActivated.addListener((event) => console.log(event));
// add the listener for tab activated from another window
chrome.windows.onFocusChanged.addListener((event) => console.log(event));

The new Alarm API, which appeared in Manifest V3 in addition to setInterval and setTimeout, also helps to avoid some errors caused by the server worker falling asleep.

/**
 * Creates an alarm with the specified name and delay
 * @param alarmName
 * @param delay in ms
 */
 const createAlarm = (alarmName: string, delay: number) => {
    chrome.alarms.create(alarmName, { when: Date.now() + delay });
 };

/**
 * Creates a periodic alarm with the specified name and interval
 * @param alarmName
 * @param interval in minutes!
 */
 const createPeriodicAlarm = (alarmName: string, interval: number) => {
    chrome.alarms.create(alarmName, { periodInMinutes: interval });
 };

Using the Alarm API

/**
 * setTimeout implementation
 * @param callback
 * @param timeout in ms
 */
 setTimeout = (callback: () => void, timeout: number): number => {
    const timerId = this.generateId();
    alarmApi.createAlarm(`${timerId}`, timeout);
    alarmApi.onAlarmFires(`${timerId}`, callback);
    return timerId;
 };

/**
 * setInterval implementation
 * @param callback
 * @param interval in ms
 */
 setInterval = (callback: () => void, interval: number): number => {
    const timerId = this.generateId();
    alarmApi.createPeriodicAlarm(`${timerId}`, this.convertMsToMin(interval));
    alarmApi.onAlarmFires(`${timerId}`, callback);
    return timerId;
 };

Timer implementation for MV3

However, the Alarm API has a rather inconvenient limitation: it is impossible to set a timer that works in 3-5-10 seconds but only in a minute or more.

Service worker waking up

When the service worker goes into hibernation mode, it unloads the data that was stored in RAM. So after waking up, it takes a while for the extension to initialize the modules: an inexcusably long 1.5-2 seconds on average. As you can see, there were no such problems with the background page, as it was only reloaded when the browser was restarted.

Solution

We added a module to store each state change from RAM. The last saved state AdGuard VPN uses when the service worker wakes up for quick recovery. This reduces the delay in restarting the service worker to 150-200 ms.

interface StateStorageInterface {
   // Gets the value for the specified key from the session storage
   getItem<T>(key: StorageKey): T;


   // Sets the value for the specified key in the session storage
   setItem<T>(key: StorageKey, value: T): void;


   // Initializes the storage by loading the data from the session storage,
   // or creating a new storage with the default data if none exists.
   init(): Promise<void>;
}

Extension state repository interface

Implementing this solution required moving changeable fields from classes to the state store. In their place, we added getters and setters to the classes.

For example, if the Fallback API used to look like this:

export class FallbackApi {
   fallbackInfo: FallbackInfo;

   private setFallbackInfo(fallbackInfo: FallbackInfo) {
       this.fallbackInfo = fallbackInfo;
   }

   private async getFallbackInfo(): Promise<FallbackInfo> {
       if (FallbackApi.needsUpdate(this.fallbackInfo)) {
           await this.updateFallbackInfo();
       }

       return this.fallbackInfo;
   }
}

Now its appearance has changed:

export class FallbackApi {
   private get fallbackInfo(): FallbackInfo {
       return sessionState.getItem(StorageKey.FallbackInfo);
   }

   private set fallbackInfo(value: FallbackInfo) {
       sessionState.setItem(StorageKey.FallbackInfo, value);
   }

   private static needsUpdate(fallbackInfo: FallbackInfo): boolean {
       return fallbackInfo.expiresInMs < Date.now();
   }

   private async getFallbackInfo(): Promise<FallbackInfo> {
       if (this.fallbackInfo && FallbackApi.needsUpdate(this.fallbackInfo)) {
           await this.updateFallbackInfo();
           if (this.fallbackInfo) {
               return this.fallbackInfo;
           }
       }

       return this.fallbackInfo;
   }
}

Here are a few more examples:

class Endpoints implements EndpointsInterface {
   vpnInfo?: VpnExtensionInfoInterface;
}

Adding a state to the Endpoints class in Manifest V2

class Endpoints implements EndpointsInterface {
   #state: EndpointsState | undefined;

   private get vpnInfo(): VpnExtensionInfoInterface | null {
       return this.state.vpnInfo;
   }

   private set vpnInfo(vpnInfo: VpnExtensionInfoInterface | null) {
       this.state.vpnInfo = vpnInfo;
       sessionState.setItem(StorageKey.Endpoints, this.state);
   }

   public get state(): EndpointsState {
       if (!this.#state) {
           throw new Error('Endpoints API is not initialized');
       }

       return this.#state;
   }

   public set state(value: EndpointsState) {
       this.#state = value;
   }
}

Adding a state to the Endpoints class in Manifest V3

export class Credentials implements CredentialsInterface {
   vpnToken: VpnTokenData | null;

   vpnCredentials: CredentialsDataInterface | null;
}

Adding a state to the Credentials class in Manifest V2

export class Credentials implements CredentialsInterface {
   state: CredentialsState;

   saveCredentialsState = () => {
       sessionState.setItem(StorageKey.CredentialsState, this.state);
   };

   get vpnToken() {
       return this.state.vpnToken;
   }

   set vpnToken(vpnToken: VpnTokenData | null) {
       this.state.vpnToken = vpnToken;
       this.saveCredentialsState();
   }

   get vpnCredentials() {
       return this.state.vpnCredentials;
   }

   set vpnCredentials(vpnCredentials: CredentialsDataInterface | null) {
       this.state.vpnCredentials = vpnCredentials;
       this.saveCredentialsState();
   }
}

Adding a state to the Credentials class in Manifest V3

NetworkConnectionObserver implementation

The NetworkConnectionObserver is a class which monitors the network status (online/offline). It is used when an app does not get a response from the server and helps to identify the cause of the problem: no Internet connection from the user or an issue on the server side. Also, NetworkConnectionObserver monitors the user's transition from offline to online. In this case access rights are checked and the connection is restored if it was established before the user went offline.

In MV2, the NetworkConnectionObserver is implemented very simply. It listens for the online event and calls the callback passed to it.

class NetworkConnectionObserver {
   constructor(callback: () => Promise<void>) {
       window.addEventListener('online', callback);
   }
}

Within MV3 the service worker does not have access to window, so the implementation is more complicated. A solution would be to use an offscreen document. This would create an invisible page with access to window and document and the ability to perform actions there that are not available to the service worker.

However, Google promises that in the future the offscreen document will fall asleep after a period of inactivity, similar to the service worker. This way, the NetworkConnectionObserver will not be able to listen to the online/offline event all the time, which will lead to various problems.

Now we have to use a not-so-subtle solution: every half second we check the state of navigator.online and call callback if it has changed from offline to online.

/**
 * Starts checking if the network connection is online at a specified time interval.
 */
 private startCheckIsOnline() {
    setInterval(() => {
        this.setIsOnline(navigator.onLine);
    }, this.CHECK_ONLINE_INTERVAL_MS);
 }

/**
 * Calls handler if network connection becomes online and sets isOnline value.
 */
 private setIsOnline(isOnline: boolean) {
    if (isOnline && !this.isOnline) {
        this.onlineHandler(); // callback
    }
    this.isOnline = isOnline;
 }

Websocket support issues

Now in Manifest V3, messages received by the extension over a websocket connection will not wake up the sleeping service worker. As a result, the extension may miss the message about the need to update backend server access keys and may not be able to handle error messages about reaching the traffic limit, the connected device limit, and non-routable domains.

Solution

The initial impulse was to go against Chrome's vision and make an unsleeping service worker. In fact, we did it. But then we decided to try to play by the rules of Manifest V3 and connect to the websocket only when the service worker is awake.

Despite the found solution, we really hope that Google will solve the problem that we reported and add the possibility to extend the service worker life with messages received over the websocket connection.

Early triggering of the onAuthRequired handler

We’ve also fixed an issue with a browser popup requesting proxy authorization. This used to happen when the session storage wasn't able to load the credentials before the onAuthRequired event handler fired at the service worker wakeup.

Solution

To fix this problem, we've added an asynchronous callback for the onAuthRequired handler. It is now set to wait until the session storage has loaded the credentials and entered them.

/**
 * Adds listener for the onAuthRequired event
 */
 private addOnAuthRequiredListener = () => {
    chrome.webRequest.onAuthRequired.addListener(
        this.onAuthRequiredHandler,
        { urls: ['<all_urls>'] },
        ['blocking'],
    );
 };

onAuthRequired handler with a synchronous callback

/**
 * Adds listener for the onAuthRequired event
 */
 private addOnAuthRequiredListener = () => {
    chrome.webRequest.onAuthRequired.addListener(
        this.onAuthRequiredHandler,
        { urls: ['<all_urls>'] },
        ['asyncBlocking'],
    );
 };

onAuthRequired handler with an asynchronous callback

In conclusion

Manifest V3 is far from being the smartest and most stable environment. Nevertheless, you can already install AdGuard VPN MV3 Browser Extension and try it out. As always, we rely heavily on feedback: if you find a bug, please report it on GitHub.

Liked this post?

AdGuard VPN
for Windows

Use any browser or app and never worry about your anonymity again. The entire world is at your fingertips with AdGuard VPN.
Learn more
Download
By downloading the program you accept the terms of the License agreement

AdGuard VPN
for Mac

In just two clicks, select a city from anywhere in the world — we have 70+ locations — and your data is invisible to the prying eyes of corporations and governments.
Learn more
Download
By downloading the program you accept the terms of the License agreement

AdGuard VPN
for iOS

Boost your online protection by taking it with you wherever you go. Use AdGuard VPN to enjoy your favorite movies and shows!
Learn more
App Store
By downloading the program you accept the terms of the License agreement

AdGuard VPN
for Android

Remain anonymous wherever you go with AdGuard VPN! Dozens of locations, fast and reliable connection — all in your pocket.
Learn more
Google Play
By downloading the program you accept the terms of the License agreement
Download
By downloading the program you accept the terms of the License agreement

AdGuard VPN
for Chrome

Hide your true location and emerge from another place in the world — access any content without speed limits and preserve your web anonymity.
Learn more
Install
By downloading the program you accept the terms of the License agreement

AdGuard VPN
for Edge

Get to a different location in one click, hide your IP, and make your web surfing safe and anonymous.
Learn more
Install
By downloading the program you accept the terms of the License agreement

AdGuard VPN
for Firefox

Protect your privacy, hide your real location, and decide to where you need the VPN and where you don't!
Learn more
Install
By downloading the program you accept the terms of the License agreement

AdGuard VPN
for Opera

Be a ninja in your Opera browser: move quickly to any part of the world and remain unnoticed.
Learn more
Install
By downloading the program you accept the terms of the License agreement
AdGuard VPN
download has started
Click the button indicated by the arrow to start the installation.
Scan to install AdGuard VPN on your mobile device