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