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,
          name: "myapi.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,
          name: "myapi.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.