318 lines
8.1 KiB
TypeScript
318 lines
8.1 KiB
TypeScript
import type { AxiosInstance } from 'axios';
|
|
|
|
export type Identifier = string | number;
|
|
|
|
type Nullable<T> = T | null;
|
|
|
|
export interface AssetObject {
|
|
ordinal?: number;
|
|
url?: string | null;
|
|
metadata?: Record<string, unknown> | 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<string, unknown> | null;
|
|
expiresAt?: number | null;
|
|
assets?: Record<string, AssetLike> | AssetLike[] | null;
|
|
objects?: AssetObject[] | null;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface DocumentVersionLike {
|
|
assets?: Record<string, AssetLike> | AssetLike[] | null;
|
|
metadata?: Record<string, unknown> & { 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<unknown>;
|
|
|
|
export type GetAsset = (document: DocumentLike, assetType: string) => Nullable<AssetLike>;
|
|
|
|
export const getAssetFromGroup = (
|
|
assets?: AssetLike[] | Record<string, AssetLike> | null,
|
|
assetType: string = '',
|
|
): Nullable<AssetLike> => {
|
|
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<DocumentVersionLike>, 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<string, unknown> | 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<DocumentLike>,
|
|
type: string,
|
|
{
|
|
ensureAssetUrl,
|
|
getAsset,
|
|
ensureOptions,
|
|
}: {
|
|
ensureAssetUrl?: EnsureAssetUrl;
|
|
getAsset?: GetAsset;
|
|
ensureOptions?: { force?: boolean; [key: string]: unknown };
|
|
} = {},
|
|
): Nullable<string> => {
|
|
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<Identifier, AssetLike>;
|
|
assetInflight: Map<string, Promise<AssetLike | null>>;
|
|
|
|
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<AssetLike>) {
|
|
if (entry?.id) {
|
|
this.assetCache.set(entry.id, entry);
|
|
}
|
|
}
|
|
|
|
ensureAsset(
|
|
documentId?: Identifier | null,
|
|
asset?: Nullable<AssetLike>,
|
|
{ force = false }: { force?: boolean } = {},
|
|
): Promise<Nullable<AssetLike>> {
|
|
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<AssetLike | null> = 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;
|