Initial commit
This commit is contained in:
1
packages/storage/index.mts
Normal file
1
packages/storage/index.mts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/index.js';
|
||||
157
packages/storage/lib/base/base.ts
Normal file
157
packages/storage/lib/base/base.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { SessionAccessLevelEnum, StorageEnum } from './enums.js';
|
||||
import type { BaseStorageType, StorageConfigType, ValueOrUpdateType } from './types.js';
|
||||
|
||||
/**
|
||||
* Chrome reference error while running `processTailwindFeatures` in tailwindcss.
|
||||
* To avoid this, we need to check if globalThis.chrome is available and add fallback logic.
|
||||
*/
|
||||
const chrome = globalThis.chrome;
|
||||
|
||||
/**
|
||||
* Sets or updates an arbitrary cache with a new value or the result of an update function.
|
||||
*/
|
||||
const updateCache = async <D>(valueOrUpdate: ValueOrUpdateType<D>, cache: D | null): Promise<D> => {
|
||||
// Type guard to check if our value or update is a function
|
||||
const isFunction = <D>(value: ValueOrUpdateType<D>): value is (prev: D) => D | Promise<D> =>
|
||||
typeof value === 'function';
|
||||
|
||||
// Type guard to check in case of a function if it's a Promise
|
||||
const returnsPromise = <D>(func: (prev: D) => D | Promise<D>): func is (prev: D) => Promise<D> =>
|
||||
// Use ReturnType to infer the return type of the function and check if it's a Promise
|
||||
(func as (prev: D) => Promise<D>) instanceof Promise;
|
||||
if (isFunction(valueOrUpdate)) {
|
||||
// Check if the function returns a Promise
|
||||
if (returnsPromise(valueOrUpdate)) {
|
||||
return valueOrUpdate(cache as D);
|
||||
} else {
|
||||
return valueOrUpdate(cache as D);
|
||||
}
|
||||
} else {
|
||||
return valueOrUpdate;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If one session storage needs access from content scripts, we need to enable it globally.
|
||||
* @default false
|
||||
*/
|
||||
let globalSessionAccessLevelFlag: StorageConfigType['sessionAccessForContentScripts'] = false;
|
||||
|
||||
/**
|
||||
* Checks if the storage permission is granted in the manifest.json.
|
||||
*/
|
||||
const checkStoragePermission = (storageEnum: StorageEnum): void => {
|
||||
if (!chrome) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chrome.storage[storageEnum]) {
|
||||
throw new Error(`"storage" permission in manifest.ts: "storage ${storageEnum}" isn't defined`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a storage area for persisting and exchanging data.
|
||||
*/
|
||||
export const createStorage = <D = string>(
|
||||
key: string,
|
||||
fallback: D,
|
||||
config?: StorageConfigType<D>,
|
||||
): BaseStorageType<D> => {
|
||||
let cache: D | null = null;
|
||||
let initialCache = false;
|
||||
let listeners: Array<() => void> = [];
|
||||
|
||||
const storageEnum = config?.storageEnum ?? StorageEnum.Local;
|
||||
const liveUpdate = config?.liveUpdate ?? false;
|
||||
|
||||
const serialize = config?.serialization?.serialize ?? ((v: D) => v);
|
||||
const deserialize = config?.serialization?.deserialize ?? (v => v as D);
|
||||
|
||||
// Set global session storage access level for StoryType.Session, only when not already done but needed.
|
||||
if (
|
||||
globalSessionAccessLevelFlag === false &&
|
||||
storageEnum === StorageEnum.Session &&
|
||||
config?.sessionAccessForContentScripts === true
|
||||
) {
|
||||
checkStoragePermission(storageEnum);
|
||||
|
||||
chrome?.storage[storageEnum]
|
||||
.setAccessLevel({
|
||||
accessLevel: SessionAccessLevelEnum.ExtensionPagesAndContentScripts,
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
console.error('Please call .setAccessLevel() into different context, like a background script.');
|
||||
});
|
||||
globalSessionAccessLevelFlag = true;
|
||||
}
|
||||
|
||||
// Register life cycle methods
|
||||
const get = async (): Promise<D> => {
|
||||
checkStoragePermission(storageEnum);
|
||||
const value = await chrome?.storage[storageEnum].get([key]);
|
||||
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return deserialize(value[key]) ?? fallback;
|
||||
};
|
||||
|
||||
const set = async (valueOrUpdate: ValueOrUpdateType<D>) => {
|
||||
if (!initialCache) {
|
||||
cache = await get();
|
||||
}
|
||||
cache = await updateCache(valueOrUpdate, cache);
|
||||
|
||||
await chrome?.storage[storageEnum].set({ [key]: serialize(cache) });
|
||||
_emitChange();
|
||||
};
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners = [...listeners, listener];
|
||||
|
||||
return () => {
|
||||
listeners = listeners.filter(l => l !== listener);
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshot = () => cache;
|
||||
|
||||
const _emitChange = () => {
|
||||
listeners.forEach(listener => listener());
|
||||
};
|
||||
|
||||
// Listener for live updates from the browser
|
||||
const _updateFromStorageOnChanged = async (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: ValueOrUpdateType<D> = deserialize(changes[key].newValue);
|
||||
|
||||
if (cache === valueOrUpdate) return;
|
||||
|
||||
cache = await updateCache(valueOrUpdate, cache);
|
||||
|
||||
_emitChange();
|
||||
};
|
||||
|
||||
get().then(data => {
|
||||
cache = data;
|
||||
initialCache = true;
|
||||
_emitChange();
|
||||
});
|
||||
|
||||
// Register listener for live updates for our storage area
|
||||
if (liveUpdate) {
|
||||
chrome?.storage[storageEnum].onChanged.addListener(_updateFromStorageOnChanged);
|
||||
}
|
||||
|
||||
return {
|
||||
get,
|
||||
set,
|
||||
getSnapshot,
|
||||
subscribe,
|
||||
};
|
||||
};
|
||||
42
packages/storage/lib/base/enums.ts
Normal file
42
packages/storage/lib/base/enums.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Storage area type for persisting and exchanging data.
|
||||
* @see https://developer.chrome.com/docs/extensions/reference/storage/#overview
|
||||
*/
|
||||
export enum StorageEnum {
|
||||
/**
|
||||
* 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 SessionAccessLevelEnum} 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 StorageEnum.Session} Storage Area.
|
||||
* @implements Chromes [Session Access Level](https://developer.chrome.com/docs/extensions/reference/storage/#method-StorageArea-setAccessLevel)
|
||||
*/
|
||||
export enum SessionAccessLevelEnum {
|
||||
/**
|
||||
* 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',
|
||||
}
|
||||
3
packages/storage/lib/base/index.ts
Normal file
3
packages/storage/lib/base/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './base.js';
|
||||
export * from './enums.js';
|
||||
export type * from './types.js';
|
||||
54
packages/storage/lib/base/types.ts
Normal file
54
packages/storage/lib/base/types.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { StorageEnum } from './index.js';
|
||||
|
||||
export type ValueOrUpdateType<D> = D | ((prev: D) => Promise<D> | D);
|
||||
|
||||
export type BaseStorageType<D> = {
|
||||
get: () => Promise<D>;
|
||||
set: (value: ValueOrUpdateType<D>) => Promise<void>;
|
||||
getSnapshot: () => D | null;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
export type StorageConfigType<D = string> = {
|
||||
/**
|
||||
* Assign the {@link StorageEnum} to use.
|
||||
* @default Local
|
||||
*/
|
||||
storageEnum?: StorageEnum;
|
||||
/**
|
||||
* Only for {@link StorageEnum.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 StorageEnum.Session} storage area with
|
||||
* {@link StorageConfigType.sessionAccessForContentScripts} potentially also set to true.
|
||||
* @see https://stackoverflow.com/a/75637138/2763239
|
||||
* @default false
|
||||
*/
|
||||
liveUpdate?: boolean;
|
||||
/**
|
||||
* An optional props for converting values from storage and into it.
|
||||
* @default undefined
|
||||
*/
|
||||
serialization?: {
|
||||
/**
|
||||
* convert non-native values to string to be saved in storage
|
||||
*/
|
||||
serialize: (value: D) => string;
|
||||
/**
|
||||
* convert string value from storage to non-native values
|
||||
*/
|
||||
deserialize: (text: string) => D;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ThemeStateType {
|
||||
theme: 'light' | 'dark';
|
||||
isLight: boolean;
|
||||
}
|
||||
|
||||
export type ThemeStorageType = BaseStorageType<ThemeStateType> & {
|
||||
toggle: () => Promise<void>;
|
||||
};
|
||||
28
packages/storage/lib/impl/example-theme-storage.ts
Normal file
28
packages/storage/lib/impl/example-theme-storage.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createStorage, StorageEnum } from '../base/index.js';
|
||||
import type { ThemeStateType, ThemeStorageType } from '../base/index.js';
|
||||
|
||||
const storage = createStorage<ThemeStateType>(
|
||||
'theme-storage-key',
|
||||
{
|
||||
theme: 'light',
|
||||
isLight: true,
|
||||
},
|
||||
{
|
||||
storageEnum: StorageEnum.Local,
|
||||
liveUpdate: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const exampleThemeStorage: ThemeStorageType = {
|
||||
...storage,
|
||||
toggle: async () => {
|
||||
await storage.set(currentState => {
|
||||
const newTheme = currentState.theme === 'light' ? 'dark' : 'light';
|
||||
|
||||
return {
|
||||
theme: newTheme,
|
||||
isLight: newTheme === 'light',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
1
packages/storage/lib/impl/index.ts
Normal file
1
packages/storage/lib/impl/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './example-theme-storage.js';
|
||||
2
packages/storage/lib/index.ts
Normal file
2
packages/storage/lib/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type * from './types.js';
|
||||
export * from './impl/index.js';
|
||||
8
packages/storage/lib/types.ts
Normal file
8
packages/storage/lib/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ValueOrUpdateType } from './base/index.js';
|
||||
|
||||
export type BaseStorageType<D> = {
|
||||
get: () => Promise<D>;
|
||||
set: (value: ValueOrUpdateType<D>) => Promise<void>;
|
||||
getSnapshot: () => D | null;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
};
|
||||
27
packages/storage/package.json
Normal file
27
packages/storage/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@extension/storage",
|
||||
"version": "0.5.0",
|
||||
"description": "chrome extension - storage",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"types": "index.mts",
|
||||
"main": "dist/index.mjs",
|
||||
"scripts": {
|
||||
"clean:bundle": "rimraf dist",
|
||||
"clean:node_modules": "pnpx rimraf node_modules",
|
||||
"clean:turbo": "rimraf .turbo",
|
||||
"clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo",
|
||||
"ready": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
"format": "prettier . --write --ignore-path ../../.prettierignore",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@extension/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
8
packages/storage/tsconfig.json
Normal file
8
packages/storage/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@extension/tsconfig/module",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["index.mts", "lib"]
|
||||
}
|
||||
Reference in New Issue
Block a user