import { Asset, AssetMode } from "@noya-app/noya-schemas"; import { Base64, uuid } from "@noya-app/noya-utils"; import { Observable } from "@noya-app/observable"; import { UploadProgress } from "./rpc/types"; import { RPCManager } from "./rpcManager"; type AssetManagerOptions = { assetStore?: IAssetStore; initialAssets?: Asset[]; }; export type AssetUploadProgress = UploadProgress & { id: string; name?: string; contentType?: string; }; /** * AssetManager is an abstraction for managing assets. * It can be backed by IndexedDB, or a remote asset server. */ export class AssetManager { isInitialized$ = new Observable(false); assets$ = new Observable([]); uploads$ = new Observable>({ // example: { // name: "example.png", // percent: 50, // loaded: 1024 * 1024 * 2, // total: 1024 * 1024 * 5, // contentType: "image/png", // }, // example2: { // name: "example2.png", // percent: 50, // loaded: 1024 * 1024 * 2, // total: 1024 * 1024 * 5, // contentType: "image/png", // }, }); _assetStore: IAssetStore; get assetStore() { return this._assetStore; } set assetStore(value: IAssetStore) { this._assetStore = value; this.fetch(); } constructor(options: AssetManagerOptions = {}) { this._assetStore = options.assetStore ?? new AssetStoreMemory(); if (options.initialAssets) { this.set(options.initialAssets); } } // Public facing asset type exposing stableId as `id`. // Memoize by the asset object reference itself. _toNoyaAsset = (asset: Asset): NoyaAsset => _toNoyaAsset(asset); /** * Handles external updates to the asset list * * Preserves signed urls for unchanged assets to improve browser caching. */ set = (assets: Asset[]) => { const old = this.assets$.get(); const oldUrls = Object.fromEntries( old .filter((a) => { try { const parsedUrl = new URL(a.url); return parsedUrl.searchParams.get("token") !== null; } catch (error) { return false; } }) .map((a) => [a.id, a.url]) ); const newAssets = assets.map((a) => ({ ...a, url: oldUrls[a.id] ?? a.url, })); this.assets$.set(newAssets); this.isInitialized$.set(true); }; fetch = async (): Promise => { try { const assets = await this.assetStore.list(); this.set(assets); } catch (error) { console.warn( "Failed to fetch assets. This is probably okay, they will be pushed to us later.", error ); } }; _withUploadProgress = async ( name: string | undefined, fn: (onUploadProgress: CreateAssetOptions["onUploadProgress"]) => Promise ): Promise => { const uploadId = uuid(); try { return await fn((progress) => { this.uploads$.set({ ...this.uploads$.get(), [uploadId]: { ...progress, name, id: uploadId }, }); }); } finally { const { [uploadId]: _, ...uploads } = this.uploads$.get(); this.uploads$.set(uploads); } }; create = async ( options: CreateAssetOptions | Uint8Array | File | Blob ): Promise => { const normalized = await normalizeAssetData(options); const { stableId: stableIdOverride, mode } = !(options instanceof Uint8Array) && !(options instanceof File) && !(options instanceof Blob) ? { stableId: options.stableId, mode: options.mode } : {}; const asset = await this._withUploadProgress( normalized.name, (onUploadProgress) => this.assetStore.create({ data: normalized.data, contentType: normalized.contentType, ...(stableIdOverride ? { stableId: stableIdOverride } : {}), ...(mode ? { mode } : {}), onUploadProgress, }) ); await this.fetch(); const created = this.assets$.get().find((a) => a.id === asset.id) ?? asset; return this._toNoyaAsset(created); }; delete = async (id: string): Promise => { const assets = this.assets$.get(); const resolved = assets.find((a) => a.id === id || a.stableId === id); const realId = resolved?.id ?? id; const deleted = resolved ? this._toNoyaAsset(resolved) : undefined; await this.assetStore.delete(realId); await this.fetch(); return deleted; }; update = async ( id: string, options: UpdateAssetOptions | Uint8Array | File | Blob ): Promise => { if (!this.assetStore.update) { throw new Error("Asset store does not support updates"); } const assets = this.assets$.get(); const resolved = assets.find((a) => a.id === id || a.stableId === id); const realId = resolved?.id ?? id; const normalized = await normalizeAssetData(options); const asset = await this._withUploadProgress( normalized.name, (onUploadProgress) => this.assetStore.update!(realId, { data: normalized.data, contentType: normalized.contentType, onUploadProgress, }) ); await this.fetch(); const updated = this.assets$.get().find((a) => a.id === asset.id) ?? asset; return this._toNoyaAsset(updated); }; read = (idOrStableId: string): NoyaAsset | undefined => { const assets = this.assets$.get(); const a = assets.find((x) => x.stableId === idOrStableId) || assets.find((x) => x.id === idOrStableId); return a ? this._toNoyaAsset(a) : undefined; }; // Back-compat alias getAsset = (idOrStableId: string) => this.read(idOrStableId); /** * Internal: get the server id for an asset when given a stableId or id. */ _getServerAssetId = (idOrStableId: string): string | undefined => { const assets = this.assets$.get(); const a = assets.find((x) => x.stableId === idOrStableId) || assets.find((x) => x.id === idOrStableId); return a?.id; }; // Back-compat alias getRealAssetId = (idOrStableId: string) => this._getServerAssetId(idOrStableId); } export type StoreAsset = { id: string; stableId: string; data: Uint8Array; createdAt: string; size: number; contentType?: string; width: number | null; height: number | null; userId: string | null; mode?: AssetMode; version?: number; }; export type CreateAssetOptions = { data: Uint8Array; contentType?: string; // Preserve a caller-specified stableId (e.g., during import) stableId?: string; // Asset mutability mode mode?: AssetMode; onUploadProgress?: (progress: { loaded: number; total?: number; percent?: number; }) => void; }; export type UpdateAssetOptions = { data: Uint8Array; contentType?: string; onUploadProgress?: (progress: { loaded: number; total?: number; percent?: number; }) => void; }; type NormalizedAssetData = { data: Uint8Array; contentType?: string; name?: string; }; async function normalizeAssetData( input: Uint8Array | File | Blob | { data: Uint8Array; contentType?: string } ): Promise { if (input instanceof Uint8Array) { return { data: input }; } else if (input instanceof File) { return { data: new Uint8Array(await input.arrayBuffer()), contentType: input.type, name: input.name, }; } else if (input instanceof Blob) { return { data: new Uint8Array(await input.arrayBuffer()), contentType: input.type, }; } else { return { data: input.data, contentType: input.contentType, }; } } export interface IAssetStore { read: (id: string) => Promise; list: () => Promise; create: (options: CreateAssetOptions) => Promise; update?: (id: string, options: UpdateAssetOptions) => Promise; delete: (id: string) => Promise; // Optional internal method for returning raw assets with bytes for local bridges _listWithBytes?: () => Promise; } export type NoyaAsset = Omit; // Memoized factory for NoyaAsset objects keyed by Asset object reference. // This ensures stable object identity when the same Asset object is passed multiple times. const _toNoyaAssetCache = new WeakMap(); const _toNoyaAsset = (asset: Asset): NoyaAsset => { let cached = _toNoyaAssetCache.get(asset); if (!cached) { cached = { id: asset.stableId, url: asset.url, createdAt: asset.createdAt, size: asset.size, contentType: asset.contentType, width: asset.width ?? null, height: asset.height ?? null, userId: asset.userId ?? null, mode: asset.mode, version: asset.version, }; _toNoyaAssetCache.set(asset, cached); } return cached; }; type AssetManagerLocalOptions = { randomId?: () => string; databaseName?: string; indexedDB?: typeof globalThis.indexedDB; }; /** * AssetManagerLocal is a local asset manager that uses IndexedDB to store assets. */ export class AssetStoreIndexedDB implements IAssetStore { databaseName: string; randomId: () => string; db: Promise; constructor(options: AssetManagerLocalOptions = {}) { this.databaseName = options.databaseName ?? "noya-assets"; this.randomId = options.randomId ?? uuid; this.db = this._initialize(options); } async _initialize({ indexedDB = globalThis.indexedDB, }: { indexedDB?: typeof globalThis.indexedDB; } & Omit = {}): Promise { const db = await new Promise((resolve, reject) => { const request = indexedDB.open(this.databaseName, 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; if (!db.objectStoreNames.contains("assets")) { db.createObjectStore("assets", { keyPath: "id" }); } }; }); await this._populateUrlCache(db); return db; } read = async (id: string): Promise => { const asset = await this._readWithBytes(id); if (!asset) return null; const { data: _, ...rest } = asset; return { ...rest, url: this._getUrl(id) }; }; _readWithBytes = async (id: string): Promise => { const db = await this.db; return new Promise((resolve, reject) => { const transaction = db.transaction("assets", "readonly"); const objectStore = transaction.objectStore("assets"); const request = objectStore.get(id) as IDBRequest; request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); }; list = async (): Promise => { const db = await this.db; return new Promise((resolve, reject) => { const transaction = db.transaction("assets", "readonly"); const objectStore = transaction.objectStore("assets"); const request = objectStore.getAll() as IDBRequest; request.onerror = () => reject(request.error); request.onsuccess = () => resolve( request.result.map(({ data, ...rest }) => ({ ...rest, url: this._getUrl(rest.id), })) ); }); }; _listWithBytes = async (): Promise => { const db = await this.db; return this._list(db); }; create = async (asset: CreateAssetOptions): Promise => { const id = this.randomId(); const createdAsset: StoreAsset = { id, stableId: asset.stableId ?? uuid(), data: asset.data, size: asset.data.length, createdAt: new Date().toISOString(), contentType: asset.contentType, width: null, height: null, userId: null, mode: undefined, version: undefined, }; const db = await this.db; await db .transaction("assets", "readwrite") .objectStore("assets") .put(createdAsset); this._createAssetUrl(createdAsset); return { id, stableId: createdAsset.stableId, url: this._getUrl(id), createdAt: createdAsset.createdAt, size: createdAsset.size, contentType: createdAsset.contentType, width: createdAsset.width, height: createdAsset.height, userId: createdAsset.userId, mode: createdAsset.mode, version: createdAsset.version, }; }; update = async (id: string, options: UpdateAssetOptions): Promise => { const db = await this.db; const existing = await this._readWithBytes(id); if (!existing) { throw new Error(`Asset ${id} not found`); } const updatedAsset: StoreAsset = { ...existing, data: options.data, size: options.data.length, contentType: options.contentType ?? existing.contentType, version: (existing.version ?? 0) + 1, }; await db .transaction("assets", "readwrite") .objectStore("assets") .put(updatedAsset); // Revoke old URL and create new one const oldUrl = this._urlCache.get(id); if (oldUrl) { URL.revokeObjectURL(oldUrl); } this._createAssetUrl(updatedAsset); return { id, stableId: updatedAsset.stableId, url: this._getUrl(id), createdAt: updatedAsset.createdAt, size: updatedAsset.size, contentType: updatedAsset.contentType, width: updatedAsset.width, height: updatedAsset.height, userId: updatedAsset.userId, mode: updatedAsset.mode, version: updatedAsset.version, }; }; delete = async (id: string) => { const db = await this.db; await db .transaction("assets", "readwrite") .objectStore("assets") .delete(id); }; // Private methods _list = async (db: IDBDatabase): Promise => { return new Promise((resolve, reject) => { const transaction = db.transaction("assets", "readonly"); const objectStore = transaction.objectStore("assets"); const request = objectStore.getAll(); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); }; _urlCache = new Map(); _getUrl = (id: string) => { const url = this._urlCache.get(id); if (!url) { throw new Error(`URL for asset ${id} not found`); } return url; }; _createAssetUrl(asset: StoreAsset) { const blob = new Blob([asset.data], { ...(asset.contentType ? { type: asset.contentType } : {}), }); const url = URL.createObjectURL(blob); this._urlCache.set(asset.id, url); } async _populateUrlCache(db: IDBDatabase) { const assets = await this._list(db); for (const asset of assets) { this._createAssetUrl(asset); } } } type AssetStoreMemoryOptions = { randomId?: () => string; initialAssets?: StoreAsset[]; }; export class AssetStoreMemory implements IAssetStore { assets: StoreAsset[] = []; randomId: () => string; constructor(options: AssetStoreMemoryOptions = {}) { this.randomId = options.randomId ?? uuid; this.assets = options.initialAssets ?? []; this.assets.forEach((asset) => { this._createAssetUrl(asset); }); } read = async (id: string): Promise => { const asset = this.assets.find((asset) => asset.id === id); return asset ? { ...asset, url: this._getUrl(id) } : null; }; list = async (): Promise => { return this.assets.map(({ data, ...rest }) => ({ ...rest, url: this._getUrl(rest.id), })); }; _listWithBytes = async (): Promise => { return this.assets; }; create = async (asset: CreateAssetOptions): Promise => { const id = this.randomId(); const createdAsset: StoreAsset = { id, stableId: asset.stableId ?? uuid(), data: asset.data, size: asset.data.length, createdAt: new Date().toISOString(), contentType: asset.contentType, width: null, height: null, userId: null, mode: undefined, version: undefined, }; this.assets.push(createdAsset); this._createAssetUrl(createdAsset); return { id, stableId: createdAsset.stableId, url: this._getUrl(id), createdAt: createdAsset.createdAt, size: createdAsset.size, contentType: createdAsset.contentType, width: createdAsset.width, height: createdAsset.height, userId: createdAsset.userId, mode: createdAsset.mode, version: createdAsset.version, }; }; update = async (id: string, options: UpdateAssetOptions): Promise => { const existingIndex = this.assets.findIndex((asset) => asset.id === id); if (existingIndex === -1) { throw new Error(`Asset ${id} not found`); } const existing = this.assets[existingIndex]; const updatedAsset: StoreAsset = { ...existing, data: options.data, size: options.data.length, contentType: options.contentType ?? existing.contentType, version: (existing.version ?? 0) + 1, }; this.assets[existingIndex] = updatedAsset; // Revoke old URL and create new one const oldUrl = this._urlCache.get(id); if (oldUrl) { URL.revokeObjectURL(oldUrl); } this._createAssetUrl(updatedAsset); return { id, stableId: updatedAsset.stableId, url: this._getUrl(id), createdAt: updatedAsset.createdAt, size: updatedAsset.size, contentType: updatedAsset.contentType, width: updatedAsset.width, height: updatedAsset.height, userId: updatedAsset.userId, mode: updatedAsset.mode, version: updatedAsset.version, }; }; delete = async (id: string) => { this.assets = this.assets.filter((asset) => asset.id !== id); }; _urlCache = new Map(); _getUrl = (id: string) => { const url = this._urlCache.get(id); if (!url) { throw new Error(`URL for asset ${id} not found`); } return url; }; _createAssetUrl(asset: StoreAsset) { const blob = asset.contentType ? new Blob([asset.data], { type: asset.contentType }) : new Blob([asset.data]); const url = URL.createObjectURL(blob); this._urlCache.set(asset.id, url); } } export class AssetStoreRemote implements IAssetStore { constructor(private rpcManager: RPCManager) {} read = async (id: string) => { const list = await this.list(); return ( list.find((asset) => asset.id === id) || list.find((asset) => asset.stableId === id) || null ); }; list = async () => { const response = await this.rpcManager.requestRoute("GET /api/assets"); return JSON.parse(response.body) as Asset[]; }; create = async (options: CreateAssetOptions) => { const response = await this.rpcManager.requestRoute("POST /api/assets", { body: Base64.encode(options.data), headers: { ...(options.contentType ? { "Content-Type": options.contentType } : {}), }, searchParams: { ...(options.mode ? { mode: options.mode } : {}), }, onUploadProgress: options.onUploadProgress, }); return JSON.parse(response.body) as Asset; }; update = async (id: string, options: UpdateAssetOptions) => { const response = await this.rpcManager.requestRoute( `PUT /api/assets/${id}` as "PUT /api/assets/:id", { body: Base64.encode(options.data), headers: { ...(options.contentType ? { "Content-Type": options.contentType } : {}), }, onUploadProgress: options.onUploadProgress, } ); return JSON.parse(response.body) as Asset; }; delete = async (id: string) => { await this.rpcManager.requestRoute( `DELETE /api/assets/${id}` as "DELETE /api/assets/:id" ); }; }