hot-reloader.js 12.9 KB
Newer Older
1
import { join, normalize } from 'path'
2 3
import WebpackDevMiddleware from 'webpack-dev-middleware'
import WebpackHotMiddleware from 'webpack-hot-middleware'
4
import errorOverlayMiddleware from './lib/error-overlay-middleware'
5
import del from 'del'
6
import onDemandEntryHandler, {normalizePage} from './on-demand-entry-handler'
T
Tim Neutkens 已提交
7
import webpack from 'webpack'
8
import WebSocket from 'ws'
9
import getBaseWebpackConfig from '../build/webpack-config'
10
import {IS_BUNDLED_PAGE_REGEX, ROUTE_NAME_REGEX, BLOCKED_PAGES} from 'next-server/constants'
T
Tim Neutkens 已提交
11
import {route} from 'next-server/dist/server/router'
12 13 14 15 16
import globModule from 'glob'
import {promisify} from 'util'
import {createPagesMapping, createEntrypoints} from '../build/entries'

const glob = promisify(globModule)
T
Tim Neutkens 已提交
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31

export async function renderScriptError (res, error) {
  // Asks CDNs and others to not to cache the errored page
  res.setHeader('Cache-Control', 'no-cache, no-store, max-age=0, must-revalidate')

  if (error.code === 'ENOENT' || error.message === 'INVALID_BUILD_ID') {
    res.statusCode = 404
    res.end('404 - Not Found')
    return
  }

  console.error(error.stack)
  res.statusCode = 500
  res.end('500 - Internal Error')
}
T
Tim Neutkens 已提交
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53

function addCorsSupport (req, res) {
  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']) {
    res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'])
  }

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

  return { preflight: false }
}

54
const matchNextPageBundleRequest = route('/_next/static/:buildId/pages/:path*.js(.map)?')
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89

// Recursively look up the issuer till it ends up at the root
function findEntryModule (issuer) {
  if (issuer.issuer) {
    return findEntryModule(issuer.issuer)
  }

  return issuer
}

