feat(client,baker): implement incremental SHA-256 Service Worker asset caching
This commit is contained in:
parent
f61ff41527
commit
ffb45a480f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
)
|
||||
}
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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[]>
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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/**"
|
||||
]
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue