Files
2026-10-18 23:18:25 +07:00

759 lines
29 KiB
TypeScript

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<string, string> = {
'audio/wave': 'audio/wav',
'audio/x-wav': 'audio/wav',
'application/ogg': 'audio/ogg',
'audio/mp3': 'audio/mpeg',
'image/jpg': 'image/jpeg',
}
const mimeTypes: Record<string, string> = {
'.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<bigint>
wait?: (milliseconds: number, signal: AbortSignal) => Promise<void>
}
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<string, LocalPackage>()
private readonly descriptions = new Map<string, LocalPackage>()
private readonly remembering = new Map<string, Promise<LocalPackage>>()
private readonly repaired = new Set<string>()
private readonly checked = new Map<string, string>()
private readonly loading = new Map<string, Promise<void>>()
private readonly assetErrors = new Map<string, string>()
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<string>()
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<T>(action: () => Promise<T>): Promise<T> {
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<void> {
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<LocalPackage> {
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<LocalPackage> {
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<string, string>()
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<LocalPackage> {
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<void> {
checkHex(key)
await this.exclusive(async () => {
const dir = await this.path(key)
await rm(dir, { recursive: true, force: true })
this.forget(key)
})
}
private forget(key: string) {
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)
}
async clear(): Promise<void> {
await this.exclusive(async () => {
await Promise.allSettled([...this.loading.values(), ...this.remembering.values()])
const root = await this.path()
for (const key of await readdir(root)) {
if (!hex.test(key)) continue
// Удаляется сама ссылка, без перехода к её цели.
await rm(join(root, key), { recursive: true, force: true })
this.forget(key)
}
})
}
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<LocalPackage> {
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<boolean> {
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<void> {
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<LocalPackage> {
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<StoredFile> {
const idle = new AbortController()
const combined = AbortSignal.any([signal, idle.signal])
let timer: ReturnType<typeof setTimeout>
const reset = () => {
clearTimeout(timer)
timer = setTimeout(() => idle.abort(), 30_000)
}
reset()
const network = async <T>(operation: () => Promise<T>): Promise<T> => {
try {
return await operation()
} catch {
signal.throwIfAborted()
throw new RetryableError('Обрыв соединения или таймаут загрузки')
}
}
let response: Response | undefined
let reader: ReadableStreamDefaultReader<Uint8Array> | 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(() => {})
}
}
}