diff --git a/.changeset/warm-pens-tan.md b/.changeset/warm-pens-tan.md new file mode 100644 index 00000000..7cbac3ff --- /dev/null +++ b/.changeset/warm-pens-tan.md @@ -0,0 +1,5 @@ +--- +"@webx-kit/storage": patch +--- + +feat(storage): support storage prefix diff --git a/packages/storage/demo/shared/atoms.ts b/packages/storage/demo/shared/atoms.ts index c6642ea6..1b26c701 100644 --- a/packages/storage/demo/shared/atoms.ts +++ b/packages/storage/demo/shared/atoms.ts @@ -1,6 +1,8 @@ import { atomWithStorage } from 'jotai/utils'; import { createStorage } from '@/index'; -const chromeStorage = createStorage(); +const chromeStorage = createStorage({ + prefix: 'jotai:', +}); export const apiKeyAtom = atomWithStorage('apiKey', 'DEFAULT', chromeStorage); diff --git a/packages/storage/demo/shared/unstorage.ts b/packages/storage/demo/shared/unstorage.ts index 3964d0fa..340d37ec 100644 --- a/packages/storage/demo/shared/unstorage.ts +++ b/packages/storage/demo/shared/unstorage.ts @@ -3,7 +3,7 @@ import { createStorage } from 'unstorage'; import { createDriver } from '@/unstorage'; export const storage = createStorage({ - driver: createDriver(), + driver: createDriver({ prefix: 'unstorage:' }), }); export function useUnstorage(key: string, defaultValue?: any) { diff --git a/packages/storage/e2e/jotai.spec.ts b/packages/storage/e2e/jotai.spec.ts index 0f3d4af9..7f15958b 100644 --- a/packages/storage/e2e/jotai.spec.ts +++ b/packages/storage/e2e/jotai.spec.ts @@ -12,6 +12,10 @@ test('Single page', async ({ getURL, page }) => { await page.reload(); await expect(page.getByTestId('apiKey')).toHaveText('Changed'); + + await expect(page.evaluate(() => chrome.storage.local.get())).resolves.toEqual({ + 'jotai:apiKey': 'Changed', + }); }); test('Cross pages', async ({ getURL, context }) => { diff --git a/packages/storage/e2e/unstorage.spec.ts b/packages/storage/e2e/unstorage.spec.ts index 65fd14d4..6abf6e19 100644 --- a/packages/storage/e2e/unstorage.spec.ts +++ b/packages/storage/e2e/unstorage.spec.ts @@ -9,6 +9,10 @@ test('Single page', async ({ getURL, page }) => { await page.reload(); await expect(page.getByTestId('apiKey')).toHaveText('Changed'); + + await expect(page.evaluate(() => chrome.storage.local.get())).resolves.toEqual({ + 'unstorage:apiKey': 'Changed', + }); }); test('Cross pages', async ({ getURL, context }) => { diff --git a/packages/storage/package.json b/packages/storage/package.json index b80e0e4a..b0f957ee 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -35,6 +35,9 @@ "test": "playwright test", "lint:type": "tsc --noEmit" }, + "dependencies": { + "type-fest": "^4.10.0" + }, "devDependencies": { "@modern-js/app-tools": "^2.46.1", "@playwright/test": "^1.41.0", diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 4758012a..e335ecf5 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -1,50 +1,68 @@ +import type { StringKeyOf } from 'type-fest'; + export interface ChromeStorage = Record> { - setItem: (key: K, value: T[K]) => Promise; - getItem: (key: K, defaultValue?: T[K]) => Promise; + setItem: >(key: K, value: T[K]) => Promise; + getItem: >(key: K, defaultValue?: T[K]) => Promise; hasItem: (key: string) => Promise; getKeys: () => Promise; - removeItem: (key: keyof T | (keyof T)[]) => Promise; + removeItem: (key: StringKeyOf | StringKeyOf[]) => Promise; clear: () => Promise; - subscribe: (key: K, callback: (value: T[K] | null) => void) => VoidFunction; + subscribe: >(key: K, callback: (value: T[K] | null) => void) => VoidFunction; watch: (callback: (event: 'update' | 'remove', key: string) => void) => VoidFunction; } +export interface CreateStorageOptions { + /** + * @default "local" + */ + area?: chrome.storage.AreaName; + prefix?: string; +} + export function createStorage = Record>( - area: chrome.storage.AreaName = 'local' + options?: CreateStorageOptions ): ChromeStorage { + const { area = 'local', prefix } = options || {}; const storage = chrome.storage[area]; + const [addPrefix, removePrefix] = createPrefixHelpers(prefix); - async function setItem(key: K, value: T[K]): Promise { - return storage.set({ [key]: value }); + async function setItem>(key: K, value: T[K]): Promise { + return storage.set({ [addPrefix(key)]: value }); } - async function getItem(key: K, defaultValue?: T[K]): Promise { - const result = await storage.get(key as string); - return key in result ? result[key as string] : arguments.length === 2 ? defaultValue : null; + async function getItem>(key: K, defaultValue?: T[K]): Promise { + const prefixedKey = addPrefix(key); + const result = await storage.get(prefixedKey); + return prefixedKey in result ? result[prefixedKey] : arguments.length === 2 ? defaultValue : null; } async function hasItem(key: string): Promise { - const result = await storage.get(key as string); - return key in result; + const prefixedKey = addPrefix(key); + const result = await storage.get(prefixedKey); + return prefixedKey in result; } async function getKeys(): Promise { const result = await storage.get(); - return Object.keys(result); + const prefixedKeys = Object.keys(result); + if (!prefix) return prefixedKeys; + return prefixedKeys.filter((key) => key.startsWith(prefix)).map(removePrefix); } - async function removeItem(key: keyof T | (keyof T)[]): Promise { - return storage.remove(key as string | string[]); + async function removeItem(key: StringKeyOf | StringKeyOf[]): Promise { + return storage.remove(Array.isArray(key) ? key.map(addPrefix) : addPrefix(key)); } async function clear(): Promise { - return storage.clear(); + if (!prefix) return storage.clear(); + const keys = await getKeys(); + return storage.remove(keys.map(addPrefix)); } type OnChangedListener = Parameters<(typeof storage)['onChanged']['addListener']>[0]; let onChangedActive = false; - const onChangedCallbacks = new Map void>>(); + const onChangedCallbacks = new Map void>>(); const onChangedListener: OnChangedListener = (changes) => { for (const [key, value] of Object.entries(changes)) { const changedCallback = onChangedCallbacks.get(key); @@ -74,10 +92,11 @@ export function createStorage = Record(key: K, callback: (value: T[K] | null) => void): VoidFunction { - addCallback(key as string, callback); + function subscribe>(key: K, callback: (value: T[K] | null) => void): VoidFunction { + const prefixedKey = addPrefix(key); + addCallback(prefixedKey, callback); return () => { - removeCallback(key as string, callback); + removeCallback(prefixedKey, callback); }; } @@ -85,7 +104,7 @@ export function createStorage = Record { for (const [key, value] of Object.entries(changes)) { const type = 'newValue' in value ? 'update' : 'remove'; - callback(type, key); + callback(type, removePrefix(key)); } }; storage.onChanged.addListener(listener); @@ -105,3 +124,17 @@ export function createStorage = Record(value: T): T { + return value; +} + +type StringTransformer = (key: string) => string; + +function createPrefixHelpers(prefix?: string): [StringTransformer, StringTransformer] { + if (!prefix) return [identity, identity]; + const len = prefix.length; + const add: StringTransformer = (key) => prefix + key; + const remove: StringTransformer = (key) => (key.startsWith(prefix) ? key.slice(len) : key); + return [add, remove]; +} diff --git a/packages/storage/src/unstorage.ts b/packages/storage/src/unstorage.ts index b97b9e7a..2fcb2646 100644 --- a/packages/storage/src/unstorage.ts +++ b/packages/storage/src/unstorage.ts @@ -1,7 +1,7 @@ -import { ChromeStorage, createStorage } from './index'; +import { ChromeStorage, CreateStorageOptions, createStorage } from './index'; -export function createDriver(): ChromeStorage { - const storage = createStorage(); +export function createDriver(options?: CreateStorageOptions): ChromeStorage { + const storage = createStorage(options); return { ...storage, getItem: (key: string) => storage.getItem(key), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8816429..2fafb33c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ importers: devDependencies: '@modern-js/app-tools': specifier: ^2.46.1 - version: 2.46.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) + version: 2.46.1(react-dom@18.2.0)(react@18.2.0)(type-fest@4.10.0)(typescript@5.3.3) '@types/chrome': specifier: ^0.0.258 version: 0.0.258 @@ -87,7 +87,7 @@ importers: devDependencies: '@modern-js/app-tools': specifier: ^2.46.1 - version: 2.46.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) + version: 2.46.1(react-dom@18.2.0)(react@18.2.0)(type-fest@4.10.0)(typescript@5.3.3) '@playwright/test': specifier: ^1.41.0 version: 1.41.0 @@ -347,10 +347,14 @@ importers: version: 5.3.3 packages/storage: + dependencies: + type-fest: + specifier: ^4.10.0 + version: 4.10.0 devDependencies: '@modern-js/app-tools': specifier: ^2.46.1 - version: 2.46.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) + version: 2.46.1(react-dom@18.2.0)(react@18.2.0)(type-fest@4.10.0)(typescript@5.3.3) '@playwright/test': specifier: ^1.41.0 version: 1.41.0 @@ -2782,7 +2786,7 @@ packages: read-yaml-file: 1.1.0 dev: true - /@modern-js/app-tools@2.46.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3): + /@modern-js/app-tools@2.46.1(react-dom@18.2.0)(react@18.2.0)(type-fest@4.10.0)(typescript@5.3.3): resolution: {integrity: sha512-s3+5bqWrEV4AM8G3K/TPdGRxjVEqYLhZEJdBEQPFtGKVSRuSpyYqM8bjp1GXM7Doyqjc96tBsU7SUTAyMSOoXw==} engines: {node: '>=14.17.6'} hasBin: true @@ -2802,7 +2806,7 @@ packages: '@modern-js/server-core': 2.46.1 '@modern-js/server-utils': 2.46.1(@babel/traverse@7.23.7) '@modern-js/types': 2.46.1 - '@modern-js/uni-builder': 2.46.1(@babel/traverse@7.23.7)(esbuild@0.17.19)(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3) + '@modern-js/uni-builder': 2.46.1(@babel/traverse@7.23.7)(esbuild@0.17.19)(react-dom@18.2.0)(react@18.2.0)(type-fest@4.10.0)(typescript@5.3.3) '@modern-js/upgrade': 2.46.1 '@modern-js/utils': 2.46.1 '@rsbuild/plugin-esbuild': 0.3.4(@swc/helpers@0.5.3) @@ -3405,7 +3409,7 @@ packages: resolution: {integrity: sha512-Z6eA3kc+raiTP+FgxItzxnQ7JV1gOEC63floqguL2PJrVJMz1BqfQqKeen0i7uDinjGI+G0A/2eIpJbkL6Wc1A==} dev: true - /@modern-js/uni-builder@2.46.1(@babel/traverse@7.23.7)(esbuild@0.17.19)(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3): + /@modern-js/uni-builder@2.46.1(@babel/traverse@7.23.7)(esbuild@0.17.19)(react-dom@18.2.0)(react@18.2.0)(type-fest@4.10.0)(typescript@5.3.3): resolution: {integrity: sha512-AK4G9ha1Vs9J65YNy0lI82/JlgkGo0HVXTcImMjGuMwZ/03qM1QvBonjm1VxowSe+r+NXMBt4WwpIHOjtGdQOw==} dependencies: '@babel/core': 7.23.7 @@ -3414,7 +3418,7 @@ packages: '@modern-js/prod-server': 2.46.1(react-dom@18.2.0)(react@18.2.0) '@modern-js/server': 2.46.1(@babel/traverse@7.23.7)(@rsbuild/core@0.3.4)(react-dom@18.2.0)(react@18.2.0) '@modern-js/utils': 2.46.1 - '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(react-refresh@0.14.0)(webpack@5.89.0) + '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(react-refresh@0.14.0)(type-fest@4.10.0)(webpack@5.89.0) '@rsbuild/babel-preset': 0.3.4(@rsbuild/core@0.3.4)(@swc/helpers@0.5.3) '@rsbuild/core': 0.3.4 '@rsbuild/plugin-assets-retry': 0.3.4(@rsbuild/core@0.3.4)(@swc/helpers@0.5.3) @@ -3494,7 +3498,7 @@ packages: '@modern-js/prod-server': 2.46.1(react-dom@18.2.0)(react@18.2.0) '@modern-js/server': 2.46.1(@babel/traverse@7.23.7)(@rsbuild/core@0.3.4) '@modern-js/utils': 2.46.1 - '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(react-refresh@0.14.0)(webpack@5.89.0) + '@pmmmwh/react-refresh-webpack-plugin': 0.5.10(react-refresh@0.14.0)(type-fest@4.10.0)(webpack@5.89.0) '@rsbuild/babel-preset': 0.3.4(@rsbuild/core@0.3.4)(@swc/helpers@0.5.3) '@rsbuild/core': 0.3.4 '@rsbuild/plugin-assets-retry': 0.3.4(@rsbuild/core@0.3.4)(@swc/helpers@0.5.3) @@ -3783,7 +3787,7 @@ packages: playwright: 1.41.0 dev: true - /@pmmmwh/react-refresh-webpack-plugin@0.5.10(react-refresh@0.14.0)(webpack@5.89.0): + /@pmmmwh/react-refresh-webpack-plugin@0.5.10(react-refresh@0.14.0)(type-fest@4.10.0)(webpack@5.89.0): resolution: {integrity: sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==} engines: {node: '>= 10.13'} peerDependencies: @@ -3819,6 +3823,7 @@ packages: react-refresh: 0.14.0 schema-utils: 3.3.0 source-map: 0.7.4 + type-fest: 4.10.0 webpack: 5.89.0(esbuild@0.17.19) dev: true @@ -11152,6 +11157,10 @@ packages: engines: {node: '>=8'} dev: true + /type-fest@4.10.0: + resolution: {integrity: sha512-NPaKJsb4wyJ16qc8zBQrWswLKv/YirgBFykvUQ1Iajt2wd+twC8E4hFXdlIXqiMl6kWA0zY8tUJ9ELVAdu5h7w==} + engines: {node: '>=16'} + /typed-array-buffer@1.0.0: resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} engines: {node: '>= 0.4'}