import type { Identifier } from './types/identifiers'; import type { AssetObject, AssetLike } from './types/assets'; import type { DocumentVersion, Document } from './types/documents'; type Nullable = T | null; export type { AssetObject, AssetLike, DocumentVersion as DocumentVersionLike, Document }; export const resolveAssetExpiresAt = (asset?: { download?: { expires_at: number } | null } | null): number | null => asset?.download?.expires_at ?? null; export const resolveAssetUrl = (asset?: { download?: { url: string } | null } | null): string | null => asset?.download?.url ?? null; export type EnsureAssetUrl = ( documentId: Identifier, asset: AssetLike, options?: { force?: boolean;[key: string]: unknown }, ) => Promise; export type GetAsset = (document: Document, assetType: string) => Nullable; export const getAssetFromGroup = ( assets?: AssetLike[] | Record | null, assetType: string = '', ): Nullable => { if (!assetType || !assets) { return null; } if (Array.isArray(assets)) { return assets.find((entry) => entry?.asset_type === assetType) || null; } return assets?.[assetType] || null; }; export const getAssetFromVersion = (currentVersion: Nullable, assetType: string) => { if (!currentVersion) { return null; } return getAssetFromGroup(currentVersion.assets, assetType); }; const normalizeAssetObjects = (objects?: AssetObject[] | null): AssetObject[] => { if (!Array.isArray(objects)) { return []; } return objects .filter((entry) => Number.isInteger(entry?.ordinal)) .slice() .sort((a, b) => a.ordinal - b.ordinal); }; export class AssetView { asset: AssetLike | null; private _objectsRef: AssetObject[] | null; private _sortedObjects: AssetObject[]; constructor(asset?: AssetLike | null) { this.asset = asset || null; this._objectsRef = null; this._sortedObjects = []; } getCardinality(): number { if (!this.asset) { return 0; } const objectsCount = this.getObjects().length; if (objectsCount > 0) { return objectsCount; } if (this.asset.url || this.asset.metadata) { return 1; } return 0; } getObjects(): AssetObject[] { if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) { return []; } if (this._objectsRef === this.asset.objects) { return this._sortedObjects; } this._objectsRef = this.asset.objects; this._sortedObjects = normalizeAssetObjects(this.asset.objects); return this._sortedObjects; } getObject(ordinal = 1): AssetObject | null { const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal); if (fromObjects) { return fromObjects; } if (ordinal === 1 && this.asset) { const primaryUrl = resolveAssetUrl(this.asset); if (primaryUrl || this.asset.metadata) { return { ordinal: 1, url: primaryUrl || null, metadata: this.asset.metadata || null, expires_at: resolveAssetExpiresAt(this.asset), }; } } return null; } getPrimaryObject(): AssetObject | null { return this.getObject(1); } getPrimaryMetadata(): Record | null { return this.getPrimaryObject()?.metadata || null; } getPrimaryUrl(): string | null { return this.getPrimaryObject()?.url || null; } hasObject(ordinal: number): boolean { return Boolean(this.getObject(ordinal)); } } export const createAssetView = (asset?: AssetLike | null): AssetView => new AssetView(asset); export const resolveDocumentAssetUrl = ( doc: Nullable, type: string, { ensureAssetUrl, getAsset, ensureOptions, }: { ensureAssetUrl?: EnsureAssetUrl; getAsset?: GetAsset; ensureOptions?: { force?: boolean;[key: string]: unknown }; } = {}, ): Nullable => { if (!doc || !type) { return null; } const asset = getAsset ? getAsset(doc, type) : null; if (!asset) { return null; } const view = createAssetView(asset); const object = view.getPrimaryObject(); const url = object?.url || view.getPrimaryUrl(); const expiresAt = object?.expires_at ?? resolveAssetExpiresAt(asset); const now = Date.now(); if (url && (!expiresAt || expiresAt > now)) { return url; } if (doc.id && asset.id && ensureAssetUrl) { const force = Boolean(url && expiresAt && expiresAt <= now); const options: { force: boolean;[key: string]: unknown } = { force, ...(ensureOptions || {}), }; ensureAssetUrl(doc.id, asset, options).catch(() => { }); } return null; }; class AssetManager { fetchAsset: ((id: Identifier) => Promise) | null; assetCache: Map; assetInflight: Map>; constructor({ fetchAsset }: { fetchAsset: ((id: Identifier) => Promise) | null }) { this.fetchAsset = fetchAsset; this.assetCache = new Map(); this.assetInflight = new Map(); } setFetchAsset(fetchAsset: ((id: Identifier) => Promise) | null) { this.fetchAsset = fetchAsset; } rememberAsset(entry?: Nullable) { if (entry?.id) { this.assetCache.set(entry.id, entry); } } ensureAsset( documentId?: Identifier | null, asset?: Nullable, { force = false }: { force?: boolean } = {}, ): Promise> { if (!documentId || !asset?.id) { return Promise.resolve(asset); } const baseAsset = this.assetCache.get(asset.id) || asset; const view = createAssetView(baseAsset); const assetExpiresAt = resolveAssetExpiresAt(baseAsset); const now = Date.now(); const isPrimarySatisfied = () => { const object = view.getObject(1); if (object?.url) { const objectExpiresAt = object.expires_at ?? null; if (!objectExpiresAt || objectExpiresAt > now) { return true; } } const assetUrl = resolveAssetUrl(baseAsset); if (assetUrl && (!assetExpiresAt || assetExpiresAt > now)) { return true; } return false; }; let needsFetch = force; if (!needsFetch) { needsFetch = !isPrimarySatisfied(); } if (!needsFetch) { this.rememberAsset(baseAsset); return Promise.resolve(baseAsset); } const inflightKey = `${documentId}:${asset.id}`; if (!force && this.assetInflight.has(inflightKey)) { return this.assetInflight.get(inflightKey); } if (!this.fetchAsset) { return Promise.reject(new Error('AssetManager fetcher is not configured.')); } const request: Promise = this.fetchAsset(asset.id) .then((data) => { if (!data) return null; const cachedEntry = this.assetCache.get(asset.id) || baseAsset; const combined = { ...cachedEntry, ...asset, ...data }; const expires_at = resolveAssetExpiresAt(combined); const entry = { ...combined, url: resolveAssetUrl(combined), expires_at, }; this.rememberAsset(entry); return entry; }) .finally(() => { this.assetInflight.delete(inflightKey); }); this.assetInflight.set(inflightKey, request); return request; } reset() { this.assetCache.clear(); this.assetInflight.clear(); } } export default AssetManager;