next.js/packages/next/src/client/components/router-reducer/fetch-server-response.ts
fetch-server-response.ts674 lines24.6 KB
'use client'

// TODO: Explicitly import from client.browser
// eslint-disable-next-line import/no-extraneous-dependencies
import {
  createFromReadableStream as createFromReadableStreamBrowser,
  createFromFetch as createFromFetchBrowser,
} from 'react-server-dom-webpack/client'

import { InvariantError } from '../../../shared/lib/invariant-error'
import { fetch } from '../segment-cache/fetch'
import type {
  FlightRouterState,
  InitialRSCPayload,
  NavigationFlightResponse,
} from '../../../shared/lib/app-router-types'

import {
  type NEXT_ROUTER_PREFETCH_HEADER,
  type NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
  type NEXT_INSTANT_PREFETCH_HEADER,
  NEXT_ROUTER_STATE_TREE_HEADER,
  NEXT_RSC_UNION_QUERY,
  NEXT_URL,
  RSC_HEADER,
  RSC_CONTENT_TYPE_HEADER,
  NEXT_HMR_REFRESH_HEADER,
  NEXT_DID_POSTPONE_HEADER,
  NEXT_HTML_REQUEST_ID_HEADER,
  NEXT_REQUEST_ID_HEADER,
} from '../app-router-headers'
import { callServer } from '../../app-call-server'
import { findSourceMapURL } from '../../app-find-source-map-url'
import {
  normalizeFlightData,
  prepareFlightRouterStateForRequest,
  type NormalizedFlightData,
} from '../../flight-data-helpers'
import { setCacheBustingSearchParam } from './set-cache-busting-search-param'
import { urlToUrlWithoutFlightMarker } from '../../route-params'
import type { NormalizedSearch } from '../segment-cache/cache-key'
import { getDeploymentId } from '../../../shared/lib/deployment-id'
import { getNavigationBuildId } from '../../navigation-build-id'
import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants'
import {
  stripIsPartialByte,
  createNonTaskyPrefetchResponseStream,
} from '../segment-cache/cache'
import { UnknownDynamicStaleTime } from '../segment-cache/bfcache'

const createFromReadableStream =
  createFromReadableStreamBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromReadableStream']
const createFromFetch =
  createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch']

let createDebugChannel:
  | typeof import('../../dev/debug-channel').createDebugChannel
  | undefined

if (process.env.__NEXT_DEV_SERVER && process.env.__NEXT_REACT_DEBUG_CHANNEL) {
  createDebugChannel = (
    require('../../dev/debug-channel') as typeof import('../../dev/debug-channel')
  ).createDebugChannel
}

export interface FetchServerResponseOptions {
  readonly flightRouterState: FlightRouterState
  readonly nextUrl: string | null
  readonly isHmrRefresh?: boolean
}

export type StaticStageData<
  T extends
    | NavigationFlightResponse
    | InitialRSCPayload = NavigationFlightResponse,
> = {
  readonly response: T
  readonly isResponsePartial: boolean
}

type SpaFetchServerResponseResult = {
  flightData: NormalizedFlightData[]
  canonicalUrl: URL
  renderedSearch: NormalizedSearch
  couldBeIntercepted: boolean
  supportsPerSegmentPrefetching: boolean
  postponed: boolean
  dynamicStaleTime: number
  staticStageData: StaticStageData | null
  runtimePrefetchStream: ReadableStream<Uint8Array> | null
  responseHeaders: Headers
  debugInfo: Array<any> | null
}

type MpaFetchServerResponseResult = string

export type FetchServerResponseResult =
  | MpaFetchServerResponseResult
  | SpaFetchServerResponseResult

export type RequestHeaders = {
  [RSC_HEADER]?: '1'
  [NEXT_ROUTER_STATE_TREE_HEADER]?: string
  [NEXT_URL]?: string
  [NEXT_ROUTER_PREFETCH_HEADER]?: '1' | '2'
  [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]?: string
  'x-deployment-id'?: string
  [NEXT_HMR_REFRESH_HEADER]?: '1'
  // A header that is only added in test mode to assert on fetch priority
  'Next-Test-Fetch-Priority'?: RequestInit['priority']
  [NEXT_HTML_REQUEST_ID_HEADER]?: string // dev-only
  [NEXT_REQUEST_ID_HEADER]?: string // dev-only
  [NEXT_INSTANT_PREFETCH_HEADER]?: '1' // testing API only
}

