import { createHash, randomUUID } from 'node:crypto' import { createReadStream } from 'node:fs' import { lstat, mkdir, open, readFile, readdir, rename, rm, statfs } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' import { setTimeout as wait } from 'node:timers/promises' import { parseScreenPackage, type ScreenPackage } from './screen-package.js' const digest = (value: string) => createHash('sha256').update(value).digest('hex') const hex = /^[a-f0-9]{64}$/ const reserve = 64n * 1024n * 1024n const mimeAliases: Record = { 'audio/wave': 'audio/wav', 'audio/x-wav': 'audio/wav', 'application/ogg': 'audio/ogg', 'audio/mp3': 'audio/mpeg', 'image/jpg': 'image/jpeg', } const mimeTypes: Record = { '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.mp4': 'video/mp4', '.webm': 'video/webm', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', } export interface StoredFile { id: string size: number sha256: string mime: string } export interface LocalPackage { formatVersion: 1 key: string version: string package: ScreenPackage files: StoredFile[] } export interface DownloadProgress { stage: 'downloading' | 'verifying' | 'activating' | 'ready' assetId: string | null downloadedBytes: number completedFiles: number totalFiles: number totalBytes: number | null } export interface VerificationProgress { completed: number total: number | null } interface Dependencies { fetch?: typeof fetch freeBytes?: () => Promise wait?: (milliseconds: number, signal: AbortSignal) => Promise } class RetryableError extends Error {} export function packageKey(server: string, gameId: number): string { return digest(JSON.stringify([server, gameId])) } function versionOf(pkg: ScreenPackage, files: StoredFile[]): string { return digest( JSON.stringify([ pkg.metadataVersion, files.map(({ id, size, sha256, mime }) => ({ id, size, sha256, mime })), ]), ) } function checkHex(value: unknown): asserts value is string { if (typeof value !== 'string' || !hex.test(value)) throw new Error('Некорректный идентификатор пакета или файла') } async function checksum(path: string) { const hash = createHash('sha256') let size = 0 for await (const chunk of createReadStream(path)) { hash.update(chunk) size += chunk.length } return { size, sha256: hash.digest('hex') } } export class PackageStore { private readonly root: string private busy = false private cancellation: AbortController | null = null private readonly ready = new Map() private readonly descriptions = new Map() private readonly remembering = new Map>() private readonly repaired = new Set() private readonly checked = new Map() private readonly loading = new Map>() private readonly assetErrors = new Map() constructor( root: string, private readonly dependencies: Dependencies = {}, ) { this.root = resolve(root) } // Все сегменты создаёт сервис; существующие симлинки никогда не обходятся. private async path(...parts: string[]) { let path = this.root for (const part of ['', ...parts]) { if (part) path = join(path, part) try { const info = await lstat(path) if (info.isSymbolicLink()) throw new Error('Симлинк в хранилище пакетов') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } } return path } async folders(key: string) { checkHex(key) const pkg = this.list().find((pkg) => pkg.key === key) if (!pkg) throw new Error('Игра не найдена') const folders = new Set() for (const asset of pkg.package.assets) { try { folders.add(dirname((await this.localFile(key, asset.id)).path)) } catch { /* Отсутствующий файл не должен вести пользователя в пустую папку. */ } } if (!folders.size) throw new Error('Материалы игры ещё не загружены') return [...folders] } private async exclusive(action: () => Promise): Promise { if (this.busy) throw new Error('Хранилище пакетов занято') this.busy = true try { return await action() } finally { this.busy = false } } private async syncDirectory(path: string) { // Windows не поддерживает fsync каталога; файлы синхронизируются на всех ОС. if (process.platform === 'win32') return const handle = await open(path, 'r') try { await handle.sync() } finally { await handle.close() } } private async writeJSON(path: string, value: unknown) { const handle = await open(path, 'wx', 0o600) try { await handle.writeFile(JSON.stringify(value)) await handle.sync() } finally { await handle.close() } } private async cleanup(key: string, keep?: string) { const dir = await this.path(key) for (const name of await readdir(dir)) { if ( (/^staging-[a-f0-9-]{36}$/.test(name) || /^v-[a-f0-9]{64}$/.test(name) || name === 'current.tmp') && name !== keep ) { // rm удаляет сам симлинк, не его цель; содержимое по ссылке не читается. await rm(join(dir, name), { recursive: true, force: true }) } } } async initialize(onProgress: (progress: VerificationProgress) => void = () => {}): Promise { onProgress({ completed: 0, total: null }) await this.exclusive(async () => { await this.path() await mkdir(this.root, { recursive: true }) await this.path() this.ready.clear() this.descriptions.clear() this.checked.clear() this.repaired.clear() this.assetErrors.clear() for (const key of await readdir(this.root)) { if (!hex.test(key)) continue try { const description = parseScreenPackage( JSON.parse(await readFile(await this.path(key, 'description.json'), 'utf8')), ) if (packageKey(description.server, description.game.id) === key) this.descriptions.set(key, this.descriptionPackage(description)) } catch { /* Старые пакеты не содержат отдельного описания. */ } try { await this.path(key) const manifest = await this.load(key) this.ready.set(key, manifest) await this.cleanup(key, `v-${manifest.version}`) } catch { // Повреждённый пакет остаётся для явного удаления, но не становится готовым. try { let keep: string | undefined try { const pointer: unknown = JSON.parse( await readFile(await this.path(key, 'current.json'), 'utf8'), ) checkHex(pointer) keep = `v-${pointer}` } catch { /* Без корректного указателя готовой версии нет. */ } await this.cleanup(key, keep) } catch { /* Чужие пути не обходим. */ } } } const packages = this.list() const total = packages.reduce((count, pkg) => count + pkg.package.assets.length, 0) let completed = 0 onProgress({ completed, total }) for (const pkg of packages) { for (const asset of pkg.package.assets) { await this.checkAsset(pkg.key, asset.id) onProgress({ completed: ++completed, total }) } } }) } private async load(key: string): Promise { checkHex(key) const pointer: unknown = JSON.parse( await readFile(await this.path(key, 'current.json'), 'utf8'), ) checkHex(pointer) return this.verifyDirectory(key, pointer) } private async verifyDirectory(key: string, version: string): Promise { const raw = JSON.parse( await readFile(await this.path(key, `v-${version}`, 'manifest.json'), 'utf8'), ) if ( raw?.formatVersion !== 1 || raw.key !== key || raw.version !== version || !Array.isArray(raw.files) ) throw new Error('Повреждён манифест пакета') const pkg = parseScreenPackage(raw.package) if (packageKey(pkg.server, pkg.game.id) !== key || raw.files.length !== pkg.assets.length) throw new Error('Неполный пакет') const files: StoredFile[] = [] const signatures = new Map() for (let i = 0; i < pkg.assets.length; i++) { const file = raw.files[i] as StoredFile if ( !file || file.id !== pkg.assets[i]!.id || !Number.isSafeInteger(file.size) || file.size <= 0 || !hex.test(file.sha256) || !Object.values(mimeTypes).includes(file.mime) ) throw new Error('Повреждён список файлов') const path = await this.path(key, `v-${version}`, 'assets', file.id) const info = await lstat(path) if (!info.isFile()) throw new Error('Ресурс не является файлом') const actual = await checksum(path) if (actual.size !== file.size || actual.sha256 !== file.sha256) throw new Error('Повреждён файл пакета') files.push({ id: file.id, ...actual, mime: file.mime }) signatures.set( `${key}/${file.id}`, `${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`, ) } if (versionOf(pkg, files) !== version) throw new Error('Повреждена версия пакета') for (const [token, signature] of signatures) this.checked.set(token, signature) return { formatVersion: 1, key, version, package: pkg, files } } list(): LocalPackage[] { return structuredClone([...new Map([...this.ready, ...this.descriptions]).values()]) } cancel(): void { this.cancellation?.abort() } async verify(key: string): Promise { return this.exclusive(async () => { try { const pkg = await this.load(key) this.ready.set(key, pkg) return structuredClone(pkg) } catch (error) { this.ready.delete(key) throw error } }) } async remove(key: string): Promise { checkHex(key) await this.exclusive(async () => { const dir = await this.path(key) await rm(dir, { recursive: true, force: true }) this.ready.delete(key) this.descriptions.delete(key) for (const token of this.checked.keys()) if (token.startsWith(`${key}/`)) this.checked.delete(token) for (const token of this.repaired) if (token.startsWith(`${key}/`)) this.repaired.delete(token) for (const token of this.assetErrors.keys()) if (token.startsWith(`${key}/`)) this.assetErrors.delete(token) }) } private descriptionPackage(pkg: ScreenPackage): LocalPackage { return { formatVersion: 1, key: packageKey(pkg.server, pkg.game.id), version: pkg.metadataVersion, package: pkg, files: [], } } description(key: string): LocalPackage | null { return structuredClone(this.descriptions.get(key) ?? null) } async remember(value: ScreenPackage): Promise { if (this.busy) throw new Error('Хранилище пакетов занято') const pkg = parseScreenPackage(value) const local = this.descriptionPackage(pkg) const previous = this.remembering.get(local.key) const job = (async () => { await previous?.catch(() => {}) await mkdir(await this.path(local.key), { recursive: true }) const temporary = await this.path(local.key, `description-${randomUUID()}.tmp`) try { await this.writeJSON(temporary, pkg) await rename(temporary, await this.path(local.key, 'description.json')) this.descriptions.set(local.key, local) return structuredClone(local) } finally { await rm(temporary, { force: true }) } })() this.remembering.set(local.key, job) try { return await job } finally { if (this.remembering.get(local.key) === job) this.remembering.delete(local.key) } } private async cachedFile(key: string, assetId: string) { const data: unknown = JSON.parse( await readFile(await this.path(key, 'cache', `${assetId}.json`), 'utf8'), ) if (!data || typeof data !== 'object') throw new Error('Повреждён кеш ресурса') const f = data as StoredFile if ( f.id !== assetId || !Number.isSafeInteger(f.size) || f.size <= 0 || !hex.test(f.sha256) || !Object.values(mimeTypes).includes(f.mime) ) throw new Error('Повреждён кеш ресурса') const path = await this.path(key, 'cache', assetId) const info = await lstat(path) if (!info.isFile() || info.size !== f.size) throw new Error('Повреждён файл кеша') return { path, size: f.size, mime: f.mime, sha256: f.sha256 } } async localFile(key: string, assetId: string) { checkHex(key) checkHex(assetId) const pkg = this.ready.get(key) const file = pkg?.files.find((file) => file.id === assetId) if (pkg && file && !this.repaired.has(`${key}/${assetId}`)) { try { const path = await this.path(key, `v-${pkg.version}`, 'assets', assetId) const info = await lstat(path) if (!info.isFile() || info.size !== file.size) throw new Error('Повреждён файл пакета') return { path, size: file.size, mime: file.mime, sha256: file.sha256 } } catch { /* Отдельный ресурс можно восстановить, не теряя остальные. */ } } return this.cachedFile(key, assetId) } canRead(key: string, assetId: string) { return ( this.checked.has(`${key}/${assetId}`) || !!this.ready .get(key) ?.files.some((f) => f.id === assetId && !this.repaired.has(`${key}/${assetId}`)) ) } assetStatus(key: string, assetId: string) { const token = `${key}/${assetId}` const error = this.assetErrors.get(token) ?? '' const status = this.loading.has(token) ? 'loading' : this.canRead(key, assetId) ? 'ready' : error ? 'error' : 'missing' return { status, error } as const } async checkAsset(key: string, assetId: string): Promise { const token = `${key}/${assetId}` try { const file = await this.localFile(key, assetId) const stat = await lstat(file.path) const signature = `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${file.sha256}` if (this.checked.get(token) === signature) return true const actual = await checksum(file.path) if (actual.size === file.size && actual.sha256 === file.sha256) { this.checked.set(token, signature) return true } } catch { /* Отсутствующие и повреждённые файлы доступны для повторной загрузки. */ } this.checked.delete(token) this.repaired.add(token) return false } async ensureAsset( pkg: ScreenPackage, assetId: string, signal: AbortSignal, progress: (bytes: number) => void = () => {}, ): Promise { const key = packageKey(pkg.server, pkg.game.id) checkHex(assetId) const asset = pkg.assets.find((a) => a.id === assetId) if (!asset) throw new Error('Ресурс отсутствует в описании игры') const token = `${key}/${assetId}` const existing = this.loading.get(token) if (existing) return existing this.assetErrors.delete(token) const job = (async () => { signal.throwIfAborted() if (await this.checkAsset(key, assetId)) return signal.throwIfAborted() await this.remembering.get(key) if (!this.descriptions.has(key)) await this.remember(pkg) signal.throwIfAborted() await mkdir(await this.path(key, 'cache'), { recursive: true }) const name = `${assetId}-${randomUUID()}.tmp` const path = await this.path(key, 'cache', name) const metadata = await this.path(key, 'cache', `${name}.json`) try { let file: StoredFile | undefined for (let attempt = 0; attempt < 3; attempt++) { try { signal.throwIfAborted() file = await this.fetchFile(asset, path, signal, progress) break } catch (error) { await rm(path, { force: true }) signal.throwIfAborted() if (!(error instanceof RetryableError) || attempt === 2) throw error if (this.dependencies.wait) await this.dependencies.wait(attempt ? 3000 : 1000, signal) else await wait(attempt ? 3000 : 1000, undefined, { signal }) } } if (!file) throw new Error('Файл не загружен') const actual = await checksum(path) if (actual.size !== file.size || actual.sha256 !== file.sha256) throw new Error('Ошибка проверки скачанного файла') signal.throwIfAborted() await this.writeJSON(metadata, file) await rename(path, await this.path(key, 'cache', assetId)) await rename(metadata, await this.path(key, 'cache', `${assetId}.json`)) // Восстановленный кеш заменяет повреждённый ресурс старого пакета. this.repaired.add(token) const info = await lstat(await this.path(key, 'cache', assetId)) this.checked.set(token, `${info.size}:${info.mtimeMs}:${info.ctimeMs}:${file.sha256}`) } finally { await rm(path, { force: true }) await rm(metadata, { force: true }) } })() this.loading.set(token, job) try { await job } catch (error) { if (!signal.aborted) this.assetErrors.set(token, error instanceof Error ? error.message : 'Файл недоступен') throw error } finally { if (this.loading.get(token) === job) this.loading.delete(token) } } private async space(needed = 0) { const available = this.dependencies.freeBytes ? await this.dependencies.freeBytes() : await statfs(this.root, { bigint: true }).then((s) => s.bavail * s.bsize) if (available < reserve + BigInt(needed)) throw new Error('Недостаточно места для пакета (нужен резерв 64 МиБ)') } async download( value: ScreenPackage, options: { signal?: AbortSignal; onProgress?: (progress: DownloadProgress) => void } = {}, ): Promise { return this.exclusive(async () => { const pkg = parseScreenPackage(value) const key = packageKey(pkg.server, pkg.game.id) this.cancellation = new AbortController() const signal = AbortSignal.any([ this.cancellation.signal, ...(options.signal ? [options.signal] : []), ]) const stage = `staging-${randomUUID()}` let staged = '' let published = '' let activated = false const files: StoredFile[] = [] let completedBytes = 0 const report = (stage: DownloadProgress['stage'], assetId: string | null, bytes = 0) => { try { options.onProgress?.({ stage, assetId, downloadedBytes: completedBytes + bytes, completedFiles: files.length, totalFiles: pkg.assets.length, totalBytes: stage === 'ready' ? completedBytes : null, }) } catch { /* Наблюдатель не должен прерывать изменение хранилища. */ } } try { signal.throwIfAborted() await this.path() await mkdir(this.root, { recursive: true }) const dir = await this.path(key) await mkdir(dir, { recursive: true }) await this.space() staged = await this.path(key, stage) await mkdir(join(staged, 'assets'), { recursive: true }) for (const asset of pkg.assets) { let file: StoredFile | undefined for (let attempt = 0; attempt < 3; attempt++) { signal.throwIfAborted() const path = join(staged, 'assets', asset.id) try { file = await this.fetchFile(asset, path, signal, (bytes) => report('downloading', asset.id, bytes), ) break } catch (error) { await rm(path, { force: true }) signal.throwIfAborted() if (!(error instanceof RetryableError) || attempt === 2) throw error const delay = attempt === 0 ? 1000 : 3000 if (this.dependencies.wait) await this.dependencies.wait(delay, signal) else await wait(delay, undefined, { signal }) } } if (!file) throw new Error('Не удалось скачать файл') report('verifying', asset.id, file.size) const actual = await checksum(join(staged, 'assets', asset.id)) signal.throwIfAborted() if (actual.size !== file.size || actual.sha256 !== file.sha256) throw new Error('Ошибка проверки скачанного файла') files.push(file) completedBytes += file.size } const version = versionOf(pkg, files) const manifest: LocalPackage = { formatVersion: 1, key, version, package: pkg, files } await this.writeJSON(join(staged, 'manifest.json'), manifest) await this.syncDirectory(join(staged, 'assets')) await this.syncDirectory(staged) report('activating', null) signal.throwIfAborted() const target = await this.path(key, `v-${version}`) let exists = false try { await lstat(target) exists = true } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } if (exists) { try { await this.verifyDirectory(key, version) } catch { // Повторная загрузка восстанавливает уже повреждённую локальную копию. if (this.ready.get(key)?.version === version) this.ready.delete(key) await rm(target, { recursive: true, force: true }) exists = false } } if (!exists) { await rename(staged, target) staged = '' published = target } await this.syncDirectory(dir) const pointer = await this.path(key, 'current.tmp') await rm(pointer, { force: true }) await this.writeJSON(pointer, version) signal.throwIfAborted() await rename(pointer, await this.path(key, 'current.json')) activated = true this.ready.set(key, manifest) this.descriptions.delete(key) await rm(await this.path(key, 'description.json'), { force: true }) for (const file of files) this.repaired.delete(`${key}/${file.id}`) await this.syncDirectory(dir).catch(() => {}) // После переключения указателя сбой очистки не отменяет успешную активацию. await this.cleanup(key, `v-${version}`).catch(() => {}) report('ready', null) return structuredClone(manifest) } catch (error) { if (signal.aborted && !activated) throw new Error('Загрузка отменена') if ((error as NodeJS.ErrnoException).code === 'ENOSPC') throw new Error('Недостаточно места для пакета') throw error } finally { this.cancellation = null if (staged) await rm(staged, { recursive: true, force: true }).catch(() => {}) if (published && !activated) await rm(published, { recursive: true, force: true }).catch(() => {}) if (!activated) await rm(await this.path(key, 'current.tmp'), { force: true }).catch(() => {}) } }) } private async fetchFile( asset: ScreenPackage['assets'][number], path: string, signal: AbortSignal, progress: (bytes: number) => void, ): Promise { const idle = new AbortController() const combined = AbortSignal.any([signal, idle.signal]) let timer: ReturnType const reset = () => { clearTimeout(timer) timer = setTimeout(() => idle.abort(), 30_000) } reset() const network = async (operation: () => Promise): Promise => { try { return await operation() } catch { signal.throwIfAborted() throw new RetryableError('Обрыв соединения или таймаут загрузки') } } let response: Response | undefined let reader: ReadableStreamDefaultReader | undefined try { let url = new URL(asset.url) for (let redirects = 0; ; redirects++) { if ( !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash || [...url.searchParams.keys()].some((key) => /token|signature|credential|password|authorization/i.test(key), ) ) throw new Error('Недопустимый адрес медиа') response = await network(() => (this.dependencies.fetch ?? fetch)(url.href, { redirect: 'manual', credentials: 'omit', headers: { 'Accept-Encoding': 'identity' }, signal: combined, }), ) reset() if (![301, 302, 303, 307, 308].includes(response.status)) break await response.body?.cancel() const location = response.headers.get('location') if (!location || redirects === 5) throw new Error('Недопустимый редирект медиа') const next = new URL(location, url) if (url.protocol === 'https:' && next.protocol !== 'https:') throw new Error('Небезопасный редирект медиа') url = next } if ([408, 429].includes(response.status) || response.status >= 500) throw new RetryableError(`Ошибка загрузки: HTTP ${response.status}`) if (response.status !== 200 || !response.body) throw new Error(`Медиа недоступно: HTTP ${response.status}`) const encoding = response.headers.get('content-encoding') if (encoding && encoding !== 'identity') throw new Error('Неожиданное сжатие медиа') const header = response.headers.get('content-length') const length = header === null ? null : Number(header) if (length !== null && (!Number.isSafeInteger(length) || length <= 0)) throw new Error('Некорректный размер медиа') const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() const mime = contentType && contentType !== 'application/octet-stream' ? (mimeAliases[contentType] ?? contentType) : mimeTypes[extname(url.pathname).toLowerCase()] if (!mime || !Object.values(mimeTypes).includes(mime)) throw new Error('Неподдерживаемый тип медиа') await this.space(length ?? 0) const file = await open(path, 'wx', 0o600) let size = 0 const hash = createHash('sha256') try { reader = response.body.getReader() for (;;) { const chunk = await network(() => reader!.read()) signal.throwIfAborted() if (chunk.done) break reset() if ( !Number.isSafeInteger(size + chunk.value.length) || (length !== null && size + chunk.value.length > length) ) throw new RetryableError('Размер медиа не совпадает с ответом сервера') await this.space(chunk.value.length) await file.writeFile(chunk.value) hash.update(chunk.value) size += chunk.value.length progress(size) } if (!size || (length !== null && size !== length)) throw new RetryableError('Файл загружен не полностью') await file.sync() } finally { await file.close() } return { id: asset.id, size, sha256: hash.digest('hex'), mime } } finally { clearTimeout(timer!) if (reader) await reader.cancel().catch(() => {}) else await response?.body?.cancel().catch(() => {}) } } }