import type { AxiosInstance } from 'axios'; export type Identifier = string | number; type Nullable = T | null; export interface AssetObject { ordinal?: number; url?: string | null; metadata?: Record | null; expires_at?: number | null; [key: string]: unknown; } export interface AssetLike { id?: Identifier; asset_type?: string; cardinality?: number | null; url?: string | null; expires_at?: number | null; metadata?: Record | null; expiresAt?: number | null; assets?: Record | AssetLike[] | null; objects?: AssetObject[] | null; [key: string]: unknown; } export interface DocumentVersionLike { assets?: Record | AssetLike[] | null; metadata?: Record & { page_count?: number } | null; [key: string]: unknown; } export interface DocumentLike { id?: Identifier; current_version?: DocumentVersionLike | null; [key: string]: unknown; } export const resolveAssetExpiresAt = ( asset?: { expiresAt?: number | null; expires_at?: number | null } | null, ): number | null => { const camel = Number(asset?.expiresAt); if (Number.isFinite(camel)) { return camel; } const snake = Number(asset?.expires_at); if (Number.isFinite(snake)) { return snake; } return null; }; export type EnsureAssetUrl = ( documentId: Identifier, asset: AssetLike, options?: { force?: boolean; [key: string]: unknown }, ) => Promise; export type GetAsset = (document: DocumentLike, 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 ?? null, 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) { if (this.asset.url || this.asset.metadata) { return { ordinal: 1, url: this.asset.url || 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 = Number.isFinite(object?.expires_at) ? Number(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 { api: AxiosInstance | null; assetPresignTtlMs: number; assetCache: Map; assetInflight: Map>; constructor({ api, assetPresignTtlMs }: { api: AxiosInstance | null; assetPresignTtlMs: number }) { this.api = api; this.assetPresignTtlMs = assetPresignTtlMs; this.assetCache = new Map(); this.assetInflight = new Map(); } setApi(api: AxiosInstance | null) { this.api = api; } 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 ?? null); } 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 = Number.isFinite(object.expires_at) ? Number(object.expires_at) : null; if (!objectExpiresAt || objectExpiresAt > now) { return true; } } if (baseAsset.url && (!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.api) { return Promise.reject(new Error('AssetManager API client is not configured.')); } const request: Promise = this.api .get(`/assets/${asset.id}`) .then(({ data }) => { const cachedEntry = this.assetCache.get(asset.id) || baseAsset; const combined = { ...cachedEntry, ...asset, ...data }; const expiresAt = resolveAssetExpiresAt(data) ?? resolveAssetExpiresAt(combined) ?? Date.now() + this.assetPresignTtlMs; const entry = { ...combined, expiresAt, }; 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;