next.js/packages/next/src/server/render-result.ts
render-result.ts419 lines12.8 KB
import type { OutgoingHttpHeaders, ServerResponse } from 'http'
import type { Readable } from 'stream'
import type { CacheControl } from './lib/cache-control'
import type { FetchMetrics } from './base-http'
import type { PrefetchHints } from '../shared/lib/app-router-types'

import {
  chainStreams,
  streamFromBuffer,
  streamFromString,
  streamToString,
} from './stream-utils/node-web-streams-helper'
import {
  isAbortError,
  pipeToNodeResponse,
  pipeNodeReadableToNodeResponse,
} from './pipe-readable'
import type { RenderResumeDataCache } from './resume-data-cache/resume-data-cache'
import { InvariantError } from '../shared/lib/invariant-error'
import type {
  HTML_CONTENT_TYPE_HEADER,
  JSON_CONTENT_TYPE_HEADER,
  TEXT_PLAIN_CONTENT_TYPE_HEADER,
} from '../lib/constants'
import type { RSC_CONTENT_TYPE_HEADER } from '../client/components/app-router-headers'

type ContentTypeOption =
  | typeof RSC_CONTENT_TYPE_HEADER // For App Page RSC responses
  | typeof HTML_CONTENT_TYPE_HEADER // For App Page, Pages HTML responses
  | typeof JSON_CONTENT_TYPE_HEADER // For API routes, Next.js data requests
  | typeof TEXT_PLAIN_CONTENT_TYPE_HEADER // For simplified errors

export type AppPageRenderResultMetadata = {
  flightData?: Buffer
  cacheControl?: CacheControl
  staticBailoutInfo?: {
    stack?: string
    description?: string
  }

  /**
   * The postponed state if the render had postponed and needs to be resumed.
   */
  postponed?: string

  /**
   * The headers to set on the response that were added by the render.
   */
  headers?: OutgoingHttpHeaders
  statusCode?: number
  fetchTags?: string
  fetchMetrics?: FetchMetrics

  segmentData?: Map<string, Buffer>

  /**
   * Per-route prefetch hints computed at build time (e.g. segment inlining
   * decisions based on gzip sizes). Written to prefetch-hints.json by the
   * build pipeline.
   */
  prefetchHints?: PrefetchHints

  /**
   * In development, the resume data cache is warmed up before the render. This
   * is attached to the metadata so that it can be used during the render. When
   * prerendering, the filled resume data cache is also attached to the metadata
   * so that it can be used when prerendering matching fallback shells.
   */
  renderResumeDataCache?: RenderResumeDataCache
}

export type PagesRenderResultMetadata = {
  pageData?: any
  cacheControl?: CacheControl
  isNotFound?: boolean
  isRedirect?: boolean
}

export type StaticRenderResultMetadata = {}

export type RenderResultMetadata = AppPageRenderResultMetadata &
  PagesRenderResultMetadata &
  StaticRenderResultMetadata

export type RenderResultResponse =
  | ReadableStream<Uint8Array>[]
  | ReadableStream<Uint8Array>
  | Readable
  | string
  | Buffer
  | null

export type RenderResultOptions<
  Metadata extends RenderResultMetadata = RenderResultMetadata,
> = {
  contentType: ContentTypeOption | null
  waitUntil?: Promise<unknown>
  metadata: Metadata
}

function isNodeReadable(value: unknown): value is Readable {
  return (
    value !== null &&
    typeof value === 'object' &&
    typeof (value as Record<string, unknown>).pipe === 'function' &&
    typeof (value as Record<string, unknown>).on === 'function' &&
    !(value instanceof ReadableStream)
  )
}

export default class RenderResult<
  Metadata extends RenderResultMetadata = RenderResultMetadata,
