webui: MCP Diagnostics improvements (#21803)
* Add MCP Connection diagnostics and CORS hint to web-ui * tidy up test * webui: Refactor and improve MCP diagnostic logging --------- Co-authored-by: evalstate <1936278+evalstate@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
bafae27654
commit
227ed28e12
@@ -15,6 +15,18 @@
|
||||
let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
|
||||
|
||||
let isExpanded = $derived(defaultExpanded);
|
||||
|
||||
function formatLogDetails(details: unknown): string {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
return String(details);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if logs.length > 0}
|
||||
@@ -53,6 +65,16 @@
|
||||
|
||||
<span class="break-all">{log.message}</span>
|
||||
</div>
|
||||
|
||||
{#if log.details !== undefined}
|
||||
<details class="ml-11">
|
||||
<summary class="cursor-pointer text-[10px] text-muted-foreground"> details </summary>
|
||||
|
||||
<pre
|
||||
class="mt-1 overflow-x-auto rounded bg-background/70 p-2 text-[10px] break-all whitespace-pre-wrap text-foreground/80">
|
||||
{formatLogDetails(log.details)}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
|
||||
@@ -48,6 +48,26 @@ export const EXPECTED_THEMED_ICON_PAIR_COUNT = 2;
|
||||
/** CORS proxy URL query parameter name */
|
||||
export const CORS_PROXY_URL_PARAM = 'url';
|
||||
|
||||
/** Number of trailing characters to keep visible when partially redacting mcp-session-id */
|
||||
export const MCP_SESSION_ID_VISIBLE_CHARS = 5;
|
||||
|
||||
/** Partial-redaction rules for MCP headers: header name -> visible trailing chars */
|
||||
export const MCP_PARTIAL_REDACT_HEADERS = new Map<string, number>([
|
||||
['mcp-session-id', MCP_SESSION_ID_VISIBLE_CHARS]
|
||||
]);
|
||||
|
||||
/** Header names whose values should be redacted in diagnostic logs */
|
||||
export const REDACTED_HEADERS = new Set([
|
||||
'authorization',
|
||||
'api-key',
|
||||
'cookie',
|
||||
'mcp-session-id',
|
||||
'proxy-authorization',
|
||||
'set-cookie',
|
||||
'x-auth-token',
|
||||
'x-api-key'
|
||||
]);
|
||||
|
||||
/** Human-readable labels for MCP transport types */
|
||||
export const MCP_TRANSPORT_LABELS: Record<MCPTransportType, string> = {
|
||||
[MCPTransportType.WEBSOCKET]: 'WebSocket',
|
||||
|
||||
@@ -15,7 +15,8 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import {
|
||||
DEFAULT_MCP_CONFIG,
|
||||
DEFAULT_CLIENT_VERSION,
|
||||
DEFAULT_IMAGE_MIME_TYPE
|
||||
DEFAULT_IMAGE_MIME_TYPE,
|
||||
MCP_PARTIAL_REDACT_HEADERS
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
MCPConnectionPhase,
|
||||
@@ -43,9 +44,17 @@ import {
|
||||
buildProxiedUrl,
|
||||
buildProxiedHeaders,
|
||||
getAuthHeaders,
|
||||
sanitizeHeaders,
|
||||
throwIfAborted,
|
||||
isAbortError,
|
||||
createBase64DataUrl
|
||||
createBase64DataUrl,
|
||||
getRequestUrl,
|
||||
getRequestMethod,
|
||||
getRequestBody,
|
||||
summarizeRequestBody,
|
||||
formatDiagnosticErrorMessage,
|
||||
extractJsonRpcMethods,
|
||||
type RequestBodySummary
|
||||
} from '$lib/utils';
|
||||
|
||||
interface ToolResultContentItem {
|
||||
@@ -62,6 +71,16 @@ interface ToolCallResult {
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DiagnosticRequestDetails {
|
||||
url: string;
|
||||
method: string;
|
||||
credentials?: RequestCredentials;
|
||||
mode?: RequestMode;
|
||||
headers: Record<string, string>;
|
||||
body: RequestBodySummary;
|
||||
jsonRpcMethods?: string[];
|
||||
}
|
||||
|
||||
export class MCPService {
|
||||
/**
|
||||
* Create a connection log entry for phase tracking.
|
||||
@@ -87,6 +106,225 @@ export class MCPService {
|
||||
};
|
||||
}
|
||||
|
||||
private static createDiagnosticRequestDetails(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
baseInit: RequestInit,
|
||||
requestHeaders: Headers,
|
||||
extraRedactedHeaders?: Iterable<string>
|
||||
): DiagnosticRequestDetails {
|
||||
const body = getRequestBody(input, init);
|
||||
const details: DiagnosticRequestDetails = {
|
||||
url: getRequestUrl(input),
|
||||
method: getRequestMethod(input, init, baseInit).toUpperCase(),
|
||||
credentials: init?.credentials ?? baseInit.credentials,
|
||||
mode: init?.mode ?? baseInit.mode,
|
||||
headers: sanitizeHeaders(requestHeaders, extraRedactedHeaders, MCP_PARTIAL_REDACT_HEADERS),
|
||||
body: summarizeRequestBody(body)
|
||||
};
|
||||
const jsonRpcMethods = extractJsonRpcMethods(body);
|
||||
|
||||
if (jsonRpcMethods) {
|
||||
details.jsonRpcMethods = jsonRpcMethods;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
private static summarizeError(error: unknown): Record<string, unknown> {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
cause:
|
||||
error.cause instanceof Error
|
||||
? { name: error.cause.name, message: error.cause.message }
|
||||
: error.cause,
|
||||
stack: error.stack?.split('\n').slice(0, 6).join('\n')
|
||||
};
|
||||
}
|
||||
|
||||
return { value: String(error) };
|
||||
}
|
||||
|
||||
private static getBrowserContext(
|
||||
targetUrl: URL,
|
||||
useProxy: boolean
|
||||
): Record<string, unknown> | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
location: window.location.href,
|
||||
origin: window.location.origin,
|
||||
protocol: window.location.protocol,
|
||||
isSecureContext: window.isSecureContext,
|
||||
targetOrigin: targetUrl.origin,
|
||||
targetProtocol: targetUrl.protocol,
|
||||
sameOrigin: window.location.origin === targetUrl.origin,
|
||||
useProxy
|
||||
};
|
||||
}
|
||||
|
||||
private static getConnectionHints(
|
||||
targetUrl: URL,
|
||||
config: MCPServerConfig,
|
||||
error: unknown
|
||||
): string[] {
|
||||
const hints: string[] = [];
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const headerNames = Object.keys(config.headers ?? {});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (
|
||||
window.location.protocol === 'https:' &&
|
||||
targetUrl.protocol === 'http:' &&
|
||||
!config.useProxy
|
||||
) {
|
||||
hints.push(
|
||||
'The page is running over HTTPS but the MCP server is HTTP. Browsers often block this as mixed content; enable the proxy or use HTTPS/WSS for the MCP server.'
|
||||
);
|
||||
}
|
||||
|
||||
if (window.location.origin !== targetUrl.origin && !config.useProxy) {
|
||||
hints.push(
|
||||
'This is a cross-origin browser request. If the server is reachable from curl or Node but not from the browser, missing CORS headers are the most likely cause.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (headerNames.length > 0) {
|
||||
hints.push(
|
||||
`Custom request headers are configured (${headerNames.join(', ')}). That triggers a CORS preflight, so the server must allow OPTIONS and include the matching Access-Control-Allow-Headers response.`
|
||||
);
|
||||
}
|
||||
|
||||
if (config.credentials && config.credentials !== 'omit') {
|
||||
hints.push(
|
||||
'Credentials are enabled for this connection. Cross-origin credentialed requests need Access-Control-Allow-Credentials: true and cannot use a wildcard Access-Control-Allow-Origin.'
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('Failed to fetch')) {
|
||||
hints.push(
|
||||
'"Failed to fetch" is a browser-level network failure. Common causes are CORS rejection, mixed-content blocking, certificate/TLS errors, DNS failures, or nothing listening on the target port.'
|
||||
);
|
||||
}
|
||||
|
||||
return hints;
|
||||
}
|
||||
|
||||
private static createDiagnosticFetch(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
baseInit: RequestInit,
|
||||
targetUrl: URL,
|
||||
useProxy: boolean,
|
||||
onLog?: (log: MCPConnectionLog) => void
|
||||
): {
|
||||
fetch: typeof fetch;
|
||||
disable: () => void;
|
||||
} {
|
||||
let enabled = true;
|
||||
const logIfEnabled = (log: MCPConnectionLog) => {
|
||||
if (enabled) {
|
||||
onLog?.(log);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
fetch: async (input, init) => {
|
||||
const startedAt = performance.now();
|
||||
const requestHeaders = new Headers(baseInit.headers);
|
||||
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
for (const [key, value] of input.headers.entries()) {
|
||||
requestHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (init?.headers) {
|
||||
for (const [key, value] of new Headers(init.headers).entries()) {
|
||||
requestHeaders.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const request = this.createDiagnosticRequestDetails(
|
||||
input,
|
||||
init,
|
||||
baseInit,
|
||||
requestHeaders,
|
||||
Object.keys(config.headers ?? {})
|
||||
);
|
||||
const { method, url } = request;
|
||||
|
||||
logIfEnabled(
|
||||
this.createLog(
|
||||
MCPConnectionPhase.INITIALIZING,
|
||||
`HTTP ${method} ${url}`,
|
||||
MCPLogLevel.INFO,
|
||||
{
|
||||
serverName,
|
||||
request
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
...baseInit,
|
||||
...init,
|
||||
headers: requestHeaders
|
||||
});
|
||||
const durationMs = Math.round(performance.now() - startedAt);
|
||||
|
||||
logIfEnabled(
|
||||
this.createLog(
|
||||
MCPConnectionPhase.INITIALIZING,
|
||||
`HTTP ${response.status} ${method} ${url} (${durationMs}ms)`,
|
||||
response.ok ? MCPLogLevel.INFO : MCPLogLevel.WARN,
|
||||
{
|
||||
response: {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: sanitizeHeaders(response.headers, undefined, MCP_PARTIAL_REDACT_HEADERS),
|
||||
durationMs
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - startedAt);
|
||||
|
||||
logIfEnabled(
|
||||
this.createLog(
|
||||
MCPConnectionPhase.ERROR,
|
||||
`HTTP ${method} ${url} failed: ${formatDiagnosticErrorMessage(error)}`,
|
||||
MCPLogLevel.ERROR,
|
||||
{
|
||||
serverName,
|
||||
request,
|
||||
error: this.summarizeError(error),
|
||||
browser: this.getBrowserContext(targetUrl, useProxy),
|
||||
hints: this.getConnectionHints(targetUrl, config, error),
|
||||
durationMs
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if an error indicates an expired/invalidated MCP session.
|
||||
* Per MCP spec 2025-11-25: HTTP 404 means session invalidated, client MUST
|
||||
@@ -113,9 +351,14 @@ export class MCPService {
|
||||
* @returns Object containing the created transport and the transport type used
|
||||
* @throws {Error} If url is missing, WebSocket + proxy combination, or all transports fail
|
||||
*/
|
||||
static createTransport(config: MCPServerConfig): {
|
||||
static createTransport(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
onLog?: (log: MCPConnectionLog) => void
|
||||
): {
|
||||
transport: Transport;
|
||||
type: MCPTransportType;
|
||||
stopPhaseLogging: () => void;
|
||||
} {
|
||||
if (!config.url) {
|
||||
throw new Error('MCP server configuration is missing url');
|
||||
@@ -154,11 +397,20 @@ export class MCPService {
|
||||
|
||||
return {
|
||||
transport: new WebSocketClientTransport(url),
|
||||
type: MCPTransportType.WEBSOCKET
|
||||
type: MCPTransportType.WEBSOCKET,
|
||||
stopPhaseLogging: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
const url = useProxy ? buildProxiedUrl(config.url) : new URL(config.url);
|
||||
const { fetch: diagnosticFetch, disable: stopPhaseLogging } = this.createDiagnosticFetch(
|
||||
serverName,
|
||||
config,
|
||||
requestInit,
|
||||
url,
|
||||
useProxy,
|
||||
onLog
|
||||
);
|
||||
|
||||
if (useProxy && import.meta.env.DEV) {
|
||||
console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
|
||||
@@ -171,17 +423,24 @@ export class MCPService {
|
||||
|
||||
return {
|
||||
transport: new StreamableHTTPClientTransport(url, {
|
||||
requestInit
|
||||
requestInit,
|
||||
fetch: diagnosticFetch
|
||||
}),
|
||||
type: MCPTransportType.STREAMABLE_HTTP
|
||||
type: MCPTransportType.STREAMABLE_HTTP,
|
||||
stopPhaseLogging
|
||||
};
|
||||
} catch (httpError) {
|
||||
console.warn(`[MCPService] StreamableHTTP failed, trying SSE transport...`, httpError);
|
||||
|
||||
try {
|
||||
return {
|
||||
transport: new SSEClientTransport(url, { requestInit }),
|
||||
type: MCPTransportType.SSE
|
||||
transport: new SSEClientTransport(url, {
|
||||
requestInit,
|
||||
fetch: diagnosticFetch,
|
||||
eventSourceInit: { fetch: diagnosticFetch }
|
||||
}),
|
||||
type: MCPTransportType.SSE,
|
||||
stopPhaseLogging
|
||||
};
|
||||
} catch (sseError) {
|
||||
const httpMsg = httpError instanceof Error ? httpError.message : String(httpError);
|
||||
@@ -263,7 +522,11 @@ export class MCPService {
|
||||
console.log(`[MCPService][${serverName}] Creating transport...`);
|
||||
}
|
||||
|
||||
const { transport, type: transportType } = this.createTransport(serverConfig);
|
||||
const {
|
||||
transport,
|
||||
type: transportType,
|
||||
stopPhaseLogging
|
||||
} = this.createTransport(serverName, serverConfig, (log) => onPhase?.(log.phase, log));
|
||||
|
||||
// Setup WebSocket reconnection handler
|
||||
if (transportType === MCPTransportType.WEBSOCKET) {
|
||||
@@ -294,6 +557,24 @@ export class MCPService {
|
||||
}
|
||||
);
|
||||
|
||||
const runtimeErrorHandler = (error: Error) => {
|
||||
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
|
||||
};
|
||||
|
||||
client.onerror = (error) => {
|
||||
onPhase?.(
|
||||
MCPConnectionPhase.ERROR,
|
||||
this.createLog(
|
||||
MCPConnectionPhase.ERROR,
|
||||
`Protocol error: ${error.message}`,
|
||||
MCPLogLevel.ERROR,
|
||||
{
|
||||
error: this.summarizeError(error)
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Phase: Initializing
|
||||
onPhase?.(
|
||||
MCPConnectionPhase.INITIALIZING,
|
||||
@@ -301,7 +582,49 @@ export class MCPService {
|
||||
);
|
||||
|
||||
console.log(`[MCPService][${serverName}] Connecting to server...`);
|
||||
await client.connect(transport);
|
||||
try {
|
||||
await client.connect(transport);
|
||||
// Transport diagnostics are only for the initial handshake, not long-lived traffic.
|
||||
stopPhaseLogging();
|
||||
client.onerror = runtimeErrorHandler;
|
||||
} catch (error) {
|
||||
client.onerror = runtimeErrorHandler;
|
||||
const url =
|
||||
(serverConfig.useProxy ?? false)
|
||||
? buildProxiedUrl(serverConfig.url)
|
||||
: new URL(serverConfig.url);
|
||||
|
||||
onPhase?.(
|
||||
MCPConnectionPhase.ERROR,
|
||||
this.createLog(
|
||||
MCPConnectionPhase.ERROR,
|
||||
`Connection failed during initialize: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
MCPLogLevel.ERROR,
|
||||
{
|
||||
error: this.summarizeError(error),
|
||||
config: {
|
||||
serverName,
|
||||
configuredUrl: serverConfig.url,
|
||||
effectiveUrl: url.href,
|
||||
transportType,
|
||||
useProxy: serverConfig.useProxy ?? false,
|
||||
headers: sanitizeHeaders(
|
||||
serverConfig.headers,
|
||||
Object.keys(serverConfig.headers ?? {}),
|
||||
MCP_PARTIAL_REDACT_HEADERS
|
||||
),
|
||||
credentials: serverConfig.credentials
|
||||
},
|
||||
browser: this.getBrowserContext(url, serverConfig.useProxy ?? false),
|
||||
hints: this.getConnectionHints(url, serverConfig, error)
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const serverVersion = client.getServerVersion();
|
||||
const serverCapabilities = client.getServerCapabilities();
|
||||
|
||||
@@ -1460,12 +1460,14 @@ class MCPStore {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
logs.push({
|
||||
timestamp: new Date(),
|
||||
phase: MCPConnectionPhase.ERROR,
|
||||
message: `Connection failed: ${message}`,
|
||||
level: MCPLogLevel.ERROR
|
||||
});
|
||||
if (logs.at(-1)?.phase !== MCPConnectionPhase.ERROR) {
|
||||
logs.push({
|
||||
timestamp: new Date(),
|
||||
phase: MCPConnectionPhase.ERROR,
|
||||
message: `Connection failed: ${message}`,
|
||||
level: MCPLogLevel.ERROR
|
||||
});
|
||||
}
|
||||
|
||||
this.updateHealthCheck(server.id, {
|
||||
status: HealthCheckStatus.ERROR,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { REDACTED_HEADERS } from '$lib/constants';
|
||||
import { redactValue } from './redact';
|
||||
|
||||
/**
|
||||
* Get authorization headers for API requests
|
||||
@@ -20,3 +22,46 @@ export function getJsonHeaders(): Record<string, string> {
|
||||
...getAuthHeaders()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTTP headers by redacting sensitive values.
|
||||
* Known sensitive headers (from REDACTED_HEADERS) and any extra headers
|
||||
* specified by the caller are fully redacted. Headers listed in
|
||||
* `partialRedactHeaders` are partially redacted, showing only the
|
||||
* specified number of trailing characters.
|
||||
*
|
||||
* @param headers - Headers to sanitize
|
||||
* @param extraRedactedHeaders - Additional header names to fully redact
|
||||
* @param partialRedactHeaders - Map of header name -> number of trailing chars to keep visible
|
||||
* @returns Object with header names as keys and (possibly redacted) values
|
||||
*/
|
||||
export function sanitizeHeaders(
|
||||
headers?: HeadersInit,
|
||||
extraRedactedHeaders?: Iterable<string>,
|
||||
partialRedactHeaders?: Map<string, number>
|
||||
): Record<string, string> {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized = new Headers(headers);
|
||||
const sanitized: Record<string, string> = {};
|
||||
const redactedHeaders = new Set(
|
||||
Array.from(extraRedactedHeaders ?? [], (header) => header.toLowerCase())
|
||||
);
|
||||
|
||||
for (const [key, value] of normalized.entries()) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
const partialChars = partialRedactHeaders?.get(normalizedKey);
|
||||
|
||||
if (partialChars !== undefined) {
|
||||
sanitized[key] = redactValue(value, partialChars);
|
||||
} else if (REDACTED_HEADERS.has(normalizedKey) || redactedHeaders.has(normalizedKey)) {
|
||||
sanitized[key] = redactValue(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
// API utilities
|
||||
export { getAuthHeaders, getJsonHeaders } from './api-headers';
|
||||
export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers';
|
||||
export { apiFetch, apiFetchWithParams, apiPost, type ApiFetchOptions } from './api-fetch';
|
||||
export { validateApiKey } from './api-key-validation';
|
||||
|
||||
@@ -164,6 +164,20 @@ export { runLegacyMigration, isMigrationNeeded } from './legacy-migration';
|
||||
// Cache utilities
|
||||
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
|
||||
|
||||
// Redaction utilities
|
||||
export { redactValue } from './redact';
|
||||
|
||||
// Request inspection utilities
|
||||
export {
|
||||
getRequestUrl,
|
||||
getRequestMethod,
|
||||
getRequestBody,
|
||||
summarizeRequestBody,
|
||||
formatDiagnosticErrorMessage,
|
||||
extractJsonRpcMethods,
|
||||
type RequestBodySummary
|
||||
} from './request-helpers';
|
||||
|
||||
// Abort signal utilities
|
||||
export {
|
||||
throwIfAborted,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Redacts a sensitive value, optionally showing the last N characters.
|
||||
*
|
||||
* @param value - The value to redact
|
||||
* @param showLastChars - If provided, reveals the last N characters with a leading mask
|
||||
* @returns The redacted string
|
||||
*/
|
||||
export function redactValue(value: string, showLastChars?: number): string {
|
||||
if (showLastChars) {
|
||||
return `....${value.slice(-showLastChars)}`;
|
||||
}
|
||||
|
||||
return '[redacted]';
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* HTTP request inspection utilities for diagnostic logging.
|
||||
* These helpers extract metadata from fetch-style request arguments
|
||||
* without exposing sensitive payload data.
|
||||
*/
|
||||
|
||||
export interface RequestBodySummary {
|
||||
kind: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function getRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (input instanceof URL) {
|
||||
return input.href;
|
||||
}
|
||||
|
||||
return input.url;
|
||||
}
|
||||
|
||||
export function getRequestMethod(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
baseInit?: RequestInit
|
||||
): string {
|
||||
if (init?.method) {
|
||||
return init.method;
|
||||
}
|
||||
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
return input.method;
|
||||
}
|
||||
|
||||
return baseInit?.method ?? 'GET';
|
||||
}
|
||||
|
||||
export function getRequestBody(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): BodyInit | null | undefined {
|
||||
if (init?.body !== undefined) {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
return input.body;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function summarizeRequestBody(body: BodyInit | null | undefined): RequestBodySummary {
|
||||
if (body == null) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return { kind: 'string', size: body.length };
|
||||
}
|
||||
|
||||
if (body instanceof Blob) {
|
||||
return { kind: 'blob', size: body.size };
|
||||
}
|
||||
|
||||
if (body instanceof URLSearchParams) {
|
||||
return { kind: 'urlsearchparams', size: body.toString().length };
|
||||
}
|
||||
|
||||
if (body instanceof FormData) {
|
||||
return { kind: 'formdata' };
|
||||
}
|
||||
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return { kind: 'arraybuffer', size: body.byteLength };
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return { kind: body.constructor.name, size: body.byteLength };
|
||||
}
|
||||
|
||||
return { kind: typeof body };
|
||||
}
|
||||
|
||||
export function formatDiagnosticErrorMessage(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return message.includes('Failed to fetch') ? `${message} (check CORS?)` : message;
|
||||
}
|
||||
|
||||
export function extractJsonRpcMethods(body: BodyInit | null | undefined): string[] | undefined {
|
||||
if (typeof body !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const methods = messages
|
||||
.map((message: Record<string, unknown>) =>
|
||||
typeof message?.method === 'string' ? (message.method as string) : undefined
|
||||
)
|
||||
.filter((method: string | undefined): method is string => Boolean(method));
|
||||
|
||||
return methods.length > 0 ? methods : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user