refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,23 @@
{
"name": "@fluxer/http_client",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,407 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {HttpStatus, REDIRECT_STATUS_CODES} from '@fluxer/constants/src/HttpConstants';
import {
buildRequestHeaders,
classifyRequestError,
createRequestSignal,
resolveRequestBody,
statusToMetricLabel,
} from '@fluxer/http_client/src/HttpClientRequestInternals';
import type {
HttpClientMetrics,
HttpClientTelemetry,
HttpClientTracing,
} from '@fluxer/http_client/src/HttpClientTelemetryTypes';
import type {
HttpClient,
HttpClientFactoryOptions,
HttpMethod,
RequestOptions,
RequestUrlPolicy,
RequestUrlValidationContext,
ResponseStream,
StreamResponse,
} from '@fluxer/http_client/src/HttpClientTypes';
import {HttpError} from '@fluxer/http_client/src/HttpError';
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_REDIRECTS = 5;
const DEFAULT_SERVICE_NAME = 'unknown';
interface ResolvedClientConfig {
defaultHeaders: Record<string, string>;
defaultTimeoutMs: number;
maxRedirects: number;
requestUrlPolicy?: RequestUrlPolicy;
telemetry?: HttpClientTelemetry;
}
function createDefaultHeaders(userAgent: string, defaultHeaders?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
Accept: '*/*',
'User-Agent': userAgent,
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
};
if (defaultHeaders) {
for (const [key, value] of Object.entries(defaultHeaders)) {
headers[key] = value;
}
}
return headers;
}
function resolveClientConfig(
userAgentOrOptions: string | HttpClientFactoryOptions,
telemetry?: HttpClientTelemetry,
): ResolvedClientConfig {
if (typeof userAgentOrOptions === 'string') {
return {
defaultHeaders: createDefaultHeaders(userAgentOrOptions),
defaultTimeoutMs: DEFAULT_TIMEOUT_MS,
maxRedirects: DEFAULT_MAX_REDIRECTS,
requestUrlPolicy: undefined,
telemetry,
};
}
const maxRedirects =
typeof userAgentOrOptions.maxRedirects === 'number' && userAgentOrOptions.maxRedirects >= 0
? userAgentOrOptions.maxRedirects
: DEFAULT_MAX_REDIRECTS;
const defaultTimeoutMs =
typeof userAgentOrOptions.defaultTimeoutMs === 'number' && userAgentOrOptions.defaultTimeoutMs > 0
? userAgentOrOptions.defaultTimeoutMs
: DEFAULT_TIMEOUT_MS;
return {
defaultHeaders: createDefaultHeaders(userAgentOrOptions.userAgent, userAgentOrOptions.defaultHeaders),
defaultTimeoutMs,
maxRedirects,
requestUrlPolicy: userAgentOrOptions.requestUrlPolicy,
telemetry: userAgentOrOptions.telemetry,
};
}
function createFetchInit(
method: HttpMethod,
headers: Record<string, string>,
body: string | undefined,
signal: AbortSignal,
): RequestInit {
return {
method,
headers,
body,
signal,
redirect: 'manual',
};
}
function isRedirectStatus(status: number): boolean {
return REDIRECT_STATUS_CODES.includes(status as (typeof REDIRECT_STATUS_CODES)[number]);
}
const SENSITIVE_REDIRECT_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']);
const BODY_RELATED_HEADERS = new Set(['content-type', 'content-length', 'transfer-encoding']);
function shouldSwitchToGet(status: number, method: HttpMethod): boolean {
if (status === HttpStatus.SEE_OTHER) {
return true;
}
if (status === HttpStatus.MOVED_PERMANENTLY || status === HttpStatus.FOUND) {
return method !== 'GET' && method !== 'HEAD';
}
return false;
}
function buildRedirectHeaders(
headers: Record<string, string>,
stripSensitive: boolean,
dropBodyHeaders: boolean,
): Record<string, string> {
const nextHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const lowerKey = key.toLowerCase();
if (stripSensitive && SENSITIVE_REDIRECT_HEADERS.has(lowerKey)) {
continue;
}
if (dropBodyHeaders && BODY_RELATED_HEADERS.has(lowerKey)) {
continue;
}
nextHeaders[key] = value;
}
return nextHeaders;
}
async function fetchWithRedirects(
url: string,
method: HttpMethod,
headers: Record<string, string>,
body: string | undefined,
signal: AbortSignal,
maxRedirects: number,
requestUrlPolicy?: RequestUrlPolicy,
): Promise<Response> {
let currentUrl = new URL(url);
let currentMethod: HttpMethod = method;
let currentBody = body;
let currentHeaders = {...headers};
await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, {
phase: 'initial',
redirectCount: 0,
});
let response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
let redirectCount = 0;
while (isRedirectStatus(response.status)) {
if (redirectCount >= maxRedirects) {
throw new HttpError(`Maximum number of redirects (${maxRedirects}) exceeded`);
}
const location = response.headers.get('location');
if (!location) {
throw new HttpError('Received redirect response without Location header', response.status);
}
const previousUrl = currentUrl;
const nextUrl = new URL(location, response.url || currentUrl.href);
const switchToGet = shouldSwitchToGet(response.status, currentMethod);
if (switchToGet) {
currentMethod = 'GET';
currentBody = undefined;
}
const previousOrigin = previousUrl.origin;
const nextOrigin = nextUrl.origin;
const stripSensitive = previousOrigin !== nextOrigin;
currentHeaders = buildRedirectHeaders(currentHeaders, stripSensitive, switchToGet);
const nextRedirectCount = redirectCount + 1;
await validateRequestUrlPolicy(requestUrlPolicy, nextUrl, {
phase: 'redirect',
redirectCount: nextRedirectCount,
previousUrl: previousUrl.href,
});
currentUrl = nextUrl;
response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
redirectCount = nextRedirectCount;
}
return response;
}
async function validateRequestUrlPolicy(
requestUrlPolicy: RequestUrlPolicy | undefined,
url: URL,
context: RequestUrlValidationContext,
): Promise<void> {
if (!requestUrlPolicy) {
return;
}
await requestUrlPolicy.validate(url, context);
}
async function runWithTracing<T>(
tracing: HttpClientTracing | undefined,
method: HttpMethod,
url: string,
serviceName: string,
fn: () => Promise<T>,
): Promise<T> {
if (!tracing) {
return fn();
}
return tracing.withSpan(
{
name: 'http_client.fetch',
attributes: {
'http.request.method': method,
'url.full': url,
'service.name': serviceName,
},
},
fn,
);
}
function recordSuccessfulRequestMetrics(
metrics: HttpClientMetrics | undefined,
serviceName: string,
method: HttpMethod,
status: number,
durationMs: number,
): void {
if (!metrics) {
return;
}
const statusCategory = statusToMetricLabel(status);
metrics.histogram({
name: 'http_client.latency',
dimensions: {service: serviceName, method},
valueMs: durationMs,
});
metrics.counter({
name: 'http_client.request',
dimensions: {service: serviceName, method, status: statusCategory},
});
metrics.counter({
name: 'http_client.response',
dimensions: {service: serviceName, status_code: statusCategory},
});
}
function recordHttpErrorMetrics(
metrics: HttpClientMetrics | undefined,
serviceName: string,
method: HttpMethod,
status: string,
durationMs: number,
): void {
if (!metrics) {
return;
}
metrics.counter({
name: 'http_client.request',
dimensions: {service: serviceName, method, status},
});
metrics.histogram({
name: 'http_client.latency',
dimensions: {service: serviceName, method},
valueMs: durationMs,
});
}
function recordUnhandledErrorMetrics(
metrics: HttpClientMetrics | undefined,
serviceName: string,
method: HttpMethod,
status: string,
errorType: string,
durationMs: number,
): void {
if (!metrics) {
return;
}
metrics.counter({
name: 'http_client.request',
dimensions: {service: serviceName, method, status},
});
metrics.histogram({
name: 'http_client.latency',
dimensions: {service: serviceName, method, error_type: errorType},
valueMs: durationMs,
});
}
export function createHttpClient(userAgent: string, telemetry?: HttpClientTelemetry): HttpClient;
export function createHttpClient(options: HttpClientFactoryOptions): HttpClient;
export function createHttpClient(
userAgentOrOptions: string | HttpClientFactoryOptions,
telemetry?: HttpClientTelemetry,
): HttpClient {
const config = resolveClientConfig(userAgentOrOptions, telemetry);
const metrics = config.telemetry?.metrics;
const tracing = config.telemetry?.tracing;
async function request(opts: RequestOptions): Promise<StreamResponse> {
const startTime = Date.now();
const method: HttpMethod = opts.method ?? 'GET';
const serviceName = opts.serviceName ?? DEFAULT_SERVICE_NAME;
const timeoutMs = typeof opts.timeout === 'number' && opts.timeout > 0 ? opts.timeout : config.defaultTimeoutMs;
const requestSignal = createRequestSignal(timeoutMs, opts.signal);
const headers = buildRequestHeaders(config.defaultHeaders, opts.headers);
const body = resolveRequestBody(opts.body, headers);
try {
const response = await runWithTracing(tracing, method, opts.url, serviceName, async () => {
return fetchWithRedirects(
opts.url,
method,
headers,
body,
requestSignal.signal,
config.maxRedirects,
config.requestUrlPolicy,
);
});
const result: StreamResponse = {
stream: response.body,
headers: response.headers,
status: response.status,
url: response.url || opts.url,
};
const durationMs = Date.now() - startTime;
recordSuccessfulRequestMetrics(metrics, serviceName, method, result.status, durationMs);
return result;
} catch (error) {
const durationMs = Date.now() - startTime;
if (error instanceof HttpError) {
recordHttpErrorMetrics(metrics, serviceName, method, error.status?.toString() ?? 'error', durationMs);
throw error;
}
const classifiedError = classifyRequestError(error);
const status = classifiedError.isNetworkError ? 'network_error' : 'error';
recordUnhandledErrorMetrics(metrics, serviceName, method, status, classifiedError.errorType, durationMs);
throw new HttpError(
classifiedError.message,
undefined,
undefined,
classifiedError.isNetworkError,
classifiedError.errorType,
);
} finally {
requestSignal.cleanup();
}
}
async function sendRequest(opts: RequestOptions): Promise<StreamResponse> {
return request(opts);
}
async function streamToString(stream: ResponseStream): Promise<string> {
if (!stream) {
return '';
}
return new Response(stream).text();
}
return {
request,
sendRequest,
streamToString,
};
}

