fix: improvements for build and dev stability (#4470)

This commit is contained in:
Pooya Parsa 2018-12-09 14:12:22 +03:30 committed by GitHub
parent 669ffa51ed
commit fe0516978a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 274 additions and 187 deletions

View File

View File

@ -8,14 +8,11 @@ import pify from 'pify'
import serialize from 'serialize-javascript'
import upath from 'upath'
import concat from 'lodash/concat'
import debounce from 'lodash/debounce'
import map from 'lodash/map'
import omit from 'lodash/omit'
import template from 'lodash/template'
import uniq from 'lodash/uniq'
import uniqBy from 'lodash/uniqBy'
import values from 'lodash/values'
import devalue from '@nuxtjs/devalue'
@ -59,10 +56,11 @@ export default class Builder {
this.nuxt.hook('build:done', () => {
consola.info('Waiting for file changes')
this.watchClient()
this.watchRestart()
})
// Stop watching on nuxt.close()
this.nuxt.hook('close', () => this.unwatch())
// Close hook
this.nuxt.hook('close', () => this.close())
}
if (this.options.build.analyze) {
@ -502,6 +500,7 @@ export default class Builder {
watchClient() {
const src = this.options.srcDir
let patterns = [
r(src, this.options.dir.layouts),
r(src, this.options.dir.store),
@ -509,6 +508,7 @@ export default class Builder {
r(src, `${this.options.dir.layouts}/*.{vue,js}`),
r(src, `${this.options.dir.layouts}/**/*.{vue,js}`)
]
if (this._nuxtPages) {
patterns.push(
r(src, this.options.dir.pages),
@ -516,7 +516,8 @@ export default class Builder {
r(src, `${this.options.dir.pages}/**/*.{vue,js}`)
)
}
patterns = map(patterns, upath.normalizeSafe)
patterns = patterns.map(upath.normalizeSafe)
const options = this.options.watchers.chokidar
/* istanbul ignore next */
@ -529,43 +530,49 @@ export default class Builder {
.on('unlink', refreshFiles)
// Watch for custom provided files
let customPatterns = concat(
this.options.build.watch,
...values(omit(this.options.build.styleResources, ['options']))
)
customPatterns = map(uniq(customPatterns), upath.normalizeSafe)
const customPatterns = uniq([
...this.options.build.watch,
...Object.values(omit(this.options.build.styleResources, ['options']))
]).map(upath.normalizeSafe)
this.watchers.custom = chokidar
.watch(customPatterns, options)
.on('change', refreshFiles)
}
watchServer() {
const nuxtRestartWatch = concat(
this.options.serverMiddleware
.filter(isString)
.map(this.nuxt.resolver.resolveAlias),
this.options.watch.map(this.nuxt.resolver.resolveAlias),
path.join(this.options.rootDir, 'nuxt.config.js')
)
watchRestart() {
const nuxtRestartWatch = [
// Server middleware
...this.options.serverMiddleware.filter(isString),
// Custom watchers
...this.options.watch
].map(this.nuxt.resolver.resolveAlias)
this.watchers.restart = chokidar
.watch(nuxtRestartWatch, this.options.watchers.chokidar)
.on('change', (_path) => {
this.watchers.restart.close()
const { name, ext } = path.parse(_path)
this.nuxt.callHook('watch:fileChanged', this, `${name}${ext}`)
.on('all', (event, _path) => {
if (['add', 'change', 'unlink'].includes(event) === false) return
this.nuxt.callHook('watch:fileChanged', this, _path) // Legacy
this.nuxt.callHook('watch:restart', { event, path: _path })
})
}
async unwatch() {
unwatch() {
for (const watcher in this.watchers) {
if (this.watchers[watcher]) {
this.watchers[watcher].close()
}
this.watchers[watcher].close()
}
}
if (this.bundleBuilder.unwatch) {
await this.bundleBuilder.unwatch()
async close() {
if (this.__closed) return
this.__closed = true
// Unwatch
this.unwatch()
// Close bundleBuilder
if (typeof this.bundleBuilder.close === 'function') {
await this.bundleBuilder.close()
}
}
}

View File

@ -6,12 +6,8 @@ import * as commands from './commands'
import * as imports from './imports'
export default class NuxtCommand {
constructor({ name, description, usage, options, run } = {}) {
this.name = name || ''
this.description = description || ''
this.usage = usage || ''
this.options = Object.assign({}, options)
this._run = run
constructor(cmd = { name: '', usage: '', description: '', options: {} }) {
this.cmd = cmd
}
static async load(name) {
@ -33,7 +29,7 @@ export default class NuxtCommand {
}
run() {
return this._run(this)
return this.cmd.run(this)
}
showVersion() {
@ -63,8 +59,8 @@ export default class NuxtCommand {
const config = await loadNuxtConfig(argv)
const options = Object.assign(config, extraOptions || {})
for (const name of Object.keys(this.options)) {
this.options[name].prepare && this.options[name].prepare(this, options, argv)
for (const name of Object.keys(this.cmd.options)) {
this.cmd.options[name].prepare && this.cmd.options[name].prepare(this, options, argv)
}
return options
@ -97,8 +93,8 @@ export default class NuxtCommand {
default: {}
}
for (const name of Object.keys(this.options)) {
const option = this.options[name]
for (const name of Object.keys(this.cmd.options)) {
const option = this.cmd.options[name]
if (option.alias) {
minimistOptions.alias[option.alias] = name
@ -118,8 +114,8 @@ export default class NuxtCommand {
const options = []
let maxOptionLength = 0
for (const name in this.options) {
const option = this.options[name]
for (const name in this.cmd.options) {
const option = this.cmd.options[name]
let optionHelp = '--'
optionHelp += option.type === 'boolean' && option.default ? 'no-' : ''
@ -141,12 +137,12 @@ export default class NuxtCommand {
)
}).join('\n')
const usage = foldLines(`Usage: nuxt ${this.usage} [options]`, startSpaces)
const description = foldLines(this.description, startSpaces)
const usage = foldLines(`Usage: nuxt ${this.cmd.usage} [options]`, startSpaces)
const description = foldLines(this.cmd.description, startSpaces)
const opts = foldLines(`Options:`, startSpaces) + '\n\n' + _opts
let helpText = colorize(`${usage}\n\n`)
if (this.description) helpText += colorize(`${description}\n\n`)
if (this.cmd.description) helpText += colorize(`${description}\n\n`)
if (options.length) helpText += colorize(`${opts}\n\n`)
return helpText

View File

@ -1,8 +1,7 @@
import consola from 'consola'
import chalk from 'chalk'
import env from 'std-env'
import { common, server } from '../options'
import { showBanner } from '../utils'
import { showBanner, eventsMapping, formatPath } from '../utils'
export default {
name: 'dev',
@ -12,68 +11,68 @@ export default {
...common,
...server
},
async run(cmd) {
const argv = cmd.getArgv()
await this.startDev(cmd, argv)
},
const errorHandler = (err, instance) => {
instance && instance.builder.watchServer()
consola.error(err)
async startDev(cmd, argv) {
try {
await this._startDev(cmd, argv)
} catch (error) {
consola.error(error)
}
},
// Start dev
async function startDev(oldInstance) {
let nuxt, builder
async _startDev(cmd, argv) {
// Load config
const config = await cmd.getNuxtConfig(argv, { dev: true })
try {
nuxt = await cmd.getNuxt(
await cmd.getNuxtConfig(argv, { dev: true })
)
builder = await cmd.getBuilder(nuxt)
} catch (err) {
return errorHandler(err, oldInstance)
}
// Initialize nuxt instance
const nuxt = await cmd.getNuxt(config)
const logChanged = (name) => {
consola.log({
type: 'change',
icon: chalk.blue.bold(env.windows ? '»' : '↻'),
message: chalk.blue(name)
})
}
// Setup hooks
nuxt.hook('watch:restart', payload => this.onWatchRestart(payload, { nuxt, builder, cmd, argv }))
nuxt.hook('bundler:change', changedFileName => this.onBundlerChange(changedFileName))
nuxt.hook('watch:fileChanged', async (builder, name) => {
logChanged(name)
await startDev({ nuxt: builder.nuxt, builder })
})
// Start listening
await nuxt.server.listen()
nuxt.hook('bundler:change', (name) => {
logChanged(name)
})
// Create builder instance
const builder = await cmd.getBuilder(nuxt)
return (
Promise.resolve()
.then(() => oldInstance && oldInstance.nuxt.clearHook('watch:fileChanged'))
.then(() => oldInstance && oldInstance.builder.unwatch())
// Start build
.then(() => builder.build())
// Close old nuxt no matter if build successfully
.catch((err) => {
oldInstance && oldInstance.nuxt.close()
// Jump to errorHandler
throw err
})
.then(() => oldInstance && oldInstance.nuxt.close())
// Start listening
.then(() => nuxt.server.listen())
// Show banner
.then(() => showBanner(nuxt))
// Start watching serverMiddleware changes
.then(() => builder.watchServer())
// Handle errors
.catch(err => errorHandler(err, { builder, nuxt }))
)
}
// Start Build
await builder.build()
await startDev()
// Show banner after build
showBanner(nuxt)
// Return instance
return nuxt
},
logChanged({ event, path }) {
const { icon, color, action } = eventsMapping[event] || eventsMapping.change
consola.log({
type: event,
icon: chalk[color].bold(icon),
message: `${action} ${chalk.cyan(path)}`
})
},
async onWatchRestart({ event, path }, { nuxt, cmd, argv }) {
this.logChanged({
event,
path: formatPath(path)
})
await nuxt.close()
await this.startDev(cmd, argv)
},
onBundlerChange(changedFileName) {
this.logChanged(changedFileName)
}
}

View File

@ -18,6 +18,12 @@ const _require = esm(module, {
}
})
export const eventsMapping = {
add: { icon: '+', color: 'green', action: 'Created' },
change: { icon: env.windows ? '»' : '↻', color: 'blue', action: 'Updated' },
unlink: { icon: '-', color: 'red', action: 'Removed' }
}
const getRootDir = argv => path.resolve(argv._[0] || '.')
const getNuxtConfigFile = argv => path.resolve(getRootDir(argv), argv['config-file'])
@ -45,6 +51,9 @@ export async function loadNuxtConfig(argv) {
consola.fatal('Error while fetching async configuration')
}
}
// Keep _nuxtConfigFile for watching
options._nuxtConfigFile = nuxtConfigFile
} else if (argv['config-file'] !== 'nuxt.config.js') {
consola.fatal('Could not load config file: ' + argv['config-file'])
}
@ -128,3 +137,10 @@ export function normalizeArg(arg, defaultValue) {
}
return arg
}
export function formatPath(filePath) {
if (!filePath) {
return
}
return filePath.replace(process.cwd() + path.sep, '')
}

View File

@ -13,7 +13,7 @@ describe('dev', () => {
expect(typeof dev.run).toBe('function')
})
test('reloads on fileChanged hook', async () => {
test('reloads on fileRestartHook', async () => {
const Nuxt = mockNuxt()
const Builder = mockBuilder()
@ -23,21 +23,18 @@ describe('dev', () => {
expect(Builder.prototype.build).toHaveBeenCalled()
expect(Nuxt.prototype.server.listen).toHaveBeenCalled()
expect(Builder.prototype.watchServer).toHaveBeenCalled()
// expect(Builder.prototype.watchRestart).toHaveBeenCalled()
jest.clearAllMocks()
const builder = new Builder()
builder.nuxt = new Nuxt()
await Nuxt.fileChangedHook(builder)
await Nuxt.fileRestartHook(builder)
expect(consola.log).toHaveBeenCalled()
expect(Nuxt.prototype.clearHook).toHaveBeenCalled()
expect(Builder.prototype.unwatch).toHaveBeenCalled()
expect(Builder.prototype.build).toHaveBeenCalled()
expect(Nuxt.prototype.close).toHaveBeenCalled()
expect(Nuxt.prototype.server.listen).toHaveBeenCalled()
expect(Builder.prototype.watchServer).toHaveBeenCalled()
expect(consola.error).not.toHaveBeenCalled()
})
@ -53,13 +50,13 @@ describe('dev', () => {
const builder = new Builder()
builder.nuxt = new Nuxt()
Builder.prototype.build = jest.fn().mockImplementationOnce(() => Promise.reject(new Error('Build Error')))
await Nuxt.fileChangedHook(builder)
await Nuxt.fileRestartHook(builder)
expect(Nuxt.prototype.close).toHaveBeenCalled()
expect(consola.error).toHaveBeenCalledWith(new Error('Build Error'))
})
test('catches watchServer error', async () => {
test.skip('catches watchRestart error', async () => {
const Nuxt = mockNuxt()
const Builder = mockBuilder()
@ -68,11 +65,11 @@ describe('dev', () => {
const builder = new Builder()
builder.nuxt = new Nuxt()
Builder.prototype.watchServer = jest.fn().mockImplementationOnce(() => Promise.reject(new Error('watchServer Error')))
await Nuxt.fileChangedHook(builder)
Builder.prototype.watchRestart = jest.fn().mockImplementationOnce(() => Promise.reject(new Error('watchRestart Error')))
await Nuxt.fileRestartHook(builder)
expect(consola.error).toHaveBeenCalledWith(new Error('watchServer Error'))
expect(Builder.prototype.watchServer).toHaveBeenCalledTimes(2)
expect(consola.error).toHaveBeenCalledWith(new Error('watchRestart Error'))
expect(Builder.prototype.watchRestart).toHaveBeenCalledTimes(2)
})
test('catches error on hook error', async () => {
@ -87,10 +84,10 @@ describe('dev', () => {
})
const builder = new Builder()
builder.nuxt = new Nuxt()
await Nuxt.fileChangedHook(builder)
await Nuxt.fileRestartHook(builder)
expect(consola.error).toHaveBeenCalledWith(new Error('Config Error'))
expect(Builder.prototype.watchServer).toHaveBeenCalledTimes(1)
// expect(Builder.prototype.watchRestart).toHaveBeenCalledTimes(1)
})
test('catches error on startDev', async () => {

View File

@ -84,12 +84,13 @@ export const mockNuxt = (implementation) => {
const Nuxt = function () {}
Object.assign(Nuxt.prototype, {
hook(type, fn) {
if (type === 'watch:fileChanged') {
Nuxt.fileChangedHook = fn
if (type === 'watch:restart') {
Nuxt.fileRestartHook = fn
}
},
options: {},
clearHook: jest.fn(),
clearHooks: jest.fn(),
close: jest.fn(),
ready: jest.fn(),
server: {
@ -108,7 +109,7 @@ export const mockBuilder = (implementation) => {
Object.assign(Builder.prototype, {
build: jest.fn().mockImplementationOnce(() => Promise.resolve()),
unwatch: jest.fn().mockImplementationOnce(() => Promise.resolve()),
watchServer: jest.fn().mockImplementationOnce(() => Promise.resolve())
watchRestart: jest.fn().mockImplementationOnce(() => Promise.resolve())
}, implementation || {})
imports.builder.mockImplementation(() => ({ Builder }))

View File

@ -45,6 +45,10 @@ export default class Hookable {
}
}
clearHooks() {
this._hooks = {}
}
flatHooks(configHooks, hooks = {}, parentName) {
Object.keys(configHooks).forEach((key) => {
const subHook = configHooks[key]

View File

@ -27,6 +27,7 @@ export default () => ({
serverMiddleware: [],
// Dirs and extensions
_nuxtConfigFile: undefined,
srcDir: undefined,
buildDir: '.nuxt',
modulesDir: [

View File

@ -10,8 +10,14 @@ import { guardDir, isNonEmptyString, isPureObject, isUrl } from '@nuxt/common'
import { getDefaultNuxtConfig } from './config'
export function getNuxtConfig(_options) {
// Prevent duplicate calls
if (_options.__normalized__) {
return _options
}
// Clone options to prevent unwanted side-effects
const options = Object.assign({}, _options)
options.__normalized__ = true
// Normalize options
if (options.loading === true) {
@ -76,6 +82,14 @@ export function getNuxtConfig(_options) {
// Resolve buildDir
options.buildDir = path.resolve(options.rootDir, options.buildDir)
// Default value for _nuxtConfigFile
if (!options._nuxtConfigFile) {
options._nuxtConfigFile = path.resolve(options.rootDir, 'nuxt.config.js')
}
// Watch for _nuxtConfigFile changes
options.watch.push(options._nuxtConfigFile)
// Protect rootDir against buildDir
guardDir(options, 'rootDir', 'buildDir')

View File

@ -79,5 +79,7 @@ export default class Nuxt extends Hookable {
if (typeof callback === 'function') {
await callback()
}
this.clearHooks()
}
}

View File

@ -1,4 +1,5 @@
import path from 'path'
import consola from 'consola'
import launchMiddleware from 'launch-editor-middleware'
import serveStatic from 'serve-static'
import servePlaceholder from 'serve-placeholder'
@ -35,6 +36,9 @@ export default class Server {
// Create new connect instance
this.app = connect()
// Close hook
this.nuxt.hook('close', () => this.close())
}
async ready() {
@ -52,14 +56,6 @@ export default class Server {
// Call done hook
await this.nuxt.callHook('render:done', this)
// Close all listeners after nuxt close
this.nuxt.hook('close', async () => {
for (const listener of this.listeners) {
await listener.close()
}
this.listeners = []
})
}
async setupMiddleware() {
@ -175,16 +171,18 @@ export default class Server {
}
useMiddleware(middleware) {
// Resolve middleware
if (typeof middleware === 'string') {
middleware = this.nuxt.resolver.requireModule(middleware)
}
let handler = middleware.handler || middleware
// Resolve handler
if (typeof middleware.handler === 'string') {
middleware.handler = this.nuxt.resolver.requireModule(middleware.handler)
// Resolve handler setup as string (path)
if (typeof handler === 'string') {
try {
handler = this.nuxt.resolver.requireModule(middleware.handler || middleware)
} catch (err) {
if (!this.options.dev) throw err[0]
// Only warn missing file in development
consola.warn(err[0])
}
}
const handler = middleware.handler || middleware
// Resolve path
const path = (
@ -231,4 +229,25 @@ export default class Server {
await this.nuxt.callHook('listen', listener.server, listener)
}
async close() {
if (this.__closed) return
this.__closed = true
for (const listener of this.listeners) {
await listener.close()
}
this.listeners = []
if (typeof this.renderer.close === 'function') {
await this.renderer.close()
}
this.app.removeAllListeners()
this.app = null
for (const key in this.resources) {
delete this.resources[key]
}
}
}

View File

@ -448,4 +448,13 @@ export default class VueRenderer {
interpolate: /{{([\s\S]+?)}}/g
})
}
close() {
if (this.__closed) return
this.__closed = true
for (const key in this.renderer) {
delete this.renderer[key]
}
}
}

View File

@ -30,8 +30,10 @@ export class WebpackBundler {
// Initialize shared MFS for dev
if (this.context.options.dev) {
this.mfs = new MFS()
this.mfs.exists = function (...args) { return Promise.resolve(this.existsSync(...args)) }
this.mfs.readFile = function (...args) { return Promise.resolve(this.readFileSync(...args)) }
// TODO: Enable when async FS rquired
// this.mfs.exists = function (...args) { return Promise.resolve(this.existsSync(...args)) }
// this.mfs.readFile = function (...args) { return Promise.resolve(this.readFileSync(...args)) }
}
}
@ -121,63 +123,59 @@ export class WebpackBundler {
})
}
webpackCompile(compiler) {
return new Promise(async (resolve, reject) => {
const name = compiler.options.name
const { nuxt, options } = this.context
async webpackCompile(compiler) {
const name = compiler.options.name
const { nuxt, options } = this.context
await nuxt.callHook('build:compile', { name, compiler })
await nuxt.callHook('build:compile', { name, compiler })
// Load renderer resources after build
compiler.hooks.done.tap('load-resources', async (stats) => {
await nuxt.callHook('build:compiled', {
name,
compiler,
stats
})
// Reload renderer if available
await nuxt.callHook('build:resources', this.mfs)
// Resolve on next tick
process.nextTick(resolve)
// Load renderer resources after build
compiler.hooks.done.tap('load-resources', async (stats) => {
await nuxt.callHook('build:compiled', {
name,
compiler,
stats
})
if (options.dev) {
// --- Dev Build ---
// Client Build, watch is started by dev-middleware
if (['client', 'modern'].includes(name)) {
return this.webpackDev(compiler)
}
// Server, build and watch for changes
this.compilersWatching.push(
compiler.watch(options.watchers.webpack, (err) => {
/* istanbul ignore if */
if (err) return reject(err)
})
)
} else {
// --- Production Build ---
compiler.run((err, stats) => {
/* istanbul ignore next */
if (err) {
return reject(err)
} else if (stats.hasErrors()) {
if (options.build.quiet === true) {
err = stats.toString(options.build.stats)
}
if (!err) {
// actual errors will be printed by webpack itself
err = 'Nuxt Build Error'
}
// Reload renderer
await nuxt.callHook('build:resources', this.mfs)
})
return reject(err)
}
resolve()
// --- Dev Build ---
if (options.dev) {
// Client Build, watch is started by dev-middleware
if (['client', 'modern'].includes(name)) {
return new Promise((resolve, reject) => {
compiler.hooks.done.tap('nuxt-dev', () => resolve())
this.webpackDev(compiler)
})
}
})
// Server, build and watch for changes
return new Promise((resolve, reject) => {
const watching = compiler.watch(options.watchers.webpack, (err) => {
if (err) {
return reject(err)
}
watching.close = pify(watching.close)
this.compilersWatching.push(watching)
resolve()
})
})
}
// --- Production Build ---
compiler.run = pify(compiler.run)
const stats = await compiler.run()
if (stats.hasErrors()) {
if (options.build.quiet === true) {
return Promise.reject(stats.toString(options.build.stats))
} else {
// Actual error will be printet by webpack
throw new Error('Nuxt Build Error')
}
}
}
webpackDev(compiler) {
@ -229,12 +227,33 @@ export class WebpackBundler {
async unwatch() {
for (const watching of this.compilersWatching) {
watching.close()
await watching.close()
}
}
async close() {
if (this.__closed) return
this.__closed = true
// Unwatch
await this.unwatch()
// Stop webpack middleware
for (const devMiddleware of Object.values(this.devMiddleware)) {
await devMiddleware.close()
}
// Cleanup MFS
if (this.mfs) {
delete this.mfs.data
delete this.mfs
}
// Cleanup more resources
delete this.compilers
delete this.compilersWatching
delete this.devMiddleware
delete this.hotMiddleware
}
forGenerate() {

View File

@ -75,8 +75,11 @@ describe('fallback generate', () => {
})
test('generate.fallback = true is transformed to /404.html', () => {
nuxt.options.generate.fallback = true
const options = getNuxtConfig(nuxt.options)
const options = getNuxtConfig({
generate: {
fallback: true
}
})
expect(options.generate.fallback).toBe('404.html')
})