decouple builder from renderer + improvements

This commit is contained in:
Pooya Parsa 2017-06-14 20:43:43 +04:30
parent b61694ca21
commit 42bf9bb41d
13 changed files with 473 additions and 396 deletions

View File

@ -64,10 +64,10 @@ if (typeof options.rootDir !== 'string') {
// Force development mode: add hot reloading and watching changes // Force development mode: add hot reloading and watching changes
options.dev = true options.dev = true
var nuxt = module.exports = new Nuxt(options) var nuxt = new Nuxt(options)
var port = argv.port || process.env.PORT || process.env.npm_package_config_nuxt_port var port = argv.port || process.env.PORT || process.env.npm_package_config_nuxt_port
var host = argv.hostname || process.env.HOST || process.env.npm_package_config_nuxt_host var host = argv.hostname || process.env.HOST || process.env.npm_package_config_nuxt_host
var server = nuxt.server = new nuxt.Server(nuxt).listen(port, host) var server = new Nuxt.Server(nuxt).listen(port, host)
listenOnConfigChanges(nuxt, server) listenOnConfigChanges(nuxt, server)
@ -102,3 +102,5 @@ function listenOnConfigChanges(nuxt, server) {
chokidar.watch(nuxtConfigFile, Object.assign({}, nuxt.options.watchers.chokidar, { ignoreInitial: true })) chokidar.watch(nuxtConfigFile, Object.assign({}, nuxt.options.watchers.chokidar, { ignoreInitial: true }))
.on('all', build) .on('all', build)
} }
module.exports = nuxt

View File

@ -55,7 +55,9 @@ if (typeof options.rootDir !== 'string') {
} }
options.dev = false // Force production mode (no webpack middleware called) options.dev = false // Force production mode (no webpack middleware called)
var nuxt = module.exports = new Nuxt(options) var nuxt = new Nuxt(options)
var port = argv.port || process.env.PORT || process.env.npm_package_config_nuxt_port var port = argv.port || process.env.PORT || process.env.npm_package_config_nuxt_port
var host = argv.hostname || process.env.HOST || process.env.npm_package_config_nuxt_host var host = argv.hostname || process.env.HOST || process.env.npm_package_config_nuxt_host
module.exports = nuxt.server = new nuxt.Server(nuxt).listen(port, host) new Nuxt.Server(nuxt).listen(port, host)
module.exports = nuxt

View File