View File

@@ -0,0 +1,197 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {HttpErrorType} from '@fluxer/http_client/src/HttpError';
const NETWORK_ERROR_CODES = new Set([
'ENOTFOUND',
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EAI_AGAIN',
'EHOSTUNREACH',
'ENETUNREACH',
]);
const NETWORK_ERROR_MESSAGE_FRAGMENTS = [
'ENOTFOUND',
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EAI_AGAIN',
'EHOSTUNREACH',
'ENETUNREACH',
'fetch failed',
] as const;
interface NodeErrorLike {
code?: unknown;
cause?: unknown;
}
interface NodeCauseLike {
code?: unknown;
}
export interface ClassifiedRequestError {
message: string;
isNetworkError: boolean;
errorType: HttpErrorType;
}
export interface RequestSignalContext {
signal: AbortSignal;
cleanup(): void;
}
function isNodeErrorLike(error: unknown): error is NodeErrorLike {
return typeof error === 'object' && error !== null;
}
function resolveErrorCode(error: unknown): string | undefined {
if (!isNodeErrorLike(error)) {
return undefined;
}
if (typeof error.code === 'string') {
return error.code;
}
if (!isNodeErrorLike(error.cause)) {
return undefined;
}
const cause: NodeCauseLike = error.cause;
if (typeof cause.code === 'string') {
return cause.code;
}
return undefined;
}
export function buildRequestHeaders(
defaultHeaders: Record<string, string>,
requestHeaders?: Record<string, string>,
): Record<string, string> {
if (!requestHeaders) {
return {...defaultHeaders};
}
return {...defaultHeaders, ...requestHeaders};
}
export function resolveRequestBody(body: unknown, headers: Record<string, string>): string | undefined {
if (body === null || body === undefined) {
return undefined;
}
if (typeof body === 'string') {
return body;
}
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json';
}
return JSON.stringify(body);
}
export function createRequestSignal(timeoutMs: number, inputSignal?: AbortSignal): RequestSignalContext {
const timeoutController = new AbortController();
const combinedController = new AbortController();
const attachedListeners: Array<{signal: AbortSignal; listener: () => void}> = [];
const timeoutId = setTimeout(() => {
timeoutController.abort('Request timed out');
}, timeoutMs);
function abortWithReason(reason: unknown): void {
if (!combinedController.signal.aborted) {
combinedController.abort(reason);
}
}
function attachSignal(signal: AbortSignal): void {
if (signal.aborted) {
abortWithReason(signal.reason);
return;
}
const listener = () => {
abortWithReason(signal.reason);
};
signal.addEventListener('abort', listener, {once: true});
attachedListeners.push({signal, listener});
}
if (inputSignal) {
attachSignal(inputSignal);
}
attachSignal(timeoutController.signal);
function cleanup(): void {
clearTimeout(timeoutId);
for (const attachedListener of attachedListeners) {
attachedListener.signal.removeEventListener('abort', attachedListener.listener);
}
}
return {
signal: combinedController.signal,
cleanup,
};
}
export function classifyRequestError(error: unknown): ClassifiedRequestError {
const message = error instanceof Error ? error.message : 'Request failed';
if (error instanceof Error && error.name === 'AbortError') {
return {
message,
isNetworkError: false,
errorType: 'aborted',
};
}
const errorCode = resolveErrorCode(error);
const containsNetworkErrorMessage = NETWORK_ERROR_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment));
const isNetworkError =
(errorCode !== undefined && NETWORK_ERROR_CODES.has(errorCode)) ||
containsNetworkErrorMessage ||
error instanceof TypeError;
if (isNetworkError) {
return {
message,
isNetworkError: true,
errorType: 'network_error',
};
}
return {
message,
isNetworkError: false,
errorType: 'unknown',
};
}
export function statusToMetricLabel(status: number): string {
if (status >= 200 && status < 300) {
return '2xx';
}
return status.toString();
}

