next.js/packages/next/src/server/server-utils.ts
server-utils.ts504 lines14.5 KB
import type { Rewrite } from '../lib/load-custom-routes'
import type { RouteMatchFn } from '../shared/lib/router/utils/route-matcher'
import type { NextConfig } from './config'
import type { BaseNextRequest } from './base-http'
import type { NextUrlWithParsedQuery } from './request-meta'
import type { ParsedUrlQuery } from 'querystring'

import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
import { getPathMatch } from '../shared/lib/router/utils/path-match'
import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
import { getRouteMatcher } from '../shared/lib/router/utils/route-matcher'
import {
  matchHas,
  prepareDestination,
} from '../shared/lib/router/utils/prepare-destination'
import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash'
import { normalizeRscURL } from '../shared/lib/router/utils/app-paths'
import {
  NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER,
  NEXT_CACHE_REVALIDATED_TAGS_HEADER,
  NEXT_INTERCEPTION_MARKER_PREFIX,
  NEXT_QUERY_PARAM_PREFIX,
} from '../lib/constants'
import { normalizeNextQueryParam } from './web/utils'
import type { IncomingHttpHeaders, IncomingMessage } from 'http'
import { decodeQueryPathParameter } from './lib/decode-query-path-parameter'
import type { DeepReadonly } from '../shared/lib/deep-readonly'
import { parseReqUrl } from '../lib/url'
import { formatUrl } from '../shared/lib/router/utils/format-url'

function filterInternalQuery(
  query: Record<string, undefined | string | string[]>,
  paramKeys: string[]
) {
  // this is used to pass query information in rewrites
  // but should not be exposed in final query
  delete query['nextInternalLocale']

  for (const key in query) {
    const isNextQueryPrefix =
      key !== NEXT_QUERY_PARAM_PREFIX && key.startsWith(NEXT_QUERY_PARAM_PREFIX)

    const isNextInterceptionMarkerPrefix =
      key !== NEXT_INTERCEPTION_MARKER_PREFIX &&
      key.startsWith(NEXT_INTERCEPTION_MARKER_PREFIX)

    if (
      isNextQueryPrefix ||
      isNextInterceptionMarkerPrefix ||
      paramKeys.includes(key)
    ) {
      delete query[key]
    }
  }
}

export function normalizeCdnUrl(
  req: BaseNextRequest | IncomingMessage,
  paramKeys: string[]
) {
  // make sure to normalize req.url from CDNs to strip dynamic and rewrite
  // params from the query which are added during routing
  const _parsedUrl = parseReqUrl(req.url!)

  // we can't normalize if we can't parse
  if (!_parsedUrl) {
    return req.url
  }
  delete (_parsedUrl as any).search
  filterInternalQuery(_parsedUrl.query, paramKeys)

  req.url = formatUrl(_parsedUrl)
}

export function interpolateDynamicPath(
  pathname: string,
  params: ParsedUrlQuery,
  defaultRouteRegex?: ReturnType<typeof getNamedRouteRegex> | undefined
) {
  if (!defaultRouteRegex) return pathname

  for (const param of Object.keys(defaultRouteRegex.groups)) {
    const { optional, repeat } = defaultRouteRegex.groups[param]
    let builtParam = `[${repeat ? '...' : ''}${param}]`

    if (optional) {
      builtParam = `[${builtParam}]`
    }

    let paramValue: string
    const value = params[param]

    if (Array.isArray(value)) {
      paramValue = value.map((v) => v && encodeURIComponent(v)).join('/')
    } else if (value) {
      paramValue = encodeURIComponent(value)
    } else {
      paramValue = ''
    }

    if (paramValue || optional) {
      pathname = pathname.replaceAll(builtParam, paramValue)
    }
  }

  return pathname
}

