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 listenerfire.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 theExtensionAPI
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, theEventManager
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 theExtensionAPIPersistent
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:
Bug-1748546 ported the browserAction and pageAction API namespace implementations to
ExtensionAPIPersistent
and, in particular, the changes applied to:ext-browserAction.js: https://hg.mozilla.org/integration/autoland/rev/08a3eaa8bce7
ext-pageAction.js: https://hg.mozilla.org/integration/autoland/rev/ed616e2e0abb
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 aterminateBackground
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 executedThis method also accept a few optional parameters:
if
expectStopped
is set tofalse
, the helper will assert thatextension.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 totrue
, the helper will ignore all the conditions that would reset the idle timeout due to some work still pending (e.g. aNativeMessaging
’sPort
still open, aStreamFilter
instance still active or aPromise
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 automaticallyextension.eventPage.enabled
pref is responsible for enabling/disabling Event Page support for manifest_version 2 extension, technically it is now set totrue
on all channels, but it would still be worth flipping it totrue
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 ofPERSISTED_EVENTS
)a
name
string property (instead of the two separatemodule
andevent
string properties that are used forEventManager
instances from persisted onesno
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()
}
}
}
}