chromium/ui/file_manager/file_manager/common/js/storage.ts

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

export interface ChangedValue {
  newValue: any;
}

export type ChangedValues = Record<string, ChangedValue>;

export type OnChangedListener =
    (changedValues: ChangedValues, storageNamespace: string) => void;

/**
 * Class used to emit window.localStorage change events to event listeners.
 * This class does 3 things:
 *
 * 1. Holds the onChanged event listeners for the current window.
 * 2. Sends broadcast event to all windows.
 * 3. Listens to broadcast events and propagates to the listeners in
 *    the current window.
 *
 * NOTE: This doesn't support the `oldValue` because it's simpler and the
 * current clients of `onChanged` don't need it.
 */
class StorageChangeTracker {
  /** Storage onChanged event listeners for the current window. */
  private listeners_: OnChangedListener[] = [];

  /**
   * @param storageNamespace_ Storage namespace argument added when calling
   *     listeners.
   */
  constructor(private storageNamespace_: string) {
    /** Event to send local storage changes to all window listeners. */
    window.addEventListener('storage', this.onStorageEvent_.bind(this));
  }

  /** Resets for testing: removes all listeners. */
  resetForTesting() {
    this.listeners_ = [];
  }

  /** Adds an onChanged event listener for the current window. */
  addListener(callback: OnChangedListener) {
    this.listeners_.push(callback);
  }

  /** Notifies listeners of key value changes. */
  keysChanged(changedValues: Record<string, any>) {
    const changedKeys: ChangedValues = {};

    for (const [k, v] of Object.entries(changedValues)) {
      // `oldValue` isn't necessary for the current use case.
      changedKeys[k] = {newValue: v};
    }

    this.notifyLocally_(changedKeys);
  }

  /** Processes storage event and notifies listeners. */
  private onStorageEvent_(event: StorageEvent) {
    const {key, newValue} = event;
    if (key === null || newValue === null) {
      return;
    }

    const changedKeys: ChangedValues = {};

    try {
      changedKeys[key] = {newValue: JSON.parse(newValue)};
    } catch (error) {
      // This is expected when window.localStorage is used directly instead of
      // `local.storage` defined below.
      console.debug(
          `Cannot parse local storage value from key '${key}' as JSON`, error);
      changedKeys[key] = {newValue};
    }

    this.notifyLocally_(changedKeys);
  }

  /** Notifies local (current window) listeners of key value changes. */
  private notifyLocally_(keys: ChangedValues) {
    for (const listener of this.listeners_) {
      try {
        listener(keys, this.storageNamespace_);
      } catch (error) {
        console.error('Error calling storage.onChanged listener', error);
      }
    }
  }
}

/**
 * StorageAreaImpl using window.localStorage as the storage area.
 */
class StorageAreaImpl {
  readonly storageChangeTracker: StorageChangeTracker;

  constructor(type: string) {
    this.storageChangeTracker = new StorageChangeTracker(type);
  }

  /** Gets values of `keys` and returns them in the callback. */
  get(keys: string|string[], callback: (arg0: Record<string, any>) => void) {
    const keyList = Array.isArray(keys) ? keys : [keys];
    const result: Record<string, any> = {};
    for (const key of keyList) {
      result[key as string] = this.getValue_(key);
    }
    callback(result);
  }

  /** Gets the value of `key` from local storage. */
  private getValue_(key: string): any {
    const value = window.localStorage.getItem(key) as string;
    try {
      return JSON.parse(value);
    } catch (error) {
      console.warn(
          `Failed to JSON parse localStorage value from key: "${key}" ` +
              `returning the raw value.`,
          error);
      return value;
    }
  }

  /** Async version of `this.get()`. */
  async getAsync(keys: string|string[]): Promise<Record<string, any>> {
    return new Promise(resolve => this.get(keys, resolve));
  }

  /**
   * Stores items in local storage.
   * @param items The items to store.
   * @param callback Callback to be called when the items have been stored.
   */
  set(items: Record<string, any>, callback?: () => void) {
    for (const key in items) {
      const value = JSON.stringify(items[key]);
      window.localStorage.setItem(key, value);
    }
    this.notifyChange_(Object.keys(items));
    callback?.();
  }

  /**
   * Async version of `this.set()`.
   * @param items The items to store.
   */
  async setAsync(items: Record<string, any>): Promise<void> {
    return new Promise(resolve => this.set(items, resolve));
  }

  /** Removes the given `keys` from local storage. */
  remove(keys: string|string[]) {
    const keyList = Array.isArray(keys) ? keys : [keys];
    for (const key of keyList) {
      window.localStorage.removeItem(key);
    }
    this.notifyChange_(keyList);
  }

  /** Clears local storage. */
  clear() {
    window.localStorage.clear();
    this.notifyChange_([]);
  }

  /** Notifies key changes to storage change tracker listeners. */
  private notifyChange_(keys: string[]) {
    const values: Record<string, any> = {};
    for (const k of keys) {
      values[k] = this.getValue_(k);
    }

    this.storageChangeTracker.keysChanged(values);
  }
}



export namespace storage {
  export const local = new StorageAreaImpl('local');
  export const onChanged: StorageChangeTracker = local.storageChangeTracker;
}