function doMpaNavigation(url: string): FetchServerResponseResult {
  return urlToUrlWithoutFlightMarker(new URL(url, location.origin)).toString()
}

let isPageUnloading = false

if (typeof window !== 'undefined') {
  // Track when the page is unloading, e.g. due to reloading the page or
  // performing hard navigations. This allows us to suppress error logging when
  // the browser cancels in-flight requests during page unload.
  window.addEventListener('pagehide', () => {
    isPageUnloading = true
  })

  // Reset the flag on pageshow, e.g. when navigating back and the JavaScript
  // execution context is restored by the browser.
  window.addEventListener('pageshow', () => {
    isPageUnloading = false
  })
}

/**
 * Fetch the flight data for the provided url. Takes in the current router state
 * to decide what to render server-side.
 */
export async function fetchServerResponse(
  url: URL,
  options: FetchServerResponseOptions
): Promise<FetchServerResponseResult> {
  const { flightRouterState, nextUrl } = options

  const headers: RequestHeaders = {
    // Enable flight response
    [RSC_HEADER]: '1',
    // Provide the current router state
    [NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest(
      flightRouterState,
      options.isHmrRefresh
    ),
  }

  if (process.env.NODE_ENV === 'development' && options.isHmrRefresh) {
    headers[NEXT_HMR_REFRESH_HEADER] = '1'
  }

  if (nextUrl) {
    headers[NEXT_URL] = nextUrl
  }

  // In static export mode, we need to modify the URL to request the .txt file,
  // but we should preserve the original URL for the canonical URL and error handling.
  const originalUrl = url

  try {
    if (process.env.NODE_ENV === 'production') {
      if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
        // In "output: export" mode, we can't rely on headers to distinguish
        // between HTML and RSC requests. Instead, we append an extra prefix
        // to the request.
        url = new URL(url)
        if (url.pathname.endsWith('/')) {
          url.pathname += 'index.txt'
        } else {
          url.pathname += '.txt'
        }
      }
    }

    // Typically, during a navigation, we decode the response using Flight's
    // `createFromFetch` API, which accepts a `fetch` promise.
    // TODO: Remove this check once the old PPR flag is removed
    const isLegacyPPR =
      process.env.__NEXT_PPR && !process.env.__NEXT_CACHE_COMPONENTS
    const shouldImmediatelyDecode = !isLegacyPPR
    const res = await createFetch<NavigationFlightResponse>(
      url,
      headers,
      'auto',
      shouldImmediatelyDecode
    )

    // If the fetch succeeds while we're in the offline state, notify the
    // offline module so it can short-circuit the polling loop.
    if (process.env.__NEXT_USE_OFFLINE) {
      const { notifyOnline } =
        require('../offline') as typeof import('../offline')
      notifyOnline()
    }

    const responseUrl = urlToUrlWithoutFlightMarker(new URL(res.url))
    const canonicalUrl = res.redirected ? responseUrl : originalUrl

    const contentType = res.headers.get('content-type') || ''
    const interception = !!res.headers.get('vary')?.includes(NEXT_URL)
    const postponed = !!res.headers.get(NEXT_DID_POSTPONE_HEADER)
    let isFlightResponse = contentType.startsWith(RSC_CONTENT_TYPE_HEADER)

    if (process.env.NODE_ENV === 'production') {
      if (process.env.__NEXT_CONFIG_OUTPUT === 'export') {
        if (!isFlightResponse) {
          isFlightResponse = contentType.startsWith('text/plain')
        }
      }
    }

    // If fetch returns something different than flight response handle it like a mpa navigation
    // If the fetch was not 200, we also handle it like a mpa navigation
    if (!isFlightResponse || !res.ok || !res.body) {
      // in case the original URL came with a hash, preserve it before redirecting to the new URL
      if (url.hash) {
        responseUrl.hash = url.hash
      }

      return doMpaNavigation(responseUrl.toString())
    }

    // We may navigate to a page that requires a different Webpack runtime.
    // In prod, every page will have the same Webpack runtime.
    // In dev, the Webpack runtime is minimal for each page.
    // We need to ensure the Webpack runtime is updated before executing client-side JS of the new page.
    // TODO: This needs to happen in the Flight Client.
    // Or Webpack needs to include the runtime update in the Flight response as
    // a blocking script.
    if (process.env.NODE_ENV !== 'production' && !process.env.TURBOPACK) {
      await (
        require('../../dev/hot-reloader/app/hot-reloader-app') as typeof import('../../dev/hot-reloader/app/hot-reloader-app')
      ).waitForWebpackRuntimeHotUpdate()
    }

    let flightResponsePromise = res.flightResponsePromise
    if (flightResponsePromise === null) {
      // Typically, `createFetch` would have already started decoding the
      // Flight response. If it hasn't, though, we need to decode it now.
      // TODO: This should only be reachable if legacy PPR is enabled (i.e. PPR
      // without Cache Components). Remove this branch once legacy PPR
      // is deleted.
      flightResponsePromise =
        createFromNextReadableStream<NavigationFlightResponse>(
          res.body,
          headers,
          { allowPartialStream: postponed }
        )
    }

    const [flightResponse, cacheData] = await Promise.all([
      flightResponsePromise,
      res.cacheData,
    ])

    if (
      (res.headers.get(NEXT_NAV_DEPLOYMENT_ID_HEADER) ?? flightResponse.b) !==
      getNavigationBuildId()
    ) {
      // The server build does not match the client build.
      return doMpaNavigation(res.url)
    }

    const normalizedFlightData = normalizeFlightData(flightResponse.f)
    if (typeof normalizedFlightData === 'string') {
      return doMpaNavigation(normalizedFlightData)
    }

    const staticStageData =
      cacheData !== null
        ? await resolveStaticStageData(cacheData, flightResponse, headers)
        : null

    return {
      flightData: normalizedFlightData,
      canonicalUrl: canonicalUrl,
      // TODO: We should be able to read this from the rewrite header, not the
      // Flight response. Theoretically they should always agree, but there are
      // currently some cases where it's incorrect for interception routes. We
      // can always trust the value in the response body. However, per-segment
      // prefetch responses don't embed the value in the body; they rely on the
      // header alone. So we need to investigate why the header is sometimes
      // wrong for interception routes.
      renderedSearch: flightResponse.q as NormalizedSearch,
      couldBeIntercepted: interception,
      supportsPerSegmentPrefetching: flightResponse.S,
      postponed,
      // The dynamicStaleTime is only present in the response body when
      // a page exports unstable_dynamicStaleTime and this is a dynamic render.
      // When absent (UnknownDynamicStaleTime), the client falls back to the
      // global DYNAMIC_STALETIME_MS. The value is in seconds.
      dynamicStaleTime: flightResponse.d ?? UnknownDynamicStaleTime,
      staticStageData,
      runtimePrefetchStream: flightResponse.p ?? null,
      responseHeaders: res.headers,
      debugInfo: flightResponsePromise._debugInfo ?? null,
    }
  } catch (err) {
    // If the fetch rejected due to a network error, wait for connectivity
    // to be restored and then retry. checkOfflineError returns true for
    // network errors (and starts the polling loop); returns false for
    // intentional aborts/timeouts, which fall through to the MPA fallback.
    //
    // Note: when the user navigates multiple times while offline, each
    // navigation queues a separate retry here. Once connectivity returns,
    // all pending retries resume simultaneously. This is mitigated in PR 3
    // by reusing back-forward cache entries during offline navigation, which
    // avoids issuing new fetches in the first place.
    if (process.env.__NEXT_USE_OFFLINE && !isPageUnloading) {
      const { checkOfflineError, getOffline, waitForConnection } =
        require('../offline') as typeof import('../offline')
      if (checkOfflineError(err)) {
        const offline = getOffline()
        if (offline !== null) {
          await waitForConnection(offline)
        }
        return fetchServerResponse(url, options)
      }
    }

    if (!isPageUnloading) {
      console.error(
        `Failed to fetch RSC payload for ${originalUrl}. Falling back to browser navigation.`,
        err
      )
    }

    // If fetch fails handle it like a mpa navigation
    // TODO-APP: Add a test for the case where a CORS request fails, e.g. external url redirect coming from the response.
    // See https://github.com/vercel/next.js/issues/43605#issuecomment-1451617521 for a reproduction.
    return originalUrl.toString()
  }
}

