feat(client,baker): implement incremental SHA-256 Service Worker asset caching

This commit is contained in:
troytt 2026-10-01 01:09:17 +00:00
parent f61ff41527
commit ffb45a480f
16 changed files with 1659 additions and 7 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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",

View File

@ -39,6 +39,7 @@ const TARGET_SCRIPTS: Readonly<Record<string, string>> = {
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

View File

@ -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'

View File

@ -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<string, string>()
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<string, string> = {}
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)`,
)
}

View File

@ -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'))

View File

@ -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<Record<ToolbarI18nKey, { readonly zh: string
'toolbar.automap.act': { zh: '全幕', en: 'Act' },
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' },
'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' },
'toolbar.diag.quotaExceeded': {
zh: '单 IP 桥接配额已满(上限 5 个并发连接),请关闭多余标签页后重试。',
en: 'Bridge connection quota reached (max 5 connections per IP). Close extra tabs and retry.',

View File

@ -48,6 +48,10 @@ import { PacketInspector } from './inspector/packet-inspector.ts'
import { MapService } from './map/map-service.ts'
import { OnlineSession } from './session/online-session.ts'
import { getGlobalSettingsStore, type SettingsStore } from './settings/settings-store.ts'
import {
getAssetCacheStatus,
registerAssetCache,
} from './sw/register.ts'
import { Toolbar } from './toolbar/toolbar.ts'
import { HudModel } from './ui-model/hud-model.ts'
import type { SceneFrameSnapshot } from './view/scene-source.ts'
@ -156,6 +160,8 @@ async function bootPlayApp(): Promise<PlayAppController> {
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<PlayAppController> {
},
},
})
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)

78
src/client/sw/protocol.ts Normal file
View File

@ -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<Response | undefined>
put(request: Request | string, response: Response): Promise<void>
delete(request: Request | string): Promise<boolean>
keys(): Promise<readonly Request[]>
}
export interface CacheStorageLike {
open(cacheName: string): Promise<CacheLike>
delete(cacheName: string): Promise<boolean>
keys(): Promise<readonly string[]>
}

336
src/client/sw/register.ts Normal file
View File

@ -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<Response>) | 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<T extends SwWorkerToClientMessage>(
worker: ServiceWorker,
message: SwClientToWorkerMessage,
timeoutMs = 8000,
): Promise<T> {
return new Promise<T>((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<T>) => {
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<Response> = (input, init) =>
fetch(input, init),
): Promise<AssetManifest> {
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<ServiceWorker | null> {
if (reg.active) return reg.active
const installingOrWaiting = reg.installing ?? reg.waiting
if (!installingOrWaiting) return null
return await new Promise<ServiceWorker | null>((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<AssetCacheStatus> {
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<SwSyncManifestAck>(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<AssetCacheStatus> {
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<SwStatsAck>(
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<SwClearCacheAck>(
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<SwSyncManifestAck>(
navigator.serviceWorker.controller,
{
type: 'D2_SW_SYNC_MANIFEST',
manifest: lastManifest,
},
4000,
)
}
} catch {
// Ignore
}
const status = await getAssetCacheStatus()
return { deletedEntries, status }
}
export type { SwCacheStats }

395
src/client/sw/sw.ts Normal file
View File

@ -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<Response>
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<Response>
private readonly origin: string
private readonly onQuotaError?: ((url: string, err: unknown) => void) | undefined
private activeManifest: AssetManifest | null = null
private manifestLoadPromise: Promise<AssetManifest | null> | null = null
private syncInFlight: Promise<SwSyncManifestAck> | 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<AssetManifest | null> {
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<SwSyncManifestAck> {
const run = async (): Promise<SwSyncManifestAck> => {
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<SwClearCacheAck> {
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<SwCacheStats> {
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<Response> {
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<SwSyncManifestAck | SwClearCacheAck | SwStatsAck> {
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<unknown>): void
}
interface SwFetchEventLike extends SwExtendableEventLike {
readonly request: Request
respondWith(r: Promise<Response> | 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<void> }
skipWaiting(): Promise<void>
addEventListener(type: string, listener: (event: Event) => void): void
}
function bindServiceWorkerGlobal(): void {
if (typeof self === 'undefined') return
const scope = self as unknown as Partial<SwGlobalScopeLike>
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()

View File

@ -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<void>) | 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<number> {
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

View File

@ -502,4 +502,188 @@ export function validatePackManifest<T extends PackManifestStatsLike>(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<Record<string, string>>
}
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<string, unknown>
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<string, unknown>
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<Record<string, string>>,
}
}
/**
* 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,
}
}

View File

@ -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<string, { request: Request; response: Response }>()
throwQuotaOnPut = false
private keyOf(req: Request | string): string {
return typeof req === 'string' ? req : req.url
}
async match(request: Request | string): Promise<Response | undefined> {
const entry = this.store.get(this.keyOf(request))
return entry ? entry.response.clone() : undefined
}
async put(request: Request | string, response: Response): Promise<void> {
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<boolean> {
return this.store.delete(this.keyOf(request))
}
async keys(): Promise<readonly Request[]> {
return [...this.store.values()].map((v) => v.request)
}
}
class MockCacheStorage implements CacheStorageLike {
readonly buckets = new Map<string, MockCache>()
async open(cacheName: string): Promise<MockCache> {
let bucket = this.buckets.get(cacheName)
if (!bucket) {
bucket = new MockCache()
this.buckets.set(cacheName, bucket)
}
return bucket
}
async delete(cacheName: string): Promise<boolean> {
return this.buckets.delete(cacheName)
}
async keys(): Promise<readonly string[]> {
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<Response> => {
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()
})
})

25
tsconfig.sw.json Normal file
View File

@ -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/**"
]
}

View File

@ -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',
},
},
},