fix(portals): stop treating a slow panel as a dead host; IPv4 fallback budget in Electron (#1621)

* fix(portals): apply the IPv6->IPv4 fallback budget in the Electron process

The 2500 ms happy-eyeballs attempt timeout from #1404 only ever ran in the
web backend. The Electron main process and its playlist-refresh and EPG
workers kept Node's 250 ms default, so a dual-stack panel hostname behind a
VPN or a slow link failed every connection attempt in a row and tripped the
host connectivity guard. The module now lives in `@iptvnator/shared/host-health`
and every Node isolate that opens connections applies it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): stop treating a slow panel as a dead one in the host guard

axios raises the same ECONNABORTED whether the SYN went unanswered or the
panel accepted the connection and then thought for longer than the request
budget. Two such timeouts opened the breaker and every request to the panel
was refused for 30 s with "portal is not responding" — the shape behind the
"connection keeps dropping" reports on 0.23 and nightly.

Both transports now report whether the TCP connection was established
(`onConnect`: Electron through a per-request observed agent instead of the
shared keep-alive globalAgent, the web backend through the transport that owns
the ClientRequest), and `classifyHostRequestFailure(error, { connected })`
downgrades a host-level code observed after the handshake to inconclusive.
Redirect attribution keeps precedence. A host that never accepts the
connection trips the guard exactly as before.

The Xtream mock gains a `silent:silent` scenario whose detail actions accept
and never answer, plus a real-socket regression spec for the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): let an accepted connection clear the host-failure streak

Review finding: an unanswered SYN, then an accepted-but-slow timeout, then
another unanswered SYN still reached the two-failure threshold, because the
middle request was merely not counted. An accepted TCP connection is the
reachability the guard measures, so it now reads as `responded` and clears
the streak like an HTTP response would. Regression coverage for the mixed
sequence on one flapping loopback origin (Electron) and through the proxy
route (web backend).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): credit an accepted connection when it happens, not when the request settles

Review findings. A request that connected and then hung for 30 s cleared,
on its eventual timeout, the failures later requests had recorded while it
waited — reopening a host that had just died on evidence older than theirs.
The connect hook now reports the connection the moment it fires through a
new `HostConnectivityGuard.reportConnected`, which clears the failure streak
but closes no open or half-open breaker (the trial keeps its slot until it
settles), and the settled timeout is inconclusive.

Electron also skips the socket observer while an environment proxy
(`http_proxy` / `https_proxy` / `all_proxy`) applies to the request: through
a proxy the socket connects to the proxy, whose handshake proves nothing
about the portal, so those requests keep the pre-observer behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(portals): decide the proxy exemption with axios' own resolution

Review findings. The hand-rolled environment check ignored `no_proxy`, so a
LAN portal exempted from the proxy lost its connect observer and slow
requests to it still tripped the breaker; it also read the variables with
`??`, letting an empty lowercase one mask a populated uppercase one that
axios would honour. The decision now calls `proxy-from-env`'s
`getProxyForUrl`, the same pinned package axios' http adapter uses,
declared as a direct dependency so the packaged app carries it.

