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

View File

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

View File

@ -1,4 +1,5 @@
import path from 'path'
import chalk from 'chalk'
import chokidar from 'chokidar'
import consola from 'consola'
import fsExtra from 'fs-extra'
@ -7,7 +8,6 @@ import hash from 'hash-sum'
import pify from 'pify'
import upath from 'upath'
import semver from 'semver'
import chalk from 'chalk'
import debounce from 'lodash/debounce'
import omit from 'lodash/omit'
@ -23,7 +23,9 @@ import {
determineGlobals,
stripWhitespace,
isIndexFileAndFolder,
scanRequireTree
scanRequireTree,
TARGETS,
isFullStatic
} from '@nuxt/utils'
import Ignore from './ignore'
@ -102,6 +104,7 @@ export default class Builder {
}
forGenerate () {
this.options.target = TARGETS.static
this.bundleBuilder.forGenerate()
}
@ -122,6 +125,13 @@ export default class Builder {
consola.info('Initial build may take a while')
} else {
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

View File

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

View File

@ -4,7 +4,7 @@ import uniqBy from 'lodash/uniqBy'
import serialize from 'serialize-javascript'
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 {
constructor (builder, options) {
@ -20,6 +20,7 @@ export default class TemplateContext {
uniqBy,
isDev: options.dev,
isTest: options.test,
isFullStatic: isFullStatic(options),
debug: options.debug,
buildIndicator: options.dev && options.build.indicator,
vue: { config: options.vue.config },

View File

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

View File

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

View File

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

View File

@ -1,15 +1,20 @@
import { TARGETS } from '@nuxt/utils'
import BuildContext from '../../src/context/build'
describe('builder: buildContext', () => {
test('should construct context', () => {
const builder = {
nuxt: { options: {} }
nuxt: {
options: {
target: TARGETS.server
}
}
}
const context = new BuildContext(builder)
expect(context._builder).toEqual(builder)
expect(context.nuxt).toEqual(builder.nuxt)
expect(context.options).toEqual(builder.nuxt.options)
expect(context.isStatic).toEqual(false)
expect(context.target).toEqual('server')
})
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 { createLock } from '../utils'
@ -62,7 +64,7 @@ export default {
},
async run (cmd) {
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)
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
const generator = await cmd.getGenerator(nuxt)
await generator.generate({ build: true })
@ -81,6 +84,9 @@ export default {
// Build only
const builder = await cmd.getBuilder(nuxt)
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 { normalizeArg, createLock } from '../utils'
@ -53,14 +54,25 @@ export default {
}
},
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
if (!config.build) {
config.build = {}
}
config.build = config.build || {}
config.build.analyze = false
// Set flag to keep the prerendering behaviour
config._legacyGenerate = true
const nuxt = await cmd.getNuxt(config)
if (cmd.argv.lock) {
@ -82,12 +94,14 @@ export default {
}
const generator = await cmd.getGenerator(nuxt)
await nuxt.server.listen()
const { errors } = await generator.generate({
init: true,
build: cmd.argv.build
})
await nuxt.close()
if (cmd.argv['fail-on-error'] && errors.length > 0) {
throw new Error('Error generating pages, exiting with non-zero code')
}

View File

@ -1,8 +1,10 @@
const commands = {
start: () => import('./start'),
serve: () => import('./serve'),
dev: () => import('./dev'),
build: () => import('./build'),
generate: () => import('./generate'),
export: () => import('./export'),
webpack: () => import('./webpack'),
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 { showBanner } from '../utils/banner'
@ -11,6 +12,9 @@ export default {
},
async run (cmd) {
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)
// 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': {
type: 'boolean',
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'
},

View File

@ -20,11 +20,14 @@ export function showBanner (nuxt, showMemoryUsage = true) {
const messageLines = []
// Name and version
const { bannerColor } = nuxt.options.cli
const { bannerColor, badgeMessages } = nuxt.options.cli
titleLines.push(`${chalk[bannerColor].bold('Nuxt.js')} ${nuxt.constructor.version}`)
// 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) {
titleLines.push(getFormattedMemoryUsage())
@ -36,8 +39,8 @@ export function showBanner (nuxt, showMemoryUsage = true) {
}
// Add custom badge messages
if (nuxt.options.cli.badgeMessages.length) {
messageLines.push('', ...nuxt.options.cli.badgeMessages)
if (badgeMessages.length) {
messageLines.push('', ...badgeMessages)
}
process.stdout.write(successBox(messageLines.join('\n'), titleLines.join('\n')))

View File

@ -1,6 +1,7 @@
import path from 'path'
import defaultsDeep from 'lodash/defaultsDeep'
import { loadNuxtConfig as _loadNuxtConfig, getDefaultNuxtConfig } from '@nuxt/config'
import { MODES } from '@nuxt/utils'
export async function loadNuxtConfig (argv, configContext) {
const rootDir = path.resolve(argv._[0] || '.')
@ -14,7 +15,8 @@ export async function loadNuxtConfig (argv, configContext) {
})
// 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
options.server = defaultsDeep({

View File

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

View File

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

View File

@ -21,7 +21,7 @@ describe('cli/command', () => {
const cmd = new Command({ options: allOptions })
const minimistOptions = cmd._getMinimistOptions()
expect(minimistOptions.string.length).toBe(5)
expect(minimistOptions.string.length).toBe(6)
expect(minimistOptions.boolean.length).toBe(5)
expect(minimistOptions.alias.c).toBe('config-file')
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 { TARGETS } from '@nuxt/utils'
import * as utils from '../../src/utils/'
import { consola, mockGetNuxtStart, mockGetNuxtConfig, NuxtCommand } from '../utils'
@ -35,8 +36,16 @@ describe('start', () => {
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 () => {
mockGetNuxtStart()
mockGetNuxtConfig()
const cmd = NuxtCommand.from(start, ['start', '.'])
await cmd.run()
@ -46,6 +55,7 @@ describe('start', () => {
test('start can set force exit explicitly', async () => {
mockGetNuxtStart()
mockGetNuxtConfig()
const cmd = NuxtCommand.from(start, ['start', '.', '--force-exit'])
await cmd.run()
@ -56,6 +66,7 @@ describe('start', () => {
test('start can disable force exit explicitly', async () => {
mockGetNuxtStart()
mockGetNuxtConfig()
const cmd = NuxtCommand.from(start, ['start', '.', '--no-force-exit'])
await cmd.run()

View File

@ -1,4 +1,5 @@
import { getDefaultNuxtConfig } from '@nuxt/config'
import { TARGETS, MODES } from '@nuxt/utils'
import { consola } from '../utils'
import { loadNuxtConfig } from '../../src/utils/config'
import * as utils from '../../src/utils'
@ -24,7 +25,7 @@ describe('cli/utils', () => {
const options = await loadNuxtConfig(argv)
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.port).toBe(3000)
expect(options.server.socket).not.toBeDefined()
@ -40,7 +41,7 @@ describe('cli/utils', () => {
const options = await loadNuxtConfig(argv)
expect(options.testOption).toBe(true)
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.port).toBe(3001)
expect(options.server.socket).toBe('/var/run/nuxt.sock')
@ -149,6 +150,9 @@ describe('cli/utils', () => {
showBanner({
options: {
render: {
ssr: true
},
cli: {
badgeMessages,
bannerColor
@ -179,6 +183,9 @@ describe('cli/utils', () => {
cli: {
badgeMessages: [],
bannerColor: 'green'
},
render: {
ssr: false
}
},
server: {
@ -193,6 +200,37 @@ describe('cli/utils', () => {
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', () => {
showMemoryUsage()

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import defu from 'defu'
import pick from 'lodash/pick'
import uniq from 'lodash/uniq'
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'
export function getNuxtConfig (_options) {
@ -89,6 +89,26 @@ export function getNuxtConfig (_options) {
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
if (!/\/$/.test(options.router.base)) {
options.router.base += '/'
@ -241,7 +261,7 @@ export function getNuxtConfig (_options) {
hashAlgorithm: 'sha256',
allowedSources: undefined,
policies: undefined,
addMeta: Boolean(options._generate),
addMeta: Boolean(options.target === TARGETS.static),
unsafeInlineCompatibility: false,
reportOnly: options.debug
})
@ -316,14 +336,6 @@ export function getNuxtConfig (_options) {
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 (options.render.ssr === false && options.pageTransition) {
options.pageTransition.appear = true
@ -436,5 +448,18 @@ export function getNuxtConfig (_options) {
.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
}

View File

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

View File

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

View File

@ -25,7 +25,7 @@ describe('config: options', () => {
jest.spyOn(path, 'resolve').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()
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', () => {
const { build, render } = getNuxtConfig({ mode: 'test' })
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)
}
// Generate unique and human readable dst filename
const dst =
template.fileName ||
path.basename(srcPath.dir) + `.${srcPath.name}.${hash(src)}` + srcPath.ext
// Mostly for DX, some people prefers `filename` vs `fileName`
const fileName = template.fileName || template.filename
// Generate unique and human readable dst filename if not provided
const dst = fileName || `${path.basename(srcPath.dir)}.${srcPath.name}.${hash(src)}${srcPath.ext}`
// Add to templates list
const templateObj = {
src,
@ -58,6 +57,7 @@ export default class ModuleContainer {
}
this.options.build.templates.push(templateObj)
return templateObj
}

View File

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

View File

@ -1,16 +1,18 @@
import path from 'path'
import Chalk from 'chalk'
import chalk from 'chalk'
import consola from 'consola'
import fsExtra from 'fs-extra'
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 {
constructor (nuxt, builder) {
this.nuxt = nuxt
this.options = nuxt.options
this.builder = builder
this.isFullStatic = false
// Set variables
this.staticRoutes = path.resolve(this.options.srcDir, this.options.dir.static)
@ -20,19 +22,25 @@ export default class Generator {
this.distPath,
isUrl(this.options.build.publicPath) ? '' : this.options.build.publicPath
)
this.generatedRoutes = new Set()
}
async generate ({ build = true, init = true } = {}) {
consola.debug('Initializing generator...')
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()
consola.info('Generating pages')
const errors = await this.generateRoutes(routes)
await this.afterGenerate()
@ -56,6 +64,22 @@ export default class Generator {
// Start build process
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
@ -67,7 +91,7 @@ export default class Generator {
async initRoutes (...args) {
// Resolve config.generate.routes promises before generating the routes
let generateRoutes = []
if (this.options.router.mode !== 'hash') {
if (this.options.mode === MODES.universal && this.options.router.mode !== 'hash') {
try {
generateRoutes = await promisifyRoute(
this.options.generate.routes || [],
@ -78,14 +102,14 @@ export default class Generator {
throw e // eslint-disable-line no-unreachable
}
}
// Generate only index.html for router.mode = 'hash'
let routes =
this.options.router.mode === 'hash'
? ['/']
: flatRoutes(this.options.router.routes)
routes = routes.filter(route => this.options.generate.exclude.every(regex => !regex.test(route)))
let routes = []
// Generate only index.html for router.mode = 'hash' or client-side apps
if (this.options.mode === MODES.spa || this.options.router.mode === 'hash') {
routes = ['/']
} else {
routes = flatRoutes(this.getAppRoutes())
}
routes = routes.filter(route => this.shouldGenerateRoute(route))
routes = this.decorateWithPayloads(routes, generateRoutes)
// extendRoutes hook
@ -94,14 +118,34 @@ export default class Generator {
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) {
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
while (routes.length) {
while (this.routes.length) {
let n = 0
await Promise.all(
routes
this.routes
.splice(0, this.options.generate.concurrency)
.map(async ({ route, payload }) => {
await waitFor(n++ * this.options.generate.interval)
@ -123,12 +167,12 @@ export default class Generator {
const isHandled = type === 'handled'
const color = isHandled ? 'yellow' : 'red'
let line = Chalk[color](` ${route}\n\n`)
let line = chalk[color](` ${route}\n\n`)
if (isHandled) {
line += Chalk.grey(JSON.stringify(error, undefined, 2) + '\n')
line += chalk.grey(JSON.stringify(error, undefined, 2) + '\n')
} else {
line += Chalk.grey(error.stack || error.message || `${error}`)
line += chalk.grey(error.stack || error.message || `${error}`)
}
return line
@ -153,7 +197,10 @@ export default class Generator {
}
// 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 {
html = this.minifyHtml(html)
@ -162,20 +209,27 @@ export default class Generator {
}
await fsExtra.writeFile(fallbackPath, html, 'utf8')
consola.success('Client-side fallback created: `' + fallback + '`')
}
async initDist () {
// Clean destination folder
await fsExtra.remove(this.distPath)
consola.info(`Generating output directory: ${path.basename(this.distPath)}/`)
await this.nuxt.callHook('generate:distRemoved', this)
// Copy static and built files
if (await fsExtra.exists(this.staticRoutes)) {
await fsExtra.copy(this.staticRoutes, this.distPath)
}
// Copy .nuxt/dist/client/ to dist/_nuxt/
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
// https://help.github.com/articles/files-that-start-with-an-underscore-are-missing/
const nojekyllPath = path.resolve(this.distPath, '.nojekyll')
@ -207,11 +261,34 @@ export default class Generator {
const pageErrors = []
try {
const res = await this.nuxt.server.renderRoute(route, {
_generate: true,
payload
const renderContext = {
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) {
pageErrors.push({ type: 'handled', route, error: res.error })
}

View File

@ -5,10 +5,12 @@ export const createNuxt = () => ({
renderRoute: jest.fn(() => ({ html: 'rendered html' }))
},
options: {
mode: 'universal',
srcDir: '/var/nuxt/src',
buildDir: '/var/nuxt/build',
generate: { dir: '/var/nuxt/generate' },
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)
generator.initDist = jest.fn()
fsExtra.exists.mockReturnValueOnce(true)
generator.getBuildConfig = jest.fn(() => ({ ssr: true, target: 'static' }))
await generator.initiate({ build: false, init: false })
@ -87,7 +89,7 @@ describe('generator: initialize', () => {
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()
nuxt.options = {
...nuxt.options,
@ -97,14 +99,14 @@ describe('generator: initialize', () => {
routes: ['/foo', '/foo/bar']
},
router: {
mode: 'history',
routes: ['/index', '/about', '/test']
mode: 'history'
}
}
const generator = new Generator(nuxt)
flatRoutes.mockImplementationOnce(routes => routes)
promisifyRoute.mockImplementationOnce(routes => routes)
generator.getAppRoutes = jest.fn(() => ['/index', '/about', '/test'])
generator.decorateWithPayloads = jest.fn(() => 'decoratedRoutes')
const routes = await generator.initRoutes()
@ -130,8 +132,7 @@ describe('generator: initialize', () => {
routes: ['/foo', '/foo/bar']
},
router: {
mode: 'hash',
routes: ['/index', '/about', '/test']
mode: 'hash'
}
}
const generator = new Generator(nuxt)

View File

@ -43,7 +43,7 @@ describe('generator: generate route', () => {
const returned = await generator.generateRoute({ route, payload, errors })
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).nthCalledWith(1, '[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 })
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).toBeCalledWith('generate:routeFailed', {
route,

View File

@ -7,7 +7,6 @@ export default async function renderAndGetWindow (
{
loadedCallback,
loadingTimeout = 2000,
ssr,
globals
} = {}
) {
@ -49,9 +48,7 @@ export default async function renderAndGetWindow (
const { window } = await jsdom.JSDOM.fromURL(url, options)
// If Nuxt could not be loaded (error from the server-side)
const nuxtExists = window.document.body.innerHTML.includes(
ssr ? `window.${globals.context}` : `<div id="${globals.id}">`
)
const nuxtExists = window.document.body.innerHTML.includes(`id="${globals.id}"`)
if (!nuxtExists) {
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 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) {
// Get context
@ -28,7 +28,7 @@ export default ({ options, nuxt, renderRoute, resources }) => async function nux
preloadFiles
} = result
if (redirected) {
if (redirected && context.target !== TARGETS.static) {
await nuxt.callHook('render:routeDone', url, result, context)
return html
}

View File

@ -320,13 +320,11 @@ export default class Server {
renderAndGetWindow (url, opts = {}, {
loadingTimeout = 2000,
loadedCallback = this.globals.loadedCallback,
ssr = this.options.render.ssr,
globals = this.globals
} = {}) {
return renderAndGetWindow(url, opts, {
loadingTimeout,
loadedCallback,
ssr,
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) {
return { req, res }
@ -14,3 +15,7 @@ export const determineGlobals = function determineGlobals (globalName, 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 './cjs'
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 cjs from '../src/cjs'
import * as modern from '../src/modern'
import * as constants from '../src/constants'
describe('util: entry', () => {
test('should export all methods from utils folder', () => {
@ -22,7 +23,8 @@ describe('util: entry', () => {
...task,
...timer,
...cjs,
...modern
...modern,
...constants
})
})
})

View File

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

View File

@ -4,7 +4,8 @@ import Vue from 'vue'
'getMatchedComponentsInstances',
'getChildrenComponentInstancesUsingFetch',
'promisify',
'globalHandleError'
'globalHandleError',
'urlJoin'
] : [],
...features.layouts ? [
'sanitizeComponent'
@ -57,6 +58,7 @@ export default {
domProps: {
id: '__layout'
},
key: this.layoutName
}, [layoutEl])
<% } else { %>
@ -110,8 +112,8 @@ export default {
created () {
// Add this.$nuxt in child instances
Vue.prototype.<%= globals.nuxt %> = this
// add to window so we can listen when ready
if (process.client) {
// add to window so we can listen when ready
window.<%= globals.nuxt %> = <%= (globals.nuxt !== '$nuxt' ? 'window.$nuxt = ' : '') %>this
<% if (features.clientOnline) { %>
this.refreshOnlineStatus()
@ -125,10 +127,22 @@ export default {
// Add $nuxt.context
this.context = this.$options.context
},
<% if (loading) { %>
mounted () {
this.$loading = this.$refs.loading
<% if (loading || isFullStatic) { %>
async mounted () {
<% 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: {
'nuxt.err': 'errorChanged'
},
@ -139,10 +153,13 @@ export default {
return !this.isOnline
},
<% if (features.fetch) { %>
isFetching() {
isFetching () {
return this.nbFetching > 0
}
<% } %>
},<% } %>
<% if (nuxtOptions.target === 'static') { %>
isPreview () {
return Boolean(this.$options.previewData)
},<% } %>
},
<% } %>
methods: {
@ -257,7 +274,7 @@ export default {
return this.<%= globals.nuxt %>.error({ statusCode: 500, message: e.message })
}
})
}
},
<% } else { %>
setLayout (layout) {
<% if (debug) { %>
@ -277,9 +294,27 @@ export default {
layout = 'default'
}
return Promise.resolve(layouts['_' + layout])
}
},
<% } /* splitChunks.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) { %>
components: {

View File

@ -19,6 +19,7 @@ import {
import { createApp<% if (features.layouts) { %>, NuxtError<% } %> } from './index.js'
<% 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
<% if (isFullStatic) { %>import './jsonp'<% } %>
<% if (features.fetch) { %>
// Fetch mixin
@ -136,7 +137,7 @@ function mapTransitions (toComponents, to, from) {
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())
this._routeChanged = Boolean(app.nuxt.err) || from.name !== to.name
this._paramChanged = !this._routeChanged && from.path !== to.path
@ -150,7 +151,6 @@ function mapTransitions (toComponents, to, from) {
<% } %>
try {
<% if (loading) { %>
if (this._queryChanged) {
const Components = await resolveRouteComponents(
to,
@ -170,11 +170,12 @@ function mapTransitions (toComponents, to, from) {
}
return false
})
<% if (loading) { %>
if (startLoader && this.$loading.start && !this.$loading.manual) {
this.$loading.start()
}
}
<% } %>
}
// Call next()
next()
} catch (error) {
@ -429,7 +430,7 @@ async function render (to, from, next) {
<% if (features.asyncData || features.fetch) { %>
let instances
// 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
Component._path = compile(to.matched[matches[i]].path)(to.params)
Component._dataRefresh = false
@ -484,8 +485,24 @@ async function render (to, from, next) {
<% if (features.asyncData) { %>
// Call asyncData(context)
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)
.then((asyncDataResult) => {
<% } %>
promise.then((asyncDataResult) => {
applyAsyncData(Component, asyncDataResult)
<% if (loading) { %>
if (this.$loading.increase) {
@ -501,6 +518,12 @@ async function render (to, from, next) {
this.$loading.manual = Component.options.loading === false
<% 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)
if (hasFetch) {
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
app = __app.app
router = __app.router
@ -777,6 +800,16 @@ function addHotReload ($component, depth) {
// Create Vue instance
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') { %>
// Load layout
const layout = NUXT.layout || 'default'
@ -825,6 +858,10 @@ function addHotReload ($component, depth) {
router.beforeEach(loadAsyncComponents.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 (NUXT.serverRendered && NUXT.routePath === _app.context.route.path) {
mount()
@ -839,6 +876,8 @@ function addHotReload ($component, depth) {
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) => {
// If not redirected
if (!path) {

View File

@ -43,7 +43,7 @@ export default {
meta: [
{
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 () {
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 () {
const conn = navigator.connection
@ -101,7 +106,15 @@ export default {
<% if (router.linkPrefetchedClass) { %>promises.push(componentOrPromise)<% } %>
}
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())
<% } %>
}<% 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
<%= isTest ? '/* eslint-disable camelcase */' : '' %>
<% plugins.forEach((plugin) => { %>
@ -228,6 +235,12 @@ async function createApp (ssrContext) {
<% } %>
<% }) %>
<%= 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 (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() {
if (!isSsrHydration(this)) {
<% if (isFullStatic) { %>createdFullStatic.call(this)<% } %>
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() {
if (!this._fetchPromise) {
this._fetchPromise = $_fetch.call(this)
@ -71,6 +99,9 @@ async function $_fetch() {
try {
await this.$options.fetch.call(this)
} catch (err) {
if (process.dev) {
console.error('Error in fetch():', err)
}
error = normalizeError(err)
}
@ -85,4 +116,3 @@ async function $_fetch() {
this.$nextTick(() => this.<%= globals.nuxt %>.nbFetching--)
}

View File

@ -10,6 +10,9 @@ async function serverPrefetch() {
try {
await this.$options.fetch.call(this)
} catch (err) {
if (process.dev) {
console.error('Error in fetch():', err)
}
this.$fetchState.error = normalizeError(err)
}
this.$fetchState.pending = false
@ -27,7 +30,7 @@ async function serverPrefetch() {
}
export default {
beforeCreate() {
created() {
if (!hasFetch(this)) {
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) => {
// If static target, render on client-side
ssrContext.redirected = opts
// If nuxt generate
if (!ssrContext.res) {
if (ssrContext.target === 'static' || !ssrContext.res) {
ssrContext.nuxt.serverRendered = false
return
}
@ -71,8 +71,13 @@ export default async (ssrContext) => {
ssrContext.next = createNext(ssrContext)
// Used for beforeNuxtRender({ Components, nuxtState })
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: '' }
// 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)
const { app, router<%= (store ? ', store' : '') %> } = await createApp(ssrContext)
const _app = new Vue(app)
@ -100,6 +105,10 @@ export default async (ssrContext) => {
}
const renderErrorPage = async () => {
// Don't server-render the page in static target
if (ssrContext.target === 'static') {
ssrContext.nuxt.serverRendered = false
}
<% if (features.layouts) { %>
// Load layout for error page
const layout = (NuxtError.options || NuxtError).layout
@ -245,10 +254,6 @@ export default async (ssrContext) => {
// ...If .validate() returned false
if (!isValid) {
// Don't server-render the page in generate mode
if (ssrContext._generate) {
ssrContext.nuxt.serverRendered = false
}
// Render a 404 error page
return render404Page()
}

View File

@ -156,10 +156,10 @@ export async function setContext (app, context) {
env: <%= JSON.stringify(env) %><%= isTest ? '// eslint-disable-line' : '' %>
}
// Only set once
if (context.req) {
if (!process.static && context.req) {
app.context.req = context.req
}
if (context.res) {
if (!process.static && context.res) {
app.context.res = context.res
}
if (context.ssrContext) {
@ -642,3 +642,11 @@ export function addLifecycleHook(vm, hook, 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
renderContext.url = url
// Add target to the renderContext
renderContext.target = this.serverContext.nuxt.options.target
const { req = {} } = renderContext
const { req = {}, res = {} } = renderContext
// renderContext.spa
if (renderContext.spa === undefined) {
// 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

View File

@ -3,7 +3,7 @@ import cloneDeep from 'lodash/cloneDeep'
import VueMeta from 'vue-meta'
import { createRenderer } from 'vue-server-renderer'
import LRU from 'lru-cache'
import { isModernRequest } from '@nuxt/utils'
import { TARGETS, isModernRequest } from '@nuxt/utils'
import BaseRenderer from './base'
export default class SPARenderer extends BaseRenderer {
@ -27,9 +27,9 @@ export default class SPARenderer extends BaseRenderer {
}
async render (renderContext) {
const { url = '/', req = {}, _generate } = renderContext
const { url = '/', req = {} } = renderContext
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}`
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
const templateParams = {

View File

@ -3,6 +3,7 @@ import crypto from 'crypto'
import { format } from 'util'
import fs from 'fs-extra'
import consola from 'consola'
import { TARGETS, urlJoin } from '@nuxt/utils'
import devalue from '@nuxt/devalue'
import { createBundleRenderer } from 'vue-server-renderer'
import BaseRenderer from './base'
@ -100,7 +101,8 @@ export default class SSRRenderer extends BaseRenderer {
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 {
html: APP,
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)
const containsUnsafeInlineScriptSrc = csp.policies && csp.policies['script-src'] && csp.policies['script-src'].includes('\'unsafe-inline\'')
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
let serializedSession
if (shouldInjectScripts || shouldHashCspScriptSrc) {
// Only serialized session if need inject scripts or csp hash
serializedSession = `window.${this.serverContext.globals.context}=${devalue(renderContext.nuxt)};`
inlineScripts.push(serializedSession)
}
if (shouldInjectScripts) {
APP += `<script>${serializedSession}</script>`
}
}
// Calculate CSP hashes
const cspScriptSrcHashes = []
if (csp) {
if (shouldHashCspScriptSrc) {
for (const script of inlineScripts) {
const hash = crypto.createHash(csp.hashAlgorithm)
hash.update(serializedSession)
hash.update(script)
cspScriptSrcHashes.push(`'${csp.hashAlgorithm}-${hash.digest('base64')}'`)
}
}
// Call ssr:csp hook
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 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 * as WebpackConfigs from './config'
@ -244,6 +244,6 @@ export class WebpackBundler {
}
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 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 StyleLoader from '../utils/style-loader'
import WarningIgnorePlugin from '../plugins/warning-ignore'
import { reservedVueTags } from '../utils/reserved-tags'
export default class WebpackBaseConfig {
@ -47,6 +46,10 @@ export default class WebpackBaseConfig {
return this.dev ? 'development' : 'production'
}
get target () {
return this.buildContext.target
}
get dev () {
return this.buildContext.options.dev
}
@ -139,7 +142,9 @@ export default class WebpackBaseConfig {
const env = {
'process.env.NODE_ENV': 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) {
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', () => {
test('Fail with routes() which throw an error', async () => {
@ -12,9 +12,11 @@ describe('basic fail generate', () => {
const nuxt = new Nuxt(options)
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!')
})
})

View File

@ -4,6 +4,7 @@ import { resolve } from 'path'
import { remove } from 'fs-extra'
import serveStatic from 'serve-static'
import finalhandler from 'finalhandler'
import { TARGETS } from '@nuxt/utils'
import { Builder, Generator, getPort, loadFixture, Nuxt, rp, listPaths, equalOrStartsWith } from '../utils'
let port
@ -19,7 +20,12 @@ let changedFileName
describe('basic generate', () => {
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)
await nuxt.ready()
@ -47,7 +53,7 @@ describe('basic generate', () => {
})
test('Check builder', () => {
expect(builder.bundleBuilder.buildContext.isStatic).toBe(true)
expect(builder.bundleBuilder.buildContext.target).toBe(TARGETS.static)
expect(builder.build).toHaveBeenCalledTimes(1)
})
@ -167,10 +173,9 @@ describe('basic generate', () => {
test('/validate should not be server-rendered', async () => {
const { body: html } = await rp(url('/validate'))
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 html = window.document.body.innerHTML
expect(html).toContain('This page could not be found')
@ -185,7 +190,6 @@ describe('basic generate', () => {
test('/redirect should not be server-rendered', async () => {
const { body: html } = await rp(url('/redirect'))
expect(html).toContain('<div id="__nuxt"></div>')
expect(html).toContain('serverRendered:!1')
})
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 () => {
await expect(rp(url('/-ignored'))).rejects.toMatchObject({
response: {

View File

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

View File

@ -1,4 +1,5 @@
import consola from 'consola'
import { MODES } from '@nuxt/utils'
import { Nuxt } from '../utils'
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 () => {
const nuxt = new Nuxt({
_start: true,
mode: 'universal',
mode: MODES.universal,
dev: false,
buildDir: '/path/to/404'
})
@ -25,7 +26,7 @@ describe('renderer', () => {
test('detect no-build (SPA)', async () => {
const nuxt = new Nuxt({
_start: true,
mode: 'spa',
mode: MODES.spa,
dev: false,
buildDir: '/path/to/404'
})
@ -37,7 +38,7 @@ describe('renderer', () => {
test('detect no-modern-build', async () => {
const nuxt = new Nuxt({
_start: true,
mode: 'universal',
mode: MODES.universal,
modern: 'client',
dev: false,
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 () => {
const filter = filename => filename === 'vue-app.nuxt.js'
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)
})
})

View File

@ -8690,6 +8690,13 @@ node-gyp@^5.0.2:
tar "^4.4.12"
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:
version "0.4.0"
resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"