Implementing an event

Like a function, an event requires a definition in the schema and an implementation in Javascript inside an instance of ExtensionAPI.

Declaring an event in the API schema

The definition for a simple event looks like this:

[
  {
    "namespace": "myapi",
    "events": [
      {
        "name": "onSomething",
        "type": "function",
        "description": "Description of the event",
        "parameters": [
          {
            "name": "param1",
            "description": "Description of the first callback parameter",
            "type": "number"
          }
        ]
      }
    ]
  }
]

This fragment defines an event that is used from an extension with code such as:

browser.myapi.onSomething.addListener(param1 => {
  console.log(`Something happened: ${param1}`);
});

Note that the schema syntax looks similar to that for a function, but for an event, the parameters property specifies the arguments that will be passed to a listener.

Implementing an event

Just like with functions, defining an event in the schema causes wrappers to be automatically created and exposed to an extensions’ appropriate Javascript contexts. An event appears to an extension as an object with three standard function properties: addListener(), removeListener(), and hasListener(). Also like functions, if an API defines an event but does not implement it in a child process, the wrapper in the child process effectively proxies these calls to the implementation in the main process.

A helper class called EventManager makes implementing events relatively simple. A simple event implementation looks like:

this.myapi = class extends ExtensionAPI {
  getAPI(context) {
    return {
      myapi: {
        onSomething: new EventManager({
          context,
          name: "myapi.onSomething",
          register: fire => {
            const callback = value => {
              fire.async(value);
            };
            RegisterSomeInternalCallback(callback);
            return () => {
              UnregisterInternalCallback(callback);
            };
          }
        }).api(),
      }
    }
  }
}

The EventManager class is usually just used directly as in this example. The first argument to the constructor is an ExtensionContext instance, typically just the object passed to the API’s getAPI() function. The second argument is a name, it is used only for debugging. The third argument is the important piece, it is a function that is called the first time a listener is added for this event. This function is passed an object (fire in the example) that is used to invoke the extension’s listener whenever the event occurs. The fire object has several different methods for invoking listeners, but for events implemented in the main process, the only valid method is async() which executes the listener asynchronously.

The event setup function (the function passed to the EventManager constructor) must return a cleanup function, which will be called when the listener is removed either explicitly by the extension by calling removeListener() or implicitly when the extension Javascript context from which the listener was added is destroyed.

In this example, RegisterSomeInternalCallback() and UnregisterInternalCallback() represent methods for listening for some internal browser event from chrome privileged code. This is typically something like adding an observer using Services.obs or attaching a listener to an EventEmitter.

After constructing an instance of EventManager, its api() method returns an object with with addListener(), removeListener(), and hasListener() methods. This is the standard extension event interface, this object is suitable for returning from the extension’s getAPI() method as in the example above.

Handling extra arguments to addListener()

The standard addListener() method for events may accept optional addition parameters to allow extra information to be passed when registering an event listener. One common application of this parameter is for filtering, so that extensions that only care about a small subset of the instances of some event can avoid the overhead of receiving the ones they don’t care about.

Extra parameters to addListener() are defined in the schema with the the extraParameters property. For example:

[
  {
    "namespace": "myapi",
    "events": [
      {
        "name": "onSomething",
        "type": "function",
        "description": "Description of the event",
        "parameters": [
          {
            "name": "param1",
            "description": "Description of the first callback parameter",
            "type": "number"
          }
        ],
        "extraParameters": [
          {
            "name": "minValue",
            "description": "Only call the listener for values of param1 at least as large as this value.",
            "type": "number"
          }
        ]
      }
    ]
  }
]

