refactor/code-splitting #31
@@ -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.
|
||||
|
||||
@@ -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