From 7cce493a87c69e367d6dfc134ed02f2cb944ea76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Dziewo=C5=84ski?= Date: Sat, 10 Feb 2024 15:09:10 +0100 Subject: [PATCH] Split mediawiki.storage's SafeStorage to separate file This makes it easier to document it correctly in JSDoc, without having to use longnames everywhere. Change-Id: I0d306282f5f5e20c3e7203066491eec70e744a97 --- resources/Resources.php | 5 +- resources/src/mediawiki.storage.js | 343 ------------------ .../src/mediawiki.storage/.eslintrc.json | 5 + .../src/mediawiki.storage/SafeStorage.js | 244 +++++++++++++ resources/src/mediawiki.storage/index.js | 99 +++++ 5 files changed, 352 insertions(+), 344 deletions(-) delete mode 100644 resources/src/mediawiki.storage.js create mode 100644 resources/src/mediawiki.storage/.eslintrc.json create mode 100644 resources/src/mediawiki.storage/SafeStorage.js create mode 100644 resources/src/mediawiki.storage/index.js diff --git a/resources/Resources.php b/resources/Resources.php index 2c88e807f7b..5a47a94cb6c 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -961,7 +961,10 @@ return [ ], ], 'mediawiki.storage' => [ - 'scripts' => 'resources/src/mediawiki.storage.js', + 'packageFiles' => [ + 'resources/src/mediawiki.storage/index.js', + 'resources/src/mediawiki.storage/SafeStorage.js', + ], 'dependencies' => [ 'mediawiki.util', ], diff --git a/resources/src/mediawiki.storage.js b/resources/src/mediawiki.storage.js deleted file mode 100644 index e741207fac3..00000000000 --- a/resources/src/mediawiki.storage.js +++ /dev/null @@ -1,343 +0,0 @@ -( function () { - 'use strict'; - - var EXPIRY_PREFIX = '_EXPIRY_'; - - // Catch exceptions to avoid fatal in Chrome's "Block data storage" mode - // which throws when accessing the localStorage property itself, as opposed - // to the standard behaviour of throwing on getItem/setItem. (T148998) - var - localStorage = ( function () { - try { - return window.localStorage; - } catch ( e ) {} - }() ), - sessionStorage = ( function () { - try { - return window.sessionStorage; - } catch ( e ) {} - }() ); - - /** - * A wrapper for the HTML5 Storage interface (`localStorage` or `sessionStorage`) - * that is safe to call in all browsers. - * - * The constructor is not publicly accessible. An instance can be accessed from - * the {@link mw.storage} or {@link module:mediawiki.mediawiki.storage} - * - * @class SafeStorage - * @param {Object|undefined} store The Storage instance to wrap around - * @hideconstructor - */ - function SafeStorage( store ) { - this.store = store; - - // Purge expired items once per page session - if ( !window.QUnit ) { - var storage = this; - setTimeout( function () { - storage.clearExpired(); - }, 2000 ); - } - } - - /** - * Retrieve value from device storage. - * - * @memberof SafeStorage - * @param {string} key Key of item to retrieve - * @return {string|null|boolean} String value, null if no value exists, or false - * if storage is not available. - */ - SafeStorage.prototype.get = function ( key ) { - if ( this.isExpired( key ) ) { - return null; - } - try { - return this.store.getItem( key ); - } catch ( e ) {} - return false; - }; - - /** - * Set a value in device storage. - * - * @memberof SafeStorage - * @param {string} key Key name to store under - * @param {string} value Value to be stored - * @param {number} [expiry] Number of seconds after which this item can be deleted - * @return {boolean} The value was set - */ - SafeStorage.prototype.set = function ( key, value, expiry ) { - if ( key.slice( 0, EXPIRY_PREFIX.length ) === EXPIRY_PREFIX ) { - throw new Error( 'Key can\'t have a prefix of ' + EXPIRY_PREFIX ); - } - // Compare to `false` instead of checking falsiness to tolerate subclasses and mocks in - // extensions that weren't updated to add a return value to setExpires(). - if ( this.setExpires( key, expiry ) === false ) { - // If we failed to set the expiration time, don't try to set the value, - // otherwise it might end up set with no expiration. - return false; - } - try { - this.store.setItem( key, value ); - return true; - } catch ( e ) {} - return false; - }; - - /** - * Remove a value from device storage. - * - * @memberof SafeStorage - * @param {string} key Key of item to remove - * @return {boolean} Whether the key was removed - */ - SafeStorage.prototype.remove = function ( key ) { - try { - this.store.removeItem( key ); - this.setExpires( key ); - return true; - } catch ( e ) {} - return false; - }; - - /** - * Retrieve JSON object from device storage. - * - * @memberof SafeStorage - * @param {string} key Key of item to retrieve - * @return {Object|null|boolean} Object, null if no value exists or value - * is not JSON-parseable, or false if storage is not available. - */ - SafeStorage.prototype.getObject = function ( key ) { - var json = this.get( key ); - - if ( json === false ) { - return false; - } - - try { - return JSON.parse( json ); - } catch ( e ) {} - - return null; - }; - - /** - * Set an object value in device storage by JSON encoding. - * - * @memberof SafeStorage - * @param {string} key Key name to store under - * @param {Object} value Object value to be stored - * @param {number} [expiry] Number of seconds after which this item can be deleted - * @return {boolean} The value was set - */ - SafeStorage.prototype.setObject = function ( key, value, expiry ) { - var json; - try { - json = JSON.stringify( value ); - return this.set( key, json, expiry ); - } catch ( e ) {} - return false; - }; - - /** - * Set the expiry time for an item in the store. - * - * @memberof SafeStorage - * @param {string} key Key name - * @param {number} [expiry] Number of seconds after which this item can be deleted, - * omit to clear the expiry (either making the item never expire, or to clean up - * when deleting a key). - * @return {boolean} The expiry was set (or cleared) [since 1.41] - */ - SafeStorage.prototype.setExpires = function ( key, expiry ) { - if ( expiry ) { - try { - this.store.setItem( - EXPIRY_PREFIX + key, - Math.floor( Date.now() / 1000 ) + expiry - ); - return true; - } catch ( e ) {} - } else { - try { - this.store.removeItem( EXPIRY_PREFIX + key ); - return true; - } catch ( e ) {} - } - return false; - }; - - // Minimum amount of time (in milliseconds) for an iteration involving localStorage access. - var MIN_WORK_TIME = 3; - - /** - * Clear any expired items from the store - * - * @private - * @return {jQuery.Promise} Resolves when items have been expired - */ - SafeStorage.prototype.clearExpired = function () { - var storage = this; - return this.getExpiryKeys().then( function ( keys ) { - return $.Deferred( function ( d ) { - mw.requestIdleCallback( function iterate( deadline ) { - while ( keys[ 0 ] !== undefined && deadline.timeRemaining() > MIN_WORK_TIME ) { - var key = keys.shift(); - if ( storage.isExpired( key ) ) { - storage.remove( key ); - } - } - if ( keys[ 0 ] !== undefined ) { - // Ran out of time with keys still to remove, continue later - mw.requestIdleCallback( iterate ); - } else { - return d.resolve(); - } - } ); - } ); - } ); - }; - - /** - * Get all keys with expiry values - * - * @private - * @return {jQuery.Promise} Promise resolving with all the keys which have - * expiry values (unprefixed), or as many could be retrieved in the allocated time. - */ - SafeStorage.prototype.getExpiryKeys = function () { - var store = this.store; - return $.Deferred( function ( d ) { - mw.requestIdleCallback( function ( deadline ) { - var prefixLength = EXPIRY_PREFIX.length; - var keys = []; - var length = 0; - try { - length = store.length; - } catch ( e ) {} - - // Optimization: If time runs out, degrade to checking fewer keys. - // We will get another chance during a future page view. Iterate forward - // so that older keys are checked first and increase likelihood of recovering - // from key exhaustion. - // - // We don't expect to have more keys than we can handle in 50ms long-task window. - // But, we might still run out of time when other tasks run before this, - // or when the device receives UI events (especially on low-end devices). - for ( var i = 0; ( i < length && deadline.timeRemaining() > MIN_WORK_TIME ); i++ ) { - var key = null; - try { - key = store.key( i ); - } catch ( e ) {} - if ( key !== null && key.slice( 0, prefixLength ) === EXPIRY_PREFIX ) { - keys.push( key.slice( prefixLength ) ); - } - } - d.resolve( keys ); - } ); - } ).promise(); - }; - - /** - * Check if a given key has expired - * - * @private - * @param {string} key Key name - * @return {boolean} Whether key is expired - */ - SafeStorage.prototype.isExpired = function ( key ) { - var expiry; - try { - expiry = this.store.getItem( EXPIRY_PREFIX + key ); - } catch ( e ) { - return false; - } - return !!expiry && expiry < Math.floor( Date.now() / 1000 ); - }; - - /** - * @classdesc A safe interface to HTML5 `localStorage` and `sessionStorage`. - * - * This normalises differences across browsers and silences any and all - * exceptions that may occur. - * - * **Note**: Storage keys are not automatically prefixed in relation to - * MediaWiki and/or the current wiki. Always **prefix your keys** with "mw" to - * avoid conflicts with gadgets, JavaScript libraries, browser extensions, - * internal CDN or webserver cookies, and third-party applications that may - * be embedded on the page. - * - * **Warning**: This API has limited storage space and does not use an expiry - * by default. This means unused **keys are stored forever**, unless you - * opt-in to the `expiry` parameter or otherwise make sure that your code - * can rediscover and delete keys you created in the past. - * - * If you don't use the `expiry` parameter, avoid keys with variable - * components as this leads to untracked keys that your code has no way - * to know about and delete when the data is no longer needed. Instead, - * store dynamic values in an object under a single constant key that you - * manage or replace over time. - * See also . - * - * @example mw.storage.set( key, value, expiry ); - * mw.storage.set( key, value ); // stored indefinitely - * mw.storage.get( key ); - * - * @example var local = require( 'mediawiki.storage' ).local; - * local.set( key, value, expiry ); - * local.get( key ); - * - * @example mw.storage.session.set( key, value ); - * mw.storage.session.get( key ); - * - * @example var session = require( 'mediawiki.storage' ).session; - * session.set( key, value ); - * session.get( key ); - * - * This normalises differences across browsers and silences any and all - * exceptions that may occur. - * - * **Note**: Data persisted via `sessionStorage` will persist for the lifetime - * of the browser *tab*, not the browser *window*. - * For longer-lasting persistence across tabs, refer to mw.storage or mw.cookie instead. - * - * @class MwSafeStorage - * @extends SafeStorage - * @hideconstructor - */ - - /** - * @type {MwSafeStorage} - */ - mw.storage = new SafeStorage( localStorage ); - - /** - * A safe interface to HTML5 `sessionStorage`. - * - * @name MwSafeStorage.session - * @type {SafeStorage} - */ - mw.storage.session = new SafeStorage( sessionStorage ); - - /** - * Provides safe access to HTML5 session storage and local storage. - * @exports mediawiki.storage - */ - module.exports = { - /** - * Safe access to localStorage. - * - * @type {SafeStorage} - */ - local: mw.storage, - /** - * Safe access to sessionStorage. - * @type {SafeStorage} - */ - session: mw.storage.session - }; - -}() ); diff --git a/resources/src/mediawiki.storage/.eslintrc.json b/resources/src/mediawiki.storage/.eslintrc.json new file mode 100644 index 00000000000..8204f4eddba --- /dev/null +++ b/resources/src/mediawiki.storage/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "env": { + "commonjs": true + } +} diff --git a/resources/src/mediawiki.storage/SafeStorage.js b/resources/src/mediawiki.storage/SafeStorage.js new file mode 100644 index 00000000000..00158785720 --- /dev/null +++ b/resources/src/mediawiki.storage/SafeStorage.js @@ -0,0 +1,244 @@ +'use strict'; +var EXPIRY_PREFIX = '_EXPIRY_'; + +/** + * A wrapper for the HTML5 Storage interface (`localStorage` or `sessionStorage`) + * that is safe to call in all browsers. + * + * The constructor is not publicly accessible. An instance can be accessed from + * the {@link mw.storage} or {@link module:mediawiki.mediawiki.storage} + * + * @class SafeStorage + * @param {Object|undefined} store The Storage instance to wrap around + * @hideconstructor + */ +function SafeStorage( store ) { + this.store = store; + + // Purge expired items once per page session + if ( !window.QUnit ) { + var storage = this; + setTimeout( function () { + storage.clearExpired(); + }, 2000 ); + } +} + +/** + * Retrieve value from device storage. + * + * @memberof SafeStorage + * @param {string} key Key of item to retrieve + * @return {string|null|boolean} String value, null if no value exists, or false + * if storage is not available. + */ +SafeStorage.prototype.get = function ( key ) { + if ( this.isExpired( key ) ) { + return null; + } + try { + return this.store.getItem( key ); + } catch ( e ) {} + return false; +}; + +/** + * Set a value in device storage. + * + * @memberof SafeStorage + * @param {string} key Key name to store under + * @param {string} value Value to be stored + * @param {number} [expiry] Number of seconds after which this item can be deleted + * @return {boolean} The value was set + */ +SafeStorage.prototype.set = function ( key, value, expiry ) { + if ( key.slice( 0, EXPIRY_PREFIX.length ) === EXPIRY_PREFIX ) { + throw new Error( 'Key can\'t have a prefix of ' + EXPIRY_PREFIX ); + } + // Compare to `false` instead of checking falsiness to tolerate subclasses and mocks in + // extensions that weren't updated to add a return value to setExpires(). + if ( this.setExpires( key, expiry ) === false ) { + // If we failed to set the expiration time, don't try to set the value, + // otherwise it might end up set with no expiration. + return false; + } + try { + this.store.setItem( key, value ); + return true; + } catch ( e ) {} + return false; +}; + +/** + * Remove a value from device storage. + * + * @memberof SafeStorage + * @param {string} key Key of item to remove + * @return {boolean} Whether the key was removed + */ +SafeStorage.prototype.remove = function ( key ) { + try { + this.store.removeItem( key ); + this.setExpires( key ); + return true; + } catch ( e ) {} + return false; +}; + +/** + * Retrieve JSON object from device storage. + * + * @memberof SafeStorage + * @param {string} key Key of item to retrieve + * @return {Object|null|boolean} Object, null if no value exists or value + * is not JSON-parseable, or false if storage is not available. + */ +SafeStorage.prototype.getObject = function ( key ) { + var json = this.get( key ); + + if ( json === false ) { + return false; + } + + try { + return JSON.parse( json ); + } catch ( e ) {} + + return null; +}; + +/** + * Set an object value in device storage by JSON encoding. + * + * @memberof SafeStorage + * @param {string} key Key name to store under + * @param {Object} value Object value to be stored + * @param {number} [expiry] Number of seconds after which this item can be deleted + * @return {boolean} The value was set + */ +SafeStorage.prototype.setObject = function ( key, value, expiry ) { + var json; + try { + json = JSON.stringify( value ); + return this.set( key, json, expiry ); + } catch ( e ) {} + return false; +}; + +/** + * Set the expiry time for an item in the store. + * + * @memberof SafeStorage + * @param {string} key Key name + * @param {number} [expiry] Number of seconds after which this item can be deleted, + * omit to clear the expiry (either making the item never expire, or to clean up + * when deleting a key). + * @return {boolean} The expiry was set (or cleared) [since 1.41] + */ +SafeStorage.prototype.setExpires = function ( key, expiry ) { + if ( expiry ) { + try { + this.store.setItem( + EXPIRY_PREFIX + key, + Math.floor( Date.now() / 1000 ) + expiry + ); + return true; + } catch ( e ) {} + } else { + try { + this.store.removeItem( EXPIRY_PREFIX + key ); + return true; + } catch ( e ) {} + } + return false; +}; + +// Minimum amount of time (in milliseconds) for an iteration involving localStorage access. +var MIN_WORK_TIME = 3; + +/** + * Clear any expired items from the store + * + * @private + * @return {jQuery.Promise} Resolves when items have been expired + */ +SafeStorage.prototype.clearExpired = function () { + var storage = this; + return this.getExpiryKeys().then( function ( keys ) { + return $.Deferred( function ( d ) { + mw.requestIdleCallback( function iterate( deadline ) { + while ( keys[ 0 ] !== undefined && deadline.timeRemaining() > MIN_WORK_TIME ) { + var key = keys.shift(); + if ( storage.isExpired( key ) ) { + storage.remove( key ); + } + } + if ( keys[ 0 ] !== undefined ) { + // Ran out of time with keys still to remove, continue later + mw.requestIdleCallback( iterate ); + } else { + return d.resolve(); + } + } ); + } ); + } ); +}; + +/** + * Get all keys with expiry values + * + * @private + * @return {jQuery.Promise} Promise resolving with all the keys which have + * expiry values (unprefixed), or as many could be retrieved in the allocated time. + */ +SafeStorage.prototype.getExpiryKeys = function () { + var store = this.store; + return $.Deferred( function ( d ) { + mw.requestIdleCallback( function ( deadline ) { + var prefixLength = EXPIRY_PREFIX.length; + var keys = []; + var length = 0; + try { + length = store.length; + } catch ( e ) {} + + // Optimization: If time runs out, degrade to checking fewer keys. + // We will get another chance during a future page view. Iterate forward + // so that older keys are checked first and increase likelihood of recovering + // from key exhaustion. + // + // We don't expect to have more keys than we can handle in 50ms long-task window. + // But, we might still run out of time when other tasks run before this, + // or when the device receives UI events (especially on low-end devices). + for ( var i = 0; ( i < length && deadline.timeRemaining() > MIN_WORK_TIME ); i++ ) { + var key = null; + try { + key = store.key( i ); + } catch ( e ) {} + if ( key !== null && key.slice( 0, prefixLength ) === EXPIRY_PREFIX ) { + keys.push( key.slice( prefixLength ) ); + } + } + d.resolve( keys ); + } ); + } ).promise(); +}; + +/** + * Check if a given key has expired + * + * @private + * @param {string} key Key name + * @return {boolean} Whether key is expired + */ +SafeStorage.prototype.isExpired = function ( key ) { + var expiry; + try { + expiry = this.store.getItem( EXPIRY_PREFIX + key ); + } catch ( e ) { + return false; + } + return !!expiry && expiry < Math.floor( Date.now() / 1000 ); +}; + +module.exports = SafeStorage; diff --git a/resources/src/mediawiki.storage/index.js b/resources/src/mediawiki.storage/index.js new file mode 100644 index 00000000000..7333c65f61c --- /dev/null +++ b/resources/src/mediawiki.storage/index.js @@ -0,0 +1,99 @@ +'use strict'; + +// Catch exceptions to avoid fatal in Chrome's "Block data storage" mode +// which throws when accessing the localStorage property itself, as opposed +// to the standard behaviour of throwing on getItem/setItem. (T148998) +var + localStorage = ( function () { + try { + return window.localStorage; + } catch ( e ) {} + }() ), + sessionStorage = ( function () { + try { + return window.sessionStorage; + } catch ( e ) {} + }() ); + +/** + * @classdesc A safe interface to HTML5 `localStorage` and `sessionStorage`. + * + * This normalises differences across browsers and silences any and all + * exceptions that may occur. + * + * **Note**: Storage keys are not automatically prefixed in relation to + * MediaWiki and/or the current wiki. Always **prefix your keys** with "mw" to + * avoid conflicts with gadgets, JavaScript libraries, browser extensions, + * internal CDN or webserver cookies, and third-party applications that may + * be embedded on the page. + * + * **Warning**: This API has limited storage space and does not use an expiry + * by default. This means unused **keys are stored forever**, unless you + * opt-in to the `expiry` parameter or otherwise make sure that your code + * can rediscover and delete keys you created in the past. + * + * If you don't use the `expiry` parameter, avoid keys with variable + * components as this leads to untracked keys that your code has no way + * to know about and delete when the data is no longer needed. Instead, + * store dynamic values in an object under a single constant key that you + * manage or replace over time. + * See also . + * + * @example mw.storage.set( key, value, expiry ); + * mw.storage.set( key, value ); // stored indefinitely + * mw.storage.get( key ); + * + * @example var local = require( 'mediawiki.storage' ).local; + * local.set( key, value, expiry ); + * local.get( key ); + * + * @example mw.storage.session.set( key, value ); + * mw.storage.session.get( key ); + * + * @example var session = require( 'mediawiki.storage' ).session; + * session.set( key, value ); + * session.get( key ); + * + * This normalises differences across browsers and silences any and all + * exceptions that may occur. + * + * **Note**: Data persisted via `sessionStorage` will persist for the lifetime + * of the browser *tab*, not the browser *window*. + * For longer-lasting persistence across tabs, refer to mw.storage or mw.cookie instead. + * + * @class MwSafeStorage + * @extends SafeStorage + * @hideconstructor + */ +var SafeStorage = require( './SafeStorage.js' ); + +/** + * @type {MwSafeStorage} + */ +mw.storage = new SafeStorage( localStorage ); + +/** + * A safe interface to HTML5 `sessionStorage`. + * + * @name MwSafeStorage.session + * @type {SafeStorage} + */ +mw.storage.session = new SafeStorage( sessionStorage ); + +/** + * Provides safe access to HTML5 session storage and local storage. + * @exports mediawiki.storage + */ +module.exports = { + /** + * Safe access to localStorage. + * + * @type {SafeStorage} + */ + local: mw.storage, + /** + * Safe access to sessionStorage. + * @type {SafeStorage} + */ + session: mw.storage.session +};