diff --git a/src/lib/AssetManager.ts b/src/lib/AssetManager.ts new file mode 100644 index 0000000..4dbbfb1 --- /dev/null +++ b/src/lib/AssetManager.ts @@ -0,0 +1,64 @@ +const normalizePath = (path: string) => path.trim().toLowerCase().replaceAll(/\\/g, '/'); + +/** + * AssetManager provides an in-memory cache for game assets. Outbound HTTP requests are coalesced + * into a single request for any given asset path. Assets are cached based on their normalized path + * name. + */ +class AssetManager { + #baseUrl: string; + #normalizePath: boolean; + #cache = new globalThis.Map(); + #pendingRequests = new globalThis.Map>(); + + constructor(baseUrl: string, normalizePath = true) { + this.#baseUrl = baseUrl; + this.#normalizePath = normalizePath; + } + + getAsset(path: string) { + const cacheKey = normalizePath(path); + + const cachedAsset = this.#cache.get(cacheKey); + if (cachedAsset) { + return Promise.resolve(cachedAsset); + } + + const pendingAssetRequest = this.#pendingRequests.get(cacheKey); + if (pendingAssetRequest) { + return pendingAssetRequest; + } + + const newAssetRequest = this.#getMissingAsset(path, cacheKey); + this.#pendingRequests.set(cacheKey, newAssetRequest); + + return newAssetRequest; + } + + async #getMissingAsset(path: string, cacheKey: string) { + const response = await fetch(this.#getFullUrl(path)); + + // Handle non-2xx responses + if (!response.ok) { + this.#pendingRequests.delete(cacheKey); + + throw new Error(`Error fetching asset: ${response.status} ${response.statusText}`); + } + + const data = await response.arrayBuffer(); + this.#cache.set(cacheKey, data); + + this.#pendingRequests.delete(cacheKey); + + return data; + } + + #getFullUrl(path: string) { + const urlPath = this.#normalizePath ? normalizePath(path) : path; + return `${this.#baseUrl}/${urlPath}`; + } +} + +export default AssetManager; + +export { AssetManager }; diff --git a/src/lib/index.ts b/src/lib/index.ts index 9b3f054..0395fc1 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,2 +1,3 @@ export * from './util.js'; export * from './controls/OrbitControls.js'; +export * from './AssetManager.js'; diff --git a/src/spec/AssetManager.spec.ts b/src/spec/AssetManager.spec.ts new file mode 100644 index 0000000..39f56dc --- /dev/null +++ b/src/spec/AssetManager.spec.ts @@ -0,0 +1,51 @@ +import AssetManager from '../lib/AssetManager'; +import { describe, expect, test, vi } from 'vitest'; + +const createFetchResponse = (status: number, statusText: string, data: ArrayBuffer) => ({ + ok: status >= 200 && status <= 299, + status, + statusText, + arrayBuffer: () => new Promise((resolve) => resolve(data)), +}); + +describe('AssetManager', () => { + describe('getAsset', () => { + test('should return expected asset buffer when fetch succeeds', async () => { + const assetManager = new AssetManager('http://example.local', true); + const assetBuffer = new ArrayBuffer(7); + + const mockFetch = vi.fn(); + mockFetch.mockResolvedValue(createFetchResponse(200, 'Okay', assetBuffer)); + globalThis.fetch = mockFetch; + + const returnedAssetBuffer = await assetManager.getAsset('foo'); + + expect(returnedAssetBuffer).toEqual(assetBuffer); + }); + + test('should throw when fetch fails', async () => { + const assetManager = new AssetManager('http://example.local', true); + const assetBuffer = new ArrayBuffer(7); + + const mockFetch = vi.fn(); + mockFetch.mockResolvedValue(createFetchResponse(404, 'Not Found', assetBuffer)); + globalThis.fetch = mockFetch; + + await expect(assetManager.getAsset('foo')).rejects.toBeInstanceOf(Error); + }); + + test('should only fetch once for a given asset path', async () => { + const assetManager = new AssetManager('http://example.local', true); + const assetBuffer = new ArrayBuffer(7); + + const mockFetch = vi.fn(); + mockFetch.mockResolvedValue(createFetchResponse(200, 'Okay', assetBuffer)); + globalThis.fetch = mockFetch; + + await assetManager.getAsset('foo'); + await assetManager.getAsset('foo'); + + expect(mockFetch).toHaveBeenCalledOnce(); + }); + }); +});