// This is a subset of the standard Response type. We use a custom type for
// this so we can limit which details about the response leak into the rest of
// the codebase. For example, there's some custom logic for manually following
// redirects, so "redirected" in this type could be a composite of multiple
// browser fetch calls; however, this fact should not leak to the caller.
export type RSCResponse<T> = {
  ok: boolean
  redirected: boolean
  headers: Headers
  body: ReadableStream<Uint8Array> | null
  status: number
  url: string
  flightResponsePromise: (Promise<T> & { _debugInfo?: Array<any> }) | null
  cacheData: Promise<FetchResponseCacheData | null>
}

type FetchResponseCacheData = {
  isResponsePartial: boolean
  responseBodyClone?: ReadableStream<Uint8Array>
}

/**
 * Strips the leading isPartial byte from an RSC navigation response and
 * clones the body for segment cache extraction.
 *
 * When cache components is enabled, the server prepends a single byte:
 * '~' (0x7e) for partial, '#' (0x23) for complete. This must be stripped
 * before Flight decoding because it's not valid RSC data. The body is
 * cloned before Flight can consume it so the clone is available for later use.
 *
 * When cache components is disabled, returns the original response with
 * cacheData: null.
 */
export async function processFetch(response: Response): Promise<{
  response: Response
  cacheData: FetchResponseCacheData | null
}> {
  if (process.env.__NEXT_CACHE_COMPONENTS) {
    if (!response.body) {
      throw new InvariantError(
        'Expected RSC navigation response to have a body'
      )
    }

    const { stream, isPartial } = await stripIsPartialByte(response.body)

    let responseStream: ReadableStream<Uint8Array>
    let cacheData: FetchResponseCacheData

    if (process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS) {
      const [stream1, stream2] = stream.tee()
      responseStream = stream1
      cacheData = { isResponsePartial: isPartial, responseBodyClone: stream2 }
    } else {
      responseStream = stream
      cacheData = { isResponsePartial: isPartial }
    }

    const strippedResponse = new Response(responseStream, {
      headers: response.headers,
      status: response.status,
      statusText: response.statusText,
    })

    // The Response constructor doesn't preserve `url` or `redirected` from
    // the original. We need both: `url` for React DevTools and `redirected`
    // for the redirect replay logic below.
    Object.defineProperty(strippedResponse, 'url', { value: response.url })
    Object.defineProperty(strippedResponse, 'redirected', {
      value: response.redirected,
    })

    return { response: strippedResponse, cacheData }
  }

  return { response, cacheData: null }
}

