Fri, 16 Jan 2015 18:13:44 +0100
Integrate suggestion from review to improve consistency with existing code.
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 const SEARCH_RESPONSE_SUGGESTION_JSON = "application/x-suggestions+json";
7 const BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled";
8 const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown";
9 const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed";
11 const Cc = Components.classes;
12 const Ci = Components.interfaces;
13 const Cr = Components.results;
14 const Cu = Components.utils;
16 const HTTP_OK = 200;
17 const HTTP_INTERNAL_SERVER_ERROR = 500;
18 const HTTP_BAD_GATEWAY = 502;
19 const HTTP_SERVICE_UNAVAILABLE = 503;
21 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
22 Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm");
23 Cu.import("resource://gre/modules/Services.jsm");
25 /**
26 * SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch
27 * and can collect results for a given search by using the search URL supplied
28 * by the subclass. We do it this way since the AutoCompleteController in
29 * Mozilla requires a unique XPCOM Service for every search provider, even if
30 * the logic for two providers is identical.
31 * @constructor
32 */
33 function SuggestAutoComplete() {
34 this._init();
35 }
36 SuggestAutoComplete.prototype = {
38 _init: function() {
39 this._addObservers();
40 this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF);
41 },
43 get _suggestionLabel() {
44 delete this._suggestionLabel;
45 let bundle = Services.strings.createBundle("chrome://global/locale/search/search.properties");
46 return this._suggestionLabel = bundle.GetStringFromName("suggestion_label");
47 },
49 /**
50 * Search suggestions will be shown if this._suggestEnabled is true.
51 */
52 _suggestEnabled: null,
54 /*************************************************************************
55 * Server request backoff implementation fields below
56 * These allow us to throttle requests if the server is getting hammered.
57 **************************************************************************/
59 /**
60 * This is an array that contains the timestamps (in unixtime) of
61 * the last few backoff-triggering errors.
62 */
63 _serverErrorLog: [],
65 /**
66 * If we receive this number of backoff errors within the amount of time
67 * specified by _serverErrorPeriod, then we initiate backoff.
68 */
69 _maxErrorsBeforeBackoff: 3,
71 /**
72 * If we receive enough consecutive errors (where "enough" is defined by
73 * _maxErrorsBeforeBackoff above) within this time period,
74 * we trigger the backoff behavior.
75 */
76 _serverErrorPeriod: 600000, // 10 minutes in milliseconds
78 /**
79 * If we get another backoff error immediately after timeout, we increase the
80 * backoff to (2 x old period) + this value.
81 */
82 _serverErrorTimeoutIncrement: 600000, // 10 minutes in milliseconds
84 /**
85 * The current amount of time to wait before trying a server request
86 * after receiving a backoff error.
87 */
88 _serverErrorTimeout: 0,
90 /**
91 * Time (in unixtime) after which we're allowed to try requesting again.
92 */
93 _nextRequestTime: 0,
95 /**
96 * The last engine we requested against (so that we can tell if the
97 * user switched engines).
98 */
99 _serverErrorEngine: null,
101 /**
102 * The XMLHttpRequest object.
103 * @private
104 */
105 _request: null,
107 /**
108 * The object implementing nsIAutoCompleteObserver that we notify when
109 * we have found results
110 * @private
111 */
112 _listener: null,
114 /**
115 * If this is true, we'll integrate form history results with the
116 * suggest results.
117 */
118 _includeFormHistory: true,
120 /**
121 * True if a request for remote suggestions was sent. This is used to
122 * differentiate between the "_request is null because the request has
123 * already returned a result" and "_request is null because no request was
124 * sent" cases.
125 */
126 _sentSuggestRequest: false,
128 /**
129 * This is the callback for the suggest timeout timer.
130 */
131 notify: function SAC_notify(timer) {
132 // FIXME: bug 387341
133 // Need to break the cycle between us and the timer.
134 this._formHistoryTimer = null;
136 // If this._listener is null, we've already sent out suggest results, so
137 // nothing left to do here.
138 if (!this._listener)
139 return;
141 // Otherwise, the XMLHTTPRequest for suggest results is taking too long,
142 // so send out the form history results and cancel the request.
143 this._listener.onSearchResult(this, this._formHistoryResult);
144 this._reset();
145 },
147 /**
148 * This determines how long (in ms) we should wait before giving up on
149 * the suggestions and just showing local form history results.
150 */
151 _suggestionTimeout: 500,
153 /**
154 * This is the callback for that the form history service uses to
155 * send us results.
156 */
157 onSearchResult: function SAC_onSearchResult(search, result) {
158 this._formHistoryResult = result;
160 if (this._request) {
161 // We still have a pending request, wait a bit to give it a chance to
162 // finish.
163 this._formHistoryTimer = Cc["@mozilla.org/timer;1"].
164 createInstance(Ci.nsITimer);
165 this._formHistoryTimer.initWithCallback(this, this._suggestionTimeout,
166 Ci.nsITimer.TYPE_ONE_SHOT);
167 } else if (!this._sentSuggestRequest) {
168 // We didn't send a request, so just send back the form history results.
169 this._listener.onSearchResult(this, this._formHistoryResult);
170 this._reset();
171 }
172 },
174 /**
175 * This is the URI that the last suggest request was sent to.
176 */
177 _suggestURI: null,
179 /**
180 * Autocomplete results from the form history service get stored here.
181 */
182 _formHistoryResult: null,
184 /**
185 * This holds the suggest server timeout timer, if applicable.
186 */
187 _formHistoryTimer: null,
189 /**
190 * Maximum number of history items displayed. This is capped at 7
191 * because the primary consumer (Firefox search bar) displays 10 rows
192 * by default, and so we want to leave some space for suggestions
193 * to be visible.
194 */
195 _historyLimit: 7,
197 /**
198 * This clears all the per-request state.
199 */
200 _reset: function SAC_reset() {
201 // Don't let go of our listener and form history result if the timer is
202 // still pending, the timer will call _reset() when it fires.
203 if (!this._formHistoryTimer) {
204 this._listener = null;
205 this._formHistoryResult = null;
206 }
207 this._request = null;
208 },
210 /**
211 * This sends an autocompletion request to the form history service,
212 * which will call onSearchResults with the results of the query.
213 */
214 _startHistorySearch: function SAC_SHSearch(searchString, searchParam) {
215 var formHistory =
216 Cc["@mozilla.org/autocomplete/search;1?name=form-history"].
217 createInstance(Ci.nsIAutoCompleteSearch);
218 formHistory.startSearch(searchString, searchParam, this._formHistoryResult, this);
219 },
221 /**
222 * Makes a note of the fact that we've received a backoff-triggering
223 * response, so that we can adjust the backoff behavior appropriately.
224 */
225 _noteServerError: function SAC__noteServeError() {
226 var currentTime = Date.now();
228 this._serverErrorLog.push(currentTime);
229 if (this._serverErrorLog.length > this._maxErrorsBeforeBackoff)
230 this._serverErrorLog.shift();
232 if ((this._serverErrorLog.length == this._maxErrorsBeforeBackoff) &&
233 ((currentTime - this._serverErrorLog[0]) < this._serverErrorPeriod)) {
234 // increase timeout, and then don't request until timeout is over
235 this._serverErrorTimeout = (this._serverErrorTimeout * 2) +
236 this._serverErrorTimeoutIncrement;
237 this._nextRequestTime = currentTime + this._serverErrorTimeout;
238 }
239 },
241 /**
242 * Resets the backoff behavior; called when we get a successful response.
243 */
244 _clearServerErrors: function SAC__clearServerErrors() {
245 this._serverErrorLog = [];
246 this._serverErrorTimeout = 0;
247 this._nextRequestTime = 0;
248 },
250 /**
251 * This checks whether we should send a server request (i.e. we're not
252 * in a error-triggered backoff period.
253 *
254 * @private
255 */
256 _okToRequest: function SAC__okToRequest() {
257 return Date.now() > this._nextRequestTime;
258 },
260 /**
261 * This checks to see if the new search engine is different
262 * from the previous one, and if so clears any error state that might
263 * have accumulated for the old engine.
264 *
265 * @param engine The engine that the suggestion request would be sent to.
266 * @private
267 */
268 _checkForEngineSwitch: function SAC__checkForEngineSwitch(engine) {
269 if (engine == this._serverErrorEngine)
270 return;
272 // must've switched search providers, clear old errors
273 this._serverErrorEngine = engine;
274 this._clearServerErrors();
275 },
277 /**
278 * This returns true if the status code of the HTTP response
279 * represents a backoff-triggering error.
280 *
281 * @param status The status code from the HTTP response
282 * @private
283 */
284 _isBackoffError: function SAC__isBackoffError(status) {
285 return ((status == HTTP_INTERNAL_SERVER_ERROR) ||
286 (status == HTTP_BAD_GATEWAY) ||
287 (status == HTTP_SERVICE_UNAVAILABLE));
288 },
290 /**
291 * Called when the 'readyState' of the XMLHttpRequest changes. We only care
292 * about state 4 (COMPLETED) - handle the response data.
293 * @private
294 */
295 onReadyStateChange: function() {
296 // xxx use the real const here
297 if (!this._request || this._request.readyState != 4)
298 return;
300 try {
301 var status = this._request.status;
302 } catch (e) {
303 // The XML HttpRequest can throw NS_ERROR_NOT_AVAILABLE.
304 return;
305 }
307 if (this._isBackoffError(status)) {
308 this._noteServerError();
309 return;
310 }
312 var responseText = this._request.responseText;
313 if (status != HTTP_OK || responseText == "")
314 return;
316 this._clearServerErrors();
318 try {
319 var serverResults = JSON.parse(responseText);
320 } catch(ex) {
321 Components.utils.reportError("Failed to parse JSON from " + this._suggestURI.spec + ": " + ex);
322 return;
323 }
325 var searchString = serverResults[0] || "";
326 var results = serverResults[1] || [];
328 var comments = []; // "comments" column values for suggestions
329 var historyResults = [];
330 var historyComments = [];
332 // If form history is enabled and has results, add them to the list.
333 if (this._includeFormHistory && this._formHistoryResult &&
334 (this._formHistoryResult.searchResult ==
335 Ci.nsIAutoCompleteResult.RESULT_SUCCESS)) {
336 var maxHistoryItems = Math.min(this._formHistoryResult.matchCount, this._historyLimit);
337 for (var i = 0; i < maxHistoryItems; ++i) {
338 var term = this._formHistoryResult.getValueAt(i);
340 // we don't want things to appear in both history and suggestions
341 var dupIndex = results.indexOf(term);
342 if (dupIndex != -1)
343 results.splice(dupIndex, 1);
345 historyResults.push(term);
346 historyComments.push("");
347 }
348 }
350 // fill out the comment column for the suggestions
351 for (var i = 0; i < results.length; ++i)
352 comments.push("");
354 // if we have any suggestions, put a label at the top
355 if (comments.length > 0)
356 comments[0] = this._suggestionLabel;
358 // now put the history results above the suggestions
359 var finalResults = historyResults.concat(results);
360 var finalComments = historyComments.concat(comments);
362 // Notify the FE of our new results
363 this.onResultsReady(searchString, finalResults, finalComments,
364 this._formHistoryResult);
366 // Reset our state for next time.
367 this._reset();
368 },
370 /**
371 * Notifies the front end of new results.
372 * @param searchString the user's query string
373 * @param results an array of results to the search
374 * @param comments an array of metadata corresponding to the results
375 * @private
376 */
377 onResultsReady: function(searchString, results, comments,
378 formHistoryResult) {
379 if (this._listener) {
380 var result = new FormAutoCompleteResult(
381 searchString,
382 Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
383 0,
384 "",
385 results,
386 results,
387 comments,
388 formHistoryResult);
390 this._listener.onSearchResult(this, result);
392 // Null out listener to make sure we don't notify it twice, in case our
393 // timer callback still hasn't run.
394 this._listener = null;
395 }
396 },
398 /**
399 * Initiates the search result gathering process. Part of
400 * nsIAutoCompleteSearch implementation.
401 *
402 * @param searchString the user's query string
403 * @param searchParam unused, "an extra parameter"; even though
404 * this parameter and the next are unused, pass
405 * them through in case the form history
406 * service wants them
407 * @param previousResult unused, a client-cached store of the previous
408 * generated resultset for faster searching.
409 * @param listener object implementing nsIAutoCompleteObserver which
410 * we notify when results are ready.
411 */
412 startSearch: function(searchString, searchParam, previousResult, listener) {
413 // Don't reuse a previous form history result when it no longer applies.
414 if (!previousResult)
415 this._formHistoryResult = null;
417 var formHistorySearchParam = searchParam.split("|")[0];
419 // Receive the information about the privacy mode of the window to which
420 // this search box belongs. The front-end's search.xml bindings passes this
421 // information in the searchParam parameter. The alternative would have
422 // been to modify nsIAutoCompleteSearch to add an argument to startSearch
423 // and patch all of autocomplete to be aware of this, but the searchParam
424 // argument is already an opaque argument, so this solution is hopefully
425 // less hackish (although still gross.)
426 var privacyMode = (searchParam.split("|")[1] == "private");
428 // Start search immediately if possible, otherwise once the search
429 // service is initialized
430 if (Services.search.isInitialized) {
431 this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode);
432 return;
433 }
435 Services.search.init((function startSearch_cb(aResult) {
436 if (!Components.isSuccessCode(aResult)) {
437 Cu.reportError("Could not initialize search service, bailing out: " + aResult);
438 return;
439 }
440 this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode);
441 }).bind(this));
442 },
444 /**
445 * Actual implementation of search.
446 */
447 _triggerSearch: function(searchString, searchParam, listener, privacyMode) {
448 // If there's an existing request, stop it. There is no smart filtering
449 // here as there is when looking through history/form data because the
450 // result set returned by the server is different for every typed value -
451 // "ocean breathes" does not return a subset of the results returned for
452 // "ocean", for example. This does nothing if there is no current request.
453 this.stopSearch();
455 this._listener = listener;
457 var engine = Services.search.currentEngine;
459 this._checkForEngineSwitch(engine);
461 if (!searchString ||
462 !this._suggestEnabled ||
463 !engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON) ||
464 !this._okToRequest()) {
465 // We have an empty search string (user pressed down arrow to see
466 // history), or search suggestions are disabled, or the current engine
467 // has no suggest functionality, or we're in backoff mode; so just use
468 // local history.
469 this._sentSuggestRequest = false;
470 this._startHistorySearch(searchString, searchParam);
471 return;
472 }
474 // Actually do the search
475 this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
476 createInstance(Ci.nsIXMLHttpRequest);
477 var submission = engine.getSubmission(searchString,
478 SEARCH_RESPONSE_SUGGESTION_JSON);
479 this._suggestURI = submission.uri;
480 var method = (submission.postData ? "POST" : "GET");
481 this._request.open(method, this._suggestURI.spec, true);
482 this._request.channel.notificationCallbacks = new AuthPromptOverride();
483 if (this._request.channel instanceof Ci.nsIPrivateBrowsingChannel) {
484 this._request.channel.setPrivate(privacyMode);
485 }
487 var self = this;
488 function onReadyStateChange() {
489 self.onReadyStateChange();
490 }
491 this._request.onreadystatechange = onReadyStateChange;
492 this._request.send(submission.postData);
494 if (this._includeFormHistory) {
495 this._sentSuggestRequest = true;
496 this._startHistorySearch(searchString, searchParam);
497 }
498 },
500 /**
501 * Ends the search result gathering process. Part of nsIAutoCompleteSearch
502 * implementation.
503 */
504 stopSearch: function() {
505 if (this._request) {
506 this._request.abort();
507 this._reset();
508 }
509 },
511 /**
512 * nsIObserver
513 */
514 observe: function SAC_observe(aSubject, aTopic, aData) {
515 switch (aTopic) {
516 case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID:
517 this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF);
518 break;
519 case XPCOM_SHUTDOWN_TOPIC:
520 this._removeObservers();
521 break;
522 }
523 },
525 _addObservers: function SAC_addObservers() {
526 Services.prefs.addObserver(BROWSER_SUGGEST_PREF, this, false);
528 Services.obs.addObserver(this, XPCOM_SHUTDOWN_TOPIC, false);
529 },
531 _removeObservers: function SAC_removeObservers() {
532 Services.prefs.removeObserver(BROWSER_SUGGEST_PREF, this);
534 Services.obs.removeObserver(this, XPCOM_SHUTDOWN_TOPIC);
535 },
537 // nsISupports
538 QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch,
539 Ci.nsIAutoCompleteObserver])
540 };
542 function AuthPromptOverride() {
543 }
544 AuthPromptOverride.prototype = {
545 // nsIAuthPromptProvider
546 getAuthPrompt: function (reason, iid) {
547 // Return a no-op nsIAuthPrompt2 implementation.
548 return {
549 promptAuth: function () {
550 throw Cr.NS_ERROR_NOT_IMPLEMENTED;
551 },
552 asyncPromptAuth: function () {
553 throw Cr.NS_ERROR_NOT_IMPLEMENTED;
554 }
555 };
556 },
558 // nsIInterfaceRequestor
559 getInterface: function SSLL_getInterface(iid) {
560 return this.QueryInterface(iid);
561 },
563 // nsISupports
564 QueryInterface: XPCOMUtils.generateQI([Ci.nsIAuthPromptProvider,
565 Ci.nsIInterfaceRequestor])
566 };
567 /**
568 * SearchSuggestAutoComplete is a service implementation that handles suggest
569 * results specific to web searches.
570 * @constructor
571 */
572 function SearchSuggestAutoComplete() {
573 // This calls _init() in the parent class (SuggestAutoComplete) via the
574 // prototype, below.
575 this._init();
576 }
577 SearchSuggestAutoComplete.prototype = {
578 classID: Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"),
579 __proto__: SuggestAutoComplete.prototype,
580 serviceURL: ""
581 };
583 var component = [SearchSuggestAutoComplete];
584 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);