The validated-axios spec clears and restores every proxy variable around each
case, so a runner that exports a proxy cannot change what the cases prove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-19 14:50:50 +02:00
1 parent 7522c7689f
commit 01c423ac43
36 files changed
+1220 -53

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
---
type: fix
area: portals
---
A slow Xtream or Stalker panel is no longer mistaken for a dead one: a request
that reached the panel and then timed out no longer trips the 30-second
"portal is not responding" pause, which only fires when the panel never
accepts the connection. The desktop app also gives IPv4 fallback the same
2.5-second budget as the self-hosted backend, so dual-stack panels behind
VPNs stop failing in bursts.
+3 -3
View File
@@ -375,7 +375,7 @@ This is an Nx monorepo with the following structure:
- **apps/web** - Angular application (frontend, shared by Electron and PWA)
- **apps/electron-backend** - Electron main process
- **apps/web-backend** - HTTP backend for the self-hosted PWA (`/parse`, `/parse-xml`, `/xtream`, `/stalker` CORS proxy endpoints). At startup it raises Node's happy-eyeballs per-attempt connection timeout to 2500 ms (`network-family-autoselection.ts`) so dual-stack provider hostnames fall back to IPv4 behind IPv6-less VPN/Docker networks; an explicit `--network-family-autoselection-attempt-timeout` passed via `NODE_OPTIONS`/CLI always wins. Outbound provider failures are logged hostname-only with the underlying Node error codes and return the primary code in the error body (`provider-error.ts`) — the proxied URL query carries credentials and must never be logged. Every proxied request carries the same timeout as its Electron counterpart (Xtream 30 s, Stalker 15 s / 30 s for `create_link`, playlist and XMLTV 30 s). The shared per-host circuit breaker (`host-guard.ts`, injected via `WebBackendAppOptions.hostGuard`) covers `/xtream` and `/stalker` only — playlist/XMLTV downloads keep the timeout but no breaker, matching Electron. A fast-fail keeps the route's normal failure shape (HTTP 200 with a `{message, status}` body), `skipConnectionGuard=true` carries the Stalker discovery exemption through the proxy, and `POST /connectivity-guard/reset` is the PWA's counterpart to the `CONNECTIVITY_GUARD_RESET` IPC
- **apps/web-backend** - HTTP backend for the self-hosted PWA (`/parse`, `/parse-xml`, `/xtream`, `/stalker` CORS proxy endpoints). At startup it raises Node's happy-eyeballs per-attempt connection timeout to 2500 ms (`applyDefaultAutoSelectFamilyAttemptTimeout` in `libs/shared/host-health`; the Electron main process and its playlist-refresh and EPG workers apply the same default through `apps/electron-backend/src/app/util/network-defaults.ts`, since Node keeps it per isolate) so dual-stack provider hostnames fall back to IPv4 behind IPv6-less VPN/Docker networks; an explicit `--network-family-autoselection-attempt-timeout` passed via `NODE_OPTIONS`/CLI always wins. Outbound provider failures are logged hostname-only with the underlying Node error codes and return the primary code in the error body (`provider-error.ts`) — the proxied URL query carries credentials and must never be logged. Every proxied request carries the same timeout as its Electron counterpart (Xtream 30 s, Stalker 15 s / 30 s for `create_link`, playlist and XMLTV 30 s). The shared per-host circuit breaker (`host-guard.ts`, injected via `WebBackendAppOptions.hostGuard`) covers `/xtream` and `/stalker` only — playlist/XMLTV downloads keep the timeout but no breaker, matching Electron. A fast-fail keeps the route's normal failure shape (HTTP 200 with a `{message, status}` body), `skipConnectionGuard=true` carries the Stalker discovery exemption through the proxy, and `POST /connectivity-guard/reset` is the PWA's counterpart to the `CONNECTIVITY_GUARD_RESET` IPC
- **apps/remote-control-web** - Mobile remote-control web app served by the Electron backend
- **apps/web-e2e** - Playwright E2E tests against the web app
- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app
@@ -396,7 +396,7 @@ This is an Nx monorepo with the following structure:
- **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
- **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)
- **shared/logging** - Dependency-free structured redaction for diagnostic logs
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438)
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Also home to the two Node networking helpers both backends share: the happy-eyeballs attempt-timeout default (`network-family-autoselection.ts`) and the socket-connect observer that feeds the guard's `connected` flag (`socket-connect-observer.ts`). Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438)
- **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
- **shared/m3u-utils** - M3U playlist utilities
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/<slug>.svg`)
@@ -817,7 +817,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, source orchestration in `epg-worker.service.ts`, per-import lifecycle in `epg-fetch-operation.ts`, worker bootstrap/shutdown and clear protocol in `epg-worker-runtime.ts`, DB lookups in `epg-query.service.ts`
- `xtream.events.ts` - Xtream Codes API
- `stalker.events.ts` - Stalker portal API
- `connectivity-guard.events.ts` - `CONNECTIVITY_GUARD_RESET`: forgets the connection failures recorded for a portal host. Both portal handlers above run every request through the per-host circuit breaker (rules in `@iptvnator/shared/host-health`, process-wide instance in `util/host-connectivity-guard.ts`; the web backend runs the same breaker over its proxy routes) — after 2 consecutive connection-level failures (no HTTP response; `ETIMEDOUT`/`ENOTFOUND`/`ECONNREFUSED`/… but never `ECONNRESET`) requests to that endpoint fail immediately for 30 s. The key is `URL.origin`, not `URL.host`, which would give `http://panel` and `https://panel` one shared record and let a dead TLS listener fast-fail the working HTTP one instead of hanging the full 30 s/15 s axios timeout again, with one half-open trial request afterwards. Any HTTP response (4xx and 5xx included) clears the record. The refusal is a real `Error` whose wording is a renderer contract (`buildHostConnectivityFastFailMessage` in `libs/shared/interfaces`): it must carry no `HTTP Error <code>`, no timeout wording and none of the auth phrases, or Stalker endpoint discovery misclassifies it and lazy portal repair fires against a host just declared dead. Discovery probes are exempt via the `skipConnectionGuard` payload flag (bypass + no failure counting, but successes still clear the record). Every user-driven retry/refresh that issues portal requests must reset BEFORE its first request, or the affordance fast-fails and looks broken; automatic and first-load paths deliberately do not reset. Current senders: Xtream content-gate Retry, Stalker catalog append retry (`retryContentPage`), Stalker search-page retry, `StalkerItvCacheService.refresh()` (Live TV refresh), both account-info dialogs' Retry, the destructive Xtream refresh (`XtreamRefreshFlowService`, before it deletes the cached catalog — one flow shared by both entry points, `PlaylistRefreshActionService.refreshXtream()` and `RecentPlaylistsComponent.refreshXtreamPlaylist()`, which supply only a progress reporter), `StalkerPortalDiscoveryService.discover()`, and `PortalStatusService` on `skipCache`. Kill switch: `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1`. Contract: `docs/architecture/host-connectivity-guard.md`
- `connectivity-guard.events.ts` - `CONNECTIVITY_GUARD_RESET`: forgets the connection failures recorded for a portal host. Both portal handlers above run every request through the per-host circuit breaker (rules in `@iptvnator/shared/host-health`, process-wide instance in `util/host-connectivity-guard.ts`; the web backend runs the same breaker over its proxy routes) — after 2 consecutive connection-level failures (no HTTP response; `ETIMEDOUT`/`ENOTFOUND`/`ECONNREFUSED`/… but never `ECONNRESET`, and never a timeout after the TCP handshake — both transports report `connected` through an `onConnect` hook, and a panel that accepted the connection and then went silent is slow, not dead: the connection clears the streak the moment it happens through `reportConnected`, never when the timeout settles, never closes an open breaker, and is not credited at all through an environment proxy) requests to that endpoint fail immediately for 30 s. The key is `URL.origin`, not `URL.host`, which would give `http://panel` and `https://panel` one shared record and let a dead TLS listener fast-fail the working HTTP one instead of hanging the full 30 s/15 s axios timeout again, with one half-open trial request afterwards. Any HTTP response (4xx and 5xx included) clears the record. The refusal is a real `Error` whose wording is a renderer contract (`buildHostConnectivityFastFailMessage` in `libs/shared/interfaces`): it must carry no `HTTP Error <code>`, no timeout wording and none of the auth phrases, or Stalker endpoint discovery misclassifies it and lazy portal repair fires against a host just declared dead. Discovery probes are exempt via the `skipConnectionGuard` payload flag (bypass + no failure counting, but successes still clear the record). Every user-driven retry/refresh that issues portal requests must reset BEFORE its first request, or the affordance fast-fails and looks broken; automatic and first-load paths deliberately do not reset. Current senders: Xtream content-gate Retry, Stalker catalog append retry (`retryContentPage`), Stalker search-page retry, `StalkerItvCacheService.refresh()` (Live TV refresh), both account-info dialogs' Retry, the destructive Xtream refresh (`XtreamRefreshFlowService`, before it deletes the cached catalog — one flow shared by both entry points, `PlaylistRefreshActionService.refreshXtream()` and `RecentPlaylistsComponent.refreshXtreamPlaylist()`, which supply only a progress reporter), `StalkerPortalDiscoveryService.discover()`, and `PortalStatusService` on `skipCache`. Kill switch: `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1`. Contract: `docs/architecture/host-connectivity-guard.md`
- `player.events.ts` - External player IPC registration; MPV/VLC lifecycle logic lives in `mpv-session.service.ts`, `vlc-session.service.ts`, and shared `external-player-*` helpers
- `settings.events.ts` - App settings
- `electron.events.ts` - App version, etc.
@@ -5,7 +5,7 @@ import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
* between the frontend and the electron backend.
*/
import axios, { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { ipcMain } from 'electron';
import {
classifyStalkerAuthFailureBody,
@@ -20,12 +20,16 @@ import { rememberStalkerPlaybackContext } from '../services/stalker-playback-con
import { emitPortalDebugEvent } from './portal-debug.events';
import { formatPortalRequestError } from './portal-request-error.util';
import { assertRemoteUrlAllowed } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import {
requestWithValidatedRedirects,
ValidatedAxiosRequestConfig,
} from '../util/validated-axios';
import {
HostConnectivityGuardError,
HostRequestToken,
beginGuardedHostRequest,
observeGuardedHostRequest,
reportGuardedHostConnected,
reportGuardedHostFailure,
reportGuardedHostSuccess,
releaseGuardedHostRequest,
@@ -77,6 +81,9 @@ ipcMain.handle(
let debugRequest: Record<string, unknown> | undefined;
let requestUrlForLog = payload.url;
let guardToken: HostRequestToken | null = null;
// Set by the transport once the portal accepts the TCP connection, so
// a timeout can be told apart from a host that never answered at all.
let socketConnected = false;
const countsTowardsGuard = !payload.skipConnectionGuard;
try {
const { url, macAddress, params, token, serialNumber, requestId } =
@@ -110,7 +117,11 @@ ipcMain.handle(
const requestTimeout = isCreateLink ? 30000 : 15000;
// Configure axios request
const config: AxiosRequestConfig = {
const config: ValidatedAxiosRequestConfig = {
onConnect: () => {
socketConnected = true;
reportGuardedHostConnected(guardToken);
},
method: 'GET',
signal: probeControl.signal,
url: fullUrl,
@@ -226,6 +237,7 @@ ipcMain.handle(
reportGuardedHostFailure(guardToken, error, {
countFailures: countsTowardsGuard,
requestUrl: requestUrlForLog,
connected: socketConnected,
});
console.error(
@@ -42,6 +42,22 @@ jest.mock('./portal-debug.events', () => ({
emitPortalDebugEvent: jest.fn(),
}));
// The transport's connect observer, captured per request so a test can play
// the panel that accepts the connection and then goes quiet.
const mockConnectHooks: Array<() => void> = [];
jest.mock('@iptvnator/shared/host-health', () => {
const actual = jest.requireActual('@iptvnator/shared/host-health');
return {
...actual,
observeAgentSocketConnections: jest.fn(
(agent: unknown, onConnect: () => void) => {
mockConnectHooks.push(onConnect);
return agent;
}
),
};
});
describe('XtreamEvents session cancellation', () => {
let consoleErrorSpy: jest.SpyInstance;
@@ -722,6 +738,64 @@ describe('XtreamEvents host connectivity guard', () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('keeps contacting a panel that accepts the connection and then times out', async () => {
// The reported symptom: a live but overloaded panel. axios raises the
// same code as for an unanswered SYN, but the socket did connect.
mockConnectHooks.length = 0;
axiosMock.mockImplementation(() => {
mockConnectHooks.at(-1)?.();
return Promise.reject(timedOut());
});
await expect(request()).rejects.toMatchObject({
message: 'timeout of 30000ms exceeded',
});
await expect(request()).rejects.toMatchObject({
message: 'timeout of 30000ms exceeded',
});
await expect(request()).rejects.toMatchObject({
message: 'timeout of 30000ms exceeded',
});
expect(mockConnectHooks).toHaveLength(3);
expect(axiosMock).toHaveBeenCalledTimes(3);
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
it('does not let a hung request that connected earlier reopen a host that died meanwhile', async () => {
mockConnectHooks.length = 0;
const hung = createDeferred<never>();
// A: the panel accepts the connection, then never answers.
axiosMock.mockImplementationOnce(() => {
mockConnectHooks.at(-1)?.();
return hung.promise;
});
const first = request().then(
() => undefined,
(error) => error
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(mockConnectHooks).toHaveLength(1);
// B and C: the host is gone, nothing connects.
axiosMock.mockRejectedValueOnce(timedOut());
axiosMock.mockRejectedValueOnce(timedOut());
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toBeDefined();
await expect(request()).rejects.toThrow(
buildHostConnectivityFastFailMessage(SERVER_ENDPOINT)
);
// A's timeout settles last; its connect is older than B's and C's
// failures and must not clear them.
hung.reject(timedOut());
await first;
await expect(request()).rejects.toThrow(
buildHostConnectivityFastFailMessage(SERVER_ENDPOINT)
);
expect(axiosMock).toHaveBeenCalledTimes(3);
});
it('contacts the panel again once the guard is reset', async () => {
axiosMock.mockRejectedValue(timedOut());
await expect(request()).rejects.toBeDefined();
@@ -27,6 +27,7 @@ import {
HostConnectivityGuardError,
HostRequestToken,
beginGuardedHostRequest,
reportGuardedHostConnected,
reportGuardedHostFailure,
reportGuardedHostSuccess,
releaseGuardedHostRequest,
@@ -82,6 +83,9 @@ ipcMain.handle(
payload.requestId
);
let initialResponded = false;
// Set by the transport once the panel accepts the TCP connection, so a
// timeout can be told apart from a host that never answered at all.
let socketConnected = false;
let activeRequestKey: string | null = null;
let requestUrlForLog = payload.url;
let guardToken: HostRequestToken | null = null;
@@ -111,6 +115,10 @@ ipcMain.handle(
onResponse: () => {
initialResponded = true;
},
onConnect: () => {
socketConnected = true;
reportGuardedHostConnected(guardToken);
},
method: 'GET',
url: apiUrl.toString(),
headers: {
@@ -230,6 +238,7 @@ ipcMain.handle(
if (payload.connectionTest) {
reportGuardedHostFailure(guardToken, error, {
requestUrl: requestUrlForLog,
connected: socketConnected,
});
return {
payload: null,
@@ -253,6 +262,7 @@ ipcMain.handle(
reportGuardedHostFailure(guardToken, error, {
requestUrl: requestUrlForLog,
connected: socketConnected,
});
if (!payload.suppressErrorLog) {
@@ -0,0 +1,151 @@
/**
* The guard against a real socket: a panel that accepts the connection and
* then never answers (the "connection keeps dropping" reports) versus a port
* nothing listens on. Real axios, real loopback servers, short timeouts.
*/
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import {
beginGuardedHostRequest,
releaseGuardedHostRequest,
reportGuardedHostConnected,
reportGuardedHostFailure,
resetHostConnectivityGuardForTests,
} from './host-connectivity-guard';
import { requestWithValidatedRedirects } from './validated-axios';
const REQUEST_TIMEOUT_MS = 150;
async function withSilentServer<T>(
run: (origin: string) => Promise<T>
): Promise<T> {
const server: Server = createServer(() => undefined);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
return await run(
`http://127.0.0.1:${(server.address() as AddressInfo).port}`
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
/** One guarded request the way the Xtream handler issues it. */
async function guardedRequest(origin: string): Promise<string | undefined> {
const url = `${origin}/player_api.php?action=get_vod_info`;
let connected = false;
const token = beginGuardedHostRequest(url);
try {
await requestWithValidatedRedirects(
url,
{
method: 'GET',
timeout: REQUEST_TIMEOUT_MS,
onConnect: () => {
connected = true;
reportGuardedHostConnected(token);
},
},
{ allowPrivateNetworks: true }
);
return undefined;
} catch (error) {
reportGuardedHostFailure(token, error, {
requestUrl: url,
connected,
});
return (error as { code?: string }).code;
} finally {
releaseGuardedHostRequest(token);
}
}
describe('host connectivity guard against a real socket', () => {
let consoleWarnSpy: jest.SpyInstance;
beforeEach(() => {
resetHostConnectivityGuardForTests();
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
});
afterEach(() => {
consoleWarnSpy.mockRestore();
resetHostConnectivityGuardForTests();
});
it('keeps admitting a panel that accepts connections but never answers', async () => {
await withSilentServer(async (origin) => {
expect(await guardedRequest(origin)).toBe('ECONNABORTED');
expect(await guardedRequest(origin)).toBe('ECONNABORTED');
// Before the fix these two timeouts opened the breaker and this
// third attempt threw the fast-fail without touching the socket.
expect(() =>
releaseGuardedHostRequest(
beginGuardedHostRequest(`${origin}/player_api.php`)
)
).not.toThrow();
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
});
it('lets an accepted-but-silent request clear a refused one, so refuse/accept/refuse is not a streak', async () => {
// One origin that flaps: nothing listening, then a listener that
// accepts and never answers, then nothing listening again. The
// middle request proved the host alive, so the two refusals around
// it are not two consecutive failures.
const server = createServer(() => undefined);
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const port = (server.address() as AddressInfo).port;
const origin = `http://127.0.0.1:${port}`;
const stop = () =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
});
await stop();
try {
expect(await guardedRequest(origin)).toBe('ECONNREFUSED');
await new Promise<void>((resolve) =>
server.listen(port, '127.0.0.1', resolve)
);
expect(await guardedRequest(origin)).toBe('ECONNABORTED');
await stop();
expect(await guardedRequest(origin)).toBe('ECONNREFUSED');
expect(() =>
releaseGuardedHostRequest(
beginGuardedHostRequest(`${origin}/player_api.php`)
)
).not.toThrow();
expect(consoleWarnSpy).not.toHaveBeenCalled();
// A second refusal in a row is a streak again.
expect(await guardedRequest(origin)).toBe('ECONNREFUSED');
expect(() =>
beginGuardedHostRequest(`${origin}/player_api.php`)
).toThrow(/not responding/);
} finally {
await stop().catch(() => undefined);
}
});
it('still opens for a host that never accepts the connection', async () => {
const origin = await withSilentServer(async (origin) => origin);
expect(await guardedRequest(origin)).toBe('ECONNREFUSED');
expect(await guardedRequest(origin)).toBe('ECONNREFUSED');
expect(() =>
beginGuardedHostRequest(`${origin}/player_api.php`)
).toThrow(/not responding/);
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('is not answering')
);
});
});
@@ -7,6 +7,7 @@
import { HostConnectivityGuardError } from '@iptvnator/shared/host-health';
import {
beginGuardedHostRequest,
reportGuardedHostConnected,
reportGuardedHostFailure,
resetHostConnectivityGuardForTests,
setHostConnectivityGuardEnabled,
@@ -157,6 +158,52 @@ describe('reportGuardedHostFailure', () => {
);
});
it('credits an accepted connection when it happens, not when the hung request times out', () => {
const silentTimeout = () =>
Object.assign(new Error('timeout of 30000ms exceeded'), {
code: 'ECONNABORTED',
config: { url: URL_ON_ENDPOINT },
});
// A connects and hangs. While it waits, the host dies: B and C are
// refused and open the breaker.
const hung = beginGuardedHostRequest(URL_ON_ENDPOINT);
reportGuardedHostConnected(hung);
attempt(ownFailure());
attempt(ownFailure());
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
// A's timeout finally settles. Its connect is older than B's and
// C's failures and must not reopen the host.
reportGuardedHostFailure(hung, silentTimeout(), {
requestUrl: URL_ON_ENDPOINT,
connected: true,
});
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
});
it('lets an accepted connection clear a refusal recorded before it', () => {
attempt(ownFailure());
const slow = beginGuardedHostRequest(URL_ON_ENDPOINT);
reportGuardedHostConnected(slow);
reportGuardedHostFailure(
slow,
Object.assign(new Error('timeout of 30000ms exceeded'), {
code: 'ECONNABORTED',
config: { url: URL_ON_ENDPOINT },
}),
{ requestUrl: URL_ON_ENDPOINT, connected: true }
);
attempt(ownFailure());
// Refuse, accept-but-silent, refuse: not two consecutive failures.
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow();
});
it('counts a failure that names no endpoint at all', () => {
// Not every transport error carries a config; absence must not become
// an excuse to ignore the evidence.
@@ -94,6 +94,21 @@ export function reportGuardedHostSuccess(token: HostRequestToken | null): void {
getHostConnectivityGuard().reportSuccess(token);
}
}
/**
* The endpoint accepted the TCP connection for this request — reachability
* evidence, reported the moment it happens (from the transport's `onConnect`
* hook), never deferred to the request's outcome. A request that connects and
* then hangs for 30 s must not, when it finally times out, clear failures
* that later requests recorded in between: those are newer evidence.
*/
export function reportGuardedHostConnected(
token: HostRequestToken | null
): void {
if (token && currentTokens.has(token)) {
getHostConnectivityGuard().reportConnected(token);
}
}
/**
* Records what a failed request proved about its endpoint.
*
@@ -101,11 +116,22 @@ export function reportGuardedHostSuccess(token: HostRequestToken | null): void {
* discovery): their failures are expected and must not count, but an error that
* still carries an HTTP response proves the origin answered, and dropping that
* is what would let the breaker open in the middle of discovery.
*
* `connected` is the transport's word that the endpoint accepted the TCP
* connection (`ValidatedAxiosRequestConfig.onConnect`). A timeout after that
* is a slow panel, not a dead one, and does not count; the connection itself
* was already credited by `reportGuardedHostConnected` when it happened —
* see `classifyHostRequestFailure`. Redirect attribution is checked first so
* an exempt probe's redirect evidence is not lost behind the exemption.
*/
export function reportGuardedHostFailure(
token: HostRequestToken | null,
error: unknown,
options: { countFailures?: boolean; requestUrl?: string } = {}
options: {
countFailures?: boolean;
requestUrl?: string;
connected?: boolean;
} = {}
): void {
if (!token || !currentTokens.has(token)) {
return;
@@ -113,22 +139,27 @@ export function reportGuardedHostFailure(
const guard = getHostConnectivityGuard();
const countFailures = options.countFailures ?? true;
switch (classifyHostRequestFailure(error)) {
case 'host-level': {
if (!countFailures) {
break;
}
if (
countFailures &&
classifyHostRequestFailure(error) === 'host-level' &&
failedAfterRedirect(error, token, options.requestUrl)
) {
// The guarded endpoint answered with a redirect, so this clears its
// record like any other response rather than merely declining to
// count the downstream failure. The failing hop is not guarded (it
// has no token of its own), so such a chain keeps costing a full
// timeout — a documented gap.
guard.reportSuccess(token);
return;
}
if (failedAfterRedirect(error, token, options.requestUrl)) {
// The guarded endpoint answered with a redirect, so this clears
// its record like any other response rather than merely
// declining to count the downstream failure. The failing hop is
// not guarded (it has no token of its own), so such a chain
// keeps costing a full timeout — a documented gap.
guard.reportSuccess(token);
break;
switch (
classifyHostRequestFailure(error, { connected: options.connected })
) {
case 'host-level': {
if (countFailures) {
guard.reportFailure(token);
}
guard.reportFailure(token);
break;
}
case 'responded':
@@ -0,0 +1,28 @@
import {
applyDefaultAutoSelectFamilyAttemptTimeout,
AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG,
} from '@iptvnator/shared/host-health';
/**
* Raises Node's happy-eyeballs per-attempt connection budget from 250 ms to
* 2500 ms for this isolate. The same fix the web backend applies at startup
* (#1400): behind VPN or slow links a working IPv4 handshake to a dual-stack
* panel routinely needs more than 250 ms, and a host whose IPv6 route is
* dead then fails every attempt — two of those in a row and the connectivity
* guard declares the panel unreachable.
*
* Node keeps the default per isolate, so the main process and each worker
* thread that opens connections (playlist refresh, EPG) must call this
* themselves; a value set in main never reaches a worker.
*/
export function applyElectronNetworkDefaults(
log: (line: string) => void = () => undefined
): number | null {
const attemptTimeout = applyDefaultAutoSelectFamilyAttemptTimeout();
if (attemptTimeout !== null) {
log(
`connection attempt timeout set to ${attemptTimeout} ms for IPv6->IPv4 fallback; pass ${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG} via NODE_OPTIONS to override`
);
}
return attemptTimeout;
}
@@ -17,8 +17,34 @@ jest.mock('axios', () => ({
const axiosMock = axios as unknown as jest.Mock;
/** Every variable axios' proxy resolution reads, in both spellings. */
const PROXY_ENV_NAMES = [
'http_proxy',
'https_proxy',
'all_proxy',
'no_proxy',
].flatMap((name) => [name, name.toUpperCase()]);
describe('requestWithValidatedRedirects', () => {
const publicResolver = async () => ['93.184.216.34'];
let ambientProxyEnv: Record<string, string | undefined>;
// The observer decision reads the process environment; a runner that
// exports a proxy must not change what these cases prove.
beforeEach(() => {
ambientProxyEnv = Object.fromEntries(
PROXY_ENV_NAMES.map((name) => [name, process.env[name]])
);
for (const name of PROXY_ENV_NAMES) delete process.env[name];
});
afterEach(() => {
for (const name of PROXY_ENV_NAMES) {
const value = ambientProxyEnv[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
function createCapturingAgentFactory() {
const lookups: LookupFunction[] = [];
@@ -144,6 +170,127 @@ describe('requestWithValidatedRedirects', () => {
expect(requestConfig.url).toBe('https://epg.example/guide.xml');
});
it('hands the request its own observed agent when onConnect is requested', async () => {
axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: {} });
const onConnect = jest.fn();
await requestWithValidatedRedirects(
'http://panel.example/player_api.php',
{ method: 'GET', onConnect },
// The portal handlers' policy: private targets allowed, no pinning,
// which used to leave the request on the shared global agent.
{ allowPrivateNetworks: true }
);
const requestConfig = axiosMock.mock.calls[0][0];
expect(requestConfig).not.toHaveProperty('onConnect');
expect(requestConfig.httpAgent).toBeInstanceOf(HttpAgent);
// The observer is installed on the instance, never on the prototype
// every other agent shares.
expect(
Object.prototype.hasOwnProperty.call(
requestConfig.httpAgent,
'addRequest'
)
).toBe(true);
expect(
Object.prototype.hasOwnProperty.call(new HttpAgent(), 'addRequest')
).toBe(false);
expect(onConnect).not.toHaveBeenCalled();
});
it('does not observe a socket that would belong to an environment proxy', async () => {
process.env['HTTP_PROXY'] = 'http://proxy.example:3128';
axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: {} });
await requestWithValidatedRedirects(
'http://panel.example/player_api.php',
{ method: 'GET', onConnect: jest.fn() },
{ allowPrivateNetworks: true }
);
// Through a proxy the handshake proves nothing about the portal,
// so the request keeps axios' proxy support and gets no observer.
const requestConfig = axiosMock.mock.calls[0][0];
expect(requestConfig.httpAgent).toBeUndefined();
expect(requestConfig.proxy).not.toBe(false);
});
it('reads the proxy variables the way axios does: an empty lowercase one does not mask the uppercase one', async () => {
process.env['http_proxy'] = '';
process.env['HTTP_PROXY'] = 'http://proxy.example:3128';
axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: {} });
await requestWithValidatedRedirects(
'http://panel.example/player_api.php',
{ method: 'GET', onConnect: jest.fn() },
{ allowPrivateNetworks: true }
);
expect(axiosMock.mock.calls[0][0].httpAgent).toBeUndefined();
});
it('keeps observing a portal that no_proxy exempts from the environment proxy', async () => {
process.env['http_proxy'] = 'http://proxy.example:3128';
process.env['no_proxy'] = 'localhost,panel.example';
axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: {} });
await requestWithValidatedRedirects(
'http://panel.example/player_api.php',
{ method: 'GET', onConnect: jest.fn() },
{ allowPrivateNetworks: true }
);
// axios connects directly here, so the handshake is the portal's own.
const requestConfig = axiosMock.mock.calls[0][0];
expect(requestConfig.httpAgent).toBeInstanceOf(HttpAgent);
expect(
Object.prototype.hasOwnProperty.call(
requestConfig.httpAgent,
'addRequest'
)
).toBe(true);
});
it('still observes a pinned request under an environment proxy, since pinning disables the proxy', async () => {
process.env['https_proxy'] = 'http://proxy.example:3128';
axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: {} });
await requestWithValidatedRedirects(
'https://panel.example/player_api.php',
{ method: 'GET', onConnect: jest.fn() },
{ resolveHostname: publicResolver }
);
const requestConfig = axiosMock.mock.calls[0][0];
expect(requestConfig.proxy).toBe(false);
expect(
Object.prototype.hasOwnProperty.call(
requestConfig.httpsAgent,
'addRequest'
)
).toBe(true);
});
it('observes the agent an explicit factory supplies instead of replacing it', async () => {
axiosMock.mockResolvedValueOnce({ status: 200, headers: {}, data: {} });
const { factory } = createCapturingAgentFactory();
await requestWithValidatedRedirects(
'https://panel.example/player_api.php',
{ agentFactory: factory, method: 'GET', onConnect: jest.fn() },
{ resolveHostname: publicResolver }
);
const requestConfig = axiosMock.mock.calls[0][0];
const created = jest.mocked(factory.createHttpsAgent).mock.results[0]
.value;
expect(requestConfig.httpsAgent).toBe(created);
expect(
Object.prototype.hasOwnProperty.call(created, 'addRequest')
).toBe(true);
});
it('supplies the validated lookup to an explicit HTTP agent factory', async () => {
axiosMock.mockResolvedValueOnce({
status: 200,
@@ -8,6 +8,8 @@ import type { LookupAddress } from 'node:dns';
import { Agent as HttpAgent } from 'node:http';
import { Agent as HttpsAgent } from 'node:https';
import { isIP, LookupFunction } from 'node:net';
import { observeAgentSocketConnections } from '@iptvnator/shared/host-health';
import { getProxyForUrl } from 'proxy-from-env';
import {
RemoteUrlPolicy,
UnsafeUrlError,
@@ -32,6 +34,17 @@ export type ValidatedAxiosRequestConfig = Omit<
> & {
agentFactory?: ValidatedRequestAgentFactory;
onResponse?: () => void;
/**
* Called once a hop's TCP connection is established (or a pooled, already
* connected socket was handed to it). Lets the host connectivity guard
* tell a panel that never accepted the connection from one that accepted
* it and then went silent. Requesting it gives the request its own agent
* instead of the shared keep-alive `globalAgent`, since the observer is
* per request and must never be installed on a shared agent. Not honoured
* while an environment proxy applies to the URL: the socket would be the
* proxy's, and its handshake proves nothing about the portal.
*/
onConnect?: () => void;
};
function copyHeadersWithoutSensitiveValues(
@@ -134,6 +147,53 @@ function pinRequestToValidatedAddresses(
};
}
/**
* Whether axios would route `url` through an environment proxy. The very
* resolution axios' http adapter performs (`proxy-from-env`, the same pinned
* package): `<protocol>_proxy` / `all_proxy` in either case with the same
* lowercase-then-uppercase fallback, and `no_proxy` exemptions honoured — a
* LAN portal listed there connects directly and keeps its observer.
*/
function environmentProxiesUrl(url: URL): boolean {
return getProxyForUrl(url.href) !== '';
}
function observeHopConnections(
config: AxiosRequestConfig,
url: URL,
onConnect: (() => void) | undefined
): AxiosRequestConfig {
if (!onConnect) {
return config;
}
// Unpinned requests keep axios' environment proxy support. Through a
// proxy the observed socket connects to the proxy, not the portal, and a
// proxy that accepts TCP but cannot reach the portal would then pass as
// the portal answering. No observer there: such requests keep reporting
// their timeouts as host-level, exactly as before the hook existed.
if (config.proxy !== false && environmentProxiesUrl(url)) {
return config;
}
if (url.protocol === 'https:') {
return {
...config,
httpsAgent: observeAgentSocketConnections(
config.httpsAgent ?? new HttpsAgent(),
onConnect
),
};
}
return {
...config,
httpAgent: observeAgentSocketConnections(
config.httpAgent ?? new HttpAgent(),
onConnect
),
};
}
function getRedirectValidationPolicy(
currentUrl: string,
initialOrigin: string | undefined,
@@ -168,7 +228,7 @@ function getRedirectValidationPolicy(
*/
export async function requestWithValidatedRedirects<T = unknown>(
rawUrl: string,
{ onResponse, ...config }: ValidatedAxiosRequestConfig = {},
{ onResponse, onConnect, ...config }: ValidatedAxiosRequestConfig = {},
policy: RemoteUrlPolicy = {},
maxRedirects = 5
): Promise<AxiosResponse<T>> {
@@ -198,10 +258,14 @@ export async function requestWithValidatedRedirects<T = unknown>(
validatedUrl.origin === initialOrigin
? initialAddresses
: validatedTarget.addresses;
const pinnedConfig = pinRequestToValidatedAddresses(
requestConfig,
const pinnedConfig = observeHopConnections(
pinRequestToValidatedAddresses(
requestConfig,
validatedUrl,
addresses
),
validatedUrl,
addresses
onConnect
);
const response = await axios<T>({
...pinnedConfig,
@@ -12,6 +12,7 @@ import {
EpgDatabaseSourceClearOperation,
} from './epg-database';
import { openEpgSourceStream } from './epg-source-stream';
import { applyElectronNetworkDefaults } from '../util/network-defaults';
import { StreamingEpgParser } from './epg-streaming-parser';
import { UnsafeUrlError } from '../events/url-safety';
import {
@@ -79,6 +80,9 @@ interface WorkerResponse {
};
}
// Worker threads keep their own Node networking defaults; see the helper.
applyElectronNetworkDefaults();
const loggerLabel = '[EPG Worker]';
// Batch size for database inserts
@@ -21,6 +21,7 @@ import {
isInvalidTlsCertificateError,
} from '../util/security-errors';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import { applyElectronNetworkDefaults } from '../util/network-defaults';
import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source';
import {
armWorkerPerformanceCapture,
@@ -34,6 +35,9 @@ type ActiveRefreshState = {
controller: AbortController;
};
// Worker threads keep their own Node networking defaults; see the helper.
applyElectronNetworkDefaults();
const activeRefreshes = new Map<string, ActiveRefreshState>();
if (!parentPort) {
+8
View File
@@ -31,6 +31,7 @@ import SharedEvents from './app/events/shared.events';
import SquirrelEvents from './app/events/squirrel.events';
import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
import { applyElectronNetworkDefaults } from './app/util/network-defaults';
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
import { AppUpdateService } from './app/services/app-update.service';
import {
@@ -60,6 +61,13 @@ import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service';
app.setName('iptvnator');
// Before the first portal, playlist or update request leaves this process.
applyElectronNetworkDefaults((line) => {
if (isStartupTraceEnabled()) {
console.log(`[IPTVnator Trace][startup] ${line}`);
}
});
// Packaged Linux launchers force X11 via the .desktop entry
// (electron-builder `executableArgs`), but direct binary/AppImage launches
// from a terminal bypass that entry. Embedded MPV supports X11/XWayland
+44 -15
View File
@@ -115,6 +115,21 @@ export function releaseProviderRequest(
}
/** The host answered — whatever the status was, it is reachable. */
/**
* The provider accepted the TCP connection — reachability evidence, reported
* the moment the transport's `onConnect` fires rather than when the request
* settles. A request that connects and then hangs must not, on its eventual
* timeout, clear failures that later requests recorded in between.
*/
export function reportProviderRequestConnected(
guard: HostConnectivityGuard,
token: HostRequestToken | null
): void {
if (token) {
guard.reportConnected(token);
}
}
export function reportProviderRequestSuccess(
guard: HostConnectivityGuard,
token: HostRequestToken | null
@@ -185,7 +200,18 @@ export function reportProviderRequestFailure(
guard: HostConnectivityGuard,
token: HostRequestToken | null,
error: unknown,
options: { countFailures?: boolean; requestUrl?: string } = {}
options: {
countFailures?: boolean;
requestUrl?: string;
/**
* The transport's word that the provider accepted the TCP connection
* (`WebBackendHttpGetOptions.onConnect`). A timeout after that is a
* slow provider, not a dead one, and does not count; the connection
* itself was credited by `reportProviderRequestConnected` when it
* happened — see `classifyHostRequestFailure`.
*/
connected?: boolean;
} = {}
): void {
if (!token) {
return;
@@ -200,21 +226,24 @@ export function reportProviderRequestFailure(
error = error.cause;
}
const countFailures = options.countFailures ?? true;
switch (classifyHostRequestFailure(error)) {
// Redirect attribution is checked BEFORE the exemption and before the
// connected rule, not after. A 3xx from the guarded endpoint is an answer,
// and an exempt probe observing one has to clear the record just as it
// does for any other response — otherwise an ordinary timeout, a probe
// that was redirected to a dead destination, and another ordinary timeout
// still read as two consecutive failures.
if (
!manualChain &&
classifyHostRequestFailure(error) === 'host-level' &&
failedAfterRedirect(error, token, options.requestUrl)
) {
guard.reportSuccess(token);
return;
}
switch (
classifyHostRequestFailure(error, { connected: options.connected })
) {
case 'host-level':
// Redirect attribution is checked BEFORE the exemption, not after.
// A 3xx from the guarded endpoint is an answer, and an exempt probe
// observing one has to clear the record just as it does for any
// other response — otherwise an ordinary timeout, a probe that was
// redirected to a dead destination, and another ordinary timeout
// still read as two consecutive failures.
if (
!manualChain &&
failedAfterRedirect(error, token, options.requestUrl)
) {
guard.reportSuccess(token);
break;
}
if (!countFailures) {
break;
}
@@ -1,3 +1,4 @@
import { observeRequestSocketConnect } from '@iptvnator/shared/host-health';
import axios from 'axios';
import {
request as httpRequest,
@@ -45,8 +46,9 @@ export class ProviderAxiosTransport implements WebBackendHttpClient {
agent.options.servername = isIP(hostname) ? '' : hostname;
agent.options.checkServerIdentity = (_name, certificate) =>
checkServerIdentity(hostname, certificate);
const { onConnect, ...axiosOptions } = options;
const response = await axios.get<T>(root, {
...options,
...axiosOptions,
params: undefined,
transport: {
request: (
@@ -65,6 +67,9 @@ export class ProviderAxiosTransport implements WebBackendHttpClient {
callback
);
retainHeaderTimeout(request, options.timeout);
if (onConnect) {
observeRequestSocketConnect(request, onConnect);
}
return request;
},
},
@@ -233,6 +233,32 @@ describe('real axios validated transport', () => {
});
});
});
it('reports the accepted connection of a provider that then never answers', async () => {
const provider = express().use(() => {
/* Deliberately silent synthetic provider. */
});
await withServer(provider, async (url) => {
const onConnect = jest.fn();
await expect(
new ValidatedHttpClient(lan).get(url, {
timeout: 100,
onConnect,
})
).rejects.toMatchObject({ cause: { code: 'ECONNABORTED' } });
expect(onConnect).toHaveBeenCalledTimes(1);
});
});
it('reports nothing for a provider that refuses the connection', async () => {
// A listener that has just closed: nothing accepts on that port.
const url = await withServer(express(), async (url) => url);
const onConnect = jest.fn();
await expect(
new ValidatedHttpClient(lan).get(url, { timeout: 1_000, onConnect })
).rejects.toMatchObject({ cause: { code: 'ECONNREFUSED' } });
expect(onConnect).not.toHaveBeenCalled();
});
it('does not cap a healthy trickling body at the inactivity timeout', async () => {
const provider = express().use((_req, res) => {
let count = 0;
@@ -17,6 +17,13 @@ export interface WebBackendHttpGetOptions {
readonly responseType?: 'arraybuffer';
readonly timeout?: number;
readonly signal?: AbortSignal;
/**
* Called once a hop's TCP connection is established. The host guard uses
* it to tell a provider that never accepted the connection from one that
* accepted it and then went silent. Honoured by the transport, which owns
* the `ClientRequest` (see `ProviderAxiosTransport`).
*/
readonly onConnect?: () => void;
}
export type ProviderTransportOptions = Omit<
@@ -41,6 +41,90 @@ function createTestGuard(): {
}
describe('web backend host connectivity guard', () => {
it('keeps proxying to a provider that accepts connections but never answers', async () => {
// The same `ECONNABORTED` axios raises for an unanswered SYN, but the
// transport saw the handshake: a slow panel, not a dead one.
const httpClient = new StubHttpClient();
httpClient.queueNetworkError(hostLevelFailure('ECONNABORTED'), {
connected: true,
});
httpClient.queueNetworkError(hostLevelFailure('ECONNABORTED'), {
connected: true,
});
httpClient.queueResponse({ user_info: { username: 'demo' } });
const { guard } = createTestGuard();
await withServer(
createWebBackendApp({
hostGuard: guard,
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://xtream.example'
);
const call = () =>
fetch(
`${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info`
);
await call();
await call();
const third = await call();
expect(httpClient.requests).toHaveLength(3);
expect(await third.json()).toEqual({
action: 'get_account_info',
payload: { user_info: { username: 'demo' } },
});
}
);
});
it('lets an accepted-but-silent request clear a refused one instead of counting between them', async () => {
const httpClient = new StubHttpClient();
httpClient.queueNetworkError(hostLevelFailure('ECONNREFUSED'));
httpClient.queueNetworkError(hostLevelFailure('ECONNABORTED'), {
connected: true,
});
httpClient.queueNetworkError(hostLevelFailure('ECONNREFUSED'));
httpClient.queueResponse({ user_info: { username: 'demo' } });
const { guard } = createTestGuard();
await withServer(
createWebBackendApp({
hostGuard: guard,
httpClient,
resolveHostname: resolvePublicHost,
}),
async (baseUrl) => {
const targetId = await registerProviderTarget(
baseUrl,
'http://xtream.example'
);
const call = () =>
fetch(
`${baseUrl}/xtream?targetId=${targetId}&username=demo&password=secret&action=get_account_info`
);
await call();
await call();
await call();
const fourth = await call();
// Refuse, accept-but-silent, refuse: the middle request
// proved the provider alive, so the fourth still goes out.
expect(httpClient.requests).toHaveLength(4);
expect(await fourth.json()).toEqual({
action: 'get_account_info',
payload: { user_info: { username: 'demo' } },
});
}
);
});
it('fast-fails Xtream requests with HTTP 200 and the provider-error body once the host stops answering', async () => {
const httpClient = new StubHttpClient();
httpClient.queueNetworkError(hostLevelFailure());
@@ -39,6 +39,8 @@ export class StubHttpClient implements WebBackendHttpClient {
private readonly queuedResponses: Array<{
readonly data: unknown;
readonly error?: Error;
/** Report the TCP connection as accepted before failing. */
readonly connected?: boolean;
readonly status?: number;
readonly statusText?: string;
readonly headers?: { location?: string };
@@ -60,8 +62,15 @@ export class StubHttpClient implements WebBackendHttpClient {
this.queuedResponses.push({ data: null, error: new Error(message) });
}
queueNetworkError(error: Error): void {
this.queuedResponses.push({ data: null, error });
queueNetworkError(
error: Error,
options: { connected?: boolean } = {}
): void {
this.queuedResponses.push({
data: null,
error,
connected: options.connected,
});
}
async get<T>(
@@ -81,6 +90,9 @@ export class StubHttpClient implements WebBackendHttpClient {
}
if (response.error) {
if (response.connected) {
options.onConnect?.();
}
// Shaped like a real axios rejection, because the guard reads these
// fields to tell a redirect hop from the endpoint we asked for. A
// bare Error here hid a bug where every ordinary /xtream failure
@@ -20,6 +20,7 @@ import {
observeProviderRequest,
PROVIDER_REQUEST_TIMEOUT_MS,
releaseProviderRequest,
reportProviderRequestConnected,
reportProviderRequestFailure,
reportProviderRequestSuccess,
resetProviderHost,
@@ -259,6 +260,9 @@ export function createWebBackendApp(
// The URL actually requested, so a failure on a redirect hop can be
// told apart from a failure of the endpoint we guarded.
let requestUrl: string | undefined;
// Whether the provider accepted the TCP connection: a timeout after
// that is a slow provider, not a dead one.
let connected = false;
try {
// Before URL validation, not after: that step resolves the hostname
// over DNS, and a dead host is exactly where DNS is slow or failing
@@ -284,6 +288,10 @@ export function createWebBackendApp(
const response = await httpClient.get(requestUrl, {
params: getProxyParams(req, ['targetId', 'connectionTest']),
timeout: PROVIDER_REQUEST_TIMEOUT_MS.xtream,
onConnect: () => {
connected = true;
reportProviderRequestConnected(hostGuard, guardToken);
},
});
reportProviderRequestSuccess(hostGuard, guardToken);
guardToken = null;
@@ -295,6 +303,7 @@ export function createWebBackendApp(
} catch (error) {
reportProviderRequestFailure(hostGuard, guardToken, error, {
requestUrl,
connected,
});
logProviderRequestFailure({ error, route: '/xtream', url });
if (getQueryString(req, 'connectionTest') === 'true') {
@@ -331,6 +340,9 @@ export function createWebBackendApp(
getQueryString(req, 'skipConnectionGuard') !== 'true';
let guardToken: HostRequestToken | null = null;
let requestUrl: string | undefined;
// Whether the portal accepted the TCP connection: a timeout after
// that is a slow portal, not a dead one.
let connected = false;
try {
// `macAddress`, `token` and `serialNumber` are portal credentials,
// not protocol content: they reach the portal only as the same
@@ -400,6 +412,10 @@ export function createWebBackendApp(
params['action'] === 'create_link'
? PROVIDER_REQUEST_TIMEOUT_MS.stalkerCreateLink
: PROVIDER_REQUEST_TIMEOUT_MS.stalker,
onConnect: () => {
connected = true;
reportProviderRequestConnected(hostGuard, guardToken);
},
});
reportProviderRequestSuccess(hostGuard, guardToken);
guardToken = null;
@@ -415,6 +431,7 @@ export function createWebBackendApp(
reportProviderRequestFailure(hostGuard, guardToken, error, {
countFailures: countsTowardsGuard,
requestUrl,
connected,
});
logProviderRequestFailure({ error, route: '/stalker', url });
res.json(normalizeProviderError(error));
+1 -1
View File
@@ -1,7 +1,7 @@
import {
applyDefaultAutoSelectFamilyAttemptTimeout,
AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG,
} from './app/network-family-autoselection';
} from '@iptvnator/shared/host-health';
import { createWebBackendApp } from './app/web-backend-app';
const attemptTimeout = applyDefaultAutoSelectFamilyAttemptTimeout();
+8
View File
@@ -121,9 +121,17 @@ media. Barriers and delays are coordination tools, not timing inputs.
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
| `silent` | `silent` | silent detail endpoints | 2 | 2 | 2 | 5 | active |
Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account).
`silent` imports and browses normally, but `get_vod_info` and
`get_series_info` accept the connection and never answer — the client's own
timeout (30 s in the app) ends each request. It models a live but overloaded
panel, which is what the host connectivity guard must NOT mistake for a dead
host: open two movie details and the guard has to stay closed. A dead host
is reproduced with a non-routable address instead (`http://10.255.255.1:8080`).
`multisrc1` and `multisrc2` deliberately share one faker seed, so both portals
generate an identical catalog. That overlap is what the VOD multi-source E2E
needs — the same movie present in two different playlists.
@@ -6,9 +6,21 @@ import { handleGetVodInfo } from '../handlers/get-vod-info.handler.js';
import { handleGetSeriesInfo } from '../handlers/get-series-info.handler.js';
import { handleGetFullEpg } from '../handlers/get-full-epg.handler.js';
import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js';
import { getScenario } from '../scenarios.js';
export function dispatchAction(req: Request, res: Response): void {
const action = (req.query['action'] as string) ?? '';
const { username = '', password = '' } = req.query as Record<
string,
string
>;
// A silent action holds the connection open and never writes a byte:
// the panel accepted the request and then went quiet. The client's own
// timeout ends it; the server only sees the socket close.
if (getScenario(username, password).silentActions?.includes(action)) {
return;
}
switch (action) {
case '':
@@ -37,6 +37,15 @@ export interface ScenarioConfig {
* gate rejects the public HLS stub every other scenario redirects to).
*/
downloadStreamFixture?: 'slow-series' | 'local-media';
/**
* Optional `player_api.php` actions the portal accepts and then never
* answers: the connection stays open until the client gives up. Models a
* live but overloaded panel (the shape behind "connection keeps
* dropping" reports) so the host connectivity guard can be exercised
* without a real dead host. Everything else answers normally, so the
* source still imports.
*/
silentActions?: readonly string[];
}
/**
@@ -254,6 +263,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
accountStatus: 'Disabled',
expiryDate: '2020-01-01',
},
'silent:silent': {
name: 'silent-details',
description:
'Live panel whose detail endpoints never answer — host guard tests',
seed: 6006,
categoryCount: { live: 2, vod: 2, series: 2 },
itemsPerCategory: 5,
seasonsPerSeries: 1,
episodesPerSeason: 3,
accountStatus: 'Active',
expiryDate: '2099-12-31',
silentActions: ['get_vod_info', 'get_series_info'],
},
};
/** Convert credential pair to a numeric seed for unknown credentials. */
@@ -269,6 +269,31 @@ describe('Xtream mock server factory', () => {
}
});
it('never answers the silent scenario detail actions while the rest of the portal works', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
);
try {
const categories = await fetch(
`${running.origin}/player_api.php?username=silent&password=silent&action=get_vod_categories`
);
expect(categories.status).toBe(200);
expect(await categories.json()).toHaveLength(2);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 300);
await expect(
fetch(
`${running.origin}/player_api.php?username=silent&password=silent&action=get_vod_info&vod_id=1`,
{ signal: controller.signal }
)
).rejects.toMatchObject({ name: 'AbortError' });
clearTimeout(timer);
} finally {
await running.close();
}
});
it('keeps a non-EPG timezone stream empty across repeated short-EPG requests', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
+44 -5
View File
@@ -109,16 +109,55 @@ errs towards contacting the host:
| Trip | 2 consecutive host-level failures within an inclusive 120 s window |
| Open for | 30 s (`OPEN_DURATION_MS`), matching the repo's other cooldowns |
| Half-open | exactly ONE trial request; the rest keep fast-failing until it settles |
| Reset | any HTTP response — 200, 404, even 502 — the host answered |
| Reset | any HTTP response — 200, 404, even 502; an accepted TCP connection clears the streak but not an open breaker |
| Key | `URL.origin` — scheme, host **and** port (see below) |
| Kill switch | `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` (read per call) |
**Host-level failure** means an error with no HTTP response whose code is one of
`ETIMEDOUT`, `ECONNABORTED`, `ENOTFOUND`, `EAI_AGAIN`, `ECONNREFUSED`,
`EHOSTUNREACH`, `ENETUNREACH`. `ECONNRESET` is deliberately excluded: a reset
mid-transfer happens on hosts that are very much alive. Cancelled requests
(`ERR_CANCELED`) and SSRF-policy refusals are `inconclusive` — they say nothing
about reachability and only release the half-open slot.
`EHOSTUNREACH`, `ENETUNREACH` — observed **before the TCP connection was
established**. `ECONNRESET` is deliberately excluded: a reset mid-transfer
happens on hosts that are very much alive. Cancelled requests (`ERR_CANCELED`)
and SSRF-policy refusals are `inconclusive` — they say nothing about
reachability and only release the half-open slot.
**A timeout after the handshake is a slow host, not a dead one.** axios raises
the same `ECONNABORTED` whether the SYN went unanswered or the panel accepted
the connection and then thought for longer than the request budget (a heavy
`get_vod_info` on a busy home server). Only the former is what the guard
exists for; tripping on the latter turned "slow" into thirty seconds of
"portal is not responding" for every request, which is how users described
it. Each transport therefore reports whether the connection was established —
Electron through `ValidatedAxiosRequestConfig.onConnect` (the request gets its
own agent, observed via `observeAgentSocketConnections`, instead of the shared
keep-alive `globalAgent`; a pooled socket that is already connected counts as
connected), the web backend through `WebBackendHttpGetOptions.onConnect`,
honoured by `ProviderAxiosTransport`, which owns the `ClientRequest` — and
the accepted connection is credited THE MOMENT IT HAPPENS
(`reportGuardedHostConnected` / `reportProviderRequestConnected` from the
connect hook call `HostConnectivityGuard.reportConnected`, which clears the
failure streak but deliberately closes no open or half-open breaker: whether a
host that accepts a connection also answers is exactly what the trial exists
to find out, so the trial keeps its slot until it settles), and
`classifyHostRequestFailure(error, { connected })` then reads the eventual
host-level code as `inconclusive`. Two things hang on that
ordering. Clearing at connect time is what keeps an unanswered SYN, an
accepted-but-slow request and another unanswered SYN from adding up to a
trip, since the middle request proved the host alive in between. Crediting it
at connect time rather than when the timeout settles is what keeps a request
that connected and then hung for 30 s from reopening a breaker that later
requests opened in the meantime — by then its evidence is older than theirs.
Such a request still costs its full timeout; the guard only stops charging it
to the host. Electron does not install the observer while an environment
proxy applies to the request — decided by the very resolution axios performs
(`proxy-from-env`, the same pinned package: `<protocol>_proxy` / `all_proxy`
in either case, `no_proxy` exemptions honoured, so a LAN portal listed there
keeps its observer): through a proxy the socket connects to the proxy, whose
handshake proves nothing about the portal, so those requests keep reporting
their timeouts as host-level exactly as before. Regression coverage:
`apps/electron-backend/src/app/util/host-connectivity-guard.slow-host.spec.ts`
(real loopback sockets) and the Xtream mock's `silent` scenario, whose
`get_vod_info` / `get_series_info` accept the connection and never answer.
**A failure is only charged to the endpoint that produced it.** Reaching any
later hop _proves_ the guarded endpoint answered — the first hop is always the
+2
View File
@@ -1 +1,3 @@
export * from './lib/host-connectivity-guard';
export * from './lib/network-family-autoselection';
export * from './lib/socket-connect-observer';
@@ -37,6 +37,36 @@ describe('classifyHostRequestFailure', () => {
}
});
it('reads a timeout after an accepted connection as inconclusive, never host-level', () => {
// axios raises the same code whether the SYN went unanswered or the
// panel accepted the connection and then thought for longer than
// the budget. Only the former is a dead host. The accepted
// connection was reported when it happened; by the time the
// timeout settles it is stale evidence and must not clear failures
// recorded since.
for (const code of ['ECONNABORTED', 'ETIMEDOUT']) {
expect(
classifyHostRequestFailure(timeoutError(code), {
connected: true,
})
).toBe('inconclusive');
expect(
classifyHostRequestFailure(timeoutError(code), {
connected: false,
})
).toBe('host-level');
}
// An HTTP response outranks the flag either way.
expect(
classifyHostRequestFailure(
Object.assign(timeoutError('ECONNABORTED'), {
response: { status: 504 },
}),
{ connected: true }
)
).toBe('responded');
});
it('treats an error carrying an HTTP response as proof the host answered', () => {
// 5xx reaches the handlers as a rejection: validateStatus only
// tolerates < 500. The host still answered.
@@ -208,6 +238,28 @@ describe('HostConnectivityGuard', () => {
});
});
it('lets an accepted connection clear the streak without closing an open breaker', () => {
// One refusal, then a request whose connection is accepted: the
// streak restarts from zero, so the next refusal is not the second.
failRequest();
guard.reportConnected(expectAllowed());
failRequest();
expectAllowed();
expect(opened).toEqual([]);
// A second refusal in a row opens it. The half-open trial connects
// but stays silent: the breaker keeps waiting for the trial to
// settle instead of closing on the handshake alone.
failRequest();
expect(opened).toEqual([HOST]);
advance(OPEN_DURATION_MS + 1);
const trial = expectAllowed();
guard.reportConnected(trial);
expectBlocked();
guard.reportInconclusive(trial);
expectAllowed();
});
it('allows requests to a host it has never seen', () => {
expect(guard.check(HOST).allowed).toBe(true);
expect(opened).toEqual([]);
@@ -115,6 +115,13 @@ export type HostConnectivityCheck =
| { readonly allowed: true; readonly token: HostRequestToken }
| { readonly allowed: false; readonly retryAfterMs: number };
/**
* `responded`: the endpoint answered with an HTTP response of any status.
* `host-level`: the host never answered. `inconclusive`: the failure says
* nothing about reachability — including a timeout after an accepted
* connection, whose evidence the owner already reported at connect time (see
* {@link classifyHostRequestFailure}).
*/
export type HostRequestOutcome = 'responded' | 'host-level' | 'inconclusive';
interface HostState {
@@ -154,7 +161,40 @@ export function isHostConnectivityGuardDisabled(): boolean {
* that says nothing about reachability: a cancelled request, a URL rejected by
* the SSRF policy, a bug in our own code.
*/
export function classifyHostRequestFailure(error: unknown): HostRequestOutcome {
export interface HostRequestFailureContext {
/**
* Whether the transport saw the TCP connection for this request get
* established (or handed the request a socket that already was).
*/
readonly connected?: boolean;
}
/**
* Reads what a failed request proves about its endpoint.
*
* `context.connected` matters for the timeout codes: axios raises the same
* `ECONNABORTED` whether the SYN went unanswered or the panel accepted the
* connection and then thought for longer than the request budget. Only the
* former is a host that stopped answering. A panel that is merely slow (a
* heavy `get_vod_info` on a busy home server) accepts every connection, and
* tripping the breaker on it turns "slow" into thirty seconds of "not
* responding" for every request — the reported symptom. So a host-level
* code observed after the handshake is `inconclusive`: it must not count.
*
* It does not clear the streak here either, and that is deliberate. The
* accepted connection IS reachability evidence and does clear the streak —
* but the owner reports it the moment the socket connects
* (`reportConnected` from the transport's connect hook), not when the
* timeout settles up to 30 s later. Ordering matters: while A sits connected and
* silent, B and C can fail to connect and open the breaker for a host that
* has just died; clearing at A's settle time would reopen it on evidence
* older than B's and C's failures. The remaining codes cannot occur once a
* connection exists, so the rule costs nothing for them.
*/
export function classifyHostRequestFailure(
error: unknown,
context: HostRequestFailureContext = {}
): HostRequestOutcome {
if (!error || typeof error !== 'object') {
return 'inconclusive';
}
@@ -165,7 +205,7 @@ export function classifyHostRequestFailure(error: unknown): HostRequestOutcome {
const code = (error as { code?: unknown }).code;
if (typeof code === 'string' && HOST_LEVEL_FAILURE_CODES.has(code)) {
return 'host-level';
return context.connected ? 'inconclusive' : 'host-level';
}
return 'inconclusive';
@@ -279,6 +319,28 @@ export class HostConnectivityGuard {
state.lastTouchedAt = this.now();
}
/**
* The host accepted this request's TCP connection. Reported the moment it
* happens, so its place in the order of evidence is exact: it clears the
* failure streak recorded up to now, and later failures start a new one.
*
* It deliberately does NOT close an open or half-open breaker. Whether a
* host that has just accepted a connection also answers is exactly what
* the half-open trial exists to find out, so the trial keeps its slot
* until it settles, and a request admitted before the breaker opened does
* not reopen the host on the strength of a handshake alone.
*/
reportConnected(token: HostRequestToken): void {
const state = this.states.get(token.endpoint);
if (!state || isHostConnectivityGuardDisabled()) {
return;
}
state.consecutiveFailures = 0;
state.lastFailureAt = 0;
state.lastTouchedAt = this.now();
}
/** The host did not answer at all. */
reportFailure(token: HostRequestToken): void {
const state = this.states.get(token.endpoint);
@@ -0,0 +1,100 @@
import { createServer, Server, Agent, request as httpRequest } from 'node:http';
import { AddressInfo } from 'node:net';
import { observeAgentSocketConnections } from './socket-connect-observer';
async function withSilentServer<T>(
run: (port: number, server: Server) => Promise<T>
): Promise<T> {
// Accepts every connection and never writes a byte: a live but silent host.
const server = createServer(() => undefined);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
return await run((server.address() as AddressInfo).port, server);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
function requestOnce(
agent: Agent,
port: number,
timeoutMs: number
): Promise<{ error?: NodeJS.ErrnoException }> {
return new Promise((resolve) => {
const request = httpRequest(
{ host: '127.0.0.1', port, path: '/', agent, timeout: timeoutMs },
() => resolve({})
);
request.on('timeout', () =>
request.destroy(
Object.assign(new Error('timeout'), { code: 'ECONNABORTED' })
)
);
request.on('error', (error: NodeJS.ErrnoException) =>
resolve({ error })
);
request.end();
});
}
describe('observeAgentSocketConnections', () => {
it('reports the connect of a fresh connection even when the host then never answers', async () => {
await withSilentServer(async (port) => {
const onConnect = jest.fn();
const agent = observeAgentSocketConnections(new Agent(), onConnect);
const { error } = await requestOnce(agent, port, 100);
expect(error?.code).toBe('ECONNABORTED');
expect(onConnect).toHaveBeenCalledTimes(1);
});
});
it('stays silent when the host refuses the connection', async () => {
// A listener that has just closed: nothing is accepting on that port.
const port = await withSilentServer(async (port) => port);
const onConnect = jest.fn();
const agent = observeAgentSocketConnections(new Agent(), onConnect);
const { error } = await requestOnce(agent, port, 1_000);
expect(error?.code).toBe('ECONNREFUSED');
expect(onConnect).not.toHaveBeenCalled();
});
it('reports a pooled keep-alive socket as connected right away', async () => {
const server = createServer((_request, response) => response.end('ok'));
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const onConnect = jest.fn();
const agent = observeAgentSocketConnections(
new Agent({ keepAlive: true }),
onConnect
);
const fetchOk = (port: number) =>
new Promise<void>((resolve) => {
httpRequest({ host: '127.0.0.1', port, agent }, (response) =>
response.resume().on('end', resolve)
).end();
});
try {
const port = (server.address() as AddressInfo).port;
await fetchOk(port);
expect(onConnect).toHaveBeenCalledTimes(1);
// Same agent, same idle socket: no `connect` event will ever fire
// again for it, yet the host demonstrably accepted it.
expect(
Object.values(agent.freeSockets).flat()
).toHaveLength(1);
await fetchOk(port);
expect(onConnect).toHaveBeenCalledTimes(2);
} finally {
agent.destroy();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});
@@ -0,0 +1,59 @@
import type { ClientRequest } from 'node:http';
import type { Socket } from 'node:net';
/**
* The one method every Node HTTP/HTTPS agent funnels a request through, pooled
* socket or fresh connection alike. `@types/node` leaves it undeclared, so the
* shape is spelled out here rather than reached through `any`.
*/
interface RequestAcceptingAgent {
addRequest(request: ClientRequest, ...rest: unknown[]): void;
}
/**
* Reports the moment the TCP connection carrying `request` is known to be
* established. A keep-alive agent can hand the request a socket whose
* `connect` event fired long ago, so a socket that is no longer connecting
* counts right away: the host DID accept a connection, which is the fact
* wanted. A socket that never connects (refused, unresolvable, unanswered
* SYN) never reports.
*/
export function observeRequestSocketConnect(
request: ClientRequest,
onConnect: () => void
): void {
request.once('socket', (socket: Socket) => {
if (socket.connecting) {
socket.once('connect', onConnect);
} else {
onConnect();
}
});
}
/**
* {@link observeRequestSocketConnect} for a transport that never hands its
* `ClientRequest` back (axios' default adapter): hooking the agent's
* `addRequest` sees every request the agent takes, pooled or fresh.
*
* Mutates and returns the same agent so it can be passed straight into an
* axios config. Never install this on a shared agent such as
* `http.globalAgent`: the callback belongs to one request, and a second
* install on the same agent stacks on the first rather than replacing it.
*/
export function observeAgentSocketConnections<TAgent extends object>(
agent: TAgent,
onConnect: () => void
): TAgent {
const target = agent as unknown as RequestAcceptingAgent;
const original = target.addRequest;
target.addRequest = function observedAddRequest(
this: RequestAcceptingAgent,
request: ClientRequest,
...rest: unknown[]
): void {
observeRequestSocketConnect(request, onConnect);
return original.call(this, request, ...rest);
};
return agent;
}
+4 -2
View File
@@ -127,6 +127,7 @@
"ms": "2.1.3",
"ngx-indexed-db": "22.0.0",
"ngx-skeleton-loader": "11.3.0",
"proxy-from-env": "2.1.0",
"rxjs": "7.8.2",
"saxes": "6.0.0",
"shaka-player": "5.2.4",
@@ -157,6 +158,7 @@
"@angular/service-worker": "22.1.6",
"@astrojs/mdx": "7.0.7",
"@astrojs/sitemap": "3.7.4",
"@babel/core": "7.29.7",
"@electron/asar": "3.4.1",
"@eslint/eslintrc": "3.3.7",
"@eslint/js": "^9.38.0",
@@ -192,6 +194,7 @@
"@types/jest": "^30.0.0",
"@types/mocha": "9.0.0",
"@types/node": "20.19.9",
"@types/proxy-from-env": "1.0.4",
"@types/video.js": "7.3.58",
"@typescript-eslint/eslint-plugin": "^8.69.0",
"@typescript-eslint/parser": "^8.69.0",
@@ -238,8 +241,7 @@
"typescript": "6.0.3",
"typescript-eslint": "^8.69.0",
"yaml": "2.9.0",
"zod": "4.5.4",
"@babel/core": "7.29.7"
"zod": "4.5.4"
},
"pnpm": {
"overrides": {
+13
View File
@@ -167,6 +167,9 @@ importers:
ngx-skeleton-loader:
specifier: 11.3.0
version: 11.3.0(@angular/common@22.1.6(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2))(@angular/core@22.1.6(@angular/compiler@22.1.6)(rxjs@7.8.2)(zone.js@0.16.3))
proxy-from-env:
specifier: 2.1.0
version: 2.1.0
rxjs:
specifier: 7.8.2
version: 7.8.2
@@ -339,6 +342,9 @@ importers:
'@types/node':
specifier: 20.19.9
version: 20.19.9
'@types/proxy-from-env':
specifier: 1.0.4
version: 1.0.4
'@types/video.js':
specifier: 7.3.58
version: 7.3.58
@@ -4285,6 +4291,9 @@ packages:
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
'@types/proxy-from-env@1.0.4':
resolution: {integrity: sha512-TPR9/bCZAr3V1eHN4G3LD3OLicdJjqX1QRXWuNcCYgE66f/K8jO2ZRtHxI2D9MbnuUP6+qiKSS8eUHp6TFHGCw==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -13715,6 +13724,10 @@ snapshots:
'@types/parse-json@4.0.2': {}
'@types/proxy-from-env@1.0.4':
dependencies:
'@types/node': 20.19.9
'@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {}