/** * Configurable options to change persistence behavior * * @group Options */ export type PersistOptions = { /** Default/Initial value if undefined */ default?: T, /** Storage implementation, defaults to LocalStorage */ storage?: Storage, /** Force value to have [proto]type */ type?: any, } /** * Sync variable's value with persistent storage (LocalStorage by default) * * @example * ```ts * const theme = new Persist('theme.current', {default: 'os'}); * console.log(theme.value); // Output: os * * theme.value = 'light'; // Any changes to `.value` will automatically sync with localStorage * * location.reload(); // Simulate refresh * console.log(theme.value); // Output: light * ``` */ export class Persist { /** Backend service to store data, must implement `Storage` interface */ private readonly storage!: Storage; /** Listeners which should be notified on changes */ private watches: { [key: string]: Function } = {}; /** Private value field */ private _value!: T; /** Current value or default if undefined */ get value(): T { return this._value !== undefined ? this._value : this.options?.default; } /** Set value with proxy object wrapper to sync future changes */ set value(v: T | undefined) { if(v == null || typeof v != 'object') this._value = v; // @ts-ignore else this._value = new Proxy(v, { get: (target: T, p: string | symbol): any => { const f = typeof (target)[p] == 'function'; if(!f) return (target)[p]; return (...args: any[]) => { const value = (target)[p](...args); this.save(); return value; }; }, set: (target: T, p: string | symbol, newValue: any): boolean => { (target)[p] = newValue; this.save(); return true; } }); this.save(); } /** * @param {string} key Primary key value will be stored under * @param {PersistOptions} options Configure using {@link PersistOptions} */ constructor(public readonly key: string, public options: PersistOptions = {}) { this.storage = options.storage || localStorage; this.load(); } /** Notify listeners of change */ private notify(value: T) { Object.values(this.watches).forEach(watch => watch(value)); } /** Delete value from storage */ clear() { this.storage.removeItem(this.key); } /** Save current value to storage */ save() { if(this._value === undefined) this.clear(); else this.storage.setItem(this.key, JSON.stringify(this._value)); this.notify(this.value); } /** Load value from storage */ load() { if(this.storage[this.key] != undefined) { let value = JSON.parse(this.storage.getItem(this.key)); if(value != null && typeof value == 'object' && this.options.type) value.__proto__ = this.options.type.prototype; this.value = value; } else this.value = this.options.default || undefined; } /** Callback to listen for changes */ watch(fn: (value: T) => any): () => void { const index = Object.keys(this.watches).length; this.watches[index] = fn; return () => { delete this.watches[index]; }; } /** Return value as JSON string */ toString() { return JSON.stringify(this.value); } /** Return current value */ valueOf() { return this.value; } } /** * Sync class property with persistent storage (LocalStorage by default) * * @example * ```ts * class ThemeEngine { * @persist({default: 'os'}) current!: string; * } * * const theme = new ThemeEngine(); * console.log(theme.current) // Output: os * * theme.current = 'light'; //Any changes will be automatically saved to localStorage * * location.reload(); // Simulate refresh * console.log(theme.current) // Output: light * ``` * * @group Decorators * @param {PersistOptions & {key?: string}} options Configure using {@link PersistOptions} * @returns Decorator function */ export function persist(options?: PersistOptions & { key?: string }) { return (target: any, prop: any) => { const key = options?.key || `${target.constructor.name}.${prop.toString()}`; const wrapper = new Persist(key, options); Object.defineProperty(target, prop, { get: function() { return wrapper.value; }, set: function(v) { wrapper.value = v; } }); }; }