Jul 28, 2018

What's the best way to collaborate an WebExtension-based addon with others?

このエントリの日本語版はこちらから読めます。

When I migrated my addon Tree Style Tab from XUL to WebExtensions, I wrote some concerns about communication between addons. Let's share my knowledge around the topic.

Case 1: Request-response type APIbrowser.tabs.onHighlighted.addListener()

TST provides some APIs for other addons. Some APIs are callable via browser.runtime.sendMessage() like as:

const kTST_ID = 'treestyletab@piro.sakura.ne.jp';
browser.runtime.sendMessage(kTST_ID, {
  type: 'expand-tree',
  tab:  2 // Tab.id
});

This example expands a collapsed tree belonging to the specified tab. As you saw, such a "request-resposne" type API is very easy to implement. The receiver addon (in this case, Tree Style Tab) just need to define a listener for browser.runtime.onMessageExternal like as:

browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (!message || !message.type)
    return; // Ignore invalid type API call.

  switch (message.type) {
    case 'expand-tree':
      // Here is a code to expand specified tree.
      // If needed, you can return a promise to wait until the operation finishes.
      return Promise.resolve(true);
  }
});

Then there is one problem: when a client addon sends any message to missing server addon, it will be reported as an error like "Error: Could not establish connection. Receiving end does not exist."

Solution 1: an option to enable/disable integration with other addons

One of solutions is: providing an option to activate/deactivate such an integration. Here is an example based on storage.local:

let enableIntegration = false;
browser.storage.local.get({ enableIntegration })
  .then(values => {
    enableIntegration = values.enableIntegration;
  });

function someFeature() {
  // ...
  if (enableIntegration)
    browser.runtime.sendMessage(kSERVER_ID, { ... });
}

But it introduces a new problem: which is the best default configuration, enabled or disabled? If enabled by default, users will see mysterious errors in the debug console. If disabled by default, users will need to activate the option manually to activate integration and some people won't realize there is such an option.

Solution 2: initialization based on management API of WE

Client addons can know that the server addon is installed or not via the browser.management.getAll(). Thus you don't need to provide such an option, instead you can activate the integration only when the server addon is available:

let serverIsActive = false;
browser.management.getAll().then(items => {
  for (const item of items) {
    if (item.id == kSERVER_ID && item.enabled) {
      serverIsActive  = true;
      break;
    }
  }
});

function someFeature() {
  // ...
  if (serverIsActive)
    browser.runtime.sendMessage(kSERVER_ID, { ... });
}

However, you need to add the management permission to the list of static permissions in your manifest.json. Sadly it is impossible to be listed in the optional_permissions. The installation prompt for your addon will show a new extra line like: "Monitor extension usage and manage themes". I think most people dislike any needless permission for an addon, so this can be an disadvantage to the previous solution.

Solution 3: simply ignore the error

Both solutions 1 and 2 have concerns. So I ordinarily do nothing - I usually send messages to server addons without confirmation. Those messages will be processed if luckily the server is activated, otherwise they will be ignored. Only some developers will look errors via the debugger console, but most people don't realize that.

Of course you can suppressed such errors by an error handler like:

function handleMissingReceiverError(error) {
  if (!error ||
      !error.message ||
      error.message.indexOf('Could not establish connection. Receiving end does not exist.') == -1)
    throw error;
}

browser.runtime.sendMessage(kSERVER_ID, { ... })
  .catch(handleMissingReceiverError);

This is the easiest way and I use this in most cases.

And, for developers I sometimes providing an option to deactivate integration. Because they intentionally deactivate the integration, I believe that I have no need to notify existence of the integration feature again.

Case 2: Pure broadcasting is impossible on WebExtensions!

Anyway, on the other hand, there are more other type of APIs: "broadcast" and "publish-subscribe".

Very sad news, an WE-based addon cannot "broadcast" messages to other unknown addons due to WE API's restriction. browser.runtime.sendMessage() can send any message to other addons, but it requires the exact ID of the receiver. Thus a receiver addon must notify its ID to the broadcaster addon. As you know, such an API model is called as "publish-subscribe" (aka "pub-sub").

(Added at 2018.11.6) Around tabs and bookmarks, some limited information are shared between addons so they may work like as broadcasting. For example, Firefox 63 and later have ability to "multiselect" tabs via browser.tabs.highlight({ windowId: <id>, tabs: <indexesOfTabs> }) or browser.tabs.update(<id>, { highlight: <boolean> }) and updated state is notified to listeners registered by browser.tabs.onHighlighted.addListener(), and it will allow you to develop addons which collaborate around multiselected tabs. But such APIs are very specific and unavailable to share general information.