> {
  /**
   * The detected content type for the response. This is used to set the
   * `Content-Type` header.
   */
  public readonly contentType: ContentTypeOption | null

  /**
   * The metadata for the response. This is used to set the revalidation times
   * and other metadata.
   */
  public readonly metadata: Readonly<Metadata>

  /**
   * The response itself. This can be a string, a stream, or null. If it's a
   * string, then it's a static response. If it's a stream, then it's a
   * dynamic response. If it's null, then the response was not found or was
   * already sent.
   */
  private response: RenderResultResponse

  /**
   * A render result that represents an empty response. This is used to
   * represent a response that was not found or was already sent.
   */
  public static readonly EMPTY = new RenderResult<StaticRenderResultMetadata>(
    null,
    { metadata: {}, contentType: null }
  )

  /**
   * Creates a new RenderResult instance from a static response.
   *
   * @param value the static response value
   * @param contentType the content type of the response
   * @returns a new RenderResult instance
   */
  public static fromStatic(
    value: string | Buffer,
    contentType: ContentTypeOption
  ) {
    return new RenderResult<StaticRenderResultMetadata>(value, {
      metadata: {},
      contentType,
    })
  }

  private readonly waitUntil?: Promise<unknown>

  constructor(
    response: RenderResultResponse,
    { contentType, waitUntil, metadata }: RenderResultOptions<Metadata>
  ) {
    this.response = response
    this.contentType = contentType
    this.metadata = metadata
    this.waitUntil = waitUntil
  }

  public assignMetadata(metadata: Metadata) {
    Object.assign(this.metadata, metadata)
  }

  /**
   * Returns true if the response is null. It can be null if the response was
   * not found or was already sent.
   */
  public get isNull(): boolean {
    return this.response === null
  }

  /**
   * Returns false if the response is a string. It can be a string if the page
   * was prerendered. If it's not, then it was generated dynamically.
   */
  public get isDynamic(): boolean {
    return typeof this.response !== 'string'
  }

  /**
   * Returns the response if it is a string. If the page was dynamic, this will
   * return a promise if the `stream` option is true, or it will throw an error.
   *
   * @param stream Whether or not to return a promise if the response is dynamic
   * @returns The response as a string
   */
  public toUnchunkedString(stream?: false): string
  public toUnchunkedString(stream: true): Promise<string>
  public toUnchunkedString(stream = false): Promise<string> | string {
    if (this.response === null) {
      // If the response is null, return an empty string. This behavior is
      // intentional as we're now providing the `RenderResult.EMPTY` value.
      return ''
    }

    if (typeof this.response !== 'string') {
      if (!stream) {
        throw new InvariantError(
          'dynamic responses cannot be unchunked. This is a bug in Next.js'
        )
      }

      return streamToString(this.readable)
    }

    return this.response
  }

  /**
   * Returns a readable stream of the response.
   */
  private get readable(): ReadableStream<Uint8Array> {
    if (this.response === null) {
      // If the response is null, return an empty stream. This behavior is
      // intentional as we're now providing the `RenderResult.EMPTY` value.
      return new ReadableStream<Uint8Array>({
        start(controller) {
          controller.close()
        },
      })
    }

    if (typeof this.response === 'string') {
      return streamFromString(this.response)
    }

    if (Buffer.isBuffer(this.response)) {
      return streamFromBuffer(this.response)
    }

    // If the response is an array of streams, then chain them together.
    if (Array.isArray(this.response)) {
      return chainStreams(...this.response)
    }

    if (isNodeReadable(this.response)) {
      if (process.env.NEXT_RUNTIME === 'edge') {
        throw new InvariantError(
          'Node.js Readable cannot be converted to a web stream in the edge runtime'
        )
      } else {
        let Readable: typeof import('node:stream').Readable
        if (process.env.TURBOPACK) {
          Readable = (require('node:stream') as typeof import('node:stream'))
            .Readable
        } else {
          Readable = (
            __non_webpack_require__(
              'node:stream'
            ) as typeof import('node:stream')
          ).Readable
        }
        return Readable.toWeb(this.response) as ReadableStream<Uint8Array>
      }
    }

    return this.response
  }

  /**
   * Coerces the response to an array of streams. This will convert the response
   * to an array of streams if it is not already one.
   *
   * @returns An array of streams
   */
  private coerce(): ReadableStream<Uint8Array>[] {
    if (this.response === null) {
      // If the response is null, return an empty stream. This behavior is
      // intentional as we're now providing the `RenderResult.EMPTY` value.
      return []
    }

    if (typeof this.response === 'string') {
      return [streamFromString(this.response)]
    } else if (Array.isArray(this.response)) {
      return this.response
    } else if (Buffer.isBuffer(this.response)) {
      return [streamFromBuffer(this.response)]
    } else if (isNodeReadable(this.response)) {
      if (process.env.NEXT_RUNTIME === 'edge') {
        throw new InvariantError(
          'Node.js Readable cannot be converted to a web stream in the edge runtime'
        )
      } else {
        let Readable: typeof import('node:stream').Readable
        if (process.env.TURBOPACK) {
          Readable = (require('node:stream') as typeof import('node:stream'))
            .Readable
        } else {
          Readable = (
            __non_webpack_require__(
              'node:stream'
            ) as typeof import('node:stream')
          ).Readable
        }
        return [Readable.toWeb(this.response) as ReadableStream<Uint8Array>]
      }
    } else {
      return [this.response]
    }
  }

  /**
   * Pipes the response through a transform stream. This converts the response
   * to a single readable stream (chaining if needed) and pipes it through the
   * provided transform.
   *
   * @param transform The transform stream to pipe through
   */
  public pipeThrough(transform: TransformStream<Uint8Array, Uint8Array>): void {
    this.response = this.readable.pipeThrough(transform)
  }

  /**
   * Unshifts a new stream to the response. This will convert the response to an
   * array of streams if it is not already one and will add the new stream to
   * the start of the array. When this response is piped, all of the streams
   * will be piped one after the other.
   *
   * @param readable The new stream to unshift
   */
  public unshift(readable: ReadableStream<Uint8Array>): void {
    // Coerce the response to an array of streams.
    this.response = this.coerce()

    // Add the new stream to the start of the array.
    this.response.unshift(readable)
  }

  /**
   * Chains a new stream to the response. This will convert the response to an
   * array of streams if it is not already one and will add the new stream to
   * the end. When this response is piped, all of the streams will be piped
   * one after the other.
   *
   * @param readable The new stream to chain
   */
  public push(readable: ReadableStream<Uint8Array>): void {
    // Coerce the response to an array of streams.
    this.response = this.coerce()

    // Add the new stream to the end of the array.
    this.response.push(readable)
  }

  /**
   * Pipes the response to a writable stream. This will close/cancel the
   * writable stream if an error is encountered. If this doesn't throw, then
   * the writable stream will be closed or aborted.
   *
   * @param writable Writable stream to pipe the response to
   */
  public async pipeTo(writable: WritableStream<Uint8Array>): Promise<void> {
    try {
      await this.readable.pipeTo(writable, {
        // We want to close the writable stream ourselves so that we can wait
        // for the waitUntil promise to resolve before closing it. If an error
        // is encountered, we'll abort the writable stream if we swallowed the
        // error.
        preventClose: true,
      })

      // If there is a waitUntil promise, wait for it to resolve before
      // closing the writable stream.
      if (this.waitUntil) await this.waitUntil

      // Close the writable stream.
      await writable.close()
    } catch (err) {
      // If this is an abort error, we should abort the writable stream (as we
      // took ownership of it when we started piping). We don't need to re-throw
      // because we handled the error.
      if (isAbortError(err)) {
        // Abort the writable stream if an error is encountered.
        await writable.abort(err)

        return
      }

      // We're not aborting the writer here as when this method throws it's not
      // clear as to how so the caller should assume it's their responsibility
      // to clean up the writer.
      throw err
    }
  }

  /**
   * Pipes the response to a node response. This will close/cancel the node
   * response if an error is encountered.
   *
   * @param res
   */
  public async pipeToNodeResponse(res: ServerResponse) {
    if (
      this.response !== null &&
      typeof this.response !== 'string' &&
      !Buffer.isBuffer(this.response) &&
      !Array.isArray(this.response) &&
      isNodeReadable(this.response)
    ) {
      await pipeNodeReadableToNodeResponse(this.response, res, this.waitUntil)
      return
    }
    await pipeToNodeResponse(this.readable, res, this.waitUntil)
  }
}
Quest for Codev2.0.0
/
SIGN IN