diff --git a/packages/core/src/utils-hoist/index.ts b/packages/core/src/utils-hoist/index.ts index fb90f8dcabb7..1625ea6c0868 100644 --- a/packages/core/src/utils-hoist/index.ts +++ b/packages/core/src/utils-hoist/index.ts @@ -73,6 +73,7 @@ export { extractRequestData, winterCGHeadersToDict, winterCGRequestToRequestData, + httpRequestToRequestData, extractQueryParamsFromUrl, headersToDict, } from './requestdata'; diff --git a/packages/core/src/utils-hoist/requestdata.ts b/packages/core/src/utils-hoist/requestdata.ts index a82fe2ff7fec..5a40c1fa5945 100644 --- a/packages/core/src/utils-hoist/requestdata.ts +++ b/packages/core/src/utils-hoist/requestdata.ts @@ -14,6 +14,7 @@ import { DEBUG_BUILD } from './debug-build'; import { isPlainObject, isString } from './is'; import { logger } from './logger'; import { normalize } from './normalize'; +import { dropUndefinedKeys } from './object'; import { truncate } from './string'; import { stripUrlQueryAndFragment } from './url'; import { getClientIPAddress, ipHeaderNames } from './vendor/getIpAddress'; @@ -451,6 +452,43 @@ export function winterCGRequestToRequestData(req: WebFetchRequest): RequestEvent }; } +/** + * Convert a HTTP request object to RequestEventData to be passed as normalizedRequest. + * Instead of allowing `PolymorphicRequest` to be passed, + * we want to be more specific and generally require a http.IncomingMessage-like object. + */ +export function httpRequestToRequestData(request: { + method?: string; + url?: string; + headers?: { + [key: string]: string | string[] | undefined; + }; + protocol?: string; + socket?: unknown; +}): RequestEventData { + const headers = request.headers || {}; + const host = headers.host || ''; + const protocol = request.socket && (request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'; + const originalUrl = request.url || ''; + const absoluteUrl = originalUrl.startsWith(protocol) ? originalUrl : `${protocol}://${host}${originalUrl}`; + + // This is non-standard, but may be sometimes set + // It may be overwritten later by our own body handling + const data = (request as PolymorphicRequest).body || undefined; + + // This is non-standard, but may be set on e.g. Next.js or Express requests + const cookies = (request as PolymorphicRequest).cookies; + + return dropUndefinedKeys({ + url: absoluteUrl, + method: request.method, + query_string: extractQueryParamsFromUrl(originalUrl), + headers: headersToDict(headers), + cookies, + data, + }); +} + /** Extract the query params from an URL. */ export function extractQueryParamsFromUrl(url: string): string | undefined { // url is path and query string diff --git a/packages/google-cloud-serverless/src/gcpfunction/http.ts b/packages/google-cloud-serverless/src/gcpfunction/http.ts index 0e45074fdda5..46b61c0ee8f1 100644 --- a/packages/google-cloud-serverless/src/gcpfunction/http.ts +++ b/packages/google-cloud-serverless/src/gcpfunction/http.ts @@ -2,11 +2,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, handleCallbackErrors, + httpRequestToRequestData, + isString, + logger, setHttpStatus, + stripUrlQueryAndFragment, } from '@sentry/core'; -import { isString, logger, stripUrlQueryAndFragment } from '@sentry/core'; import { captureException, continueTrace, flush, getCurrentScope, startSpanManual } from '@sentry/node'; - import { DEBUG_BUILD } from '../debug-build'; import { domainify, markEventUnhandled, proxyFunction } from '../utils'; import type { HttpFunction, WrapperOptions } from './general'; @@ -44,6 +46,9 @@ function _wrapHttpFunction(fn: HttpFunction, options: Partial): const baggage = req.headers?.baggage; return continueTrace({ sentryTrace, baggage }, () => { + const normalizedRequest = httpRequestToRequestData(req); + getCurrentScope().setSDKProcessingMetadata({ normalizedRequest }); + return startSpanManual( { name: `${reqMethod} ${reqUrl}`, @@ -54,10 +59,6 @@ function _wrapHttpFunction(fn: HttpFunction, options: Partial): }, }, span => { - getCurrentScope().setSDKProcessingMetadata({ - request: req, - }); - // eslint-disable-next-line @typescript-eslint/unbound-method const _end = res.end; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/google-cloud-serverless/test/gcpfunction/http.test.ts b/packages/google-cloud-serverless/test/gcpfunction/http.test.ts index 8b0168e61949..d19850ce6679 100644 --- a/packages/google-cloud-serverless/test/gcpfunction/http.test.ts +++ b/packages/google-cloud-serverless/test/gcpfunction/http.test.ts @@ -176,11 +176,12 @@ describe('GCPFunction', () => { expect(defaultIntegrations).toContain('RequestData'); expect(mockScope.setSDKProcessingMetadata).toHaveBeenCalledWith({ - request: { + normalizedRequest: { method: 'POST', - url: '/path?q=query', + url: 'http://hostname/path?q=query', headers: { host: 'hostname', 'content-type': 'application/json' }, - body: { foo: 'bar' }, + query_string: 'q=query', + data: { foo: 'bar' }, }, }); }); diff --git a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts index 41cbb497064a..a96b3131a56f 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/_error.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/_error.ts @@ -1,4 +1,4 @@ -import { captureException, withScope } from '@sentry/core'; +import { captureException, httpRequestToRequestData, withScope } from '@sentry/core'; import { vercelWaitUntil } from '@sentry/core'; import type { NextPageContext } from 'next'; import { flushSafelyWithTimeout } from '../utils/responseEnd'; @@ -38,7 +38,8 @@ export async function captureUnderscoreErrorException(contextOrProps: ContextOrP withScope(scope => { if (req) { - scope.setSDKProcessingMetadata({ request: req }); + const normalizedRequest = httpRequestToRequestData(req); + scope.setSDKProcessingMetadata({ normalizedRequest }); } // If third-party libraries (or users themselves) throw something falsy, we want to capture it as a message (which diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index f9beb8cabb87..347ac9ffd7e9 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -3,6 +3,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, continueTrace, + httpRequestToRequestData, setHttpStatus, startSpanManual, withIsolationScope, @@ -65,8 +66,9 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz }, () => { const reqMethod = `${(req.method || 'GET').toUpperCase()} `; + const normalizedRequest = httpRequestToRequestData(req); - isolationScope.setSDKProcessingMetadata({ request: req }); + isolationScope.setSDKProcessingMetadata({ normalizedRequest }); isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`); return startSpanManual( diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index 159ee669ec09..202a0f2e9c37 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -6,6 +6,7 @@ import { getIsolationScope, getRootSpan, getTraceData, + httpRequestToRequestData, } from '@sentry/core'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached'; @@ -61,10 +62,9 @@ export function withTracedServerSideDataFetcher Pr this: unknown, ...args: Parameters ): Promise<{ data: ReturnType; sentryTrace?: string; baggage?: string }> { + const normalizedRequest = httpRequestToRequestData(req); getCurrentScope().setTransactionName(`${options.dataFetchingMethodName} (${options.dataFetcherRouteName})`); - getIsolationScope().setSDKProcessingMetadata({ - request: req, - }); + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest }); const span = getActiveSpan(); diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 69daee26da39..058d856dfd81 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -8,9 +8,10 @@ import { handleCallbackErrors, setCapturedScopesOnSpan, startSpan, + vercelWaitUntil, + winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; -import { vercelWaitUntil, winterCGRequestToRequestData } from '@sentry/core'; import type { TransactionSource } from '@sentry/types'; import type { EdgeRouteHandler } from '../edge/types'; import { flushSafelyWithTimeout } from './utils/responseEnd'; diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 969727d1ed3d..4d948c9d37c4 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -5,17 +5,19 @@ import { VERSION } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; import { getRequestInfo } from '@opentelemetry/instrumentation-http'; -import { addBreadcrumb, getClient, getIsolationScope, withIsolationScope } from '@sentry/core'; import { - extractQueryParamsFromUrl, + addBreadcrumb, getBreadcrumbLogLevelFromHttpStatusCode, + getClient, + getIsolationScope, getSanitizedUrlString, - headersToDict, + httpRequestToRequestData, logger, parseUrl, stripUrlQueryAndFragment, + withIsolationScope, } from '@sentry/core'; -import type { PolymorphicRequest, RequestEventData, SanitizedRequestData, Scope } from '@sentry/types'; +import type { RequestEventData, SanitizedRequestData, Scope } from '@sentry/types'; import { DEBUG_BUILD } from '../../debug-build'; import type { NodeClient } from '../../sdk/client'; import { getRequestUrl } from '../../utils/getRequestUrl'; @@ -131,26 +133,10 @@ export class SentryHttpInstrumentation extends InstrumentationBase'; - const protocol = request.socket && (request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'; - const originalUrl = request.url || ''; - const absoluteUrl = originalUrl.startsWith(protocol) ? originalUrl : `${protocol}://${host}${originalUrl}`; - - // This is non-standard, but may be set on e.g. Next.js or Express requests - const cookies = (request as PolymorphicRequest).cookies; - - const normalizedRequest: RequestEventData = { - url: absoluteUrl, - method: request.method, - query_string: extractQueryParamsFromUrl(request.url || ''), - headers: headersToDict(request.headers), - cookies, - }; + const normalizedRequest = httpRequestToRequestData(request); patchRequestToCaptureBody(request, isolationScope); diff --git a/packages/remix/src/utils/errors.ts b/packages/remix/src/utils/errors.ts index 3d7e6edcfa00..d2e040bcd05c 100644 --- a/packages/remix/src/utils/errors.ts +++ b/packages/remix/src/utils/errors.ts @@ -1,13 +1,20 @@ import type { AppData, DataFunctionArgs, EntryContext, HandleDocumentRequestFunction } from '@remix-run/node'; -import { captureException, getClient, handleCallbackErrors } from '@sentry/core'; -import { addExceptionMechanism, isPrimitive, logger, objectify } from '@sentry/core'; -import type { Span } from '@sentry/types'; +import { + addExceptionMechanism, + captureException, + getClient, + handleCallbackErrors, + isPrimitive, + logger, + objectify, + winterCGRequestToRequestData, +} from '@sentry/core'; +import type { RequestEventData, Span } from '@sentry/types'; import { DEBUG_BUILD } from './debug-build'; import type { RemixOptions } from './remixOptions'; import { storeFormDataKeys } from './utils'; import { extractData, isResponse, isRouteErrorResponse } from './vendor/response'; import type { DataFunction, RemixRequest } from './vendor/types'; -import { normalizeRemixRequest } from './web-fetch'; /** * Captures an exception happened in the Remix server. @@ -41,12 +48,10 @@ export async function captureRemixServerException( return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let normalizedRequest: Record = request as unknown as any; + let normalizedRequest: RequestEventData = {}; try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - normalizedRequest = normalizeRemixRequest(request as unknown as any); + normalizedRequest = winterCGRequestToRequestData(request); } catch (e) { DEBUG_BUILD && logger.warn('Failed to normalize Remix request'); } @@ -54,11 +59,7 @@ export async function captureRemixServerException( const objectifiedErr = objectify(err); captureException(isResponse(objectifiedErr) ? await extractResponseError(objectifiedErr) : objectifiedErr, scope => { - scope.setSDKProcessingMetadata({ - request: { - ...normalizedRequest, - }, - }); + scope.setSDKProcessingMetadata({ normalizedRequest }); scope.addEventProcessor(event => { addExceptionMechanism(event, { diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 912cf368f6e9..ac1382bc5e00 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -11,11 +11,12 @@ import { spanToJSON, spanToTraceHeader, startSpan, + winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; import { dynamicSamplingContextToSentryBaggageHeader, fill, isNodeEnv, loadModule, logger } from '@sentry/core'; import { continueTrace, getDynamicSamplingContextFromSpan } from '@sentry/opentelemetry'; -import type { TransactionSource, WrappedFunction } from '@sentry/types'; +import type { RequestEventData, TransactionSource, WrappedFunction } from '@sentry/types'; import type { Span } from '@sentry/types'; import { DEBUG_BUILD } from './debug-build'; @@ -39,7 +40,6 @@ import type { ServerRoute, ServerRouteManifest, } from './vendor/types'; -import { normalizeRemixRequest } from './web-fetch'; let FUTURE_FLAGS: FutureConfig | undefined; @@ -296,10 +296,10 @@ function wrapRequestHandler( return withIsolationScope(async isolationScope => { const options = getClient()?.getOptions(); - let normalizedRequest: Record = request; + let normalizedRequest: RequestEventData = {}; try { - normalizedRequest = normalizeRemixRequest(request); + normalizedRequest = winterCGRequestToRequestData(request); } catch (e) { DEBUG_BUILD && logger.warn('Failed to normalize Remix request'); } @@ -311,11 +311,7 @@ function wrapRequestHandler( isolationScope.setTransactionName(name); } - isolationScope.setSDKProcessingMetadata({ - request: { - ...normalizedRequest, - }, - }); + isolationScope.setSDKProcessingMetadata({ normalizedRequest }); if (!options || !hasTracingEnabled(options)) { return origRequestHandler.call(this, request, loadContext); diff --git a/packages/remix/src/utils/web-fetch.ts b/packages/remix/src/utils/web-fetch.ts deleted file mode 100644 index 6e188bd9d440..000000000000 --- a/packages/remix/src/utils/web-fetch.ts +++ /dev/null @@ -1,180 +0,0 @@ -// Based on Remix's implementation of Fetch API -// https://github.com/remix-run/web-std-io/blob/d2a003fe92096aaf97ab2a618b74875ccaadc280/packages/fetch/ -// The MIT License (MIT) - -// Copyright (c) 2016 - 2020 Node Fetch Team - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import type { RemixRequest } from './vendor/types'; - -/* - * Symbol extractor utility to be able to access internal fields of Remix requests. - */ -const getInternalSymbols = ( - request: Record, -): { - bodyInternalsSymbol: string; - requestInternalsSymbol: string; -} => { - const symbols = Object.getOwnPropertySymbols(request); - return { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - bodyInternalsSymbol: symbols.find(symbol => symbol.toString().includes('Body internals')) as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - requestInternalsSymbol: symbols.find(symbol => symbol.toString().includes('Request internals')) as any, - }; -}; - -/** - * Vendored from: - * https://github.com/remix-run/web-std-io/blob/f715b354c8c5b8edc550c5442dec5712705e25e7/packages/fetch/src/utils/get-search.js#L5 - */ -export const getSearch = (parsedURL: URL): string => { - if (parsedURL.search) { - return parsedURL.search; - } - - const lastOffset = parsedURL.href.length - 1; - const hash = parsedURL.hash || (parsedURL.href[lastOffset] === '#' ? '#' : ''); - return parsedURL.href[lastOffset - hash.length] === '?' ? '?' : ''; -}; - -/** - * Convert a Request to Node.js http request options. - * The options object to be passed to http.request - * Vendored / modified from: - * https://github.com/remix-run/web-std-io/blob/f715b354c8c5b8edc550c5442dec5712705e25e7/packages/fetch/src/request.js#L259 - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const normalizeRemixRequest = (request: RemixRequest): Record => { - const { requestInternalsSymbol, bodyInternalsSymbol } = getInternalSymbols(request); - - if (!requestInternalsSymbol && !request.headers) { - throw new Error('Could not find request headers'); - } - - const internalRequest = request[requestInternalsSymbol]; - - const parsedURL = internalRequest ? internalRequest.parsedURL : new URL(request.url); - const headers = internalRequest ? new Headers(internalRequest.headers) : request.headers; - - // Fetch step 1.3 - if (!headers.has('Accept')) { - headers.set('Accept', '*/*'); - } - - // HTTP-network-or-cache fetch steps 2.4-2.7 - let contentLengthValue = null; - if (request.body === null && /^(post|put)$/i.test(request.method)) { - contentLengthValue = '0'; - } - - const internalBody = request[bodyInternalsSymbol]; - if (request.body !== null && internalBody) { - const totalBytes = internalBody.size; - // Set Content-Length if totalBytes is a number (that is not NaN) - if (typeof totalBytes === 'number' && !Number.isNaN(totalBytes)) { - contentLengthValue = String(totalBytes); - } - } - - if (contentLengthValue) { - headers.set('Content-Length', contentLengthValue); - } - - // HTTP-network-or-cache fetch step 2.11 - if (!headers.has('User-Agent')) { - headers.set('User-Agent', 'node-fetch'); - } - - // HTTP-network-or-cache fetch step 2.15 - if (request.compress && !headers.has('Accept-Encoding')) { - headers.set('Accept-Encoding', 'gzip,deflate,br'); - } - - let { agent } = request; - - if (typeof agent === 'function') { - agent = agent(parsedURL); - } - - if (!headers.has('Connection') && !agent) { - headers.set('Connection', 'close'); - } - - // HTTP-network fetch step 4.2 - // chunked encoding is handled by Node.js - const search = getSearch(parsedURL); - - // Manually spread the URL object instead of spread syntax - const requestOptions = { - path: parsedURL.pathname + search, - pathname: parsedURL.pathname, - hostname: parsedURL.hostname, - protocol: parsedURL.protocol, - port: parsedURL.port, - hash: parsedURL.hash, - search: parsedURL.search, - // @ts-expect-error - it does not has a query - query: parsedURL.query, - href: parsedURL.href, - method: request.method, - headers: objectFromHeaders(headers), - insecureHTTPParser: request.insecureHTTPParser, - agent, - - // [SENTRY] For compatibility with Sentry SDK RequestData parser, adding `originalUrl` property. - originalUrl: parsedURL.href, - }; - - return requestOptions; -}; - -// This function is a `polyfill` for Object.fromEntries() -function objectFromHeaders(headers: Headers): Record { - const result: Record = {}; - let iterator: IterableIterator<[string, string]>; - - if (hasIterator(headers)) { - iterator = getIterator(headers) as IterableIterator<[string, string]>; - } else { - return {}; - } - - for (const [key, value] of iterator) { - result[key] = value; - } - return result; -} - -type IterableType = { - [Symbol.iterator]: () => Iterator; -}; - -function hasIterator(obj: T): obj is T & IterableType { - return obj !== null && typeof (obj as IterableType)[Symbol.iterator] === 'function'; -} - -function getIterator(obj: T): Iterator { - if (hasIterator(obj)) { - return (obj as IterableType)[Symbol.iterator](); - } - throw new Error('Object does not have an iterator'); -} diff --git a/packages/remix/test/utils/normalizeRemixRequest.test.ts b/packages/remix/test/utils/normalizeRemixRequest.test.ts deleted file mode 100644 index 64de88510014..000000000000 --- a/packages/remix/test/utils/normalizeRemixRequest.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { RemixRequest } from '../../src/utils/vendor/types'; -import { normalizeRemixRequest } from '../../src/utils/web-fetch'; - -class Headers { - private _headers: Record = {}; - - constructor(headers?: Iterable<[string, string]>) { - if (headers) { - for (const [key, value] of headers) { - this.set(key, value); - } - } - } - static fromEntries(entries: Iterable<[string, string]>): Headers { - return new Headers(entries); - } - entries(): IterableIterator<[string, string]> { - return Object.entries(this._headers)[Symbol.iterator](); - } - - [Symbol.iterator](): IterableIterator<[string, string]> { - return this.entries(); - } - - get(key: string): string | null { - return this._headers[key] ?? null; - } - - has(key: string): boolean { - return this._headers[key] !== undefined; - } - - set(key: string, value: string): void { - this._headers[key] = value; - } -} - -class Request { - private _url: string; - private _options: { method: string; body?: any; headers: Headers }; - - constructor(url: string, options: { method: string; body?: any; headers: Headers }) { - this._url = url; - this._options = options; - } - - get method() { - return this._options.method; - } - - get url() { - return this._url; - } - - get headers() { - return this._options.headers; - } - - get body() { - return this._options.body; - } -} - -describe('normalizeRemixRequest', () => { - it('should normalize remix web-fetch request', () => { - const headers = new Headers(); - headers.set('Accept', 'text/html,application/json'); - headers.set('Cookie', 'name=value'); - const request = new Request('https://example.com/api/json?id=123', { - method: 'GET', - headers: headers as any, - }); - - const expected = { - agent: undefined, - hash: '', - headers: { - Accept: 'text/html,application/json', - Connection: 'close', - Cookie: 'name=value', - 'User-Agent': 'node-fetch', - }, - hostname: 'example.com', - href: 'https://example.com/api/json?id=123', - insecureHTTPParser: undefined, - method: 'GET', - originalUrl: 'https://example.com/api/json?id=123', - path: '/api/json?id=123', - pathname: '/api/json', - port: '', - protocol: 'https:', - query: undefined, - search: '?id=123', - }; - - const normalizedRequest = normalizeRemixRequest(request as unknown as RemixRequest); - expect(normalizedRequest).toEqual(expected); - }); -});