Bug 1406181 - Add ExtensionStorageIDB JSM module. draft
authorLuca Greco <lgreco@mozilla.com>
Tue, 17 Oct 2017 04:12:15 +0200
changeset 802304 4605e69d072f83bfcc5d5dc6cbee5cd5b89c1b32
parent 802303 5f107e6633a37aa51925d0e251ca5a7056801978
child 802305 fbc9126012d8290eee686a72afc9b5796d2e9e46
push id111857
push userluca.greco@alcacoop.it
push dateThu, 31 May 2018 17:21:49 +0000
bugs1406181
milestone62.0a1
Bug 1406181 - Add ExtensionStorageIDB JSM module. This patch defined a new ExtensionStorageIDB module, which provides the same "internal" API currently provided by ExtensionStorage and uses IndexedDB as its backend (instead of the JSONFile used as the backend provided by ExtensionStorage). MozReview-Commit-ID: DsvPudExcyr
modules/libpref/init/all.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ExtensionChild.jsm
toolkit/components/extensions/ExtensionStorage.jsm
toolkit/components/extensions/ExtensionStorageIDB.jsm
toolkit/components/extensions/child/ext-storage.js
toolkit/components/extensions/moz.build
toolkit/components/extensions/parent/ext-storage.js
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -4920,16 +4920,19 @@ pref("extensions.webextensions.remote", 
 // unless other process sandboxing and extension remoting prefs are changed.
 pref("extensions.webextensions.protocol.remote", true);
 
 // Enable tab hiding API by default.
 pref("extensions.webextensions.tabhide.enabled", true);
 
 pref("extensions.webextensions.background-delayed-startup", false);
 
+// Whether or not the installed extensions should be migrated to the storage.local IndexedDB backend.
+pref("extensions.webextensions.ExtensionStorageIDB.enabled", false);
+
 // Report Site Issue button
 pref("extensions.webcompat-reporter.newIssueEndpoint", "https://webcompat.com/issues/new");
 #if defined(MOZ_DEV_EDITION) || defined(NIGHTLY_BUILD)
 pref("extensions.webcompat-reporter.enabled", true);
 #else
 pref("extensions.webcompat-reporter.enabled", false);
 #endif
 
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -36,16 +36,17 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
   AddonSettings: "resource://gre/modules/addons/AddonSettings.jsm",
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
+  ContextualIdentityService: "resource://gre/modules/ContextualIdentityService.jsm",
   ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm",
   ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm",
   ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
   ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.jsm",
   FileSource: "resource://gre/modules/L10nRegistry.jsm",
   L10nRegistry: "resource://gre/modules/L10nRegistry.jsm",
   Log: "resource://gre/modules/Log.jsm",
   MessageChannel: "resource://gre/modules/MessageChannel.jsm",
@@ -126,16 +127,20 @@ function classifyPermission(perm) {
   return {permission: perm};
 }
 
 const LOGGER_ID_BASE = "addons.webextension.";
 const UUID_MAP_PREF = "extensions.webextensions.uuids";
 const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
 const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
 
+// The userContextID reserved for the extension storage (its purpose is ensuring that the IndexedDB
+// storage used by the browser.storage.local API is not directly accessible from the extension code).
+const WEBEXT_STORAGE_USER_CONTEXT_ID = "userContextIdInternal.webextStorageLocal";
+
 const COMMENT_REGEXP = new RegExp(String.raw`
     ^
     (
       (?:
         [^"\n] |
         " (?:[^"\\\n] | \\.)* "
       )*?
     )
@@ -212,27 +217,33 @@ var UninstallObserver = {
 
   onUninstalled(addon) {
     let uuid = UUIDMap.get(addon.id, false);
     if (!uuid) {
       return;
     }
 
     if (!Services.prefs.getBoolPref(LEAVE_STORAGE_PREF, false)) {
-      // Clear browser.local.storage
+      // Clear browser.storage.local backends.
       AsyncShutdown.profileChangeTeardown.addBlocker(
-        `Clear Extension Storage ${addon.id}`,
-        ExtensionStorage.clear(addon.id));
+        `Clear Extension Storage ${addon.id} (File Backend)`,
+        ExtensionStorage.clear(addon.id, {shouldNotifyListeners: false}));
 
       // Clear any IndexedDB storage created by the extension
       let baseURI = Services.io.newURI(`moz-extension://${uuid}/`);
       let principal = Services.scriptSecurityManager.createCodebasePrincipal(
         baseURI, {});
       Services.qms.clearStoragesForPrincipal(principal);
 
+      // Clear any storage.local data stored in the IDBBackend.
+      let storagePrincipal = Services.scriptSecurityManager.createCodebasePrincipal(baseURI, {
+        userContextId: ContextualIdentityService.getPrivateIdentity(WEBEXT_STORAGE_USER_CONTEXT_ID),
+      });
+      Services.qms.clearStoragesForPrincipal(storagePrincipal);
+
       // Clear localStorage created by the extension
       let storage = Services.domStorageManager.getStorage(null, principal);
       if (storage) {
         storage.clear();
       }
 
       // Remove any permissions related to the unlimitedStorage permission
       // if we are also removing all the data stored by the extension.
@@ -1264,16 +1275,24 @@ class Extension extends ExtensionData {
     // This is filled in the first time an extension child is created.
     this.parentMessageManager = null;
 
     this.id = addonData.id;
     this.version = addonData.version;
     this.baseURL = this.getURL("");
     this.baseURI = Services.io.newURI(this.baseURL).QueryInterface(Ci.nsIURL);
     this.principal = this.createPrincipal();
+
+    // Create an extension principal isolated in a reserved user context id (its purpose is ensuring
+    // that the IndexedDB storage used by the browser.storage.local API is not directly accessible
+    // from the extension code).
+    this.storagePrincipal = this.createPrincipal(this.baseURI, {
+      userContextId: ContextualIdentityService.getPrivateIdentity(WEBEXT_STORAGE_USER_CONTEXT_ID),
+    });
+
     this.views = new Set();
     this._backgroundPageFrameLoader = null;
 
     this.onStartup = null;
 
     this.hasShutdown = false;
     this.onShutdown = new Set();
 
@@ -1385,18 +1404,18 @@ class Extension extends ExtensionData {
       this.emitter.emit(data.event, ...data.args);
     }
   }
 
   testMessage(...args) {
     this.emit("test-harness-message", ...args);
   }
 
-  createPrincipal(uri = this.baseURI) {
-    return Services.scriptSecurityManager.createCodebasePrincipal(uri, {});
+  createPrincipal(uri = this.baseURI, originAttributes = {}) {
+    return Services.scriptSecurityManager.createCodebasePrincipal(uri, originAttributes);
   }
 
   // Checks that the given URL is a child of our baseURI.
   isExtensionURL(url) {
     let uri = Services.io.newURI(url);
 
     let common = this.baseURI.getCommonBaseSpec(uri);
     return common == this.baseURL;
@@ -1505,16 +1524,17 @@ class Extension extends ExtensionData {
       registeredContentScripts: new Map(),
       webAccessibleResources: this.webAccessibleResources.map(res => res.glob),
       whiteListedHosts: this.whiteListedHosts.patterns.map(pat => pat.pattern),
       localeData: this.localeData.serialize(),
       childModules: this.modules && this.modules.child,
       dependencies: this.dependencies,
       permissions: this.permissions,
       principal: this.principal,
+      storagePrincipal: this.storagePrincipal,
       optionalPermissions: this.manifest.optional_permissions,
       schemaURLs: this.schemaURLs,
     };
   }
 
   get contentScripts() {
     return this.manifest.content_scripts || [];
   }
--- a/toolkit/components/extensions/ExtensionChild.jsm
+++ b/toolkit/components/extensions/ExtensionChild.jsm
@@ -594,16 +594,17 @@ class BrowserExtensionContent extends Ev
     defineLazyGetter(this, "scripts", () => {
       return data.contentScripts.map(scriptData => new ExtensionContent.Script(this, scriptData));
     });
 
     this.webAccessibleResources = data.webAccessibleResources.map(res => new MatchGlob(res));
     this.permissions = data.permissions;
     this.optionalPermissions = data.optionalPermissions;
     this.principal = data.principal;
+    this.storagePrincipal = data.storagePrincipal;
 
     let restrictSchemes = !this.hasPermission("mozillaAddons");
 
     this.whiteListedHosts = new MatchPatternSet(data.whiteListedHosts, {restrictSchemes, ignorePath: true});
 
     this.apiManager = this.getAPIManager();
 
     this.localeData = new LocaleData(data.localeData);
--- a/toolkit/components/extensions/ExtensionStorage.jsm
+++ b/toolkit/components/extensions/ExtensionStorage.jsm
@@ -125,16 +125,28 @@ var ExtensionStorage = {
     if (!promise) {
       promise = this._readFile(extensionId);
       this.jsonFilePromises.set(extensionId, promise);
     }
     return promise;
   },
 
   /**
+   * Clear the cached jsonFilePromise for a given extensionId
+   * (used by ExtensionStorageIDB to free the jsonFile once the data migration
+   * has been completed).
+   *
+   * @param {string} extensionId
+   *        The ID of the extension for which to return a file.
+   */
+  clearCachedFile(extensionId) {
+    this.jsonFilePromises.delete(extensionId);
+  },
+
+  /**
    * Sanitizes the given value, and returns a JSON-compatible
    * representation of it, based on the privileges of the given global.
    *
    * @param {value} value
    *        The value to sanitize.
    * @param {Context} context
    *        The extension context in which to sanitize the value
    * @returns {value}
@@ -234,32 +246,42 @@ var ExtensionStorage = {
   },
 
   /**
    * Asynchronously clears all storage entries for the given extension
    * ID.
    *
    * @param {string} extensionId
    *        The ID of the extension for which to clear storage.
+   * @param {object} options
+   * @param {boolean} [options.shouldNotifyListeners = true]
+   *         Whether or not collect and send the changes to the listeners,
+   *         used when the extension data is being cleared on uninstall.
    * @returns {Promise<void>}
    */
-  async clear(extensionId) {
+  async clear(extensionId, {shouldNotifyListeners = true} = {}) {
     let jsonFile = await this.getFile(extensionId);
 
     let changed = false;
     let changes = {};
 
     for (let [prop, oldValue] of jsonFile.data.entries()) {
-      changes[prop] = {oldValue: serialize(oldValue)};
+      if (shouldNotifyListeners) {
+        changes[prop] = {oldValue: serialize(oldValue)};
+      }
+
       jsonFile.data.delete(prop);
       changed = true;
     }
 
     if (changed) {
-      this.notifyListeners(extensionId, changes);
+      if (shouldNotifyListeners) {
+        this.notifyListeners(extensionId, changes);
+      }
+
       jsonFile.saveSoon();
     }
     return null;
   },
 
   /**
    * Asynchronously retrieves the values for the given storage items for
    * the given extension ID.
@@ -341,16 +363,19 @@ var ExtensionStorage = {
     } else if (topic == "extension-invalidate-storage-cache") {
       for (let promise of this.jsonFilePromises.values()) {
         promise.then(jsonFile => { jsonFile.finalize(); });
       }
       this.jsonFilePromises.clear();
     }
   },
 
+  // Serializes an arbitrary value into a StructuredCloneHolder, if appropriate.
+  serialize,
+
   /**
    * Serializes the given storage items for transporting between processes.
    *
    * @param {BaseContext} context
    *        The context to use for the created StructuredCloneHolder
    *        objects.
    * @param {Array<string>|object} items
    *        The items to serialize. If an object is provided, its
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/ExtensionStorageIDB.jsm
@@ -0,0 +1,416 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["ExtensionStorageIDB"];
+
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://gre/modules/IndexedDB.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
+  ExtensionUtils: "resource://gre/modules/ExtensionUtils.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+  OS: "resource://gre/modules/osfile.jsm",
+});
+
+const IDB_NAME = "webExtensions-storage-local";
+const IDB_DATA_STORENAME = "storage-local-data";
+const IDB_VERSION = 1;
+
+// Whether or not the installed extensions should be migrated to the storage.local IndexedDB backend.
+const BACKEND_ENABLED_PREF = "extensions.webextensions.ExtensionStorageIDB.enabled";
+
+class ExtensionStorageLocalIDB extends IndexedDB {
+  onupgradeneeded(event) {
+    if (event.oldVersion < 1) {
+      this.createObjectStore(IDB_DATA_STORENAME);
+    }
+  }
+
+  static openForExtension(extension) {
+    // The db is opened using an extension principal isolated in a reserved user context id.
+    return this.openForPrincipal(extension.storagePrincipal, IDB_NAME, IDB_VERSION);
+  }
+
+  async isEmpty() {
+    const cursor = await this.objectStore(IDB_DATA_STORENAME, "readonly").openKeyCursor();
+    return cursor.done;
+  }
+
+  /**
+   * Asynchronously sets the values of the given storage items.
+   *
+   * @param {object} items
+   *        The storage items to set. For each property in the object,
+   *        the storage value for that property is set to its value in
+   *        said object. Any values which are StructuredCloneHolder
+   *        instances are deserialized before being stored.
+   * @param {object}  options
+   * @param {function} options.serialize
+   *        Set to a function which will be used to serialize the values into
+   *        a StructuredCloneHolder object (if appropriate) and being sent
+   *        across the processes (it is also used to detect data cloning errors
+   *        and raise an appropriate error to the caller).
+   *
+   * @returns {Promise<null|object>}
+   *        Return a promise which resolves to the computed "changes" object
+   *        or null.
+   */
+  async set(items, {serialize} = {}) {
+    const changes = {};
+    let changed = false;
+
+    // Explicitly create a transaction, so that we can explicitly abort it
+    // as soon as one of the put requests fails.
+    const transaction = this.transaction(IDB_DATA_STORENAME, "readwrite");
+    const objectStore = transaction.objectStore(IDB_DATA_STORENAME, "readwrite");
+
+    for (let key of Object.keys(items)) {
+      try {
+        let oldValue = await objectStore.get(key);
+
+        await objectStore.put(items[key], key);
+
+        changes[key] = {
+          oldValue: oldValue && serialize ? serialize(oldValue) : oldValue,
+          newValue: serialize ? serialize(items[key]) : items[key],
+        };
+        changed = true;
+      } catch (err) {
+        transaction.abort();
+
+        // Ensure that the error we throw is converted into an ExtensionError
+        // (e.g. DataCloneError instances raised from the internal IndexedDB
+        // operation have to be converted to be accessible to the extension code).
+        throw new ExtensionUtils.ExtensionError(String(err));
+      }
+    }
+
+    return changed ? changes : null;
+  }
+
+  /**
+   * Asynchronously retrieves the values for the given storage items.
+   *
+   * @param {Array<string>|object|null} [keysOrItems]
+   *        The storage items to get. If an array, the value of each key
+   *        in the array is returned. If null, the values of all items
+   *        are returned. If an object, the value for each key in the
+   *        object is returned, or that key's value if the item is not
+   *        set.
+   * @returns {Promise<object>}
+   *        An object which has a property for each requested key,
+   *        containing that key's value as stored in the IndexedDB
+   *        storage.
+   */
+  async get(keysOrItems) {
+    let keys;
+    let defaultValues;
+
+    if (Array.isArray(keysOrItems)) {
+      keys = keysOrItems;
+    } else if (keysOrItems && typeof(keysOrItems) === "object") {
+      keys = Object.keys(keysOrItems);
+      defaultValues = keysOrItems;
+    }
+
+    const result = {};
+
+    // Retrieve all the stored data using a cursor when browser.storage.local.get()
+    // has been called with no keys.
+    if (keys == null) {
+      const cursor = await this.objectStore(IDB_DATA_STORENAME, "readonly").openCursor();
+      while (!cursor.done) {
+        result[cursor.key] = cursor.value;
+        await cursor.continue();
+      }
+    } else {
+      const objectStore = this.objectStore(IDB_DATA_STORENAME);
+      for (let key of keys) {
+        const storedValue = await objectStore.get(key);
+        if (storedValue === undefined) {
+          if (defaultValues && defaultValues[key] !== undefined) {
+            result[key] = defaultValues[key];
+          }
+        } else {
+          result[key] = storedValue;
+        }
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * Asynchronously removes the given storage items.
+   *
+   * @param {string|Array<string>} keys
+   *        A string key of a list of storage items keys to remove.
+   * @returns {Promise<Object>}
+   *          Returns an object which contains applied changes.
+   */
+  async remove(keys) {
+    // Ensure that keys is an array of strings.
+    keys = [].concat(keys);
+
+    if (keys.length === 0) {
+      // Early exit if there is nothing to remove.
+      return null;
+    }
+
+    const changes = {};
+    let changed = false;
+
+    const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite");
+
+    for (let key of keys) {
+      let oldValue = await objectStore.get(key);
+      changes[key] = {oldValue};
+
+      if (oldValue) {
+        changed = true;
+      }
+
+      await objectStore.delete(key);
+    }
+
+    return changed ? changes : null;
+  }
+
+  /**
+   * Asynchronously clears all storage entries.
+   *
+   * @returns {Promise<Object>}
+   *          Returns an object which contains applied changes.
+   */
+  async clear() {
+    const changes = {};
+    let changed = false;
+
+    const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite");
+
+    const cursor = await objectStore.openCursor();
+    while (!cursor.done) {
+      changes[cursor.key] = {oldValue: cursor.value};
+      changed = true;
+      await cursor.continue();
+    }
+
+    await objectStore.clear();
+
+    return changed ? changes : null;
+  }
+}
+
+/**
+ * Migrate the data stored in the JSONFile backend to the IDB Backend.
+ *
+ * Returns a promise which is resolved once the data migration has been
+ * completed and the new IDB backend can be enabled.
+ * Rejects if the data has been read successfully from the JSONFile backend
+ * but it failed to be saved in the new IDB backend.
+ *
+ * This method is called only from the main process (where the file
+ * can be opened).
+ *
+ * @param {Extension} extension
+ *        The extension to migrate to the new IDB backend.
+ */
+async function migrateJSONFileData(extension) {
+  let oldStoragePath;
+  let oldStorageExists;
+  let idbConn;
+  let hasEmptyIDB;
+  let oldDataRead = false;
+  let migrated = false;
+
+  try {
+    idbConn = await ExtensionStorageLocalIDB.openForExtension(extension);
+    hasEmptyIDB = await idbConn.isEmpty();
+
+    if (!hasEmptyIDB) {
+      // If the IDB backend is enabled and there is data already stored in the IDB backend,
+      // there is no "going back": any data that has not been migrated will be still on disk
+      // but it is not going to be migrated anymore, it could be eventually used to allow
+      // a user to manually retrieve the old data file).
+      return;
+    }
+
+    // Migrate any data stored in the JSONFile backend (if any), and remove the old data file
+    // if the migration has been completed successfully.
+    oldStoragePath = ExtensionStorage.getStorageFile(extension.id);
+    oldStorageExists = await OS.File.exists(oldStoragePath);
+
+    if (oldStorageExists) {
+      Services.console.logStringMessage(
+        `Migrating storage.local data for ${extension.policy.debugName}...`);
+
+      const jsonFile = await ExtensionStorage.getFile(extension.id);
+      const data = {};
+      for (let [key, value] of jsonFile.data.entries()) {
+        data[key] = value;
+      }
+      oldDataRead = true;
+      await idbConn.set(data);
+      migrated = true;
+      Services.console.logStringMessage(
+        `storage.local data successfully migrated to IDB Backend for ${extension.policy.debugName}.`);
+    }
+  } catch (err) {
+    extension.logWarning(`Error on migrating storage.local data: ${err.message}::${err.stack}`);
+    if (oldDataRead) {
+      // If the data has been read successfully and it has been failed to be stored
+      // into the IndexedDB backend, then clear any partially stored data and reject
+      // the data migration promise explicitly (which would prevent the new backend
+      // from being enabled for this session).
+      Services.qms.clearStoragesForPrincipal(extension.storagePrincipal);
+      throw err;
+    }
+  } finally {
+    // Clear the jsonFilePromise cached by the ExtensionStorage (so that the file
+    // can be immediatelly removed when we call OS.File.remove).
+    ExtensionStorage.clearCachedFile(extension.id);
+  }
+
+  // If the IDB backend has been enabled, try to remove the old storage.local data file,
+  // but keep using the selected backend even if it fails to be removed.
+  if (oldStorageExists && migrated) {
+    try {
+      await OS.File.remove(oldStoragePath);
+    } catch (err) {
+      extension.logWarning(err.message);
+    }
+  }
+}
+
+/**
+ * This ExtensionStorage class implements a backend for the storage.local API which
+ * uses IndexedDB to store the data.
+ */
+this.ExtensionStorageIDB = {
+  BACKEND_ENABLED_PREF,
+
+  // Map<extension-id, Set<Function>>
+  listeners: new Map(),
+
+  // Keep track if the IDB backend has been selected or not for a running extension
+  // (the selected backend should never change while the extension is running, even if the
+  // related preference has been changed in the meantime):
+  //
+  //   WeakMap<extension -> Promise<boolean>
+  selectedBackendPromises: new WeakMap(),
+
+  init() {
+    XPCOMUtils.defineLazyPreferenceGetter(this, "isBackendEnabled", BACKEND_ENABLED_PREF, false);
+  },
+
+  /**
+   * Select the preferred backend and return a promise which is resolved once the
+   * selected backend is ready to be used (e.g. if the extension is switching from
+   * the old JSONFile storage to the new IDB backend, any previously stored data will
+   * be migrated to the backend before the promise is resolved).
+   *
+   * This method is called from both the main and child (content or extension) processes:
+   * - an extension child context will call this method lazily, when the browser.storage.local
+   *   is being used for the first time, and it will result into asking the main process
+   *   to call the same method in the main process
+   * - on the main process side, it will check if the new IDB backend can be used (and if it can,
+   *   it will migrate any existing data into the new backend, which needs to happen in the
+   *   main process where the file can directly be accessed)
+   *
+   * The result will be cached while the extension is still running, and so an extension
+   * child context is going to ask the main process only once per child process, and on the
+   * main process side the backend selection and data migration will happen only once.
+   *
+   * @param {BaseContext} context
+   *        The extension context that is selecting the storage backend.
+   *
+   * @returns {Promise<boolean>}
+   *          Returns a promise which resolves to true if the extension should use
+   *          the IDB backend, or false if it should use the old JSONFile backend
+   *          (e.g. because the IDB backend has not been enabled from the preference)
+   */
+  selectBackend(context) {
+    const {extension} = context;
+
+    if (!this.selectedBackendPromises.has(extension)) {
+      let promise;
+
+      if (context.childManager) {
+        // Ask the parent process if the new backend is enabled for the
+        // running extension.
+        promise = context.childManager.callParentAsyncFunction(
+          "storage.local.IDBBackend.selectBackend", []);
+      } else {
+        // If migrating to the IDB backend is not enabled by the preference, then we
+        // don't need to migrate any data and the new backend is not enabled.
+        if (!this.isBackendEnabled) {
+          return Promise.resolve(false);
+        }
+
+        promise = migrateJSONFileData(extension).then(() => true).catch(err => {
+          // If the data migration promise is rejected, the old data has been read
+          // successfully from the old JSONFile backend but it failed to be saved
+          // into the IndexedDB backend (which is likely unrelated to the kind of
+          // data stored and more likely a general issue with the IndexedDB backend)
+          // In this case we keep the JSONFile backend enabled for this session
+          // and we will retry to migrate to the IDB Backend the next time the
+          // extension is being started.
+          // TODO Bug 1465129: This should be a very unlikely scenario, some telemetry
+          // data about it may be useful.
+          extension.logWarning("JSONFile backend is being kept enabled by an unexpected " +
+                               `IDBBackend failure: ${err.message}::${err.stack}`);
+          return false;
+        });
+      }
+
+      this.selectedBackendPromises.set(extension, promise);
+    }
+
+    return this.selectedBackendPromises.get(extension);
+  },
+
+  /**
+   * Open a connection to the IDB storage.local db for a given extension.
+   * given extension.
+   *
+   * @param {Extension|BrowserExtensionContent} extension
+   *        The extension for which to set storage values.
+   *
+   * @returns {Promise<ExtensionStorageLocalIDB>}
+   *          Return a promise which resolves to the opened IDB connection.
+   */
+  open(extension) {
+    return ExtensionStorageLocalIDB.openForExtension(extension);
+  },
+
+  addOnChangedListener(extensionId, listener) {
+    let listeners = this.listeners.get(extensionId) || new Set();
+    listeners.add(listener);
+    this.listeners.set(extensionId, listeners);
+  },
+
+  removeOnChangedListener(extensionId, listener) {
+    let listeners = this.listeners.get(extensionId);
+    listeners.delete(listener);
+  },
+
+  notifyListeners(extensionId, changes) {
+    let listeners = this.listeners.get(extensionId);
+    if (listeners) {
+      for (let listener of listeners) {
+        listener(changes);
+      }
+    }
+  },
+
+  hasListeners(extensionId) {
+    let listeners = this.listeners.get(extensionId);
+    return listeners && listeners.size > 0;
+  },
+};
+
+ExtensionStorageIDB.init();
--- a/toolkit/components/extensions/child/ext-storage.js
+++ b/toolkit/components/extensions/child/ext-storage.js
@@ -1,19 +1,120 @@
 "use strict";
 
 ChromeUtils.defineModuleGetter(this, "ExtensionStorage",
                                "resource://gre/modules/ExtensionStorage.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionStorageIDB",
+                               "resource://gre/modules/ExtensionStorageIDB.jsm");
 ChromeUtils.defineModuleGetter(this, "TelemetryStopwatch",
                                "resource://gre/modules/TelemetryStopwatch.jsm");
 
 const storageGetHistogram = "WEBEXT_STORAGE_LOCAL_GET_MS";
 const storageSetHistogram = "WEBEXT_STORAGE_LOCAL_SET_MS";
 
+// Wrap a storage operation in a TelemetryStopWatch.
+async function measureOp(histogram, fn) {
+  const stopwatchKey = {};
+  TelemetryStopwatch.start(histogram, stopwatchKey);
+  try {
+    let result = await fn();
+    TelemetryStopwatch.finish(histogram, stopwatchKey);
+    return result;
+  } catch (err) {
+    TelemetryStopwatch.cancel(histogram, stopwatchKey);
+    throw err;
+  }
+}
+
 this.storage = class extends ExtensionAPI {
+  getLocalFileBackend(context, {deserialize, serialize}) {
+    return {
+      get(keys) {
+        return measureOp(storageGetHistogram, () => {
+          return context.childManager.callParentAsyncFunction(
+            "storage.local.JSONFileBackend.get",
+            [serialize(keys)]).then(deserialize);
+        });
+      },
+      set(items) {
+        return measureOp(storageSetHistogram, () => {
+          return context.childManager.callParentAsyncFunction(
+            "storage.local.JSONFileBackend.set", [serialize(items)]);
+        });
+      },
+      remove(keys) {
+        return context.childManager.callParentAsyncFunction(
+          "storage.local.JSONFileBackend.remove", [serialize(keys)]);
+      },
+      clear() {
+        return context.childManager.callParentAsyncFunction(
+          "storage.local.JSONFileBackend.clear", []);
+      },
+    };
+  }
+
+  getLocalIDBBackend(context, {hasParentListeners, serialize}) {
+    const dbPromise = ExtensionStorageIDB.open(context.extension);
+
+    return {
+      get(keys) {
+        return measureOp(storageGetHistogram, async () => {
+          const db = await dbPromise;
+          return db.get(keys);
+        });
+      },
+      set(items) {
+        return measureOp(storageSetHistogram, async () => {
+          const db = await dbPromise;
+          const changes = await db.set(items, {
+            serialize: ExtensionStorage.serialize,
+          });
+
+          if (!changes) {
+            return;
+          }
+
+          const hasListeners = await hasParentListeners();
+          if (hasListeners) {
+            await context.childManager.callParentAsyncFunction(
+              "storage.local.IDBBackend.fireOnChanged", [changes]);
+          }
+        });
+      },
+      async remove(keys) {
+        const db = await dbPromise;
+        const changes = await db.remove(keys);
+
+        if (!changes) {
+          return;
+        }
+
+        const hasListeners = await hasParentListeners();
+        if (hasListeners) {
+          await context.childManager.callParentAsyncFunction(
+            "storage.local.IDBBackend.fireOnChanged", [changes]);
+        }
+      },
+      async clear() {
+        const db = await dbPromise;
+        const changes = await db.clear(context.extension);
+
+        if (!changes) {
+          return;
+        }
+
+        const hasListeners = await hasParentListeners();
+        if (hasListeners) {
+          await context.childManager.callParentAsyncFunction(
+            "storage.local.IDBBackend.fireOnChanged", [changes]);
+        }
+      },
+    };
+  }
+
   getAPI(context) {
     const serialize = ExtensionStorage.serializeForContext.bind(null, context);
     const deserialize = ExtensionStorage.deserializeForContext.bind(null, context);
 
     function sanitize(items) {
       // The schema validator already takes care of arrays (which are only allowed
       // to contain strings). Strings and null are safe values.
       if (typeof items != "object" || items === null || Array.isArray(items)) {
@@ -28,57 +129,61 @@ this.storage = class extends ExtensionAP
       // So we enumerate all properties and sanitize each value individually.
       let sanitized = {};
       for (let [key, value] of Object.entries(items)) {
         sanitized[key] = ExtensionStorage.sanitize(value, context);
       }
       return sanitized;
     }
 
+    // Detect the actual storage.local enabled backend for the extension (as soon as the
+    // storage.local API has been accessed for the first time).
+    let promiseStorageLocalBackend;
+    const getStorageLocalBackend = async () => {
+      const idbBackendSelected = await ExtensionStorageIDB.selectBackend(context);
+      if (!idbBackendSelected) {
+        return this.getLocalFileBackend(context, {deserialize, serialize});
+      }
+
+      return this.getLocalIDBBackend(context, {
+        hasParentListeners() {
+          // We spare a good amount of memory if there are no listeners around
+          // (e.g. because they have never been subscribed or they have been removed
+          // in the meantime).
+          return context.childManager.callParentAsyncFunction(
+            "storage.local.IDBBackend.hasListeners", []);
+        },
+        serialize,
+      });
+    };
+
+    // Generate the backend-agnostic local API wrapped methods.
+    const local = {};
+    for (let method of ["get", "set", "remove", "clear"]) {
+      local[method] = async function(...args) {
+        if (!promiseStorageLocalBackend) {
+          promiseStorageLocalBackend = getStorageLocalBackend();
+        }
+        const backend = await promiseStorageLocalBackend;
+        return backend[method](...args);
+      };
+    }
+
     return {
       storage: {
-        local: {
-          get: async function(keys) {
-            const stopwatchKey = {};
-            TelemetryStopwatch.start(storageGetHistogram, stopwatchKey);
-            try {
-              let result = await context.childManager.callParentAsyncFunction("storage.local.get", [
-                serialize(keys),
-              ]).then(deserialize);
-              TelemetryStopwatch.finish(storageGetHistogram, stopwatchKey);
-              return result;
-            } catch (e) {
-              TelemetryStopwatch.cancel(storageGetHistogram, stopwatchKey);
-              throw e;
-            }
-          },
-          set: async function(items) {
-            const stopwatchKey = {};
-            TelemetryStopwatch.start(storageSetHistogram, stopwatchKey);
-            try {
-              let result = await context.childManager.callParentAsyncFunction("storage.local.set", [
-                serialize(items),
-              ]);
-              TelemetryStopwatch.finish(storageSetHistogram, stopwatchKey);
-              return result;
-            } catch (e) {
-              TelemetryStopwatch.cancel(storageSetHistogram, stopwatchKey);
-              throw e;
-            }
-          },
-        },
+        local,
 
         sync: {
-          get: function(keys) {
+          get(keys) {
             keys = sanitize(keys);
             return context.childManager.callParentAsyncFunction("storage.sync.get", [
               keys,
             ]);
           },
-          set: function(items) {
+          set(items) {
             items = sanitize(items);
             return context.childManager.callParentAsyncFunction("storage.sync.set", [
               items,
             ]);
           },
         },
 
         managed: {
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -15,16 +15,17 @@ EXTRA_JS_MODULES += [
     'ExtensionCommon.jsm',
     'ExtensionContent.jsm',
     'ExtensionPageChild.jsm',
     'ExtensionParent.jsm',
     'ExtensionPermissions.jsm',
     'ExtensionPreferencesManager.jsm',
     'ExtensionSettingsStore.jsm',
     'ExtensionStorage.jsm',
+    'ExtensionStorageIDB.jsm',
     'ExtensionStorageSync.jsm',
     'ExtensionUtils.jsm',
     'FindContent.jsm',
     'LegacyExtensionsUtils.jsm',
     'MessageChannel.jsm',
     'NativeManifests.jsm',
     'NativeMessaging.jsm',
     'ProxyScriptContext.jsm',
--- a/toolkit/components/extensions/parent/ext-storage.js
+++ b/toolkit/components/extensions/parent/ext-storage.js
@@ -1,13 +1,14 @@
 "use strict";
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
   ExtensionStorage: "resource://gre/modules/ExtensionStorage.jsm",
+  ExtensionStorageIDB: "resource://gre/modules/ExtensionStorageIDB.jsm",
   extensionStorageSync: "resource://gre/modules/ExtensionStorageSync.jsm",
   NativeManifests: "resource://gre/modules/NativeManifests.jsm",
 });
 
 var {
   ExtensionError,
 } = ExtensionUtils;
 
@@ -30,47 +31,68 @@ const lookupManagedStorage = async (exte
     return ExtensionStorage._serializableMap(info.manifest.data);
   }
   return null;
 };
 
 this.storage = class extends ExtensionAPI {
   getAPI(context) {
     let {extension} = context;
+
     return {
       storage: {
         local: {
-          get: function(spec) {
-            return ExtensionStorage.get(extension.id, spec);
-          },
-          set: function(items) {
-            return ExtensionStorage.set(extension.id, items);
+          // Private storage.local JSONFile backend methods (used internally by the child
+          // ext-storage.js module).
+          JSONFileBackend: {
+            get(spec) {
+              return ExtensionStorage.get(extension.id, spec);
+            },
+            set(items) {
+              return ExtensionStorage.set(extension.id, items);
+            },
+            remove(keys) {
+              return ExtensionStorage.remove(extension.id, keys);
+            },
+            clear() {
+              return ExtensionStorage.clear(extension.id);
+            },
           },
-          remove: function(keys) {
-            return ExtensionStorage.remove(extension.id, keys);
-          },
-          clear: function() {
-            return ExtensionStorage.clear(extension.id);
+          // Private storage.local IDB backend methods (used internally by the child ext-storage.js
+          // module).
+          IDBBackend: {
+            selectBackend() {
+              return ExtensionStorageIDB.selectBackend(context);
+            },
+            hasListeners() {
+              return ExtensionStorageIDB.hasListeners(extension.id);
+            },
+            fireOnChanged(changes) {
+              ExtensionStorageIDB.notifyListeners(extension.id, changes);
+            },
+            onceDataMigrated() {
+              return ExtensionStorageIDB.onceDataMigrated(context);
+            },
           },
         },
 
         sync: {
-          get: function(spec) {
+          get(spec) {
             enforceNoTemporaryAddon(extension.id);
             return extensionStorageSync.get(extension, spec, context);
           },
-          set: function(items) {
+          set(items) {
             enforceNoTemporaryAddon(extension.id);
             return extensionStorageSync.set(extension, items, context);
           },
-          remove: function(keys) {
+          remove(keys) {
             enforceNoTemporaryAddon(extension.id);
             return extensionStorageSync.remove(extension, keys, context);
           },
-          clear: function() {
+          clear() {
             enforceNoTemporaryAddon(extension.id);
             return extensionStorageSync.clear(extension, context);
           },
         },
 
         managed: {
           async get(keys) {
             enforceNoTemporaryAddon(extension.id);
@@ -96,19 +118,21 @@ this.storage = class extends ExtensionAP
             let listenerLocal = changes => {
               fire.raw(changes, "local");
             };
             let listenerSync = changes => {
               fire.async(changes, "sync");
             };
 
             ExtensionStorage.addOnChangedListener(extension.id, listenerLocal);
+            ExtensionStorageIDB.addOnChangedListener(extension.id, listenerLocal);
             extensionStorageSync.addOnChangedListener(extension, listenerSync, context);
             return () => {
               ExtensionStorage.removeOnChangedListener(extension.id, listenerLocal);
+              ExtensionStorageIDB.removeOnChangedListener(extension.id, listenerLocal);
               extensionStorageSync.removeOnChangedListener(extension, listenerSync);
             };
           },
         }).api(),
       },
     };
   }
 };