Skip to content

Instantly share code, notes, and snippets.

@wolph
Last active April 30, 2025 11:15
Show Gist options
  • Save wolph/518019fe4690ce9341a9acf04c72deef to your computer and use it in GitHub Desktop.
Save wolph/518019fe4690ce9341a9acf04c72deef to your computer and use it in GitHub Desktop.

GMStorage – Advanced Storage Utility for Userscripts

The GMStorage class is an advanced wrapper for Greasemonkey, Tampermonkey, or Violentmonkey storage APIs. It streamlines persistent data management by offering a natural JavaScript object interface with built-in synchronization across tabs, deep object update detection, and robust merging of default values.

Key Features:

  • Natural Read/Write Operations:
    Access and update storage keys as if they were object properties.

  • Deep Proxying for Nested Updates:
    Automatically detect and persist changes in nested object properties without extra effort.

  • Recursive Defaults Merging:
    When initializing storage, missing properties are recursively merged from provided defaults rather than simply overriding entire objects.

  • Cross-Tab Synchronization:
    Updates from one script instance or tab are automatically propagated to all others using a global patch on GM_setValue.

  • Always-Trigger Listeners:
    Listeners are notified on every change—including redundant updates—allowing real-time monitoring of data state.

  • Flexible Asynchronous Listening:
    Use promise-based one-shot waiting with await storage.listen(key) or continuously monitor updates using asynchronous iteration (for await ... of).

  • Resource Management:
    Clean up event listeners via clearListeners and release global hooks through dispose to prevent memory leaks.


Table of Contents

  1. Installation
  2. Constructor and Initialization
  3. API Reference
  4. Advanced Example
  5. Summary of Benefits

Installation

Include the script in your userscript metadata block. Ensure that the following grants are declared:

// ==UserScript==
// @name         Storage Tests
// @namespace    https://wol.ph/
// @version      2025-04-02
// @author       
// @description  Demonstrates the usage of the GMStorage class with advanced features.
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_addValueChangeListener
// @grant        GM_removeValueChangeListener
// @require      https://your.cdn/path/to/GMStorage.js
// ==/UserScript==

Then either include your GMStorage script file via @require or paste the code inline.


Constructor and Initialization

The GMStorage constructor accepts two optional parameters:

  • defaultValues (Object)
    An object with key/value pairs that initialize storage when keys are missing. Defaults are applied recursively to merge with pre-existing stored objects.

  • debug (Boolean)
    Enables debug logging when set to true, printing detailed change notifications to console.debug().

Parameter Order:
Note that the first parameter is the set of default values and the second parameter toggles debugging.

Example:

// Define default values for the storage.
const defaults = {
  username: "Anonymous",
  preferences: {
    theme: "light",
    notifications: true,
  },
  settings: { volume: 50, fontSize: 12 },
  user: null
};

// Create an instance with defaults and enable debug logging.
const storage = new GMStorage(defaults, true);

Note:
During initialization, GMStorage merges defaults with any existing stored values. For example, if storage already contains { settings: { fontSize: 12, notifications: false } }, after initialization the merged value will be:
{ settings: { theme: 'light', fontSize: 12, notifications: false } }
with new keys added from the defaults.


API Reference

1. Reading and Writing Values

You can treat the GMStorage instance like a plain JavaScript object. All storage keys are exposed as properties.

// Writing a new value:
storage.someKey = "Hello, World!";

// Reading a value:
console.log(storage.someKey); // "Hello, World!"

// Updating an existing value:
storage.someKey = "Updated Value";
console.log(storage.someKey); // "Updated Value"

2. Working with Default Values

For any key not already set in storage, GMStorage automatically initializes using the provided defaults.

const defaults = {
  username: "Anonymous",
  preferences: { theme: "dark", notifications: true },
};

const storage = new GMStorage(defaults, false);

// If "username" is not set previously, it defaults to "Anonymous":
console.log(storage.username); // "Anonymous"

// Changing the value updates storage immediately:
storage.username = "JohnDoe";

3. Enabling Debug Logging

Enabling debug mode outputs detailed information about each change, making it easier to troubleshoot.

const storage = new GMStorage({}, true);

// Setting a value triggers a debug message:
storage.someKey = 42;
// Example Console Output:
// GMStorage: Key 'someKey' set locally. Old: undefined New: 42

4. Handling Deep Object Updates

GMStorage uses deep proxies to monitor updates to nested object properties. Any change within an object is automatically cached and written back to storage.

const storage = new GMStorage();

// Initialize a nested settings object.
storage.settings = { theme: "light", notifications: false };

// Changing a nested property triggers a deep update:
storage.settings.theme = "dark";
console.log(storage.settings.theme); // "dark"

// Debug log example:
// GMStorage [deep]: Key 'settings', property 'theme' set. Old value: "light" New value: "dark"

5. Listening for Updates

GMStorage provides flexible listeners for changes:

a) One-Shot Update Listener

Await the next change to a key.

(async () => {
  console.log("Waiting for an update on 'someKey'...");
  const newValue = await storage.listen("someKey");
  console.log("Received new value:", newValue);
})();

setTimeout(() => {
  storage.someKey = "New Value";
}, 2000);

b) Continuous Listening Using for await...of

Continuously monitor updates via async iteration.

(async () => {
  console.log("Listening for continuous updates on 'settings'...");
  for await (const value of storage.listen("settings")) {
    console.log("Updated settings:", value);
    // Exit condition example.
    if (value.volume > 90) break;
  }
})();