function erroredPages (compilation, options = {enhanceName: (name) => name}) {
  const failedPages = {}
  for (const error of compilation.errors) {
    const entryModule = findEntryModule(error.origin)
    const {name} = entryModule
    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
}
90

N
nkzawa 已提交
91
export default class HotReloader {
92
  constructor (dir, { config, buildId } = {}) {
93
    this.buildId = buildId
N
nkzawa 已提交
94
    this.dir = dir
95 96 97
    this.middlewares = []
    this.webpackDevMiddleware = null
    this.webpackHotMiddleware = null
98
    this.initialized = false
N
nkzawa 已提交
99
    this.stats = null
100
    this.serverPrevDocumentHash = null
101

102
    this.config = config
N
nkzawa 已提交
103 104
  }

105
  async run (req, res, parsedUrl) {
T
Tim Neutkens 已提交
106 107 108 109 110 111 112 113 114
    // 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
    }

115 116 117 118
    // 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
119
    const handlePageBundleRequest = async (res, parsedUrl) => {
120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
      const {pathname} = parsedUrl
      const params = matchNextPageBundleRequest(pathname)
      if (!params) {
        return {}
      }

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

      const page = `/${params.path.join('/')}`
      if (BLOCKED_PAGES.indexOf(page) === -1) {
        try {
          await this.ensurePage(page)
        } catch (error) {
T
Tim Neutkens 已提交
135
          await renderScriptError(res, error)
136 137 138 139 140
          return {finished: true}
        }

        const errors = await this.getCompilationErrors(page)
        if (errors.length > 0) {
T
Tim Neutkens 已提交
141
          await renderScriptError(res, errors[0])
142 143 144 145 146 147 148
          return {finished: true}
        }
      }

      return {}
    }

149
    const {finished} = await handlePageBundleRequest(res, parsedUrl)
150

151 152 153
    for (const fn of this.middlewares) {
      await new Promise((resolve, reject) => {
        fn(req, res, (err) => {
154
          if (err) return reject(err)
155 156 157 158
          resolve()
        })
      })
    }
159 160

    return {finished}
N
nkzawa 已提交
161 162
  }

163 164 165 166
  async clean () {
    return del(join(this.dir, this.config.distDir), { force: true })
  }

167 168 169 170 171 172 173
  addWsConfig (configs) {
    const { websocketProxyPath, websocketProxyPort } = this.config.onDemandEntries
    const opts = {
      'process.env.NEXT_WS_PORT': websocketProxyPort || this.wsPort,
      'process.env.NEXT_WS_PROXY_PATH': JSON.stringify(websocketProxyPath)
    }
    configs[0].plugins.push(new webpack.DefinePlugin(opts))
174 175
  }

176 177 178 179 180 181 182 183 184 185
  async getWebpackConfig () {
    const pagePaths = await glob(`+(_app|_document|_error).+(${this.config.pageExtensions.join('|')})`, {cwd: join(this.dir, 'pages')})
    const pages = createPagesMapping(pagePaths, this.config.pageExtensions)
    const entrypoints = createEntrypoints(pages, 'server', this.buildId, this.config)
    return Promise.all([
      getBaseWebpackConfig(this.dir, { dev: true, isServer: false, config: this.config, buildId: this.buildId, entrypoints: entrypoints.client }),
      getBaseWebpackConfig(this.dir, { dev: true, isServer: true, config: this.config, buildId: this.buildId, entrypoints: entrypoints.server })
    ])
  }

N
nkzawa 已提交
186
  async start () {
187
    await this.clean()
T
Tim Neutkens 已提交
188

189
    this.wsPort = await new Promise((resolve, reject) => {
190 191 192
      const { websocketPort } = this.config.onDemandEntries
      // create on-demand-entries WebSocket
      this.wss = new WebSocket.Server({ port: websocketPort }, function (err) {
193 194 195 196 197 198 199 200 201
        if (err) {
          return reject(err)
        }

        const {port} = this.address()
        if (!port) {
          return reject(new Error('No websocket port could be detected'))
        }
        resolve(port)
202 203 204
      })
    })

205
    const configs = await this.getWebpackConfig()
206
    this.addWsConfig(configs)
N
Naoyuki Kanezawa 已提交
207

208
    const multiCompiler = webpack(configs)
T
Tim Neutkens 已提交
209

210
    const buildTools = await this.prepareBuildTools(multiCompiler)
211 212
    this.assignBuildTools(buildTools)

T
Tim Neutkens 已提交
213
    this.stats = (await this.waitUntilValid()).stats[0]
N
nkzawa 已提交
214 215
  }

216
  async stop (webpackDevMiddleware) {
217
    this.wss.close()
218 219
    const middleware = webpackDevMiddleware || this.webpackDevMiddleware
    if (middleware) {
N
nkzawa 已提交
220
      return new Promise((resolve, reject) => {
221
        middleware.close((err) => {
222
          if (err) return reject(err)
N
nkzawa 已提交
223 224 225 226 227 228
          resolve()
        })
      })
    }
  }

229 230 231
  async reload () {
    this.stats = null

232
    await this.clean()
T
Tim Neutkens 已提交
233

234
    const configs = await this.getWebpackConfig()
235
    this.addWsConfig(configs)
236

T
Tim Neutkens 已提交
237 238
    const compiler = webpack(configs)

239 240 241 242 243 244 245 246 247 248 249 250 251
    const buildTools = await this.prepareBuildTools(compiler)
    this.stats = await this.waitUntilValid(buildTools.webpackDevMiddleware)

    const oldWebpackDevMiddleware = this.webpackDevMiddleware

    this.assignBuildTools(buildTools)
    await this.stop(oldWebpackDevMiddleware)
  }

  assignBuildTools ({ webpackDevMiddleware, webpackHotMiddleware, onDemandEntries }) {
    this.webpackDevMiddleware = webpackDevMiddleware
    this.webpackHotMiddleware = webpackHotMiddleware
    this.onDemandEntries = onDemandEntries
252
    this.wss.on('connection', this.onDemandEntries.wsConnection)
253 254 255
    this.middlewares = [
      webpackDevMiddleware,
      webpackHotMiddleware,
256
      errorOverlayMiddleware,
257 258 259 260
      onDemandEntries.middleware()
    ]
  }

261 262 263 264 265 266
  async prepareBuildTools (multiCompiler) {
    // This plugin watches for changes to _document.js and notifies the client side that it should reload the page
    multiCompiler.compilers[1].hooks.done.tap('NextjsHotReloaderForServer', (stats) => {
      if (!this.initialized) {
        return
      }
N
nkzawa 已提交
267

268 269 270 271
      const {compilation} = stats

      // We only watch `_document` for changes on the server compilation
      // the rest of the files will be triggered by the client compilation
272
      const documentChunk = compilation.chunks.find(c => c.name === normalize(`static/${this.buildId}/pages/_document.js`))
273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
      // If the document chunk can't be found we do nothing
      if (!documentChunk) {
        console.warn('_document.js chunk not found')
        return
      }

      // Initial value
      if (this.serverPrevDocumentHash === null) {
        this.serverPrevDocumentHash = documentChunk.hash
        return
      }

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

      // Notify reload to reload the page, as _document.js was changed (different hash)
291
      this.send('reloadPage')
292
      this.serverPrevDocumentHash = documentChunk.hash
N
nkzawa 已提交
293
    })
N
nkzawa 已提交
294

295
    multiCompiler.compilers[0].hooks.done.tap('NextjsHotReloaderForClient', (stats) => {
296
      const { compilation } = stats
297 298 299
      const chunkNames = new Set(
        compilation.chunks
          .map((c) => c.name)
300
          .filter(name => IS_BUNDLED_PAGE_REGEX.test(name))
301 302
      )

303 304 305
      if (this.initialized) {
        // detect chunks which have to be replaced with a new template
        // e.g, pages/index.js <-> pages/_error.js
306 307 308 309 310 311 312 313 314
        const addedPages = diff(chunkNames, this.prevChunkNames)
        const removedPages = diff(this.prevChunkNames, chunkNames)

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

317 318 319 320 321 322
        if (removedPages.size > 0) {
          for (const removedPage of removedPages) {
            let page = '/' + ROUTE_NAME_REGEX.exec(removedPage)[1].replace(/\\/g, '/')
            page = page === '/index' ? '/' : page
            this.send('removedPage', page)
          }
N
Naoyuki Kanezawa 已提交
323
        }
N
nkzawa 已提交
324
      }
N
nkzawa 已提交
325

326 327 328
      this.initialized = true
      this.stats = stats
      this.prevChunkNames = chunkNames
N
nkzawa 已提交
329 330
    })

331
    // We don’t watch .git/ .next/ and node_modules for changes
332
    const ignored = [
333 334 335
      /[\\/]\.git[\\/]/,
      /[\\/]\.next[\\/]/,
      /[\\/]node_modules[\\/]/
336
    ]
337

338
    let webpackDevMiddlewareConfig = {
339
      publicPath: `/_next/static/webpack`,
N
nkzawa 已提交
340
      noInfo: true,
341
      logLevel: 'silent',
R
Radovan Šmitala 已提交
342 343
      watchOptions: { ignored },
      writeToDisk: true
344 345 346
    }

    if (this.config.webpackDevMiddleware) {
347
      console.log(`> Using "webpackDevMiddleware" config function defined in ${this.config.configOrigin}.`)
348 349 350
      webpackDevMiddlewareConfig = this.config.webpackDevMiddleware(webpackDevMiddlewareConfig)
    }

351
    const webpackDevMiddleware = WebpackDevMiddleware(multiCompiler, webpackDevMiddlewareConfig)
352

353
    const webpackHotMiddleware = WebpackHotMiddleware(multiCompiler.compilers[0], {
354
      path: '/_next/webpack-hmr',
355 356
      log: false,
      heartbeat: 2500
357
    })
T
Tim Neutkens 已提交
358

359
    const onDemandEntries = onDemandEntryHandler(webpackDevMiddleware, multiCompiler, {
360
      dir: this.dir,
361
      buildId: this.buildId,
362
      reload: this.reload.bind(this),
363
      pageExtensions: this.config.pageExtensions,
364
      wsPort: this.wsPort,
365 366
      ...this.config.onDemandEntries
    })
367

368 369 370 371 372
    return {
      webpackDevMiddleware,
      webpackHotMiddleware,
      onDemandEntries
    }
N
nkzawa 已提交
373 374
  }

375 376
  waitUntilValid (webpackDevMiddleware) {
    const middleware = webpackDevMiddleware || this.webpackDevMiddleware
N
nkzawa 已提交
377
    return new Promise((resolve) => {
378
      middleware.waitUntilValid(resolve)
N
nkzawa 已提交
379 380 381
    })
  }

382 383
  async getCompilationErrors (page) {
    const normalizedPage = normalizePage(page)
384 385 386
    // When we are reloading, we need to wait until it's reloaded properly.
    await this.onDemandEntries.waitUntilReloaded()

387 388 389 390 391
    if (this.stats.hasErrors()) {
      const {compilation} = this.stats
      const failedPages = erroredPages(compilation, {
        enhanceName (name) {
          return '/' + ROUTE_NAME_REGEX.exec(name)[1]
N
nkzawa 已提交
392
        }
393 394 395 396 397
      })

      // If there is an error related to the requesting page we display it instead of the first error
      if (failedPages[normalizedPage] && failedPages[normalizedPage].length > 0) {
        return failedPages[normalizedPage]
N
nkzawa 已提交
398
      }
399 400 401

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

404
    return []
N
nkzawa 已提交
405 406
  }

407 408
  send (action, ...args) {
    this.webpackHotMiddleware.publish({ action, data: args })
N
nkzawa 已提交
409
  }
410

T
Tim Neutkens 已提交
411
  async ensurePage (page) {
412
    // Make sure we don't re-build or dispose prebuilt pages
413
    if (BLOCKED_PAGES.indexOf(page) !== -1) {
414 415
      return
    }
T
Tim Neutkens 已提交
416
    await this.onDemandEntries.ensurePage(page)
417
  }
N
nkzawa 已提交
418
}
N
nkzawa 已提交
419

N
nkzawa 已提交
420 421 422
function diff (a, b) {
  return new Set([...a].filter((v) => !b.has(v)))
}