From 42bf9bb41d50e05bf20c7f61cdaa5a10d5edbc9a Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 14 Jun 2017 20:43:43 +0430 Subject: [PATCH] decouple builder from renderer + improvements --- bin/nuxt-dev | 6 +- bin/nuxt-start | 6 +- lib/builder.js | 428 ++++++++++++--------------------------- lib/defaults.js | 42 +++- lib/generator.js | 8 +- lib/nuxt.js | 103 ++++------ lib/renderer.js | 152 +++++++++++--- lib/utils.js | 115 +++++++++++ package.json | 1 - test/basic.test.js | 2 +- test/children.test.js | 2 +- test/error.test.js | 2 +- test/with-config.test.js | 2 +- 13 files changed, 473 insertions(+), 396 deletions(-) diff --git a/bin/nuxt-dev b/bin/nuxt-dev index 84efb2e442..df72c433c6 100755 --- a/bin/nuxt-dev +++ b/bin/nuxt-dev @@ -64,10 +64,10 @@ if (typeof options.rootDir !== 'string') { // Force development mode: add hot reloading and watching changes 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 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) @@ -102,3 +102,5 @@ function listenOnConfigChanges(nuxt, server) { chokidar.watch(nuxtConfigFile, Object.assign({}, nuxt.options.watchers.chokidar, { ignoreInitial: true })) .on('all', build) } + +module.exports = nuxt diff --git a/bin/nuxt-start b/bin/nuxt-start index d1d6a0b16c..bbdb5df1ae 100755 --- a/bin/nuxt-start +++ b/bin/nuxt-start @@ -55,7 +55,9 @@ if (typeof options.rootDir !== 'string') { } 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 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 diff --git a/lib/builder.js b/lib/builder.js index 4ae2efa29f..710769b1c2 100644 --- a/lib/builder.js +++ b/lib/builder.js @@ -4,15 +4,16 @@ import fs from 'fs-extra' import hash from 'hash-sum' import pify from 'pify' import webpack from 'webpack' -import PostCompilePlugin from 'post-compile-webpack-plugin' 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 Tapable from 'tappable' -import { isUrl, r, wp } from './utils' +import { isUrl, r, wp, createRoutes } from './utils' import clientWebpackConfig from './webpack/client.config.js' import serverWebpackConfig from './webpack/server.config.js' import defaults from './defaults' +import MFS from 'memory-fs' const debug = require('debug')('nuxt:build') debug.color = 2 // Force green color @@ -24,12 +25,39 @@ const writeFile = pify(fs.writeFile) const mkdirp = pify(fs.mkdirp) 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 { constructor (nuxt) { super() this.nuxt = nuxt 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 let extraDefaults = {} 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)) { extraDefaults.postcss = defaultsPostcss } - this.options.build = _.defaultsDeep(this.options.build, extraDefaults) + _.defaultsDeep(this.options.build, extraDefaults) + /* istanbul ignore if */ if (this.options.dev && isUrl(this.options.build.publicPath)) { this.options.build.publicPath = defaults.build.publicPath } - // Stats - this.webpackStats = { + // If store defined, update store options to true unless explicitly disabled + 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, children: false, modules: false, 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 () { @@ -76,7 +92,6 @@ export default class Builder extends Tapable { if (this._buildStatus === STATUS.BUILD_DONE) { return this } - // If building if (this._buildStatus === STATUS.BUILDING) { return new Promise((resolve) => { @@ -92,7 +107,6 @@ export default class Builder extends Tapable { // Check if pages dir exists and warn if not this._nuxtPages = typeof this.options.build.createRoutes !== 'function' - if (this._nuxtPages) { if (!fs.existsSync(join(this.options.srcDir, 'pages'))) { let dir = this.options.srcDir @@ -113,39 +127,19 @@ export default class Builder extends Tapable { if (!this.options.dev) { await mkdirp(r(this.options.buildDir, 'dist')) } + // Generate routes and interpret the template files await this.generateRoutesAndFiles() - // Generate .nuxt/dist/ files - await this.buildFiles() + + // Start webpack build + await this.webpackBuild() + // Flag to set that building is 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 } - 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 () { debug('Generating files...') // -- Templates -- @@ -171,7 +165,7 @@ export default class Builder extends Tapable { env: this.options.env, head: this.options.head, 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, plugins: this.options.plugins.map((p, i) => { if (typeof p === 'string') p = { src: p } @@ -180,7 +174,7 @@ export default class Builder extends Tapable { }), appPath: './App.vue', 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, components: { ErrorPage: this.options.ErrorPage ? r(this.options.ErrorPage) : null @@ -212,7 +206,7 @@ export default class Builder extends Tapable { if (this._nuxtPages) { // Use nuxt.js createRoutes bases on pages/ 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 { 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 this.options.router.extendRoutes(templateVars.router.routes, r) } - // Routes for generate command - this.routes = this.flatRoutes(templateVars.router.routes || []) // -- Store -- // 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) { - debug('Adding webpack middleware...') - this.createWebpackMiddleware() - this.webpackWatchAndUpdate() - this.watchFiles() - } else { - debug('Building files...') - await this.webpackRunClient() - await this.webpackRunServer() - this.addAppTemplate() + this.webpackDev() } - } - 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 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 () { + // Start build return new Promise((resolve, reject) => { - const clientConfig = this.getWebpackClientConfig() - const clientCompiler = webpack(clientConfig) - clientCompiler.run((err, stats) => { - if (err) return reject(err) - console.log('[nuxt:build:client]\n', stats.toString(this.webpackStats)) // eslint-disable-line no-console - if (stats.hasErrors()) { - return reject(new Error('Webpack build exited with errors')) + this.compiler.run((err, multiStats) => { + if (err) { + return reject(err) + } + for (let _stats of multiStats.stats) { + console.log(_stats.toString(this.webpackStats)) // eslint-disable-line no-console + if (_stats.hasErrors()) { + return reject(new Error('Webpack build exited with errors')) + } } resolve() }) }) } - webpackRunServer () { - return new Promise((resolve, reject) => { - 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() - }) - }) - }) - }) - } + webpackDev () { + debug('Adding webpack middleware...') - createRenderer (bundle, manifest) { - // Create bundle renderer to give a fresh context for every request - this.renderer = createBundleRenderer(bundle, Object.assign({ - clientManifest: manifest, - runInNewContext: false, - basedir: this.options.rootDir - }, this.options.build.ssr)) - this.renderToString = pify(this.renderer.renderToString) - this.renderToStream = this.renderer.renderToStream + // Use MFS for faster builds + let mfs = new MFS() + this.compiler.compilers.forEach(compiler => { + compiler.outputFileSystem = mfs + }) + + let clientConfig = this.compiler.$client.options + + // 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 () { diff --git a/lib/defaults.js b/lib/defaults.js index 8d6ff4c75f..137e31e56d 100755 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -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'), buildDir: '.nuxt', build: { @@ -78,6 +117,7 @@ export default { scrollBehavior: null }, render: { + ssr: {}, http2: { push: false }, diff --git a/lib/generator.js b/lib/generator.js index d73cb05cec..61bf3f142e 100644 --- a/lib/generator.js +++ b/lib/generator.js @@ -4,7 +4,7 @@ import _ from 'lodash' import { resolve, join, dirname, sep } from 'path' import { minify } from 'html-minifier' import Tapable from 'tappable' -import { isUrl, promisifyRoute, waitFor } from './utils' +import { isUrl, promisifyRoute, waitFor, flatRoutes } from './utils' const debug = require('debug')('nuxt:generate') const copy = pify(fs.copy) @@ -12,6 +12,8 @@ const remove = pify(fs.remove) const writeFile = pify(fs.writeFile) const mkdirp = pify(fs.mkdirp) +debug('loaded') + export default class Generator extends Tapable { constructor (nuxt) { super() @@ -31,7 +33,7 @@ export default class Generator extends Tapable { let distNuxtPath = join(distPath, (isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath)) // Launch build process - await this.nuxt.builder.build() + await this.nuxt.build() // Clean destination folder await remove(distPath) @@ -78,7 +80,7 @@ export default class Generator extends Tapable { } // 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) while (routes.length) { diff --git a/lib/nuxt.js b/lib/nuxt.js index 6a6fbed55c..415909c329 100644 --- a/lib/nuxt.js +++ b/lib/nuxt.js @@ -1,77 +1,29 @@ -import _ from 'lodash' -import fs from 'fs-extra' -import { resolve, join } from 'path' import Tapable from 'tappable' import * as Utils from './utils' -import Builder from './builder' import Renderer from './renderer' -import Generator from './generator' import ModuleContainer from './module-container' import Server from './server' -import Defaults from './defaults' +import defaults from './defaults' export default class Nuxt extends Tapable { constructor (_options = {}) { super() - // Clone options to prevent unwanted side-effects - const options = Object.assign({}, _options) + this.options = defaults(_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') { - 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') - } + this.initialized = false + this.errorHandler = this.errorHandler.bind(this) // Create instance of core components this.moduleContainer = new Nuxt.ModuleContainer(this) - this.builder = new Nuxt.Builder(this) this.renderer = new Nuxt.Renderer(this) - this.generator = new Nuxt.Generator(this) // Backward compatibility this.render = this.renderer.render.bind(this.renderer) this.renderRoute = this.renderer.renderRoute.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.initialized = false } async init () { @@ -79,29 +31,56 @@ export default class Nuxt extends Tapable { 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 - // Including modules await this.applyPluginsAsync('beforeInit') - // Including Build await this.applyPluginsAsync('init') - // Extra jobs + this.initialized = true this.applyPluginsAsync('afterInit').catch(this.errorHandler) - this.initialized = true 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 () { - // Global error handler // Silent if (this.options.errorHandler === false) { return } - // Custom eventHandler + // Custom errorHandler if (typeof this.options.errorHandler === 'function') { return this.options.errorHandler.apply(this, arguments) } - // Default + // Default handler // eslint-disable-next-line no-console console.error.apply(this, arguments) process.exit(1) @@ -131,6 +110,9 @@ export default class Nuxt extends Tapable { if (this.customFilesWatcher) { this.customFilesWatcher.close() } + + promises.push(this.applyPluginsAsync('close')) + return Promise.all(promises).then(() => { if (typeof callback === 'function') callback() }) @@ -138,10 +120,7 @@ export default class Nuxt extends Tapable { } // Add core components to Nuxt class -Nuxt.Defaults = Defaults Nuxt.Utils = Utils Nuxt.Renderer = Renderer -Nuxt.Builder = Builder Nuxt.ModuleContainer = ModuleContainer Nuxt.Server = Server -Nuxt.Generator = Generator diff --git a/lib/renderer.js b/lib/renderer.js index f0ae561a75..bead1a7796 100644 --- a/lib/renderer.js +++ b/lib/renderer.js @@ -7,8 +7,9 @@ import pify from 'pify' import serveStatic from 'serve-static' import compression from 'compression' import _ from 'lodash' -import { resolve } from 'path' -import {readFileSync} from 'fs' +import { resolve, join } from 'path' +import fs from 'fs-extra' +import { createBundleRenderer } from 'vue-server-renderer' import { getContext, setAnsiColors, encodeHtml } from './utils' const debug = require('debug')('nuxt:render') @@ -17,61 +18,149 @@ setAnsiColors(ansiHTML) let jsdom = null +const parseTemplate = templateStr => _.template(templateStr, { + interpolate: /{{([\s\S]+?)}}/g +}) + export default class Renderer extends Tapable { constructor (nuxt) { super() this.nuxt = nuxt this.options = nuxt.options - this.nuxt.plugin('init', () => { - // For serving static/ files to / - this.serveStatic = pify(serveStatic(resolve(this.options.srcDir, 'static'), this.options.render.static)) + // Will be loaded by createRenderer + this.bundleRenderer = null + this.renderToStream = null + this.renderToString = null - // 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 - })) + 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)) + } + } - // gzip middleware for production - if (!this.options.dev && this.options.render.gzip) { - this.gzipMiddleware = pify(compression(this.options.render.gzip)) + async init () { + if (this._init) { + 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 - this.errorTemplate = _.template(readFileSync(resolve(__dirname, 'views', 'error.html'), 'utf8'), { - interpolate: /{{([\s\S]+?)}}/g - }) + Object.keys(resourceMap).forEach(resourceKey => { + let { path, transform } = resourceMap[resourceKey] + 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) { /* istanbul ignore if */ - if (!this.nuxt.builder.renderer || !this.nuxt.builder.appTemplate) { + if (!this.bundleRenderer || !this.resources.appTemplate) { return new Promise((resolve) => { setTimeout(() => { resolve(this.render(req, res)) }, 1000) }) } + // Get context const context = getContext(req, res) res.statusCode = 200 + 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.webpackHotMiddleware(req, res) } - if (!this.options.dev && this.options.render.gzip) { + + // Gzip middleware for production + if (this.gzipMiddleware) { await this.gzipMiddleware(req, res) } + // 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) { // Compatibility with base url for dev server req.url = req.url.replace(this.options.router.base, '/') } + // Serve static/ files await this.serveStatic(req, res) + // Serve .nuxt/dist/ files (only for production) if (!this.options.dev && req.url.indexOf(this.options.build.publicPath) === 0) { const url = req.url @@ -80,17 +169,22 @@ export default class Renderer extends Tapable { /* istanbul ignore next */ req.url = url } + if (this.options.dev && req.url.indexOf(this.options.build.publicPath) === 0 && req.url.includes('.hot-update.json')) { res.statusCode = 404 return res.end() } + const { html, error, redirected, resourceHints } = await this.renderRoute(req.url, context) + if (redirected) { return html } + if (error) { res.statusCode = context.nuxt.error.statusCode || 500 } + // ETag header if (!error && 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) } + // HTTP2 push headers if (!error && this.options.render.http2.push) { // 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 res.setHeader('Link', pushAssets.join(',')) } + + // Send response res.setHeader('Content-Type', 'text/html; charset=utf-8') res.setHeader('Content-Length', Buffer.byteLength(html)) res.end(html, 'utf8') @@ -127,11 +224,13 @@ export default class Renderer extends Tapable { console.error(err) // eslint-disable-line no-console return err } - const html = this.errorTemplate({ + // Render error template + const html = this.resources.errorTemplate({ /* istanbul ignore if */ error: err, stack: ansiHTML(encodeHtml(err.stack)) }) + // Send response res.statusCode = 500 res.setHeader('Content-Type', 'text/html; charset=utf-8') res.setHeader('Content-Length', Buffer.byteLength(html)) @@ -143,29 +242,34 @@ export default class Renderer extends Tapable { async renderRoute (url, context = {}) { // Log rendered url debug(`Rendering url ${url}`) + // Add url and isSever to the context context.url = url context.isServer = true + // 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) { APP = '
' } 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() - if (this._routerBaseSpecified) { + if (this.options._routerBaseSpecified) { HEAD += `` } const resourceHints = context.renderResourceHints() HEAD += resourceHints + context.renderStyles() APP += `` APP += context.renderScripts() - const html = this.nuxt.builder.appTemplate({ + + const html = this.resources.appTemplate({ HTML_ATTRS: 'data-n-head-ssr ' + m.htmlAttrs.text(), BODY_ATTRS: m.bodyAttrs.text(), HEAD, APP }) + return { html, resourceHints, diff --git a/lib/utils.js b/lib/utils.js index 326b153e93..9bf2167488 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -97,3 +97,118 @@ export function r () { args = args.map(normalize) 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) +} diff --git a/package.json b/package.json index 13896c2897..242035d12c 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "offline-plugin": "^4.8.1", "opencollective": "^1.0.3", "pify": "^3.0.0", - "post-compile-webpack-plugin": "^0.1.1", "preload-webpack-plugin": "^1.2.2", "progress-bar-webpack-plugin": "^1.9.3", "script-ext-html-webpack-plugin": "^1.8.1", diff --git a/test/basic.test.js b/test/basic.test.js index e064a5a4db..2b246363cf 100755 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -18,7 +18,7 @@ test.before('Init Nuxt.js', async t => { } nuxt = new Nuxt(options) await nuxt.build() - server = new nuxt.Server(nuxt) + server = new Nuxt.Server(nuxt) server.listen(port, 'localhost') }) diff --git a/test/children.test.js b/test/children.test.js index 2536ed4e22..d79f5a768c 100644 --- a/test/children.test.js +++ b/test/children.test.js @@ -15,7 +15,7 @@ test.before('Init Nuxt.js', async t => { } nuxt = new Nuxt(options) await nuxt.build() - server = new nuxt.Server(nuxt) + server = new Nuxt.Server(nuxt) server.listen(port, 'localhost') }) diff --git a/test/error.test.js b/test/error.test.js index 26fba45f6e..c11446ba1a 100644 --- a/test/error.test.js +++ b/test/error.test.js @@ -15,7 +15,7 @@ test.before('Init Nuxt.js', async t => { } nuxt = new Nuxt(options) await nuxt.build() - server = new nuxt.Server(nuxt) + server = new Nuxt.Server(nuxt) server.listen(port, 'localhost') }) diff --git a/test/with-config.test.js b/test/with-config.test.js index 41fb678f42..0cab513c7c 100644 --- a/test/with-config.test.js +++ b/test/with-config.test.js @@ -17,7 +17,7 @@ test.before('Init Nuxt.js', async t => { config.dev = false nuxt = new Nuxt(config) await nuxt.build() - server = new nuxt.Server(nuxt) + server = new Nuxt.Server(nuxt) server.listen(port, 'localhost') })