@ -4,15 +4,16 @@ import fs from 'fs-extra'
import hash from 'hash-sum' import hash from 'hash-sum'
import pify from 'pify' import pify from 'pify'
import webpack from 'webpack' import webpack from 'webpack'
import PostCompilePlugin from 'post-compile-webpack-plugin'
import serialize from 'serialize-javascript' import serialize from 'serialize-javascript'
import { createBundleRenderer } from 'vue-server-renderer' import webpackDevMiddleware from 'webpack-dev-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware'
import { join, resolve, basename, dirname } from 'path' import { join, resolve, basename, dirname } from 'path'
import Tapable from 'tappable' import Tapable from 'tappable'
import { isUrl, r, wp } from './utils' import { isUrl, r, wp, createRoutes } from './utils'
import clientWebpackConfig from './webpack/client.config.js' import clientWebpackConfig from './webpack/client.config.js'
import serverWebpackConfig from './webpack/server.config.js' import serverWebpackConfig from './webpack/server.config.js'
import defaults from './defaults' import defaults from './defaults'
import MFS from 'memory-fs'
const debug = require('debug')('nuxt:build') const debug = require('debug')('nuxt:build')
debug.color = 2 // Force green color debug.color = 2 // Force green color
@ -24,12 +25,39 @@ const writeFile = pify(fs.writeFile)
const mkdirp = pify(fs.mkdirp) const mkdirp = pify(fs.mkdirp)
const glob = pify(require('glob')) const glob = pify(require('glob'))
const host = process.env.HOST || process.env.npm_package_config_nuxt_host || 'localhost'
const port = process.env.PORT || process.env.npm_package_config_nuxt_port || '3000'
debug('loaded')
export default class Builder extends Tapable { export default class Builder extends Tapable {
constructor (nuxt) { constructor (nuxt) {
super() super()
this.nuxt = nuxt this.nuxt = nuxt
this.options = nuxt.options this.options = nuxt.options
this._buildStatus = STATUS.INITIAL
this.initialized = false
// Fields that set on build
this.compiler = null
this.webpackDevMiddleware = null
this.webpackHotMiddleware = null
if (nuxt.initialized) {
// If nuxt already initialized
this._init = this.init().catch(this.nuxt.errorHandler)
} else {
// Wait for hook
this.nuxt.plugin('init', this.init.bind(this))
}
}
async init () {
if (this._init) {
return this._init
}
// Add extra loaders only if they are not already provided // Add extra loaders only if they are not already provided
let extraDefaults = {} let extraDefaults = {}
if (this.options.build && !Array.isArray(this.options.build.loaders)) { if (this.options.build && !Array.isArray(this.options.build.loaders)) {
@ -38,37 +66,25 @@ export default class Builder extends Tapable {
if (this.options.build && !Array.isArray(this.options.build.postcss)) { if (this.options.build && !Array.isArray(this.options.build.postcss)) {
extraDefaults.postcss = defaultsPostcss extraDefaults.postcss = defaultsPostcss
} }
this.options.build = _.defaultsDeep(this.options.build, extraDefaults) _.defaultsDeep(this.options.build, extraDefaults)
/* istanbul ignore if */ /* istanbul ignore if */
if (this.options.dev && isUrl(this.options.build.publicPath)) { if (this.options.dev && isUrl(this.options.build.publicPath)) {
this.options.build.publicPath = defaults.build.publicPath this.options.build.publicPath = defaults.build.publicPath
} }
// Stats // If store defined, update store options to true unless explicitly disabled
this.webpackStats = { if (this.options.store !== false && fs.existsSync(join(this.options.srcDir, 'store'))) {
this.options.store = true
}
// Mute stats on dev
this.webpackStats = this.options.dev ? '' : {
chunks: false, chunks: false,
children: false, children: false,
modules: false, modules: false,
colors: true colors: true
} }
// Register lifecycle hooks
if (this.nuxt.options.dev) {
// Don't await for build on dev (faster startup)
this.nuxt.plugin('afterInit', () => {
this.build().catch(this.nuxt.errorHandler)
})
} else {
this.nuxt.plugin('init', () => {
// Guess it is build or production
// If build is not called it may be nuxt.start
if (this._buildStatus === STATUS.INITIAL) {
return this.production()
}
})
}
this._buildStatus = STATUS.INITIAL
} }
async build () { async build () {
@ -76,7 +92,6 @@ export default class Builder extends Tapable {
if (this._buildStatus === STATUS.BUILD_DONE) { if (this._buildStatus === STATUS.BUILD_DONE) {
return this return this
} }
// If building // If building
if (this._buildStatus === STATUS.BUILDING) { if (this._buildStatus === STATUS.BUILDING) {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -92,7 +107,6 @@ export default class Builder extends Tapable {
// Check if pages dir exists and warn if not // Check if pages dir exists and warn if not
this._nuxtPages = typeof this.options.build.createRoutes !== 'function' this._nuxtPages = typeof this.options.build.createRoutes !== 'function'
if (this._nuxtPages) { if (this._nuxtPages) {
if (!fs.existsSync(join(this.options.srcDir, 'pages'))) { if (!fs.existsSync(join(this.options.srcDir, 'pages'))) {
let dir = this.options.srcDir let dir = this.options.srcDir
@ -113,39 +127,19 @@ export default class Builder extends Tapable {
if (!this.options.dev) { if (!this.options.dev) {
await mkdirp(r(this.options.buildDir, 'dist')) await mkdirp(r(this.options.buildDir, 'dist'))
} }
// Generate routes and interpret the template files // Generate routes and interpret the template files
await this.generateRoutesAndFiles() await this.generateRoutesAndFiles()
// Generate .nuxt/dist/ files
await this.buildFiles() // Start webpack build
await this.webpackBuild()
// Flag to set that building is done // Flag to set that building is done
this._buildStatus = STATUS.BUILD_DONE this._buildStatus = STATUS.BUILD_DONE
return this
}
async production () {
// Production, create server-renderer
const serverConfig = this.getWebpackServerConfig()
const bundlePath = join(serverConfig.output.path, 'server-bundle.json')
const manifestPath = join(serverConfig.output.path, 'client-manifest.json')
if (!fs.existsSync(bundlePath) || !fs.existsSync(manifestPath)) {
throw new Error(`No build files found in ${serverConfig.output.path}, please run \`nuxt build\` before launching \`nuxt start\``)
}
const bundle = fs.readFileSync(bundlePath, 'utf8')
const manifest = fs.readFileSync(manifestPath, 'utf8')
this.createRenderer(JSON.parse(bundle), JSON.parse(manifest))
this.addAppTemplate()
return this return this
} }
addAppTemplate () {
let templatePath = resolve(this.options.buildDir, 'dist', 'index.html')
if (fs.existsSync(templatePath)) {
this.appTemplate = _.template(fs.readFileSync(templatePath, 'utf8'), {
interpolate: /{{([\s\S]+?)}}/g
})
}
}
async generateRoutesAndFiles () { async generateRoutesAndFiles () {
debug('Generating files...') debug('Generating files...')
// -- Templates -- // -- Templates --
@ -171,7 +165,7 @@ export default class Builder extends Tapable {
env: this.options.env, env: this.options.env,
head: this.options.head, head: this.options.head,
middleware: fs.existsSync(join(this.options.srcDir, 'middleware')), middleware: fs.existsSync(join(this.options.srcDir, 'middleware')),
store: this.options.store || fs.existsSync(join(this.options.srcDir, 'store')), store: this.options.store,
css: this.options.css, css: this.options.css,
plugins: this.options.plugins.map((p, i) => { plugins: this.options.plugins.map((p, i) => {
if (typeof p === 'string') p = { src: p } if (typeof p === 'string') p = { src: p }
@ -180,7 +174,7 @@ export default class Builder extends Tapable {
}), }),
appPath: './App.vue', appPath: './App.vue',
layouts: Object.assign({}, this.options.layouts), layouts: Object.assign({}, this.options.layouts),
loading: (typeof this.options.loading === 'string' ? r(this.options.srcDir, this.options.loading) : this.options.loading), loading: typeof this.options.loading === 'string' ? r(this.options.srcDir, this.options.loading) : this.options.loading,
transition: this.options.transition, transition: this.options.transition,
components: { components: {
ErrorPage: this.options.ErrorPage ? r(this.options.ErrorPage) : null ErrorPage: this.options.ErrorPage ? r(this.options.ErrorPage) : null
@ -212,7 +206,7 @@ export default class Builder extends Tapable {
if (this._nuxtPages) { if (this._nuxtPages) {
// Use nuxt.js createRoutes bases on pages/ // Use nuxt.js createRoutes bases on pages/
const files = await glob('pages/**/*.vue', { cwd: this.options.srcDir }) const files = await glob('pages/**/*.vue', { cwd: this.options.srcDir })
templateVars.router.routes = this.createRoutes(files, this.options.srcDir) templateVars.router.routes = createRoutes(files, this.options.srcDir)
} else { } else {
templateVars.router.routes = this.options.build.createRoutes(this.options.srcDir) templateVars.router.routes = this.options.build.createRoutes(this.options.srcDir)
} }
@ -221,8 +215,6 @@ export default class Builder extends Tapable {
// let the user extend the routes // let the user extend the routes
this.options.router.extendRoutes(templateVars.router.routes, r) this.options.router.extendRoutes(templateVars.router.routes, r)
} }
// Routes for generate command
this.routes = this.flatRoutes(templateVars.router.routes || [])
// -- Store -- // -- Store --
// Add store if needed // Add store if needed
@ -290,258 +282,100 @@ export default class Builder extends Tapable {
})) }))
} }
async buildFiles () { webpackBuild () {
debug('Building files...')
let compilersOptions = []
// Client
let clientConfig = clientWebpackConfig.call(this)
clientConfig.name = '$client'
compilersOptions.push(clientConfig)
// Server
if (this.options.ssr !== false) {
let serverConfig = serverWebpackConfig.call(this)
serverConfig.name = '$server'
compilersOptions.push(serverConfig)
}
// Leverage webpack multi-compiler for faster builds
this.compiler = webpack(compilersOptions)
// Access to compilers with name
this.compiler.compilers.forEach(compiler => {
if (compiler.name) {
this.compiler[compiler.name] = compiler
}
})
// Add middleware for dev
if (this.options.dev) { if (this.options.dev) {
debug('Adding webpack middleware...') this.webpackDev()
this.createWebpackMiddleware()
this.webpackWatchAndUpdate()
this.watchFiles()
} else {
debug('Building files...')
await this.webpackRunClient()
await this.webpackRunServer()
this.addAppTemplate()
} }
}
createRoutes (files, srcDir) { // Start build
let routes = []
files.forEach((file) => {
let keys = file.replace(/^pages/, '').replace(/\.vue$/, '').replace(/\/{2,}/g, '/').split('/').slice(1)
let route = { name: '', path: '', component: r(srcDir, file) }
let parent = routes
keys.forEach((key, i) => {
route.name = route.name ? route.name + '-' + key.replace('_', '') : key.replace('_', '')
route.name += (key === '_') ? 'all' : ''
let child = _.find(parent, { name: route.name })
if (child) {
if (!child.children) {
child.children = []
}
parent = child.children
route.path = ''
} else {
if (key === 'index' && (i + 1) === keys.length) {
route.path += (i > 0 ? '' : '/')
} else {
route.path += '/' + (key === '_' ? '*' : key.replace('_', ':'))
if (key !== '_' && key.indexOf('_') !== -1) {
route.path += '?'
}
}
}
})
// Order Routes path
parent.push(route)
parent.sort((a, b) => {
if (!a.path.length || a.path === '/') {
return -1
}
if (!b.path.length || b.path === '/') {
return 1
}
let res = 0
let _a = a.path.split('/')
let _b = b.path.split('/')
for (let i = 0; i < _a.length; i++) {
if (res !== 0) {
break
}
let y = (_a[i].indexOf('*') > -1) ? 2 : (_a[i].indexOf(':') > -1 ? 1 : 0)
let z = (_b[i].indexOf('*') > -1) ? 2 : (_b[i].indexOf(':') > -1 ? 1 : 0)
res = y - z
if (i === _b.length - 1 && res === 0) {
res = 1
}
}
return res === 0 ? -1 : res
})
})
return this.cleanChildrenRoutes(routes)
}
cleanChildrenRoutes (routes, isChild = false) {
let start = -1
let routesIndex = []
routes.forEach((route) => {
if (/-index$/.test(route.name) || route.name === 'index') {
// Save indexOf 'index' key in name
let res = route.name.split('-')
let s = res.indexOf('index')
start = (start === -1 || s < start) ? s : start
routesIndex.push(res)
}
})
routes.forEach((route) => {
route.path = (isChild) ? route.path.replace('/', '') : route.path
if (route.path.indexOf('?') > -1) {
let names = route.name.split('-')
let paths = route.path.split('/')
if (!isChild) {
paths.shift()
} // clean first / for parents
routesIndex.forEach((r) => {
let i = r.indexOf('index') - start // children names
if (i < paths.length) {
for (let a = 0; a <= i; a++) {
if (a === i) {
paths[a] = paths[a].replace('?', '')
}
if (a < i && names[a] !== r[a]) {
break
}
}
}
})
route.path = (isChild ? '' : '/') + paths.join('/')
}
route.name = route.name.replace(/-index$/, '')
if (route.children) {
if (route.children.find((child) => child.path === '')) {
delete route.name
}
route.children = this.cleanChildrenRoutes(route.children, true)
}
})
return routes
}
flatRoutes (router, path = '', routes = []) {
router.forEach((r) => {
if (!r.path.includes(':') && !r.path.includes('*')) {
if (r.children) {
this.flatRoutes(r.children, path + r.path + '/', routes)
} else {
routes.push((r.path === '' && path[path.length - 1] === '/' ? path.slice(0, -1) : path) + r.path)
}
}
})
return routes
}
getWebpackClientConfig () {
return clientWebpackConfig.call(this)
}
getWebpackServerConfig () {
return serverWebpackConfig.call(this)
}
createWebpackMiddleware () {
const clientConfig = this.getWebpackClientConfig()
const host = process.env.HOST || process.env.npm_package_config_nuxt_host || 'localhost'
const port = process.env.PORT || process.env.npm_package_config_nuxt_port || '3000'
// setup on the fly compilation + hot-reload
clientConfig.entry.app = _.flatten(['webpack-hot-middleware/client?reload=true', clientConfig.entry.app])
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
new PostCompilePlugin(stats => {
if (!stats.hasErrors() && !stats.hasWarnings()) {
// We don't use os.host() here because browsers have special behaviour with localhost
// For example chrome allows Geolocation api only to https or localhost origins
let _host = host === '0.0.0.0' ? 'localhost' : host
console.log(`> Open http://${_host}:${port}\n`) // eslint-disable-line no-console
}
})
)
const clientCompiler = webpack(clientConfig)
this.clientCompiler = clientCompiler
// Add the middleware to the instance context
this.webpackDevMiddleware = pify(require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
stats: this.webpackStats,
quiet: true,
noInfo: true,
watchOptions: this.options.watchers.webpack
}))
this.webpackHotMiddleware = pify(require('webpack-hot-middleware')(clientCompiler, {
log: () => {
}
}))
clientCompiler.plugin('done', () => {
const fs = this.webpackDevMiddleware.fileSystem
const filePath = join(clientConfig.output.path, 'index.html')
if (fs.existsSync(filePath)) {
const template = fs.readFileSync(filePath, 'utf-8')
this.appTemplate = _.template(template, {
interpolate: /{{([\s\S]+?)}}/g
})
}
this.watchHandler()
})
}
webpackWatchAndUpdate () {
const MFS = require('memory-fs') // <- dependencies of webpack
const serverFS = new MFS()
const clientFS = this.clientCompiler.outputFileSystem
const serverConfig = this.getWebpackServerConfig()
const serverCompiler = webpack(serverConfig)
const bundlePath = join(serverConfig.output.path, 'server-bundle.json')
const manifestPath = join(serverConfig.output.path, 'client-manifest.json')
serverCompiler.outputFileSystem = serverFS
const watchHandler = (err) => {
if (err) throw err
const bundleExists = serverFS.existsSync(bundlePath)
const manifestExists = clientFS.existsSync(manifestPath)
if (bundleExists && manifestExists) {
const bundle = serverFS.readFileSync(bundlePath, 'utf8')
const manifest = clientFS.readFileSync(manifestPath, 'utf8')
this.createRenderer(JSON.parse(bundle), JSON.parse(manifest))
}
}
this.watchHandler = watchHandler
this.webpackServerWatcher = serverCompiler.watch(this.options.watchers.webpack, watchHandler)
}
webpackRunClient () {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const clientConfig = this.getWebpackClientConfig() this.compiler.run((err, multiStats) => {
const clientCompiler = webpack(clientConfig) if (err) {
clientCompiler.run((err, stats) => { return reject(err)
if (err) return reject(err) }
console.log('[nuxt:build:client]\n', stats.toString(this.webpackStats)) // eslint-disable-line no-console for (let _stats of multiStats.stats) {
if (stats.hasErrors()) { console.log(_stats.toString(this.webpackStats)) // eslint-disable-line no-console
return reject(new Error('Webpack build exited with errors')) if (_stats.hasErrors()) {
return reject(new Error('Webpack build exited with errors'))
}
} }
resolve() resolve()
}) })
}) })
} }
webpackRunServer () { webpackDev () {
return new Promise((resolve, reject) => { debug('Adding webpack middleware...')
const serverConfig = this.getWebpackServerConfig()
const serverCompiler = webpack(serverConfig)
serverCompiler.run((err, stats) => {
if (err) return reject(err)
console.log('[nuxt:build:server]\n', stats.toString(this.webpackStats)) // eslint-disable-line no-console
if (stats.hasErrors()) return reject(new Error('Webpack build exited with errors'))
const bundlePath = join(serverConfig.output.path, 'server-bundle.json')
const manifestPath = join(serverConfig.output.path, 'client-manifest.json')
readFile(bundlePath, 'utf8')
.then(bundle => {
readFile(manifestPath, 'utf8')
.then(manifest => {
this.createRenderer(JSON.parse(bundle), JSON.parse(manifest))
resolve()
})
})
})
})
}
createRenderer (bundle, manifest) { // Use MFS for faster builds
// Create bundle renderer to give a fresh context for every request let mfs = new MFS()
this.renderer = createBundleRenderer(bundle, Object.assign({ this.compiler.compilers.forEach(compiler => {
clientManifest: manifest, compiler.outputFileSystem = mfs
runInNewContext: false, })
basedir: this.options.rootDir
}, this.options.build.ssr)) let clientConfig = this.compiler.$client.options
this.renderToString = pify(this.renderer.renderToString)
this.renderToStream = this.renderer.renderToStream // Setup on the fly compilation + hot-reload
clientConfig.entry.app = _.flatten(['webpack-hot-middleware/client?reload=true', clientConfig.entry.app])
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
)
// Create webpack dev middleware
this.webpackDevMiddleware = pify(webpackDevMiddleware(this.compiler.$client, {
publicPath: clientConfig.output.publicPath,
stats: this.webpackStats,
quiet: true,
noInfo: true,
watchOptions: this.options.watchers.webpack
}))
this.webpackHotMiddleware = pify(webpackHotMiddleware(this.compiler.$client, {
log: false,
heartbeat: 2500
}))
// Run after compilation is done
this.compiler.plugin('done', async stats => {
// Reload renderer if available
if (this.nuxt.renderer) {
await this.nuxt.renderer.loadResources(mfs)
}
// Show open URL
if (!stats.hasErrors() && !stats.hasWarnings()) {
let _host = host === '0.0.0.0' ? 'localhost' : host
console.log(`> Open http://${_host}:${port}\n`) // eslint-disable-line no-console
}
})
this.watchFiles()
} }
watchFiles () { watchFiles () {

View File

@ -1,4 +1,43 @@
export default { import _ from 'lodash'
import { join, resolve } from 'path'
import { existsSync } from 'fs'
export default function defaults (_options) {
// Clone options to prevent unwanted side-effects
const options = Object.assign({}, _options)
// Normalize options
if (options.loading === true) {
delete options.loading
}
if (options.router && typeof options.router.middleware === 'string') {
options.router.middleware = [options.router.middleware]
}
if (options.router && typeof options.router.base === 'string') {
options._routerBaseSpecified = true
}
if (typeof options.transition === 'string') {
options.transition = { name: options.transition }
}
// Apply defaults
_.defaultsDeep(options, defaultOptions)
// Resolve dirs
options.rootDir = (typeof options.rootDir === 'string' && options.rootDir ? options.rootDir : process.cwd())
options.srcDir = (typeof options.srcDir === 'string' && options.srcDir ? resolve(options.rootDir, options.srcDir) : options.rootDir)
options.buildDir = join(options.rootDir, options.buildDir)
// If app.html is defined, set the template path to the user template
options.appTemplatePath = resolve(__dirname, 'views/app.template.html')
if (existsSync(join(options.srcDir, 'app.html'))) {
options.appTemplatePath = join(options.srcDir, 'app.html')
}
return options
}
const defaultOptions = {
dev: (process.env.NODE_ENV !== 'production'), dev: (process.env.NODE_ENV !== 'production'),
buildDir: '.nuxt', buildDir: '.nuxt',
build: { build: {
@ -78,6 +117,7 @@ export default {
scrollBehavior: null scrollBehavior: null
}, },
render: { render: {
ssr: {},
http2: { http2: {
push: false push: false
}, },

View File

@ -4,7 +4,7 @@ import _ from 'lodash'
import { resolve, join, dirname, sep } from 'path' import { resolve, join, dirname, sep } from 'path'
import { minify } from 'html-minifier' import { minify } from 'html-minifier'
import Tapable from 'tappable' import Tapable from 'tappable'
import { isUrl, promisifyRoute, waitFor } from './utils' import { isUrl, promisifyRoute, waitFor, flatRoutes } from './utils'
const debug = require('debug')('nuxt:generate') const debug = require('debug')('nuxt:generate')
const copy = pify(fs.copy) const copy = pify(fs.copy)
@ -12,6 +12,8 @@ const remove = pify(fs.remove)
const writeFile = pify(fs.writeFile) const writeFile = pify(fs.writeFile)
const mkdirp = pify(fs.mkdirp) const mkdirp = pify(fs.mkdirp)
debug('loaded')
export default class Generator extends Tapable { export default class Generator extends Tapable {
constructor (nuxt) { constructor (nuxt) {
super() super()
@ -31,7 +33,7 @@ export default class Generator extends Tapable {
let distNuxtPath = join(distPath, (isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath)) let distNuxtPath = join(distPath, (isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath))
// Launch build process // Launch build process
await this.nuxt.builder.build() await this.nuxt.build()
// Clean destination folder // Clean destination folder
await remove(distPath) await remove(distPath)
@ -78,7 +80,7 @@ export default class Generator extends Tapable {
} }
// Generate only index.html for router.mode = 'hash' // Generate only index.html for router.mode = 'hash'
let routes = (this.options.router.mode === 'hash') ? ['/'] : this.nuxt.builder.routes let routes = (this.options.router.mode === 'hash') ? ['/'] : flatRoutes(this.options.router.routes)
routes = decorateWithPayloads(routes) routes = decorateWithPayloads(routes)
while (routes.length) { while (routes.length) {

View File

@ -1,77 +1,29 @@
import _ from 'lodash'
import fs from 'fs-extra'
import { resolve, join } from 'path'
import Tapable from 'tappable' import Tapable from 'tappable'
import * as Utils from './utils' import * as Utils from './utils'
import Builder from './builder'
import Renderer from './renderer' import Renderer from './renderer'
import Generator from './generator'
import ModuleContainer from './module-container' import ModuleContainer from './module-container'
import Server from './server' import Server from './server'
import Defaults from './defaults' import defaults from './defaults'
export default class Nuxt extends Tapable { export default class Nuxt extends Tapable {
constructor (_options = {}) { constructor (_options = {}) {
super() super()
// Clone options to prevent unwanted side-effects this.options = defaults(_options)
const options = Object.assign({}, _options)
// Normalize options this.initialized = false
if (options.loading === true) { this.errorHandler = this.errorHandler.bind(this)
delete options.loading
}
if (options.router && typeof options.router.middleware === 'string') {
options.router.middleware = [options.router.middleware]
}
if (options.router && typeof options.router.base === 'string') {
this._routerBaseSpecified = true
}
if (typeof options.transition === 'string') {
options.transition = { name: options.transition }
}
// Apply defaults
this.options = _.defaultsDeep(options, Nuxt.Defaults)
// Resolve dirs
this.options.rootDir = (typeof options.rootDir === 'string' && options.rootDir ? options.rootDir : process.cwd())
this.options.srcDir = (typeof options.srcDir === 'string' && options.srcDir ? resolve(options.rootDir, options.srcDir) : this.options.rootDir)
this.options.buildDir = join(this.options.rootDir, options.buildDir)
// If store defined, update store options to true
if (fs.existsSync(join(this.options.srcDir, 'store'))) {
this.options.store = true
}
// If app.html is defined, set the template path to the user template
this.options.appTemplatePath = resolve(__dirname, 'views/app.template.html')
if (fs.existsSync(join(this.options.srcDir, 'app.html'))) {
this.options.appTemplatePath = join(this.options.srcDir, 'app.html')
}
// Create instance of core components // Create instance of core components
this.moduleContainer = new Nuxt.ModuleContainer(this) this.moduleContainer = new Nuxt.ModuleContainer(this)
this.builder = new Nuxt.Builder(this)
this.renderer = new Nuxt.Renderer(this) this.renderer = new Nuxt.Renderer(this)
this.generator = new Nuxt.Generator(this)
// Backward compatibility // Backward compatibility
this.render = this.renderer.render.bind(this.renderer) this.render = this.renderer.render.bind(this.renderer)
this.renderRoute = this.renderer.renderRoute.bind(this.renderer) this.renderRoute = this.renderer.renderRoute.bind(this.renderer)
this.renderAndGetWindow = this.renderer.renderAndGetWindow.bind(this.renderer) this.renderAndGetWindow = this.renderer.renderAndGetWindow.bind(this.renderer)
this.build = this.builder.build.bind(this.builder)
this.generate = this.generator.generate.bind(this.generator)
this.dir = options.rootDir
this.srcDir = options.srcDir
this.buildDir = options.buildDir
this.dev = options.dev
this.Server = Nuxt.Server
this.Utils = Nuxt.Utils
this.errorHandler = this.errorHandler.bind(this)
this._init = this.init().catch(this.errorHandler) this._init = this.init().catch(this.errorHandler)
this.initialized = false
} }
async init () { async init () {
@ -79,29 +31,56 @@ export default class Nuxt extends Tapable {
return this._init return this._init
} }
// Call to build on dev
if (this.options.dev) {
this.builder.build().catch(this.errorHandler)
}
// Wait for all components to be ready // Wait for all components to be ready
// Including modules
await this.applyPluginsAsync('beforeInit') await this.applyPluginsAsync('beforeInit')
// Including Build
await this.applyPluginsAsync('init') await this.applyPluginsAsync('init')
// Extra jobs this.initialized = true
this.applyPluginsAsync('afterInit').catch(this.errorHandler) this.applyPluginsAsync('afterInit').catch(this.errorHandler)
this.initialized = true
return this return this
} }
get builder () {
if (this._builder) {
return this._builder
}
const Builder = require('./builder').default
this._builder = new Builder(this)
return this._builder
}
get generator () {
if (this._generator) {
return this._generator
}
const Generator = require('./generator').default
this._generator = new Generator(this)
return this._generator
}
build () {
return this.builder.build.apply(this.builder, arguments)
}
generate () {
return this.generator.generate.apply(this.generator, arguments)
}
errorHandler () { errorHandler () {
// Global error handler
// Silent // Silent
if (this.options.errorHandler === false) { if (this.options.errorHandler === false) {
return return
} }
// Custom eventHandler // Custom errorHandler
if (typeof this.options.errorHandler === 'function') { if (typeof this.options.errorHandler === 'function') {
return this.options.errorHandler.apply(this, arguments) return this.options.errorHandler.apply(this, arguments)
} }
// Default // Default handler
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error.apply(this, arguments) console.error.apply(this, arguments)
process.exit(1) process.exit(1)
@ -131,6 +110,9 @@ export default class Nuxt extends Tapable {
if (this.customFilesWatcher) { if (this.customFilesWatcher) {
this.customFilesWatcher.close() this.customFilesWatcher.close()
} }
promises.push(this.applyPluginsAsync('close'))
return Promise.all(promises).then(() => { return Promise.all(promises).then(() => {
if (typeof callback === 'function') callback() if (typeof callback === 'function') callback()
}) })
@ -138,10 +120,7 @@ export default class Nuxt extends Tapable {
} }
// Add core components to Nuxt class // Add core components to Nuxt class
Nuxt.Defaults = Defaults
Nuxt.Utils = Utils Nuxt.Utils = Utils
Nuxt.Renderer = Renderer Nuxt.Renderer = Renderer
Nuxt.Builder = Builder
Nuxt.ModuleContainer = ModuleContainer Nuxt.ModuleContainer = ModuleContainer
Nuxt.Server = Server Nuxt.Server = Server
Nuxt.Generator = Generator

View File

@ -7,8 +7,9 @@ import pify from 'pify'
import serveStatic from 'serve-static' import serveStatic from 'serve-static'
import compression from 'compression' import compression from 'compression'
import _ from 'lodash' import _ from 'lodash'
import { resolve } from 'path' import { resolve, join } from 'path'
import {readFileSync} from 'fs' import fs from 'fs-extra'
import { createBundleRenderer } from 'vue-server-renderer'
import { getContext, setAnsiColors, encodeHtml } from './utils' import { getContext, setAnsiColors, encodeHtml } from './utils'
const debug = require('debug')('nuxt:render') const debug = require('debug')('nuxt:render')
@ -17,61 +18,149 @@ setAnsiColors(ansiHTML)
let jsdom = null let jsdom = null
const parseTemplate = templateStr => _.template(templateStr, {
interpolate: /{{([\s\S]+?)}}/g
})
export default class Renderer extends Tapable { export default class Renderer extends Tapable {
constructor (nuxt) { constructor (nuxt) {
super() super()
this.nuxt = nuxt this.nuxt = nuxt
this.options = nuxt.options this.options = nuxt.options
this.nuxt.plugin('init', () => { // Will be loaded by createRenderer
// For serving static/ files to / this.bundleRenderer = null
this.serveStatic = pify(serveStatic(resolve(this.options.srcDir, 'static'), this.options.render.static)) this.renderToStream = null
this.renderToString = null
// For serving .nuxt/dist/ files (only when build.publicPath is not an URL) if (nuxt.initialized) {
this.serveStaticNuxt = pify(serveStatic(resolve(this.options.buildDir, 'dist'), { // If nuxt already initialized
maxAge: (this.options.dev ? 0 : '1y') // 1 year in production this._init = this.init().catch(this.nuxt.errorHandler)
})) } else {
// Wait for hook
this.nuxt.plugin('init', this.init.bind(this))
}
}
// gzip middleware for production async init () {
if (!this.options.dev && this.options.render.gzip) { if (this._init) {
this.gzipMiddleware = pify(compression(this.options.render.gzip)) return this._init
}
// Renderer runtime resources
this.resources = {
clientManifest: null,
serverBundle: null,
appTemplate: null,
errorTemplate: parseTemplate(fs.readFileSync(resolve(__dirname, 'views', 'error.html'), 'utf8'))
}
// For serving static/ files to /
this.serveStatic = pify(serveStatic(resolve(this.options.srcDir, 'static'), this.options.render.static))
// For serving .nuxt/dist/ files (only when build.publicPath is not an URL)
this.serveStaticNuxt = pify(serveStatic(resolve(this.options.buildDir, 'dist'), {
maxAge: (this.options.dev ? 0 : '1y') // 1 year in production
}))
// gzip middleware for production
if (!this.options.dev && this.options.render.gzip) {
this.gzipMiddleware = pify(compression(this.options.render.gzip))
}
// Try to load resources from fs
return this.loadResources()
}
async loadResources (_fs = fs, distPath) {
distPath = distPath || resolve(this.options.buildDir, 'dist')
const resourceMap = {
clientManifest: {
path: join(distPath, 'client-manifest.json'),
transform: JSON.parse
},
serverBundle: {
path: join(distPath, 'server-bundle.json'),
transform: JSON.parse
},
appTemplate: {
path: join(distPath, 'index.html'),
transform: parseTemplate
} }
}
// Error template Object.keys(resourceMap).forEach(resourceKey => {
this.errorTemplate = _.template(readFileSync(resolve(__dirname, 'views', 'error.html'), 'utf8'), { let { path, transform } = resourceMap[resourceKey]
interpolate: /{{([\s\S]+?)}}/g let data
}) if (_fs.existsSync(path)) {
data = _fs.readFileSync(path, 'utf8')
if (typeof transform === 'function') {
data = transform(data)
}
}
if (data) {
this.resources[resourceKey] = data
}
}) })
this.createRenderer()
}
createRenderer () {
// If resources are not yet provided
if (!this.resources.serverBundle || !this.resources.clientManifest) {
return
}
// Create bundle renderer for SSR
this.bundleRenderer = createBundleRenderer(this.resources.serverBundle, Object.assign({
clientManifest: this.resources.clientManifest,
runInNewContext: false,
basedir: this.options.rootDir
}, this.options.render.ssr))
// Promisify renderToString
this.bundleRenderer.renderToString = pify(this.bundleRenderer.renderToString)
debug('ready')
} }
async render (req, res) { async render (req, res) {
/* istanbul ignore if */ /* istanbul ignore if */
if (!this.nuxt.builder.renderer || !this.nuxt.builder.appTemplate) { if (!this.bundleRenderer || !this.resources.appTemplate) {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
resolve(this.render(req, res)) resolve(this.render(req, res))
}, 1000) }, 1000)
}) })
} }
// Get context // Get context
const context = getContext(req, res) const context = getContext(req, res)
res.statusCode = 200 res.statusCode = 200
try { try {
if (this.options.dev) { // Call webpack middleware only in development
// Call webpack middleware only in development if (this.options.dev && this.nuxt.builder && this.nuxt.builder.webpackDevMiddleware) {
await this.nuxt.builder.webpackDevMiddleware(req, res) await this.nuxt.builder.webpackDevMiddleware(req, res)
await this.nuxt.builder.webpackHotMiddleware(req, res) await this.nuxt.builder.webpackHotMiddleware(req, res)
} }
if (!this.options.dev && this.options.render.gzip) {
// Gzip middleware for production
if (this.gzipMiddleware) {
await this.gzipMiddleware(req, res) await this.gzipMiddleware(req, res)
} }
// If base in req.url, remove it for the middleware and vue-router // If base in req.url, remove it for the middleware and vue-router
if (this.options.router.base !== '/' && req.url.indexOf(this.options.router.base) === 0) { if (this.options.router.base !== '/' && req.url.indexOf(this.options.router.base) === 0) {
// Compatibility with base url for dev server // Compatibility with base url for dev server
req.url = req.url.replace(this.options.router.base, '/') req.url = req.url.replace(this.options.router.base, '/')
} }
// Serve static/ files // Serve static/ files
await this.serveStatic(req, res) await this.serveStatic(req, res)
// Serve .nuxt/dist/ files (only for production) // Serve .nuxt/dist/ files (only for production)
if (!this.options.dev && req.url.indexOf(this.options.build.publicPath) === 0) { if (!this.options.dev && req.url.indexOf(this.options.build.publicPath) === 0) {
const url = req.url const url = req.url
@ -80,17 +169,22 @@ export default class Renderer extends Tapable {
/* istanbul ignore next */ /* istanbul ignore next */
req.url = url req.url = url
} }
if (this.options.dev && req.url.indexOf(this.options.build.publicPath) === 0 && req.url.includes('.hot-update.json')) { if (this.options.dev && req.url.indexOf(this.options.build.publicPath) === 0 && req.url.includes('.hot-update.json')) {
res.statusCode = 404 res.statusCode = 404
return res.end() return res.end()
} }
const { html, error, redirected, resourceHints } = await this.renderRoute(req.url, context) const { html, error, redirected, resourceHints } = await this.renderRoute(req.url, context)
if (redirected) { if (redirected) {
return html return html
} }
if (error) { if (error) {
res.statusCode = context.nuxt.error.statusCode || 500 res.statusCode = context.nuxt.error.statusCode || 500
} }
// ETag header // ETag header
if (!error && this.options.render.etag) { if (!error && this.options.render.etag) {
const etag = generateETag(html, this.options.render.etag) const etag = generateETag(html, this.options.render.etag)
@ -101,6 +195,7 @@ export default class Renderer extends Tapable {
} }
res.setHeader('ETag', etag) res.setHeader('ETag', etag)
} }
// HTTP2 push headers // HTTP2 push headers
if (!error && this.options.render.http2.push) { if (!error && this.options.render.http2.push) {
// Parse resourceHints to extract HTTP.2 prefetch/push headers // Parse resourceHints to extract HTTP.2 prefetch/push headers
@ -118,6 +213,8 @@ export default class Renderer extends Tapable {
// https://blog.cloudflare.com/http-2-server-push-with-multiple-assets-per-link-header // https://blog.cloudflare.com/http-2-server-push-with-multiple-assets-per-link-header
res.setHeader('Link', pushAssets.join(',')) res.setHeader('Link', pushAssets.join(','))
} }
// Send response
res.setHeader('Content-Type', 'text/html; charset=utf-8') res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Content-Length', Buffer.byteLength(html)) res.setHeader('Content-Length', Buffer.byteLength(html))
res.end(html, 'utf8') res.end(html, 'utf8')
@ -127,11 +224,13 @@ export default class Renderer extends Tapable {
console.error(err) // eslint-disable-line no-console console.error(err) // eslint-disable-line no-console
return err return err
} }
const html = this.errorTemplate({ // Render error template
const html = this.resources.errorTemplate({
/* istanbul ignore if */ /* istanbul ignore if */
error: err, error: err,
stack: ansiHTML(encodeHtml(err.stack)) stack: ansiHTML(encodeHtml(err.stack))
}) })
// Send response
res.statusCode = 500 res.statusCode = 500
res.setHeader('Content-Type', 'text/html; charset=utf-8') res.setHeader('Content-Type', 'text/html; charset=utf-8')
res.setHeader('Content-Length', Buffer.byteLength(html)) res.setHeader('Content-Length', Buffer.byteLength(html))
@ -143,29 +242,34 @@ export default class Renderer extends Tapable {
async renderRoute (url, context = {}) { async renderRoute (url, context = {}) {
// Log rendered url // Log rendered url
debug(`Rendering url ${url}`) debug(`Rendering url ${url}`)
// Add url and isSever to the context // Add url and isSever to the context
context.url = url context.url = url
context.isServer = true context.isServer = true
// Call renderToString from the bundleRenderer and generate the HTML (will update the context as well) // Call renderToString from the bundleRenderer and generate the HTML (will update the context as well)
let APP = await this.nuxt.builder.renderToString(context) let APP = await this.bundleRenderer.renderToString(context)
if (!context.nuxt.serverRendered) { if (!context.nuxt.serverRendered) {
APP = '<div id="__nuxt"></div>' APP = '<div id="__nuxt"></div>'
} }
const m = context.meta.inject() const m = context.meta.inject()
let HEAD = m.meta.text() + m.title.text() + m.link.text() + m.style.text() + m.script.text() + m.noscript.text() let HEAD = m.meta.text() + m.title.text() + m.link.text() + m.style.text() + m.script.text() + m.noscript.text()
if (this._routerBaseSpecified) { if (this.options._routerBaseSpecified) {
HEAD += `<base href="${this.options.router.base}">` HEAD += `<base href="${this.options.router.base}">`
} }
const resourceHints = context.renderResourceHints() const resourceHints = context.renderResourceHints()
HEAD += resourceHints + context.renderStyles() HEAD += resourceHints + context.renderStyles()
APP += `<script type="text/javascript">window.__NUXT__=${serialize(context.nuxt, { isJSON: true })}</script>` APP += `<script type="text/javascript">window.__NUXT__=${serialize(context.nuxt, { isJSON: true })}</script>`
APP += context.renderScripts() APP += context.renderScripts()
const html = this.nuxt.builder.appTemplate({
const html = this.resources.appTemplate({
HTML_ATTRS: 'data-n-head-ssr ' + m.htmlAttrs.text(), HTML_ATTRS: 'data-n-head-ssr ' + m.htmlAttrs.text(),
BODY_ATTRS: m.bodyAttrs.text(), BODY_ATTRS: m.bodyAttrs.text(),
HEAD, HEAD,
APP APP
}) })
return { return {
html, html,
resourceHints, resourceHints,

View File

@ -97,3 +97,118 @@ export function r () {
args = args.map(normalize) args = args.map(normalize)
return wp(resolve.apply(null, args)) return wp(resolve.apply(null, args))
} }
export function flatRoutes (router, path = '', routes = []) {
router.forEach((r) => {
if (!r.path.includes(':') && !r.path.includes('*')) {
if (r.children) {
flatRoutes(r.children, path + r.path + '/', routes)
} else {
routes.push((r.path === '' && path[path.length - 1] === '/' ? path.slice(0, -1) : path) + r.path)
}
}
})
return routes
}
export function cleanChildrenRoutes (routes, isChild = false) {
let start = -1
let routesIndex = []
routes.forEach((route) => {
if (/-index$/.test(route.name) || route.name === 'index') {
// Save indexOf 'index' key in name
let res = route.name.split('-')
let s = res.indexOf('index')
start = (start === -1 || s < start) ? s : start
routesIndex.push(res)
}
})
routes.forEach((route) => {
route.path = (isChild) ? route.path.replace('/', '') : route.path
if (route.path.indexOf('?') > -1) {
let names = route.name.split('-')
let paths = route.path.split('/')
if (!isChild) {
paths.shift()
} // clean first / for parents
routesIndex.forEach((r) => {
let i = r.indexOf('index') - start // children names
if (i < paths.length) {
for (let a = 0; a <= i; a++) {
if (a === i) {
paths[a] = paths[a].replace('?', '')
}
if (a < i && names[a] !== r[a]) {
break
}
}
}
})
route.path = (isChild ? '' : '/') + paths.join('/')
}
route.name = route.name.replace(/-index$/, '')
if (route.children) {
if (route.children.find((child) => child.path === '')) {
delete route.name
}
route.children = cleanChildrenRoutes(route.children, true)
}
})
return routes
}
export function createRoutes (files, srcDir) {
let routes = []
files.forEach((file) => {
let keys = file.replace(/^pages/, '').replace(/\.vue$/, '').replace(/\/{2,}/g, '/').split('/').slice(1)
let route = { name: '', path: '', component: r(srcDir, file) }
let parent = routes
keys.forEach((key, i) => {
route.name = route.name ? route.name + '-' + key.replace('_', '') : key.replace('_', '')
route.name += (key === '_') ? 'all' : ''
let child = _.find(parent, { name: route.name })
if (child) {
if (!child.children) {
child.children = []
}
parent = child.children
route.path = ''
} else {
if (key === 'index' && (i + 1) === keys.length) {
route.path += (i > 0 ? '' : '/')
} else {
route.path += '/' + (key === '_' ? '*' : key.replace('_', ':'))
if (key !== '_' && key.indexOf('_') !== -1) {
route.path += '?'
}
}
}
})
// Order Routes path
parent.push(route)
parent.sort((a, b) => {
if (!a.path.length || a.path === '/') {
return -1
}
if (!b.path.length || b.path === '/') {
return 1
}
let res = 0
let _a = a.path.split('/')
let _b = b.path.split('/')
for (let i = 0; i < _a.length; i++) {
if (res !== 0) {
break
}
let y = (_a[i].indexOf('*') > -1) ? 2 : (_a[i].indexOf(':') > -1 ? 1 : 0)
let z = (_b[i].indexOf('*') > -1) ? 2 : (_b[i].indexOf(':') > -1 ? 1 : 0)
res = y - z
if (i === _b.length - 1 && res === 0) {
res = 1
}
}
return res === 0 ? -1 : res
})
})
return cleanChildrenRoutes(routes)
}

View File

@ -84,7 +84,6 @@
"offline-plugin": "^4.8.1", "offline-plugin": "^4.8.1",
"opencollective": "^1.0.3", "opencollective": "^1.0.3",
"pify": "^3.0.0", "pify": "^3.0.0",
"post-compile-webpack-plugin": "^0.1.1",
"preload-webpack-plugin": "^1.2.2", "preload-webpack-plugin": "^1.2.2",
"progress-bar-webpack-plugin": "^1.9.3", "progress-bar-webpack-plugin": "^1.9.3",
"script-ext-html-webpack-plugin": "^1.8.1", "script-ext-html-webpack-plugin": "^1.8.1",

View File

@ -18,7 +18,7 @@ test.before('Init Nuxt.js', async t => {
} }
nuxt = new Nuxt(options) nuxt = new Nuxt(options)
await nuxt.build() await nuxt.build()
server = new nuxt.Server(nuxt) server = new Nuxt.Server(nuxt)
server.listen(port, 'localhost') server.listen(port, 'localhost')
}) })

View File

@ -15,7 +15,7 @@ test.before('Init Nuxt.js', async t => {
} }
nuxt = new Nuxt(options) nuxt = new Nuxt(options)
await nuxt.build() await nuxt.build()
server = new nuxt.Server(nuxt) server = new Nuxt.Server(nuxt)
server.listen(port, 'localhost') server.listen(port, 'localhost')
}) })

View File

@ -15,7 +15,7 @@ test.before('Init Nuxt.js', async t => {
} }
nuxt = new Nuxt(options) nuxt = new Nuxt(options)
await nuxt.build() await nuxt.build()
server = new nuxt.Server(nuxt) server = new Nuxt.Server(nuxt)
server.listen(port, 'localhost') server.listen(port, 'localhost')
}) })

View File

@ -17,7 +17,7 @@ test.before('Init Nuxt.js', async t => {
config.dev = false config.dev = false
nuxt = new Nuxt(config) nuxt = new Nuxt(config)
await nuxt.build() await nuxt.build()
server = new nuxt.Server(nuxt) server = new Nuxt.Server(nuxt)
server.listen(port, 'localhost') server.listen(port, 'localhost')
}) })