hot-reloader.ts 15.8 KB
Newer Older
1 2 3 4 5
import fs from 'fs'
import { IncomingMessage, ServerResponse } from 'http'
import { join, normalize, relative as relativePath, sep } from 'path'
import { promisify } from 'util'
import webpack from 'webpack'
6 7
import WebpackDevMiddleware from 'webpack-dev-middleware'
import WebpackHotMiddleware from 'webpack-hot-middleware'
8 9 10

import { createEntrypoints, createPagesMapping } from '../build/entries'
import { watchCompilers } from '../build/output'
11
import getBaseWebpackConfig from '../build/webpack-config'
12 13 14
import { NEXT_PROJECT_ROOT_DIST_CLIENT } from '../lib/constants'
import { fileExists } from '../lib/file-exists'
import { recursiveDelete } from '../lib/recursive-delete'
15
import {
16 17
  BLOCKED_PAGES,
  CLIENT_STATIC_FILES_RUNTIME_AMP,
18 19
  IS_BUNDLED_PAGE_REGEX,
  ROUTE_NAME_REGEX,
20 21
} from '../next-server/lib/constants'
import { route } from '../next-server/server/router'
22
import errorOverlayMiddleware from './lib/error-overlay-middleware'
C
Connor Davis 已提交
23
import { findPageFile } from './lib/find-page-file'
24 25 26
import onDemandEntryHandler, { normalizePage } from './on-demand-entry-handler'
import { NextHandleFunction } from 'connect'
import { UrlObject } from 'url'
27 28 29

const access = promisify(fs.access)
const readFile = promisify(fs.readFile)
T
Tim Neutkens 已提交
30

31
export async function renderScriptError(res: ServerResponse, error: Error) {
T
Tim Neutkens 已提交
32
  // Asks CDNs and others to not to cache the errored page
33 34 35 36
  res.setHeader(
    'Cache-Control',
    'no-cache, no-store, max-age=0, must-revalidate'
  )
T
Tim Neutkens 已提交
37

38 39 40 41
  if (
    (error as any).code === 'ENOENT' ||
    error.message === 'INVALID_BUILD_ID'
  ) {
T
Tim Neutkens 已提交
42 43 44 45 46 47 48 49 50
    res.statusCode = 404
    res.end('404 - Not Found')
    return
  }

  console.error(error.stack)
  res.statusCode = 500
  res.end('500 - Internal Error')
}
T
Tim Neutkens 已提交
51

52
function addCorsSupport(req: IncomingMessage, res: ServerResponse) {
T
Tim Neutkens 已提交
53 54 55 56 57 58 59 60
  if (!req.headers.origin) {
    return { preflight: false }
  }

  res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
  res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET')
  // Based on https://github.com/primus/access-control/blob/4cf1bc0e54b086c91e6aa44fb14966fa5ef7549c/index.js#L158
  if (req.headers['access-control-request-headers']) {
61 62 63
    res.setHeader('Access-Control-Allow-Headers', req.headers[
      'access-control-request-headers'
    ] as string)
T
Tim Neutkens 已提交
64 65 66 67 68 69 70 71 72 73 74
  }

  if (req.method === 'OPTIONS') {
    res.writeHead(200)
    res.end()
    return { preflight: true }
  }

  return { preflight: false }
}

75 76 77
const matchNextPageBundleRequest = route(
  '/_next/static/:buildId/pages/:path*.js(.map)?'
)
78 79

// Recursively look up the issuer till it ends up at the root
80
function findEntryModule(issuer: any): any {
81 82 83 84 85 86 87
  if (issuer.issuer) {
    return findEntryModule(issuer.issuer)
  }

  return issuer
}

88 89 90 91 92
function erroredPages(
  compilation: webpack.compilation.Compilation,
  options = { enhanceName: (name: string) => name }
) {
  const failedPages: { [page: string]: any[] } = {}
93
  for (const error of compilation.errors) {
94 95 96 97
    if (!error.origin) {
      continue
    }

98
    const entryModule = findEntryModule(error.origin)
99
    const { name } = entryModule
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
    if (!name) {
      continue
    }

    // Only pages have to be reloaded
    if (!IS_BUNDLED_PAGE_REGEX.test(name)) {
      continue
    }

    const enhancedName = options.enhanceName(name)

    if (!failedPages[enhancedName]) {
      failedPages[enhancedName] = []
    }

    failedPages[enhancedName].push(error)
  }

  return failedPages
}
120

