feat: options.target and full-static export (#6159)

* feat: add options.target

* fix(lint): lint

* fix(test): update snapshots

* fix(builder): default value for target

* fix(test): fix test

* fix(test): test fixing

* fix: use this.options.target

* fix: final test

* Update packages/vue-renderer/src/renderer.js

Co-Authored-By: Alexander Lichter <manniL@gmx.net>

* feat: Add target option and update banner

* fix(lint): fix

* feat: Add warning when using serverMiddleware in static target

* chore(utils): add TARGETS and MODES as constants

* hotfix: lint

* chore(module): add filename as alias of fileName

* feat: introducing nuxt export and router/routes.json

* hotfix: Fix the linting lord

* chore(core): add comment for filename vs fileName

* fix: use targets constant

* chore: remove warning

* fix: unit testing

* wip: refactor and use TARGETS

* fix: lint

* feat: add target as alias for first arg value

* fix: generate only for SPA

* chore: explain to use nuxt static X

* fix: render SPA fallback on redirect for static target

* fix: lint issue

* fix: only target is useful for now

* wip

* wip: nuxt static export is looking good

* Update packages/generator/src/generator.js

Co-Authored-By: Devon Rueckner <indirectlylit@users.noreply.github.com>

* Update packages/cli/src/options/common.js

Co-Authored-By: Alexander Lichter <manniL@gmx.net>

* feat: add options.target

* fix(lint): lint

* fix(test): update snapshots

* fix(builder): default value for target

* fix(test): fix test

* fix(test): test fixing

* fix: use this.options.target

* fix: final test

* Update packages/vue-renderer/src/renderer.js

Co-Authored-By: Alexander Lichter <manniL@gmx.net>

* feat: Add target option and update banner

* fix(lint): fix

* feat: Add warning when using serverMiddleware in static target

* chore(utils): add TARGETS and MODES as constants

* hotfix: lint

* chore(module): add filename as alias of fileName

* feat: introducing nuxt export and router/routes.json

* hotfix: Fix the linting lord

* chore(core): add comment for filename vs fileName

* fix: use targets constant

* chore: remove warning

* fix: unit testing

* wip: refactor and use TARGETS

* fix: lint

* feat: add target as alias for first arg value

* chore: explain to use nuxt static X

* fix: render SPA fallback on redirect for static target

* fix: lint issue

* fix: only target is useful for now

* wip

* wip: nuxt static export is looking good

* Update packages/generator/src/generator.js

Co-Authored-By: Devon Rueckner <indirectlylit@users.noreply.github.com>

* Update packages/cli/src/options/common.js

Co-Authored-By: Alexander Lichter <manniL@gmx.net>

* fix: duplicate imports

* chore: don't server render if an error happens on static target

* test: update unit and add export

* lint: fix

* lint: fix

* fix: e2e test

* fix: fallback only for static target

* fix: dev test

* feat: add generate.crawler

* fix: full static is when generate.static is given

* chore: improvements

* fix: Add isFullStatic in nuxt/config.json

* feat: handle fetch for full static

* feat: router.prefetchPayloads for full static

* chore: use fetch in async-data example

* fix: add target only if given

* fix: use created to have access to props in fetchOnServer

* chore: add console.error in dev for easy debugging

* feat: payload smart pre-fetching

* fix: remove alias for target

* fix: increment payloadFetchIndex is static set to false

* chore: lint

* chore: add serve command

* chore: rename universal to server-side

* fix: handle payloadPath on SPA fallback

* fix: lint

* chore lint again

* feat: handle spa fallback

* feat: support string for exclude

* fix: fallback only if no extension or html

* chore: use JSON.stringify() for static target

* chore: lint again, dammit

* chore: fix tests and remove too early return

* fix: early return only for server target

* fix: update tests

* fix: unit tests

* chore: add ssr option

* chore: add logic for ssr option

* fix: #6682

* chore(dx): add next command to run

* fix: lint

* fix: tests

* chore: keep old behaviour for nuxt build in spa

* fix: test again, oh boy

* fix: alright this is good now

* chore: add comment for spa fallback

* chore: move routes.json to dot nuxt dir

* chore: simplify check for promise

* chore: unique lock id

* chore: refactor isFullStatic

* fix: dont set default in build context

* chore: add test for serve

* chore: update tests

* hotfix: lint tests

* chore(dx): improve message for bundling

* feat: js payload extraction with jsonp

* fix: keep serialized session script for legacy generate

* fix: call to setPagePayload from fetchPayload

* use devalue for payload chunks

* feat: add initial load state chunk

* feat: preload payload and state scripts

* fix(vue-app): don't re-render the app if trailing slash on SSG

* hotfix: remove console.log

* chore(dx): add deploy infos for nuxt export

Co-authored-by: Pooya Parsa <pyapar@gmail.com>

* chore: handle fetching payload.js for nuxt state

* chore(dx): error when using nuxt generate and static

* chore: remove static option for clarity

* chore: remove serverless target

* hotfix: lint

* hotfix: unit tests

* chore: update legacy js resource

* chore: remove query params from url in static target

* fix: use globalName and urlJoin

* chore: typo

* feat: previewMode 👀

* chore: rename to enablePreview

* fix: wait next tick to avoid error on spa

* chore: try 1 sec

* hotfix: test only for linux, wtf azure

* refactor: static assets

- generalize logic for modules need emit export static assets
- allow customization for version, dir and base
- serialization logic is only in ssr now

* feat: smart state chunk creates

* fix(client): ignore payload load error

* perf: avoide payload loading for spa initial

* perf: avoid loading failed chunks again

* chore(cli): add simple compression for nuxt serve

* test: update snapshots

* fix version snapshot

* fix(generator): set staticAssetsBase on context only for full static

* fix tests

* fix: honor shouldHashCspScriptSrc

* chore(dx): add log for client-side fallback creation

Co-authored-by: Xin Du (Clark) <clark.duxin@gmail.com>
Co-authored-by: Alexander Lichter <manniL@gmx.net>
Co-authored-by: Pooya Parsa <pooya@pi0.ir>
Co-authored-by: Devon Rueckner <indirectlylit@users.noreply.github.com>
Co-authored-by: Pooya Parsa <pyapar@gmail.com>
This commit is contained in:
Sébastien Chopin 2020-05-07 21:08:01 +02:00 committed by GitHub
parent a0db3644f6
commit 917adc0618
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 1105 additions and 186 deletions

View File

@ -12,13 +12,12 @@
</template> </template>
<script> <script>
import axios from 'axios'
export default { export default {
async asyncData ({ params }) { async asyncData ({ params }) {
// We can use async/await ES6 feature // We can use async/await ES6 feature
const { data } = await axios.get(`https://jsonplaceholder.typicode.com/posts/${params.id}`) const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`).then(res => res.json())
return { post: data }
return { post }
}, },
head () { head () {
return { return {

View File

@ -7,6 +7,9 @@
<NuxtLink :to="{ name: 'posts-id', params: { id: post.id } }"> <NuxtLink :to="{ name: 'posts-id', params: { id: post.id } }">
{{ post.title }} {{ post.title }}
</NuxtLink> </NuxtLink>
<NuxtLink :to="{ name: 'posts-id', params: { id: post.id } }">
{{ post.title }}
</NuxtLink>
</li> </li>
</ul> </ul>
<p> <p>
@ -18,14 +21,13 @@
</template> </template>
<script> <script>
import axios from 'axios'
export default { export default {
asyncData ({ req, params }) { asyncData ({ req, params }) {
// We can return a Promise instead of calling the callback // We can return a Promise instead of calling the callback
return axios.get('https://jsonplaceholder.typicode.com/posts') return fetch('https://jsonplaceholder.typicode.com/posts')
.then((res) => { .then(res => res.json())
return { posts: res.data.slice(0, 5) } .then((data) => {
return { posts: data.slice(0, 5) }
}) })
}, },
head: { head: {

View File

@ -1,4 +1,5 @@
import path from 'path' import path from 'path'
import chalk from 'chalk'
import chokidar from 'chokidar' import chokidar from 'chokidar'
import consola from 'consola' import consola from 'consola'
import fsExtra from 'fs-extra' import fsExtra from 'fs-extra'
@ -7,7 +8,6 @@ import hash from 'hash-sum'
import pify from 'pify' import pify from 'pify'
import upath from 'upath' import upath from 'upath'
import semver from 'semver' import semver from 'semver'
import chalk from 'chalk'
import debounce from 'lodash/debounce' import debounce from 'lodash/debounce'
import omit from 'lodash/omit' import omit from 'lodash/omit'
@ -23,7 +23,9 @@ import {
determineGlobals, determineGlobals,
stripWhitespace, stripWhitespace,
isIndexFileAndFolder, isIndexFileAndFolder,
scanRequireTree scanRequireTree,
TARGETS,
isFullStatic
} from '@nuxt/utils' } from '@nuxt/utils'
import Ignore from './ignore' import Ignore from './ignore'
@ -102,6 +104,7 @@ export default class Builder {
} }
forGenerate () { forGenerate () {
this.options.target = TARGETS.static
this.bundleBuilder.forGenerate() this.bundleBuilder.forGenerate()
} }
@ -122,6 +125,13 @@ export default class Builder {
consola.info('Initial build may take a while') consola.info('Initial build may take a while')
} else { } else {
consola.info('Production build') consola.info('Production build')
if (this.options.render.ssr) {
consola.info(`Bundling for ${chalk.bold.yellow('server')} and ${chalk.bold.green('client')} side`)
} else {
consola.info(`Bundling only for ${chalk.bold.green('client')} side`)
}
const target = isFullStatic(this.options) ? 'full static' : this.options.target
consola.info(`Target: ${chalk.bold.cyan(target)}`)
} }
// Wait for nuxt ready // Wait for nuxt ready

View File

@ -3,7 +3,7 @@ export default class BuildContext {
this._builder = builder this._builder = builder
this.nuxt = builder.nuxt this.nuxt = builder.nuxt
this.options = builder.nuxt.options this.options = builder.nuxt.options
this.isStatic = false this.target = builder.nuxt.options.target
} }
get buildOptions () { get buildOptions () {

View File

@ -4,7 +4,7 @@ import uniqBy from 'lodash/uniqBy'
import serialize from 'serialize-javascript' import serialize from 'serialize-javascript'
import devalue from '@nuxt/devalue' import devalue from '@nuxt/devalue'
import { r, wp, wChunk, serializeFunction } from '@nuxt/utils' import { r, wp, wChunk, serializeFunction, isFullStatic } from '@nuxt/utils'
export default class TemplateContext { export default class TemplateContext {
constructor (builder, options) { constructor (builder, options) {
@ -20,6 +20,7 @@ export default class TemplateContext {
uniqBy, uniqBy,
isDev: options.dev, isDev: options.dev,
isTest: options.test, isTest: options.test,
isFullStatic: isFullStatic(options),
debug: options.debug, debug: options.debug,
buildIndicator: options.dev && options.build.indicator, buildIndicator: options.dev && options.build.indicator,
vue: { config: options.vue.config }, vue: { config: options.vue.config },

View File

@ -5,6 +5,9 @@ export const createNuxt = () => ({
build: { build: {
watch: [] watch: []
}, },
render: {
ssr: true
},
router: {}, router: {},
dir: { dir: {
app: 'app' app: 'app'

View File

@ -35,6 +35,7 @@ describe('builder: builder build', () => {
nuxt.options.dir = { pages: '/var/nuxt/src/pages' } nuxt.options.dir = { pages: '/var/nuxt/src/pages' }
nuxt.options.build.template = { dir: '/var/nuxt/src/template' } nuxt.options.build.template = { dir: '/var/nuxt/src/template' }
nuxt.options.build.createRoutes = jest.fn() nuxt.options.build.createRoutes = jest.fn()
nuxt.options.render = { ssr: true }
const bundleBuilder = { build: jest.fn() } const bundleBuilder = { build: jest.fn() }
const builder = new Builder(nuxt, bundleBuilder) const builder = new Builder(nuxt, bundleBuilder)
@ -47,7 +48,7 @@ describe('builder: builder build', () => {
const buildReturn = await builder.build() const buildReturn = await builder.build()
expect(consola.info).toBeCalledTimes(1) expect(consola.info).toBeCalledTimes(3)
expect(consola.info).toBeCalledWith('Production build') expect(consola.info).toBeCalledWith('Production build')
expect(nuxt.ready).toBeCalledTimes(1) expect(nuxt.ready).toBeCalledTimes(1)
expect(nuxt.callHook).toBeCalledTimes(3) expect(nuxt.callHook).toBeCalledTimes(3)
@ -117,6 +118,7 @@ describe('builder: builder build', () => {
nuxt.options.buildDir = '/var/nuxt/build' nuxt.options.buildDir = '/var/nuxt/build'
nuxt.options.dir = { pages: '/var/nuxt/src/pages' } nuxt.options.dir = { pages: '/var/nuxt/src/pages' }
nuxt.options.build.createRoutes = jest.fn() nuxt.options.build.createRoutes = jest.fn()
nuxt.options.render = { ssr: true }
const bundleBuilder = { build: jest.fn() } const bundleBuilder = { build: jest.fn() }
const builder = new Builder(nuxt, bundleBuilder) const builder = new Builder(nuxt, bundleBuilder)

View File

@ -30,6 +30,7 @@ TemplateContext {
], ],
"head": "test_head", "head": "test_head",
"isDev": "test_dev", "isDev": "test_dev",
"isFullStatic": false,
"isTest": "test_test", "isTest": "test_test",
"layoutTransition": Object { "layoutTransition": Object {
"name": "test_layout_trans", "name": "test_layout_trans",

View File

@ -1,15 +1,20 @@
import { TARGETS } from '@nuxt/utils'
import BuildContext from '../../src/context/build' import BuildContext from '../../src/context/build'
describe('builder: buildContext', () => { describe('builder: buildContext', () => {
test('should construct context', () => { test('should construct context', () => {
const builder = { const builder = {
nuxt: { options: {} } nuxt: {
options: {
target: TARGETS.server
}
}
} }
const context = new BuildContext(builder) const context = new BuildContext(builder)
expect(context._builder).toEqual(builder) expect(context._builder).toEqual(builder)
expect(context.nuxt).toEqual(builder.nuxt) expect(context.nuxt).toEqual(builder.nuxt)
expect(context.options).toEqual(builder.nuxt.options) expect(context.options).toEqual(builder.nuxt.options)
expect(context.isStatic).toEqual(false) expect(context.target).toEqual('server')
}) })
test('should return builder plugins context', () => { test('should return builder plugins context', () => {

View File

@ -1,3 +1,5 @@
import consola from 'consola'
import { MODES, TARGETS } from '@nuxt/utils'
import { common, locking } from '../options' import { common, locking } from '../options'
import { createLock } from '../utils' import { createLock } from '../utils'
@ -62,7 +64,7 @@ export default {
}, },
async run (cmd) { async run (cmd) {
const config = await cmd.getNuxtConfig({ dev: false, server: false, _build: true }) const config = await cmd.getNuxtConfig({ dev: false, server: false, _build: true })
config.server = config.mode === 'spa' && cmd.argv.generate !== false config.server = (config.mode === MODES.spa || config.ssr === false) && cmd.argv.generate !== false
const nuxt = await cmd.getNuxt(config) const nuxt = await cmd.getNuxt(config)
if (cmd.argv.lock) { if (cmd.argv.lock) {
@ -73,7 +75,8 @@ export default {
})) }))
} }
if (nuxt.options.mode === 'spa' && cmd.argv.generate !== false) { // TODO: remove if in Nuxt 3
if (nuxt.options.mode === MODES.spa && nuxt.options.target === TARGETS.server && cmd.argv.generate !== false) {
// Build + Generate for static deployment // Build + Generate for static deployment
const generator = await cmd.getGenerator(nuxt) const generator = await cmd.getGenerator(nuxt)
await generator.generate({ build: true }) await generator.generate({ build: true })
@ -81,6 +84,9 @@ export default {
// Build only // Build only
const builder = await cmd.getBuilder(nuxt) const builder = await cmd.getBuilder(nuxt)
await builder.build() await builder.build()
const nextCommand = nuxt.options.target === TARGETS.static ? 'nuxt export' : 'nuxt start'
consola.info('Ready to run `' + (nextCommand) + '`')
} }
} }
} }

View File

@ -0,0 +1,50 @@
import path from 'path'
import consola from 'consola'
import { TARGETS } from '@nuxt/utils'
import { common, locking } from '../options'
import { createLock } from '../utils'
export default {
name: 'export',
description: 'Export a static generated web application',
usage: 'export <dir>',
options: {
...common,
...locking,
'fail-on-error': {
type: 'boolean',
default: false,
description: 'Exit with non-zero status code if there are errors when exporting pages'
}
},
async run (cmd) {
const config = await cmd.getNuxtConfig({
dev: false,
target: TARGETS.static,
_build: cmd.argv.build
})
const nuxt = await cmd.getNuxt(config)
if (cmd.argv.lock) {
await cmd.setLock(await createLock({
id: 'export',
dir: nuxt.options.generate.dir,
root: config.rootDir
}))
}
const generator = await cmd.getGenerator(nuxt)
await nuxt.server.listen()
const { errors } = await generator.generate({
init: true,
build: false
})
await nuxt.close()
if (cmd.argv['fail-on-error'] && errors.length > 0) {
throw new Error('Error exporting pages, exiting with non-zero code')
}
consola.info('Ready to run `nuxt serve` or deploy `' + path.basename(nuxt.options.generate.dir) + '/` directory')
}
}

View File

@ -1,3 +1,4 @@
import { TARGETS } from '@nuxt/utils'
import { common, locking } from '../options' import { common, locking } from '../options'
import { normalizeArg, createLock } from '../utils' import { normalizeArg, createLock } from '../utils'
@ -53,14 +54,25 @@ export default {
} }
}, },
async run (cmd) { async run (cmd) {
const config = await cmd.getNuxtConfig({ dev: false, _generate: true, _build: cmd.argv.build }) const config = await cmd.getNuxtConfig({
dev: false,
_build: cmd.argv.build
})
if (config.target === TARGETS.static) {
throw new Error("Please use `nuxt export` when using `target: 'static'`")
}
// Forcing static target anyway
config.target = TARGETS.static
// Disable analyze if set by the nuxt config // Disable analyze if set by the nuxt config
if (!config.build) { config.build = config.build || {}
config.build = {}
}
config.build.analyze = false config.build.analyze = false
// Set flag to keep the prerendering behaviour
config._legacyGenerate = true
const nuxt = await cmd.getNuxt(config) const nuxt = await cmd.getNuxt(config)
if (cmd.argv.lock) { if (cmd.argv.lock) {
@ -82,12 +94,14 @@ export default {
} }
const generator = await cmd.getGenerator(nuxt) const generator = await cmd.getGenerator(nuxt)
await nuxt.server.listen()
const { errors } = await generator.generate({ const { errors } = await generator.generate({
init: true, init: true,
build: cmd.argv.build build: cmd.argv.build
}) })
await nuxt.close()
if (cmd.argv['fail-on-error'] && errors.length > 0) { if (cmd.argv['fail-on-error'] && errors.length > 0) {
throw new Error('Error generating pages, exiting with non-zero code') throw new Error('Error generating pages, exiting with non-zero code')
} }

View File

@ -1,8 +1,10 @@
const commands = { const commands = {
start: () => import('./start'), start: () => import('./start'),
serve: () => import('./serve'),
dev: () => import('./dev'), dev: () => import('./dev'),
build: () => import('./build'), build: () => import('./build'),
generate: () => import('./generate'), generate: () => import('./generate'),
export: () => import('./export'),
webpack: () => import('./webpack'), webpack: () => import('./webpack'),
help: () => import('./help') help: () => import('./help')
} }

View File

@ -0,0 +1,83 @@
import { promises as fs } from 'fs'
import { join, extname, basename } from 'path'
import connect from 'connect'
import serveStatic from 'serve-static'
import compression from 'compression'
import { getNuxtConfig } from '@nuxt/config'
import { TARGETS } from '@nuxt/utils'
import { Listener } from '@nuxt/server'
import { common, server } from '../options'
import { showBanner } from '../utils/banner'
import * as imports from '../imports'
export default {
name: 'serve',
description: 'Serve the exported static application (should be compiled with `nuxt build` and `nuxt export` first)',
usage: 'serve <dir>',
options: {
'config-file': common['config-file'],
version: common.version,
help: common.help,
...server
},
async run (cmd) {
let options = await cmd.getNuxtConfig({ dev: false })
// add default options
options = getNuxtConfig(options)
try {
// overwrites with build config
const buildConfig = require(join(options.buildDir, 'nuxt/config.json'))
options.target = buildConfig.target
} catch (err) {}
if (options.target === TARGETS.server) {
throw new Error('You cannot use `nuxt serve` with ' + TARGETS.server + ' target, please use `nuxt start`')
}
const distStat = await fs.stat(options.generate.dir).catch(err => null) // eslint-disable-line handle-callback-err
if (!distStat || !distStat.isDirectory()) {
throw new Error('Output directory `' + basename(options.generate.dir) + '/` does not exists, please run `nuxt export` before `nuxt serve`.')
}
const app = connect()
app.use(compression({ threshold: 0 }))
app.use(
serveStatic(options.generate.dir, {
extensions: ['html']
})
)
if (options.generate.fallback) {
const fallbackFile = await fs.readFile(join(options.generate.dir, options.generate.fallback), 'utf-8')
app.use((req, res, next) => {
const ext = extname(req.url) || '.html'
if (ext !== '.html') {
return next()
}
res.writeHeader(200, {
'Content-Type': 'text/html'
})
res.write(fallbackFile)
res.end()
})
}
const { port, host, socket, https } = options.server
const listener = new Listener({
port,
host,
socket,
https,
app,
dev: true, // try another port if taken
baseURL: options.router.base
})
await listener.listen()
const { Nuxt } = await imports.core()
showBanner({
constructor: Nuxt,
options,
server: {
listeners: [listener]
}
}, false)
}
}

View File

@ -1,3 +1,4 @@
import { TARGETS } from '@nuxt/utils'
import { common, server } from '../options' import { common, server } from '../options'
import { showBanner } from '../utils/banner' import { showBanner } from '../utils/banner'
@ -11,6 +12,9 @@ export default {
}, },
async run (cmd) { async run (cmd) {
const config = await cmd.getNuxtConfig({ dev: false, _start: true }) const config = await cmd.getNuxtConfig({ dev: false, _start: true })
if (config.target === TARGETS.static) {
throw new Error('You cannot use `nuxt start` with ' + TARGETS.static + ' target, please use `nuxt export` and `nuxt serve`')
}
const nuxt = await cmd.getNuxt(config) const nuxt = await cmd.getNuxt(config)
// Listen and show ready banner // Listen and show ready banner

View File

@ -28,10 +28,20 @@ export default {
} }
} }
}, },
target: {
alias: 't',
type: 'string',
description: 'Build/start app for a different target, e.g. server, serverless and static',
prepare (cmd, options, argv) {
if (argv.target) {
options.target = argv.target
}
}
},
'force-exit': { 'force-exit': {
type: 'boolean', type: 'boolean',
default (cmd) { default (cmd) {
return ['build', 'generate'].includes(cmd.name) return ['build', 'generate', 'export'].includes(cmd.name)
}, },
description: 'Whether Nuxt.js should force exit after the command has finished' description: 'Whether Nuxt.js should force exit after the command has finished'
}, },

View File

@ -20,11 +20,14 @@ export function showBanner (nuxt, showMemoryUsage = true) {
const messageLines = [] const messageLines = []
// Name and version // Name and version
const { bannerColor } = nuxt.options.cli const { bannerColor, badgeMessages } = nuxt.options.cli
titleLines.push(`${chalk[bannerColor].bold('Nuxt.js')} ${nuxt.constructor.version}`) titleLines.push(`${chalk[bannerColor].bold('Nuxt.js')} ${nuxt.constructor.version}`)
// Running mode // Running mode
titleLines.push(`Running in ${nuxt.options.dev ? chalk.bold.blue('development') : chalk.bold.green('production')} mode (${chalk.bold(nuxt.options.mode)})`) const rendering = nuxt.options.render.ssr ? chalk.bold.yellow('server-side') : chalk.bold.yellow('client-side')
const envMode = nuxt.options.dev ? chalk.bold.blue('development') : chalk.bold.green('production')
const sentence = `Running in ${envMode}, with ${rendering} rendering and ${chalk.bold.cyan(nuxt.options.target)} target.`
titleLines.push(sentence)
if (showMemoryUsage) { if (showMemoryUsage) {
titleLines.push(getFormattedMemoryUsage()) titleLines.push(getFormattedMemoryUsage())
@ -36,8 +39,8 @@ export function showBanner (nuxt, showMemoryUsage = true) {
} }
// Add custom badge messages // Add custom badge messages
if (nuxt.options.cli.badgeMessages.length) { if (badgeMessages.length) {
messageLines.push('', ...nuxt.options.cli.badgeMessages) messageLines.push('', ...badgeMessages)
} }
process.stdout.write(successBox(messageLines.join('\n'), titleLines.join('\n'))) process.stdout.write(successBox(messageLines.join('\n'), titleLines.join('\n')))

View File

@ -1,6 +1,7 @@
import path from 'path' import path from 'path'
import defaultsDeep from 'lodash/defaultsDeep' import defaultsDeep from 'lodash/defaultsDeep'
import { loadNuxtConfig as _loadNuxtConfig, getDefaultNuxtConfig } from '@nuxt/config' import { loadNuxtConfig as _loadNuxtConfig, getDefaultNuxtConfig } from '@nuxt/config'
import { MODES } from '@nuxt/utils'
export async function loadNuxtConfig (argv, configContext) { export async function loadNuxtConfig (argv, configContext) {
const rootDir = path.resolve(argv._[0] || '.') const rootDir = path.resolve(argv._[0] || '.')
@ -14,7 +15,8 @@ export async function loadNuxtConfig (argv, configContext) {
}) })
// Nuxt Mode // Nuxt Mode
options.mode = (argv.spa && 'spa') || (argv.universal && 'universal') || options.mode options.mode =
(argv.spa && MODES.spa) || (argv.universal && MODES.universal) || options.mode
// Server options // Server options
options.server = defaultsDeep({ options.server = defaultsDeep({

View File

@ -18,6 +18,9 @@ exports[`cli/command builds help text 1`] = `
--modern, -m Build/Start app for --modern, -m Build/Start app for
modern browsers, e.g. server, client and modern browsers, e.g. server, client and
false false
--target, -t Build/start app for a
different target, e.g. server,
serverless and static
--force-exit Whether Nuxt.js --force-exit Whether Nuxt.js
should force exit after the command has should force exit after the command has
finished finished

View File

@ -1,3 +1,4 @@
import { MODES, TARGETS } from '@nuxt/utils'
import * as utils from '../../src/utils' import * as utils from '../../src/utils'
import { mockGetNuxt, mockGetBuilder, mockGetGenerator, NuxtCommand } from '../utils' import { mockGetNuxt, mockGetBuilder, mockGetGenerator, NuxtCommand } from '../utils'
@ -23,7 +24,7 @@ describe('build', () => {
test('builds on universal mode', async () => { test('builds on universal mode', async () => {
mockGetNuxt({ mockGetNuxt({
mode: 'universal', mode: MODES.universal,
build: { build: {
analyze: true analyze: true
} }
@ -37,7 +38,8 @@ describe('build', () => {
test('generates on spa mode', async () => { test('generates on spa mode', async () => {
mockGetNuxt({ mockGetNuxt({
mode: 'spa', mode: MODES.spa,
target: TARGETS.server,
build: { build: {
analyze: false analyze: false
} }
@ -51,7 +53,7 @@ describe('build', () => {
test('build with devtools', async () => { test('build with devtools', async () => {
mockGetNuxt({ mockGetNuxt({
mode: 'universal' mode: MODES.universal
}) })
const builder = mockGetBuilder(Promise.resolve()) const builder = mockGetBuilder(Promise.resolve())
@ -67,7 +69,7 @@ describe('build', () => {
test('build with modern mode', async () => { test('build with modern mode', async () => {
mockGetNuxt({ mockGetNuxt({
mode: 'universal' mode: MODES.universal
}) })
mockGetBuilder(Promise.resolve()) mockGetBuilder(Promise.resolve())
@ -114,7 +116,7 @@ describe('build', () => {
test('build locks project by default', async () => { test('build locks project by default', async () => {
mockGetNuxt({ mockGetNuxt({
mode: 'universal' mode: MODES.universal
}) })
mockGetBuilder(Promise.resolve()) mockGetBuilder(Promise.resolve())
@ -131,7 +133,7 @@ describe('build', () => {
test('build can disable locking', async () => { test('build can disable locking', async () => {
mockGetNuxt({ mockGetNuxt({
mode: 'universal' mode: MODES.universal
}) })
mockGetBuilder(Promise.resolve()) mockGetBuilder(Promise.resolve())

View File

@ -21,7 +21,7 @@ describe('cli/command', () => {
const cmd = new Command({ options: allOptions }) const cmd = new Command({ options: allOptions })
const minimistOptions = cmd._getMinimistOptions() const minimistOptions = cmd._getMinimistOptions()
expect(minimistOptions.string.length).toBe(5) expect(minimistOptions.string.length).toBe(6)
expect(minimistOptions.boolean.length).toBe(5) expect(minimistOptions.boolean.length).toBe(5)
expect(minimistOptions.alias.c).toBe('config-file') expect(minimistOptions.alias.c).toBe('config-file')
expect(minimistOptions.default.c).toBe(common['config-file'].default) expect(minimistOptions.default.c).toBe(common['config-file'].default)

View File

@ -0,0 +1,118 @@
import * as utils from '../../src/utils'
import { mockGetNuxt, mockGetGenerator, NuxtCommand } from '../utils'
describe('export', () => {
let exportCommand
beforeAll(async () => {
exportCommand = await import('../../src/commands/export').then(m => m.default)
jest.spyOn(process, 'exit').mockImplementation(code => code)
jest.spyOn(utils, 'forceExit').mockImplementation(() => {})
jest.spyOn(utils, 'createLock').mockImplementation(() => () => {})
})
afterAll(() => {
process.exit.mockRestore()
})
afterEach(() => jest.resetAllMocks())
test('has run function', () => {
expect(typeof exportCommand.run).toBe('function')
})
test('init by default, build false', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
const generator = mockGetGenerator()
await NuxtCommand.from(exportCommand).run()
expect(generator).toHaveBeenCalled()
expect(generator.mock.calls[0][0].init).toBe(true)
expect(generator.mock.calls[0][0].build).toBe(false)
})
test('force-exits by default', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator()
const cmd = NuxtCommand.from(exportCommand, ['export', '.'])
await cmd.run()
expect(utils.forceExit).toHaveBeenCalledTimes(1)
expect(utils.forceExit).toHaveBeenCalledWith('export', 5)
})
test('can set force exit explicitly', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator()
const cmd = NuxtCommand.from(exportCommand, ['export', '.', '--force-exit'])
await cmd.run()
expect(utils.forceExit).toHaveBeenCalledTimes(1)
expect(utils.forceExit).toHaveBeenCalledWith('export', false)
})
test('can disable force exit explicitly', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator()
const cmd = NuxtCommand.from(exportCommand, ['generate', '.', '--no-force-exit'])
await cmd.run()
expect(utils.forceExit).not.toHaveBeenCalled()
})
test('locks project by default', async () => {
const releaseLock = jest.fn(() => Promise.resolve())
const createLock = jest.fn(() => releaseLock)
jest.spyOn(utils, 'createLock').mockImplementation(createLock)
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator()
const cmd = NuxtCommand.from(exportCommand, ['export', '.'])
await cmd.run()
expect(createLock).toHaveBeenCalledTimes(1)
expect(releaseLock).toHaveBeenCalledTimes(1)
})
test('can disable locking', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator()
const createLock = jest.fn(() => Promise.resolve())
jest.spyOn(utils, 'createLock').mockImplementationOnce(() => createLock)
const cmd = NuxtCommand.from(exportCommand, ['export', '.', '--no-lock'])
await cmd.run()
expect(createLock).not.toHaveBeenCalled()
})
test('throw an error when fail-on-error enabled and page errors', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator(() => ({ errors: [{ type: 'dummy' }] }))
const cmd = NuxtCommand.from(exportCommand, ['export', '.', '--fail-on-error'])
await expect(cmd.run()).rejects.toThrow('Error exporting pages, exiting with non-zero code')
})
test('do not throw an error when fail-on-error disabled and page errors', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator(() => ({ errors: [{ type: 'dummy' }] }))
const cmd = NuxtCommand.from(exportCommand, ['export', '.'])
await cmd.run()
})
test('do not throw an error when fail-on-error enabled and no page errors', async () => {
mockGetNuxt({ generate: { dir: 'dist' } })
mockGetGenerator()
const cmd = NuxtCommand.from(exportCommand, ['export', '.', '--fail-on-error'])
await cmd.run()
})
})

View File

@ -0,0 +1,44 @@
import { promises as fs } from 'fs'
import { TARGETS } from '@nuxt/utils'
import * as utils from '../../src/utils/'
import { consola, mockNuxt, mockGetNuxtConfig, NuxtCommand } from '../utils'
describe('serve', () => {
let serve
beforeAll(async () => {
serve = await import('../../src/commands/serve').then(m => m.default)
jest.spyOn(utils, 'forceExit').mockImplementation(() => {})
})
afterEach(() => {
jest.resetAllMocks()
})
test('has run function', () => {
expect(typeof serve.run).toBe('function')
})
test('error if starts with server target', () => {
mockGetNuxtConfig({ target: TARGETS.server })
const cmd = NuxtCommand.from(serve)
expect(cmd.run()).rejects.toThrow(new Error('You cannot use `nuxt serve` with server target, please use `nuxt start`'))
})
test('error if dist/ does not exists', () => {
mockGetNuxtConfig({ target: TARGETS.static })
const cmd = NuxtCommand.from(serve)
expect(cmd.run()).rejects.toThrow(new Error('Output directory `dist/` does not exists, please run `nuxt export` before `nuxt serve`.'))
})
test('no error if dist/ dir exists', async () => {
mockGetNuxtConfig({ target: TARGETS.static })
mockNuxt()
fs.stat = jest.fn().mockImplementationOnce(() => Promise.resolve(({
isDirectory: () => true
})))
fs.readFile = jest.fn().mockImplementationOnce(() => Promise.resolve('HTML here'))
await NuxtCommand.from(serve).run()
expect(consola.fatal).not.toHaveBeenCalled()
})
})

View File

@ -1,4 +1,5 @@
import fs from 'fs-extra' import fs from 'fs-extra'
import { TARGETS } from '@nuxt/utils'
import * as utils from '../../src/utils/' import * as utils from '../../src/utils/'
import { consola, mockGetNuxtStart, mockGetNuxtConfig, NuxtCommand } from '../utils' import { consola, mockGetNuxtStart, mockGetNuxtConfig, NuxtCommand } from '../utils'
@ -35,8 +36,16 @@ describe('start', () => {
expect(consola.fatal).not.toHaveBeenCalled() expect(consola.fatal).not.toHaveBeenCalled()
}) })
test('error if starts with static target', () => {
mockGetNuxtStart()
mockGetNuxtConfig({ target: TARGETS.static })
const cmd = NuxtCommand.from(start)
expect(cmd.run()).rejects.toThrow(new Error('You cannot use `nuxt start` with static target, please use `nuxt export` and `nuxt serve`'))
})
test('start doesnt force-exit by default', async () => { test('start doesnt force-exit by default', async () => {
mockGetNuxtStart() mockGetNuxtStart()
mockGetNuxtConfig()
const cmd = NuxtCommand.from(start, ['start', '.']) const cmd = NuxtCommand.from(start, ['start', '.'])
await cmd.run() await cmd.run()
@ -46,6 +55,7 @@ describe('start', () => {
test('start can set force exit explicitly', async () => { test('start can set force exit explicitly', async () => {
mockGetNuxtStart() mockGetNuxtStart()
mockGetNuxtConfig()
const cmd = NuxtCommand.from(start, ['start', '.', '--force-exit']) const cmd = NuxtCommand.from(start, ['start', '.', '--force-exit'])
await cmd.run() await cmd.run()
@ -56,6 +66,7 @@ describe('start', () => {
test('start can disable force exit explicitly', async () => { test('start can disable force exit explicitly', async () => {
mockGetNuxtStart() mockGetNuxtStart()
mockGetNuxtConfig()
const cmd = NuxtCommand.from(start, ['start', '.', '--no-force-exit']) const cmd = NuxtCommand.from(start, ['start', '.', '--no-force-exit'])
await cmd.run() await cmd.run()

View File

@ -1,4 +1,5 @@
import { getDefaultNuxtConfig } from '@nuxt/config' import { getDefaultNuxtConfig } from '@nuxt/config'
import { TARGETS, MODES } from '@nuxt/utils'
import { consola } from '../utils' import { consola } from '../utils'
import { loadNuxtConfig } from '../../src/utils/config' import { loadNuxtConfig } from '../../src/utils/config'
import * as utils from '../../src/utils' import * as utils from '../../src/utils'
@ -24,7 +25,7 @@ describe('cli/utils', () => {
const options = await loadNuxtConfig(argv) const options = await loadNuxtConfig(argv)
expect(options.rootDir).toBe(process.cwd()) expect(options.rootDir).toBe(process.cwd())
expect(options.mode).toBe('universal') expect(options.mode).toBe(MODES.universal)
expect(options.server.host).toBe('localhost') expect(options.server.host).toBe('localhost')
expect(options.server.port).toBe(3000) expect(options.server.port).toBe(3000)
expect(options.server.socket).not.toBeDefined() expect(options.server.socket).not.toBeDefined()
@ -40,7 +41,7 @@ describe('cli/utils', () => {
const options = await loadNuxtConfig(argv) const options = await loadNuxtConfig(argv)
expect(options.testOption).toBe(true) expect(options.testOption).toBe(true)
expect(options.rootDir).toBe('/some/path') expect(options.rootDir).toBe('/some/path')
expect(options.mode).toBe('spa') expect(options.mode).toBe(MODES.spa)
expect(options.server.host).toBe('nuxt-host') expect(options.server.host).toBe('nuxt-host')
expect(options.server.port).toBe(3001) expect(options.server.port).toBe(3001)
expect(options.server.socket).toBe('/var/run/nuxt.sock') expect(options.server.socket).toBe('/var/run/nuxt.sock')
@ -149,6 +150,9 @@ describe('cli/utils', () => {
showBanner({ showBanner({
options: { options: {
render: {
ssr: true
},
cli: { cli: {
badgeMessages, badgeMessages,
bannerColor bannerColor
@ -179,6 +183,9 @@ describe('cli/utils', () => {
cli: { cli: {
badgeMessages: [], badgeMessages: [],
bannerColor: 'green' bannerColor: 'green'
},
render: {
ssr: false
} }
}, },
server: { server: {
@ -193,6 +200,37 @@ describe('cli/utils', () => {
stdout.mockRestore() stdout.mockRestore()
}) })
test('showBanner does print env, rendering mode and target', () => {
const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => {})
const successBox = jest.fn().mockImplementation((m, t) => t + m)
jest.spyOn(fmt, 'successBox').mockImplementation(successBox)
showBanner({
options: {
dev: false,
target: TARGETS.static,
render: {
ssr: false
},
cli: {
bannerColor: 'green',
badgeMessages: []
}
},
server: {
listeners: []
}
}, false)
expect(successBox).toHaveBeenCalledTimes(1)
expect(stdout).toHaveBeenCalledTimes(1)
expect(stdout).toHaveBeenCalledWith(expect.stringMatching('Nuxt.js'))
expect(stdout).toHaveBeenCalledWith(expect.stringMatching('Running in production'))
expect(stdout).toHaveBeenCalledWith(expect.stringMatching('client-side rendering'))
expect(stdout).toHaveBeenCalledWith(expect.stringMatching('static target'))
stdout.mockRestore()
})
test('showMemoryUsage prints memory usage', () => { test('showMemoryUsage prints memory usage', () => {
showMemoryUsage() showMemoryUsage()

View File

@ -22,6 +22,10 @@ export const mockGetNuxt = (options = {}, implementation) => {
Command.prototype.getNuxt = jest.fn().mockImplementationOnce(() => { Command.prototype.getNuxt = jest.fn().mockImplementationOnce(() => {
return Object.assign({ return Object.assign({
hook: jest.fn(), hook: jest.fn(),
server: {
listen: jest.fn()
},
close: jest.fn(),
options options
}, implementation) }, implementation)
}) })
@ -68,8 +72,9 @@ export const mockGetNuxtStart = (ssr) => {
return { listen } return { listen }
} }
export const mockGetNuxtConfig = () => { export const mockGetNuxtConfig = (config = {}) => {
const spy = jest.fn() const spy = jest.fn()
spy.mockReturnValue(config)
Command.prototype.getNuxtConfig = spy Command.prototype.getNuxtConfig = spy
return spy return spy
} }

View File

@ -1,5 +1,6 @@
import capitalize from 'lodash/capitalize' import capitalize from 'lodash/capitalize'
import env from 'std-env' import env from 'std-env'
import { TARGETS, MODES } from '@nuxt/utils'
export default () => ({ export default () => ({
// Env // Env
@ -8,8 +9,15 @@ export default () => ({
debug: undefined, // = dev debug: undefined, // = dev
env: {}, env: {},
// Target
target: TARGETS.server,
// Rendering
ssr: true,
// TODO: remove in Nuxt 3
// Mode // Mode
mode: 'universal', mode: MODES.universal,
modern: undefined, modern: undefined,
globalName: undefined, globalName: undefined,
@ -53,17 +61,6 @@ export default () => ({
'**/*.spec.*' '**/*.spec.*'
], ],
// Generate
generate: {
dir: 'dist',
routes: [],
exclude: [],
concurrency: 500,
interval: 0,
subFolders: true,
fallback: '200.html'
},
// Watch // Watch
watch: [], watch: [],
watchers: { watchers: {

View File

@ -0,0 +1,17 @@
export default () => ({
dir: 'dist',
routes: [],
exclude: [],
concurrency: 500,
interval: 0,
subFolders: true,
fallback: '200.html',
crawler: true,
staticAssets: {
base: undefined, // Default: "/_nuxt/static:
versionBase: undefined, // Default: "_nuxt/static/{version}""
dir: 'static',
version: undefined // Default: "{timeStampSec}"
}
})

View File

@ -9,6 +9,7 @@ import render from './render'
import router from './router' import router from './router'
import server from './server' import server from './server'
import cli from './cli' import cli from './cli'
import generate from './generate'
export const defaultNuxtConfigFile = 'nuxt.config' export const defaultNuxtConfigFile = 'nuxt.config'
@ -26,6 +27,7 @@ export function getDefaultNuxtConfig (options = {}) {
render: render(), render: render(),
router: router(), router: router(),
server: server(options), server: server(options),
cli: cli() cli: cli(),
generate: generate()
} }
} }

View File

@ -1,5 +1,7 @@
import { MODES } from '@nuxt/utils'
export default () => ({ export default () => ({
universal: { [MODES.universal]: {
build: { build: {
ssr: true ssr: true
}, },
@ -7,7 +9,7 @@ export default () => ({
ssr: true ssr: true
} }
}, },
spa: { [MODES.spa]: {
build: { build: {
ssr: false ssr: false
}, },

View File

@ -13,5 +13,6 @@ export default () => ({
stringifyQuery: false, stringifyQuery: false,
fallback: false, fallback: false,
prefetchLinks: true, prefetchLinks: true,
prefetchPayloads: true,
trailingSlash: undefined trailingSlash: undefined
}) })

View File

@ -5,7 +5,7 @@ import defu from 'defu'
import pick from 'lodash/pick' import pick from 'lodash/pick'
import uniq from 'lodash/uniq' import uniq from 'lodash/uniq'
import consola from 'consola' import consola from 'consola'
import { guardDir, isNonEmptyString, isPureObject, isUrl, getMainModule } from '@nuxt/utils' import { TARGETS, MODES, guardDir, isNonEmptyString, isPureObject, isUrl, getMainModule, urlJoin } from '@nuxt/utils'
import { defaultNuxtConfigFile, getDefaultNuxtConfig } from './config' import { defaultNuxtConfigFile, getDefaultNuxtConfig } from './config'
export function getNuxtConfig (_options) { export function getNuxtConfig (_options) {
@ -89,6 +89,26 @@ export function getNuxtConfig (_options) {
defaultsDeep(options, nuxtConfig) defaultsDeep(options, nuxtConfig)
// Target
options.target = options.target || 'server'
if (!Object.values(TARGETS).includes(options.target)) {
consola.warn(`Unknown target: ${options.target}. Falling back to server`)
options.target = 'server'
}
// SSR root option
if (options.ssr === false) {
options.mode = MODES.spa
}
// Apply mode preset
const modePreset = options.modes[options.mode || MODES.universal]
if (!modePreset) {
consola.warn(`Unknown mode: ${options.mode}. Falling back to ${MODES.universal}`)
}
defaultsDeep(options, modePreset || options.modes[MODES.universal])
// Sanitize router.base // Sanitize router.base
if (!/\/$/.test(options.router.base)) { if (!/\/$/.test(options.router.base)) {
options.router.base += '/' options.router.base += '/'
@ -241,7 +261,7 @@ export function getNuxtConfig (_options) {
hashAlgorithm: 'sha256', hashAlgorithm: 'sha256',
allowedSources: undefined, allowedSources: undefined,
policies: undefined, policies: undefined,
addMeta: Boolean(options._generate), addMeta: Boolean(options.target === TARGETS.static),
unsafeInlineCompatibility: false, unsafeInlineCompatibility: false,
reportOnly: options.debug reportOnly: options.debug
}) })
@ -316,14 +336,6 @@ export function getNuxtConfig (_options) {
delete options.render.gzip delete options.render.gzip
} }
// Apply mode preset
const modePreset = options.modes[options.mode || 'universal']
if (!modePreset) {
consola.warn(`Unknown mode: ${options.mode}. Falling back to universal`)
}
defaultsDeep(options, modePreset || options.modes.universal)
// If no server-side rendering, add appear true transition // If no server-side rendering, add appear true transition
if (options.render.ssr === false && options.pageTransition) { if (options.render.ssr === false && options.pageTransition) {
options.pageTransition.appear = true options.pageTransition.appear = true
@ -436,5 +448,18 @@ export function getNuxtConfig (_options) {
.map(([path, handler]) => ({ path, handler })) .map(([path, handler]) => ({ path, handler }))
} }
// Generate staticAssets
const { staticAssets } = options.generate
if (!staticAssets.version) {
staticAssets.version = String(Math.round(Date.now() / 1000))
}
if (!staticAssets.base) {
const publicPath = isUrl(options.build.publicPath) ? '' : options.build.publicPath // "/_nuxt" or custom CDN URL
staticAssets.base = urlJoin(publicPath, staticAssets.dir)
}
if (!staticAssets.versionBase) {
staticAssets.versionBase = urlJoin(staticAssets.base, staticAssets.version)
}
return options return options
} }

View File

@ -190,11 +190,18 @@ Object {
}, },
"generate": Object { "generate": Object {
"concurrency": 500, "concurrency": 500,
"crawler": true,
"dir": "/var/nuxt/test/dist", "dir": "/var/nuxt/test/dist",
"exclude": Array [], "exclude": Array [],
"fallback": "200.html", "fallback": "200.html",
"interval": 0, "interval": 0,
"routes": Array [], "routes": Array [],
"staticAssets": Object {
"base": "/_nuxt/static",
"dir": "static",
"version": "x",
"versionBase": "/_nuxt/static/x",
},
"subFolders": true, "subFolders": true,
}, },
"globalName": "nuxt", "globalName": "nuxt",
@ -339,6 +346,7 @@ Object {
"mode": "history", "mode": "history",
"parseQuery": false, "parseQuery": false,
"prefetchLinks": true, "prefetchLinks": true,
"prefetchPayloads": true,
"routeNameSplitter": "-", "routeNameSplitter": "-",
"routes": Array [], "routes": Array [],
"scrollBehavior": null, "scrollBehavior": null,
@ -354,6 +362,7 @@ Object {
}, },
"serverMiddleware": Array [], "serverMiddleware": Array [],
"srcDir": "/var/nuxt/test", "srcDir": "/var/nuxt/test",
"ssr": true,
"styleExtensions": Array [ "styleExtensions": Array [
"css", "css",
"pcss", "pcss",
@ -364,6 +373,7 @@ Object {
"sass", "sass",
"less", "less",
], ],
"target": "server",
"test": true, "test": true,
"vue": Object { "vue": Object {
"config": Object { "config": Object {

View File

@ -171,11 +171,18 @@ Object {
}, },
"generate": Object { "generate": Object {
"concurrency": 500, "concurrency": 500,
"crawler": true,
"dir": "dist", "dir": "dist",
"exclude": Array [], "exclude": Array [],
"fallback": "200.html", "fallback": "200.html",
"interval": 0, "interval": 0,
"routes": Array [], "routes": Array [],
"staticAssets": Object {
"base": undefined,
"dir": "static",
"version": undefined,
"versionBase": undefined,
},
"subFolders": true, "subFolders": true,
}, },
"globalName": undefined, "globalName": undefined,
@ -310,6 +317,7 @@ Object {
"mode": "history", "mode": "history",
"parseQuery": false, "parseQuery": false,
"prefetchLinks": true, "prefetchLinks": true,
"prefetchPayloads": true,
"routeNameSplitter": "-", "routeNameSplitter": "-",
"routes": Array [], "routes": Array [],
"scrollBehavior": null, "scrollBehavior": null,
@ -325,6 +333,7 @@ Object {
}, },
"serverMiddleware": Array [], "serverMiddleware": Array [],
"srcDir": undefined, "srcDir": undefined,
"ssr": true,
"styleExtensions": Array [ "styleExtensions": Array [
"css", "css",
"pcss", "pcss",
@ -335,6 +344,7 @@ Object {
"sass", "sass",
"less", "less",
], ],
"target": "server",
"test": true, "test": true,
"vue": Object { "vue": Object {
"config": Object { "config": Object {
@ -527,11 +537,18 @@ Object {
}, },
"generate": Object { "generate": Object {
"concurrency": 500, "concurrency": 500,
"crawler": true,
"dir": "dist", "dir": "dist",
"exclude": Array [], "exclude": Array [],
"fallback": "200.html", "fallback": "200.html",
"interval": 0, "interval": 0,
"routes": Array [], "routes": Array [],
"staticAssets": Object {
"base": undefined,
"dir": "static",
"version": undefined,
"versionBase": undefined,
},
"subFolders": true, "subFolders": true,
}, },
"globalName": undefined, "globalName": undefined,
@ -666,6 +683,7 @@ Object {
"mode": "history", "mode": "history",
"parseQuery": false, "parseQuery": false,
"prefetchLinks": true, "prefetchLinks": true,
"prefetchPayloads": true,
"routeNameSplitter": "-", "routeNameSplitter": "-",
"routes": Array [], "routes": Array [],
"scrollBehavior": null, "scrollBehavior": null,
@ -681,6 +699,7 @@ Object {
}, },
"serverMiddleware": Array [], "serverMiddleware": Array [],
"srcDir": undefined, "srcDir": undefined,
"ssr": true,
"styleExtensions": Array [ "styleExtensions": Array [
"css", "css",
"pcss", "pcss",
@ -691,6 +710,7 @@ Object {
"sass", "sass",
"less", "less",
], ],
"target": "server",
"test": true, "test": true,
"vue": Object { "vue": Object {
"config": Object { "config": Object {

View File

@ -25,7 +25,7 @@ describe('config: options', () => {
jest.spyOn(path, 'resolve').mockImplementation((...args) => args.join('/').replace(/\\+/, '/')) jest.spyOn(path, 'resolve').mockImplementation((...args) => args.join('/').replace(/\\+/, '/'))
jest.spyOn(path, 'join').mockImplementation((...args) => args.join('/').replace(/\\+/, '/')) jest.spyOn(path, 'join').mockImplementation((...args) => args.join('/').replace(/\\+/, '/'))
expect(getNuxtConfig({})).toMatchSnapshot() expect(getNuxtConfig({ generate: { staticAssets: { version: 'x' } } })).toMatchSnapshot()
process.cwd.mockRestore() process.cwd.mockRestore()
path.resolve.mockRestore() path.resolve.mockRestore()
@ -124,6 +124,17 @@ describe('config: options', () => {
}) })
}) })
test('should fallback to server target', () => {
const { target } = getNuxtConfig({ target: 0 })
expect(target).toEqual('server')
})
test('should check unknown target', () => {
const { target } = getNuxtConfig({ target: 'test' })
expect(consola.warn).toHaveBeenCalledWith('Unknown target: test. Falling back to server')
expect(target).toEqual('server')
})
test('should check unknown mode', () => { test('should check unknown mode', () => {
const { build, render } = getNuxtConfig({ mode: 'test' }) const { build, render } = getNuxtConfig({ mode: 'test' })
expect(consola.warn).toHaveBeenCalledWith('Unknown mode: test. Falling back to universal') expect(consola.warn).toHaveBeenCalledWith('Unknown mode: test. Falling back to universal')

View File

@ -45,11 +45,10 @@ export default class ModuleContainer {
throw new Error('Template src not found: ' + src) throw new Error('Template src not found: ' + src)
} }
// Generate unique and human readable dst filename // Mostly for DX, some people prefers `filename` vs `fileName`
const dst = const fileName = template.fileName || template.filename
template.fileName || // Generate unique and human readable dst filename if not provided
path.basename(srcPath.dir) + `.${srcPath.name}.${hash(src)}` + srcPath.ext const dst = fileName || `${path.basename(srcPath.dir)}.${srcPath.name}.${hash(src)}${srcPath.ext}`
// Add to templates list // Add to templates list
const templateObj = { const templateObj = {
src, src,
@ -58,6 +57,7 @@ export default class ModuleContainer {
} }
this.options.build.templates.push(templateObj) this.options.build.templates.push(templateObj)
return templateObj return templateObj
} }

View File

@ -12,7 +12,8 @@
"chalk": "^3.0.0", "chalk": "^3.0.0",
"consola": "^2.11.3", "consola": "^2.11.3",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"html-minifier": "^4.0.0" "html-minifier": "^4.0.0",
"node-html-parser": "^1.2.4"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@ -1,16 +1,18 @@
import path from 'path' import path from 'path'
import Chalk from 'chalk' import chalk from 'chalk'
import consola from 'consola' import consola from 'consola'
import fsExtra from 'fs-extra' import fsExtra from 'fs-extra'
import htmlMinifier from 'html-minifier' import htmlMinifier from 'html-minifier'
import { parse } from 'node-html-parser'
import { flatRoutes, isString, isUrl, promisifyRoute, waitFor } from '@nuxt/utils' import { isFullStatic, flatRoutes, isString, isUrl, promisifyRoute, waitFor, TARGETS, MODES } from '@nuxt/utils'
export default class Generator { export default class Generator {
constructor (nuxt, builder) { constructor (nuxt, builder) {
this.nuxt = nuxt this.nuxt = nuxt
this.options = nuxt.options this.options = nuxt.options
this.builder = builder this.builder = builder
this.isFullStatic = false
// Set variables // Set variables
this.staticRoutes = path.resolve(this.options.srcDir, this.options.dir.static) this.staticRoutes = path.resolve(this.options.srcDir, this.options.dir.static)
@ -20,19 +22,25 @@ export default class Generator {
this.distPath, this.distPath,
isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath
) )
this.generatedRoutes = new Set()
} }
async generate ({ build = true, init = true } = {}) { async generate ({ build = true, init = true } = {}) {
consola.debug('Initializing generator...') consola.debug('Initializing generator...')
await this.initiate({ build, init }) await this.initiate({ build, init })
consola.debug('Preparing routes for generate...') // Payloads for full static
if (this.isFullStatic) {
consola.info('Full static mode activated')
const { staticAssets } = this.options.generate
this.staticAssetsDir = path.resolve(this.distNuxtPath, staticAssets.dir, staticAssets.version)
this.staticAssetsBase = this.options.generate.staticAssets.versionBase
}
consola.debug('Preparing routes for generate...')
const routes = await this.initRoutes() const routes = await this.initRoutes()
consola.info('Generating pages') consola.info('Generating pages')
const errors = await this.generateRoutes(routes) const errors = await this.generateRoutes(routes)
await this.afterGenerate() await this.afterGenerate()
@ -56,6 +64,22 @@ export default class Generator {
// Start build process // Start build process
await this.builder.build() await this.builder.build()
this.isFullStatic = isFullStatic(this.options)
} else {
const hasBuilt = await fsExtra.exists(this.srcBuiltPath)
if (!hasBuilt) {
throw new Error(
`No build files found in ${this.srcBuiltPath}.\nPlease run \`nuxt build --target static\` before calling \`nuxt export\``
)
}
const config = this.getBuildConfig()
if (config.target !== TARGETS.static) {
throw new Error(
`In order to use \`nuxt export\`, you need to run \`nuxt build --target static\``
)
}
this.isFullStatic = config.isFullStatic
this.options.render.ssr = config.ssr
} }
// Initialize dist directory // Initialize dist directory
@ -67,7 +91,7 @@ export default class Generator {
async initRoutes (...args) { async initRoutes (...args) {
// Resolve config.generate.routes promises before generating the routes // Resolve config.generate.routes promises before generating the routes
let generateRoutes = [] let generateRoutes = []
if (this.options.router.mode !== 'hash') { if (this.options.mode === MODES.universal && this.options.router.mode !== 'hash') {
try { try {
generateRoutes = await promisifyRoute( generateRoutes = await promisifyRoute(
this.options.generate.routes || [], this.options.generate.routes || [],
@ -78,14 +102,14 @@ export default class Generator {
throw e // eslint-disable-line no-unreachable throw e // eslint-disable-line no-unreachable
} }
} }
// Generate only index.html for router.mode = 'hash' let routes = []
let routes = // Generate only index.html for router.mode = 'hash' or client-side apps
this.options.router.mode === 'hash' if (this.options.mode === MODES.spa || this.options.router.mode === 'hash') {
? ['/'] routes = ['/']
: flatRoutes(this.options.router.routes) } else {
routes = flatRoutes(this.getAppRoutes())
routes = routes.filter(route => this.options.generate.exclude.every(regex => !regex.test(route))) }
routes = routes.filter(route => this.shouldGenerateRoute(route))
routes = this.decorateWithPayloads(routes, generateRoutes) routes = this.decorateWithPayloads(routes, generateRoutes)
// extendRoutes hook // extendRoutes hook
@ -94,14 +118,34 @@ export default class Generator {
return routes return routes
} }
shouldGenerateRoute (route) {
return this.options.generate.exclude.every((regex) => {
if (typeof regex === 'string') {
return regex !== route
}
return !regex.test(route)
})
}
getBuildConfig () {
return require(path.join(this.options.buildDir, 'nuxt/config.json'))
}
getAppRoutes () {
return require(path.join(this.options.buildDir, 'routes.json'))
}
async generateRoutes (routes) { async generateRoutes (routes) {
const errors = [] const errors = []
this.routes = routes
// Add routes to the tracked generated routes (for crawler)
this.routes.forEach(({ route }) => this.generatedRoutes.add(route))
// Start generate process // Start generate process
while (routes.length) { while (this.routes.length) {
let n = 0 let n = 0
await Promise.all( await Promise.all(
routes this.routes
.splice(0, this.options.generate.concurrency) .splice(0, this.options.generate.concurrency)
.map(async ({ route, payload }) => { .map(async ({ route, payload }) => {
await waitFor(n++ * this.options.generate.interval) await waitFor(n++ * this.options.generate.interval)
@ -123,12 +167,12 @@ export default class Generator {
const isHandled = type === 'handled' const isHandled = type === 'handled'
const color = isHandled ? 'yellow' : 'red' const color = isHandled ? 'yellow' : 'red'
let line = Chalk[color](` ${route}\n\n`) let line = chalk[color](` ${route}\n\n`)
if (isHandled) { if (isHandled) {
line += Chalk.grey(JSON.stringify(error, undefined, 2) + '\n') line += chalk.grey(JSON.stringify(error, undefined, 2) + '\n')
} else { } else {
line += Chalk.grey(error.stack || error.message || `${error}`) line += chalk.grey(error.stack || error.message || `${error}`)
} }
return line return line
@ -153,7 +197,10 @@ export default class Generator {
} }
// Render and write the SPA template to the fallback path // Render and write the SPA template to the fallback path
let { html } = await this.nuxt.server.renderRoute('/', { spa: true }) let { html } = await this.nuxt.server.renderRoute('/', {
spa: true,
staticAssetsBase: this.staticAssetsBase
})
try { try {
html = this.minifyHtml(html) html = this.minifyHtml(html)
@ -162,20 +209,27 @@ export default class Generator {
} }
await fsExtra.writeFile(fallbackPath, html, 'utf8') await fsExtra.writeFile(fallbackPath, html, 'utf8')
consola.success('Client-side fallback created: `' + fallback + '`')
} }
async initDist () { async initDist () {
// Clean destination folder // Clean destination folder
await fsExtra.remove(this.distPath) await fsExtra.remove(this.distPath)
consola.info(`Generating output directory: ${path.basename(this.distPath)}/`)
await this.nuxt.callHook('generate:distRemoved', this) await this.nuxt.callHook('generate:distRemoved', this)
// Copy static and built files // Copy static and built files
if (await fsExtra.exists(this.staticRoutes)) { if (await fsExtra.exists(this.staticRoutes)) {
await fsExtra.copy(this.staticRoutes, this.distPath) await fsExtra.copy(this.staticRoutes, this.distPath)
} }
// Copy .nuxt/dist/client/ to dist/_nuxt/
await fsExtra.copy(this.srcBuiltPath, this.distNuxtPath) await fsExtra.copy(this.srcBuiltPath, this.distNuxtPath)
if (this.payloadDir) {
await fsExtra.ensureDir(this.payloadDir)
}
// Add .nojekyll file to let GitHub Pages add the _nuxt/ folder // Add .nojekyll file to let GitHub Pages add the _nuxt/ folder
// https://help.github.com/articles/files-that-start-with-an-underscore-are-missing/ // https://help.github.com/articles/files-that-start-with-an-underscore-are-missing/
const nojekyllPath = path.resolve(this.distPath, '.nojekyll') const nojekyllPath = path.resolve(this.distPath, '.nojekyll')
@ -207,11 +261,34 @@ export default class Generator {
const pageErrors = [] const pageErrors = []
try { try {
const res = await this.nuxt.server.renderRoute(route, { const renderContext = {
_generate: true, payload,
payload staticAssetsBase: this.staticAssetsBase
}
const res = await this.nuxt.server.renderRoute(route, renderContext)
html = res.html
// If crawler activated and called from generateRoutes()
if (this.options.generate.crawler && this.options.render.ssr) {
parse(html).querySelectorAll('a').map((el) => {
const href = (el.getAttribute('href') || '').split('?')[0].split('#')[0].trim()
if (href.startsWith('/') && this.shouldGenerateRoute(href) && !this.generatedRoutes.has(href)) {
this.generatedRoutes.add(href) // add the route to the tracked list
this.routes.push({ route: href })
}
}) })
;({ html } = res) }
// Save Static Assets
if (this.staticAssetsDir && renderContext.staticAssets) {
for (const asset of renderContext.staticAssets) {
const assetPath = path.join(this.staticAssetsDir, asset.path)
await fsExtra.ensureDir(path.dirname(assetPath))
await fsExtra.writeFile(assetPath, asset.src, 'utf-8')
}
}
if (res.error) { if (res.error) {
pageErrors.push({ type: 'handled', route, error: res.error }) pageErrors.push({ type: 'handled', route, error: res.error })
} }

View File

@ -5,10 +5,12 @@ export const createNuxt = () => ({
renderRoute: jest.fn(() => ({ html: 'rendered html' })) renderRoute: jest.fn(() => ({ html: 'rendered html' }))
}, },
options: { options: {
mode: 'universal',
srcDir: '/var/nuxt/src', srcDir: '/var/nuxt/src',
buildDir: '/var/nuxt/build', buildDir: '/var/nuxt/build',
generate: { dir: '/var/nuxt/generate' }, generate: { dir: '/var/nuxt/generate' },
build: { publicPath: '__public' }, build: { publicPath: '__public' },
dir: { static: '/var/nuxt/static' } dir: { static: '/var/nuxt/static' },
render: {}
} }
}) })

View File

@ -76,6 +76,8 @@ describe('generator: initialize', () => {
const generator = new Generator(nuxt, builder) const generator = new Generator(nuxt, builder)
generator.initDist = jest.fn() generator.initDist = jest.fn()
fsExtra.exists.mockReturnValueOnce(true)
generator.getBuildConfig = jest.fn(() => ({ ssr: true, target: 'static' }))
await generator.initiate({ build: false, init: false }) await generator.initiate({ build: false, init: false })
@ -87,7 +89,7 @@ describe('generator: initialize', () => {
expect(generator.initDist).not.toBeCalled() expect(generator.initDist).not.toBeCalled()
}) })
test('should init routes with generate.routes and router.routes', async () => { test('should init routes with generate.routes and routes.json', async () => {
const nuxt = createNuxt() const nuxt = createNuxt()
nuxt.options = { nuxt.options = {
...nuxt.options, ...nuxt.options,
@ -97,14 +99,14 @@ describe('generator: initialize', () => {
routes: ['/foo', '/foo/bar'] routes: ['/foo', '/foo/bar']
}, },
router: { router: {
mode: 'history', mode: 'history'
routes: ['/index', '/about', '/test']
} }
} }
const generator = new Generator(nuxt) const generator = new Generator(nuxt)
flatRoutes.mockImplementationOnce(routes => routes) flatRoutes.mockImplementationOnce(routes => routes)
promisifyRoute.mockImplementationOnce(routes => routes) promisifyRoute.mockImplementationOnce(routes => routes)
generator.getAppRoutes = jest.fn(() => ['/index', '/about', '/test'])
generator.decorateWithPayloads = jest.fn(() => 'decoratedRoutes') generator.decorateWithPayloads = jest.fn(() => 'decoratedRoutes')
const routes = await generator.initRoutes() const routes = await generator.initRoutes()
@ -130,8 +132,7 @@ describe('generator: initialize', () => {
routes: ['/foo', '/foo/bar'] routes: ['/foo', '/foo/bar']
}, },
router: { router: {
mode: 'hash', mode: 'hash'
routes: ['/index', '/about', '/test']
} }
} }
const generator = new Generator(nuxt) const generator = new Generator(nuxt)

View File

@ -43,7 +43,7 @@ describe('generator: generate route', () => {
const returned = await generator.generateRoute({ route, payload, errors }) const returned = await generator.generateRoute({ route, payload, errors })
expect(nuxt.server.renderRoute).toBeCalledTimes(1) expect(nuxt.server.renderRoute).toBeCalledTimes(1)
expect(nuxt.server.renderRoute).toBeCalledWith('/foo/', { _generate: true, payload }) expect(nuxt.server.renderRoute).toBeCalledWith(route, { payload })
expect(path.join).toBeCalledTimes(2) expect(path.join).toBeCalledTimes(2)
expect(path.join).nthCalledWith(1, '[sep]', '/foo.html') expect(path.join).nthCalledWith(1, '[sep]', '/foo.html')
expect(path.join).nthCalledWith(2, generator.distPath, 'join([sep], /foo.html)') expect(path.join).nthCalledWith(2, generator.distPath, 'join([sep], /foo.html)')
@ -81,7 +81,7 @@ describe('generator: generate route', () => {
const returned = await generator.generateRoute({ route, payload, errors }) const returned = await generator.generateRoute({ route, payload, errors })
expect(nuxt.server.renderRoute).toBeCalledTimes(1) expect(nuxt.server.renderRoute).toBeCalledTimes(1)
expect(nuxt.server.renderRoute).toBeCalledWith('/foo', { _generate: true, payload }) expect(nuxt.server.renderRoute).toBeCalledWith('/foo', { payload })
expect(nuxt.callHook).toBeCalledTimes(1) expect(nuxt.callHook).toBeCalledTimes(1)
expect(nuxt.callHook).toBeCalledWith('generate:routeFailed', { expect(nuxt.callHook).toBeCalledWith('generate:routeFailed', {
route, route,

View File

@ -7,7 +7,6 @@ export default async function renderAndGetWindow (
{ {
loadedCallback, loadedCallback,
loadingTimeout = 2000, loadingTimeout = 2000,
ssr,
globals globals
} = {} } = {}
) { ) {
@ -49,9 +48,7 @@ export default async function renderAndGetWindow (
const { window } = await jsdom.JSDOM.fromURL(url, options) const { window } = await jsdom.JSDOM.fromURL(url, options)
// If Nuxt could not be loaded (error from the server-side) // If Nuxt could not be loaded (error from the server-side)
const nuxtExists = window.document.body.innerHTML.includes( const nuxtExists = window.document.body.innerHTML.includes(`id="${globals.id}"`)
ssr ? `window.${globals.context}` : `<div id="${globals.id}">`
)
if (!nuxtExists) { if (!nuxtExists) {
const error = new Error('Could not load the nuxt app') const error = new Error('Could not load the nuxt app')

View File

@ -2,7 +2,7 @@ import generateETag from 'etag'
import fresh from 'fresh' import fresh from 'fresh'
import consola from 'consola' import consola from 'consola'
import { getContext } from '@nuxt/utils' import { getContext, TARGETS } from '@nuxt/utils'
export default ({ options, nuxt, renderRoute, resources }) => async function nuxtMiddleware (req, res, next) { export default ({ options, nuxt, renderRoute, resources }) => async function nuxtMiddleware (req, res, next) {
// Get context // Get context
@ -28,7 +28,7 @@ export default ({ options, nuxt, renderRoute, resources }) => async function nux
preloadFiles preloadFiles
} = result } = result
if (redirected) { if (redirected && context.target !== TARGETS.static) {
await nuxt.callHook('render:routeDone', url, result, context) await nuxt.callHook('render:routeDone', url, result, context)
return html return html
} }

View File

@ -320,13 +320,11 @@ export default class Server {
renderAndGetWindow (url, opts = {}, { renderAndGetWindow (url, opts = {}, {
loadingTimeout = 2000, loadingTimeout = 2000,
loadedCallback = this.globals.loadedCallback, loadedCallback = this.globals.loadedCallback,
ssr = this.options.render.ssr,
globals = this.globals globals = this.globals
} = {}) { } = {}) {
return renderAndGetWindow(url, opts, { return renderAndGetWindow(url, opts, {
loadingTimeout, loadingTimeout,
loadedCallback, loadedCallback,
ssr,
globals globals
}) })
} }

View File

@ -0,0 +1,9 @@
export const TARGETS = {
server: 'server',
static: 'static'
}
export const MODES = {
universal: 'universal',
spa: 'spa'
}

View File

@ -1,3 +1,4 @@
import { TARGETS } from './constants'
export const getContext = function getContext (req, res) { export const getContext = function getContext (req, res) {
return { req, res } return { req, res }
@ -14,3 +15,7 @@ export const determineGlobals = function determineGlobals (globalName, globals)
} }
return _globals return _globals
} }
export const isFullStatic = function (options) {
return !options.dev && !options._legacyGenerate && options.target === TARGETS.static && options.render.ssr
}

View File

@ -8,3 +8,4 @@ export * from './task'
export * from './timer' export * from './timer'
export * from './cjs' export * from './cjs'
export * from './modern' export * from './modern'
export * from './constants'

View File

@ -9,6 +9,7 @@ import * as task from '../src/task'
import * as timer from '../src/timer' import * as timer from '../src/timer'
import * as cjs from '../src/cjs' import * as cjs from '../src/cjs'
import * as modern from '../src/modern' import * as modern from '../src/modern'
import * as constants from '../src/constants'
describe('util: entry', () => { describe('util: entry', () => {
test('should export all methods from utils folder', () => { test('should export all methods from utils folder', () => {
@ -22,7 +23,8 @@ describe('util: entry', () => {
...task, ...task,
...timer, ...timer,
...cjs, ...cjs,
...modern ...modern,
...constants
}) })
}) })
}) })

View File

@ -5,11 +5,14 @@ export const template = {
dependencies, dependencies,
dir: path.join(__dirname, '..', 'template'), dir: path.join(__dirname, '..', 'template'),
files: [ files: [
'nuxt/config.json',
'App.js', 'App.js',
'client.js', 'client.js',
'index.js', 'index.js',
'jsonp.js',
'router.js', 'router.js',
'router.scrollBehavior.js', 'router.scrollBehavior.js',
'routes.json',
'server.js', 'server.js',
'utils.js', 'utils.js',
'empty.js', 'empty.js',

View File

@ -4,7 +4,8 @@ import Vue from 'vue'
'getMatchedComponentsInstances', 'getMatchedComponentsInstances',
'getChildrenComponentInstancesUsingFetch', 'getChildrenComponentInstancesUsingFetch',
'promisify', 'promisify',
'globalHandleError' 'globalHandleError',
'urlJoin'
] : [], ] : [],
...features.layouts ? [ ...features.layouts ? [
'sanitizeComponent' 'sanitizeComponent'
@ -57,6 +58,7 @@ export default {
domProps: { domProps: {
id: '__layout' id: '__layout'
}, },
key: this.layoutName key: this.layoutName
}, [layoutEl]) }, [layoutEl])
<% } else { %> <% } else { %>
@ -110,8 +112,8 @@ export default {
created () { created () {
// Add this.$nuxt in child instances // Add this.$nuxt in child instances
Vue.prototype.<%= globals.nuxt %> = this Vue.prototype.<%= globals.nuxt %> = this
// add to window so we can listen when ready
if (process.client) { if (process.client) {
// add to window so we can listen when ready
window.<%= globals.nuxt %> = <%= (globals.nuxt !== '$nuxt' ? 'window.$nuxt = ' : '') %>this window.<%= globals.nuxt %> = <%= (globals.nuxt !== '$nuxt' ? 'window.$nuxt = ' : '') %>this
<% if (features.clientOnline) { %> <% if (features.clientOnline) { %>
this.refreshOnlineStatus() this.refreshOnlineStatus()
@ -125,10 +127,22 @@ export default {
// Add $nuxt.context // Add $nuxt.context
this.context = this.$options.context this.context = this.$options.context
}, },
<% if (loading) { %> <% if (loading || isFullStatic) { %>
mounted () { async mounted () {
this.$loading = this.$refs.loading <% if (loading) { %>this.$loading = this.$refs.loading<% } %>
<% if (isFullStatic) {%>
if (this.isPreview) {
if (this.$store && this.$store._actions.nuxtServerInit) {
<% if (loading) { %>this.$loading.start()<% } %>
await app.$store.dispatch('nuxtServerInit', this.context)
}
await this.refresh()
<% if (loading) { %>this.$loading.finish()<% } %>
}
<% } %>
}, },
<% } %>
<% if (loading) { %>
watch: { watch: {
'nuxt.err': 'errorChanged' 'nuxt.err': 'errorChanged'
}, },
@ -139,10 +153,13 @@ export default {
return !this.isOnline return !this.isOnline
}, },
<% if (features.fetch) { %> <% if (features.fetch) { %>
isFetching() { isFetching () {
return this.nbFetching > 0 return this.nbFetching > 0
} },<% } %>
<% } %> <% if (nuxtOptions.target === 'static') { %>
isPreview () {
return Boolean(this.$options.previewData)
},<% } %>
}, },
<% } %> <% } %>
methods: { methods: {
@ -257,7 +274,7 @@ export default {
return this.<%= globals.nuxt %>.error({ statusCode: 500, message: e.message }) return this.<%= globals.nuxt %>.error({ statusCode: 500, message: e.message })
} }
}) })
} },
<% } else { %> <% } else { %>
setLayout (layout) { setLayout (layout) {
<% if (debug) { %> <% if (debug) { %>
@ -277,9 +294,27 @@ export default {
layout = 'default' layout = 'default'
} }
return Promise.resolve(layouts['_' + layout]) return Promise.resolve(layouts['_' + layout])
} },
<% } /* splitChunks.layouts */ %> <% } /* splitChunks.layouts */ %>
<% } /* features.layouts */ %> <% } /* features.layouts */ %>
<% if (isFullStatic) { %>
setPagePayload(payload) {
this._pagePayload = payload
this._payloadFetchIndex = 0
},
async fetchPayload(route) {
route = (route.replace(/\/$/, '') || '/').split('?')[0]
try {
const src = urlJoin(window.__NUXT_STATIC__, route, 'payload.js')
const payload = await window.__NUXT_IMPORT__(route, src)
this.setPagePayload(payload)
return payload
} catch (err) {
this.setPagePayload(false)
throw err
}
}
<% } %>
}, },
<% if (loading) { %> <% if (loading) { %>
components: { components: {

View File

@ -19,6 +19,7 @@ import {
import { createApp<% if (features.layouts) { %>, NuxtError<% } %> } from './index.js' import { createApp<% if (features.layouts) { %>, NuxtError<% } %> } from './index.js'
<% if (features.fetch) { %>import fetchMixin from './mixins/fetch.client'<% } %> <% if (features.fetch) { %>import fetchMixin from './mixins/fetch.client'<% } %>
import NuxtLink from './components/nuxt-link.<%= features.clientPrefetch ? "client" : "server" %>.js' // should be included after ./index.js import NuxtLink from './components/nuxt-link.<%= features.clientPrefetch ? "client" : "server" %>.js' // should be included after ./index.js
<% if (isFullStatic) { %>import './jsonp'<% } %>
<% if (features.fetch) { %> <% if (features.fetch) { %>
// Fetch mixin // Fetch mixin
@ -136,7 +137,7 @@ function mapTransitions (toComponents, to, from) {
return mergedTransitions return mergedTransitions
} }
<% } %> <% } %>
<% if (loading) { %>async <% } %>function loadAsyncComponents (to, from, next) { async function loadAsyncComponents (to, from, next) {
// Check if route changed (this._routeChanged), only if the page is not an error (for validate()) // Check if route changed (this._routeChanged), only if the page is not an error (for validate())
this._routeChanged = Boolean(app.nuxt.err) || from.name !== to.name this._routeChanged = Boolean(app.nuxt.err) || from.name !== to.name
this._paramChanged = !this._routeChanged && from.path !== to.path this._paramChanged = !this._routeChanged && from.path !== to.path
@ -150,7 +151,6 @@ function mapTransitions (toComponents, to, from) {
<% } %> <% } %>
try { try {
<% if (loading) { %>
if (this._queryChanged) { if (this._queryChanged) {
const Components = await resolveRouteComponents( const Components = await resolveRouteComponents(
to, to,
@ -170,11 +170,12 @@ function mapTransitions (toComponents, to, from) {
} }
return false return false
}) })
<% if (loading) { %>
if (startLoader && this.$loading.start && !this.$loading.manual) { if (startLoader && this.$loading.start && !this.$loading.manual) {
this.$loading.start() this.$loading.start()
} }
}
<% } %> <% } %>
}
// Call next() // Call next()
next() next()
} catch (error) { } catch (error) {
@ -429,7 +430,7 @@ async function render (to, from, next) {
<% if (features.asyncData || features.fetch) { %> <% if (features.asyncData || features.fetch) { %>
let instances let instances
// Call asyncData & fetch hooks on components matched by the route. // Call asyncData & fetch hooks on components matched by the route.
await Promise.all(Components.map((Component, i) => { await Promise.all(Components.map(async (Component, i) => {
// Check if only children route changed // Check if only children route changed
Component._path = compile(to.matched[matches[i]].path)(to.params) Component._path = compile(to.matched[matches[i]].path)(to.params)
Component._dataRefresh = false Component._dataRefresh = false
@ -484,8 +485,24 @@ async function render (to, from, next) {
<% if (features.asyncData) { %> <% if (features.asyncData) { %>
// Call asyncData(context) // Call asyncData(context)
if (hasAsyncData) { if (hasAsyncData) {
<% if (isFullStatic) { %>
let promise
if (this.isPreview) {
promise = promisify(Component.options.asyncData, app.context)
} else {
try {
const payload = await this.fetchPayload(to.path).then((payloadData) => payloadData.data[i])
promise = Promise.resolve(payload)
} catch (err) {
// fallback
promise = promisify(Component.options.asyncData, app.context)
}
}
<% } else { %>
const promise = promisify(Component.options.asyncData, app.context) const promise = promisify(Component.options.asyncData, app.context)
.then((asyncDataResult) => { <% } %>
promise.then((asyncDataResult) => {
applyAsyncData(Component, asyncDataResult) applyAsyncData(Component, asyncDataResult)
<% if (loading) { %> <% if (loading) { %>
if (this.$loading.increase) { if (this.$loading.increase) {
@ -501,6 +518,12 @@ async function render (to, from, next) {
this.$loading.manual = Component.options.loading === false this.$loading.manual = Component.options.loading === false
<% if (features.fetch) { %> <% if (features.fetch) { %>
<% if (isFullStatic) { %>
if (!this.isPreview) {
// Catching the error here for letting the SPA fallback and normal fetch behaviour
promises.push(this.fetchPayload(to.path).catch(err => null))
}
<% } %>
// Call fetch(context) // Call fetch(context)
if (hasFetch) { if (hasFetch) {
let p = Component.options.fetch(app.context) let p = Component.options.fetch(app.context)
@ -768,7 +791,7 @@ function addHotReload ($component, depth) {
} }
<% } %> <% } %>
<% if (features.layouts || features.transitions) { %>async <% } %>function mountApp (__app) { async function mountApp (__app) {
// Set global variables // Set global variables
app = __app.app app = __app.app
router = __app.router router = __app.router
@ -777,6 +800,16 @@ function addHotReload ($component, depth) {
// Create Vue instance // Create Vue instance
const _app = new Vue(app) const _app = new Vue(app)
<% if (isFullStatic) { %>
// Load page chunk
if (!NUXT.data && !NUXT.spa) {
try {
const payload = await _app.fetchPayload(_app.context.route.path)
Object.assign(NUXT, payload)
} catch (err) {}
}
<% } %>
<% if (features.layouts && mode !== 'spa') { %> <% if (features.layouts && mode !== 'spa') { %>
// Load layout // Load layout
const layout = NUXT.layout || 'default' const layout = NUXT.layout || 'default'
@ -825,6 +858,10 @@ function addHotReload ($component, depth) {
router.beforeEach(loadAsyncComponents.bind(_app)) router.beforeEach(loadAsyncComponents.bind(_app))
router.beforeEach(render.bind(_app)) router.beforeEach(render.bind(_app))
// Fix in static: remove trailing slash to force hydration
if (process.static && NUXT.serverRendered && NUXT.routePath !== '/' && NUXT.routePath.slice(-1) !== '/' && _app.context.route.path.slice(-1) === '/') {
_app.context.route.path = _app.context.route.path.replace(/\/+$/, '')
}
// If page already is server rendered and it was done on the same route path as client side render // If page already is server rendered and it was done on the same route path as client side render
if (NUXT.serverRendered && NUXT.routePath === _app.context.route.path) { if (NUXT.serverRendered && NUXT.routePath === _app.context.route.path) {
mount() mount()
@ -839,6 +876,8 @@ function addHotReload ($component, depth) {
mount() mount()
} }
// fix: force next tick to avoid having same timestamp when an error happen on spa fallback
await new Promise(resolve => setTimeout(resolve, 0))
render.call(_app, router.currentRoute, router.currentRoute, (path) => { render.call(_app, router.currentRoute, router.currentRoute, (path) => {
// If not redirected // If not redirected
if (!path) { if (!path) {

View File

@ -43,7 +43,7 @@ export default {
meta: [ meta: [
{ {
name: 'viewport', name: 'viewport',
content: 'width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no' content: 'width=device-width,initial-scale=1.0,minimum-scale=1.0'
} }
] ]
} }

View File

@ -71,7 +71,12 @@ export default {
}<% } %> }<% } %>
}, },
shouldPrefetch () { shouldPrefetch () {
return this.getPrefetchComponents().length > 0 <% if (isFullStatic && router.prefetchPayloads) { %>
const ref = this.$router.resolve(this.to, this.$route, this.append)
const Components = ref.resolved.matched.map(r => r.components.default)
return Components.filter(Component => ref.href || (typeof Component === 'function' && !Component.options && !Component.__prefetched)).length
<% } else { %>return this.getPrefetchComponents().length > 0<% } %>
}, },
canPrefetch () { canPrefetch () {
const conn = navigator.connection const conn = navigator.connection
@ -101,7 +106,15 @@ export default {
<% if (router.linkPrefetchedClass) { %>promises.push(componentOrPromise)<% } %> <% if (router.linkPrefetchedClass) { %>promises.push(componentOrPromise)<% } %>
} }
Component.__prefetched = true Component.__prefetched = true
}<% if (router.linkPrefetchedClass) { %> }
<% if (isFullStatic && router.prefetchPayloads) { %>
// Preload the data only if not in preview mode
if (!this.$root.isPreview) {
const { href } = this.$router.resolve(this.to, this.$route, this.append)
this.$nuxt.fetchPayload(href).catch(() => {})
}
<% } %>
<% if (router.linkPrefetchedClass) { %>
return Promise.all(promises).then(() => this.addPrefetchedClass()) return Promise.all(promises).then(() => this.addPrefetchedClass())
<% } %> <% } %>
}<% if (router.linkPrefetchedClass) { %>, }<% if (router.linkPrefetchedClass) { %>,

View File

@ -210,6 +210,13 @@ async function createApp (ssrContext) {
} }
<% } %> <% } %>
// Add enablePreview(previewData = {}) in context for plugins
if (process.static && process.client) {
app.context.enablePreview = function (previewData = {}) {
app.previewData = Object.assign({}, previewData)
inject('preview', previewData)
}
}
// Plugin execution // Plugin execution
<%= isTest ? '/* eslint-disable camelcase */' : '' %> <%= isTest ? '/* eslint-disable camelcase */' : '' %>
<% plugins.forEach((plugin) => { %> <% plugins.forEach((plugin) => { %>
@ -228,6 +235,12 @@ async function createApp (ssrContext) {
<% } %> <% } %>
<% }) %> <% }) %>
<%= isTest ? '/* eslint-enable camelcase */' : '' %> <%= isTest ? '/* eslint-enable camelcase */' : '' %>
// Lock enablePreview in context
if (process.static && process.client) {
app.context.enablePreview = function () {
console.warn('You cannot call enablePreview() outside a plugin.')
}
}
// If server-side, wait for async component to be resolved first // If server-side, wait for async component to be resolved first
if (process.server && ssrContext && ssrContext.url) { if (process.server && ssrContext && ssrContext.url) {

View File

@ -0,0 +1,80 @@
const chunks = {} // chunkId => exports
const chunksInstalling = {} // chunkId => Promise
const failedChunks = {}
function importChunk(chunkId, src) {
// Already installed
if (chunks[chunkId]) {
return Promise.resolve(chunks[chunkId])
}
// Failed loading
if (failedChunks[chunkId]) {
return Promise.reject(failedChunks[chunkId])
}
// Installing
if (chunksInstalling[chunkId]) {
return chunksInstalling[chunkId]
}
// Set a promise in chunk cache
let resolve, reject
const promise = chunksInstalling[chunkId] = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
// Clear chunk data from cache
delete chunks[chunkId]
// Start chunk loading
const script = document.createElement('script')
script.charset = 'utf-8'
script.timeout = 120
script.src = src
let timeout
// Create error before stack unwound to get useful stacktrace later
const error = new Error()
// Complete handlers
const onScriptComplete = script.onerror = script.onload = (event) => {
// Cleanups
clearTimeout(timeout)
delete chunksInstalling[chunkId]
// Avoid mem leaks in IE
script.onerror = script.onload = null
// Verify chunk is loaded
if (chunks[chunkId]) {
return resolve(chunks[chunkId])
}
// Something bad happened
const errorType = event && (event.type === 'load' ? 'missing' : event.type)
const realSrc = event && event.target && event.target.src
error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')'
error.name = 'ChunkLoadError'
error.type = errorType
error.request = realSrc
failedChunks[chunkId] = error
reject(error)
}
// Timeout
timeout = setTimeout(() => {
onScriptComplete({ type: 'timeout', target: script })
}, 120000)
// Append script
document.head.appendChild(script)
// Return promise
return promise
}
window.__NUXT_JSONP__ = function (chunkId, exports) { chunks[chunkId] = exports }
window.__NUXT_JSONP_CACHE__ = chunks
window.__NUXT_IMPORT__ = importChunk

View File

@ -32,6 +32,7 @@ function beforeMount() {
function created() { function created() {
if (!isSsrHydration(this)) { if (!isSsrHydration(this)) {
<% if (isFullStatic) { %>createdFullStatic.call(this)<% } %>
return return
} }
@ -52,6 +53,33 @@ function created() {
} }
} }
<% if (isFullStatic) { %>
function createdFullStatic() {
// Check if component has been fetched on server
let fetchedOnServer = this.$options.fetchOnServer !== false
if (typeof this.$options.fetchOnServer === 'function') {
fetchedOnServer = this.$options.fetchOnServer.call(this) !== false
}
if (!fetchedOnServer || this.$nuxt.isPreview || !this.$nuxt._pagePayload) {
return
}
this._hydrated = true
this._fetchKey = this.$nuxt._payloadFetchIndex++
const data = this.$nuxt._pagePayload.fetch[this._fetchKey]
// If fetch error
if (data && data._error) {
this.$fetchState.error = data._error
return
}
// Merge data
for (const key in data) {
Vue.set(this.$data, key, data[key])
}
}
<% } %>
function $fetch() { function $fetch() {
if (!this._fetchPromise) { if (!this._fetchPromise) {
this._fetchPromise = $_fetch.call(this) this._fetchPromise = $_fetch.call(this)
@ -71,6 +99,9 @@ async function $_fetch() {
try { try {
await this.$options.fetch.call(this) await this.$options.fetch.call(this)
} catch (err) { } catch (err) {
if (process.dev) {
console.error('Error in fetch():', err)
}
error = normalizeError(err) error = normalizeError(err)
} }
@ -85,4 +116,3 @@ async function $_fetch() {
this.$nextTick(() => this.<%= globals.nuxt %>.nbFetching--) this.$nextTick(() => this.<%= globals.nuxt %>.nbFetching--)
} }

View File

@ -10,6 +10,9 @@ async function serverPrefetch() {
try { try {
await this.$options.fetch.call(this) await this.$options.fetch.call(this)
} catch (err) { } catch (err) {
if (process.dev) {
console.error('Error in fetch():', err)
}
this.$fetchState.error = normalizeError(err) this.$fetchState.error = normalizeError(err)
} }
this.$fetchState.pending = false this.$fetchState.pending = false
@ -27,7 +30,7 @@ async function serverPrefetch() {
} }
export default { export default {
beforeCreate() { created() {
if (!hasFetch(this)) { if (!hasFetch(this)) {
return return
} }

View File

@ -0,0 +1,5 @@
<%= JSON.stringify({
isFullStatic: isFullStatic,
ssr: nuxtOptions.render.ssr,
target: nuxtOptions.target
}, null, 2) %>

View File

@ -0,0 +1 @@
<%= JSON.stringify(router.routes, null, 2) %>

View File

@ -37,9 +37,9 @@ function urlJoin () {
} }
const createNext = ssrContext => (opts) => { const createNext = ssrContext => (opts) => {
// If static target, render on client-side
ssrContext.redirected = opts ssrContext.redirected = opts
// If nuxt generate if (ssrContext.target === 'static' || !ssrContext.res) {
if (!ssrContext.res) {
ssrContext.nuxt.serverRendered = false ssrContext.nuxt.serverRendered = false
return return
} }
@ -71,8 +71,13 @@ export default async (ssrContext) => {
ssrContext.next = createNext(ssrContext) ssrContext.next = createNext(ssrContext)
// Used for beforeNuxtRender({ Components, nuxtState }) // Used for beforeNuxtRender({ Components, nuxtState })
ssrContext.beforeRenderFns = [] ssrContext.beforeRenderFns = []
// Nuxt object (window{{globals.context}}, defaults to window.__NUXT__) // Nuxt object (window.{{globals.context}}, defaults to window.__NUXT__)
ssrContext.nuxt = { <% if (features.layouts) { %>layout: 'default', <% } %>data: [], <% if (features.fetch) { %>fetch: [], <% } %>error: null<%= (store ? ', state: null' : '') %>, serverRendered: true, routePath: '' } ssrContext.nuxt = { <% if (features.layouts) { %>layout: 'default', <% } %>data: [], <% if (features.fetch) { %>fetch: [], <% } %>error: null<%= (store ? ', state: null' : '') %>, serverRendered: true, routePath: '' }
// Remove query from url is static target
if (process.static && ssrContext.url) {
ssrContext.url = ssrContext.url.split('?')[0]
}
// Create the app definition and the instance (created for each request) // Create the app definition and the instance (created for each request)
const { app, router<%= (store ? ', store' : '') %> } = await createApp(ssrContext) const { app, router<%= (store ? ', store' : '') %> } = await createApp(ssrContext)
const _app = new Vue(app) const _app = new Vue(app)
@ -100,6 +105,10 @@ export default async (ssrContext) => {
} }
const renderErrorPage = async () => { const renderErrorPage = async () => {
// Don't server-render the page in static target
if (ssrContext.target === 'static') {
ssrContext.nuxt.serverRendered = false
}
<% if (features.layouts) { %> <% if (features.layouts) { %>
// Load layout for error page // Load layout for error page
const layout = (NuxtError.options || NuxtError).layout const layout = (NuxtError.options || NuxtError).layout
@ -245,10 +254,6 @@ export default async (ssrContext) => {
// ...If .validate() returned false // ...If .validate() returned false
if (!isValid) { if (!isValid) {
// Don't server-render the page in generate mode
if (ssrContext._generate) {
ssrContext.nuxt.serverRendered = false
}
// Render a 404 error page // Render a 404 error page
return render404Page() return render404Page()
} }

View File

@ -156,10 +156,10 @@ export async function setContext (app, context) {
env: <%= JSON.stringify(env) %><%= isTest ? '// eslint-disable-line' : '' %> env: <%= JSON.stringify(env) %><%= isTest ? '// eslint-disable-line' : '' %>
} }
// Only set once // Only set once
if (context.req) { if (!process.static && context.req) {
app.context.req = context.req app.context.req = context.req
} }
if (context.res) { if (!process.static && context.res) {
app.context.res = context.res app.context.res = context.res
} }
if (context.ssrContext) { if (context.ssrContext) {
@ -642,3 +642,11 @@ export function addLifecycleHook(vm, hook, fn) {
vm.$options[hook].push(fn) vm.$options[hook].push(fn)
} }
} }
export const urlJoin = function urlJoin () {
return [].slice
.call(arguments)
.join('/')
.replace(/\/+/g, '/')
.replace(':/', '://')
}

View File

@ -274,13 +274,15 @@ export default class VueRenderer {
// Add url to the renderContext // Add url to the renderContext
renderContext.url = url renderContext.url = url
// Add target to the renderContext
renderContext.target = this.serverContext.nuxt.options.target
const { req = {} } = renderContext const { req = {}, res = {} } = renderContext
// renderContext.spa // renderContext.spa
if (renderContext.spa === undefined) { if (renderContext.spa === undefined) {
// TODO: Remove reading from renderContext.res in Nuxt3 // TODO: Remove reading from renderContext.res in Nuxt3
renderContext.spa = !this.SSR || req.spa || (renderContext.res && renderContext.res.spa) renderContext.spa = !this.SSR || req.spa || res.spa
} }
// renderContext.modern // renderContext.modern

View File

@ -3,7 +3,7 @@ import cloneDeep from 'lodash/cloneDeep'
import VueMeta from 'vue-meta' import VueMeta from 'vue-meta'
import { createRenderer } from 'vue-server-renderer' import { createRenderer } from 'vue-server-renderer'
import LRU from 'lru-cache' import LRU from 'lru-cache'
import { isModernRequest } from '@nuxt/utils' import { TARGETS, isModernRequest } from '@nuxt/utils'
import BaseRenderer from './base' import BaseRenderer from './base'
export default class SPARenderer extends BaseRenderer { export default class SPARenderer extends BaseRenderer {
@ -27,9 +27,9 @@ export default class SPARenderer extends BaseRenderer {
} }
async render (renderContext) { async render (renderContext) {
const { url = '/', req = {}, _generate } = renderContext const { url = '/', req = {} } = renderContext
const modernMode = this.options.modern const modernMode = this.options.modern
const modern = (modernMode && _generate) || isModernRequest(req, modernMode) const modern = (modernMode && this.options.target === TARGETS.static) || isModernRequest(req, modernMode)
const cacheKey = `${modern ? 'modern:' : 'legacy:'}${url}` const cacheKey = `${modern ? 'modern:' : 'legacy:'}${url}`
let meta = this.cache.get(cacheKey) let meta = this.cache.get(cacheKey)
@ -148,7 +148,12 @@ export default class SPARenderer extends BaseRenderer {
} }
} }
const APP = `${meta.BODY_SCRIPTS_PREPEND}<div id="${this.serverContext.globals.id}">${this.serverContext.resources.loadingHTML}</div>${meta.BODY_SCRIPTS}` let APP = `${meta.BODY_SCRIPTS_PREPEND}<div id="${this.serverContext.globals.id}">${this.serverContext.resources.loadingHTML}</div>${meta.BODY_SCRIPTS}`
if (renderContext.staticAssetsBase) {
// Full static, add window.__NUXT_STATIC__
APP += `<script>window.__NUXT_STATIC__='${renderContext.staticAssetsBase}';window.${this.serverContext.globals.context}={spa:!0}</script>`
}
// Prepare template params // Prepare template params
const templateParams = { const templateParams = {

View File

@ -3,6 +3,7 @@ import crypto from 'crypto'
import { format } from 'util' import { format } from 'util'
import fs from 'fs-extra' import fs from 'fs-extra'
import consola from 'consola' import consola from 'consola'
import { TARGETS, urlJoin } from '@nuxt/utils'
import devalue from '@nuxt/devalue' import devalue from '@nuxt/devalue'
import { createBundleRenderer } from 'vue-server-renderer' import { createBundleRenderer } from 'vue-server-renderer'
import BaseRenderer from './base' import BaseRenderer from './base'
@ -100,7 +101,8 @@ export default class SSRRenderer extends BaseRenderer {
APP = `<div id="${this.serverContext.globals.id}"></div>` APP = `<div id="${this.serverContext.globals.id}"></div>`
} }
if (renderContext.redirected && !renderContext._generate) { // Perf: early returns if server target and redirected
if (renderContext.redirected && renderContext.target === TARGETS.server) {
return { return {
html: APP, html: APP,
error: renderContext.nuxt.error, error: renderContext.nuxt.error,
@ -155,26 +157,66 @@ export default class SSRRenderer extends BaseRenderer {
// Only add the hash if 'unsafe-inline' rule isn't present to avoid conflicts (#5387) // Only add the hash if 'unsafe-inline' rule isn't present to avoid conflicts (#5387)
const containsUnsafeInlineScriptSrc = csp.policies && csp.policies['script-src'] && csp.policies['script-src'].includes('\'unsafe-inline\'') const containsUnsafeInlineScriptSrc = csp.policies && csp.policies['script-src'] && csp.policies['script-src'].includes('\'unsafe-inline\'')
const shouldHashCspScriptSrc = csp && (csp.unsafeInlineCompatibility || !containsUnsafeInlineScriptSrc) const shouldHashCspScriptSrc = csp && (csp.unsafeInlineCompatibility || !containsUnsafeInlineScriptSrc)
let serializedSession = '' const inlineScripts = []
if (renderContext.staticAssetsBase) {
const preloadScripts = []
renderContext.staticAssets = []
const { staticAssetsBase, url, nuxt, staticAssets } = renderContext
const { data, fetch, ...state } = nuxt
// Initial state
const nuxtStaticScript = `window.__NUXT_STATIC__='${staticAssetsBase}';`
const stateScript = `window.${this.serverContext.globals.context}=${devalue(state)};`
// Make chunk for initial state > 10 KB
const stateScriptKb = (stateScript.length * 4 /* utf8 */) / 100
if (stateScriptKb > 10) {
const statePath = urlJoin(url, 'state.js')
const stateUrl = urlJoin(staticAssetsBase, statePath)
staticAssets.push({ path: statePath, src: stateScript })
APP += `<script defer>${nuxtStaticScript}</script>`
APP += `<script defer src="${staticAssetsBase}${statePath}"></script>`
preloadScripts.push(stateUrl)
} else {
APP += `<script defer>${nuxtStaticScript}${stateScript}</script>`
}
// Page level payload.js (async loaded for CSR)
const payloadPath = urlJoin(url, 'payload.js')
const payloadUrl = urlJoin(staticAssetsBase, payloadPath)
const payloadScript = `__NUXT_JSONP__("${url}", ${devalue({ data, fetch })});`
staticAssets.push({ path: payloadPath, src: payloadScript })
preloadScripts.push(payloadUrl)
// Preload links
for (const href of preloadScripts) {
HEAD += `<link rel="preload" href="${href}" as="script">`
}
} else {
// Serialize state // Serialize state
let serializedSession
if (shouldInjectScripts || shouldHashCspScriptSrc) { if (shouldInjectScripts || shouldHashCspScriptSrc) {
// Only serialized session if need inject scripts or csp hash // Only serialized session if need inject scripts or csp hash
serializedSession = `window.${this.serverContext.globals.context}=${devalue(renderContext.nuxt)};` serializedSession = `window.${this.serverContext.globals.context}=${devalue(renderContext.nuxt)};`
inlineScripts.push(serializedSession)
} }
if (shouldInjectScripts) { if (shouldInjectScripts) {
APP += `<script>${serializedSession}</script>` APP += `<script>${serializedSession}</script>`
} }
}
// Calculate CSP hashes // Calculate CSP hashes
const cspScriptSrcHashes = [] const cspScriptSrcHashes = []
if (csp) { if (csp) {
if (shouldHashCspScriptSrc) { if (shouldHashCspScriptSrc) {
for (const script of inlineScripts) {
const hash = crypto.createHash(csp.hashAlgorithm) const hash = crypto.createHash(csp.hashAlgorithm)
hash.update(serializedSession) hash.update(script)
cspScriptSrcHashes.push(`'${csp.hashAlgorithm}-${hash.digest('base64')}'`) cspScriptSrcHashes.push(`'${csp.hashAlgorithm}-${hash.digest('base64')}'`)
} }
}
// Call ssr:csp hook // Call ssr:csp hook
await this.serverContext.nuxt.callHook('vue-renderer:ssr:csp', cspScriptSrcHashes) await this.serverContext.nuxt.callHook('vue-renderer:ssr:csp', cspScriptSrcHashes)

View File

@ -6,7 +6,7 @@ import webpackDevMiddleware from 'webpack-dev-middleware'
import webpackHotMiddleware from 'webpack-hot-middleware' import webpackHotMiddleware from 'webpack-hot-middleware'
import consola from 'consola' import consola from 'consola'
import { parallel, sequence, wrapArray, isModernRequest } from '@nuxt/utils' import { TARGETS, parallel, sequence, wrapArray, isModernRequest } from '@nuxt/utils'
import AsyncMFS from './utils/async-mfs' import AsyncMFS from './utils/async-mfs'
import * as WebpackConfigs from './config' import * as WebpackConfigs from './config'
@ -244,6 +244,6 @@ export class WebpackBundler {
} }
forGenerate () { forGenerate () {
this.buildContext.isStatic = true this.buildContext.target = TARGETS.static
} }
} }

View File

@ -11,12 +11,11 @@ import WebpackBar from 'webpackbar'
import env from 'std-env' import env from 'std-env'
import semver from 'semver' import semver from 'semver'
import { isUrl, urlJoin, getPKG } from '@nuxt/utils' import { TARGETS, isUrl, urlJoin, getPKG } from '@nuxt/utils'
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 WarningIgnorePlugin from '../plugins/warning-ignore' import WarningIgnorePlugin from '../plugins/warning-ignore'
import { reservedVueTags } from '../utils/reserved-tags' import { reservedVueTags } from '../utils/reserved-tags'
export default class WebpackBaseConfig { export default class WebpackBaseConfig {
@ -47,6 +46,10 @@ export default class WebpackBaseConfig {
return this.dev ? 'development' : 'production' return this.dev ? 'development' : 'production'
} }
get target () {
return this.buildContext.target
}
get dev () { get dev () {
return this.buildContext.options.dev return this.buildContext.options.dev
} }
@ -139,7 +142,9 @@ export default class WebpackBaseConfig {
const env = { const env = {
'process.env.NODE_ENV': JSON.stringify(this.mode), 'process.env.NODE_ENV': JSON.stringify(this.mode),
'process.mode': JSON.stringify(this.mode), 'process.mode': JSON.stringify(this.mode),
'process.static': this.buildContext.isStatic 'process.dev': this.dev,
'process.static': this.target === TARGETS.static,
'process.target': JSON.stringify(this.target)
} }
if (this.buildContext.buildOptions.aggressiveCodeRemoval) { if (this.buildContext.buildOptions.aggressiveCodeRemoval) {
env['typeof process'] = JSON.stringify(this.isServer ? 'object' : 'undefined') env['typeof process'] = JSON.stringify(this.isServer ? 'object' : 'undefined')

View File

@ -1,4 +1,4 @@
import { loadFixture, Nuxt, Generator } from '../utils' import { loadFixture, Nuxt, Builder, Generator } from '../utils'
describe('basic fail generate', () => { describe('basic fail generate', () => {
test('Fail with routes() which throw an error', async () => { test('Fail with routes() which throw an error', async () => {
@ -12,9 +12,11 @@ describe('basic fail generate', () => {
const nuxt = new Nuxt(options) const nuxt = new Nuxt(options)
await nuxt.ready() await nuxt.ready()
const generator = new Generator(nuxt) const builder = new Builder(nuxt)
builder.build = jest.fn()
const generator = new Generator(nuxt, builder)
await generator.generate({ build: false }).catch((e) => { await generator.generate().catch((e) => {
expect(e.message).toBe('Not today!') expect(e.message).toBe('Not today!')
}) })
}) })

View File

@ -4,6 +4,7 @@ import { resolve } from 'path'
import { remove } from 'fs-extra' import { remove } from 'fs-extra'
import serveStatic from 'serve-static' import serveStatic from 'serve-static'
import finalhandler from 'finalhandler' import finalhandler from 'finalhandler'
import { TARGETS } from '@nuxt/utils'
import { Builder, Generator, getPort, loadFixture, Nuxt, rp, listPaths, equalOrStartsWith } from '../utils' import { Builder, Generator, getPort, loadFixture, Nuxt, rp, listPaths, equalOrStartsWith } from '../utils'
let port let port
@ -19,7 +20,12 @@ let changedFileName
describe('basic generate', () => { describe('basic generate', () => {
beforeAll(async () => { beforeAll(async () => {
const config = await loadFixture('basic', { generate: { dir: '.nuxt-generate' } }) const config = await loadFixture('basic', {
generate: {
static: false,
dir: '.nuxt-generate'
}
})
const nuxt = new Nuxt(config) const nuxt = new Nuxt(config)
await nuxt.ready() await nuxt.ready()
@ -47,7 +53,7 @@ describe('basic generate', () => {
}) })
test('Check builder', () => { test('Check builder', () => {
expect(builder.bundleBuilder.buildContext.isStatic).toBe(true) expect(builder.bundleBuilder.buildContext.target).toBe(TARGETS.static)
expect(builder.build).toHaveBeenCalledTimes(1) expect(builder.build).toHaveBeenCalledTimes(1)
}) })
@ -167,10 +173,9 @@ describe('basic generate', () => {
test('/validate should not be server-rendered', async () => { test('/validate should not be server-rendered', async () => {
const { body: html } = await rp(url('/validate')) const { body: html } = await rp(url('/validate'))
expect(html).toContain('<div id="__nuxt"></div>') expect(html).toContain('<div id="__nuxt"></div>')
expect(html).toContain('serverRendered:!1')
}) })
test('/validate -> should display a 404', async () => { test.posix('/validate -> should display a 404', async () => {
const window = await generator.nuxt.server.renderAndGetWindow(url('/validate')) const window = await generator.nuxt.server.renderAndGetWindow(url('/validate'))
const html = window.document.body.innerHTML const html = window.document.body.innerHTML
expect(html).toContain('This page could not be found') expect(html).toContain('This page could not be found')
@ -185,7 +190,6 @@ describe('basic generate', () => {
test('/redirect should not be server-rendered', async () => { test('/redirect should not be server-rendered', async () => {
const { body: html } = await rp(url('/redirect')) const { body: html } = await rp(url('/redirect'))
expect(html).toContain('<div id="__nuxt"></div>') expect(html).toContain('<div id="__nuxt"></div>')
expect(html).toContain('serverRendered:!1')
}) })
test('/redirect -> check redirected source', async () => { test('/redirect -> check redirected source', async () => {
@ -204,6 +208,21 @@ describe('basic generate', () => {
}) })
}) })
test('nuxt re-generating with no subfolders', async () => {
generator.nuxt.options.generate.subFolders = false
generator.getAppRoutes = jest.fn(() => [])
await expect(generator.generate()).resolves.toBeTruthy()
})
test('/users/1.html', async () => {
const { body } = await rp(url('/users/1.html'))
expect(body).toContain('<h1>User: 1</h1>')
expect(existsSync(resolve(distDir, 'users/1.html'))).toBe(true)
expect(
existsSync(resolve(distDir, 'users/1/index.html'))
).toBe(false)
})
test('/-ignored', async () => { test('/-ignored', async () => {
await expect(rp(url('/-ignored'))).rejects.toMatchObject({ await expect(rp(url('/-ignored'))).rejects.toMatchObject({
response: { response: {

View File

@ -11,6 +11,7 @@ describe('generator', () => {
const nuxt = new Nuxt(config) const nuxt = new Nuxt(config)
await nuxt.ready() await nuxt.ready()
const generator = new Generator(nuxt) const generator = new Generator(nuxt)
generator.getAppRoutes = jest.fn(() => [])
const routes = await generator.initRoutes() const routes = await generator.initRoutes()
expect(routes.length).toBe(array.length) expect(routes.length).toBe(array.length)
@ -31,6 +32,8 @@ describe('generator', () => {
const nuxt = new Nuxt(config) const nuxt = new Nuxt(config)
await nuxt.ready() await nuxt.ready()
const generator = new Generator(nuxt) const generator = new Generator(nuxt)
generator.getAppRoutes = jest.fn(() => [])
const routes = await generator.initRoutes() const routes = await generator.initRoutes()
expect(routes.length).toBe(array.length) expect(routes.length).toBe(array.length)
@ -50,6 +53,7 @@ describe('generator', () => {
const nuxt = new Nuxt(config) const nuxt = new Nuxt(config)
await nuxt.ready() await nuxt.ready()
const generator = new Generator(nuxt) const generator = new Generator(nuxt)
generator.getAppRoutes = jest.fn(() => [])
const array = ['/1', '/2', '/3', '/4'] const array = ['/1', '/2', '/3', '/4']
const routes = await generator.initRoutes(array) const routes = await generator.initRoutes(array)
@ -70,6 +74,7 @@ describe('generator', () => {
const nuxt = new Nuxt(config) const nuxt = new Nuxt(config)
await nuxt.ready() await nuxt.ready()
const generator = new Generator(nuxt) const generator = new Generator(nuxt)
generator.getAppRoutes = jest.fn(() => [])
const array = ['/1', '/2', '/3', '/4'] const array = ['/1', '/2', '/3', '/4']
const routes = await generator.initRoutes(...array) const routes = await generator.initRoutes(...array)

View File

@ -1,4 +1,5 @@
import consola from 'consola' import consola from 'consola'
import { MODES } from '@nuxt/utils'
import { Nuxt } from '../utils' import { Nuxt } from '../utils'
const NO_BUILD_MSG = /Use either `nuxt build` or `builder\.build\(\)` or start nuxt in development mode/ const NO_BUILD_MSG = /Use either `nuxt build` or `builder\.build\(\)` or start nuxt in development mode/
@ -12,7 +13,7 @@ describe('renderer', () => {
test('detect no-build (Universal)', async () => { test('detect no-build (Universal)', async () => {
const nuxt = new Nuxt({ const nuxt = new Nuxt({
_start: true, _start: true,
mode: 'universal', mode: MODES.universal,
dev: false, dev: false,
buildDir: '/path/to/404' buildDir: '/path/to/404'
}) })
@ -25,7 +26,7 @@ describe('renderer', () => {
test('detect no-build (SPA)', async () => { test('detect no-build (SPA)', async () => {
const nuxt = new Nuxt({ const nuxt = new Nuxt({
_start: true, _start: true,
mode: 'spa', mode: MODES.spa,
dev: false, dev: false,
buildDir: '/path/to/404' buildDir: '/path/to/404'
}) })
@ -37,7 +38,7 @@ describe('renderer', () => {
test('detect no-modern-build', async () => { test('detect no-modern-build', async () => {
const nuxt = new Nuxt({ const nuxt = new Nuxt({
_start: true, _start: true,
mode: 'universal', mode: MODES.universal,
modern: 'client', modern: 'client',
dev: false, dev: false,
buildDir: '/path/to/404' buildDir: '/path/to/404'

View File

@ -20,7 +20,7 @@ describe('nuxt minimal vue-app bundle size limit', () => {
it('should stay within the size limit range', async () => { it('should stay within the size limit range', async () => {
const filter = filename => filename === 'vue-app.nuxt.js' const filter = filename => filename === 'vue-app.nuxt.js'
const legacyResourcesSize = await getResourcesSize(distDir, 'client', { filter }) const legacyResourcesSize = await getResourcesSize(distDir, 'client', { filter })
const LEGACY_JS_RESOURCES_KB_SIZE = 15.7 const LEGACY_JS_RESOURCES_KB_SIZE = 16.2
expect(legacyResourcesSize.uncompressed).toBeWithinSize(LEGACY_JS_RESOURCES_KB_SIZE) expect(legacyResourcesSize.uncompressed).toBeWithinSize(LEGACY_JS_RESOURCES_KB_SIZE)
}) })
}) })

View File

@ -8690,6 +8690,13 @@ node-gyp@^5.0.2:
tar "^4.4.12" tar "^4.4.12"
which "^1.3.1" which "^1.3.1"
node-html-parser@^1.2.4:
version "1.2.4"
resolved "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.2.4.tgz#bff5b403da3c5061d189e922aafb193c8e1f6f92"
integrity sha512-qHwPdGyGr9pOZBoSgUOuNPG20QYZVN00lFcxKQgjPUODSxVH7obQeLVVawa3B4cfSNtLIeczSzoy/xYA8XG5WQ==
dependencies:
he "1.1.1"
node-int64@^0.4.0: node-int64@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"