/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {errors, request} from 'undici'; import {FLUXER_USER_AGENT} from '~/Constants'; import type {ErrorType} from '~/lib/MediaTypes'; import * as metrics from '~/lib/MetricsClient'; type RequestResult = Awaited>; type ResponseStream = RequestResult['body']; interface RequestOptions { url: string; method?: 'GET' | 'POST' | 'HEAD'; headers?: Record; body?: unknown; signal?: AbortSignal; timeout?: number; } interface StreamResponse { stream: ResponseStream; headers: Headers; status: number; url: string; } interface RedirectResult { body: ResponseStream; headers: Record>; statusCode: number; finalUrl: string; } class HttpError extends Error { constructor( message: string, public readonly status?: number, public readonly response?: Response, public readonly isExpected = false, public readonly errorType?: ErrorType, ) { super(message); this.name = 'HttpError'; } } // biome-ignore lint/complexity/noStaticOnlyClass: this is fine class HttpClient { private static readonly DEFAULT_TIMEOUT = 30_000; private static readonly MAX_REDIRECTS = 5; private static readonly DEFAULT_HEADERS = { Accept: '*/*', 'User-Agent': FLUXER_USER_AGENT, 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache', }; private static getHeadersForUrl(_url: string, customHeaders?: Record): Record { return {...HttpClient.DEFAULT_HEADERS, ...customHeaders}; } private static createCombinedController(...signals: Array): AbortController { const controller = new AbortController(); for (const signal of signals) { if (signal.aborted) { controller.abort(signal.reason); break; } signal.addEventListener('abort', () => controller.abort(signal.reason), {once: true}); } return controller; } private static createTimeoutController(timeout: number): AbortController { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort('Request timed out'), timeout); controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), {once: true}); return controller; } private static normalizeHeaders(headers: Record | undefined>): Headers { const result = new Headers(); for (const [key, value] of Object.entries(headers)) { if (Array.isArray(value)) { for (const entry of value) { result.append(key, entry); } continue; } if (value) { result.set(key, value); } } return result; } private static async handleRedirect( statusCode: number, headers: Record>, currentUrl: string, options: RequestOptions, signal: AbortSignal, redirectCount = 0, ): Promise { if (redirectCount >= HttpClient.MAX_REDIRECTS) { throw new HttpError(`Maximum number of redirects (${HttpClient.MAX_REDIRECTS}) exceeded`); } if (![301, 302, 303, 307, 308].includes(statusCode)) { throw new HttpError(`Expected redirect status but got ${statusCode}`); } const location = headers.location; if (!location) { throw new HttpError('Received redirect response without Location header', statusCode); } const redirectUrl = new URL(Array.isArray(location) ? location[0] : location, currentUrl).toString(); const requestHeaders = HttpClient.getHeadersForUrl(redirectUrl, options.headers); const redirectMethod = statusCode === 303 ? 'GET' : (options.method ?? 'GET'); const redirectBody = statusCode === 303 ? undefined : options.body; const { statusCode: newStatusCode, headers: newHeaders, body, } = await request(redirectUrl, { method: redirectMethod, headers: requestHeaders, body: redirectBody ? JSON.stringify(redirectBody) : undefined, signal, }); if ([301, 302, 303, 307, 308].includes(newStatusCode)) { return HttpClient.handleRedirect( newStatusCode, newHeaders as Record>, redirectUrl, options, signal, redirectCount + 1, ); } return { body, headers: newHeaders as Record>, statusCode: newStatusCode, finalUrl: redirectUrl, }; } public static async sendRequest(options: RequestOptions): Promise { const timeoutController = HttpClient.createTimeoutController(options.timeout ?? HttpClient.DEFAULT_TIMEOUT); const combinedController = options.signal ? HttpClient.createCombinedController(options.signal, timeoutController.signal) : timeoutController; const headers = HttpClient.getHeadersForUrl(options.url, options.headers); try { const { statusCode, headers: responseHeaders, body, } = await request(options.url, { method: options.method ?? 'GET', headers, body: options.body ? JSON.stringify(options.body) : undefined, signal: combinedController.signal, }); let finalBody = body; let finalHeaders: Record | undefined> = responseHeaders; let finalStatusCode = statusCode; let finalUrl = options.url; if (statusCode === 304) { return { stream: body, headers: HttpClient.normalizeHeaders(responseHeaders), status: statusCode, url: options.url, }; } if ([301, 302, 303, 307, 308].includes(statusCode)) { const redirectResult = await HttpClient.handleRedirect( statusCode, responseHeaders as Record>, options.url, options, combinedController.signal, ); finalBody = redirectResult.body; finalHeaders = redirectResult.headers; finalStatusCode = redirectResult.statusCode; finalUrl = redirectResult.finalUrl; } return { stream: finalBody, headers: HttpClient.normalizeHeaders(finalHeaders), status: finalStatusCode, url: finalUrl, }; } catch (error) { if (error instanceof HttpError) { throw error; } if (error instanceof errors.RequestAbortedError) { metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'aborted'}}); throw new HttpError('Request aborted', undefined, undefined, true, 'other'); } if (error instanceof errors.BodyTimeoutError) { metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'body_timeout'}}); throw new HttpError('Request timed out', undefined, undefined, true, 'timeout'); } if (error instanceof errors.ConnectTimeoutError) { metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'connect_timeout'}}); throw new HttpError('Connection timeout', undefined, undefined, true, 'timeout'); } if (error instanceof errors.SocketError) { metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'socket_error'}}); throw new HttpError(`Socket error: ${error.message}`, undefined, undefined, true, 'upstream_5xx'); } const errorMessage = error instanceof Error ? error.message : 'Request failed'; const isNetworkError = error instanceof Error && (error.message.includes('ENOTFOUND') || error.message.includes('ECONNREFUSED') || error.message.includes('ECONNRESET') || error.message.includes('ETIMEDOUT') || error.message.includes('EAI_AGAIN') || error.message.includes('EHOSTUNREACH') || error.message.includes('ENETUNREACH')); if (isNetworkError) { metrics.counter({name: 'media_proxy.external.error', dimensions: {error_type: 'network'}}); } throw new HttpError( errorMessage, undefined, undefined, isNetworkError, isNetworkError ? 'upstream_5xx' : 'other', ); } } public static async streamToString(stream: ResponseStream): Promise { const chunks: Array = []; for await (const chunk of stream) { chunks.push(new Uint8Array(Buffer.from(chunk))); } return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf-8'); } } export const {sendRequest} = HttpClient;