Source code
Revision control
Copy as Markdown
Other Tools
/* 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
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
SkippableTimer: "resource:///modules/UrlbarUtils.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
});
const SEARCH_PARAMS = {
CLIENT_VARIANTS: "client_variants",
PROVIDERS: "providers",
QUERY: "q",
SEQUENCE_NUMBER: "seq",
SESSION_ID: "sid",
};
const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS";
const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE";
/**
* Client class for querying the Merino server. Each instance maintains its own
* session state including a session ID and sequence number that is included in
* its requests to Merino.
*/
export class MerinoClient {
/**
* @returns {object}
* The names of URL search params.
*/
static get SEARCH_PARAMS() {
return { ...SEARCH_PARAMS };
}
/**
* @param {string} name
* An optional name for the client. It will be included in log messages.
* @param {object} options
* Options object
* @param {string} options.cachePeriodMs
* Enables caching when nonzero. The client will cache the response
* suggestions from its most recent successful request for the specified
* period. The client will serve the cached suggestions for all fetches for
* the same URL until either the cache period elapses or a successful fetch
* for a different URL is made (ignoring session-related URL params like
* session ID and sequence number). Caching is per `MerinoClient` instance
* and is not shared across instances.
*
* WARNING: Cached suggestions are only ever evicted when new suggestions
* are cached. They are not evicted on a timer. If the client has cached
* some suggestions and no further fetches are made, they'll stay cached
* indefinitely. If your request URLs contain senstive data that should not
* stick around in the object graph indefinitely, you should either not use
* caching or you should implement an eviction mechanism.
*
* This cache strategy is intentionally simplistic and designed to be used
* by the urlbar with very short cache periods to make sure Firefox doesn't
* repeatedly call the same Merino URL on each keystroke in a urlbar
* session, which is wasteful and can cause a suggestion to flicker out of
* and into the urlbar panel as the user matches it again and again,
* especially when Merino latency is high. It is not designed to be a
* general caching mechanism. If you need more complex or long-lived
* caching, try working with the Merino team to add cache headers to the
* relevant responses so you can leverage Firefox's HTTP cache.
*/
constructor(name = "anonymous", { cachePeriodMs = 0 } = {}) {
this.#name = name;
this.#cachePeriodMs = cachePeriodMs;
ChromeUtils.defineLazyGetter(this, "logger", () =>
lazy.UrlbarUtils.getLogger({ prefix: `MerinoClient [${name}]` })
);
}
/**
* @returns {string}
* The name of the client.
*/
get name() {
return this.#name;
}
/**
* @returns {number}
* If `resetSession()` is not called within this timeout period after a
* session starts, the session will time out and the next fetch will begin a
* new session.
*/
get sessionTimeoutMs() {
return this.#sessionTimeoutMs;
}
set sessionTimeoutMs(value) {
this.#sessionTimeoutMs = value;
}
/**
* @returns {number}
* The current session ID. Null when there is no active session.
*/
get sessionID() {
return this.#sessionID;
}
/**
* @returns {number}
* The current sequence number in the current session. Zero when there is no
* active session.
*/
get sequenceNumber() {
return this.#sequenceNumber;
}
/**
* @returns {string}
* A string that indicates the status of the last fetch. The values are the
* same as the labels used in the `FX_URLBAR_MERINO_RESPONSE` histogram:
* success, timeout, network_error, http_error
*/
get lastFetchStatus() {
return this.#lastFetchStatus;
}
/**
* Fetches Merino suggestions.
*
* @param {object} options
* Options object
* @param {string} options.query
* The search string.
* @param {Array} options.providers
* Array of provider names to request from Merino. If this is given it will
* override the `merinoProviders` Nimbus variable and its fallback pref
* `browser.urlbar.merino.providers`.
* @param {number} options.timeoutMs
* Timeout in milliseconds. This method will return once the timeout
* elapses, a response is received, or an error occurs, whichever happens
* first.
* @param {string} options.extraLatencyHistogram
* If specified, the fetch's latency will be recorded in this histogram in
* addition to the usual Merino latency histogram.
* @param {string} options.extraResponseHistogram
* If specified, the fetch's response will be recorded in this histogram in
* addition to the usual Merino response histogram.
* @param {object} options.otherParams
* If specified, the otherParams will be added as a query params. Currently
* used for accuweather's location autocomplete endpoint
* @returns {Array}
* The Merino suggestions or null if there's an error or unexpected
* response.
*/
async fetch({
query,
providers = null,
timeoutMs = lazy.UrlbarPrefs.get("merinoTimeoutMs"),
extraLatencyHistogram = null,
extraResponseHistogram = null,
otherParams = {},
}) {
this.logger.debug("Fetch start", { query });
// Get the endpoint URL. It's empty by default when running tests so they
// don't hit the network.
let endpointString = lazy.UrlbarPrefs.get("merinoEndpointURL");
if (!endpointString) {
return [];
}
let url;
try {
url = new URL(endpointString);
} catch (error) {
this.logger.error("Error creating endpoint URL", error);
return [];
}
// Start setting search params. Leave session-related params for last.
url.searchParams.set(SEARCH_PARAMS.QUERY, query);
let clientVariants = lazy.UrlbarPrefs.get("merinoClientVariants");
if (clientVariants) {
url.searchParams.set(SEARCH_PARAMS.CLIENT_VARIANTS, clientVariants);
}
let providersString;
if (providers != null) {
if (!Array.isArray(providers)) {
throw new Error("providers must be an array if given");
}
providersString = providers.join(",");
} else {
let value = lazy.UrlbarPrefs.get("merinoProviders");
if (value) {
// The Nimbus variable/pref is used only if it's a non-empty string.
providersString = value;
}
}
// An empty providers string is a valid value and means Merino should
// receive the request but not return any suggestions, so do not do a simple
// `if (providersString)` here.
if (typeof providersString == "string") {
url.searchParams.set(SEARCH_PARAMS.PROVIDERS, providersString);
}
// if otherParams are present add them to the url
for (const [param, value] of Object.entries(otherParams)) {
url.searchParams.set(param, value);
}
// At this point, all search params should be set except for session-related
// params.
let details = { query, providers, timeoutMs, url: url.toString() };
this.logger.debug("Fetch details", details);
// If caching is enabled, generate the cache key for this request URL.
let cacheKey;
if (this.#cachePeriodMs && !MerinoClient._test_disableCache) {
url.searchParams.sort();
cacheKey = url.toString();
// If we have cached suggestions and they're still valid, return them.
if (
this.#cache.suggestions &&
Date.now() < this.#cache.dateMs + this.#cachePeriodMs &&
this.#cache.key == cacheKey
) {
this.logger.debug("Fetch served from cache");
return this.#cache.suggestions;
}
}
// At this point, we're calling Merino.
// Set up the Merino session ID and related state. The session ID is a UUID
// without leading and trailing braces.
if (!this.#sessionID) {
let uuid = Services.uuid.generateUUID().toString();
this.#sessionID = uuid.substring(1, uuid.length - 1);
this.#sequenceNumber = 0;
this.#sessionTimer?.cancel();
// Per spec, for the user's privacy, the session should time out and a new
// session ID should be used if the engagement does not end soon.
this.#sessionTimer = new lazy.SkippableTimer({
name: "Merino session timeout",
time: this.#sessionTimeoutMs,
logger: this.logger,
callback: () => this.resetSession(),
});
}
url.searchParams.set(SEARCH_PARAMS.SESSION_ID, this.#sessionID);
url.searchParams.set(SEARCH_PARAMS.SEQUENCE_NUMBER, this.#sequenceNumber);
this.#sequenceNumber++;
let recordResponse = category => {
this.logger.debug("Fetch done", { status: category });
Services.telemetry.getHistogramById(HISTOGRAM_RESPONSE).add(category);
if (extraResponseHistogram) {
Services.telemetry
.getHistogramById(extraResponseHistogram)
.add(category);
}
this.#lastFetchStatus = category;
recordResponse = null;
};
// Set up the timeout timer.
let timer = (this.#timeoutTimer = new lazy.SkippableTimer({
name: "Merino timeout",
time: timeoutMs,
logger: this.logger,
callback: () => {
// The fetch timed out.
this.logger.debug("Fetch timed out", { timeoutMs });
recordResponse?.("timeout");
},
}));
// If there's an ongoing fetch, abort it so there's only one at a time. By
// design we do not abort fetches on timeout or when the query is canceled
// so we can record their latency.
try {
this.#fetchController?.abort();
} catch (error) {
this.logger.error("Error aborting previous fetch", error);
}
// Do the fetch.
let response;
let controller = (this.#fetchController = new AbortController());
let stopwatchInstance = (this.#latencyStopwatchInstance = {});
TelemetryStopwatch.start(HISTOGRAM_LATENCY, stopwatchInstance);
if (extraLatencyHistogram) {
TelemetryStopwatch.start(extraLatencyHistogram, stopwatchInstance);
}
await Promise.race([
timer.promise,
(async () => {
try {
// Canceling the timer below resolves its promise, which can resolve
// the outer promise created by `Promise.race`. This inner async
// function happens not to await anything after canceling the timer,
// but if it did, `timer.promise` could win the race and resolve the
// outer promise without a value. For that reason, we declare
// `response` in the outer scope and set it here instead of returning
// the response from this inner function and assuming it will also be
// returned by `Promise.race`.
response = await fetch(url, { signal: controller.signal });
TelemetryStopwatch.finish(HISTOGRAM_LATENCY, stopwatchInstance);
if (extraLatencyHistogram) {
TelemetryStopwatch.finish(extraLatencyHistogram, stopwatchInstance);
}
this.logger.debug("Got response", {
status: response.status,
...details,
});
if (!response.ok) {
recordResponse?.("http_error");
}
} catch (error) {
TelemetryStopwatch.cancel(HISTOGRAM_LATENCY, stopwatchInstance);
if (extraLatencyHistogram) {
TelemetryStopwatch.cancel(extraLatencyHistogram, stopwatchInstance);
}
if (error.name != "AbortError") {
this.logger.error("Fetch error", error);
recordResponse?.("network_error");
}
} finally {
// Now that the fetch is done, cancel the timeout timer so it doesn't
// fire and record a timeout. If it already fired, which it would have
// on timeout, or was already canceled, this is a no-op.
timer.cancel();
if (controller == this.#fetchController) {
this.#fetchController = null;
}
this.#nextResponseDeferred?.resolve(response);
this.#nextResponseDeferred = null;
}
})(),
]);
if (timer == this.#timeoutTimer) {
this.#timeoutTimer = null;
}
// Get the response body as an object.
let body;
try {
body = await response?.json();
} catch (error) {
this.logger.error("Error getting response as JSON", error);
}
if (body) {
this.logger.debug("Response body", body);
}
if (!body?.suggestions?.length) {
recordResponse?.("no_suggestion");
return [];
}
let { suggestions, request_id } = body;
if (!Array.isArray(suggestions)) {
this.logger.error("Unexpected response", body);
recordResponse?.("no_suggestion");
return [];
}
recordResponse?.("success");
suggestions = suggestions.map(suggestion => ({
...suggestion,
request_id,
source: "merino",
}));
if (cacheKey) {
this.#cache = {
suggestions,
key: cacheKey,
dateMs: Date.now(),
};
}
return suggestions;
}
/**
* Resets the Merino session ID and related state.
*/
resetSession() {
this.#sessionID = null;
this.#sequenceNumber = 0;
this.#sessionTimer?.cancel();
this.#sessionTimer = null;
this.#nextSessionResetDeferred?.resolve();
this.#nextSessionResetDeferred = null;
}
/**
* Cancels the timeout timer.
*/
cancelTimeoutTimer() {
this.#timeoutTimer?.cancel();
}
/**
* Returns a promise that's resolved when the next response is received or a
* network error occurs.
*
* @returns {Promise}
* The promise is resolved with the `Response` object or undefined if a
* network error occurred.
*/
waitForNextResponse() {
if (!this.#nextResponseDeferred) {
this.#nextResponseDeferred = Promise.withResolvers();
}
return this.#nextResponseDeferred.promise;
}
/**
* Returns a promise that's resolved when the session is next reset, including
* on session timeout.
*
* @returns {Promise}
*/
waitForNextSessionReset() {
if (!this.#nextSessionResetDeferred) {
this.#nextSessionResetDeferred = Promise.withResolvers();
}
return this.#nextSessionResetDeferred.promise;
}
static _test_disableCache = false;
get _test_sessionTimer() {
return this.#sessionTimer;
}
get _test_timeoutTimer() {
return this.#timeoutTimer;
}
get _test_fetchController() {
return this.#fetchController;
}
get _test_latencyStopwatchInstance() {
return this.#latencyStopwatchInstance;
}
// State related to the current session.
#sessionID = null;
#sequenceNumber = 0;
#sessionTimer = null;
#sessionTimeoutMs = SESSION_TIMEOUT_MS;
#name;
#timeoutTimer = null;
#fetchController = null;
#latencyStopwatchInstance = null;
#lastFetchStatus = null;
#nextResponseDeferred = null;
#nextSessionResetDeferred = null;
#cachePeriodMs = 0;
// When caching is enabled, we cache response suggestions from the most recent
// successful request.
#cache = {
// The cached suggestions array.
suggestions: null,
// The cache key: the stringified request URL without session-related params
// (session ID and sequence number).
key: null,
// The date the suggestions were cached as returned by `Date.now()`.
dateMs: 0,
};
}