- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
263 lines
8.7 KiB
JavaScript
263 lines
8.7 KiB
JavaScript
/* 343-3 브라우저 보관함(b_asset_cache) — 진짜 TS 를 가짜 IndexedDB 위에서 돌려 JSON 으로 냄.
|
|
* 저장 끝 = 트랜잭션 완료 · 실패 한 줄 경고 · 열기 막힘 · 실패 null 안 기억 · 색인 열쇠 지우기.
|
|
* 끝에 점군 이진 풀기(decodePointPreview) — 인자로 받은 파이썬 이진 파일을 풀어 냄. */
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
|
|
|
const ROOT = path.join(__dirname, "..", "..");
|
|
const load = (rel, requireStub) => {
|
|
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
const exports = {};
|
|
new Function("exports", "require", "window", js)(exports, requireStub, global.window);
|
|
return exports;
|
|
};
|
|
|
|
const later = (fn) => setTimeout(fn, 0);
|
|
global.IDBKeyRange = {
|
|
upperBound: (v, open) => ({ test: (k) => (open ? k < v : k <= v) }),
|
|
lowerBound: (v, open) => ({ test: (k) => (open ? k > v : k >= v) }),
|
|
bound: (lo, hi) => ({ test: (k) => k >= lo && k <= hi }),
|
|
};
|
|
|
|
function makeIdb(options = {}) {
|
|
const data = new Map();
|
|
const stats = { valueReads: 0, opens: 0 };
|
|
let failLeft = options.failOpenTimes ?? 0;
|
|
let created = false;
|
|
function makeTx() {
|
|
const tx = { oncomplete: null, onabort: null, error: null };
|
|
let pending = 0;
|
|
let aborted = false;
|
|
let done = false;
|
|
const finish = () =>
|
|
later(() => {
|
|
if (pending !== 0 || done) return;
|
|
done = true;
|
|
if (aborted) tx.onabort && tx.onabort();
|
|
else tx.oncomplete && tx.oncomplete();
|
|
});
|
|
const req = (fn) => {
|
|
const r = { onsuccess: null, onerror: null, result: undefined };
|
|
pending += 1;
|
|
later(() => {
|
|
r.result = fn();
|
|
if (r.onsuccess) r.onsuccess();
|
|
pending -= 1;
|
|
finish();
|
|
});
|
|
return r;
|
|
};
|
|
const store = {
|
|
get: (key) =>
|
|
req(() => {
|
|
stats.valueReads += 1;
|
|
return data.get(key);
|
|
}),
|
|
put: (value) =>
|
|
req(() => {
|
|
// 용량 초과 — 요청은 성공으로 끝나도 트랜잭션이 통째 취소됨(실제 브라우저와 같음)
|
|
if (options.abortPuts) {
|
|
aborted = true;
|
|
tx.error = { name: "QuotaExceededError" };
|
|
} else data.set(value.key, value);
|
|
return value.key;
|
|
}),
|
|
delete: (keyOrRange) =>
|
|
req(() => {
|
|
for (const k of [...data.keys()]) {
|
|
if (typeof keyOrRange === "string" ? k === keyOrRange : keyOrRange.test(k)) {
|
|
data.delete(k);
|
|
}
|
|
}
|
|
}),
|
|
openCursor: () => {
|
|
throw new Error("값 커서를 쓰면 안 됨(값을 읽음)");
|
|
},
|
|
index: () => ({
|
|
openKeyCursor: (range) => {
|
|
const keys = [...data.entries()]
|
|
.filter(([, v]) => range.test(v.projectId))
|
|
.map(([k]) => k);
|
|
let i = 0;
|
|
const r = { onsuccess: null, result: null };
|
|
pending += 1;
|
|
const step = () =>
|
|
later(() => {
|
|
if (i < keys.length) {
|
|
r.result = { primaryKey: keys[i], continue: () => ((i += 1), step()) };
|
|
r.onsuccess && r.onsuccess();
|
|
} else {
|
|
r.result = null;
|
|
r.onsuccess && r.onsuccess();
|
|
pending -= 1;
|
|
finish();
|
|
}
|
|
});
|
|
step();
|
|
return r;
|
|
},
|
|
}),
|
|
};
|
|
tx.objectStore = () => store;
|
|
return tx;
|
|
}
|
|
const indexedDB = {
|
|
open: () => {
|
|
stats.opens += 1;
|
|
const r = {};
|
|
later(() => {
|
|
if (options.blockOpen) return r.onblocked && r.onblocked();
|
|
if (failLeft > 0) {
|
|
failLeft -= 1;
|
|
r.error = { name: "UnknownError" };
|
|
return r.onerror && r.onerror();
|
|
}
|
|
const db = {
|
|
objectStoreNames: { contains: () => created },
|
|
createObjectStore: () => ({ createIndex() {} }),
|
|
deleteObjectStore() {},
|
|
transaction: () => makeTx(),
|
|
close() {},
|
|
};
|
|
r.result = db;
|
|
if (!created) {
|
|
created = true;
|
|
r.onupgradeneeded && r.onupgradeneeded();
|
|
}
|
|
r.onsuccess && r.onsuccess();
|
|
});
|
|
return r;
|
|
},
|
|
};
|
|
return { indexedDB, data, stats };
|
|
}
|
|
|
|
const warnings = [];
|
|
console.warn = (...args) => warnings.push(args.join(" "));
|
|
let fetches = 0;
|
|
global.fetch = async (url, init) => {
|
|
fetches += 1;
|
|
const conditional = init && init.headers && init.headers["If-None-Match"];
|
|
return {
|
|
ok: !conditional,
|
|
status: conditional ? 304 : 200,
|
|
headers: { get: (name) => (name.toLowerCase() === "etag" ? '"e1"' : null) },
|
|
body: null,
|
|
arrayBuffer: async () => new TextEncoder().encode(`body:${url}`).buffer,
|
|
};
|
|
};
|
|
|
|
const stub = (id) => {
|
|
if (id.includes("config_frontend")) return { API_BASE_URL: "http://x/api" };
|
|
return {};
|
|
};
|
|
const fresh = (options) => {
|
|
const idb = makeIdb(options);
|
|
global.window = { indexedDB: idb.indexedDB };
|
|
warnings.length = 0;
|
|
fetches = 0;
|
|
return { idb, cache: load("A00_Common/b_asset_cache.ts", stub) };
|
|
};
|
|
const text = (buffer) => new TextDecoder().decode(buffer);
|
|
|
|
(async () => {
|
|
const out = {};
|
|
|
|
// 1. 받고 저장 → 두 번째는 보관본(네트워크는 뒤 확인 한 번뿐)
|
|
{
|
|
const { idb, cache } = fresh();
|
|
const first = text(await cache.fetchCachedBytes("P", "http://x/a"));
|
|
const afterFirst = fetches;
|
|
const second = text(await cache.fetchCachedBytes("P", "http://x/a"));
|
|
out.store = {
|
|
first,
|
|
second,
|
|
stored: idb.data.has("P|http://x/a"),
|
|
afterFirst,
|
|
warnings: [...warnings],
|
|
};
|
|
}
|
|
|
|
// 2. 용량 초과 — 저장이 취소되면 한 줄 경고 · 다음엔 다시 받음
|
|
{
|
|
const { idb, cache } = fresh({ abortPuts: true });
|
|
await cache.fetchCachedBytes("P", "http://x/a");
|
|
await cache.writeCachedBytes("P", "http://x/b", new ArrayBuffer(4));
|
|
const before = fetches;
|
|
await cache.fetchCachedBytes("P", "http://x/a");
|
|
out.quota = { stored: idb.data.size, warnings: [...warnings], refetched: fetches - before };
|
|
}
|
|
|
|
// 3. 열기 실패 한 번 — null 을 기억하지 않고 다음 부름에서 다시 엶
|
|
{
|
|
const { idb, cache } = fresh({ failOpenTimes: 1 });
|
|
const missed = await cache.readCachedBytes("P", "http://x/a");
|
|
await cache.writeCachedBytes("P", "http://x/a", new TextEncoder().encode("v").buffer);
|
|
const hit = await cache.readCachedBytes("P", "http://x/a");
|
|
out.retry = {
|
|
missed,
|
|
hit: hit ? text(hit) : null,
|
|
opens: idb.stats.opens,
|
|
warnings: [...warnings],
|
|
};
|
|
}
|
|
|
|
// 4. 다른 탭이 막음 — 기다리지 않고 보관 없이 받음
|
|
{
|
|
const { cache } = fresh({ blockOpen: true });
|
|
const body = text(await cache.fetchCachedBytes("P", "http://x/a"));
|
|
out.blocked = { body, warnings: [...warnings] };
|
|
}
|
|
|
|
// 5. 지우기 — 색인 열쇠만(값 안 읽음) · 앞머리 범위
|
|
{
|
|
const { idb, cache } = fresh();
|
|
const buf = new ArrayBuffer(2);
|
|
await cache.writeCachedBytes("P", "http://x/corridor/1", buf);
|
|
await cache.writeCachedBytes("P", "http://x/corridor/2", buf);
|
|
await cache.writeCachedBytes("P", "http://x/keep", buf);
|
|
await cache.writeCachedBytes("Q", "http://x/keep", buf);
|
|
await cache.writeCachedBytes("A", "http://x/keep", buf);
|
|
const readsBefore = idb.stats.valueReads;
|
|
await cache.purgeOtherProjects("P");
|
|
await cache.purgeAssetsWithPrefix("P", "http://x/corridor/");
|
|
out.purge = {
|
|
keys: [...idb.data.keys()].sort(),
|
|
valueReads: idb.stats.valueReads - readsBefore,
|
|
};
|
|
}
|
|
|
|
// 6. 등고선 주소 한 벌
|
|
{
|
|
const { cache } = fresh();
|
|
out.contour = [
|
|
cache.contourAssetUrl("P", 7, 1, false),
|
|
cache.contourAssetUrl("P", 7, 2, true, true),
|
|
];
|
|
}
|
|
|
|
// 7. 점군 이진 풀기(파이썬이 만든 파일)
|
|
if (process.argv[2]) {
|
|
global.window = {};
|
|
const api = load("B04_PreProcess/B04_PreProcess_Api_Fetch.ts", stub);
|
|
const bytes = fs.readFileSync(process.argv[2]);
|
|
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
const decoded = api.decodePointPreview(buffer);
|
|
out.preview = {
|
|
point_count: decoded.point_count,
|
|
sampled_count: decoded.sampled_count,
|
|
bounds: decoded.bounds,
|
|
first: decoded.points[0],
|
|
last: decoded.points[decoded.points.length - 1],
|
|
rgbFirst: decoded.rgb ? decoded.rgb[0] : null,
|
|
};
|
|
}
|
|
process.stdout.write(JSON.stringify(out));
|
|
})().catch((error) => {
|
|
process.stderr.write(String(error && error.stack ? error.stack : error));
|
|
process.exit(1);
|
|
});
|