refactor progress
This commit is contained in:
23
packages/http_client/package.json
Normal file
23
packages/http_client/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
407
packages/http_client/src/HttpClient.tsx
Normal file
407
packages/http_client/src/HttpClient.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
197
packages/http_client/src/HttpClientRequestInternals.tsx
Normal file
197
packages/http_client/src/HttpClientRequestInternals.tsx
Normal 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();
|
||||
}
|
||||
32
packages/http_client/src/HttpClientTelemetryTypes.tsx
Normal file
32
packages/http_client/src/HttpClientTelemetryTypes.tsx
Normal 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;
|
||||
}
|
||||
68
packages/http_client/src/HttpClientTypes.tsx
Normal file
68
packages/http_client/src/HttpClientTypes.tsx
Normal 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;
|
||||
}
|
||||
33
packages/http_client/src/HttpError.tsx
Normal file
33
packages/http_client/src/HttpError.tsx
Normal 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';
|
||||
}
|
||||
}
|
||||
256
packages/http_client/src/PublicInternetRequestUrlPolicy.tsx
Normal file
256
packages/http_client/src/PublicInternetRequestUrlPolicy.tsx
Normal 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};
|
||||
}
|
||||
993
packages/http_client/src/__tests__/HttpClient.test.tsx
Normal file
993
packages/http_client/src/__tests__/HttpClient.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
87
packages/http_client/src/__tests__/TestHttpServer.tsx
Normal file
87
packages/http_client/src/__tests__/TestHttpServer.tsx
Normal 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);
|
||||
});
|
||||
}
|
||||
5
packages/http_client/tsconfig.json
Normal file
5
packages/http_client/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
44
packages/http_client/vitest.config.ts
Normal file
44
packages/http_client/vitest.config.ts
Normal 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/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user