How about browser.storage.onChanged.addListener()? It is also unavailable for the purpose. Storage API are completely isolated for each addon, and changes produced by other addons won't be notified. browser.sessions.setTabValue()/getTabValue()/setWindowValue()/getWindowValue() are also. Values associated to tabs or windows via these APIs are isolated for each addon and inaccessible from others.

In short, we always need to implement any API to notify something general information from an server addon to other client addons, as pub-sub model.

Case 3: Pub-Sub communications on WE addons

Don't worry, you can implement such pub-sub type APIs based on the technology same to request-response type APIs. The "subscribe" (and "unsubscribe") API will be implemented a simple req-res API like as:

const subscribers = new Set();

browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (!message || !message.type)
    return; // Ignore invalid type API call.

  switch (message.type) {
    case 'subscribe':
      subscribers.add(sender.id);
      return Promise.resolve(true);

    case 'unsubscribe':
      subscribers.delete(sender.id);
      return Promise.resolve(true);
  }
});

And you just need to send messages to known subscribers like as:

async function doSomething() {
  // ... do something ...
  const message = { type: 'published', ... }; // The message to be published
  await Promise.all(
    Array.from(subscribers)
      .map(id => browser.runtime.sendMessage(id, message))
  );
}

Client addons will "subscribe" and receive published messages like as:

browser.runtime.sendMessage(kSERVER_ID, {
  type: 'subscribe'
});
browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (sender.id != kSERVER_ID)
    return;    
  switch (message.type) {
    case 'published':
      // Handle published message.
  }
});

But there is one big problem. Any "subscribing" API calls will fail if client addons send them to the server before the server addon start to listen messages from others.

(Sequence Graph of Two Addons Combined with Messaging) (This figure is initially published as a part of the migration story of TST from XUL to WE.)

Such a situation will happen in cases like:

  • The server addon is installed after client addons are installed.
  • Both the server and client addons are installed, and Firefox starts. Clients are initialized at first and the server is initialized later.

Solution 1: trying "subscribe" by a client periodically

This is the easiest way.

const subscribeTimer = setInterval(async () => {
  try {
    const response = await browser.runtime.sendMessage(kSERVER_ID, {
      type: 'subscribe'
    });
    if (response) // Assume that the server returns "true" for subscribing request.
      clearInterval(subscribeTimer);
  }
  catch(e) {
  }
  // If there is no response or any error, retry with delay.
}, 5000);

But this is a bad solution because the addon will try to subscribe infinitely if the server addon is not installed or enabled.

Solution 2: notifying "ready to subscribe" message from the server

This is the one TST does. TST caches subscribers' ID to its private storage, and sends ready type message to them after TST's initialization process is completed. Subscribers just need to listen the ready message, then they will "subscribe" successfully like as:

(Sequence Graph of Successful Initialization Based on Cached Information) (This figure is initially published as a part of the migration story of TST from XUL to WE.)

Here is an example at a client addon to subscribe triggered by a ready message from the server:

// Try to subscribe at onload. This will succeed if the server is already active.
browser.runtime.sendMessage(kSERVER_ID, {
  type: 'subscribe'
});
// Subscribing triggered by `ready`.
// This is required even if the subscribing at onload succeeded, because
// the server addon can be reloaded and you need to subscribe again.
browser.runtime.onMessageExternal.addListener((message, sender) => {
  if (sender.id != kSERVER_ID)
    return;    
  switch (message.type) {
    case 'ready':
      browser.runtime.sendMessage(kSERVER_ID, {
        type: 'subscribe'
      });
      break;
  }
});

This is better than the previous solution, but still there are some problems:

  • The initial try of subscribing can fail if the server addon is installed but not initialized yet.
    • You can solve this problem by providing an option to deactivate integration or an initialization mechanism based on the management WE API.
  • The client never receives ready message from the server, when the server addon is installed after the client is installed. Then you need to reload client addons manually.
  • Too much code for initialization.

Solution 3: providing information of API dependencies by a central server

If the server addon was able to know the list of possible client addons, it was possible that the server addon send ready message to all possible client addons. Who does provides such a list? - No one yet. We addon authors need to start a repository project like the npmjs.com or the cpan.org. Addon authors will register their addons with API information to the repository, then addons can collaborate aggressively with others based on information provided by the repository.

But this idea introduces a new problem: the repository will become the SPOF (single point of failure). So I think this is a worse stupid idea.

Conclusion

I think that client addons may/should send API messages to server addons without any confirmation. And an option to disable such integration will help developers who don't want to see needless errors.

On the other hand, pub-sub communication of WE addons is really complex, and not perfect. The solution 2 for the case 3 looks better than others, but I still finding more better solution.

エントリを編集します。

wikieditish message: Ready to edit this entry.











拡張機能