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 = { 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() 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 = { GetGame: 'Описание игры', 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 }