Remove the onMessage/onDisconnect listeners that the chrome.runtime.sendMessage
[chromium-blink-merge.git] / chrome / renderer / resources / extensions / event.js
blobe9c0db6ac1d5c5c594f7e48e29f2d935f4c83386
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5   var eventNatives = requireNative('event_natives');
6   var logging = requireNative('logging');
7   var schemaRegistry = requireNative('schema_registry');
8   var sendRequest = require('sendRequest').sendRequest;
9   var utils = require('utils');
10   var validate = require('schemaUtils').validate;
11   var unloadEvent = require('unload_event');
13   // Schemas for the rule-style functions on the events API that
14   // only need to be generated occasionally, so populate them lazily.
15   var ruleFunctionSchemas = {
16     // These values are set lazily:
17     // addRules: {},
18     // getRules: {},
19     // removeRules: {}
20   };
22   // This function ensures that |ruleFunctionSchemas| is populated.
23   function ensureRuleSchemasLoaded() {
24     if (ruleFunctionSchemas.addRules)
25       return;
26     var eventsSchema = schemaRegistry.GetSchema("events");
27     var eventType = utils.lookup(eventsSchema.types, 'id', 'events.Event');
29     ruleFunctionSchemas.addRules =
30         utils.lookup(eventType.functions, 'name', 'addRules');
31     ruleFunctionSchemas.getRules =
32         utils.lookup(eventType.functions, 'name', 'getRules');
33     ruleFunctionSchemas.removeRules =
34         utils.lookup(eventType.functions, 'name', 'removeRules');
35   }
37   // A map of event names to the event object that is registered to that name.
38   var attachedNamedEvents = {};
40   // An array of all attached event objects, used for detaching on unload.
41   var allAttachedEvents = [];
43   // A map of functions that massage event arguments before they are dispatched.
44   // Key is event name, value is function.
45   var eventArgumentMassagers = {};
47   // An attachment strategy for events that aren't attached to the browser.
48   // This applies to events with the "unmanaged" option and events without
49   // names.
50   var NullAttachmentStrategy = function(event) {
51     this.event_ = event;
52   };
53   NullAttachmentStrategy.prototype.onAddedListener =
54       function(listener) {
55   };
56   NullAttachmentStrategy.prototype.onRemovedListener =
57       function(listener) {
58   };
59   NullAttachmentStrategy.prototype.detach = function(manual) {
60   };
61   NullAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
62     // |ids| is for filtered events only.
63     return this.event_.listeners_;
64   };
66   // Handles adding/removing/dispatching listeners for unfiltered events.
67   var UnfilteredAttachmentStrategy = function(event) {
68     this.event_ = event;
69   };
71   UnfilteredAttachmentStrategy.prototype.onAddedListener =
72       function(listener) {
73     // Only attach / detach on the first / last listener removed.
74     if (this.event_.listeners_.length == 0)
75       eventNatives.AttachEvent(this.event_.eventName_);
76   };
78   UnfilteredAttachmentStrategy.prototype.onRemovedListener =
79       function(listener) {
80     if (this.event_.listeners_.length == 0)
81       this.detach(true);
82   };
84   UnfilteredAttachmentStrategy.prototype.detach = function(manual) {
85     eventNatives.DetachEvent(this.event_.eventName_, manual);
86   };
88   UnfilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
89     // |ids| is for filtered events only.
90     return this.event_.listeners_;
91   };
93   var FilteredAttachmentStrategy = function(event) {
94     this.event_ = event;
95     this.listenerMap_ = {};
96   };
98   FilteredAttachmentStrategy.idToEventMap = {};
100   FilteredAttachmentStrategy.prototype.onAddedListener = function(listener) {
101     var id = eventNatives.AttachFilteredEvent(this.event_.eventName_,
102                                               listener.filters || {});
103     if (id == -1)
104       throw new Error("Can't add listener");
105     listener.id = id;
106     this.listenerMap_[id] = listener;
107     FilteredAttachmentStrategy.idToEventMap[id] = this.event_;
108   };
110   FilteredAttachmentStrategy.prototype.onRemovedListener = function(listener) {
111     this.detachListener(listener, true);
112   };
114   FilteredAttachmentStrategy.prototype.detachListener =
115       function(listener, manual) {
116     if (listener.id == undefined)
117       throw new Error("listener.id undefined - '" + listener + "'");
118     var id = listener.id;
119     delete this.listenerMap_[id];
120     delete FilteredAttachmentStrategy.idToEventMap[id];
121     eventNatives.DetachFilteredEvent(id, manual);
122   };
124   FilteredAttachmentStrategy.prototype.detach = function(manual) {
125     for (var i in this.listenerMap_)
126       this.detachListener(this.listenerMap_[i], manual);
127   };
129   FilteredAttachmentStrategy.prototype.getListenersByIDs = function(ids) {
130     var result = [];
131     for (var i = 0; i < ids.length; i++)
132       $Array.push(result, this.listenerMap_[ids[i]]);
133     return result;
134   };
136   function parseEventOptions(opt_eventOptions) {
137     function merge(dest, src) {
138       for (var k in src) {
139         if (!$Object.hasOwnProperty(dest, k)) {
140           dest[k] = src[k];
141         }
142       }
143     }
145     var options = opt_eventOptions || {};
146     merge(options, {
147       // Event supports adding listeners with filters ("filtered events"), for
148       // example as used in the webNavigation API.
149       //
150       // event.addListener(listener, [filter1, filter2]);
151       supportsFilters: false,
153       // Events supports vanilla events. Most APIs use these.
154       //
155       // event.addListener(listener);
156       supportsListeners: true,
158       // Event supports adding rules ("declarative events") rather than
159       // listeners, for example as used in the declarativeWebRequest API.
160       //
161       // event.addRules([rule1, rule2]);
162       supportsRules: false,
164       // Event is unmanaged in that the browser has no knowledge of its
165       // existence; it's never invoked, doesn't keep the renderer alive, and
166       // the bindings system has no knowledge of it.
167       //
168       // Both events created by user code (new chrome.Event()) and messaging
169       // events are unmanaged, though in the latter case the browser *does*
170       // interact indirectly with them via IPCs written by hand.
171       unmanaged: false,
172     });
173     return options;
174   };
176   // Event object.  If opt_eventName is provided, this object represents
177   // the unique instance of that named event, and dispatching an event
178   // with that name will route through this object's listeners. Note that
179   // opt_eventName is required for events that support rules.
180   //
181   // Example:
182   //   var Event = require('event_bindings').Event;
183   //   chrome.tabs.onChanged = new Event("tab-changed");
184   //   chrome.tabs.onChanged.addListener(function(data) { alert(data); });
185   //   Event.dispatch("tab-changed", "hi");
186   // will result in an alert dialog that says 'hi'.
187   //
188   // If opt_eventOptions exists, it is a dictionary that contains the boolean
189   // entries "supportsListeners" and "supportsRules".
190   // If opt_webViewInstanceId exists, it is an integer uniquely identifying a
191   // <webview> tag within the embedder. If it does not exist, then this is an
192   // extension event rather than a <webview> event.
193   var Event = function(opt_eventName, opt_argSchemas, opt_eventOptions,
194                        opt_webViewInstanceId) {
195     this.eventName_ = opt_eventName;
196     this.argSchemas_ = opt_argSchemas;
197     this.listeners_ = [];
198     this.eventOptions_ = parseEventOptions(opt_eventOptions);
199     this.webViewInstanceId_ = opt_webViewInstanceId || 0;
201     if (!this.eventName_) {
202       if (this.eventOptions_.supportsRules)
203         throw new Error("Events that support rules require an event name.");
204       // Events without names cannot be managed by the browser by definition
205       // (the browser has no way of identifying them).
206       this.eventOptions_.unmanaged = true;
207     }
209     // Track whether the event has been destroyed to help track down the cause
210     // of http://crbug.com/258526.
211     // This variable will eventually hold the stack trace of the destroy call.
212     // TODO(kalman): Delete this and replace with more sound logic that catches
213     // when events are used without being *attached*.
214     this.destroyed_ = null;
216     if (this.eventOptions_.unmanaged)
217       this.attachmentStrategy_ = new NullAttachmentStrategy(this);
218     else if (this.eventOptions_.supportsFilters)
219       this.attachmentStrategy_ = new FilteredAttachmentStrategy(this);
220     else
221       this.attachmentStrategy_ = new UnfilteredAttachmentStrategy(this);
222   };
224   // callback is a function(args, dispatch). args are the args we receive from
225   // dispatchEvent(), and dispatch is a function(args) that dispatches args to
226   // its listeners.
227   function registerArgumentMassager(name, callback) {
228     if (eventArgumentMassagers[name])
229       throw new Error("Massager already registered for event: " + name);
230     eventArgumentMassagers[name] = callback;
231   }
233   // Dispatches a named event with the given argument array. The args array is
234   // the list of arguments that will be sent to the event callback.
235   function dispatchEvent(name, args, filteringInfo) {
236     var listenerIDs = [];
238     if (filteringInfo)
239       listenerIDs = eventNatives.MatchAgainstEventFilter(name, filteringInfo);
241     var event = attachedNamedEvents[name];
242     if (!event)
243       return;
245     var dispatchArgs = function(args) {
246       var result = event.dispatch_(args, listenerIDs);
247       if (result)
248         logging.DCHECK(!result.validationErrors, result.validationErrors);
249       return result;
250     };
252     if (eventArgumentMassagers[name])
253       eventArgumentMassagers[name](args, dispatchArgs);
254     else
255       dispatchArgs(args);
256   }
258   // Registers a callback to be called when this event is dispatched.
259   Event.prototype.addListener = function(cb, filters) {
260     if (!this.eventOptions_.supportsListeners)
261       throw new Error("This event does not support listeners.");
262     if (this.eventOptions_.maxListeners &&
263         this.getListenerCount() >= this.eventOptions_.maxListeners) {
264       throw new Error("Too many listeners for " + this.eventName_);
265     }
266     if (filters) {
267       if (!this.eventOptions_.supportsFilters)
268         throw new Error("This event does not support filters.");
269       if (filters.url && !(filters.url instanceof Array))
270         throw new Error("filters.url should be an array.");
271       if (filters.serviceType &&
272           !(typeof filters.serviceType === 'string')) {
273         throw new Error("filters.serviceType should be a string.")
274       }
275     }
276     var listener = {callback: cb, filters: filters};
277     this.attach_(listener);
278     $Array.push(this.listeners_, listener);
279   };
281   Event.prototype.attach_ = function(listener) {
282     this.attachmentStrategy_.onAddedListener(listener);
284     if (this.listeners_.length == 0) {
285       allAttachedEvents[allAttachedEvents.length] = this;
286       if (this.eventName_) {
287         if (attachedNamedEvents[this.eventName_]) {
288           throw new Error("Event '" + this.eventName_ +
289                           "' is already attached.");
290         }
291         attachedNamedEvents[this.eventName_] = this;
292       }
293     }
294   };
296   // Unregisters a callback.
297   Event.prototype.removeListener = function(cb) {
298     if (!this.eventOptions_.supportsListeners)
299       throw new Error("This event does not support listeners.");
301     var idx = this.findListener_(cb);
302     if (idx == -1)
303       return;
305     var removedListener = $Array.splice(this.listeners_, idx, 1)[0];
306     this.attachmentStrategy_.onRemovedListener(removedListener);
308     if (this.listeners_.length == 0) {
309       var i = $Array.indexOf(allAttachedEvents, this);
310       if (i >= 0)
311         delete allAttachedEvents[i];
312       if (this.eventName_) {
313         if (!attachedNamedEvents[this.eventName_])
314           throw new Error("Event '" + this.eventName_ + "' is not attached.");
315         delete attachedNamedEvents[this.eventName_];
316       }
317     }
318   };
320   // Test if the given callback is registered for this event.
321   Event.prototype.hasListener = function(cb) {
322     if (!this.eventOptions_.supportsListeners)
323       throw new Error("This event does not support listeners.");
324     return this.findListener_(cb) > -1;
325   };
327   // Test if any callbacks are registered for this event.
328   Event.prototype.hasListeners = function() {
329     return this.getListenerCount() > 0;
330   };
332   // Return the number of listeners on this event.
333   Event.prototype.getListenerCount = function() {
334     if (!this.eventOptions_.supportsListeners)
335       throw new Error("This event does not support listeners.");
336     return this.listeners_.length;
337   };
339   // Returns the index of the given callback if registered, or -1 if not
340   // found.
341   Event.prototype.findListener_ = function(cb) {
342     for (var i = 0; i < this.listeners_.length; i++) {
343       if (this.listeners_[i].callback == cb) {
344         return i;
345       }
346     }
348     return -1;
349   };
351   Event.prototype.dispatch_ = function(args, listenerIDs) {
352     if (this.destroyed_) {
353       throw new Error(this.eventName_ + ' was already destroyed at: ' +
354                       this.destroyed_);
355     }
356     if (!this.eventOptions_.supportsListeners)
357       throw new Error("This event does not support listeners.");
359     if (this.argSchemas_ && logging.DCHECK_IS_ON()) {
360       try {
361         validate(args, this.argSchemas_);
362       } catch (e) {
363         e.message += ' in ' + this.eventName_;
364         throw e;
365       }
366     }
368     // Make a copy of the listeners in case the listener list is modified
369     // while dispatching the event.
370     var listeners = $Array.slice(
371         this.attachmentStrategy_.getListenersByIDs(listenerIDs));
373     var results = [];
374     for (var i = 0; i < listeners.length; i++) {
375       try {
376         var result = this.dispatchToListener(listeners[i].callback, args);
377         if (result !== undefined)
378           $Array.push(results, result);
379       } catch (e) {
380         console.error('Error in event handler for ' +
381                       (this.eventName_ ? this.eventName_ : '(unknown)') +
382                       ': ' + e.stack);
383       }
384     }
385     if (results.length)
386       return {results: results};
387   }
389   // Can be overridden to support custom dispatching.
390   Event.prototype.dispatchToListener = function(callback, args) {
391     return $Function.apply(callback, null, args);
392   }
394   // Dispatches this event object to all listeners, passing all supplied
395   // arguments to this function each listener.
396   Event.prototype.dispatch = function(varargs) {
397     return this.dispatch_($Array.slice(arguments), undefined);
398   };
400   // Detaches this event object from its name.
401   Event.prototype.detach_ = function() {
402     this.attachmentStrategy_.detach(false);
403   };
405   Event.prototype.destroy_ = function() {
406     this.listeners_.length = 0;
407     this.detach_();
408     this.destroyed_ = new Error().stack;
409   };
411   Event.prototype.addRules = function(rules, opt_cb) {
412     if (!this.eventOptions_.supportsRules)
413       throw new Error("This event does not support rules.");
415     // Takes a list of JSON datatype identifiers and returns a schema fragment
416     // that verifies that a JSON object corresponds to an array of only these
417     // data types.
418     function buildArrayOfChoicesSchema(typesList) {
419       return {
420         'type': 'array',
421         'items': {
422           'choices': typesList.map(function(el) {return {'$ref': el};})
423         }
424       };
425     };
427     // Validate conditions and actions against specific schemas of this
428     // event object type.
429     // |rules| is an array of JSON objects that follow the Rule type of the
430     // declarative extension APIs. |conditions| is an array of JSON type
431     // identifiers that are allowed to occur in the conditions attribute of each
432     // rule. Likewise, |actions| is an array of JSON type identifiers that are
433     // allowed to occur in the actions attribute of each rule.
434     function validateRules(rules, conditions, actions) {
435       var conditionsSchema = buildArrayOfChoicesSchema(conditions);
436       var actionsSchema = buildArrayOfChoicesSchema(actions);
437       $Array.forEach(rules, function(rule) {
438         validate([rule.conditions], [conditionsSchema]);
439         validate([rule.actions], [actionsSchema]);
440       });
441     };
443     if (!this.eventOptions_.conditions || !this.eventOptions_.actions) {
444       throw new Error('Event ' + this.eventName_ + ' misses conditions or ' +
445                       'actions in the API specification.');
446     }
448     validateRules(rules,
449                   this.eventOptions_.conditions,
450                   this.eventOptions_.actions);
452     ensureRuleSchemasLoaded();
453     // We remove the first parameter from the validation to give the user more
454     // meaningful error messages.
455     validate([this.webViewInstanceId_, rules, opt_cb],
456              $Array.splice(
457                  $Array.slice(ruleFunctionSchemas.addRules.parameters), 1));
458     sendRequest("events.addRules",
459                 [this.eventName_, this.webViewInstanceId_, rules,  opt_cb],
460                 ruleFunctionSchemas.addRules.parameters);
461   }
463   Event.prototype.removeRules = function(ruleIdentifiers, opt_cb) {
464     if (!this.eventOptions_.supportsRules)
465       throw new Error("This event does not support rules.");
466     ensureRuleSchemasLoaded();
467     // We remove the first parameter from the validation to give the user more
468     // meaningful error messages.
469     validate([this.webViewInstanceId_, ruleIdentifiers, opt_cb],
470              $Array.splice(
471                  $Array.slice(ruleFunctionSchemas.removeRules.parameters), 1));
472     sendRequest("events.removeRules",
473                 [this.eventName_,
474                  this.webViewInstanceId_,
475                  ruleIdentifiers,
476                  opt_cb],
477                 ruleFunctionSchemas.removeRules.parameters);
478   }
480   Event.prototype.getRules = function(ruleIdentifiers, cb) {
481     if (!this.eventOptions_.supportsRules)
482       throw new Error("This event does not support rules.");
483     ensureRuleSchemasLoaded();
484     // We remove the first parameter from the validation to give the user more
485     // meaningful error messages.
486     validate([this.webViewInstanceId_, ruleIdentifiers, cb],
487              $Array.splice(
488                  $Array.slice(ruleFunctionSchemas.getRules.parameters), 1));
490     sendRequest("events.getRules",
491                 [this.eventName_, this.webViewInstanceId_, ruleIdentifiers, cb],
492                 ruleFunctionSchemas.getRules.parameters);
493   }
495   unloadEvent.addListener(function() {
496     for (var i = 0; i < allAttachedEvents.length; ++i) {
497       var event = allAttachedEvents[i];
498       if (event)
499         event.detach_();
500     }
501   });
503   // NOTE: Event is (lazily) exposed as chrome.Event from dispatcher.cc.
504   exports.Event = Event;
506   exports.dispatchEvent = dispatchEvent;
507   exports.parseEventOptions = parseEventOptions;
508   exports.registerArgumentMassager = registerArgumentMassager;