mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
refactor(api): tidy the api and shared packages (#2733)
This commit is contained in:
1 parent
580401d2dc
commit
5afbf67a70
333 files changed
+10180
-7910
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function formatUrlForDiagnostics(value: string | URL): string {
|
||||
const url = typeof value === 'string' ? URL.parse(value) : value;
|
||||
if (!url) {
|
||||
return '[invalid URL]';
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return '[non-HTTP URL]';
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {addAbortListener} from 'node:events';
|
||||
import type {HttpErrorType} from '@pkgs/http_client/src/HttpError';
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 30000;
|
||||
export const DEFAULT_MAX_REDIRECTS = 5;
|
||||
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
||||
|
||||
const NETWORK_ERROR_CODES = new Set([
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
@@ -11,26 +16,13 @@ const NETWORK_ERROR_CODES = new Set([
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
]);
|
||||
const NETWORK_ERROR_MESSAGE_FRAGMENTS = [
|
||||
'ENOTFOUND',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'fetch failed',
|
||||
] as const;
|
||||
const NETWORK_ERROR_MESSAGE_FRAGMENTS = [...NETWORK_ERROR_CODES, 'fetch failed'];
|
||||
|
||||
interface NodeErrorLike {
|
||||
code?: unknown;
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
interface NodeCauseLike {
|
||||
code?: unknown;
|
||||
}
|
||||
|
||||
interface ClassifiedRequestError {
|
||||
message: string;
|
||||
isNetworkError: boolean;
|
||||
@@ -39,9 +31,30 @@ interface ClassifiedRequestError {
|
||||
|
||||
interface RequestSignalContext {
|
||||
signal: AbortSignal;
|
||||
abort(reason: unknown): void;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
export function normalizeMaxRedirects(value: number | undefined): number {
|
||||
const maxRedirects = value === undefined ? DEFAULT_MAX_REDIRECTS : value;
|
||||
if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0) {
|
||||
throw new RangeError('maxRedirects must be a nonnegative safe integer');
|
||||
}
|
||||
return maxRedirects;
|
||||
}
|
||||
|
||||
export function normalizeTimeoutMs(
|
||||
value: number | undefined,
|
||||
setting: 'defaultTimeoutMs' | 'timeout',
|
||||
defaultTimeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): number {
|
||||
const timeoutMs = value === undefined ? defaultTimeoutMs : value;
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
|
||||
throw new RangeError(`${setting} must be an integer between 1 and 2147483647`);
|
||||
}
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
function isNodeErrorLike(error: unknown): error is NodeErrorLike {
|
||||
return typeof error === 'object' && error !== null;
|
||||
}
|
||||
@@ -53,84 +66,61 @@ function resolveErrorCode(error: unknown): string | 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;
|
||||
if (isNodeErrorLike(error.cause) && typeof error.cause.code === 'string') {
|
||||
return error.cause.code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildRequestHeaders(
|
||||
defaultHeaders: Record<string, string>,
|
||||
defaultHeaders: Headers | Record<string, string>,
|
||||
requestHeaders?: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
if (!requestHeaders) {
|
||||
return {...defaultHeaders};
|
||||
): Headers {
|
||||
const headers = new Headers(defaultHeaders);
|
||||
for (const [name, value] of Object.entries(requestHeaders ?? {})) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
return {...defaultHeaders, ...requestHeaders};
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function resolveRequestBody(body: unknown, headers: Record<string, string>): string | undefined {
|
||||
export function resolveRequestBody(body: unknown, headers: Headers): string | undefined {
|
||||
if (body === null || body === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
||||
if (!headers.get('content-type')) {
|
||||
headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
|
||||
}
|
||||
return body.toString();
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
if (!headers['Content-Type'] && !headers['content-type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
if (!headers.get('content-type')) {
|
||||
headers.set('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 controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
timeoutController.abort('Request timed out');
|
||||
controller.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);
|
||||
}
|
||||
timeoutId.unref();
|
||||
let inputSubscription: Disposable | undefined;
|
||||
if (inputSignal?.aborted) {
|
||||
controller.abort(inputSignal.reason);
|
||||
} else if (inputSignal) {
|
||||
inputSubscription = addAbortListener(inputSignal, () => controller.abort(inputSignal.reason));
|
||||
}
|
||||
return {
|
||||
signal: combinedController.signal,
|
||||
cleanup,
|
||||
signal: controller.signal,
|
||||
abort: (reason) => controller.abort(reason),
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
inputSubscription?.[Symbol.dispose]();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
|
||||
export type ResponseStream = ReadableStream<Uint8Array> | null;
|
||||
export type HttpMethod = 'GET' | 'POST' | 'HEAD' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
||||
@@ -13,6 +14,7 @@ export interface RequestUrlValidationContext {
|
||||
}
|
||||
|
||||
export interface RequestUrlPolicy {
|
||||
readonly dispatcher?: Dispatcher;
|
||||
validate(url: URL, context: RequestUrlValidationContext): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import dns from 'node:dns';
|
||||
import type {LookupFunction} from 'node:net';
|
||||
import {BlockList, isIP} from 'node:net';
|
||||
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
|
||||
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {HttpError} from '@pkgs/http_client/src/HttpError';
|
||||
import {Agent} from 'undici';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
|
||||
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
|
||||
const DNS_CACHE_MAX_ENTRIES = 10000;
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
const HOSTNAME_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
@@ -203,14 +207,14 @@ export function isPubliclyRoutableUrlShape(url: URL): boolean {
|
||||
|
||||
function getPolicyErrorContext(context: RequestUrlValidationContext): string {
|
||||
if (context.phase === 'redirect') {
|
||||
const previous = context.previousUrl ?? 'unknown';
|
||||
const previous = context.previousUrl === undefined ? 'unknown' : formatUrlForDiagnostics(context.previousUrl);
|
||||
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}`;
|
||||
const message = `Blocked outbound ${getPolicyErrorContext(context)} to ${formatUrlForDiagnostics(url)}: ${reason}`;
|
||||
return new HttpError(message, undefined, undefined, true, 'network_error');
|
||||
}
|
||||
|
||||
@@ -219,20 +223,7 @@ async function defaultLookupHost(hostname: string): Promise<Array<string>> {
|
||||
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;
|
||||
}
|
||||
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): Dispatcher {
|
||||
const lookup: LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
|
||||
if (error) {
|
||||
@@ -259,33 +250,46 @@ function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<
|
||||
connect: {
|
||||
lookup,
|
||||
},
|
||||
}) as unknown as NonNullable<RequestInit['dispatcher']>;
|
||||
}) as unknown as Dispatcher;
|
||||
}
|
||||
|
||||
interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy {
|
||||
dispatcher: NonNullable<RequestInit['dispatcher']>;
|
||||
readonly dispatcher: Dispatcher;
|
||||
}
|
||||
|
||||
export function createPublicInternetRequestUrlPolicy(
|
||||
options?: PublicInternetRequestUrlPolicyOptions,
|
||||
): PublicInternetRequestUrlPolicy {
|
||||
const dnsCacheTtlMs =
|
||||
typeof options?.dnsCacheTtlMs === 'number' && options.dnsCacheTtlMs > 0
|
||||
? options.dnsCacheTtlMs
|
||||
: DEFAULT_DNS_CACHE_TTL_MS;
|
||||
const dnsCacheTtlMs = options?.dnsCacheTtlMs ?? DEFAULT_DNS_CACHE_TTL_MS;
|
||||
if (!Number.isFinite(dnsCacheTtlMs) || dnsCacheTtlMs <= 0) {
|
||||
throw new RangeError('DNS cache TTL must be a positive finite number');
|
||||
}
|
||||
const lookupHost = options?.lookupHost ?? defaultLookupHost;
|
||||
const allowPrivateAddresses = options?.allowPrivateAddresses === true;
|
||||
const dnsCache = new Map<string, CachedLookupResult>();
|
||||
async function resolveHostname(hostname: string): Promise<Array<string>> {
|
||||
const now = Date.now();
|
||||
const now = performance.now();
|
||||
const cached = dnsCache.get(hostname);
|
||||
dnsCache.delete(hostname);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
dnsCache.set(hostname, cached);
|
||||
return cached.addresses;
|
||||
}
|
||||
const resolvedAddresses = deduplicateAddresses(await lookupHost(hostname));
|
||||
const expiresAt = now + dnsCacheTtlMs;
|
||||
const resolvedAddresses = [...new Set(await lookupHost(hostname))];
|
||||
const current = dnsCache.get(hostname);
|
||||
if (expiresAt <= performance.now() || (current && current.expiresAt > expiresAt)) {
|
||||
return resolvedAddresses;
|
||||
}
|
||||
dnsCache.delete(hostname);
|
||||
if (dnsCache.size >= DNS_CACHE_MAX_ENTRIES) {
|
||||
const oldest = dnsCache.keys().next();
|
||||
assert(!oldest.done, 'A full DNS cache must contain an eviction candidate');
|
||||
dnsCache.delete(oldest.value);
|
||||
}
|
||||
dnsCache.set(hostname, {
|
||||
addresses: resolvedAddresses,
|
||||
expiresAt: now + dnsCacheTtlMs,
|
||||
expiresAt,
|
||||
});
|
||||
return resolvedAddresses;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('HttpClient', () => {
|
||||
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 () => {
|
||||
it('follows 301 redirect and changes POST to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(301, {Location: `${redirectServer.url}/moved`});
|
||||
@@ -260,7 +260,7 @@ describe('HttpClient', () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toBe('/found');
|
||||
});
|
||||
it('follows 302 redirect and changes method to GET for non-GET requests', async () => {
|
||||
it('follows 302 redirect preserving PATCH', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
testServer.setHandler((_req, res) => {
|
||||
res.writeHead(302, {Location: `${redirectServer.url}/found`});
|
||||
@@ -278,7 +278,7 @@ describe('HttpClient', () => {
|
||||
const body = await client.streamToString(response.stream);
|
||||
const json = JSON.parse(body);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.method).toBe('GET');
|
||||
expect(json.method).toBe('PATCH');
|
||||
});
|
||||
it('follows 303 redirect and changes method to GET', async () => {
|
||||
const client = createHttpClient(TEST_USER_AGENT);
|
||||
|
||||
Reference in new issue
Block a user