Files
2026-10-19 00:57:56 +07:00

178 lines
6.8 KiB
TypeScript

export const NETWORK_ERROR_CODES = [
'NETWORK',
'ERR_PROXY_CONNECTION_FAILED',
'ERR_TUNNEL_CONNECTION_FAILED',
'TIMEOUT',
'ABORTED',
'ENOTFOUND',
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'UND_ERR_CONNECT_TIMEOUT',
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'ERR_TLS_CERT_ALTNAME_INVALID',
] as const
export type NetworkErrorCode = (typeof NETWORK_ERROR_CODES)[number]
export class NetworkError extends Error {
constructor(
message: string,
public code: NetworkErrorCode = 'NETWORK',
) {
super(message)
}
}
export class SessionChangedError extends Error {
constructor() {
super('Сессия изменилась. Повторите подключение игры')
}
}
export class ApiError extends Error {
constructor(public status: number) {
super(
status === 401
? 'Сессия истекла. Войдите снова'
: status === 403
? 'Нет доступа к действию'
: status === 400
? 'Действие пока недоступно для этой игры'
: `Ошибка сервера: HTTP ${status}`,
)
}
}
const chromiumCodes: Record<string, NetworkErrorCode> = {
ERR_NAME_NOT_RESOLVED: 'ENOTFOUND',
ERR_CONNECTION_REFUSED: 'ECONNREFUSED',
ERR_CONNECTION_RESET: 'ECONNRESET',
ERR_CONNECTION_TIMED_OUT: 'ETIMEDOUT',
ERR_TIMED_OUT: 'ETIMEDOUT',
ERR_PROXY_CONNECTION_FAILED: 'ERR_PROXY_CONNECTION_FAILED',
ERR_TUNNEL_CONNECTION_FAILED: 'ERR_TUNNEL_CONNECTION_FAILED',
ERR_CERT_DATE_INVALID: 'CERT_HAS_EXPIRED',
ERR_CERT_AUTHORITY_INVALID: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
ERR_CERT_COMMON_NAME_INVALID: 'ERR_TLS_CERT_ALTNAME_INVALID',
}
function causeCode(error: unknown, depth = 0): NetworkErrorCode | undefined {
if (!error || typeof error !== 'object' || depth > 3) return
if (error instanceof Error) {
const match = /^net::(ERR_[A-Z_]+)(?:\s|$)/.exec(error.message)
const code = match ? chromiumCodes[match[1]!] : undefined
if (code) return code
}
const value = error as { code?: unknown; cause?: unknown; errors?: unknown[] }
if (NETWORK_ERROR_CODES.includes(value.code as NetworkErrorCode))
return value.code as NetworkErrorCode
return (
causeCode(value.cause, depth + 1) ??
(Array.isArray(value.errors)
? value.errors.map((e) => causeCode(e, depth + 1)).find(Boolean)
: undefined)
)
}
export function networkFailure(error: unknown, signal: AbortSignal): NetworkError {
if (signal.aborted) {
return signal.reason?.name === 'TimeoutError'
? new NetworkError('Превышено время ожидания ответа сервера', 'TIMEOUT')
: new NetworkError('Подготовка отменена', 'ABORTED')
}
if (error instanceof NetworkError) return error
const code = causeCode(error) ?? 'NETWORK'
const message =
code === 'ENOTFOUND' || code === 'EAI_AGAIN'
? 'Не удалось определить адрес сервера'
: code === 'ERR_PROXY_CONNECTION_FAILED' || code === 'ERR_TUNNEL_CONNECTION_FAILED'
? 'Не удалось подключиться через системный прокси'
: code.includes('CERT') || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
? 'Ошибка защищённого соединения с сервером'
: code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT'
? 'Превышено время ожидания соединения с сервером'
: code === 'ECONNREFUSED'
? 'Сервер отклонил соединение'
: code === 'ECONNRESET'
? 'Соединение с сервером прервано'
: 'Сервер недоступен'
return new NetworkError(message, code)
}
function valueType(value: unknown): string {
return value === null
? 'null'
: value === undefined
? 'поле отсутствует'
: Array.isArray(value)
? 'массив'
: typeof value
}
export class ResponseFieldError extends Error {
constructor(
public field: string,
expected: string,
value: unknown,
message = 'Некорректный ответ API',
) {
super(`${message}: поле ${field} — ожидалось ${expected}, получено ${valueType(value)}`)
}
}
export function fieldError(
field: string | undefined,
expected: string,
value: unknown,
message: string,
): Error {
return field ? new ResponseFieldError(field, expected, value, message) : new Error(message)
}
const contextualErrors = new WeakSet<Error>()
export function requestErrorContext(
error: unknown,
operation: string,
method: string,
path: string,
): Error {
const result = error instanceof Error ? error : new Error('Не удалось обработать ответ сервера')
if (contextualErrors.has(result)) return result
const names: Record<string, string> = {
GetGame: 'Описание игры',
GetFullScenario: 'Сценарий игры',
GetGameMusic: 'Музыка игры',
GetScreenPlan: 'План экранов',
SaveScreenPlan: 'Сохранение плана',
SetScreenPlanStep: 'Переход по плану',
GetScreenState: 'Состояние экрана',
AdvanceScreenMusic: 'Следующий трек',
Login: 'Вход',
Refresh: 'Обновление сессии',
GetPlaylists: 'Список плейлистов',
CreatePlaylist: 'Создание плейлиста',
UpdatePlaylist: 'Название плейлиста',
DeletePlaylist: 'Удаление плейлиста',
UploadPlaylistTrack: 'Загрузка MP3',
DeletePlaylistTrack: 'Удаление трека',
ReorderPlaylistTracks: 'Порядок треков',
GetMe: 'Данные пользователя',
GetGames: 'Список игр',
CreateScreenSession: 'Создание экрана',
BindScreen: 'Подключение экрана',
UnbindScreen: 'Отключение экрана',
SetScreenSound: 'Назначение звука',
SetGameMusic: 'Управление музыкой',
SetScreenMode: 'Режим экрана',
SetScreenTimer: 'Таймер',
SendScreenCommand: 'Команда экрана',
GetScreenResults: 'Результаты',
}
const safePath = path.split('?')[0]!
const code =
result instanceof NetworkError
? ` [${result.code}]`
: result instanceof ApiError && !result.message.includes(`HTTP ${result.status}`)
? ` [HTTP ${result.status}]`
: ''
result.message = `${names[operation] ?? 'Запрос API'} · ${method} ${safePath}: ${result.message}${code}`
contextualErrors.add(result)
return result
}