export interface ProjectAssetStorageFileRecord { bytes: ArrayBuffer; mimeType: string; } export interface ProjectAssetStoragePackageRecord { files: Record; } export type ProjectAssetStorageRecord = ProjectAssetStoragePackageRecord; type LegacyProjectAssetStorageRecord = ProjectAssetStorageFileRecord; type ProjectAssetStorageStoredValue = ProjectAssetStorageRecord | LegacyProjectAssetStorageRecord; export interface ProjectAssetStorage { getAsset(storageKey: string): Promise; putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise; deleteAsset(storageKey: string): Promise; } export interface ProjectAssetStorageAccessResult { storage: ProjectAssetStorage | null; diagnostic: string | null; } const PROJECT_ASSET_DATABASE_NAME = "webeditor3d-project-assets"; const PROJECT_ASSET_DATABASE_VERSION = 1; const PROJECT_ASSET_OBJECT_STORE_NAME = "assets"; function cloneArrayBuffer(bytes: ArrayBuffer): ArrayBuffer { return bytes.slice(0); } function cloneFileRecord(file: ProjectAssetStorageFileRecord): ProjectAssetStorageFileRecord { return { bytes: cloneArrayBuffer(file.bytes), mimeType: file.mimeType }; } export function cloneProjectAssetStorageRecord(record: ProjectAssetStorageRecord): ProjectAssetStorageRecord { const files: Record = {}; for (const [path, file] of Object.entries(record.files)) { files[path] = cloneFileRecord(file); } return { files }; } function isObject(value: unknown): value is Record { return value !== null && typeof value === "object"; } function isLegacyProjectAssetStorageRecord(value: unknown): value is LegacyProjectAssetStorageRecord { return ( isObject(value) && value.bytes instanceof ArrayBuffer && typeof value.mimeType === "string" ); } function isProjectAssetStoragePackageRecord(value: unknown): value is ProjectAssetStorageRecord { if (!isObject(value) || !isObject(value.files)) { return false; } return Object.values(value.files).every((entry) => { return ( isObject(entry) && entry.bytes instanceof ArrayBuffer && typeof entry.mimeType === "string" ); }); } function normalizeStoredAssetRecord(storageKey: string, value: unknown): ProjectAssetStorageRecord | null { if (isProjectAssetStoragePackageRecord(value)) { return cloneProjectAssetStorageRecord(value); } if (isLegacyProjectAssetStorageRecord(value)) { return { files: { [storageKey]: cloneFileRecord(value) } }; } return null; } function getErrorDetail(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message.trim(); } return "Unknown error."; } function formatDiagnostic(prefix: string, error: unknown): string { return `${prefix} ${getErrorDetail(error)}`; } function promisifyRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.addEventListener("success", () => { resolve(request.result); }); request.addEventListener("error", () => { reject(request.error ?? new Error("IndexedDB request failed.")); }); }); } function openIndexedDb(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(PROJECT_ASSET_DATABASE_NAME, PROJECT_ASSET_DATABASE_VERSION); request.addEventListener("upgradeneeded", () => { const database = request.result; if (!database.objectStoreNames.contains(PROJECT_ASSET_OBJECT_STORE_NAME)) { database.createObjectStore(PROJECT_ASSET_OBJECT_STORE_NAME); } }); request.addEventListener("success", () => { resolve(request.result); }); request.addEventListener("error", () => { reject(request.error ?? new Error("IndexedDB open failed.")); }); }); } class IndexedDbProjectAssetStorage implements ProjectAssetStorage { private readonly databasePromise: Promise; constructor(databasePromise: Promise) { this.databasePromise = databasePromise; } private async withStore(mode: IDBTransactionMode, callback: (store: IDBObjectStore) => IDBRequest): Promise { const database = await this.databasePromise; const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, mode); const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME); const result = await promisifyRequest(callback(store)); await new Promise((resolve, reject) => { transaction.addEventListener("complete", () => resolve()); transaction.addEventListener("error", () => reject(transaction.error ?? new Error("IndexedDB transaction failed."))); transaction.addEventListener("abort", () => reject(transaction.error ?? new Error("IndexedDB transaction aborted."))); }); return result; } async getAsset(storageKey: string): Promise { const database = await this.databasePromise; const transaction = database.transaction(PROJECT_ASSET_OBJECT_STORE_NAME, "readonly"); const store = transaction.objectStore(PROJECT_ASSET_OBJECT_STORE_NAME); const result = await promisifyRequest(store.get(storageKey)); return normalizeStoredAssetRecord(storageKey, result); } async putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise { await this.withStore("readwrite", (store) => store.put(cloneProjectAssetStorageRecord(asset), storageKey) ); } async deleteAsset(storageKey: string): Promise { await this.withStore("readwrite", (store) => store.delete(storageKey)); } } class InMemoryProjectAssetStorage implements ProjectAssetStorage { private readonly values = new Map(); constructor(initialValues: Record = {}) { for (const [storageKey, asset] of Object.entries(initialValues)) { this.values.set(storageKey, cloneStoredAsset(asset)); } } async getAsset(storageKey: string): Promise { const asset = this.values.get(storageKey); if (asset === undefined) { return null; } return normalizeStoredAssetRecord(storageKey, asset); } async putAsset(storageKey: string, asset: ProjectAssetStorageRecord): Promise { this.values.set(storageKey, cloneProjectAssetStorageRecord(asset)); } async deleteAsset(storageKey: string): Promise { this.values.delete(storageKey); } } function cloneStoredAsset(asset: ProjectAssetStorageStoredValue): ProjectAssetStorageStoredValue { if (isLegacyProjectAssetStorageRecord(asset)) { return cloneFileRecord(asset); } return cloneProjectAssetStorageRecord(asset); } export function createInMemoryProjectAssetStorage( initialValues: Record = {} ): ProjectAssetStorage { return new InMemoryProjectAssetStorage(initialValues); } export async function getBrowserProjectAssetStorageAccess(): Promise { if (typeof window === "undefined") { return { storage: null, diagnostic: null }; } if (typeof indexedDB === "undefined") { return { storage: null, diagnostic: "IndexedDB is unavailable in this browser environment." }; } try { const databasePromise = openIndexedDb(); await databasePromise; return { storage: new IndexedDbProjectAssetStorage(databasePromise), diagnostic: null }; } catch (error) { return { storage: null, diagnostic: formatDiagnostic("Project asset storage could not be opened.", error) }; } }