Extra parameters defined in this way are passed to the event setup function (the last parameter to the EventManager constructor. For example, extending our example above:

this.myapi = class extends ExtensionAPI {
  getAPI(context) {
    return {
      myapi: {
        onSomething: new EventManager({
          context,
          module: "myapi",
          event: "onSomething",
          register: (fire, minValue) => {
            const callback = value => {
              if (value >= minValue) {
                fire.async(value);
              }
            };
            RegisterSomeInternalCallback(callback);
            return () => {
              UnregisterInternalCallback(callback);
            };
          }
        }).api()
      }
    }
  }
}

Handling listener return values

Some event APIs allow extensions to affect event handling in some way by returning values from event listeners that are processed by the API. This can be defined in the schema with the returns property:

[
  {
    "namespace": "myapi",
    "events": [
      {
        "name": "onSomething",
        "type": "function",
        "description": "Description of the event",
        "parameters": [
          {
            "name": "param1",
            "description": "Description of the first callback parameter",
            "type": "number"
          }
        ],
        "returns": {
          "type": "string",
          "description": "Description of how the listener return value is processed."
        }
      }
    ]
  }
]

And the implementation of the event uses the return value from fire.async() which is a Promise that resolves to the listener’s return value:

this.myapi = class extends ExtensionAPI {
  getAPI(context) {
    return {
      myapi: {
        onSomething: new EventManager({
          context,
          module: "myapi",
          event: "onSomething",
          register: fire => {
            const callback = async (value) => {
              let rv = await fire.async(value);
              log(`The onSomething listener returned the string ${rv}`);
            };
            RegisterSomeInternalCallback(callback);
            return () => {
              UnregisterInternalCallback(callback);
            };
          }
        }).api()
      }
    }
  }
}

Note that the schema returns definition is optional and serves only for documentation. That is, fire.async() always returns a Promise that resolves to the listener return value, the implementation of an event can just ignore this Promise if it doesn’t care about the return value.

Implementing an event in the child process

The reasons for implementing events in the child process are similar to the reasons for implementing functions in the child process:

  • Listeners for the event return a value that the API implementation must act on synchronously.

  • Either addListener() or the listener function has one or more parameters of a type that cannot be sent between processes.

  • The implementation of the event interacts with code that is only accessible from a child process.

  • The event can be implemented substantially more efficiently in a child process.

The process for implementing an event in the child process is the same as for functions – simply implement the event in an ExtensionAPI subclass that is loaded in a child process. And just as a function in a child process can call a function in the main process with callParentAsyncFunction(), events in a child process may subscribe to events implemented in the main process with a similar getParentEvent(). For example, the automatically generated event proxy in a child process could be written explicitly as:

this.myapi = class extends ExtensionAPI {
  getAPI(context) {
    return {
      myapi: {
        onSomething: new EventManager(
          context,
          name: "myapi.onSomething",
          register: fire => {
            const listener = (value) => {
              fire.async(value);
            };

            let parentEvent = context.childManager.getParentEvent("myapi.onSomething");
            parent.addListener(listener);
            return () => {
              parent.removeListener(listener);
            };
          }
        }).api()
      }
    }
  }
}

Events implemented in a child process have some additional methods available to dispatch listeners:

  • fire.sync() This runs the listener synchronously and returns the value returned by the listener

  • fire.raw() This runs the listener synchronously without cloning the listener arguments into the extension’s Javascript compartment. This is used as a performance optimization, it should not be used unless you have a detailed understanding of Javascript compartments and cross-compartment wrappers.

Event Listeners Persistence

Event listeners are persisted in some circumstances. Persisted event listeners can either block startup, and/or cause an Event Page or Background Service Worker to be started.

The event listener must be registered synchronously in the top level scope of the background. Event listeners registered later, or asynchronously, are not persisted.

Currently only WebRequestBlocking and Proxy events are able to block at startup, causing an addon to start earlier in Firefox startup. Whether a module can block startup is defined by a startupBlocking flag in the module definition files (ext-toolkit.json or ext-browser.json). As well, these are the only events persisted for persistent background scripts.

Events implemented only in a child process, without a parent process counterpart, cannot be persisted.

Persisted and Primed Event Listeners

In terms of terminology:

  • Persisted Event Listener is the set of data (in particular API module, API event name and the parameters passed along with addListener call if any) related to an event listener that has been registered by an Event Page (or Background Service Worker) in a previous run and being stored in the StartupCache data

  • Primed Event Listener is a “placeholder” event listener created, from the Persisted Event Listener data found in the StartupCache, while the Event Page (or Background Service Worker) is not running (either not started yet or suspended after the idle timeout was hit)

ExtensionAPIPersistent and PERSISTENT_EVENTS

Most of the WebExtensions APIs promise some API events, and it is likely that most of those events are also expected to be waking up the Event Page (or Background Service Worker) when emitted while the background extension context has not been started yet (or it was suspended after the idle timeout was hit).

