diff --git a/src/entities/Font/model/store/appliedFontsStore/errors.ts b/src/entities/Font/model/store/appliedFontsStore/errors.ts index f7201bb..3b617f1 100644 --- a/src/entities/Font/model/store/appliedFontsStore/errors.ts +++ b/src/entities/Font/model/store/appliedFontsStore/errors.ts @@ -1,5 +1,5 @@ /** - * Thrown when a font file cannot be retrieved from the network or cache. + * Thrown by {@link FontBufferCache} when a font file cannot be retrieved from the network or cache. * * @property url - The URL that was requested. * @property cause - The underlying error, if any. diff --git a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts new file mode 100644 index 0000000..311347d --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.test.ts @@ -0,0 +1,66 @@ +/** @vitest-environment jsdom */ +import { FontFetchError } from '../errors'; +import { FontBufferCache } from './FontBufferCache'; + +const makeBuffer = () => new ArrayBuffer(8); + +const makeFetcher = (overrides: Partial = {}) => + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(makeBuffer()), + clone: () => ({ ok: true, status: 200, arrayBuffer: () => Promise.resolve(makeBuffer()) }), + ...overrides, + } as Response); + +describe('FontBufferCache', () => { + let cache: FontBufferCache; + let fetcher: ReturnType; + + beforeEach(() => { + fetcher = makeFetcher(); + cache = new FontBufferCache({ fetcher }); + }); + + it('returns buffer from memory on second call without fetching', async () => { + await cache.get('https://example.com/font.woff2'); + await cache.get('https://example.com/font.woff2'); + + expect(fetcher).toHaveBeenCalledOnce(); + }); + + it('throws FontFetchError on HTTP error with correct status', async () => { + const errorFetcher = makeFetcher({ ok: false, status: 404 }); + const errorCache = new FontBufferCache({ fetcher: errorFetcher }); + + const err = await errorCache.get('https://example.com/font.woff2').catch(e => e); + expect(err).toBeInstanceOf(FontFetchError); + expect(err.status).toBe(404); + }); + + it('throws FontFetchError on network failure without status', async () => { + const networkFetcher = vi.fn().mockRejectedValue(new Error('network down')); + const networkCache = new FontBufferCache({ fetcher: networkFetcher }); + + const err = await networkCache.get('https://example.com/font.woff2').catch(e => e); + expect(err).toBeInstanceOf(FontFetchError); + expect(err.status).toBeUndefined(); + }); + + it('evict removes url from memory so next call fetches again', async () => { + await cache.get('https://example.com/font.woff2'); + cache.evict('https://example.com/font.woff2'); + await cache.get('https://example.com/font.woff2'); + + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it('clear wipes all memory cache entries', async () => { + await cache.get('https://example.com/a.woff2'); + await cache.get('https://example.com/b.woff2'); + cache.clear(); + await cache.get('https://example.com/a.woff2'); + + expect(fetcher).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts new file mode 100644 index 0000000..1e49873 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/fontBufferCache/FontBufferCache.ts @@ -0,0 +1,95 @@ +import { FontFetchError } from '../errors'; + +type Fetcher = (url: string, init?: RequestInit) => Promise; + +interface FontBufferCacheOptions { + /** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */ + fetcher?: Fetcher; + /** Cache API cache name. Defaults to `'font-cache-v1'`. */ + cacheName?: string; +} + +/** + * Three-tier font buffer cache: in-memory → Cache API → network. + * + * - **Tier 1 (memory):** Fastest — no I/O. Populated after first successful fetch. + * - **Tier 2 (Cache API):** Persists across page loads. Silently skipped in private browsing. + * - **Tier 3 (network):** Raw fetch. Throws {@link FontFetchError} on failure. + * + * The `fetcher` option is injectable for testing — pass a `vi.fn()` to avoid real network calls. + */ +export class FontBufferCache { + #buffersByUrl = new Map(); + + readonly #fetcher: Fetcher; + readonly #cacheName: string; + + constructor( + { fetcher = globalThis.fetch.bind(globalThis), cacheName = 'font-cache-v1' }: FontBufferCacheOptions = {}, + ) { + this.#fetcher = fetcher; + this.#cacheName = cacheName; + } + + /** + * Retrieves the font buffer for the given URL using the three-tier strategy. + * Stores the result in memory on success. + * + * @throws {@link FontFetchError} if the network request fails or returns a non-OK response. + */ + async get(url: string, signal?: AbortSignal): Promise { + // Tier 1: in-memory (fastest, no I/O) + const inMemory = this.#buffersByUrl.get(url); + if (inMemory) return inMemory; + + // Tier 2: Cache API + try { + if (typeof caches !== 'undefined') { + const cache = await caches.open(this.#cacheName); + const cached = await cache.match(url); + if (cached) { + const buffer = await cached.arrayBuffer(); + this.#buffersByUrl.set(url, buffer); + return buffer; + } + } + } catch { + // Cache unavailable (private browsing, security restrictions) — fall through to network + } + + // Tier 3: network + let response: Response; + try { + response = await this.#fetcher(url, { signal }); + } catch (cause) { + throw new FontFetchError(url, cause); + } + + if (!response.ok) { + throw new FontFetchError(url, undefined, response.status); + } + + try { + if (typeof caches !== 'undefined') { + const cache = await caches.open(this.#cacheName); + await cache.put(url, response.clone()); + } + } catch { + // Cache write failed (quota, storage pressure) — return font anyway + } + + const buffer = await response.arrayBuffer(); + this.#buffersByUrl.set(url, buffer); + return buffer; + } + + /** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */ + evict(url: string): void { + this.#buffersByUrl.delete(url); + } + + /** Clears all in-memory cached buffers. */ + clear(): void { + this.#buffersByUrl.clear(); + } +}