export function normalizeDynamicRouteParams(
  query: ParsedUrlQuery,
  defaultRouteRegex: ReturnType<typeof getNamedRouteRegex>,
  defaultRouteMatches: ParsedUrlQuery,
  ignoreMissingOptional: boolean
) {
  const isDefaultValueMatch = (
    candidateValue: string | undefined,
    defaultValue: string
  ) => {
    if (!candidateValue) {
      return false
    }

    let normalizedCandidateValue = normalizeRscURL(candidateValue)
    for (let i = 0; i < 3; i++) {
      if (normalizedCandidateValue === defaultValue) {
        return true
      }

      const decodedCandidateValue = decodeQueryPathParameter(
        normalizedCandidateValue
      )

      if (decodedCandidateValue === normalizedCandidateValue) {
        break
      }

      normalizedCandidateValue = decodedCandidateValue
    }

    return false
  }

  let hasValidParams = true
  let params: ParsedUrlQuery = {}

  for (const key of Object.keys(defaultRouteRegex.groups)) {
    let value: string | string[] | undefined = query[key]

    if (typeof value === 'string') {
      value = normalizeRscURL(value)
    } else if (Array.isArray(value)) {
      value = value.map(normalizeRscURL)
    }

    // if the value matches the default value we can't rely
    // on the parsed params, this is used to signal if we need
    // to parse x-now-route-matches or not
    const defaultValue = defaultRouteMatches![key]
    const isOptional = defaultRouteRegex!.groups[key].optional

    const isDefaultValue = Array.isArray(defaultValue)
      ? defaultValue.some((defaultVal) => {
          return Array.isArray(value)
            ? value.some((val) => isDefaultValueMatch(val, defaultVal))
            : isDefaultValueMatch(value, defaultVal)
        })
      : Array.isArray(value)
        ? value.some((val) => isDefaultValueMatch(val, defaultValue as string))
        : isDefaultValueMatch(value, defaultValue as string)

    if (
      isDefaultValue ||
      (typeof value === 'undefined' && !(isOptional && ignoreMissingOptional))
    ) {
      return { params: {}, hasValidParams: false }
    }

    // non-provided optional values should be undefined so normalize
    // them to undefined
    if (
      isOptional &&
      (!value ||
        (Array.isArray(value) &&
          value.length === 1 &&
          // fallback optional catch-all SSG pages have
          // [[...paramName]] for the root path on Vercel
          (value[0] === 'index' || value[0] === `[[...${key}]]`)) ||
        value === 'index' ||
        value === `[[...${key}]]`)
    ) {
      value = undefined
      delete query[key]
    }

    // query values from the proxy aren't already split into arrays
    // so make sure to normalize catch-all values
    if (
      value &&
      typeof value === 'string' &&
      defaultRouteRegex!.groups[key].repeat
    ) {
      value = value.split('/')
    }

    if (value) {
      params[key] = value
    }
  }

  return {
    params,
    hasValidParams,
  }
}