View File

@@ -0,0 +1,32 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export interface HttpClientMetrics {
counter(params: {name: string; dimensions?: Record<string, string>; value?: number}): void;
histogram(params: {name: string; dimensions?: Record<string, string>; valueMs: number}): void;
}
export interface HttpClientTracing {
withSpan<T>(options: {name: string; attributes?: Record<string, unknown>}, fn: () => Promise<T>): Promise<T>;
}
export interface HttpClientTelemetry {
metrics?: HttpClientMetrics;
tracing?: HttpClientTracing;
}

View File

@@ -0,0 +1,68 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {HttpClientTelemetry} from '@fluxer/http_client/src/HttpClientTelemetryTypes';
export type ResponseStream = ReadableStream<Uint8Array> | null;
export type HttpMethod = 'GET' | 'POST' | 'HEAD' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
export type RequestUrlValidationPhase = 'initial' | 'redirect';
export interface RequestUrlValidationContext {
phase: RequestUrlValidationPhase;
redirectCount: number;
previousUrl?: string;
}
export interface RequestUrlPolicy {
validate(url: URL, context: RequestUrlValidationContext): Promise<void>;
}
export interface RequestOptions {
url: string;
method?: HttpMethod;
headers?: Record<string, string>;
body?: unknown;
signal?: AbortSignal;
timeout?: number;
serviceName?: string;
}
export interface StreamResponse {
stream: ResponseStream;
headers: Headers;
status: number;
url: string;
}
export interface HttpClient {
request(opts: RequestOptions): Promise<StreamResponse>;
sendRequest(opts: RequestOptions): Promise<StreamResponse>;
streamToString(stream: ResponseStream): Promise<string>;
}
export interface HttpClientFactoryOptions {
userAgent: string;
telemetry?: HttpClientTelemetry;
defaultHeaders?: Record<string, string>;
defaultTimeoutMs?: number;
maxRedirects?: number;
requestUrlPolicy?: RequestUrlPolicy;
}

