Skip to content

Commit

Permalink
feat: add session storage access levels, live update across instances…
Browse files Browse the repository at this point in the history
… + TSDocs IntelliSense for guidance (#308)

Co-authored-by: JongHak Seo <[email protected]>
  • Loading branch information
D1no and Jonghakseo authored Dec 11, 2023
1 parent ea44474 commit c580f70
Show file tree
Hide file tree
Showing 2 changed files with 141 additions and 18 deletions.
158 changes: 140 additions & 18 deletions src/shared/storages/base.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,46 @@
/**
* Storage area type for persisting and exchanging data.
* @see https://developer.chrome.com/docs/extensions/reference/storage/#overview
*/
export enum StorageType {
/**
* Persist data locally against browser restarts. Will be deleted by uninstalling the extension.
* @default
*/
Local = 'local',
/**
* Uploads data to the users account in the cloud and syncs to the users browsers on other devices. Limits apply.
*/
Sync = 'sync',
/**
* Requires an [enterprise policy](https://www.chromium.org/administrators/configuring-policy-for-extensions) with a
* json schema for company wide config.
*/
Managed = 'managed',
/**
* Only persist data until the browser is closed. Recommended for service workers which can shutdown anytime and
* therefore need to restore their state. Set {@link SessionAccessLevel} for permitting content scripts access.
* @implements Chromes [Session Storage](https://developer.chrome.com/docs/extensions/reference/storage/#property-session)
*/
Session = 'session',
}

/**
* Global access level requirement for the {@link StorageType.Session} Storage Area.
* @implements Chromes [Session Access Level](https://developer.chrome.com/docs/extensions/reference/storage/#method-StorageArea-setAccessLevel)
*/
export enum SessionAccessLevel {
/**
* Storage can only be accessed by Extension pages (not Content scripts).
* @default
*/
ExtensionPagesOnly = 'TRUSTED_CONTEXTS',
/**
* Storage can be accessed by both Extension pages and Content scripts.
*/
ExtensionPagesAndContentScripts = 'TRUSTED_AND_UNTRUSTED_CONTEXTS',
}

type ValueOrUpdate<D> = D | ((prev: D) => Promise<D> | D);

export type BaseStorage<D> = {
Expand All @@ -14,15 +50,94 @@ export type BaseStorage<D> = {
subscribe: (listener: () => void) => () => void;
};

export function createStorage<D>(key: string, fallback: D, config?: { storageType?: StorageType }): BaseStorage<D> {
type StorageConfig = {
/**
* Assign the {@link StorageType} to use.
* @default Local
*/
storageType?: StorageType;
/**
* Only for {@link StorageType.Session}: Grant Content scripts access to storage area?
* @default false
*/
sessionAccessForContentScripts?: boolean;
/**
* Keeps state live in sync between all instances of the extension. Like between popup, side panel and content scripts.
* To allow chrome background scripts to stay in sync as well, use {@link StorageType.Session} storage area with
* {@link StorageConfig.sessionAccessForContentScripts} potentially also set to true.
* @see https://stackoverflow.com/a/75637138/2763239
* @default false
*/
liveUpdate?: boolean;
};

/**
* Sets or updates an arbitrary cache with a new value or the result of an update function.
*/
async function updateCache<D>(valueOrUpdate: ValueOrUpdate<D>, cache: D | null): Promise<D> {
// Type guard to check if our value or update is a function
function isFunction<D>(value: ValueOrUpdate<D>): value is (prev: D) => D | Promise<D> {
return typeof value === 'function';
}

// Type guard to check in case of a function, if its a Promise
function returnsPromise<D>(func: (prev: D) => D | Promise<D>): boolean {
// Use ReturnType to infer the return type of the function and check if it's a Promise
return (func as (prev: D) => Promise<D>) instanceof Promise;
}

if (isFunction(valueOrUpdate)) {
// Check if the function returns a Promise
if (returnsPromise(valueOrUpdate)) {
return await valueOrUpdate(cache);
} else {
return valueOrUpdate(cache);
}
} else {
return valueOrUpdate;
}
}

/**
* If one session storage needs access from content scripts, we need to enable it globally.
* @default false
*/
let globalSessionAccessLevelFlag: StorageConfig['sessionAccessForContentScripts'] = false;

/**
* Checks if the storage permission is granted in the manifest.json.
*/
function checkStoragePermission(storageType: StorageType): void {
if (chrome.storage[storageType] === undefined) {
throw new Error(`Check your storage permission in manifest.json: ${storageType} is not defined`);
}
}

/**
* Creates a storage area for persisting and exchanging data.
*/
export function createStorage<D>(key: string, fallback: D, config?: StorageConfig): BaseStorage<D> {
let cache: D | null = null;
let listeners: Array<() => void> = [];
const storageType = config?.storageType ?? StorageType.Local;
const liveUpdate = config?.liveUpdate ?? false;

// Set global session storage access level for StoryType.Session, only when not already done but needed.
if (
globalSessionAccessLevelFlag === false &&
storageType === StorageType.Session &&
config?.sessionAccessForContentScripts === true
) {
checkStoragePermission(storageType);
chrome.storage[storageType].setAccessLevel({
accessLevel: SessionAccessLevel.ExtensionPagesAndContentScripts,
});
globalSessionAccessLevelFlag = true;
}

// Register life cycle methods
const _getDataFromStorage = async (): Promise<D> => {
if (chrome.storage[storageType] === undefined) {
throw new Error(`Check your storage permission into manifest.json: ${storageType} is not defined`);
}
checkStoragePermission(storageType);
const value = await chrome.storage[storageType].get([key]);
return value[key] ?? fallback;
};
Expand All @@ -32,20 +147,8 @@ export function createStorage<D>(key: string, fallback: D, config?: { storageTyp
};

const set = async (valueOrUpdate: ValueOrUpdate<D>) => {
if (typeof valueOrUpdate === 'function') {
// eslint-disable-next-line no-prototype-builtins
if (valueOrUpdate.hasOwnProperty('then')) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
cache = await valueOrUpdate(cache);
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
cache = valueOrUpdate(cache);
}
} else {
cache = valueOrUpdate;
}
cache = await updateCache(valueOrUpdate, cache);

await chrome.storage[storageType].set({ [key]: cache });
_emitChange();
};
Expand All @@ -66,6 +169,25 @@ export function createStorage<D>(key: string, fallback: D, config?: { storageTyp
_emitChange();
});

// Listener for live updates from the browser
async function _updateFromStorageOnChanged(changes: { [key: string]: chrome.storage.StorageChange }) {
// Check if the key we are listening for is in the changes object
if (changes[key] === undefined) return;

const valueOrUpdate: ValueOrUpdate<D> = changes[key].newValue;

if (cache === valueOrUpdate) return;

cache = await updateCache(valueOrUpdate, cache);

_emitChange();
}

// Register listener for live updates for our storage area
if (liveUpdate) {
chrome.storage[storageType].onChanged.addListener(_updateFromStorageOnChanged);
}

return {
get: _getDataFromStorage,
set,
Expand Down
1 change: 1 addition & 0 deletions src/shared/storages/exampleThemeStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type ThemeStorage = BaseStorage<Theme> & {

const storage = createStorage<Theme>('theme-storage-key', 'light', {
storageType: StorageType.Local,
liveUpdate: true,
});

const exampleThemeStorage: ThemeStorage = {
Expand Down

0 comments on commit c580f70

Please sign in to comment.