N
nkzawa 已提交
121
export default class HotReloader {
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
  private dir: string
  private buildId: string
  private middlewares: any[]
  private pagesDir: string
  private webpackDevMiddleware: WebpackDevMiddleware.WebpackDevMiddleware | null
  private webpackHotMiddleware:
    | (NextHandleFunction & WebpackHotMiddleware.EventStream)
    | null
  private initialized: boolean
  private config: any
  private stats: any
  private serverPrevDocumentHash: string | null
  private prevChunkNames?: Set<any>
  private onDemandEntries: any

  constructor(
    dir: string,
    {
      config,
      pagesDir,
      buildId,
    }: { config: object; pagesDir: string; buildId: string }
  ) {
145
    this.buildId = buildId
N
nkzawa 已提交
146
    this.dir = dir
147
    this.middlewares = []
J
JJ Kasper 已提交
148
    this.pagesDir = pagesDir
149 150
    this.webpackDevMiddleware = null
    this.webpackHotMiddleware = null
151
    this.initialized = false
N
nkzawa 已提交
152
    this.stats = null
153
    this.serverPrevDocumentHash = null
154

155
    this.config = config
N
nkzawa 已提交
156 157
  }

158
  async run(req: IncomingMessage, res: ServerResponse, parsedUrl: UrlObject) {
T
Tim Neutkens 已提交
159 160 161 162 163 164 165 166 167
    // Usually CORS support is not needed for the hot-reloader (this is dev only feature)
    // With when the app runs for multi-zones support behind a proxy,
    // the current page is trying to access this URL via assetPrefix.
    // That's when the CORS support is needed.
    const { preflight } = addCorsSupport(req, res)
    if (preflight) {
      return
    }

168 169 170 171
    // When a request comes in that is a page bundle, e.g. /_next/static/<buildid>/pages/index.js
    // we have to compile the page using on-demand-entries, this middleware will handle doing that
    // by adding the page to on-demand-entries, waiting till it's done
    // and then the bundle will be served like usual by the actual route in server/index.js
172 173 174 175
    const handlePageBundleRequest = async (
      res: ServerResponse,
      parsedUrl: UrlObject
    ) => {
176
      const { pathname } = parsedUrl
177 178 179 180 181 182 183 184 185 186
      const params = matchNextPageBundleRequest(pathname)
      if (!params) {
        return {}
      }

      if (params.buildId !== this.buildId) {
        return
      }

      const page = `/${params.path.join('/')}`
187
      if (page === '/_error' || BLOCKED_PAGES.indexOf(page) === -1) {
188 189 190
        try {
          await this.ensurePage(page)
        } catch (error) {
T
Tim Neutkens 已提交
191
          await renderScriptError(res, error)
192
          return { finished: true }
193 194
        }

195 196 197
        const bundlePath = join(
          this.dir,
          this.config.distDir,
198 199
          'static/development/pages',
          page + '.js'
200 201 202 203 204 205 206 207 208 209 210 211 212
        )

        // make sure to 404 for AMP bundles in case they weren't removed
        try {
          await access(bundlePath)
          const data = await readFile(bundlePath, 'utf8')
          if (data.includes('__NEXT_DROP_CLIENT_FILE__')) {
            res.statusCode = 404
            res.end()
            return { finished: true }
          }
        } catch (_) {}

213 214
        const errors = await this.getCompilationErrors(page)
        if (errors.length > 0) {
T
Tim Neutkens 已提交
215
          await renderScriptError(res, errors[0])
216
          return { finished: true }
217 218 219 220 221 222
        }
      }

      return {}
    }

223
    const { finished } = (await handlePageBundleRequest(res, parsedUrl)) as any
224

225 226
    for (const fn of this.middlewares) {
      await new Promise((resolve, reject) => {
227
        fn(req, res, (err: Error) => {
228
          if (err) return reject(err)
229 230 231 232
          resolve()
        })
      })
    }
233

234
    return { finished }
N
nkzawa 已提交
235 236
  }

237
  async clean() {
238
    return recursiveDelete(join(this.dir, this.config.distDir))
239 240
  }

241
  async getWebpackConfig() {
C
Connor Davis 已提交
242
    const pagePaths = await Promise.all([
J
JJ Kasper 已提交
243
      findPageFile(this.pagesDir, '/_app', this.config.pageExtensions),
244
      findPageFile(this.pagesDir, '/_document', this.config.pageExtensions),
C
Connor Davis 已提交
245 246
    ])

247
    const pages = createPagesMapping(
248
      pagePaths.filter(i => i !== null) as string[],
249 250 251 252 253 254 255 256
      this.config.pageExtensions
    )
    const entrypoints = createEntrypoints(
      pages,
      'server',
      this.buildId,
      this.config
    )
257

258
    let additionalClientEntrypoints: { [file: string]: string } = {}
259 260
    additionalClientEntrypoints[CLIENT_STATIC_FILES_RUNTIME_AMP] =
      `.${sep}` +
261 262 263 264
      relativePath(
        this.dir,
        join(NEXT_PROJECT_ROOT_DIST_CLIENT, 'dev', 'amp-dev')
      )
265

266
    return Promise.all([
267 268 269 270 271
      getBaseWebpackConfig(this.dir, {
        dev: true,
        isServer: false,
        config: this.config,
        buildId: this.buildId,
J
JJ Kasper 已提交
272
        pagesDir: this.pagesDir,
273
        entrypoints: { ...entrypoints.client, ...additionalClientEntrypoints },
274 275 276 277 278 279
      }),
      getBaseWebpackConfig(this.dir, {
        dev: true,
        isServer: true,
        config: this.config,
        buildId: this.buildId,
J
JJ Kasper 已提交
280
        pagesDir: this.pagesDir,
281 282
        entrypoints: entrypoints.server,
      }),
283
    ])
284 285
  }

286
  async start() {
287
    await this.clean()
T
Tim Neutkens 已提交
288

289
    const configs = await this.getWebpackConfig()
N
Naoyuki Kanezawa 已提交
290

291
    const multiCompiler = webpack(configs)
T
Tim Neutkens 已提交
292

293
    const buildTools = await this.prepareBuildTools(multiCompiler)
294 295
    this.assignBuildTools(buildTools)

296
    this.stats = ((await this.waitUntilValid()) as any).stats[0]
N
nkzawa 已提交
297 298
  }

299
  async stop(webpackDevMiddleware?: WebpackDevMiddleware.WebpackDevMiddleware) {
300 301
    const middleware = webpackDevMiddleware || this.webpackDevMiddleware
    if (middleware) {
N
nkzawa 已提交
302
      return new Promise((resolve, reject) => {
303
        ;(middleware.close as any)((err: any) => {
304
          if (err) return reject(err)
N
nkzawa 已提交
305 306 307 308 309 310
          resolve()
        })
      })
    }
  }

311
  async reload() {
312 313
    this.stats = null

314
    await this.clean()
T
Tim Neutkens 已提交
315

316
    const configs = await this.getWebpackConfig()
T
Tim Neutkens 已提交
317 318
    const compiler = webpack(configs)

319 320 321 322 323 324
    const buildTools = await this.prepareBuildTools(compiler)
    this.stats = await this.waitUntilValid(buildTools.webpackDevMiddleware)

    const oldWebpackDevMiddleware = this.webpackDevMiddleware

    this.assignBuildTools(buildTools)
325
    await this.stop(oldWebpackDevMiddleware!)
326 327
  }

328
  assignBuildTools({
329 330
    webpackDevMiddleware,
    webpackHotMiddleware,
331 332 333 334 335
    onDemandEntries,
  }: {
    webpackDevMiddleware: WebpackDevMiddleware.WebpackDevMiddleware
    webpackHotMiddleware: NextHandleFunction & WebpackHotMiddleware.EventStream
    onDemandEntries: any
336
  }) {
337 338 339 340 341
    this.webpackDevMiddleware = webpackDevMiddleware
    this.webpackHotMiddleware = webpackHotMiddleware
    this.onDemandEntries = onDemandEntries
    this.middlewares = [
      webpackDevMiddleware,
342 343
      // must come before hotMiddleware
      onDemandEntries.middleware(),
344
      webpackHotMiddleware,
345
      errorOverlayMiddleware({ dir: this.dir }),
346 347 348
    ]
  }

349
  async prepareBuildTools(multiCompiler: webpack.MultiCompiler) {
350 351
    const tsConfigPath = join(this.dir, 'tsconfig.json')
    const useTypeScript = await fileExists(tsConfigPath)
352 353 354 355 356 357 358

    watchCompilers(
      multiCompiler.compilers[0],
      multiCompiler.compilers[1],
      useTypeScript,
      ({ errors, warnings }) => this.send('typeChecked', { errors, warnings })
    )
359

360
    // This plugin watches for changes to _document.js and notifies the client side that it should reload the page
361 362 363 364 365 366
    multiCompiler.compilers[1].hooks.done.tap(
      'NextjsHotReloaderForServer',
      stats => {
        if (!this.initialized) {
          return
        }
N
nkzawa 已提交
367

368
        const { compilation } = stats
369

370 371 372 373 374 375 376 377 378 379
        // We only watch `_document` for changes on the server compilation
        // the rest of the files will be triggered by the client compilation
        const documentChunk = compilation.chunks.find(
          c => c.name === normalize(`static/${this.buildId}/pages/_document.js`)
        )
        // If the document chunk can't be found we do nothing
        if (!documentChunk) {
          console.warn('_document.js chunk not found')
          return
        }
380

381 382 383 384 385
        // Initial value
        if (this.serverPrevDocumentHash === null) {
          this.serverPrevDocumentHash = documentChunk.hash
          return
        }
386

387 388 389 390
        // If _document.js didn't change we don't trigger a reload
        if (documentChunk.hash === this.serverPrevDocumentHash) {
          return
        }
391

392 393 394 395 396
        // Notify reload to reload the page, as _document.js was changed (different hash)
        this.send('reloadPage')
        this.serverPrevDocumentHash = documentChunk.hash
      }
    )
N
nkzawa 已提交
397

398 399 400 401 402 403 404 405 406
    multiCompiler.compilers[0].hooks.done.tap(
      'NextjsHotReloaderForClient',
      stats => {
        const { compilation } = stats
        const chunkNames = new Set(
          compilation.chunks
            .map(c => c.name)
            .filter(name => IS_BUNDLED_PAGE_REGEX.test(name))
        )
407

408 409 410
        if (this.initialized) {
          // detect chunks which have to be replaced with a new template
          // e.g, pages/index.js <-> pages/_error.js
411 412
          const addedPages = diff(chunkNames, this.prevChunkNames!)
          const removedPages = diff(this.prevChunkNames!, chunkNames)
413 414 415 416

          if (addedPages.size > 0) {
            for (const addedPage of addedPages) {
              let page =
417
                '/' + ROUTE_NAME_REGEX.exec(addedPage)![1].replace(/\\/g, '/')
418 419 420
              page = page === '/index' ? '/' : page
              this.send('addedPage', page)
            }
421
          }
N
Naoyuki Kanezawa 已提交
422

423 424 425
          if (removedPages.size > 0) {
            for (const removedPage of removedPages) {
              let page =
426
                '/' + ROUTE_NAME_REGEX.exec(removedPage)![1].replace(/\\/g, '/')
427 428 429
              page = page === '/index' ? '/' : page
              this.send('removedPage', page)
            }
430
          }
N
Naoyuki Kanezawa 已提交
431
        }
N
nkzawa 已提交
432

433 434 435 436 437
        this.initialized = true
        this.stats = stats
        this.prevChunkNames = chunkNames
      }
    )
N
nkzawa 已提交
438

439
    // We don’t watch .git/ .next/ and node_modules for changes
440
    const ignored = [
441 442
      /[\\/]\.git[\\/]/,
      /[\\/]\.next[\\/]/,
443
      /[\\/]node_modules[\\/]/,
444
    ]
445

446
    let webpackDevMiddlewareConfig = {
447
      publicPath: `/_next/static/webpack`,
N
nkzawa 已提交
448
      noInfo: true,
449
      logLevel: 'silent',
R
Radovan Šmitala 已提交
450
      watchOptions: { ignored },
451
      writeToDisk: true,
452 453 454
    }

    if (this.config.webpackDevMiddleware) {
455 456 457 458 459 460 461 462
      console.log(
        `> Using "webpackDevMiddleware" config function defined in ${
          this.config.configOrigin
        }.`
      )
      webpackDevMiddlewareConfig = this.config.webpackDevMiddleware(
        webpackDevMiddlewareConfig
      )
463 464
    }

465 466 467 468
    const webpackDevMiddleware = WebpackDevMiddleware(
      multiCompiler,
      webpackDevMiddlewareConfig
    )
469

470 471 472 473 474
    const webpackHotMiddleware = WebpackHotMiddleware(
      multiCompiler.compilers[0],
      {
        path: '/_next/webpack-hmr',
        log: false,
475
        heartbeat: 2500,
476 477
      }
    )
T
Tim Neutkens 已提交
478

479 480 481 482 483 484
    const onDemandEntries = onDemandEntryHandler(
      webpackDevMiddleware,
      multiCompiler,
      {
        dir: this.dir,
        buildId: this.buildId,
J
JJ Kasper 已提交
485
        pagesDir: this.pagesDir,
486 487 488 489 490
        distDir: this.config.distDir,
        reload: this.reload.bind(this),
        pageExtensions: this.config.pageExtensions,
        publicRuntimeConfig: this.config.publicRuntimeConfig,
        serverRuntimeConfig: this.config.serverRuntimeConfig,
491
        ...this.config.onDemandEntries,
492 493
      }
    )
494

495 496 497
    return {
      webpackDevMiddleware,
      webpackHotMiddleware,
498
      onDemandEntries,
499
    }
N
nkzawa 已提交
500 501
  }

502 503 504
  waitUntilValid(
    webpackDevMiddleware?: WebpackDevMiddleware.WebpackDevMiddleware
  ) {
505
    const middleware = webpackDevMiddleware || this.webpackDevMiddleware
506
    return new Promise(resolve => {
507
      middleware!.waitUntilValid(resolve)
N
nkzawa 已提交
508 509 510
    })
  }

511
  async getCompilationErrors(page: string) {
512
    const normalizedPage = normalizePage(page)
513 514 515
    // When we are reloading, we need to wait until it's reloaded properly.
    await this.onDemandEntries.waitUntilReloaded()

516
    if (this.stats.hasErrors()) {
517
      const { compilation } = this.stats
518
      const failedPages = erroredPages(compilation, {
519 520 521
        enhanceName(name) {
          return '/' + ROUTE_NAME_REGEX.exec(name)![1]
        },
522 523 524
      })

      // If there is an error related to the requesting page we display it instead of the first error
525 526 527 528
      if (
        failedPages[normalizedPage] &&
        failedPages[normalizedPage].length > 0
      ) {
529
        return failedPages[normalizedPage]
N
nkzawa 已提交
530
      }
531 532 533

      // If none were found we still have to show the other errors
      return this.stats.compilation.errors
N
nkzawa 已提交
534 535
    }

536
    return []
N
nkzawa 已提交
537 538
  }

539 540
  send = (action: string, ...args: any[]) => {
    this.webpackHotMiddleware!.publish({ action, data: args })
N
nkzawa 已提交
541
  }
542

543
  async ensurePage(page: string) {
544
    // Make sure we don't re-build or dispose prebuilt pages
545
    if (page !== '/_error' && BLOCKED_PAGES.indexOf(page) !== -1) {
546 547
      return
    }
548
    return this.onDemandEntries.ensurePage(page)
549
  }
N
nkzawa 已提交
550
}
N
nkzawa 已提交
551

552
function diff(a: Set<any>, b: Set<any>) {
553
  return new Set([...a].filter(v => !b.has(v)))
N
nkzawa 已提交
554
}