'use client' import { useEffect, useReducer } from 'react' import { embedAuthedFetch, needsBearerAssetFetch } from '../utils/embed-authed-fetch' /** * Resolve ANY asset URL the browser would load natively — ``, * ``, a font, a poster — for hosts whose auth rides in request * HEADERS instead of cookies: native shells (`capacitor://`, `tauri://`) and * dev-ticket web, both in bearer mode. A native subresource load can't carry * an `Authorization` header, so a gateway URL that works on the cookie-auth * web 401s there. * * When `needsBearerAssetFetch` says the URL is bearer-authed, this hook * fetches it through `embedAuthedFetch` (bearer + deduped 401-refresh-retry + * the adapter's cross-origin guard — the SAME single auth knob every embedded * `fetch` in the lib rides) and returns a blob object-URL. Every other URL is * returned unchanged, so on the cookie-auth web the hook is a pass-through. * * Returns `undefined` while the fetch is in flight OR after it fails, so * callers show their existing placeholder / "no captions" branch instead of a * broken asset; a failed fetch retries on the next mount. * * Cache: module-level, session-lifetime, keyed by full URL — so the two * `` URLs of one player, or the same avatar in twenty rows, cost one * request. Gateway URLs carry a `?v=` cache-buster, so changed * content is a new key; entries are never revoked (a VTT / avatar is a few KB * — refcount churn isn't worth it) and are dropped wholesale at session end * via `clearAuthedAssetCache`. */ const resolvedCache = new Map() const inFlight = new Map>() let cacheGeneration = 0 /** * Drop every cached blob and revoke its object URL. Hosts call this at * session end (logout / forced re-login) so a follow-on login as a * different identity cannot be served blobs fetched under the previous * one's bearer. Fetches still in flight when the clear happens are fenced * by a generation counter — their results are revoked instead of cached. */ export function clearAuthedAssetCache(): void { cacheGeneration++ for (const url of resolvedCache.values()) URL.revokeObjectURL(url) resolvedCache.clear() inFlight.clear() } function fetchAsBlobUrl(src: string, accept: string): Promise { let pending = inFlight.get(src) if (!pending) { const startedGeneration = cacheGeneration pending = embedAuthedFetch(src, { headers: { Accept: accept } }) .then(async response => { if (!response.ok) throw new Error(`asset fetch failed: ${response.status}`) const blobUrl = URL.createObjectURL(await response.blob()) if (startedGeneration === cacheGeneration) { resolvedCache.set(src, blobUrl) } else { URL.revokeObjectURL(blobUrl) } }) .finally(() => { if (inFlight.get(src) === pending) inFlight.delete(src) }) inFlight.set(src, pending) } return pending } /** * @param src the asset URL (relative or absolute), or null/undefined * @param accept `Accept` header for the authed fetch — narrow it to what the * consumer renders (`image/*`, `text/vtt`); it also replaces * `embedAuthedFetch`'s default JSON `Content-Type` on the GET. */ export function useAuthedAssetSrc(src?: string | null, accept = '*/*'): string | undefined { const bearerSrc = src && needsBearerAssetFetch(src) ? src : null const [, rerender] = useReducer((c: number) => c + 1, 0) useEffect(() => { if (!bearerSrc || resolvedCache.has(bearerSrc)) return let cancelled = false fetchAsBlobUrl(bearerSrc, accept) .catch(() => {}) .finally(() => { if (!cancelled) rerender() }) return () => { cancelled = true } }, [bearerSrc, accept]) if (!src) return undefined if (!bearerSrc) return src return resolvedCache.get(bearerSrc) }