feat: extract FontBufferCache with injectable fetcher
This commit is contained in:
@@ -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 url - The URL that was requested.
|
||||||
* @property cause - The underlying error, if any.
|
* @property cause - The underlying error, if any.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import { FontFetchError } from '../errors';
|
||||||
|
import { FontBufferCache } from './FontBufferCache';
|
||||||
|
|
||||||
|
const makeBuffer = () => new ArrayBuffer(8);
|
||||||
|
|
||||||
|
const makeFetcher = (overrides: Partial<Response> = {}) =>
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { FontFetchError } from '../errors';
|
||||||
|
|
||||||
|
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
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<string, ArrayBuffer>();
|
||||||
|
|
||||||
|
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<ArrayBuffer> {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user