221 lines
8.5 KiB
TypeScript
221 lines
8.5 KiB
TypeScript
import localforage from "localforage";
|
|
|
|
import { getActiveUserScope } from "@/lib/user-scope";
|
|
import { getResourceBlob, resourceIdFromStorageKey } from "@/services/api/resources";
|
|
|
|
type ResourceCacheMeta = {
|
|
key: string;
|
|
userScope: string;
|
|
resourceId: string;
|
|
version: string;
|
|
size: number;
|
|
mimeType: string;
|
|
lastAccessedAt: number;
|
|
};
|
|
|
|
const blobStore = localforage.createInstance({ name: "infinite-canvas", storeName: "resource_blobs" });
|
|
const metaStore = localforage.createInstance({ name: "infinite-canvas", storeName: "resource_blob_meta" });
|
|
const objectUrls = new Map<string, string>();
|
|
const sessionBlobs = new Map<string, Blob>();
|
|
const inFlight = new Map<string, Promise<string>>();
|
|
const downloadQueue: Array<() => void> = [];
|
|
let activeDownloads = 0;
|
|
let persistQueue: Promise<void> = Promise.resolve();
|
|
const MAX_CACHE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
const FALLBACK_CACHE_BYTES = 512 * 1024 * 1024;
|
|
const MIN_CACHE_BYTES = 64 * 1024 * 1024;
|
|
const MAX_CACHE_ENTRIES = 500;
|
|
const TOUCH_INTERVAL_MS = 10 * 60 * 1000;
|
|
const MAX_CONCURRENT_DOWNLOADS = 4;
|
|
|
|
export async function getCachedResourceObjectUrl(storageKey: string) {
|
|
const target = await cacheTarget(storageKey);
|
|
if (!target) return "";
|
|
return readCachedObjectUrl(target);
|
|
}
|
|
|
|
export async function cacheResourceObjectUrl(storageKey: string) {
|
|
const target = await cacheTarget(storageKey);
|
|
if (!target) return "";
|
|
const cached = await readCachedObjectUrl(target);
|
|
if (cached) return cached;
|
|
const pending = inFlight.get(target.key);
|
|
if (pending) return pending;
|
|
|
|
const task = withDownloadSlot(() => downloadAndCacheResource(storageKey, target)).finally(() => inFlight.delete(target.key));
|
|
inFlight.set(target.key, task);
|
|
return task;
|
|
}
|
|
|
|
function withDownloadSlot<T>(task: () => Promise<T>) {
|
|
return new Promise<T>((resolve, reject) => {
|
|
downloadQueue.push(() => {
|
|
activeDownloads += 1;
|
|
task().then(resolve, reject).finally(() => {
|
|
activeDownloads -= 1;
|
|
runDownloadQueue();
|
|
});
|
|
});
|
|
runDownloadQueue();
|
|
});
|
|
}
|
|
|
|
function runDownloadQueue() {
|
|
while (activeDownloads < MAX_CONCURRENT_DOWNLOADS && downloadQueue.length) downloadQueue.shift()?.();
|
|
}
|
|
|
|
export async function primeResourceBlobCache(storageKey: string, blob: Blob) {
|
|
const target = await cacheTarget(storageKey);
|
|
if (!target) return "";
|
|
sessionBlobs.set(target.key, blob);
|
|
const url = objectUrl(target.key, blob);
|
|
if (blob.size <= MAX_CACHE_BYTES) void enqueuePersist(target, blob);
|
|
return url;
|
|
}
|
|
|
|
export async function getCachedResourceBlob(storageKey: string) {
|
|
const target = await cacheTarget(storageKey);
|
|
if (!target) return null;
|
|
const cached = await blobStore.getItem<Blob>(target.key);
|
|
if (cached) {
|
|
void touchCacheMeta(target).catch(() => undefined);
|
|
return cached;
|
|
}
|
|
const sessionBlob = sessionBlobs.get(target.key);
|
|
if (sessionBlob) return sessionBlob;
|
|
const pending = inFlight.get(target.key);
|
|
if (pending) {
|
|
await pending;
|
|
return sessionBlobs.get(target.key) || blobStore.getItem<Blob>(target.key);
|
|
}
|
|
await cacheResourceObjectUrl(storageKey);
|
|
return sessionBlobs.get(target.key) || blobStore.getItem<Blob>(target.key);
|
|
}
|
|
|
|
async function downloadAndCacheResource(storageKey: string, target: ResourceCacheMeta) {
|
|
const blob = await downloadResourceBlob(storageKey, target);
|
|
if (!blob) return "";
|
|
return objectUrl(target.key, blob);
|
|
}
|
|
|
|
async function downloadResourceBlob(storageKey: string, target: ResourceCacheMeta) {
|
|
const blob = await getResourceBlob(storageKey);
|
|
if (!blob) return null;
|
|
sessionBlobs.set(target.key, blob);
|
|
if (blob.size <= MAX_CACHE_BYTES) await enqueuePersist(target, blob);
|
|
return blob;
|
|
}
|
|
|
|
function enqueuePersist(target: ResourceCacheMeta, blob: Blob) {
|
|
const task = persistQueue.then(() => persistBlob(target, blob));
|
|
persistQueue = task.catch(() => undefined);
|
|
return task.catch(() => undefined);
|
|
}
|
|
|
|
async function persistBlob(target: ResourceCacheMeta, blob: Blob) {
|
|
// 不尝试写入超过当前缓存预算的单个媒体,避免触发浏览器配额异常和无效的全量淘汰。
|
|
if (blob.size > (await cacheBudget())) return;
|
|
await evictFor(blob.size, target.key);
|
|
try {
|
|
await blobStore.setItem(target.key, blob);
|
|
await metaStore.setItem(target.key, { ...target, size: blob.size, mimeType: blob.type || target.mimeType, lastAccessedAt: Date.now() });
|
|
} catch (error) {
|
|
await evictFor(blob.size, target.key, true);
|
|
try {
|
|
await blobStore.setItem(target.key, blob);
|
|
await metaStore.setItem(target.key, { ...target, size: blob.size, mimeType: blob.type || target.mimeType, lastAccessedAt: Date.now() });
|
|
} catch {
|
|
await blobStore.removeItem(target.key);
|
|
await metaStore.removeItem(target.key);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function readCachedObjectUrl(target: ResourceCacheMeta) {
|
|
const existing = objectUrls.get(target.key);
|
|
if (existing) {
|
|
void touchCacheMeta(target).catch(() => undefined);
|
|
return existing;
|
|
}
|
|
const blob = await blobStore.getItem<Blob>(target.key);
|
|
if (!blob) return "";
|
|
void touchCacheMeta(target).catch(() => undefined);
|
|
return objectUrl(target.key, blob);
|
|
}
|
|
|
|
async function cacheTarget(storageKey: string): Promise<ResourceCacheMeta | null> {
|
|
const resourceId = resourceIdFromStorageKey(storageKey);
|
|
if (!resourceId) return null;
|
|
const userScope = getActiveUserScope();
|
|
if (userScope === "guest") throw new Error("游客不能读取远程媒体缓存");
|
|
// resource ID 是不可变资源的稳定标识,文件接口本身负责鉴权。
|
|
// 缓存初始化不能先对每个资源读取一遍元数据,否则首屏会重新形成 N+1 请求。
|
|
const version = "file";
|
|
return {
|
|
key: `${userScope}:${resourceId}:${version}`,
|
|
userScope,
|
|
resourceId,
|
|
version,
|
|
size: 0,
|
|
mimeType: "application/octet-stream",
|
|
lastAccessedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
async function touchCacheMeta(target: ResourceCacheMeta) {
|
|
const current = await metaStore.getItem<ResourceCacheMeta>(target.key);
|
|
if (!current || Date.now() - current.lastAccessedAt < TOUCH_INTERVAL_MS) return;
|
|
await metaStore.setItem(target.key, { ...current, lastAccessedAt: Date.now() });
|
|
}
|
|
|
|
async function evictFor(incomingBytes: number, protectedKey: string, aggressive = false) {
|
|
const metas: ResourceCacheMeta[] = [];
|
|
await metaStore.iterate<ResourceCacheMeta, void>((value) => {
|
|
if (value?.key) metas.push(value);
|
|
});
|
|
const budget = aggressive ? Math.max(MIN_CACHE_BYTES, (await cacheBudget()) / 2) : await cacheBudget();
|
|
let total = metas.reduce((sum, item) => sum + Math.max(0, item.size || 0), 0);
|
|
let count = metas.length;
|
|
// 当前页面正在使用的 Blob URL 不能在 LRU 清理时撤销,否则已渲染节点会立即变成失效资源。
|
|
const candidates = metas.filter((item) => item.key !== protectedKey && !objectUrls.has(item.key) && !sessionBlobs.has(item.key)).sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);
|
|
for (const candidate of candidates) {
|
|
if (total + incomingBytes <= budget && count < MAX_CACHE_ENTRIES) break;
|
|
await removeCacheEntry(candidate);
|
|
total -= Math.max(0, candidate.size || 0);
|
|
count -= 1;
|
|
}
|
|
}
|
|
|
|
async function removeCacheEntry(meta: ResourceCacheMeta) {
|
|
const url = objectUrls.get(meta.key);
|
|
if (url) URL.revokeObjectURL(url);
|
|
objectUrls.delete(meta.key);
|
|
sessionBlobs.delete(meta.key);
|
|
await Promise.all([blobStore.removeItem(meta.key), metaStore.removeItem(meta.key)]);
|
|
}
|
|
|
|
async function cacheBudget() {
|
|
if (!navigator.storage?.estimate) return FALLBACK_CACHE_BYTES;
|
|
const estimate = await navigator.storage.estimate().catch(() => null);
|
|
if (!estimate?.quota) return FALLBACK_CACHE_BYTES;
|
|
return Math.min(MAX_CACHE_BYTES, Math.max(MIN_CACHE_BYTES, Math.floor(estimate.quota * 0.2)));
|
|
}
|
|
|
|
function objectUrl(key: string, blob: Blob) {
|
|
const existing = objectUrls.get(key);
|
|
if (existing) return existing;
|
|
const url = URL.createObjectURL(blob);
|
|
objectUrls.set(key, url);
|
|
return url;
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("pagehide", (event) => {
|
|
if (event.persisted) return;
|
|
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
objectUrls.clear();
|
|
sessionBlobs.clear();
|
|
});
|
|
}
|