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.