diff --git a/CHANGELOG.md b/CHANGELOG.md index fce135710fb8..9554dca0f2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **fix(node): Avoid double-wrapping http module ([#16177](https://github.com/getsentry/sentry-javascript/pull/16177))** + +When running your application in ESM mode, there have been scenarios that resulted in the `http`/`https` emitting duplicate spans for incoming requests. This was apparently caused by us double-wrapping the modules for incoming request isolation. + +In order to solve this problem, the modules are no longer monkey patched by us for request isolation. Instead, we register diagnostics*channel hooks to handle request isolation now. +While this is generally not expected to break anything, there is one tiny change that \_may* affect you if you have been relying on very specific functionality: + +The `ignoreOutgoingRequests` option of `httpIntegration` receives the `RequestOptions` as second argument. This type is not changed, however due to how the wrapping now works, we no longer pass through the full RequestOptions, but re-construct this partially based on the generated request. For the vast majority of cases, this should be fine, but for the sake of completeness, these are the only fields that may be available there going forward - other fields that _may_ have existed before may no longer be set: + +```ts +ignoreOutgoingRequests(url: string, { + method: string; + protocol: string; + host: string; + hostname: string; // same as host + path: string; + headers: OutgoingHttpHeaders; +}) +``` + ## 9.15.0 ### Important Changes diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index a1dceaeebfda..718fdf478053 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -16,7 +16,7 @@ "clean": "rimraf tmp node_modules && yarn clean:test-applications && yarn clean:pnpm", "ci:build-matrix": "ts-node ./lib/getTestMatrix.ts", "ci:build-matrix-optional": "ts-node ./lib/getTestMatrix.ts --optional=true", - "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,pnpm-lock.yaml,.last-run.json,test-results}", + "clean:test-applications": "rimraf --glob test-applications/**/{node_modules,dist,build,.next,.nuxt,.sveltekit,.react-router,.astro,.output,pnpm-lock.yaml,.last-run.json,test-results}", "clean:pnpm": "pnpm store prune" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts index 2bcf6cbf2362..eb70f7362e63 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts @@ -62,7 +62,6 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { }); expect(serverPageRequestTxn).toMatchObject({ - breadcrumbs: expect.any(Array), contexts: { app: expect.any(Object), cloud_resource: expect.any(Object), diff --git a/dev-packages/node-integration-tests/suites/express/with-http/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/test.ts index ef27c3129020..10dbefa74a9a 100644 --- a/dev-packages/node-integration-tests/suites/express/with-http/test.ts +++ b/dev-packages/node-integration-tests/suites/express/with-http/test.ts @@ -6,36 +6,28 @@ describe('express with http import', () => { cleanupChildProcesses(); }); - createEsmAndCjsTests( - __dirname, - 'scenario.mjs', - 'instrument.mjs', - (createRunner, test) => { - test('it works when importing the http module', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /test2', - }, - }) - .expect({ - transaction: { - transaction: 'GET /test', - }, - }) - .expect({ - transaction: { - transaction: 'GET /test3', - }, - }) - .start(); - await runner.makeRequest('get', '/test'); - await runner.makeRequest('get', '/test3'); - await runner.completed(); - }); - // TODO: This is failing on ESM because importing http is triggering the http spans twice :( - // We need to fix this! - }, - { failsOnEsm: true }, - ); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('it works when importing the http module', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /test2', + }, + }) + .expect({ + transaction: { + transaction: 'GET /test', + }, + }) + .expect({ + transaction: { + transaction: 'GET /test3', + }, + }) + .start(); + await runner.makeRequest('get', '/test'); + await runner.makeRequest('get', '/test3'); + await runner.completed(); + }); + }); }); diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts index 5dbdeae2f925..4e044879d2aa 100644 --- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts +++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines */ +import type { ChannelListener } from 'node:diagnostics_channel'; +import { subscribe, unsubscribe } from 'node:diagnostics_channel'; import type * as http from 'node:http'; -import type { IncomingMessage, RequestOptions } from 'node:http'; import type * as https from 'node:https'; import type { EventEmitter } from 'node:stream'; import { context, propagation } from '@opentelemetry/api'; @@ -10,6 +11,7 @@ import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opent import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core'; import { addBreadcrumb, + addNonEnumerableProperty, generateSpanId, getBreadcrumbLogLevelFromHttpStatusCode, getClient, @@ -24,14 +26,12 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import { getRequestUrl } from '../../utils/getRequestUrl'; -import { stealthWrap } from './utils'; -import { getRequestInfo } from './vendor/getRequestInfo'; + +const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; type Http = typeof http; type Https = typeof https; -const INSTRUMENTATION_NAME = '@sentry/instrumentation-http'; - export type SentryHttpInstrumentationOptions = InstrumentationConfig & { /** * Whether breadcrumbs should be recorded for requests. @@ -58,7 +58,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. * @param request Contains the {@type RequestOptions} object used to make the outgoing request. */ - ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean; + ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean; /** * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. @@ -67,7 +67,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & { * @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request. * @param request Contains the {@type RequestOptions} object used to make the outgoing request. */ - ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean; + ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean; /** * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry. @@ -108,71 +108,134 @@ export class SentryHttpInstrumentation extends InstrumentationBase { + const data = _data as { server: http.Server }; + this._patchServerEmitOnce(data.server); + }) satisfies ChannelListener; + + const onHttpClientResponseFinish = ((_data: unknown) => { + const data = _data as { request: http.ClientRequest; response: http.IncomingMessage }; + this._onOutgoingRequestFinish(data.request, data.response); + }) satisfies ChannelListener; + + const onHttpClientRequestError = ((_data: unknown) => { + const data = _data as { request: http.ClientRequest }; + this._onOutgoingRequestFinish(data.request, undefined); + }) satisfies ChannelListener; + + /** + * You may be wondering why we register these diagnostics-channel listeners + * in such a convoluted way (as InstrumentationNodeModuleDefinition...)˝, + * instead of simply subscribing to the events once in here. + * The reason for this is timing semantics: These functions are called once the http or https module is loaded. + * If we'd subscribe before that, there seem to be conflicts with the OTEL native instrumentation in some scenarios, + * especially the "import-on-top" pattern of setting up ESM applications. + */ + return [ + new InstrumentationNodeModuleDefinition( + 'http', + ['*'], + (moduleExports: Http): Http => { + if (hasRegisteredHandlers) { + return moduleExports; + } - /** Get the instrumentation for the http module. */ - private _getHttpInstrumentation(): InstrumentationNodeModuleDefinition { - return new InstrumentationNodeModuleDefinition( - 'http', - ['*'], - (moduleExports: Http): Http => { - // Patch incoming requests for request isolation - stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + hasRegisteredHandlers = true; + + subscribe('http.server.request.start', onHttpServerRequestStart); + subscribe('http.client.response.finish', onHttpClientResponseFinish); + + // When an error happens, we still want to have a breadcrumb + // In this case, `http.client.response.finish` is not triggered + subscribe('http.client.request.error', onHttpClientRequestError); + + return moduleExports; + }, + () => { + unsubscribe('http.server.request.start', onHttpServerRequestStart); + unsubscribe('http.client.response.finish', onHttpClientResponseFinish); + unsubscribe('http.client.request.error', onHttpClientRequestError); + }, + ), + new InstrumentationNodeModuleDefinition( + 'https', + ['*'], + (moduleExports: Https): Https => { + if (hasRegisteredHandlers) { + return moduleExports; + } - // Patch outgoing requests for breadcrumbs - const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); - stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); + hasRegisteredHandlers = true; - return moduleExports; - }, - () => { - // no unwrap here - }, - ); + subscribe('http.server.request.start', onHttpServerRequestStart); + subscribe('http.client.response.finish', onHttpClientResponseFinish); + + // When an error happens, we still want to have a breadcrumb + // In this case, `http.client.response.finish` is not triggered + subscribe('http.client.request.error', onHttpClientRequestError); + + return moduleExports; + }, + () => { + unsubscribe('http.server.request.start', onHttpServerRequestStart); + unsubscribe('http.client.response.finish', onHttpClientResponseFinish); + unsubscribe('http.client.request.error', onHttpClientRequestError); + }, + ), + ]; } - /** Get the instrumentation for the https module. */ - private _getHttpsInstrumentation(): InstrumentationNodeModuleDefinition { - return new InstrumentationNodeModuleDefinition( - 'https', - ['*'], - (moduleExports: Https): Https => { - // Patch incoming requests for request isolation - stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction()); + /** + * This is triggered when an outgoing request finishes. + * It has access to the final request and response objects. + */ + private _onOutgoingRequestFinish(request: http.ClientRequest, response?: http.IncomingMessage): void { + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling finished outgoing request'); + + const _breadcrumbs = this.getConfig().breadcrumbs; + const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; + const options = getRequestOptions(request); - // Patch outgoing requests for breadcrumbs - const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction()); - stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest)); + const _ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests; + const shouldCreateBreadcrumb = + typeof _ignoreOutgoingRequests === 'function' ? !_ignoreOutgoingRequests(getRequestUrl(request), options) : true; - return moduleExports; - }, - () => { - // no unwrap here - }, - ); + if (breadCrumbsEnabled && shouldCreateBreadcrumb) { + addRequestBreadcrumb(request, response); + } } /** - * Patch the incoming request function for request isolation. + * Patch a server.emit function to handle process isolation for incoming requests. + * This will only patch the emit function if it was not already patched. */ - private _getPatchIncomingRequestFunction(): ( - original: (event: string, ...args: unknown[]) => boolean, - ) => (this: unknown, event: string, ...args: unknown[]) => boolean { + private _patchServerEmitOnce(server: http.Server): void { + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalEmit = server.emit; + + // This means it was already patched, do nothing + if ((originalEmit as { __sentry_patched__?: boolean }).__sentry_patched__) { + return; + } + + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Patching server.emit'); + // eslint-disable-next-line @typescript-eslint/no-this-alias const instrumentation = this; const { ignoreIncomingRequestBody } = instrumentation.getConfig(); - return ( - original: (event: string, ...args: unknown[]) => boolean, - ): ((this: unknown, event: string, ...args: unknown[]) => boolean) => { - return function incomingRequest(this: unknown, ...args: [event: string, ...args: unknown[]]): boolean { + const newEmit = new Proxy(originalEmit, { + apply(target, thisArg, args: [event: string, ...args: unknown[]]) { // Only traces request events if (args[0] !== 'request') { - return original.apply(this, args); + return target.apply(thisArg, args); } - instrumentation._diag.debug('http instrumentation for incoming request'); + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling incoming request'); const isolationScope = getIsolationScope().clone(); const request = args[1] as http.IncomingMessage; @@ -217,97 +280,28 @@ export class SentryHttpInstrumentation extends InstrumentationBase { - return original.apply(this, args); + return target.apply(thisArg, args); }); }); - }; - }; - } - - /** - * Patch the outgoing request function for breadcrumbs. - */ - private _getPatchOutgoingRequestFunction(): ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - original: (...args: any[]) => http.ClientRequest, - ) => (options: URL | http.RequestOptions | string, ...args: unknown[]) => http.ClientRequest { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const instrumentation = this; - - return (original: (...args: unknown[]) => http.ClientRequest): ((...args: unknown[]) => http.ClientRequest) => { - return function outgoingRequest(this: unknown, ...args: unknown[]): http.ClientRequest { - instrumentation._diag.debug('http instrumentation for outgoing requests'); - - // Making a copy to avoid mutating the original args array - // We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests` - // so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`. - // @see https://github.com/open-telemetry/opentelemetry-js/blob/7293e69c1e55ca62e15d0724d22605e61bd58952/experimental/packages/opentelemetry-instrumentation-http/src/http.ts#L756-L789 - const argsCopy = [...args]; - - const options = argsCopy.shift() as URL | http.RequestOptions | string; - - const extraOptions = - typeof argsCopy[0] === 'object' && (typeof options === 'string' || options instanceof URL) - ? (argsCopy.shift() as http.RequestOptions) - : undefined; - - const { optionsParsed } = getRequestInfo(instrumentation._diag, options, extraOptions); - - const request = original.apply(this, args) as ReturnType; - - request.prependListener('response', (response: http.IncomingMessage) => { - const _breadcrumbs = instrumentation.getConfig().breadcrumbs; - const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs; - - const _ignoreOutgoingRequests = instrumentation.getConfig().ignoreOutgoingRequests; - const shouldCreateBreadcrumb = - typeof _ignoreOutgoingRequests === 'function' - ? !_ignoreOutgoingRequests(getRequestUrl(request), optionsParsed) - : true; - - if (breadCrumbsEnabled && shouldCreateBreadcrumb) { - addRequestBreadcrumb(request, response); - } - }); + }, + }); - return request; - }; - }; - } + addNonEnumerableProperty(newEmit, '__sentry_patched__', true); - /** Path the outgoing get function for breadcrumbs. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _getPatchOutgoingGetFunction(clientRequest: (...args: any[]) => http.ClientRequest) { - return (_original: unknown): ((...args: unknown[]) => http.ClientRequest) => { - // Re-implement http.get. This needs to be done (instead of using - // getPatchOutgoingRequestFunction to patch it) because we need to - // set the trace context header before the returned http.ClientRequest is - // ended. The Node.js docs state that the only differences between - // request and get are that (1) get defaults to the HTTP GET method and - // (2) the returned request object is ended immediately. The former is - // already true (at least in supported Node versions up to v10), so we - // simply follow the latter. Ref: - // https://nodejs.org/dist/latest/docs/api/http.html#http_http_get_options_callback - // https://github.com/googleapis/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-http.ts#L198 - return function outgoingGetRequest(...args: unknown[]): http.ClientRequest { - const req = clientRequest(...args); - req.end(); - return req; - }; - }; + server.emit = newEmit; } } /** Add a breadcrumb for outgoing requests. */ -function addRequestBreadcrumb(request: http.ClientRequest, response: http.IncomingMessage): void { +function addRequestBreadcrumb(request: http.ClientRequest, response: http.IncomingMessage | undefined): void { const data = getBreadcrumbData(request); - const statusCode = response.statusCode; + const statusCode = response?.statusCode; const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); addBreadcrumb( @@ -359,10 +353,12 @@ function getBreadcrumbData(request: http.ClientRequest): Partial) => { const [event, listener, ...restArgs] = args; - if (DEBUG_BUILD) { - logger.log(INSTRUMENTATION_NAME, 'Patching request.on', event); - } - if (event === 'data') { + DEBUG_BUILD && logger.log(INSTRUMENTATION_NAME, 'Handling request.on("data")'); const callback = new Proxy(listener, { apply: (target, thisArg, args: Parameters) => { try { @@ -451,6 +444,17 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): } } +function getRequestOptions(request: http.ClientRequest): http.RequestOptions { + return { + method: request.method, + protocol: request.protocol, + host: request.host, + hostname: request.host, + path: request.path, + headers: request.getHeaders(), + }; +} + /** * Starts a session and tracks it in the context of a given isolation scope. * When the passed response is finished, the session is put into a task and is diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index f46724aa9b72..9fe4792e12a3 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -151,6 +151,7 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => return { name: INTEGRATION_NAME, setupOnce() { + // TODO: get rid of this too // Below, we instrument the Node.js HTTP API three times. 2 times Sentry-specific, 1 time OTEL specific. // Due to timing reasons, we sometimes need to apply Sentry instrumentation _before_ we apply the OTEL // instrumentation (e.g. to flush on serverless platforms), and sometimes we need to apply Sentry instrumentation @@ -165,19 +166,19 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => const instrumentSpans = _shouldInstrumentSpans(options, getClient()?.getOptions()); - // This is the "regular" OTEL instrumentation that emits spans - if (instrumentSpans) { - const instrumentationConfig = getConfigWithDefaults(options); - instrumentOtelHttp(instrumentationConfig); - } - - // This is Sentry-specific instrumentation that is applied _after_ any OTEL instrumentation. + // This is Sentry-specific instrumentation for request isolation and breadcrumbs instrumentSentryHttp({ ...options, // If spans are not instrumented, it means the HttpInstrumentation has not been added // In that case, we want to handle incoming trace extraction ourselves extractIncomingTraceFromHeader: !instrumentSpans, }); + + // This is the "regular" OTEL instrumentation that emits spans + if (instrumentSpans) { + const instrumentationConfig = getConfigWithDefaults(options); + instrumentOtelHttp(instrumentationConfig); + } }, }; }); diff --git a/packages/node/src/integrations/http/vendor/getRequestInfo.ts b/packages/node/src/integrations/http/vendor/getRequestInfo.ts deleted file mode 100644 index 4fbb78e46f17..000000000000 --- a/packages/node/src/integrations/http/vendor/getRequestInfo.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* eslint-disable complexity */ - -/** - * Vendored in from https://github.com/open-telemetry/opentelemetry-js/commit/87bd98edd24c98a5fbb9a56fed4b673b7f17a724 - */ - -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import type { RequestOptions } from 'node:http'; -import type { DiagLogger } from '@opentelemetry/api'; -import * as url from 'url'; - -/** - * Makes sure options is an url object - * return an object with default value and parsed options - * @param logger component logger - * @param options original options for the request - * @param [extraOptions] additional options for the request - */ -export const getRequestInfo = ( - logger: DiagLogger, - options: url.URL | RequestOptions | string, - extraOptions?: RequestOptions, -): { - origin: string; - pathname: string; - method: string; - invalidUrl: boolean; - optionsParsed: RequestOptions; -} => { - let pathname: string; - let origin: string; - let optionsParsed: RequestOptions; - let invalidUrl = false; - if (typeof options === 'string') { - try { - const convertedOptions = stringUrlToHttpOptions(options); - optionsParsed = convertedOptions; - pathname = convertedOptions.pathname || '/'; - } catch (e) { - invalidUrl = true; - logger.verbose( - 'Unable to parse URL provided to HTTP request, using fallback to determine path. Original error:', - e, - ); - // for backward compatibility with how url.parse() behaved. - optionsParsed = { - path: options, - }; - pathname = optionsParsed.path || '/'; - } - - origin = `${optionsParsed.protocol || 'http:'}//${optionsParsed.host}`; - if (extraOptions !== undefined) { - Object.assign(optionsParsed, extraOptions); - } - } else if (options instanceof url.URL) { - optionsParsed = { - protocol: options.protocol, - hostname: - typeof options.hostname === 'string' && options.hostname.startsWith('[') - ? options.hostname.slice(1, -1) - : options.hostname, - path: `${options.pathname || ''}${options.search || ''}`, - }; - if (options.port !== '') { - optionsParsed.port = Number(options.port); - } - if (options.username || options.password) { - optionsParsed.auth = `${options.username}:${options.password}`; - } - pathname = options.pathname; - origin = options.origin; - if (extraOptions !== undefined) { - Object.assign(optionsParsed, extraOptions); - } - } else { - optionsParsed = Object.assign({ protocol: options.host ? 'http:' : undefined }, options); - - const hostname = - optionsParsed.host || - (optionsParsed.port != null ? `${optionsParsed.hostname}${optionsParsed.port}` : optionsParsed.hostname); - origin = `${optionsParsed.protocol || 'http:'}//${hostname}`; - - pathname = (options as url.URL).pathname; - if (!pathname && optionsParsed.path) { - try { - const parsedUrl = new URL(optionsParsed.path, origin); - pathname = parsedUrl.pathname || '/'; - } catch (e) { - pathname = '/'; - } - } - } - - // some packages return method in lowercase.. - // ensure upperCase for consistency - const method = optionsParsed.method ? optionsParsed.method.toUpperCase() : 'GET'; - - return { origin, pathname, method, optionsParsed, invalidUrl }; -}; - -/** - * Mimics Node.js conversion of URL strings to RequestOptions expected by - * `http.request` and `https.request` APIs. - * - * See https://github.com/nodejs/node/blob/2505e217bba05fc581b572c685c5cf280a16c5a3/lib/internal/url.js#L1415-L1437 - * - * @param stringUrl - * @throws TypeError if the URL is not valid. - */ -function stringUrlToHttpOptions(stringUrl: string): RequestOptions & { pathname: string } { - // This is heavily inspired by Node.js handling of the same situation, trying - // to follow it as closely as possible while keeping in mind that we only - // deal with string URLs, not URL objects. - const { hostname, pathname, port, username, password, search, protocol, hash, href, origin, host } = new URL( - stringUrl, - ); - - const options: RequestOptions & { - pathname: string; - hash: string; - search: string; - href: string; - origin: string; - } = { - protocol: protocol, - hostname: hostname && hostname[0] === '[' ? hostname.slice(1, -1) : hostname, - hash: hash, - search: search, - pathname: pathname, - path: `${pathname || ''}${search || ''}`, - href: href, - origin: origin, - host: host, - }; - if (port !== '') { - options.port = Number(port); - } - if (username || password) { - options.auth = `${decodeURIComponent(username)}:${decodeURIComponent(password)}`; - } - return options; -}