diff -r 000000000000 -r 6474c204b198 toolkit/components/search/nsSearchSuggestions.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/toolkit/components/search/nsSearchSuggestions.js Wed Dec 31 06:09:35 2014 +0100 @@ -0,0 +1,584 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const SEARCH_RESPONSE_SUGGESTION_JSON = "application/x-suggestions+json"; + +const BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled"; +const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown"; +const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const HTTP_OK = 200; +const HTTP_INTERNAL_SERVER_ERROR = 500; +const HTTP_BAD_GATEWAY = 502; +const HTTP_SERVICE_UNAVAILABLE = 503; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +/** + * SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch + * and can collect results for a given search by using the search URL supplied + * by the subclass. We do it this way since the AutoCompleteController in + * Mozilla requires a unique XPCOM Service for every search provider, even if + * the logic for two providers is identical. + * @constructor + */ +function SuggestAutoComplete() { + this._init(); +} +SuggestAutoComplete.prototype = { + + _init: function() { + this._addObservers(); + this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); + }, + + get _suggestionLabel() { + delete this._suggestionLabel; + let bundle = Services.strings.createBundle("chrome://global/locale/search/search.properties"); + return this._suggestionLabel = bundle.GetStringFromName("suggestion_label"); + }, + + /** + * Search suggestions will be shown if this._suggestEnabled is true. + */ + _suggestEnabled: null, + + /************************************************************************* + * Server request backoff implementation fields below + * These allow us to throttle requests if the server is getting hammered. + **************************************************************************/ + + /** + * This is an array that contains the timestamps (in unixtime) of + * the last few backoff-triggering errors. + */ + _serverErrorLog: [], + + /** + * If we receive this number of backoff errors within the amount of time + * specified by _serverErrorPeriod, then we initiate backoff. + */ + _maxErrorsBeforeBackoff: 3, + + /** + * If we receive enough consecutive errors (where "enough" is defined by + * _maxErrorsBeforeBackoff above) within this time period, + * we trigger the backoff behavior. + */ + _serverErrorPeriod: 600000, // 10 minutes in milliseconds + + /** + * If we get another backoff error immediately after timeout, we increase the + * backoff to (2 x old period) + this value. + */ + _serverErrorTimeoutIncrement: 600000, // 10 minutes in milliseconds + + /** + * The current amount of time to wait before trying a server request + * after receiving a backoff error. + */ + _serverErrorTimeout: 0, + + /** + * Time (in unixtime) after which we're allowed to try requesting again. + */ + _nextRequestTime: 0, + + /** + * The last engine we requested against (so that we can tell if the + * user switched engines). + */ + _serverErrorEngine: null, + + /** + * The XMLHttpRequest object. + * @private + */ + _request: null, + + /** + * The object implementing nsIAutoCompleteObserver that we notify when + * we have found results + * @private + */ + _listener: null, + + /** + * If this is true, we'll integrate form history results with the + * suggest results. + */ + _includeFormHistory: true, + + /** + * True if a request for remote suggestions was sent. This is used to + * differentiate between the "_request is null because the request has + * already returned a result" and "_request is null because no request was + * sent" cases. + */ + _sentSuggestRequest: false, + + /** + * This is the callback for the suggest timeout timer. + */ + notify: function SAC_notify(timer) { + // FIXME: bug 387341 + // Need to break the cycle between us and the timer. + this._formHistoryTimer = null; + + // If this._listener is null, we've already sent out suggest results, so + // nothing left to do here. + if (!this._listener) + return; + + // Otherwise, the XMLHTTPRequest for suggest results is taking too long, + // so send out the form history results and cancel the request. + this._listener.onSearchResult(this, this._formHistoryResult); + this._reset(); + }, + + /** + * This determines how long (in ms) we should wait before giving up on + * the suggestions and just showing local form history results. + */ + _suggestionTimeout: 500, + + /** + * This is the callback for that the form history service uses to + * send us results. + */ + onSearchResult: function SAC_onSearchResult(search, result) { + this._formHistoryResult = result; + + if (this._request) { + // We still have a pending request, wait a bit to give it a chance to + // finish. + this._formHistoryTimer = Cc["@mozilla.org/timer;1"]. + createInstance(Ci.nsITimer); + this._formHistoryTimer.initWithCallback(this, this._suggestionTimeout, + Ci.nsITimer.TYPE_ONE_SHOT); + } else if (!this._sentSuggestRequest) { + // We didn't send a request, so just send back the form history results. + this._listener.onSearchResult(this, this._formHistoryResult); + this._reset(); + } + }, + + /** + * This is the URI that the last suggest request was sent to. + */ + _suggestURI: null, + + /** + * Autocomplete results from the form history service get stored here. + */ + _formHistoryResult: null, + + /** + * This holds the suggest server timeout timer, if applicable. + */ + _formHistoryTimer: null, + + /** + * Maximum number of history items displayed. This is capped at 7 + * because the primary consumer (Firefox search bar) displays 10 rows + * by default, and so we want to leave some space for suggestions + * to be visible. + */ + _historyLimit: 7, + + /** + * This clears all the per-request state. + */ + _reset: function SAC_reset() { + // Don't let go of our listener and form history result if the timer is + // still pending, the timer will call _reset() when it fires. + if (!this._formHistoryTimer) { + this._listener = null; + this._formHistoryResult = null; + } + this._request = null; + }, + + /** + * This sends an autocompletion request to the form history service, + * which will call onSearchResults with the results of the query. + */ + _startHistorySearch: function SAC_SHSearch(searchString, searchParam) { + var formHistory = + Cc["@mozilla.org/autocomplete/search;1?name=form-history"]. + createInstance(Ci.nsIAutoCompleteSearch); + formHistory.startSearch(searchString, searchParam, this._formHistoryResult, this); + }, + + /** + * Makes a note of the fact that we've received a backoff-triggering + * response, so that we can adjust the backoff behavior appropriately. + */ + _noteServerError: function SAC__noteServeError() { + var currentTime = Date.now(); + + this._serverErrorLog.push(currentTime); + if (this._serverErrorLog.length > this._maxErrorsBeforeBackoff) + this._serverErrorLog.shift(); + + if ((this._serverErrorLog.length == this._maxErrorsBeforeBackoff) && + ((currentTime - this._serverErrorLog[0]) < this._serverErrorPeriod)) { + // increase timeout, and then don't request until timeout is over + this._serverErrorTimeout = (this._serverErrorTimeout * 2) + + this._serverErrorTimeoutIncrement; + this._nextRequestTime = currentTime + this._serverErrorTimeout; + } + }, + + /** + * Resets the backoff behavior; called when we get a successful response. + */ + _clearServerErrors: function SAC__clearServerErrors() { + this._serverErrorLog = []; + this._serverErrorTimeout = 0; + this._nextRequestTime = 0; + }, + + /** + * This checks whether we should send a server request (i.e. we're not + * in a error-triggered backoff period. + * + * @private + */ + _okToRequest: function SAC__okToRequest() { + return Date.now() > this._nextRequestTime; + }, + + /** + * This checks to see if the new search engine is different + * from the previous one, and if so clears any error state that might + * have accumulated for the old engine. + * + * @param engine The engine that the suggestion request would be sent to. + * @private + */ + _checkForEngineSwitch: function SAC__checkForEngineSwitch(engine) { + if (engine == this._serverErrorEngine) + return; + + // must've switched search providers, clear old errors + this._serverErrorEngine = engine; + this._clearServerErrors(); + }, + + /** + * This returns true if the status code of the HTTP response + * represents a backoff-triggering error. + * + * @param status The status code from the HTTP response + * @private + */ + _isBackoffError: function SAC__isBackoffError(status) { + return ((status == HTTP_INTERNAL_SERVER_ERROR) || + (status == HTTP_BAD_GATEWAY) || + (status == HTTP_SERVICE_UNAVAILABLE)); + }, + + /** + * Called when the 'readyState' of the XMLHttpRequest changes. We only care + * about state 4 (COMPLETED) - handle the response data. + * @private + */ + onReadyStateChange: function() { + // xxx use the real const here + if (!this._request || this._request.readyState != 4) + return; + + try { + var status = this._request.status; + } catch (e) { + // The XML HttpRequest can throw NS_ERROR_NOT_AVAILABLE. + return; + } + + if (this._isBackoffError(status)) { + this._noteServerError(); + return; + } + + var responseText = this._request.responseText; + if (status != HTTP_OK || responseText == "") + return; + + this._clearServerErrors(); + + try { + var serverResults = JSON.parse(responseText); + } catch(ex) { + Components.utils.reportError("Failed to parse JSON from " + this._suggestURI.spec + ": " + ex); + return; + } + + var searchString = serverResults[0] || ""; + var results = serverResults[1] || []; + + var comments = []; // "comments" column values for suggestions + var historyResults = []; + var historyComments = []; + + // If form history is enabled and has results, add them to the list. + if (this._includeFormHistory && this._formHistoryResult && + (this._formHistoryResult.searchResult == + Ci.nsIAutoCompleteResult.RESULT_SUCCESS)) { + var maxHistoryItems = Math.min(this._formHistoryResult.matchCount, this._historyLimit); + for (var i = 0; i < maxHistoryItems; ++i) { + var term = this._formHistoryResult.getValueAt(i); + + // we don't want things to appear in both history and suggestions + var dupIndex = results.indexOf(term); + if (dupIndex != -1) + results.splice(dupIndex, 1); + + historyResults.push(term); + historyComments.push(""); + } + } + + // fill out the comment column for the suggestions + for (var i = 0; i < results.length; ++i) + comments.push(""); + + // if we have any suggestions, put a label at the top + if (comments.length > 0) + comments[0] = this._suggestionLabel; + + // now put the history results above the suggestions + var finalResults = historyResults.concat(results); + var finalComments = historyComments.concat(comments); + + // Notify the FE of our new results + this.onResultsReady(searchString, finalResults, finalComments, + this._formHistoryResult); + + // Reset our state for next time. + this._reset(); + }, + + /** + * Notifies the front end of new results. + * @param searchString the user's query string + * @param results an array of results to the search + * @param comments an array of metadata corresponding to the results + * @private + */ + onResultsReady: function(searchString, results, comments, + formHistoryResult) { + if (this._listener) { + var result = new FormAutoCompleteResult( + searchString, + Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + 0, + "", + results, + results, + comments, + formHistoryResult); + + this._listener.onSearchResult(this, result); + + // Null out listener to make sure we don't notify it twice, in case our + // timer callback still hasn't run. + this._listener = null; + } + }, + + /** + * Initiates the search result gathering process. Part of + * nsIAutoCompleteSearch implementation. + * + * @param searchString the user's query string + * @param searchParam unused, "an extra parameter"; even though + * this parameter and the next are unused, pass + * them through in case the form history + * service wants them + * @param previousResult unused, a client-cached store of the previous + * generated resultset for faster searching. + * @param listener object implementing nsIAutoCompleteObserver which + * we notify when results are ready. + */ + startSearch: function(searchString, searchParam, previousResult, listener) { + // Don't reuse a previous form history result when it no longer applies. + if (!previousResult) + this._formHistoryResult = null; + + var formHistorySearchParam = searchParam.split("|")[0]; + + // Receive the information about the privacy mode of the window to which + // this search box belongs. The front-end's search.xml bindings passes this + // information in the searchParam parameter. The alternative would have + // been to modify nsIAutoCompleteSearch to add an argument to startSearch + // and patch all of autocomplete to be aware of this, but the searchParam + // argument is already an opaque argument, so this solution is hopefully + // less hackish (although still gross.) + var privacyMode = (searchParam.split("|")[1] == "private"); + + // Start search immediately if possible, otherwise once the search + // service is initialized + if (Services.search.isInitialized) { + this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode); + return; + } + + Services.search.init((function startSearch_cb(aResult) { + if (!Components.isSuccessCode(aResult)) { + Cu.reportError("Could not initialize search service, bailing out: " + aResult); + return; + } + this._triggerSearch(searchString, formHistorySearchParam, listener, privacyMode); + }).bind(this)); + }, + + /** + * Actual implementation of search. + */ + _triggerSearch: function(searchString, searchParam, listener, privacyMode) { + // If there's an existing request, stop it. There is no smart filtering + // here as there is when looking through history/form data because the + // result set returned by the server is different for every typed value - + // "ocean breathes" does not return a subset of the results returned for + // "ocean", for example. This does nothing if there is no current request. + this.stopSearch(); + + this._listener = listener; + + var engine = Services.search.currentEngine; + + this._checkForEngineSwitch(engine); + + if (!searchString || + !this._suggestEnabled || + !engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON) || + !this._okToRequest()) { + // We have an empty search string (user pressed down arrow to see + // history), or search suggestions are disabled, or the current engine + // has no suggest functionality, or we're in backoff mode; so just use + // local history. + this._sentSuggestRequest = false; + this._startHistorySearch(searchString, searchParam); + return; + } + + // Actually do the search + this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. + createInstance(Ci.nsIXMLHttpRequest); + var submission = engine.getSubmission(searchString, + SEARCH_RESPONSE_SUGGESTION_JSON); + this._suggestURI = submission.uri; + var method = (submission.postData ? "POST" : "GET"); + this._request.open(method, this._suggestURI.spec, true); + this._request.channel.notificationCallbacks = new AuthPromptOverride(); + if (this._request.channel instanceof Ci.nsIPrivateBrowsingChannel) { + this._request.channel.setPrivate(privacyMode); + } + + var self = this; + function onReadyStateChange() { + self.onReadyStateChange(); + } + this._request.onreadystatechange = onReadyStateChange; + this._request.send(submission.postData); + + if (this._includeFormHistory) { + this._sentSuggestRequest = true; + this._startHistorySearch(searchString, searchParam); + } + }, + + /** + * Ends the search result gathering process. Part of nsIAutoCompleteSearch + * implementation. + */ + stopSearch: function() { + if (this._request) { + this._request.abort(); + this._reset(); + } + }, + + /** + * nsIObserver + */ + observe: function SAC_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID: + this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); + break; + case XPCOM_SHUTDOWN_TOPIC: + this._removeObservers(); + break; + } + }, + + _addObservers: function SAC_addObservers() { + Services.prefs.addObserver(BROWSER_SUGGEST_PREF, this, false); + + Services.obs.addObserver(this, XPCOM_SHUTDOWN_TOPIC, false); + }, + + _removeObservers: function SAC_removeObservers() { + Services.prefs.removeObserver(BROWSER_SUGGEST_PREF, this); + + Services.obs.removeObserver(this, XPCOM_SHUTDOWN_TOPIC); + }, + + // nsISupports + QueryInterface: XPCOMUtils.generateQI([Ci.nsIAutoCompleteSearch, + Ci.nsIAutoCompleteObserver]) +}; + +function AuthPromptOverride() { +} +AuthPromptOverride.prototype = { + // nsIAuthPromptProvider + getAuthPrompt: function (reason, iid) { + // Return a no-op nsIAuthPrompt2 implementation. + return { + promptAuth: function () { + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + }, + asyncPromptAuth: function () { + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + } + }; + }, + + // nsIInterfaceRequestor + getInterface: function SSLL_getInterface(iid) { + return this.QueryInterface(iid); + }, + + // nsISupports + QueryInterface: XPCOMUtils.generateQI([Ci.nsIAuthPromptProvider, + Ci.nsIInterfaceRequestor]) +}; +/** + * SearchSuggestAutoComplete is a service implementation that handles suggest + * results specific to web searches. + * @constructor + */ +function SearchSuggestAutoComplete() { + // This calls _init() in the parent class (SuggestAutoComplete) via the + // prototype, below. + this._init(); +} +SearchSuggestAutoComplete.prototype = { + classID: Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"), + __proto__: SuggestAutoComplete.prototype, + serviceURL: "" +}; + +var component = [SearchSuggestAutoComplete]; +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);