/** * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ // Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) import { action, comparer, computed, makeObservable, observable, observe, toJS, when } from "mobx"; import type { Draft } from "immer"; import { produce, isDraft } from "immer"; import { isEqual, isPlainObject } from "lodash"; import assert from "assert"; import type { Logger } from "../../common/logger"; export interface StorageChange { key: string; value: T | undefined; oldValue: T | undefined; } export interface StorageAdapter { [metadata: string]: unknown; getItem(key: string): T | Promise; setItem(key: string, value: T): void; removeItem(key: string): void; onChange?(change: StorageChange): void; } export interface StorageHelperOptions { readonly autoInit?: boolean; // start preloading data immediately, default: true readonly storage: StorageAdapter; readonly defaultValue: T; } export interface StorageLayer { isDefaultValue(val: T): boolean; get(): T; readonly value: T; readonly whenReady: Promise; set(value: T): void; reset(): void; merge(value: Partial | ((draft: Draft) => Partial | void)): void; } export const storageHelperLogPrefix = "[STORAGE-HELPER]:"; interface Dependencies { readonly logger: Logger; } export class StorageHelper implements StorageLayer { readonly storage: StorageAdapter; private readonly data = observable.box(undefined, { deep: true, equals: comparer.structural, }); @observable initialized = false; get whenReady() { return when(() => this.initialized); } get defaultValue(): T { // return as-is since options.defaultValue might be a getter too return this.options.defaultValue; } constructor(private readonly dependencies: Dependencies, readonly key: string, private readonly options: StorageHelperOptions) { makeObservable(this); const { storage, autoInit = true } = options; this.storage = storage; observe(this.data, (change) => { this.onChange(change.newValue as T | undefined, change.oldValue as T | undefined); }); if (autoInit) { this.init(); } } private onData = (data: T): void => { const notEmpty = data != null; const notDefault = !this.isDefaultValue(data); if (notEmpty && notDefault) { this.set(data); } this.initialized = true; }; private onError = (error: any): void => { this.dependencies.logger.error(`${storageHelperLogPrefix} loading error: ${error}`, this); }; @action init({ force = false } = {}) { if (this.initialized && !force) { return; } try { const data = this.storage.getItem(this.key); if (data instanceof Promise) { data.then(this.onData, this.onError); } else { this.onData(data); } } catch (error) { this.onError(error); } } isDefaultValue(value: T): boolean { return isEqual(value, this.defaultValue); } protected onChange(value: T | undefined, oldValue: T | undefined) { if (!this.initialized) return; try { if (value == null) { this.storage.removeItem(this.key); } else { this.storage.setItem(this.key, value); } this.storage.onChange?.({ value, oldValue, key: this.key }); } catch (error) { this.dependencies.logger.error(`${storageHelperLogPrefix} updating storage: ${error}`, this, { value, oldValue }); } } get(): T { return this.value; } @computed get value(): T { return this.data.get() ?? this.defaultValue; } @action set(value: T) { if (this.isDefaultValue(value)) { this.reset(); } else { this.data.set(value); } } @action reset() { this.data.set(undefined); } @action merge(value: T extends object ? Partial | ((draft: Draft) => Partial | void) : never) { const nextValue = produce(toJS(this.get()), (draft) => { assert(typeof draft === "object" && draft); if (typeof value == "function") { const newValue = value(draft); // merge returned plain objects from `value-as-callback` usage // otherwise `draft` can be just modified inside a callback without returning any value (void) if (newValue && !isDraft(newValue)) { Object.assign(draft, newValue); } } else if (isPlainObject(value)) { Object.assign(draft, value); } return draft; }); this.set(nextValue); } }