setTimeout(() => { storage.settings = { theme: "dark", volume: 80 }; }, 1000);
setTimeout(() => { storage.settings.volume = 95; }, 3000);

c) Callback-Based Listening

Register a persistent listener that triggers on every update—including remote or external changes.

storage.addListener('settings', (key, oldVal, newVal, remote) => {
  console.log(`Settings changed (remote: ${remote}):`, oldVal, '->', newVal);
});

6. Clearing and Disposing Listeners

Clean-up methods are available to remove listeners and dispose of the GMStorage instance:

  • Clearing Listeners:
    Stops all active GM_addValueChangeListeners and clears pending callbacks.

    storage.clearListeners();
  • Disposing the Instance:
    Removes the storage instance from the global list and releases resources. This is useful to prevent memory leaks when the instance is no longer needed.

    storage.dispose();

Advanced Example: Combining Features

This example demonstrates initializing defaults, deep updates on nested objects, and both one-shot and continuous listening.

const defaults = {
  user: {
    name: "Anonymous",
    preferences: {
      theme: "light",
      notifications: true,
    },
  },
};

const storage = new GMStorage(defaults, true);

// Output initial default value.
console.log("Username:", storage.user.name); // "Anonymous"

// Modify a nested property.
storage.user.preferences.theme = "dark";

// Listen for further updates on the 'user' key.
(async () => {
  for await (const updatedUser of storage.listen("user")) {
    console.log("User updated:", updatedUser);
  }
})();

// Simulate another update after 2 seconds.
setTimeout(() => {
  storage.user.preferences.notifications = false;
}, 2000);

Expected Console Output:

GMStorage: Key 'user' set locally. Old: undefined New: { name: 'Anonymous', preferences: { theme: 'light', notifications: true } }
GMStorage [deep]: Key 'user', property 'preferences' set. Old value: { theme: 'light', notifications: true } New value: { theme: 'dark', notifications: true }
User updated: { name: 'Anonymous', preferences: { theme: 'dark', notifications: false } }

Summary of Benefits

  • Simplified Code:
    No need for multiple GM_* calls; work directly with object properties.

  • Automatic Deep Updates:
    Nested objects update automatically without additional programming.

  • Robust Synchronization:
    Updates propagate seamlessly across tabs and script instances.

  • Flexible and Granular Listening:
    Choose between one-shot, continuous, or callback-based listeners to suit your needs.

  • Easy Debugging:
    Real-time debug logs help trace changes and diagnose issues.

  • Smart Initialization with Defaults:
    Missing keys are recursively merged with default values, saving you from manual checks.


This documentation should help you fully utilize the power of GMStorage in your userscripts. Enjoy the enhanced productivity and cross-tab synchronization, and happy coding!

