feat: improve SSR bundle (#4439)

- Better insights and inspection for server bundle
- Remove all vue related dependencies from vue-renderer package as much as possible to reduce install size of nuxt-start
- Support for single file distributions (serverless)
- Remove server-bundle.json and use the standard .js files for dist/server
- Mitigate CALL_AND_RETRY_LAST Allocation failed errors. Most of the cases happen on JSON.parse() the part when loading bundle. (#4225, #3465, #1728, #1601, #1481)
- Reduce server dist size by removing escape characters caused by JSON serialize
- Faster dev reloads and production start by removing extra JSON.serialize/JSON.parse time
- Less memory usage
- General performance improvements and refactors
This commit is contained in:
Pooya Parsa 2018-12-01 13:43:28 +03:30 committed by GitHub
parent 06ddfbb77b
commit 0f104aa588
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 329 additions and 323 deletions

View File

@ -72,7 +72,9 @@ export default class NuxtCommand {
async getNuxt(options) { async getNuxt(options) {
const { Nuxt } = await imports.core() const { Nuxt } = await imports.core()
return new Nuxt(options) const nuxt = new Nuxt(options)
await nuxt.ready()
return nuxt
} }
async getBuilder(nuxt) { async getBuilder(nuxt) {

View File

@ -1,6 +1,3 @@
import fs from 'fs'
import path from 'path'
import consola from 'consola'
import { common, server } from '../options' import { common, server } from '../options'
import { showBanner } from '../utils' import { showBanner } from '../utils'
@ -17,32 +14,10 @@ export default {
// Create production build when calling `nuxt build` // Create production build when calling `nuxt build`
const nuxt = await cmd.getNuxt( const nuxt = await cmd.getNuxt(
await cmd.getNuxtConfig(argv, { dev: false }) await cmd.getNuxtConfig(argv, { dev: false, _start: true })
) )
// Check if project is built for production // Listen and show ready banner
const distDir = path.resolve(
nuxt.options.rootDir,
nuxt.options.buildDir || '.nuxt',
'dist',
'server'
)
if (!fs.existsSync(distDir)) {
consola.fatal(
'No build files found, please run `nuxt build` before launching `nuxt start`'
)
}
// Check if SSR Bundle is required
if (nuxt.options.render.ssr === true) {
const ssrBundlePath = path.resolve(distDir, 'server-bundle.json')
if (!fs.existsSync(ssrBundlePath)) {
consola.fatal(
'No SSR build found.\nPlease start with `nuxt start --spa` or build using `nuxt build --universal`'
)
}
}
return nuxt.server.listen().then(() => { return nuxt.server.listen().then(() => {
showBanner(nuxt) showBanner(nuxt)
}) })

View File

@ -1,4 +1,4 @@
import fs from 'fs' import fs from 'fs-extra'
import { consola, mockGetNuxtStart, mockGetNuxtConfig, NuxtCommand } from '../utils' import { consola, mockGetNuxtStart, mockGetNuxtConfig, NuxtCommand } from '../utils'
describe('start', () => { describe('start', () => {
@ -22,41 +22,14 @@ describe('start', () => {
test('no error if dist dir exists', async () => { test('no error if dist dir exists', async () => {
mockGetNuxtStart() mockGetNuxtStart()
mockGetNuxtConfig() mockGetNuxtConfig()
jest.spyOn(fs, 'existsSync').mockImplementationOnce(() => true)
await NuxtCommand.from(start).run() await NuxtCommand.from(start).run()
expect(consola.fatal).not.toHaveBeenCalled() expect(consola.fatal).not.toHaveBeenCalled()
}) })
test('fatal error if dist dir doesnt exist', async () => {
mockGetNuxtStart()
jest.spyOn(fs, 'existsSync').mockImplementationOnce(() => false)
await NuxtCommand.from(start).run()
expect(consola.fatal).toHaveBeenCalledWith('No build files found, please run `nuxt build` before launching `nuxt start`')
})
test('no error on ssr and server bundle exists', async () => { test('no error on ssr and server bundle exists', async () => {
mockGetNuxtStart(true) mockGetNuxtStart(true)
mockGetNuxtConfig() mockGetNuxtConfig()
jest.spyOn(fs, 'existsSync').mockImplementation(() => true)
await NuxtCommand.from(start).run() await NuxtCommand.from(start).run()
expect(consola.fatal).not.toHaveBeenCalled() expect(consola.fatal).not.toHaveBeenCalled()
}) })
test.skip('fatal error on ssr and server bundle doesnt exist', async () => {
mockGetNuxtStart(true)
let i = 0
jest.spyOn(fs, 'existsSync').mockImplementation(() => {
return ++i === 1
})
await NuxtCommand.from(start).run()
expect(consola.fatal).toHaveBeenCalledWith('No SSR build found.\nPlease start with `nuxt start --spa` or build using `nuxt build --universal`')
})
}) })

View File

@ -91,6 +91,7 @@ export const mockNuxt = (implementation) => {
options: {}, options: {},
clearHook: jest.fn(), clearHook: jest.fn(),
close: jest.fn(), close: jest.fn(),
ready: jest.fn(),
server: { server: {
listeners: [], listeners: [],
listen: jest.fn().mockImplementationOnce(() => Promise.resolve()) listen: jest.fn().mockImplementationOnce(() => Promise.resolve())

View File

@ -64,8 +64,7 @@ export default ({ resources, options }) => function errorMiddleware(err, req, re
readSourceFactory({ readSourceFactory({
srcDir: options.srcDir, srcDir: options.srcDir,
rootDir: options.rootDir, rootDir: options.rootDir,
buildDir: options.buildDir, buildDir: options.buildDir
resources
}), }),
options.router.base, options.router.base,
true true
@ -79,7 +78,7 @@ export default ({ resources, options }) => function errorMiddleware(err, req, re
} }
} }
const readSourceFactory = ({ srcDir, rootDir, buildDir, resources }) => async function readSource(frame) { const readSourceFactory = ({ srcDir, rootDir, buildDir }) => async function readSource(frame) {
// Remove webpack:/// & query string from the end // Remove webpack:/// & query string from the end
const sanitizeName = name => const sanitizeName = name =>
name ? name.replace('webpack:///', '').split('?')[0] : null name ? name.replace('webpack:///', '').split('?')[0] : null
@ -113,11 +112,4 @@ const readSourceFactory = ({ srcDir, rootDir, buildDir, resources }) => async fu
return return
} }
} }
// Fallback: use server bundle
// TODO: restore to if after https://github.com/istanbuljs/nyc/issues/595 fixed
/* istanbul ignore next */
if (!frame.contents) {
frame.contents = resources.serverBundle.files[frame.fileName]
}
} }

View File

@ -10,6 +10,14 @@
], ],
"main": "dist/vue-app.js", "main": "dist/vue-app.js",
"typings": "types/index.d.ts", "typings": "types/index.d.ts",
"dependencies": {
"vue": "^2.5.17",
"vue-meta": "^1.5.6",
"vue-no-ssr": "^1.1.0",
"vue-router": "^3.0.2",
"vue-template-compiler": "^2.5.17",
"vuex": "^3.0.1"
},
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
} }

View File

@ -15,11 +15,7 @@
"lru-cache": "^5.1.1", "lru-cache": "^5.1.1",
"vue": "^2.5.17", "vue": "^2.5.17",
"vue-meta": "^1.5.6", "vue-meta": "^1.5.6",
"vue-no-ssr": "^1.1.0", "vue-server-renderer": "^2.5.17"
"vue-router": "^3.0.2",
"vue-server-renderer": "^2.5.17",
"vue-template-compiler": "^2.5.17",
"vuex": "^3.0.1"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@ -1,6 +1,6 @@
import path from 'path' import path from 'path'
import crypto from 'crypto' import crypto from 'crypto'
import fs from 'fs-extra' import fs from 'fs'
import consola from 'consola' import consola from 'consola'
import devalue from '@nuxtjs/devalue' import devalue from '@nuxtjs/devalue'
import invert from 'lodash/invert' import invert from 'lodash/invert'
@ -16,19 +16,19 @@ export default class VueRenderer {
// Will be set by createRenderer // Will be set by createRenderer
this.renderer = { this.renderer = {
ssr: null, ssr: undefined,
modern: null, modern: undefined,
spa: null spa: undefined
} }
// Renderer runtime resources // Renderer runtime resources
Object.assign(this.context.resources, { Object.assign(this.context.resources, {
clientManifest: null, clientManifest: undefined,
modernManifest: null, modernManifest: undefined,
serverBundle: null, serverManifest: undefined,
ssrTemplate: null, ssrTemplate: undefined,
spaTemplate: null, spaTemplate: undefined,
errorTemplate: this.constructor.parseTemplate('Nuxt.js Internal Server Error') errorTemplate: this.parseTemplate('Nuxt.js Internal Server Error')
}) })
} }
@ -98,57 +98,78 @@ export default class VueRenderer {
} }
async ready() { async ready() {
// Production: Load SSR resources from fs
if (!this.context.options.dev) { if (!this.context.options.dev) {
await this.loadResources() // Production: Load SSR resources from fs
await this.loadResources(fs)
// Verify
if (!this.isReady && this.context.options._start) {
throw new Error(
'No build files found. Use either `nuxt build` or `builder.build()` or start nuxt in development mode.'
)
}
} else {
// Development: Listen on build:resources hook
this.context.nuxt.hook('build:resources', mfs => this.loadResources(mfs, true))
} }
} }
async loadResources(_fs = fs) { loadResources(_fs, isMFS = false) {
const distPath = path.resolve(this.context.options.buildDir, 'dist', 'server') const distPath = path.resolve(this.context.options.buildDir, 'dist', 'server')
const updated = [] const updated = []
const resourceMap = this.resourceMap
this.constructor.resourceMap.forEach(({ key, fileName, transform }) => { const readResource = (fileName, encoding) => {
const rawKey = '$$' + key try {
const _path = path.join(distPath, fileName) const fullPath = path.resolve(distPath, fileName)
if (!_fs.existsSync(fullPath)) {
return
}
const contents = _fs.readFileSync(fullPath, encoding)
if (isMFS) {
// Cleanup MFS as soon as possible to save memory
_fs.unlinkSync(fullPath)
}
return contents
} catch (err) {
consola.error('Unable to load resource:', fileName, err)
}
}
// Fail when no build found and using programmatic usage for (const resourceName in resourceMap) {
if (!_fs.existsSync(_path)) { const { fileName, transform, encoding } = resourceMap[resourceName]
// TODO: Enable baack when renderer initialzation was disabled for build only scripts
// Currently this breaks normal nuxt build for first time // Load resource
// if (!this.context.options.dev) { let resource = readResource(fileName, encoding)
// const invalidSSR = !this.noSSR && key === 'serverBundle'
// const invalidSPA = this.noSSR && key === 'spaTemplate' // Skip unavailable resources
// if (invalidSPA || invalidSSR) { if (!resource) {
// consola.fatal(`Could not load Nuxt renderer, make sure to build for production: builder.build() with dev option set to false.`) consola.debug('Resource not available:', resourceName)
// } continue
// }
return // Resource not exists
} }
const rawData = _fs.readFileSync(_path, 'utf8') // Apply transforms
if (!rawData || rawData === this.context.resources[rawKey]) { if (typeof transform === 'function') {
return // No changes resource = transform(resource, {
readResource,
oldValue: this.context.resources[resourceName]
})
} }
this.context.resources[rawKey] = rawData
const data = transform(rawData) // Update resource
/* istanbul ignore if */ this.context.resources[resourceName] = resource
if (!data) { updated.push(resourceName)
return // Invalid data ? }
}
this.context.resources[key] = data
updated.push(key)
})
// Reload error template // Reload error template
const errorTemplatePath = path.resolve(this.context.options.buildDir, 'views/error.html') const errorTemplatePath = path.resolve(this.context.options.buildDir, 'views/error.html')
if (fs.existsSync(errorTemplatePath)) { if (fs.existsSync(errorTemplatePath)) {
this.context.resources.errorTemplate = this.constructor.parseTemplate( this.context.resources.errorTemplate = this.parseTemplate(
fs.readFileSync(errorTemplatePath, 'utf8') fs.readFileSync(errorTemplatePath, 'utf8')
) )
} }
// Load loading template // Reload loading template
const loadingHTMLPath = path.resolve(this.context.options.buildDir, 'loading.html') const loadingHTMLPath = path.resolve(this.context.options.buildDir, 'loading.html')
if (fs.existsSync(loadingHTMLPath)) { if (fs.existsSync(loadingHTMLPath)) {
this.context.resources.loadingHTML = fs.readFileSync(loadingHTMLPath, 'utf8') this.context.resources.loadingHTML = fs.readFileSync(loadingHTMLPath, 'utf8')
@ -158,57 +179,60 @@ export default class VueRenderer {
this.context.resources.loadingHTML = '' this.context.resources.loadingHTML = ''
} }
// Call resourcesLoaded plugin // Call createRenderer if any resource changed
await this.context.nuxt.callHook('render:resourcesLoaded', this.context.resources)
if (updated.length > 0) { if (updated.length > 0) {
this.createRenderer() this.createRenderer()
} }
// Call resourcesLoaded hook
consola.debug('Resources loaded:', updated.join(','))
return this.context.nuxt.callHook('render:resourcesLoaded', this.context.resources)
} }
get noSSR() { get noSSR() { /* Backward compatibility */
return this.context.options.render.ssr === false return this.context.options.render.ssr === false
} }
get isReady() { get SSR() {
if (this.noSSR) { return this.context.options.render.ssr === true
return Boolean(this.context.resources.spaTemplate)
}
return Boolean(this.renderer.ssr && this.context.resources.ssrTemplate)
} }
get isResourcesAvailable() { get isReady() {
// Required for both // SPA
/* istanbul ignore if */ if (!this.context.resources.spaTemplate || !this.renderer.spa) {
if (!this.context.resources.clientManifest) {
return false return false
} }
// Required for SPA rendering // SSR
if (this.noSSR) { if (this.SSR && (!this.context.resources.ssrTemplate || !this.renderer.ssr)) {
return Boolean(this.context.resources.spaTemplate) return false
} }
// Required for bundle renderer return true
return Boolean(this.context.resources.ssrTemplate && this.context.resources.serverBundle) }
get isResourcesAvailable() { /* Backward compatibility */
return this.isReady
} }
createRenderer() { createRenderer() {
// Ensure resources are available // Resource clientManifest is always required
if (!this.isResourcesAvailable) { if (!this.context.resources.clientManifest) {
return return
} }
// Create Meta Renderer // Create SPA renderer
this.renderer.spa = new SPAMetaRenderer(this) if (this.context.resources.spaTemplate) {
this.renderer.spa = new SPAMetaRenderer(this)
}
// Skip following steps if noSSR mode // Skip the rest if SSR resources are not available
if (this.noSSR) { if (!this.context.resources.ssrTemplate || !this.context.resources.serverManifest) {
return return
} }
const hasModules = fs.existsSync(path.resolve(this.context.options.rootDir, 'node_modules')) const hasModules = fs.existsSync(path.resolve(this.context.options.rootDir, 'node_modules'))
const rendererOptions = { const rendererOptions = {
runInNewContext: false, runInNewContext: false,
clientManifest: this.context.resources.clientManifest, clientManifest: this.context.resources.clientManifest,
@ -219,14 +243,14 @@ export default class VueRenderer {
// Create bundle renderer for SSR // Create bundle renderer for SSR
this.renderer.ssr = createBundleRenderer( this.renderer.ssr = createBundleRenderer(
this.context.resources.serverBundle, this.context.resources.serverManifest,
rendererOptions rendererOptions
) )
if (this.context.resources.modernManifest && if (this.context.resources.modernManifest &&
!['client', false].includes(this.context.options.modern)) { !['client', false].includes(this.context.options.modern)) {
this.renderer.modern = createBundleRenderer( this.renderer.modern = createBundleRenderer(
this.context.resources.serverBundle, this.context.resources.serverManifest,
{ {
...rendererOptions, ...rendererOptions,
clientManifest: this.context.resources.modernManifest clientManifest: this.context.resources.modernManifest
@ -248,6 +272,7 @@ export default class VueRenderer {
async renderRoute(url, context = {}) { async renderRoute(url, context = {}) {
/* istanbul ignore if */ /* istanbul ignore if */
if (!this.isReady) { if (!this.isReady) {
consola.info('Waiting for server resources...')
await waitFor(1000) await waitFor(1000)
return this.renderRoute(url, context) return this.renderRoute(url, context)
} }
@ -258,12 +283,12 @@ export default class VueRenderer {
// Add url and isSever to the context // Add url and isSever to the context
context.url = url context.url = url
// Basic response if SSR is disabled or spa data provided // Basic response if SSR is disabled or SPA data provided
const { req, res } = context const { req, res } = context
const spa = context.spa || (res && res.spa) const spa = context.spa || (res && res.spa)
const ENV = this.context.options.env const ENV = this.context.options.env
if (this.noSSR || spa) { if (!this.SSR || spa) {
const { const {
HTML_ATTRS, HTML_ATTRS,
BODY_ATTRS, BODY_ATTRS,
@ -348,39 +373,57 @@ export default class VueRenderer {
} }
} }
static parseTemplate(templateStr) { get resourceMap() {
return {
clientManifest: {
fileName: 'client.manifest.json',
transform: src => JSON.parse(src)
},
modernManifest: {
fileName: 'modern.manifest.json',
transform: src => JSON.parse(src)
},
serverManifest: {
fileName: 'server.manifest.json',
// BundleRenderer needs resolved contents
transform: (src, { readResource, oldValue = { files: {}, maps: {} } }) => {
const serverManifest = JSON.parse(src)
const resolveAssets = (obj, oldObj, encoding) => {
Object.keys(obj).forEach((name) => {
obj[name] = readResource(obj[name], encoding)
// Try to reuse deleted MFS files if no new version exists
if (!obj[name]) {
obj[name] = oldObj[name]
}
})
return obj
}
const files = resolveAssets(serverManifest.files, oldValue.files)
const maps = resolveAssets(serverManifest.maps, oldValue.maps, 'utf-8')
return {
...serverManifest,
files,
maps
}
}
},
ssrTemplate: {
fileName: 'index.ssr.html',
transform: src => this.parseTemplate(src)
},
spaTemplate: {
fileName: 'index.spa.html',
transform: src => this.parseTemplate(src)
}
}
}
parseTemplate(templateStr) {
return template(templateStr, { return template(templateStr, {
interpolate: /{{([\s\S]+?)}}/g interpolate: /{{([\s\S]+?)}}/g
}) })
} }
static get resourceMap() {
return [
{
key: 'clientManifest',
fileName: 'vue-ssr-client-manifest.json',
transform: JSON.parse
},
{
key: 'modernManifest',
fileName: 'vue-ssr-modern-manifest.json',
transform: JSON.parse
},
{
key: 'serverBundle',
fileName: 'server-bundle.json',
transform: JSON.parse
},
{
key: 'ssrTemplate',
fileName: 'index.ssr.html',
transform: this.parseTemplate
},
{
key: 'spaTemplate',
fileName: 'index.spa.html',
transform: this.parseTemplate
}
]
}
} }

View File

@ -1,4 +1,3 @@
import fs from 'fs'
import path from 'path' import path from 'path'
import pify from 'pify' import pify from 'pify'
import webpack from 'webpack' import webpack from 'webpack'
@ -14,7 +13,8 @@ import {
wrapArray wrapArray
} from '@nuxt/common' } from '@nuxt/common'
import { ClientConfig, ModernConfig, ServerConfig, PerfLoader } from './config' import { ClientConfig, ModernConfig, ServerConfig } from './config'
import PerfLoader from './utils/perf-loader'
const glob = pify(Glob) const glob = pify(Glob)
@ -27,9 +27,11 @@ export class WebpackBundler {
this.devMiddleware = {} this.devMiddleware = {}
this.hotMiddleware = {} this.hotMiddleware = {}
// Initialize shared FS and Cache // Initialize shared MFS for dev
if (this.context.options.dev) { if (this.context.options.dev) {
this.mfs = new MFS() 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)) }
} }
} }
@ -82,7 +84,7 @@ export class WebpackBundler {
'Please use https://github.com/nuxt-community/style-resources-module' 'Please use https://github.com/nuxt-community/style-resources-module'
) )
} }
Object.keys(styleResources).forEach(async (ext) => { for (const ext of Object.keys(styleResources)) {
await Promise.all(wrapArray(styleResources[ext]).map(async (p) => { await Promise.all(wrapArray(styleResources[ext]).map(async (p) => {
const styleResourceFiles = await glob(path.resolve(this.context.options.rootDir, p)) const styleResourceFiles = await glob(path.resolve(this.context.options.rootDir, p))
@ -90,7 +92,7 @@ export class WebpackBundler {
throw new Error(`Style Resource not found: ${p}`) throw new Error(`Style Resource not found: ${p}`)
} }
})) }))
}) }
// Configure compilers // Configure compilers
this.compilers = compilersOptions.map((compilersOption) => { this.compilers = compilersOptions.map((compilersOption) => {
@ -135,7 +137,7 @@ export class WebpackBundler {
}) })
// Reload renderer if available // Reload renderer if available
nuxt.server.loadResources(this.mfs || fs) await nuxt.callHook('build:resources', this.mfs)
// Resolve on next tick // Resolve on next tick
process.nextTick(resolve) process.nextTick(resolve)

View File

@ -6,14 +6,15 @@ import cloneDeep from 'lodash/cloneDeep'
import escapeRegExp from 'lodash/escapeRegExp' import escapeRegExp from 'lodash/escapeRegExp'
import VueLoader from 'vue-loader' import VueLoader from 'vue-loader'
import MiniCssExtractPlugin from 'mini-css-extract-plugin' import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import TerserWebpackPlugin from 'terser-webpack-plugin'
import WebpackBar from 'webpackbar' import WebpackBar from 'webpackbar'
import env from 'std-env' import env from 'std-env'
import { isUrl, urlJoin } from '@nuxt/common' import { isUrl, urlJoin } from '@nuxt/common'
import PerfLoader from './utils/perf-loader' import PerfLoader from '../utils/perf-loader'
import StyleLoader from './utils/style-loader' import StyleLoader from '../utils/style-loader'
import WarnFixPlugin from './plugins/warnfix' import WarnFixPlugin from '../plugins/warnfix'
export default class WebpackBaseConfig { export default class WebpackBaseConfig {
constructor(builder, options) { constructor(builder, options) {
@ -96,7 +97,7 @@ export default class WebpackBaseConfig {
return fileName return fileName
} }
devtool() { get devtool() {
return false return false
} }
@ -127,7 +128,41 @@ export default class WebpackBaseConfig {
} }
optimization() { optimization() {
return this.options.build.optimization const optimization = cloneDeep(this.options.build.optimization)
if (optimization.minimize && optimization.minimizer === undefined) {
optimization.minimizer = this.minimizer()
}
return optimization
}
minimizer() {
const minimizer = []
// https://github.com/webpack-contrib/terser-webpack-plugin
if (this.options.build.terser) {
minimizer.push(
new TerserWebpackPlugin(Object.assign({
parallel: true,
cache: this.options.build.cache,
sourceMap: this.devtool && /source-?map/.test(this.devtool),
extractComments: {
filename: 'LICENSES'
},
terserOptions: {
compress: {
ecma: this.isModern ? 6 : undefined
},
output: {
comments: /^\**!|@preserve|@license|@cc_on/
}
}
}, this.options.build.terser))
)
}
return minimizer
} }
alias() { alias() {
@ -337,10 +372,11 @@ export default class WebpackBaseConfig {
config() { config() {
// Prioritize nested node_modules in webpack search path (#2558) // Prioritize nested node_modules in webpack search path (#2558)
const webpackModulesDir = ['node_modules'].concat(this.options.modulesDir) const webpackModulesDir = ['node_modules'].concat(this.options.modulesDir)
const config = { const config = {
name: this.name, name: this.name,
mode: this.buildMode, mode: this.buildMode,
devtool: this.devtool(), devtool: this.devtool,
optimization: this.optimization(), optimization: this.optimization(),
output: this.output(), output: this.output(),
performance: { performance: {

View File

@ -2,12 +2,11 @@ import path from 'path'
import webpack from 'webpack' import webpack from 'webpack'
import HTMLPlugin from 'html-webpack-plugin' import HTMLPlugin from 'html-webpack-plugin'
import BundleAnalyzer from 'webpack-bundle-analyzer' import BundleAnalyzer from 'webpack-bundle-analyzer'
import TerserWebpackPlugin from 'terser-webpack-plugin'
import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin' import OptimizeCSSAssetsPlugin from 'optimize-css-assets-webpack-plugin'
import FriendlyErrorsWebpackPlugin from '@nuxt/friendly-errors-webpack-plugin' import FriendlyErrorsWebpackPlugin from '@nuxt/friendly-errors-webpack-plugin'
import ModernModePlugin from './plugins/vue/modern' import ModernModePlugin from '../plugins/vue/modern'
import VueSSRClientPlugin from './plugins/vue/client' import VueSSRClientPlugin from '../plugins/vue/client'
import WebpackBaseConfig from './base' import WebpackBaseConfig from './base'
export default class WebpackClientConfig extends WebpackBaseConfig { export default class WebpackClientConfig extends WebpackBaseConfig {
@ -54,6 +53,21 @@ export default class WebpackClientConfig extends WebpackBaseConfig {
return optimization return optimization
} }
minimizer() {
const minimizer = super.minimizer()
// https://github.com/NMFR/optimize-css-assets-webpack-plugin
// https://github.com/webpack-contrib/mini-css-extract-plugin#minimizing-for-production
// TODO: Remove OptimizeCSSAssetsPlugin when upgrading to webpack 5
if (this.options.build.optimizeCSS) {
minimizer.push(
new OptimizeCSSAssetsPlugin(Object.assign({}, this.options.build.optimizeCSS))
)
}
return minimizer
}
plugins() { plugins() {
const plugins = super.plugins() const plugins = super.plugins()
@ -78,7 +92,7 @@ export default class WebpackClientConfig extends WebpackBaseConfig {
chunksSortMode: 'dependency' chunksSortMode: 'dependency'
}), }),
new VueSSRClientPlugin({ new VueSSRClientPlugin({
filename: `../server/vue-ssr-${this.name}-manifest.json` filename: `../server/${this.name}.manifest.json`
}), }),
new webpack.DefinePlugin(this.env()) new webpack.DefinePlugin(this.env())
) )
@ -113,48 +127,6 @@ export default class WebpackClientConfig extends WebpackBaseConfig {
return plugins return plugins
} }
extendConfig() {
const config = super.extendConfig(...arguments)
// Add minimizer plugins
if (config.optimization.minimize && config.optimization.minimizer === undefined) {
config.optimization.minimizer = []
// https://github.com/webpack-contrib/terser-webpack-plugin
if (this.options.build.terser) {
config.optimization.minimizer.push(
new TerserWebpackPlugin(Object.assign({
parallel: true,
cache: this.options.build.cache,
sourceMap: config.devtool && /source-?map/.test(config.devtool),
extractComments: {
filename: 'LICENSES'
},
terserOptions: {
compress: {
ecma: this.isModern ? 6 : undefined
},
output: {
comments: /^\**!|@preserve|@license|@cc_on/
}
}
}, this.options.build.terser))
)
}
// https://github.com/NMFR/optimize-css-assets-webpack-plugin
// https://github.com/webpack-contrib/mini-css-extract-plugin#minimizing-for-production
// TODO: Remove OptimizeCSSAssetsPlugin when upgrading to webpack 5
if (this.options.build.optimizeCSS) {
config.optimization.minimizer.push(
new OptimizeCSSAssetsPlugin(Object.assign({}, this.options.build.optimizeCSS))
)
}
}
return config
}
config() { config() {
const config = super.config() const config = super.config()

View File

@ -1,4 +1,3 @@
export { default as ClientConfig } from './client' export { default as ClientConfig } from './client'
export { default as ModernConfig } from './modern' export { default as ModernConfig } from './modern'
export { default as ServerConfig } from './server' export { default as ServerConfig } from './server'
export { default as PerfLoader } from './utils/perf-loader'

View File

@ -4,8 +4,9 @@ import webpack from 'webpack'
import escapeRegExp from 'lodash/escapeRegExp' import escapeRegExp from 'lodash/escapeRegExp'
import nodeExternals from 'webpack-node-externals' import nodeExternals from 'webpack-node-externals'
import VueSSRServerPlugin from '../plugins/vue/server'
import WebpackBaseConfig from './base' import WebpackBaseConfig from './base'
import VueSSRServerPlugin from './plugins/vue/server'
export default class WebpackServerConfig extends WebpackBaseConfig { export default class WebpackServerConfig extends WebpackBaseConfig {
constructor(builder) { constructor(builder) {
@ -29,8 +30,8 @@ export default class WebpackServerConfig extends WebpackBaseConfig {
return whitelist return whitelist
} }
devtool() { get devtool() {
return 'cheap-module-inline-source-map' return 'cheap-module-source-map'
} }
env() { env() {
@ -45,7 +46,7 @@ export default class WebpackServerConfig extends WebpackBaseConfig {
optimization() { optimization() {
return { return {
splitChunks: false, splitChunks: false,
minimizer: [] minimizer: this.minimizer()
} }
} }
@ -53,7 +54,7 @@ export default class WebpackServerConfig extends WebpackBaseConfig {
const plugins = super.plugins() const plugins = super.plugins()
plugins.push( plugins.push(
new VueSSRServerPlugin({ new VueSSRServerPlugin({
filename: 'server-bundle.json' filename: `${this.name}.manifest.json`
}), }),
new webpack.DefinePlugin(this.env()) new webpack.DefinePlugin(this.env())
) )
@ -70,11 +71,12 @@ export default class WebpackServerConfig extends WebpackBaseConfig {
app: [path.resolve(this.options.buildDir, 'server.js')] app: [path.resolve(this.options.buildDir, 'server.js')]
}, },
output: Object.assign({}, config.output, { output: Object.assign({}, config.output, {
filename: 'server-bundle.js', filename: 'server.js',
libraryTarget: 'commonjs2' libraryTarget: 'commonjs2'
}), }),
performance: { performance: {
hints: false, hints: false,
maxEntrypointSize: Infinity,
maxAssetSize: Infinity maxAssetSize: Infinity
}, },
externals: [] externals: []

View File

@ -1,3 +1,8 @@
/**
* This file is based on Vue.js (MIT) webpack plugins
* https://github.com/vuejs/vue/blob/dev/src/server/webpack-plugin/client.js
*/
import hash from 'hash-sum' import hash from 'hash-sum'
import uniq from 'lodash/uniq' import uniq from 'lodash/uniq'
@ -6,7 +11,7 @@ import { isJS, isCSS, onEmit } from './util'
export default class VueSSRClientPlugin { export default class VueSSRClientPlugin {
constructor(options = {}) { constructor(options = {}) {
this.options = Object.assign({ this.options = Object.assign({
filename: 'vue-ssr-client-manifest.json' filename: null
}, options) }, options)
} }
@ -45,7 +50,7 @@ export default class VueSSRClientPlugin {
const assetModules = stats.modules.filter(m => m.assets.length) const assetModules = stats.modules.filter(m => m.assets.length)
const fileToIndex = file => manifest.all.indexOf(file) const fileToIndex = file => manifest.all.indexOf(file)
stats.modules.forEach((m) => { stats.modules.forEach((m) => {
// ignore modules duplicated in multiple chunks // Ignore modules duplicated in multiple chunks
if (m.chunks.length === 1) { if (m.chunks.length === 1) {
const cid = m.chunks[0] const cid = m.chunks[0]
const chunk = stats.chunks.find(c => c.id === cid) const chunk = stats.chunks.find(c => c.id === cid)
@ -54,7 +59,8 @@ export default class VueSSRClientPlugin {
} }
const id = m.identifier.replace(/\s\w+$/, '') // remove appended hash const id = m.identifier.replace(/\s\w+$/, '') // remove appended hash
const files = manifest.modules[hash(id)] = chunk.files.map(fileToIndex) const files = manifest.modules[hash(id)] = chunk.files.map(fileToIndex)
// find all asset modules associated with the same chunk
// Find all asset modules associated with the same chunk
assetModules.forEach((m) => { assetModules.forEach((m) => {
if (m.chunks.some(id => id === cid)) { if (m.chunks.some(id => id === cid)) {
files.push.apply(files, m.assets.map(fileToIndex)) files.push.apply(files, m.assets.map(fileToIndex))
@ -63,16 +69,11 @@ export default class VueSSRClientPlugin {
} }
}) })
// const debug = (file, obj) => { const src = JSON.stringify(manifest, null, 2)
// require('fs').writeFileSync(__dirname + '/' + file, JSON.stringify(obj, null, 2))
// }
// debug('stats.json', stats)
// debug('client-manifest.json', manifest)
const json = JSON.stringify(manifest, null, 2)
compilation.assets[this.options.filename] = { compilation.assets[this.options.filename] = {
source: () => json, source: () => src,
size: () => json.length size: () => src.length
} }
cb() cb()
}) })

View File

@ -1,16 +1,14 @@
/* /*
** This plugin is inspired by @vue/cli-service ModernModePlugin * This file is based on @vue/cli-service (MIT) ModernModePlugin
** https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/webpack/ModernModePlugin.js * https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/webpack/ModernModePlugin.js
*/ */
import EventEmitter from 'events'
// https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc import EventEmitter from 'events'
const safariFix = `!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();`
const assetsMap = {} const assetsMap = {}
const watcher = new EventEmitter() const watcher = new EventEmitter()
class ModernModePlugin { export default class ModernModePlugin {
constructor({ targetDir, isModernBuild }) { constructor({ targetDir, isModernBuild }) {
this.targetDir = targetDir this.targetDir = targetDir
this.isModernBuild = isModernBuild this.isModernBuild = isModernBuild
@ -83,7 +81,7 @@ class ModernModePlugin {
data.body.push({ data.body.push({
tagName: 'script', tagName: 'script',
closeTag: true, closeTag: true,
innerHTML: safariFix innerHTML: ModernModePlugin.safariFix
}) })
// inject links for legacy assets as <script nomodule> // inject links for legacy assets as <script nomodule>
@ -107,6 +105,5 @@ class ModernModePlugin {
} }
} }
ModernModePlugin.safariFix = safariFix // https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
ModernModePlugin.safariFix = `!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();`
export default ModernModePlugin

View File

@ -3,7 +3,7 @@ import { validate, isJS, onEmit } from './util'
export default class VueSSRServerPlugin { export default class VueSSRServerPlugin {
constructor(options = {}) { constructor(options = {}) {
this.options = Object.assign({ this.options = Object.assign({
filename: 'vue-ssr-server-bundle.json' filename: null
}, options) }, options)
} }
@ -44,20 +44,17 @@ export default class VueSSRServerPlugin {
stats.assets.forEach((asset) => { stats.assets.forEach((asset) => {
if (isJS(asset.name)) { if (isJS(asset.name)) {
bundle.files[asset.name] = compilation.assets[asset.name].source() bundle.files[asset.name] = asset.name
} else if (asset.name.match(/\.js\.map$/)) { } else if (asset.name.match(/\.js\.map$/)) {
bundle.maps[asset.name.replace(/\.map$/, '')] = JSON.parse(compilation.assets[asset.name].source()) bundle.maps[asset.name.replace(/\.map$/, '')] = asset.name
} }
// do not emit anything else for server
delete compilation.assets[asset.name]
}) })
const json = JSON.stringify(bundle, null, 2) const src = JSON.stringify(bundle, null, 2)
const filename = this.options.filename
compilation.assets[filename] = { compilation.assets[this.options.filename] = {
source: () => json, source: () => src,
size: () => json.length size: () => src.length
} }
cb() cb()

View File

@ -1,20 +1,21 @@
import chalk from 'chalk' /**
* This file is based on Vue.js (MIT) webpack plugins
* https://github.com/vuejs/vue/blob/dev/src/server/webpack-plugin/util.js
*/
const prefix = `[vue-server-renderer-webpack-plugin]` import consola from 'consola'
export const warn = msg => console.error(chalk.red(`${prefix} ${msg}\n`)) // eslint-disable-line no-console
export const tip = msg => console.log(chalk.yellow(`${prefix} ${msg}\n`)) // eslint-disable-line no-console
export const validate = (compiler) => { export const validate = (compiler) => {
if (compiler.options.target !== 'node') { if (compiler.options.target !== 'node') {
warn('webpack config `target` should be "node".') consola.warn('webpack config `target` should be "node".')
} }
if (compiler.options.output && compiler.options.output.libraryTarget !== 'commonjs2') { if (compiler.options.output && compiler.options.output.libraryTarget !== 'commonjs2') {
warn('webpack config `output.libraryTarget` should be "commonjs2".') consola.warn('webpack config `output.libraryTarget` should be "commonjs2".')
} }
if (!compiler.options.externals) { if (!compiler.options.externals) {
tip( consola.info(
'It is recommended to externalize dependencies in the server build for ' + 'It is recommended to externalize dependencies in the server build for ' +
'better build performance.' 'better build performance.'
) )

View File

@ -1,5 +1,7 @@
export default { export default {
styleResources: { build: {
'stylus': './nothinghere' styleResources: {
'stylus': './nothinghere'
}
} }
} }

View File

@ -143,17 +143,6 @@ describe('basic dev', () => {
}) })
}) })
test('/error no source-map (Youch)', async () => {
const sourceMaps = nuxt.renderer.resources.serverBundle.maps
nuxt.renderer.resources.serverBundle.maps = {}
await expect(nuxt.server.renderAndGetWindow(url('/error'))).rejects.toMatchObject({
statusCode: 500
})
nuxt.renderer.resources.serverBundle.maps = sourceMaps
})
test('/error should return json format error (Youch)', async () => { test('/error should return json format error (Youch)', async () => {
const opts = { const opts = {
headers: { headers: {

View File

@ -282,11 +282,6 @@ describe('basic ssr', () => {
.rejects.toMatchObject({ statusCode: 304 }) .rejects.toMatchObject({ statusCode: 304 })
}) })
test('/_nuxt/server-bundle.json should return 404', async () => {
await expect(rp(url('/_nuxt/server-bundle.json')))
.rejects.toMatchObject({ statusCode: 404 })
})
test('/_nuxt/ should return 404', async () => { test('/_nuxt/ should return 404', async () => {
await expect(rp(url('/_nuxt/'))) await expect(rp(url('/_nuxt/')))
.rejects.toMatchObject({ statusCode: 404 }) .rejects.toMatchObject({ statusCode: 404 })

View File

@ -19,22 +19,25 @@ describe('nuxt', () => {
expect(nuxt.initialized).toBe(true) expect(nuxt.initialized).toBe(true)
}) })
test('Fail to build when no pages/ directory but is in the parent', () => { test('Fail to build when no pages/ directory but is in the parent', async () => {
const config = await loadFixture('empty')
const nuxt = new Nuxt({ const nuxt = new Nuxt({
dev: false, ...config,
rootDir: resolve(__dirname, '..', 'fixtures', 'empty', 'pages') rootDir: resolve(__dirname, '..', 'fixtures', 'empty', 'pages')
}) })
return new Builder(nuxt).build().catch((err) => { try {
const s = String(err) await new Builder(nuxt).build()
expect(s).toContain('No `pages` directory found') } catch (err) {
expect(s).toContain('Did you mean to run `nuxt` in the parent (`../`) directory?') expect(err.message).toContain('No `pages` directory found')
}) expect(err.message).toContain('Did you mean to run `nuxt` in the parent (`../`) directory?')
}
expect.hasAssertions()
}) })
test('Build with default page when no pages/ directory', async () => { test('Build with default page when no pages/ directory', async () => {
const nuxt = new Nuxt() const config = await loadFixture('missing-pages-dir')
new Builder(nuxt).build() const nuxt = new Nuxt(config)
const port = await getPort() const port = await getPort()
await nuxt.server.listen(port, 'localhost') await nuxt.server.listen(port, 'localhost')
@ -45,27 +48,17 @@ describe('nuxt', () => {
await nuxt.close() await nuxt.close()
}) })
test('Fail to build when specified plugin isn\'t found', () => { test('Fail to build when specified plugin isn\'t found', async () => {
const nuxt = new Nuxt({ const config = await loadFixture('missing-plugin')
dev: false, const nuxt = new Nuxt(config)
rootDir: resolve(__dirname, '..', 'fixtures', 'missing-plugin')
})
return new Builder(nuxt).build().catch((err) => { await expect(new Builder(nuxt).build()).rejects.toThrow('Plugin not found')
const s = String(err)
expect(s).toContain('Plugin not found')
})
}) })
test('Warn when styleResource isn\'t found', () => { test('Warn when styleResource isn\'t found', async () => {
const nuxt = new Nuxt({ const config = await loadFixture('missing-style-resource')
dev: false, const nuxt = new Nuxt(config)
rootDir: resolve(__dirname, '..', 'fixtures', 'missing-style-resource')
})
return new Builder(nuxt).build().catch((err) => { await expect(new Builder(nuxt).build()).rejects.toThrow('Style Resource not found')
const s = String(err)
expect(s).toContain('Style Resource not found')
})
}) })
}) })

View File

@ -0,0 +1,30 @@
import consola from 'consola'
import { Nuxt } from '../utils'
const NO_BUILD_MSG = 'No build files found. Use either `nuxt build` or `builder.build()` or start nuxt in development mode.'
describe('renderer', () => {
test('detect no-build (Universal)', async () => {
const nuxt = new Nuxt({
_start: true,
mode: 'universal',
dev: false,
buildDir: '/path/to/404'
})
await nuxt.ready()
await expect(nuxt.renderer.renderer.isReady).toBe(false)
expect(consola.fatal).toHaveBeenCalledWith(new Error(NO_BUILD_MSG))
})
test('detect no-build (SPA)', async () => {
const nuxt = new Nuxt({
_start: true,
mode: 'spa',
dev: false,
buildDir: '/path/to/404'
})
await nuxt.ready()
await expect(nuxt.renderer.renderer.isReady).toBe(false)
expect(consola.fatal).toHaveBeenCalledWith(new Error(NO_BUILD_MSG))
})
})

View File

@ -1,6 +1,6 @@
import path from 'path' import path from 'path'
import PerfLoader from '../../packages/webpack/src/config/utils/perf-loader' import PerfLoader from '../../packages/webpack/src/utils/perf-loader'
describe('webpack configuration', () => { describe('webpack configuration', () => {
test('performance loader', () => { test('performance loader', () => {

View File

@ -9845,7 +9845,7 @@ source-map-url@^0.4.0:
source-map@0.5.6: source-map@0.5.6:
version "0.5.6" version "0.5.6"
resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" resolved "http://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412"
integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= integrity sha1-dc449SvwczxafwwRjYEzSiu19BI=
source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1: source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1: