next.js/packages/next/src/server/dev/hot-middleware.ts
hot-middleware.ts325 lines10.0 KB
// Based on https://github.com/webpack-contrib/webpack-hot-middleware/blob/9708d781ae0e46179cf8ea1a94719de4679aaf53/middleware.js
// Included License below

// Copyright JS Foundation and other contributors

// 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 { webpack } from 'next/dist/compiled/webpack/webpack'
import type ws from 'next/dist/compiled/ws'
import type { DevToolsConfig } from '../../next-devtools/dev-overlay/shared'
import { isMiddlewareFilename } from '../../build/utils'
import type { VersionInfo } from './parse-version-info'
import type { HmrMessageSentToBrowser } from './hot-reloader-types'
import { HMR_MESSAGE_SENT_TO_BROWSER } from './hot-reloader-types'
import { devIndicatorServerState } from './dev-indicator-server-state'
import { createBinaryHmrMessageData } from './messages'
import type { NextConfigComplete } from '../config-shared'

function isMiddlewareStats(stats: webpack.Stats) {
  for (const key of stats.compilation.entrypoints.keys()) {
    if (isMiddlewareFilename(key)) {
      return true
    }
  }

  return false
}

function statsToJson(stats?: webpack.Stats | null) {
  if (!stats) return {}
  return stats.toJson({
    all: false,
    errors: true,
    hash: true,
    warnings: true,
  })
}

function getStatsForSyncEvent(
  clientStats: { ts: number; stats: webpack.Stats } | null,
  serverStats: { ts: number; stats: webpack.Stats } | null
) {
  if (!clientStats) return serverStats?.stats
  if (!serverStats) return clientStats?.stats

  // Prefer the server compiler stats if it has errors.
  // Otherwise we may end up in a state where the client compilation is the latest but without errors.
  // This causes the error overlay to not display the build error.
  if (serverStats.stats.hasErrors()) {
    return serverStats.stats
  }

  // Return the latest stats
  return serverStats.ts > clientStats.ts ? serverStats.stats : clientStats.stats
}

export class WebpackHotMiddleware {
  private clientsWithoutHtmlRequestId = new Set<ws>()
  private clientsByHtmlRequestId: Map<string, ws> = new Map()
  private closed = false
  private clientLatestStats: { ts: number; stats: webpack.Stats } | null = null
  private middlewareLatestStats: { ts: number; stats: webpack.Stats } | null =
    null
  private serverLatestStats: { ts: number; stats: webpack.Stats } | null = null

  constructor(
    compilers: webpack.Compiler[],
    private versionInfo: VersionInfo,
    private devtoolsFrontendUrl: string | undefined,
    private config: NextConfigComplete,
    private devToolsConfig: DevToolsConfig
  ) {
    compilers[0].hooks.invalid.tap(
      'webpack-hot-middleware',
      this.onClientInvalid
    )
    compilers[0].hooks.done.tap('webpack-hot-middleware', this.onClientDone)
    compilers[1].hooks.invalid.tap(
      'webpack-hot-middleware',
      this.onServerInvalid
    )
    compilers[1].hooks.done.tap('webpack-hot-middleware', this.onServerDone)
    compilers[2].hooks.done.tap('webpack-hot-middleware', this.onEdgeServerDone)
    compilers[2].hooks.invalid.tap(
      'webpack-hot-middleware',
      this.onEdgeServerInvalid
    )
  }

  onClientInvalid = () => {
    if (this.closed || this.serverLatestStats?.stats.hasErrors()) return
    this.publish({
      type: HMR_MESSAGE_SENT_TO_BROWSER.BUILDING,
    })
  }

  onClientDone = (statsResult: webpack.Stats) => {
    this.clientLatestStats = { ts: Date.now(), stats: statsResult }
    if (this.closed || this.serverLatestStats?.stats.hasErrors()) return
    this.publishStats(statsResult)
  }

  onServerInvalid = () => {
    if (!this.serverLatestStats?.stats.hasErrors()) return
    this.serverLatestStats = null
    if (this.clientLatestStats?.stats) {
      this.publishStats(this.clientLatestStats.stats)
    }
  }

  onServerDone = (statsResult: webpack.Stats) => {
    if (this.closed) return
    if (statsResult.hasErrors()) {
      this.serverLatestStats = { ts: Date.now(), stats: statsResult }
      this.publishStats(statsResult)
    }
  }

  onEdgeServerInvalid = () => {
    if (!this.middlewareLatestStats?.stats.hasErrors()) return
    this.middlewareLatestStats = null
    if (this.clientLatestStats?.stats) {
      this.publishStats(this.clientLatestStats.stats)
    }
  }

  onEdgeServerDone = (statsResult: webpack.Stats) => {
    if (this.closed) return
    if (!isMiddlewareStats(statsResult)) {
      this.onServerInvalid()
      this.onServerDone(statsResult)
    }

    if (statsResult.hasErrors()) {
      this.middlewareLatestStats = { ts: Date.now(), stats: statsResult }
      this.publishStats(statsResult)
    }
  }

  public updateDevToolsConfig(newConfig: DevToolsConfig): void {
    this.devToolsConfig = newConfig
  }

  /**
   * To sync we use the most recent stats but also we append middleware
   * errors. This is because it is possible that middleware fails to compile
   * and we still want to show the client overlay with the error while
   * the error page should be rendered just fine.
   */
  onHMR = (client: ws, htmlRequestId: string | null) => {
    if (this.closed) return

    if (htmlRequestId) {
      this.clientsByHtmlRequestId.set(htmlRequestId, client)
    } else {
      this.clientsWithoutHtmlRequestId.add(client)
    }

    client.addEventListener('close', () => {
      if (htmlRequestId) {
        this.clientsByHtmlRequestId.delete(htmlRequestId)
      } else {
        this.clientsWithoutHtmlRequestId.delete(client)
      }
    })

    const syncStats = getStatsForSyncEvent(
      this.clientLatestStats,
      this.serverLatestStats
    )

    if (syncStats) {
      const stats = statsToJson(syncStats)
      const middlewareStats = statsToJson(this.middlewareLatestStats?.stats)

      if (devIndicatorServerState.disabledUntil < Date.now()) {
        devIndicatorServerState.disabledUntil = 0
      }

      this.publish({
        type: HMR_MESSAGE_SENT_TO_BROWSER.SYNC,
        hash: stats.hash!,
        errors: [...(stats.errors || []), ...(middlewareStats.errors || [])],
        warnings: [
          ...(stats.warnings || []),
          ...(middlewareStats.warnings || []),
        ],
        versionInfo: this.versionInfo,
        debug: {
          devtoolsFrontendUrl: this.devtoolsFrontendUrl,
        },
        devIndicator: devIndicatorServerState,
        devToolsConfig: this.devToolsConfig,
      })
    }
  }

  publishStats = (statsResult: webpack.Stats) => {
    const stats = statsResult.toJson({
      all: false,
      hash: true,
      warnings: true,
      errors: true,
      moduleTrace: true,
    })

    this.publish({
      type: HMR_MESSAGE_SENT_TO_BROWSER.BUILT,
      hash: stats.hash!,
      warnings: stats.warnings || [],
      errors: stats.errors || [],
    })
  }

  getClient = (htmlRequestId: string): ws | undefined => {
    return this.clientsByHtmlRequestId.get(htmlRequestId)
  }

  publishToClient = (client: ws, message: HmrMessageSentToBrowser) => {
    if (this.closed) {
      return
    }

    const data =
      typeof message.type === 'number'
        ? createBinaryHmrMessageData(message)
        : JSON.stringify(message)

    client.send(data)
  }

  publish = (message: HmrMessageSentToBrowser) => {
    if (this.closed) {
      return
    }

    for (const wsClient of [
      ...this.clientsWithoutHtmlRequestId,
      ...this.clientsByHtmlRequestId.values(),
    ]) {
      this.publishToClient(wsClient, message)
    }
  }

  publishToLegacyClients = (message: HmrMessageSentToBrowser) => {
    if (this.closed) {
      return
    }

    // Clients with a request ID are inferred App Router clients. If Cache
    // Components is not enabled, we consider those legacy clients. Pages
    // Router clients are also considered legacy clients. TODO: Maybe mark
    // clients as App Router / Pages Router clients explicitly, instead of
    // inferring it from the presence of a request ID.

    if (!this.config.cacheComponents) {
      for (const wsClient of this.clientsByHtmlRequestId.values()) {
        this.publishToClient(wsClient, message)
      }
    }

    for (const wsClient of this.clientsWithoutHtmlRequestId) {
      this.publishToClient(wsClient, message)
    }
  }

  close = () => {
    if (this.closed) {
      return
    }

    // Can't remove compiler plugins, so we just set a flag and noop if closed
    // https://github.com/webpack/tapable/issues/32#issuecomment-350644466
    this.closed = true

    for (const wsClient of [
      ...this.clientsWithoutHtmlRequestId,
      ...this.clientsByHtmlRequestId.values(),
    ]) {
      // it's okay to not cleanly close these websocket connections, this is dev
      wsClient.terminate()
    }

    this.clientsWithoutHtmlRequestId.clear()
    this.clientsByHtmlRequestId.clear()
  }

  deleteClient = (client: ws, htmlRequestId: string | null) => {
    if (htmlRequestId) {
      this.clientsByHtmlRequestId.delete(htmlRequestId)
    } else {
      this.clientsWithoutHtmlRequestId.delete(client)
    }
  }

  hasClients = () => {
    return (
      this.clientsWithoutHtmlRequestId.size + this.clientsByHtmlRequestId.size >
      0
    )
  }

  getClientCount = () => {
    return (
      this.clientsWithoutHtmlRequestId.size + this.clientsByHtmlRequestId.size
    )
  }
}
Quest for Codev2.0.0
/
SIGN IN