export function getServerUtils({
  page,
  i18n,
  basePath,
  rewrites,
  pageIsDynamic,
  trailingSlash,
  caseSensitive,
}: {
  page: string
  i18n?: NextConfig['i18n']
  basePath: string
  rewrites: DeepReadonly<{
    fallback?: ReadonlyArray<Rewrite>
    afterFiles?: ReadonlyArray<Rewrite>
    beforeFiles?: ReadonlyArray<Rewrite>
  }>
  pageIsDynamic: boolean
  trailingSlash?: boolean
  caseSensitive: boolean
}) {
  let defaultRouteRegex: ReturnType<typeof getNamedRouteRegex> | undefined
  let dynamicRouteMatcher: RouteMatchFn | undefined
  let defaultRouteMatches: ParsedUrlQuery | undefined

  if (pageIsDynamic) {
    defaultRouteRegex = getNamedRouteRegex(page, {
      prefixRouteKeys: false,
    })
    dynamicRouteMatcher = getRouteMatcher(defaultRouteRegex)
    defaultRouteMatches = dynamicRouteMatcher(page) as ParsedUrlQuery
  }

  function handleRewrites(
    req: BaseNextRequest | IncomingMessage,
    parsedUrl: DeepReadonly<NextUrlWithParsedQuery>
  ) {
    // Here we deep clone the parsedUrl to avoid mutating the original. We also
    // cast this to a mutable type so we can mutate it within this scope.
    const rewrittenParsedUrl = structuredClone(
      parsedUrl
    ) as NextUrlWithParsedQuery
    const rewriteParams: Record<string, string> = {}
    let fsPathname = rewrittenParsedUrl.pathname

    const matchesPage = () => {
      const fsPathnameNoSlash = removeTrailingSlash(fsPathname || '')
      return (
        fsPathnameNoSlash === removeTrailingSlash(page) ||
        dynamicRouteMatcher?.(fsPathnameNoSlash)
      )
    }

    const checkRewrite = (rewrite: DeepReadonly<Rewrite>): boolean => {
      const matcher = getPathMatch(
        rewrite.source + (trailingSlash ? '(/)?' : ''),
        {
          removeUnnamedParams: true,
          strict: true,
          sensitive: !!caseSensitive,
        }
      )

      if (!rewrittenParsedUrl.pathname) return false

      let params = matcher(rewrittenParsedUrl.pathname)

      if ((rewrite.has || rewrite.missing) && params) {
        const hasParams = matchHas(
          req,
          rewrittenParsedUrl.query,
          rewrite.has as Rewrite['has'],
          rewrite.missing as Rewrite['missing']
        )

        if (hasParams) {
          Object.assign(params, hasParams)
        } else {
          params = false
        }
      }

      if (params) {
        const { parsedDestination, destQuery } = prepareDestination({
          appendParamsToQuery: true,
          destination: rewrite.destination,
          params: params,
          query: rewrittenParsedUrl.query,
        })

        // if the rewrite destination is external break rewrite chain
        if (parsedDestination.protocol) {
          return true
        }

        Object.assign(rewriteParams, destQuery, params)
        Object.assign(rewrittenParsedUrl.query, parsedDestination.query)
        delete (parsedDestination as any).query

        Object.assign(rewrittenParsedUrl, parsedDestination)

        fsPathname = rewrittenParsedUrl.pathname
        if (!fsPathname) return false

        if (basePath) {
          fsPathname = fsPathname.replace(new RegExp(`^${basePath}`), '') || '/'
        }

        if (i18n) {
          const result = normalizeLocalePath(fsPathname, i18n.locales)
          fsPathname = result.pathname
          rewrittenParsedUrl.query.nextInternalLocale =
            result.detectedLocale || params.nextInternalLocale
        }

        if (fsPathname === page) {
          return true
        }

        if (pageIsDynamic && dynamicRouteMatcher) {
          const dynamicParams = dynamicRouteMatcher(fsPathname)
          if (dynamicParams) {
            rewrittenParsedUrl.query = {
              ...rewrittenParsedUrl.query,
              ...dynamicParams,
            }
            return true
          }
        }
      }

      return false
    }

    for (const rewrite of rewrites.beforeFiles || []) {
      checkRewrite(rewrite)
    }

    if (fsPathname !== page) {
      let finished = false

      for (const rewrite of rewrites.afterFiles || []) {
        finished = checkRewrite(rewrite)
        if (finished) break
      }

      if (!finished && !matchesPage()) {
        for (const rewrite of rewrites.fallback || []) {
          finished = checkRewrite(rewrite)
          if (finished) break
        }
      }
    }

    return { rewriteParams, rewrittenParsedUrl }
  }

  function getParamsFromRouteMatches(routeMatchesHeader: string) {
    // If we don't have a default route regex, we can't get params from route
    // matches
    if (!defaultRouteRegex) return null

    const { groups, routeKeys } = defaultRouteRegex

    const matcher = getRouteMatcher({
      re: {
        // Simulate a RegExp match from the \`req.url\` input
        exec: (str: string) => {
          // Normalize all the prefixed query params.
          const obj: Record<string, string> = Object.fromEntries(
            new URLSearchParams(str)
          )
          for (const [key, value] of Object.entries(obj)) {
            const normalizedKey = normalizeNextQueryParam(key)
            if (!normalizedKey) continue

            obj[normalizedKey] = value
            delete obj[key]
          }

          // Use all the named route keys.
          const result = {} as RegExpExecArray
          for (const keyName of Object.keys(routeKeys)) {
            const paramName = routeKeys[keyName]

            // If this param name is not a valid parameter name, then skip it.
            if (!paramName) continue

            const group = groups[paramName]
            const value = obj[keyName]

            // When we're missing a required param, we can't match the route.
            if (!group.optional && !value) return null

            result[group.pos] = value
          }

          return result
        },
      },
      groups,
    })

    const routeMatches = matcher(routeMatchesHeader)
    if (!routeMatches) return null

    return routeMatches
  }

  function normalizeQueryParams(
    query: Record<string, string | string[] | undefined>,
    routeParamKeys: Set<string>
  ) {
    // this is used to pass query information in rewrites
    // but should not be exposed in final query
    delete query['nextInternalLocale']

    for (const [key, value] of Object.entries(query)) {
      const normalizedKey = normalizeNextQueryParam(key)
      if (!normalizedKey) continue

      // Remove the prefixed key from the query params because we want
      // to consume it for the dynamic route matcher.
      delete query[key]
      routeParamKeys.add(normalizedKey)

      if (typeof value === 'undefined') continue

      query[normalizedKey] = Array.isArray(value)
        ? value.map((v) => decodeQueryPathParameter(v))
        : decodeQueryPathParameter(value)
    }
  }

  return {
    handleRewrites,
    defaultRouteRegex,
    dynamicRouteMatcher,
    defaultRouteMatches,
    normalizeQueryParams,
    getParamsFromRouteMatches,
    /**
     * Normalize dynamic route params.
     *
     * @param query - The query params to normalize.
     * @param ignoreMissingOptional - Whether to ignore missing optional params.
     * @returns The normalized params and whether they are valid.
     */
    normalizeDynamicRouteParams: (
      query: ParsedUrlQuery,
      ignoreMissingOptional: boolean
    ) => {
      if (!defaultRouteRegex || !defaultRouteMatches) {
        return { params: {}, hasValidParams: false }
      }

      return normalizeDynamicRouteParams(
        query,
        defaultRouteRegex,
        defaultRouteMatches,
        ignoreMissingOptional
      )
    },

    normalizeCdnUrl: (
      req: BaseNextRequest | IncomingMessage,
      paramKeys: string[]
    ) => normalizeCdnUrl(req, paramKeys),

    interpolateDynamicPath: (
      pathname: string,
      params: Record<string, undefined | string | string[]>
    ) => interpolateDynamicPath(pathname, params, defaultRouteRegex),

    filterInternalQuery: (query: ParsedUrlQuery, paramKeys: string[]) =>
      filterInternalQuery(query, paramKeys),
  }
}

export function getPreviouslyRevalidatedTags(
  headers: IncomingHttpHeaders,
  previewModeId: string | undefined
): string[] {
  return typeof headers[NEXT_CACHE_REVALIDATED_TAGS_HEADER] === 'string' &&
    headers[NEXT_CACHE_REVALIDATE_TAG_TOKEN_HEADER] === previewModeId
    ? headers[NEXT_CACHE_REVALIDATED_TAGS_HEADER].split(',')
    : []
}
Quest for Codev2.0.0
/
SIGN IN