/* * 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 Config from '@app/Config'; import {ExponentialBackoff} from '@app/lib/ExponentialBackoff'; import {HttpError, type HttpError as HttpErrorType} from '@app/lib/HttpError'; import type {HttpMethod} from '@app/lib/HttpTypes'; import {Logger} from '@app/lib/Logger'; import relayClient from '@app/lib/RelayClient'; import type {ResponseInterceptor} from '@app/types/BrandedTypes'; import type {SudoVerificationPayload} from '@app/types/Sudo'; import {getApiErrorCode, getResponseMessage, getResponseRetryAfter} from '@app/utils/ApiErrorUtils'; import {DEFAULT_API_VERSION} from '@fluxer/constants/src/AppConstants'; const SUDO_MODE_HEADER = 'x-fluxer-sudo-mode-jwt'; interface Attachment { name: string; file: File | Blob; filename: string; } interface FormField { name: string; value: string; } export interface HttpRequestConfig { url: string; method?: HttpMethod; query?: Record | URLSearchParams; body?: unknown; headers?: Record; retries?: number; timeout?: number; signal?: AbortSignal; skipAuth?: boolean; skipParsing?: boolean; binary?: boolean; reason?: string; attachments?: Array; fields?: Array; rejectWithError?: boolean; failImmediatelyWhenRateLimited?: boolean; interceptResponse?: InterceptorFn; onRateLimit?: (retryAfter: number, retry: () => void) => void; onRequestCreated?: (state: RequestState) => void; onRequestProgress?: (progress: ProgressEvent) => void; sudoRetry?: boolean; sudoApplied?: boolean; } type BodyWithoutBodyKey = [T] extends [object] ? ('body' extends keyof T ? never : T) : T; type HttpRequestBody = BodyWithoutBodyKey; export interface HttpResponse { ok: boolean; status: number; statusText?: string; headers: Record; body: T; text?: string; hasErr?: boolean; err?: Error; } interface RequestState { abortController?: AbortController; request?: XMLHttpRequest; abort?: () => void; } interface RateLimitEntry { queue: Array<() => void>; retryAfterTimestamp: number; latestErrorMessage: string; timeoutId: number; } type InterceptorFn = ( response: HttpResponse, retryWithHeaders: ( headers: Record, overrideInterceptor?: ResponseInterceptor, ) => Promise, reject: (error: Error) => void, ) => boolean | Promise | undefined; type PrepareRequestInterceptor = (state: RequestState) => void; type SudoHandler = (config: HttpRequestConfig) => Promise; type SudoTokenProvider = () => string | null; type SudoTokenListener = (token: string | null) => void; type SudoFailureHandler = (error: HttpErrorType | HttpResponse | string | unknown) => void; type AuthTokenProvider = () => string | null; const RETRYABLE_STATUS_CODES = new Set([502, 504, 507, 598, 599, 522, 523, 524]); export class HttpClient { private readonly log = new Logger('HttpClient'); private baseUrl: string; private apiVersion: number; private defaultTimeoutMs = 30000; private defaultRetryCount = 0; private readonly rateLimitMap = new Map(); private prepareRequestHandler?: PrepareRequestInterceptor; private responseInterceptor?: ResponseInterceptor; private sudoHandler?: SudoHandler; private sudoTokenProvider?: SudoTokenProvider; private sudoTokenListener?: SudoTokenListener; private sudoTokenInvalidator?: () => void; private sudoFailureHandler?: SudoFailureHandler; private authTokenProvider?: AuthTokenProvider; private relayDirectoryUrl: string | null = null; private targetInstanceDomain: string | null = null; constructor() { this.baseUrl = Config.PUBLIC_BOOTSTRAP_API_ENDPOINT; this.apiVersion = DEFAULT_API_VERSION; this.request = this.request.bind(this); this.get = this.get.bind(this); this.post = this.post.bind(this); this.put = this.put.bind(this); this.patch = this.patch.bind(this); this.delete = this.delete.bind(this); } setInterceptors(params: {prepareRequest?: PrepareRequestInterceptor; interceptResponse?: ResponseInterceptor}): void { this.prepareRequestHandler = params.prepareRequest; this.responseInterceptor = params.interceptResponse; } setBaseUrl(baseUrl: string, apiVersion?: number): void { this.baseUrl = baseUrl; if (typeof apiVersion === 'number') { this.apiVersion = apiVersion; } } setSudoHandler(handler?: SudoHandler): void { this.sudoHandler = handler; } setSudoTokenProvider(provider?: SudoTokenProvider): void { this.sudoTokenProvider = provider; } setSudoTokenListener(listener?: SudoTokenListener): void { this.sudoTokenListener = listener; } setSudoTokenInvalidator(invalidator?: () => void): void { this.sudoTokenInvalidator = invalidator; } setSudoFailureHandler(handler?: SudoFailureHandler): void { this.sudoFailureHandler = handler; } setAuthTokenProvider(provider?: AuthTokenProvider): void { this.authTokenProvider = provider; } setDefaults(options: {timeout?: number; retries?: number} = {}): void { if (typeof options.timeout === 'number') { this.defaultTimeoutMs = options.timeout; } if (typeof options.retries === 'number') { this.defaultRetryCount = options.retries; } } setRelayDirectoryUrl(directoryUrl: string | null): void { this.relayDirectoryUrl = directoryUrl; if (directoryUrl) { relayClient.setRelayDirectoryUrl(directoryUrl); this.log.info('Relay mode enabled, directory:', directoryUrl); } else { this.log.info('Relay mode disabled'); } } setTargetInstanceDomain(domain: string | null): void { this.targetInstanceDomain = domain; this.log.debug('Target instance domain set:', domain); } isRelayModeEnabled(): boolean { return this.relayDirectoryUrl != null && this.targetInstanceDomain != null; } async request(method: HttpMethod, urlOrConfig: string | HttpRequestConfig): Promise> { const config: HttpRequestConfig = typeof urlOrConfig === 'string' ? {url: urlOrConfig} : urlOrConfig; return this.executeRequest(method, config); } async get(urlOrConfig: string | HttpRequestConfig): Promise> { return this.request('GET', urlOrConfig); } async post(urlOrConfig: string | HttpRequestConfig, data?: HttpRequestBody): Promise> { return this.request('POST', this.normalizeConfig(urlOrConfig, data)); } async put(urlOrConfig: string | HttpRequestConfig, data?: HttpRequestBody): Promise> { return this.request('PUT', this.normalizeConfig(urlOrConfig, data)); } async patch(urlOrConfig: string | HttpRequestConfig, data?: HttpRequestBody): Promise> { return this.request('PATCH', this.normalizeConfig(urlOrConfig, data)); } async delete(urlOrConfig: string | HttpRequestConfig): Promise> { return this.request('DELETE', urlOrConfig); } private normalizeConfig(urlOrConfig: string | HttpRequestConfig, body?: HttpRequestBody): HttpRequestConfig { if (typeof urlOrConfig === 'string') { return {url: urlOrConfig, body}; } return {...urlOrConfig, body: body ?? urlOrConfig.body}; } private applyQueryParams(url: URL, query: Record | URLSearchParams): void { if (query instanceof URLSearchParams) { for (const [key, value] of query.entries()) { url.searchParams.set(key, value); } return; } for (const [key, value] of Object.entries(query)) { if (value == null) { continue; } url.searchParams.set(key, String(value)); } } private resolveRequestUrl( path: string, query?: Record | URLSearchParams, ): string { const requestUrl = path.startsWith('//') || path.includes('://') ? new URL(path, window.location.origin) : new URL(`${this.baseUrl}/v${this.apiVersion}${path}`, window.location.origin); if (!query) { return requestUrl.toString(); } this.applyQueryParams(requestUrl, query); return requestUrl.toString(); } private buildRequestHeaders(config: HttpRequestConfig, retryCount: number): Record { const headers: Record = {...(config.headers ?? {})}; if (!config.skipAuth && !config.url.includes('://')) { const authToken = this.authTokenProvider?.(); if (authToken) { headers.Authorization = authToken; } } if (config.reason) { headers['X-Audit-Log-Reason'] = encodeURIComponent(config.reason); } if (retryCount > 0) { headers['X-Failed-Requests'] = String(retryCount); } if (config.body && !headers['Content-Type'] && !(config.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; } const sudoToken = this.sudoTokenProvider?.(); if (sudoToken) { headers[SUDO_MODE_HEADER] = sudoToken; } return headers; } private buildFormData(config: HttpRequestConfig): FormData | null { if (!config.attachments && !config.fields) { return null; } const form = new FormData(); for (const attachment of config.attachments ?? []) { form.append(attachment.name, attachment.file, attachment.filename); } for (const field of config.fields ?? []) { form.append(field.name, field.value); } return form; } private serializeBody(config: HttpRequestConfig): string | FormData | Blob | ArrayBuffer | undefined { const form = this.buildFormData(config); if (form) return form; const {body} = config; if (!body) return; if (typeof body === 'string' || body instanceof Blob || body instanceof ArrayBuffer || body instanceof FormData) { return body; } return JSON.stringify(body); } private parseXHRResponse(xhr: XMLHttpRequest, config: HttpRequestConfig): {body: T; text?: string} { if (config.skipParsing) { return {body: undefined as T}; } if (xhr.status === 204) { return {body: undefined as T}; } if (config.binary) { return {body: xhr.response as T}; } const contentType = xhr.getResponseHeader('content-type') || ''; const text = xhr.responseText; if (contentType.includes('application/json')) { if (!text) { return {body: undefined as T}; } try { return {body: JSON.parse(text) as T, text}; } catch { return {body: text as T, text}; } } return {body: text as T, text}; } private parseXHRHeaders(xhr: XMLHttpRequest): Record { const headerMap: Record = {}; const raw = xhr.getAllResponseHeaders(); if (!raw) return headerMap; for (const line of raw.trim().split(/[\r\n]+/)) { const parts = line.split(': '); const name = parts.shift(); const value = parts.join(': '); if (name) { headerMap[name.toLowerCase()] = value; } } return headerMap; } private parseRetryAfterSeconds(body: unknown): number { const retryAfter = getResponseRetryAfter(body); if (retryAfter !== undefined && Number.isFinite(retryAfter)) { return retryAfter; } return 5; } private parseRateLimitMessage(body: unknown): string { const message = getResponseMessage(body); return message ?? ''; } private updateRateLimitEntry(urlKey: string, response?: HttpResponse): void { const existing = this.rateLimitMap.get(urlKey); if (response?.status === 429) { const retryAfter = this.parseRetryAfterSeconds(response.body); const deadline = Date.now() + retryAfter * 1000; if (existing && existing.retryAfterTimestamp >= deadline) { this.log.debug('Rate limit already present for', urlKey); return; } if (existing) { this.log.debug('Extending rate limit for', urlKey); clearTimeout(existing.timeoutId); } this.log.debug(`Rate limit for ${urlKey}, retry in ${retryAfter}s`); const timeoutId = window.setTimeout(() => this.releaseRateLimitedQueue(urlKey), retryAfter * 1000); this.rateLimitMap.set(urlKey, { queue: existing?.queue ?? [], retryAfterTimestamp: deadline, latestErrorMessage: this.parseRateLimitMessage(response.body), timeoutId, }); } else if (existing && existing.retryAfterTimestamp < Date.now()) { this.log.debug('Rate limit expired for', urlKey); this.releaseRateLimitedQueue(urlKey); } } private releaseRateLimitedQueue(urlKey: string): void { const entry = this.rateLimitMap.get(urlKey); if (!entry) { this.log.debug('Rate limit expired for', urlKey, 'but entry was already removed'); return; } clearTimeout(entry.timeoutId); this.rateLimitMap.delete(urlKey); if (!entry.queue.length) { this.log.debug('Clearing rate-limit state for', urlKey, '(no queued jobs)'); return; } const queued = entry.queue.splice(0); this.log.debug('Releasing', queued.length, 'queued requests for', urlKey); for (const fn of queued) { try { fn(); } catch (error) { this.log.error('Error while executing queued rate-limited request for', urlKey, error); } } } private shouldRetryStatus( status: number | undefined, retryCount: number | undefined, maxRetries: number | undefined, ): boolean { if (retryCount === undefined || maxRetries === undefined) return false; if (retryCount >= maxRetries) return false; return status !== undefined && RETRYABLE_STATUS_CODES.has(status); } private ensureBackoff(backoff?: ExponentialBackoff): ExponentialBackoff { if (backoff) return backoff; return new ExponentialBackoff({ minDelay: 1000, maxDelay: 30000, jitter: true, }); } private async executeRequest( method: HttpMethod, config: HttpRequestConfig, retryCount = 0, backoff?: ExponentialBackoff, ): Promise> { const effectiveConfig: HttpRequestConfig = { ...config, method, timeout: config.timeout !== undefined ? config.timeout : this.defaultTimeoutMs, retries: config.retries !== undefined ? config.retries : this.defaultRetryCount, }; const rateLimit = this.rateLimitMap.get(effectiveConfig.url); if (rateLimit) { if (effectiveConfig.failImmediatelyWhenRateLimited) { const secondsRemaining = Math.max(0, Math.round((rateLimit.retryAfterTimestamp - Date.now()) / 1000)); return { ok: false, status: 429, headers: {}, body: { message: rateLimit.latestErrorMessage, retry_after: secondsRemaining, } as T, text: '', }; } this.log.debug('Queueing rate-limited request for', effectiveConfig.url); return new Promise>((resolve, reject) => { rateLimit.queue.push(() => { this.executeRequest(method, effectiveConfig, retryCount, backoff).then(resolve, reject); }); }); } if (this.shouldUseRelay(effectiveConfig)) { return this.executeViaRelay(method, effectiveConfig); } const requestState: RequestState = {}; effectiveConfig.onRequestCreated?.(requestState); this.prepareRequestHandler?.(requestState); try { const headers = this.buildRequestHeaders(effectiveConfig, retryCount); const body = this.serializeBody(effectiveConfig); const fullUrl = this.resolveRequestUrl(effectiveConfig.url, effectiveConfig.query); const response = await this.performXHRRequest(method, fullUrl, headers, body, effectiveConfig, requestState); if (this.shouldRetryStatus(response.status, retryCount, effectiveConfig.retries)) { const retryBackoff = this.ensureBackoff(backoff); await new Promise((resolve) => setTimeout(resolve, retryBackoff.next())); return this.executeRequest(method, effectiveConfig, retryCount + 1, retryBackoff); } this.updateRateLimitEntry(effectiveConfig.url, response); const sudoHeader = response.headers[SUDO_MODE_HEADER]; if (this.sudoTokenListener && response.ok) { if (sudoHeader) { this.sudoTokenListener(sudoHeader); } else if (effectiveConfig.sudoApplied) { this.sudoTokenListener(null); } } let chainedRequest: Promise> | null = null; const retryWithHeaders = ( overrideHeaders: Record, overrideInterceptor?: ResponseInterceptor, ): Promise> => { const nextConfig: HttpRequestConfig = { ...effectiveConfig, headers: {...effectiveConfig.headers, ...overrideHeaders}, }; if (overrideInterceptor) { nextConfig.interceptResponse = overrideInterceptor; } chainedRequest = this.executeRequest(method, nextConfig, retryCount, backoff); return chainedRequest; }; const rejectIntercepted = (error: Error) => { throw error; }; if (effectiveConfig.interceptResponse) { const result = effectiveConfig.interceptResponse(response, retryWithHeaders, rejectIntercepted); if (result instanceof Promise) { return result as Promise>; } if (result === true) { return chainedRequest ?? response; } } if (this.responseInterceptor) { const result = this.responseInterceptor(response, retryWithHeaders, rejectIntercepted); if (result instanceof Promise) { return result as Promise>; } if (result === true) { return chainedRequest ?? response; } } if (!response.ok && effectiveConfig.rejectWithError !== false) { throw new HttpError({ method, url: effectiveConfig.url, ok: response.ok, status: response.status, body: response.body, text: response.text, headers: response.headers, }); } return response; } catch (error) { const urlKey = effectiveConfig.url; if ( error instanceof HttpError && error.status === 403 && !effectiveConfig.sudoRetry && this.isSudoRequiredError(error) ) { if (this.sudoTokenInvalidator) { this.sudoTokenInvalidator(); } if (this.sudoHandler) { const sudoPayload = await this.sudoHandler(effectiveConfig); if (sudoPayload) { const retryConfig = this.buildSudoRetryConfig(effectiveConfig, sudoPayload); try { return await this.executeRequest(method, retryConfig, retryCount, backoff); } catch (retryError) { if (this.sudoFailureHandler) { this.sudoFailureHandler(retryError); } if (this.sudoHandler) { const nextPayload = await this.sudoHandler(effectiveConfig); if (nextPayload) { const nextConfig = this.buildSudoRetryConfig(effectiveConfig, nextPayload); return await this.executeRequest(method, nextConfig, retryCount, backoff); } } throw retryError; } } } } else if (effectiveConfig.sudoApplied && this.sudoHandler) { if (this.sudoFailureHandler) { this.sudoFailureHandler(error); } const retryPayload = await this.sudoHandler(effectiveConfig); if (retryPayload) { const retryConfig = this.buildSudoRetryConfig(effectiveConfig, retryPayload); return this.executeRequest(method, retryConfig, retryCount, backoff); } } this.updateRateLimitEntry(urlKey); if ( !(error instanceof HttpError) && error instanceof Error && error.name !== 'AbortError' && effectiveConfig.retries && retryCount < effectiveConfig.retries ) { const retryBackoff = this.ensureBackoff(backoff); await new Promise((resolve) => setTimeout(resolve, retryBackoff.next())); return this.executeRequest(method, effectiveConfig, retryCount + 1, retryBackoff); } throw error; } } private performXHRRequest( method: HttpMethod, fullUrl: string, headers: Record, body: string | FormData | Blob | ArrayBuffer | undefined, config: HttpRequestConfig, state: RequestState, ): Promise> { return new Promise>((resolve, reject) => { const xhr = new XMLHttpRequest(); state.request = xhr; state.abort = () => xhr.abort(); if (config.onRequestProgress) { xhr.upload.addEventListener('progress', (event) => { config.onRequestProgress?.(event); }); } xhr.addEventListener('load', () => { const {body: parsedBody, text} = this.parseXHRResponse(xhr, config); const response: HttpResponse = { ok: xhr.status >= 200 && xhr.status < 300, status: xhr.status, statusText: xhr.statusText, headers: this.parseXHRHeaders(xhr), body: parsedBody, text, }; resolve(response); }); xhr.addEventListener('error', () => { reject(new Error('Network error during request')); }); xhr.addEventListener('abort', () => { reject(new DOMException('Request aborted', 'AbortError')); }); xhr.addEventListener('timeout', () => { reject(new DOMException('Request timeout', 'TimeoutError')); }); if (config.signal) { const abortHandler = () => xhr.abort(); config.signal.addEventListener('abort', abortHandler); xhr.addEventListener('loadend', () => { config.signal?.removeEventListener('abort', abortHandler); }); } xhr.open(method, fullUrl); if (config.binary) { xhr.responseType = 'blob'; } if (config.timeout && config.timeout > 0) { xhr.timeout = config.timeout; } for (const [name, value] of Object.entries(headers)) { xhr.setRequestHeader(name, value); } xhr.send(body as XMLHttpRequestBodyInit); }); } private async executeViaRelay(method: HttpMethod, config: HttpRequestConfig): Promise> { if (!this.targetInstanceDomain) { throw new Error('Cannot execute relay request: target instance domain not set'); } const path = config.url.startsWith('/') ? config.url : `/${config.url}`; const headers: Record = {...(config.headers ?? {})}; if (!config.skipAuth) { const authToken = this.authTokenProvider?.(); if (authToken) { headers.Authorization = authToken; } } if (config.reason) { headers['X-Audit-Log-Reason'] = encodeURIComponent(config.reason); } const sudoToken = this.sudoTokenProvider?.(); if (sudoToken) { headers['x-fluxer-sudo-mode-jwt'] = sudoToken; } let fullPath = path; if (config.query) { const relayUrl = new URL(path, window.location.origin); this.applyQueryParams(relayUrl, config.query); fullPath = `${relayUrl.pathname}${relayUrl.search}${relayUrl.hash}`; } this.log.debug('Executing request via relay:', method, fullPath, 'target:', this.targetInstanceDomain); const relayResponse = await relayClient.encryptedFetch(this.targetInstanceDomain, fullPath, { method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', headers, body: config.body, timeout: config.timeout ?? this.defaultTimeoutMs, signal: config.signal, }); const response: HttpResponse = { ok: relayResponse.ok, status: relayResponse.status, headers: relayResponse.headers, body: relayResponse.body, }; if (!response.ok && config.rejectWithError !== false) { throw new HttpError({ method, url: config.url, ok: response.ok, status: response.status, body: response.body, headers: response.headers, }); } return response; } private shouldUseRelay(config: HttpRequestConfig): boolean { if (!this.isRelayModeEnabled()) { return false; } if (config.url.includes('://')) { return false; } if (config.attachments || config.fields) { return false; } return true; } private isSudoRequiredError(error: HttpError): boolean { return getApiErrorCode(error) === 'SUDO_MODE_REQUIRED'; } private buildSudoRetryConfig(config: HttpRequestConfig, payload: SudoVerificationPayload): HttpRequestConfig { return { ...config, sudoRetry: true, sudoApplied: true, body: this.mergeSudoPayload(config.body, payload), }; } private mergeSudoPayload( body: HttpRequestConfig['body'], payload: SudoVerificationPayload, ): HttpRequestConfig['body'] { if (!body) { return payload; } if ( typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob) && !(body instanceof ArrayBuffer) ) { return {...(body as Record), ...payload}; } throw new Error('Cannot apply sudo verification to this request'); } } export default new HttpClient();