View File

@@ -0,0 +1,33 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export type HttpErrorType = 'aborted' | 'network_error' | 'unknown';
export class HttpError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly response?: Response,
public readonly isExpected = false,
public readonly errorType?: HttpErrorType,
) {
super(message);
this.name = 'HttpError';
}
}

View File

@@ -0,0 +1,256 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import dns from 'node:dns';
import {BlockList, isIP} from 'node:net';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@fluxer/http_client/src/HttpClientTypes';
import {HttpError} from '@fluxer/http_client/src/HttpError';
const DEFAULT_DNS_CACHE_TTL_MS = 60_000;
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
const HOSTNAME_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
interface BlockedSubnet {
address: string;
prefixLength: number;
family: 'ipv4' | 'ipv6';
}
interface CachedLookupResult {
addresses: Array<string>;
expiresAt: number;
}
export interface PublicInternetRequestUrlPolicyOptions {
dnsCacheTtlMs?: number;
lookupHost?: (hostname: string) => Promise<Array<string>>;
}
const BLOCKED_IPV4_SUBNETS: Array<BlockedSubnet> = [
{address: '0.0.0.0', prefixLength: 8, family: 'ipv4'},
{address: '10.0.0.0', prefixLength: 8, family: 'ipv4'},
{address: '100.64.0.0', prefixLength: 10, family: 'ipv4'},
{address: '127.0.0.0', prefixLength: 8, family: 'ipv4'},
{address: '169.254.0.0', prefixLength: 16, family: 'ipv4'},
{address: '172.16.0.0', prefixLength: 12, family: 'ipv4'},
{address: '192.0.0.0', prefixLength: 24, family: 'ipv4'},
{address: '192.0.2.0', prefixLength: 24, family: 'ipv4'},
{address: '192.88.99.0', prefixLength: 24, family: 'ipv4'},
{address: '192.168.0.0', prefixLength: 16, family: 'ipv4'},
{address: '198.18.0.0', prefixLength: 15, family: 'ipv4'},
{address: '198.51.100.0', prefixLength: 24, family: 'ipv4'},
{address: '203.0.113.0', prefixLength: 24, family: 'ipv4'},
{address: '224.0.0.0', prefixLength: 4, family: 'ipv4'},
{address: '240.0.0.0', prefixLength: 4, family: 'ipv4'},
{address: '255.255.255.255', prefixLength: 32, family: 'ipv4'},
];
const BLOCKED_IPV6_SUBNETS: Array<BlockedSubnet> = [
{address: '::', prefixLength: 128, family: 'ipv6'},
{address: '::1', prefixLength: 128, family: 'ipv6'},
{address: '2001:db8::', prefixLength: 32, family: 'ipv6'},
{address: 'fc00::', prefixLength: 7, family: 'ipv6'},
{address: 'fe80::', prefixLength: 10, family: 'ipv6'},
{address: 'ff00::', prefixLength: 8, family: 'ipv6'},
];
const blockedIpv4List = createBlockList(BLOCKED_IPV4_SUBNETS);
const blockedIpv6List = createBlockList(BLOCKED_IPV6_SUBNETS);
function createBlockList(subnets: Array<BlockedSubnet>): BlockList {
const blockList = new BlockList();
for (const subnet of subnets) {
blockList.addSubnet(subnet.address, subnet.prefixLength, subnet.family);
}
return blockList;
}
function stripIpv6Brackets(value: string): string {
if (value.startsWith('[') && value.endsWith(']')) {
return value.slice(1, -1);
}
return value;
}
function normalizeHostname(hostname: string): string {
const trimmed = stripIpv6Brackets(hostname.trim().toLowerCase());
return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed;
}
function isFqdnHostname(hostname: string): boolean {
if (!hostname || hostname.length > 253 || !hostname.includes('.')) {
return false;
}
const labels = hostname.split('.');
for (const label of labels) {
if (!label || label.length > 63 || !HOSTNAME_LABEL_REGEX.test(label)) {
return false;
}
}
const topLevelDomain = labels[labels.length - 1];
return !/^\d+$/.test(topLevelDomain);
}
function parseIpv4MappedIpv6Address(ipv6Address: string): string | null {
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
if (!normalized.startsWith('::ffff:')) {
return null;
}
const suffix = normalized.slice('::ffff:'.length);
if (isIP(suffix) === 4) {
return suffix;
}
const groups = suffix.split(':');
if (groups.length !== 2) {
return null;
}
const high = parseHexGroup(groups[0]);
const low = parseHexGroup(groups[1]);
if (high === null || low === null) {
return null;
}
const octet1 = (high >> 8) & 0xff;
const octet2 = high & 0xff;
const octet3 = (low >> 8) & 0xff;
const octet4 = low & 0xff;
return `${octet1}.${octet2}.${octet3}.${octet4}`;
}
function parseHexGroup(value: string | undefined): number | null {
if (!value || !/^[0-9a-f]{1,4}$/.test(value)) {
return null;
}
return Number.parseInt(value, 16);
}
function isBlockedIpAddress(address: string): boolean {
const normalizedAddress = stripIpv6Brackets(address.trim().toLowerCase());
const family = isIP(normalizedAddress);
if (family === 4) {
return blockedIpv4List.check(normalizedAddress, 'ipv4');
}
if (family === 6) {
const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress);
if (mappedIpv4) {
return blockedIpv4List.check(mappedIpv4, 'ipv4');
}
return blockedIpv6List.check(normalizedAddress, 'ipv6');
}
return true;
}
function getPolicyErrorContext(context: RequestUrlValidationContext): string {
if (context.phase === 'redirect') {
const previous = context.previousUrl ?? 'unknown';
return `redirect #${context.redirectCount} from ${previous}`;
}
return 'initial request';
}
function createBlockedRequestError(url: URL, context: RequestUrlValidationContext, reason: string): HttpError {
const message = `Blocked outbound ${getPolicyErrorContext(context)} to ${url.href}: ${reason}`;
return new HttpError(message, undefined, undefined, true, 'network_error');
}
async function defaultLookupHost(hostname: string): Promise<Array<string>> {
const addresses = await dns.promises.lookup(hostname, {all: true, verbatim: true});
return addresses.map((addressEntry) => addressEntry.address);
}
function deduplicateAddresses(addresses: Array<string>): Array<string> {
const seen = new Set<string>();
const deduplicated: Array<string> = [];
for (const address of addresses) {
if (seen.has(address)) {
continue;
}
seen.add(address);
deduplicated.push(address);
}
return deduplicated;
}
export function createPublicInternetRequestUrlPolicy(
options?: PublicInternetRequestUrlPolicyOptions,
): RequestUrlPolicy {
const dnsCacheTtlMs =
typeof options?.dnsCacheTtlMs === 'number' && options.dnsCacheTtlMs > 0
? options.dnsCacheTtlMs
: DEFAULT_DNS_CACHE_TTL_MS;
const lookupHost = options?.lookupHost ?? defaultLookupHost;
const dnsCache = new Map<string, CachedLookupResult>();
async function resolveHostname(hostname: string): Promise<Array<string>> {
const now = Date.now();
const cached = dnsCache.get(hostname);
if (cached && cached.expiresAt > now) {
return cached.addresses;
}
const resolvedAddresses = deduplicateAddresses(await lookupHost(hostname));
dnsCache.set(hostname, {
addresses: resolvedAddresses,
expiresAt: now + dnsCacheTtlMs,
});
return resolvedAddresses;
}
async function validate(url: URL, context: RequestUrlValidationContext): Promise<void> {
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
throw createBlockedRequestError(url, context, 'Only HTTP and HTTPS protocols are allowed');
}
const normalizedHostname = normalizeHostname(url.hostname);
if (!normalizedHostname) {
throw createBlockedRequestError(url, context, 'Hostname is empty');
}
if (isIP(normalizedHostname)) {
if (isBlockedIpAddress(normalizedHostname)) {
throw createBlockedRequestError(url, context, 'IP address is in an internal or special-use range');
}
return;
}
if (!isFqdnHostname(normalizedHostname)) {
throw createBlockedRequestError(url, context, 'Hostname is not a valid FQDN');
}
const resolvedAddresses = await resolveHostname(normalizedHostname);
if (resolvedAddresses.length === 0) {
throw createBlockedRequestError(url, context, 'Hostname resolved to no IP addresses');
}
for (const address of resolvedAddresses) {
if (isBlockedIpAddress(address)) {
throw createBlockedRequestError(url, context, `Hostname resolved to disallowed address ${address}`);
}
}
}
return {validate};
}

