diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index a89ac6dc..036ddb65 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -111,6 +111,67 @@ export async function purgeOtherProjects(projectId: string): Promise { }); } +/** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null. + * 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */ +export async function readCachedBytes(projectId: string, url: string): Promise { + try { + const cached = await readAsset(projectId, url); + return cached?.body ?? null; + } catch { + return null; + } +} + +/** 받아 둔 바이트를 보관함에 담는다. 실패해도 화면은 그대로 간다(용량 초과 등). */ +export async function writeCachedBytes( + projectId: string, + url: string, + body: ArrayBuffer, +): Promise { + try { + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag: null, + savedAt: Date.now(), + body, + }); + } catch { + // 담지 못해도 다음에 다시 받으면 된다 — 막지 않는다. + } +} + +/** + * 한 프로젝트 안에서 **주소 앞머리가 같은 옛 보관본**을 지운다. + * + * 3D 코리도처럼 주소에 열쇠(해시)가 박히는 자료는 정본이 바뀔 때마다 새 주소가 되어 + * 옛 보관본이 그대로 쌓인다. 하나가 17MB 대라 두어 벌만 남아도 브라우저 보관함을 크게 + * 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(2026-09-06). + */ +export async function purgeAssetsWithPrefix(projectId: string, prefix: string): Promise { + const db = await openDatabase(); + if (!db) return; + await new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, "readwrite"); + const store = transaction.objectStore(STORE); + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + const value = cursor.value as CachedAsset; + if (value.projectId === projectId && value.url.startsWith(prefix)) cursor.delete(); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + export interface CachedFetchOptions { /** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */ onProgress?: (ratio: number | null) => void; diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 11740b21..58864d84 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -14,6 +14,11 @@ * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { + purgeAssetsWithPrefix, + readCachedBytes, + writeCachedBytes, +} from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; @@ -60,31 +65,51 @@ async function requestCorridor(path: string, init: RequestInit): Promise { + const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(hash)}`; try { + // **브라우저 보관함을 먼저 본다**(2026-09-06) — 저장본이 최신이면 열쇠가 맞아 매번 + // 17MB 를 받았고, 새로고침 한 번이면 메모리 캐시가 날아가 또 받았다. 주소에 열쇠가 + // 박혀 있으므로 정본이 바뀌면 저절로 다른 주소가 되어 옛 보관본을 안 쓴다. + const cached = await readCachedBytes(projectId, url); + if (cached) { + const payload = JSON.parse(new TextDecoder().decode(cached)) as CorridorEnvelope; + if (payload?.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { + return { kind: "envelope", envelope: payload }; + } + } const response = await requestCorridor( `/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`, { method: "GET" }, ); if (!response.ok) return { kind: "missing" }; - const payload = (await response.json()) as CorridorEnvelope & { status?: string }; + const text = await response.text(); + const payload = JSON.parse(text) as CorridorEnvelope & { status?: string }; if (payload?.status === "stale") return { kind: "stale" }; - return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons) - ? { kind: "envelope", envelope: payload } - : { kind: "missing" }; + if (payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { + // 담기 전에 열쇠만 다른 옛 보관본을 치운다 — 17MB 짜리가 쌓이지 않게. + void purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)).then(() => + writeCachedBytes(projectId, url, new TextEncoder().encode(text).buffer as ArrayBuffer), + ); + return { kind: "envelope", envelope: payload }; + } + return { kind: "missing" }; } catch { return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. }