diff --git a/.gitignore b/.gitignore index e42fb23..1e98e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ samples/* # https://www.laiseek.xyz/diablo2/assets/d2-packs-<日期>.tar.xz #(Gitea 的 Release 附件在本实例上是坏的:任何大小都返回 HTTP 500) public/act-packs/ +public/asset-manifest.json *.mpq # 夹具里的 fixture.mpq 是我们自己生成的(make-map-fixtures.ts),要入库 !samples/fixtures/*.mpq diff --git a/package.json b/package.json index b12ecef..7856dcb 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,9 @@ "verify:tbl": "tsx tools/verify-tbl.ts", "verify:formats": "tsx tools/verify-formats.ts", "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && tsx src/baker/io/asset-manifest.ts && vite build", "preview": "vite preview", - "typecheck": "tsc -p tsconfig.common.json --noEmit && tsc -p tsconfig.netproto.json --noEmit && tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.client.json --noEmit && tsc -p tsconfig.baker.json --noEmit && tsc -p tsconfig.json --noEmit", + "typecheck": "tsc -p tsconfig.common.json --noEmit && tsc -p tsconfig.netproto.json --noEmit && tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.client.json --noEmit && tsc -p tsconfig.sw.json --noEmit && tsc -p tsconfig.baker.json --noEmit && tsc -p tsconfig.json --noEmit", "extract:d2net": "tsx src/baker/extract/extract-d2net-tables.ts", "extract:d2net-tables": "tsx src/baker/extract/extract-d2net-tables.ts", "bot": "tsx tools/d2-bot.ts", @@ -21,6 +21,7 @@ "netproto:replay": "tsx tools/netproto-replay.ts", "d2:replay": "tsx tools/netproto-replay.ts", "pack": "tsx src/baker/cli.ts acts", + "pack:manifest": "tsx src/baker/io/asset-manifest.ts", "pack:tiles": "tsx src/baker/packers/pack-tiles.ts", "d2s:unlock": "tsx tools/d2s-unlock.ts", "verify:play": "tsx tools/verify-play.ts", @@ -35,7 +36,7 @@ "verify:monsters": "tsx tools/verify-monsters.ts", "verify:world-graph": "tsx tools/verify-world-graph.ts samples/d2", "verify:world-walk": "tsx tools/verify-world-walk.ts", - "build:game": "vite build --base=/diablo2/ --outDir dist-game", + "build:game": "tsx src/baker/io/asset-manifest.ts && vite build --base=/diablo2/ --outDir dist-game", "pack:data": "tsx src/baker/packers/pack-act-assets.ts", "pack:acts": "tsx src/baker/packers/pack-act-assets.ts", "pack:ui": "tsx src/baker/packers/pack-ui.ts", diff --git a/src/baker/cli.ts b/src/baker/cli.ts index 5c4babf..07ad39c 100644 --- a/src/baker/cli.ts +++ b/src/baker/cli.ts @@ -39,6 +39,7 @@ const TARGET_SCRIPTS: Readonly> = { skills: 'src/baker/packers/pack-skill-assets.ts', 'drop-data': 'src/baker/packers/pack-canonical-drop-data.ts', tbl: 'src/baker/packers/pack-canonical-tbl.ts', + manifest: 'src/baker/io/asset-manifest.ts', } export interface BakerVerifySummary { @@ -164,7 +165,7 @@ export async function runBakerCli(argv: readonly string[] = process.argv.slice(2 } if (target === 'all') { - for (const key of ['tiles', 'acts', 'entities', 'missiles', 'overlays', 'ui', 'animdata', 'drop-data', 'tbl']) { + for (const key of ['tiles', 'acts', 'entities', 'missiles', 'overlays', 'ui', 'animdata', 'drop-data', 'tbl', 'manifest']) { runSubscript(TARGET_SCRIPTS[key]!, restArgs, rootDir) } return diff --git a/src/baker/index.ts b/src/baker/index.ts index 918e95a..275910a 100644 --- a/src/baker/index.ts +++ b/src/baker/index.ts @@ -11,3 +11,5 @@ export * from './io/file-source.ts' export * from './io/png.ts' export * from './io/drlg-mpq-source.ts' export * from './io/tbl-writer.ts' +export * from './io/asset-manifest.ts' + diff --git a/src/baker/io/asset-manifest.ts b/src/baker/io/asset-manifest.ts new file mode 100644 index 0000000..6186fee --- /dev/null +++ b/src/baker/io/asset-manifest.ts @@ -0,0 +1,131 @@ +/** + * Offline Asset Manifest Generator (`src/baker/io/asset-manifest.ts`). + * + * Scans `public/` and `samples/d2-packs/` (mounted at `/diablo2/packs/` in production), + * computes a 16-hex-char SHA-256 content hash per asset file, and emits + * `public/asset-manifest.json` for Service Worker per-file incremental cache invalidation. + */ + +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + isCacheableAssetPath, + validateAssetManifest, + type AssetManifest, +} from '../../common/pack-contract/index.ts' + +export interface GenerateAssetManifestOptions { + readonly rootDir?: string | undefined + readonly publicDir?: string | undefined + readonly packsDir?: string | undefined + readonly includePacksDir?: boolean | undefined + readonly outputFile?: string | null | undefined + readonly generatedAt?: string | undefined +} + +function walkDirectoryFiles(baseDir: string, currentDir: string = baseDir, out: string[] = []): string[] { + if (!existsSync(currentDir)) return out + const entries = readdirSync(currentDir).sort() + for (const name of entries) { + if (name.startsWith('.')) continue + const fullPath = join(currentDir, name) + let st + try { + st = statSync(fullPath) + } catch { + continue + } + if (st.isDirectory()) { + walkDirectoryFiles(baseDir, fullPath, out) + } else if (st.isFile()) { + out.push(fullPath) + } + } + return out +} + +export function hashFileSha256Prefix(filePath: string): { readonly hash: string; readonly bytes: number } { + const buf = readFileSync(filePath) + const hash = createHash('sha256').update(buf).digest('hex').slice(0, 16) + return { hash, bytes: buf.byteLength } +} + +/** + * Generates a deterministic `AssetManifest` across `public/` and `samples/d2-packs/`. + */ +export function generateAssetManifest(options?: GenerateAssetManifestOptions): AssetManifest { + const rootDir = resolve(options?.rootDir ?? process.cwd()) + const publicDir = resolve(options?.publicDir ?? join(rootDir, 'public')) + const packsDir = resolve(options?.packsDir ?? join(rootDir, 'samples', 'd2-packs')) + const includePacksDir = options?.includePacksDir ?? true + const outputFile = + options?.outputFile === undefined + ? join(publicDir, 'asset-manifest.json') + : options.outputFile + + const entriesMap = new Map() + let totalBytes = 0 + + if (existsSync(publicDir)) { + for (const fullPath of walkDirectoryFiles(publicDir)) { + const rel = relative(publicDir, fullPath).replace(/\\/g, '/') + if (!isCacheableAssetPath(rel)) continue + const { hash, bytes } = hashFileSha256Prefix(fullPath) + entriesMap.set(rel, hash) + totalBytes += bytes + } + } + + if (includePacksDir && existsSync(packsDir)) { + for (const fullPath of walkDirectoryFiles(packsDir)) { + const rel = relative(packsDir, fullPath).replace(/\\/g, '/') + const canonicalKey = `packs/${rel}` + if (!isCacheableAssetPath(canonicalKey)) continue + if (entriesMap.has(canonicalKey)) continue + const { hash, bytes } = hashFileSha256Prefix(fullPath) + entriesMap.set(canonicalKey, hash) + totalBytes += bytes + } + } + + const sortedKeys = [...entriesMap.keys()].sort() + const files: Record = {} + const versionHasher = createHash('sha256') + for (const key of sortedKeys) { + const hash = entriesMap.get(key)! + files[key] = hash + versionHasher.update(`${key}:${hash}\n`) + } + const version = + sortedKeys.length > 0 ? versionHasher.digest('hex').slice(0, 16) : '0000000000000000' + + const manifest: AssetManifest = validateAssetManifest({ + schema: 1, + version, + generatedAt: options?.generatedAt ?? new Date().toISOString(), + totalFiles: sortedKeys.length, + totalBytes, + files, + }) + + if (outputFile) { + mkdirSync(dirname(outputFile), { recursive: true }) + writeFileSync(outputFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + } + + return manifest +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href + +if (isMain) { + const skipPacks = process.argv.includes('--public-only') + const manifest = generateAssetManifest({ includePacksDir: !skipPacks }) + console.log( + `✓ Generated asset-manifest.json (version=${manifest.version}, files=${manifest.totalFiles}, totalMB=${(manifest.totalBytes / (1024 * 1024)).toFixed(2)} MB)`, + ) +} diff --git a/src/baker/io/publish-packs.ts b/src/baker/io/publish-packs.ts index d2dee89..762d927 100644 --- a/src/baker/io/publish-packs.ts +++ b/src/baker/io/publish-packs.ts @@ -32,9 +32,11 @@ import { import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { generateAssetManifest } from './asset-manifest.ts' /** Where the packs live inside the code repository. */ -const PACKS_DIR = resolve(import.meta.dirname, '..', 'samples', 'd2-packs') +const ROOT_DIR = resolve(import.meta.dirname, '..', '..', '..') +const PACKS_DIR = resolve(ROOT_DIR, 'samples', 'd2-packs') /** The asset repository, without credentials. */ const DEFAULT_REMOTE = 'https:\x2f\x2fgit.projectdiablo2.cn/troytt/diablo2-web-assets.git' @@ -355,8 +357,10 @@ try { // Step 0: metadata and index. if (!committed('index.json', work)) { + const assetManifest = generateAssetManifest({ rootDir: ROOT_DIR, packsDir: PACKS_DIR }) writeFileSync(join(work, 'README.md'), readme(stats, stamp)) writeFileSync(join(work, '.gitattributes'), '*.png binary -diff\n*.r8 binary -diff\n*.json text eol=lf\n') + writeFileSync(join(work, 'asset-manifest.json'), `${JSON.stringify(assetManifest, null, 2)}\n`, 'utf8') cpSync(join(PACKS_DIR, 'index.json'), join(work, 'index.json')) if (existsSync(join(PACKS_DIR, 'world-graph.json'))) { cpSync(join(PACKS_DIR, 'world-graph.json'), join(work, 'world-graph.json')) diff --git a/src/client/i18n/lang.ts b/src/client/i18n/lang.ts index d795cb1..a91dab5 100644 --- a/src/client/i18n/lang.ts +++ b/src/client/i18n/lang.ts @@ -239,6 +239,8 @@ export type ToolbarI18nKey = | 'toolbar.automap.act' | 'toolbar.viewport.label' | 'toolbar.inspector.toggle' + | 'toolbar.cache.clear' + | 'toolbar.cache.cleared' | 'toolbar.diag.quotaExceeded' | 'toolbar.diag.tlsError' | 'toolbar.diag.bareIpHttps' @@ -265,6 +267,8 @@ export const TOOLBAR_I18N: Readonly { const initialSettings = settingsStore.get() setLang(initialSettings.lang) + const initialCacheStatus = await registerAssetCache() + const minimap = new Minimap() const renderer = new SpriteRenderer(viewCanvas) const lightGrid = new LightGrid() @@ -349,6 +355,11 @@ async function bootPlayApp(): Promise { }, }, }) + toolbar.setAssetCacheUsageBytes(initialCacheStatus.usageBytes) + void assetsReadyPromise.then(async () => { + const updatedStatus = await getAssetCacheStatus() + toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes) + }) // Apply initial viewport dimensions (800×600 or 1024×768) const initialProfile = getViewportProfile(initialSettings.viewport) diff --git a/src/client/sw/protocol.ts b/src/client/sw/protocol.ts new file mode 100644 index 0000000..1a43ad0 --- /dev/null +++ b/src/client/sw/protocol.ts @@ -0,0 +1,78 @@ +/** + * Shared protocol and structural interfaces between the main browser thread + * (`src/client/sw/register.ts`) and the Service Worker (`src/client/sw/sw.ts`). + */ + +import type { AssetManifest } from '../../common/pack-contract/index.ts' + +export const ASSET_CACHE_NAME = 'd2web-assets-v1' +export const META_CACHE_NAME = 'd2web-meta-v1' +export const StoredManifestUrl = 'https://d2web.internal/__meta__/asset-manifest.json' + +export interface SwCacheStats { + readonly manifestReady: boolean + readonly manifestVersion: string | null + readonly cacheHits: number + readonly cacheMisses: number + readonly networkPassThroughs: number + readonly quotaErrors: number + readonly cachedAssetCount: number +} + +export interface SwSyncManifestMessage { + readonly type: 'D2_SW_SYNC_MANIFEST' + readonly manifest: AssetManifest +} + +export interface SwSyncManifestAck { + readonly type: 'D2_SW_SYNC_MANIFEST_ACK' + readonly ok: boolean + readonly version: string + readonly evictedCount: number + readonly evictedPaths: readonly string[] + readonly unchangedCount: number + readonly error?: string | undefined +} + +export interface SwClearCacheMessage { + readonly type: 'D2_SW_CLEAR_CACHE' +} + +export interface SwClearCacheAck { + readonly type: 'D2_SW_CLEAR_CACHE_ACK' + readonly ok: boolean + readonly deletedEntries: number +} + +export interface SwGetStatsMessage { + readonly type: 'D2_SW_GET_STATS' +} + +export interface SwStatsAck { + readonly type: 'D2_SW_STATS_ACK' + readonly ok: boolean + readonly stats: SwCacheStats +} + +export type SwClientToWorkerMessage = + | SwSyncManifestMessage + | SwClearCacheMessage + | SwGetStatsMessage + +export type SwWorkerToClientMessage = + | SwSyncManifestAck + | SwClearCacheAck + | SwStatsAck + +export interface CacheLike { + match(request: Request | string): Promise + put(request: Request | string, response: Response): Promise + delete(request: Request | string): Promise + keys(): Promise +} + +export interface CacheStorageLike { + open(cacheName: string): Promise + delete(cacheName: string): Promise + keys(): Promise +} diff --git a/src/client/sw/register.ts b/src/client/sw/register.ts new file mode 100644 index 0000000..b1bc8a3 --- /dev/null +++ b/src/client/sw/register.ts @@ -0,0 +1,336 @@ +/** + * Browser Service Worker Registration & Asset Cache Manager (`src/client/sw/register.ts`). + * + * Responsibilities: + * - Registers `./sw.js` and waits for an active Service Worker controller + * - Fetches `./asset-manifest.json` (`cache: 'no-store'`), validates it via `validateAssetManifest` + * (fail-fast on missing/corrupt manifest), and syncs per-file SHA-256 hashes to the Service Worker + * - Requests persistent storage (`navigator.storage.persist()`) + * - Exposes `getAssetCacheStatus()` and `clearClientAssetCache()` for the Settings / Toolbar UI + */ + +import { + validateAssetManifest, + type AssetManifest, +} from '../../common/pack-contract/index.ts' +import { + ASSET_CACHE_NAME, + META_CACHE_NAME, + type SwCacheStats, + type SwClearCacheAck, + type SwClientToWorkerMessage, + type SwStatsAck, + type SwSyncManifestAck, + type SwWorkerToClientMessage, +} from './protocol.ts' + +export interface AssetCacheStatus { + readonly enabled: boolean + readonly manifestVersion: string | null + readonly evictedCount: number + readonly unchangedCount: number + readonly cachedAssetCount: number + readonly usageBytes: number + readonly quotaBytes: number + readonly persisted: boolean + readonly error: string | null +} + +export interface RegisterAssetCacheOptions { + readonly swUrl?: string | undefined + readonly manifestUrl?: string | undefined + readonly forceEnable?: boolean | undefined + readonly fetchFn?: ((input: RequestInfo | URL, init?: RequestInit) => Promise) | undefined +} + +let lastManifest: AssetManifest | null = null +let lastSyncAck: SwSyncManifestAck | null = null +let lastError: string | null = null +let persistedGranted = false + +/** + * Determines whether the Service Worker should be enabled in the current environment. + * Disabled by default on Vite dev server (`:5173`) unless `?sw=1` is present in URL query. + */ +export function shouldEnableAssetCacheSw( + loc: { readonly protocol?: string; readonly hostname?: string; readonly port?: string; readonly search?: string } | undefined = + typeof window !== 'undefined' ? window.location : undefined, + forceEnable = false, +): boolean { + if (forceEnable) return true + if (!loc) return false + const search = loc.search ?? '' + if (search.includes('sw=0')) return false + if (search.includes('sw=1')) return true + // Disable by default on Vite HMR dev server port 5173 unless ?sw=1 is set + if (loc.port === '5173') return false + return true +} + +/** + * Sends a typed message to a Service Worker using a `MessageChannel` and awaits its acknowledgement. + */ +export function sendMessageToSw( + worker: ServiceWorker, + message: SwClientToWorkerMessage, + timeoutMs = 8000, +): Promise { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + const timer = setTimeout(() => { + channel.port1.onmessage = null + reject(new Error(`Service Worker message ${message.type} timed out after ${String(timeoutMs)}ms`)) + }, timeoutMs) + + channel.port1.onmessage = (event: MessageEvent) => { + clearTimeout(timer) + channel.port1.onmessage = null + resolve(event.data) + } + + worker.postMessage(message, [channel.port2]) + }) +} + +/** + * Fetches and strictly validates `asset-manifest.json` with `cache: 'no-store'`. + * Fails fast on HTTP errors or schema violations (Anti-Silent-Failure). + */ +export async function fetchAssetManifest( + manifestUrl = './asset-manifest.json', + fetchFn: (input: RequestInfo | URL, init?: RequestInit) => Promise = (input, init) => + fetch(input, init), +): Promise { + const response = await fetchFn(manifestUrl, { cache: 'no-store' }) + if (!response.ok) { + throw new Error( + `Failed to fetch ${manifestUrl}: HTTP ${String(response.status)}`, + ) + } + const raw: unknown = await response.json() + return validateAssetManifest(raw) +} + +async function resolveActiveWorker( + reg: ServiceWorkerRegistration, +): Promise { + if (reg.active) return reg.active + const installingOrWaiting = reg.installing ?? reg.waiting + if (!installingOrWaiting) return null + + return await new Promise((resolve) => { + const onStateChange = (): void => { + if (installingOrWaiting.state === 'activated') { + installingOrWaiting.removeEventListener('statechange', onStateChange) + resolve(reg.active ?? installingOrWaiting) + } else if (installingOrWaiting.state === 'redundant') { + installingOrWaiting.removeEventListener('statechange', onStateChange) + resolve(reg.active ?? null) + } + } + installingOrWaiting.addEventListener('statechange', onStateChange) + onStateChange() + }) +} + +/** + * Formats byte count into a human-readable megabyte label (e.g. `"14.2 MB"` or `"0.0 MB"`). + */ +export function formatCacheBytesMb(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0.0 MB' + const mb = bytes / (1024 * 1024) + if (mb < 0.1) return '0.1 MB' + return `${mb.toFixed(1)} MB` +} + +/** + * Registers `./sw.js`, requests persistent storage, fetches `./asset-manifest.json`, + * and performs incremental per-file cache invalidation on the Service Worker. + */ +export async function registerAssetCache( + options?: RegisterAssetCacheOptions, +): Promise { + if ( + typeof window === 'undefined' || + typeof navigator === 'undefined' || + !('serviceWorker' in navigator) || + !navigator.serviceWorker + ) { + return await getAssetCacheStatus() + } + + if (!shouldEnableAssetCacheSw(window.location, options?.forceEnable ?? false)) { + return await getAssetCacheStatus() + } + + const swUrl = options?.swUrl ?? './sw.js' + const manifestUrl = options?.manifestUrl ?? './asset-manifest.json' + const fetchFn = options?.fetchFn ?? ((input, init) => fetch(input, init)) + + try { + if (navigator.storage && typeof navigator.storage.persist === 'function') { + persistedGranted = await navigator.storage.persist().catch(() => false) + } + } catch { + persistedGranted = false + } + + try { + const manifest = await fetchAssetManifest(manifestUrl, fetchFn) + lastManifest = manifest + + const reg = await navigator.serviceWorker.register(swUrl, { + scope: './', + type: 'module', + }) + const worker = (await resolveActiveWorker(reg)) ?? navigator.serviceWorker.controller + if (!worker) { + throw new Error('Service Worker registered but failed to activate') + } + + const ack = await sendMessageToSw(worker, { + type: 'D2_SW_SYNC_MANIFEST', + manifest, + }) + if (!ack.ok) { + throw new Error(ack.error ?? 'Service Worker rejected asset-manifest.json') + } + lastSyncAck = ack + lastError = null + } catch (err) { + lastError = err instanceof Error ? err.message : String(err) + console.warn('[d2web-cache] Asset cache initialization failed:', lastError) + } + + return await getAssetCacheStatus() +} + +/** + * Queries current Cache Storage entry count and browser storage estimate. + */ +export async function getAssetCacheStatus(): Promise { + let cachedAssetCount = 0 + let usageBytes = 0 + let quotaBytes = 0 + + try { + if (typeof caches !== 'undefined' && typeof caches.open === 'function') { + const cache = await caches.open(ASSET_CACHE_NAME) + const keys = await cache.keys() + cachedAssetCount = keys.length + } + } catch { + // Ignore in environments without CacheStorage + } + + try { + if ( + typeof navigator !== 'undefined' && + navigator.storage && + typeof navigator.storage.estimate === 'function' + ) { + const est = await navigator.storage.estimate() + usageBytes = est.usage ?? 0 + quotaBytes = est.quota ?? 0 + } + } catch { + // Ignore in environments without StorageManager + } + + try { + if ( + typeof navigator !== 'undefined' && + navigator.serviceWorker?.controller + ) { + const ack = await sendMessageToSw( + navigator.serviceWorker.controller, + { type: 'D2_SW_GET_STATS' }, + 2000, + ) + if (ack.ok && ack.stats) { + cachedAssetCount = ack.stats.cachedAssetCount + } + } + } catch { + // Fallback to direct caches.open count above + } + + return { + enabled: lastSyncAck !== null && lastError === null, + manifestVersion: lastSyncAck?.version ?? lastManifest?.version ?? null, + evictedCount: lastSyncAck?.evictedCount ?? 0, + unchangedCount: lastSyncAck?.unchangedCount ?? 0, + cachedAssetCount, + usageBytes, + quotaBytes, + persisted: persistedGranted, + error: lastError, + } +} + +/** + * Clears all cached art and JSON assets from Cache Storage (`d2web-assets-v1`) + * and re-primes the active manifest so subsequent loads in the session can cache cleanly. + */ +export async function clearClientAssetCache(): Promise<{ + readonly deletedEntries: number + readonly status: AssetCacheStatus +}> { + let deletedEntries = 0 + + try { + if ( + typeof navigator !== 'undefined' && + navigator.serviceWorker?.controller + ) { + const ack = await sendMessageToSw( + navigator.serviceWorker.controller, + { type: 'D2_SW_CLEAR_CACHE' }, + 4000, + ) + if (ack.ok) { + deletedEntries = ack.deletedEntries + } + } + } catch { + // Also clear directly via window.caches below + } + + try { + if (typeof caches !== 'undefined') { + if (deletedEntries === 0) { + const c = await caches.open(ASSET_CACHE_NAME) + deletedEntries = (await c.keys()).length + } + await caches.delete(ASSET_CACHE_NAME) + await caches.delete(META_CACHE_NAME) + } + } catch { + // Ignore if CacheStorage unavailable + } + + // Re-sync manifest if we already have one in memory so future loads in this session still cache + try { + if ( + lastManifest && + typeof navigator !== 'undefined' && + navigator.serviceWorker?.controller + ) { + lastSyncAck = await sendMessageToSw( + navigator.serviceWorker.controller, + { + type: 'D2_SW_SYNC_MANIFEST', + manifest: lastManifest, + }, + 4000, + ) + } + } catch { + // Ignore + } + + const status = await getAssetCacheStatus() + return { deletedEntries, status } +} + +export type { SwCacheStats } diff --git a/src/client/sw/sw.ts b/src/client/sw/sw.ts new file mode 100644 index 0000000..6c57307 --- /dev/null +++ b/src/client/sw/sw.ts @@ -0,0 +1,395 @@ +/** + * Service Worker for Diablo II Web Client (`src/client/sw/sw.ts`). + * + * Implements: + * - On-demand `cache-first` caching in Cache Storage (`d2web-assets-v1`) for loaded art/JSON/bin assets + * - Per-file SHA-256 incremental cache invalidation driven by `asset-manifest.json` + * - Strict 200-only response filter (never caches 206 Range, 304, 4xx, 5xx, or opaque responses) + * - Direct pass-through for `.mpq` archives and HTTP `Range` requests (handled by `BrowserOpfsChunkCache`) + * - Fail-fast manifest validation (never serves stale cached pack assets before a valid manifest is synced) + */ + +import { + diffAssetManifests, + isCacheableAssetPath, + normalizeAssetManifestPath, + validateAssetManifest, + type AssetManifest, +} from '../../common/pack-contract/index.ts' +import { + ASSET_CACHE_NAME, + META_CACHE_NAME, + StoredManifestUrl, + type CacheStorageLike, + type SwCacheStats, + type SwClearCacheAck, + type SwClientToWorkerMessage, + type SwStatsAck, + type SwSyncManifestAck, +} from './protocol.ts' + +const IMMUTABLE_VITE_ASSET_RE = /^assets\/.+-[a-zA-Z0-9_-]{8,}\.(?:js|css|woff2?|ttf|otf)$/i + +export interface AssetCacheControllerOptions { + readonly caches: CacheStorageLike + readonly fetchFn: (input: Request | string, init?: RequestInit) => Promise + readonly origin: string + readonly onQuotaError?: ((url: string, err: unknown) => void) | undefined +} + +/** + * Returns true if the asset path is a Vite content-hashed immutable bundle (`assets/*-[hash].js|css`). + */ +export function isImmutableHashedAsset(urlOrPath: string): boolean { + const norm = normalizeAssetManifestPath(urlOrPath) + return IMMUTABLE_VITE_ASSET_RE.test(norm) +} + +/** + * Returns true if the request must bypass Service Worker Cache Storage interception: + * - Non-GET requests + * - Cross-origin requests + * - Requests carrying a `Range` header (e.g. MPQ range reads) + * - HTML navigation requests (`mode === 'navigate'`) + * - `.mpq` files, `asset-manifest.json`, `sw.js`, `/d2net`, `/ws/` + */ +export function shouldBypassCache(request: Request, scopeOrigin: string): boolean { + if (request.method !== 'GET') return true + if (request.mode === 'navigate') return true + if (request.headers.get('range') !== null || request.headers.get('Range') !== null) { + return true + } + + let parsed: URL + try { + parsed = new URL(request.url, scopeOrigin) + } catch { + return true + } + + if (parsed.origin !== scopeOrigin) { + return true + } + + return !isCacheableAssetPath(parsed.pathname) +} + +/** + * Returns true ONLY for full HTTP 200 OK non-opaque responses. + * Never caches 206 Partial Content, 304 Not Modified, 4xx/5xx errors, or opaque responses. + */ +export function isCacheableResponse(response: Response): boolean { + if (!response) return false + if (response.status !== 200 || !response.ok) return false + if (response.type === 'opaque' || response.type === 'error' || response.type === 'opaqueredirect') { + return false + } + return true +} + +export class AssetCacheServiceWorkerController { + private readonly caches: CacheStorageLike + private readonly fetchFn: (input: Request | string, init?: RequestInit) => Promise + private readonly origin: string + private readonly onQuotaError?: ((url: string, err: unknown) => void) | undefined + + private activeManifest: AssetManifest | null = null + private manifestLoadPromise: Promise | null = null + private syncInFlight: Promise | null = null + + private cacheHits = 0 + private cacheMisses = 0 + private networkPassThroughs = 0 + private quotaErrors = 0 + + constructor(options: AssetCacheControllerOptions) { + this.caches = options.caches + this.fetchFn = options.fetchFn + this.origin = options.origin + this.onQuotaError = options.onQuotaError + } + + private async loadStoredManifest(): Promise { + if (this.activeManifest) return this.activeManifest + if (this.manifestLoadPromise) return this.manifestLoadPromise + + this.manifestLoadPromise = (async () => { + try { + const metaCache = await this.caches.open(META_CACHE_NAME) + const res = await metaCache.match(StoredManifestUrl) + if (!res) return null + const raw: unknown = await res.json() + const validated = validateAssetManifest(raw) + this.activeManifest = validated + return validated + } catch { + return null + } finally { + this.manifestLoadPromise = null + } + })() + + return this.manifestLoadPromise + } + + /** + * Synchronizes a newly fetched `AssetManifest`, evicting ONLY cached entries whose + * content hash changed or that were removed from the manifest. + */ + async syncManifest(rawManifest: unknown): Promise { + const run = async (): Promise => { + const nextManifest = validateAssetManifest(rawManifest) + const prevManifest = await this.loadStoredManifest() + + const diff = diffAssetManifests(prevManifest, nextManifest) + const assetCache = await this.caches.open(ASSET_CACHE_NAME) + const evictedPaths: string[] = [] + + if (prevManifest === null) { + // First sync or recovered after meta reset: evict any cached pack/art asset + // that is not present in nextManifest (except immutable hashed JS/CSS bundles). + const keys = await assetCache.keys() + for (const req of keys) { + const normPath = normalizeAssetManifestPath(req.url) + if (!isImmutableHashedAsset(normPath) && !(normPath in nextManifest.files)) { + const deleted = await assetCache.delete(req) + if (deleted) evictedPaths.push(normPath) + } + } + } else if (diff.changedOrRemoved.length > 0) { + const evictSet = new Set(diff.changedOrRemoved) + const keys = await assetCache.keys() + for (const req of keys) { + const normPath = normalizeAssetManifestPath(req.url) + if (evictSet.has(normPath)) { + const deleted = await assetCache.delete(req) + if (deleted) evictedPaths.push(normPath) + } + } + } + + const metaCache = await this.caches.open(META_CACHE_NAME) + await metaCache.put( + StoredManifestUrl, + new Response(JSON.stringify(nextManifest), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + this.activeManifest = nextManifest + + return { + type: 'D2_SW_SYNC_MANIFEST_ACK', + ok: true, + version: nextManifest.version, + evictedCount: evictedPaths.length, + evictedPaths, + unchangedCount: diff.unchangedCount, + } + } + + const p = (this.syncInFlight ?? Promise.resolve()).then(run, run) + this.syncInFlight = p + try { + return await p + } finally { + if (this.syncInFlight === p) { + this.syncInFlight = null + } + } + } + + /** + * Clears all cached assets and stored manifest metadata. + */ + async clearCache(): Promise { + const assetCache = await this.caches.open(ASSET_CACHE_NAME) + const keys = await assetCache.keys() + const deletedEntries = keys.length + await this.caches.delete(ASSET_CACHE_NAME) + await this.caches.delete(META_CACHE_NAME) + this.activeManifest = null + this.cacheHits = 0 + this.cacheMisses = 0 + return { + type: 'D2_SW_CLEAR_CACHE_ACK', + ok: true, + deletedEntries, + } + } + + /** + * Returns current cache hit/miss and entry count statistics. + */ + async getStats(): Promise { + const manifest = await this.loadStoredManifest() + const assetCache = await this.caches.open(ASSET_CACHE_NAME) + const keys = await assetCache.keys() + return { + manifestReady: manifest !== null, + manifestVersion: manifest?.version ?? null, + cacheHits: this.cacheHits, + cacheMisses: this.cacheMisses, + networkPassThroughs: this.networkPassThroughs, + quotaErrors: this.quotaErrors, + cachedAssetCount: keys.length, + } + } + + /** + * Handles a `fetch` request with manifest-gated `cache-first` semantics. + */ + async handleFetch(request: Request): Promise { + if (shouldBypassCache(request, this.origin)) { + this.networkPassThroughs += 1 + return await this.fetchFn(request) + } + + if (this.syncInFlight) { + try { + await this.syncInFlight + } catch { + // Sync failure handled below by manifestReady check + } + } + + const immutableAsset = isImmutableHashedAsset(request.url) + const manifest = immutableAsset ? this.activeManifest : await this.loadStoredManifest() + + // Anti-Silent-Failure: if no valid manifest has been synced and this is not a content-hashed bundle, + // do not serve or populate potentially stale pack assets. + if (!immutableAsset && manifest === null) { + this.networkPassThroughs += 1 + return await this.fetchFn(request) + } + + const assetCache = await this.caches.open(ASSET_CACHE_NAME) + const cached = await assetCache.match(request) + if (cached) { + this.cacheHits += 1 + return cached.clone() + } + + this.cacheMisses += 1 + const response = await this.fetchFn(request) + if (isCacheableResponse(response)) { + try { + await assetCache.put(request, response.clone()) + } catch (err) { + this.quotaErrors += 1 + this.onQuotaError?.(request.url, err) + } + } + return response + } + + /** + * Handles a client `postMessage` command (`D2_SW_SYNC_MANIFEST`, `D2_SW_CLEAR_CACHE`, `D2_SW_GET_STATS`). + */ + async handleMessage( + msg: SwClientToWorkerMessage, + ): Promise { + if (!msg || typeof msg !== 'object' || !('type' in msg)) { + throw new Error('Invalid Service Worker message') + } + switch (msg.type) { + case 'D2_SW_SYNC_MANIFEST': + return await this.syncManifest(msg.manifest) + case 'D2_SW_CLEAR_CACHE': + return await this.clearCache() + case 'D2_SW_GET_STATS': + return { + type: 'D2_SW_STATS_ACK', + ok: true, + stats: await this.getStats(), + } + } + } +} + +interface SwExtendableEventLike extends Event { + waitUntil(f: Promise): void +} + +interface SwFetchEventLike extends SwExtendableEventLike { + readonly request: Request + respondWith(r: Promise | Response): void +} + +interface SwMessageEventLike extends SwExtendableEventLike { + readonly data: unknown + readonly ports: readonly MessagePort[] +} + +interface SwGlobalScopeLike { + readonly location: { readonly origin: string } + readonly caches: CacheStorageLike + readonly clients: { claim(): Promise } + skipWaiting(): Promise + addEventListener(type: string, listener: (event: Event) => void): void +} + +function bindServiceWorkerGlobal(): void { + if (typeof self === 'undefined') return + const scope = self as unknown as Partial + if ( + typeof scope.skipWaiting !== 'function' || + !scope.clients || + typeof scope.clients.claim !== 'function' || + !scope.caches + ) { + return + } + + const controller = new AssetCacheServiceWorkerController({ + caches: scope.caches, + fetchFn: (input, init) => fetch(input, init), + origin: scope.location?.origin ?? '', + onQuotaError: (url, err) => { + console.warn(`[d2web-sw] Cache Storage quota exceeded for ${url}:`, err) + }, + }) + + scope.addEventListener!('install', (ev) => { + const ext = ev as SwExtendableEventLike + ext.waitUntil(scope.skipWaiting!()) + }) + + scope.addEventListener!('activate', (ev) => { + const ext = ev as SwExtendableEventLike + ext.waitUntil(scope.clients!.claim()) + }) + + scope.addEventListener!('fetch', (ev) => { + const fetchEv = ev as SwFetchEventLike + if (shouldBypassCache(fetchEv.request, scope.location?.origin ?? '')) { + return + } + fetchEv.respondWith(controller.handleFetch(fetchEv.request)) + }) + + scope.addEventListener!('message', (ev) => { + const msgEv = ev as SwMessageEventLike + const replyPort = msgEv.ports?.[0] + const raw = msgEv.data as SwClientToWorkerMessage + const work = controller + .handleMessage(raw) + .then((ack) => { + replyPort?.postMessage(ack) + }) + .catch((err: unknown) => { + replyPort?.postMessage({ + type: 'D2_SW_SYNC_MANIFEST_ACK', + ok: false, + version: '', + evictedCount: 0, + evictedPaths: [], + unchangedCount: 0, + error: err instanceof Error ? err.message : String(err), + } satisfies SwSyncManifestAck) + }) + msgEv.waitUntil(work) + }) +} + +bindServiceWorkerGlobal() diff --git a/src/client/toolbar/toolbar.ts b/src/client/toolbar/toolbar.ts index 5b40f4d..2ca7b32 100644 --- a/src/client/toolbar/toolbar.ts +++ b/src/client/toolbar/toolbar.ts @@ -20,6 +20,10 @@ import { type ViewportResolution, } from '../settings/client-settings.ts' import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts' +import { + clearClientAssetCache, + formatCacheBytesMb, +} from '../sw/register.ts' import { getViewportProfile, type ViewportProfile, @@ -55,6 +59,7 @@ export interface ToolbarCallbacks { readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined readonly onInspectorToggle?: ((open: boolean) => void) | undefined + readonly onClearCache?: (() => void | Promise) | undefined } export interface ToolbarOptions { @@ -83,7 +88,7 @@ export interface ToolbarSnapshot { * 3. Live Language Toggle (`中` / `EN`) * 4. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) * 5. Automap Reveal Selector (`off | level | act`) - * 6. Viewport Size Toggle (`800x600 | 1024x768`) + * 6. Viewport Size Toggle (`800x600 | 1024x768`) + Asset Cache Clear button */ export class Toolbar { private readonly store: SettingsStore @@ -97,6 +102,8 @@ export class Toolbar { } private serverInputValue: string private serverInputValid = true + private cacheUsageBytes = 0 + private cacheClearedFlash = false private unsubscribeStore: (() => void) | null = null private unsubscribeLang: (() => void) | null = null @@ -117,6 +124,7 @@ export class Toolbar { private viewportLabelEl: HTMLElement | null = null private viewportSelectEl: HTMLSelectElement | null = null private inspectorBtnEl: HTMLButtonElement | null = null + private cacheClearBtnEl: HTMLButtonElement | null = null private diagBannerEl: HTMLElement | null = null constructor(options?: ToolbarOptions) { @@ -325,6 +333,24 @@ export class Toolbar { return next } + setAssetCacheUsageBytes(bytes: number): void { + this.cacheUsageBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0 + this.syncDom() + } + + async clearAssetCache(): Promise { + const res = await clearClientAssetCache() + this.cacheUsageBytes = 0 + this.cacheClearedFlash = true + this.syncDom() + await this.callbacks.onClearCache?.() + setTimeout(() => { + this.cacheClearedFlash = false + this.syncDom() + }, 1500) + return res.deletedEntries + } + getSnapshot(): ToolbarSnapshot { const settings = this.store.get() const lang = settings.lang || getGameLang() @@ -482,7 +508,7 @@ export class Toolbar { automapGroup.appendChild(automapLabel) automapGroup.appendChild(automapSelect) - // Group 6: Viewport Size Selector (800x600 / 1024x768) + // Group 6: Viewport Size Selector (800x600 / 1024x768) + Asset Cache Clear button const viewportGroup = doc.createElement('div') viewportGroup.className = 'd2-toolbar__group' const viewportLabel = doc.createElement('label') @@ -500,8 +526,18 @@ export class Toolbar { viewportSelect.addEventListener('change', () => { this.setViewport(viewportSelect.value as ViewportResolution) }) + + const cacheClearBtn = doc.createElement('button') + cacheClearBtn.type = 'button' + cacheClearBtn.id = 'd2-toolbar-cache-clear-btn' + cacheClearBtn.className = 'd2-toolbar__btn' + cacheClearBtn.addEventListener('click', () => { + void this.clearAssetCache() + }) + viewportGroup.appendChild(viewportLabel) viewportGroup.appendChild(viewportSelect) + viewportGroup.appendChild(cacheClearBtn) // Diagnostic Banner const diagBanner = doc.createElement('div') @@ -536,6 +572,7 @@ export class Toolbar { this.viewportLabelEl = viewportLabel this.viewportSelectEl = viewportSelect this.inspectorBtnEl = inspectorBtn + this.cacheClearBtnEl = cacheClearBtn this.diagBannerEl = diagBanner this.syncDom() @@ -617,6 +654,17 @@ export class Toolbar { if (this.viewportSelectEl) { this.viewportSelectEl.value = snap.settings.viewport } + if (this.cacheClearBtnEl) { + if (this.cacheClearedFlash) { + this.cacheClearBtnEl.textContent = tToolbar('toolbar.cache.cleared', lang) + } else { + const baseLabel = tToolbar('toolbar.cache.clear', lang) + this.cacheClearBtnEl.textContent = + this.cacheUsageBytes > 0 + ? `${baseLabel} (${formatCacheBytesMb(this.cacheUsageBytes)})` + : baseLabel + } + } if (this.diagBannerEl) { if (snap.diagnosticBannerText) { this.diagBannerEl.textContent = snap.diagnosticBannerText diff --git a/src/common/pack-contract/index.ts b/src/common/pack-contract/index.ts index 9989410..61e10fa 100644 --- a/src/common/pack-contract/index.ts +++ b/src/common/pack-contract/index.ts @@ -502,4 +502,188 @@ export function validatePackManifest(manifest: return manifest } +export interface AssetManifest { + readonly schema: 1 + readonly version: string + readonly generatedAt: string + readonly totalFiles: number + readonly totalBytes: number + /** + * Map from canonical relative asset path (e.g. "act-packs/tiles/index.json", + * "packs/entities/char-so.r8", "missiles/arrow.png", "ui/manifest.json") + * to its 16-hex-char SHA-256 content hash prefix. + */ + readonly files: Readonly> +} + +export interface AssetManifestDiff { + readonly changedOrRemoved: readonly string[] + readonly added: readonly string[] + readonly unchangedCount: number +} + +const HEX_HASH_RE = /^[0-9a-f]{16,64}$/i + +const CACHEABLE_PREFIXES = [ + 'packs/', + 'act-packs/', + 'missiles/', + 'overlays/', + 'skills/', + 'ui/', + 'assets/', +] as const + +const CACHEABLE_EXT_RE = + /\.(?:json|png|r8|bin|d2|txt|dc6|dcc|dt1|ds1|tbl|dat|woff2?|ttf|otf|mp3|ogg|wav|js|css)$/i + +/** + * Normalizes a URL or relative path into a canonical manifest key: + * - Strips scheme/origin, query string, and hash fragment + * - Converts backslashes to forward slashes and collapses duplicate slashes + * - Strips leading `/` and optional `diablo2/` deployment base prefix + * - Maps `samples/d2-packs/...` to canonical `packs/...` + */ +export function normalizeAssetManifestPath(urlOrPath: string): string { + if (typeof urlOrPath !== 'string') return '' + let clean = urlOrPath.trim() + if (!clean) return '' + + // Strip scheme + origin if full URL + clean = clean.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*/, '') + // Strip query and hash + const qIdx = clean.search(/[?#]/) + if (qIdx >= 0) { + clean = clean.slice(0, qIdx) + } + + clean = clean.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/+/, '') + if (clean.startsWith('diablo2/')) { + clean = clean.slice('diablo2/'.length) + } + if (clean.startsWith('samples/d2-packs/')) { + clean = `packs/${clean.slice('samples/d2-packs/'.length)}` + } + return clean +} + +/** + * Returns true if the URL or path is a cacheable art/JSON/static asset. + * Explicitly excludes `.mpq` archives (cached via OPFS range chunks), + * `asset-manifest.json` (always fetched fresh), `sw.js`, and WebSocket endpoints. + */ +export function isCacheableAssetPath(urlOrPath: string): boolean { + const norm = normalizeAssetManifestPath(urlOrPath) + if (!norm) return false + const lower = norm.toLowerCase() + if ( + lower === 'asset-manifest.json' || + lower.endsWith('/asset-manifest.json') || + lower === 'sw.js' || + lower.endsWith('/sw.js') || + lower.endsWith('.mpq') || + lower.startsWith('d2net/') || + lower.startsWith('ws/') + ) { + return false + } + if (!CACHEABLE_PREFIXES.some((prefix) => lower.startsWith(prefix))) { + return false + } + return CACHEABLE_EXT_RE.test(lower) +} + +/** + * Strictly validates an `AssetManifest` payload, failing fast on missing or corrupt fields. + */ +export function validateAssetManifest(raw: unknown): AssetManifest { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Asset manifest validation failed: expected JSON object') + } + const obj = raw as Record + if (obj.schema !== 1) { + throw new Error(`Asset manifest validation failed: unsupported schema=${String(obj.schema)} (expected 1)`) + } + if (typeof obj.version !== 'string' || obj.version.trim().length === 0) { + throw new Error('Asset manifest validation failed: missing non-empty version string') + } + if (typeof obj.generatedAt !== 'string' || obj.generatedAt.trim().length === 0) { + throw new Error('Asset manifest validation failed: missing non-empty generatedAt timestamp') + } + if (typeof obj.totalFiles !== 'number' || !Number.isInteger(obj.totalFiles) || obj.totalFiles < 0) { + throw new Error(`Asset manifest validation failed: invalid totalFiles=${String(obj.totalFiles)}`) + } + if (typeof obj.totalBytes !== 'number' || !Number.isFinite(obj.totalBytes) || obj.totalBytes < 0) { + throw new Error(`Asset manifest validation failed: invalid totalBytes=${String(obj.totalBytes)}`) + } + if (!obj.files || typeof obj.files !== 'object' || Array.isArray(obj.files)) { + throw new Error('Asset manifest validation failed: files must be a plain object map') + } + + const filesRec = obj.files as Record + for (const [key, val] of Object.entries(filesRec)) { + if (!key || key.startsWith('/') || key.includes('..') || key.includes('\\')) { + throw new Error(`Asset manifest validation failed: invalid asset path key "${key}"`) + } + if (typeof val !== 'string' || !HEX_HASH_RE.test(val)) { + throw new Error(`Asset manifest validation failed: invalid sha256 hash "${String(val)}" for "${key}"`) + } + } + + return { + schema: 1, + version: obj.version, + generatedAt: obj.generatedAt, + totalFiles: obj.totalFiles, + totalBytes: obj.totalBytes, + files: filesRec as Readonly>, + } +} + +/** + * Computes incremental invalidation between a previously cached manifest and a newly fetched manifest. + * Only files whose hash changed or that were removed from `next` are marked for cache eviction. + */ +export function diffAssetManifests( + prev: AssetManifest | null | undefined, + next: AssetManifest, +): AssetManifestDiff { + if (!prev) { + return { + changedOrRemoved: [], + added: Object.keys(next.files).sort(), + unchangedCount: 0, + } + } + + const changedOrRemoved: string[] = [] + const added: string[] = [] + let unchangedCount = 0 + + for (const [path, prevHash] of Object.entries(prev.files)) { + const nextHash = next.files[path] + if (nextHash === undefined || nextHash !== prevHash) { + changedOrRemoved.push(path) + } else { + unchangedCount += 1 + } + } + + for (const path of Object.keys(next.files)) { + if (!(path in prev.files)) { + added.push(path) + } + } + + changedOrRemoved.sort() + added.sort() + + return { + changedOrRemoved, + added, + unchangedCount, + } +} + + diff --git a/tests/sw/asset-cache.test.ts b/tests/sw/asset-cache.test.ts new file mode 100644 index 0000000..10fc8e7 --- /dev/null +++ b/tests/sw/asset-cache.test.ts @@ -0,0 +1,413 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + diffAssetManifests, + isCacheableAssetPath, + normalizeAssetManifestPath, + validateAssetManifest, + type AssetManifest, +} from '../../src/common/pack-contract/index.ts' +import { generateAssetManifest } from '../../src/baker/io/asset-manifest.ts' +import { + ASSET_CACHE_NAME, + type CacheLike, + type CacheStorageLike, +} from '../../src/client/sw/protocol.ts' +import { + AssetCacheServiceWorkerController, + isCacheableResponse, + isImmutableHashedAsset, + shouldBypassCache, +} from '../../src/client/sw/sw.ts' +import { + formatCacheBytesMb, + shouldEnableAssetCacheSw, +} from '../../src/client/sw/register.ts' +import { Toolbar } from '../../src/client/toolbar/toolbar.ts' +import { createSettingsStore } from '../../src/client/settings/settings-store.ts' + +class MockCache implements CacheLike { + readonly store = new Map() + throwQuotaOnPut = false + + private keyOf(req: Request | string): string { + return typeof req === 'string' ? req : req.url + } + + async match(request: Request | string): Promise { + const entry = this.store.get(this.keyOf(request)) + return entry ? entry.response.clone() : undefined + } + + async put(request: Request | string, response: Response): Promise { + if (this.throwQuotaOnPut) { + const err = new Error('QuotaExceededError: storage full') + err.name = 'QuotaExceededError' + throw err + } + const reqObj = typeof request === 'string' ? new Request(request) : request + this.store.set(this.keyOf(request), { + request: reqObj, + response: response.clone(), + }) + } + + async delete(request: Request | string): Promise { + return this.store.delete(this.keyOf(request)) + } + + async keys(): Promise { + return [...this.store.values()].map((v) => v.request) + } +} + +class MockCacheStorage implements CacheStorageLike { + readonly buckets = new Map() + + async open(cacheName: string): Promise { + let bucket = this.buckets.get(cacheName) + if (!bucket) { + bucket = new MockCache() + this.buckets.set(cacheName, bucket) + } + return bucket + } + + async delete(cacheName: string): Promise { + return this.buckets.delete(cacheName) + } + + async keys(): Promise { + return [...this.buckets.keys()] + } +} + +const SAMPLE_MANIFEST_V1: AssetManifest = { + schema: 1, + version: '1111222233334444', + generatedAt: '2026-10-01T00:00:00.000Z', + totalFiles: 4, + totalBytes: 4096, + files: { + 'act-packs/tiles/index.json': 'aaaa1111bbbb2222', + 'missiles/arrow.json': 'cccc3333dddd4444', + 'missiles/arrow.png': 'eeee5555ffff6666', + 'packs/entities/char-so.r8': '1234567890abcdef', + }, +} + +describe('Client Asset Cache & Per-File Hash Invalidation (Service Worker + Cache Storage)', () => { + it('normalizes asset paths consistently across /, /diablo2/, and /samples/d2-packs/', () => { + expect( + normalizeAssetManifestPath('https://www.laiseek.xyz/diablo2/act-packs/tiles/index.json'), + ).toBe('act-packs/tiles/index.json') + expect( + normalizeAssetManifestPath('/act-packs/tiles/index.json?v=123#top'), + ).toBe('act-packs/tiles/index.json') + expect( + normalizeAssetManifestPath('/samples/d2-packs/entities/char-so.r8'), + ).toBe('packs/entities/char-so.r8') + expect( + normalizeAssetManifestPath('/diablo2/samples/d2-packs/entities/char-so.r8'), + ).toBe('packs/entities/char-so.r8') + expect( + normalizeAssetManifestPath('/diablo2/packs/entities/char-so.r8'), + ).toBe('packs/entities/char-so.r8') + expect(normalizeAssetManifestPath('missiles\\arrow.png')).toBe('missiles/arrow.png') + }) + + it('classifies cacheable art/JSON assets while strictly bypassing .mpq, Range, manifest, sw.js, and WebSockets', () => { + expect(isCacheableAssetPath('/diablo2/packs/act1/town/scene.json')).toBe(true) + expect(isCacheableAssetPath('/diablo2/packs/act1/town/page0.png')).toBe(true) + expect(isCacheableAssetPath('/samples/d2-packs/entities/char-so.r8')).toBe(true) + expect(isCacheableAssetPath('/act-packs/tiles/drlg-bundle.bin')).toBe(true) + expect(isCacheableAssetPath('/samples/d2-packs/anim/AnimData.d2')).toBe(true) + expect(isCacheableAssetPath('/samples/d2-packs/anim/HitClass.txt')).toBe(true) + expect(isCacheableAssetPath('/missiles/arrow.png')).toBe(true) + expect(isCacheableAssetPath('/overlays/cast.json')).toBe(true) + expect(isCacheableAssetPath('/skills/icon_36.png')).toBe(true) + expect(isCacheableAssetPath('/ui/manifest.json')).toBe(true) + expect(isCacheableAssetPath('/diablo2/assets/play-Zb_JWO5r.js')).toBe(true) + + // Excluded paths + expect(isCacheableAssetPath('/diablo2/data/d2char.mpq')).toBe(false) + expect(isCacheableAssetPath('/samples/d2/Patch_D2.mpq')).toBe(false) + expect(isCacheableAssetPath('/diablo2/asset-manifest.json')).toBe(false) + expect(isCacheableAssetPath('/asset-manifest.json')).toBe(false) + expect(isCacheableAssetPath('/diablo2/sw.js')).toBe(false) + expect(isCacheableAssetPath('/sw.js')).toBe(false) + expect(isCacheableAssetPath('/d2net/ws')).toBe(false) + expect(isCacheableAssetPath('/play.html')).toBe(false) + + const origin = 'https://www.laiseek.xyz' + // Range header must bypass + const rangeReq = new Request(`${origin}/diablo2/packs/act1/page0.png`, { + headers: { Range: 'bytes=0-1023' }, + }) + expect(shouldBypassCache(rangeReq, origin)).toBe(true) + + // POST must bypass + const postReq = new Request(`${origin}/diablo2/packs/index.json`, { method: 'POST' }) + expect(shouldBypassCache(postReq, origin)).toBe(true) + + // Cross-origin must bypass + const crossReq = new Request('https://other.example.com/missiles/arrow.png') + expect(shouldBypassCache(crossReq, origin)).toBe(true) + + // Normal GET art/json request does NOT bypass + const okReq = new Request(`${origin}/diablo2/missiles/arrow.png`) + expect(shouldBypassCache(okReq, origin)).toBe(false) + + // Immutable Vite bundle detection + expect(isImmutableHashedAsset('/diablo2/assets/play-Zb_JWO5r.js')).toBe(true) + expect(isImmutableHashedAsset('/assets/ui/panel.png')).toBe(false) + }) + + it('validates AssetManifest strictly and fails fast on corrupt inputs', () => { + expect(validateAssetManifest(SAMPLE_MANIFEST_V1)).toEqual(SAMPLE_MANIFEST_V1) + + expect(() => validateAssetManifest(null)).toThrow(/expected JSON object/) + expect(() => validateAssetManifest({ ...SAMPLE_MANIFEST_V1, schema: 2 })).toThrow(/schema/) + expect(() => validateAssetManifest({ ...SAMPLE_MANIFEST_V1, version: '' })).toThrow(/version/) + expect(() => + validateAssetManifest({ + ...SAMPLE_MANIFEST_V1, + files: { '../secret.txt': 'aaaa1111bbbb2222' }, + }), + ).toThrow(/invalid asset path key/) + expect(() => + validateAssetManifest({ + ...SAMPLE_MANIFEST_V1, + files: { 'missiles/arrow.png': 'not-a-hex-hash' }, + }), + ).toThrow(/invalid sha256 hash/) + }) + + it('computes exact per-file incremental diff between two manifests', () => { + const nextManifest: AssetManifest = { + schema: 1, + version: '9999888877776666', + generatedAt: '2026-10-01T01:00:00.000Z', + totalFiles: 4, + totalBytes: 4096, + files: { + 'act-packs/tiles/index.json': 'aaaa1111bbbb2222', // unchanged + 'missiles/arrow.json': 'ffff0000ffff0000', // hash changed! + // 'missiles/arrow.png' removed! + 'packs/entities/char-so.r8': '1234567890abcdef', // unchanged + 'ui/cursor.png': '5555666677778888', // newly added + }, + } + + const diff = diffAssetManifests(SAMPLE_MANIFEST_V1, nextManifest) + expect(diff.changedOrRemoved).toEqual(['missiles/arrow.json', 'missiles/arrow.png']) + expect(diff.added).toEqual(['ui/cursor.png']) + expect(diff.unchangedCount).toBe(2) + }) + + it('serves cached 200 responses with 0 network calls on second load, rejects non-200 responses, and evicts only changed files on manifest update', async () => { + const origin = 'https://www.laiseek.xyz' + const caches = new MockCacheStorage() + const networkCalls: string[] = [] + let arrowJsonPayload = '{"v":1}' + + const fetchFn = vi.fn(async (input: Request | string): Promise => { + const url = typeof input === 'string' ? input : input.url + networkCalls.push(url) + if (url.endsWith('/missiles/arrow.json')) { + return new Response(arrowJsonPayload, { status: 200 }) + } + if (url.endsWith('/missiles/arrow.png')) { + return new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 }) + } + if (url.endsWith('/samples/d2-packs/entities/char-so.r8')) { + return new Response(new Uint8Array([1, 2, 3, 4]), { status: 200 }) + } + if (url.endsWith('/missiles/missing.json')) { + return new Response('Not Found', { status: 404 }) + } + if (url.endsWith('/act-packs/tiles/partial.bin')) { + return new Response(new Uint8Array([9, 9]), { status: 206 }) + } + return new Response('OK', { status: 200 }) + }) + + const quotaErrors: string[] = [] + const controller = new AssetCacheServiceWorkerController({ + caches, + fetchFn, + origin, + onQuotaError: (url) => quotaErrors.push(url), + }) + + // 1. Before manifest is synced, pack/art requests pass through without caching (Anti-Silent-Failure) + const preSyncRes = await controller.handleFetch( + new Request(`${origin}/diablo2/missiles/arrow.json`), + ) + expect(await preSyncRes.text()).toBe('{"v":1}') + const preStats = await controller.getStats() + expect(preStats.manifestReady).toBe(false) + expect(preStats.cachedAssetCount).toBe(0) + expect(preStats.networkPassThroughs).toBe(1) + + // 2. Sync V1 manifest + const syncAck1 = await controller.syncManifest(SAMPLE_MANIFEST_V1) + expect(syncAck1.ok).toBe(true) + expect(syncAck1.version).toBe(SAMPLE_MANIFEST_V1.version) + + // 3. First fetch after manifest sync: misses cache, fetches 200 OK from network, stores in Cache Storage + networkCalls.length = 0 + const r1 = await controller.handleFetch(new Request(`${origin}/diablo2/missiles/arrow.json`)) + const r2 = await controller.handleFetch(new Request(`${origin}/diablo2/missiles/arrow.png`)) + const r3 = await controller.handleFetch( + new Request(`${origin}/samples/d2-packs/entities/char-so.r8`), + ) + expect(await r1.text()).toBe('{"v":1}') + expect((await r2.arrayBuffer()).byteLength).toBe(4) + expect((await r3.arrayBuffer()).byteLength).toBe(4) + expect(networkCalls).toHaveLength(3) + + // 4. Second fetch of the same 3 assets: 100% cache hit, 0 network requests! + networkCalls.length = 0 + const hit1 = await controller.handleFetch(new Request(`${origin}/diablo2/missiles/arrow.json`)) + const hit2 = await controller.handleFetch(new Request(`${origin}/diablo2/missiles/arrow.png`)) + const hit3 = await controller.handleFetch( + new Request(`${origin}/samples/d2-packs/entities/char-so.r8`), + ) + expect(await hit1.text()).toBe('{"v":1}') + expect((await hit2.arrayBuffer()).byteLength).toBe(4) + expect((await hit3.arrayBuffer()).byteLength).toBe(4) + expect(networkCalls).toHaveLength(0) + + const statsAfterHits = await controller.getStats() + expect(statsAfterHits.cacheHits).toBe(3) + expect(statsAfterHits.cacheMisses).toBe(3) + expect(statsAfterHits.cachedAssetCount).toBe(3) + + // 5. Non-200 responses (404, 206) are never cached + const r404 = await controller.handleFetch(new Request(`${origin}/diablo2/missiles/missing.json`)) + expect(r404.status).toBe(404) + const r206 = await controller.handleFetch( + new Request(`${origin}/diablo2/act-packs/tiles/partial.bin`), + ) + expect(r206.status).toBe(206) + expect((await controller.getStats()).cachedAssetCount).toBe(3) + expect(isCacheableResponse(r404)).toBe(false) + expect(isCacheableResponse(r206)).toBe(false) + + // 6. Incremental invalidation: update arrow.json hash, keep arrow.png and char-so.r8 unchanged + arrowJsonPayload = '{"v":2}' + const manifestV2: AssetManifest = { + ...SAMPLE_MANIFEST_V1, + version: '2222333344445555', + files: { + ...SAMPLE_MANIFEST_V1.files, + 'missiles/arrow.json': '9999999999999999', // changed hash! + }, + } + const syncAck2 = await controller.syncManifest(manifestV2) + expect(syncAck2.evictedCount).toBe(1) + expect(syncAck2.evictedPaths).toEqual(['missiles/arrow.json']) + expect(syncAck2.unchangedCount).toBe(3) + + // Fetching arrow.png and char-so.r8 still hits cache (0 network calls), + // while fetching arrow.json re-fetches v2 from network! + networkCalls.length = 0 + const unchangedPng = await controller.handleFetch( + new Request(`${origin}/diablo2/missiles/arrow.png`), + ) + const unchangedR8 = await controller.handleFetch( + new Request(`${origin}/samples/d2-packs/entities/char-so.r8`), + ) + expect((await unchangedPng.arrayBuffer()).byteLength).toBe(4) + expect((await unchangedR8.arrayBuffer()).byteLength).toBe(4) + expect(networkCalls).toHaveLength(0) + + const updatedJson = await controller.handleFetch( + new Request(`${origin}/diablo2/missiles/arrow.json`), + ) + expect(await updatedJson.text()).toBe('{"v":2}') + expect(networkCalls).toEqual([`${origin}/diablo2/missiles/arrow.json`]) + + // 7. QuotaExceededError during cache.put still returns network response and records warning + const assetBucket = await caches.open(ASSET_CACHE_NAME) + assetBucket.throwQuotaOnPut = true + const quotaRes = await controller.handleFetch( + new Request(`${origin}/diablo2/act-packs/tiles/index.json`), + ) + expect(quotaRes.status).toBe(200) + expect(quotaErrors).toEqual([`${origin}/diablo2/act-packs/tiles/index.json`]) + expect((await controller.getStats()).quotaErrors).toBe(1) + assetBucket.throwQuotaOnPut = false + + // 8. Clear cache wipes all entries + const clearAck = await controller.clearCache() + expect(clearAck.ok).toBe(true) + expect(clearAck.deletedEntries).toBe(3) + expect((await controller.getStats()).cachedAssetCount).toBe(0) + }) + + it('generates deterministic AssetManifest files offline from public/ and samples/d2-packs/', () => { + const tmpRoot = mkdtempSync(join(tmpdir(), 'd2web-manifest-test-')) + try { + const pubDir = join(tmpRoot, 'public') + const packsDir = join(tmpRoot, 'samples', 'd2-packs') + mkdirSync(join(pubDir, 'missiles'), { recursive: true }) + mkdirSync(join(pubDir, 'ui'), { recursive: true }) + mkdirSync(join(packsDir, 'entities'), { recursive: true }) + + writeFileSync(join(pubDir, 'missiles', 'arrow.json'), '{"name":"arrow"}\n') + writeFileSync(join(pubDir, 'ui', 'manifest.json'), '{"version":"1"}\n') + writeFileSync(join(packsDir, 'entities', 'char-so.r8'), new Uint8Array([10, 20, 30, 40])) + // Ignored files (sw.js, .mpq) + writeFileSync(join(pubDir, 'sw.js'), '// sw') + writeFileSync(join(packsDir, 'd2data.mpq'), new Uint8Array([0, 1, 2])) + + const m1 = generateAssetManifest({ + rootDir: tmpRoot, + generatedAt: '2026-10-01T00:00:00.000Z', + }) + const m2 = generateAssetManifest({ + rootDir: tmpRoot, + generatedAt: '2026-10-01T00:00:00.000Z', + }) + + expect(m1.version).toBe(m2.version) + expect(m1.totalFiles).toBe(3) + expect(Object.keys(m1.files)).toEqual([ + 'missiles/arrow.json', + 'packs/entities/char-so.r8', + 'ui/manifest.json', + ]) + } finally { + rmSync(tmpRoot, { recursive: true, force: true }) + } + }) + + it('respects dev port 5173 vs ?sw=1 and formats cache megabytes cleanly', async () => { + expect(shouldEnableAssetCacheSw({ port: '5173', search: '' })).toBe(false) + expect(shouldEnableAssetCacheSw({ port: '5173', search: '?sw=1' })).toBe(true) + expect(shouldEnableAssetCacheSw({ port: '4173', search: '' })).toBe(true) + expect(shouldEnableAssetCacheSw({ port: '', search: '?sw=0' })).toBe(false) + + expect(formatCacheBytesMb(0)).toBe('0.0 MB') + expect(formatCacheBytesMb(25 * 1024)).toBe('0.1 MB') + expect(formatCacheBytesMb(15.25 * 1024 * 1024)).toBe('15.3 MB') + + let clearCallbackCount = 0 + const toolbar = new Toolbar({ + store: createSettingsStore(), + callbacks: { + onClearCache: () => { + clearCallbackCount += 1 + }, + }, + }) + toolbar.setAssetCacheUsageBytes(12 * 1024 * 1024) + await toolbar.clearAssetCache() + expect(clearCallbackCount).toBe(1) + toolbar.destroy() + }) +}) diff --git a/tsconfig.sw.json b/tsconfig.sw.json new file mode 100644 index 0000000..2d562f7 --- /dev/null +++ b/tsconfig.sw.json @@ -0,0 +1,25 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "WebWorker"], + "types": [], + "noEmit": true + }, + "include": [ + "src/client/sw/protocol.ts", + "src/client/sw/sw.ts", + "src/common/**/*.ts", + "src/common/**/*.d.ts" + ], + "exclude": [ + "src/server", + "src/server/**", + "src/baker", + "src/baker/**", + "tools", + "tools/**", + "tests", + "tests/**" + ] +} diff --git a/vite.config.ts b/vite.config.ts index 19c06c8..5875dcd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,19 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url)) * Vite configuration for the unified Diablo II web client (`play.html`). */ export default defineConfig({ + plugins: [ + { + name: 'd2web-sw-dev-alias', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (req.url && /^\/(?:diablo2\/)?sw\.js(?:\?.*)?$/.test(req.url)) { + req.url = '/src/client/sw/sw.ts' + } + next() + }) + }, + }, + ], server: { host: '127.0.0.1', port: 5173, @@ -27,6 +40,11 @@ export default defineConfig({ rollupOptions: { input: { play: resolve(__dirname, 'play.html'), + sw: resolve(__dirname, 'src/client/sw/sw.ts'), + }, + output: { + entryFileNames: (chunkInfo) => + chunkInfo.name === 'sw' ? 'sw.js' : 'assets/[name]-[hash].js', }, }, },