diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 825198426eec..8e916d75ffc4 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -24,31 +24,16 @@ import type { ReactRouterDomPkg, RemixRequest, RequestHandler, - RouteMatch, ServerBuild, ServerRoute, ServerRouteManifest, } from './types'; +import { extractData, getRequestMatch, isResponse, json, matchServerRoutes } from './vendor/response'; import { normalizeRemixRequest } from './web-fetch'; // Flag to track if the core request handler is instrumented. export let isRequestHandlerWrapped = false; -// Taken from Remix Implementation -// https://github.com/remix-run/remix/blob/32300ec6e6e8025602cea63e17a2201989589eab/packages/remix-server-runtime/responses.ts#L60-L77 -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isResponse(value: any): value is Response { - return ( - value != null && - /* eslint-disable @typescript-eslint/no-unsafe-member-access */ - typeof value.status === 'number' && - typeof value.statusText === 'string' && - typeof value.headers === 'object' && - typeof value.body !== 'undefined' - /* eslint-enable @typescript-eslint/no-unsafe-member-access */ - ); -} - const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); function isRedirectResponse(response: Response): boolean { return redirectStatusCodes.has(response.status); @@ -58,21 +43,6 @@ function isCatchResponse(response: Response): boolean { return response.headers.get('X-Remix-Catch') != null; } -// Based on Remix Implementation -// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/data.ts#L131-L145 -async function extractData(response: Response): Promise { - const contentType = response.headers.get('Content-Type'); - - // Cloning the response to avoid consuming the original body stream - const responseClone = response.clone(); - - if (contentType && /\bapplication\/json\b/.test(contentType)) { - return responseClone.json(); - } - - return responseClone.text(); -} - async function extractResponseError(response: Response): Promise { const responseData = await extractData(response); @@ -94,9 +64,11 @@ async function captureRemixServerException(err: Error, name: string, request: Re return; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any let normalizedRequest: Record = request as unknown as any; try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any normalizedRequest = normalizeRemixRequest(request as unknown as any); } catch (e) { __DEBUG_BUILD__ && logger.warn('Failed to normalize Remix request'); @@ -245,29 +217,6 @@ function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string } return {}; } -// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L1-L4 -export type JsonFunction = (data: Data, init?: number | ResponseInit) => Response; - -/** - * This is a shortcut for creating `application/json` responses. Converts `data` - * to JSON and sets the `Content-Type` header. - * - * @see https://remix.run/api/remix#json - * - * https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L12-L24 - */ -const json: JsonFunction = (data, init = {}) => { - const responseInit = typeof init === 'number' ? { status: init } : init; - const headers = new Headers(responseInit.headers); - if (!headers.has('Content-Type')) { - headers.set('Content-Type', 'application/json; charset=utf-8'); - } - return new Response(JSON.stringify(data), { - ...responseInit, - headers, - }); -}; - function makeWrappedRootLoader(origLoader: DataFunction): DataFunction { return async function (this: unknown, args: DataFunctionArgs): Promise { const res = await origLoader.call(this, args); @@ -307,31 +256,6 @@ export function createRoutes(manifest: ServerRouteManifest, parentId?: string): })); } -// Remix Implementation: -// https://github.com/remix-run/remix/blob/38e127b1d97485900b9c220d93503de0deb1fc81/packages/remix-server-runtime/routeMatching.ts#L12-L24 -// -// Changed so that `matchRoutes` function is passed in. -function matchServerRoutes( - routes: ServerRoute[], - pathname: string, - pkg?: ReactRouterDomPkg, -): RouteMatch[] | null { - if (!pkg) { - return null; - } - - const matches = pkg.matchRoutes(routes, pathname); - if (!matches) { - return null; - } - - return matches.map(match => ({ - params: match.params, - pathname: match.pathname, - route: match.route, - })); -} - /** * Starts a new transaction for the given request to be used by different `RequestHandler` wrappers. * @@ -445,33 +369,6 @@ function wrapRequestHandler(origRequestHandler: RequestHandler, build: ServerBui }; } -// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L573-L586 -function isIndexRequestUrl(url: URL): boolean { - for (const param of url.searchParams.getAll('index')) { - // only use bare `?index` params without a value - // ✅ /foo?index - // ✅ /foo?index&index=123 - // ✅ /foo?index=123&index - // ❌ /foo?index=123 - if (param === '') { - return true; - } - } - - return false; -} - -// https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L588-L596 -function getRequestMatch(url: URL, matches: RouteMatch[]): RouteMatch { - const match = matches.slice(-1)[0]; - - if (!isIndexRequestUrl(url) && match.route.id.endsWith('/index')) { - return matches.slice(-2)[0]; - } - - return match; -} - /** * Instruments `remix` ServerBuild for performance tracing and error tracking. */ diff --git a/packages/remix/src/utils/types.ts b/packages/remix/src/utils/types.ts index 225a3ea1ad56..e57b8ad15e34 100644 --- a/packages/remix/src/utils/types.ts +++ b/packages/remix/src/utils/types.ts @@ -1,7 +1,15 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable max-lines */ /* eslint-disable @typescript-eslint/ban-types */ // Types vendored from @remix-run/server-runtime@1.6.0: // https://github.com/remix-run/remix/blob/f3691d51027b93caa3fd2cdfe146d7b62a6eb8f2/packages/remix-server-runtime/server.ts +// Copyright 2021 Remix Software Inc. +// +// 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 * as Express from 'express'; import type { Agent } from 'https'; import type { ComponentType } from 'react'; diff --git a/packages/remix/src/utils/getIpAddress.ts b/packages/remix/src/utils/vendor/getIpAddress.ts similarity index 66% rename from packages/remix/src/utils/getIpAddress.ts rename to packages/remix/src/utils/vendor/getIpAddress.ts index a756c424f40e..d63e31779aac 100644 --- a/packages/remix/src/utils/getIpAddress.ts +++ b/packages/remix/src/utils/vendor/getIpAddress.ts @@ -1,5 +1,27 @@ // Vendored / modified from @sergiodxa/remix-utils + // https://github.com/sergiodxa/remix-utils/blob/02af80e12829a53696bfa8f3c2363975cf59f55e/src/server/get-client-ip-address.ts +// MIT License + +// Copyright (c) 2021 Sergio Xalambrí + +// 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 { isIP } from 'net'; diff --git a/packages/remix/src/utils/vendor/response.ts b/packages/remix/src/utils/vendor/response.ts new file mode 100644 index 000000000000..ab5e02425c8e --- /dev/null +++ b/packages/remix/src/utils/vendor/response.ts @@ -0,0 +1,126 @@ +// Copyright 2021 Remix Software Inc. +// +// 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 { ReactRouterDomPkg, RouteMatch, ServerRoute } from '../types'; + +/** + * Based on Remix Implementation + * + * https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/data.ts#L131-L145 + */ +export async function extractData(response: Response): Promise { + const contentType = response.headers.get('Content-Type'); + + // Cloning the response to avoid consuming the original body stream + const responseClone = response.clone(); + + if (contentType && /\bapplication\/json\b/.test(contentType)) { + return responseClone.json(); + } + + return responseClone.text(); +} + +/** + * Taken from Remix Implementation + * + * https://github.com/remix-run/remix/blob/32300ec6e6e8025602cea63e17a2201989589eab/packages/remix-server-runtime/responses.ts#L60-L77 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isResponse(value: any): value is Response { + return ( + value != null && + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + typeof value.status === 'number' && + typeof value.statusText === 'string' && + typeof value.headers === 'object' && + typeof value.body !== 'undefined' + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ + ); +} + +// https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L1-L4 +export type JsonFunction = (data: Data, init?: number | ResponseInit) => Response; + +/** + * This is a shortcut for creating `application/json` responses. Converts `data` + * to JSON and sets the `Content-Type` header. + * + * @see https://remix.run/api/remix#json + * + * https://github.com/remix-run/remix/blob/7688da5c75190a2e29496c78721456d6e12e3abe/packages/remix-server-runtime/responses.ts#L12-L24 + */ +export const json: JsonFunction = (data, init = {}) => { + const responseInit = typeof init === 'number' ? { status: init } : init; + const headers = new Headers(responseInit.headers); + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json; charset=utf-8'); + } + return new Response(JSON.stringify(data), { + ...responseInit, + headers, + }); +}; + +/** + * Remix Implementation: + * https://github.com/remix-run/remix/blob/38e127b1d97485900b9c220d93503de0deb1fc81/packages/remix-server-runtime/routeMatching.ts#L12-L24 + * + * Changed so that `matchRoutes` function is passed in. + */ +export function matchServerRoutes( + routes: ServerRoute[], + pathname: string, + pkg?: ReactRouterDomPkg, +): RouteMatch[] | null { + if (!pkg) { + return null; + } + + const matches = pkg.matchRoutes(routes, pathname); + if (!matches) { + return null; + } + + return matches.map(match => ({ + params: match.params, + pathname: match.pathname, + route: match.route, + })); +} + +/** + * https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L573-L586 + */ +export function isIndexRequestUrl(url: URL): boolean { + for (const param of url.searchParams.getAll('index')) { + // only use bare `?index` params without a value + // ✅ /foo?index + // ✅ /foo?index&index=123 + // ✅ /foo?index=123&index + // ❌ /foo?index=123 + if (param === '') { + return true; + } + } + + return false; +} + +/** + * https://github.com/remix-run/remix/blob/97999d02493e8114c39d48b76944069d58526e8d/packages/remix-server-runtime/server.ts#L588-L596 + */ +export function getRequestMatch(url: URL, matches: RouteMatch[]): RouteMatch { + const match = matches.slice(-1)[0]; + + if (!isIndexRequestUrl(url) && match.route.id.endsWith('/index')) { + return matches.slice(-2)[0]; + } + + return match; +} diff --git a/packages/remix/src/utils/web-fetch.ts b/packages/remix/src/utils/web-fetch.ts index 853eac775c84..1e69a77b5dba 100644 --- a/packages/remix/src/utils/web-fetch.ts +++ b/packages/remix/src/utils/web-fetch.ts @@ -1,10 +1,31 @@ // Based on Remix's implementation of Fetch API -// https://github.com/remix-run/web-std-io/tree/main/packages/fetch +// 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 { logger } from '@sentry/utils'; -import { getClientIPAddress } from './getIpAddress'; import type { RemixRequest } from './types'; +import { getClientIPAddress } from './vendor/getIpAddress'; /* * Symbol extractor utility to be able to access internal fields of Remix requests. @@ -17,7 +38,9 @@ const getInternalSymbols = ( } => { 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, }; }; @@ -42,6 +65,7 @@ export const getSearch = (parsedURL: URL): string => { * 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); diff --git a/packages/remix/test/utils/getIpAddress.test.ts b/packages/remix/test/utils/getIpAddress.test.ts index f5cf1df07068..9e05dec9515a 100644 --- a/packages/remix/test/utils/getIpAddress.test.ts +++ b/packages/remix/test/utils/getIpAddress.test.ts @@ -1,4 +1,4 @@ -import { getClientIPAddress } from '../../src/utils/getIpAddress'; +import { getClientIPAddress } from '../../src/utils/vendor/getIpAddress'; class Headers { private _headers: Record = {};