refactor(api): tidy the api and shared packages (#2733)

This commit is contained in:
Hampus
2026-09-13 17:37:48 +02:00
committed by GitHub
parent 580401d2dc
commit 5afbf67a70
333 changed files with 10180 additions and 7910 deletions
+101 -68
View File
@@ -1,10 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {addAbortListener} from 'node:events';
import {finished} from 'node:stream';
import {HttpStatus, REDIRECT_STATUS_CODES} from '@fluxer/constants/src/HttpConstants';
import {
buildRequestHeaders,
classifyRequestError,
createRequestSignal,
DEFAULT_MAX_REDIRECTS,
DEFAULT_TIMEOUT_MS,
normalizeMaxRedirects,
normalizeTimeoutMs,
resolveRequestBody,
statusToMetricLabel,
} from '@pkgs/http_client/src/HttpClientRequestInternals';
@@ -20,32 +26,28 @@ import type {
StreamResponse,
} from '@pkgs/http_client/src/HttpClientTypes';
import {HttpError} from '@pkgs/http_client/src/HttpError';
import type {Dispatcher} from 'undici-types';
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_MAX_REDIRECTS = 5;
const DEFAULT_SERVICE_NAME = 'unknown';
interface ResolvedClientConfig {
defaultHeaders: Record<string, string>;
defaultHeaders: Headers;
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 createDefaultHeaders(userAgent: string, defaultHeaders?: Record<string, string>): Headers {
return buildRequestHeaders(
{
Accept: '*/*',
'User-Agent': userAgent,
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
},
defaultHeaders,
);
}
function resolveClientConfig(
@@ -61,14 +63,8 @@ function resolveClientConfig(
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;
const maxRedirects = normalizeMaxRedirects(userAgentOrOptions.maxRedirects);
const defaultTimeoutMs = normalizeTimeoutMs(userAgentOrOptions.defaultTimeoutMs, 'defaultTimeoutMs');
return {
defaultHeaders: createDefaultHeaders(userAgentOrOptions.userAgent, userAgentOrOptions.defaultHeaders),
defaultTimeoutMs,
@@ -80,10 +76,10 @@ function resolveClientConfig(
function createFetchInit(
method: HttpMethod,
headers: Record<string, string>,
headers: Headers,
body: string | undefined,
signal: AbortSignal,
dispatcher: NonNullable<RequestInit['dispatcher']> | undefined,
dispatcher: Dispatcher | undefined,
): RequestInit {
return {
method,
@@ -95,10 +91,15 @@ function createFetchInit(
};
}
function resolveRequestUrlPolicyDispatcher(
requestUrlPolicy: RequestUrlPolicy | undefined,
): NonNullable<RequestInit['dispatcher']> | undefined {
return (requestUrlPolicy as {dispatcher?: NonNullable<RequestInit['dispatcher']>} | undefined)?.dispatcher;
function parseRequestUrl(value: string, base?: string): URL {
const url = URL.parse(value, base);
if (!url) {
throw new TypeError('Invalid URL');
}
if (url.username || url.password) {
throw new TypeError('Request URL must not include credentials');
}
return url;
}
function isRedirectStatus(status: number): boolean {
@@ -106,33 +107,36 @@ function isRedirectStatus(status: number): boolean {
}
const SENSITIVE_REDIRECT_HEADERS = new Set(['authorization', 'cookie', 'proxy-authorization']);
const BODY_RELATED_HEADERS = new Set(['content-type', 'content-length', 'transfer-encoding']);
const BODY_RELATED_HEADERS = new Set([
'content-encoding',
'content-language',
'content-length',
'content-location',
'content-type',
'transfer-encoding',
]);
function shouldSwitchToGet(status: number, method: HttpMethod): boolean {
if (status === HttpStatus.SEE_OTHER) {
return true;
return method !== 'GET' && method !== 'HEAD';
}
if (status === HttpStatus.MOVED_PERMANENTLY || status === HttpStatus.FOUND) {
return method !== 'GET' && method !== 'HEAD';
return method === 'POST';
}
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;
function buildRedirectHeaders(headers: Headers, stripSensitive: boolean, dropBodyHeaders: boolean): Headers {
const nextHeaders = new Headers(headers);
if (stripSensitive) {
for (const name of SENSITIVE_REDIRECT_HEADERS) {
nextHeaders.delete(name);
}
if (dropBodyHeaders && BODY_RELATED_HEADERS.has(lowerKey)) {
continue;
}
if (dropBodyHeaders) {
for (const name of BODY_RELATED_HEADERS) {
nextHeaders.delete(name);
}
nextHeaders[key] = value;
}
return nextHeaders;
}
@@ -140,27 +144,33 @@ function buildRedirectHeaders(
async function fetchWithRedirects(
url: string,
method: HttpMethod,
headers: Record<string, string>,
headers: Headers,
body: string | undefined,
signal: AbortSignal,
maxRedirects: number,
requestUrlPolicy?: RequestUrlPolicy,
): Promise<Response> {
let currentUrl = new URL(url);
let currentUrl = parseRequestUrl(url);
let currentMethod: HttpMethod = method;
let currentBody = body;
let currentHeaders = {...headers};
const dispatcher = resolveRequestUrlPolicyDispatcher(requestUrlPolicy);
await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, {
phase: 'initial',
redirectCount: 0,
});
let currentHeaders = headers;
const dispatcher = requestUrlPolicy?.dispatcher;
await validateRequestUrlPolicy(
requestUrlPolicy,
currentUrl,
{
phase: 'initial',
redirectCount: 0,
},
signal,
);
let response = await fetch(
currentUrl.href,
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
);
let redirectCount = 0;
while (isRedirectStatus(response.status)) {
await response.body?.cancel();
if (redirectCount >= maxRedirects) {
throw new HttpError(`Maximum number of redirects (${maxRedirects}) exceeded`);
}
@@ -169,7 +179,7 @@ async function fetchWithRedirects(
throw new HttpError('Received redirect response without Location header', response.status);
}
const previousUrl = currentUrl;
const nextUrl = new URL(location, response.url || currentUrl.href);
const nextUrl = parseRequestUrl(location, response.url || currentUrl.href);
const switchToGet = shouldSwitchToGet(response.status, currentMethod);
if (switchToGet) {
currentMethod = 'GET';
@@ -180,11 +190,16 @@ async function fetchWithRedirects(
const stripSensitive = previousOrigin !== nextOrigin;
currentHeaders = buildRedirectHeaders(currentHeaders, stripSensitive, switchToGet);
const nextRedirectCount = redirectCount + 1;
await validateRequestUrlPolicy(requestUrlPolicy, nextUrl, {
phase: 'redirect',
redirectCount: nextRedirectCount,
previousUrl: previousUrl.href,
});
await validateRequestUrlPolicy(
requestUrlPolicy,
nextUrl,
{
phase: 'redirect',
redirectCount: nextRedirectCount,
previousUrl: previousUrl.href,
},
signal,
);
currentUrl = nextUrl;
response = await fetch(
currentUrl.href,
@@ -199,11 +214,21 @@ async function validateRequestUrlPolicy(
requestUrlPolicy: RequestUrlPolicy | undefined,
url: URL,
context: RequestUrlValidationContext,
signal: AbortSignal,
): Promise<void> {
signal.throwIfAborted();
if (!requestUrlPolicy) {
return;
}
await requestUrlPolicy.validate(url, context);
let abortSubscription: Disposable | undefined;
try {
await new Promise<void>((resolve, reject) => {
abortSubscription = addAbortListener(signal, () => reject(signal.reason));
void requestUrlPolicy.validate(url, context).then(resolve, reject);
});
} finally {
abortSubscription?.[Symbol.dispose]();
}
}
function recordSuccessfulRequestMetrics(
@@ -287,10 +312,11 @@ export function createHttpClient(
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 timeoutMs = normalizeTimeoutMs(opts.timeout, 'timeout', config.defaultTimeoutMs);
const headers = buildRequestHeaders(config.defaultHeaders, opts.headers);
const body = resolveRequestBody(opts.body, headers);
const requestSignal = createRequestSignal(timeoutMs, opts.signal);
let responseOwnsSignal = false;
try {
const response = await fetchWithRedirects(
opts.url,
@@ -309,8 +335,16 @@ export function createHttpClient(
};
const durationMs = Date.now() - startTime;
recordSuccessfulRequestMetrics(metrics, serviceName, method, result.status, durationMs);
if (result.stream) {
const stopObserving = finished(result.stream, () => {
stopObserving();
requestSignal.cleanup();
});
responseOwnsSignal = true;
}
return result;
} catch (error) {
requestSignal.abort(error);
const durationMs = Date.now() - startTime;
if (error instanceof HttpError) {
recordHttpErrorMetrics(metrics, serviceName, method, error.status?.toString() ?? 'error', durationMs);
@@ -327,12 +361,11 @@ export function createHttpClient(
classifiedError.errorType,
);
} finally {
requestSignal.cleanup();
if (!responseOwnsSignal) {
requestSignal.cleanup();
}
}
}
async function sendRequest(opts: RequestOptions): Promise<StreamResponse> {
return request(opts);
}
async function streamToString(stream: ResponseStream): Promise<string> {
if (!stream) {
return '';
@@ -341,7 +374,7 @@ export function createHttpClient(
}
return {
request,
sendRequest,
sendRequest: request,
streamToString,
};
}