Initial commit

This commit is contained in:
Aditya Gupta
2026-06-29 19:29:19 +05:30
committed by GitHub
commit aabb8986aa
292 changed files with 17976 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from './lib/index.js';

View 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,
};
};

View 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',
}

View File

@@ -0,0 +1,3 @@
export * from './base.js';
export * from './enums.js';
export type * from './types.js';

View 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>;
};

View 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',
};
});
},
};

View File

@@ -0,0 +1 @@
export * from './example-theme-storage.js';

View File

@@ -0,0 +1,2 @@
export type * from './types.js';
export * from './impl/index.js';

View 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;
};

View 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:*"
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@extension/tsconfig/module",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist"
},
"include": ["index.mts", "lib"]
}