@chromahq/store
Version:
Centralized, persistent store for Chrome extensions using zustand, accessible from service workers and React, with chrome.storage.local persistence.
849 lines (837 loc) • 27 kB
JavaScript
'use strict';
var React = require('react');
var vanilla = require('zustand/vanilla');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var React__namespace = /*#__PURE__*/_interopNamespaceDefault(React);
function chromeStoragePersist(options = {}) {
return (config) => (set, get, store) => {
const key = options.name;
let isInitialized = false;
let persistenceSetup = false;
const initialState = config(set, get, store);
const loadPersistedState = async () => {
try {
if (!chrome?.storage?.local) {
isInitialized = true;
setupPersistence();
return;
}
const result = await new Promise((resolve, reject) => {
chrome.storage.local.get([key], (result2) => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError);
} else {
resolve(result2);
}
});
});
if (result[key]) {
const mergedState = { ...initialState, ...result[key] };
set(mergedState);
} else {
await persistState(initialState);
}
} catch (error) {
console.error(`Failed to load persisted state for "${key}":`, error);
} finally {
isInitialized = true;
setupPersistence();
if (options.onReady) {
options.onReady();
}
}
};
let persistDebounceTimer = null;
const PERSIST_DEBOUNCE_MS = 500;
const persistState = async (state) => {
if (!chrome?.storage?.local) {
return;
}
return new Promise((resolve) => {
chrome.storage.local.set({ [key]: state }, () => {
if (chrome.runtime.lastError) {
console.error(`Failed to persist state for "${key}":`, chrome.runtime.lastError);
}
resolve();
});
});
};
const setupPersistence = () => {
if (persistenceSetup) return;
persistenceSetup = true;
store.subscribe((state) => {
if (persistDebounceTimer) {
clearTimeout(persistDebounceTimer);
}
persistDebounceTimer = setTimeout(() => {
persistDebounceTimer = null;
persistState(state);
}, PERSIST_DEBOUNCE_MS);
});
};
loadPersistedState();
return initialState;
};
}
function useCentralStore(store, selector) {
return React.useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState())
);
}
function useCentralDispatch(store) {
return store.setState;
}
function useStoreReady(store) {
return React.useSyncExternalStore(
store.onReady,
store.isReady,
() => false
// Server-side fallback
);
}
function useStoreReset(store) {
return store.reset;
}
class BridgeStore {
// Consider state stale after 30s hidden
constructor(bridge, initialState, storeName = "default", readyCallbacks = /* @__PURE__ */ new Set()) {
this.listeners = /* @__PURE__ */ new Set();
this.currentState = null;
this.previousState = null;
this.initialState = null;
this.ready = false;
this.readyCallbacks = /* @__PURE__ */ new Set();
this.initializationAttempts = 0;
this.maxInitializationAttempts = 10;
this.initializationTimer = null;
this.isInitializing = false;
// Store handler references for cleanup (prevents memory leaks)
this.reconnectHandler = null;
this.disconnectHandler = null;
this.stateChangedHandler = null;
// Debounce timer for state sync (optimization for rapid updates)
this.stateSyncDebounceTimer = null;
this.stateSyncDebounceMs = 50;
// Reduced to 50ms for faster reactivity
// Reconnect delay timer (to allow SW to bootstrap before re-initializing)
this.reconnectDelayTimer = null;
// Visibility change handling - refresh state when tab becomes visible
this.visibilityHandler = null;
this.lastVisibleAt = Date.now();
this.staleThresholdMs = 3e4;
this.initialize = async () => {
if (this.isInitializing) {
return;
}
if (this.initializationTimer) {
clearTimeout(this.initializationTimer);
this.initializationTimer = null;
}
this.initializationAttempts++;
this.isInitializing = true;
try {
if (this.initializationAttempts > this.maxInitializationAttempts) {
console.error(
`BridgeStore[${this.storeName}]: Max initialization attempts (${this.maxInitializationAttempts}) reached, giving up`
);
this.isInitializing = false;
return;
}
if (!this.bridge.isConnected) {
const delay = Math.min(500 * Math.pow(2, this.initializationAttempts - 1), 5e3);
this.isInitializing = false;
this.initializationTimer = setTimeout(() => this.initialize(), delay);
return;
}
const state = await this.bridge.send(`store:${this.storeName}:getState`);
this.previousState = this.currentState;
this.currentState = state;
if (this.initialState === null) {
this.initialState = state;
}
this.notifyListeners();
this.ready = true;
this.isInitializing = false;
this.notifyReady();
} catch (error) {
this.isInitializing = false;
console.error(
`BridgeStore[${this.storeName}]: Failed to initialize (attempt ${this.initializationAttempts}):`,
error
);
if (this.initializationAttempts < this.maxInitializationAttempts) {
const delay = Math.min(1e3 * Math.pow(2, this.initializationAttempts - 1), 1e4);
this.initializationTimer = setTimeout(() => this.initialize(), delay);
} else {
console.error(`BridgeStore[${this.storeName}]: Max attempts reached, cannot retry`);
}
}
};
this.stateSyncSequence = 0;
this.pendingStateSync = false;
this.notifyListeners = () => {
if (!this.listeners) {
return;
}
if (this.currentState && this.previousState) {
this.listeners.forEach((listener) => listener(this.currentState, this.previousState));
}
};
this.getState = () => {
return this.currentState;
};
this.subscribe = (listener) => {
if (!this.listeners) {
console.error("BridgeStore: Cannot subscribe, listeners not initialized");
return () => {
};
}
this.listeners.add(listener);
if (this.currentState && this.previousState) {
listener(this.currentState, this.previousState);
}
return () => {
if (this.listeners) {
this.listeners.delete(listener);
}
};
};
// Additional StoreApi methods
this.destroy = () => {
if (this.initializationTimer) {
clearTimeout(this.initializationTimer);
this.initializationTimer = null;
}
if (this.stateSyncDebounceTimer) {
clearTimeout(this.stateSyncDebounceTimer);
this.stateSyncDebounceTimer = null;
}
if (this.reconnectDelayTimer) {
clearTimeout(this.reconnectDelayTimer);
this.reconnectDelayTimer = null;
}
if (this.bridge.off) {
if (this.reconnectHandler) {
this.bridge.off("bridge:connected", this.reconnectHandler);
this.reconnectHandler = null;
}
if (this.disconnectHandler) {
this.bridge.off("bridge:disconnected", this.disconnectHandler);
this.disconnectHandler = null;
}
if (this.stateChangedHandler) {
this.bridge.off(`store:${this.storeName}:stateChanged`, this.stateChangedHandler);
this.stateChangedHandler = null;
}
}
if (this.visibilityHandler && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", this.visibilityHandler);
this.visibilityHandler = null;
}
if (this.listeners) {
this.listeners.clear();
}
this.readyCallbacks.clear();
};
this.getInitialState = () => {
return this.getState();
};
this.isReady = () => {
return this.ready;
};
this.onReady = (callback) => {
if (this.ready) {
callback();
} else {
this.readyCallbacks.add(callback);
}
return () => {
this.readyCallbacks.delete(callback);
};
};
this.reset = () => {
if (this.initialState !== null) {
if (!this.bridge.isConnected) {
this.previousState = this.currentState;
this.currentState = { ...this.initialState };
this.notifyListeners();
return;
}
const stateBeforeReset = this.currentState ? { ...this.currentState } : null;
this.previousState = this.currentState;
this.currentState = { ...this.initialState };
this.notifyListeners();
this.bridge.send(`store:${this.storeName}:reset`).catch((error) => {
console.error(`BridgeStore[${this.storeName}]: Failed to reset state via bridge:`, error);
if (stateBeforeReset !== null) {
this.previousState = this.currentState;
this.currentState = stateBeforeReset;
this.notifyListeners();
}
});
}
};
this.notifyReady = () => {
this.readyCallbacks.forEach((callback) => callback());
this.readyCallbacks.clear();
};
/**
* Force re-initialization of the store (useful for debugging or after reconnection)
*/
this.forceInitialize = async () => {
if (this.initializationTimer) {
clearTimeout(this.initializationTimer);
this.initializationTimer = null;
}
this.ready = false;
this.isInitializing = false;
this.initializationAttempts = 0;
await this.initialize();
};
/**
* Get debug information about the store state
*/
this.getDebugInfo = () => {
return {
storeName: this.storeName,
ready: this.ready,
isInitializing: this.isInitializing,
bridgeConnected: this.bridge.isConnected,
hasCurrentState: this.currentState !== null,
hasInitialState: this.initialState !== null,
readyCallbacksCount: this.readyCallbacks.size,
initializationAttempts: this.initializationAttempts,
maxInitializationAttempts: this.maxInitializationAttempts
};
};
/**
* Update the bridge reference and re-register all event listeners.
* Called when createBridgeStore receives a new bridge object (e.g., after React remount).
* This is critical for React StrictMode which causes double-mounting.
*/
this.updateBridge = (newBridge) => {
if (this.bridge === newBridge) {
return;
}
this.bridge = newBridge;
this.reregisterEventListeners();
};
this.bridge = bridge;
this.currentState = initialState || null;
this.previousState = initialState || null;
this.initialState = initialState || null;
this.storeName = storeName;
this.readyCallbacks = readyCallbacks;
this.setupStateSync();
this.setupReconnectListener();
this.setupVisibilityListener();
this.initialize();
}
setupReconnectListener() {
if (this.bridge.on) {
this.disconnectHandler = () => {
this.ready = false;
if (this.stateSyncDebounceTimer) {
clearTimeout(this.stateSyncDebounceTimer);
this.stateSyncDebounceTimer = null;
}
this.pendingStateSync = false;
this.isInitializing = false;
if (this.initializationTimer) {
clearTimeout(this.initializationTimer);
this.initializationTimer = null;
}
};
this.bridge.on("bridge:disconnected", this.disconnectHandler);
this.reconnectHandler = () => {
if (this.reconnectDelayTimer) {
clearTimeout(this.reconnectDelayTimer);
this.reconnectDelayTimer = null;
}
this.reregisterEventListeners();
this.forceInitialize();
};
this.bridge.on("bridge:connected", this.reconnectHandler);
}
}
/**
* Re-register all event listeners on the bridge
* Called after reconnection because React StrictMode may have created a new eventListenersRef
* IMPORTANT: Remove existing listeners first to prevent duplicate handlers
*/
reregisterEventListeners() {
if (!this.bridge.on) return;
const eventKey = `store:${this.storeName}:stateChanged`;
if (this.stateChangedHandler) {
if (this.bridge.off) {
this.bridge.off(eventKey, this.stateChangedHandler);
}
this.bridge.on(eventKey, this.stateChangedHandler);
}
if (this.disconnectHandler) {
if (this.bridge.off) {
this.bridge.off("bridge:disconnected", this.disconnectHandler);
}
this.bridge.on("bridge:disconnected", this.disconnectHandler);
}
if (this.reconnectHandler) {
if (this.bridge.off) {
this.bridge.off("bridge:connected", this.reconnectHandler);
}
this.bridge.on("bridge:connected", this.reconnectHandler);
}
}
setupVisibilityListener() {
if (typeof document === "undefined") return;
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
const hiddenDuration = Date.now() - this.lastVisibleAt;
if (hiddenDuration > this.staleThresholdMs && this.ready && this.bridge.isConnected) {
this.fetchAndApplyState();
}
this.lastVisibleAt = Date.now();
}
};
document.addEventListener("visibilitychange", this.visibilityHandler);
}
/**
* Apply state directly from broadcast payload (no round-trip)
*/
applyBroadcastState(newState) {
if (!newState || typeof newState !== "object") {
return;
}
this.previousState = this.currentState;
this.currentState = newState;
this.notifyListeners();
}
/**
* Fetch state from SW (fallback when broadcast doesn't include payload)
*/
fetchAndApplyState() {
if (this.pendingStateSync) {
return;
}
this.pendingStateSync = true;
const currentSequence = ++this.stateSyncSequence;
this.bridge.send(`store:${this.storeName}:getState`).then((newState) => {
if (currentSequence === this.stateSyncSequence) {
this.previousState = this.currentState;
this.currentState = newState;
this.notifyListeners();
}
}).catch((error) => {
console.error(`BridgeStore[${this.storeName}]: Failed to sync state:`, error);
}).finally(() => {
this.pendingStateSync = false;
});
}
setupStateSync() {
if (this.bridge.on) {
this.stateChangedHandler = (payload) => {
if (this.stateSyncDebounceTimer) {
clearTimeout(this.stateSyncDebounceTimer);
}
this.stateSyncDebounceTimer = setTimeout(() => {
this.stateSyncDebounceTimer = null;
if (payload && typeof payload === "object") {
this.applyBroadcastState(payload);
} else {
this.fetchAndApplyState();
}
}, this.stateSyncDebounceMs);
};
const eventKey = `store:${this.storeName}:stateChanged`;
this.bridge.on(eventKey, this.stateChangedHandler);
}
}
setState(partial, replace) {
let actualUpdate;
if (typeof partial === "function") {
if (this.currentState === null) {
return;
}
actualUpdate = partial(this.currentState);
} else {
actualUpdate = partial;
}
const stateBeforeUpdate = this.currentState ? { ...this.currentState } : null;
this.applyOptimisticUpdate(actualUpdate, replace);
const payload = { partial: actualUpdate, replace };
this.bridge.send(`store:${this.storeName}:setState`, payload).catch((error) => {
console.error(`BridgeStore[${this.storeName}]: Failed to update state via bridge:`, error);
if (stateBeforeUpdate !== null) {
this.previousState = this.currentState;
this.currentState = stateBeforeUpdate;
this.notifyListeners();
}
});
}
applyOptimisticUpdate(actualUpdate, replace) {
if (this.currentState) {
this.previousState = this.currentState;
if (replace) {
this.currentState = actualUpdate;
} else {
this.currentState = { ...this.currentState, ...actualUpdate };
}
this.notifyListeners();
}
}
}
const storeCache = /* @__PURE__ */ new Map();
function createBridgeStore(bridge, initialState, storeName = "default", readyCallbacks = /* @__PURE__ */ new Set()) {
if (storeCache.has(storeName)) {
const cached = storeCache.get(storeName);
cached.updateBridge(bridge);
readyCallbacks.forEach((cb) => cached.onReady(cb));
return cached;
}
const store = new BridgeStore(bridge, initialState, storeName, readyCallbacks);
storeCache.set(storeName, store);
return store;
}
function clearStoreCache() {
storeCache.clear();
}
function destroyStore(storeName) {
const store = storeCache.get(storeName);
if (store) {
store.destroy();
storeCache.delete(storeName);
}
}
function createActionHookForStore(store, actionsFactory) {
return function useCustomActions() {
const baseActions = useStoreActions(store);
return React.useMemo(() => actionsFactory(baseActions), [baseActions]);
};
}
function useStoreActions(store) {
return React.useMemo(
() => ({
update: (partial) => {
store.setState((state) => ({ ...state, ...partial }));
},
updateWith: (updater) => {
store.setState((state) => updater(state));
},
replace: (newState) => {
store.setState(newState, true);
},
setState: store.setState.bind(store)
}),
[store]
);
}
function createStoreHooks() {
const StoreContext = React.createContext(null);
function StoreProvider({ store, children }) {
const storeRef = React.useRef(store);
return React__namespace.createElement(StoreContext.Provider, { value: storeRef.current }, children);
}
function useStore(selector) {
const store = React.useContext(StoreContext);
if (!store) throw new Error("useStore must be used within a StoreProvider");
return useCentralStore(store, selector);
}
function useStoreInstance() {
const store = React.useContext(StoreContext);
if (!store) throw new Error("useStoreInstance must be used within a StoreProvider");
return store;
}
function useActions() {
const store = useStoreInstance();
return useStoreActions(store);
}
function useAction(actionKey) {
const store = useStoreInstance();
return React__namespace.useCallback(
(...args) => {
const fn = store.getState()[actionKey];
if (typeof fn !== "function") {
throw new Error("useAction only supports function actions");
}
return fn(...args);
},
[store, actionKey]
);
}
function createActionHook(actionsFactory) {
return function useCustomActions() {
const store = useStoreInstance();
const baseActions = useStoreActions(store);
return React.useMemo(() => actionsFactory(baseActions), [baseActions]);
};
}
return {
createActionHook,
StoreProvider,
useStore,
useStoreInstance,
useActions,
useAction
};
}
const readyCallbacks = /* @__PURE__ */ new Set();
class StoreBuilder {
constructor(name = "default") {
this.onReadyCallbacks = /* @__PURE__ */ new Set();
this.config = {
name,
slices: []
};
}
/**
* Add state slices to the store
*/
withSlices(...slices) {
this.config.slices = [...this.config.slices, ...slices];
return this;
}
onReady(callback) {
this.onReadyCallbacks.add(callback);
return this;
}
/**
* Attach a bridge for cross-context communication
*/
withBridge(bridge) {
this.config.bridge = bridge;
return this;
}
/**
* Enable persistence with Chrome storage
*/
withPersistence(options) {
this.config.persistence = options;
return this;
}
/**
* Create the store
*/
async create() {
if (this.config.slices.length === 0) {
throw new Error("Store must have at least one slice. Use withSlices() to add state.");
}
return await this.createBaseStore();
}
async createBaseStore() {
const bridge = this.config.bridge;
if (bridge) {
return createBridgeStore(bridge, void 0, this.config.name, this.onReadyCallbacks);
}
return this.createServiceWorkerStore();
}
createServiceWorkerStore() {
let isReady = false;
let initialState = null;
let runtimeBridge;
const notifyReady = () => {
isReady = true;
readyCallbacks.forEach((callback) => callback());
this.onReadyCallbacks.forEach((callback) => callback());
readyCallbacks.clear();
this.onReadyCallbacks.clear();
};
const creator = (set, get, store2) => {
let state = {};
for (const slice of this.config.slices) {
const sliceState = slice(set, get, store2);
state = { ...state, ...sliceState };
}
if (initialState === null) {
initialState = { ...state };
}
return state;
};
const persistOptions = {
name: this.config.name,
onReady: notifyReady
};
const persistedCreator = chromeStoragePersist(persistOptions)(creator);
const store = vanilla.createStore(persistedCreator);
let broadcastDebounceTimer = null;
const BROADCAST_DEBOUNCE_MS = 50;
store.subscribe(() => {
if (runtimeBridge) {
if (broadcastDebounceTimer) {
clearTimeout(broadcastDebounceTimer);
}
broadcastDebounceTimer = setTimeout(() => {
broadcastDebounceTimer = null;
runtimeBridge.broadcast(`store:${this.config.name}:stateChanged`, store.getState());
}, BROADCAST_DEBOUNCE_MS);
}
});
const centralStore = Object.assign(store, {
isReady: () => isReady,
reset: () => {
if (initialState !== null) {
store.setState(initialState, true);
}
},
setBridge: (bridge) => {
runtimeBridge = bridge;
},
onReady: (callback) => {
if (isReady) {
callback();
} else {
readyCallbacks.add(callback);
}
return () => {
readyCallbacks.delete(callback);
};
}
});
return centralStore;
}
}
function createStore(name) {
return new StoreBuilder(name);
}
function createServiceWorkerStore(slices, name = "default", persistOptions) {
const sliceArray = Array.isArray(slices) ? slices : [slices];
let builder = createStore(name).withSlices(...sliceArray);
if (persistOptions) {
builder = builder.withPersistence(persistOptions);
}
return builder.create();
}
function createUIStore(bridge, initialState, name = "default") {
return createBridgeStore(bridge, initialState, name);
}
function autoRegisterStoreHandlers(store, storeName = "default") {
if (!store) {
throw new Error("autoRegisterStoreHandlers: store parameter is required");
}
if (typeof store.getState !== "function") {
throw new Error("autoRegisterStoreHandlers: store must have getState method");
}
if (typeof store.setState !== "function") {
throw new Error("autoRegisterStoreHandlers: store must have setState method");
}
class AutoGetStoreStateMessage {
handle() {
if (!store) {
console.error(`[Store] Store instance not available for "${storeName}"`);
throw new Error("Store instance not available");
}
try {
return store.getState();
} catch (error) {
console.error(`[Store] GetState failed for "${storeName}":`, error);
throw error;
}
}
}
class AutoSetStoreStateMessage {
handle(...args) {
if (!store) {
throw new Error("Store instance not available");
}
let partial;
let replace = false;
if (args.length === 1 && args[0] && typeof args[0] === "object") {
const payload = args[0];
if ("partial" in payload) {
({ partial, replace = false } = payload);
} else {
partial = payload;
replace = false;
}
} else if (args.length >= 2) {
partial = args[0];
replace = args[1] || false;
} else if (args.length === 1) {
partial = args[0];
replace = false;
} else {
return store.getState();
}
if (partial === void 0) {
return store.getState();
}
if (replace) {
store.setState(partial, true);
} else {
store.setState(partial);
}
const updatedState = store.getState();
return updatedState;
}
}
class AutoResetStoreMessage {
handle() {
if (!store) {
console.error(`[Store] Store instance not available for "${storeName}"`);
throw new Error("Store instance not available");
}
try {
if (typeof store.reset === "function") {
store.reset();
}
return store.getState();
} catch (error) {
console.error(`[Store] Reset failed for "${storeName}":`, error);
throw error;
}
}
}
return {
GetStoreStateMessage: AutoGetStoreStateMessage,
SetStoreStateMessage: AutoSetStoreStateMessage,
ResetStoreMessage: AutoResetStoreMessage
};
}
async function init(storeDefinition) {
try {
let builder = createStore(storeDefinition.name);
if (storeDefinition.slices) {
builder = builder.withSlices(...storeDefinition.slices);
}
const store = await builder.create();
return {
def: storeDefinition,
store,
classes: autoRegisterStoreHandlers(store, storeDefinition.name)
};
} catch (error) {
console.error(`Failed to initialize store "${storeDefinition.name}":`, error);
throw error;
}
}
exports.BridgeStore = BridgeStore;
exports.StoreBuilder = StoreBuilder;
exports.chromeStoragePersist = chromeStoragePersist;
exports.clearStoreCache = clearStoreCache;
exports.createActionHookForStore = createActionHookForStore;
exports.createBridgeStore = createBridgeStore;
exports.createServiceWorkerStore = createServiceWorkerStore;
exports.createStore = createStore;
exports.createStoreHooks = createStoreHooks;
exports.createUIStore = createUIStore;
exports.destroyStore = destroyStore;
exports.init = init;
exports.useCentralDispatch = useCentralDispatch;
exports.useCentralStore = useCentralStore;
exports.useStoreReady = useStoreReady;
exports.useStoreReset = useStoreReset;