View File

@@ -0,0 +1,993 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {HttpStatus} from '@fluxer/constants/src/HttpConstants';
import {createTestServer, readRequestBody, type TestServer} from '@fluxer/http_client/src/__tests__/TestHttpServer';
import {createHttpClient} from '@fluxer/http_client/src/HttpClient';
import type {
HttpClientMetrics,
HttpClientTelemetry,
HttpClientTracing,
} from '@fluxer/http_client/src/HttpClientTelemetryTypes';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@fluxer/http_client/src/HttpClientTypes';
import {HttpError} from '@fluxer/http_client/src/HttpError';
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
const TEST_USER_AGENT = 'FluxerHttpClient/1.0 (Test)';
describe('HttpClient', () => {
let testServer: TestServer;
let redirectServer: TestServer;
beforeAll(async () => {
testServer = await createTestServer();
redirectServer = await createTestServer();
});
afterAll(async () => {
await testServer.close();
await redirectServer.close();
});
describe('createHttpClient', () => {
it('creates an HTTP client with the provided user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.headers['user-agent'] ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(TEST_USER_AGENT);
});
});
describe('sendRequest', () => {
describe('basic requests', () => {
it('sends a GET request by default', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('sends a POST request with body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const requestBody = {message: 'Hello, World!'};
testServer.setHandler(async (req, res) => {
const body = await readRequestBody(req);
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method, receivedBody: JSON.parse(body)}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: requestBody,
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
expect(json.receivedBody).toEqual(requestBody);
});
it('sends a HEAD request', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain', 'X-Custom-Header': 'test-value'});
res.end();
});
const response = await client.sendRequest({
url: testServer.url,
method: 'HEAD',
});
expect(response.status).toBe(200);
expect(response.headers.get('X-Custom-Header')).toBe('test-value');
});
});
describe('headers', () => {
it('sends default headers including user agent', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
userAgent: req.headers['user-agent'],
accept: req.headers['accept'],
cacheControl: req.headers['cache-control'],
pragma: req.headers['pragma'],
}),
);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.userAgent).toBe(TEST_USER_AGENT);
expect(json.accept).toBe('*/*');
expect(json.cacheControl).toBe('no-cache, no-store, must-revalidate');
expect(json.pragma).toBe('no-cache');
});
it('allows custom headers to override defaults', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(
JSON.stringify({
accept: req.headers['accept'],
customHeader: req.headers['x-custom-header'],
}),
);
});
const response = await client.sendRequest({
url: testServer.url,
headers: {
Accept: 'application/json',
'X-Custom-Header': 'custom-value',
},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(json.accept).toBe('application/json');
expect(json.customHeader).toBe('custom-value');
});
it('normalizes response headers correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
'X-Single-Value': 'single',
});
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.headers.get('content-type')).toBe('text/plain');
expect(response.headers.get('x-single-value')).toBe('single');
});
});
describe('status codes', () => {
it('returns 200 status for successful requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('returns 404 status for not found', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(404);
});
it('returns 500 status for server errors', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(500);
res.end('Internal Server Error');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(500);
});
it('handles 304 Not Modified without following redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(HttpStatus.NOT_MODIFIED);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(HttpStatus.NOT_MODIFIED);
expect(response.url).toBe(new URL(testServer.url).href);
});
});
describe('redirects', () => {
it('follows 301 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/target`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url, method: req.method}));
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(response.url).toBe(`${redirectServer.url}/target`);
expect(json.path).toBe('/target');
});
it('follows 301 redirect and changes method to GET for non-GET requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('follows 302 redirect', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(req.url ?? '');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(response.status).toBe(200);
expect(body).toBe('/found');
});
it('follows 302 redirect and changes method to GET for non-GET requests', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/found`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'PATCH',
body: {value: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('follows 303 redirect and changes method to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('GET');
});
it('drops content headers when redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(303, {Location: `${redirectServer.url}/see-other`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('drops content headers when 301 redirect switches to GET', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let contentType: string | undefined;
let contentLength: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
res.end();
});
redirectServer.setHandler((req, res) => {
contentType = req.headers['content-type'] as string | undefined;
contentLength = req.headers['content-length'] as string | undefined;
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
expect(contentType).toBeUndefined();
expect(contentLength).toBeUndefined();
});
it('strips sensitive headers on cross-origin redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const secretToken = 'Bearer ultra-secret-token';
const secretCookie = 'session=super-secret';
const secretProxyAuth = 'Basic dXNlcjpwYXNz';
let leakedAuthorization: string | undefined;
let leakedCookie: string | undefined;
let leakedProxyAuthorization: string | undefined;
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/steal`});
res.end();
});
redirectServer.setHandler((req, res) => {
leakedAuthorization = req.headers['authorization'] as string | undefined;
leakedCookie = req.headers['cookie'] as string | undefined;
leakedProxyAuthorization = req.headers['proxy-authorization'] as string | undefined;
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
headers: {
Authorization: secretToken,
Cookie: secretCookie,
'Proxy-Authorization': secretProxyAuth,
},
});
expect(leakedAuthorization).toBeUndefined();
expect(leakedCookie).toBeUndefined();
expect(leakedProxyAuthorization).toBeUndefined();
});
it('keeps sensitive headers on same-origin redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const secretToken = 'Bearer safe-token';
let receivedAuthorization: string | undefined;
let requestCount = 0;
testServer.setHandler((req, res) => {
requestCount += 1;
if (requestCount === 1) {
res.writeHead(302, {Location: '/same-origin'});
res.end();
return;
}
receivedAuthorization = req.headers['authorization'] as string | undefined;
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({
url: testServer.url,
headers: {
Authorization: secretToken,
},
});
expect(receivedAuthorization).toBe(secretToken);
});
it('follows 307 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(307, {Location: `${redirectServer.url}/temp`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows 308 redirect preserving method', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(308, {Location: `${redirectServer.url}/permanent`});
res.end();
});
redirectServer.setHandler((req, res) => {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({method: req.method}));
});
const response = await client.sendRequest({
url: testServer.url,
method: 'POST',
body: {data: 'test'},
});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.method).toBe('POST');
});
it('follows multiple redirects up to max limit', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let redirectCount = 0;
testServer.setHandler((_req, res) => {
redirectCount++;
if (redirectCount < 5) {
res.writeHead(302, {Location: `${testServer.url}/redirect${redirectCount}`});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({redirectCount}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.redirectCount).toBe(5);
});
it('throws error when exceeding max redirects', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${testServer.url}/redirect`});
res.end();
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow(
'Maximum number of redirects (5) exceeded',
);
});
it('throws error when redirect has no Location header', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(302);
res.end();
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow(
'Received redirect response without Location header',
);
});
it('handles relative redirect URLs', async () => {
const client = createHttpClient(TEST_USER_AGENT);
let requestCount = 0;
testServer.setHandler((req, res) => {
requestCount++;
if (requestCount === 1) {
res.writeHead(302, {Location: '/relative-path'});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({path: req.url}));
}
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
const json = JSON.parse(body);
expect(response.status).toBe(200);
expect(json.path).toBe('/relative-path');
});
it('validates redirect targets with request URL policy before following', async () => {
const validationCalls: Array<RequestUrlValidationContext> = [];
const requestUrlPolicy: RequestUrlPolicy = {
async validate(_url, context) {
validationCalls.push(context);
if (context.phase === 'redirect') {
throw new HttpError('Blocked redirect target', undefined, undefined, true, 'network_error');
}
},
};
const client = createHttpClient({
userAgent: TEST_USER_AGENT,
requestUrlPolicy,
});
testServer.setHandler((_req, res) => {
res.writeHead(302, {Location: `${redirectServer.url}/blocked`});
res.end();
});
redirectServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('should-not-be-called');
});
await expect(client.sendRequest({url: testServer.url})).rejects.toThrow('Blocked redirect target');
expect(validationCalls).toHaveLength(2);
expect(validationCalls[0]?.phase).toBe('initial');
expect(validationCalls[1]?.phase).toBe('redirect');
});
});
describe('timeout', () => {
it('uses default timeout of 30 seconds', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
const response = await client.sendRequest({url: testServer.url});
expect(response.status).toBe(200);
});
it('respects custom timeout', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
setTimeout(() => {
res.writeHead(200);
res.end('OK');
}, 500);
});
await expect(
client.sendRequest({
url: testServer.url,
timeout: 100,
}),
).rejects.toThrow();
});
});
describe('abort signal', () => {
it('aborts request when signal is triggered', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
testServer.setHandler((_req, res) => {
setTimeout(() => {
res.writeHead(200);
res.end('OK');
}, 1000);
});
const requestPromise = client.sendRequest({
url: testServer.url,
signal: controller.signal,
});
setTimeout(() => controller.abort(), 50);
await expect(requestPromise).rejects.toThrow();
});
it('handles pre-aborted signal', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const controller = new AbortController();
controller.abort('Pre-aborted');
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await expect(
client.sendRequest({
url: testServer.url,
signal: controller.signal,
}),
).rejects.toThrow();
});
});
describe('error handling', () => {
it('throws HttpError for connection refused', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://127.0.0.1:1'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
it('throws HttpError for DNS resolution failure', async () => {
const client = createHttpClient(TEST_USER_AGENT);
try {
await client.sendRequest({url: 'http://this-domain-does-not-exist-12345.invalid'});
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(HttpError);
const httpError = error as HttpError;
expect(httpError.isExpected).toBe(true);
}
});
});
});
describe('streamToString', () => {
it('converts response stream to string', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello, World!');
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('Hello, World!');
});
it('handles empty response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
testServer.setHandler((_req, res) => {
res.writeHead(204);
res.end();
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe('');
});
it('handles large response body', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const largeContent = 'x'.repeat(1024 * 1024);
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(largeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body.length).toBe(largeContent.length);
});
it('handles UTF-8 content correctly', async () => {
const client = createHttpClient(TEST_USER_AGENT);
const unicodeContent = 'Hello, World! Emoji: \u{1F600} Chinese: \u4E2D\u6587 Arabic: \u0639\u0631\u0628\u064A';
testServer.setHandler((_req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain; charset=utf-8'});
res.end(unicodeContent);
});
const response = await client.sendRequest({url: testServer.url});
const body = await client.streamToString(response.stream);
expect(body).toBe(unicodeContent);
});
});
describe('telemetry', () => {
it('records metrics for successful requests', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
const responseMetric = recordedMetrics.find((m) => m.name === 'http_client.response');
expect(latencyMetric).toBeDefined();
expect(latencyMetric?.dimensions?.service).toBe('test-service');
expect(latencyMetric?.dimensions?.method).toBe('GET');
expect(requestMetric).toBeDefined();
expect(requestMetric?.dimensions?.service).toBe('test-service');
expect(requestMetric?.dimensions?.status).toBe('2xx');
expect(responseMetric).toBeDefined();
expect(responseMetric?.dimensions?.service).toBe('test-service');
expect(responseMetric?.dimensions?.status_code).toBe('2xx');
});
it('records metrics for error responses', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(404);
res.end('Not Found');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('404');
});
it('records metrics for network errors', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
try {
await client.sendRequest({url: 'http://127.0.0.1:1', serviceName: 'test-service'});
} catch {}
const requestMetric = recordedMetrics.find((m) => m.name === 'http_client.request');
expect(requestMetric?.dimensions?.status).toBe('network_error');
});
it('wraps request in tracing span when tracing is provided', async () => {
let spanName: string | undefined;
let spanAttributes: Record<string, unknown> | undefined;
const tracing: HttpClientTracing = {
withSpan: async (options, fn) => {
spanName = options.name;
spanAttributes = options.attributes;
return fn();
},
};
const telemetry: HttpClientTelemetry = {tracing};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url, serviceName: 'test-service'});
expect(spanName).toBe('http_client.fetch');
expect(spanAttributes?.['http.request.method']).toBe('GET');
expect(spanAttributes?.['url.full']).toBe(testServer.url);
expect(spanAttributes?.['service.name']).toBe('test-service');
});
it('uses default service name when not provided', async () => {
const recordedMetrics: Array<{type: string; name: string; dimensions?: Record<string, string>}> = [];
const metrics: HttpClientMetrics = {
counter: (params) => {
recordedMetrics.push({type: 'counter', ...params});
},
histogram: (params) => {
recordedMetrics.push({type: 'histogram', ...params});
},
};
const telemetry: HttpClientTelemetry = {metrics};
const client = createHttpClient(TEST_USER_AGENT, telemetry);
testServer.setHandler((_req, res) => {
res.writeHead(200);
res.end('OK');
});
await client.sendRequest({url: testServer.url});
const latencyMetric = recordedMetrics.find((m) => m.name === 'http_client.latency');
expect(latencyMetric?.dimensions?.service).toBe('unknown');
});
});
});
describe('HttpError', () => {
it('creates error with message', () => {
const error = new HttpError('Test error');
expect(error.message).toBe('Test error');
expect(error.name).toBe('HttpError');
expect(error.status).toBeUndefined();
expect(error.response).toBeUndefined();
expect(error.isExpected).toBe(false);
expect(error.errorType).toBeUndefined();
});
it('creates error with status code', () => {
const error = new HttpError('Not Found', 404);
expect(error.message).toBe('Not Found');
expect(error.status).toBe(404);
});
it('creates error with response', () => {
const response = new Response('Error body', {status: 500});
const error = new HttpError('Server Error', 500, response);
expect(error.status).toBe(500);
expect(error.response).toBe(response);
});
it('creates error with isExpected flag', () => {
const error = new HttpError('Expected error', 400, undefined, true);
expect(error.isExpected).toBe(true);
});
it('creates error with errorType', () => {
const error = new HttpError('Aborted', undefined, undefined, false, 'aborted');
expect(error.errorType).toBe('aborted');
});
it('is an instance of Error', () => {
const error = new HttpError('Test');
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(HttpError);
});
});

View File

@@ -0,0 +1,92 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {RequestUrlValidationContext} from '@fluxer/http_client/src/HttpClientTypes';
import {createPublicInternetRequestUrlPolicy} from '@fluxer/http_client/src/PublicInternetRequestUrlPolicy';
import {describe, expect, it, vi} from 'vitest';
function createContext(overrides?: Partial<RequestUrlValidationContext>): RequestUrlValidationContext {
return {
phase: 'initial',
redirectCount: 0,
...overrides,
};
}
describe('createPublicInternetRequestUrlPolicy', () => {
it('blocks non-http protocols', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('ftp://example.com/file.txt'), createContext())).rejects.toThrow(
'Only HTTP and HTTPS protocols are allowed',
);
});
it('blocks hostnames that are not FQDNs', async () => {
const lookupHost = vi.fn(async () => ['93.184.216.34']);
const policy = createPublicInternetRequestUrlPolicy({lookupHost});
await expect(policy.validate(new URL('https://localhost/path'), createContext())).rejects.toThrow(
'Hostname is not a valid FQDN',
);
expect(lookupHost).not.toHaveBeenCalled();
});
it('blocks internal IPv4 literal addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('http://127.0.0.1/admin'), createContext())).rejects.toThrow(
'IP address is in an internal or special-use range',
);
});
it('blocks internal IPv6 literal addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('http://[::1]/admin'), createContext())).rejects.toThrow(
'IP address is in an internal or special-use range',
);
});
it('blocks IPv4-mapped IPv6 loopback literals', async () => {
const policy = createPublicInternetRequestUrlPolicy();
await expect(policy.validate(new URL('http://[::ffff:7f00:1]/admin'), createContext())).rejects.toThrow(
'IP address is in an internal or special-use range',
);
});
it('blocks FQDNs that resolve to internal addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy({
lookupHost: async () => ['10.0.0.5'],
});
await expect(policy.validate(new URL('https://cdn.example.com/image.png'), createContext())).rejects.toThrow(
'Hostname resolved to disallowed address',
);
});
it('allows FQDNs that resolve only to public addresses', async () => {
const policy = createPublicInternetRequestUrlPolicy({
lookupHost: async () => ['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946'],
});
await expect(policy.validate(new URL('https://example.com/image.png'), createContext())).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,87 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http';
export type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
export interface TestServer {
url: string;
port: number;
server: Server;
close: () => Promise<void>;
setHandler: (handler: RouteHandler) => void;
}
export async function createTestServer(): Promise<TestServer> {
let currentHandler: RouteHandler = (_req, res) => {
res.writeHead(404);
res.end('Not Found');
};
const server = createServer((req, res) => {
Promise.resolve(currentHandler(req, res)).catch((error) => {
res.writeHead(500);
res.end(String(error));
});
});
return new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
return;
}
const port = address.port;
const url = `http://127.0.0.1:${port}`;
resolve({
url,
port,
server,
close: () =>
new Promise<void>((resolveClose, rejectClose) => {
server.close((err) => {
if (err) {
rejectClose(err);
} else {
resolveClose();
}
});
}),
setHandler: (handler: RouteHandler) => {
currentHandler = handler;
},
});
});
});
}
export function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Array<Buffer> = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,44 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});