diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/private/SuggestBackendJs.sys.mjs | 443 |
1 files changed, 443 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/SuggestBackendJs.sys.mjs b/browser/components/urlbar/private/SuggestBackendJs.sys.mjs new file mode 100644 index 0000000000..4a91e41b59 --- /dev/null +++ b/browser/components/urlbar/private/SuggestBackendJs.sys.mjs @@ -0,0 +1,443 @@ +/* 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/. */ + +import { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const RS_COLLECTION = "quicksuggest"; + +// Entries are added to `SuggestionsMap` map in chunks, and each chunk will add +// at most this many entries. +const SUGGESTIONS_MAP_CHUNK_SIZE = 1000; + +const TELEMETRY_LATENCY = "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS"; + +// See `SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD`. When a full +// keyword starts with one of the prefixes in this list, the user must type the +// entire prefix to start triggering matches based on that full keyword, instead +// of only the first word. +const KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS = ["how to"]; + +/** + * The Suggest JS backend. Not used when the Rust backend is enabled. + */ +export class SuggestBackendJs extends BaseFeature { + constructor(...args) { + super(...args); + this.#emitter = new lazy.EventEmitter(); + } + + get shouldEnable() { + return !lazy.UrlbarPrefs.get("quickSuggestRustEnabled"); + } + + /** + * @returns {RemoteSettings} + * The underlying `RemoteSettings` client object. + */ + get rs() { + return this.#rs; + } + + /** + * @returns {EventEmitter} + * The client will emit events on this object. + */ + get emitter() { + return this.#emitter; + } + + /** + * @returns {object} + * Global quick suggest configuration stored in remote settings. When the + * config changes the `emitter` property will emit a "config-set" event. The + * config is an object that looks like this: + * + * { + * impression_caps: { + * nonsponsored: { + * lifetime, + * custom: [ + * { interval_s, max_count }, + * ], + * }, + * sponsored: { + * lifetime, + * custom: [ + * { interval_s, max_count }, + * ], + * }, + * }, + * show_less_frequently_cap, + * } + */ + get config() { + return this.#config; + } + + /** + * @returns {Array} + * Array of `BasicFeature` instances. + */ + get features() { + return [...this.#features]; + } + + enable(enabled) { + if (!enabled) { + this.#enableSettings(false); + } else if (this.#features.size) { + this.#enableSettings(true); + this.#syncAll(); + } + } + + /** + * Registers a quick suggest feature that uses remote settings. + * + * @param {BaseFeature} feature + * An instance of a `BaseFeature` subclass. See `BaseFeature` for methods + * that the subclass must implement. + */ + register(feature) { + this.logger.debug("Registering feature: " + feature.name); + this.#features.add(feature); + if (this.isEnabled) { + if (this.#features.size == 1) { + this.#enableSettings(true); + } + this.#syncFeature(feature); + } + } + + /** + * Unregisters a quick suggest feature that uses remote settings. + * + * @param {BaseFeature} feature + * An instance of a `BaseFeature` subclass. + */ + unregister(feature) { + this.logger.debug("Unregistering feature: " + feature.name); + this.#features.delete(feature); + if (!this.#features.size) { + this.#enableSettings(false); + } + } + + /** + * Queries remote settings suggestions from all registered features. + * + * @param {string} searchString + * The search string. + * @returns {Array} + * The remote settings suggestions. If there are no matches, an empty array + * is returned. + */ + async query(searchString) { + let suggestions; + let stopwatchInstance = {}; + TelemetryStopwatch.start(TELEMETRY_LATENCY, stopwatchInstance); + try { + suggestions = await this.#queryHelper(searchString); + TelemetryStopwatch.finish(TELEMETRY_LATENCY, stopwatchInstance); + } catch (error) { + TelemetryStopwatch.cancel(TELEMETRY_LATENCY, stopwatchInstance); + this.logger.error("Query error: " + error); + } + + return suggestions || []; + } + + async #queryHelper(searchString) { + this.logger.info("Handling query: " + JSON.stringify(searchString)); + + let results = await Promise.all( + [...this.#features].map(async feature => { + let suggestions = await feature.queryRemoteSettings(searchString); + return [feature, suggestions ?? []]; + }) + ); + + let allSuggestions = []; + for (let [feature, suggestions] of results) { + for (let suggestion of suggestions) { + // Features typically return suggestion objects straight from their + // suggestion maps. We don't want consumers to modify those objects + // since they are the source of truth (tests especially tend to do + // this), so return copies to consumers. + allSuggestions.push({ + ...suggestion, + source: "remote-settings", + provider: feature.name, + }); + } + } + + return allSuggestions; + } + + async #enableSettings(enabled) { + if (enabled && !this.#rs) { + this.logger.debug("Creating RemoteSettings client"); + this.#onSettingsSync = event => this.#syncAll({ event }); + this.#rs = lazy.RemoteSettings(RS_COLLECTION); + this.#rs.on("sync", this.#onSettingsSync); + await this.#syncConfig(); + } else if (!enabled && this.#rs) { + this.logger.debug("Destroying RemoteSettings client"); + this.#rs.off("sync", this.#onSettingsSync); + this.#rs = null; + this.#onSettingsSync = null; + } + } + + async #syncConfig() { + this.logger.debug("Syncing config"); + let rs = this.#rs; + + let configArray = await rs.get({ filters: { type: "configuration" } }); + if (rs != this.#rs) { + return; + } + + this.logger.debug("Got config array: " + JSON.stringify(configArray)); + this.#setConfig(configArray?.[0]?.configuration || {}); + } + + async #syncFeature(feature) { + this.logger.debug("Syncing feature: " + feature.name); + await feature.onRemoteSettingsSync(this.#rs); + } + + async #syncAll({ event = null } = {}) { + this.logger.debug("Syncing all"); + let rs = this.#rs; + + // Remove local files of deleted records + if (event?.data?.deleted) { + await Promise.all( + event.data.deleted + .filter(d => d.attachment) + .map(entry => + Promise.all([ + this.#rs.attachments.deleteDownloaded(entry), // type: data + this.#rs.attachments.deleteFromDisk(entry), // type: icon + ]) + ) + ); + if (rs != this.#rs) { + return; + } + } + + let promises = [this.#syncConfig()]; + for (let feature of this.#features) { + promises.push(this.#syncFeature(feature)); + } + await Promise.all(promises); + } + + /** + * Sets the quick suggest config and emits a "config-set" event. + * + * @param {object} config + * The config object. + */ + #setConfig(config) { + config = lazy.UrlbarUtils.copySnakeKeysToCamel(config ?? {}); + this.logger.debug("Setting config: " + JSON.stringify(config)); + this.#config = config; + this.#emitter.emit("config-set"); + } + + async _test_syncAll() { + if (this.#rs) { + // `RemoteSettingsClient` won't start another import if it's already + // importing. Wait for it to finish before starting the new one. + await this.#rs._importingPromise; + await this.#syncAll(); + } + } + + // The `RemoteSettings` client. + #rs = null; + + // Registered `BaseFeature` instances. + #features = new Set(); + + // Configuration data synced from remote settings. See the `config` getter. + #config = {}; + + #emitter = null; + #logger = null; + #onSettingsSync = null; +} + +/** + * A wrapper around `Map` that handles quick suggest suggestions from remote + * settings. It maps keywords to suggestions. It has two benefits over `Map`: + * + * - The main benefit is that map entries are added in batches on idle to avoid + * blocking the main thread for too long, since there can be many suggestions + * and keywords. + * - A secondary benefit is that the interface is tailored to quick suggest + * suggestions, which have a `keywords` property. + */ +export class SuggestionsMap { + /** + * Returns the list of suggestions for a keyword. + * + * @param {string} keyword + * The keyword. + * @returns {Array} + * The array of suggestions for the keyword. If the keyword isn't in the + * map, the array will be empty. + */ + get(keyword) { + let object = this.#suggestionsByKeyword.get(keyword.toLocaleLowerCase()); + if (!object) { + return []; + } + return Array.isArray(object) ? object : [object]; + } + + /** + * Adds a list of suggestion objects to the results map. Each suggestion must + * have a property whose value is an array of keyword strings. The + * suggestion's keywords will be taken from this array either exactly as they + * are specified or by generating new keywords from them; see `mapKeyword`. + * + * @param {Array} suggestions + * Array of suggestion objects. + * @param {object} options + * Options object. + * @param {string} options.keywordsProperty + * The name of the keywords property in each suggestion. + * @param {Function} options.mapKeyword + * If null, the keywords for each suggestion will be taken from the keywords + * array exactly as they are specified. Otherwise, this function will be + * called for each string in the array, and it should return an array of + * strings. The suggestion's final list of keywords will be the union of all + * strings returned by this function. See also the `MAP_KEYWORD_*` consts. + */ + async add( + suggestions, + { keywordsProperty = "keywords", mapKeyword = null } = {} + ) { + // There can be many suggestions, and each suggestion can have many + // keywords. To avoid blocking the main thread for too long, update the map + // in chunks, and to avoid blocking the UI and other higher priority work, + // do each chunk only when the main thread is idle. During each chunk, we'll + // add at most `chunkSize` entries to the map. + let suggestionIndex = 0; + let keywordIndex = 0; + + // Keep adding chunks until all suggestions have been fully added. + while (suggestionIndex < suggestions.length) { + await new Promise(resolve => { + Services.tm.idleDispatchToMainThread(() => { + // Keep updating the map until the current chunk is done. + let indexInChunk = 0; + while ( + indexInChunk < SuggestionsMap.chunkSize && + suggestionIndex < suggestions.length + ) { + let suggestion = suggestions[suggestionIndex]; + let keywords = suggestion[keywordsProperty]; + if (keywordIndex == keywords.length) { + // We've added entries for all keywords of the current suggestion. + // Move on to the next suggestion. + suggestionIndex++; + keywordIndex = 0; + continue; + } + + // As a convenience, allow `mapKeyword` to return a string even + // though the JSDoc says an array must be returned. + let originalKeyword = keywords[keywordIndex]; + let mappedKeywords = + mapKeyword?.(originalKeyword) ?? originalKeyword; + if (typeof mappedKeywords == "string") { + mappedKeywords = [mappedKeywords]; + } + + for (let keyword of mappedKeywords) { + // If the keyword's only suggestion is `suggestion`, store it + // directly as the value. Otherwise store an array of unique + // suggestions. See the `#suggestionsByKeyword` comment. + let object = this.#suggestionsByKeyword.get(keyword); + if (!object) { + this.#suggestionsByKeyword.set(keyword, suggestion); + } else { + let isArray = Array.isArray(object); + if (!isArray && object != suggestion) { + this.#suggestionsByKeyword.set(keyword, [object, suggestion]); + } else if (isArray && !object.includes(suggestion)) { + object.push(suggestion); + } + } + } + + keywordIndex++; + indexInChunk++; + } + + // The current chunk is done. + resolve(); + }); + }); + } + } + + clear() { + this.#suggestionsByKeyword.clear(); + } + + /** + * @returns {Function} + * A `mapKeyword` function that maps a keyword to an array containing the + * keyword's first word plus every subsequent prefix of the keyword. The + * strings in `KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS` will modify this + * behavior: When a full keyword starts with one of the prefixes in that + * list, the generated prefixes will start at that prefix instead of the + * first word. + */ + static get MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD() { + return fullKeyword => { + let prefix = KEYWORD_PREFIXES_TO_TREAT_AS_SINGLE_WORDS.find(p => + fullKeyword.startsWith(p + " ") + ); + let spaceIndex = prefix ? prefix.length : fullKeyword.indexOf(" "); + + let keywords = [fullKeyword]; + if (spaceIndex >= 0) { + for (let i = spaceIndex; i < fullKeyword.length; i++) { + keywords.push(fullKeyword.substring(0, i)); + } + } + return keywords; + }; + } + + // Maps each keyword in the dataset to one or more suggestions for the + // keyword. If only one suggestion uses a keyword, the keyword's value in the + // map will be the suggestion object. If more than one suggestion uses the + // keyword, the value will be an array of the suggestions. The reason for not + // always using an array is that we expect the vast majority of keywords to be + // used by only one suggestion, and since there are potentially very many + // keywords and suggestions and we keep them in memory all the time, we want + // to save as much memory as possible. + #suggestionsByKeyword = new Map(); + + // This is only defined as a property so that tests can override it. + static chunkSize = SUGGESTIONS_MAP_CHUNK_SIZE; +} |