As part of implementing a WebExtensions API that is meant to persist all or some of its API event listeners:

  • the WebExtensions API namespace class should extend ExtensionAPIPersistent (instead of extending the ExtensionAPI class)

  • the WebExtensions API namespace should have a PERSISTENT_EVENTS property, which is expected to be set to an object defining methods. Each method should be named after the related API event name, which are going to be called internally:

    • while the extension Event Page (or Background Service Worker) isn’t running (either never started yet or suspended after the idle timeout). These methods are called by the WebExtensions internals to create placeholder API event listeners in the parent process for each of the API event listeners persisted for that extension. These placeholder listeners are internally referred to as primed listeners).

    • while the extension Event Page (or Background Service Worker) is running (as well as for any other extension context types they may have been created for the extension). These methods are called by the WebExtensions internals to create the parent process callback that will be responsible for forwarding the API events to the extension callbacks in the child processes.

  • in the getAPI method. For all the API namespace properties that represent API events returned by this method, the EventManager instances created for each of the API events that is expected to persist its listeners should include following options:

    • module, to be set to the API module name as listed in "ext-toolkit.json" / "ext-browser.json" / "ext-android.json" (which, in most cases, is the same as the API namespace name string).

    • event, to be set to the API event name string.

    • extensionApi, to be set to the ExtensionAPIPersistent class instance.

Taking a look to some of the patches landed to introduce API event listener persistency on some of the existing API as part of introducing support for the Event Page may also be useful:

Follows an example of what has been described previously in a code snippet form:

this.myApiName = class extends ExtensionAPIPersistent {
  PERSISTENT_EVENTS = {
    // @param {object}             options
    // @param {object}             options.fire
    // @param {function}           options.fire.async
    // @param {function}           options.fire.sync
    // @param {function}           options.fire.raw
    //        For primed listeners `fire.async`/`fire.sync`/`fire.raw` will
    //        collect the pending events to be send to the background context
    //        and implicitly wake up the background context (Event Page or
    //        Background Service Worker), or forward the event right away if
    //        the background context is running.
    // @param {function}           [options.fire.wakeup = undefined]
    //        For primed listeners, the `fire` object also provide a `wakeup` method
    //        which can be used by the primed listener to explicitly `wakeup` the
    //        background context (Event Page or Background Service Worker) and wait for
    //        it to be running (by awaiting on the Promise returned by wakeup to be
    //        resolved).
    // @param {ProxyContextParent} [options.context=undefined]
    //        This property is expected to be undefined for primed listeners (which
    //        are created while the background extension context does not exist) and
    //        to be set to a ProxyContextParent instance (the same got by the getAPI
    //        method) when the method is called for a listener registered by a
    //        running extension context.
    //
    // @param {object}            [apiEventsParams=undefined]
    //        The additional addListener parameter if any (some API events are allowing
    //        the extensions to pass some parameters along with the extension callback).
    onMyEventName({ context, fire }, apiEventParams = undefined) {
      const listener = (...) {
        // Wake up the EventPage (or Background ServiceWorker).
        if (fire.wakeup) {
          await fire.wakeup();
        }

        fire.async(...);
      }

      // Subscribe a listener to an internal observer or event which will be notified
      // when we need to call fire to either send the event to an extension context
      // already running or wake up a suspended event page and accumulate the events
      // to be fired once the extension context is running again and a callback registered
      // back (which will be used to convert the primed listener created while
      // the non persistent background extension context was not running yet)
      ...
      return {
        unregister() {
          // Unsubscribe a listener from an internal observer or event.
          ...
         }
        convert(fireToExtensionCallback) {
          // Convert gets called once the primed API event listener,
          // created while the extension background context has been
          // suspended, is being converted to a parent process API
          // event listener callback that is responsible for forwarding the
          // events to the child processes.
          //
          // The `fireToExtensionCallback` parameter is going to be the
          // one that will emit the event to the extension callback (while
          // the one got from the API event registrar method may be the one
          // that is collecting the events to emit up until the background
          // context got started up again).
          fire = fireToExtensionCallback;
        },
      };
    },
    ...
  };

  getAPI(context) {
    ...
    return {
      myAPIName: {
        ...
        onMyEventName: new EventManager({
          context,
          // NOTE: module is expected to be the API module name as listed in
          // ext-toolkit.json / ext-browser.json / ext-android.json.
          module: "myAPIName",
          event: "onMyEventNAme",
          extensionApi: this,
        }),
      },
    };
  }
};