// ==UserScript==
// @name GMStorage with Deep Proxy Fix and Recursive Defaults
// @namespace https://wol.ph
// @version 1.2
// @description GMStorage library: deep proxy, always-trigger listeners, recursive defaults.
// @author YourName
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_listValues
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// ==/UserScript==
/**
* GMStorage class
*
* Provides a proxy-based API for effortless reading and writing to GM storage,
* with support for deep object updates, automatic synchronization between tabs,
* and recursive application of default values.
*
* Usage:
* // Create an instance with debugging enabled and default values:
* const defaults = {
* some_key: 42,
* settings: { theme: 'dark', notifications: true },
* user: null
* };
* // Assume storage already contains: { settings: { fontSize: 12, notifications: false } }
* const storage = new GMStorage(defaults, true);
*
* // After initialization, storage will effectively contain:
* // {
* // some_key: 42, // Added from defaults
* // settings: { theme: 'dark', fontSize: 12, notifications: false }, // Merged
* // user: null // Added from defaults
* // }
*
* // Setting and getting values:
* storage.whatever = 123;
* console.log(storage.whatever); // 123
* console.log(storage.settings.theme); // 'dark'
* storage.settings.fontSize = 14; // Auto-saves the whole 'settings' object
*
* // One-shot awaiting the next update:
* const newValue = await storage.listen('some_key');
*
* // Continuous listening using async iteration:
* for await (const value of storage.listen('settings')) {
* console.log("Updated settings:", value);
* }
* // Continuous listening using callback:
* storage.addListener('settings', (key, oldVal, newVal, remote) => {
* console.log(`Settings changed (remote: ${remote}):`, oldVal, '->', newVal);
* });
*/
class GMStorage {
/**
* @param {Object} [defaults={}] - An object containing default key/value pairs. Defaults are applied recursively to existing objects in storage.
* @param {boolean} [debug=false] - Enable debug logging if true.
*/
constructor(defaults = {}, debug = false) {
this._debug = debug;
this._log = debug ? (...args) => console.debug(...args) : () => {};
this._listenerMap = {};
this._pendingListeners = {};
this._alwaysListeners = {};
this._data = {};
this._suppressGlobal = false; // Flag to prevent self-notification via global patch
// --- Global GM_setValue Patch (same as before) ---
if (!GMStorage.__globalPatched) {
GMStorage.__globalPatched = true;
const originalGMSetValue = GM_setValue;
GM_setValue = (key, value) => {
const oldValue = GM_getValue(key); // Get value *before* setting
originalGMSetValue(key, value);
if (window.__gmStorageInstances) {
for (const storage of window.__gmStorageInstances) {
// Use try-catch as external scripts might modify _data unexpectedly
try {
// Check if the key exists in this instance's known keys (_data)
// and if the notification isn't suppressed (i.e., it was an external call)
if (!storage._suppressGlobal && key in storage._data) {
// Pass correct old/new values to the internal update handler
storage._handleRemoteUpdate(key, oldValue, value, false); // false indicates not truly remote, but external
}
} catch (e) {
console.error("GMStorage: Error processing external GM_setValue notification", e);
}
}
}
};
}
if (!window.__gmStorageInstances) {
window.__gmStorageInstances = [];
}
window.__gmStorageInstances.push(this);
// --- End Global Patch ---
// --- Initialization with Recursive Defaults ---
const existingKeys = new Set(GM_listValues());
// 1. Process all default keys
for (const key in defaults) {
if (!defaults.hasOwnProperty(key)) continue; // Skip inherited properties
const defaultValue = defaults[key];
if (existingKeys.has(key)) {
// Key exists in storage, potentially merge
let currentValue = GM_getValue(key);
if (this._isObject(currentValue) && this._isObject(defaultValue)) {
// Both are objects, attempt recursive merge
const changed = this._applyDefaultsRecursively(currentValue, defaultValue);
if (changed) {
this._log(`GMStorage: Applied defaults recursively to key '${key}', updating storage.`);
this._suppressGlobal = true;
try {
GM_setValue(key, currentValue); // Save merged value
} catch (e) {
this._log(`GMStorage: Error saving merged defaults for key '${key}'`, e);
// If saving fails, maybe revert currentValue? For now, just log.
// currentValue = GM_getValue(key); // Re-fetch original? Risky.
}
this._suppressGlobal = false;
}
// Use the (potentially merged) currentValue for the cache
this._data[key] = this._createDeepProxy(currentValue, key);
} else {
// Key exists, but cannot merge (e.g., different types or not objects)
// Load the existing value directly into the cache.
this._data[key] = this._isObject(currentValue)
? this._createDeepProxy(currentValue, key)
: currentValue;
}
existingKeys.delete(key); // Mark as handled
} else {
// Key does not exist in storage, set it from default
this._log(`GMStorage: Initializing key '${key}' from defaults.`);
this._suppressGlobal = true;
try {
GM_setValue(key, defaultValue);
} catch (e) {
this._log(`GMStorage: Error saving initial default for key '${key}'`, e);
}
this._suppressGlobal = false;
this._data[key] = this._isObject(defaultValue)
? this._createDeepProxy(defaultValue, key)
: defaultValue;
}
// Add listener regardless of whether it was new or merged
this._addChangeListener(key);
}
// 2. Process remaining keys that were in storage but not in defaults
for (const key of existingKeys) { // Iterate remaining keys in the set
const value = GM_getValue(key);
this._data[key] = this._isObject(value)
? this._createDeepProxy(value, key)
: value;
this._addChangeListener(key);
}
// --- End Initialization ---
// Return the proxy wrapper for the instance
return new Proxy(this, {
get: (target, prop) => {
// Allow access to internal methods/properties starting with '_'
if (typeof prop === 'string' && prop.startsWith('_')) {
return target[prop];
}
// Also allow access to standard class methods like addListener, listen, clearListeners
if (prop in target && typeof target[prop] === 'function') {
// Ensure 'this' context is correct when calling methods via proxy
return target[prop].bind(target);
}
// Access data properties
if (prop in target._data) {
return target._data[prop];
}
// Handle symbols like Symbol.toStringTag if needed, or return undefined for others
if (typeof prop === 'symbol') {
return target[prop];
}
return undefined; // Default for properties not found
},
set: (target, prop, value) => {
// Disallow setting internal properties directly
if (typeof prop === 'string' && prop.startsWith('_')) {
console.warn(`GMStorage: Attempted to set internal property '${prop}'. This is not allowed.`);
return false; // Indicate failure
}
const oldValue = target._data[prop]; // Get old value from cache
// Prepare the new value (proxy if object)
const newValue = target._isObject(value)
? target._createDeepProxy(value, prop)
: value;
target._data[prop] = newValue; // Update cache first
// Persist to GM storage
target._suppressGlobal = true; // Prevent self-notification from global patch
try {
GM_setValue(prop, value); // Save the raw value, not the proxy
} catch(e) {
target._log(`GMStorage: Error setting key '${String(prop)}' in GM_setValue`, e);
// Optionally revert cache change?
// target._data[prop] = oldValue; // Revert? Might cause inconsistencies.
target._suppressGlobal = false;
return false; // Indicate set failure
}
target._suppressGlobal = false;
target._log(
`GMStorage: Key '${String(prop)}' set locally. Old:`, oldValue, "New:", newValue
);
// Notify internal listeners (awaiters and always)
target._notifyAwaiters(prop, newValue); // Notify with the proxied value
target._notifyAlwaysListeners(prop, oldValue, newValue, false); // Notify with old cached value and new proxied value
// Ensure a GM listener exists for potential remote changes
if (!target._listenerMap[prop]) {
target._addChangeListener(prop);
}
return true; // Indicate success
},
deleteProperty: (target, prop) => {
if (typeof prop === 'string' && prop.startsWith('_')) {
console.warn(`GMStorage: Attempted to delete internal property '${prop}'. This is not allowed.`);
return false;
}
if (prop in target._data) {
const oldValue = target._data[prop];
delete target._data[prop]; // Delete from cache
target._suppressGlobal = true;
try {
GM_deleteValue(prop); // Assuming GM_deleteValue exists or use GM_setValue(prop, undefined)
} catch (e) {
// Handle environments without GM_deleteValue if necessary
try { GM_setValue(prop, undefined); } catch (e2) {
target._log(`GMStorage: Error deleting key '${String(prop)}'`, e, e2);
// Revert cache deletion?
// target._data[prop] = oldValue;
target._suppressGlobal = false;
return false; // Indicate failure
}
}
target._suppressGlobal = false;
target._log(`GMStorage: Key '${String(prop)}' deleted.`);
// Notify listeners about the deletion (newValue is undefined)
target._notifyAwaiters(prop, undefined);
target._notifyAlwaysListeners(prop, oldValue, undefined, false);
// Optionally remove the GM listener if no longer needed?
// if (target._listenerMap[prop]) {
// GM_removeValueChangeListener(target._listenerMap[prop]);
// delete target._listenerMap[prop];
//}
return true; // Indicate success
}
return false; // Property didn't exist
},
ownKeys: (target) => {
// Combine keys from internal properties/methods and data cache
const internalKeys = Reflect.ownKeys(target).filter(k => typeof k === 'string' && k.startsWith('_'));
const methodKeys = Object.getOwnPropertyNames(Object.getPrototypeOf(target))
.filter(name => name !== 'constructor' && typeof target[name] === 'function');
const dataKeys = Reflect.ownKeys(target._data);
// Use a Set to avoid duplicates if methods/internals somehow clash with data keys
return [...new Set([...internalKeys, ...methodKeys, ...dataKeys])];
},
getOwnPropertyDescriptor: (target, prop) => {
if (prop in target._data) {
// Descriptor for data properties
return {
configurable: true,
enumerable: true,
value: target._data[prop],
writable: true // Assuming data properties should be writable
};
}
// Descriptor for internal methods/properties (use Reflect)
if (Reflect.has(target, prop)) {
return Reflect.getOwnPropertyDescriptor(target, prop);
}
// Check prototype for methods
const proto = Object.getPrototypeOf(target);
if (Reflect.has(proto, prop)) {
return Reflect.getOwnPropertyDescriptor(proto, prop);
}
return undefined; // Property not found
},
has: (target, prop) => {
// Check internal properties/methods first, then data cache
return Reflect.has(target, prop) || (prop in target._data);
}
});
}
/**
* Recursively applies default values to a current value object.
* Only adds keys from defaultValue that are missing in currentValue.
* Recurses if both currentValue[key] and defaultValue[key] are objects.
* Modifies currentValue in place.
*
* @param {Object} currentValue The object to apply defaults to.
* @param {Object} defaultValue The object containing default values.
* @returns {boolean} True if any changes were made to currentValue, false otherwise.
* @private
*/
_applyDefaultsRecursively(currentValue, defaultValue) {
// Ensure both are actual objects (not null, arrays maybe handled depending on _isObject)
if (!this._isObject(currentValue) || !this._isObject(defaultValue)) {
return false;
}
let changed = false;
for (const key in defaultValue) {
if (defaultValue.hasOwnProperty(key)) {
const defaultPropValue = defaultValue[key];
if (!currentValue.hasOwnProperty(key)) {
// Key is missing in current object, add it from defaults
currentValue[key] = this._cloneDeep(defaultPropValue); // Clone default value to avoid shared references
changed = true;
} else {
// Key exists in current object. Recurse only if both properties are objects.
const currentPropValue = currentValue[key];
if (this._isObject(currentPropValue) && this._isObject(defaultPropValue)) {
// Recurse. If the recursive call returns true, it means a change was made deeper down.
if (this._applyDefaultsRecursively(currentPropValue, defaultPropValue)) {
changed = true; // Propagate change flag upwards
}
}
// Otherwise (key exists, but not both objects, or primitive types), keep the current value. No change needed for this key.
}
}
}
return changed; // Return true if any changes were made at this level or below
}
/**
* Returns a deep copy of a JSON-serializable object.
* Handles potential circular references or non-serializable values gracefully by returning the original object on error.
* @param {*} obj The object to clone.
* @returns {*} A deep copy, or the original object if cloning fails.
* @private
*/
_cloneDeep(obj) {
// Optimization: If it's not an object, it doesn't need cloning in this context.
if (!this._isObject(obj)) {
return obj;
}
try {
// Basic deep clone using JSON methods. Sufficient for GM storage which requires JSON-serializable data.
return JSON.parse(JSON.stringify(obj));
} catch (e) {
this._log("GMStorage: _cloneDeep failed, returning original object.", e, obj);
// Return the original object reference if cloning fails (e.g., circular references, functions, etc.)
// This might lead to unintended shared references if the default object is complex and non-JSON-safe.
return obj;
}
}
/**
* Checks whether a value is a plain object (and not null or an array).
* @param {*} val The value to check.
* @returns {boolean} True if 'val' is a plain object.
* @private
*/
_isObject(val) {
// return val !== null && typeof val === 'object'; // Original version (includes arrays, etc.)
return Object.prototype.toString.call(val) === '[object Object]'; // Stricter: only plain objects
}
/**
* Creates a deep proxy for an object so that nested updates are persisted.
* @param {Object} obj The object to wrap.
* @param {string} topKey The corresponding top-level storage key.
* @returns {Proxy} A deep proxy of the object.
* @private
*/
_createDeepProxy(obj, topKey) {
const self = this;
// Ensure the object passed is actually an object
if (!this._isObject(obj)) {
this._log(`GMStorage Warning: _createDeepProxy called with non-object for key '${topKey}'. Value:`, obj);
return obj; // Return the non-object value as is
}
return new Proxy(obj, {
get(target, prop, receiver) {
// Optimization: Return early for symbols like Symbol.toStringTag
if (typeof prop === 'symbol') {
return Reflect.get(target, prop, receiver);
}
const value = Reflect.get(target, prop, receiver); // Use Reflect for better handling of getters/prototype chain
// If the retrieved value is an object, wrap it in a proxy recursively.
// Check if the value already has a proxy marker to prevent infinite loops (optional, maybe overkill)
if (self._isObject(value)) { // && !value.__isGMStorageProxy) {
// Object.defineProperty(value, '__isGMStorageProxy', { value: true, enumerable: false, configurable: true }); // Add marker
return self._createDeepProxy(value, topKey); // Recursive call
}
return value;
},
set(target, prop, value, receiver) {
// Get the root object from the cache using topKey
const rootObj = self._data[topKey];
if (!rootObj) {
console.error(`GMStorage Error: Cannot find root object for key '${topKey}' during deep set.`);
return false; // Indicate failure
}
// Clone the current root object *before* making changes to get an accurate "oldValue" for listeners.
// Crucially, clone the *root* object, not just the nested target.
const oldRootClone = self._cloneDeep(rootObj);
const oldValueAtProp = Reflect.get(target, prop, receiver); // Get old value at the specific property being set
// Apply the change to the target object (which is part of the nested structure within rootObj)
const success = Reflect.set(target, prop, value, receiver); // Use Reflect.set
if (success) {
// If the set was successful, persist the entire modified root object.
self._suppressGlobal = true; // Prevent self-notification from global patch
try {
GM_setValue(topKey, rootObj); // Save the *entire modified root object*
} catch (e) {
self._log(`GMStorage Error: Failed to save deep update for key '${topKey}'`, e);
// Optionally revert the change in the target object? Complex.
self._suppressGlobal = false;
return false; // Indicate failure
}
self._suppressGlobal = false;
self._log(
`GMStorage [deep]: Key '${topKey}', property '${String(prop)}' set. Old value at prop:`, oldValueAtProp, "New value:", value
);
// Notify listeners with the updated ROOT object
self._notifyAwaiters(topKey, rootObj);
// Notify always listeners with the cloned OLD ROOT and the current ROOT
self._notifyAlwaysListeners(topKey, oldRootClone, rootObj, false);
}
return success;
},
deleteProperty(target, prop) {
// Get the root object from the cache
const rootObj = self._data[topKey];
if (!rootObj) {
console.error(`GMStorage Error: Cannot find root object for key '${topKey}' during deep delete.`);
return false; // Indicate failure
}
// Check if the property actually exists before proceeding
if (!Reflect.has(target, prop)) {
return true; // Property doesn't exist, delete operation is trivially successful
}
// Clone the root object *before* deletion for accurate "oldValue".
const oldRootClone = self._cloneDeep(rootObj);
// Perform the deletion on the target object
const success = Reflect.deleteProperty(target, prop);
if (success) {
// Persist the change by saving the modified root object.
self._suppressGlobal = true;
try {
GM_setValue(topKey, rootObj);
} catch (e) {
self._log(`GMStorage Error: Failed to save deep delete for key '${topKey}'`, e);
// Optionally revert? Complex.
self._suppressGlobal = false;
return false; // Indicate failure
}
self._suppressGlobal = false;
self._log(
`GMStorage [deep]: Key '${topKey}', property '${String(prop)}' deleted.`
);
// Notify listeners with the updated ROOT object.
self._notifyAwaiters(topKey, rootObj);
// Notify always listeners with the cloned OLD ROOT and the current ROOT.
self._notifyAlwaysListeners(topKey, oldRootClone, rootObj, false);
}
return success;
},
// Add ownKeys and getOwnPropertyDescriptor for better compatibility if needed,
// especially if iterating over proxied objects.
ownKeys(target) {
return Reflect.ownKeys(target);
},
getOwnPropertyDescriptor(target, prop) {
return Reflect.getOwnPropertyDescriptor(target, prop);
},
has(target, prop) {
return Reflect.has(target, prop);
}
});
}
/**
* Internal handler for processing updates received from GM_addValueChangeListener
* or the patched global GM_setValue.
* @param {string} key The storage key that changed.
* @param {*} oldValueGM The old value provided by the GM listener or global patch.
* @param {*} newValueGM The new value provided by the GM listener or global patch.
* @param {boolean} remote True if the change came from GM_addValueChangeListener's `remote` flag.
* @private
*/
_handleRemoteUpdate(key, oldValueGM, newValueGM, remote) {
// oldValueGM from GM might not accurately reflect the state before a deep nested change if
// only GM_setValue was used externally on the root key.
// We'll use our cached `this._data[key]` as the more reliable 'previous' state for listeners.
const prevCachedValue = this._data[key];
// Determine the new value, creating a proxy if it's an object.
const newValue = this._isObject(newValueGM)
? this._createDeepProxy(newValueGM, key) // Create proxy for the new incoming object
: newValueGM;
// Update the local cache *after* potentially creating the proxy.
this._data[key] = newValue;
this._log(
`GMStorage (${remote ? 'remote' : 'external'}): Key '${key}' changed.`,
"Previous cached value:", prevCachedValue, // Log our cached value
"New value:", newValue // Log the processed new value
// Optionally log the values from GM for comparison:
// "GM Old:", oldValueGM,
// "GM New:", newValueGM
);
// Notify awaiters and always listeners.
this._notifyAwaiters(key, newValue);
// Crucially, pass the *previous cached value* as the `oldValue` argument to listeners,
// as it's likely more accurate than `oldValueGM` for deep objects.
this._notifyAlwaysListeners(key, prevCachedValue, newValue, remote);
}
/**
* Adds a GM storage change listener for remote/external changes.
* @param {string} key The storage key.
* @private
*/
_addChangeListener(key) {
if (this._listenerMap[key]) return; // Don't add multiple listeners for the same key
try {
const listenerId = GM_addValueChangeListener(key, (name, oldValueGM, newValueGM, remote) => {
// This callback handles *only truly remote* changes (from other tabs/scripts using GM API directly).
// Changes from the *patched* GM_setValue in the same tab are handled by the patch itself.
if (remote) {
this._handleRemoteUpdate(name, oldValueGM, newValueGM, true); // true for remote
}
// We don't need to handle non-remote (!remote) changes here, because:
// 1. If the change originated from *this* instance's proxy setters, _suppressGlobal prevents the global patch notification.
// 2. If the change originated from an *external* GM_setValue call in the *same* tab/window context,
// the global patch already called _handleRemoteUpdate with remote=false.
// Processing non-remote events here would lead to double notifications for external same-tab sets.
});
this._listenerMap[key] = listenerId;
} catch (e) {
this._log(`GMStorage Error: Failed to add GM value change listener for key '${key}'`, e);
}
}
/**
* Notifies all pending one-shot listeners waiting for the next update on a key.
* @param {string} key The storage key.
* @param {*} newValue The new value for the key (potentially proxied).
* @private
*/
_notifyAwaiters(key, newValue) {
if (this._pendingListeners[key] && this._pendingListeners[key].length > 0) {
this._log(`GMStorage: Notifying ${this._pendingListeners[key].length} awaiter(s) for key '${key}'`);
// Resolve with a deep clone to prevent listeners from modifying the shared cached object?
// const valueForListener = this._cloneDeep(newValue); // Consider implications
for (const resolve of this._pendingListeners[key]) {
try {
resolve(newValue); // Pass the cached (potentially proxied) value
} catch (e) {
console.error(`GMStorage: Error resolving awaiter promise for key '${key}'`, e);
}
}
this._pendingListeners[key] = []; // Clear listeners after resolving
}
}
/**
* Notifies all persistent listeners (registered via addListener) for a given key.
* @param {string} key The storage key.
* @param {*} oldValue The previous value (ideally from cache or clone before change).
* @param {*} newValue The new value (potentially proxied).
* @param {boolean} remote True if the change originated remotely.
* @private
*/
_notifyAlwaysListeners(key, oldValue, newValue, remote) {
if (this._alwaysListeners[key] && this._alwaysListeners[key].length > 0) {
this._log(`GMStorage: Notifying ${this._alwaysListeners[key].length} always listener(s) for key '${key}' (remote: ${remote})`);
// Provide clones to listeners to prevent them from modifying the internal cache?
// const oldClone = this._cloneDeep(oldValue);
// const newClone = this._cloneDeep(newValue);
for (const cb of this._alwaysListeners[key]) {
try {
// Pass the old and new values (potentially proxies) and the remote flag
cb(key, oldValue, newValue, remote);
} catch(e) {
console.error(`GMStorage: Error in alwaysListener callback for key '${key}':`, e);
}
}
}
}
/**
* Returns a promise that resolves on the next update for the specified key.
* @param {string} key The storage key.
* @returns {Promise<*>} A promise that resolves with the new value.
* @private
*/
_listenOnce(key) {
return new Promise((resolve) => {
if (!this._pendingListeners[key]) {
this._pendingListeners[key] = [];
}
this._pendingListeners[key].push(resolve);
this._log(`GMStorage: Awaiter added for next update of key '${key}'`);
});
}
// --- Public API Methods ---
/**
* Adds a persistent listener callback for a given key.
* The callback is triggered every time the value associated with the key is
* updated, either locally, remotely, or via external GM_setValue calls.
* The callback receives (key, oldValue, newValue, remote).
* Note: `oldValue` is the value before the update (based on internal cache or clones),
* `newValue` is the value after the update (potentially a proxy for objects).
*
* @param {string} key The storage key to listen to.
* @param {Function} callback A function with signature `(key: string, oldValue: any, newValue: any, remote: boolean) => void`.
*/
addListener(key, callback) {
if (typeof callback !== 'function') {
console.error(`GMStorage: addListener requires a function callback for key '${key}'.`);
return;
}
if (!this._alwaysListeners[key]) {
this._alwaysListeners[key] = [];
}
// Avoid adding the exact same callback multiple times
if (!this._alwaysListeners[key].includes(callback)) {
this._alwaysListeners[key].push(callback);
this._log(`GMStorage: Added always listener for key '${key}'`);
} else {
this._log(`GMStorage: Listener already exists for key '${key}', not adding again.`);
}
}
/**
* Removes a previously added persistent listener callback for a specific key.
*
* @param {string} key The storage key the listener was attached to.
* @param {Function} callback The exact callback function that was previously passed to addListener.
* @returns {boolean} True if a listener was found and removed, false otherwise.
*/
removeListener(key, callback) {
if (this._alwaysListeners[key]) {
const index = this._alwaysListeners[key].indexOf(callback);
if (index > -1) {
this._alwaysListeners[key].splice(index, 1);
this._log(`GMStorage: Removed always listener for key '${key}'`);
// Clean up the array if it becomes empty
if (this._alwaysListeners[key].length === 0) {
delete this._alwaysListeners[key];
}
return true;
}
}
this._log(`GMStorage: No matching always listener found to remove for key '${key}'`);
return false;
}
/**
* Returns the raw, non-proxied value for a given key from the storage.
* This is a deep clone of the stored data to strip out any proxy wrappers.
*
* @param {string} key - The storage key.
* @returns {*} The raw (non-proxied) value.
*/
getRaw(key) {
// If the stored value is an object, clone it to remove proxy wrappers.
// Otherwise, just return the value directly.
return this._isObject(this._data[key]) ? this._cloneDeep(this._data[key]) : this._data[key];
}
/**
* Returns an asynchronous listener object for a specific key.
* This object can be awaited directly to get the *next* update (like `_listenOnce`),
* or used in a `for await...of` loop to continuously receive all subsequent updates.
*
* Usage:
* // One-shot await:
* const nextValue = await storage.listen('some_key');
*
* // Continuous iteration:
* const listener = storage.listen('some_key');
* for await (const updatedValue of listener) {
* console.log("Key updated:", updatedValue);
* if (updatedValue.someCondition) break; // Stop listening
* }
* // Note: The loop will run indefinitely unless broken out of.
*
* @param {string} key The storage key to listen for updates.
* @returns {AsyncListener} An object that is both thenable (for single await) and async iterable (for `for await`).
*/
listen(key) {
// Ensure the key is being monitored by GM
if (!this._listenerMap[key] && !(key in this._data)) {
// If the key isn't in our cache yet (maybe never set),
// we might still want to listen for future remote sets. Add a listener.
this._addChangeListener(key);
} else if (!this._listenerMap[key] && key in this._data) {
// Key is known, but somehow listener wasn't added (shouldn't happen with current logic, but be safe)
this._addChangeListener(key);
}
return new AsyncListener(this, key);
}
/**
* Clears *all* GM_addValueChangeListeners added by *this specific instance*.
* Also clears internal pending awaiters and persistent listener callbacks.
* This instance will stop receiving remote updates after this is called.
* Note: This does NOT remove the global GM_setValue patch or affect other instances.
*/
clearListeners() {
this._log("GMStorage: Clearing all listeners for this instance.");
// Remove GM listeners
for (const key in this._listenerMap) {
try {
GM_removeValueChangeListener(this._listenerMap[key]);
} catch (e) {
this._log(`GMStorage Error: Failed to remove GM listener for key '${key}' (ID: ${this._listenerMap[key]})`, e);
}
delete this._listenerMap[key];
}
// Clear pending promises (reject them?)
for (const key in this._pendingListeners) {
// Maybe reject promises instead of leaving them hanging?
// for (const resolve of this._pendingListeners[key]) { /* Can't reject resolve */ }
delete this._pendingListeners[key]; // Just clear the array
}
this._pendingListeners = {}; // Reset the main object
// Clear persistent callbacks
this._alwaysListeners = {};
}
/**
* Removes this instance from the global list used by the GM_setValue patch.
* Call this when the storage instance is no longer needed to prevent memory leaks
* and unnecessary processing in the global patch. Also calls `clearListeners`.
*/
dispose() {
this._log("GMStorage: Disposing instance.");
this.clearListeners(); // Clear GM listeners and internal callbacks
// Remove from global instance list
if (window.__gmStorageInstances) {
const index = window.__gmStorageInstances.indexOf(this);
if (index > -1) {
window.__gmStorageInstances.splice(index, 1);
this._log("GMStorage: Removed instance from global list.");
}
}
// Optionally, try to nullify internal state?
this._data = {};
// Proxies make full cleanup hard, but this reduces footprint.
}
}
// --- AsyncListener Helper Class (same as before) ---
/**
* AsyncListener class: Enables both promise-based (await) and async iterator usage
* for listening to GMStorage updates.
*/
class AsyncListener {
/**
* @param {GMStorage} storage The GMStorage instance.
* @param {string} key The storage key to listen for.
*/
constructor(storage, key) {
this.storage = storage;
this.key = key;
this._iteratorActive = false; // Flag to prevent multiple concurrent iterations
}
/**
* Thenable implementation for `await storage.listen('key')`.
* Resolves with the *next* value update for the key.
* @param {Function} resolve Resolve callback.
* @param {Function} reject Reject callback.
* @returns {Promise<*>} A promise that resolves with the next update value.
*/
then(resolve, reject) {
// Ensure 'this' context is correct when _listenOnce is called
return this.storage._listenOnce(this.key).then(resolve, reject);
}
/**
* Async iterator implementation for `for await (const value of storage.listen('key'))`.
* Continuously yields successive update values for the key.
* @returns {Object} An async iterator.
*/
[Symbol.asyncIterator]() {
// Prevent multiple simultaneous iterations on the same listener object
if (this._iteratorActive) {
throw new Error("GMStorage: Async iterator already in use for this listener instance.");
}
this._iteratorActive = true;
const storage = this.storage;
const key = this.key;
const self = this; // Reference to this AsyncListener instance
return {
async next() {
// Use _listenOnce to wait for the next update
try {
const value = await storage._listenOnce(key);
// Check if the storage instance or key still seems valid (basic check)
if (!storage || !(key in storage._data) && !(key in storage._pendingListeners) && !(key in storage._alwaysListeners)) {
// If the key seems to have disappeared or storage disposed, end iteration
self._iteratorActive = false; // Release lock
return { value: undefined, done: true };
}
return { value: value, done: false }; // Yield the value
} catch (error) {
// If _listenOnce somehow rejects (e.g., if storage is disposed and promises are rejected)
console.error(`GMStorage: Error during async iteration for key '${key}'`, error);
self._iteratorActive = false; // Release lock
return { value: undefined, done: true }; // End iteration on error
}
},
// Optional: Implement a 'return' method to clean up when the loop breaks/exits early
async return() {
storage._log(`GMStorage: Async iterator for key '${key}' is ending.`);
self._iteratorActive = false; // Release the lock
// We don't need to explicitly remove listeners here, as _listenOnce only registers
// temporary awaiters. Persistent listeners aren't tied to the iterator lifecycle.
return { done: true };
},
// Optional: Implement 'throw' method for error propagation
async throw(error) {
console.error(`GMStorage: Error thrown into async iterator for key '${key}'`, error);
self._iteratorActive = false; // Release the lock
throw error; // Re-throw the error
}
};
}
}
// Example Usage (optional, for testing)
/*
(function() {
'use strict';
const initialDefaults = {
count: 0,
user: { name: 'Guest', loggedIn: false, prefs: { theme: 'light' } },
settings: { notifications: true, volume: 50 },
lastVisit: null
};
console.log("GMStorage Initializing...");
// Simulate existing data
// GM_setValue('settings', { volume: 75, experimental: true });
// GM_setValue('user', { name: 'Alice', loggedIn: true });
const storage = new GMStorage(initialDefaults, true); // Enable debugging
console.log("Initial storage state:", JSON.stringify({
count: storage.count,
user: storage.user,
settings: storage.settings,
lastVisit: storage.lastVisit
}, null, 2));
// --- Test Listeners ---
storage.addListener('count', (key, oldVal, newVal, remote) => {
console.log(`ALWAYS LISTENER: Count changed (Remote: ${remote})`, oldVal, '->', newVal);
});
storage.addListener('user', (key, oldVal, newVal, remote) => {
console.log(`ALWAYS LISTENER: User changed (Remote: ${remote})`, JSON.stringify(oldVal), '->', JSON.stringify(newVal));
});
// Test async iterator
async function watchSettings() {
console.log("Starting settings watch (for await)...");
try {
for await (const settings of storage.listen('settings')) {
console.log("FOR AWAIT: Settings updated:", JSON.stringify(settings));
// Example exit condition
if (settings.volume > 90) {
console.log("FOR AWAIT: Volume > 90, stopping watch.");
break;
}
}
} catch (e) {
console.error("Settings watch loop error:", e);
} finally {
console.log("Settings watch loop finished.");
}
}
// watchSettings(); // Uncomment to run the async iterator test
// Test one-shot await
async function getNextVisit() {
console.log("Waiting for next 'lastVisit' update (await)...");
const nextVisit = await storage.listen('lastVisit');
console.log("AWAIT: Got next lastVisit:", nextVisit);
}
// getNextVisit(); // Uncomment to run the one-shot await test
// --- Test Modifications ---
console.log("--- Making changes ---");
// Simple set
setTimeout(() => {
console.log("Setting count = 1");
storage.count = 1; // Should trigger count listener
}, 1000);
// Set same value (should still trigger always listener)
setTimeout(() => {
console.log("Setting count = 1 (again)");
storage.count = 1; // Should trigger count listener again
}, 2000);
// Deep object modification
setTimeout(() => {
console.log("Setting user.prefs.theme = 'dark'");
if (storage.user && storage.user.prefs) {
storage.user.prefs.theme = 'dark'; // Should trigger user listener
} else { console.log("Cannot set theme, user/prefs missing"); }
}, 3000);
// Replace whole object
setTimeout(() => {
console.log("Setting user = { name: 'Bob', loggedIn: true }");
storage.user = { name: 'Bob', loggedIn: true }; // Should trigger user listener (no prefs now)
}, 4000);
// Modify settings (for the async iterator)
setTimeout(() => {
console.log("Setting settings.volume = 80");
if (storage.settings) {
storage.settings.volume = 80;
}
}, 5000);
// Set lastVisit (for the one-shot await)
setTimeout(() => {
console.log("Setting lastVisit = Date.now()");
storage.lastVisit = Date.now();
}, 6000);
// Modify settings again (to potentially stop the iterator)
setTimeout(() => {
console.log("Setting settings.volume = 95");
if (storage.settings) {
storage.settings.volume = 95;
}
}, 7000);
// Simulate external set using global GM_setValue (triggers global patch)
setTimeout(() => {
console.log("Simulating external GM_setValue('count', 100)");
GM_setValue('count', 100); // Use the (potentially patched) global function
}, 8000);
// Test dispose
// setTimeout(() => {
// console.log("Disposing storage instance");
// storage.dispose();
// // Further changes should not trigger listeners of the disposed instance
// // External sets might still trigger listeners of *other* instances if they exist.
// GM_setValue('count', 500);
// }, 9000);
})();
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment