mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
* fix(pwa): tolerate broken IPv6 routes and surface provider error codes Node's happy-eyeballs racing gives each address attempt only 250 ms, so a dual-stack provider hostname behind an IPv4-only VPN namespace (Gluetun, WireGuard) exhausts every attempt and the web backend answered with a bare 502. Raise the per-attempt budget to 2500 ms at startup — keeping the IPv6->IPv4 fallback automatic — while an explicit --network-family-autoselection-attempt-timeout from NODE_OPTIONS still wins. Provider proxy failures now log the target hostname plus the underlying Node error codes (never the URL query, which carries credentials) and return the primary code in the error body, so the app shows "Bad Gateway (ETIMEDOUT)" instead of an unexplained 502. Closes #1400 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pwa): surface proxy network codes in import/refresh toasts, document runtime contract Codex: /parse connection failures arrive as HTTP 500 whose body carries the new code field, but fetchFromUrl()/refreshPlaylist() mapped only the HTTP status, so the toast stayed generic. Append the code to the translated message (regression-covered for both flows). Greptile: record the web-backend happy-eyeballs/diagnostics runtime contract in CLAUDE.md's monorepo structure section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pwa): drop provider reason phrases from logs, honor underscore flag spellings Codex round 2: the HTTP reason phrase is provider-controlled and can echo the credential-bearing request URL, so the failure log now carries only the numeric status; and Node treats underscores and dashes interchangeably in flag names, so the explicit-override check normalizes spelling before matching (verified live: --network_family_autoselection_attempt_timeout applies in both CLI and NODE_OPTIONS forms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pwa): match the timeout flag as a complete NODE_OPTIONS token Codex round 3 (P3): a raw substring search also fired on the flag text embedded in another option's value, silently skipping the 2500 ms default. Tokenize NODE_OPTIONS on whitespace and match the normalized option name exactly or with '='. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
/**
|
|
* Provider-facing error normalization for the web backend proxy routes.
|
|
*
|
|
* Outbound provider failures used to collapse into a bare `502 Bad Gateway`,
|
|
* hiding the network error codes (`ETIMEDOUT`, `ENETUNREACH`, ...) that
|
|
* explain what actually went wrong (#1400). These helpers surface those codes
|
|
* in the JSON error body and in a server-side log line. The log line carries
|
|
* only the provider hostname — never the full URL, whose query string
|
|
* routinely holds Xtream credentials.
|
|
*/
|
|
|
|
export interface ProviderError extends Error {
|
|
readonly code?: unknown;
|
|
readonly cause?: unknown;
|
|
readonly errors?: unknown;
|
|
readonly response?: {
|
|
readonly status?: number;
|
|
readonly statusText?: string;
|
|
};
|
|
}
|
|
|
|
export interface NormalizedProviderError {
|
|
readonly message: string;
|
|
readonly status: number;
|
|
readonly code?: string;
|
|
}
|
|
|
|
const MAX_COLLECTED_CODES = 5;
|
|
const MAX_TRAVERSAL_DEPTH = 4;
|
|
|
|
/**
|
|
* Collects the `code` properties of an error, its `cause` chain, and any
|
|
* `AggregateError` members — Node's happy-eyeballs failure is an
|
|
* `AggregateError` whose members carry the per-address codes that tell the
|
|
* IPv6-vs-IPv4 story. Bounded so a hostile or cyclic error graph cannot spin.
|
|
*/
|
|
export function collectProviderErrorCodes(error: unknown): string[] {
|
|
const codes: string[] = [];
|
|
visitErrorNode(error, 0, codes, new Set());
|
|
return codes;
|
|
}
|
|
|
|
function visitErrorNode(
|
|
error: unknown,
|
|
depth: number,
|
|
codes: string[],
|
|
seen: Set<unknown>
|
|
): void {
|
|
if (
|
|
!error ||
|
|
typeof error !== 'object' ||
|
|
depth > MAX_TRAVERSAL_DEPTH ||
|
|
seen.has(error) ||
|
|
codes.length >= MAX_COLLECTED_CODES
|
|
) {
|
|
return;
|
|
}
|
|
seen.add(error);
|
|
|
|
const candidate = error as ProviderError;
|
|
if (
|
|
typeof candidate.code === 'string' &&
|
|
candidate.code.length > 0 &&
|
|
!codes.includes(candidate.code)
|
|
) {
|
|
codes.push(candidate.code);
|
|
}
|
|
|
|
if (Array.isArray(candidate.errors)) {
|
|
for (const nested of candidate.errors) {
|
|
visitErrorNode(nested, depth + 1, codes, seen);
|
|
}
|
|
}
|
|
|
|
visitErrorNode(candidate.cause, depth + 1, codes, seen);
|
|
}
|
|
|
|
export function normalizeProviderError(
|
|
error: unknown
|
|
): NormalizedProviderError {
|
|
const providerError = error as ProviderError | null | undefined;
|
|
const response = providerError?.response;
|
|
if (
|
|
response &&
|
|
(response.status !== undefined || response.statusText !== undefined)
|
|
) {
|
|
return {
|
|
message: response.statusText ?? 'Bad Gateway',
|
|
status: response.status ?? 502,
|
|
};
|
|
}
|
|
|
|
const code = collectProviderErrorCodes(error)[0];
|
|
return {
|
|
message: code ? `Bad Gateway (${code})` : 'Bad Gateway',
|
|
status: 502,
|
|
...(code ? { code } : {}),
|
|
};
|
|
}
|
|
|
|
export function logProviderRequestFailure(options: {
|
|
readonly error: unknown;
|
|
readonly route: string;
|
|
readonly url: URL | string;
|
|
readonly logger?: (message: string) => void;
|
|
}): void {
|
|
const log = options.logger ?? console.error;
|
|
log(
|
|
`[web-backend] ${options.route} request to ${safeHostname(options.url)} failed: ${describeProviderFailure(options.error)}`
|
|
);
|
|
}
|
|
|
|
function describeProviderFailure(error: unknown): string {
|
|
const providerError = error as ProviderError | null | undefined;
|
|
const response = providerError?.response;
|
|
if (
|
|
response &&
|
|
(response.status !== undefined || response.statusText !== undefined)
|
|
) {
|
|
// The reason phrase is provider-controlled text and can echo the
|
|
// credential-bearing request URL; only the numeric status may be
|
|
// logged. (The statusText still reaches the client response body,
|
|
// which the provider could see anyway.)
|
|
return response.status !== undefined
|
|
? `HTTP ${response.status}`
|
|
: 'HTTP error';
|
|
}
|
|
|
|
const codes = collectProviderErrorCodes(error);
|
|
return codes.length > 0 ? codes.join(', ') : 'unknown network error';
|
|
}
|
|
|
|
function safeHostname(url: URL | string): string {
|
|
try {
|
|
return (url instanceof URL ? url : new URL(url)).hostname;
|
|
} catch {
|
|
return '<invalid url>';
|
|
}
|
|
}
|