/**
 * Resolves the static stage response from the raw `processFetch` outputs and
 * the decoded flight response, for writing into the segment cache.
 *
 * - Fully static: use the decoded flight response as-is, no truncation needed.
 * - Not fully static + `l` field: truncate the body clone at the static stage
 *   byte boundary and decode.
 * - Otherwise: no cache-worthy data.
 */
export async function resolveStaticStageData<
  T extends NavigationFlightResponse | InitialRSCPayload,
>(
  cacheData: FetchResponseCacheData,
  flightResponse: T,
  headers: RequestHeaders | undefined
): Promise<StaticStageData<T> | null> {
  const { isResponsePartial, responseBodyClone } = cacheData

  if (responseBodyClone) {
    if (!isResponsePartial) {
      // Fully static — cache the entire decoded response as-is.
      responseBodyClone.cancel()

      return { response: flightResponse, isResponsePartial: false }
    }

    if (flightResponse.l !== undefined) {
      // Partially static — truncate the body clone at the byte boundary and
      // decode it.
      const response = await decodeStaticStage<T>(
        responseBodyClone,
        flightResponse.l,
        headers
      )

      return { response, isResponsePartial: true }
    }

    // No caching — cancel the unused clone.
    responseBodyClone.cancel()
  }

  return null
}

