Skip to content

Commit 13fbe3a

Browse files
committed
add namespace option for cache isolation
1 parent ade341f commit 13fbe3a

File tree

2 files changed

+113
-4
lines changed

2 files changed

+113
-4
lines changed

src/cache.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { hash } from 'ohash'
12
import { version } from '../package.json'
23

34
type Awaitable<T> = T | Promise<T>
@@ -22,23 +23,67 @@ export function memoryStorage() {
2223

2324
const ONE_WEEK = 1000 * 60 * 60 * 24 * 7
2425

25-
export function createCachedAsyncStorage(storage: Storage) {
26+
interface CachedStorageOptions {
27+
/**
28+
* Array of values used to create an isolated cache key namespace.
29+
* Each element is stringified or hashed to form a unique cache key prefix.
30+
*
31+
* @example
32+
* ```ts
33+
* const providerName = 'google-fonts'
34+
* const providerOptions = { apiKey: 'xxx', subset: 'latin' }
35+
* createCachedAsyncStorage(storage, {
36+
* namespace: [providerName, providerOptions]
37+
* })
38+
* // Results in cache keys like: 'google-fonts:hash_of_options:actual_key'
39+
* ```
40+
*/
41+
namespace?: any[]
42+
}
43+
44+
export function createCachedAsyncStorage(storage: Storage, options: CachedStorageOptions = {}) {
45+
function resolveKey(key: string): string {
46+
if (!options?.namespace || options.namespace.length === 0) {
47+
return key
48+
}
49+
50+
return `${createCacheKey(...options.namespace)}:${key}`
51+
}
52+
2653
return {
2754
async getItem<T = unknown>(key: string, init?: () => T | Promise<T>) {
55+
const resolvedKey = resolveKey(key)
2856
const now = Date.now()
29-
const res = await storage.getItem(key)
57+
const res = await storage.getItem(resolvedKey)
3058
if (res && res.expires > now && res.version === version) {
3159
return res.data
3260
}
3361
if (!init) {
3462
return null
3563
}
3664
const data = await init()
37-
await storage.setItem(key, { expires: now + ONE_WEEK, version, data })
65+
await storage.setItem(resolvedKey, { expires: now + ONE_WEEK, version, data })
3866
return data
3967
},
4068
async setItem(key: string, data: unknown) {
41-
await storage.setItem(key, { expires: Date.now() + ONE_WEEK, version, data })
69+
await storage.setItem(resolveKey(key), { expires: Date.now() + ONE_WEEK, version, data })
4270
},
4371
}
4472
}
73+
74+
function createCacheKey(...fragments: any[]): string {
75+
const parts = fragments.map((f) => {
76+
// Don't hash string values to maintain readability.
77+
const part = typeof f === 'string' ? f : hash(f)
78+
return sanitize(part)
79+
})
80+
81+
return parts.join(':')
82+
}
83+
84+
function sanitize(input: string): string {
85+
if (!input)
86+
return ''
87+
88+
return input.replace(/[^\w.-]/g, '_')
89+
}

test/cache.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,68 @@ describe('cache storage', () => {
5050
expect(customStorage.setItem).toHaveBeenCalledWith('key', expect.objectContaining({ data: 'value' }))
5151
expect(customStorage.setItem).toHaveBeenCalledWith('another-key', expect.objectContaining({ data: 'value' }))
5252
})
53+
54+
describe('keyFragments option', () => {
55+
it('preserves string fragments in cache key', async () => {
56+
const storage = {
57+
getItem: vi.fn(),
58+
setItem: vi.fn(),
59+
}
60+
const cached = createCachedAsyncStorage(storage, {
61+
namespace: ['provider-name', { a: 1 }, 'variant-a'],
62+
})
63+
await cached.setItem('test-key', 'data')
64+
65+
expect(storage.setItem).toHaveBeenCalledExactlyOnceWith(
66+
expect.stringMatching(/^provider-name:.+:variant-a:.+$/),
67+
expect.objectContaining({ data: 'data' }),
68+
)
69+
})
70+
71+
it('generates different keys for different object fragments', async () => {
72+
const storage = {
73+
getItem: vi.fn(),
74+
setItem: vi.fn(),
75+
}
76+
77+
const cachedA = createCachedAsyncStorage(storage, {
78+
namespace: [{ variant: 'A' }],
79+
})
80+
const cachedB = createCachedAsyncStorage(storage, {
81+
namespace: [{ variant: 'B' }],
82+
})
83+
await cachedA.setItem('key', 'data')
84+
await cachedB.setItem('key', 'data')
85+
86+
const keyA = storage.setItem.mock.calls.at(0)?.at(0) as string | undefined
87+
const keyB = storage.setItem.mock.calls.at(1)?.at(0) as string | undefined
88+
89+
expect(storage.setItem).toHaveBeenCalledTimes(2)
90+
expect(keyA).toBeDefined()
91+
expect(keyB).toBeDefined()
92+
expect(keyA).not.toBe(keyB)
93+
})
94+
95+
it.each([
96+
{ input: 'provider/name' },
97+
{ input: 'provider@v2' },
98+
{ input: 'provider name' },
99+
{ input: 'provider:name' },
100+
])('sanitizes "$input" in fragments', async ({ input }) => {
101+
const storage = {
102+
getItem: vi.fn(),
103+
setItem: vi.fn(),
104+
}
105+
const cached = createCachedAsyncStorage(storage, {
106+
namespace: [input],
107+
})
108+
109+
await cached.setItem('test-key', 'data')
110+
111+
expect(storage.setItem).toHaveBeenCalledExactlyOnceWith(
112+
expect.not.stringContaining(input),
113+
expect.objectContaining({ data: 'data' }),
114+
)
115+
})
116+
})
53117
})

0 commit comments

Comments
 (0)