Testing Persisted API Event Listeners

  • extension.terminateBackground({ expectStopped: true, disableResetIdleForTest: false } = {}):

    • The wrapper object returned by ExtensionTestUtils.loadExtension provides a terminateBackground method which can be used to simulate an idle timeout, by explicitly triggering the same logic handling the idle timeout.

    • By default this helper will also implicitly assert that extension.backgroundState is set to "stopped" once the terminateBackground async logic has been fully executed

    • This method also accept a few optional parameters:

      • if expectStopped is set to false, the helper will assert that extension.backgroundState is set to “running” once the terminateBackground async logic has been fully executed, which is meant to be used in specific tests that covers the reset idle timeout logic and conditions.

      • if disableResetIdleForTest is set to true, the helper will ignore all the conditions that would reset the idle timeout due to some work still pending (e.g. a NativeMessaging’s Port still open, a StreamFilter instance still active or a Promise from an API event listener call not yet resolved)

  • ExtensionTestUtils.testAssertions.assertPersistentListeners:

    • This test assertion helper can be used to more easily assert what should be the persisted state of a given API event (e.g. assert it to not be persisted, or to be persisted and/or primed)

assertPersistentListeners(extension, "browserAction", "onClicked", {
   primed: false,
 });
 await extension.terminateBackground();
 assertPersistentListeners(extension, "browserAction", "onClicked", {
   primed: true,
 });
  • extensions.background.idle.timeout preference determines how long to wait (between API events being notified to the extension event page) before considering the Event Page in the idle state and suspend it, in some xpcshell test this pref may be set to 0 to reduce the amount of time the test will have to wait for the Event Page to be suspended automatically

  • extension.eventPage.enabled pref is responsible for enabling/disabling Event Page support for manifest_version 2 extension, technically it is now set to true on all channels, but it would still be worth flipping it to true explicitly in tests that are meant to cover Event Page behaviors for manifest_version 2 test extension until the pref is completely removed (mainly to make sure that if the pref would need to be flipped to false for any reason, the tests will still be passing)

Persisted Event listeners internals

The ExtensionAPIPersistent class provides a way to quickly introduce API event listener persistency to a new WebExtensions API, and reduce the number of code duplication, the following section provide some more details about what the abstractions are doing internally in practice.

WebExtensions APIs classes that extend the ExtensionAPIPersistent base class are still able to support non persisted listeners along with persisted ones (e.g. events that are persisting the listeners registered from an event page are already not persisting listeners registered from other extension contexts) and can mix persisted and non-persisted events.

As an example in toolkit/components/extensions/parent/ext-runtime.js` the two events onSuspend and onSuspendCanceled are expected to be never persisted nor primed (even for an event page) and so their EventManager instances receive the following options:

  • a register callback (instead of the one part of PERSISTED_EVENTS)

  • a name string property (instead of the two separate module and event string properties that are used for EventManager instances from persisted ones

  • no extensionApi property (because that is only needed for events that are expected to persist event page listeners)

In practice ExtensionAPIPersistent extends the ExtensionAPI class to provide a generic primeListeners method, which is the method responsible for priming a persisted listener when the event page has been suspended or not started yet.

The primeListener method is expected to return an object with an unregister and convert method, while the register callback passed to the EventManager constructor is expected to return the unregister method.

function somethingListener(fire, minValue) => {
  const callback = value => {
    if (value >= minValue) {
      fire.async(value);
    }
  };
  RegisterSomeInternalCallback(callback);
  return {
    unregister() {
      UnregisterInternalCallback(callback);
    },
    convert(_fire, context) {
      fire = _fire;
    }
  };
}

this.myapi = class extends ExtensionAPI {
  primeListener(extension, event, fire, params, isInStartup) {
    if (event == "onSomething") {
      // Note that we return the object with unregister and convert here.
      return somethingListener(fire, ...params);
    }
    // If an event other than onSomething was requested, we are not returning
    // anything for it, thus it would not be persistable.
  }
  getAPI(context) {
    return {
      myapi: {
        onSomething: new EventManager({
          context,
          module: "myapi",
          event: "onSomething",
          register: (fire, minValue) => {
            // Note that we return unregister here.
            return somethingListener(fire, minValue).unregister;
          }
        }).api()
      }
    }
  }
}