/**
 * Truncates and buffers a Flight stream clone at the given byte boundary and
 * decodes the static stage prefix. Used by both the navigation path and the
 * initial HTML hydration path.
 */
export async function decodeStaticStage<T>(
  responseBodyClone: ReadableStream<Uint8Array>,
  staticStageByteLengthPromise: Promise<number>,
  headers: RequestHeaders | undefined
): Promise<T> {
  const staticStageByteLength = await staticStageByteLengthPromise

  // Buffer the truncated stream into a single chunk before passing it to
  // Flight. This ensures all model data is available synchronously, which is
  // required for readVaryParams to synchronously read the thenable status.
  const { stream } = await createNonTaskyPrefetchResponseStream(
    responseBodyClone,
    staticStageByteLength
  )

  return createFromNextReadableStream<T>(stream, headers, {
    allowPartialStream: true,
  })
}

export async function createFetch<T>(
  url: URL,
  headers: RequestHeaders,
  fetchPriority: 'auto' | 'high' | 'low' | null,
  shouldImmediatelyDecode: boolean,
  signal?: AbortSignal
): Promise<RSCResponse<T>> {
  // TODO: In output: "export" mode, the headers do nothing. Omit them (and the
  // cache busting search param) from the request so they're
  // maximally cacheable.

  if (process.env.__NEXT_TEST_MODE && fetchPriority !== null) {
    headers['Next-Test-Fetch-Priority'] = fetchPriority
  }

  const deploymentId = getDeploymentId()
  if (deploymentId) {
    headers['x-deployment-id'] = deploymentId
  }

  if (process.env.__NEXT_DEV_SERVER) {
    if (self.__next_r) {
      headers[NEXT_HTML_REQUEST_ID_HEADER] = self.__next_r
    }

    // Create a new request ID for the server action request. The server uses
    // this to tag debug information sent via WebSocket to the client, which
    // then routes those chunks to the debug channel associated with this ID.
    headers[NEXT_REQUEST_ID_HEADER] = crypto
      .getRandomValues(new Uint32Array(1))[0]
      .toString(16)
  }

  const fetchOptions: RequestInit = {
    // Backwards compat for older browsers. `same-origin` is the default in modern browsers.
    credentials: 'same-origin',
    headers,
    priority: fetchPriority || undefined,
    signal,
  }
  // `fetchUrl` is slightly different from `url` because we add a cache-busting
  // search param to it. This should not leak outside of this function, so we
  // track them separately.
  let fetchUrl = new URL(url)
  await setCacheBustingSearchParam(fetchUrl, headers)
  let processed = fetch(fetchUrl, fetchOptions).then(processFetch)
  let fetchPromise = processed.then(({ response }) => response)

  // Immediately pass the fetch promise to the Flight client so that the debug
  // info includes the latency from the client to the server. The internal timer
  // in React starts as soon as `createFromFetch` is called.
  //
  // The only case where we don't do this is during a prefetch, because a
  // top-level prefetch response never blocks a navigation; if it hasn't already
  // been written into the cache by the time the navigation happens, the router
  // will go straight to a dynamic request.
  let flightResponsePromise = shouldImmediatelyDecode
    ? createFromNextFetch<T>(fetchPromise, headers)
    : null
  let browserResponse = await fetchPromise

  // If the server responds with a redirect (e.g. 307), and the redirected
  // location does not contain the cache busting search param set in the
  // original request, the response is likely invalid — when following the
  // redirect, the browser forwards the request headers, but since the cache
  // busting search param is missing, the server will reject the request due to
  // a mismatch.
  //
  // Ideally, we would be able to intercept the redirect response and perform it
  // manually, instead of letting the browser automatically follow it, but this
  // is not allowed by the fetch API.
  //
  // So instead, we must "replay" the redirect by fetching the new location
  // again, but this time we'll append the cache busting search param to prevent
  // a mismatch.
  //
  // TODO: We can optimize Next.js's built-in middleware APIs by returning a
  // custom status code, to prevent the browser from automatically following it.
  //
  // This does not affect Server Action-based redirects; those are encoded
  // differently, as part of the Flight body. It only affects redirects that
  // occur in a middleware or a third-party proxy.

  let redirected = browserResponse.redirected
  if (process.env.__NEXT_CLIENT_VALIDATE_RSC_REQUEST_HEADERS) {
    // This is to prevent a redirect loop. Same limit used by Chrome.
    const MAX_REDIRECTS = 20
    for (let n = 0; n < MAX_REDIRECTS; n++) {
      if (!browserResponse.redirected) {
        // The server did not perform a redirect.
        break
      }
      const responseUrl = new URL(browserResponse.url, fetchUrl)
      if (responseUrl.origin !== fetchUrl.origin) {
        // The server redirected to an external URL. The rest of the logic below
        // is not relevant, because it only applies to internal redirects.
        break
      }
      if (
        responseUrl.searchParams.get(NEXT_RSC_UNION_QUERY) ===
        fetchUrl.searchParams.get(NEXT_RSC_UNION_QUERY)
      ) {
        // The redirected URL already includes the cache busting search param.
        // This was probably intentional. Regardless, there's no reason to
        // issue another request to this URL because it already has the param
        // value that we would have added below.
        break
      }
      // The RSC request was redirected. Assume the response is invalid.
      //
      // Append the cache busting search param to the redirected URL and
      // fetch again.
      // TODO: We should abort the previous request.
      fetchUrl = new URL(responseUrl)
      await setCacheBustingSearchParam(fetchUrl, headers)
      processed = fetch(fetchUrl, fetchOptions).then(processFetch)
      fetchPromise = processed.then(({ response }) => response)
      flightResponsePromise = shouldImmediatelyDecode
        ? createFromNextFetch<T>(fetchPromise, headers)
        : null
      browserResponse = await fetchPromise
      // We just performed a manual redirect, so this is now true.
      redirected = true
    }
  }

  // Remove the cache busting search param from the response URL, to prevent it
  // from leaking outside of this function.
  const responseUrl = new URL(browserResponse.url, fetchUrl)
  responseUrl.searchParams.delete(NEXT_RSC_UNION_QUERY)

  const rscResponse: RSCResponse<T> = {
    url: responseUrl.href,

    // This is true if any redirects occurred, either automatically by the
    // browser, or manually by us. So it's different from
    // `browserResponse.redirected`, which only tells us whether the browser
    // followed a redirect, and only for the last response in the chain.
    redirected,

    // These can be copied from the last browser response we received. We
    // intentionally only expose the subset of fields that are actually used
    // elsewhere in the codebase.
    ok: browserResponse.ok,
    headers: browserResponse.headers,
    body: browserResponse.body,
    status: browserResponse.status,

    // This is the exact promise returned by `createFromFetch`. It contains
    // debug information that we need to transfer to any derived promises that
    // are later rendered by React.
    flightResponsePromise: flightResponsePromise,

    cacheData: processed.then(({ cacheData }) => cacheData),
  }

  return rscResponse
}

export function createFromNextReadableStream<T>(
  flightStream: ReadableStream<Uint8Array>,
  requestHeaders: RequestHeaders | undefined,
  options?: { allowPartialStream?: boolean }
): Promise<T> {
  return createFromReadableStream(flightStream, {
    callServer,
    findSourceMapURL,
    debugChannel: createDebugChannel && createDebugChannel(requestHeaders),
    unstable_allowPartialStream: options?.allowPartialStream,
  })
}

function createFromNextFetch<T>(
  promiseForResponse: Promise<Response>,
  requestHeaders: RequestHeaders
): Promise<T> & { _debugInfo?: Array<any> } {
  return createFromFetch(promiseForResponse, {
    callServer,
    findSourceMapURL,
    debugChannel: createDebugChannel && createDebugChannel(requestHeaders),
  })
}
Quest for Codev2.0.0
/
SIGN IN