UNPKG

@georapbox/web-storage

Version:

WebStorage is a lightweight wrapper for namespaced localStorage/sessionStorage with support for serializable values and safe, tuple-based error handling.

264 lines (229 loc) 9.52 kB
// @ts-check /** * The type of web storage to use. * * @typedef {'localStorage' | 'sessionStorage'} WebStorageType */ /** * Options for configuring the WebStorage instance. * * @typedef {object} WebStorageOptions * @property {WebStorageType} [driver] - The preferred driver to use. Use one between "localStorage" and "sessionStorage". * @property {string} [keyPrefix] - The prefix for all keys stored in the offline storage. Value is trimmed internally (both left and right) to avoid potential user mistakes. */ /** * A tuple representing either a successful value or an error. * * @template T * @typedef {[T | null, Error | null]} Result<T> */ const DEFAULT_DRIVER = 'localStorage'; const DEFAULT_KEY_PREFIX = 'web-storage/'; const STORAGE_TEST_KEY = '__web-storage-test__'; /** * Removes the specified prefix from a string if it exists. * If the string does not start with the prefix, it returns the original string. * * @param {string} str - The string from which to remove the prefix. * @param {string} prefix - The prefix to remove from the string. * @returns {string} - The string without the prefix, or the original string if the prefix was not found. */ function removePrefix(str, prefix) { if (str.startsWith(prefix)) { return str.slice(prefix.length); } return str; } class WebStorage { /** @type {Storage} */ #driver; /** @type {string} */ #keyPrefix = ''; /** * Creates a new instance of WebStorage. * * @param {WebStorageOptions} [options={}] - The options to configure the WebStorage instance. * @throws {Error} - Throws if `option.driver` is any value other than "localStorage" or "sessionStorage". * @throws {TypeError} - Throws if `option.keyPrefix` is not of type `String`. */ constructor(options = {}) { const defaults = { driver: DEFAULT_DRIVER, keyPrefix: DEFAULT_KEY_PREFIX }; const opts = { ...defaults, ...options }; if (opts.driver !== 'localStorage' && opts.driver !== 'sessionStorage') { throw new Error('The "driver" option must be one of "localStorage" or "sessionStorage".'); } if (typeof opts.keyPrefix !== 'string') { throw new TypeError('The "keyPrefix" option must be a string.'); } this.#driver = window[opts.driver]; this.#keyPrefix = opts.keyPrefix.trim(); } /** * Checks if `storageType` is supported and is available. * Storage might be unavailable due to no browser support or due to being full or due to browser privacy settings. * * @param {WebStorageType} storageType - The storage type; available values "localStorage" or "sessionStorage". * @returns {boolean} - Returns `true` if `storage` available; otherwise `false`. */ static isAvailable(storageType) { try { const storage = window[storageType]; const testKey = STORAGE_TEST_KEY; storage.setItem(testKey, 'test'); storage.getItem(testKey); storage.removeItem(testKey); return true; } catch { return false; } } /** * Creates a new instance of WebStorage with the provided options. * * @param {WebStorageOptions} [options] - The options to configure the WebStorage instance. * @returns {WebStorage} - Returns a new instance of WebStorage. */ static createInstance(options) { return new WebStorage(options); } /** * Saves an item to storage with the specified key. * * @template T * @param {string} key - The key under which to store the item. * @param {T} value - The item to save to the selected storage. * @throws {TypeError} - Throws if `key` is not a string. * @returns {Result<boolean>} - Returns an array with two elements: the first is `true` if the item was saved successfully, or `false` if it was not, and the second is `null` if no error occurred, or an `Error` object if an error occurred. */ setItem(key, value) { if (typeof key !== 'string') { throw new TypeError("Failed to execute 'setItem' on 'Storage': The first argument must be a string."); } try { const storageKey = this.#keyPrefix + key; const safeValue = value == null || typeof value === 'function' ? null : value; this.#driver.setItem(storageKey, JSON.stringify(safeValue)); return [true, null]; } catch (error) { return [false, error instanceof Error ? error : new Error(String(error))]; } } /** * Gets the saved item for the specified key from the storage for a specific datastore. * * @template T * @param {string} key - The key of the item to retrieve. * @throws {TypeError} - Throws if `key` is not a string. * @returns {Result<T>} - Returns an array with two elements: the first is the value of the saved item, and the second is `null` if no error occurred, or an `Error` object if an error occurred. */ getItem(key) { if (typeof key !== 'string') { throw new TypeError("Failed to execute 'getItem' on 'Storage': The first argument must be a string."); } try { const raw = this.#driver.getItem(this.#keyPrefix + key); return raw === null ? [null, null] : [/** @type {T} */ (JSON.parse(raw)), null]; } catch (error) { return [null, error instanceof Error ? error : new Error(String(error))]; } } /** * Removes the saved item for the specified key from storage. * * @param {string} key - The key of the item to remove. * @throws {TypeError} - Throws if `key` is not a string. * @returns {Result<boolean>} - Returns an array with two elements: the first is `true` if the item was removed successfully, or `false` if it was not, and the second is `null` if no error occurred, or an `Error` object if an error occurred. */ removeItem(key) { if (typeof key !== 'string') { throw new TypeError("Failed to execute 'removeItem' on 'Storage': The first argument must be a string."); } try { this.#driver.removeItem(this.#keyPrefix + key); return [true, null]; } catch (error) { return [false, error instanceof Error ? error : new Error(String(error))]; } } /** * Removes all saved items from storage for a specific datastore. * * @returns {Result<boolean>} - Returns an array with two elements: the first is `true` if all items were removed successfully, or `false` if they were not, and the second is `null` if no error occurred, or an `Error` object if an error occurred. */ clear() { try { this.#iterateStorage(key => this.#driver.removeItem(key)); return [true, null]; } catch (error) { return [false, error instanceof Error ? error : new Error(String(error))]; } } /** * Gets all keys (unprefixed) of saved items in a specific datastore. * * @returns {Result<string[]>} - Returns an array with two elements: the first is an array of keys (without the prefix) for the saved items, and the second is `null` if no error occurred, or an `Error` object if an error occurred. */ keys() { try { /** @type {string[]} */ const result = []; this.#iterateStorage(key => { const unprefixedKey = removePrefix(key, this.#keyPrefix); result.push(unprefixedKey); }); return [result, null]; } catch (error) { return [[], error instanceof Error ? error : new Error(String(error))]; } } /** * Gets the number of saved items in a specific datastore. * * @returns {Result<number>} - Returns an array with two elements: the first is the number of items saved in the datastore, and the second is `null` if no error occurred, or an `Error` object if an error occurred. */ length() { const [keys, err] = this.keys(); if (!Array.isArray(keys) || err) { return [0, err]; } return [keys.length, null]; } /** * Iterates over all saved items in storage for a specific datastore and execute a callback function for each key-value pair. * * @template T * @param {(value: T, key: string) => void} iteratorCallback - The callback function to execute for each key-value pair. * @throws {TypeError} - Throws if `iteratorCallback` is not a function. * @returns {Result<boolean>} - Returns an array with two elements: the first is `true` if the iteration was successful, or `false` if it was not, and the second is `null` if no error occurred, or an `Error` object if an error occurred. */ iterate(iteratorCallback) { if (typeof iteratorCallback !== 'function') { throw new TypeError("Failed to iterate on 'Storage': 'iteratorCallback' must be a function."); } try { this.#iterateStorage((key, value) => { const unprefixedKey = removePrefix(key, this.#keyPrefix); const parsedValue = /** @type {T} */ (value === null ? null : JSON.parse(value)); iteratorCallback(parsedValue, unprefixedKey); }); return [true, null]; } catch (error) { return [false, error instanceof Error ? error : new Error(String(error))]; } } /** * Iterates over all keys in the storage and executes a callback function for each key-value pair. * * @param {(key: string, value: string | null) => void} callback - The callback function to execute for each key-value pair. */ #iterateStorage(callback) { const keys = Object.keys(this.#driver); for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (key.startsWith(this.#keyPrefix)) { callback(key, this.#driver.getItem(key)); } } } } export { WebStorage };