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
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
// Ideally, we'd have this be a separate JSON file that can be loaded at runtime
// using import BUNDLED_MANIFEST from "url" with { type: "json" }.
// Unfortunately, this is not yet available to system modules - see bug 1983997.
//
// Do not read this BUNDLED_MANIFEST.version constant directly. Use the static
// getter on RemoteRenderer instead, so that the bundled version number can be
// stubbed out in tests.
const BUNDLED_MANIFEST = Object.freeze({
version: "0.0.0-alpha",
jsHash: "tQLriCnN",
cssHash: "BVSpgoIh",
});
const BUNDLED_SCRIPT_URI = `resource://newtab/data/content/bundled-renderer-index.js`;
const BUNDLED_STYLE_URI = `resource://newtab/data/content/bundled-renderer.css`;
const PREF_REMOTE_RENDERER_VERSION =
"browser.newtabpage.activity-stream.remote-renderer.version";
const COLLECTION_NAME = "newtab-renderer";
const QUIT_TOPIC = "quit-application-granted";
const lazy = XPCOMUtils.declareLazy({
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
cacheStorage: () => {
return Services.cache2.diskCacheStorage(Services.loadContextInfo.default);
},
remoteRendererVersion: {
pref: PREF_REMOTE_RENDERER_VERSION,
default: "",
},
});
ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
return console.createInstance({
prefix: "RemoteRenderer",
maxLogLevel: Services.prefs.getBoolPref(
"browser.newtabpage.activity-stream.remote-renderer.log",
false
)
? "Debug"
: "Warn",
});
});
/**
* RemoteRenderer manages the retrieval and caching of newtab renderer bundles
* from Remote Settings. It handles versioning, cache validation, and fallback
* to bundled resources when cached content is unavailable or invalid.
*/
export class RemoteRenderer {
/**
* Remote Settings client for fetching renderer bundles.
*
* @type {RemoteSettingsClient}
*/
#rsClient = null;
/**
* nsIURIs mapping to nsICacheEntry's that are unlikely to be used anymore
* and can be doomed on shutdown.
*
* @type {nsIURI[]}
*/
#cacheEntryURIsToDoomAtShutdown = [];
/**
* A DeferredTask to debounce revalidating against RemoteSettings, to see
* if there is a new version to cache and serve.
*
* @type {DeferredTask|null}
*/
#revalidateDebouncer = null;
/**
* The current bundled version of the renderer. Exposed as a static getter
* for easier stubbing out in tests.
*
* @type {string}
*/
static get BUNDLED_VERSION() {
return BUNDLED_MANIFEST.version;
}
/**
* We debounce renderer requests by this amount before we attempt a refresh
* from RemoteSettings. Exposed as a static getter for easier stubbing out
* in tests.
*/
static get REVALIDATION_DEBOUNCE_RATE_MS() {
return 500; // ms
}
constructor() {
lazy.logConsole.log("RemoteRenderer constructed.");
this.#rsClient = lazy.RemoteSettings(COLLECTION_NAME);
this.#revalidateDebouncer = new lazy.DeferredTask(async () => {
await this.maybeRevalidate();
}, RemoteRenderer.REVALIDATION_DEBOUNCE_RATE_MS);
Services.obs.addObserver(this, QUIT_TOPIC, true);
}
QueryInterface = ChromeUtils.generateQI([
Ci.nsIObserver,
Ci.nsISupportsWeakReference,
]);
observe(_subject, topic, _data) {
if (topic === QUIT_TOPIC) {
this.onShutdown();
Services.obs.removeObserver(this, QUIT_TOPIC);
}
}
onShutdown() {
for (let uri of this.#cacheEntryURIsToDoomAtShutdown) {
lazy.cacheStorage.asyncDoomURI(uri, "", null);
}
this.#cacheEntryURIsToDoomAtShutdown = [];
}
/**
* Returns true if the cache entry associated with the passed in URI is
* scheduled to be doomed on shutdown.
*
* @param {nsIURI} uri
* @returns {boolean}
*/
willDoomOnShutdown(uri) {
return this.#cacheEntryURIsToDoomAtShutdown.find(doomedURI =>
doomedURI.equals(uri)
);
}
/**
* Queues a nsICacheEntry mapping to the nsIURI to be doomed on shutdown.
*
* @param {nsIURI} uri - Cache key nsIURI to doom on shutdown.
*/
doomCacheEntryOnShutdown(uri) {
this.#cacheEntryURIsToDoomAtShutdown.push(uri);
}
/**
* Returns a renderer configuration based on cached or bundled resources.
* Checks for a valid cached renderer matching the version stored in prefs.
* If all cache entries exist (manifest, script, style), returns the cached
* renderer configuration.
*
* Otherwise, clears cache prefs and returns bundled renderer configuration.
*
* @returns {Promise<object>} Renderer configuration with appProps containing
* manifest, renderUpdate flag, isCached flag, isStaleData flag, and initialState.
*/
async assign() {
this.#revalidateDebouncer.disarm();
this.#revalidateDebouncer.arm();
if (lazy.remoteRendererVersion) {
const version = lazy.remoteRendererVersion;
lazy.logConsole.debug(
`Evaluating remote renderer version ${version} (bundled version: ${RemoteRenderer.BUNDLED_VERSION})`
);
// It's possible that the bundled version is greater than what's already
// in the cache, in which case, we don't want to do any of these things,
// and fall through to using the bundled version.
if (Services.vc.compare(RemoteRenderer.BUNDLED_VERSION, version) < 0) {
lazy.logConsole.debug(
`Remote renderer version ${version} is higher. Attempting to use it.`
);
const manifestCacheURI = this.makeManifestEntryURI(version);
let manifestExists = false;
const scriptCacheURI = this.makeScriptEntryURI(version);
let scriptExists = false;
const styleCacheURI = this.makeStyleEntryURI(version);
let styleExists = false;
try {
manifestExists = lazy.cacheStorage.exists(manifestCacheURI, "");
scriptExists = lazy.cacheStorage.exists(scriptCacheURI, "");
styleExists = lazy.cacheStorage.exists(styleCacheURI, "");
} catch (e) {
lazy.logConsole.warn(
"Checking existence of cached resources failed, possibly because the " +
"cache index was being written."
);
}
if (manifestExists && scriptExists && styleExists) {
lazy.logConsole.debug(`Evaluation passed. Using version ${version}`);
const manifestStream =
await this.getCachedEntryStream(manifestCacheURI);
if (manifestStream) {
const manifestString =
await this.pumpInputStreamToString(manifestStream);
const manifest = JSON.parse(manifestString);
return {
appProps: {
manifest,
renderUpdate: true,
isCached: true,
isStaleData: false,
initialState: {
start: {
location: "Munchkin land",
},
path: {
color: "yellow",
material: "brick",
destination: "Oz",
},
toDo: [
{
isComplete: false,
task: "see the wizard",
},
{
isComplete: false,
task: "find a way home",
},
],
},
},
};
}
// Otherwise, getting the manifest stream failed.
lazy.logConsole.warn(
"Manifest stream could not be fetched. Falling back to bundled renderer."
);
}
}
}
// If we got here, we're using the bundled version. If we happen to have a
// cached renderer, blow it away.
this.resetCache();
lazy.logConsole.debug(
`Using bundled version ${RemoteRenderer.BUNDLED_VERSION}`
);
return {
appProps: {
manifest: {
version: "0.0.1",
buildTime: "2026-02-02T20:41:31.966Z",
file: "index.tQLriCnN.js",
hash: "tQLriCnN",
dataSchemaVersion: "1.2.1",
cssFile: "",
},
renderUpdate: true,
isCached: false,
isStaleData: false,
initialState: {
start: {
location: "Munchkinland",
},
path: {
color: "yellow",
material: "brick",
destination: "Oz",
},
toDo: [
{
isComplete: false,
task: "see the wizard",
},
{
isComplete: false,
task: "find a way home",
},
],
},
},
};
}
/**
* Constructs an nsIURI for a specific resource type and version.
*
* @param {string} type
* Resource type ("manifest", "script", or "style")
* @param {string} version
* Version identifier
* @returns {nsIURI}
* The constructed nsIURI
*/
#makeEntryURI(type, version) {
return Services.io.newURI(
`moz-newtab-remote-renderer://${type}/?version=${version}`
);
}
/**
* Creates an nsIURI for the manifest cache entry.
*
* @param {string} version - Version identifier
* @returns {nsIURI}
* The constructed nsIURI for the manifest.
*/
makeManifestEntryURI(version) {
return this.#makeEntryURI("manifest", version);
}
/**
* Creates an nsIURI for the script cache entry.
*
* @param {string} version
* Version identifier
* @returns {nsIURI}
* The constructed nsIURI for the renderer script.
*/
makeScriptEntryURI(version) {
return this.#makeEntryURI("script", version);
}
/**
* Creates an nsIURI for the style cache entry.
*
* @param {string} version
* Version identifier
* @returns {nsIURI}
* The constructed nsIURI for the renderer styles
*/
makeStyleEntryURI(version) {
return this.#makeEntryURI("style", version);
}
/**
* Clears cache preferences and schedules current cached renderer entries
* to be doomed on shutdown.
*/
resetCache() {
const { remoteRendererVersion } = lazy;
Services.prefs.clearUserPref(PREF_REMOTE_RENDERER_VERSION);
if (remoteRendererVersion) {
const manifestCacheURI = this.makeManifestEntryURI(remoteRendererVersion);
const scriptCacheURI = this.makeScriptEntryURI(remoteRendererVersion);
const styleCacheURI = this.makeStyleEntryURI(remoteRendererVersion);
this.doomCacheEntryOnShutdown(manifestCacheURI);
this.doomCacheEntryOnShutdown(scriptCacheURI);
this.doomCacheEntryOnShutdown(styleCacheURI);
}
}
/**
* Writes content to a single cache entry from an ArrayBuffer.
*
* @param {nsIURI} uri - Cache key URI
* @param {ArrayBuffer} arrayBuffer - Content to write
* @param {string} version - Version identifier
* @returns {Promise<undefined>}
*/
async writeCacheEntry(uri, arrayBuffer, version) {
await new Promise((resolve, reject) => {
lazy.cacheStorage.asyncOpenURI(
uri,
"",
Ci.nsICacheStorage.OPEN_TRUNCATE,
{
onCacheEntryCheck() {
return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED;
},
async onCacheEntryAvailable(entry, isNew, status) {
if (!Components.isSuccessCode(status)) {
reject(new Error("Failed to open cache entry for writing"));
return;
}
try {
let inputStream = Cc[
"@mozilla.org/io/arraybuffer-input-stream;1"
].createInstance(Ci.nsIArrayBufferInputStream);
inputStream.setData(arrayBuffer, 0, arrayBuffer.byteLength);
let outputStream = entry.openOutputStream(0, -1);
await new Promise((resolveWrite, rejectWrite) => {
lazy.NetUtil.asyncCopy(inputStream, outputStream, result => {
if (Components.isSuccessCode(result)) {
resolveWrite();
} else {
rejectWrite(
new Error(`Failed to write to cache: ${result}`)
);
}
});
});
outputStream.close();
entry.setMetaDataElement("version", version);
entry.setMetaDataElement("timestamp", Date.now().toString());
resolve();
} catch (e) {
reject(e);
}
},
}
);
});
}
/**
* Writes JS and CSS content to the HTTP cache atomically.
* Version metadata acts as atomic commit flag - both entries written with same version.
*
* @param {object} content
* @param {ArrayBuffer} content.js - JavaScript bundle
* @param {ArrayBuffer} content.css - CSS stylesheet
* @param {string} content.version - Version identifier
* @returns {Promise<undefined>}
*/
async updateFromRemoteSettings({ manifest, js, css, version }) {
await Promise.all([
this.writeCacheEntry(
this.makeManifestEntryURI(version),
manifest,
version
),
this.writeCacheEntry(this.makeScriptEntryURI(version), js, version),
this.writeCacheEntry(this.makeStyleEntryURI(version), css, version),
]);
Services.prefs.setCharPref(PREF_REMOTE_RENDERER_VERSION, version);
}
/**
* Retrieves the script resource for a given renderer configuration.
* Attempts to load from cache based on the renderer's manifest version.
* Falls back to bundled script if cache entry doesn't exist.
*
* @param {object} renderer - Renderer configuration with appProps.manifest.version
* @returns {Promise<object>} Object with inputStream, contentType, and success flag
*/
async getScriptResource(renderer) {
const { version } = renderer.appProps.manifest;
const scriptCacheURI = this.makeScriptEntryURI(version);
let entryExists = false;
try {
entryExists = lazy.cacheStorage.exists(scriptCacheURI, "");
} catch (e) {
lazy.logConsole.warn(
"Checking that the script resource exists failed, " +
"probably because the cache index was being written."
);
}
if (!entryExists) {
lazy.logConsole.debug(
"Falling back to bundled script stream because entry does not exist."
);
return this.#fallbackToBundledScriptStream();
}
const scriptStream = await this.getCachedEntryStream(scriptCacheURI);
if (!scriptStream) {
lazy.logConsole.debug(
"Falling back to bundled script stream because cached entry could " +
"not be retrieved."
);
return this.#fallbackToBundledScriptStream();
}
return {
inputStream: scriptStream.QueryInterface(Ci.nsIInputStream),
contentType: "application/javascript",
success: true,
};
}
/**
* Clears the cache and loads the bundled script stream. This gets called if
* something goes wrong attemptingn to get a cached script stream.
*
* @returns {Promise<object>} Object with inputStream, contentType, and success flag
*/
async #fallbackToBundledScriptStream() {
// Make sure the renderer cache is clear
this.resetCache();
try {
// Then serve up the bundled renderer instead.
const inputStream = Cc[
"@mozilla.org/io/arraybuffer-input-stream;1"
].createInstance(Ci.nsIArrayBufferInputStream);
const response = await fetch(BUNDLED_SCRIPT_URI);
const buffer = await response.arrayBuffer();
inputStream.setData(buffer, 0, buffer.byteLength);
return {
inputStream,
contentType: "application/javascript",
success: true,
};
} catch (e) {
// We did our best, but for some reason couldn't get the fallback bundled
// script. Tell the content process to cancel the channel load.
lazy.logConsole.error("Failed to fallback to bundled script stream", e);
return {
success: false,
};
}
}
/**
* Retrieves the style resource for a given renderer configuration.
* Attempts to load from cache based on the renderer's manifest version.
* Falls back to bundled stylesheet if cache entry doesn't exist.
*
* @param {object} renderer - Renderer configuration with appProps.manifest.version
* @returns {Promise<object>} Object with inputStream, contentType, and success flag
*/
async getStyleResource(renderer) {
const { version } = renderer.appProps.manifest;
const styleCacheURI = this.makeStyleEntryURI(version);
let entryExists = false;
try {
entryExists = lazy.cacheStorage.exists(styleCacheURI, "");
} catch (e) {
lazy.logConsole.warn(
"Checking that the style resource exists failed, " +
"probably because the cache index was being written."
);
}
if (!entryExists) {
lazy.logConsole.debug(
"Falling back to bundled style stream because entry does not exist."
);
return this.#fallbackToBundledStyleStream();
}
let styleStream = await this.getCachedEntryStream(styleCacheURI);
if (!styleStream) {
lazy.logConsole.debug(
"Falling back to bundled style stream because cached entry could " +
"not be retrieved."
);
return this.#fallbackToBundledStyleStream();
}
return {
inputStream: styleStream.QueryInterface(Ci.nsIInputStream),
contentType: "text/css",
success: true,
};
}
/**
* Clears the cache and loads the bundled style stream. This gets called if
* something goes wrong attemptingn to get a cached style stream.
*
* @returns {Promise<object>} Object with inputStream, contentType, and success flag
*/
async #fallbackToBundledStyleStream() {
// Make sure the renderer cache is clear
this.resetCache();
try {
// Then serve up the bundled renderer instead.
const inputStream = Cc[
"@mozilla.org/io/arraybuffer-input-stream;1"
].createInstance(Ci.nsIArrayBufferInputStream);
const response = await fetch(BUNDLED_STYLE_URI);
const buffer = await response.arrayBuffer();
inputStream.setData(buffer, 0, buffer.byteLength);
return {
inputStream,
contentType: "text/css",
success: true,
};
} catch (e) {
lazy.logConsole.error("Failed to fallback to bundled style stream", e);
// We did our best, but for some reason couldn't get the fallback bundled
// styles. Tell the content process to cancel the channel load.
return {
success: false,
};
}
}
/**
* Checks Remote Settings for updates and fetches new content if available.
* Called in the background when serving stale content.
*
* @returns {Promise<undefined>}
*/
async maybeRevalidate() {
const currentVersion =
lazy.remoteRendererVersion || RemoteRenderer.BUNDLED_VERSION;
let latestVersion = await this.getExpectedVersionFromRemoteSettings();
if (Services.vc.compare(currentVersion, latestVersion) < 0) {
let newContent = await this.fetchLatestContent();
if (
newContent &&
newContent.manifest &&
newContent.js &&
newContent.css
) {
await this.updateFromRemoteSettings(newContent);
}
}
}
/**
* Gets the expected version from Remote Settings by checking the version
* field in the records.
*
* @returns {Promise<string|null>}
*/
async getExpectedVersionFromRemoteSettings() {
try {
let records = await this.#rsClient.get();
if (!records || records.length === 0) {
return null;
}
// In the current model, we'll only have a single renderer published to
// RemoteSettings at any given time, and it is expected that both the
// script and style resources will have the same version string, so we
// just return the first record's version.
let version = records[0]?.version;
return version || null;
} catch (e) {
console.error("Failed to get version from Remote Settings:", e);
return null;
}
}
/**
* Fetches the latest JS and CSS bundle from Remote Settings.
* Downloads attachments and returns them as ArrayBuffers.
*
* @returns {Promise<{js: ArrayBuffer, css: ArrayBuffer, version: string}|null>}
*/
async fetchLatestContent() {
try {
const records = await this.#rsClient.get();
if (!records || records.length === 0) {
return null;
}
const jsRecord = records.find(r => r.type === "js");
const cssRecord = records.find(r => r.type === "css");
if (!jsRecord || !cssRecord) {
console.error("Missing JS or CSS record in Remote Settings");
return null;
}
const { version, buildTime, dataSchemaVersion, hash } = jsRecord;
// Constructing what the renderer expects for a manifest. This may
// change over time, but for now, this is what it wants.
const manifestString = JSON.stringify({
version,
buildTime,
hash,
dataSchemaVersion,
file: jsRecord.attachment.filename,
cssFile: cssRecord.attachment.filename,
});
const manifest = new TextEncoder().encode(manifestString);
let [jsAttachment, cssAttachment] = await Promise.all([
this.#rsClient.attachments.download(jsRecord),
this.#rsClient.attachments.download(cssRecord),
]);
return {
js: jsAttachment.buffer,
css: cssAttachment.buffer,
version,
manifest: manifest.buffer,
};
} catch (e) {
console.error("Failed to fetch content from Remote Settings:", e);
return null;
}
}
/**
* @param {nsIURI} resourceUri - Cache key URI
* @returns {Promise<nsIInputStream|null>}
*/
async getCachedEntryStream(resourceUri) {
const cacheEntry = await this.openCacheEntry(resourceUri);
if (!cacheEntry) {
return null;
}
return cacheEntry.openInputStream(0);
}
/**
* Opens a cache entry for reading.
*
* @param {nsIURI} uri - Cache key URI
* @returns {Promise<nsICacheEntry|null>}
*/
async openCacheEntry(resourceURI) {
return new Promise(resolve => {
lazy.cacheStorage.asyncOpenURI(
resourceURI,
"",
Ci.nsICacheStorage.OPEN_READONLY,
{
onCacheEntryCheck() {
return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED;
},
onCacheEntryAvailable(entry, isNew, status) {
if (isNew || !Components.isSuccessCode(status)) {
resolve(null);
} else {
resolve(entry);
}
},
}
);
});
}
/**
* Reads an input stream completely and returns its contents as a UTF-8 string.
* Uses NetUtil.asyncFetch to consume the stream asynchronously.
*
* @param {nsIInputStream} inputStream - Stream to read
* @returns {Promise<string>} Stream contents as UTF-8 string
*/
pumpInputStreamToString(inputStream) {
return new Promise((resolve, reject) => {
lazy.NetUtil.asyncFetch(inputStream, (stream, status) => {
if (!Components.isSuccessCode(status)) {
reject(new Error(`Failed to read cache entry: ${status}`));
return;
}
try {
let data = lazy.NetUtil.readInputStreamToString(
stream,
stream.available(),
{ charset: "UTF-8" }
);
resolve(data);
} catch (e) {
reject(e);
}
});
});
}
}