176 lines
6.7 KiB
TypeScript
176 lines
6.7 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: 'Описание игры',
|
|
GetGameMusic: 'Музыка игры',
|
|
GetScreenPlan: 'План экранов',
|
|
SaveScreenPlan: 'Сохранение плана',
|
|
SetScreenPlanStep: 'Переход по плану',
|
|
GetScreenState: 'Состояние экрана',
|
|
AdvanceScreenMusic: 'Следующий трек',
|
|
Login: 'Вход',
|
|
Refresh: 'Обновление сессии',
|
|
GetPlaylists: 'Список плейлистов',
|
|
CreatePlaylist: 'Создание плейлиста',
|
|
UpdatePlaylist: 'Название плейлиста',
|
|
DeletePlaylist: 'Удаление плейлиста',
|
|
UploadPlaylistTrack: 'Загрузка MP3',
|
|
DeletePlaylistTrack: 'Удаление трека',
|
|
ReorderPlaylistTracks: 'Порядок треков',
|
|
GetMe: 'Данные пользователя',
|
|
GetGames: 'Список игр',
|
|
CreateScreenSession: 'Создание экрана',
|
|
BindScreen: 'Подключение экрана',
|
|
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
|
|
}
|